From b0173551fc040bc0d5e5b5135c47bc8250156bac Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Sun, 19 Jul 2026 02:44:58 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ 17376b5e74eb --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 52 +++--- boatstack/changelog.go | 15 +- boatstack/changelog_test.go | 22 ++- boatstack/cmd/boatstack-helper/main.go | 53 +++++- boatstack/cmd/boatstack-helper/main_test.go | 158 +++++++++++++++++ boatstack/export.go | 4 +- boatstack/export_test.go | 5 + 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/references/workflow.md | 9 +- 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 | 8 +- 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 + .../2026-07-19-minimal-dated-changelogs.md | 6 + 26 files changed, 733 insertions(+), 115 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 create mode 100644 release-notes/2026-07-19-minimal-dated-changelogs.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f99318..20513fb 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/17376b5e74eb69d341c6df1b69251f2e07d47c4f/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..ea6e636 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 43583, - "estimated_tokens": 10896, + "characters": 43912, + "estimated_tokens": 10978, "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": "f679c93d361def0a2c8903c1b5714d4bf707add3eeeeec88370b8ceef936a8e0", "README.md": "a840004db57a129ecd361bb0421adfe02065d86751736dd91953c8a8b178e5ef", "assets/boatstack-journey.svg": "c1f7fe2741f5e9ca66bb3fe9b103e6364ba5acbca8b7a8054768ffd85cf325ea", "assets/boatstack-mark.svg": "ec96165583b15cfd446c27049d49217973f3e9b1defa5771cc08eec0c9542ce4", @@ -31,43 +31,45 @@ "boatstack/assets/templates/test-plan.md": "6db8a9f27dd171fb80222a501cae50eb051e7278c04703fa43b5ff86dd4d2df4", "boatstack/atomic_unix.go": "89f2723361591de2bb8bd22ce7e34ec529d3278509f0df78fd5c4a7d4140fbe9", "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", - "boatstack/changelog.go": "5f0b1e7a66586c58aad03bfea8126178316f82ff6583bd67d35d6fcf71a6f9bc", - "boatstack/changelog_test.go": "2cee0d580dc2fa0752ee58564d030961b20139008de132ee8abff9e6b6713e28", - "boatstack/cmd/boatstack-helper/main.go": "b4b8b43d80dbf60f15e18885ff8ee01637df6e47c0249938714d885163350539", + "boatstack/changelog.go": "c5e1f31440b44d61e6037ad27af0333540af3545d655e35819a0241cbbebd8ec", + "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", + "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_test.go": "9474985bf2aab4f2f14d37dcc3cedc6d7a74db39739fbbfd7615297e35b079b5", + "boatstack/export.go": "786858fbd1a0172371f310937d0f3244cf1f57246e916fdac581e3da8618e67a", + "boatstack/export_test.go": "fbe19b8627c9a66d6e5231aa0e275dccb28f85046a3f2f242a2ca306bb6c2665", "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/references/workflow.md": "a342d519f8420f34b9431c4624fc6f823034218eebb520e74d0cf5f4f3d5a382", "boatstack/release.go": "fa2ac926df89c90c5844e938a2e02d4b8dbbaefbf85bb7a1a89fc51690bea520", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/run.go": "a9afb239c5e2cb80f96dab594f0dc94852f2f16625b1de05c5110ee85f61cfaa", "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": "c418dfc92d3e0a38996162f4b4c120693817c93056be15dfa7828295011457eb", "docs/generated-files.md": "136422baf0c7fc2bd5100cfe0ebdb3d9d0705dfd7e7d54bf745dd1037e63492c", - "docs/getting-started.md": "c298c0d78054266099ae98232a4c29976fa4e176d4ace49b6a20a8c13c35bc51", - "docs/public-claims.json": "80ed7c175d3288ebe417dc1c4f9b6c60001c4a22453516f64d93c45534020cc3", + "docs/getting-started.md": "eacc814fdffdfa3c7d8052b7cd99a79c04da5c75d88d8b44f3fb68d9afec0316", + "docs/public-claims.json": "df3d78d6c2a9b20d3a6c20bbea1c3d5eb5d7fbedfad60e34cbb121498e9a7c84", "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": "f18205ea54ae03756bd5bb7513a48ac494b9ed6e5bce8015bd72fee7d8b46c42", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -119,13 +121,15 @@ "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-minimal-dated-changelogs.md": "6f525e5c5cf26cc72969a161bbdaeb4a5c1f14759c5985335e97e3d3f45b1daf", "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": "17376b5e74eb69d341c6df1b69251f2e07d47c4f", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/changelog.go b/boatstack/changelog.go index 84b6416..cff16fa 100644 --- a/boatstack/changelog.go +++ b/boatstack/changelog.go @@ -6,6 +6,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" ) const changelogPath = "CHANGELOG.md" @@ -20,6 +21,18 @@ var changelogCategories = map[string]bool{ "Maintenance": true, } +func isUnreleasedHeading(line string) bool { + if line == "## Unreleased" || line == "## [Unreleased]" { + return true + } + const prefix = "## [Unreleased] - " + if !strings.HasPrefix(line, prefix) { + return false + } + _, err := time.Parse("2006-01-02", strings.TrimPrefix(line, prefix)) + return err == nil +} + // changelogEntries returns the categorized bullets in the Unreleased section. // Historical release sections are intentionally ignored: the policy requires a // new reader-facing entry for the change being prepared, not rewritten history. @@ -31,7 +44,7 @@ func changelogEntries(value []byte) (map[string]int, error) { for _, rawLine := range strings.Split(strings.ReplaceAll(string(value), "\r\n", "\n"), "\n") { line := strings.TrimSpace(rawLine) if strings.HasPrefix(line, "## ") { - if line == "## Unreleased" { + if isUnreleasedHeading(line) { if foundUnreleased { return nil, fmt.Errorf("%s must contain exactly one ## Unreleased section", changelogPath) } diff --git a/boatstack/changelog_test.go b/boatstack/changelog_test.go index 747758f..3d72ad3 100644 --- a/boatstack/changelog_test.go +++ b/boatstack/changelog_test.go @@ -20,6 +20,24 @@ func writeChangelog(t *testing.T, repo, value string) { } } +func TestUnreleasedHeadingCompatibility(t *testing.T) { + for _, test := range []struct { + heading string + want bool + }{ + {heading: "## Unreleased", want: true}, + {heading: "## [Unreleased]", want: true}, + {heading: "## [Unreleased] - 2026-07-19", want: true}, + {heading: "## [Unreleased] - 2026-7-19", want: false}, + {heading: "## [Unreleased] - not-a-date", want: false}, + {heading: "## [1.0.0] - 2026-07-19", want: false}, + } { + if got := isUnreleasedHeading(test.heading); got != test.want { + t.Errorf("isUnreleasedHeading(%q) = %v, want %v", test.heading, got, test.want) + } + } +} + func TestChangelogPolicyAcceptsExistingAndFirstEntries(t *testing.T) { for _, test := range []struct { name string @@ -32,8 +50,8 @@ func TestChangelogPolicyAcceptsExistingAndFirstEntries(t *testing.T) { head: "# Changelog\n\n## Unreleased\n\n### Added\n\n- Existing capability.\n- New reader-visible capability.\n\n## 1.0.0\n\n- First release.\n", }, { - name: "first changelog", - head: "# Changelog\n\n## Unreleased\n\n### Maintenance\n\n- Document the supported delivery workflow.\n", + name: "first dated changelog", + head: "# Changelog\n\n## [Unreleased] - 2026-07-19\n\n### Maintenance\n\n- Document the supported delivery workflow.\n", }, { name: "existing legacy changelog adopts policy", 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..e9b8c30 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 @@ -238,7 +238,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "boatstack-run": "First run the read-only next-status --repo . --json. If SOURCE_PLAN_READY, execute auto-plan without Git preflight and pause at its normal decision or approval boundary. If NOT_STARTED, respond Start a Boatstack feature and ask the user to save exactly one host Plan-mode file, then run /auto-plan; do not fetch or require a feature branch. If FEATURE_COMPLETE, respond Feature complete with No action required without requiring a remote or fetching. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the project-local helper run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage, up to three complete automated repair-and-gate cycles for the active slice in this invocation. Stop immediately on an amendment, ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence and do not create durable autopilot state. Report the feature, active slice, stages completed during this invocation, completion or pause reason, repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", "auto-plan": "Discover exactly one saved Plan-mode file and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions.", "plan-gate": "Run check-plan read-only, present its fingerprint and all open decisions, and require explicit human approval. While plan approval is pending, the normal user action is the exact standalone reply a. Trim surrounding whitespace and match a case-insensitively; do not treat [a] or an a embedded in other text as approval. Continue accepting the full reply approve for compatibility, but do not advertise it in the user-facing response. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval with the resolved human, RFC3339 timestamp, and exact displayed fingerprint so it writes only approval.md. While pending, respond Ready for your approval and render the one next action as: Reply `a` to approve. After recording, respond Approved — ready to build and make entering the host execution mode and running /build the one next action. Remain in Plan mode; do not compile or request an early mode switch.", - "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and approval.md and run activate-plan before the first product-code edit. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under CHANGELOG.md ## Unreleased before recording test evidence; if the file is absent, create the documented skeleton and first categorized entry. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the approved boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", + "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and approval.md and run activate-plan before the first product-code edit. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under the current CHANGELOG.md Unreleased heading before recording test evidence. Use only the one allowed category needed by the entry and do not add empty category headings. If the file is absent, create the documented minimal skeleton with ## [Unreleased] - YYYY-MM-DD and the first categorized entry; if it exists, add to the current file without rewriting its history or layout. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the approved boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", "repair": "First run next-status --repo . --json. Repair requires an active managed delivery and the user's exact free-form requested change. If NOT_STARTED or SOURCE_PLAN_READY, respond No active delivery to repair and make /auto-plan the one next action; do not ask for repair details. If DRAFT_PLAN or APPROVED, route to the returned plan-gate or build operation because no managed delivery exists yet. If FEATURE_COMPLETE and the user supplied an exact correction, preserve the published evidence and plan a linked Boatstack feature with parent_delivery set to the completed feature; otherwise ask for the exact correction. Stop on BLOCKED or INVALID_STATE and preserve all artifacts. For an active delivery, read delivery-status, the current plan lock and acceptance criteria, the actual diff, and current receipts. Compare the exact request with approved intent. Classify it as implementation_repair, verification_repair, review_repair, requirement_amendment, or needs_clarification, then invoke record-change before any product edit. Same-intent repairs may proceed at the returned RESUME_STAGE; requirement amendments and ambiguous intent must stop for a concise plan amendment or one clarifying question. Never edit changes.md or managed delivery state directly. After a repair, reuse the existing /test-gate and /review-gate; do not invent repair-specific gates. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action because Boatstack's hook did not start; reserve reinstall guidance for Boatstack runtime integrity errors.", "test-gate": "Read delivery-status and test only the active delivery slice. Run the internal repository safety check, build a requirement-to-evidence matrix, and treat self-authored tests as evidence rather than the sole oracle. External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. Commit the intentional slice product and evidence diff, then record-delivery-gate for the active feature and slice with --gate test and PASS or PASS_WITH_GAPS. Editing evidence Markdown alone never passes the gate. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required non-destructive repair the one next action.", "review-gate": "Read delivery-status and review the active slice's actual diff against approved intent, invariants, risks, gaps, and test evidence. Run the internal repository safety check. Executable destructive capability is blocking even when ordinary tests pass. When workflow.maintain_changelog is true, verify the new CHANGELOG.md Unreleased entry accurately describes the actual reader-visible impact rather than commits, PR metadata, artifacts, or test commands. On pass invoke record-delivery-gate for the same feature and slice with --gate review; it must reject a changed or untested diff and a missing or malformed required changelog entry. Then respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", diff --git a/boatstack/export_test.go b/boatstack/export_test.go index 08beaa8..340bd5a 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -223,6 +223,11 @@ func TestExportAndDriftCheck(t *testing.T) { if !strings.Contains(build, "activate-plan") || !strings.Contains(build, "READY_FOR_BUILD") || !strings.Contains(build, "without activating") || strings.Contains(build, "compile-plan") { t.Fatal("build adapter must activate the Markdown plan exactly once") } + for _, expected := range []string{"## [Unreleased] - YYYY-MM-DD", "do not add empty category headings", "without rewriting its history or layout"} { + if !strings.Contains(build, expected) { + t.Fatalf("build adapter is missing changelog authoring rule %q", expected) + } + } if !strings.Contains(build, "delivery-status") || !strings.Contains(build, "push and PR mutation are never build tactics") { t.Fatal("build adapter does not confine work to the active delivery slice") } 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/references/workflow.md b/boatstack/references/workflow.md index 22a5168..12d1d2c 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -252,11 +252,14 @@ ad-hoc PR path. Scan operational changes and configured `high_risk_paths` before activation and after relevant edits. A dangerous capability may remain visible as source for review, but it cannot execute and blocks progression until removed or isolated behind the operator boundary. When `workflow.maintain_changelog` is enabled, update `CHANGELOG.md` before -recording test evidence. Add a concise bullet under `## Unreleased` and one of +recording test evidence. Add a concise bullet under the current Unreleased heading and one of `Added`, `Changed`, `Fixed`, `Removed`, `Security`, `Documentation`, or `Maintenance`. Describe the actual reader-visible outcome, not the commit, PR, -Boatstack artifacts, or test commands. If the file does not exist, create the -documented skeleton and its first entry. +Boatstack artifacts, or test commands. Add only the category needed by the entry; +do not add empty category headings. If the file does not exist, create the +documented minimal skeleton with `## [Unreleased] - YYYY-MM-DD` and its first +entry. If it exists, add to the current file without rewriting its released +history or existing layout. ### `BUILD -> TEST_GATE` 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..25ee94b 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 **10978 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 [`17376b5e74eb69d341c6df1b69251f2e07d47c4f`](https://github.com/operatorstack/intelligence-flow/tree/17376b5e74eb69d341c6df1b69251f2e07d47c4f/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..29a4251 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 @@ -184,21 +186,21 @@ This policy is optional and disabled by default. To require readable changelog e } ``` -If `CHANGELOG.md` already exists, keep its released history and add a new bullet under `## Unreleased`. Boatstack compares the branch with its merge base, so editing only an older release does not satisfy the policy. +If `CHANGELOG.md` already exists, keep its released history and layout and add a new bullet under its current Unreleased heading. Boatstack compares the branch with its merge base, so editing only an older release does not satisfy the policy. If the file does not exist, the first managed delivery slice or Boatstack-prepared ad-hoc PR creates it as user-owned Markdown: ```markdown # Changelog -## Unreleased +## [Unreleased] - 2026-07-19 ### Added - Explain the reader-visible capability or outcome. ``` -Supported categories are `Added`, `Changed`, `Fixed`, `Removed`, `Security`, `Documentation`, and `Maintenance`. Write about the actual outcome rather than commits, PR numbers, generated artifacts, or test commands. Every slice of a multi-PR delivery needs its own entry. Boatstack installation and update PRs are exempt. +Supported categories are `Added`, `Changed`, `Fixed`, `Removed`, `Security`, `Documentation`, and `Maintenance`. Include only categories that contain an entry; do not add empty category headings. Write about the actual outcome rather than commits, PR numbers, generated artifacts, or test commands. Every slice of a multi-PR delivery needs its own entry. Boatstack installation and update PRs are exempt. ## 5. Prove, review, and prepare the PR diff --git a/docs/public-claims.json b/docs/public-claims.json index 2899565..5487cfd 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "0633de4b4ed8385f180284ae21c10cc2e9761399", + "source_commit": "17376b5e74eb69d341c6df1b69251f2e07d47c4f", "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:17376b5e74eb69d341c6df1b69251f2e07d47c4f" }, { "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:17376b5e74eb69d341c6df1b69251f2e07d47c4f" }, { "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:17376b5e74eb69d341c6df1b69251f2e07d47c4f" }, { "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:17376b5e74eb69d341c6df1b69251f2e07d47c4f" }, { "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:17376b5e74eb69d341c6df1b69251f2e07d47c4f" }, { "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:17376b5e74eb69d341c6df1b69251f2e07d47c4f" }, { "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:17376b5e74eb69d341c6df1b69251f2e07d47c4f" }, { "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:17376b5e74eb69d341c6df1b69251f2e07d47c4f" }, { "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:17376b5e74eb69d341c6df1b69251f2e07d47c4f" }, { "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:17376b5e74eb69d341c6df1b69251f2e07d47c4f" }, { "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:17376b5e74eb69d341c6df1b69251f2e07d47c4f" } ] } 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..24e37b4 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": "17376b5e74eb69d341c6df1b69251f2e07d47c4f", "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. diff --git a/release-notes/2026-07-19-minimal-dated-changelogs.md b/release-notes/2026-07-19-minimal-dated-changelogs.md new file mode 100644 index 0000000..ca592fc --- /dev/null +++ b/release-notes/2026-07-19-minimal-dated-changelogs.md @@ -0,0 +1,6 @@ +### Keep new changelogs concise and dated + +New Boatstack-managed changelogs now start with a dated, versionless +`Unreleased` heading and include only categories that contain an entry. Existing +changelog layouts and released history remain unchanged when Boatstack adds the +next reader-visible note.