From b664f7afcb545793978ce0cec873381ea855993b Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 10 Aug 2026 17:36:08 +0100 Subject: [PATCH 1/3] feat: make Boatstack explicitly engaged --- .github/tests/test_detached_supervision.py | 24 +- boatstack/SKILL.md | 12 +- boatstack/activation.go | 165 ++++++------- boatstack/attach.go | 2 +- .../cmd/boatstack-helper/command_trace.go | 4 +- .../coverage_conformance_test.go | 19 +- boatstack/cmd/boatstack-helper/main.go | 51 +++- boatstack/delivery.go | 34 ++- boatstack/delivery_test.go | 16 +- .../denial_escalation_conformance_test.go | 3 + boatstack/denial_test.go | 3 +- boatstack/detached_test.go | 90 +++++-- boatstack/engagement.go | 195 +++++++++++++++ boatstack/engagement_surface_registry_test.go | 93 +++++++ boatstack/engagement_test.go | 229 ++++++++++++++++++ boatstack/export.go | 8 +- boatstack/export_test.go | 12 +- boatstack/hooks.go | 42 +++- boatstack/hooks_test.go | 19 +- boatstack/init.go | 8 +- boatstack/init_test.go | 2 +- boatstack/lifecycle_event_registry_test.go | 2 +- boatstack/operation_test.go | 8 +- boatstack/planning.go | 12 +- .../planning_transport_conformance_test.go | 4 + boatstack/references/artifacts.md | 2 +- boatstack/references/failure-moves.md | 2 +- .../irreversible-operation-boundary.md | 6 +- boatstack/references/workflow.md | 10 +- boatstack/runtime_cache.go | 2 +- boatstack/runtime_cache_test.go | 1 + boatstack/safety.go | 46 ++-- boatstack/safety_test.go | 47 +++- boatstack/skill_frontmatter_test.go | 2 +- boatstack/statemap.go | 2 +- boatstack/statemap_conformance_test.go | 2 +- .../2026-08-10-explicit-engagement.md | 2 + 37 files changed, 934 insertions(+), 247 deletions(-) create mode 100644 boatstack/engagement.go create mode 100644 boatstack/engagement_surface_registry_test.go create mode 100644 boatstack/engagement_test.go create mode 100644 release-notes/2026-08-10-explicit-engagement.md diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index d10bbc1..77729bf 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -190,7 +190,7 @@ def test_bootstrap_oracle_is_credential_free_and_executes_exact_output(self) -> } for host, event in events.items(): admitted = self.run_helper( - "ambient-safety-hook", "--host", host, "--repo", ".", stdin=json.dumps(event) + "engagement-probe", "--host", host, "--repo", ".", stdin=json.dumps(event) ) self.assertNotIn("deny", admitted.stdout.lower(), host) @@ -229,28 +229,30 @@ def test_activate_installs_guard_preserving_user_hooks(self) -> None: text = claude_config.read_text() self.assertIn("my-own.sh", text) - self.assertIn("ambient-safety-hook", text) + self.assertIn("engagement-probe", text) + self.assertIn("engagement.json", text) + self.assertIn("commandWindows", text) self.assertIn("theme", text) # Idempotent: re-activating changes nothing. again = self.helper_json("activate", "--repo", ".", "--host", "claude") self.assertTrue(all(host["action"] == "unchanged" for host in again["hosts"])) - # Deactivate removes only the ambient guard. + # Deactivate removes only the engagement probe. self.run_helper("deactivate", "--repo", ".", "--host", "claude") after = claude_config.read_text() - self.assertNotIn("ambient-safety-hook", after) + self.assertNotIn("engagement-probe", after) self.assertIn("my-own.sh", after) - def test_ambient_guard_enforces_managed_and_noops_unmanaged(self) -> None: - # Unattached: the developer-level guard must not control this repository. - unmanaged = self.run_helper("ambient-safety-hook", "--host", "claude", "--repo", ".", stdin=DESTRUCTIVE_EVENT) - self.assertNotIn('"permissionDecision":"deny"', unmanaged.stdout) + def test_engagement_probe_is_inert_before_and_after_attachment(self) -> None: + # Unattached: the developer-level probe must not control this repository. + unmanaged = self.run_helper("engagement-probe", "--host", "claude", "--repo", ".", stdin=DESTRUCTIVE_EVENT) + self.assertEqual(unmanaged.stdout, "") - # Attached: the same destructive command is denied by the same engine. + # Attachment is configuration, not workflow authority. self.run_helper("attach", "--repo", ".", "--mode", "detached") - managed = self.run_helper("ambient-safety-hook", "--host", "claude", "--repo", ".", stdin=DESTRUCTIVE_EVENT) - self.assertIn('"permissionDecision":"deny"', managed.stdout) + attached = self.run_helper("engagement-probe", "--host", "claude", "--repo", ".", stdin=DESTRUCTIVE_EVENT) + self.assertEqual(attached.stdout, "") def test_detached_work_keeps_repo_product_only(self) -> None: self.run_helper("attach", "--repo", ".", "--mode", "detached") diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index f831d85..697d7b7 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -1,13 +1,13 @@ --- name: boatstack -description: Use when the user explicitly asks for Boatstack, asks what is next in Boatstack, or works on an active or current-branch published Boatstack delivery. Do not activate from installation, attachment, saved or approved drafts, or repository presence alone. +description: Use only when the user explicitly asks for Boatstack, asks what is next in Boatstack, or works on a verified active pre-publication Boatstack delivery in the current worktree and branch. Do not activate from installation, attachment, saved or approved drafts, published work, repository presence, or repository administration. --- # Boatstack Build the smallest complete product slice that can be independently verified. Implementation methods remain open: project facts, approval, and gate evidence are canonical; host-specific prompts are adapters. You are free in how you build. Only claims of completion require evidence. -Boatstack starts only from an explicit user request or a managed delivery bound to the current worktree and branch. A saved, approved, policy-ready, ambiguous, stale, or invalid draft is repository evidence, not ambient workflow authority. Before explicit use, only the irreversible-operation safety floor and Boatstack-owned state boundaries apply. +Boatstack starts only from an explicit user request or a verified active pre-publication delivery bound to the current worktree and branch. A saved, approved, policy-ready, ambiguous, stale, invalid, or published delivery is repository evidence, not ambient workflow authority. Outside engagement, Boatstack applies no workflow, state, publication, or irreversible-operation policy. ## Start by selecting the operation @@ -44,17 +44,17 @@ After preflight, repeatedly run `next-status --repo . --feature --json`, When `delivery.terminal` is `merged`, follow the post-publish prescriptions exactly. After publication, run `flow next` (or `next-status`). When it prescribes `flow watch`, run the watch and re-resolve when it exits. When checks fail, it prescribes `record-change --source-stage ci`; derive the exact message, classification, evidence, and changed repair mechanism from the failing check logs, never from memory, then repair, re-gate, and republish with `publish-pr --action update`. When the PR is observed merge-eligible, it prescribes the exact `gh pr merge` command; run it only as rendered, under the host's own permissions — Boatstack never merges, and you never merge without the prescription. A required review approval, a changes-requested verdict, a closed PR, or an unverifiable PR position always ends your turn at the operator frontier. -## Enforce the irreversible-operation boundary +## Enforce the irreversible-operation boundary during engagement -Read [irreversible-operation-boundary.md](references/irreversible-operation-boundary.md). Project hooks hard-deny high-confidence destructive shell and MCP operations on every supported agent call. Never request or invent an in-session bypass. After an external-write failure, preserve state, use read-only diagnosis, retain the immutable target boundary, and choose only proven transactional retry or fix-forward recovery. Source edits may be reviewed, but an executable destructive capability blocks activation and every later gate. +Read [irreversible-operation-boundary.md](references/irreversible-operation-boundary.md). During an explicit Boatstack command or verified active delivery, project hooks hard-deny high-confidence destructive shell and MCP operations. Outside engagement, Boatstack is inert; the host, operator, and external platform own safety. Never request or invent an in-session bypass while engaged. After an engaged external-write failure, preserve state, use read-only diagnosis, retain the immutable target boundary, and choose only proven transactional retry or fix-forward recovery. This enforcement is defense in depth, not a complete sandbox. Keep least-privilege service credentials and service-side destructive approval in place. Read `authority_status` from `run-preflight`: `HOOK_GUARDED` never proves ambient cloud authority absent, while `CREDENTIAL_ENFORCED` means a trusted external attestor supplied a current repository-only receipt. Never strengthen the former into the latter in prose. ## Keep repository administration outside delivery -Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes are repository administration, not product intent. Never route them to `auto-plan` or `repair` unless the exact target branch belongs to an active managed delivery. For an explicit branch and remote ref, use the project-local `workspace-sync` helper. It fetches the exact source, checkpoints branch and dirty-worktree state, aligns the branch in its owning worktree, and returns verified recovery refs. +Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes are repository administration, not Boatstack intent. Never activate Boatstack or route them to `auto-plan`, `repair`, or `workspace-sync`. If the user explicitly asks Boatstack to manage repository alignment during an active delivery, the named command remains available. -For requests such as “ensure main is same as origin/main remove any current changes,” inspect only the named refs and worktree, then invoke `.product-loop/boatstack workspace-sync --repo . --branch main --source origin/main`. If the guard denies a raw hard reset or clean, report the denial and this single recovery action immediately. Do not inspect feature plans, scan the repository, search for the helper, or retry destructive Git. +For requests such as “ensure main is same as origin/main remove any current changes,” use ordinary repository administration. Do not inspect feature plans, invoke Boatstack, or render a Boatstack response unless the user explicitly asks Boatstack to perform that operation. ## Bound the outcome diff --git a/boatstack/activation.go b/boatstack/activation.go index 0c8b805..c5a7fab 100644 --- a/boatstack/activation.go +++ b/boatstack/activation.go @@ -7,27 +7,24 @@ import ( "strings" ) -// ambientHookMarker identifies a user-level hook entry as Boatstack's ambient -// guard. It is distinct from the embedded hookCommandMarker (".product-loop/hooks/ -// guard"): the ambient command runs the external helper's ambient-safety-hook and -// never names the in-repo guard, so ownership is detected by this substring. -const ambientHookMarker = "ambient-safety-hook" +// engagementHookMarker identifies the user-level engagement probe. The legacy +// marker is recognized only so updates can remove the superseded hook entry. +const engagementHookMarker = "engagement-probe" +const legacyAmbientHookMarker = "ambient-safety-hook" -// containsAmbientHook reports whether a hook value is (or contains) a Boatstack -// ambient-guard entry, by finding the ambient marker in any command string. -func containsAmbientHook(value any) bool { +func containsEngagementHook(value any) bool { switch typed := value.(type) { case string: - return strings.Contains(typed, ambientHookMarker) + return strings.Contains(typed, engagementHookMarker) || strings.Contains(typed, legacyAmbientHookMarker) case []any: for _, item := range typed { - if containsAmbientHook(item) { + if containsEngagementHook(item) { return true } } case map[string]any: for _, item := range typed { - if containsAmbientHook(item) { + if containsEngagementHook(item) { return true } } @@ -35,7 +32,7 @@ func containsAmbientHook(value any) bool { return false } -// detachedHelperPath resolves the helper the ambient hook should invoke: the +// detachedHelperPath resolves the helper the engagement probe should invoke: the // external shared-runtime slot's binary when present (stable across helper // relocation), else the running executable, else the bare name. func detachedHelperPath(repo string) string { @@ -48,24 +45,21 @@ func detachedHelperPath(repo string) string { return "boatstack-helper" } -// ambientDesiredEntry is the per-event ambient-guard entry for a host, shaped like -// the embedded entry but running the external ambient command. -func ambientDesiredEntry(host, event, helper string) map[string]any { +func engagementDesiredEntry(host, event, helper string) map[string]any { entry := desiredHostHookForEvent(host, event) - overrideHookCommand(entry, ambientHookCommand(host, helper)) + overrideHookCommands(entry, engagementProbeCommand(host, helper), engagementProbePowerShellCommand(host, helper)) return entry } // Detached activation. A detached repository has no in-repo host hook, so the -// developer installs one user-level (developer-scoped) hook per coding agent. That -// hook runs Boatstack's ambient guard, which enforces policy only on attached -// repositories and no-ops everywhere else (RepositoryIsManaged / AmbientHookDecision). +// developer installs one user-level engagement probe per coding agent. The probe +// is inert unless a worktree-local active-delivery lease is valid. // // activation deliberately does NOT silently rewrite a developer's global host // configuration. It emits the exact per-host config location and the precise // Boatstack-owned snippet to add, so activation is transparent and never clobbers // existing global hooks. The snippet is host-neutral in intent: every supported -// agent gets the same ambient guard, shaped for that agent's hook schema. +// agent gets the same engagement probe, shaped for that agent's hook schema. // HostActivation is the activation instruction for one coding agent. type HostActivation struct { @@ -112,43 +106,52 @@ func userHostConfigPath(host string) (string, error) { } } -// ambientHookCommand builds the shell command a user-level hook runs: the absolute -// helper binary invoking the ambient guard for the current repository. claude +// engagementProbeCommand builds the shell command a user-level hook runs. Claude // exposes the project directory as ${CLAUDE_PROJECT_DIR}; the others resolve it // from Git at hook time. -func ambientHookCommand(host, helper string) string { +func engagementProbeCommand(host, helper string) string { + root := `ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"` if host == "claude" { - return fmt.Sprintf(`%q ambient-safety-hook --host claude --repo "${CLAUDE_PROJECT_DIR}"`, helper) + root = `ROOT="${CLAUDE_PROJECT_DIR:-}"` } - return fmt.Sprintf(`%q ambient-safety-hook --host %s --repo "$(git rev-parse --show-toplevel)"`, helper, host) + return fmt.Sprintf(`%s; [ -n "$ROOT" ] || exit 0; GIT_DIR="$(git -C "$ROOT" rev-parse --path-format=absolute --git-dir 2>/dev/null)" || exit 0; LEASE="$GIT_DIR/boatstack/engagement.json"; [ -f "$LEASE" ] && [ ! -L "$LEASE" ] || exit 0; BRANCH="$(git -C "$ROOT" branch --show-current 2>/dev/null)"; LEASE_BRANCH="$(sed -n 's/.*"branch"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$LEASE" | head -n 1)"; [ -n "$BRANCH" ] && [ "$BRANCH" = "$LEASE_BRANCH" ] || exit 0; exec %q engagement-probe --host %s --repo "$ROOT"`, root, helper, host) } -// ambientHostFragment shapes the ambient guard into a host's hook schema, reusing +func engagementProbePowerShellCommand(host, helper string) string { + root := `(& git rev-parse --show-toplevel 2>$null)` + if host == "claude" { + root = `$env:CLAUDE_PROJECT_DIR` + } + return fmt.Sprintf(`$root = %s; if (-not $root) { exit 0 }; $gitDir = (& git -C $root rev-parse --path-format=absolute --git-dir 2>$null); if (-not $gitDir) { exit 0 }; $leasePath = Join-Path $gitDir 'boatstack/engagement.json'; if (-not (Test-Path -LiteralPath $leasePath -PathType Leaf)) { exit 0 }; $leaseInfo = Get-Item -LiteralPath $leasePath; if ($leaseInfo.Attributes -band [IO.FileAttributes]::ReparsePoint) { exit 0 }; try { $lease = Get-Content -LiteralPath $leasePath -Raw | ConvertFrom-Json } catch { exit 0 }; $branch = (& git -C $root branch --show-current 2>$null); if (-not $branch -or $lease.branch -ne $branch) { exit 0 }; & %s engagement-probe --host %s --repo $root; exit $LASTEXITCODE`, root, powerShellPlanningWord(helper), host) +} + +// engagementHostFragment shapes the engagement probe into a host's hook schema, reusing // the embedded entry shape and overriding only the command so the guard runs from // the external helper rather than an in-repo guard script. -func ambientHostFragment(host, helper string) ([]byte, error) { - command := ambientHookCommand(host, helper) +func engagementHostFragment(host, helper string) ([]byte, error) { + command := engagementProbeCommand(host, helper) + commandWindows := engagementProbePowerShellCommand(host, helper) events := map[string]any{} for _, event := range hookEvents(host) { entry := desiredHostHookForEvent(host, event) - overrideHookCommand(entry, command) + overrideHookCommands(entry, command, commandWindows) events[event] = entry } return GeneratedJSON(map[string]any{"schema_version": 1, "host": host, "scope": "user", "events": events}) } // overrideHookCommand replaces the command in a desired-hook entry (both the flat -// cursor form and the nested hooks[] form) with the ambient command. -func overrideHookCommand(entry map[string]any, command string) { +// cursor form and the nested hooks[] form) with the engagement-probe command. +func overrideHookCommands(entry map[string]any, command, commandWindows string) { if _, ok := entry["command"]; ok { entry["command"] = command - delete(entry, "commandWindows") + entry["commandWindows"] = commandWindows } if nested, ok := entry["hooks"].([]any); ok { for _, item := range nested { if hook, ok := item.(map[string]any); ok { hook["command"] = command - delete(hook, "commandWindows") + hook["commandWindows"] = commandWindows } } } @@ -189,7 +192,7 @@ func DetachedActivationPlan(repoPath string, hosts []string) (ActivationPlan, er if pathErr != nil { continue } - snippet, fragErr := ambientHostFragment(host, helper) + snippet, fragErr := engagementHostFragment(host, helper) if fragErr != nil { return ActivationPlan{}, fragErr } @@ -197,33 +200,33 @@ func DetachedActivationPlan(repoPath string, hosts []string) (ActivationPlan, er Host: host, ConfigPath: configPath, Snippet: string(snippet), - Instruction: fmt.Sprintf("Merge the Boatstack ambient guard for %s into %s (developer-level, applies to every repository; it enforces Boatstack only on attached repositories).", host, configPath), + Instruction: fmt.Sprintf("Merge the Boatstack engagement probe for %s into %s. It emits no output and applies no policy unless this worktree has a verified active delivery.", host, configPath), }) } - plan.Reason = "Add the developer-level ambient guard for each coding agent you use. It no-ops on repositories you have not attached." + plan.Reason = "Add the developer-level engagement probe for each coding agent you use. Repository presence and attachment alone remain inert." return plan, nil } -// AmbientHostResult is the per-host outcome of an install/uninstall. -type AmbientHostResult struct { +// EngagementHostResult is the per-host outcome of an install/uninstall. +type EngagementHostResult struct { Host string `json:"host"` ConfigPath string `json:"config_path"` Action string `json:"action"` // installed | removed | unchanged } -// AmbientActivationResult is the deterministic outcome of installing or removing -// the developer-level ambient guard. -type AmbientActivationResult struct { - SchemaVersion int `json:"schema_version"` - VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED - Mode string `json:"mode,omitempty"` - RepoRoot string `json:"repo_root,omitempty"` - Hosts []AmbientHostResult `json:"hosts,omitempty"` - Reason string `json:"reason"` +// EngagementActivationResult is the deterministic outcome of installing or +// removing the developer-level engagement probe. +type EngagementActivationResult struct { + SchemaVersion int `json:"schema_version"` + VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED + Mode string `json:"mode,omitempty"` + RepoRoot string `json:"repo_root,omitempty"` + Hosts []EngagementHostResult `json:"hosts,omitempty"` + Reason string `json:"reason"` } -func blockedAmbient(reason string) AmbientActivationResult { - return AmbientActivationResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason} +func blockedEngagementActivation(reason string) EngagementActivationResult { + return EngagementActivationResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason} } func defaultActivationHosts(hosts []string) []string { @@ -233,11 +236,11 @@ func defaultActivationHosts(hosts []string) []string { return hosts } -// mergeAmbientHooks installs exactly one ambient-guard entry per host event, -// preserving every non-ambient entry (a user's own hooks, and any embedded guard) +// mergeEngagementHooks installs exactly one engagement probe per host event, +// preserving every unrelated entry (a user's own hooks, and any embedded guard) // verbatim. Stripping then re-adding the single owned entry makes reinstall // idempotent — the same input config yields the same output. -func mergeAmbientHooks(config map[string]any, host, helper string) error { +func mergeEngagementHooks(config map[string]any, host, helper string) error { hooks, ok := config["hooks"].(map[string]any) if config["hooks"] == nil { hooks = map[string]any{} @@ -256,12 +259,12 @@ func mergeAmbientHooks(config map[string]any, host, helper string) error { } kept := []any{} for _, entry := range entries { - if containsAmbientHook(entry) { + if containsEngagementHook(entry) { continue } kept = append(kept, entry) } - kept = append(kept, ambientDesiredEntry(host, event, helper)) + kept = append(kept, engagementDesiredEntry(host, event, helper)) hooks[event] = kept } if host == "cursor" && config["version"] == nil { @@ -270,9 +273,9 @@ func mergeAmbientHooks(config map[string]any, host, helper string) error { return nil } -// removeAmbientHooks strips only Boatstack ambient-guard entries, preserving all +// removeEngagementHooks strips only Boatstack engagement probes, preserving all // other entries. It reports whether anything changed. -func removeAmbientHooks(config map[string]any, host string) bool { +func removeEngagementHooks(config map[string]any, host string) bool { hooks, ok := config["hooks"].(map[string]any) if !ok { return false @@ -285,7 +288,7 @@ func removeAmbientHooks(config map[string]any, host string) bool { } kept := []any{} for _, entry := range existing { - if containsAmbientHook(entry) { + if containsEngagementHook(entry) { changed = true continue } @@ -300,23 +303,23 @@ func removeAmbientHooks(config map[string]any, host string) bool { return changed } -// InstallAmbientHooks merges the ambient guard into each agent's developer-level +// InstallEngagementProbes merges the engagement probe into each agent's developer-level // config. It requires the repository to be attached in detached mode. It preserves // existing user hooks and is idempotent. -func InstallAmbientHooks(repoPath string, hosts []string) (AmbientActivationResult, error) { +func InstallEngagementProbes(repoPath string, hosts []string) (EngagementActivationResult, error) { root, err := ResolveRepository(repoPath) if err != nil { - return blockedAmbient(err.Error()), nil + return blockedEngagementActivation(err.Error()), nil } _, ok, verifyErr := detachedContextFor(root) if verifyErr != nil { - return blockedAmbient(verifyErr.Error() + " Reattach before activating."), nil + return blockedEngagementActivation(verifyErr.Error() + " Reattach before activating."), nil } if !ok { - return blockedAmbient("This repository is not attached in detached mode. Run `boatstack-helper attach --repo . --mode detached` first."), nil + return blockedEngagementActivation("This repository is not attached in detached mode. Run `boatstack-helper attach --repo . --mode detached` first."), nil } helper := detachedHelperPath(root) - result := AmbientActivationResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED", Mode: string(SupervisionDetached), RepoRoot: root} + result := EngagementActivationResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED", Mode: string(SupervisionDetached), RepoRoot: root} for _, host := range defaultActivationHosts(hosts) { configPath, pathErr := userHostConfigPath(host) if pathErr != nil { @@ -324,63 +327,63 @@ func InstallAmbientHooks(repoPath string, hosts []string) (AmbientActivationResu } config, loadErr := loadHookConfig(configPath) if loadErr != nil { - return blockedAmbient(fmt.Sprintf("Boatstack could not read %s: %v", configPath, loadErr)), nil + return blockedEngagementActivation(fmt.Sprintf("Boatstack could not read %s: %v", configPath, loadErr)), nil } before, _ := MarshalJSON(config) - if err := mergeAmbientHooks(config, host, helper); err != nil { - return blockedAmbient(err.Error()), nil + if err := mergeEngagementHooks(config, host, helper); err != nil { + return blockedEngagementActivation(err.Error()), nil } after, marshalErr := MarshalJSON(config) if marshalErr != nil { - return blockedAmbient(marshalErr.Error()), nil + return blockedEngagementActivation(marshalErr.Error()), nil } action := "unchanged" if string(before) != string(after) { if err := atomicWriteMode(configPath, after, 0o644); err != nil { - return blockedAmbient(err.Error()), nil + return blockedEngagementActivation(err.Error()), nil } action = "installed" } - result.Hosts = append(result.Hosts, AmbientHostResult{Host: host, ConfigPath: configPath, Action: action}) + result.Hosts = append(result.Hosts, EngagementHostResult{Host: host, ConfigPath: configPath, Action: action}) } - result.Reason = "Installed the Boatstack ambient guard into your developer-level host configuration. It enforces Boatstack only on attached repositories and leaves all other repositories uncontrolled." + result.Reason = "Installed the Boatstack engagement probe. It applies policy only for a verified active delivery in the current worktree and branch." return result, nil } -// RemoveAmbientHooks removes the ambient guard from each agent's developer-level +// RemoveEngagementProbes removes the engagement probe from each agent's developer-level // config, preserving every other entry. -func RemoveAmbientHooks(repoPath string, hosts []string) (AmbientActivationResult, error) { +func RemoveEngagementProbes(repoPath string, hosts []string) (EngagementActivationResult, error) { root, err := ResolveRepository(repoPath) if err != nil { - return blockedAmbient(err.Error()), nil + return blockedEngagementActivation(err.Error()), nil } - result := AmbientActivationResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED", RepoRoot: root} + result := EngagementActivationResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED", RepoRoot: root} for _, host := range defaultActivationHosts(hosts) { configPath, pathErr := userHostConfigPath(host) if pathErr != nil { continue } if !fileExists(configPath) { - result.Hosts = append(result.Hosts, AmbientHostResult{Host: host, ConfigPath: configPath, Action: "unchanged"}) + result.Hosts = append(result.Hosts, EngagementHostResult{Host: host, ConfigPath: configPath, Action: "unchanged"}) continue } config, loadErr := loadHookConfig(configPath) if loadErr != nil { - return blockedAmbient(fmt.Sprintf("Boatstack could not read %s: %v", configPath, loadErr)), nil + return blockedEngagementActivation(fmt.Sprintf("Boatstack could not read %s: %v", configPath, loadErr)), nil } action := "unchanged" - if removeAmbientHooks(config, host) { + if removeEngagementHooks(config, host) { after, marshalErr := MarshalJSON(config) if marshalErr != nil { - return blockedAmbient(marshalErr.Error()), nil + return blockedEngagementActivation(marshalErr.Error()), nil } if err := atomicWriteMode(configPath, after, 0o644); err != nil { - return blockedAmbient(err.Error()), nil + return blockedEngagementActivation(err.Error()), nil } action = "removed" } - result.Hosts = append(result.Hosts, AmbientHostResult{Host: host, ConfigPath: configPath, Action: action}) + result.Hosts = append(result.Hosts, EngagementHostResult{Host: host, ConfigPath: configPath, Action: action}) } - result.Reason = "Removed the Boatstack ambient guard from your developer-level host configuration." + result.Reason = "Removed the Boatstack engagement probe from your developer-level host configuration." return result, nil } diff --git a/boatstack/attach.go b/boatstack/attach.go index cba8a3a..df14e9f 100644 --- a/boatstack/attach.go +++ b/boatstack/attach.go @@ -185,7 +185,7 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { invalidateWorkspaceCache() // Populate the external shared-runtime slot from the running helper so the - // developer-level ambient guard has a stable helper to invoke. The binding is + // developer-level engagement probe has a stable helper to invoke. The binding is // written above, so WorkspaceFor now resolves detached and the slot is external. source := strings.TrimSpace(opts.BinaryPath) if source == "" { diff --git a/boatstack/cmd/boatstack-helper/command_trace.go b/boatstack/cmd/boatstack-helper/command_trace.go index db5b2c0..e254c77 100644 --- a/boatstack/cmd/boatstack-helper/command_trace.go +++ b/boatstack/cmd/boatstack-helper/command_trace.go @@ -18,7 +18,7 @@ type commandTracePolicy struct { // to the enforcement path; every other dispatch is recorded once by run(). var commandTracePolicies = map[string]commandTracePolicy{ "attach": {Category: "supervision"}, "detach": {Category: "supervision"}, - "detached-status": {Category: "supervision"}, "config-rebind": {Category: "supervision"}, "context": {Category: "supervision"}, + "detached-status": {Category: "supervision"}, "engagement-status": {Category: "supervision"}, "config-rebind": {Category: "supervision"}, "context": {Category: "supervision"}, "activate": {Category: "supervision"}, "deactivate": {Category: "supervision"}, "init": {Category: "installation"}, "update": {Category: "installation"}, "check-update": {Category: "installation"}, "repair-status": {Category: "installation"}, @@ -48,7 +48,7 @@ var commandTracePolicies = map[string]commandTracePolicy{ "flow": {Category: "flow"}, "retro": {Category: "analysis"}, "insight": {Category: "insight"}, "version": {Category: "diagnostic"}, "safety-hook": {Category: "safety", ExcludedReason: "latency-sensitive enforcement path"}, - "ambient-safety-hook": {Category: "safety", ExcludedReason: "latency-sensitive enforcement path"}, + "engagement-probe": {Category: "safety", ExcludedReason: "latency-sensitive engagement path"}, "bootstrap-safety-hook": {Category: "safety", ExcludedReason: "latency-sensitive enforcement path"}, } diff --git a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go index 04cb736..7ddc568 100644 --- a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go +++ b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go @@ -70,16 +70,17 @@ var nonDeliveryVerbs = map[string]bool{ // PR construction / verification helpers reached around the ship gate. "check-pr": true, // Detached Supervision lifecycle (control-plane ownership, not delivery moves). - "attach": true, - "detach": true, - "detached-status": true, - "config-rebind": true, - "context": true, - "activate": true, - "deactivate": true, + "attach": true, + "detach": true, + "detached-status": true, + "engagement-status": true, + "config-rebind": true, + "context": true, + "activate": true, + "deactivate": true, // Safety hooks and workspace management (guard/scaffold, not delivery moves). "safety-hook": true, - "ambient-safety-hook": true, + "engagement-probe": true, "bootstrap-safety-hook": true, "workspace-cut": true, "workspace-cleanup": true, @@ -154,7 +155,7 @@ func TestCommandTraceInventoryCoversDispatchSurfaceExactly(t *testing.T) { t.Errorf("command trace policy names stale verb %q", verb) } } - allowedExcluded := map[string]bool{"safety-hook": true, "ambient-safety-hook": true, "bootstrap-safety-hook": true} + allowedExcluded := map[string]bool{"safety-hook": true, "engagement-probe": true, "bootstrap-safety-hook": true} for verb, policy := range commandTracePolicies { if policy.ExcludedReason != "" && !allowedExcluded[verb] { t.Errorf("only latency-sensitive safety hooks may be excluded; found %q", verb) diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 815be56..30d87be 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -155,6 +155,15 @@ func detachedStatusCommand(arguments []string) int { return emitJSON(result) } +func engagementStatusCommand(arguments []string) int { + flags := flag.NewFlagSet("engagement-status", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose current engagement should be resolved") + if err := flags.Parse(arguments); err != nil { + return 2 + } + return emitJSON(boatstack.ResolveEngagement(*repo, boatstack.EngagementRequest{})) +} + func configRebindCommand(arguments []string) int { flags := flag.NewFlagSet("config-rebind", flag.ContinueOnError) repo := flags.String("repo", ".", "attached repository whose configuration authority should be rebound") @@ -212,7 +221,7 @@ func activateCommand(arguments []string) int { } return emitJSON(result) } - result, err := boatstack.InstallAmbientHooks(*repo, hosts) + result, err := boatstack.InstallEngagementProbes(*repo, hosts) if err != nil { return fail(err) } @@ -236,7 +245,7 @@ func deactivateCommand(arguments []string) int { if strings.TrimSpace(*host) != "" { hosts = []string{*host} } - result, err := boatstack.RemoveAmbientHooks(*repo, hosts) + result, err := boatstack.RemoveEngagementProbes(*repo, hosts) if err != nil { return fail(err) } @@ -474,7 +483,7 @@ func exportCommand(arguments []string) int { fmt.Println(" " + path) } for _, path := range boatstack.HostHookPaths(bundle.Config.Adapters) { - fmt.Println(" " + path + " (merge safety hook)") + fmt.Println(" " + path + " (merge inert engagement probe)") } return 0 } @@ -1290,12 +1299,10 @@ func safetyHookCommand(arguments []string) int { return 0 } -// ambientSafetyHookCommand is the guard entry for a developer-level (user-scoped) -// hook that runs for every repository. It enforces Boatstack only on managed -// repositories and no-ops everywhere else, so detached activation can install one -// user-level hook without controlling unattached repositories. -func ambientSafetyHookCommand(arguments []string) int { - flags := flag.NewFlagSet("ambient-safety-hook", flag.ContinueOnError) +// engagementProbeCLI is the inert developer-level entry point. It emits no +// policy effect unless a verified active-delivery lease exists in this worktree. +func engagementProbeCLI(arguments []string) int { + flags := flag.NewFlagSet("engagement-probe", flag.ContinueOnError) host := flags.String("host", "", "cursor, claude, codex, or gemini") repo := flags.String("repo", ".", "repository the coding agent is operating in") if err := flags.Parse(arguments); err != nil { @@ -1305,7 +1312,7 @@ func ambientSafetyHookCommand(arguments []string) int { if err != nil { input = nil } - value, _ := boatstack.AmbientHookDecision(boatstack.SafetyHookOptions{Host: *host, Repo: *repo, Input: input}) + value, _ := boatstack.EngagementProbeDecision(boatstack.SafetyHookOptions{Host: *host, Repo: *repo, Input: input}) if err := emitHookOutput(os.Stdout, *host, value); err != nil { return failSafetyHook(fmt.Errorf("cannot emit hook decision: %w", err)) } @@ -1623,14 +1630,30 @@ func workspaceSyncCommand(arguments []string) int { return 0 } +// resolveCommandScope projects every named Boatstack request through the same +// engagement resolver used by hooks. Hook processes resolve ambient ACTIVE +// authority from their payload path; ordinary named commands receive ephemeral +// COMMAND authority and never persist it. +func resolveCommandScope(verb string, arguments []string) boatstack.EngagementStatus { + if verb == "safety-hook" || verb == "engagement-probe" || verb == "bootstrap-safety-hook" { + return boatstack.ResolveEngagement(traceFlag(arguments, "--repo"), boatstack.EngagementRequest{}) + } + repo := traceFlag(arguments, "--repo") + if repo == "" { + repo = "." + } + return boatstack.ResolveEngagement(repo, boatstack.EngagementRequest{ExplicitCommand: true}) +} + func run() (result int) { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } if complete := commandTraceCompletion(os.Args[1], os.Args[2:]); complete != nil { defer func() { complete(result) }() } + _ = resolveCommandScope(os.Args[1], os.Args[2:]) switch os.Args[1] { case "attach": return attachCommand(os.Args[2:]) @@ -1638,6 +1661,8 @@ func run() (result int) { return detachCommand(os.Args[2:]) case "detached-status": return detachedStatusCommand(os.Args[2:]) + case "engagement-status": + return engagementStatusCommand(os.Args[2:]) case "config-rebind": return configRebindCommand(os.Args[2:]) case "context": @@ -1732,8 +1757,8 @@ func run() (result int) { return renderDenialCommand(os.Args[2:]) case "safety-hook": return safetyHookCommand(os.Args[2:]) - case "ambient-safety-hook": - return ambientSafetyHookCommand(os.Args[2:]) + case "engagement-probe": + return engagementProbeCLI(os.Args[2:]) case "bootstrap-safety-hook": return bootstrapSafetyHookCommand(os.Args[2:]) case "hydrate-runtime": diff --git a/boatstack/delivery.go b/boatstack/delivery.go index 73409e2..7657ec0 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -410,7 +410,7 @@ func initializeDeliveryState(repo, feature, planPath, lockPath string) error { if existing, loadErr := LoadDeliveryState(repo, feature); loadErr == nil { // Re-activating the exact same lock is a no-op: never disturb progress. if existing.PlanLockHash == lockHash { - return nil + return syncEngagementLease(repo, existing) } // A plan amendment mid-delivery must preserve every already-published // slice. deliveryDefinitions freshly recomputes ALL slices from the new @@ -421,15 +421,29 @@ func initializeDeliveryState(repo, feature, planPath, lockPath string) error { if err := validateAmendmentPreservesProgress(existing, slices); err != nil { return err } - return saveDeliveryState(repo, reconcileAmendedDeliveryState(existing, slices, lockHash)) + next := reconcileAmendedDeliveryState(existing, slices, lockHash) + if err := saveDeliveryState(repo, next); err != nil { + return err + } + return syncEngagementLease(repo, next) } - return saveDeliveryState(repo, DeliveryState{ + next := DeliveryState{ SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: lockHash, ActiveIndex: 0, Slices: slices, Mode: "NORMAL", RepairCounters: map[string]int{"implementation_repair": 0, "verification_repair": 0, "review_repair": 0}, ParentDelivery: strings.TrimSpace(stringValue(plan["parent_delivery"])), Goal: deliveryGoalSnapshot(repo), - }) + } + // Write the lease first. Until delivery state agrees, ResolveEngagement still + // returns DORMANT; if the state write fails, remove the unaccepted lease. + if err := syncEngagementLease(repo, next); err != nil { + return err + } + if err := saveDeliveryState(repo, next); err != nil { + _ = clearEngagementLease(repo) + return err + } + return nil } // guardReactivationPreservesProgress lets ActivatePlan reject a @@ -1252,6 +1266,9 @@ func MarkDeliveryPublished(repo, feature, sliceID, url string) error { if err := saveDeliveryState(repo, state); err != nil { return err } + if err := syncEngagementLease(repo, state); err != nil { + return err + } reconcileInsightsForFeature(repo, feature) return nil } @@ -1277,6 +1294,9 @@ func MarkDeliveryPublished(repo, feature, sliceID, url string) error { if err := saveDeliveryState(repo, state); err != nil { return err } + if err := syncEngagementLease(repo, state); err != nil { + return err + } reconcileInsightsForFeature(repo, feature) return nil } @@ -1574,9 +1594,15 @@ func DiscardDelivery(repoPath, feature string, force bool) (DiscardDeliveryResul if err := os.MkdirAll(archiveDir, 0o755); err != nil { return DiscardDeliveryResult{}, err } + engagement := ResolveEngagement(repo, EngagementRequest{}) if err := os.Rename(featureDir, destination); err != nil { return DiscardDeliveryResult{}, err } + if engagement.Mode == EngagementActive && engagement.Feature == feature { + if err := clearEngagementLease(repo); err != nil { + return DiscardDeliveryResult{}, err + } + } return DiscardDeliveryResult{ Feature: feature, Action: "discarded", ArchivePath: ".git/boatstack/deliveries/.discarded/" + filepath.Base(destination), diff --git a/boatstack/delivery_test.go b/boatstack/delivery_test.go index 368a209..b3b62e3 100644 --- a/boatstack/delivery_test.go +++ b/boatstack/delivery_test.go @@ -487,8 +487,19 @@ func TestDeliveryGateRejectsStateFromAnotherPlanLock(t *testing.T) { func TestManagedDeliveryHookDeniesDirectPublicationRoutes(t *testing.T) { repo := t.TempDir() runGit(t, repo, "init", "-b", "main") + lockPath := filepath.Join(WorkspaceFor(repo).FeatureDir("phased-feature"), "plan.lock.json") + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(lockPath, []byte("publication fixture 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: "phased-feature", PlanLockHash: strings.Repeat("a", 64), + SchemaVersion: deliveryStateSchemaVersion, Feature: "phased-feature", PlanLockHash: lockHash, ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "BUILD", HeadBranch: "main"}}, }); err != nil { t.Fatal(err) @@ -514,6 +525,9 @@ func TestManagedDeliveryHookDeniesDirectPublicationRoutes(t *testing.T) { if err := saveDeliveryState(repo, state); err != nil { t.Fatal(err) } + if err := syncEngagementLease(repo, state); err != nil { + t.Fatal(err) + } related := ClassifyCommand(repo, "git push origin main") if len(related) != 1 || related[0].BranchRelation != "current_branch" || related[0].ParentDelivery != "published-parent" { t.Fatalf("related publication denial was not identified: %#v", related) diff --git a/boatstack/denial_escalation_conformance_test.go b/boatstack/denial_escalation_conformance_test.go index 6a528d5..f09f2c7 100644 --- a/boatstack/denial_escalation_conformance_test.go +++ b/boatstack/denial_escalation_conformance_test.go @@ -29,6 +29,7 @@ func tamperEvent(path string) []byte { // repeat notice and the fresh-probe prescription; the first two do not. func TestThirdIdenticalDenialEscalates(t *testing.T) { repo := safetyTestRepo(t) + engageHookFixture(t, repo) event := tamperEvent(".git/boatstack/deliveries/demo/state.json") for attempt := 1; attempt <= denialEscalationThreshold; attempt++ { output, denied := HookDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event}) @@ -57,6 +58,7 @@ func TestThirdIdenticalDenialEscalates(t *testing.T) { // clears the ledger — the next denial starts unescalated. func TestAllowedMutationResetsTheLedger(t *testing.T) { repo := safetyTestRepo(t) + engageHookFixture(t, repo) event := tamperEvent(".git/boatstack/deliveries/demo/state.json") for i := 0; i < denialEscalationThreshold-1; i++ { if _, denied := HookDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event}); !denied { @@ -82,6 +84,7 @@ func TestAllowedMutationResetsTheLedger(t *testing.T) { // allowed call does NOT reset the ledger (only mutation progress does). func TestDenialKeysAreIsolated(t *testing.T) { repo := safetyTestRepo(t) + engageHookFixture(t, repo) tamper := SafetyFinding{Category: "workflow-state-tamper", Source: "delivery-state"} phase := SafetyFinding{Category: "workflow-phase-bypass", WorkflowStage: "DRAFT_PLAN", Source: "planning-state"} if got := recordDenial(repo, tamper); got != 1 { diff --git a/boatstack/denial_test.go b/boatstack/denial_test.go index dffb78f..ddce1bf 100644 --- a/boatstack/denial_test.go +++ b/boatstack/denial_test.go @@ -203,7 +203,8 @@ func TestDenialDemoRendersAllSamples(t *testing.T) { func TestGuardScriptPlainMessagesUnchanged(t *testing.T) { script := string(guardShellScript()) for _, want := range []string{ - "could not resolve the repository; denying tool execution.", + "boatstack/engagement.json", + "exit 0", "shared runtime checksum is invalid; rerun the verified tagged installer.", "bs_deny ", "bs_color", } { diff --git a/boatstack/detached_test.go b/boatstack/detached_test.go index 32629f1..9390907 100644 --- a/boatstack/detached_test.go +++ b/boatstack/detached_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" ) @@ -300,32 +301,37 @@ func TestContextProjectionReportsMode(t *testing.T) { } // control-law: unattached-repositories-are-not-controlled -// A developer-level guard allows an unmanaged repository (no Boatstack control) but -// enforces the full policy on a managed one. -func TestAmbientHookNoOpsUnmanagedButEnforcesManaged(t *testing.T) { +// Attachment and repository presence remain inert. Only a verified active +// delivery engages the developer-level probe. +func TestEngagementProbeRequiresActiveDelivery(t *testing.T) { repo := detachedTestRepo(t, "https://github.com/acme/app.git") event := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git reset --hard HEAD~1"}}`) - // Unmanaged: the ambient guard must not control this repository. - output, denied := AmbientHookDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event}) + // Unmanaged: the engagement probe must not control this repository. + output, denied := EngagementProbeDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event}) if denied { - t.Fatalf("ambient guard controlled an unattached repository: %s", output) + t.Fatalf("engagement probe controlled an unattached repository: %s", output) } - // Attached (detached): the same destructive command is now denied. + // Attachment alone still carries no policy authority. if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil { t.Fatal(err) } invalidateWorkspaceCache() - output, denied = AmbientHookDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event}) + output, denied = EngagementProbeDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event}) + if denied { + t.Fatalf("engagement probe controlled an attached dormant repository: %s", output) + } + engageHookFixture(t, repo) + output, denied = EngagementProbeDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event}) if !denied || !strings.Contains(string(output), `"permissionDecision":"deny"`) { - t.Fatalf("ambient guard did not enforce policy on a managed repository: %s", output) + t.Fatalf("engagement probe did not enforce policy on an active delivery: %s", output) } } // control-law: unattached-repositories-are-not-controlled // Activation instructions are host-neutral (every agent), point at developer-level -// config outside the repo, and install the ambient guard that no-ops unattached +// config outside the repo, and install the engagement probe that no-ops unattached // repositories. An unattached repository has nothing to activate. func TestActivationPlanIsHostNeutralAndExternal(t *testing.T) { repo := detachedTestRepo(t, "https://github.com/acme/app.git") @@ -357,8 +363,11 @@ func TestActivationPlanIsHostNeutralAndExternal(t *testing.T) { if strings.HasPrefix(host.ConfigPath, repo+string(filepath.Separator)) { t.Fatalf("%s activation must not point inside the repo: %s", host.Host, host.ConfigPath) } - if !strings.Contains(host.Snippet, "ambient-safety-hook") || !strings.Contains(host.Snippet, "--host "+host.Host) { - t.Fatalf("%s snippet missing ambient guard command: %s", host.Host, host.Snippet) + if !strings.Contains(host.Snippet, "engagement-probe") || !strings.Contains(host.Snippet, "--host "+host.Host) { + t.Fatalf("%s snippet missing engagement probe command: %s", host.Host, host.Snippet) + } + if !strings.Contains(host.Snippet, "engagement.json") || !strings.Contains(host.Snippet, `"commandWindows"`) { + t.Fatalf("%s snippet can load the helper before its cross-shell lease probe: %s", host.Host, host.Snippet) } var decoded any if err := json.Unmarshal([]byte(host.Snippet), &decoded); err != nil { @@ -367,8 +376,37 @@ func TestActivationPlanIsHostNeutralAndExternal(t *testing.T) { } } +func TestDetachedShellProbeDoesNotLoadHelperUntilEngaged(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash unavailable") + } + repo := safetyTestRepo(t) + marker := filepath.Join(t.TempDir(), "helper-ran") + helper := filepath.Join(t.TempDir(), "boatstack-helper") + body := []byte("#!/usr/bin/env bash\ntouch " + strconv.Quote(marker) + "\n") + if err := os.WriteFile(helper, body, 0o755); err != nil { + t.Fatal(err) + } + run := func() { + command := exec.Command("bash", "-c", engagementProbeCommand("codex", helper)) + command.Dir = repo + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("engagement probe failed: %v: %s", err, output) + } + } + run() + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("dormant detached probe loaded its helper: %v", err) + } + engageHookFixture(t, repo) + run() + if _, err := os.Stat(marker); err != nil { + t.Fatalf("active detached probe did not load its helper: %v", err) + } +} + // control-law: detached-control-state-never-enters-the-plant -// Attaching populates the external shared-runtime slot so the ambient guard has a +// Attaching populates the external shared-runtime slot so the engagement probe has a // helper to invoke, without writing into the repository. func TestAttachPopulatesExternalRuntimeSlot(t *testing.T) { repo := detachedTestRepo(t, "https://github.com/acme/app.git") @@ -397,9 +435,9 @@ func TestAttachPopulatesExternalRuntimeSlot(t *testing.T) { } // control-law: activation-preserves-existing-host-config -// Installing the ambient guard adds only a Boatstack-owned entry, preserves the +// Installing the engagement probe adds only a Boatstack-owned entry, preserves the // developer's existing hooks, and is idempotent. -func TestActivateInstallsAmbientGuardPreservingUserHooks(t *testing.T) { +func TestActivateInstallsEngagementProbePreservingUserHooks(t *testing.T) { repo := detachedTestRepo(t, "https://github.com/acme/app.git") userRoot := t.TempDir() t.Setenv("BOATSTACK_USER_CONFIG_ROOT", userRoot) @@ -417,7 +455,7 @@ func TestActivateInstallsAmbientGuardPreservingUserHooks(t *testing.T) { t.Fatal(err) } - result, err := InstallAmbientHooks(repo, []string{"claude"}) + result, err := InstallEngagementProbes(repo, []string{"claude"}) if err != nil { t.Fatal(err) } @@ -432,15 +470,15 @@ func TestActivateInstallsAmbientGuardPreservingUserHooks(t *testing.T) { if !strings.Contains(text, "my-own-check.sh") { t.Fatalf("install clobbered the developer's own hook: %s", text) } - if !strings.Contains(text, "ambient-safety-hook") { - t.Fatalf("install did not add the ambient guard: %s", text) + if !strings.Contains(text, "engagement-probe") { + t.Fatalf("install did not add the engagement probe: %s", text) } if !strings.Contains(text, `"theme"`) { t.Fatalf("install dropped unrelated user settings: %s", text) } // Idempotent: a second install changes nothing. - again, err := InstallAmbientHooks(repo, []string{"claude"}) + again, err := InstallEngagementProbes(repo, []string{"claude"}) if err != nil { t.Fatal(err) } @@ -448,20 +486,20 @@ func TestActivateInstallsAmbientGuardPreservingUserHooks(t *testing.T) { t.Fatalf("second install was not idempotent: %+v", again) } - // Deactivate removes only the ambient guard, preserving the developer's hook. - removed, err := RemoveAmbientHooks(repo, []string{"claude"}) + // Deactivate removes only the engagement probe, preserving the developer's hook. + removed, err := RemoveEngagementProbes(repo, []string{"claude"}) if err != nil { t.Fatal(err) } if removed.Hosts[0].Action != "removed" { - t.Fatalf("deactivate did not remove the ambient guard: %+v", removed) + t.Fatalf("deactivate did not remove the engagement probe: %+v", removed) } after, err := os.ReadFile(claudeCfg) if err != nil { t.Fatal(err) } - if strings.Contains(string(after), "ambient-safety-hook") { - t.Fatalf("deactivate left the ambient guard behind: %s", after) + if strings.Contains(string(after), "engagement-probe") { + t.Fatalf("deactivate left the engagement probe behind: %s", after) } if !strings.Contains(string(after), "my-own-check.sh") { t.Fatalf("deactivate removed the developer's own hook: %s", after) @@ -469,11 +507,11 @@ func TestActivateInstallsAmbientGuardPreservingUserHooks(t *testing.T) { } // control-law: unattached-repositories-are-not-controlled -// Installing the ambient guard requires an attachment; an unattached repo is refused. +// Installing the engagement probe requires an attachment; an unattached repo is refused. func TestActivateRefusesUnattachedRepository(t *testing.T) { repo := detachedTestRepo(t, "https://github.com/acme/app.git") t.Setenv("BOATSTACK_USER_CONFIG_ROOT", t.TempDir()) - result, err := InstallAmbientHooks(repo, []string{"claude"}) + result, err := InstallEngagementProbes(repo, []string{"claude"}) if err != nil { t.Fatal(err) } diff --git a/boatstack/engagement.go b/boatstack/engagement.go new file mode 100644 index 0000000..602db98 --- /dev/null +++ b/boatstack/engagement.go @@ -0,0 +1,195 @@ +package boatstack + +import ( + "os" + "path/filepath" + "strings" +) + +// EngagementMode is the single authority projection used by host hooks and +// command adapters. Repository presence and historical workflow evidence are +// deliberately absent from this vocabulary. +type EngagementMode string + +const ( + EngagementDormant EngagementMode = "DORMANT" + EngagementCommand EngagementMode = "COMMAND" + EngagementActive EngagementMode = "ACTIVE" +) + +const engagementLeaseSchemaVersion = 1 + +// EngagementRequest declares whether the current operation is an explicit +// Boatstack command. Command engagement is ephemeral and is never persisted. +type EngagementRequest struct { + ExplicitCommand bool +} + +// EngagementStatus is the canonical answer to whether Boatstack owns the +// current operation. ACTIVE is valid only when the worktree-local lease agrees +// with valid delivery state and the current branch. +type EngagementStatus struct { + SchemaVersion int `json:"schema_version"` + Mode EngagementMode `json:"mode"` + RepoRoot string `json:"repo_root,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + Branch string `json:"branch,omitempty"` + Feature string `json:"feature,omitempty"` + Slice string `json:"slice,omitempty"` + PlanLockHash string `json:"plan_lock_sha256,omitempty"` + Reason string `json:"reason"` +} + +type engagementLease struct { + SchemaVersion int `json:"schema_version"` + RepoRoot string `json:"repo_root"` + GitDir string `json:"git_dir"` + WorktreeID string `json:"worktree_id,omitempty"` + Branch string `json:"branch"` + Feature string `json:"feature"` + Slice string `json:"slice"` + PlanLockHash string `json:"plan_lock_sha256"` +} + +// engagementLeasePath is intentionally rooted in the per-worktree Git dir for +// both embedded and detached installations. Generated hook shims can therefore +// prove that no engagement exists before they load or hydrate any runtime. +func engagementLeasePath(repo string) (string, error) { + gitDir, err := worktreeGitDir(repo) + if err != nil { + return "", err + } + return filepath.Join(gitDir, controlDirName, "engagement.json"), nil +} + +func dormantEngagement(reason string) EngagementStatus { + return EngagementStatus{SchemaVersion: engagementLeaseSchemaVersion, Mode: EngagementDormant, Reason: reason} +} + +// ResolveEngagement is the sole authority resolver for ambient Boatstack +// behavior. Invalid, stale, terminal, cross-branch, and cross-worktree evidence +// is inert here; explicit Boatstack commands validate and diagnose that evidence +// through their own fail-closed boundaries. +func ResolveEngagement(repoPath string, request EngagementRequest) EngagementStatus { + repo, err := ResolveRepository(repoPath) + if err != nil { + return dormantEngagement("repository identity is unavailable") + } + workspace := WorkspaceFor(repo) + if request.ExplicitCommand { + return EngagementStatus{ + SchemaVersion: engagementLeaseSchemaVersion, Mode: EngagementCommand, + RepoRoot: repo, WorktreeID: workspace.WorktreeID, + Branch: strings.TrimSpace(gitOutput(repo, "branch", "--show-current")), + Reason: "an explicit Boatstack command owns only this operation", + } + } + path, err := engagementLeasePath(repo) + if err != nil { + return dormantEngagement("no worktree engagement lease exists") + } + info, err := os.Lstat(path) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return dormantEngagement("no valid worktree engagement lease exists") + } + value, err := os.ReadFile(path) + if err != nil { + return dormantEngagement("the worktree engagement lease is unreadable") + } + var lease engagementLease + if err := DecodeJSON("load engagement lease", path, value, &lease); err != nil || + lease.SchemaVersion != engagementLeaseSchemaVersion || + !featureSlugPattern.MatchString(lease.Feature) || + !featureSlugPattern.MatchString(lease.Slice) || strings.TrimSpace(lease.PlanLockHash) == "" { + return dormantEngagement("the worktree engagement lease is invalid") + } + gitDir, err := worktreeGitDir(repo) + if err != nil || canonicalizeExistingAncestor(lease.RepoRoot) != canonicalizeExistingAncestor(repo) || + canonicalizeExistingAncestor(lease.GitDir) != canonicalizeExistingAncestor(gitDir) || + lease.WorktreeID != workspace.WorktreeID { + return dormantEngagement("the engagement lease belongs to another worktree") + } + branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current")) + if branch == "" || lease.Branch != branch { + return dormantEngagement("the engagement lease belongs to another branch") + } + state, err := LoadDeliveryState(repo, lease.Feature) + if err != nil || state.PlanLockHash != lease.PlanLockHash || state.ActiveIndex >= len(state.Slices) { + return dormantEngagement("the engagement lease has no current active delivery") + } + if err := checkDeliveryPlanLock(repo, lease.Feature, state); err != nil { + return dormantEngagement("the engagement lease has no valid plan lock") + } + slice := state.Slices[state.ActiveIndex] + if slice.ID != lease.Slice || slice.Status == StatusPublished || !stateMatchesBranch(state, branch) { + return dormantEngagement("the engagement lease does not match the current delivery") + } + return EngagementStatus{ + SchemaVersion: engagementLeaseSchemaVersion, Mode: EngagementActive, + RepoRoot: repo, WorktreeID: workspace.WorktreeID, Branch: branch, + Feature: lease.Feature, Slice: lease.Slice, PlanLockHash: lease.PlanLockHash, + Reason: "a verified delivery is active in this worktree and branch", + } +} + +func engagementLeaseForState(repo string, state DeliveryState) (engagementLease, bool, error) { + if state.ActiveIndex < 0 || state.ActiveIndex >= len(state.Slices) { + return engagementLease{}, false, nil + } + slice := state.Slices[state.ActiveIndex] + if slice.Status == StatusPublished { + return engagementLease{}, false, nil + } + root, err := ResolveRepository(repo) + if err != nil { + return engagementLease{}, false, err + } + branch := strings.TrimSpace(gitOutput(root, "branch", "--show-current")) + if branch == "" || !stateMatchesBranch(state, branch) { + return engagementLease{}, false, nil + } + gitDir, err := worktreeGitDir(root) + if err != nil { + return engagementLease{}, false, err + } + return engagementLease{ + SchemaVersion: engagementLeaseSchemaVersion, RepoRoot: root, GitDir: gitDir, + WorktreeID: WorkspaceFor(root).WorktreeID, Branch: branch, + Feature: state.Feature, Slice: slice.ID, PlanLockHash: state.PlanLockHash, + }, true, nil +} + +// syncEngagementLease makes activation and publication the only durable lease +// transitions. Historical delivery files remain intact after release. +func syncEngagementLease(repo string, state DeliveryState) error { + path, err := engagementLeasePath(repo) + if err != nil { + return err + } + lease, active, err := engagementLeaseForState(repo, state) + if err != nil { + return err + } + if !active { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + value, err := MarshalJSON(lease) + if err != nil { + return err + } + return atomicWriteMode(path, value, 0o600) +} + +func clearEngagementLease(repo string) error { + path, err := engagementLeasePath(repo) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/boatstack/engagement_surface_registry_test.go b/boatstack/engagement_surface_registry_test.go new file mode 100644 index 0000000..3f63bb2 --- /dev/null +++ b/boatstack/engagement_surface_registry_test.go @@ -0,0 +1,93 @@ +package boatstack + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" +) + +// engagementSurfaceClasses inventories every production call that can decide, +// persist, expose, or route engagement. The reviewed digest makes an added, +// removed, or bypassing call site a deliberate contract change in CI. +var engagementSurfaceClasses = map[string]string{ + "ResolveEngagement": "resolver", + "syncEngagementLease": "lease-writer", + "clearEngagementLease": "lease-writer", + "HookDecision": "policy-entry", + "EngagementProbeDecision": "host-entry", + "activeManagedOperationScope": "operation-entry", + "initializeDeliveryState": "activation-writer", + "MarkDeliveryPublished": "release-writer", + "DiscardDelivery": "release-writer", + "guardShellScript": "shell-renderer", + "guardPowerShellScript": "shell-renderer", + "desiredHostHookForEvent": "host-renderer", + "engagementProbeCommand": "host-renderer", + "engagementProbePowerShellCommand": "host-renderer", +} + +func engagementCalledName(call *ast.CallExpr) string { + switch function := call.Fun.(type) { + case *ast.Ident: + return function.Name + case *ast.SelectorExpr: + return function.Sel.Name + default: + return "" + } +} + +func TestEngagementSurfaceRegistryIsComplete(t *testing.T) { + entries := []string{} + set := token.NewFileSet() + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + commandFiles, err := filepath.Glob(filepath.Join("cmd", "boatstack-helper", "*.go")) + if err != nil { + t.Fatal(err) + } + files = append(files, commandFiles...) + for _, path := range files { + if strings.HasSuffix(path, "_test.go") { + continue + } + parsed, err := parser.ParseFile(set, path, nil, 0) + if err != nil { + t.Fatal(err) + } + for _, declaration := range parsed.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Body == nil { + continue + } + counts := map[string]int{} + ast.Inspect(function.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + name := engagementCalledName(call) + class, tracked := engagementSurfaceClasses[name] + if !tracked { + return true + } + counts[name]++ + entries = append(entries, filepath.ToSlash(path)+":"+function.Name.Name+":"+name+":"+class+":"+strconv.Itoa(counts[name])) + return true + }) + } + } + sort.Strings(entries) + digest := SHA256Bytes([]byte(strings.Join(entries, "\n"))) + const expected = "965433e25f06d745b46c752e553beeec82ebe24f508fcf3422da6d273291c3b4" + if digest != expected { + t.Fatalf("engagement surface registry changed: got %s; classify the new or removed site and update the reviewed digest\n%s", digest, strings.Join(entries, "\n")) + } +} diff --git a/boatstack/engagement_test.go b/boatstack/engagement_test.go new file mode 100644 index 0000000..2aa5265 --- /dev/null +++ b/boatstack/engagement_test.go @@ -0,0 +1,229 @@ +package boatstack + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func destructiveHookEvent(host string) []byte { + switch host { + case "cursor": + return []byte(`{"hook_event_name":"beforeShellExecution","command":"git reset --hard HEAD~1"}`) + case "gemini": + return []byte(`{"hook_event_name":"BeforeTool","tool_name":"run_shell_command","tool_input":{"command":"git reset --hard HEAD~1"}}`) + default: + return []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git reset --hard HEAD~1"}}`) + } +} + +func activeEngagementFixture(t *testing.T, repo, feature string) DeliveryState { + t.Helper() + branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current")) + lockPath := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "plan.lock.json") + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(lockPath, []byte("active engagement lock\n"), 0o644); err != nil { + t.Fatal(err) + } + lockHash, err := SHA256File(lockPath) + if err != nil { + t.Fatal(err) + } + state := DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: lockHash, + ActiveIndex: 0, Mode: "NORMAL", RepairCounters: map[string]int{}, + Slices: []DeliverySlice{{ID: "delivery", Status: StatusBuild, BaseBranch: "main", HeadBranch: branch}}, + } + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + if err := syncEngagementLease(repo, state); err != nil { + t.Fatal(err) + } + return state +} + +func TestDormantRepositoryHasZeroBoatstackPolicyEffects(t *testing.T) { + repo := safetyTestRepo(t) + draftPlan := writeValidSavedFeaturePlan(t, repo, "saved-draft") + draftDir := filepath.Dir(draftPlan) + if err := os.WriteFile(filepath.Join(draftDir, "plan.approval.json"), []byte(`{"approved":true}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(draftDir, "plan.lock.json"), []byte(`{"schema_version":2}`), 0o644); err != nil { + t.Fatal(err) + } + writeInvalidDelivery(t, repo, "stale-delivery") + writePublishedDelivery(t, repo, "published-work", "OPEN") + + status := ResolveEngagement(repo, EngagementRequest{}) + if status.Mode != EngagementDormant { + t.Fatalf("repository evidence activated Boatstack: %+v", status) + } + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { + output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: destructiveHookEvent(host)}) + if denied { + t.Fatalf("%s dormant hook denied repository administration: %s", host, output) + } + if len(output) != 0 { + t.Fatalf("%s dormant hook emitted output: %s", host, output) + } + } + write := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":".git/boatstack/deliveries/demo/state.json","content":"{}"}}`) + if output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: write}); denied { + t.Fatalf("dormant raw managed-state write was controlled: %s", output) + } + for _, command := range []string{ + "git clean -fd", "git branch -D old-work", "git worktree remove /tmp/old-worktree", + "git reset --hard HEAD", "gcloud sql instances delete fixture", + } { + event := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":` + strconv.Quote(command) + `}}`) + if output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: event}); denied { + t.Fatalf("dormant operation %q was controlled: %s", command, output) + } + } + for host, event := range map[string][]byte{ + "cursor": []byte(`{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__db__execute_sql","tool_input":{"query":"DROP TABLE fixture"}}`), + "claude": []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__database__query","tool_input":{"query":"DELETE FROM fixture"}}`), + "codex": []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__database__query","tool_input":{"query":"DELETE FROM fixture"}}`), + "gemini": []byte(`{"hook_event_name":"BeforeTool","tool_name":"mcp__database__query","tool_input":{"query":"DELETE FROM fixture"}}`), + } { + if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: event}); denied { + t.Fatalf("%s dormant MCP mutation was controlled: %s", host, output) + } + } +} + +func TestExplicitCommandIsEphemeral(t *testing.T) { + repo := safetyTestRepo(t) + command := ResolveEngagement(repo, EngagementRequest{ExplicitCommand: true}) + if command.Mode != EngagementCommand { + t.Fatalf("explicit command did not receive command scope: %+v", command) + } + if ambient := ResolveEngagement(repo, EngagementRequest{}); ambient.Mode != EngagementDormant { + t.Fatalf("command scope leaked into later ambient work: %+v", ambient) + } +} + +func TestActiveLeaseSurvivesResolverRestartAndScopesEveryHost(t *testing.T) { + repo := safetyTestRepo(t) + activeEngagementFixture(t, repo, "active-feature") + for attempt := 0; attempt < 2; attempt++ { + status := ResolveEngagement(repo, EngagementRequest{}) + if status.Mode != EngagementActive || status.Feature != "active-feature" || status.Slice != "delivery" { + t.Fatalf("active engagement was not durable: %+v", status) + } + } + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { + if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: destructiveHookEvent(host)}); !denied { + t.Fatalf("%s active hook allowed destructive operation: %s", host, output) + } + } +} + +func TestWrongBranchAndPublishedDeliveryAreDormant(t *testing.T) { + repo := safetyTestRepo(t) + state := activeEngagementFixture(t, repo, "scoped-feature") + runGit(t, repo, "switch", "-c", "unrelated") + if status := ResolveEngagement(repo, EngagementRequest{}); status.Mode != EngagementDormant { + t.Fatalf("cross-branch delivery remained engaged: %+v", status) + } + runGit(t, repo, "switch", "main") + state.Slices[0].Status = StatusPublished + state.Slices[0].PRState = "OPEN" + state.ActiveIndex = 1 + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + if err := syncEngagementLease(repo, state); err != nil { + t.Fatal(err) + } + if status := ResolveEngagement(repo, EngagementRequest{}); status.Mode != EngagementDormant { + t.Fatalf("published delivery retained ambient authority: %+v", status) + } + if path, err := engagementLeasePath(repo); err != nil { + t.Fatal(err) + } else if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("publication did not release the engagement lease: %v", err) + } +} + +func TestActiveLeaseDoesNotCrossWorktrees(t *testing.T) { + repo := safetyTestRepo(t) + activeEngagementFixture(t, repo, "worktree-feature") + linked := filepath.Join(t.TempDir(), "linked") + runGit(t, repo, "worktree", "add", "-b", "unrelated-worktree", linked) + if status := ResolveEngagement(linked, EngagementRequest{}); status.Mode != EngagementDormant { + t.Fatalf("active engagement crossed into another worktree: %+v", status) + } + if output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: linked, Input: destructiveHookEvent("codex")}); denied { + t.Fatalf("unrelated worktree was controlled: %s", output) + } +} + +func TestMalformedOrSymlinkedLeaseIsDormant(t *testing.T) { + repo := safetyTestRepo(t) + path, err := engagementLeasePath(repo) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{not-json"), 0o600); err != nil { + t.Fatal(err) + } + if status := ResolveEngagement(repo, EngagementRequest{}); status.Mode != EngagementDormant { + t.Fatalf("malformed lease acquired authority: %+v", status) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "lease.json") + if err := os.WriteFile(target, []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + if status := ResolveEngagement(repo, EngagementRequest{}); status.Mode != EngagementDormant { + t.Fatalf("symlinked lease acquired authority: %+v", status) + } +} + +func TestDormantGuardExitsBeforeHydration(t *testing.T) { + repo := safetyTestRepo(t) + guard := filepath.Join(repo, "guard.sh") + if err := os.WriteFile(guard, guardShellScript(), 0o755); err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(t.TempDir(), "hydrated") + command := exec.Command("bash", guard, "codex") + command.Dir = repo + command.Env = append(os.Environ(), "BOATSTACK_HYDRATE_COMMAND=touch "+sentinel) + command.Stdin = strings.NewReader(string(destructiveHookEvent("codex")) + "\n") + output, err := command.CombinedOutput() + if err != nil || len(output) != 0 { + t.Fatalf("dormant guard was not silent: err=%v output=%s", err, output) + } + if _, err := os.Stat(sentinel); !os.IsNotExist(err) { + t.Fatal("dormant guard attempted runtime hydration") + } +} + +func TestEngagementStatusJSONShape(t *testing.T) { + repo := safetyTestRepo(t) + value, err := json.Marshal(ResolveEngagement(repo, EngagementRequest{})) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(value), `"mode":"DORMANT"`) { + t.Fatalf("engagement status omitted mode: %s", value) + } +} diff --git a/boatstack/export.go b/boatstack/export.go index 1e56019..497bf81 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -30,7 +30,7 @@ var allowedAdapters = map[string]bool{ const executionModeDX = "\n\n**Execution Mode Notice:** Boatstack requires active tool use (such as shell execution) to run its commands and inspect the workspace. If you are currently operating in a Plan Mode, Read-Only Mode, or a state with restricted tool access, you MUST immediately inform the user that Boatstack requires full execution capabilities and ask them to approve exiting that mode or grant execution permissions before you can proceed. Do not attempt to mock or bypass the operation." -const repositoryAdministrationBoundary = `Repository administration is outside managed product delivery. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes must never route to auto-plan or repair unless the exact target branch belongs to an active managed delivery. For requests such as "ensure main is same is origin/main remove any current changes", inspect only the named refs and worktree, then invoke the exact project-local .product-loop/boatstack workspace-sync --repo . --branch main --source origin/main. The helper checkpoints recoverable state before alignment. If raw destructive Git is denied, report that denial and this one recovery action immediately; do not inspect plans, scan the repository, search for the helper, or retry the destructive command.` +const repositoryAdministrationBoundary = `Repository administration is outside Boatstack engagement. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes must never activate Boatstack or route to auto-plan, repair, or workspace-sync. Use ordinary repository tools unless the user explicitly asks Boatstack to manage an operation during an active delivery.` var ( readCanonical = ReadCanonical @@ -439,12 +439,12 @@ Boatstack's repository hooks deny high-confidence irreversible operations across adapterSkill := fmt.Sprintf(`--- name: %s -description: Use when the user explicitly asks for Boatstack or when work targets an active or current-branch published Boatstack delivery. Do not activate from installation, attachment, saved or approved drafts, repository presence, or repository administration alone. +description: Use only when the user explicitly asks for Boatstack or when work targets a verified active pre-publication Boatstack delivery in the current worktree and branch. Do not activate from installation, attachment, saved or approved drafts, published work, repository presence, or repository administration. --- # Boatstack adapter - Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid managed operations are next, boatstack-next, run, boatstack-run, insight-capture, insight-frontier, root-cause, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, workspace-cleanup, and workspace-reap. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route requests to preserve a vague idea or customer message to insight-capture, and requests for pending ideas to the read-only insight-frontier operation. Insight capture is independent of managed delivery: it requires an exact Value Map preview and a separate state-scoped save confirmation, then creates a tracked artifact below docs/insights so the information can cross into engineering through a review PR. No insight content or event may be stored in detached or Git control state. Route bug diagnosis such as a stack trace or "why did this crash" to the read-only root-cause operation, which classifies the failure and produces a source plan to hand to auto-plan; it never edits code or advances a gate. Route run and requests such as "run Boatstack through ship" to boatstack-run. Boatstack begins only on an explicit user request or a managed delivery bound to the current worktree and branch. Installation, attachment, saved or approved drafts, ambiguity, and invalid candidates never control ordinary repository tools. After explicit invocation, carry the selected feature slug through status and planning calls. For an active or current-branch published managed delivery, automatically use repair only for product behavior, implementation, test, review, or delivery-evidence failures and changes. Never instruct the user to manually repeat a push or PR mutation denied by the safety hook. + Read .product-loop/project.json and .product-loop/workflow.md only after explicit invocation or verified active engagement. The requested operation is supplied by the user; valid managed operations are next, boatstack-next, run, boatstack-run, insight-capture, insight-frontier, root-cause, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, workspace-cleanup, and workspace-reap. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route requests to preserve a vague idea or customer message to insight-capture, and requests for pending ideas to the read-only insight-frontier operation. Route bug diagnosis such as a stack trace or "why did this crash" to the read-only root-cause operation. Boatstack begins only on an explicit user request or a verified active pre-publication delivery bound to the current worktree and branch. Installation, attachment, drafts, approvals, ambiguity, invalid candidates, published work, and repository administration never control ordinary repository tools. After explicit invocation, carry the selected feature slug through status and planning calls. Automatically use repair only while that verified pre-publication delivery remains active. Once publication succeeds, later CI repair, PR update, cleanup, or corrective work requires another explicit Boatstack request. %s @@ -462,7 +462,7 @@ For each finite product question, show 2-3 choices with compact keys such as 1a/ Use .product-loop/artifacts.md for document boundaries and .product-loop/failure-moves.md for improvement experiments. If a structured question tool is unavailable, ask 1-3 plain-text questions and return WAITING_FOR_INPUT; never select defaults on the user's behalf. Do not implement from an unapproved or stale plan. Implementation tactics are open; completion, approval, and shipping claims require current evidence. Do not branch on model identity; use observable state and gate evidence. -Repository hooks enforce Boatstack's immutable deny policy across every agent call. Never request an in-session bypass for a blocked irreversible operation. After an external-write failure, preserve state, run only read-only diagnosis, and prefer transactional rollback or fix-forward recovery. Source code may be edited for review, but executable destructive capability blocks running it and blocks test, review, and ship progression. +During explicit Boatstack commands and verified active deliveries, repository hooks enforce Boatstack's immutable deny policy. Outside engagement they emit nothing, block nothing, and do not load or hydrate Boatstack. Never request an in-session bypass while engaged. After an engaged external-write failure, preserve state, run only read-only diagnosis, and prefer transactional rollback or fix-forward recovery. At ship, prove whether a failing check is pre-existing by checking the base branch. Keep unrelated repairs in a separate PR; do not modify unrelated code under the approved feature lock. A repository-policy bypass requires explicit human authorization and recorded evidence. diff --git a/boatstack/export_test.go b/boatstack/export_test.go index bd222aa..40fcfa0 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -432,8 +432,8 @@ func TestExportAndDriftCheck(t *testing.T) { } for _, path := range []string{".claude/skills/boatstack/SKILL.md", ".gemini/skills/boatstack/SKILL.md", ".agents/skills/boatstack/SKILL.md"} { router := string(bundle.Files[path]) - if !strings.Contains(router, "automatically use repair") || !strings.Contains(router, "current-branch published managed delivery") || !strings.Contains(router, "Never instruct the user to manually repeat") { - t.Fatalf("%s does not auto-route free-form delivery changes", path) + if !strings.Contains(router, "verified active pre-publication") || !strings.Contains(router, "published work") || !strings.Contains(router, "requires another explicit Boatstack request") { + t.Fatalf("%s does not enforce explicit engagement", path) } } codexRouter := string(bundle.Files[".agents/skills/boatstack/SKILL.md"]) @@ -598,12 +598,10 @@ func TestPortableHostAdaptersShareWorkflowAndArtifactContract(t *testing.T) { for _, expected := range []string{ ".product-loop/project.json", ".product-loop/workflow.md", - "Repository administration is outside managed product delivery", + "Repository administration is outside Boatstack engagement", "Branch synchronization, status, switching, worktree maintenance", - "must never route to auto-plan or repair", - "ensure main is same is origin/main remove any current changes", - ".product-loop/boatstack workspace-sync --repo . --branch main --source origin/main", - "do not inspect plans, scan the repository, search for the helper", + "must never activate Boatstack", + "Use ordinary repository tools", } { if !strings.Contains(surface, expected) { t.Fatalf("%s adapter does not reference shared repository contract %q", host, expected) diff --git a/boatstack/hooks.go b/boatstack/hooks.go index 9f53998..9dc0c1c 100644 --- a/boatstack/hooks.go +++ b/boatstack/hooks.go @@ -148,8 +148,22 @@ bs_deny() { HOST="${1:-}" ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" if [[ -z "$ROOT" ]]; then - bs_deny "Boatstack safety guard could not resolve the repository; denying tool execution." - exit 2 + exit 0 +fi + +# Repository presence is not engagement. This worktree-local lease probe runs +# before platform detection, runtime discovery, checksum work, or hydration. +# Missing, unsafe, stale-branch, and malformed evidence is inert here; the +# trusted helper validates the complete lease and delivery state when active. +GIT_DIR="$(git rev-parse --path-format=absolute --git-dir 2>/dev/null || true)" +LEASE="$GIT_DIR/boatstack/engagement.json" +if [[ -z "$GIT_DIR" || ! -f "$LEASE" || -L "$LEASE" ]]; then + exit 0 +fi +LEASE_BRANCH="$(sed -n 's/.*"branch"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$LEASE" | head -n 1)" +CURRENT_BRANCH="$(git branch --show-current 2>/dev/null || true)" +if [[ -z "$LEASE_BRANCH" || "$LEASE_BRANCH" != "$CURRENT_BRANCH" ]]; then + exit 0 fi COMMON="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" @@ -293,9 +307,17 @@ function Bs-Deny($msg) { } $root = (& git rev-parse --show-toplevel 2>$null) if (-not $root) { - Bs-Deny "Boatstack safety guard could not resolve the repository; denying tool execution." - exit 2 -} + exit 0 +} +$gitDir = (& git rev-parse --path-format=absolute --git-dir 2>$null) +if (-not $gitDir) { exit 0 } +$leasePath = Join-Path $gitDir "boatstack/engagement.json" +if (-not (Test-Path -LiteralPath $leasePath -PathType Leaf)) { exit 0 } +$leaseInfo = Get-Item -LiteralPath $leasePath +if ($leaseInfo.Attributes -band [IO.FileAttributes]::ReparsePoint) { exit 0 } +try { $lease = Get-Content -LiteralPath $leasePath -Raw | ConvertFrom-Json } catch { exit 0 } +$currentBranch = (& git branch --show-current 2>$null) +if (-not $lease.branch -or $lease.branch -ne $currentBranch) { exit 0 } $common = (& git rev-parse --path-format=absolute --git-common-dir 2>$null) if (-not $common) { Bs-Deny "Boatstack safety guard could not resolve the Git common directory; denying tool execution." @@ -407,7 +429,7 @@ func desiredHostHookForEvent(host, event string) map[string]any { "matcher": "Bash|Shell|Write|Edit|ApplyPatch|Create|Delete|Move|Rename|mcp__.*", "hooks": []any{map[string]any{ "type": "command", "command": hookCommand(host), - "shell": "bash", "timeout": 10, "statusMessage": "Checking Boatstack execution policy", + "shell": "bash", "timeout": 10, }}, } case "codex": @@ -415,15 +437,15 @@ func desiredHostHookForEvent(host, event string) map[string]any { "matcher": "Bash|Shell|Write|Edit|ApplyPatch|Create|Delete|Move|Rename|mcp__.*", "hooks": []any{map[string]any{ "type": "command", "command": hookCommand(host), "commandWindows": hookCommandWindows(host), - "timeout": 10, "statusMessage": "Checking Boatstack execution policy", + "timeout": 10, }}, } case "gemini": return map[string]any{ "matcher": ".*", "sequential": true, "hooks": []any{map[string]any{ - "name": "boatstack-safety-guard", "type": "command", "command": hookCommand(host), - "timeout": 10000, "description": "Checking Boatstack execution policy", + "name": "boatstack-engagement-probe", "type": "command", "command": hookCommand(host), + "timeout": 10000, }}, } default: @@ -676,7 +698,7 @@ func mergeHostHookWithOwnership(config map[string]any, host string, installed ma return fmt.Errorf("ambiguous Boatstack hook collision in %s", event) } if found > 0 && !verified && !repair { - return fmt.Errorf("drifted %s Boatstack safety hook for %s; rerun the update with --repair only after reviewing the owned-state preview", host, event) + return fmt.Errorf("drifted %s Boatstack engagement probe for %s; rerun the update with --repair only after reviewing the owned-state preview", host, event) } kept = append(kept, desiredHostHookForEvent(host, event)) hooks[event] = kept diff --git a/boatstack/hooks_test.go b/boatstack/hooks_test.go index a8c77b6..087ae9f 100644 --- a/boatstack/hooks_test.go +++ b/boatstack/hooks_test.go @@ -128,7 +128,7 @@ func TestInstalledHookValidationAllowsTemplateMigrationButRejectsUserDrift(t *te if err != nil { t.Fatal(err) } - fragment = []byte(strings.ReplaceAll(string(fragment), "Checking Boatstack execution policy", "Checking irreversible-operation policy")) + fragment = []byte(strings.ReplaceAll(string(fragment), `"timeout": 10`, `"timeout": 11`)) fragmentPath := filepath.Join(repo, ".product-loop", "hooks", "claude.fragment.json") if err := os.MkdirAll(filepath.Dir(fragmentPath), 0o755); err != nil { t.Fatal(err) @@ -142,7 +142,7 @@ func TestInstalledHookValidationAllowsTemplateMigrationButRejectsUserDrift(t *te if err != nil { t.Fatal(err) } - hookValue = []byte(strings.ReplaceAll(string(hookValue), "Checking Boatstack execution policy", "Checking irreversible-operation policy")) + hookValue = []byte(strings.ReplaceAll(string(hookValue), `"timeout": 10`, `"timeout": 11`)) if err := os.WriteFile(hookPath, hookValue, 0o644); err != nil { t.Fatal(err) } @@ -154,7 +154,7 @@ func TestInstalledHookValidationAllowsTemplateMigrationButRejectsUserDrift(t *te t.Fatalf("healthy installed hook blocked template migration: %v", err) } - hookValue = []byte(strings.ReplaceAll(string(hookValue), `"timeout": 10`, `"timeout": 99`)) + hookValue = []byte(strings.ReplaceAll(string(hookValue), `"timeout": 11`, `"timeout": 99`)) if err := os.WriteFile(hookPath, hookValue, 0o644); err != nil { t.Fatal(err) } @@ -163,7 +163,7 @@ func TestInstalledHookValidationAllowsTemplateMigrationButRejectsUserDrift(t *te } } -func TestMissingHelperLauncherFailsClosed(t *testing.T) { +func TestMissingHelperLauncherIsSilentWhenDormantAndFailsClosedWhenActive(t *testing.T) { if _, err := exec.LookPath("bash"); err != nil { t.Skip("bash unavailable") } @@ -182,8 +182,17 @@ func TestMissingHelperLauncherFailsClosed(t *testing.T) { // reaching for the network; auto-hydration has its own dedicated subtests. command.Env = append(os.Environ(), "BOATSTACK_AUTO_HYDRATE=0") output, err := command.CombinedOutput() + if err != nil || len(output) != 0 { + t.Fatalf("dormant probe loaded the missing runtime: err=%v output=%s", err, output) + } + + engageHookFixture(t, repo) + command = exec.Command("bash", path, "cursor") + command.Dir = repo + command.Env = append(os.Environ(), "BOATSTACK_AUTO_HYDRATE=0") + output, err = command.CombinedOutput() if err == nil || !strings.Contains(string(output), "shared runtime is missing") { - t.Fatalf("missing helper did not fail closed: err=%v output=%s", err, output) + t.Fatalf("active probe did not fail closed: err=%v output=%s", err, output) } // The deny is a one-line self-heal: it must embed the exact pinned installer. if !strings.Contains(string(output), "BOATSTACK_MODE=hydrate BOATSTACK_VERSION="+Version) { diff --git a/boatstack/init.go b/boatstack/init.go index cb9f76c..f513c66 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -570,7 +570,7 @@ func RunInit(options InitOptions) (returnErr error) { fmt.Fprintln(options.Output, " "+path) } for _, path := range HostHookPaths(config.Adapters) { - fmt.Fprintln(options.Output, " "+path+" (merge Boatstack safety hook; preserve existing settings)") + fmt.Fprintln(options.Output, " "+path+" (merge inert Boatstack engagement probe; preserve existing settings)") } for _, path := range executionInterceptorPaths(config.Adapters) { fmt.Fprintln(options.Output, " "+path+" (replace only the marker-bounded Boatstack interceptor)") @@ -773,9 +773,9 @@ func RunInit(options InitOptions) (returnErr error) { } else { fmt.Fprintln(options.Output, "\nPASS: Boatstack core installed without a language runtime.") } - fmt.Fprintln(options.Output, "PASS: generated irreversible-operation hook contracts verified for installed hosts.") - fmt.Fprintln(options.Output, "Host activation remains an operator-visible boundary; run doctor after reload and verify each host reports its hook as active.") - fmt.Fprintln(options.Output, "Hooks are defense in depth; keep least-privilege credentials and service-side destructive approval.") + fmt.Fprintln(options.Output, "PASS: generated engagement probes verified for installed hosts.") + fmt.Fprintln(options.Output, "Boatstack remains dormant until an explicit command or a verified active delivery engages it.") + fmt.Fprintln(options.Output, "External systems remain responsible for repository and infrastructure safety while Boatstack is dormant.") keys := sortedKeys(states) for _, name := range keys { state := states[name] diff --git a/boatstack/init_test.go b/boatstack/init_test.go index f047be7..acd2298 100644 --- a/boatstack/init_test.go +++ b/boatstack/init_test.go @@ -44,7 +44,7 @@ func TestRuntimeFreeInit(t *testing.T) { if !strings.Contains(output.String(), "PASS: Boatstack core installed without a language runtime") { t.Fatalf("unexpected init output: %s", output.String()) } - for _, expected := range []string{"generated irreversible-operation hook contracts verified", "Host activation remains an operator-visible boundary", "least-privilege credentials"} { + for _, expected := range []string{"generated engagement probes verified", "remains dormant until an explicit command", "External systems remain responsible"} { if !strings.Contains(output.String(), expected) { t.Fatalf("init output is missing safety guidance %q: %s", expected, output.String()) } diff --git a/boatstack/lifecycle_event_registry_test.go b/boatstack/lifecycle_event_registry_test.go index a083f13..0efe224 100644 --- a/boatstack/lifecycle_event_registry_test.go +++ b/boatstack/lifecycle_event_registry_test.go @@ -136,7 +136,7 @@ func TestLifecycleEventRegistryIsComplete(t *testing.T) { } sort.Strings(entries) digest := SHA256Bytes([]byte(strings.Join(entries, "\n"))) - const expected = "98c48b24b87e5c95fcbd9d64e93ce05b42a484e221df01b57e5f7bf3d7be4ec4" + const expected = "2b6d3a0ba8b53513e744b478949da352e932aa2998b796f8077efaf286a14c0f" if digest != expected { t.Fatalf("lifecycle event registry changed: got %s; classify the new or removed site and update the reviewed digest\n%s", digest, strings.Join(entries, "\n")) } diff --git a/boatstack/operation_test.go b/boatstack/operation_test.go index 5a85794..17ff212 100644 --- a/boatstack/operation_test.go +++ b/boatstack/operation_test.go @@ -40,10 +40,14 @@ func activeOperationTestRepo(t *testing.T) string { if err != nil { t.Fatal(err) } - if err := saveDeliveryState(repo, DeliveryState{ + state := DeliveryState{ SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: lockHash, ActiveIndex: 0, Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: "BUILD", BaseBranch: "main", HeadBranch: "main"}}, - }); err != nil { + } + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + if err := syncEngagementLease(repo, state); err != nil { t.Fatal(err) } return repo diff --git a/boatstack/planning.go b/boatstack/planning.go index e3966e0..35cdbeb 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -516,7 +516,7 @@ func CheckInstallationHealth(repoPath string) error { return err } // Embedded installations own merged host settings in the repository and can - // verify them here. Detached controller state owns generated hook fragments; + // verify them here. Detached controller state owns generated engagement probes; // developer-level host activation is a separate, operator-visible boundary. // CheckExport above verifies those fragments without misreading them as merged // .cursor/.claude/.codex/.gemini configurations. @@ -528,7 +528,7 @@ func CheckInstallationHealth(repoPath string) error { hostAdapters := normalizedAdapters(config.Adapters) if contains(hostAdapters, "claude") { if _, err := lookPath("bash"); err != nil { - return fmt.Errorf("Claude Code safety hooks require Bash; install Git Bash or Bash, then rerun doctor") + return fmt.Errorf("Claude Code engagement probes require Bash; install Git Bash or Bash, then rerun doctor") } } if err := verifyGeneratedRuntime(ctx.ExportRoot()); err != nil { @@ -552,11 +552,13 @@ func CheckInstallationHealth(repoPath string) error { } for _, input := range inputs { if _, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: input}); denied { - return fmt.Errorf("%s safety hook denied its read-only smoke event", host) + return fmt.Errorf("%s engagement probe denied its read-only smoke event", host) } } - if _, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(`{"malformed":true}`)}); !denied { - return fmt.Errorf("%s safety hook did not fail closed on malformed input", host) + _, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(`{"malformed":true}`)}) + engaged := ResolveEngagement(repo, EngagementRequest{}).Mode == EngagementActive + if engaged != denied { + return fmt.Errorf("%s engagement probe contract drifted: active=%t denied=%t", host, engaged, denied) } } return verifyLocalRuntime(ctx.ExportRoot()) diff --git a/boatstack/planning_transport_conformance_test.go b/boatstack/planning_transport_conformance_test.go index acc6244..fa56fe5 100644 --- a/boatstack/planning_transport_conformance_test.go +++ b/boatstack/planning_transport_conformance_test.go @@ -295,6 +295,10 @@ func TestPlanningTransportFailureClassesFailClosedWithoutExecuting(t *testing.T) "PowerShell delimiter collision": powerShellPlanningEnvelope(t, `.product-loop\boatstack.ps1`, repo, "transport-failures", "plan.md", "# Plan\n'@\ntouch sentinel\n"), "PowerShell trailing command": strings.TrimSuffix(powerShellValid, "\n") + "; touch sentinel\n", } + // Dormant host probes are intentionally inert. The explicit bootstrap/helper + // boundary validates these envelopes before mutation; host-level denial is an + // additional active-delivery control and is tested under a real lease here. + engageHookFixture(t, repo) for name, command := range cases { t.Run(name, func(t *testing.T) { findings := ClassifyCommand(repo, command) diff --git a/boatstack/references/artifacts.md b/boatstack/references/artifacts.md index 9edb323..651a618 100644 --- a/boatstack/references/artifacts.md +++ b/boatstack/references/artifacts.md @@ -149,7 +149,7 @@ clone, `external` outside the repository (Detached Supervision). | delivery-state | runtime-worktree | per-worktree | delivery transitions | | operation-ledger | runtime-worktree | per-worktree | run-preflight, publishers | | flow-logs | runtime-worktree | per-worktree | flow | -| guard-denial-ledger | runtime-worktree | per-worktree | safety-hook, ambient-safety-hook | +| guard-denial-ledger | runtime-worktree | per-worktree | safety-hook, engagement-probe | | runtime-slots | runtime-shared | git-common | init, update, hydrate-runtime | | runtime-bootstrap-slots | runtime-shared | git-common | init, update, hydrate-runtime | | mutation-receipts | runtime-shared | git-common | activate-plan, undo | diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md index 716016b..0d40483 100644 --- a/boatstack/references/failure-moves.md +++ b/boatstack/references/failure-moves.md @@ -29,7 +29,7 @@ The `root-cause` operation operationalizes this taxonomy for a single bug: it cl | Post-publication correction routing | CI, review, or a denied push targets work already marked published — including a published-but-open *earlier slice* inside a still-active delivery, which the pointer-based advisors mis-routed to the active slice | Resolve the target through the same addressable-slice set the actuators use ({active slice} ∪ {published slices whose PR is not terminal}), keyed off the correction's branch: route a non-terminal published slice to an in-place re-gate/`publish-pr --action update` of *that* slice (not the active one), and only a terminal PR to an independently approved corrective child. Run recovery routing, change recording, and the publication-bypass finding through that one resolver so the advisor layer cannot drift from the actuator layer | Treating PR creation as completion, asking the user to bypass the guard, or an advisor/actuator addressability split-brain that repairs the wrong slice | | Unobserved side-effect completion | The same visible state could mean not started, executing, succeeded with a lost response, or failed | Durable operation receipt; exact lease; observe completion; reconcile the expected postcondition before retry | Conversation-scoped retry loops, duplicate PRs, or phantom success | | Postcondition-regressed terminal receipt | A durable local operation once reached `SUCCEEDED`, but its repository postcondition was later restored or regressed while the detached receipt survived; a retry consumes the terminal identity and returns success without rebuilding the missing state | Before consuming terminal success, verify the operation's declared postcondition against current repository and runtime state; keep success when it holds, otherwise reopen only the same bounded atomic-local operation and re-execute under its existing authority | Globally reopening terminal receipts, retrying irreversible external effects, or trusting the receipt instead of the current postcondition | -| Ambient malformed-draft lockout | A hand-authored feature `plan.md` never passed through the helper, so candidate discovery treats its `CheckPlan` failure as global workflow authority and blocks unrelated repository work | Keep unselected drafts observational; route only an explicitly selected candidate through plan-gate validation. `repair-state` remains the bounded quarantine path and refuses any directory with a lock, `pr.md`, delivery state, or tracked files | Silently accepting the malformed selected plan, or weakening managed-path and destructive-operation controls | +| Malformed-draft selection | A hand-authored feature `plan.md` never passed through the helper, so candidate discovery can mistake its `CheckPlan` failure for workflow authority | Keep unselected drafts inert; validate only an explicitly selected candidate. `repair-state` remains the bounded quarantine path and refuses any directory with a lock, `pr.md`, delivery state, or tracked files | Silently accepting the selected plan, or allowing draft evidence to create engagement | | Premature supervisory pointer advance | A durable supervisory pointer/state advances on request-success and revokes the correction actuator for a target whose postcondition (CI, merge) is not yet observed, so the stranded target can never be re-addressed | Separate the advance from correctability: keep a bounded in-place actuator for a non-terminal target (re-gate/re-publish the same open PR) and a bounded forward actuator once it is terminal (corrective child); resolve addressability network-free from a persisted terminal-state cache, never advance a supervisory pointer past an unobserved postcondition | Serializing legitimately-parallel work by refusing to advance, or persisting an identity/status that deadlocks the corrected retry | | Non-transactional multi-file promote | A managed artifact spans files that must land together (e.g. the compiled `tasks.json`, `test-matrix.json`, `evidence.md`, and the `plan.lock.json` that binds them), but independent non-atomic writes can leave a partial set on a crash or a failed post-write check | Promote the whole set through the transactional mutation boundary as one mutation: base-hash preconditions, supervisor-authority binding, atomic all-or-nothing write, post-write verification with automatic rollback, and a reversible receipt whose inverse bytes make the boundary closed under inversion — `undo` re-applies the inverse as a mutation (with redo as undo-of-the-undo), and a domain guard refuses reversal once a delivery gate would be stranded | Patching consistency after the fact with hash guards instead of making the promote atomic, persisting a rejected identity so a corrected retry deadlocks, or undoing an activation that strands live delivery state | | Provenance-blind runtime install | A write path stamps an artifact's declared identity (version/commit) from one origin — the running process's compile-time globals — while binding its integrity proof (checksum) to a different origin — the passed bytes; every checksum gate passes because the lock is internally consistent, but the binary self-reports a third value and the version gate fail-closes (clone-wide when the runtime is shared). Symptom: `update -binary ` run by an older helper writes newer bytes into the older version's slot, then every worktree's guard denies at once | Derive the installed artifact's identity from the artifact itself (execute its `version` self-report) and enforce it at the *write* boundary: refuse to install a `-binary` whose self-report disagrees with the running process, and re-exec a cross-version candidate so it installs itself — running becomes installed, so its embedded bundle, constants, slot path, and receipts are all authoritative by construction. Re-hash the just-written slot against its manifest and roll back on mismatch | Executing an untrusted candidate (bounded, operator-invoked only), or converting a recoverable slot mismatch into a hard clone-wide refuse that blocks legitimate upgrades; a per-read self-report exec would tax every guard event, so identity is enforced where it is written, not on the hydration hot path | diff --git a/boatstack/references/irreversible-operation-boundary.md b/boatstack/references/irreversible-operation-boundary.md index 1aa1b19..f491688 100644 --- a/boatstack/references/irreversible-operation-boundary.md +++ b/boatstack/references/irreversible-operation-boundary.md @@ -2,9 +2,9 @@ Boatstack removes high-confidence irreversible external side effects from the coding agent's reachable action space. It does not restrict ordinary implementation choices. -## Immutable policy +## Immutable engaged policy -The guard always denies: +While Boatstack is explicitly engaged, the guard always denies: - database or schema drops, truncation, resets, flushes, destructive downgrades, clean restores, and unbounded deletes or updates; - recursive removal of repository, home, root, parent, or wildcard targets; @@ -16,7 +16,7 @@ The guard always denies: There is no break-glass token or in-session override. Intentional destructive recovery belongs to a separately controlled operator surface outside Boatstack. Agents may edit source that describes a dangerous operation for review, but may not execute it; an operational diff containing that capability blocks build activation and subsequent gates until it is removed or transferred to the operator boundary. -Recoverable repository alignment is not an exception to this policy. Raw `git reset --hard`, `git clean`, and forced history replacement remain denied. The project-local `workspace-sync` helper may align one exact local branch to one freshly fetched remote branch only after it creates and verifies Git recovery refs for the original branch and any staged, unstaged, or untracked work. It blocks active managed-delivery branches and reports the retained recovery refs. +This policy applies only during an explicit Boatstack command or verified active delivery. Outside engagement, Boatstack is inert and repository administration remains under the host and operator's authority. During engagement, the project-local `workspace-sync` helper remains the bounded alignment actuator. ## Failure response diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 7eeea0d..d504231 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -34,7 +34,7 @@ A published delivery cannot be reset. Its correction uses a deterministic new fe `recovery-status` is the read-only resolver for CI failures, review findings, denied publication, and ordinary corrections. It selects by explicit feature, current active branch, current published branch, recorded PR identity, or one unambiguous candidate. It never chooses by recency. A stale reported head SHA, branch mismatch, or multiple match returns a blocker instead of drafting against the wrong delivery. -`repair-state` is the bounded recovery for the one state `recovery-status` cannot resolve: an unregistered feature draft whose `plan.md` never passed through the helper, so it has no plan lock and no delivery state. A malformed such draft makes the guard fail closed at `INVALID_STATE` and deny every product mutation. `repair-state` quarantines that directory out of `.product-loop/features/` into `/boatstack/quarantine//` — reversible, never a hard delete — and returns the workflow to `auto-plan`. It resolves the sole malformed candidate when `--feature` is omitted and refuses ambiguity. It acts only on a directory carrying no durable authority: it refuses any feature with a valid saved plan, a plan lock, a `pr.md`, a managed delivery state, git-tracked files, or an active or published delivery. The guard allowlists it independent of stage but still rejects shell metacharacters and non-helper executables; gating for registered, active, or published deliveries is unchanged. +`repair-state` is the explicit bounded recovery for an unregistered feature draft whose `plan.md` never passed through the helper, so it has no plan lock and no delivery state. Such a draft is inert outside an explicit Boatstack command. When selected, `repair-state` quarantines that directory out of `.product-loop/features/` into `/boatstack/quarantine//` — reversible, never a hard delete — and returns the workflow to `auto-plan`. It resolves the sole malformed candidate when `--feature` is omitted and refuses ambiguity. It acts only on a directory carrying no durable authority: it refuses any feature with a valid saved plan, a plan lock, a `pr.md`, managed delivery state, git-tracked files, or an active or published delivery. The **transactional mutation boundary** is the standing form of the same principle `repair-state` applies once: a supervisor that removes an actuator must still expose a bounded actuator capable of reaching every valid state, including reversing its own last move. When Boatstack promotes a managed artifact that spans files which must land together, the candidate bytes are submitted as a mutation set that the runtime confirms against per-file base hashes and a supervisor-authority token, writes atomically all-or-nothing, verifies after the write, and rolls back automatically on failure — recording a reversible receipt with per-file before/after hashes. A rejected mutation persists no identity, so a candidate recomputed against the current base and authority applies cleanly: refusal is fail-closed but never a deadlock. Plan activation promotes all five of its managed artifacts — the compiled `tasks.json`, `test-matrix.json`, `evidence.md`, `journey-oracles.json`, and the `plan.lock.json` — through a single mutation, so they land or fail together. @@ -44,13 +44,13 @@ The `SOURCE_PLAN` file is required from entry through completion of `BUILD`. Aft ## Irreversible-operation boundary -Every installed host routes supported shell and MCP events through Boatstack's immutable safety guard. High-confidence database, filesystem, Git-history, cloud-resource, and recovery destruction is always denied before execution. There is no prompt, approval reply, break-glass token, or in-session override. Source may be edited for review, but executable destructive capability blocks activation and gate progression until it is removed or moved to an operator-owned process. +Every installed host carries an inert engagement probe. With no verified active pre-publication delivery in the current worktree and branch, the probe exits silently before runtime loading or hydration and Boatstack applies no policy. During engagement, supported shell and MCP events pass through Boatstack's immutable safety guard. High-confidence database, filesystem, Git-history, cloud-resource, and recovery destruction is then denied before execution. There is no prompt, approval reply, break-glass token, or in-session override while engaged. After an external-write failure, preserve state and use only read-only diagnosis. Do not escalate privileges, broaden the target, or invent a reset. Use a transactional retry only when retry safety is demonstrated; otherwise stop and fix forward. Destructive recovery is operator-only outside Boatstack. See `irreversible-operation-boundary.md` for the classified operations and evaluation status. -Repository administration is not a delivery transition. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes do not enter `auto-plan` or `repair` unless the exact target branch belongs to an active managed delivery. Use the project-local `workspace-sync` helper for recoverable branch alignment: it fetches the exact remote source, preserves the original branch and dirty worktree under verified Git refs, updates the branch in its owning worktree, and verifies the final ref and clean status. A raw destructive-Git denial must return this one recovery action immediately without plan inspection or repository-wide discovery. +Repository administration is not Boatstack engagement. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes use ordinary repository tools and never enter `auto-plan`, `repair`, or `workspace-sync` unless the user explicitly invokes Boatstack during an active delivery. -Hooks are defense in depth rather than a complete sandbox. Protected systems still require least-privilege credentials, scoped service roles, backups, and service-side destructive approval. `run-preflight` reports `HOOK_GUARDED` for this default posture and never presents it as credential isolation. Repositories configured for `credential-enforced` mode block before delivery mutation unless a trusted external attestor supplies a current repository-only receipt; only that path reports `CREDENTIAL_ENFORCED`. A missing, drifted, or failing helper denies execution and requires reinstall or repair. Cursor's exact `MainThreadShellExec not initialized` error occurs before the Boatstack hook starts; preserve fail-closed behavior, reload the Cursor window, and retry before diagnosing the Boatstack installation. +During engagement, hooks are defense in depth rather than a complete sandbox. Protected systems still require least-privilege credentials, scoped service roles, backups, and service-side destructive approval. `run-preflight` reports `HOOK_GUARDED` for this posture and never presents it as credential isolation. Repositories configured for `credential-enforced` mode block before delivery mutation unless a trusted external attestor supplies a current repository-only receipt; only that path reports `CREDENTIAL_ENFORCED`. A missing, drifted, or failing helper denies an engaged operation and requires reinstall or repair. Outside engagement, the probe exits silently. Cursor's exact `MainThreadShellExec not initialized` error occurs before the Boatstack hook starts; reload the Cursor window and retry before diagnosing an engaged installation. ## User-facing response contract @@ -454,7 +454,7 @@ A terminal update receipt is consumed only while its target postcondition still `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. -The runtime bytes never travel through Git — only the guard's baked version path and the committed version pin do — so a teammate who pulls a merged version bump, or clones fresh, starts with the new pointers but an **empty**, gitignored, version-keyed shared slot. Rather than fail-close every such teammate until they re-install by hand, the safety guard **auto-hydrates** an absent slot: it runs the tag-pinned, `.sha256`-verified installer in a branch-free, slot-only `hydrate` mode, serialized clone-wide by an atomic `mkdir` lock (peers wait briefly for the slot to appear) and bounded by a timeout, then falls through to the existing missing/symlink/manifest/checksum gates. Hydration is strictly additive: those gates remain the sole authority for execution and stay fail-closed, so a disabled, timed-out, or failed hydration simply denies — now with the exact one-line self-heal command embedded in the message. The `hydrate-runtime` helper subcommand it invokes rewrites no committed generated file and requires no dedicated branch; it refuses to populate a slot whose identity disagrees with the worktree's pin, and since the installer downloads the exact pinned version first, running equals installed by construction (the runtime-cache re-hash-and-rollback is the backstop). This is a deliberate posture change — the guard runs a fetched installer on cold start — bounded by tag pinning, HTTPS, sidecar verification, the guard's own checksum re-verify before `exec`, the clone-wide lock, the timeout, and the `BOATSTACK_AUTO_HYDRATE=0` kill switch (with a `BOATSTACK_HYDRATE_COMMAND` override). It never becomes a new authority for execution. +The runtime bytes never travel through Git. A dormant engagement probe never loads or hydrates them. When a verified active lease exists, the guard may **auto-hydrate** an absent slot using the tag-pinned, checksum-verified installer in branch-free `hydrate` mode, serialized clone-wide and bounded by a timeout. The existing missing, symlink, manifest, and checksum gates remain fail-closed during engagement. Before a durable update attempt is created, Boatstack verifies the dedicated branch, base commit, repair classification, and current diff. Invalid workspace state consumes no retry budget. The update transaction then reuses one semantic ownership projection for admission, mutation, final verification, staging, and preview. Generated files must match their prepared bytes, host-hook files must preserve their non-Boatstack JSON, and `.cursorrules`, `CLAUDE.md`, and `GEMINI.md` must preserve everything outside their single Boatstack marker boundary. diff --git a/boatstack/runtime_cache.go b/boatstack/runtime_cache.go index 122532d..b3d4519 100644 --- a/boatstack/runtime_cache.go +++ b/boatstack/runtime_cache.go @@ -203,7 +203,7 @@ func installCommandRuntime(source, repo string, integrations map[string]Integrat } // installDetachedRuntime populates a detached repository's external shared-runtime -// slot from the running helper, so the developer-level ambient guard has a stable +// slot from the running helper, so the developer-level engagement probe has a stable // helper to invoke. Unlike installSharedRuntime it scopes the symlink check to the // external control root and does not consult any in-repo generated lock. It // requires WorkspaceFor(repo) to already resolve detached (attach writes the diff --git a/boatstack/runtime_cache_test.go b/boatstack/runtime_cache_test.go index 3445479..0c84286 100644 --- a/boatstack/runtime_cache_test.go +++ b/boatstack/runtime_cache_test.go @@ -20,6 +20,7 @@ func runtimeTestRepo(t *testing.T) string { if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true}); err != nil { t.Fatal(err) } + engageHookFixture(t, repo) return repo } diff --git a/boatstack/safety.go b/boatstack/safety.go index 90dedae..6efc5ed 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -712,9 +712,9 @@ func planningMarkdownPath(path string) bool { // and product baseline before it creates managed delivery state. // // The first raw write into the feature tree remains denied. Every planning -// artifact crosses the owned planning-write boundary, so an ambient hook cannot +// artifact crosses the owned planning-write boundary, so an engaged hook cannot // be bypassed by creating or editing controller state with a host writer. -// control-law: ambient-plans-never-activate-workflow-control +// control-law: draft-plans-never-activate-workflow-control // control-law: first-planning-write-uses-the-owned-channel func preActivationFinding(repo, attemptedPath string) (SafetyFinding, bool) { if !featureScopedPath(attemptedPath) { @@ -1218,20 +1218,11 @@ func supervisedToolIdentity(name string, input any) (string, string) { } func activeManagedOperationScope(repo string) (OperationScope, string, bool) { - active, err := ActiveManagedDeliveries(repo) - if err != nil || len(active) == 0 { + engagement := ResolveEngagement(repo, EngagementRequest{}) + if engagement.Mode != EngagementActive { return OperationScope{}, "", false } - branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current")) - for _, feature := range active { - state, loadErr := LoadDeliveryState(repo, feature) - if loadErr != nil || !stateMatchesBranch(state, branch) || state.ActiveIndex >= len(state.Slices) { - continue - } - slice := state.Slices[state.ActiveIndex] - return OperationScope{Feature: feature, Slice: slice.ID, Worktree: filepath.Base(repo), HeadBranch: branch}, state.PlanLockHash, true - } - return OperationScope{}, "", false + return OperationScope{Feature: engagement.Feature, Slice: engagement.Slice, Worktree: filepath.Base(repo), HeadBranch: engagement.Branch}, engagement.PlanLockHash, true } func operationRetryClassForTool(name string) string { @@ -1633,35 +1624,26 @@ 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 -// that runs for every repository the coding agent opens. It enforces Boatstack only -// on managed repositories — those with a detached attachment or an embedded install -// — and returns a plain allow (no Boatstack decision) everywhere else, so a -// user-level hook never controls an unattached repository. On a managed repository -// it delegates to the full HookDecision. -func AmbientHookDecision(options SafetyHookOptions) ([]byte, bool) { - host := strings.ToLower(strings.TrimSpace(options.Host)) - contract, supported := hookHostContracts[host] - repo, err := ResolveRepository(options.Repo) - if err != nil || !RepositoryIsManaged(repo) { - if supported { - value, _ := contract.allow() - return value, false - } - return nil, false - } +// EngagementProbeDecision is the developer-level entry point. Repository +// presence carries no authority; HookDecision applies policy only when the +// canonical resolver proves a current active delivery. +func EngagementProbeDecision(options SafetyHookOptions) ([]byte, bool) { return HookDecision(options) } func HookDecision(options SafetyHookOptions) ([]byte, bool) { host := strings.ToLower(strings.TrimSpace(options.Host)) contract, supported := hookHostContracts[host] + engagement := ResolveEngagement(options.Repo, EngagementRequest{}) + if engagement.Mode != EngagementActive { + return nil, false + } if !supported { finding := SafetyFinding{Category: "unsupported-host", Reason: "unknown host is denied by the fail-closed guard", Source: "hook"} value, _ := structuredHookDeny("", "codex", finding) return value, true } - repo, err := ResolveRepository(options.Repo) + repo, err := ResolveRepository(engagement.RepoRoot) if err != nil { finding := SafetyFinding{Category: "unresolved-repository", Reason: "repository identity could not be established", Source: "hook"} value, _ := contract.deny("", finding) diff --git a/boatstack/safety_test.go b/boatstack/safety_test.go index 056fcc3..1ebd2c6 100644 --- a/boatstack/safety_test.go +++ b/boatstack/safety_test.go @@ -24,6 +24,32 @@ func safetyTestRepo(t *testing.T) string { return repo } +func engageHookFixture(t *testing.T, repo string) { + t.Helper() + lockPath := filepath.Join(WorkspaceFor(repo).FeatureDir("hook-fixture"), "plan.lock.json") + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(lockPath, []byte("hook fixture lock\n"), 0o644); err != nil { + t.Fatal(err) + } + lockHash, err := SHA256File(lockPath) + if err != nil { + t.Fatal(err) + } + state := DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: "hook-fixture", PlanLockHash: lockHash, + ActiveIndex: 0, Slices: []DeliverySlice{{ID: "delivery", Status: StatusBuild, BaseBranch: "main", HeadBranch: "main"}}, + Mode: "NORMAL", RepairCounters: map[string]int{}, + } + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + if err := syncEngagementLease(repo, state); err != nil { + t.Fatal(err) + } +} + func writeValidSavedFeaturePlan(t *testing.T, repo, feature string) string { t.Helper() directory := filepath.Join(repo, ".product-loop", "features", feature) @@ -163,6 +189,7 @@ func TestExternalControlPlaneMCPMutationsAreDenied(t *testing.T) { // control-law: external-control-plane-effects-stay-operator-only func TestIncidentCommandsAreDeniedThroughCodexHook(t *testing.T) { repo := safetyTestRepo(t) + engageHookFixture(t, repo) script := filepath.Join(repo, "scripts", "dev_environment.py") if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil { t.Fatal(err) @@ -529,6 +556,7 @@ func TestInvokedRepositoryScriptIsInspected(t *testing.T) { func TestMCPAndMalformedEventsFailClosedWithoutEchoingSecrets(t *testing.T) { repo := safetyTestRepo(t) + engageHookFixture(t, repo) event := []byte(`{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__cloud__delete_database","tool_input":{"database":"primary","token":"secret-value"}}`) cursorOutput, cursorDenied := HookDecision(SafetyHookOptions{Host: "cursor", Repo: repo, Input: event}) if !cursorDenied || !strings.Contains(string(cursorOutput), `"permission":"deny"`) { @@ -552,6 +580,7 @@ func TestMCPAndMalformedEventsFailClosedWithoutEchoingSecrets(t *testing.T) { func TestHostContractsNormalizeCanonicalInputs(t *testing.T) { repo := safetyTestRepo(t) + engageHookFixture(t, repo) cases := []struct { name, host string input string @@ -591,6 +620,7 @@ func TestHostContractsNormalizeCanonicalInputs(t *testing.T) { func TestMalformedHostPayloadsDenyWithoutLeakingInput(t *testing.T) { repo := safetyTestRepo(t) + engageHookFixture(t, repo) for _, test := range []struct{ host, input, reason string }{ {"cursor", ``, "empty-input"}, {"cursor", `{`, "invalid-json"}, @@ -624,6 +654,7 @@ func TestMalformedHostPayloadsDenyWithoutLeakingInput(t *testing.T) { func TestCursorMalformedPayloadGuidesOneRetryThenExternalDiagnosis(t *testing.T) { repo := safetyTestRepo(t) + engageHookFixture(t, repo) output, denied := HookDecision(SafetyHookOptions{Host: "cursor", Repo: repo, Input: []byte(`{"hook_event_name":"beforeShellExecution"}`)}) if !denied { t.Fatal("missing Cursor command was allowed") @@ -638,6 +669,7 @@ func TestCursorMalformedPayloadGuidesOneRetryThenExternalDiagnosis(t *testing.T) func TestEveryHostUsesStableEmptyCommandReason(t *testing.T) { repo := safetyTestRepo(t) + engageHookFixture(t, repo) inputs := map[string]string{ "cursor": `{"hook_event_name":"beforeShellExecution","command":""}`, "claude": `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":""}}`, @@ -653,6 +685,7 @@ func TestEveryHostUsesStableEmptyCommandReason(t *testing.T) { func TestBlockedHookNeverCreatesSentinelSideEffect(t *testing.T) { repo := safetyTestRepo(t) + engageHookFixture(t, repo) sentinel := filepath.Join(repo, "sentinel") command := "rm -rf . && touch " + sentinel event, _ := json.Marshal(map[string]any{"command": command}) @@ -866,20 +899,20 @@ func TestUnactivatedApprovalAndPolicyStateRemainObservations(t *testing.T) { } } -func TestCursorPreToolUseAllowsNativeEditWithAmbientDraft(t *testing.T) { +func TestCursorPreToolUseIsSilentWithDormantDraft(t *testing.T) { repo := nextTestRepo(t) writeValidSavedFeaturePlan(t, repo, "cursor-feature") input := []byte(`{"hook_event_name":"preToolUse","tool_name":"Write","tool_input":{"file_path":"src/app.ts","content":"changed"}}`) for attempt := 0; attempt < 2; attempt++ { output, denied := HookDecision(SafetyHookOptions{Host: "cursor", Repo: repo, Input: input}) - if denied || !strings.Contains(string(output), `"permission":"allow"`) { - t.Fatalf("Cursor native edit was controlled by an ambient draft: %s", output) + if denied || len(output) != 0 { + t.Fatalf("Cursor dormant probe produced a policy effect: %s", output) } } } -// Relation conformance: every supported host reaches the same ambient boundary. -func TestAmbientDraftDoesNotControlNativeEditAcrossHostContracts(t *testing.T) { +// Relation conformance: every supported host reaches the same dormant boundary. +func TestDormantDraftDoesNotControlNativeEditAcrossHostContracts(t *testing.T) { repo := nextTestRepo(t) writeValidSavedFeaturePlan(t, repo, "host-conformance") tests := map[string][]byte{ @@ -891,8 +924,8 @@ func TestAmbientDraftDoesNotControlNativeEditAcrossHostContracts(t *testing.T) { for host, input := range tests { t.Run(host, func(t *testing.T) { output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: input}) - if denied { - t.Fatalf("%s native tool was controlled by an ambient draft: %s", host, output) + if denied || len(output) != 0 { + t.Fatalf("%s dormant probe produced a policy effect: %s", host, output) } }) } diff --git a/boatstack/skill_frontmatter_test.go b/boatstack/skill_frontmatter_test.go index 5c4dd0d..a80cb8c 100644 --- a/boatstack/skill_frontmatter_test.go +++ b/boatstack/skill_frontmatter_test.go @@ -58,7 +58,7 @@ func TestBoatstackRoutersHaveUnindentedTopLevelKeys(t *testing.T) { ".gemini/skills/boatstack/SKILL.md", } { frontmatter := skillFrontmatterForTest(t, bundle.Files[path]) - if !strings.Contains(frontmatter, "\ndescription: Use when") { + if !strings.Contains(frontmatter, "\ndescription:") { t.Errorf("%s description is not at column 1:\n%s", path, frontmatter) } for _, line := range strings.Split(frontmatter, "\n") { diff --git a/boatstack/statemap.go b/boatstack/statemap.go index 1d79a6b..9c30785 100644 --- a/boatstack/statemap.go +++ b/boatstack/statemap.go @@ -216,7 +216,7 @@ func StateRegistry() []StateEntry { // Written only by the hook's own deny path; per-worktree so one // worktree's denial history never escalates a sibling's denials. Name: "guard-denial-ledger", Class: ClassRuntimeWorktree, Partition: "per-worktree", Gitignored: true, GuardProtected: true, - OwnerVerbs: []string{"safety-hook", "ambient-safety-hook"}, + OwnerVerbs: []string{"safety-hook", "engagement-probe"}, Sample: func(w WorkspaceContext) (string, error) { base, err := w.GuardDir() if err != nil { diff --git a/boatstack/statemap_conformance_test.go b/boatstack/statemap_conformance_test.go index b435a41..b2f7d33 100644 --- a/boatstack/statemap_conformance_test.go +++ b/boatstack/statemap_conformance_test.go @@ -156,7 +156,7 @@ func TestGuardClassifiersMatchDeclaredOwnership(t *testing.T) { // pressure should flow toward WorkspaceContext/statemap, not new literals. func TestProductLoopLiteralsStayInDeclaredFiles(t *testing.T) { allowed := map[string]string{ - "activation.go": "controller-syntax", "delivery.go": "controller-syntax", "export.go": "controller-bundle", + "delivery.go": "controller-syntax", "export.go": "controller-bundle", "hooks.go": "embedded-installation", "launcher.go": "embedded-installation", "init.go": "embedded-installation", "installation_repair.go": "embedded-installation", "mutation_undo.go": "controller-syntax", diff --git a/release-notes/2026-08-10-explicit-engagement.md b/release-notes/2026-08-10-explicit-engagement.md new file mode 100644 index 0000000..d7f51bd --- /dev/null +++ b/release-notes/2026-08-10-explicit-engagement.md @@ -0,0 +1,2 @@ +### Boatstack stays dormant until engaged +Boatstack now applies workflow policy only to an explicit Boatstack command or a verified active delivery in the current worktree. Repository installation, saved state, and published delivery history no longer affect ordinary tools, while active deliveries remain supervised across host restarts until publication succeeds. From 49cc439f83ca997bc7769ed9e7bb138dcac8819d Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 10 Aug 2026 17:53:41 +0100 Subject: [PATCH 2/3] test: bind guard fixtures to current branch --- boatstack/safety_test.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/boatstack/safety_test.go b/boatstack/safety_test.go index 1ebd2c6..8a64e25 100644 --- a/boatstack/safety_test.go +++ b/boatstack/safety_test.go @@ -26,6 +26,10 @@ func safetyTestRepo(t *testing.T) string { func engageHookFixture(t *testing.T, repo string) { t.Helper() + branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current")) + if branch == "" { + t.Fatal("hook fixture repository has no current branch") + } lockPath := filepath.Join(WorkspaceFor(repo).FeatureDir("hook-fixture"), "plan.lock.json") if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { t.Fatal(err) @@ -39,7 +43,7 @@ func engageHookFixture(t *testing.T, repo string) { } state := DeliveryState{ SchemaVersion: deliveryStateSchemaVersion, Feature: "hook-fixture", PlanLockHash: lockHash, - ActiveIndex: 0, Slices: []DeliverySlice{{ID: "delivery", Status: StatusBuild, BaseBranch: "main", HeadBranch: "main"}}, + ActiveIndex: 0, Slices: []DeliverySlice{{ID: "delivery", Status: StatusBuild, BaseBranch: branch, HeadBranch: branch}}, Mode: "NORMAL", RepairCounters: map[string]int{}, } if err := saveDeliveryState(repo, state); err != nil { @@ -50,6 +54,17 @@ func engageHookFixture(t *testing.T, repo string) { } } +func TestEngageHookFixtureBindsCurrentNonMainBranch(t *testing.T) { + repo := safetyTestRepo(t) + runGit(t, repo, "switch", "-c", "fixture-topic") + engageHookFixture(t, repo) + + status := ResolveEngagement(repo, EngagementRequest{}) + if status.Mode != EngagementActive || status.Branch != "fixture-topic" { + t.Fatalf("non-main hook fixture engagement = %+v, want ACTIVE on fixture-topic", status) + } +} + func writeValidSavedFeaturePlan(t *testing.T, repo, feature string) string { t.Helper() directory := filepath.Join(repo, ".product-loop", "features", feature) From 3ea3bce4d6f4a83b342ba383b8e399635254bb76 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 10 Aug 2026 18:13:26 +0100 Subject: [PATCH 3/3] fix: wait for runtime publication before validation --- boatstack/hooks.go | 22 +++++++++-- boatstack/hooks_hydrate_test.go | 67 +++++++++++++++++++++++++++++++++ boatstack/launcher.go | 21 +++++++++-- boatstack/launcher_test.go | 66 ++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 6 deletions(-) diff --git a/boatstack/hooks.go b/boatstack/hooks.go index 9dc0c1c..d8f0e4f 100644 --- a/boatstack/hooks.go +++ b/boatstack/hooks.go @@ -186,6 +186,7 @@ esac HELPER="$COMMON/boatstack/runtimes/%s/%s/${OS_NAME}-${ARCH}/boatstack-helper${EXTENSION}" MANIFEST="$COMMON/boatstack/runtimes/%s/%s/${OS_NAME}-${ARCH}/runtime.lock.json" +HYDRATE_LOCK="$COMMON/boatstack/hydrate-%s.lock" # Auto-hydrate a missing or incomplete shared-runtime slot. A teammate who pulls # a version bump or clones fresh inherits the committed pointers (this guard's # baked version path) but an empty, gitignored slot, so without this the very next @@ -199,7 +200,6 @@ MANIFEST="$COMMON/boatstack/runtimes/%s/%s/${OS_NAME}-${ARCH}/runtime.lock.json" # window must join the lock and wait, not skip the block and deny a half-slot. if { [[ ! -x "$HELPER" || -L "$HELPER" || ! -f "$MANIFEST" || -L "$MANIFEST" ]]; } && [[ "${BOATSTACK_AUTO_HYDRATE:-1}" != "0" ]]; then mkdir -p "$COMMON/boatstack" 2>/dev/null || true - HYDRATE_LOCK="$COMMON/boatstack/hydrate-%s.lock" if mkdir "$HYDRATE_LOCK" 2>/dev/null; then # Double-checked locking. A slow guard can reach this mkdir only after the # winner already hydrated and released the lock, so its mkdir succeeds too. @@ -238,6 +238,16 @@ if { [[ ! -x "$HELPER" || -L "$HELPER" || ! -f "$MANIFEST" || -L "$MANIFEST" ]]; done fi fi +# Both paths can become visible while the writer still owns the lock. Treat +# lock release, not path existence, as the shared-runtime publication point. +for _ in $(seq 1 12); do + [[ -d "$HYDRATE_LOCK" ]] || break + sleep 1 +done +if [[ -d "$HYDRATE_LOCK" ]]; then + bs_deny "Boatstack shared runtime hydration did not complete; denying tool execution." + exit 2 +fi if [[ ! -x "$HELPER" ]]; then bs_deny "Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone:" echo " %s" >&2 @@ -334,6 +344,8 @@ $arch = switch ($architecture) { } $helper = Join-Path $common "boatstack/runtimes/%s/%s/windows-$arch/boatstack-helper.exe" $manifestPath = Join-Path $common "boatstack/runtimes/%s/%s/windows-$arch/runtime.lock.json" +$bsCommon = Join-Path $common "boatstack" +$hydrateLock = Join-Path $bsCommon "hydrate-%s.lock" # Auto-hydrate a missing shared-runtime slot (see the bash guard for rationale): # a teammate who pulls a version bump or clones fresh inherits the committed # pointers but an empty, gitignored slot. On an absent slot we run the tag-pinned, @@ -341,9 +353,7 @@ $manifestPath = Join-Path $common "boatstack/runtimes/%s/%s/windows-$arch/runtim # with an atomic directory lock. Purely additive: the gates below stay # authoritative and fail-closed if hydration is disabled, fails, or is skipped. if (((-not (Test-Path -LiteralPath $helper -PathType Leaf)) -or (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf))) -and $env:BOATSTACK_AUTO_HYDRATE -ne "0") { - $bsCommon = Join-Path $common "boatstack" New-Item -ItemType Directory -Path $bsCommon -Force -ErrorAction SilentlyContinue | Out-Null - $hydrateLock = Join-Path $bsCommon "hydrate-%s.lock" $acquired = $false try { New-Item -ItemType Directory -Path $hydrateLock -ErrorAction Stop | Out-Null; $acquired = $true } catch { $acquired = $false } if ($acquired) { @@ -371,6 +381,12 @@ if (((-not (Test-Path -LiteralPath $helper -PathType Leaf)) -or (-not (Test-Path } } } +# A ready-looking slot is not published until its writer releases the lock. +for ($i = 0; $i -lt 12 -and (Test-Path -LiteralPath $hydrateLock); $i++) { Start-Sleep -Seconds 1 } +if (Test-Path -LiteralPath $hydrateLock) { + Bs-Deny "Boatstack shared runtime hydration did not complete; denying tool execution." + exit 2 +} if (-not (Test-Path -LiteralPath $helper -PathType Leaf)) { Bs-Deny "Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone:" [Console]::Error.WriteLine(" %s") diff --git a/boatstack/hooks_hydrate_test.go b/boatstack/hooks_hydrate_test.go index 36f16ca..f64ad8a 100644 --- a/boatstack/hooks_hydrate_test.go +++ b/boatstack/hooks_hydrate_test.go @@ -331,3 +331,70 @@ func TestGuardAutoHydrationWaitsWhenSlotHalfWritten(t *testing.T) { t.Fatalf("guard skipped hydration on a half-written slot instead of waiting: err=%v output=%s", err, output) } } + +func TestGuardWaitsForHydrationPublicationBeforeManifestValidation(t *testing.T) { + requireBash(t) + repo := runtimeTestRepo(t) + binaryPath, manifestPath, err := sharedRuntimePaths(repo, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + helperBytes := []byte("#!/usr/bin/env bash\nexit 0\n") + manifestBytes := []byte(fmt.Sprintf(`{"binary_sha256":"%s"}`, SHA256Bytes(helperBytes))) + if err := os.RemoveAll(filepath.Dir(binaryPath)); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(binaryPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(binaryPath, helperBytes, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifestPath, []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + lockPath := hydrateLockPath(t, binaryPath) + if err := os.MkdirAll(lockPath, 0o755); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(lockPath) + + type guardResult struct { + output string + err error + } + result := make(chan guardResult, 1) + go func() { + output, runErr := runGuard(t, repo, "claude") + result <- guardResult{output: output, err: runErr} + }() + select { + case early := <-result: + t.Fatalf("guard validated before hydration publication: err=%v output=%s", early.err, early.output) + case <-time.After(300 * time.Millisecond): + } + + if err := os.WriteFile(manifestPath, manifestBytes, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Remove(lockPath); err != nil { + t.Fatal(err) + } + select { + case completed := <-result: + if completed.err != nil { + t.Fatalf("guard did not accept the published runtime: err=%v output=%s", completed.err, completed.output) + } + case <-time.After(15 * time.Second): + t.Fatal("guard did not resume after hydration publication") + } +} + +func TestPowerShellGuardWaitsForPublicationBeforeManifestValidation(t *testing.T) { + script := string(guardPowerShellScript()) + barrier := strings.Index(script, "for ($i = 0; $i -lt 12 -and (Test-Path -LiteralPath $hydrateLock); $i++)") + validation := strings.Index(script, "Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json") + if barrier < 0 || validation < 0 || barrier >= validation { + t.Fatalf("PowerShell guard lacks a pre-validation publication barrier: barrier=%d validation=%d", barrier, validation) + } +} diff --git a/boatstack/launcher.go b/boatstack/launcher.go index 778b15f..76ba3d7 100644 --- a/boatstack/launcher.go +++ b/boatstack/launcher.go @@ -44,6 +44,7 @@ PLATFORM="${OS_NAME}-${ARCH}" SLOT="$COMMON/boatstack/runtimes/$VERSION/$SOURCE_COMMIT/$PLATFORM" HELPER="$SLOT/boatstack-helper$EXTENSION" MANIFEST="$SLOT/runtime.lock.json" +HYDRATE_LOCK="$COMMON/boatstack/hydrate-$VERSION.lock" bs_slot_ready() { [[ -x "$HELPER" && ! -L "$HELPER" && -f "$MANIFEST" && ! -L "$MANIFEST" ]] @@ -51,7 +52,6 @@ bs_slot_ready() { if ! bs_slot_ready && [[ "${BOATSTACK_AUTO_HYDRATE:-1}" != "0" ]]; then mkdir -p "$COMMON/boatstack" 2>/dev/null || true - HYDRATE_LOCK="$COMMON/boatstack/hydrate-$VERSION.lock" if mkdir "$HYDRATE_LOCK" 2>/dev/null; then if ! bs_slot_ready; then ( @@ -75,6 +75,15 @@ if ! bs_slot_ready && [[ "${BOATSTACK_AUTO_HYDRATE:-1}" != "0" ]]; then fi fi +# Path visibility is not publication. A concurrent hydrator copies the helper +# and manifest before releasing this lock, so even a ready-looking slot must +# wait for that release before its manifest is parsed. +for _ in $(seq 1 12); do + [[ -d "$HYDRATE_LOCK" ]] || break + sleep 1 +done +[[ ! -d "$HYDRATE_LOCK" ]] || bs_fail "shared runtime hydration did not complete" + bs_slot_ready || bs_fail "the exact pinned shared runtime is missing or unsafe" manifest_value() { sed -n 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST" | head -n 1 @@ -127,6 +136,8 @@ $platform = "windows-$arch" $slot = Join-Path $common "boatstack/runtimes/$version/$sourceCommit/$platform" $helper = Join-Path $slot "boatstack-helper.exe" $manifestPath = Join-Path $slot "runtime.lock.json" +$boatstackRoot = Join-Path $common "boatstack" +$lockPath = Join-Path $boatstackRoot "hydrate-$version.lock" function Slot-Ready { (Test-Path -LiteralPath $helper -PathType Leaf) -and (Test-Path -LiteralPath $manifestPath -PathType Leaf) -and @@ -135,9 +146,7 @@ function Slot-Ready { } if ((-not (Slot-Ready)) -and ($env:BOATSTACK_AUTO_HYDRATE -ne "0")) { - $boatstackRoot = Join-Path $common "boatstack" New-Item -ItemType Directory -Force -Path $boatstackRoot | Out-Null - $lockPath = Join-Path $boatstackRoot "hydrate-$version.lock" $ownsLock = $false try { New-Item -ItemType Directory -ErrorAction Stop -Path $lockPath | Out-Null; $ownsLock = $true } catch {} if ($ownsLock) { @@ -156,6 +165,12 @@ if ((-not (Slot-Ready)) -and ($env:BOATSTACK_AUTO_HYDRATE -ne "0")) { } } +# File existence can become visible before the writer has finished publishing +# the manifest. Never parse shared runtime state until the writer releases the +# version-scoped hydration lock. +for ($i = 0; $i -lt 12 -and (Test-Path -LiteralPath $lockPath); $i++) { Start-Sleep -Seconds 1 } +if (Test-Path -LiteralPath $lockPath) { Fail-Activation "shared runtime hydration did not complete" } + if (-not (Slot-Ready)) { Fail-Activation "the exact pinned shared runtime is missing or unsafe" } try { $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json } catch { Fail-Activation "shared runtime manifest is malformed" } if ($manifest.boatstack_version -ne $version) { Fail-Activation "shared runtime version does not match the launcher pin" } diff --git a/boatstack/launcher_test.go b/boatstack/launcher_test.go index d5d9a77..0c932b3 100644 --- a/boatstack/launcher_test.go +++ b/boatstack/launcher_test.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "testing" + "time" ) func buildLauncherTestHelper(t *testing.T) string { @@ -148,6 +149,71 @@ func TestTrackedLauncherActivatesFreshLinkedWorktreeWithoutHookTrust(t *testing. } } +func TestTrackedLauncherWaitsForHydrationPublicationBeforeManifestValidation(t *testing.T) { + _, linked := launcherTestRepository(t) + binary, manifest, err := sharedRuntimePaths(linked, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + helperBytes, err := os.ReadFile(binary) + if err != nil { + t.Fatal(err) + } + manifestBytes, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Dir(binary)); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(binary), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(binary, helperBytes, 0o755); err != nil { + t.Fatal(err) + } + // A parseable but incomplete manifest makes premature validation + // deterministic: an unlocked reader reports a version mismatch immediately. + if err := os.WriteFile(manifest, []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + lockPath := hydrateLockPath(t, binary) + if err := os.MkdirAll(lockPath, 0o755); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(lockPath) + + type launcherResult struct { + output string + err error + } + result := make(chan launcherResult, 1) + go func() { + output, runErr := runLauncher(t, linked, "version") + result <- launcherResult{output: output, err: runErr} + }() + select { + case early := <-result: + t.Fatalf("launcher validated before hydration publication: err=%v output=%s", early.err, early.output) + case <-time.After(300 * time.Millisecond): + } + + if err := os.WriteFile(manifest, manifestBytes, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Remove(lockPath); err != nil { + t.Fatal(err) + } + select { + case completed := <-result: + if completed.err != nil || !strings.Contains(completed.output, Version) { + t.Fatalf("launcher did not accept the published runtime: err=%v output=%s", completed.err, completed.output) + } + case <-time.After(15 * time.Second): + t.Fatal("launcher did not resume after hydration publication") + } +} + // control-law: tracked-launcher-selects-only-the-pinned-runtime // Relation conformance for the detached failure mode: tracked launcher -> exact // hydrate operation -> Git-common bootstrap -> detached shared activation ->