diff --git a/boatstack/detached_command_admission_conformance_test.go b/boatstack/detached_command_admission_conformance_test.go new file mode 100644 index 0000000..c3421dc --- /dev/null +++ b/boatstack/detached_command_admission_conformance_test.go @@ -0,0 +1,204 @@ +package boatstack + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func detachedPolicyReadyFixture(t *testing.T) (string, WorkspaceContext, FlowNext) { + t.Helper() + repo := detachedTestRepo(t, "https://github.com/acme/detached-command-admission.git") + // Exercise the real macOS failure shape: the trusted helper path contains a + // space and therefore must survive rendering and literal parsing unchanged. + t.Setenv(stateRootEnv, filepath.Join(t.TempDir(), "Application Support")) + invalidateWorkspaceCache() + embeddedFeatureForDetach(t, repo, "feature-one", "") + result, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach detached fixture: %+v %v", result, err) + } + workspace, err := ResolveWorkspaceContext(repo) + if err != nil || workspace.Mode != SupervisionDetached { + t.Fatalf("resolve detached workspace: %+v %v", workspace, err) + } + next, err := NextControl(repo, "feature-one") + if err != nil || next.Prescribed == nil || next.Prescribed.Verb != "activate-plan" { + t.Fatalf("policy-ready fixture did not prescribe activation: %+v %v", next, err) + } + return repo, workspace, next +} + +// Positive and relation conformance for control-law: +// guard-never-denies-an-owned-transition. +func TestDetachedPrescribedActivationPassesEveryHostGuard(t *testing.T) { + repo, _, next := detachedPolicyReadyFixture(t) + command := next.Prescribed.CommandLine() + words, complete := literalCommandWords(strings.TrimPrefix(command, "& ")) + if !complete || len(words) < 2 || words[0] != next.Prescribed.Program || !strings.Contains(words[0], "Application Support") { + t.Fatalf("fixture lost the path-with-spaces witness: %q", command) + } + if findings := ClassifyCommand(repo, command); len(findings) != 0 { + t.Fatalf("guard denied its exact detached activation prescription: %+v\n%s", findings, command) + } + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { + t.Run(host, func(t *testing.T) { + if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, command)}); denied { + t.Fatalf("%s denied the exact owned transition: %s", host, output) + } + }) + } +} + +// Negative, bypass, and failure-state conformance for control-law: +// guard-never-denies-an-owned-transition. Only the exact bound helper and its +// same-workspace paths receive semantic admission; lexical lookalikes retain the +// controller-state tamper denial. +func TestDetachedCommandAdmissionRejectsUnownedAndWrongStageForms(t *testing.T) { + repo, workspace, next := detachedPolicyReadyFixture(t) + valid := next.Prescribed.CommandLine() + otherRoot := filepath.Join(filepath.Dir(workspace.controlRoot), "ffffffffffffffff") + otherHelper := filepath.Join(otherRoot, productLoopDirName, "bin", helperName()) + otherOutput := filepath.Join(otherRoot, productLoopDirName, "features", "feature-one", "plan.lock.json") + otherRepo := t.TempDir() + wrongStage := PrescribedCommand{ + Program: workspace.HelperPath(), Verb: "record-approval", + Args: []string{"--plan", filepath.Join(workspace.FeatureDir("feature-one"), "plan.md")}, + }.CommandLine() + + tests := []struct { + name string + command string + category string + }{ + {"sibling helper", strings.Replace(valid, posixPlanningWord(workspace.HelperPath()), posixPlanningWord(otherHelper), 1), "workflow-state-tamper"}, + {"mixed controller roots", strings.Replace(valid, posixPlanningWord(filepath.Join(workspace.FeatureDir("feature-one"), "plan.lock.json")), posixPlanningWord(otherOutput), 1), "workflow-state-tamper"}, + {"conflicting repository flags", valid + " --repo " + posixPlanningWord(otherRepo), "workflow-state-tamper"}, + {"conflicting feature flags", valid + " --feature feature-two", "workflow-state-tamper"}, + {"compound command", valid + " ; echo bypass", "workflow-state-tamper"}, + {"wrong stage", wrongStage, "workflow-phase-bypass"}, + {"raw controller deletion", "rm -rf " + posixPlanningWord(workspace.controlRoot), "workflow-state-tamper"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + findings := ClassifyCommand(repo, test.command) + if len(findings) == 0 || findings[0].Category != test.category { + t.Fatalf("%s escaped the boundary: %+v\n%s", test.name, findings, test.command) + } + }) + } + if status, err := ResolveNext(repo, "feature-one"); err != nil || status.ObservedStage != "POLICY_READY" { + t.Fatalf("denials changed protected workflow state: %+v %v", status, err) + } +} + +// Relation conformance for control-law: active-delivery-effects-are-supervised. +// Boatstack's own read-only detached observations are not external effects and +// must never create a competing generic operation receipt. +func TestDetachedReadOnlyHelperIsReceiptFreeDuringActiveDelivery(t *testing.T) { + repo, workspace, _ := detachedPolicyReadyFixture(t) + directory := workspace.FeatureDir("feature-one") + lockPath := filepath.Join(directory, "plan.lock.json") + if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil { + t.Fatal(err) + } + lockHash, err := SHA256File(lockPath) + if err != nil { + t.Fatal(err) + } + if err := saveDeliveryState(repo, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: "feature-one", PlanLockHash: lockHash, + ActiveIndex: 0, Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: "BUILD", BaseBranch: "main", HeadBranch: "main"}}, + }); err != nil { + t.Fatal(err) + } + command := PrescribedCommand{ + Program: workspace.HelperPath(), Verb: "next-status", + Args: []string{"--repo", repo, "--feature", "feature-one"}, + }.CommandLine() + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { + if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, command)}); denied { + t.Fatalf("%s denied a detached read-only observation: %s", host, output) + } + status, statusErr := ResolveOperationStatus(repo, "") + if statusErr != nil || status.Operation != nil { + t.Fatalf("%s created a generic operation receipt: %+v %v", host, status, statusErr) + } + } +} + +// Positive, negative, and relation conformance for control-law: +// guard-never-denies-an-owned-transition. The opt-in driver is an owned +// coordinator only when its feature scope is explicit; the helper retains the +// inner AutoDerivable and transition-allowlist gates. +func TestDetachedFlowExecuteCoordinatorRequiresExplicitFeature(t *testing.T) { + repo, workspace, _ := detachedPolicyReadyFixture(t) + command := PrescribedCommand{ + Program: workspace.HelperPath(), Verb: "flow", + Args: []string{"next", "--repo", repo, "--feature", "feature-one", "--execute"}, + }.CommandLine() + if findings := ClassifyCommand(repo, command); len(findings) != 0 { + t.Fatalf("guard denied the exact feature-scoped flow coordinator: %+v", findings) + } + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { + if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, command)}); denied { + t.Fatalf("%s denied the exact feature-scoped flow coordinator: %s", host, output) + } + } + + unscoped := strings.Replace(command, " --feature feature-one", "", 1) + if findings := ClassifyCommand(repo, unscoped); len(findings) == 0 || findings[0].Category != "workflow-state-tamper" { + t.Fatalf("unscoped execute coordinator crossed the protected boundary: %+v", findings) + } + malformed := command + " --unknown" + if findings := ClassifyCommand(repo, malformed); len(findings) == 0 { + t.Fatalf("malformed execute coordinator crossed the protected boundary: %+v", findings) + } +} + +// Relation conformance for control-law: guard-never-denies-an-owned-transition. +// Feed the real detached solution set back through the full classifier at each +// live delivery stage so helper-path protection and the workflow oracle cannot +// drift independently again. +func TestDetachedDeliverySolutionSetPassesFullClassifier(t *testing.T) { + for _, stage := range []string{"BUILD", "TEST_PASSED", "REVIEW_PASSED"} { + t.Run(stage, func(t *testing.T) { + repo, workspace, _ := detachedPolicyReadyFixture(t) + lockPath := filepath.Join(workspace.FeatureDir("feature-one"), "plan.lock.json") + if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil { + t.Fatal(err) + } + lockHash, err := SHA256File(lockPath) + if err != nil { + t.Fatal(err) + } + if err := saveDeliveryState(repo, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: "feature-one", PlanLockHash: lockHash, + ActiveIndex: 0, Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: stage, BaseBranch: "main", HeadBranch: "main"}}, + }); err != nil { + t.Fatal(err) + } + next, err := NextControl(repo, "feature-one") + if err != nil { + t.Fatal(err) + } + options := append([]PrescribedCommand{}, next.Alternatives...) + if next.Prescribed != nil { + options = append(options, *next.Prescribed) + } + if len(options) == 0 { + t.Fatalf("%s exposed no legal solution", stage) + } + for _, option := range options { + if option.Program == "gh" { + continue + } + command := substituteOwedFlags(option.CommandLine()) + if findings := ClassifyCommand(repo, command); len(findings) != 0 { + t.Errorf("%s guard denied its prescribed %q: %+v", stage, command, findings) + } + } + }) + } +} diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go index 667b994..2e92bee 100644 --- a/boatstack/flow_control.go +++ b/boatstack/flow_control.go @@ -3,6 +3,7 @@ package boatstack import ( "fmt" "path/filepath" + "runtime" "strings" "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" @@ -332,12 +333,16 @@ func (p PrescribedCommand) CommandLine() string { } parts = append(parts, flag, "") } - line := strings.Join(parts, " ") - if literalPlanningInput { - for index := range parts { + for index := range parts { + if parts[index] != "" { parts[index] = posixPlanningWord(parts[index]) } - line = strings.Join(parts, " ") + } + line := strings.Join(parts, " ") + if filepath.IsAbs(program) && runtime.GOOS == "windows" { + line = "& " + line + } + if literalPlanningInput { return line + " <<'BOATSTACK_PLAN_EOF'\n\nBOATSTACK_PLAN_EOF" } return line diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 26b383e..c9e1aa0 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -465,6 +465,8 @@ For an available version, create `chore/update-boatstack-v` and downloa If the ignored local install lock is missing, malformed, or carries a development identity, the verified target helper derives the prior stable version only from `HEAD:.product-loop/generated.lock.json`. That committed pin makes the local provenance path repairable without trusting drifted worktree bytes. Every mutable controller target is paired with its owning storage boundary: embedded worktree state uses the worktree Git directory, embedded shared state uses the Git common directory, and detached state uses the external Boatstack control root. Effectful callers validate the paired boundary and never reconstruct it from the repository path. +The detached helper and its generated feature paths necessarily live inside that protected external root. Host hooks admit those paths only when a literal command uses the exact regular, non-symlink helper bound by the current repository's verified workspace context, every controller operand stays inside the same root, and the requested transition belongs to the resolved workflow position. Read-only helper observations remain read-only. A sibling helper, mixed controller roots, raw file operation, malformed command, or stage-invalid transition remains denied without changing controller state. + A terminal update receipt is consumed only while its target postcondition still holds. Before returning success for a prior `install-update`, Boatstack checks the target generated bundle, host hooks, execution interceptors, committed runtime pin, shared and local runtime identity, and preserved integrations. If an operator restored the old committed pin or otherwise removed that local atomic result, Boatstack records `POSTCONDITION_MISSING`, reopens only that `ATOMIC_LOCAL` update, and performs a fresh bounded attempt. PR publication and other external operations retain terminal replay suppression and are never reopened by this rule. `update -binary ` installs the passed binary's **own self-reported version**, not the running helper's. Because each helper embeds its own version-bound generated bundle and compile-time constants, an older helper cannot correctly install a newer one in-process; when the passed binary self-reports a different identity, the whole update is re-executed by that binary so it installs itself — its bundle, constants, version-keyed shared-runtime slot, and durable receipt are then authoritative by construction, and the hand-off terminates in a single hop. The write boundary refuses to install a `-binary` whose self-report disagrees with the process running it, and re-hashes the freshly written slot against its manifest, rolling back on mismatch — so a runtime can never be labeled one version while carrying another's bytes. diff --git a/boatstack/safety.go b/boatstack/safety.go index 45c63e2..af58a84 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -50,6 +50,18 @@ type SafetyHookOptions struct { Input []byte } +// ownedCommandAdmission is the typed exception to lexical managed-path +// protection. It never makes a command safe by basename or path text alone: an +// admitted command must use the exact helper from the repository's verified +// WorkspaceContext, stay inside that same controller root, and be legal at the +// resolved workflow position. +// control-law: guard-never-denies-an-owned-transition +type ownedCommandAdmission struct { + Allowed bool + ReadOnly bool + Finding *SafetyFinding +} + type hookDecodeError struct { code string } @@ -259,6 +271,260 @@ func controlledPhaseTransition(command, stage string) bool { return false } +func commandFlagValue(words []string, name string) (string, bool) { + value := "" + seen := false + for index := 2; index < len(words); index++ { + candidate := "" + if words[index] == name { + if index+1 >= len(words) { + return "", false + } + candidate = words[index+1] + if strings.HasPrefix(candidate, "-") { + return "", false + } + index++ + } else if strings.HasPrefix(words[index], name+"=") { + candidate = strings.TrimPrefix(words[index], name+"=") + } else { + continue + } + if strings.TrimSpace(candidate) == "" || (seen && candidate != value) { + return "", false + } + value = candidate + seen = true + } + return value, true +} + +func mergeCommandFeature(current, candidate string) (string, bool) { + if candidate == "" { + return current, true + } + if !featureSlugPattern.MatchString(candidate) || (current != "" && current != candidate) { + return "", false + } + return candidate, true +} + +func ownedCommandFeature(workspace WorkspaceContext, words []string) (string, bool) { + explicit, flagsOK := commandFlagValue(words, "--feature") + if !flagsOK { + return "", false + } + feature, ok := mergeCommandFeature("", explicit) + if !ok { + return "", false + } + featureRoot := workspace.FeatureRoot() + for _, word := range words[2:] { + if !filepath.IsAbs(word) || !pathWithin(featureRoot, word) { + continue + } + relative, err := filepath.Rel(featureRoot, word) + if err != nil { + return "", false + } + parts := strings.Split(filepath.ToSlash(relative), "/") + if len(parts) == 0 { + return "", false + } + feature, ok = mergeCommandFeature(feature, parts[0]) + if !ok { + return "", false + } + } + return feature, true +} + +func ownedReadOnlyHelperCommand(words []string) bool { + if len(words) < 2 { + return false + } + if readOnlyHelperVerbs[words[1]] { + return true + } + if len(words) < 3 { + return false + } + switch words[1] { + case "insight": + return map[string]bool{"check": true, "list": true, "show": true, "frontier": true, "evaluate": true}[words[2]] + case "flow": + if !map[string]bool{"check": true, "next": true, "tasks": true, "frontier": true, "watch": true, "report": true}[words[2]] { + return false + } + for _, word := range words[3:] { + if word == "--execute" || strings.HasPrefix(word, "--execute=") { + return false + } + } + return true + } + return false +} + +func knownOwnedMutationVerb(verb string) bool { + if stageIndependentRecoveryVerbs[verb] { + return true + } + for _, verbs := range stageMutationVerbs { + for _, candidate := range verbs { + if candidate == verb { + return true + } + } + } + for _, candidate := range []string{"record-delivery-gate", "record-change", "publish-pr", "attach-evidence", "flow"} { + if candidate == verb { + return true + } + } + return false +} + +func commandMatchesSolutionVerb(next FlowNext, verb string) bool { + options := append([]PrescribedCommand{}, next.Alternatives...) + if next.Prescribed != nil { + options = append(options, *next.Prescribed) + } + for _, option := range options { + if option.Program == "gh" { + continue + } + if option.Verb == verb { + return true + } + } + return false +} + +func ownedFlowExecuteCoordinator(words []string, feature string) bool { + if len(words) < 4 || words[1] != "flow" || words[2] != "next" || feature == "" { + return false + } + execute := false + for index := 3; index < len(words); index++ { + word := words[index] + switch { + case word == "--repo" || word == "--feature": + if index+1 >= len(words) || strings.TrimSpace(words[index+1]) == "" { + return false + } + index++ + case strings.HasPrefix(word, "--repo=") || strings.HasPrefix(word, "--feature="): + if strings.TrimSpace(strings.SplitN(word, "=", 2)[1]) == "" { + return false + } + case word == "--json" || word == "--json=true" || word == "--json=false": + case word == "--execute" || word == "--execute=true": + if execute { + return false + } + execute = true + default: + return false + } + } + return execute +} + +func ownedBoatstackCommand(repo, command string) ownedCommandAdmission { + if !deliveryStatePathPattern.MatchString(command) { + return ownedCommandAdmission{} + } + trimmed := strings.TrimSpace(command) + if strings.HasPrefix(trimmed, "& ") { + trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, "& ")) + } + words, complete := literalCommandWords(trimmed) + if !complete || len(words) < 2 { + return ownedCommandAdmission{} + } + workspace, err := ResolveWorkspaceContext(repo) + if err != nil || workspace.Mode != SupervisionDetached || !filepath.IsAbs(words[0]) { + return ownedCommandAdmission{} + } + executable, err := filepath.Abs(words[0]) + if err != nil || canonicalizeExistingAncestor(executable) != canonicalizeExistingAncestor(workspace.HelperPath()) { + return ownedCommandAdmission{} + } + info, err := os.Lstat(executable) + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return ownedCommandAdmission{} + } + repoArg, repoFlagOK := commandFlagValue(words, "--repo") + if !repoFlagOK { + return ownedCommandAdmission{} + } + if repoArg != "" { + candidate := repoArg + if !filepath.IsAbs(candidate) { + candidate = filepath.Join(repo, filepath.FromSlash(candidate)) + } + absolute, absErr := filepath.Abs(candidate) + if absErr != nil || canonicalizeExistingAncestor(absolute) != canonicalizeExistingAncestor(workspace.RepoRoot) { + return ownedCommandAdmission{} + } + } + for _, word := range words[2:] { + if !deliveryStatePathPattern.MatchString(word) { + continue + } + if !filepath.IsAbs(word) || !pathWithin(workspace.controlRoot, word) { + return ownedCommandAdmission{} + } + } + if ownedReadOnlyHelperCommand(words) { + return ownedCommandAdmission{Allowed: true, ReadOnly: true} + } + if stageIndependentRecoveryVerbs[words[1]] { + return ownedCommandAdmission{Allowed: true} + } + feature, featureOK := ownedCommandFeature(workspace, words) + if featureOK && feature != "" { + status, resolveErr := ResolveNext(repo, feature) + if resolveErr == nil { + // flow next --execute is an exact, feature-scoped coordinator. Its + // driver re-resolves this same oracle and executes only a prescribed, + // AutoDerivable, explicitly allowlisted helper transition. The outer + // coordinator therefore belongs to the owned boundary even though its + // verb is not itself the current transition. + // control-law: guard-never-denies-an-owned-transition + if ownedFlowExecuteCoordinator(words, feature) { + return ownedCommandAdmission{Allowed: true} + } + if controlledPhaseTransition(trimmed, status.ObservedStage) { + return ownedCommandAdmission{Allowed: true} + } + if next, nextErr := nextControlFromStatus(repo, status); nextErr == nil && commandMatchesSolutionVerb(next, words[1]) { + return ownedCommandAdmission{Allowed: true} + } + if knownOwnedMutationVerb(words[1]) { + finding := SafetyFinding{ + Category: "workflow-phase-bypass", Reason: "the Boatstack transition is not owned by the current workflow stage", Source: "workflow-stage", + BlockingFeature: feature, WorkflowStage: status.ObservedStage, NextOperation: status.NextOperation, + } + return ownedCommandAdmission{Finding: &finding} + } + } + } + return ownedCommandAdmission{} +} + +func isPureReadOnlyCommandForRepo(repo, command string) bool { + if isPureReadOnlyCommand(command) { + return true + } + if !deliveryStatePathPattern.MatchString(command) { + return false + } + admission := ownedBoatstackCommand(repo, command) + return admission.Allowed && admission.ReadOnly +} + func controlledWorkspaceSync(repo, command string) bool { if strings.ContainsAny(command, "\n`><;&|") || strings.Contains(command, "$(") { return false @@ -798,13 +1064,21 @@ func ClassifyCommand(repo, command string) []SafetyFinding { validatedPlanningTransport = true command = transport.Header } - if deliveryStatePathPattern.MatchString(command) && !validatedPlanningTransport && !isPureReadOnlyCommand(command) && !approvedUpdatePublisherPattern.MatchString(command) { + owned := ownedCommandAdmission{} + if deliveryStatePathPattern.MatchString(command) { + owned = ownedBoatstackCommand(repo, command) + } + if owned.Finding != nil { + return []SafetyFinding{*owned.Finding} + } + readOnly := isPureReadOnlyCommand(command) || (owned.Allowed && owned.ReadOnly) + if deliveryStatePathPattern.MatchString(command) && !validatedPlanningTransport && !owned.Allowed && !readOnly && !approvedUpdatePublisherPattern.MatchString(command) { // 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 insightArtifactPathPattern.MatchString(command) && (!isPureReadOnlyCommand(command) || insightInPlaceMutationPattern.MatchString(command)) && !insightGitStagingPattern.MatchString(command) { + if insightArtifactPathPattern.MatchString(command) && (!readOnly || insightInPlaceMutationPattern.MatchString(command)) && !insightGitStagingPattern.MatchString(command) { return []SafetyFinding{{Category: "workflow-state-tamper", Reason: "tracked insight artifacts may be changed only by Boatstack insight transitions", Source: "insight-state", AttemptedPath: insightArtifactPathPattern.FindString(command)}} } if directPublicationPattern.MatchString(command) && !approvedPublisherPattern.MatchString(command) { @@ -812,24 +1086,24 @@ func ClassifyCommand(repo, command string) []SafetyFinding { return []SafetyFinding{finding} } } - if strings.Contains(command, "workspace-sync") && !isPureReadOnlyCommand(command) && !controlledWorkspaceSync(repo, command) { + if strings.Contains(command, "workspace-sync") && !readOnly && !owned.Allowed && !controlledWorkspaceSync(repo, command) { return []SafetyFinding{{Category: "workspace-sync-bypass", Reason: "recoverable branch alignment must use the exact project-local Boatstack helper", Source: "command"}} } findings := classifySafetyText(command, "command", commandExecutesLiveSQL(command)) if len(findings) > 0 { return dedupeFindings(findings) } - if !isPureReadOnlyCommand(command) { + if !readOnly { // Feed any named .product-loop/features/ operand so the first-write latch // sees raw shell writes (cp/tee/redirect) the same way it sees a Write tool. - if finding, blocked := preActivationFinding(repo, featuresPathInCommand(command)); blocked && !approvedPublisherPattern.MatchString(command) && !controlledPhaseTransition(command, finding.WorkflowStage) && !controlledWorkspaceSync(repo, command) { + if finding, blocked := preActivationFinding(repo, featuresPathInCommand(command)); blocked && !owned.Allowed && !approvedPublisherPattern.MatchString(command) && !controlledPhaseTransition(command, finding.WorkflowStage) && !controlledWorkspaceSync(repo, command) { return []SafetyFinding{finding} } } if regexp.MustCompile(`(?i)\b(?:rm\s+-[^\n;]*(?:r[^\n;]*f|f[^\n;]*r)|remove-item\s+[^\n;]*-recurse[^\n;]*-force)\b`).MatchString(command) && strings.Contains(command, repo) { findings = append(findings, SafetyFinding{Category: "filesystem-destruction", Reason: "recursive deletion of the repository is denied", Source: "command"}) } - if len(findings) > 0 || isPureReadOnlyCommand(command) { + if len(findings) > 0 || readOnly { return dedupeFindings(findings) } // Only inspect files the command actually EXECUTES (interpreter / SQL-client @@ -914,10 +1188,20 @@ func ClassifyTool(repo, name string, input any) []SafetyFinding { return dedupeFindings(findings) } -func mutationCapableTool(name string, input any) bool { +func mutationCapableTool(repo, name string, input any) bool { if strings.EqualFold(name, "Bash") || strings.EqualFold(name, "Shell") || strings.EqualFold(name, "beforeShellExecution") || strings.EqualFold(name, "run_shell_command") { object, ok := input.(map[string]any) - return !ok || !isPureReadOnlyCommand(stringValue(object["command"])) + if !ok { + return true + } + command := stringValue(object["command"]) + if deliveryStatePathPattern.MatchString(command) && ownedBoatstackCommand(repo, command).Allowed { + // Boatstack transitions have their own deterministic receipts and + // reconciliation. Wrapping them in generic host-operation supervision + // creates a competing controller and can deadlock the owned transition. + return false + } + return !isPureReadOnlyCommandForRepo(repo, command) } lower := strings.ToLower(name) return mutationToolPattern.MatchString(lower) || (strings.HasPrefix(lower, "mcp__") && !externalReadOnlyToolPattern.MatchString(lower)) @@ -970,7 +1254,7 @@ func hookAttemptKey(host, fingerprint string, eventValue []byte) string { } func superviseToolAttempt(repo, host, name string, input any, eventValue []byte) *SafetyFinding { - if !mutationCapableTool(name, input) { + if !mutationCapableTool(repo, name, input) { return nil } scope, authority, managed := activeManagedOperationScope(repo) @@ -1084,7 +1368,7 @@ func completeSupervisedToolEvent(repo, host string, value []byte) (bool, bool) { if malformed { return true, true } - if name == "" || input == nil || !mutationCapableTool(name, input) { + if name == "" || input == nil || !mutationCapableTool(repo, name, input) { return true, false } kind, fingerprint := supervisedToolIdentity(name, input) @@ -1409,7 +1693,7 @@ func HookDecision(options SafetyHookOptions) ([]byte, bool) { // An allowed mutation-capable call is forward progress: stale denial // history must not escalate the next unrelated denial. // control-law: repeated-denials-escalate-to-solutions - if mutationCapableTool(name, input) { + if mutationCapableTool(repo, name, input) { resetDenialLedger(repo) } value, _ := contract.allow() diff --git a/release-notes/2026-08-10-detached-command-admission.md b/release-notes/2026-08-10-detached-command-admission.md new file mode 100644 index 0000000..9ad83f6 --- /dev/null +++ b/release-notes/2026-08-10-detached-command-admission.md @@ -0,0 +1,3 @@ +### Allow verified detached Boatstack transitions + +Detached Boatstack commands now pass the ambient safety hook when the exact repository-bound helper owns the current workflow transition. Direct edits to controller state, spoofed helpers, cross-repository paths, and stage-invalid transitions remain denied.