From dbaa6409e4842a760b1a88aa5b9656af0bc393a3 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 27 Jul 2026 22:32:06 +0100 Subject: [PATCH 1/2] feat(boatstack): denials carry their computed solution set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A denial that only states the rule leaves a weaker model retrying the same blocked call. Every guard denial now carries the law's computed solution set: a capped "You can:" list of exact runnable commands legal from the position the finding describes, with owed human inputs marked, plus the full set on the opt-in structured payload (additive keys, schema_version stays 1). A protected-path denial additionally names the verbs that own the attempted path, derived at runtime from the state-ownership map — a verb whose full arguments cannot be derived is named, never fabricated into a command. Enumeration reads only the finding's own fields and the declared tables (the planning enumeration for phase findings, the registry's observe rows, the ownership map for protected paths), so the deny path stays fast and cannot itself fail on unreadable state. Tamper findings now carry the matched managed-path fragment as AttemptedPath (bounded, secret-free) to key the ownership lookup; the host deny contracts take the resolved repo. Conformance: a denial-category totality sweep (every category enumerates picks or sits on a documented exception list), text-guard closure over every pick, phase-bypass picks re-checked against the interlock at the finding's own stage, ownership derivation pinned per subtree, rendering checks in all three modes, and dual-reward corpus routine additions from real enumerations — the constitutional floor is untouched by construction. The corpus immediately caught one stage-inconsistent pick during authoring (record-approval offered at NOT_STARTED), which is exactly the class this law exists to stop. control-law: solution-set-derives-from-guard-declarations control-law: guard-never-prescribes-what-it-would-deny Disclosure-Reviewed: reviewed — public-safe only, private facet kept out of this commit --- ...2026-07-27-denials-name-their-solutions.md | 7 + .../product-engineering-loop/denial.go | 105 +++++++++- .../denial_solutions.go | 194 ++++++++++++++++++ .../denial_solutions_conformance_test.go | 190 +++++++++++++++++ .../product-engineering-loop/denial_test.go | 4 +- .../product-engineering-loop/safety.go | 67 +++--- .../safety_corpus_test.go | 11 +- .../product-engineering-loop/safety_test.go | 6 +- 8 files changed, 550 insertions(+), 34 deletions(-) create mode 100644 labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-27-denials-name-their-solutions.md create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/denial_solutions.go create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/denial_solutions_conformance_test.go diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-27-denials-name-their-solutions.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-27-denials-name-their-solutions.md new file mode 100644 index 000000000..8fa5d7ec0 --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-27-denials-name-their-solutions.md @@ -0,0 +1,7 @@ +### Denials now hand you the legal moves + +A denial that only states the rule leaves an agent — especially a smaller model — retrying the same blocked call. Every guard denial now carries its computed solution set: a short "You can:" list of exact runnable commands that are legal from the position the denial describes, with owed human inputs marked. A plan-gate denial lists the planning channel commands for that stage; an operation denial lists the inspection commands; a protected-path denial additionally names the verbs that own the path, straight from the state-ownership map. + +The picks are computed from the same declarations the guard enforces, and conformance sweeps keep the loop closed: every category of denial either enumerates picks or is a documented exception, and every pick passes the guard's own laws — the guard never hands out a command it would then deny. + +The plain reason string carries up to three picks on every host; the full set rides on the opt-in structured payload, additively, under the same schema version. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/denial.go b/labs/12-product-engineering-loop/product-engineering-loop/denial.go index 1412fff54..eca7d548e 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/denial.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/denial.go @@ -58,6 +58,18 @@ type Denial struct { Detail string // guidance; may contain `code` spans Reassurance string // "Nothing was written; your files are untouched." (empty if an effect occurred) Hint string // recovery command, e.g. "boatstack-helper diagnose-hook" + // Options is the denial's computed solution set: the admissible commands + // from exactly the position the finding describes, so a weaker model picks + // a legal move instead of retrying the blocked one. Derived from the same + // declarations the guard enforces; renders as a short "You can:" list and + // rides in full on the structured payload. + // control-law: solution-set-derives-from-guard-declarations + Options []PrescribedCommand + OptionsTruncated bool + // OwnerVerbs names the verbs that own a protected path (state-tamper + // denials), derived from the state-ownership map. Named, never compiled + // into runnable commands — their full arguments are not derivable here. + OwnerVerbs []string } // --- ANSI palette (truecolor; matches the approved mockup) ------------------- @@ -107,6 +119,29 @@ func (d Denial) Render(mode RenderMode) string { } } +// optionLines renders the solution set as at most `limit` numbered command +// lines, plus an overflow note. Shared by the three text renderers so every +// surface shows the same picks. +// control-law: solution-set-derives-from-guard-declarations +func (d Denial) optionLines(limit int) []string { + if len(d.Options) == 0 { + return nil + } + shown := d.Options + if len(shown) > limit { + shown = shown[:limit] + } + lines := make([]string, 0, len(shown)+1) + for i, option := range shown { + lines = append(lines, fmt.Sprintf(" %d) %s", i+1, option.CommandLine())) + } + hidden := len(d.Options) - len(shown) + if d.OptionsTruncated || hidden > 0 { + lines = append(lines, " (more legal moves: run boatstack-helper next-status)") + } + return lines +} + func (d Denial) renderPlain(badge string) string { var b strings.Builder head := badge @@ -122,6 +157,13 @@ func (d Denial) renderPlain(badge string) string { b.WriteString("\n\n↳ ") b.WriteString(d.Reassurance) } + if len(d.OwnerVerbs) > 0 { + b.WriteString("\n\nThis path is owned by: " + strings.Join(d.OwnerVerbs, ", ") + ".") + } + if lines := d.optionLines(solutionSetTextCap); len(lines) > 0 { + b.WriteString("\n\nYou can:\n") + b.WriteString(strings.Join(lines, "\n")) + } if d.Hint != "" { b.WriteString("\n\nFalse positive? run: ") b.WriteString(d.Hint) @@ -141,6 +183,15 @@ func (d Denial) renderMarkdown(badge string) string { if d.Reassurance != "" { b.WriteString("\n\n↳ _" + d.Reassurance + "_") } + if len(d.OwnerVerbs) > 0 { + b.WriteString("\n\nThis path is owned by: `" + strings.Join(d.OwnerVerbs, "`, `") + "`.") + } + if lines := d.optionLines(solutionSetTextCap); len(lines) > 0 { + b.WriteString("\n\nYou can:\n") + for _, line := range lines { + b.WriteString("\n" + line) + } + } if d.Hint != "" { b.WriteString("\n\nFalse positive? run `" + d.Hint + "`") } @@ -160,6 +211,15 @@ func (d Denial) renderANSI(badge string) string { if d.Reassurance != "" { b.WriteString("\n" + fgGray + "↳ " + d.Reassurance + ansiReset) } + if len(d.OwnerVerbs) > 0 { + b.WriteString("\n" + fgGray + "this path is owned by: " + ansiReset + fgCode + strings.Join(d.OwnerVerbs, ", ") + ansiReset) + } + if lines := d.optionLines(solutionSetTextCap); len(lines) > 0 { + b.WriteString("\n" + fgGray + "you can:" + ansiReset) + for _, line := range lines { + b.WriteString("\n" + fgCode + line + ansiReset) + } + } if d.Hint != "" { b.WriteString("\n" + fgGray + ansiDim + "false positive? run " + ansiReset + fgCode + d.Hint + ansiReset) } @@ -206,6 +266,33 @@ func (d Denial) Structured() map[string]any { if d.Hint != "" { out["hint"] = d.Hint } + // Additive keys only — schema_version stays 1; a consumer that ignores them + // loses nothing (the flat reason string already carries the capped picks). + // control-law: solution-set-derives-from-guard-declarations + if len(d.Options) > 0 { + options := make([]map[string]any, 0, len(d.Options)) + for _, option := range d.Options { + row := map[string]any{ + "verb": option.Verb, + "command_line": option.CommandLine(), + "transition": string(option.Transition), + } + if len(option.Args) > 0 { + row["args"] = option.Args + } + if len(option.RequiresHumanInput) > 0 { + row["requires_human_input"] = option.RequiresHumanInput + } + options = append(options, row) + } + out["options"] = options + if d.OptionsTruncated { + out["options_truncated"] = true + } + } + if len(d.OwnerVerbs) > 0 { + out["owner_verbs"] = d.OwnerVerbs + } return out } @@ -296,11 +383,27 @@ func DenialDemo(host string, mode RenderMode) string { if i > 0 { b.WriteString("\n\n") } - b.WriteString(denialFor(host, finding).Render(mode)) + b.WriteString(denialWithOptions(".", host, finding).Render(mode)) } return b.String() } +// denialWithOptions composes the pure finding→Denial mapping with the +// enumerated solution set for the finding's position. denialFor stays pure +// (DenialDemo and tests use it directly); the hook deny paths call this so +// every real denial carries its picks. +// control-law: solution-set-derives-from-guard-declarations +func denialWithOptions(repo, host string, finding SafetyFinding) Denial { + d := denialFor(host, finding) + set := enumerateDenialSolutions(repo, host, finding) + d.Options = set.Options + d.OptionsTruncated = set.Truncated + if finding.Category == "workflow-state-tamper" { + d.OwnerVerbs = tamperOwnerVerbs(repo, finding.AttemptedPath) + } + return d +} + const reassureUntouched = "Nothing was written; your files are untouched." // denialFor maps a SafetyFinding to a structured Denial. It preserves every diff --git a/labs/12-product-engineering-loop/product-engineering-loop/denial_solutions.go b/labs/12-product-engineering-loop/product-engineering-loop/denial_solutions.go new file mode 100644 index 000000000..3eaf32fb9 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/denial_solutions.go @@ -0,0 +1,194 @@ +package boatstack + +import ( + "strings" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// A denial that only states the law leaves a weaker model looping on the same +// blocked call. Each denial therefore carries the law's computed solution set: +// the admissible commands from exactly the position the finding describes, +// derived from the same declarations the guard enforces (the planning +// enumeration for phase findings, the registry's observe rows, the +// state-ownership map for protected paths) — never a hand-written list. +// The enumeration reads only the finding's own fields and the declared tables, +// so the deny path stays fast and cannot itself fail on unreadable state. +// control-law: solution-set-derives-from-guard-declarations + +// denialMarker names a denial-prescribed helper verb outside the delivery +// model, mirroring the planning./recovery. marker convention: self-describing +// provenance, never a legal registry transition, never auto-driven. +func denialMarker(verb string) deliverycontrol.TransitionID { + return deliverycontrol.TransitionID("denial." + strings.ReplaceAll(verb, "-", "_")) +} + +// denialSolutionExceptions are the finding categories that deliberately carry +// no solution set, with the reason. The totality sweep fails a new category +// until it gains an enumeration rule or an entry here. +var denialSolutionExceptions = map[string]string{ + "malformed-tool-input": "the tool event itself is unreadable; the Detail already names diagnose-hook with the exact host", + "unsupported-host": "an unknown host has no trusted verb surface to enumerate", + "unresolved-repository": "without a repository identity no command can be assembled faithfully", +} + +// enumerateDenialSolutions computes the solution set for a denial finding. +// host is the coding host the hook is serving (for diagnose-hook assembly). +func enumerateDenialSolutions(repo, host string, finding SafetyFinding) SolutionSet { + set := SolutionSet{Basis: "denial", Stage: finding.WorkflowStage} + if _, excepted := denialSolutionExceptions[finding.Category]; excepted { + return set + } + switch { + case finding.Category == "workflow-phase-bypass", finding.Category == "workflow-state-invalid": + // The finding carries the exact planning position; re-run the planning + // enumeration from it. Pure — no filesystem reads on the deny path. + status := NextStatus{ + ObservedStage: finding.WorkflowStage, + NextOperation: finding.NextOperation, + Feature: finding.BlockingFeature, + } + if finding.Category == "workflow-state-invalid" { + status.ObservedStage = "INVALID_STATE" + if finding.BlockingFeature != "" { + status.BlockingAmbiguity = []string{finding.BlockingFeature} + } + } + next := FlowNext{} + if cmd, _ := prescribePlanning(repo, status); cmd != nil { + next.Prescribed = cmd + } + planning := enumeratePlanningSolutions(repo, status, next) + set.Options, set.Truncated = planning.Options, planning.Truncated + return set + + case finding.Category == "workflow-state-tamper": + // The state-ownership map already declares who may write the path; the + // pick list is the position observers plus the hook diagnosis, and the + // owning verbs surface separately (OwnerVerbs) — a verb whose full + // arguments we cannot derive is named, never fabricated into a command. + appendObserveOption(&set, repo, "", "delivery.next") + appendDiagnoseHook(&set, repo, host) + return set + + case finding.Category == "workflow-publication-bypass": + if finding.BlockingFeature != "" { + if cmd, ok := prescribeCommand(repo, finding.BlockingFeature, NextStatus{ActiveSlice: finding.BlockingSlice}, PublishTransition); ok { + appendSolution(&set, *cmd) + } + } + appendObserveOption(&set, repo, finding.BlockingFeature, "delivery.recovery_status") + appendObserveOption(&set, repo, "", "delivery.next") + return set + + case strings.HasPrefix(finding.Category, "operation-"): + // Observation-only by design: inspect the durable operation state before + // any retry (the observed-effect discipline). + appendSolution(&set, PrescribedCommand{ + Verb: "operation-status", Args: repoFlagArgs(repo), AutoDerivable: true, + Transition: denialMarker("operation-status"), + }) + appendSolution(&set, PrescribedCommand{ + Verb: "mutation-status", Args: repoFlagArgs(repo), AutoDerivable: true, + Transition: denialMarker("mutation-status"), + }) + return set + + case finding.Category == "filesystem-destruction": + // The sanctioned actuator for the one deletion Boatstack owns; the + // operator confirmation is owed, never assumed. + appendSolution(&set, PrescribedCommand{ + Verb: "workspace-reap", Args: repoFlagArgs(repo), + RequiresHumanInput: []string{"--confirm"}, + Transition: denialMarker("workspace-reap"), + }) + appendDoctor(&set, repo) + appendObserveOption(&set, repo, "", "delivery.next") + return set + } + + // Generic fallthrough (destruction families, sync bypass, anything new): + // the position observers and the installation diagnosis are always legal. + appendObserveOption(&set, repo, "", "delivery.next") + appendDoctor(&set, repo) + return set +} + +// tamperOwnerVerbs derives the owning verbs of a protected path from the +// state-ownership map: the guard-protected entry whose boatstack subtree the +// attempted path names. Derived at runtime from StateRegistry — the same +// declaration the statemap conformance holds to the guard patterns. +// control-law: every-managed-path-has-a-declared-owner +func tamperOwnerVerbs(repo, attempted string) []string { + if attempted == "" { + return nil + } + normalized := filepath_ToSlashLower(attempted) + w := WorkspaceFor(repo) + for _, entry := range StateRegistry() { + if !entry.GuardProtected { + continue + } + sample, err := entry.Sample(w) + if err != nil { + continue + } + key := boatstackSubtreeKey(filepath_ToSlashLower(sample)) + if key != "" && strings.Contains(normalized, "boatstack/"+key) { + return entry.OwnerVerbs + } + } + return nil +} + +// boatstackSubtreeKey extracts the first path segment after the last +// "boatstack/" in a sample path — the subtree a guard-protected entry owns. +func boatstackSubtreeKey(path string) string { + marker := "boatstack/" + index := strings.LastIndex(path, marker) + if index < 0 { + return "" + } + rest := path[index+len(marker):] + if cut := strings.IndexByte(rest, '/'); cut >= 0 { + return rest[:cut] + } + return rest +} + +// filepath_ToSlashLower normalizes a path for fragment matching across +// platforms and case conventions. +func filepath_ToSlashLower(path string) string { + return strings.ToLower(strings.ReplaceAll(path, "\\", "/")) +} + +func appendObserveOption(set *SolutionSet, repo, feature, transition string) { + descriptor, ok := deliverycontrol.Transition(deliverycontrol.TransitionID(transition)) + if !ok { + return + } + if cmd, ok := prescribeObserve(repo, feature, descriptor); ok { + appendSolution(set, *cmd) + } +} + +func appendDoctor(set *SolutionSet, repo string) { + appendSolution(set, PrescribedCommand{ + Verb: "doctor", Args: repoFlagArgs(repo), AutoDerivable: true, + Transition: MarkerRecoveryDoctor, + }) +} + +func appendDiagnoseHook(set *SolutionSet, repo, host string) { + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + appendDoctor(set, repo) + return + } + appendSolution(set, PrescribedCommand{ + Verb: "diagnose-hook", + Args: append([]string{"--host", host}, repoFlagArgs(repo)...), + AutoDerivable: true, + Transition: denialMarker("diagnose-hook"), + }) +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/denial_solutions_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/denial_solutions_conformance_test.go new file mode 100644 index 000000000..e6eca577b --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/denial_solutions_conformance_test.go @@ -0,0 +1,190 @@ +package boatstack + +import ( + "strings" + "testing" +) + +// control-law: guard-never-prescribes-what-it-would-deny +// control-law: solution-set-derives-from-guard-declarations +// +// Denial-basis conformance for the solution set. A denial that only states the +// law leaves a weaker model looping on the same blocked call — the sibling +// harness's paid canary recorded exactly that trajectory: sixteen no-progress +// repair attempts escalating into a protected-boundary write. Every denial +// therefore carries computed picks, and these sweeps hold that carrier total +// (every category enumerates or is a documented exception) and closed (every +// pick passes the guard's own text laws). + +// denialCategoryInventory is one representative finding per category +// denialFor distinguishes, including the operation-* and generic fallthroughs. +// Extend it together with denialFor; the totality sweep fails a category that +// is neither enumerated nor excepted. +var denialCategoryInventory = []SafetyFinding{ + {Category: "malformed-tool-input", Reason: "empty-command", Source: "hook"}, + {Category: "workflow-state-invalid", NextOperation: "discard-delivery", BlockingFeature: "stale", Source: "delivery-state"}, + {Category: "workflow-state-tamper", Source: "delivery-state", AttemptedPath: ".git/boatstack/deliveries/demo/state.json"}, + {Category: "workflow-phase-bypass", Source: "planning-state", WorkflowStage: "DRAFT_PLAN", NextOperation: "plan-gate", BlockingFeature: "demo"}, + {Category: "workflow-phase-bypass", Source: "planning-state", WorkflowStage: "NOT_STARTED", NextOperation: "planning-write", AttemptedPath: ".product-loop/features/demo/plan.md"}, + {Category: "workflow-publication-bypass", BlockingFeature: "demo", BlockingSlice: "s1", Source: "tool-input"}, + {Category: "operation-in-flight", OperationID: "op_1", OperationState: "RUNNING", Source: "operation-state"}, + {Category: "operation-already-succeeded", OperationID: "op_2", OperationState: "SUCCEEDED", Source: "operation-state"}, + {Category: "operation-reconciliation-required", OperationID: "op_3", Source: "operation-state"}, + {Category: "operation-retry-exhausted", OperationID: "op_4", Source: "operation-state"}, + {Category: "operation-state-invalid", Source: "operation-state"}, + {Category: "git-history-destruction", Source: "command"}, + {Category: "workspace-sync-bypass", Source: "command"}, + {Category: "filesystem-destruction", Source: "command"}, + {Category: "database-destruction", Source: "command"}, + {Category: "infrastructure-destruction", Source: "command"}, + {Category: "external-resource-destruction", Source: "tool-input"}, + {Category: "unsupported-host", Source: "hook"}, + {Category: "unresolved-repository", Source: "hook"}, + {Category: "symlink-entrypoint", Source: "entry.sh"}, +} + +// Totality: every denial category yields a non-empty solution set or sits on +// the documented exception list with a reason. +func TestEveryDenialCategoryEnumeratesOrIsExcepted(t *testing.T) { + for _, finding := range denialCategoryInventory { + set := enumerateDenialSolutions(".", "claude", finding) + if reason, excepted := denialSolutionExceptions[finding.Category]; excepted { + if reason == "" { + t.Errorf("exception for %s must carry a reason", finding.Category) + } + if len(set.Options) != 0 { + t.Errorf("%s is excepted but enumerates options — remove the stale exception", finding.Category) + } + continue + } + if len(set.Options) == 0 { + t.Errorf("category %s enumerates no options and is not a documented exception", finding.Category) + } + } +} + +// Closure: every denial pick, after owed-input substitution, passes the +// text-level guard laws — the managed-state path law and the destruction +// classifier. The guard never hands out a command it would then deny as text. +func TestDenialSolutionCommandsPassTheTextGuards(t *testing.T) { + for _, finding := range denialCategoryInventory { + set := enumerateDenialSolutions(".", "claude", finding) + for _, option := range set.Options { + line := substituteOwedFlags(option.CommandLine()) + if deliveryStatePathPattern.MatchString(line) && !isPureReadOnlyCommand(line) && !approvedUpdatePublisherPattern.MatchString(line) { + t.Errorf("%s: pick %q names managed state the guard would deny", finding.Category, line) + } + if findings := classifySafetyText(line, "command", commandExecutesLiveSQL(line)); len(findings) > 0 { + t.Errorf("%s: pick %q trips the text guard: %+v", finding.Category, line, findings) + } + if option.AutoDerivable != (len(option.RequiresHumanInput) == 0) { + t.Errorf("%s: AutoDerivable must equal owed-input emptiness: %+v", finding.Category, option) + } + for _, owed := range option.RequiresHumanInput { + for _, arg := range option.Args { + if arg == owed { + t.Errorf("%s: owed flag %s fabricated into Args: %+v", finding.Category, owed, option) + } + } + } + } + } +} + +// Relation: a phase-bypass denial's picks are exactly guard-admitted at the +// finding's own stage — the same closure the flow basis holds, entered through +// the denial door. +func TestPhaseBypassDenialPicksAreGuardAdmitted(t *testing.T) { + finding := SafetyFinding{ + Category: "workflow-phase-bypass", Source: "planning-state", + WorkflowStage: "DRAFT_PLAN", NextOperation: "plan-gate", BlockingFeature: "demo", + } + set := enumerateDenialSolutions(".", "claude", finding) + if len(set.Options) == 0 { + t.Fatal("phase-bypass must enumerate picks") + } + for _, option := range set.Options { + line := substituteOwedFlags(option.CommandLine()) + if !controlledPhaseTransition(line, finding.WorkflowStage) { + t.Errorf("denial pick %q is not admitted at %s", line, finding.WorkflowStage) + } + } +} + +// Ownership: a state-tamper denial names the attempted path's declared owner +// verbs from the state-ownership map, and each named verb is a real one the +// map declares for that subtree. +func TestTamperDenialNamesDeclaredOwnerVerbs(t *testing.T) { + repo := safetyTestRepo(t) + cases := map[string][]string{ + ".git/boatstack/deliveries/demo/state.json": {"activate-plan", "record-delivery-gate", "record-change", "publish-pr", "repair-state", "discard-delivery"}, + ".git/boatstack/updates/v9.9.9/pr-preview.json": {"prepare-update-pr", "publish-update-pr"}, + ".git/boatstack/mutations/v1/abc.json": {"activate-plan", "undo"}, + ".git/boatstack/quarantine/demo/receipt.json": {"repair-state"}, + "state-root/boatstack/registry.json": {"attach", "detach"}, + ".git/boatstack/visual-evidence/x/manifest.json": {"record-pr-visual-evidence", "capture-evidence", "record-pr-visual-publication"}, + "boatstack/repositories/sample/binding.json": {"attach", "detach", "activate"}, + } + for attempted, want := range cases { + got := tamperOwnerVerbs(repo, attempted) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("owner verbs for %s = %v, want %v", attempted, got, want) + } + } + if got := tamperOwnerVerbs(repo, ""); got != nil { + t.Errorf("empty attempted path must derive no owners, got %v", got) + } + if got := tamperOwnerVerbs(repo, "src/app.ts"); got != nil { + t.Errorf("unmanaged path must derive no owners, got %v", got) + } +} + +// Rendering: the plain denial carries the capped "You can:" list; the +// structured payload carries the full set additively under schema_version 1. +func TestDenialRenderingCarriesTheSolutionSet(t *testing.T) { + finding := SafetyFinding{ + Category: "workflow-phase-bypass", Source: "planning-state", + WorkflowStage: "DRAFT_PLAN", NextOperation: "plan-gate", BlockingFeature: "demo", + } + denial := denialWithOptions(".", "claude", finding) + if len(denial.Options) == 0 { + t.Fatal("denial must carry options") + } + for _, mode := range []RenderMode{RenderPlain, RenderMarkdown, RenderANSI} { + out := denial.Render(mode) + if !strings.Contains(strings.ToLower(out), "you can:") { + t.Errorf("mode %v must render the You can list:\n%s", mode, out) + } + } + plain := denial.Render(RenderPlain) + if got := strings.Count(plain, "\n "); got > solutionSetTextCap+1 { + t.Errorf("plain rendering must cap the pick list, got %d lines:\n%s", got, plain) + } + structured := denial.Structured() + if structured["schema_version"] != 1 { + t.Fatalf("options are additive; schema_version must stay 1, got %v", structured["schema_version"]) + } + options, ok := structured["options"].([]map[string]any) + if !ok || len(options) != len(denial.Options) { + t.Fatalf("structured options must carry the full set: %v", structured["options"]) + } + for _, row := range options { + if row["command_line"] == "" || row["verb"] == "" || row["transition"] == "" { + t.Errorf("structured option incomplete: %v", row) + } + } + + tamper := denialWithOptions(".", "claude", SafetyFinding{ + Category: "workflow-state-tamper", Source: "delivery-state", + AttemptedPath: ".git/boatstack/deliveries/demo/state.json", + }) + if len(tamper.OwnerVerbs) == 0 { + t.Fatal("tamper denial must derive owner verbs") + } + if out := tamper.Render(RenderPlain); !strings.Contains(out, "This path is owned by: activate-plan") { + t.Errorf("tamper rendering must name the owners:\n%s", out) + } + if verbs, ok := tamper.Structured()["owner_verbs"].([]string); !ok || len(verbs) == 0 { + t.Errorf("structured tamper payload must carry owner_verbs") + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/denial_test.go b/labs/12-product-engineering-loop/product-engineering-loop/denial_test.go index 4f4aaaff1..f2c5517b7 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/denial_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/denial_test.go @@ -154,7 +154,7 @@ func TestStructuredDenialObjectAndRichGate(t *testing.T) { // Rich object is gated off by default; the flat reason stays complete. t.Setenv("BOATSTACK_DENIAL_RICH", "") - out, err := structuredHookDeny("claude", finding) + out, err := structuredHookDeny(".", "claude", finding) if err != nil { t.Fatal(err) } @@ -172,7 +172,7 @@ func TestStructuredDenialObjectAndRichGate(t *testing.T) { // Opt-in adds the object while keeping the flat reason. t.Setenv("BOATSTACK_DENIAL_RICH", "1") - out, err = structuredHookDeny("claude", finding) + out, err = structuredHookDeny(".", "claude", finding) if err != nil { t.Fatal(err) } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/safety.go b/labs/12-product-engineering-loop/product-engineering-loop/safety.go index f17686393..d0f85b628 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/safety.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/safety.go @@ -807,7 +807,10 @@ func ClassifyCommand(repo, command string) []SafetyFinding { return []SafetyFinding{{Category: "malformed-tool-input", Reason: "empty-command", Source: "tool-input"}} } if deliveryStatePathPattern.MatchString(command) && !isPureReadOnlyCommand(command) && !approvedUpdatePublisherPattern.MatchString(command) { - return []SafetyFinding{{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state"}} + // AttemptedPath carries the matched managed-path fragment (bounded and + // secret-free, like the phase-bypass finding) so the denial can name the + // path's declared owner verbs from the state-ownership map. + return []SafetyFinding{{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state", AttemptedPath: deliveryStatePathPattern.FindString(command)}} } if directPublicationPattern.MatchString(command) && !approvedPublisherPattern.MatchString(command) { if finding, blocked := publicationBypassFinding(repo, "direct push or PR mutation is denied while a managed delivery slice is active", "tool-input"); blocked { @@ -895,7 +898,7 @@ func ClassifyTool(repo, name string, input any) []SafetyFinding { } publicationText := strings.ToLower(combined) if deliveryStatePathPattern.MatchString(combined) && regexp.MustCompile(`(?:write|edit|delete|remove|move|rename|create|update)`).MatchString(nameLower) { - findings = append(findings, SafetyFinding{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state"}) + findings = append(findings, SafetyFinding{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state", AttemptedPath: deliveryStatePathPattern.FindString(combined)}) } if (strings.Contains(publicationText, "pull_request") || strings.Contains(publicationText, "pull request")) && regexp.MustCompile(`(?:create|update|edit|merge|publish)`).MatchString(publicationText) { @@ -1121,7 +1124,10 @@ func dedupeFindings(values []SafetyFinding) []SafetyFinding { type hookHostContract struct { decode func([]byte) (string, any, error) allow func() ([]byte, error) - deny func(SafetyFinding) ([]byte, error) + // deny takes the resolved repository so the denial can carry its computed + // solution set (empty when the repository could not be resolved). + // control-law: solution-set-derives-from-guard-declarations + deny func(repo string, finding SafetyFinding) ([]byte, error) } func decodeJSONObject(host string, value []byte) (map[string]any, error) { @@ -1259,17 +1265,17 @@ func decodeGeminiHook(value []byte) (string, any, error) { return name, input, nil } -func structuredHookDeny(host string, finding SafetyFinding) ([]byte, error) { - message := denialMessage(host, finding) +func structuredHookDeny(repo, host string, finding SafetyFinding) ([]byte, error) { + denial := denialWithOptions(repo, host, finding) hookOutput := map[string]any{ - "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": message, + "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": denial.Render(RenderPlain), } // Opt-in structured object for hosts that adopt rich denial rendering. Nested // inside the host's existing container; the flat reason above is always the // complete fallback for any host that ignores it. Off by default (no host // documents tolerating unknown keys — see references/host-hook-contracts.md). if denialRichEnabled() { - hookOutput["boatstackDenial"] = denialFor(host, finding).Structured() + hookOutput["boatstackDenial"] = denial.Structured() } value, err := json.Marshal(map[string]any{"hookSpecificOutput": hookOutput}) return append(value, '\n'), err @@ -1282,13 +1288,14 @@ var hookHostContracts = map[string]hookHostContract{ value, err := json.Marshal(map[string]any{"continue": true, "permission": "allow"}) return append(value, '\n'), err }, - deny: func(finding SafetyFinding) ([]byte, error) { - message := denialMessage("cursor", finding) + deny: func(repo string, finding SafetyFinding) ([]byte, error) { + denial := denialWithOptions(repo, "cursor", finding) + message := denial.Render(RenderPlain) payload := map[string]any{ "continue": true, "permission": "deny", "user_message": message, "agent_message": message, } if denialRichEnabled() { - payload["boatstackDenial"] = denialFor("cursor", finding).Structured() + payload["boatstackDenial"] = denial.Structured() } value, err := json.Marshal(payload) return append(value, '\n'), err @@ -1297,12 +1304,16 @@ var hookHostContracts = map[string]hookHostContract{ "claude": { decode: func(value []byte) (string, any, error) { return decodePreToolUseHook("claude", value) }, allow: func() ([]byte, error) { return nil, nil }, - deny: func(finding SafetyFinding) ([]byte, error) { return structuredHookDeny("claude", finding) }, + deny: func(repo string, finding SafetyFinding) ([]byte, error) { + return structuredHookDeny(repo, "claude", finding) + }, }, "codex": { decode: func(value []byte) (string, any, error) { return decodePreToolUseHook("codex", value) }, allow: func() ([]byte, error) { return nil, nil }, - deny: func(finding SafetyFinding) ([]byte, error) { return structuredHookDeny("codex", finding) }, + deny: func(repo string, finding SafetyFinding) ([]byte, error) { + return structuredHookDeny(repo, "codex", finding) + }, }, "gemini": { decode: decodeGeminiHook, @@ -1310,10 +1321,11 @@ var hookHostContracts = map[string]hookHostContract{ value, err := json.Marshal(map[string]any{"decision": "allow"}) return append(value, '\n'), err }, - deny: func(finding SafetyFinding) ([]byte, error) { - payload := map[string]any{"decision": "deny", "reason": denialMessage("gemini", finding)} + deny: func(repo string, finding SafetyFinding) ([]byte, error) { + denial := denialWithOptions(repo, "gemini", finding) + payload := map[string]any{"decision": "deny", "reason": denial.Render(RenderPlain)} if denialRichEnabled() { - payload["boatstackDenial"] = denialFor("gemini", finding).Structured() + payload["boatstackDenial"] = denial.Structured() } value, err := json.Marshal(payload) return append(value, '\n'), err @@ -1322,12 +1334,13 @@ var hookHostContracts = map[string]hookHostContract{ } // denialMessage renders the human-facing reason string embedded in a host's hook -// decision. It delegates to the structured Denial model (denial.go) and renders -// the plain, multi-line form — the safe default that every host displays. Richer -// treatments (markdown, ANSI, the structured object) are produced from the same -// Denial by the CLI/guard surfaces and the opt-in rich path. -func denialMessage(host string, finding SafetyFinding) string { - return denialFor(host, finding).Render(RenderPlain) +// decision. It delegates to the structured Denial model (denial.go) — including +// the finding's computed solution set — and renders the plain, multi-line form, +// the safe default that every host displays. Richer treatments (markdown, ANSI, +// the structured object) are produced from the same Denial by the CLI/guard +// surfaces and the opt-in rich path. +func denialMessage(repo, host string, finding SafetyFinding) string { + return denialWithOptions(repo, host, finding).Render(RenderPlain) } // AmbientHookDecision is the entry point for a developer-level (user-scoped) guard @@ -1355,19 +1368,19 @@ func HookDecision(options SafetyHookOptions) ([]byte, bool) { contract, supported := hookHostContracts[host] if !supported { finding := SafetyFinding{Category: "unsupported-host", Reason: "unknown host is denied by the fail-closed guard", Source: "hook"} - value, _ := structuredHookDeny("codex", finding) + value, _ := structuredHookDeny("", "codex", finding) return value, true } repo, err := ResolveRepository(options.Repo) if err != nil { finding := SafetyFinding{Category: "unresolved-repository", Reason: "repository identity could not be established", Source: "hook"} - value, _ := contract.deny(finding) + value, _ := contract.deny("", finding) return value, true } if handled, malformed := completeSupervisedToolEvent(repo, host, options.Input); handled { if malformed { finding := SafetyFinding{Category: "malformed-tool-input", Reason: "invalid-post-event", Source: "hook"} - value, _ := contract.deny(finding) + value, _ := contract.deny(repo, finding) return value, true } value, _ := contract.allow() @@ -1381,19 +1394,19 @@ func HookDecision(options SafetyHookOptions) ([]byte, bool) { reason = decodeErr.code } finding := SafetyFinding{Category: "malformed-tool-input", Reason: reason, Source: "hook"} - value, _ := contract.deny(finding) + value, _ := contract.deny(repo, finding) return value, true } findings := ClassifyTool(repo, name, input) if len(findings) == 0 { if finding := superviseToolAttempt(repo, host, name, input, options.Input); finding != nil { - value, _ := contract.deny(*finding) + value, _ := contract.deny(repo, *finding) return value, true } value, _ := contract.allow() return value, false } - value, _ := contract.deny(findings[0]) + value, _ := contract.deny(repo, findings[0]) return value, true } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/safety_corpus_test.go b/labs/12-product-engineering-loop/product-engineering-loop/safety_corpus_test.go index 633f2cf3f..c5f786ce6 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/safety_corpus_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/safety_corpus_test.go @@ -109,10 +109,19 @@ func TestGuardCorpusDualReward(t *testing.T) { "content": "Delivery state lives under `.git/boatstack/deliveries/` and is owned by the helper.", }, "routine", false}, {"edit-note-mentions-git-reset", "Edit", map[string]any{ - "file_path": filepath.Join(repo, "notes.md"), + "file_path": filepath.Join(repo, "notes.md"), "old_string": "TODO", "new_string": "Never run `git reset --hard` on shared branches; use revert PRs.", }, "routine", false}, + // Solution-set picks: commands the guard itself enumerates as legal moves + // at THIS fixture's stage must pass the guard + // (guard-never-prescribes-what-it-would-deny). Stage-scoped picks like + // record-approval are held in their own fixture by the closure sweep. + {"pick-undo", "", `boatstack-helper undo --mutation abc123`, "routine", false}, + {"pick-discard-delivery", "", `boatstack-helper discard-delivery --feature stale`, "routine", false}, + {"pick-operation-status", "", `boatstack-helper operation-status`, "routine", false}, + {"pick-record-change", "", `boatstack-helper record-change --feature demo --slice s1 --message fix --source-stage ci --classification implementation_repair`, "routine", false}, + {"pick-next-status", "", `boatstack-helper next-status`, "routine", false}, } var constTotal, constBlocked, routineTotal, routinePassed int diff --git a/labs/12-product-engineering-loop/product-engineering-loop/safety_test.go b/labs/12-product-engineering-loop/product-engineering-loop/safety_test.go index e92164541..9b127030b 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/safety_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/safety_test.go @@ -100,7 +100,7 @@ func TestReapHelperIsExemptWhileRawWorktreeRemovalStaysDenied(t *testing.T) { if len(rawFilesystem) == 0 || rawFilesystem[0].Category != "filesystem-destruction" { t.Fatalf("raw worktree deletion was not denied: %#v", rawFilesystem) } - if message := denialMessage("cursor", rawFilesystem[0]); !strings.Contains(message, "workspace-reap") { + if message := denialMessage(".", "cursor", rawFilesystem[0]); !strings.Contains(message, "workspace-reap") { t.Fatalf("filesystem-destruction denial should redirect to workspace-reap: %s", message) } @@ -109,7 +109,7 @@ func TestReapHelperIsExemptWhileRawWorktreeRemovalStaysDenied(t *testing.T) { if len(rawState) == 0 || rawState[0].Category != "workflow-state-tamper" { t.Fatalf("raw runtime-state deletion was not denied: %#v", rawState) } - if message := denialMessage("cursor", rawState[0]); !strings.Contains(message, "workspace-reap") { + if message := denialMessage(".", "cursor", rawState[0]); !strings.Contains(message, "workspace-reap") { t.Fatalf("workflow-state-tamper denial should mention workspace-reap: %s", message) } } @@ -142,7 +142,7 @@ func TestWorkspaceSyncIsTheOnlyAllowedRepositoryAlignmentCommand(t *testing.T) { if len(raw) == 0 || raw[0].Category != "git-history-destruction" { t.Fatalf("raw hard reset was not denied: %#v", raw) } - message := denialMessage("cursor", raw[0]) + message := denialMessage(".", "cursor", raw[0]) for _, expected := range []string{"project-local workspace-sync", "do not scan delivery artifacts", "do not", "retry"} { if !strings.Contains(message, expected) { t.Fatalf("hard-reset denial omitted %q: %s", expected, message) From 9e159d92b8096168b1466654e55ba1fab4d5981d Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 27 Jul 2026 22:43:53 +0100 Subject: [PATCH 2/2] test(boatstack): make the tamper-rendering conformance hermetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The projected distribution runs the Go tests outside any Git repository; owner-verb derivation resolves per-worktree sample paths, which needs a real Git directory. The tamper rendering check now uses the git-backed fixture like its sibling ownership test, instead of the package directory. Disclosure-Reviewed: reviewed — public-safe only, private facet kept out of this commit --- .../denial_solutions_conformance_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/labs/12-product-engineering-loop/product-engineering-loop/denial_solutions_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/denial_solutions_conformance_test.go index e6eca577b..dfee3c8f4 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/denial_solutions_conformance_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/denial_solutions_conformance_test.go @@ -174,7 +174,10 @@ func TestDenialRenderingCarriesTheSolutionSet(t *testing.T) { } } - tamper := denialWithOptions(".", "claude", SafetyFinding{ + // Ownership derivation resolves per-worktree sample paths, which needs a + // real Git directory — the projected distribution runs these tests outside + // any repository, so the tamper case uses the git-backed fixture. + tamper := denialWithOptions(safetyTestRepo(t), "claude", SafetyFinding{ Category: "workflow-state-tamper", Source: "delivery-state", AttemptedPath: ".git/boatstack/deliveries/demo/state.json", })