From fadefe8c33c7cdba8706cad203928671e285ef90 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Sun, 19 Jul 2026 02:42:05 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ 340059469cf1 --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 43 ++--- boatstack/cmd/boatstack-helper/main.go | 53 +++++- boatstack/cmd/boatstack-helper/main_test.go | 158 +++++++++++++++++ boatstack/export.go | 2 +- boatstack/hooks.go | 92 +++++++++- boatstack/hooks_test.go | 48 ++++- boatstack/init.go | 7 +- boatstack/init_test.go | 4 +- boatstack/planning.go | 41 ++++- boatstack/references/host-hook-contracts.md | 28 +++ .../irreversible-operation-boundary.md | 2 +- boatstack/runtime_cache_test.go | 25 +++ boatstack/safety.go | 167 +++++++++++++----- boatstack/safety_test.go | 59 ++++++- docs/evidence-engineered-coding.md | 4 +- docs/getting-started.md | 2 + docs/public-claims.json | 24 +-- docs/troubleshooting.md | 2 + labs/diagram-json/plan.lock.json | 2 +- ...26-07-19-cross-harness-hook-conformance.md | 9 + 21 files changed, 673 insertions(+), 101 deletions(-) create mode 100644 boatstack/cmd/boatstack-helper/main_test.go create mode 100644 boatstack/references/host-hook-contracts.md create mode 100644 release-notes/2026-07-19-cross-harness-hook-conformance.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f99318..5060213 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ # Contributing -Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/0633de4b4ed8385f180284ae21c10cc2e9761399/labs/12-product-engineering-loop). +Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/340059469cf115431f0f91c0a88beb34374d1e5a/labs/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/UPSTREAM.json b/UPSTREAM.json index cb8b0f2..b9474ae 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 43583, - "estimated_tokens": 10896, + "characters": 43677, + "estimated_tokens": 10920, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "c4a9a512b125fe3b19714b6ec09e9267480ea1653df2d9cf4f4c6173e17884c4", + "CONTRIBUTING.md": "79338a8a60c24719b8618b3ffe5b6112075d4035d9c2aec07a55aa50f09daa4c", "README.md": "a840004db57a129ecd361bb0421adfe02065d86751736dd91953c8a8b178e5ef", "assets/boatstack-journey.svg": "c1f7fe2741f5e9ca66bb3fe9b103e6364ba5acbca8b7a8054768ffd85cf325ea", "assets/boatstack-mark.svg": "ec96165583b15cfd446c27049d49217973f3e9b1defa5771cc08eec0c9542ce4", @@ -33,30 +33,32 @@ "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", "boatstack/changelog.go": "5f0b1e7a66586c58aad03bfea8126178316f82ff6583bd67d35d6fcf71a6f9bc", "boatstack/changelog_test.go": "2cee0d580dc2fa0752ee58564d030961b20139008de132ee8abff9e6b6713e28", - "boatstack/cmd/boatstack-helper/main.go": "b4b8b43d80dbf60f15e18885ff8ee01637df6e47c0249938714d885163350539", + "boatstack/cmd/boatstack-helper/main.go": "03637dce9ffd5e36355ec8c09bfdf4a9c34769808cdd7d330634e79b5bb9ac77", + "boatstack/cmd/boatstack-helper/main_test.go": "ff73003b6a5157202fa09ddf1129fb13c3d79702b2e05a8721ce5a11bf5ab779", "boatstack/delivery.go": "565bc8465252a798124a29874e1aeffff16e9027d1a86715e11b3e4bc0743bd2", "boatstack/delivery_test.go": "2545c26389d330a92c11e7b09fb2342101db5d09a825a2a9e81b4ea9eea9407b", - "boatstack/export.go": "42b438d5b1976836ddb9322a09fa7949c044e2cd2ed0e50d4961329c0184b6a8", + "boatstack/export.go": "9f4b2b48ea19368232a7d335fc23f632e8a1081736582dab61fd1ba6125850a3", "boatstack/export_test.go": "9474985bf2aab4f2f14d37dcc3cedc6d7a74db39739fbbfd7615297e35b079b5", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", - "boatstack/hooks.go": "1d5d8c4bf7e6e867c8bf07e391d86158347269f856647a5d256f345bbb8d3c96", - "boatstack/hooks_test.go": "c3f359416ea53f258d8747d0247381e8946efd4d4a5bcf072c4147f885475ad3", - "boatstack/init.go": "fb863a68a6cced5bd2dcf9cd8ca53f0dab7ef0309830269efd985ad8b8a92059", - "boatstack/init_test.go": "eb91d280077115c0dbe3d8472e1e4fa9794d5ae6e5a58afbb9c3f4f39aee80d8", + "boatstack/hooks.go": "ed131c526f434d7c3309a388b97f3d28df2a6bdf9bc7322499405d5174ab8518", + "boatstack/hooks_test.go": "6153759d463203607c50c418eb74017d2dc71118891f7567dec81fef9bd380d0", + "boatstack/init.go": "b0619af89a108442decb8848b670bb648e2a81726faa8e8aa22510a522248de5", + "boatstack/init_test.go": "fa48be69d07691fa7842224ec831e5f290504d8b6565263c9735d2dabd9b43b8", "boatstack/init_transaction.go": "112456c4e1c4db54c4137bcf4f7a9a9e63399a6f5971e9b3dc952d0c4b2aa4b6", "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", "boatstack/next.go": "9a9b3b9dde0a528991d2b6ac358638b249a791a4eb3e7a2656bd43b652a2c628", "boatstack/next_test.go": "1244fdf5e447646793ffdab81b8ff9403b849e9fa050a41bc9e7145bca08a0d2", "boatstack/plan.go": "2497e1d32d88a8e433bc5996b57d175bda4e6ba334cf2510efb7c84fd88a98f3", "boatstack/plan_test.go": "006cdc6681f77e579c5a0f709e30ede759c337132d4f2f5193b7b79b29bd7149", - "boatstack/planning.go": "3a26417a295e5dfc2b6dcac702287c04b6053e7b74215858a4ea11cf9f9dadfe", + "boatstack/planning.go": "1237ae552a72d5312c1ef61e30f430976f526d5473d91c7fbfcb9148eb7079b9", "boatstack/planning_test.go": "6b156a64182ed76d4c3d392b4c5a26abe5d8b81cea27ee12ac7c4627c827e186", "boatstack/pr.go": "ba537fc35cc8d2aaf584c5ba6de1194fdd1c4255dfb227a09478457752e2b9ad", "boatstack/pr_test.go": "ae23130d9d96cf214cf272227aa572dd09e2fe3adac22921949f541ba99ecb23", "boatstack/references/artifacts.md": "8f2e79b8af4bd3ad2e32aa3e8c07f10812555d0a3247e4991db75cc1cda395c3", "boatstack/references/failure-moves.md": "1d35126348d0b681976e8819665e16fd745fd65eca271492603cb80aab75bf49", - "boatstack/references/irreversible-operation-boundary.md": "2a695f2d7de95cfc8750f107bef9c86581712aa1f02e7233b69b850d8c2af42e", + "boatstack/references/host-hook-contracts.md": "1382213ad004389de6da5a03af43ec28ace6329c9e7a3f07148566cf2ea12727", + "boatstack/references/irreversible-operation-boundary.md": "631743991ace65977586e4537f8dd50f8ae88f8e16f27cf7baad93b2791a73df", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", "boatstack/references/workflow.md": "b460b647bdd5d0ce4f1bd1015434a9e6c3b8ef525920ed018fb26a22bbc3aa9a", "boatstack/release.go": "fa2ac926df89c90c5844e938a2e02d4b8dbbaefbf85bb7a1a89fc51690bea520", @@ -65,9 +67,9 @@ "boatstack/run_test.go": "fdc416f15e787b5c8401fc0f0e3aeb58b4891c828a8a869c8dce4e8f1541d809", "boatstack/runtime.go": "504b7b68e550ed178c2c12bb815aa21e67300cad65cf2311bb5e8b2860747f81", "boatstack/runtime_cache.go": "60c4eb0c7dde91d40d6ef3f05adc1a1282d17ff1ca12470d0a008454f7ca7489", - "boatstack/runtime_cache_test.go": "4cbca9dec7800d7df6e3ec0d74c7ecbe1508e5c5a288d863f35fc8d22986c308", - "boatstack/safety.go": "8bcce4c11094b4018093ac5fb5a5256cc5c63c825ddf7ec1a46b9d0098557d33", - "boatstack/safety_test.go": "03885d5a93f42b6adba6eef6bbef0680d81c0e1442fb72626a105da4862aecdf", + "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", + "boatstack/safety.go": "6a70f6886a6a599dea60eaeea568c4a8df0f36c8a2f68f1f2c0fc2e28554584b", + "boatstack/safety_test.go": "cf07f67354e96437e72ddbe107a4071a39689bf2e50df4017e28909194be59e4", "boatstack/skill_frontmatter.go": "85ba7389e5d41f52a716b912ffb6b03c81e575b0b049b2cc11f7cd45c3da93c4", "boatstack/skill_frontmatter_test.go": "ad92021f44f09d99949bda6670a8e350af85254dee087eeb86f3815dbdf7f500", "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4", @@ -78,14 +80,14 @@ "docs/account-recovery-walkthrough.md": "676034974594a7d1a559b24dbed31d7ccc429eb81404b203ca07bbdaa19ec3d3", "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", - "docs/evidence-engineered-coding.md": "e06074a2da10e6d1504bf19a3679095fa93d361fac8d66afc041eca44bac80d6", + "docs/evidence-engineered-coding.md": "3e51926eb4a23d1774b93c59e8cf2b670decabcb746bdd0db1dc2a790fa40f1f", "docs/generated-files.md": "136422baf0c7fc2bd5100cfe0ebdb3d9d0705dfd7e7d54bf745dd1037e63492c", - "docs/getting-started.md": "c298c0d78054266099ae98232a4c29976fa4e176d4ace49b6a20a8c13c35bc51", - "docs/public-claims.json": "80ed7c175d3288ebe417dc1c4f9b6c60001c4a22453516f64d93c45534020cc3", + "docs/getting-started.md": "d57fa781176321dd25f2917488133f912ec1ef1645a3c43f9630db0485049dd8", + "docs/public-claims.json": "44bb7a014da3f53d627d441d4473262c712fdc80a19d979b8c89f906a4078a6e", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "d65c66e323037bda5d45aacef5d48afa6bf93da55901378891d235aca3a5684f", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", - "docs/troubleshooting.md": "6b3dc443752e88d13ca4b279fa8024061cb9095cc6822f7b79fa93d74fe8717c", + "docs/troubleshooting.md": "744786a3df7ff3ff52cb3e64393d1858332f8d178108fdac7b773a75c5bcc313", "docs/validation-and-evidence.md": "e7d91ad49c6adb44784ebe7d94feceb6abd445857f9a0716f0758bf6b55296c5", "docs/why-these-steps.md": "80957af13979070e8b2f2a8db78ce06d20d152bbc8ec41c3a8003f28393f6369", "install.ps1": "960b2b20b406bb2878a560e9ace53fe7226bc510be6ee8466ce4e608beb5625a", @@ -95,7 +97,7 @@ "labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71", "labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39", - "labs/diagram-json/plan.lock.json": "ee380e4e0beedc036db86615fb112822614b10037d47a7bc1dbb45f142822f22", + "labs/diagram-json/plan.lock.json": "e28185904ad653ccf43858afaf5b17c07f773e22ed21b8d3f88987e753011a99", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -119,13 +121,14 @@ "release-notes/2026-07-18-safety-sql-boundaries.md": "32011ca3d02a371e8f3f2899ffb34df3af0843d18d25e7db95fbca32c2dcf18c", "release-notes/2026-07-18-stacked-bar-mark.md": "c4d5bd5fb89c280d7fba015384fd795fcb8c31ffe501078aa55a90cbcf66ba7b", "release-notes/2026-07-18-startup-recovery-routing.md": "c305a1c2b8347e1bc6d7fcbd4a8e629438cdeb4bcf6f181a7d6fc154a88f4318", + "release-notes/2026-07-19-cross-harness-hook-conformance.md": "15dc22cc3b534c6d0bd89f34cd69cabf94a3610d445a039ba111fd8bce727e68", "release-notes/2026-07-19-optional-repository-changelog.md": "7ae6bed436c269335ac5055e04438489451bef00836fecb3e5fcb06a18501853", "release-notes/2026-07-19-valid-skill-frontmatter.md": "a0a002f7891474d7d1eea49e3234cae5ddbd941158726b83555dc48474181a92" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "0633de4b4ed8385f180284ae21c10cc2e9761399", + "commit": "340059469cf115431f0f91c0a88beb34374d1e5a", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index f503959..bb592f7 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -7,6 +7,7 @@ import ( "os" "sort" "strings" + "time" boatstack "github.com/operatorstack/boatstack/boatstack" ) @@ -16,6 +17,32 @@ func fail(err error) int { return 1 } +const cursorHookSettleDelay = 50 * time.Millisecond + +var hookOutputSleep = time.Sleep + +func emitHookOutput(writer io.Writer, host string, value []byte) error { + if len(value) == 0 { + return nil + } + if _, err := writer.Write(value); err != nil { + return err + } + // Cursor currently has a host-side race that can lose output from compiled + // hooks which exit immediately. Keep the workaround isolated to its adapter. + if strings.EqualFold(strings.TrimSpace(host), "cursor") { + hookOutputSleep(cursorHookSettleDelay) + } + return nil +} + +func failSafetyHook(err error) int { + fmt.Fprintln(os.Stderr, "BLOCKED:", err) + // Claude Code and Codex both define exit 2 as a blocking PreToolUse error. + // Exit 1 is non-blocking in Claude and must never represent policy failure. + return 2 +} + func initCommand(arguments []string) int { flags := flag.NewFlagSet("init", flag.ContinueOnError) repo := flags.String("repo", ".", "repository to initialize") @@ -383,6 +410,22 @@ func doctorCommand(arguments []string) int { return fail(err) } fmt.Printf("PASS: Boatstack %s installation and generated adapters are healthy\n", boatstack.Version) + hosts, err := boatstack.DoctorHookHosts(*repo) + if err != nil { + return fail(err) + } + for _, host := range hosts { + name := strings.ToUpper(host) + fmt.Printf("HOST_CONTRACT_%s=PASS\nHOST_ACTIVATION_%s=OPERATOR_VERIFY\n", name, name) + switch host { + case "cursor": + fmt.Println("HOST_ACTIVATION_GUIDANCE_CURSOR=Reload Cursor and confirm both Boatstack hooks are enabled; Cursor hooks remain defense in depth.") + case "claude": + fmt.Println("HOST_ACTIVATION_GUIDANCE_CLAUDE=Reload Claude Code and use /hooks to confirm the Boatstack PreToolUse hook is active.") + case "codex": + fmt.Println("HOST_ACTIVATION_GUIDANCE_CODEX=Trust this linked worktree, use /hooks to review and trust the exact Boatstack hook, then start a new task.") + } + } if update, ok := boatstack.CachedUpdate(*repo); ok { fmt.Printf("UPDATE_AVAILABLE=%s\nRELEASE_URL=%s\n", update.LatestVersion, update.ReleaseURL) } @@ -401,8 +444,8 @@ func safetyHookCommand(arguments []string) int { input = nil } value, _ := boatstack.HookDecision(boatstack.SafetyHookOptions{Host: *host, Repo: *repo, Input: input}) - if len(value) > 0 { - fmt.Print(string(value)) + if err := emitHookOutput(os.Stdout, *host, value); err != nil { + return failSafetyHook(fmt.Errorf("cannot emit hook decision: %w", err)) } return 0 } @@ -419,11 +462,11 @@ func bootstrapSafetyHookCommand(arguments []string) int { input = nil } if err := boatstack.HydrateWorktree(*repo); err != nil { - return fail(fmt.Errorf("worktree runtime activation failed: %w", err)) + return failSafetyHook(fmt.Errorf("worktree runtime activation failed: %w", err)) } value, _ := boatstack.HookDecision(boatstack.SafetyHookOptions{Host: *host, Repo: *repo, Input: input}) - if len(value) > 0 { - fmt.Print(string(value)) + if err := emitHookOutput(os.Stdout, *host, value); err != nil { + return failSafetyHook(fmt.Errorf("cannot emit hook decision: %w", err)) } return 0 } diff --git a/boatstack/cmd/boatstack-helper/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go new file mode 100644 index 0000000..ddd2767 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/main_test.go @@ -0,0 +1,158 @@ +package main + +import ( + "bytes" + "context" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + boatstack "github.com/operatorstack/boatstack/boatstack" +) + +func TestEmitHookOutputSettlesOnlyCursor(t *testing.T) { + previous := hookOutputSleep + defer func() { hookOutputSleep = previous }() + delays := []time.Duration{} + hookOutputSleep = func(value time.Duration) { delays = append(delays, value) } + + var output bytes.Buffer + if err := emitHookOutput(&output, "cursor", []byte("{\"permission\":\"deny\"}\n")); err != nil { + t.Fatal(err) + } + if output.String() != "{\"permission\":\"deny\"}\n" { + t.Fatalf("unexpected output: %q", output.String()) + } + if len(delays) != 1 || delays[0] != cursorHookSettleDelay { + t.Fatalf("Cursor settle delays = %v", delays) + } + + delays = nil + if err := emitHookOutput(&output, "claude", []byte("{}\n")); err != nil { + t.Fatal(err) + } + if err := emitHookOutput(&output, "codex", nil); err != nil { + t.Fatal(err) + } + if len(delays) != 0 { + t.Fatalf("non-Cursor hosts unexpectedly settled: %v", delays) + } +} + +func TestBootstrapFailureUsesBlockingExitCode(t *testing.T) { + if code := bootstrapSafetyHookCommand([]string{"--host", "claude", "--repo", t.TempDir()}); code != 2 { + t.Fatalf("bootstrap failure exit = %d, want 2", code) + } +} + +func liveHostArguments(host, prompt string) []string { + switch host { + case "cursor": + return []string{"-p", "--force", prompt} + case "claude": + return []string{"-p", "--dangerously-skip-permissions", prompt} + case "codex": + return []string{"exec", "--dangerously-bypass-hook-trust", "--sandbox", "danger-full-access", prompt} + default: + return nil + } +} + +func liveHostRepo(t *testing.T, host, helper string) string { + t.Helper() + repo := t.TempDir() + if output, err := exec.Command("git", "-C", repo, "init", "-b", "main").CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, output) + } + config := boatstack.ProjectConfig{ + SchemaVersion: 1, + Project: boatstack.Project{Name: "live-hook-" + host, DefaultBranch: "main", Commands: map[string]string{"test": "true"}}, + Workflow: boatstack.Workflow{HumanPlanApproval: true, IndependentReviewForHighRisk: true, AllowPassWithGaps: true}, + Adapters: []string{host}, + } + value, err := boatstack.MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".boatstack-project.json"), value, 0o644); err != nil { + t.Fatal(err) + } + if err := boatstack.RunInit(boatstack.InitOptions{Repo: repo, BinaryPath: helper, IntegrationChoice: "core", Yes: true, Output: io.Discard}); err != nil { + t.Fatal(err) + } + scriptDirectory := filepath.Join(repo, "scripts") + if err := os.MkdirAll(scriptDirectory, 0o755); err != nil { + t.Fatal(err) + } + // The script itself has a harmless side effect. The policy marker in the + // comment makes Boatstack deny its invocation, so a sentinel proves whether + // the real host honored the hook without executing a destructive operation. + script := "#!/usr/bin/env bash\n# git reset --hard is operator-only\ntouch \"$1\"\n" + if err := os.WriteFile(filepath.Join(scriptDirectory, "blocked-probe.sh"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return repo +} + +func TestLiveHostHarnessesAreExplicitlyOptIn(t *testing.T) { + if os.Getenv("BOATSTACK_LIVE_HOST_TESTS") != "1" { + t.Skip("set BOATSTACK_LIVE_HOST_TESTS=1 to exercise installed host harnesses") + } + commands := map[string]string{"cursor": "cursor-agent", "claude": "claude", "codex": "codex"} + available := map[string]string{} + for host, command := range commands { + if path, err := exec.LookPath(command); err == nil { + available[host] = path + } + } + if len(available) == 0 { + t.Skip("no supported live host is installed") + } + helper := filepath.Join(t.TempDir(), "boatstack-helper") + if output, err := exec.Command("go", "build", "-o", helper, ".").CombinedOutput(); err != nil { + t.Fatalf("build live helper: %v: %s", err, output) + } + for _, host := range []string{"cursor", "claude", "codex"} { + path := available[host] + if path == "" { + continue + } + t.Run(host, func(t *testing.T) { + repo := liveHostRepo(t, host, helper) + safe := filepath.Join(repo, "safe-sentinel") + blocked := filepath.Join(repo, "blocked-sentinel") + prompts := []struct { + text string + sentinel string + wantExists bool + }{ + {"Use the shell exactly once to run: touch " + safe, safe, true}, + {"Use the shell exactly once to run: bash scripts/blocked-probe.sh " + blocked, blocked, false}, + } + for _, probe := range prompts { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + command := exec.CommandContext(ctx, path, liveHostArguments(host, probe.text)...) + command.Dir = repo + output, runErr := command.CombinedOutput() + cancel() + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("%s live probe timed out", host) + } + exists := false + if _, err := os.Stat(probe.sentinel); err == nil { + exists = true + } + if exists != probe.wantExists { + t.Fatalf("%s sentinel exists=%t want=%t; err=%v output=%s", host, exists, probe.wantExists, runErr, output) + } + if !probe.wantExists && !strings.Contains(string(output), "Boatstack denied") { + t.Fatalf("%s blocked probe lacked Boatstack evidence: err=%v output=%s", host, runErr, output) + } + } + }) + } +} diff --git a/boatstack/export.go b/boatstack/export.go index 975cc56..2af881b 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -197,7 +197,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte } } - for _, name := range []string{"workflow.md", "artifacts.md", "failure-moves.md", "irreversible-operation-boundary.md"} { + for _, name := range []string{"workflow.md", "artifacts.md", "failure-moves.md", "irreversible-operation-boundary.md", "host-hook-contracts.md"} { value, err := readCanonical("references/" + name) if err != nil { return ExportBundle{}, err diff --git a/boatstack/hooks.go b/boatstack/hooks.go index 7b4b7af..b68b540 100644 --- a/boatstack/hooks.go +++ b/boatstack/hooks.go @@ -124,6 +124,9 @@ exit $LASTEXITCODE } func hookCommand(host string) string { + if host == "claude" { + return `bash "${CLAUDE_PROJECT_DIR}/.product-loop/hooks/guard.sh" claude` + } return `bash "$(git rev-parse --show-toplevel)/.product-loop/hooks/guard.sh" ` + host } @@ -143,7 +146,7 @@ func desiredHostHookForEvent(host, event string) map[string]any { "matcher": "Bash|Shell|mcp__.*", "hooks": []any{map[string]any{ "type": "command", "command": hookCommand(host), - "timeout": 10, "statusMessage": "Checking Boatstack execution policy", + "shell": "bash", "timeout": 10, "statusMessage": "Checking Boatstack execution policy", }}, } case "codex": @@ -221,6 +224,79 @@ func containsBoatstackHook(value any) bool { return false } +func validateBoatstackHookEntry(host, event string, value any) error { + entry, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("%s Boatstack hook for %s is not an object", host, event) + } + allowedOuter := map[string]bool{} + if host == "cursor" { + allowedOuter = map[string]bool{"command": true, "commandWindows": true, "failClosed": true, "timeout": true} + if stringValue(entry["command"]) == "" { + return fmt.Errorf("cursor Boatstack hook for %s has no command", event) + } + if entry["failClosed"] != true { + return fmt.Errorf("cursor Boatstack hook for %s must fail closed", event) + } + } else { + allowedOuter = map[string]bool{"matcher": true, "hooks": true} + if stringValue(entry["matcher"]) == "" { + return fmt.Errorf("%s Boatstack hook for %s has no matcher", host, event) + } + handlers, ok := entry["hooks"].([]any) + if !ok || len(handlers) != 1 { + return fmt.Errorf("%s Boatstack hook for %s must contain exactly one command handler", host, event) + } + handler, ok := handlers[0].(map[string]any) + if !ok || handler["type"] != "command" || stringValue(handler["command"]) == "" { + return fmt.Errorf("%s Boatstack hook for %s has an invalid command handler", host, event) + } + allowedHandler := map[string]bool{"type": true, "command": true, "timeout": true, "statusMessage": true} + if host == "claude" { + allowedHandler["shell"] = true + // Older installed Boatstack fragments relied on Claude's Bash default. + // Accept that structurally during update preflight, while every newly + // generated hook pins the documented shell explicitly. + if handler["shell"] != nil && handler["shell"] != "bash" { + return fmt.Errorf("claude Boatstack hook for %s must use the bash harness", event) + } + } else { + allowedHandler["commandWindows"] = true + } + for key := range handler { + if !allowedHandler[key] { + return fmt.Errorf("%s Boatstack hook for %s contains unsupported handler field %s", host, event, key) + } + } + } + for key := range entry { + if !allowedOuter[key] { + return fmt.Errorf("%s Boatstack hook for %s contains unsupported field %s", host, event, key) + } + } + return nil +} + +func validateHostHookConfig(host string, config map[string]any) error { + if host == "cursor" && config["version"] != float64(1) { + return fmt.Errorf("Cursor hook config version must be 1") + } + hooks, ok := config["hooks"].(map[string]any) + if !ok { + return fmt.Errorf("host hook config has non-object hooks") + } + expected := map[string]bool{} + for _, event := range hookEvents(host) { + expected[event] = true + } + for event, entries := range hooks { + if containsBoatstackHook(entries) && !expected[event] { + return fmt.Errorf("%s Boatstack hook is attached to unsupported event %s", host, event) + } + } + return nil +} + func loadHookConfig(path string) (map[string]any, error) { value, err := os.ReadFile(path) if os.IsNotExist(err) { @@ -258,6 +334,9 @@ func mergeHostHook(config map[string]any, host string) error { for _, entry := range entries { if containsBoatstackHook(entry) { found++ + if err := validateBoatstackHookEntry(host, event, entry); err != nil { + return err + } continue } kept = append(kept, entry) @@ -271,7 +350,7 @@ func mergeHostHook(config map[string]any, host string) error { if host == "cursor" && config["version"] == nil { config["version"] = float64(1) } - return nil + return validateHostHookConfig(host, config) } func InstallHostHooks(repo string, adapters []string) error { @@ -361,6 +440,9 @@ func checkHostHooks(repo string, adapters []string, expectedForEvent func(host, if err != nil { return err } + if err := validateHostHookConfig(host, config); err != nil { + return err + } hooks, ok := config["hooks"].(map[string]any) if !ok { return fmt.Errorf("missing %s hooks in %s", host, path) @@ -370,6 +452,9 @@ func checkHostHooks(repo string, adapters []string, expectedForEvent func(host, if err != nil { return err } + if err := validateBoatstackHookEntry(host, event, expectedEntry); err != nil { + return err + } entries, ok := hooks[event].([]any) if !ok { return fmt.Errorf("missing %s safety event %s in %s", host, event, path) @@ -378,6 +463,9 @@ func checkHostHooks(repo string, adapters []string, expectedForEvent func(host, for _, entry := range entries { if containsBoatstackHook(entry) { matches++ + if err := validateBoatstackHookEntry(host, event, entry); err != nil { + return err + } current, _ := json.Marshal(entry) expected, _ := json.Marshal(expectedEntry) if string(current) != string(expected) { diff --git a/boatstack/hooks_test.go b/boatstack/hooks_test.go index 39fc142..fd28e82 100644 --- a/boatstack/hooks_test.go +++ b/boatstack/hooks_test.go @@ -50,7 +50,8 @@ func TestHostHookMergeRejectsAmbiguousCollisionAndDrift(t *testing.T) { t.Fatalf("expected ambiguous collision, got %v", err) } config = map[string]any{"hooks": map[string]any{"PreToolUse": []any{desiredHostHook("codex")}}} - config["hooks"].(map[string]any)["PreToolUse"].([]any)[0].(map[string]any)["timeout"] = float64(99) + handler := config["hooks"].(map[string]any)["PreToolUse"].([]any)[0].(map[string]any)["hooks"].([]any)[0].(map[string]any) + handler["timeout"] = float64(99) value, _ = MarshalJSON(config) if err := os.WriteFile(path, value, 0o644); err != nil { t.Fatal(err) @@ -60,6 +61,51 @@ func TestHostHookMergeRejectsAmbiguousCollisionAndDrift(t *testing.T) { } } +func TestGeneratedHostHooksSatisfyHarnessShapes(t *testing.T) { + for _, host := range []string{"cursor", "claude", "codex"} { + t.Run(host, func(t *testing.T) { + for _, event := range hookEvents(host) { + entry := desiredHostHookForEvent(host, event) + if err := validateBoatstackHookEntry(host, event, entry); err != nil { + t.Fatal(err) + } + if host == "claude" { + handler := entry["hooks"].([]any)[0].(map[string]any) + if handler["shell"] != "bash" || !strings.Contains(handler["command"].(string), "${CLAUDE_PROJECT_DIR}") { + t.Fatalf("Claude hook does not use its documented project Bash harness: %#v", handler) + } + } + if host == "codex" { + handler := entry["hooks"].([]any)[0].(map[string]any) + if stringValue(handler["commandWindows"]) == "" { + t.Fatalf("Codex hook lacks commandWindows: %#v", handler) + } + } + } + }) + } +} + +func TestHostHookValidationRejectsUnsupportedBoatstackFields(t *testing.T) { + entry := desiredHostHook("claude") + handler := entry["hooks"].([]any)[0].(map[string]any) + handler["commandWindows"] = "unsupported" + if err := validateBoatstackHookEntry("claude", "PreToolUse", entry); err == nil || !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("expected unsupported Claude field failure, got %v", err) + } +} + +func TestHostHookValidationRejectsWrongEventAndCursorVersion(t *testing.T) { + codex := map[string]any{"hooks": map[string]any{"PostToolUse": []any{desiredHostHook("codex")}}} + if err := validateHostHookConfig("codex", codex); err == nil || !strings.Contains(err.Error(), "unsupported event") { + t.Fatalf("expected wrong Codex event failure, got %v", err) + } + cursor := map[string]any{"version": float64(2), "hooks": map[string]any{"beforeShellExecution": []any{desiredHostHook("cursor")}}} + if err := validateHostHookConfig("cursor", cursor); err == nil || !strings.Contains(err.Error(), "version must be 1") { + t.Fatalf("expected Cursor version failure, got %v", err) + } +} + func TestInstalledHookValidationAllowsTemplateMigrationButRejectsUserDrift(t *testing.T) { repo := t.TempDir() adapters := []string{"claude"} diff --git a/boatstack/init.go b/boatstack/init.go index 94d9392..c4bb8a3 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -515,7 +515,8 @@ func RunInit(options InitOptions) (returnErr error) { } else { fmt.Fprintln(options.Output, "\nPASS: Boatstack core installed without a language runtime.") } - fmt.Fprintln(options.Output, "PASS: fail-closed irreversible-operation hooks verified for installed hosts.") + 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.") keys := sortedKeys(states) for _, name := range keys { @@ -557,6 +558,10 @@ func RunInit(options InitOptions) (returnErr error) { fmt.Fprintln(options.Output, "\nAfter that PR is merged, reload Cursor, Codex, or Claude and start in Plan mode:") fmt.Fprintln(options.Output, " 1. Describe the product change and save the host plan (use .product-loop/intake/ if the host exposes no path).") } + fmt.Fprintln(options.Output, "Host activation checklist:") + fmt.Fprintln(options.Output, " Cursor: reload the window and confirm beforeShellExecution and beforeMCPExecution are enabled; the hook is defense in depth.") + fmt.Fprintln(options.Output, " Claude Code: reload, then use /hooks to confirm the Boatstack PreToolUse hook is active (Bash is required).") + fmt.Fprintln(options.Output, " Codex: trust this exact linked-worktree path, use /hooks to review and trust the Boatstack hook, then start a new task.") fmt.Fprintln(options.Output, "Boatstack start command by host:") fmt.Fprintln(options.Output, " Claude Code: /auto-plan") fmt.Fprintln(options.Output, " Cursor: /auto-plan") diff --git a/boatstack/init_test.go b/boatstack/init_test.go index 3902931..db88cd5 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{"fail-closed irreversible-operation hooks verified", "least-privilege credentials"} { + for _, expected := range []string{"generated irreversible-operation hook contracts verified", "Host activation remains an operator-visible boundary", "least-privilege credentials"} { if !strings.Contains(output.String(), expected) { t.Fatalf("init output is missing safety guidance %q: %s", expected, output.String()) } @@ -61,6 +61,8 @@ func TestRuntimeFreeInit(t *testing.T) { "Cursor: /auto-plan", "Codex: $boatstack auto-plan", "reload Claude Code before using its slash commands", + "trust this exact linked-worktree path", + "beforeShellExecution and beforeMCPExecution", } { if !strings.Contains(output.String(), expected) { t.Fatalf("init output is missing host start guidance %q: %s", expected, output.String()) diff --git a/boatstack/planning.go b/boatstack/planning.go index d2cf224..de1cc8f 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -199,6 +199,12 @@ func Doctor(repoPath string) error { if err := CheckHostHooks(repo, config.Adapters); err != nil { return err } + 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") + } + } if err := verifyGeneratedRuntime(repo); err != nil { return err } @@ -206,17 +212,22 @@ func Doctor(repoPath string) error { return err } for _, host := range []string{"cursor", "claude", "codex"} { - if !contains(config.Adapters, host) { + if !contains(hostAdapters, host) { continue } - var input []byte + inputs := [][]byte{} if host == "cursor" { - input = []byte(`{"command":"git status --short"}`) + inputs = append(inputs, + []byte(`{"hook_event_name":"beforeShellExecution","command":"git status --short"}`), + []byte(`{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__status__read","tool_input":"{\"scope\":\"local\"}","command":"status-server"}`), + ) } else { - input = []byte(`{"tool_name":"Bash","tool_input":{"command":"git status --short"}}`) + inputs = append(inputs, []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`)) } - if _, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: input}); denied { - return fmt.Errorf("%s safety hook denied its read-only smoke event", host) + 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) + } } 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) @@ -225,6 +236,24 @@ func Doctor(repoPath string) error { return verifyLocalRuntime(repo) } +func DoctorHookHosts(repoPath string) ([]string, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return nil, err + } + config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + if err != nil { + return nil, err + } + hosts := []string{} + for _, host := range []string{"cursor", "claude", "codex"} { + if contains(normalizedAdapters(config.Adapters), host) { + hosts = append(hosts, host) + } + } + return hosts, nil +} + func DoctorRepairHint(err error) error { if err == nil { return nil diff --git a/boatstack/references/host-hook-contracts.md b/boatstack/references/host-hook-contracts.md new file mode 100644 index 0000000..e55065c --- /dev/null +++ b/boatstack/references/host-hook-contracts.md @@ -0,0 +1,28 @@ +# Host hook contracts + +Verified against the published host contracts on 2026-07-19. Recheck these +sources before changing a generated adapter or making a stronger enforcement +claim. + +| Host | Configuration and event | Blocking result | Activation boundary | +| --- | --- | --- | --- | +| Cursor | `.cursor/hooks.json`; `beforeShellExecution` and `beforeMCPExecution` | JSON `permission: "deny"`; generated entries set `failClosed: true` | Reload and host enablement are operator-visible. A current fast-exit output race is mitigated with a 50 ms settle delay, but Cursor remains defense in depth. | +| Claude Code | `.claude/settings.json`; `PreToolUse` | Exit 0 with `hookSpecificOutput.permissionDecision: "deny"`, or exit 2 with a secret-free error | The generated command explicitly uses Bash and `${CLAUDE_PROJECT_DIR}`. Reload and confirm with `/hooks`. | +| Codex | `.codex/hooks.json`; `PreToolUse` | Exit 0 with `hookSpecificOutput.permissionDecision: "deny"`, or exit 2 with a secret-free error | The project path and exact hook hash must be reviewed and trusted. A linked worktree is a distinct project path. Start a new task after trust changes. | + +Sources: + +- Cursor: https://cursor.com/docs/hooks +- Claude Code: https://code.claude.com/docs/en/hooks +- Codex: https://learn.chatgpt.com/docs/hooks + +## Compatibility policy + +The shared classifier accepts only the normalized tool name and input produced +by a host adapter. Current event names are authoritative. Missing event names +receive bounded legacy support only when the payload is unambiguous; unknown, +ambiguous, or malformed events deny without echoing tool arguments. + +Deterministic schema, payload, decision, exit-code, and hydration fixtures block +release. Live host checks are opt-in through `BOATSTACK_LIVE_HOST_TESTS=1` and +report host availability separately from deterministic conformance. diff --git a/boatstack/references/irreversible-operation-boundary.md b/boatstack/references/irreversible-operation-boundary.md index 42ad194..b36d0cb 100644 --- a/boatstack/references/irreversible-operation-boundary.md +++ b/boatstack/references/irreversible-operation-boundary.md @@ -28,7 +28,7 @@ Planning declares each external side effect with its kind, immutable target iden ## Defense in depth -Project hooks are deterministic interception, not a complete security sandbox. Host APIs can change, some tool surfaces may not expose hooks, and an agent can possess credentials broader than the repository intends. [Codex documents that current `PreToolUse` shell interception is incomplete](https://learn.chatgpt.com/docs/hooks); [Claude documents that command hooks run with the user's full permissions](https://code.claude.com/docs/en/hooks); Cursor documents pre-shell and pre-MCP interception but host enablement remains a separate trust boundary. Protected services still require least-privilege credentials, scoped roles, backups, and service-side approval for destructive administration. `doctor` verifies the generated fragments, launchers, helper version, and fail-closed smoke behavior; host trust or enablement remains an operator-visible assumption when the host does not expose that state. +Project hooks are deterministic interception, not a complete security sandbox. Host APIs can change, some tool surfaces may not expose hooks, and an agent can possess credentials broader than the repository intends. [Codex requires project-local hooks and their exact definitions to be trusted](https://learn.chatgpt.com/docs/hooks); [Claude documents that command hooks run with the user's full permissions](https://code.claude.com/docs/en/hooks); Cursor documents pre-shell and pre-MCP interception but host enablement remains a separate trust boundary, and a current fast-exit race can drop hook output. Protected services still require least-privilege credentials, scoped roles, backups, and service-side approval for destructive administration. `doctor` verifies generated contracts, launchers, helper version, and fail-closed smoke behavior, then reports host activation as an operator verification step rather than claiming that repository structure proves the host actually loaded the hook. ## Evaluation status diff --git a/boatstack/runtime_cache_test.go b/boatstack/runtime_cache_test.go index 5610649..3445479 100644 --- a/boatstack/runtime_cache_test.go +++ b/boatstack/runtime_cache_test.go @@ -37,6 +37,31 @@ func TestHydrateWorktreeRestoresIgnoredRuntime(t *testing.T) { } } +func TestHydrationPrecedesEveryHostContractDecision(t *testing.T) { + repo := runtimeTestRepo(t) + inputs := map[string][]byte{ + "cursor": []byte(`{"hook_event_name":"beforeShellExecution","command":"git status --short"}`), + "claude": []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`), + "codex": []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`), + } + for _, host := range []string{"cursor", "claude", "codex"} { + t.Run(host, func(t *testing.T) { + if err := os.RemoveAll(filepath.Join(repo, ".product-loop", "bin")); err != nil { + t.Fatal(err) + } + if err := HydrateWorktree(repo); err != nil { + t.Fatal(err) + } + if _, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: inputs[host]}); denied { + t.Fatalf("%s denied its safe first hydrated event", host) + } + if err := verifyLocalRuntime(repo); err != nil { + t.Fatalf("%s did not leave a verified local runtime: %v", host, err) + } + }) + } +} + func TestHydrateWorktreeIsSafeUnderConcurrentFirstUse(t *testing.T) { repo := runtimeTestRepo(t) if err := os.RemoveAll(filepath.Join(repo, ".product-loop", "bin")); err != nil { diff --git a/boatstack/safety.go b/boatstack/safety.go index ad82d16..34f81f1 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -276,33 +276,93 @@ func dedupeFindings(values []SafetyFinding) []SafetyFinding { return result } -func hookToolInput(host string, value []byte) (string, any, error) { +type hookHostContract struct { + decode func([]byte) (string, any, error) + allow func() ([]byte, error) + deny func(SafetyFinding) ([]byte, error) +} + +func decodeJSONObject(host string, value []byte) (map[string]any, error) { var event map[string]any if err := DecodeJSON("parse "+host+" hook event", "stdin", value, &event); err != nil { + return nil, err + } + return event, nil +} + +func cursorMCPInput(value any) (any, error) { + if text, ok := value.(string); ok { + if strings.TrimSpace(text) == "" { + return nil, fmt.Errorf("Cursor beforeMCPExecution tool_input is empty") + } + var decoded any + if err := json.Unmarshal([]byte(text), &decoded); err != nil { + return nil, fmt.Errorf("Cursor beforeMCPExecution tool_input is not valid JSON: %w", err) + } + return decoded, nil + } + if value == nil { + return nil, fmt.Errorf("Cursor beforeMCPExecution is missing tool_input") + } + return value, nil +} + +func decodeCursorHook(value []byte) (string, any, error) { + event, err := decodeJSONObject("cursor", value) + if err != nil { return "", nil, err } - if host == "cursor" { - command := stringValue(event["command"]) + eventName := stringValue(event["hook_event_name"]) + command := stringValue(event["command"]) + toolName := stringValue(event["tool_name"]) + toolInput := event["tool_input"] + + switch eventName { + case "beforeShellExecution": if command == "" { - if input, ok := event["tool_input"].(map[string]any); ok { - command = stringValue(input["command"]) + return "", nil, fmt.Errorf("Cursor beforeShellExecution is missing command") + } + return "Bash", map[string]any{"command": command}, nil + case "beforeMCPExecution": + if toolName == "" { + return "", nil, fmt.Errorf("Cursor beforeMCPExecution is missing tool_name") + } + input, inputErr := cursorMCPInput(toolInput) + if inputErr != nil { + return "", nil, inputErr + } + return toolName, input, nil + case "": + // Older Cursor builds omitted hook_event_name. Preserve only the two + // unambiguous shapes; an MCP transport command must never be classified + // as the requested tool operation. + if toolName != "" && toolInput != nil { + if command != "" { + return "", nil, fmt.Errorf("legacy Cursor hook input is ambiguous between shell and MCP execution") + } + input, inputErr := cursorMCPInput(toolInput) + if inputErr != nil { + return "", nil, inputErr } + return toolName, input, nil } - if command != "" { + if command != "" && toolName == "" { return "Bash", map[string]any{"command": command}, nil } - name := stringValue(event["tool_name"]) - if name == "" { - name = stringValue(event["server_name"]) + "_" + stringValue(event["method"]) - } - input := event["tool_input"] - if input == nil { - input = event["arguments"] - } - if strings.Trim(name, "_") == "" || input == nil { - return "", nil, fmt.Errorf("Cursor hook input has no command or MCP tool arguments") - } - return name, input, nil + return "", nil, fmt.Errorf("legacy Cursor hook input has no unambiguous shell command or MCP tool") + default: + return "", nil, fmt.Errorf("unsupported Cursor hook event %s", eventName) + } +} + +func decodePreToolUseHook(host string, value []byte) (string, any, error) { + event, err := decodeJSONObject(host, value) + if err != nil { + return "", nil, err + } + eventName := stringValue(event["hook_event_name"]) + if eventName != "" && eventName != "PreToolUse" { + return "", nil, fmt.Errorf("unsupported %s hook event %s", host, eventName) } name := stringValue(event["tool_name"]) input := event["tool_input"] @@ -312,6 +372,41 @@ func hookToolInput(host string, value []byte) (string, any, error) { return name, input, nil } +func structuredHookDeny(finding SafetyFinding) ([]byte, error) { + message := denialMessage(finding) + value, err := json.Marshal(map[string]any{ + "hookSpecificOutput": map[string]any{ + "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": message, + }, + }) + return append(value, '\n'), err +} + +var hookHostContracts = map[string]hookHostContract{ + "cursor": { + decode: decodeCursorHook, + allow: func() ([]byte, error) { + value, err := json.Marshal(map[string]any{"continue": true, "permission": "allow"}) + return append(value, '\n'), err + }, + deny: func(finding SafetyFinding) ([]byte, error) { + message := denialMessage(finding) + value, err := json.Marshal(map[string]any{ + "continue": true, "permission": "deny", "user_message": message, "agent_message": message, + }) + return append(value, '\n'), err + }, + }, + "claude": { + decode: func(value []byte) (string, any, error) { return decodePreToolUseHook("claude", value) }, + allow: func() ([]byte, error) { return nil, nil }, deny: structuredHookDeny, + }, + "codex": { + decode: func(value []byte) (string, any, error) { return decodePreToolUseHook("codex", value) }, + allow: func() ([]byte, error) { return nil, nil }, deny: structuredHookDeny, + }, +} + func denialMessage(finding SafetyFinding) string { if finding.Category == "workflow-state-invalid" { return "Boatstack denied publication because managed delivery state cannot be verified. Re-run the active Boatstack operation or repair the installation before publishing." @@ -327,51 +422,33 @@ func denialMessage(finding SafetyFinding) string { func HookDecision(options SafetyHookOptions) ([]byte, bool) { host := strings.ToLower(strings.TrimSpace(options.Host)) - if host != "cursor" && host != "claude" && host != "codex" { + contract, supported := hookHostContracts[host] + if !supported { finding := SafetyFinding{Category: "unsupported-host", Reason: "unknown host is denied by the fail-closed guard", Source: "hook"} - value, _ := hookDenyJSON("codex", finding) + value, _ := structuredHookDeny(finding) return value, true } repo, err := ResolveRepository(options.Repo) if err != nil { finding := SafetyFinding{Category: "unresolved-repository", Reason: "repository identity could not be established", Source: "hook"} - value, _ := hookDenyJSON(host, finding) + value, _ := contract.deny(finding) return value, true } - name, input, err := hookToolInput(host, options.Input) + name, input, err := contract.decode(options.Input) if err != nil { finding := SafetyFinding{Category: "malformed-tool-input", Reason: err.Error(), Source: "hook"} - value, _ := hookDenyJSON(host, finding) + value, _ := contract.deny(finding) return value, true } findings := ClassifyTool(repo, name, input) if len(findings) == 0 { - if host == "cursor" { - value, _ := json.Marshal(map[string]any{"continue": true, "permission": "allow"}) - return append(value, '\n'), false - } - return nil, false + value, _ := contract.allow() + return value, false } - value, _ := hookDenyJSON(host, findings[0]) + value, _ := contract.deny(findings[0]) return value, true } -func hookDenyJSON(host string, finding SafetyFinding) ([]byte, error) { - message := denialMessage(finding) - if host == "cursor" { - value, err := json.Marshal(map[string]any{ - "continue": true, "permission": "deny", "user_message": message, "agent_message": message, - }) - return append(value, '\n'), err - } - value, err := json.Marshal(map[string]any{ - "hookSpecificOutput": map[string]any{ - "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": message, - }, - }) - return append(value, '\n'), err -} - func operationalChangedFiles(repo string, highRisk []string, defaultBranch string) ([]string, error) { diffStart := "HEAD" if strings.TrimSpace(defaultBranch) != "" { diff --git a/boatstack/safety_test.go b/boatstack/safety_test.go index 3c01c75..6434274 100644 --- a/boatstack/safety_test.go +++ b/boatstack/safety_test.go @@ -156,13 +156,14 @@ func TestInvokedRepositoryScriptIsInspected(t *testing.T) { func TestMCPAndMalformedEventsFailClosedWithoutEchoingSecrets(t *testing.T) { repo := safetyTestRepo(t) - event := []byte(`{"tool_name":"mcp__cloud__delete_database","tool_input":{"database":"primary","token":"secret-value"}}`) + 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"`) { t.Fatalf("Cursor MCP deletion was not denied: %s", cursorOutput) } for _, host := range []string{"claude", "codex"} { - output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: event}) + preToolEvent := []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__cloud__delete_database","tool_input":{"database":"primary","token":"secret-value"}}`) + output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: preToolEvent}) if !denied || !strings.Contains(string(output), `"permissionDecision":"deny"`) { t.Fatalf("%s MCP deletion was not denied: %s", host, output) } @@ -176,6 +177,60 @@ func TestMCPAndMalformedEventsFailClosedWithoutEchoingSecrets(t *testing.T) { } } +func TestHostContractsNormalizeCanonicalInputs(t *testing.T) { + repo := safetyTestRepo(t) + cases := []struct { + name, host string + input string + denied bool + output string + }{ + {"cursor shell allow", "cursor", `{"hook_event_name":"beforeShellExecution","command":"git status --short"}`, false, `"permission":"allow"`}, + {"cursor shell deny", "cursor", `{"hook_event_name":"beforeShellExecution","command":"git reset --hard HEAD~1"}`, true, `"permission":"deny"`}, + {"cursor MCP object deny ignores transport command", "cursor", `{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__cloud__delete_database","tool_input":{"database":"primary"},"command":"docker"}`, true, `"permission":"deny"`}, + {"cursor MCP string deny ignores transport URL", "cursor", `{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__cloud__delete_database","tool_input":"{\"database\":\"primary\"}","url":"https://example.invalid/mcp"}`, true, `"permission":"deny"`}, + {"cursor legacy shell allow", "cursor", `{"command":"git status --short"}`, false, `"permission":"allow"`}, + {"cursor legacy ambiguous deny", "cursor", `{"command":"docker","tool_name":"mcp__status__read","tool_input":{}}`, true, `"permission":"deny"`}, + {"claude allow", "claude", `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, false, ""}, + {"claude deny", "claude", `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git reset --hard HEAD~1"}}`, true, `"permissionDecision":"deny"`}, + {"codex allow", "codex", `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, false, ""}, + {"codex deny", "codex", `{"hook_event_name":"PreToolUse","tool_name":"mcp__cloud__delete_database","tool_input":{"database":"primary"}}`, true, `"permissionDecision":"deny"`}, + {"wrong event deny", "codex", `{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, true, `"permissionDecision":"deny"`}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + output, denied := HookDecision(SafetyHookOptions{Host: test.host, Repo: repo, Input: []byte(test.input)}) + if denied != test.denied { + t.Fatalf("denied = %t, want %t; output=%s", denied, test.denied, output) + } + if test.output != "" && !strings.Contains(string(output), test.output) { + t.Fatalf("output %s does not contain %s", output, test.output) + } + if test.output == "" && len(output) != 0 { + t.Fatalf("expected empty allow output, got %s", output) + } + }) + } +} + +func TestMalformedHostPayloadsDenyWithoutLeakingInput(t *testing.T) { + repo := safetyTestRepo(t) + for _, test := range []struct{ host, input string }{ + {"cursor", `{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__cloud__delete_database","tool_input":"secret-not-json"}`}, + {"cursor", `{"hook_event_name":"unknown","command":"secret-command"}`}, + {"claude", `{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"secret-command"}}`}, + {"codex", `{"hook_event_name":"PreToolUse","tool_name":"Bash"}`}, + } { + output, denied := HookDecision(SafetyHookOptions{Host: test.host, Repo: repo, Input: []byte(test.input)}) + if !denied { + t.Fatalf("%s malformed payload was allowed", test.host) + } + if strings.Contains(string(output), "secret") { + t.Fatalf("%s denial leaked input: %s", test.host, output) + } + } +} + func TestBlockedHookNeverCreatesSentinelSideEffect(t *testing.T) { repo := safetyTestRepo(t) sentinel := filepath.Join(repo, "sentinel") diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index af8728f..a4c7a71 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -96,7 +96,7 @@ subject to acceptance criteria pass approval is current ``` -That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **10896 estimated tokens**, while host adapters point to one operation at a time. +That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **10920 estimated tokens**, while host adapters point to one operation at a time. ## Control appears at transitions @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`0633de4b4ed8385f180284ae21c10cc2e9761399`](https://github.com/operatorstack/intelligence-flow/tree/0633de4b4ed8385f180284ae21c10cc2e9761399/labs/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`340059469cf115431f0f91c0a88beb34374d1e5a`](https://github.com/operatorstack/intelligence-flow/tree/340059469cf115431f0f91c0a88beb34374d1e5a/labs/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/getting-started.md b/docs/getting-started.md index e4c48d7..68170c5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -50,6 +50,8 @@ Review and commit the paths printed by the installer. Merge this infrastructure The installer keeps a versioned, verified runtime under Git's common directory. A linked worktree still starts without the ignored `.product-loop/bin/` directory, but its first guarded Cursor, Codex, or Claude call restores that local runtime automatically before evaluating the original command. This performs no download and changes no tracked files. +Host activation is separate from runtime installation. Codex requires the exact linked-worktree project path and hook definition to be reviewed and trusted through `/hooks`; start a new task after trusting it. Claude Code requires Bash and exposes the active `PreToolUse` hook through `/hooks`. Cursor requires a window reload and enabled `beforeShellExecution` and `beforeMCPExecution` hooks. + Different Boatstack versions use separate cached runtimes, so an older worktree is not silently run with a newer helper. A separate clone has a different Git common directory and still needs one installer run. ## 2. Start with the idea diff --git a/docs/public-claims.json b/docs/public-claims.json index 2899565..de43517 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "0633de4b4ed8385f180284ae21c10cc2e9761399", + "source_commit": "340059469cf115431f0f91c0a88beb34374d1e5a", "statuses": ["verified", "observed", "still_being_evaluated"], "claims": [ { @@ -12,7 +12,7 @@ "readable_evidence": "why-these-steps.md#portable-workflow-and-state", "implementation": ["../boatstack/export.go", "../boatstack/references/artifacts.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go"], - "last_verified_version": "source:0633de4b4ed8385f180284ae21c10cc2e9761399" + "last_verified_version": "source:340059469cf115431f0f91c0a88beb34374d1e5a" }, { "id": "human-decisions", @@ -23,7 +23,7 @@ "readable_evidence": "why-these-steps.md#human-decisions", "implementation": ["../boatstack/references/workflow.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:0633de4b4ed8385f180284ae21c10cc2e9761399" + "last_verified_version": "source:340059469cf115431f0f91c0a88beb34374d1e5a" }, { "id": "validation-provenance", @@ -34,7 +34,7 @@ "readable_evidence": "why-these-steps.md#validation-provenance", "implementation": ["validation-and-evidence.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go"], - "last_verified_version": "source:0633de4b4ed8385f180284ae21c10cc2e9761399" + "last_verified_version": "source:340059469cf115431f0f91c0a88beb34374d1e5a" }, { "id": "irreversible-operations", @@ -46,7 +46,7 @@ "readable_evidence": "why-these-steps.md#irreversible-operations", "implementation": ["safety.md", "../boatstack/safety.go", "../boatstack/hooks.go"], "verification": ["../boatstack/safety_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:0633de4b4ed8385f180284ae21c10cc2e9761399" + "last_verified_version": "source:340059469cf115431f0f91c0a88beb34374d1e5a" }, { "id": "reviewer-ready-pr", @@ -57,7 +57,7 @@ "readable_evidence": "why-these-steps.md#reviewer-ready-pr", "implementation": ["../boatstack/pr.go", "getting-started.md"], "verification": ["../boatstack/pr_test.go"], - "last_verified_version": "source:0633de4b4ed8385f180284ae21c10cc2e9761399" + "last_verified_version": "source:340059469cf115431f0f91c0a88beb34374d1e5a" }, { "id": "phase-scoped-delivery", @@ -68,7 +68,7 @@ "readable_evidence": "why-these-steps.md#phase-scoped-delivery", "implementation": ["../boatstack/delivery.go", "../boatstack/safety.go", "../boatstack/hooks.go", "../boatstack/references/workflow.md"], "verification": ["../boatstack/delivery_test.go", "../boatstack/pr_test.go"], - "last_verified_version": "source:0633de4b4ed8385f180284ae21c10cc2e9761399" + "last_verified_version": "source:340059469cf115431f0f91c0a88beb34374d1e5a" }, { "id": "model-neutral-contract", @@ -79,7 +79,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:0633de4b4ed8385f180284ae21c10cc2e9761399" + "last_verified_version": "source:340059469cf115431f0f91c0a88beb34374d1e5a" }, { "id": "cross-model-failures", @@ -90,7 +90,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:0633de4b4ed8385f180284ae21c10cc2e9761399" + "last_verified_version": "source:340059469cf115431f0f91c0a88beb34374d1e5a" }, { "id": "lower-cost-outcomes", @@ -101,7 +101,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:0633de4b4ed8385f180284ae21c10cc2e9761399" + "last_verified_version": "source:340059469cf115431f0f91c0a88beb34374d1e5a" }, { "id": "git-worktree-activation", @@ -112,7 +112,7 @@ "readable_evidence": "why-these-steps.md#git-worktree-activation", "implementation": ["../boatstack/runtime_cache.go", "../boatstack/hooks.go"], "verification": ["../boatstack/runtime_cache_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:0633de4b4ed8385f180284ae21c10cc2e9761399" + "last_verified_version": "source:340059469cf115431f0f91c0a88beb34374d1e5a" }, { "id": "visible-updates", @@ -123,7 +123,7 @@ "readable_evidence": "why-these-steps.md#visible-updates", "implementation": ["../boatstack/update.go", "../boatstack/init.go"], "verification": ["../boatstack/update_test.go", "../boatstack/init_test.go", "../boatstack/export_test.go"], - "last_verified_version": "source:0633de4b4ed8385f180284ae21c10cc2e9761399" + "last_verified_version": "source:340059469cf115431f0f91c0a88beb34374d1e5a" } ] } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9e3939f..fa75aae 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -21,6 +21,8 @@ If a safe diagnostic was denied, keep the denial output and report the smallest The hook fails closed. In a linked worktree, the first guarded call should restore the ignored local helper from the verified repository-family cache. If Boatstack reports that the shared runtime is missing, run the official installer once from any checkout belonging to that Git clone, run `doctor`, and reload the coding host. Do not copy an executable without its verified runtime manifest. +`doctor` proves the generated host contract, not host activation. In Codex, trust the exact linked-worktree path, open `/hooks`, review and trust the current Boatstack hook hash, and start a new task. In Claude Code, reload and use `/hooks` to confirm the `PreToolUse` hook; Bash is required. In Cursor, reload the window and confirm both pre-execution hooks are enabled. Cursor hooks remain defense in depth because host-side output handling can change independently of Boatstack. + If the worktree expects a different Boatstack version or source commit, update or rebase its committed Boatstack infrastructure. Boatstack will not run a newer cached helper against an older worktree contract. ## Cursor reports `MainThreadShellExec not initialized` diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index a9dca3f..25a5c50 100644 --- a/labs/diagram-json/plan.lock.json +++ b/labs/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "labs/diagram-json/plan.md", "plan_sha256": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "schema_version": 1, - "source_commit": "0633de4b4ed8385f180284ae21c10cc2e9761399", + "source_commit": "340059469cf115431f0f91c0a88beb34374d1e5a", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-07-19-cross-harness-hook-conformance.md b/release-notes/2026-07-19-cross-harness-hook-conformance.md new file mode 100644 index 0000000..530fdd3 --- /dev/null +++ b/release-notes/2026-07-19-cross-harness-hook-conformance.md @@ -0,0 +1,9 @@ +### Enforce safety hooks consistently across supported hosts + +Boatstack now applies explicit Cursor, Claude Code, and Codex hook contracts over +the shared irreversible-operation policy. Cursor MCP calls are decoded without +mistaking transport fields for shell commands, enforcing runtime failures block +with each host's documented exit semantics, and Codex linked-worktree trust and +hook-review steps are surfaced during installation and diagnosis. Deterministic +contract and process tests cover configuration merging, runtime hydration, +decision schemas, and host-specific settle behavior.