diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cb35cbb..34ce0c4 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/4fee357eb535287be4b172b2af4c2e44939ce196/examples/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/15c2f1faa8f0a0e133dea89983ca08b3aec4787c/examples/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/README.md b/README.md
index 20d781e..d91b53b 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
Build freely. Prove it. Ship.
-Boatstack is **evidence-engineered coding**: a model-neutral coding node that turns product intent and repository context into an explicitly approved, tested, reviewable change. It does not prescribe the model, implementation technique, tools, or document structure. It governs what may be claimed, approved, or shipped. Its behavior is generated from [Intelligence Flow at `4fee357eb535287be4b172b2af4c2e44939ce196`](https://github.com/operatorstack/intelligence-flow/tree/4fee357eb535287be4b172b2af4c2e44939ce196/examples/12-product-engineering-loop).
+Boatstack is **evidence-engineered coding**: a model-neutral coding node that turns product intent and repository context into an explicitly approved, tested, reviewable change. It does not prescribe the model, implementation technique, tools, or document structure. It governs what may be claimed, approved, or shipped. Its behavior is generated from [Intelligence Flow at `15c2f1faa8f0a0e133dea89983ca08b3aec4787c`](https://github.com/operatorstack/intelligence-flow/tree/15c2f1faa8f0a0e133dea89983ca08b3aec4787c/examples/12-product-engineering-loop).
> **You are free in how you build. Only claims of completion require evidence.**
@@ -46,7 +46,7 @@ irm https://raw.githubusercontent.com/operatorstack/boatstack/main/install.ps1 |
The installer previews the generated paths, verifies the platform helper, asks about optional gstack and Spec Kit integrations, runs a smoke check, and prints the exact infrastructure commit commands. Boatstack core requires no Python, Node, Go, or package manager. The helper is repository-local and ignored; the adapters and policy are committed.
-**New here?** [Install and ship your first feature](docs/getting-started.md) · [Understand generated files](docs/generated-files.md) · [Troubleshoot](docs/troubleshooting.md) · [See the real account-recovery walkthrough](docs/account-recovery-walkthrough.md)
+**New here?** [Install and ship your first feature](docs/getting-started.md) · [Safety boundary](docs/safety.md) · [Understand generated files](docs/generated-files.md) · [Troubleshoot](docs/troubleshooting.md) · [See the real account-recovery walkthrough](docs/account-recovery-walkthrough.md)
**Go deeper:** [Validation and evidence](docs/validation-and-evidence.md) · [gstack and Spec Kit](#use-boatstack-with-gstack-and-github-spec-kit) · [Evidence-engineered coding](docs/evidence-engineered-coding.md)
@@ -58,6 +58,12 @@ idea -> Plan mode -> /auto-plan -> questions -> /plan-gate
At ship, Boatstack compiles the approved intent, actual committed diff, evidence, decisions, gaps, rollout, and rollback into a reviewer-ready title and body. It shows the exact preview first; GitHub changes only after `open PR` or `update PR`. For an existing branch, simply ask **“Use Boatstack to improve this PR.”** There is no extra `/pr-brief` command, and missing workflow evidence is labeled `NOT_VERIFIED` rather than invented.
+## Irreversible operations stop before execution
+
+Boatstack installs fail-closed project hooks for Cursor, Codex, and Claude. High-confidence database resets and drops, broad recursive deletion, destructive Git history changes, cloud-resource destruction, and backup removal are always denied—across ordinary agent tool calls, not only Boatstack commands. There is no in-session bypass. After an external-write failure, the agent must preserve state, diagnose read-only, and use only a proven transactional retry or fix-forward repair.
+
+The hooks are defense in depth, not a security sandbox. Keep least-privilege credentials, service-side destructive approval, and backups. See [Safety boundary](docs/safety.md) for the exact policy, host limitations, and evaluation status.
+
## Plan first, then auto-plan
Start with ordinary product intent **inside Cursor, Codex, or Claude Plan mode**:
@@ -315,7 +321,7 @@ Read the [research and design record](docs/research-and-design.md) and [corpus a
## Context has a budget
-The three canonical runtime references currently total approximately **6039 estimated tokens** using `ceil(characters / 4)`. That is a stable compactness signal, not provider billing. Host adapters stay thin and load the operation-specific slice on demand.
+The three canonical runtime references currently total approximately **7645 estimated tokens** using `ceil(characters / 4)`. That is a stable compactness signal, not provider billing. Host adapters stay thin and load the operation-specific slice on demand.
## Status
diff --git a/UPSTREAM.json b/UPSTREAM.json
index 8de04b1..2608a6c 100644
--- a/UPSTREAM.json
+++ b/UPSTREAM.json
@@ -1,20 +1,21 @@
{
"canonical_context": {
- "characters": 24156,
- "estimated_tokens": 6039,
+ "characters": 30577,
+ "estimated_tokens": 7645,
"estimator": "ceil(total characters / 4); compactness signal, not provider billing",
"files": [
"product-engineering-loop/references/workflow.md",
"product-engineering-loop/references/artifacts.md",
- "product-engineering-loop/references/failure-moves.md"
+ "product-engineering-loop/references/failure-moves.md",
+ "product-engineering-loop/references/irreversible-operation-boundary.md"
]
},
"files": {
".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957",
- "CONTRIBUTING.md": "89ac3ca1d81d4a10e433fcf3246413ae4856f47ae9850471717d75623f82914f",
- "README.md": "ad6943d05ba387efcfefc99a2f847a2fd45b4d035766e56b69ad85ac70bff866",
+ "CONTRIBUTING.md": "900d51e37210819f6be849df9544a18d50f68470815d69365b9bfa0a285975c8",
+ "README.md": "cb9fe8c1a9aa9918b1578a78386c712ac70181337db11f685742f8a943c9eb69",
"assets/boatstack-mark.svg": "c46e935f06fcfde3b37abfd579c1963b765b2337a0fa993f9538c9b652297e39",
- "boatstack/SKILL.md": "ca9f6119c85dc178f7a98bc4dc8fa0b66f1f6d00465381f5489272d7413ea6bc",
+ "boatstack/SKILL.md": "4d80c39521763831655df0f98224769e0bf3caf535c0751ecf96c33bc33039e5",
"boatstack/agents/openai.yaml": "68a30a60859556c5a26e16d184594ca243a6043d99c8cf7d66b5dd6d50a93cd1",
"boatstack/assets/templates/adr.md": "c577a3c1c1319061f61deb053597e6e853657022185fe28b8f733327e2a78565",
"boatstack/assets/templates/approval.md": "74b0b816703a6dce3c96c8f95f981af910b020b6908e7f76cf5630778637e9f5",
@@ -23,45 +24,53 @@
"boatstack/assets/templates/gaps.md": "911cc2f086104d35071b952950c2ec44258641419f10b2355c594f33eb492cbe",
"boatstack/assets/templates/move.md": "91bfd9a9b9426ac023eb88fd19f4f638190481c1855f1239acc73830528e50f0",
"boatstack/assets/templates/plan-lock.json": "a51e17bb74aa7cd95daaa70fab646a20374ff4bc1d63468d61c5119da61e930f",
- "boatstack/assets/templates/plan.md": "358216e38d41f930c23c526741b718f30100c0071c8373a0c8b765d0e59f3fdf",
+ "boatstack/assets/templates/plan.md": "3c573cdee094cf8fb134f9ea72d103a1f84c6b561e0cc2925fce16baec5574ec",
"boatstack/assets/templates/questions.md": "5875bbfc32d5a1b326c2a48da7449bb90c87f462a4c3a862173247e5f7ea6415",
"boatstack/assets/templates/test-plan.md": "6db8a9f27dd171fb80222a501cae50eb051e7278c04703fa43b5ff86dd4d2df4",
"boatstack/atomic_unix.go": "89f2723361591de2bb8bd22ce7e34ec529d3278509f0df78fd5c4a7d4140fbe9",
"boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f",
- "boatstack/cmd/boatstack-helper/main.go": "8f63ed355fd9e28d57b04d744ce1369865d875047db0bdfc2add5ea60987a500",
- "boatstack/export.go": "f09b39643eba5b96b5f53b5f838a07cd78186524db667319a0f55d9d98b0b6dd",
- "boatstack/export_test.go": "2298a48972b30c072005722572d4b45822cad389839834f14629fff3ec43b6f8",
+ "boatstack/cmd/boatstack-helper/main.go": "7e3039dbae40a98666709583c42ca52fc94570b6cfebf16508c100d6f9593144",
+ "boatstack/export.go": "bfc8971516addb67cbc27cfce44180e4b62db2b3dc588634d7d5b8f8762d9d1c",
+ "boatstack/export_test.go": "dd3c2ea58f1ba5591ac21c7c161c730a8d0ac11cbca0148d7f6d505a6a8d0e4b",
"boatstack/go.mod": "daf262a00abfe961d8ca266d4b26eea09a6aee73e4c53baaa537a809eaef59f6",
- "boatstack/init.go": "40e62f3502fe704e98dfbb0017e8869fd6aed3011d508a8448d882fe9ed6b52c",
- "boatstack/init_test.go": "282451f7abd03c32c536512568f0cfcb858754835523d16706ac1de4f5fc6419",
+ "boatstack/hooks.go": "3030ca262a39b5bbef8509bb1395b3b9635719dd9ce0b196f72c596922509e92",
+ "boatstack/hooks_test.go": "5b8852e6176d96315c983f261d8503b57298063eb251283088eb103e42d7ec0d",
+ "boatstack/init.go": "90d38fb666ac8bbdf3c3f17f8367d3542963477a1eb67a4f1ffcc83dad01a7a6",
+ "boatstack/init_test.go": "5a65a7d8243e615ef47f797680a3b508a15a7b638e43c857ac74fb139cfded58",
"boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c",
- "boatstack/plan.go": "8250ee93f91b4dd60e3788636ab79f32c626ace6daef6677fdc6338858f89a52",
- "boatstack/plan_test.go": "f95ce7a38276f957064ee83901566c84ebf3eec0683e81794f823dc446b2ac54",
- "boatstack/planning.go": "d8b0b9842beb37392f0993f273849d45edd9564c1b17afe78d418a8c9c0d8f06",
+ "boatstack/plan.go": "519fe7a782c0384d62fda228c58145d36e01a3941691b1839a2d1476528c27b7",
+ "boatstack/plan_test.go": "006cdc6681f77e579c5a0f709e30ede759c337132d4f2f5193b7b79b29bd7149",
+ "boatstack/planning.go": "a4ff58547b6b880742d213c3cebccb2d7288a622b6c392c31f7dba827af71568",
"boatstack/planning_test.go": "4662908c1ec063aa8ef6f91db52247864303d9b91ef2363a8f68b41082fe383f",
- "boatstack/pr.go": "3b9c924b5a149230b28d98a849c85788d4ee20c6a8a4af8f83c6aa74b01f73cf",
- "boatstack/pr_test.go": "f03d8d4cbb879f6c1cfe4b9daa240bda75b737490540930085cdf719a063701f",
- "boatstack/references/artifacts.md": "fc6438b43a6de998fa20da91f1703248ca0b2707b83e4d65898eab9a80cb9aeb",
- "boatstack/references/failure-moves.md": "2d7d3988c70718e9cc02104f9899a00208173e2f654d1046edd22079f4d46f41",
+ "boatstack/pr.go": "6fbfd1e673c55e8358090693b20edb9bc6e8efb8913de9ffcaa5143ca24f0947",
+ "boatstack/pr_test.go": "f200a3a860e3da22798ec17a8eaf335724885b09d48b36bf0c350acdd3cc3ab7",
+ "boatstack/references/artifacts.md": "0a72961aa7a942056f3185417f545d205a3ce21856e602e367141660298c9410",
+ "boatstack/references/failure-moves.md": "5ac4bbc279a1f7c2b420c15b0f9bc73fb15c37c8f2ec08c45e1acd3aae46b75a",
+ "boatstack/references/irreversible-operation-boundary.md": "2a695f2d7de95cfc8750f107bef9c86581712aa1f02e7233b69b850d8c2af42e",
"boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae",
- "boatstack/references/workflow.md": "84c9a244d8d8564d4dee1a8c4b38d7bc41685b395dcdebab26233c591b2a2dd2",
+ "boatstack/references/workflow.md": "113bf1f207eaa019223462b85faac380d7c43c3217494845e8552f7707f5e645",
"boatstack/runtime.go": "b988d57ec14e15fc6a57949a995879fc0e0d6bfa9a7b62935e7754df0b85d87a",
- "boatstack/testdata/reviewer-pr-body.md": "7cf83e5deb07bc1d145266820afd7f58e3d3ec6b8d3ca1eb2f9b353b36925c2e",
+ "boatstack/safety.go": "fbf30c34642db6ac18e0e15abbf78cbcd9177cc7aa678b44b4eaabc0202f5bd5",
+ "boatstack/safety_test.go": "62375fd640d543ab8875c7b31fd935ac7f5385830f625123f44508e629b4ff08",
+ "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4",
+ "boatstack/testdata/safety/safe_apply.py.txt": "c9ec7fb932cf21b6aa8df597c4d4c54d6ec65e796240e49118d699f583383975",
+ "boatstack/testdata/safety/unsafe_apply.py.txt": "42db1751865cc15c4dd69a03146b5deca8f21f916d258e433b27bbef5f884ab1",
"docs/account-recovery-walkthrough.md": "912edec85d930750c044bcd6117df9d03491a8c91f139af3ee82ab853452f1b7",
"docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6",
"docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79",
- "docs/evidence-engineered-coding.md": "2249eab16ea28543f077935ef45b9ef336aeeedfbd9fbca89bc537808a67cc40",
- "docs/generated-files.md": "6670e6f607ac8a7a4a7201429a944e4fccdbc40c27f0909430aea93c12f5eaa1",
- "docs/getting-started.md": "bd943f6e965e2f4fc9be5348cc76574d5d0926f2b18d9d93697155d5f5f9c690",
- "docs/research-and-design.md": "84e0eac2b59843c1e9b7a9d8c60ec12cca563e501c9e7306e283cef683795cc1",
- "docs/troubleshooting.md": "d961f6f209fb291bf0aec6be3ac41cc0a1eeec4526d0668d67ae2fe71c59c41d",
- "docs/validation-and-evidence.md": "3b5ed588bd44c5568f0c313be0dfaa411e959dc184fe886dfd0a81aee9fd25cc",
+ "docs/evidence-engineered-coding.md": "0ec724a5374d6182d510dd0e4db2b9f10283a2c432da0869b64df312ce90f06a",
+ "docs/generated-files.md": "9df63782f3211d2cb3eea6011ab36abe7eeb30767e9b875c6f33f0c2e2763036",
+ "docs/getting-started.md": "0cc31c99c3b34f04bd6c8b0d5de24c0843da9a8ec0d7a27348d91274220b6f00",
+ "docs/research-and-design.md": "c9e6fffd3a094cda9f6534027f9e1cec41f14cbcff705fe621698f8094013690",
+ "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6",
+ "docs/troubleshooting.md": "b9f0409bebb688fed5dddd61e6434c703b8ecc7dc3ded8c4ebb1986259d5ca30",
+ "docs/validation-and-evidence.md": "a9fe9274f3dc22b152094a307feda5d8c3ab099755100aef77bda13024cc3166",
"examples/diagram-json/README.md": "061b583180e43bbd26618bbd9d3d79af4b75d7c8f37c66475640745a97328fbc",
"examples/diagram-json/approval.md": "bc421a825349923512d5cb0ce489310d3a4d7cbac35e661a693b4a32eec263d1",
"examples/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d",
"examples/diagram-json/compiled/tasks.json": "f040696f1f8bcedc4a8ed9816a61a49edbda970ec0cc3b28175ba37b73bbc896",
"examples/diagram-json/compiled/test-matrix.json": "6c6895c509271e4337f3c91d9f62ee3a2b34e768e78513784cb012506a328ecf",
- "examples/diagram-json/plan.lock.json": "7d5274e024ea27231b1f57dcff36465a58b01fc4862bca3d7efad91ebb327041",
+ "examples/diagram-json/plan.lock.json": "c97f88b126b2ed9c92c76c1b228b6c762a8fb1b966bfe3f0d36ee49532d6f2c6",
"examples/diagram-json/plan.md": "3ad35cc3cbe48306e7ee401bd9e9047d25e46c8a6fe9679aa1b3f5e96ceea292",
"examples/diagram-json/questions.md": "1a0050041cac0a8d53e6ebfe04cbec4a298cdc8c50efeeb6fa15aeb663c5ec76",
"examples/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed",
@@ -74,7 +83,7 @@
"generator": "operatorstack/intelligence-flow:boatstack-distribution",
"schema_version": 1,
"source": {
- "commit": "4fee357eb535287be4b172b2af4c2e44939ce196",
+ "commit": "15c2f1faa8f0a0e133dea89983ca08b3aec4787c",
"path": "examples/12-product-engineering-loop",
"repository": "operatorstack/intelligence-flow"
}
diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md
index 4771833..229e196 100644
--- a/boatstack/SKILL.md
+++ b/boatstack/SKILL.md
@@ -23,6 +23,12 @@ Map the request to one operation:
For the full state machine, read [workflow.md](references/workflow.md). For artifact meanings and templates, read [artifacts.md](references/artifacts.md).
+## Enforce the irreversible-operation boundary
+
+Read [irreversible-operation-boundary.md](references/irreversible-operation-boundary.md). Project hooks hard-deny high-confidence destructive shell and MCP operations on every supported agent call. Never request or invent an in-session bypass. After an external-write failure, preserve state, use read-only diagnosis, retain the immutable target boundary, and choose only proven transactional retry or fix-forward recovery. Source edits may be reviewed, but an executable destructive capability blocks activation and every later gate.
+
+This enforcement is defense in depth, not a complete sandbox. Keep least-privilege service credentials and service-side destructive approval in place.
+
## Bound the outcome
For ordinary feature work, define one bounded outcome:
@@ -70,8 +76,9 @@ Normal approval is simply `approve`. Use an explicit supplied identity first; ot
8. Run product, design, engineering, and developer-experience reviews only when applicable. If gstack is installed, its review skills can implement these lenses; do not require it.
9. If Spec Kit is installed, use its constitution/specify/clarify/plan/tasks/analyze/checklist flow as an artifact generator. The canonical artifact contract remains authoritative.
10. For every planned validation, record the exact `criteria` it can support plus `run`, `origin`, `oracle`, and `independence`. Commands, automated tests, external checks, and named human review procedures are all valid forms, but an ambiguous claim without a threshold/rubric and authorized decision remains `BLOCKED`.
-11. Write only Markdown feature artifacts, including the canonical structured `plan.md`. Put its authoritative JSON inside the marked Boatstack block and run `boatstack-helper check-plan --plan /plan.md`; this command is read-only. If the host blocks its ordinary Markdown writer, pass the document to `boatstack-helper planning-write --repo . --feature --artifact ` on stdin. Never use arbitrary shell redirection to evade a host write boundary.
-12. End with a **draft**, never an implied approval. Do not generate executable task state, JSON artifacts, locks, or implementation changes from `auto-plan`.
+11. For every external write, record `affected_paths` plus side-effect kind, immutable target identity, reversibility, failure policy, and `destructive: false`. Reject ambiguous reset rollback or target names.
+12. Write only Markdown feature artifacts, including the canonical structured `plan.md`. Put its authoritative JSON inside the marked Boatstack block and run `boatstack-helper check-plan --plan /plan.md`; this command is read-only. If the host blocks its ordinary Markdown writer, pass the document to `boatstack-helper planning-write --repo . --feature --artifact ` on stdin. Never use arbitrary shell redirection to evade a host write boundary.
+13. End with a **draft**, never an implied approval. Do not generate executable task state, JSON artifacts, locks, or implementation changes from `auto-plan`.
Do not treat an ADR as general project context. ADRs record accepted durable decisions. Use a question ledger for unknowns and a gap ledger for known divergence.
diff --git a/boatstack/assets/templates/plan.md b/boatstack/assets/templates/plan.md
index 2ae2feb..18d52ec 100644
--- a/boatstack/assets/templates/plan.md
+++ b/boatstack/assets/templates/plan.md
@@ -30,6 +30,8 @@
"title": "",
"depends_on": [],
"acceptance_criteria": ["AC-1"],
+ "affected_paths": [""],
+ "side_effects": [],
"validation": [
{
"criteria": ["AC-1"],
@@ -45,3 +47,18 @@
}
```
+
+For an external write, replace the empty `side_effects` list with entries such as:
+
+```json
+{
+ "kind": "database-write",
+ "target": "",
+ "reversibility": "transactional",
+ "failure_policy": "rollback-transaction",
+ "destructive": false
+}
+```
+
+Boatstack rejects ambiguous targets, automated resets, and destructive rollback. Use
+`stop-and-fix-forward` when a transaction cannot contain the full operation.
diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go
index 107ab80..57ebd0d 100644
--- a/boatstack/cmd/boatstack-helper/main.go
+++ b/boatstack/cmd/boatstack-helper/main.go
@@ -61,6 +61,9 @@ func exportCommand(arguments []string) int {
if err := boatstack.CheckExport(*repo, bundle.Files); err != nil {
return fail(err)
}
+ if err := boatstack.CheckHostHooks(*repo, bundle.Config.Adapters); err != nil {
+ return fail(err)
+ }
fmt.Printf("PASS: %d generated files match Boatstack %s\n", len(bundle.Files), boatstack.Version)
return 0
}
@@ -68,6 +71,9 @@ func exportCommand(arguments []string) int {
if err := boatstack.WriteExport(*repo, bundle.Files); err != nil {
return fail(err)
}
+ if err := boatstack.InstallHostHooks(*repo, bundle.Config.Adapters); err != nil {
+ return fail(err)
+ }
fmt.Printf("PASS: wrote %d generated files to %s\n", len(bundle.Files), *repo)
return 0
}
@@ -82,6 +88,9 @@ func exportCommand(arguments []string) int {
}() {
fmt.Println(" " + path)
}
+ for _, path := range boatstack.HostHookPaths(bundle.Config.Adapters) {
+ fmt.Println(" " + path + " (merge safety hook)")
+ }
return 0
}
@@ -199,6 +208,45 @@ func doctorCommand(arguments []string) int {
return 0
}
+func safetyHookCommand(arguments []string) int {
+ flags := flag.NewFlagSet("safety-hook", flag.ContinueOnError)
+ host := flags.String("host", "", "cursor, claude, or codex")
+ repo := flags.String("repo", ".", "repository protected by the hook")
+ if err := flags.Parse(arguments); err != nil {
+ return 2
+ }
+ input, err := io.ReadAll(os.Stdin)
+ if err != nil {
+ input = nil
+ }
+ value, _ := boatstack.HookDecision(boatstack.SafetyHookOptions{Host: *host, Repo: *repo, Input: input})
+ if len(value) > 0 {
+ fmt.Print(string(value))
+ }
+ return 0
+}
+
+func checkSafetyCommand(arguments []string) int {
+ flags := flag.NewFlagSet("check-safety", flag.ContinueOnError)
+ repo := flags.String("repo", ".", "repository whose operational diff should be checked")
+ if err := flags.Parse(arguments); err != nil {
+ return 2
+ }
+ report, err := boatstack.CheckRepositorySafety(*repo)
+ if err != nil {
+ return fail(err)
+ }
+ value, err := boatstack.MarshalJSON(report)
+ if err != nil {
+ return fail(err)
+ }
+ fmt.Print(string(value))
+ if report.Status != "PASS" {
+ return 1
+ }
+ return 0
+}
+
func prContextCommand(arguments []string) int {
flags := flag.NewFlagSet("pr-context", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose branch should be projected")
@@ -281,7 +329,7 @@ func publishPRCommand(arguments []string) int {
func run() int {
if len(os.Args) < 2 {
- fmt.Fprintln(os.Stderr, "usage: boatstack-helper ")
+ fmt.Fprintln(os.Stderr, "usage: boatstack-helper ")
return 2
}
switch os.Args[1] {
@@ -307,6 +355,10 @@ func run() int {
return publishPRCommand(os.Args[2:])
case "doctor":
return doctorCommand(os.Args[2:])
+ case "safety-hook":
+ return safetyHookCommand(os.Args[2:])
+ case "check-safety":
+ return checkSafetyCommand(os.Args[2:])
case "version":
fmt.Printf("Boatstack %s (%s)\n", boatstack.Version, boatstack.SourceCommit)
return 0
diff --git a/boatstack/export.go b/boatstack/export.go
index ed84937..3dd2019 100644
--- a/boatstack/export.go
+++ b/boatstack/export.go
@@ -113,8 +113,19 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte
files[".product-loop/project.json"] = projectJSON
files[".product-loop/.gitignore"] = []byte("bin/\n")
files[".product-loop/intake/.gitkeep"] = []byte{}
+ files[".product-loop/hooks/guard.sh"] = guardShellScript()
+ files[".product-loop/hooks/guard.ps1"] = guardPowerShellScript()
+ for _, host := range []string{"cursor", "claude", "codex"} {
+ if contains(adapters, host) {
+ fragment, fragmentErr := hookFragmentJSON(host)
+ if fragmentErr != nil {
+ return ExportBundle{}, fragmentErr
+ }
+ files[".product-loop/hooks/"+host+".fragment.json"] = fragment
+ }
+ }
- for _, name := range []string{"workflow.md", "artifacts.md", "failure-moves.md"} {
+ for _, name := range []string{"workflow.md", "artifacts.md", "failure-moves.md", "irreversible-operation-boundary.md"} {
value, err := ReadCanonical("references/" + name)
if err != nil {
return ExportBundle{}, err
@@ -150,12 +161,12 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte
}
operations := map[string]string{
- "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. 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.",
+ "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. 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. The normal user action is simply approve. 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 with Reply approve as the one next action. 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. Implementation tactics remain open inside the approved boundary. 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.",
- "test-gate": "Build a requirement-to-evidence matrix and treat self-authored tests as evidence rather than the sole oracle. 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 repair or authorization the one next action.",
- "review-gate": "Review the actual diff against approved intent, invariants, risks, gaps, and test evidence. On pass 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.",
- "ship-gate": "Prepare a reviewer-ready PR only; do not merge or deploy without separate authorization. Require the current managed feature approval, lock, test evidence, and review evidence, and commit the intentional product/artifact diff before projection. Internally run pr-context --repo . --feature in json and template formats, project the approved intent, actual committed diff, decisions, evidence, gaps, rollout, and rollback into its required pr.md path, then run check-pr --repo . --preview . Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance; add UI evidence, security/privacy, migration, or operations sections only when the diff makes them relevant. Show the exact title and rendered body before any GitHub mutation. If PR_ACTION is open, respond PR ready with Reply open PR as the one next action; if update, use Reply update PR; if manual, preserve the preview and give one manual publication action. Only after that exact reply: commit only the reviewed pr.md, rerun check-pr and require the same preview fingerprint (PREVIEW_FINGERPRINT), then run publish-pr with --action open or update and that fingerprint. The publisher performs a non-force push and rechecks context before GitHub mutation. If the diff or evidence changes, regenerate instead. If a required check fails on the base branch too, record the evidence and recommend a separate repair PR. Never edit unrelated code in this approved feature branch; a policy-approved bypass requires explicit human authorization. After publication respond PR opened with the link and make Review the PR the one next action; never imply merge authorization.",
+ "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. 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. 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.",
+ "test-gate": "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. 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": "Run the internal repository safety check and review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Executable destructive capability is blocking even when ordinary tests pass. On pass 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.",
+ "ship-gate": "Prepare a reviewer-ready PR only; do not merge or deploy without separate authorization. Require the current managed feature approval, lock, test evidence, review evidence, and a passing repository safety scan, and commit the intentional product/artifact diff before projection. Internally run pr-context --repo . --feature in json and template formats, project the approved intent, actual committed diff, decisions, evidence, gaps, rollout, rollback, safety outcome, and operator-only recovery boundary into its required pr.md path, then run check-pr --repo . --preview . Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance; add UI evidence, security/privacy, migration, or operations sections only when the diff makes them relevant. Show the exact title and rendered body before any GitHub mutation. If PR_ACTION is open, respond PR ready with Reply open PR as the one next action; if update, use Reply update PR; if manual, preserve the preview and give one manual publication action. Only after that exact reply: commit only the reviewed pr.md, rerun check-pr and require the same preview fingerprint (PREVIEW_FINGERPRINT), then run publish-pr with --action open or update and that fingerprint. The publisher performs a non-force push and rechecks context before GitHub mutation. If the diff or evidence changes, regenerate instead. If a required check fails on the base branch too, record the evidence and recommend a separate repair PR. Never edit unrelated code in this approved feature branch; a policy-approved bypass requires explicit human authorization. After publication respond PR opened with the link and make Review the PR the one next action; never imply merge authorization.",
"review": "Alias of review-gate: review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Use Review passed or Changes required and the same single-action routing as review-gate.",
"ship": "Alias of ship-gate: prepare and preview the exact reviewer-ready title and body before any GitHub mutation. Require Reply open PR or Reply update PR before publication, recheck the preview against current evidence, and never merge or deploy. Keep pre-existing unrelated failures out of the approved feature branch. Use PR ready before confirmation or PR opened after publication.",
"retro": "Classify evidence and propose a move; never promote it or change durable rules without a paired gate. Respond Improvement proposed and make reviewing or authorizing the experiment the one next action.",
@@ -175,6 +186,7 @@ Do not start build work until the explicit plan gate has produced approval.md an
Implementation methods are open. Claims of completion, approval, review, and shipping require evidence.
When the user naturally asks Boatstack to prepare, improve, summarize, or update an existing PR without a managed feature package, generate an evidence-limited ad-hoc PR brief. Use the committed branch diff and observed checks, label missing evidence NOT_VERIFIED, and never imply Boatstack approval or passed gates. This is natural-language behavior, not a /pr-brief command. Preview the exact title and body before asking for one open/update confirmation.
Do not branch behavior on model name, provider, or price; branch on observed work state and evidence.
+Boatstack's repository hooks deny high-confidence irreversible operations across every agent call. There is no in-session bypass. Preserve failed external state, use read-only diagnosis and fix-forward recovery, and leave intentional destructive recovery to an operator-owned surface outside Boatstack.
`
files[fmt.Sprintf(".cursor/rules/%s.mdc", adapterName)], err = GeneratedFrontmatter(rule)
if err != nil {
@@ -202,6 +214,8 @@ Normal approval is simply approve. Use an explicit supplied identity first; othe
Use .product-loop/artifacts.md for document boundaries and .product-loop/failure-moves.md for improvement experiments. If a structured question tool is unavailable, ask 1-3 plain-text questions and return WAITING_FOR_INPUT; never select defaults on the user's behalf. Do not implement from an unapproved or stale plan. Implementation tactics are open; completion, approval, and shipping claims require current evidence. Do not branch on model identity; use observable state and gate evidence.
+Repository hooks enforce Boatstack's immutable deny policy across every agent call. Never request an in-session bypass for a blocked irreversible operation. After an external-write failure, preserve state, run only read-only diagnosis, and prefer transactional rollback or fix-forward recovery. Source code may be edited for review, but executable destructive capability blocks running it and blocks test, review, and ship progression.
+
At ship, prove whether a failing check is pre-existing by checking the base branch. Keep unrelated repairs in a separate PR; do not modify unrelated code under the approved feature lock. A repository-policy bypass requires explicit human authorization and recorded evidence.
For a managed ship, use the internal pr-context operation with --feature to project the feature spec, accepted decisions, actual committed diff, evidence ledger, review findings, gaps, rollout, and rollback into the required pr.md artifact. Inspect the returned changed files, diff stat, high-risk matches, and the actual diff before writing claims; commits alone are not authoritative. Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance. Add UI evidence, security/privacy, migration, or operations sections only when relevant. For a natural-language request to improve an existing or ad-hoc PR, run pr-context without --feature and use the same reviewer-first format from observed branch facts, but mark unavailable approval or gate evidence as NOT_VERIFIED. Never create or advertise a /pr-brief command. Validate with check-pr and always show the exact title and rendered body before publication. Ask for exactly Reply open PR or Reply update PR. Only after that reply, commit only pr.md, revalidate the unchanged preview fingerprint, and invoke the internal publish-pr operation with the selected action. It may perform a normal push but never force-push. Any intervening product diff or evidence change invalidates the preview. Keep model attribution inside collapsed provenance. Internal helper names and hashes stay out of the primary response.
@@ -243,6 +257,10 @@ Explain the user or engineering outcome, not merely the files edited.
|---|---|---|---|
| | | NOT_VERIFIED | |
+## Operational safety
+
+State the operational-diff safety result and keep destructive recovery operator-only.
+
## Known gaps and risks
List explicit gaps with impact and revisit trigger, or state that no material gaps are known.
diff --git a/boatstack/export_test.go b/boatstack/export_test.go
index b0782f0..64a6a5a 100644
--- a/boatstack/export_test.go
+++ b/boatstack/export_test.go
@@ -39,6 +39,12 @@ func TestExportAndDriftCheck(t *testing.T) {
if err := CheckExport(repo, bundle.Files); err != nil {
t.Fatal(err)
}
+ if err := InstallHostHooks(repo, config.Adapters); err != nil {
+ t.Fatal(err)
+ }
+ if err := CheckHostHooks(repo, config.Adapters); err != nil {
+ t.Fatal(err)
+ }
for _, path := range []string{
".cursor/commands/plan-gate.md",
".cursor/commands/review.md",
@@ -47,6 +53,9 @@ func TestExportAndDriftCheck(t *testing.T) {
".product-loop/.gitignore",
".product-loop/templates/plan.md",
".product-loop/templates/approval.md",
+ ".product-loop/hooks/guard.sh",
+ ".product-loop/hooks/guard.ps1",
+ ".product-loop/hooks/cursor.fragment.json",
} {
if !fileExists(filepath.Join(repo, filepath.FromSlash(path))) {
t.Fatalf("expected generated file %s", path)
@@ -115,7 +124,7 @@ func TestExportAndDriftCheck(t *testing.T) {
}
}
prTemplate := string(bundle.Files[".github/PULL_REQUEST_TEMPLATE/boatstack.md"])
- for _, expected := range []string{"## Why this change", "## What changed", "## Review order", "## Evidence", "## Known gaps and risks", "## Rollout and rollback", "Boatstack provenance"} {
+ for _, expected := range []string{"## Why this change", "## What changed", "## Review order", "## Evidence", "## Operational safety", "## Known gaps and risks", "## Rollout and rollback", "Boatstack provenance"} {
if !strings.Contains(prTemplate, expected) {
t.Fatalf("generated PR template is missing %q", expected)
}
@@ -133,6 +142,11 @@ func TestExportAndDriftCheck(t *testing.T) {
t.Fatalf("canonical workflow is missing response contract %q", expected)
}
}
+ for _, expected := range []string{"irreversible", "operator-only", "fix-forward", "least-privilege"} {
+ if !strings.Contains(strings.ToLower(workflow), strings.ToLower(expected)) {
+ t.Fatalf("canonical workflow is missing safety boundary %q", expected)
+ }
+ }
for _, path := range []string{".agents/skills/boatstack/SKILL.md", ".claude/skills/boatstack/SKILL.md"} {
adapter := string(bundle.Files[path])
for _, expected := range []string{"User-facing response contract", "exactly one Next step", "Normal approval is simply approve", "filesystem username", "Never create or advertise a /pr-brief command", "Reply open PR", "Reply update PR"} {
diff --git a/boatstack/hooks.go b/boatstack/hooks.go
new file mode 100644
index 0000000..da045cd
--- /dev/null
+++ b/boatstack/hooks.go
@@ -0,0 +1,265 @@
+package boatstack
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+const hookCommandMarker = ".product-loop/hooks/guard"
+
+func guardShellScript() []byte {
+ return []byte(`#!/usr/bin/env bash
+# Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json.
+set -u
+
+HOST="${1:-}"
+ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+if [[ -z "$ROOT" ]]; then
+ echo "Boatstack safety guard could not resolve the repository; denying tool execution." >&2
+ exit 2
+fi
+
+HELPER="$ROOT/.product-loop/bin/boatstack-helper"
+if [[ ! -x "$HELPER" ]]; then
+ echo "Boatstack safety helper is missing; rerun the installer from the repository root." >&2
+ exit 2
+fi
+
+exec "$HELPER" safety-hook --host "$HOST" --repo "$ROOT"
+`)
+}
+
+func guardPowerShellScript() []byte {
+ return []byte(`# Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json.
+param([Parameter(Mandatory=$true)][string]$HostName)
+$ErrorActionPreference = "Stop"
+$root = (& git rev-parse --show-toplevel 2>$null)
+if (-not $root) {
+ [Console]::Error.WriteLine("Boatstack safety guard could not resolve the repository; denying tool execution.")
+ exit 2
+}
+$helper = Join-Path $root ".product-loop/bin/boatstack-helper.exe"
+if (-not (Test-Path -LiteralPath $helper -PathType Leaf)) {
+ [Console]::Error.WriteLine("Boatstack safety helper is missing; rerun the installer from the repository root.")
+ exit 2
+}
+& $helper safety-hook --host $HostName --repo $root
+exit $LASTEXITCODE
+`)
+}
+
+func hookCommand(host string) string {
+ return `bash "$(git rev-parse --show-toplevel)/.product-loop/hooks/guard.sh" ` + host
+}
+
+func hookCommandWindows(host string) string {
+ return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$r = & git rev-parse --show-toplevel; & (Join-Path $r '.product-loop/hooks/guard.ps1') ` + host + `"`
+}
+
+func desiredHostHookForEvent(host, event string) map[string]any {
+ switch host {
+ case "cursor":
+ return map[string]any{
+ "command": hookCommand(host), "commandWindows": hookCommandWindows(host),
+ "failClosed": true, "timeout": 10,
+ }
+ case "claude":
+ return map[string]any{
+ "matcher": "Bash|Shell|mcp__.*",
+ "hooks": []any{map[string]any{
+ "type": "command", "command": hookCommand(host),
+ "timeout": 10, "statusMessage": "Checking irreversible-operation policy",
+ }},
+ }
+ case "codex":
+ return map[string]any{
+ "matcher": "Bash|Shell|mcp__.*",
+ "hooks": []any{map[string]any{
+ "type": "command", "command": hookCommand(host), "commandWindows": hookCommandWindows(host),
+ "timeout": 10, "statusMessage": "Checking irreversible-operation policy",
+ }},
+ }
+ default:
+ return map[string]any{}
+ }
+}
+
+func hookEvents(host string) []string {
+ if host == "cursor" {
+ return []string{"beforeShellExecution", "beforeMCPExecution"}
+ }
+ return []string{"PreToolUse"}
+}
+
+func desiredHostHook(host string) map[string]any {
+ return desiredHostHookForEvent(host, hookEvents(host)[0])
+}
+
+func hookFragmentJSON(host string) ([]byte, error) {
+ events := map[string]any{}
+ for _, event := range hookEvents(host) {
+ events[event] = desiredHostHookForEvent(host, event)
+ }
+ return GeneratedJSON(map[string]any{"schema_version": 1, "host": host, "events": events})
+}
+
+func hostHookConfigPath(repo, host string) string {
+ switch host {
+ case "cursor":
+ return filepath.Join(repo, ".cursor", "hooks.json")
+ case "claude":
+ return filepath.Join(repo, ".claude", "settings.json")
+ case "codex":
+ return filepath.Join(repo, ".codex", "hooks.json")
+ default:
+ return ""
+ }
+}
+
+func HostHookPaths(adapters []string) []string {
+ paths := []string{}
+ for _, host := range []string{"cursor", "claude", "codex"} {
+ if contains(adapters, host) {
+ paths = append(paths, filepath.ToSlash(strings.TrimPrefix(hostHookConfigPath("", host), string(filepath.Separator))))
+ }
+ }
+ return paths
+}
+
+func containsBoatstackHook(value any) bool {
+ switch typed := value.(type) {
+ case string:
+ return strings.Contains(typed, hookCommandMarker)
+ case []any:
+ for _, item := range typed {
+ if containsBoatstackHook(item) {
+ return true
+ }
+ }
+ case map[string]any:
+ for _, item := range typed {
+ if containsBoatstackHook(item) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func loadHookConfig(path string) (map[string]any, error) {
+ value, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return map[string]any{}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ config := map[string]any{}
+ if err := json.Unmarshal(value, &config); err != nil {
+ return nil, fmt.Errorf("invalid host hook config %s: %w", path, err)
+ }
+ return config, nil
+}
+
+func mergeHostHook(config map[string]any, host string) error {
+ hooks, ok := config["hooks"].(map[string]any)
+ if config["hooks"] == nil {
+ hooks = map[string]any{}
+ config["hooks"] = hooks
+ } else if !ok {
+ return fmt.Errorf("host hook config has non-object hooks")
+ }
+ for _, event := range hookEvents(host) {
+ entries := []any{}
+ if existing := hooks[event]; existing != nil {
+ var entriesOK bool
+ entries, entriesOK = existing.([]any)
+ if !entriesOK {
+ return fmt.Errorf("host hook event %s is not a list", event)
+ }
+ }
+ kept := []any{}
+ found := 0
+ for _, entry := range entries {
+ if containsBoatstackHook(entry) {
+ found++
+ continue
+ }
+ kept = append(kept, entry)
+ }
+ if found > 1 {
+ return fmt.Errorf("ambiguous Boatstack hook collision in %s", event)
+ }
+ kept = append(kept, desiredHostHookForEvent(host, event))
+ hooks[event] = kept
+ }
+ if host == "cursor" && config["version"] == nil {
+ config["version"] = float64(1)
+ }
+ return nil
+}
+
+func InstallHostHooks(repo string, adapters []string) error {
+ for _, host := range []string{"cursor", "claude", "codex"} {
+ if !contains(adapters, host) {
+ continue
+ }
+ path := hostHookConfigPath(repo, host)
+ config, err := loadHookConfig(path)
+ if err != nil {
+ return err
+ }
+ if err := mergeHostHook(config, host); err != nil {
+ return err
+ }
+ value, err := MarshalJSON(config)
+ if err != nil {
+ return err
+ }
+ if err := atomicWrite(path, value); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func CheckHostHooks(repo string, adapters []string) error {
+ for _, host := range []string{"cursor", "claude", "codex"} {
+ if !contains(adapters, host) {
+ continue
+ }
+ path := hostHookConfigPath(repo, host)
+ config, err := loadHookConfig(path)
+ if err != nil {
+ return err
+ }
+ hooks, ok := config["hooks"].(map[string]any)
+ if !ok {
+ return fmt.Errorf("missing %s hooks in %s", host, path)
+ }
+ for _, event := range hookEvents(host) {
+ entries, ok := hooks[event].([]any)
+ if !ok {
+ return fmt.Errorf("missing %s safety event %s in %s", host, event, path)
+ }
+ matches := 0
+ for _, entry := range entries {
+ if containsBoatstackHook(entry) {
+ matches++
+ current, _ := json.Marshal(entry)
+ expected, _ := json.Marshal(desiredHostHookForEvent(host, event))
+ if string(current) != string(expected) {
+ return fmt.Errorf("drifted %s Boatstack safety hook", host)
+ }
+ }
+ }
+ if matches != 1 {
+ return fmt.Errorf("expected exactly one %s Boatstack safety hook for %s; found %d", host, event, matches)
+ }
+ }
+ }
+ return nil
+}
diff --git a/boatstack/hooks_test.go b/boatstack/hooks_test.go
new file mode 100644
index 0000000..268e56d
--- /dev/null
+++ b/boatstack/hooks_test.go
@@ -0,0 +1,98 @@
+package boatstack
+
+import (
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestHostHookMergePreservesUnrelatedConfiguration(t *testing.T) {
+ repo := t.TempDir()
+ path := filepath.Join(repo, ".cursor", "hooks.json")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ initial := `{"version":1,"theme":"kept","hooks":{"beforeShellExecution":[{"command":"./existing-check.sh"}]}}`
+ if err := os.WriteFile(path, []byte(initial), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := InstallHostHooks(repo, []string{"cursor", "claude", "codex"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := CheckHostHooks(repo, []string{"cursor", "claude", "codex"}); err != nil {
+ t.Fatal(err)
+ }
+ value, _ := os.ReadFile(path)
+ if !strings.Contains(string(value), `"theme": "kept"`) || !strings.Contains(string(value), "existing-check.sh") {
+ t.Fatalf("hook merge discarded unrelated configuration: %s", value)
+ }
+ if err := InstallHostHooks(repo, []string{"cursor", "claude", "codex"}); err != nil {
+ t.Fatalf("idempotent reinstall failed: %v", err)
+ }
+}
+
+func TestHostHookMergeRejectsAmbiguousCollisionAndDrift(t *testing.T) {
+ repo := t.TempDir()
+ path := filepath.Join(repo, ".codex", "hooks.json")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ entry := desiredHostHook("codex")
+ config := map[string]any{"hooks": map[string]any{"PreToolUse": []any{entry, entry}}}
+ value, _ := MarshalJSON(config)
+ if err := os.WriteFile(path, value, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := InstallHostHooks(repo, []string{"codex"}); err == nil || !strings.Contains(err.Error(), "ambiguous") {
+ 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)
+ value, _ = MarshalJSON(config)
+ if err := os.WriteFile(path, value, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := CheckHostHooks(repo, []string{"codex"}); err == nil || !strings.Contains(err.Error(), "drifted") {
+ t.Fatalf("expected drifted fragment failure, got %v", err)
+ }
+}
+
+func TestMissingHelperLauncherFailsClosed(t *testing.T) {
+ if _, err := exec.LookPath("bash"); err != nil {
+ t.Skip("bash unavailable")
+ }
+ repo := t.TempDir()
+ runGit(t, repo, "init", "-b", "main")
+ path := filepath.Join(repo, ".product-loop", "hooks", "guard.sh")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, guardShellScript(), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ command := exec.Command("bash", path, "cursor")
+ command.Dir = repo
+ output, err := command.CombinedOutput()
+ if err == nil || !strings.Contains(string(output), "helper is missing") {
+ t.Fatalf("missing helper did not fail closed: err=%v output=%s", err, output)
+ }
+}
+
+func TestHookFragmentsAreValidJSON(t *testing.T) {
+ for _, host := range []string{"cursor", "claude", "codex"} {
+ value, err := hookFragmentJSON(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var decoded map[string]any
+ if err := json.Unmarshal(value, &decoded); err != nil {
+ t.Fatalf("%s fragment is invalid JSON: %v", host, err)
+ }
+ if !strings.Contains(string(value), hookCommandMarker) {
+ t.Fatalf("%s fragment lacks Boatstack marker", host)
+ }
+ }
+}
diff --git a/boatstack/init.go b/boatstack/init.go
index cf30880..7767dc9 100644
--- a/boatstack/init.go
+++ b/boatstack/init.go
@@ -302,6 +302,9 @@ func RunInit(options InitOptions) error {
for _, path := range paths {
fmt.Fprintln(options.Output, " "+path)
}
+ for _, path := range HostHookPaths(config.Adapters) {
+ fmt.Fprintln(options.Output, " "+path+" (merge Boatstack safety hook; preserve existing settings)")
+ }
if !fileExists(configPath) {
fmt.Fprintln(options.Output, " .boatstack-project.json (editable repository facts)")
}
@@ -320,6 +323,9 @@ func RunInit(options InitOptions) error {
if err := WriteExport(repo, bundle.Files); err != nil {
return err
}
+ if err := InstallHostHooks(repo, config.Adapters); err != nil {
+ return err
+ }
binaryPath, binaryHash, err := copyHelper(options.BinaryPath, repo)
if err != nil {
return err
@@ -334,10 +340,15 @@ func RunInit(options InitOptions) error {
if err := CheckExport(repo, bundle.Files); err != nil {
return err
}
+ if err := CheckHostHooks(repo, config.Adapters); err != nil {
+ return err
+ }
if err := Doctor(repo); err != nil {
return fmt.Errorf("post-install smoke check failed: %w", err)
}
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, "Hooks are defense in depth; keep least-privilege credentials and service-side destructive approval.")
keys := sortedKeys(states)
for _, name := range keys {
state := states[name]
@@ -345,6 +356,7 @@ func RunInit(options InitOptions) error {
}
fmt.Fprintln(options.Output, "\nBefore product work, commit Boatstack infrastructure in its own PR:")
stagePaths := append([]string{".boatstack-project.json"}, paths...)
+ stagePaths = append(stagePaths, HostHookPaths(config.Adapters)...)
fmt.Fprintln(options.Output, " git status --short")
fmt.Fprintln(options.Output, " git add -- "+strings.Join(stagePaths, " "))
fmt.Fprintln(options.Output, " git commit -m \"chore: install Boatstack\"")
diff --git a/boatstack/init_test.go b/boatstack/init_test.go
index 511c2c4..00c3163 100644
--- a/boatstack/init_test.go
+++ b/boatstack/init_test.go
@@ -27,7 +27,8 @@ func TestRuntimeFreeInit(t *testing.T) {
}
for _, path := range []string{
".boatstack-project.json", ".product-loop/project.json", ".product-loop/generated.lock.json",
- ".product-loop/bin/install.lock.json", ".cursor/commands/auto-plan.md",
+ ".product-loop/bin/install.lock.json", ".cursor/commands/auto-plan.md", ".product-loop/hooks/guard.sh",
+ ".cursor/hooks.json", ".claude/settings.json", ".codex/hooks.json",
} {
if !fileExists(filepath.Join(repo, filepath.FromSlash(path))) {
t.Fatalf("init did not create %s", path)
@@ -43,6 +44,11 @@ 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"} {
+ if !strings.Contains(output.String(), expected) {
+ t.Fatalf("init output is missing safety guidance %q: %s", expected, output.String())
+ }
+ }
for _, expected := range []string{"commit Boatstack infrastructure in its own PR", "git add -- .boatstack-project.json", "git push -u origin chore/install-boatstack", "reload Cursor, Codex, or Claude"} {
if !strings.Contains(output.String(), expected) {
t.Fatalf("init output is missing %q: %s", expected, output.String())
diff --git a/boatstack/plan.go b/boatstack/plan.go
index c68817c..1689c42 100644
--- a/boatstack/plan.go
+++ b/boatstack/plan.go
@@ -471,6 +471,9 @@ func ValidatePlan(plan map[string]any) error {
if len(mapped) == 0 && stringValue(task["enabling_reason"]) == "" {
return fmt.Errorf("task %s must map acceptance criteria or state an enabling_reason", id)
}
+ if err := validateTaskSafety(task); err != nil {
+ return fmt.Errorf("task %s safety: %w", id, err)
+ }
graph[id] = dependencies
}
uncovered := []string{}
@@ -521,6 +524,96 @@ func ValidatePlan(plan map[string]any) error {
return nil
}
+func taskSafetyText(task map[string]any) string {
+ parts := []string{stringValue(task["title"]), stringValue(task["rollback_boundary"])}
+ if validations, ok := objectSlice(task["validation"]); ok {
+ for _, validation := range validations {
+ parts = append(parts, stringValue(validation["run"]), stringValue(validation["origin"]))
+ }
+ }
+ if paths, ok := stringSlice(task["affected_paths"]); ok {
+ parts = append(parts, paths...)
+ }
+ return strings.ToLower(strings.Join(parts, " "))
+}
+
+func taskHasExternalWrite(task map[string]any) bool {
+ text := taskSafetyText(task)
+ for _, marker := range []string{
+ "database", "migration", "migrate", "seed database", "deploy", "supabase", "postgres",
+ "terraform", "pulumi", "kubectl", "cloud", "production", "staging", "external write",
+ } {
+ if strings.Contains(text, marker) {
+ return true
+ }
+ }
+ return false
+}
+
+func destructiveRollback(value string) bool {
+ text := strings.ToLower(strings.TrimSpace(value))
+ if text == "" {
+ return false
+ }
+ for _, safePrefix := range []string{"no reset", "never reset", "do not reset", "operator-only", "operator only"} {
+ if strings.Contains(text, safePrefix) {
+ return false
+ }
+ }
+ for _, marker := range []string{"reset database", "reset db", "reset local db", "drop schema", "drop database", "truncate", "wipe database", "destroy infrastructure"} {
+ if strings.Contains(text, marker) {
+ return true
+ }
+ }
+ return false
+}
+
+func validateTaskSafety(task map[string]any) error {
+ if destructiveRollback(stringValue(task["rollback_boundary"])) {
+ return fmt.Errorf("destructive rollback is not executable authority; use transactional rollback, fix-forward recovery, or an operator-only runbook")
+ }
+ if !taskHasExternalWrite(task) {
+ return nil
+ }
+ paths, pathsOK := stringSlice(task["affected_paths"])
+ if !pathsOK || len(paths) == 0 {
+ return fmt.Errorf("external-write tasks require affected_paths")
+ }
+ effects, effectsOK := objectSlice(task["side_effects"])
+ if !effectsOK || len(effects) == 0 {
+ return fmt.Errorf("external-write tasks require structured side_effects")
+ }
+ for index, effect := range effects {
+ for _, field := range []string{"kind", "target", "reversibility", "failure_policy"} {
+ if strings.TrimSpace(stringValue(effect[field])) == "" {
+ return fmt.Errorf("side_effects[%d].%s is required", index, field)
+ }
+ }
+ target := strings.ToLower(strings.TrimSpace(stringValue(effect["target"])))
+ for _, ambiguous := range []string{"unknown", "tbd", "database", "staging", "production", "local database"} {
+ if target == ambiguous {
+ return fmt.Errorf("side_effects[%d].target must use an immutable target identity", index)
+ }
+ }
+ destructive, ok := effect["destructive"].(bool)
+ if !ok {
+ return fmt.Errorf("side_effects[%d].destructive must be boolean", index)
+ }
+ if destructive {
+ return fmt.Errorf("side_effects[%d] requests a destructive operation; move it to an operator-owned surface", index)
+ }
+ reversibility := strings.ToLower(stringValue(effect["reversibility"]))
+ if reversibility != "transactional" && reversibility != "fix-forward" && reversibility != "reversible" {
+ return fmt.Errorf("side_effects[%d].reversibility must be transactional, fix-forward, or reversible", index)
+ }
+ failurePolicy := strings.ToLower(stringValue(effect["failure_policy"]))
+ if failurePolicy != "rollback-transaction" && failurePolicy != "stop-and-fix-forward" {
+ return fmt.Errorf("side_effects[%d].failure_policy must be rollback-transaction or stop-and-fix-forward", index)
+ }
+ }
+ return nil
+}
+
func CompilePlan(plan map[string]any) (map[string]any, map[string]any, string, error) {
if err := ValidatePlan(plan); err != nil {
return nil, nil, "", err
@@ -570,7 +663,7 @@ func CompilePlan(plan map[string]any) (map[string]any, map[string]any, string, e
rows = append(rows, row)
evidence = append(evidence, fmt.Sprintf("| %s: %s | %s | `BLOCKED` | |", criterionID, stringValue(criterion["text"]), strings.Join(servingIDs, ", ")))
}
- evidence = append(evidence, "", "## Commands and checks", "", "## Review findings", "", "## Known gaps", "", "## Rollout and rollback", "")
+ evidence = append(evidence, "", "## Safety evidence", "", "- Operational diff safety: `BLOCKED`", "- External target and recovery evidence: pending", "", "## Commands and checks", "", "## Review findings", "", "## Known gaps", "", "## Rollout and rollback", "")
taskGraph := map[string]any{
"schema_version": 1,
"feature_id": plan["feature_id"],
@@ -711,6 +804,17 @@ func ActivatePlan(options ActivationOptions) error {
if err != nil {
return err
}
+ repo, err := ResolveRepository(filepath.Dir(options.PlanPath))
+ if err != nil {
+ return err
+ }
+ safety, err := CheckRepositorySafety(repo)
+ if err != nil {
+ return err
+ }
+ if safety.Status != "PASS" {
+ return fmt.Errorf("operational diff contains an irreversible capability: %s", safety.Findings[0].Category)
+ }
if err := CompilePlanFiles(options.PlanPath, options.OutDir); err != nil {
return err
}
diff --git a/boatstack/plan_test.go b/boatstack/plan_test.go
index c415b24..43a072e 100644
--- a/boatstack/plan_test.go
+++ b/boatstack/plan_test.go
@@ -88,6 +88,11 @@ func writePlanInputs(t *testing.T, root string, marked bool) (string, string, st
func TestMarkdownPlanActivationAndStaleness(t *testing.T) {
root := t.TempDir()
sourcePlan, _, planPath := writePlanInputs(t, root, true)
+ runGit(t, root, "init", "-b", "main")
+ runGit(t, root, "config", "user.name", "Boatstack Test")
+ runGit(t, root, "config", "user.email", "boatstack@example.invalid")
+ runGit(t, root, "add", ".")
+ runGit(t, root, "commit", "-m", "record approved planning inputs")
approval := filepath.Join(root, "approval.md")
compiled := filepath.Join(root, "compiled")
lock := filepath.Join(root, "plan.lock.json")
@@ -178,6 +183,38 @@ func TestMarkdownPlanRejectsMissingMultipleMalformedAndOpenQuestions(t *testing.
}
}
+func TestExternalWritePlanRequiresSafeExplicitSideEffects(t *testing.T) {
+ root := t.TempDir()
+ _, _, planPath := writePlanInputs(t, root, true)
+ plan := validPlan()
+ task := plan["tasks"].([]any)[0].(map[string]any)
+ task["title"] = "apply database schema migration"
+ task["affected_paths"] = []any{"scripts/apply_schema.py"}
+ task["rollback_boundary"] = "reset local DB"
+ writeMarkdownPlan(t, planPath, plan, true)
+ if _, err := CheckPlan(planPath); err == nil || !strings.Contains(err.Error(), "destructive rollback") {
+ t.Fatalf("ambiguous destructive rollback did not block planning: %v", err)
+ }
+ task["rollback_boundary"] = "stop and fix forward"
+ writeMarkdownPlan(t, planPath, plan, true)
+ if _, err := CheckPlan(planPath); err == nil || !strings.Contains(err.Error(), "structured side_effects") {
+ t.Fatalf("missing external side-effect declaration did not block: %v", err)
+ }
+ task["side_effects"] = []any{map[string]any{
+ "kind": "database-schema-write", "target": "project-ref-7f31",
+ "reversibility": "transactional", "failure_policy": "rollback-transaction", "destructive": false,
+ }}
+ writeMarkdownPlan(t, planPath, plan, true)
+ if _, err := CheckPlan(planPath); err != nil {
+ t.Fatalf("safe explicit external-write plan should pass: %v", err)
+ }
+ task["side_effects"].([]any)[0].(map[string]any)["target"] = "local database"
+ writeMarkdownPlan(t, planPath, plan, true)
+ if _, err := CheckPlan(planPath); err == nil || !strings.Contains(err.Error(), "immutable target identity") {
+ t.Fatalf("ambiguous external target did not block: %v", err)
+ }
+}
+
func TestReadOnlyCheckAndFailedActivationWriteNothing(t *testing.T) {
root := t.TempDir()
_, _, planPath := writePlanInputs(t, root, true)
diff --git a/boatstack/planning.go b/boatstack/planning.go
index 081dafe..4f5bc78 100644
--- a/boatstack/planning.go
+++ b/boatstack/planning.go
@@ -195,6 +195,26 @@ func Doctor(repoPath string) error {
if err := CheckExport(repo, bundle.Files); err != nil {
return err
}
+ if err := CheckHostHooks(repo, config.Adapters); err != nil {
+ return err
+ }
+ for _, host := range []string{"cursor", "claude", "codex"} {
+ if !contains(config.Adapters, host) {
+ continue
+ }
+ var input []byte
+ if host == "cursor" {
+ input = []byte(`{"command":"git status --short"}`)
+ } else {
+ input = []byte(`{"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)
+ }
+ 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)
+ }
+ }
lockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json")
value, err := os.ReadFile(lockPath)
if err != nil {
diff --git a/boatstack/pr.go b/boatstack/pr.go
index bb7caf9..fc6bb8a 100644
--- a/boatstack/pr.go
+++ b/boatstack/pr.go
@@ -47,6 +47,8 @@ type PRContext struct {
ProjectCommands map[string]string `json:"project_commands,omitempty"`
HighRiskFiles []string `json:"high_risk_files,omitempty"`
GateStatus map[string]string `json:"gate_status,omitempty"`
+ SafetyStatus string `json:"safety_status"`
+ SafetyFindings []SafetyFinding `json:"safety_findings,omitempty"`
Sources []PRSource `json:"sources,omitempty"`
PreviewPath string `json:"preview_path"`
}
@@ -406,6 +408,13 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) {
sources = append(sources, managedSources...)
gateStatus = statuses
}
+ safety, err := CheckRepositorySafety(repo)
+ if err != nil {
+ return PRContext{}, fmt.Errorf("cannot establish operational safety evidence: %w", err)
+ }
+ if mode == "managed" && safety.Status != "PASS" {
+ return PRContext{}, fmt.Errorf("managed PR is blocked by executable irreversible capability: %s", safety.Findings[0].Category)
+ }
sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path })
fingerprintPayload, err := MarshalJSON(map[string]any{
"schema_version": prPreviewSchemaVersion,
@@ -417,6 +426,8 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) {
"merge_base_commit": mergeBaseCommit,
"product_diff_sha256": SHA256Bytes(diff),
"gate_status": gateStatus,
+ "safety_status": safety.Status,
+ "safety_findings": safety.Findings,
"sources": sources,
})
if err != nil {
@@ -430,6 +441,7 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) {
ContextPaths: config.Project.Context, ProjectCommands: config.Project.Commands,
HighRiskFiles: highRiskChangedFiles(changed, config.Project.HighRiskPaths),
GateStatus: gateStatus, Sources: sources,
+ SafetyStatus: safety.Status, SafetyFindings: safety.Findings,
PreviewPath: previewPath,
}, nil
}
@@ -600,7 +612,7 @@ func ParsePRPreview(path string) (PRPreview, error) {
}
for _, heading := range []string{
"## Why this change", "## What changed", "## Review order", "## Evidence",
- "## Known gaps and risks", "## Rollout and rollback",
+ "## Operational safety", "## Known gaps and risks", "## Rollout and rollback",
} {
if section(body, heading) == "" {
return PRPreview{}, fmt.Errorf("PR body requires a non-empty %s section", strings.TrimPrefix(heading, "## "))
@@ -787,6 +799,7 @@ func PRPreviewTemplate(context PRContext) string {
encoded, _ := json.Marshal(value)
return string(encoded)
}
+ safetySummary := "Repository safety scan: `" + context.SafetyStatus + "`. Destructive recovery remains operator-only outside Boatstack."
return strings.Join([]string{
"---",
"boatstack_pr_version: 1",
@@ -801,6 +814,7 @@ func PRPreviewTemplate(context PRContext) string {
"## What changed", "", "| Area | Before | After | Reviewer focus |", "|---|---|---|---|", "| | | | |", "",
"## Review order", "", "1. Start with the contract or boundary that defines the behavior.", "",
"## Evidence", "", "| Claim | Evidence | Result | Source |", "|---|---|---|---|", "| | | `NOT_VERIFIED` | |", "",
+ "## Operational safety", "", safetySummary, "",
"## Known gaps and risks", "", "List explicit gaps or say that no material gaps are known.", "",
"## Rollout and rollback", "", "Describe deployment impact and the smallest safe rollback.", "",
"", "Boatstack provenance
", "", "Summarize mode, approval/evidence availability, and coding-host attribution here.", "", " ", "",
diff --git a/boatstack/pr_test.go b/boatstack/pr_test.go
index f651e8c..f274501 100644
--- a/boatstack/pr_test.go
+++ b/boatstack/pr_test.go
@@ -236,6 +236,10 @@ Reviewers need a concise, evidence-backed view of the approved outcome.
| Approved behavior is implemented | Contract assertions passed | ` + "`PASS`" + ` | [Evidence ledger](.product-loop/features/reviewer-ready/evidence.md) |
| Review found no blocking issue | Independent diff review | ` + "`PASS_WITH_GAPS`" + ` | [Evidence ledger](.product-loop/features/reviewer-ready/evidence.md) |
+## Operational safety
+
+Repository safety scan passed. Destructive recovery remains operator-only.
+
## Known gaps and risks
One non-critical portability gap remains recorded with an owner.
@@ -255,6 +259,23 @@ No migration is required; revert the feature commit to roll back.
`
}
+func TestManagedPRBlocksCommittedIrreversibleCapability(t *testing.T) {
+ repo := prTestRepo(t)
+ activateManagedFeature(t, repo, "reviewer-ready")
+ path := filepath.Join(repo, "scripts", "recover.sql")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("DROP SCHEMA public CASCADE;\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, repo, "add", "scripts/recover.sql")
+ runGit(t, repo, "commit", "-m", "add unsafe recovery")
+ if _, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"}); err == nil || !strings.Contains(err.Error(), "irreversible capability") {
+ t.Fatalf("managed PR did not block committed destructive code: %v", err)
+ }
+}
+
func TestManagedPRRequiresCurrentApprovalLockAndGateEvidence(t *testing.T) {
repo := prTestRepo(t)
directory := activateManagedFeature(t, repo, "reviewer-ready")
diff --git a/boatstack/references/artifacts.md b/boatstack/references/artifacts.md
index 2f6277f..46a143d 100644
--- a/boatstack/references/artifacts.md
+++ b/boatstack/references/artifacts.md
@@ -16,6 +16,7 @@ Artifacts separate facts, decisions, unknowns, incompleteness, and evidence. Com
| Test plan | Requirement-to-evidence mapping with each validation's origin, falsifiable oracle, procedure, and independence | Planning and after discovered failure modes |
| Gap ledger | Known divergence between desired and current state | Work is deferred, partial, incompatible, or intentionally absent |
| Risk/threat note | Assets, actors, trust boundaries, abuse/failure paths | Security, data, tenancy, billing, auth, or destructive paths change |
+| Side-effect declaration | Affected paths, immutable external target, reversibility, failure policy, and destructive flag | A task can write outside the repository |
| Runbook | Deploy, observe, recover, and roll back | Operational behavior changes |
| Evidence ledger | Commands, results, review evidence, screenshots, CI and runtime links | Every gate |
| PR preview | Exact reviewer-ready title/body plus a hidden fingerprint of the committed diff and evidence | Ship gate, before opening or updating GitHub |
@@ -72,6 +73,10 @@ For managed work it lives under `.product-loop/features//pr.md` and may
`auto-plan` and `plan-gate` create or update Markdown only. `plan.md` is the canonical structured input and `approval.md` is the human-approval receipt. Compiled JSON and `plan.lock.json` begin only at `build` activation, after the receipt is verified. This keeps planning compatible with hosts that intentionally restrict Plan mode to documents.
+## Safety boundary
+
+The generated host hook fragments and launchers are committed installation infrastructure. Their policy is immutable in project configuration. The machine-local helper is ignored and restored by the installer. Safety evidence belongs in the feature evidence ledger: target identity, failure behavior, independent oracle, operational-diff scan, and the operator-only recovery boundary. A source edit is reviewable evidence, not permission to execute it.
+
## Templates
Copy only the templates required for the current slice from `assets/templates/`. Do not create empty ceremony. The feature spec, question ledger, test plan, gap ledger, and evidence ledger are the usual minimum for material product work.
diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md
index c925ac0..a96f391 100644
--- a/boatstack/references/failure-moves.md
+++ b/boatstack/references/failure-moves.md
@@ -17,6 +17,7 @@ Select a move only after locating the failure below its surface symptom. “Time
| Security/tenancy | Trust boundary or data scope violated | Specialist review; invariant test; deny-by-default guard | Generic prompt mistaken for enforcement |
| Integration/deploy | Local pass but runtime fails | Environment parity; canary; health checks; rollback | Treating staging as identical to production |
| Documentation drift | Durable behavior and docs disagree | Update source-of-truth artifact; drift check | Growing instructions with unverified rules |
+| Irreversible recovery escalation | A failed external operation causes authority/target broadening or an invented reset | Immutable pre-execution deny; preserve state; read-only diagnosis; transactional retry or fix forward | False denial of legitimate isolated development operations |
## Lessons encoded from the benchmark campaign
@@ -27,6 +28,7 @@ Select a move only after locating the failure below its surface symptom. “Time
- **Development promotion is not product promotion.** A +7 point development result became a statistical wash on the full distribution. Representative evaluation and holdout remain mandatory.
- **Do not discard near-correct work.** Repair attempts can wash or regress, so retain prior evidence and compare states.
- **Model changes relocate the bottleneck.** The same harness exposed different binding modes on Gemini and Qwen. Route moves by measured failure population, not by a universal “best loop.”
+- **Tool failure must not create recovery authority.** The sanitized database incident moved from a partial schema apply failure to an invented reset path. The irreversible-operation guard is `PROPOSED`, not promoted: evaluate its deny corpus, safe corpus, latency, and workflow regressions against the unguarded baseline.
## Move proposal schema
diff --git a/boatstack/references/irreversible-operation-boundary.md b/boatstack/references/irreversible-operation-boundary.md
new file mode 100644
index 0000000..42ad194
--- /dev/null
+++ b/boatstack/references/irreversible-operation-boundary.md
@@ -0,0 +1,35 @@
+# Irreversible-operation boundary
+
+Boatstack removes high-confidence irreversible external side effects from the coding agent's reachable action space. It does not restrict ordinary implementation choices.
+
+## Immutable policy
+
+The guard always denies:
+
+- database or schema drops, truncation, resets, flushes, destructive downgrades, clean restores, and unbounded deletes or updates;
+- recursive removal of repository, home, root, parent, or wildcard targets;
+- destructive Git cleanup, hard resets, and forced remote-history replacement;
+- cloud, project, database, cluster, namespace, or volume destruction;
+- disabling recovery or deleting backups and snapshots.
+
+There is no break-glass token or in-session override. Intentional destructive recovery belongs to a separately controlled operator surface outside Boatstack. Agents may edit source that describes a dangerous operation for review, but may not execute it; an operational diff containing that capability blocks build activation and subsequent gates until it is removed or transferred to the operator boundary.
+
+## Failure response
+
+After an external-write failure:
+
+1. preserve the partial state;
+2. use read-only inspection to establish the exact target and failure;
+3. stop rather than widen credentials, targets, or authority;
+4. retry only when the operation is transactional and retry-safe, otherwise fix forward;
+5. record the failure and recovery evidence.
+
+Planning declares each external side effect with its kind, immutable target identity, reversibility, failure policy, and `destructive: false`. Test evidence must independently prove target selection and transactional or fix-forward behavior.
+
+## 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.
+
+## Evaluation status
+
+This guard is a **PROPOSED** Move. Existing benchmark evidence supports deterministic protocol enforcement over stronger prompting, and a sanitized database incident establishes the target mechanism: failed external operation -> scope drift -> invented destructive recovery. The exact guard is not promoted until paired evaluation demonstrates zero destructive executions, retained safe diagnostics and transactional operations, bounded latency, no secret-bearing denial logs, and no workflow regression.
diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md
index 7c0f502..5341eed 100644
--- a/boatstack/references/workflow.md
+++ b/boatstack/references/workflow.md
@@ -25,6 +25,14 @@ Each transition emits an artifact and evidence. A host adapter may change how a
The `SOURCE_PLAN` file is required from entry through completion of `BUILD`. After build, its path and hash remain recorded for provenance, but `TEST_GATE`, `REVIEW_GATE`, and `SHIP_GATE` do not require the original file to be present.
+## Irreversible-operation boundary
+
+Every installed host routes supported shell and MCP events through Boatstack's immutable safety guard. High-confidence database, filesystem, Git-history, cloud-resource, and recovery destruction is always denied before execution. There is no prompt, approval reply, break-glass token, or in-session override. Source may be edited for review, but executable destructive capability blocks activation and gate progression until it is removed or moved to an operator-owned process.
+
+After an external-write failure, preserve state and use only read-only diagnosis. Do not escalate privileges, broaden the target, or invent a reset. Use a transactional retry only when retry safety is demonstrated; otherwise stop and fix forward. Destructive recovery is operator-only outside Boatstack. See `irreversible-operation-boundary.md` for the classified operations and evaluation status.
+
+Hooks are defense in depth rather than a complete sandbox. Protected systems still require least-privilege credentials, scoped service roles, backups, and service-side destructive approval. A missing, drifted, or failing helper denies execution and requires reinstall or repair.
+
## User-facing response contract
Helper commands and state labels are internal control machinery. Every normal response uses:
@@ -131,6 +139,8 @@ Create tasks in dependency order. Each task names:
- rollback boundary;
- unknowns that would stop implementation.
+An external-write task also names `affected_paths` and a compact `side_effects` record: operation kind, immutable target identity, reversibility, failure policy, and `destructive: false`. Ambiguous targets such as “local database” and rollback text such as “reset local DB” block approval. Ordinary tasks do not need side-effect ceremony.
+
Run only relevant review lenses:
- product/taste: value, scope, user journey, non-goals;
@@ -184,6 +194,8 @@ Implement one coherent task slice at a time. After each slice:
4. record deviations or new unknowns;
5. continue, ask, or re-plan explicitly.
+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.
+
### `BUILD -> TEST_GATE`
Crossing this boundary ends the requirement to keep loading or checking the source Plan-mode file. Its recorded path and hash preserve provenance. Subsequent gates judge the approved intent against the actual diff and evidence.
@@ -199,6 +211,8 @@ Create requirement-to-evidence traceability. Use this evidence ladder:
The riskier the slice, the less acceptable same-model, self-authored tests are as the only oracle.
+External-write evidence must establish immutable target identity, transactional or fix-forward behavior, and an independent safety oracle. A dry run that only prints the intended command does not prove the live target or failure behavior.
+
### `TEST_GATE -> REVIEW_GATE`
Review only after required mechanical checks pass, unless reviewing a failure is the goal. The reviewer inspects the actual diff and reports findings by severity with file/line evidence, consequence, and correction.
diff --git a/boatstack/safety.go b/boatstack/safety.go
new file mode 100644
index 0000000..1cf12cd
--- /dev/null
+++ b/boatstack/safety.go
@@ -0,0 +1,414 @@
+package boatstack
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+// SafetyFinding is intentionally small and secret-free. The guard reports the
+// class and a stable explanation, never the full command or tool arguments.
+type SafetyFinding struct {
+ Category string `json:"category"`
+ Reason string `json:"reason"`
+ Source string `json:"source,omitempty"`
+}
+
+type SafetyReport struct {
+ Status string `json:"status"`
+ Findings []SafetyFinding `json:"findings"`
+}
+
+type SafetyHookOptions struct {
+ Host string
+ Repo string
+ Input []byte
+}
+
+var readOnlyStage = regexp.MustCompile(`(?i)^\s*(?:env\s+[^ ]+\s+)*(?:rg|grep|git\s+(?:grep|diff|status|show|log)|cat|sed|head|tail|less|find\s+[^\n]*-(?:print|ls)|psql\s+[^\n]*\s-c\s+["']?\s*select\b)`)
+
+var irreversiblePatterns = []struct {
+ category string
+ reason string
+ pattern *regexp.Regexp
+}{
+ {"database-destruction", "database or schema destruction is operator-only", regexp.MustCompile(`(?is)\bdrop\s+(?:database|schema|table)\b|\balter\s+table\b[^;\n]*\bdrop\s+(?:column|constraint)\b|\btruncate(?:\s+table)?\b|\bdrop\s+schema\b[^;\n]*\bcascade\b`)},
+ {"database-reset", "database reset, flush, or destructive downgrade is operator-only", regexp.MustCompile(`(?i)(?:--reset-public\b|\b(?:supabase\s+db\s+reset|prisma\s+migrate\s+reset|rails\s+db:(?:drop|reset)|django-admin\s+flush|manage\.py\s+flush|alembic\s+downgrade\s+base|pg_restore\b[^\n]*\s--clean\b))`)},
+ {"filesystem-destruction", "recursive deletion of a broad or protected path is denied", regexp.MustCompile(`(?i)\b(?:rm\s+-[^\n;]*(?:r[^\n;]*f|f[^\n;]*r)|remove-item\s+[^\n;]*-recurse[^\n;]*-force)\s+(?:["']?(?:/|~|\$home|\$HOME|\.|\.\.)["']?\s*(?:;|&&|\|\||$)|[^\s;]*\*[^\s;]*)`)},
+ {"git-history-destruction", "destructive Git cleanup or history replacement is denied", regexp.MustCompile(`(?i)\bgit\s+(?:reset\s+--hard\b|clean\s+-[^\s]*(?:f[^\s]*d|d[^\s]*f|x)[^\s]*|push\b[^\n]*(?:--force(?:-with-lease)?|-f\b))`)},
+ {"infrastructure-destruction", "cloud or infrastructure destruction is operator-only", regexp.MustCompile(`(?i)\b(?:terraform|tofu|pulumi)\s+destroy\b|\bkubectl\s+delete\s+(?:namespace|cluster|persistentvolume|persistentvolumeclaim|pvc)\b|\bdocker\s+volume\s+(?:rm|prune)\b|\bgcloud\s+(?:projects|sql\s+instances|compute\s+(?:instances|disks))\s+delete\b|\baws\s+[^\n]*(?:delete-cluster|delete-db-instance|terminate-instances|delete-volume|delete-bucket)\b`)},
+ {"recovery-destruction", "backup deletion or recovery disablement is operator-only", regexp.MustCompile(`(?i)\b(?:delete|remove|disable)\b[^\n;]*(?:backup|snapshot|point-in-time|pitr|recovery)\b`)},
+}
+
+var operationalPathPattern = regexp.MustCompile(`(?i)(?:^|/)(?:scripts?|migrations?|schema|database|db|deploy|infra|ops|terraform|k8s)(?:/|$)|\.(?:sql|ps1|sh|bash|py)$`)
+var mutationStatementPattern = regexp.MustCompile(`(?is)\b(?:delete\s+from|update\s+[^\s;]+)\b[^;]*`)
+
+func classifySafetyText(value, source string) []SafetyFinding {
+ if isPureReadOnlyCommand(value) {
+ return nil
+ }
+ findings := []SafetyFinding{}
+ seen := map[string]bool{}
+ for _, rule := range irreversiblePatterns {
+ if rule.pattern.MatchString(value) && !seen[rule.category] {
+ seen[rule.category] = true
+ findings = append(findings, SafetyFinding{Category: rule.category, Reason: rule.reason, Source: source})
+ }
+ }
+ for _, statement := range mutationStatementPattern.FindAllString(strings.ToLower(value), -1) {
+ normalized := " " + strings.Join(strings.Fields(statement), " ") + " "
+ if !strings.Contains(normalized, " where ") {
+ findings = append(findings, SafetyFinding{Category: "unbounded-data-mutation", Reason: "unbounded data deletion or update is denied", Source: source})
+ break
+ }
+ }
+ return findings
+}
+
+// isPureReadOnlyCommand recognizes a deliberately narrow diagnostic surface.
+// Every pipeline stage must itself be read-only, and compound shell syntax is
+// rejected. Quoted search patterns may name dangerous operations without
+// turning the diagnostic search into an executable capability.
+func isPureReadOnlyCommand(value string) bool {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" || strings.ContainsAny(trimmed, "\n`><") || strings.Contains(trimmed, "$(") ||
+ strings.Contains(trimmed, ";") || strings.Contains(trimmed, "&&") || strings.Contains(trimmed, "||") || strings.Contains(trimmed, "<<") {
+ return false
+ }
+ stages, ok := shellPipelineStages(trimmed)
+ if !ok {
+ return false
+ }
+ for _, stage := range stages {
+ if !readOnlyStage.MatchString(strings.TrimSpace(stage)) {
+ return false
+ }
+ }
+ return true
+}
+
+func shellPipelineStages(value string) ([]string, bool) {
+ stages := []string{}
+ start := 0
+ var quote rune
+ escaped := false
+ for index, char := range value {
+ if escaped {
+ escaped = false
+ continue
+ }
+ if char == '\\' && quote != '\'' {
+ escaped = true
+ continue
+ }
+ if quote != 0 {
+ if char == quote {
+ quote = 0
+ }
+ continue
+ }
+ if char == '\'' || char == '"' {
+ quote = char
+ continue
+ }
+ if char == '|' {
+ stages = append(stages, value[start:index])
+ start = index + 1
+ }
+ }
+ if quote != 0 || escaped {
+ return nil, false
+ }
+ stages = append(stages, value[start:])
+ return stages, true
+}
+
+func safeRepositoryPath(repo, candidate string) (string, bool) {
+ candidate = strings.Trim(candidate, "\"'`;,()[]{}")
+ if candidate == "" || strings.HasPrefix(candidate, "-") {
+ return "", false
+ }
+ ext := strings.ToLower(filepath.Ext(candidate))
+ if ext != ".py" && ext != ".sh" && ext != ".bash" && ext != ".ps1" && ext != ".sql" {
+ return "", false
+ }
+ path := candidate
+ if !filepath.IsAbs(path) {
+ path = filepath.Join(repo, filepath.FromSlash(candidate))
+ }
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return "", false
+ }
+ rel, err := filepath.Rel(repo, abs)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return "", false
+ }
+ info, err := os.Lstat(abs)
+ if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
+ return "", false
+ }
+ return abs, true
+}
+
+func invokedRepositoryFiles(repo, command string) []string {
+ paths := []string{}
+ seen := map[string]bool{}
+ for _, token := range strings.Fields(command) {
+ if path, ok := safeRepositoryPath(repo, token); ok && !seen[path] {
+ seen[path] = true
+ paths = append(paths, path)
+ }
+ }
+ return paths
+}
+
+func ClassifyCommand(repo, command string) []SafetyFinding {
+ if strings.TrimSpace(command) == "" {
+ return []SafetyFinding{{Category: "malformed-tool-input", Reason: "empty shell input is denied by the fail-closed guard", Source: "tool-input"}}
+ }
+ findings := classifySafetyText(command, "command")
+ if regexp.MustCompile(`(?i)\b(?:rm\s+-[^\n;]*(?:r[^\n;]*f|f[^\n;]*r)|remove-item\s+[^\n;]*-recurse[^\n;]*-force)\b`).MatchString(command) && strings.Contains(command, repo) {
+ findings = append(findings, SafetyFinding{Category: "filesystem-destruction", Reason: "recursive deletion of the repository is denied", Source: "command"})
+ }
+ if len(findings) > 0 || isPureReadOnlyCommand(command) {
+ return dedupeFindings(findings)
+ }
+ for _, token := range strings.Fields(command) {
+ candidate := strings.Trim(token, "\"'`;,()[]{}")
+ if ext := strings.ToLower(filepath.Ext(candidate)); ext != ".py" && ext != ".sh" && ext != ".bash" && ext != ".ps1" && ext != ".sql" {
+ continue
+ }
+ path := candidate
+ if !filepath.IsAbs(path) {
+ path = filepath.Join(repo, filepath.FromSlash(path))
+ }
+ if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
+ return []SafetyFinding{{Category: "symlink-entrypoint", Reason: "an invoked repository entrypoint is a symlink and cannot be inspected safely", Source: filepath.Base(path)}}
+ }
+ }
+ for _, path := range invokedRepositoryFiles(repo, command) {
+ value, err := os.ReadFile(path)
+ if err != nil {
+ return []SafetyFinding{{Category: "unreadable-entrypoint", Reason: "an invoked repository entrypoint could not be inspected", Source: filepath.Base(path)}}
+ }
+ relative, relErr := filepath.Rel(repo, path)
+ if relErr != nil {
+ relative = filepath.Base(path)
+ }
+ findings = append(findings, classifySafetyText(string(value), filepath.ToSlash(relative))...)
+ }
+ return dedupeFindings(findings)
+}
+
+func ClassifyTool(repo, name string, input any) []SafetyFinding {
+ if strings.EqualFold(name, "Bash") || strings.EqualFold(name, "Shell") || strings.EqualFold(name, "beforeShellExecution") {
+ if object, ok := input.(map[string]any); ok {
+ return ClassifyCommand(repo, stringValue(object["command"]))
+ }
+ }
+ value, err := json.Marshal(input)
+ if err != nil {
+ return []SafetyFinding{{Category: "malformed-tool-input", Reason: "tool arguments could not be inspected", Source: "tool-input"}}
+ }
+ combined := name + " " + string(value)
+ findings := classifySafetyText(combined, "tool-input")
+ nameLower := strings.ToLower(name)
+ if regexp.MustCompile(`(?:delete|destroy|reset|drop|truncate|terminate)`).MatchString(nameLower) && regexp.MustCompile(`(?:database|schema|project|cluster|namespace|volume|bucket|backup|snapshot|instance)`).MatchString(strings.ToLower(combined)) {
+ findings = append(findings, SafetyFinding{Category: "external-resource-destruction", Reason: "destructive external-resource tools are operator-only", Source: "tool-input"})
+ }
+ return dedupeFindings(findings)
+}
+
+func dedupeFindings(values []SafetyFinding) []SafetyFinding {
+ seen := map[string]bool{}
+ result := []SafetyFinding{}
+ for _, value := range values {
+ key := value.Category + "\x00" + value.Source
+ if !seen[key] {
+ seen[key] = true
+ result = append(result, value)
+ }
+ }
+ sort.Slice(result, func(i, j int) bool {
+ if result[i].Category == result[j].Category {
+ return result[i].Source < result[j].Source
+ }
+ return result[i].Category < result[j].Category
+ })
+ return result
+}
+
+func hookToolInput(host string, value []byte) (string, any, error) {
+ var event map[string]any
+ if err := json.Unmarshal(value, &event); err != nil {
+ return "", nil, fmt.Errorf("invalid hook JSON")
+ }
+ if host == "cursor" {
+ command := stringValue(event["command"])
+ if command == "" {
+ if input, ok := event["tool_input"].(map[string]any); ok {
+ command = stringValue(input["command"])
+ }
+ }
+ if command != "" {
+ 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
+ }
+ name := stringValue(event["tool_name"])
+ input := event["tool_input"]
+ if name == "" || input == nil {
+ return "", nil, fmt.Errorf("%s hook input is missing tool_name or tool_input", host)
+ }
+ return name, input, nil
+}
+
+func denialMessage(finding SafetyFinding) string {
+ return "Boatstack denied an irreversible operation (" + finding.Category + "). Preserve the current state and use read-only diagnosis or fix-forward recovery; destructive recovery is operator-only outside the agent workflow."
+}
+
+func HookDecision(options SafetyHookOptions) ([]byte, bool) {
+ host := strings.ToLower(strings.TrimSpace(options.Host))
+ if host != "cursor" && host != "claude" && host != "codex" {
+ finding := SafetyFinding{Category: "unsupported-host", Reason: "unknown host is denied by the fail-closed guard", Source: "hook"}
+ value, _ := hookDenyJSON("codex", finding)
+ return value, true
+ }
+ repo, err := ResolveRepository(options.Repo)
+ if err != nil {
+ finding := SafetyFinding{Category: "unresolved-repository", Reason: "repository identity could not be established", Source: "hook"}
+ value, _ := hookDenyJSON(host, finding)
+ return value, true
+ }
+ name, input, err := hookToolInput(host, options.Input)
+ if err != nil {
+ finding := SafetyFinding{Category: "malformed-tool-input", Reason: err.Error(), Source: "hook"}
+ value, _ := hookDenyJSON(host, 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, _ := hookDenyJSON(host, 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) != "" {
+ if head, headErr := gitCommand(repo, "branch", "--show-current"); headErr == nil && head != defaultBranch {
+ if baseCommit, baseErr := resolveBaseCommit(repo, defaultBranch); baseErr == nil {
+ if mergeBase, mergeErr := gitCommand(repo, "merge-base", baseCommit, "HEAD"); mergeErr == nil && mergeBase != "" {
+ diffStart = mergeBase
+ }
+ }
+ }
+ }
+ command := exec.Command("git", "-C", repo, "diff", "--name-only", "--diff-filter=ACMR", diffStart)
+ value, err := command.Output()
+ if err != nil {
+ return nil, err
+ }
+ untrackedCommand := exec.Command("git", "-C", repo, "ls-files", "--others", "--exclude-standard")
+ untracked, err := untrackedCommand.Output()
+ if err != nil {
+ return nil, err
+ }
+ paths := []string{}
+ seen := map[string]bool{}
+ all := strings.TrimSpace(string(value)) + "\n" + strings.TrimSpace(string(untracked))
+ for _, path := range strings.Split(all, "\n") {
+ path = filepath.ToSlash(strings.TrimSpace(path))
+ if path == "" || seen[path] {
+ continue
+ }
+ seen[path] = true
+ matched := operationalPathPattern.MatchString(path)
+ for _, pattern := range highRisk {
+ if ok, _ := filepath.Match(filepath.FromSlash(pattern), filepath.FromSlash(path)); ok {
+ matched = true
+ }
+ prefix := strings.TrimSuffix(filepath.ToSlash(pattern), "/**")
+ if prefix != pattern && strings.HasPrefix(path, prefix+"/") {
+ matched = true
+ }
+ }
+ if matched {
+ paths = append(paths, path)
+ }
+ }
+ return paths, nil
+}
+
+func CheckRepositorySafety(repoPath string) (SafetyReport, error) {
+ repo, err := ResolveRepository(repoPath)
+ if err != nil {
+ return SafetyReport{}, err
+ }
+ highRisk := []string{}
+ defaultBranch := ""
+ configPath := filepath.Join(repo, ".product-loop", "project.json")
+ if value, readErr := os.ReadFile(configPath); readErr == nil {
+ var config ProjectConfig
+ if json.Unmarshal(value, &config) == nil {
+ highRisk = config.Project.HighRiskPaths
+ defaultBranch = config.Project.DefaultBranch
+ }
+ }
+ paths, err := operationalChangedFiles(repo, highRisk, defaultBranch)
+ if err != nil {
+ return SafetyReport{}, err
+ }
+ findings := []SafetyFinding{}
+ for _, relative := range paths {
+ value, readErr := os.ReadFile(filepath.Join(repo, filepath.FromSlash(relative)))
+ if readErr != nil {
+ return SafetyReport{}, readErr
+ }
+ findings = append(findings, classifySafetyText(string(value), relative)...)
+ }
+ findings = dedupeFindings(findings)
+ status := "PASS"
+ if len(findings) > 0 {
+ status = "BLOCKED"
+ }
+ return SafetyReport{Status: status, Findings: findings}, nil
+}
diff --git a/boatstack/safety_test.go b/boatstack/safety_test.go
new file mode 100644
index 0000000..cc0ff17
--- /dev/null
+++ b/boatstack/safety_test.go
@@ -0,0 +1,227 @@
+package boatstack
+
+import (
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func safetyTestRepo(t *testing.T) string {
+ t.Helper()
+ repo := t.TempDir()
+ runGit(t, repo, "init", "-b", "main")
+ runGit(t, repo, "config", "user.name", "Boatstack Test")
+ runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
+ if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# Safety fixture\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, repo, "add", ".")
+ runGit(t, repo, "commit", "-m", "base")
+ return repo
+}
+
+func TestIrreversibleCommandCorpusIsDenied(t *testing.T) {
+ repo := safetyTestRepo(t)
+ cases := map[string]string{
+ "schema drop": `psql -c "DROP SCHEMA public CASCADE"`,
+ "truncate": `psql -c "TRUNCATE TABLE accounts"`,
+ "unbounded delete": `psql -c "DELETE FROM accounts"`,
+ "multiline update": "psql <<'SQL'\nUPDATE accounts\nSET active = false;\nSQL",
+ "database reset": `supabase db reset`,
+ "recursive root": `rm -rf /`,
+ "wildcard deletion": `rm -rf build/*`,
+ "compound pipeline": `rg reset scripts | rm -rf .`,
+ "subshell": `echo $(git reset --hard HEAD~1)`,
+ "environment prefix": `TARGET=dev sh -c 'DROP SCHEMA public CASCADE'`,
+ "hard reset": `git reset --hard HEAD~1`,
+ "force push": `git push --force origin main`,
+ "cloud deletion": `gcloud sql instances delete primary`,
+ "namespace deletion": `kubectl delete namespace production`,
+ "volume deletion": `docker volume rm data-volume`,
+ "backup deletion": `aws rds delete-db-snapshot --db-snapshot-identifier backup-1`,
+ "powershell": `Remove-Item -Recurse -Force $HOME`,
+ }
+ for name, command := range cases {
+ t.Run(name, func(t *testing.T) {
+ if findings := ClassifyCommand(repo, command); len(findings) == 0 {
+ t.Fatalf("dangerous command was allowed: %s", command)
+ }
+ })
+ }
+}
+
+func TestInvokedSymlinkFailsClosed(t *testing.T) {
+ repo := safetyTestRepo(t)
+ target := filepath.Join(repo, "target.py")
+ link := filepath.Join(repo, "run.py")
+ if err := os.WriteFile(target, []byte("print('safe')\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(target, link); err != nil {
+ t.Skipf("symlinks unavailable: %v", err)
+ }
+ findings := ClassifyCommand(repo, "python run.py")
+ if len(findings) == 0 || findings[0].Category != "symlink-entrypoint" {
+ t.Fatalf("invoked symlink did not fail closed: %#v", findings)
+ }
+}
+
+func TestSafeDiagnosticsAndFixForwardCommandsRemainAllowed(t *testing.T) {
+ repo := safetyTestRepo(t)
+ safeScript := filepath.Join(repo, "scripts", "apply_schema.py")
+ if err := os.MkdirAll(filepath.Dir(safeScript), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ fixture, err := os.ReadFile("testdata/safety/safe_apply.py.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(safeScript, fixture, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ commands := []string{
+ `rg -n "reset-public|DROP SCHEMA public CASCADE" scripts/apply_schema.py`,
+ `git diff -- scripts/apply_schema.py | head -20`,
+ `python scripts/apply_schema.py --dry-run`,
+ `psql -c "SELECT current_database()"`,
+ }
+ for _, command := range commands {
+ if findings := ClassifyCommand(repo, command); len(findings) != 0 {
+ t.Fatalf("safe command %q was denied: %#v", command, findings)
+ }
+ }
+}
+
+func TestInvokedRepositoryScriptIsInspected(t *testing.T) {
+ repo := safetyTestRepo(t)
+ path := filepath.Join(repo, "scripts", "apply_schema.py")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ unsafe, err := os.ReadFile("testdata/safety/unsafe_apply.py.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, unsafe, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ findings := ClassifyCommand(repo, "python scripts/apply_schema.py")
+ if len(findings) == 0 || findings[0].Source != "scripts/apply_schema.py" {
+ t.Fatalf("indirect destructive script was not denied with a repository-relative source: %#v", findings)
+ }
+ if strings.Contains(findings[0].Source, repo) {
+ t.Fatal("guard leaked an absolute repository path")
+ }
+}
+
+func TestMCPAndMalformedEventsFailClosedWithoutEchoingSecrets(t *testing.T) {
+ repo := safetyTestRepo(t)
+ event := []byte(`{"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})
+ if !denied || !strings.Contains(string(output), `"permissionDecision":"deny"`) {
+ t.Fatalf("%s MCP deletion was not denied: %s", host, output)
+ }
+ if strings.Contains(string(output), "secret-value") || strings.Contains(string(output), "primary") {
+ t.Fatalf("%s denial leaked tool arguments: %s", host, output)
+ }
+ }
+ output, denied := HookDecision(SafetyHookOptions{Host: "cursor", Repo: repo, Input: []byte(`{"bad":true}`)})
+ if !denied || !strings.Contains(string(output), `"permission":"deny"`) {
+ t.Fatalf("malformed Cursor event did not fail closed: %s", output)
+ }
+}
+
+func TestBlockedHookNeverCreatesSentinelSideEffect(t *testing.T) {
+ repo := safetyTestRepo(t)
+ sentinel := filepath.Join(repo, "sentinel")
+ command := "rm -rf . && touch " + sentinel
+ event, _ := json.Marshal(map[string]any{"command": command})
+ _, denied := HookDecision(SafetyHookOptions{Host: "cursor", Repo: repo, Input: event})
+ if !denied {
+ if output, err := exec.Command("sh", "-c", command).CombinedOutput(); err != nil {
+ t.Fatalf("sentinel simulation: %v: %s", err, output)
+ }
+ }
+ if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
+ t.Fatal("blocked command created its sentinel side effect")
+ }
+}
+
+func TestOperationalDiffBlocksGateProgression(t *testing.T) {
+ repo := safetyTestRepo(t)
+ path := filepath.Join(repo, "scripts", "recover.sql")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("DROP SCHEMA public CASCADE;\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ report, err := CheckRepositorySafety(repo)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if report.Status != "BLOCKED" || len(report.Findings) == 0 {
+ t.Fatalf("operational destructive capability did not block gates: %#v", report)
+ }
+ if err := os.WriteFile(path, []byte("BEGIN;\nSELECT current_database();\nCOMMIT;\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ report, err = CheckRepositorySafety(repo)
+ if err != nil || report.Status != "PASS" {
+ t.Fatalf("fix-forward operational diff did not pass: %#v %v", report, err)
+ }
+}
+
+func TestConfiguredHighRiskPathsParticipateInSafetyScan(t *testing.T) {
+ repo := safetyTestRepo(t)
+ config := testConfig()
+ config.Project.DefaultBranch = "main"
+ config.Project.HighRiskPaths = []string{"config/operations.txt"}
+ value, err := MarshalJSON(config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(repo, ".product-loop"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, repo, "add", ".product-loop/project.json")
+ runGit(t, repo, "commit", "-m", "configure high-risk path")
+ runGit(t, repo, "switch", "-c", "feat/operations")
+ path := filepath.Join(repo, "config", "operations.txt")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("terraform destroy -auto-approve\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, repo, "add", "config/operations.txt")
+ runGit(t, repo, "commit", "-m", "add operational instruction")
+ report, err := CheckRepositorySafety(repo)
+ if err != nil || report.Status != "BLOCKED" {
+ t.Fatalf("configured high-risk path was not scanned: %#v %v", report, err)
+ }
+}
+
+func TestSafetyGuardLatencyIsBounded(t *testing.T) {
+ repo := safetyTestRepo(t)
+ started := time.Now()
+ for index := 0; index < 1000; index++ {
+ ClassifyCommand(repo, `git status --short`)
+ ClassifyCommand(repo, `psql -c "DROP SCHEMA public CASCADE"`)
+ }
+ if elapsed := time.Since(started); elapsed > 2*time.Second {
+ t.Fatalf("2,000 safety classifications exceeded the 2s fixture bound: %s", elapsed)
+ }
+}
diff --git a/boatstack/testdata/reviewer-pr-body.md b/boatstack/testdata/reviewer-pr-body.md
index 425988d..941b62d 100644
--- a/boatstack/testdata/reviewer-pr-body.md
+++ b/boatstack/testdata/reviewer-pr-body.md
@@ -24,6 +24,10 @@ Repository hooks could not reliably find the project toolchain when an IDE pushe
| Privacy detection remains active with the compact model | Targeted detector scenario | `NOT_VERIFIED` | Current branch test notes |
| Static typing remains clean | Project type-check command | `NOT_VERIFIED` | Current branch test notes |
+## Operational safety
+
+Repository safety scan passed. Destructive recovery remains operator-only.
+
## Security and privacy
The fallback keeps privacy detection enabled and removes an unexpected network/download side effect from the test path.
diff --git a/boatstack/testdata/safety/safe_apply.py.txt b/boatstack/testdata/safety/safe_apply.py.txt
new file mode 100644
index 0000000..0574f9d
--- /dev/null
+++ b/boatstack/testdata/safety/safe_apply.py.txt
@@ -0,0 +1,14 @@
+"""Sanitized fix-forward migrator used by the irreversible-operation tests."""
+
+import argparse
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--dry-run", action="store_true")
+args = parser.parse_args()
+
+expected_target = "project-ref-7f31"
+assert expected_target == configured_target
+
+if not args.dry_run:
+ with connection.transaction():
+ apply_forward_only_changes()
diff --git a/boatstack/testdata/safety/unsafe_apply.py.txt b/boatstack/testdata/safety/unsafe_apply.py.txt
new file mode 100644
index 0000000..38c81df
--- /dev/null
+++ b/boatstack/testdata/safety/unsafe_apply.py.txt
@@ -0,0 +1,9 @@
+"""Sanitized partial-schema migrator that must never be executed by an agent."""
+
+import argparse
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--reset-public", action="store_true")
+
+if parser.parse_args().reset_public:
+ cursor.execute("DROP SCHEMA public CASCADE")
diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md
index 0910b91..ef7568c 100644
--- a/docs/evidence-engineered-coding.md
+++ b/docs/evidence-engineered-coding.md
@@ -90,7 +90,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 **6039 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 **7645 estimated tokens**, while host adapters point to one operation at a time.
## Control appears at transitions
@@ -140,6 +140,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 [`4fee357eb535287be4b172b2af4c2e44939ce196`](https://github.com/operatorstack/intelligence-flow/tree/4fee357eb535287be4b172b2af4c2e44939ce196/examples/12-product-engineering-loop).
+The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`15c2f1faa8f0a0e133dea89983ca08b3aec4787c`](https://github.com/operatorstack/intelligence-flow/tree/15c2f1faa8f0a0e133dea89983ca08b3aec4787c/examples/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/generated-files.md b/docs/generated-files.md
index bd31c1a..8c33a3e 100644
--- a/docs/generated-files.md
+++ b/docs/generated-files.md
@@ -14,6 +14,8 @@ Boatstack creates two different kinds of repository state. Keeping them separate
| `.agents/skills/boatstack/` | Boatstack-generated Codex/open-agent adapter | Commit. |
| `.claude/skills/boatstack/` | Boatstack-generated Claude adapter | Commit. |
| `.github/PULL_REQUEST_TEMPLATE/boatstack.md` | Boatstack-generated PR adapter | Commit. |
+| `.product-loop/hooks/` | Boatstack-generated fail-closed Bash/PowerShell launchers and host fragments | Commit; do not weaken or edit directly. |
+| `.cursor/hooks.json`, `.claude/settings.json`, `.codex/hooks.json` | Merged host configuration | Commit the Boatstack fragment alongside preserved unrelated settings. |
| `.product-loop/bin/` | Machine-local | Do not commit. It contains the verified platform helper and local install lock and is ignored. |
The installation manifest `.product-loop/generated.lock.json` describes generated infrastructure. It is different from a feature's `plan.lock.json`, which proves that a specific approved plan activated without drift.
@@ -46,6 +48,8 @@ Committed adapters remain available after cloning. Restore only the ignored help
Never delete untracked adapters merely to make a feature diff smaller. If Boatstack was installed without committing its infrastructure, stop and create the installation PR first. Run this read-only check whenever commands disappear or generated state looks suspicious:
+`doctor` also verifies that each configured host contains exactly one current Boatstack fragment and that malformed events are denied. Some hosts do not expose whether a repository is trusted or hooks are enabled; confirm that host state separately. The hooks complement, rather than replace, least-privilege external credentials.
+
```bash
.product-loop/bin/boatstack-helper doctor --repo .
```
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 68f1931..21706d5 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -22,6 +22,8 @@ irm https://raw.githubusercontent.com/operatorstack/boatstack/main/install.ps1 |
Choose `core` unless you already want gstack review lenses, Spec Kit artifact generation, or both. Confirm the real test command when asked. The installer previews every path before writing it, verifies the helper checksum, and runs `doctor` after installation.
+The install also merges Boatstack's fail-closed safety fragments into all portable host configurations. Review those fragments in the infrastructure PR. They deny high-confidence irreversible operations on every supported agent call; no approval phrase bypasses them.
+
Review the printed paths and repository facts:
```bash
@@ -154,6 +156,8 @@ Run the gates in order:
- `review-gate` reviews the actual diff and may send the feature back for a local repair.
- `ship-gate` generates a reviewer-ready title and body from the approved intent, actual committed diff, evidence, decisions, and gaps.
+For external writes, the gates also require immutable target identity, transactional or fix-forward failure behavior, an independent safety oracle, and an operational diff with no executable destructive recovery. Operator-only recovery remains outside the feature branch.
+
Boatstack shows the exact title and rendered body before changing GitHub. Reply `open PR` when the branch has no PR. Reply `update PR` when one already exists. It then rechecks the diff and evidence before publication. If anything changed after the preview, Boatstack regenerates it instead of publishing stale claims. Merge and deploy remain separate decisions.
You do not need another slash command for existing work. Ask naturally:
diff --git a/docs/research-and-design.md b/docs/research-and-design.md
index d119d83..3e90f5e 100644
--- a/docs/research-and-design.md
+++ b/docs/research-and-design.md
@@ -98,9 +98,12 @@ Those are **summary-only evidence** in this design. They are not represented as
| The development result did not transfer | Spec-first was a statistical wash on the full board | Promotion requires representative distribution/holdout, not only a tuned dev slice |
| Model change relocated the bottleneck | Same harness exposed near-miss dominance on Gemini and step exhaustion on Qwen | Diagnose the active population each time; do not encode model-specific recipes |
| Mid-run aggregates changed direction | Qwen board interpretation moved as task coverage deepened | Compare paired completed coverage and uncertainty, not early aggregate rank |
+| External failure invited destructive recovery | A sanitized partial schema apply failure led to an invented reset path before review removed it | Treat recovery authority as a deterministic boundary: preserve state, diagnose read-only, transact or fix forward |
Sources: [`RESEARCH_LOG.md`](../11-harbor-submit/RESEARCH_LOG.md), [`EXPERIMENT_GEMINI20_2026-07-15.md`](../11-harbor-submit/EXPERIMENT_GEMINI20_2026-07-15.md), [`ZERO_TO_QWEN.md`](../11-harbor-submit/ZERO_TO_QWEN.md), and [`docs/12-self-verification-fidelity.md`](../../docs/12-self-verification-fidelity.md).
+The irreversible-operation boundary is a **PROPOSED** Move. The incident supports the target failure mechanism, while the benchmark campaign supports deterministic enforcement over stronger wording. Neither proves the new guard's net effect. Promotion requires a paired unguarded baseline, destructive and safe corpora, real host events, bounded latency, secret-free denials, and no workflow regression.
+
## What two example repositories add
Terminal-Bench supplies failure mechanics; the product repositories supply real engineering context.
diff --git a/docs/safety.md b/docs/safety.md
new file mode 100644
index 0000000..39757e9
--- /dev/null
+++ b/docs/safety.md
@@ -0,0 +1,31 @@
+
+
+# Irreversible-operation safety
+
+Boatstack leaves implementation open while removing high-confidence irreversible side effects from the coding agent's reachable action space.
+
+## What is always denied
+
+- database/schema drop, truncate, reset, flush, destructive downgrade, clean restore, and unbounded delete/update;
+- broad recursive deletion of repository, home, root, parent, or wildcard targets;
+- destructive Git cleanup, hard reset, and forced remote-history replacement;
+- cloud, project, database, cluster, namespace, or volume destruction;
+- disabling recovery or deleting backups and snapshots.
+
+There is no break-glass token or approval phrase. Intentional destructive recovery is performed by a human through a separately controlled operator surface outside Boatstack.
+
+## What happens after a failed external write
+
+Boatstack preserves the partial state, allows bounded read-only diagnosis, and blocks authority or target broadening. A retry must be proven transactional and retry-safe; otherwise the plan fixes forward. Planning and evidence identify the immutable target, affected paths, reversibility, failure policy, and an independent safety oracle.
+
+Source may describe dangerous behavior for review. That is not execution permission: invoking such a repository script is denied, and an operational diff containing the capability blocks later gates until it is removed or transferred to the operator boundary.
+
+## How installation enforces it
+
+The installer merges only Boatstack-owned fragments into the repository's Cursor, Claude, and Codex hook configuration and preserves unrelated settings. Portable Bash and PowerShell launchers call the verified repository-local helper. Missing helpers, malformed events, drift, and ambiguous hook collisions fail closed. `doctor` checks fragment integrity, launchers, helper version, and safe/deny smoke behavior.
+
+Host trust or enablement may not be machine-inspectable. Hooks are therefore defense in depth rather than a complete sandbox. [Codex documents incomplete interception for some shell paths](https://learn.chatgpt.com/docs/hooks), [Claude notes that command hooks run with the user's full permissions](https://code.claude.com/docs/en/hooks), and Cursor's pre-shell/pre-MCP hooks still depend on the host loading the project configuration. Protected systems still need least-privilege credentials, scoped service roles, backups, and service-side approval for destructive administration.
+
+## Evidence status
+
+This is a **PROPOSED** Move, not a claim of experimental proof. Existing benchmark results support deterministic enforcement over stronger prompting, and a sanitized partial-schema incident establishes the target failure mechanism. Promotion requires paired evaluation against the unguarded baseline: zero destructive executions, safe diagnostics and transactional operations retained, bounded latency, no secret-bearing denial logs, and no existing-workflow regression.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 4261f0e..d94e0d0 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -2,6 +2,16 @@
# Troubleshooting Boatstack
+## An operation is denied as irreversible
+
+Boatstack has no in-session override. Preserve the current external state and use read-only diagnosis. Remove the destructive capability, replace it with transactional or fix-forward behavior, or move intentional recovery into a separately controlled operator runbook. Do not rename or wrap the command to evade classification.
+
+If a safe diagnostic is denied, retain the denial evidence and report the smallest reproducible command as a false-positive candidate. The Move remains proposed until its false-denial evaluation passes.
+
+## The safety helper or hook is missing or drifted
+
+The hook fails closed. Rerun the official installer from the repository root, run `.product-loop/bin/boatstack-helper doctor --repo .`, reload the host, and inspect its trust/hook enablement. Do not delete the hook to unblock work.
+
## Cursor does not recognize a slash command
Cursor discovers project commands from `.cursor/commands/*.md`. Check the installation:
diff --git a/docs/validation-and-evidence.md b/docs/validation-and-evidence.md
index 10a7f09..ec7b8dc 100644
--- a/docs/validation-and-evidence.md
+++ b/docs/validation-and-evidence.md
@@ -2,6 +2,10 @@
# Validation and evidence
+## External-write safety evidence
+
+External-write claims require more than a successful happy path. Record the immutable target identity, transaction or fix-forward behavior, and an oracle independent of the implementation. The operational diff must contain no agent-executable reset, drop, broad delete, or destructive recovery. A `--dry-run` is useful, but cannot by itself prove which live target was selected or how a partial failure recovers.
+
Boatstack separates producing a change from proving a claim about that change. A successful command is evidence only when its relationship to an approved requirement and a falsifiable oracle is explicit.
```text
diff --git a/examples/diagram-json/plan.lock.json b/examples/diagram-json/plan.lock.json
index 2efa0ab..c6df2e2 100644
--- a/examples/diagram-json/plan.lock.json
+++ b/examples/diagram-json/plan.lock.json
@@ -6,7 +6,7 @@
"plan_path": "examples/diagram-json/plan.md",
"plan_sha256": "3ad35cc3cbe48306e7ee401bd9e9047d25e46c8a6fe9679aa1b3f5e96ceea292",
"schema_version": 1,
- "source_commit": "4fee357eb535287be4b172b2af4c2e44939ce196",
+ "source_commit": "15c2f1faa8f0a0e133dea89983ca08b3aec4787c",
"source_plan_path": "examples/diagram-json/source-plan.md",
"source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b",
"spec_path": "examples/diagram-json/spec.md",