diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fbcbded..7de1bc8 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/f671638f234faa96ea85336e9a76b164020df172/labs/12-product-engineering-loop). +Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4/labs/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/UPSTREAM.json b/UPSTREAM.json index 5f87173..52ad814 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "d5e99e49316655e93981e74df8a3b1cdc39dc656971574a6eb2d55e2d316b107", + "CONTRIBUTING.md": "f4661ce261ab80742282fcfe23bc42a1f155dc0ea4a2484ff6333493e2d059d1", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -40,9 +40,9 @@ "boatstack/capture_test.go": "63fa1177738081f1e862364d7a4257f5e259f8e9c36276ba1775b8085b277105", "boatstack/changelog.go": "6b06be7cd9738de29ba6e87aa2569f3b027a2e618b04524f5abd7abaa17945bf", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "8ece156fa5c341e5ceb41d0a7abe4b7da378a0db9244bdb25b348b02e2c39b0a", + "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "aff3bbada81820f9b77c0f4339c6e78e6489e1b82176b57129b5102664ada5a8", "boatstack/cmd/boatstack-helper/flow.go": "5ab24541d3f85c2f442730d3122d18eb6676600bc11fde4805e803a25a8300c7", - "boatstack/cmd/boatstack-helper/main.go": "a4a29e53d803cd1d8ec35e105bab17000e2855f978393031f1516aab26c732b6", + "boatstack/cmd/boatstack-helper/main.go": "7575dfbf03fd1ca0f7f3f5d519750a5b1fa81ff02a89547c053d94769e580b1b", "boatstack/cmd/boatstack-helper/main_test.go": "b36c52d6d5c9dd2428730de10ff18194b7e32a98722e41341c301c6f7a04cad5", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", @@ -57,6 +57,8 @@ "boatstack/delivery_reactivation_test.go": "573a2dba0034bc4290478414e3bdd8670b06a326128eb0295d77e748ecc8689e", "boatstack/delivery_test.go": "45c48ff7581c911bcaf821c3e4241d4ae2a9bb4aa682485cc58b6ad8fe1c85bf", "boatstack/deliverycontrol_parity_test.go": "f8662cfc35043395a0e1eef8a87051c2120752f38b09c56b78f82896008f1b65", + "boatstack/denial.go": "10ac51d100ae5d6cd167d63d7c1c7721c96e09c29e6290177a9bda13b61427f9", + "boatstack/denial_test.go": "c480c0b2a489838b22b4d5ec20cc30ef1859cc0820a75974bc56a46c31f90041", "boatstack/docs/control-law-scoping.md": "0ae984821248eabda8c0eeaf201b367991e6742984e7c718df20ecc24caee475", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", "boatstack/export.go": "b3e28b571024b1b7a97b28f226f7c89734c95dcf1854c3d1a6dc672f34d4ded7", @@ -78,7 +80,7 @@ "boatstack/flow_trace_test.go": "99f89a831e904f6a8ef710b6977ed3a808ce1c7ddfaba457b292d84f2ddca51b", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", - "boatstack/hooks.go": "2cec5b1dd7f86c18421b54514fb83700833fb916ea1dffe82e96cfc3e59da234", + "boatstack/hooks.go": "a3881c69dd88025c914ebaaa43362a2b1c47565f0ec16074e078d16cb6cfb853", "boatstack/hooks_hydrate_test.go": "7beeb26b2b1398741e8a28963a9686e974047016cc736f233024004add1afc32", "boatstack/hooks_test.go": "fb75e3aabf2204871b3e6d16de98d26fb33b0ec19e41aae761cf1f34397c31f4", "boatstack/hydrate_runtime_test.go": "dbd5eae2ba85701e4af0430ba3a0d70ea98e028b66992bd4fc05f3f582398627", @@ -139,7 +141,7 @@ "boatstack/references/artifacts.md": "5fa888ac519085d65cee1d04df5902761651bcf2d7af81711fa0f8ecd1fc0f59", "boatstack/references/config-schema.md": "0170b90f1d0a592f58e255ffeff642fa037676042443f74a0f1b6e39be5dbbb8", "boatstack/references/failure-moves.md": "b65ef72035afa6ad0dce589a0b38f84bc40cde3864c9ecf973f08fc687f001c3", - "boatstack/references/host-hook-contracts.md": "d68ae1556e7b1e29e9ac7cb4db767809d510aabf0be52e60e44665ea7abb980e", + "boatstack/references/host-hook-contracts.md": "2a89d44d0e418a53f2e3b6300fed957cdf878f45ea97ce24b55b66065f0eaa1d", "boatstack/references/irreversible-operation-boundary.md": "e0076f0fea3bf729b2e9bdf353eaeaaf7cdafabfaf26b8d9b27287e5414c2441", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", "boatstack/references/workflow.md": "81da3ea831ef244eb03b09e1989052b6f52db816e3d65be98da3ab9d5cc31969", @@ -152,7 +154,7 @@ "boatstack/runtime_cache.go": "89834409b426dce292fd810a091de1433fefbfba9249d8c1e31ccc40f0d5dbd6", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", "boatstack/runtime_provenance_test.go": "1d52f1e6b0691cf4667729cc9b9f3c55c128f0aa3321f3a2843a9aa6fd0e73dc", - "boatstack/safety.go": "24df06e3c0f5dd54ee361ebf7bfd2deca7b18193c2d58d1eb9331c012d342eda", + "boatstack/safety.go": "5ea64b051d5409b1a4a1731135f95afae31465ea328722fbb8b193260a663de3", "boatstack/safety_test.go": "01f28bc3bfcb6bdd47b307e309e36bbc1921b6426ad0b777d81fe4131200c37e", "boatstack/safety_update_publisher_test.go": "ed3f8187036623694dfe7c395cdae00fdae14609bab6124d1fdfc6fe73fa2196", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", @@ -177,10 +179,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "df054f49d532c8b1b7d94184810d1b3b5bf18cdc30eb985b4b6d0639162e341a", - "docs/evidence-engineered-coding.md": "67c93b4ba927fca0e58ca08e34769f2ee554742ddda9738cb7503df01a5ec043", + "docs/evidence-engineered-coding.md": "3c7c1ba355eb11306ed0990b4fbf7b139bd733dd356fd596c0a81bed15460ae3", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "1dd4f4e2e636cc5adfc2f79939629701e171087c3d5e558cf919548b9224adfd", - "docs/public-claims.json": "de8052214b9038f0c61140f07d588fcce77b822d75310115718daa910e72652e", + "docs/public-claims.json": "388c9d265b989d94075ca0d3fbe237414d8cfc9bc3edf5746552d42049321f3b", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -194,7 +196,7 @@ "labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71", "labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39", - "labs/diagram-json/plan.lock.json": "e3690d98b9e3763e88c62dd12c04894c2c5cd733135b27413a3bd0e65db0d5a2", + "labs/diagram-json/plan.lock.json": "9a1c7fded38aaa2c498f9cfb18de9cc7ee2428f299052f266831bd1bee63b9f7", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -298,6 +300,7 @@ "release-notes/2026-07-25-root-cause-operation.md": "5bf1f082e9123c5a7bcc8bc01b12e97b24b5ae15958577b4ff2a358994fca891", "release-notes/2026-07-25-runtime-simplified-technical-english.md": "917fbfb51ae56e5c6e0d9c705b84da492ef3b8f8782ea4b62635814259f11bb4", "release-notes/2026-07-25-update-publish-guard-unblock.md": "adf06ee02b8d3c995525bb9673c2f1fea66a147df8751d65885ced83da0e96e2", + "release-notes/2026-07-26-calm-denials.md": "9e4a5fc23b02500cf2f124cb462a8d863ed953a899c9485a81e4971dd89c9576", "release-notes/2026-07-26-guard-etxtbsy-retry.md": "4238591804be62f8b9a76dd5cda18923af60ef67932d40d70cab0d815124bcaf", "release-notes/2026-07-26-guard-hydrate-double-check.md": "83a5591aba6bf30c9f4008ba8d26bf1994ef3fd61145f46fcdd1678912b9990b", "release-notes/2026-07-26-hidden-jflow-design-note.md": "f60ed9dbbfb46a172ac9d33dd758a3166f820007b1673029f29f0fbefa0e5c0a" @@ -305,7 +308,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "f671638f234faa96ea85336e9a76b164020df172", + "commit": "dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go index c7c5bf1..8ce4014 100644 --- a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go +++ b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go @@ -55,6 +55,7 @@ var nonDeliveryVerbs = map[string]bool{ "check-safety": true, "doctor": true, "diagnose-hook": true, + "render-denial": true, "workspace-status": true, // Evidence / capability substrate (a separate tenant, not the delivery graph). "record-pr-visual-evidence": true, diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index ba66165..6aa9ea6 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -15,7 +15,7 @@ import ( ) func fail(err error) int { - fmt.Fprintln(os.Stderr, "BLOCKED:", err) + fmt.Fprintln(os.Stderr, boatstack.FormatBlocked(os.Stderr, err.Error())) return 1 } @@ -39,7 +39,7 @@ func emitHookOutput(writer io.Writer, host string, value []byte) error { } func failSafetyHook(err error) int { - fmt.Fprintln(os.Stderr, "BLOCKED:", err) + fmt.Fprintln(os.Stderr, boatstack.FormatBlocked(os.Stderr, err.Error())) // Claude Code and Codex both define exit 2 as a blocking PreToolUse error. // Exit 1 is non-blocking in Claude and must never represent policy failure. return 2 @@ -871,6 +871,21 @@ func doctorCommand(arguments []string) int { return 0 } +func renderDenialCommand(arguments []string) int { + flags := flag.NewFlagSet("render-denial", flag.ContinueOnError) + mode := flags.String("mode", "ansi", "render mode: ansi | plain | markdown") + host := flags.String("host", "claude", "coding host: claude | codex | cursor | gemini") + demo := flags.Bool("demo", false, "render a representative set of denials") + if err := flags.Parse(arguments); err != nil { + return fail(err) + } + if !*demo { + return fail(fmt.Errorf("render-denial requires --demo (optional: --mode, --host)")) + } + fmt.Println(boatstack.DenialDemo(*host, boatstack.ParseRenderMode(*mode))) + return 0 +} + func diagnoseHookCommand(arguments []string) int { flags := flag.NewFlagSet("diagnose-hook", flag.ContinueOnError) host := flags.String("host", "", "cursor, claude, or codex") @@ -1221,7 +1236,7 @@ func workspaceSyncCommand(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] { @@ -1297,6 +1312,8 @@ func run() int { return doctorCommand(os.Args[2:]) case "diagnose-hook": return diagnoseHookCommand(os.Args[2:]) + case "render-denial": + return renderDenialCommand(os.Args[2:]) case "safety-hook": return safetyHookCommand(os.Args[2:]) case "bootstrap-safety-hook": diff --git a/boatstack/denial.go b/boatstack/denial.go new file mode 100644 index 0000000..843ba36 --- /dev/null +++ b/boatstack/denial.go @@ -0,0 +1,465 @@ +package boatstack + +import ( + "fmt" + "os" + "strings" +) + +// Boatstack denials describe a guardrail, not a crash. A denial is captured once +// as a structured value and rendered four ways so every surface — each coding host +// and the raw terminal — shows the calmest treatment it can render, while the flat +// text stays a complete fallback everywhere: +// +// - Plain : multi-line text (default hook reason; safe on every host) +// - Markdown : light inline markup for hosts that render it +// - ANSI : soft-coral pill + reassurance for a real terminal (CLI, guards) +// - Structured: a machine object for a host that adopts rich denial rendering +// +// Nothing here carries secrets: a Denial holds only category slugs, fixed guidance, +// and bounded finding fields, mirroring SafetyFinding's secret-free contract. + +// Severity selects the calm color family. Protected = a guardrail stopped a +// protected effect (coral); Advisory = recoverable / retry / input problem (amber). +type Severity int + +const ( + SeverityProtected Severity = iota + SeverityAdvisory + SeverityInfo +) + +func (s Severity) slug() string { + switch s { + case SeverityAdvisory: + return "advisory" + case SeverityInfo: + return "info" + default: + return "protected" + } +} + +// RenderMode chooses how a Denial becomes text. +type RenderMode int + +const ( + RenderPlain RenderMode = iota + RenderMarkdown + RenderANSI +) + +// Denial is the single structured description of a blocked action. +type Denial struct { + Category string // machine slug, e.g. "workflow-state-tamper" + Badge string // "Blocked by Boatstack" + Qualifier string // "protected path" | "managed runtime authority" | "" + Severity Severity // + Detail string // guidance; may contain `code` spans + Reassurance string // "Nothing was written; your files are untouched." (empty if an effect occurred) + Hint string // recovery command, e.g. "boatstack-helper diagnose-hook" +} + +// --- ANSI palette (truecolor; matches the approved mockup) ------------------- + +const ( + ansiReset = "\x1b[0m" + ansiBold = "\x1b[1m" + ansiDim = "\x1b[2m" + + // soft coral #e59280 / amber #e6b566 / calm gray #8b8f98 / pill ink #17181c + fgCoral = "\x1b[38;2;229;146;128m" + bgCoral = "\x1b[48;2;229;146;128m" + fgAmber = "\x1b[38;2;230;181;102m" + bgAmber = "\x1b[48;2;230;181;102m" + fgGray = "\x1b[38;2;139;143;152m" + fgInk = "\x1b[38;2;23;24;28m" + fgCode = "\x1b[38;2;199;205;215m" +) + +func (d Denial) sevFG() string { + if d.Severity == SeverityAdvisory { + return fgAmber + } + return fgCoral +} + +func (d Denial) sevBG() string { + if d.Severity == SeverityAdvisory { + return bgAmber + } + return bgCoral +} + +// Render turns a Denial into text for the given mode. +func (d Denial) Render(mode RenderMode) string { + badge := d.Badge + if badge == "" { + badge = "Blocked by Boatstack" + } + switch mode { + case RenderMarkdown: + return d.renderMarkdown(badge) + case RenderANSI: + return d.renderANSI(badge) + default: + return d.renderPlain(badge) + } +} + +func (d Denial) renderPlain(badge string) string { + var b strings.Builder + head := badge + if d.Qualifier != "" { + head += " · " + d.Qualifier + } + b.WriteString(head) + if d.Detail != "" { + b.WriteString("\n\n") + b.WriteString(d.Detail) + } + if d.Reassurance != "" { + b.WriteString("\n\n↳ ") + b.WriteString(d.Reassurance) + } + if d.Hint != "" { + b.WriteString("\n\nFalse positive? run: ") + b.WriteString(d.Hint) + } + return b.String() +} + +func (d Denial) renderMarkdown(badge string) string { + var b strings.Builder + b.WriteString("**" + badge + "**") + if d.Qualifier != "" { + b.WriteString(" · " + d.Qualifier) + } + if d.Detail != "" { + b.WriteString("\n\n" + d.Detail) + } + if d.Reassurance != "" { + b.WriteString("\n\n↳ _" + d.Reassurance + "_") + } + if d.Hint != "" { + b.WriteString("\n\nFalse positive? run `" + d.Hint + "`") + } + return b.String() +} + +func (d Denial) renderANSI(badge string) string { + var b strings.Builder + // pill: severity background + dark ink, self-contained contrast on any terminal + b.WriteString(d.sevBG() + fgInk + ansiBold + " ⊘ " + badge + " " + ansiReset) + if d.Qualifier != "" { + b.WriteString(" " + d.sevFG() + ansiDim + d.Qualifier + ansiReset) + } + if d.Detail != "" { + b.WriteString("\n\n" + ansiCode(d.Detail)) + } + if d.Reassurance != "" { + b.WriteString("\n" + fgGray + "↳ " + d.Reassurance + ansiReset) + } + if d.Hint != "" { + b.WriteString("\n" + fgGray + ansiDim + "false positive? run " + ansiReset + fgCode + d.Hint + ansiReset) + } + return b.String() +} + +// ansiCode dims the surrounding text and brightens inline `code` spans. +func ansiCode(s string) string { + parts := strings.Split(s, "`") + var b strings.Builder + for i, part := range parts { + if i%2 == 1 { + b.WriteString(fgCode + part + ansiReset) + } else { + b.WriteString(part) + } + } + return b.String() +} + +// Structured is the machine object for a host that adopts rich denial rendering. +// It is emitted only when denialRichEnabled() is set, and the flat reason string +// is always populated alongside it as the fallback. +func (d Denial) Structured() map[string]any { + badge := d.Badge + if badge == "" { + badge = "Blocked by Boatstack" + } + out := map[string]any{ + "schema_version": 1, + "category": d.Category, + "badge": badge, + "severity": d.Severity.slug(), + } + if d.Qualifier != "" { + out["qualifier"] = d.Qualifier + } + if d.Detail != "" { + out["detail"] = d.Detail + } + if d.Reassurance != "" { + out["reassurance"] = d.Reassurance + } + if d.Hint != "" { + out["hint"] = d.Hint + } + return out +} + +// --- environment gating ------------------------------------------------------ + +// colorEnabled reports whether ANSI styling should be emitted to f. Honors +// BOATSTACK_COLOR=always|never|auto (default auto), NO_COLOR, TERM=dumb, and +// otherwise requires f to be a character device (a real terminal). stdlib only. +func colorEnabled(f *os.File) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("BOATSTACK_COLOR"))) { + case "always", "1", "true", "yes", "on": + return true + case "never", "0", "false", "no", "off": + return false + } + if os.Getenv("NO_COLOR") != "" { + return false + } + if strings.EqualFold(strings.TrimSpace(os.Getenv("TERM")), "dumb") { + return false + } + if f == nil { + return false + } + info, err := f.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + +// denialRichEnabled reports whether the structured denial object should be added +// to a host's hook decision. Default off: no host documents tolerating unknown +// keys, so we opt in explicitly (the flat reason string is always the fallback). +func denialRichEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("BOATSTACK_DENIAL_RICH"))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +// renderModeForFile picks ANSI when color is enabled for f, else Plain. Used by +// the terminal surfaces (CLI errors, guard fallbacks routed through Go). +func renderModeForFile(f *os.File) RenderMode { + if colorEnabled(f) { + return RenderANSI + } + return RenderPlain +} + +// FormatBlocked renders a CLI "BLOCKED" error line for the given stream. On a +// color-capable terminal it is a soft-coral pill; otherwise it is the literal +// "BLOCKED: " plain form, so scripts and tests that match that prefix are +// unaffected when output is piped or captured. +func FormatBlocked(f *os.File, msg string) string { + if colorEnabled(f) { + return bgCoral + fgInk + ansiBold + " ⊘ Blocked " + ansiReset + " " + msg + } + return "BLOCKED: " + msg +} + +// ParseRenderMode maps a flag value to a RenderMode ("ansi"|"plain"|"markdown"). +func ParseRenderMode(value string) RenderMode { + switch strings.ToLower(strings.TrimSpace(value)) { + case "ansi", "color", "terminal": + return RenderANSI + case "markdown", "md": + return RenderMarkdown + default: + return RenderPlain + } +} + +// DenialDemo renders a representative set of denials for the render-denial --demo +// command, so an operator can see the plain/markdown/ANSI treatments directly. +func DenialDemo(host string, mode RenderMode) string { + samples := []SafetyFinding{ + {Category: "workflow-state-tamper"}, + {Category: "filesystem-destruction"}, + {Category: "workflow-phase-bypass", BlockingFeature: "checkout-flow", WorkflowStage: "PLAN_PENDING", NextOperation: "plan-gate"}, + {Category: "malformed-tool-input", Reason: "empty-command"}, + {Category: "operation-already-succeeded", OperationID: "op_9f2c", OperationState: "SUCCEEDED", AttemptNumber: 1}, + } + var b strings.Builder + for i, finding := range samples { + if i > 0 { + b.WriteString("\n\n") + } + b.WriteString(denialFor(host, finding).Render(mode)) + } + return b.String() +} + +const reassureUntouched = "Nothing was written; your files are untouched." + +// denialFor maps a SafetyFinding to a structured Denial. It preserves every +// piece of information the previous flat messages carried (guidance, machine +// tokens like HOST_PAYLOAD_MALFORMED, and the interpolated recovery context), +// and adds the calm framing: a badge, a qualifier, a severity, and — when the +// action was blocked before any effect — a reassurance line. +func denialFor(host string, finding SafetyFinding) Denial { + d := Denial{Category: finding.Category, Badge: "Blocked by Boatstack", Severity: SeverityProtected} + + switch finding.Category { + case "malformed-tool-input": + name := strings.ToUpper(strings.TrimSpace(host)) + if name == "" { + name = "HOST" + } + d.Severity = SeverityAdvisory + d.Qualifier = "unreadable tool event" + d.Detail = "Boatstack could not inspect the " + name + " hook event (HOST_PAYLOAD_MALFORMED:" + finding.Reason + + "). No unsafe operation was detected; execution is denied because the intended command or tool call is unavailable. Retry once with an explicit non-empty command. If this repeats, stop shell and tool retries and preserve current edits." + if strings.EqualFold(host, "cursor") { + d.Detail += " Start a new Cursor task and run `.product-loop/bin/boatstack-helper diagnose-hook --host cursor --repo .` from an external terminal. Do not reinstall Boatstack unless it separately reports a missing, drifted, unsafe, or checksum-invalid runtime." + } else { + d.Detail += " Run `.product-loop/bin/boatstack-helper diagnose-hook --host " + strings.ToLower(host) + " --repo .` from an external terminal before changing the installation." + } + return d + + case "workflow-state-invalid": + d.Severity = SeverityAdvisory + d.Qualifier = "delivery state unverified" + d.Detail = "Publication is denied because managed delivery state cannot be verified. Re-run the active Boatstack operation or repair the installation before publishing." + d.Reassurance = "Nothing was published." + return d + + case "workflow-state-tamper": + d.Qualifier = "managed runtime authority" + d.Detail = "Change `.git/boatstack/` only through the command that owns it — a build, test, review, or ship transition for delivery state, or `publish-update-pr` for a version update." + d.Reassurance = "Nothing was written; your runtime state is unchanged." + d.Hint = "boatstack-helper diagnose-hook" + return d + + case "workflow-phase-bypass": + target := "the saved Boatstack plan" + if finding.BlockingFeature != "" { + target = fmt.Sprintf("Boatstack feature %q", finding.BlockingFeature) + } + next := finding.NextOperation + if next == "" { + next = "repair-state" + } + attempted := "" + if finding.AttemptedPath != "" { + attempted = " Attempted path: " + finding.AttemptedPath + "." + } + d.Qualifier = "plan gate" + d.Detail = fmt.Sprintf("Product mutation is denied because %s is at %s.%s Continue with `%s`; unrelated task completions do not authorize implementation.", target, finding.WorkflowStage, attempted, next) + d.Reassurance = reassureUntouched + return d + + case "workflow-publication-bypass": + target := "the active managed delivery" + if finding.BlockingFeature != "" { + target = fmt.Sprintf("managed delivery %q", finding.BlockingFeature) + } + relation := "" + switch finding.BranchRelation { + case "unrelated": + relation = " It is unrelated to the current branch." + case "ambiguous": + relation = " More than one delivery may be blocking publication." + } + context := publicationRecoveryContext(finding) + d.Qualifier = "publication authority" + d.Detail = target + " still owns publication authority." + relation + context + " Resolve the reported change through the managed recovery path; do not repeat this push or PR mutation manually." + d.Reassurance = "No push or pull request was made." + return d + + case "operation-in-flight": + d.Severity = SeverityAdvisory + d.Qualifier = "already supervised" + d.Detail = "Boatstack is already supervising this exact operation." + operationContext(finding) + " Wait for its completion event; do not launch it again." + d.Reassurance = "The original operation is still running." + d.Hint = "boatstack-helper operation-status" + return d + + case "operation-already-succeeded": + d.Severity = SeverityInfo + d.Qualifier = "already completed" + d.Detail = "Boatstack already observed this exact operation succeed." + operationContext(finding) + " Continue from the resulting repository state instead of repeating it." + d.Reassurance = "The earlier run's result stands." + return d + + case "operation-reconciliation-required": + d.Severity = SeverityAdvisory + d.Qualifier = "needs reconciliation" + d.Detail = "Boatstack cannot yet distinguish success from an interrupted response." + operationContext(finding) + " Reconcile the expected postcondition with operation-status before any retry." + d.Hint = "boatstack-helper operation-status" + return d + + case "operation-retry-exhausted": + d.Severity = SeverityAdvisory + d.Qualifier = "retry budget spent" + d.Detail = "Boatstack exhausted the persistent retry budget for this operation." + operationContext(finding) + " Preserve current state and use the reported manual recovery; do not repeat the tool call." + return d + + case "git-history-destruction": + d.Qualifier = "history-destructive git" + d.Detail = "Use the project-local workspace-sync operation to checkpoint current state and align the exact branch; do not scan delivery artifacts or retry the destructive command." + d.Reassurance = "Nothing was written; your Git history is intact." + return d + + case "workspace-sync-bypass": + d.Qualifier = "unverified workspace sync" + d.Detail = "Invoke only the exact project-local workspace-sync helper for the current repository." + d.Reassurance = reassureUntouched + return d + } + + // operation-* fallthrough (operation-state-invalid and any other operation-*) + if strings.HasPrefix(finding.Category, "operation-") { + d.Severity = SeverityAdvisory + d.Qualifier = "operation state unverified" + d.Detail = "Boatstack could not verify the durable operation state." + operationContext(finding) + " Inspect operation-status before retrying." + d.Hint = "boatstack-helper operation-status" + return d + } + + // Generic protected-effect denial: database/filesystem/infrastructure/recovery + // destruction, unbounded mutation, external-resource destruction, entrypoint + // safety, unsupported host, unresolved repository, and anything new. + d.Qualifier = "protected effect" + d.Detail = fmt.Sprintf("Boatstack denied an irreversible operation (%s). Preserve the current state and use read-only diagnosis or fix-forward recovery; destructive recovery is operator-only outside the agent workflow.", finding.Category) + d.Reassurance = reassureUntouched + return d +} + +func operationContext(finding SafetyFinding) string { + if finding.OperationID == "" && finding.OperationState == "" && finding.AttemptNumber == 0 { + return "" + } + return fmt.Sprintf(" operation=%s state=%s attempt=%d.", finding.OperationID, finding.OperationState, finding.AttemptNumber) +} + +func publicationRecoveryContext(finding SafetyFinding) string { + var parts []string + if finding.BlockingSlice != "" { + parts = append(parts, "slice="+finding.BlockingSlice) + } + if finding.BranchRelation != "" { + parts = append(parts, "relation="+finding.BranchRelation) + } + if finding.ParentDelivery != "" { + parts = append(parts, "parent="+finding.ParentDelivery) + } + if finding.NextOperation != "" { + parts = append(parts, "next="+finding.NextOperation) + } + if len(parts) == 0 { + return "" + } + return " Recovery context: " + strings.Join(parts, " ") + "." +} diff --git a/boatstack/denial_test.go b/boatstack/denial_test.go new file mode 100644 index 0000000..68a1153 --- /dev/null +++ b/boatstack/denial_test.go @@ -0,0 +1,188 @@ +package boatstack + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +func TestDenialRenderModesCarryTheSameInformation(t *testing.T) { + d := denialFor("claude", SafetyFinding{Category: "workflow-state-tamper"}) + + plain := d.Render(RenderPlain) + if !strings.Contains(plain, "Blocked by Boatstack") || !strings.Contains(plain, "managed runtime authority") { + t.Fatalf("plain missing badge/qualifier: %q", plain) + } + if !strings.Contains(plain, ".git/boatstack/") || !strings.Contains(plain, "publish-update-pr") { + t.Fatalf("plain dropped guidance detail: %q", plain) + } + if !strings.Contains(plain, "Nothing was written") { + t.Fatalf("plain missing reassurance: %q", plain) + } + if strings.Contains(plain, "\x1b[") { + t.Fatalf("plain must not contain ANSI: %q", plain) + } + + md := d.Render(RenderMarkdown) + if !strings.Contains(md, "**Blocked by Boatstack**") { + t.Fatalf("markdown missing bold badge: %q", md) + } + + ansi := d.Render(RenderANSI) + if !strings.Contains(ansi, "\x1b[") || !strings.Contains(ansi, "Blocked by Boatstack") { + t.Fatalf("ansi missing escape/badge: %q", ansi) + } +} + +func TestDenialReassuranceIsCategoryAware(t *testing.T) { + // A blocked-before-effect denial reassures that nothing was written. + tamper := denialFor("claude", SafetyFinding{Category: "workflow-state-tamper"}).Render(RenderPlain) + if !strings.Contains(tamper, "Nothing was written") { + t.Fatalf("protected denial should reassure nothing was written: %q", tamper) + } + // An already-succeeded operation did have an effect — it must NOT claim nothing happened. + done := denialFor("claude", SafetyFinding{Category: "operation-already-succeeded", OperationID: "op_1"}).Render(RenderPlain) + if strings.Contains(done, "Nothing was written") { + t.Fatalf("already-succeeded must not claim nothing was written: %q", done) + } + if !strings.Contains(done, "earlier run's result stands") { + t.Fatalf("already-succeeded should note the result stands: %q", done) + } +} + +func TestDenialPreservesMachineTokens(t *testing.T) { + d := denialFor("cursor", SafetyFinding{Category: "malformed-tool-input", Reason: "empty-command"}) + plain := d.Render(RenderPlain) + if !strings.Contains(plain, "HOST_PAYLOAD_MALFORMED:empty-command") { + t.Fatalf("malformed denial dropped machine token: %q", plain) + } + if d.Severity != SeverityAdvisory { + t.Fatalf("malformed input should be advisory, got %v", d.Severity) + } +} + +func TestDenialGenericFallbackNamesCategory(t *testing.T) { + d := denialFor("claude", SafetyFinding{Category: "database-destruction"}) + plain := d.Render(RenderPlain) + if !strings.Contains(plain, "(database-destruction)") { + t.Fatalf("generic denial should name its category: %q", plain) + } + if !strings.Contains(plain, "Nothing was written") { + t.Fatalf("generic protected denial should reassure: %q", plain) + } +} + +func TestColorEnabledHonorsEnvAndDevice(t *testing.T) { + // A regular file is not a character device → auto = no color. + f, err := os.CreateTemp(t.TempDir(), "notty") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + t.Setenv("NO_COLOR", "") + t.Setenv("BOATSTACK_COLOR", "") + if colorEnabled(f) { + t.Fatal("auto mode on a regular file must not enable color") + } + t.Setenv("BOATSTACK_COLOR", "always") + if !colorEnabled(f) { + t.Fatal("BOATSTACK_COLOR=always must force color") + } + t.Setenv("BOATSTACK_COLOR", "never") + if colorEnabled(f) { + t.Fatal("BOATSTACK_COLOR=never must disable color") + } + t.Setenv("BOATSTACK_COLOR", "") + t.Setenv("NO_COLOR", "1") + if colorEnabled(f) { + t.Fatal("NO_COLOR must disable color") + } +} + +func TestFormatBlockedPreservesPlainPrefix(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "notty") + if err != nil { + t.Fatal(err) + } + defer f.Close() + t.Setenv("NO_COLOR", "") + t.Setenv("BOATSTACK_COLOR", "") + if got := FormatBlocked(f, "check-plan requires --plan"); got != "BLOCKED: check-plan requires --plan" { + t.Fatalf("non-terminal must keep the literal BLOCKED: prefix, got %q", got) + } + t.Setenv("BOATSTACK_COLOR", "always") + got := FormatBlocked(f, "boom") + if !strings.Contains(got, "\x1b[") || !strings.Contains(got, "Blocked") || !strings.Contains(got, "boom") { + t.Fatalf("terminal form should be an ANSI pill carrying the message, got %q", got) + } +} + +func TestStructuredDenialObjectAndRichGate(t *testing.T) { + finding := SafetyFinding{Category: "workflow-state-tamper"} + obj := denialFor("claude", finding).Structured() + if obj["category"] != "workflow-state-tamper" || obj["severity"] != "protected" || obj["badge"] == "" { + t.Fatalf("structured object missing fields: %+v", obj) + } + + // Rich object is gated off by default; the flat reason stays complete. + t.Setenv("BOATSTACK_DENIAL_RICH", "") + out, err := structuredHookDeny("claude", finding) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(out, &decoded); err != nil { + t.Fatalf("deny output is not valid JSON: %v", err) + } + hook, _ := decoded["hookSpecificOutput"].(map[string]any) + if hook["permissionDecisionReason"] == "" { + t.Fatal("flat reason must always be present") + } + if _, present := hook["boatstackDenial"]; present { + t.Fatal("structured object must be OFF by default") + } + + // Opt-in adds the object while keeping the flat reason. + t.Setenv("BOATSTACK_DENIAL_RICH", "1") + out, err = structuredHookDeny("claude", finding) + if err != nil { + t.Fatal(err) + } + decoded = map[string]any{} + if err := json.Unmarshal(out, &decoded); err != nil { + t.Fatal(err) + } + hook, _ = decoded["hookSpecificOutput"].(map[string]any) + if _, present := hook["boatstackDenial"]; !present { + t.Fatal("BOATSTACK_DENIAL_RICH=1 must add the structured object") + } + if hook["permissionDecisionReason"] == "" { + t.Fatal("flat reason must remain complete alongside the structured object") + } +} + +func TestDenialDemoRendersAllSamples(t *testing.T) { + out := DenialDemo("claude", RenderPlain) + for _, want := range []string{"managed runtime authority", "plan gate", "HOST_PAYLOAD_MALFORMED", "already completed"} { + if !strings.Contains(out, want) { + t.Fatalf("demo missing %q in:\n%s", want, out) + } + } +} + +// Guard-script generation stays syntactically emittable and the plain fallback +// keeps the exact human message (asserts the ANSI helper did not alter wording). +func TestGuardScriptPlainMessagesUnchanged(t *testing.T) { + script := string(guardShellScript()) + for _, want := range []string{ + "could not resolve the repository; denying tool execution.", + "shared runtime checksum is invalid; rerun the verified tagged installer.", + "bs_deny ", "bs_color", + } { + if !strings.Contains(script, want) { + t.Fatalf("guard script missing %q", want) + } + } +} diff --git a/boatstack/hooks.go b/boatstack/hooks.go index 4e58985..9f53998 100644 --- a/boatstack/hooks.go +++ b/boatstack/hooks.go @@ -128,16 +128,33 @@ func guardShellScript() []byte { # Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json. set -u +# A denial is a guardrail, not a crash. On a real terminal render a soft-coral +# badge; when stderr is piped/captured (a host UI, a log) emit the plain message +# unchanged. Honors NO_COLOR and BOATSTACK_COLOR=never. +bs_color=0 +case "${BOATSTACK_COLOR:-auto}" in + always|1|true|yes|on) bs_color=1 ;; + never|0|false|no|off) bs_color=0 ;; + *) if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then bs_color=1; fi ;; +esac +bs_deny() { + if [ "$bs_color" = 1 ]; then + printf '\033[48;2;229;146;128m\033[38;2;23;24;28m\033[1m ⊘ Blocked by Boatstack \033[0m %%s\n' "$1" >&2 + else + printf '%%s\n' "$1" >&2 + fi +} + 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 + bs_deny "Boatstack safety guard could not resolve the repository; denying tool execution." exit 2 fi COMMON="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" if [[ -z "$COMMON" ]]; then - echo "Boatstack safety guard could not resolve the Git common directory; denying tool execution." >&2 + bs_deny "Boatstack safety guard could not resolve the Git common directory; denying tool execution." exit 2 fi @@ -145,12 +162,12 @@ case "$(uname -s)" in Darwin) OS_NAME="darwin"; EXTENSION="" ;; Linux) OS_NAME="linux"; EXTENSION="" ;; MINGW*|MSYS*|CYGWIN*) OS_NAME="windows"; EXTENSION=".exe" ;; - *) echo "Boatstack safety guard found an unsupported operating system; denying tool execution." >&2; exit 2 ;; + *) bs_deny "Boatstack safety guard found an unsupported operating system; denying tool execution."; exit 2 ;; esac case "$(uname -m)" in x86_64|amd64) ARCH="amd64" ;; arm64|aarch64) ARCH="arm64" ;; - *) echo "Boatstack safety guard found an unsupported architecture; denying tool execution." >&2; exit 2 ;; + *) bs_deny "Boatstack safety guard found an unsupported architecture; denying tool execution."; exit 2 ;; esac HELPER="$COMMON/boatstack/runtimes/%s/%s/${OS_NAME}-${ARCH}/boatstack-helper${EXTENSION}" @@ -208,12 +225,12 @@ if { [[ ! -x "$HELPER" || -L "$HELPER" || ! -f "$MANIFEST" || -L "$MANIFEST" ]]; fi fi if [[ ! -x "$HELPER" ]]; then - echo "Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone:" >&2 + bs_deny "Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone:" echo " %s" >&2 exit 2 fi if [[ -L "$HELPER" || ! -f "$MANIFEST" || -L "$MANIFEST" ]]; then - echo "Boatstack shared runtime is unsafe or incomplete; rerun the verified tagged installer." >&2 + bs_deny "Boatstack shared runtime is unsafe or incomplete; rerun the verified tagged installer." exit 2 fi EXPECTED="$(sed -n 's/.*"binary_sha256"[[:space:]]*:[[:space:]]*"\([0-9a-f]\{64\}\)".*/\1/p' "$MANIFEST" | head -n 1)" @@ -222,11 +239,11 @@ if command -v sha256sum >/dev/null 2>&1; then elif command -v shasum >/dev/null 2>&1; then ACTUAL="$(shasum -a 256 "$HELPER" | awk '{print $1}')" else - echo "Boatstack cannot verify the shared runtime checksum; denying tool execution." >&2 + bs_deny "Boatstack cannot verify the shared runtime checksum; denying tool execution." exit 2 fi if [[ -z "$EXPECTED" || "$ACTUAL" != "$EXPECTED" ]]; then - echo "Boatstack shared runtime checksum is invalid; rerun the verified tagged installer." >&2 + bs_deny "Boatstack shared runtime checksum is invalid; rerun the verified tagged installer." exit 2 fi @@ -255,14 +272,33 @@ func guardPowerShellScript() []byte { return []byte(fmt.Sprintf(`# Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json. param([Parameter(Mandatory=$true)][string]$HostName) $ErrorActionPreference = "Stop" + +# A denial is a guardrail, not a crash. On a real console render a soft-coral +# badge; when stderr is redirected (a host UI, a log) emit the plain message +# unchanged. Honors NO_COLOR and BOATSTACK_COLOR=never. ESC via [char]27 (no +# backtick — this script is a Go raw string). +$bsColor = $false +switch ("$($env:BOATSTACK_COLOR)".ToLowerInvariant()) { + { $_ -in 'always','1','true','yes','on' } { $bsColor = $true } + { $_ -in 'never','0','false','no','off' } { $bsColor = $false } + default { if ((-not [Console]::IsErrorRedirected) -and (-not $env:NO_COLOR)) { $bsColor = $true } } +} +function Bs-Deny($msg) { + if ($bsColor) { + $e = [char]27 + [Console]::Error.WriteLine("$e[48;2;229;146;128m$e[38;2;23;24;28m$e[1m ⊘ Blocked by Boatstack $e[0m $msg") + } else { + [Console]::Error.WriteLine($msg) + } +} $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.") + Bs-Deny "Boatstack safety guard could not resolve the repository; denying tool execution." exit 2 } $common = (& git rev-parse --path-format=absolute --git-common-dir 2>$null) if (-not $common) { - [Console]::Error.WriteLine("Boatstack safety guard could not resolve the Git common directory; denying tool execution.") + Bs-Deny "Boatstack safety guard could not resolve the Git common directory; denying tool execution." exit 2 } $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() @@ -270,7 +306,7 @@ $arch = switch ($architecture) { "x64" { "amd64" } "arm64" { "arm64" } default { - [Console]::Error.WriteLine("Boatstack safety guard found an unsupported architecture; denying tool execution.") + Bs-Deny "Boatstack safety guard found an unsupported architecture; denying tool execution." exit 2 } } @@ -314,29 +350,29 @@ if (((-not (Test-Path -LiteralPath $helper -PathType Leaf)) -or (-not (Test-Path } } if (-not (Test-Path -LiteralPath $helper -PathType Leaf)) { - [Console]::Error.WriteLine("Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone:") + Bs-Deny "Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone:" [Console]::Error.WriteLine(" %s") exit 2 } $helperInfo = Get-Item -LiteralPath $helper if (($helperInfo.Attributes -band [IO.FileAttributes]::ReparsePoint) -or -not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - [Console]::Error.WriteLine("Boatstack shared runtime is unsafe or incomplete; rerun the verified tagged installer.") + Bs-Deny "Boatstack shared runtime is unsafe or incomplete; rerun the verified tagged installer." exit 2 } $manifestInfo = Get-Item -LiteralPath $manifestPath if ($manifestInfo.Attributes -band [IO.FileAttributes]::ReparsePoint) { - [Console]::Error.WriteLine("Boatstack shared runtime manifest is unsafe; rerun the verified tagged installer.") + Bs-Deny "Boatstack shared runtime manifest is unsafe; rerun the verified tagged installer." exit 2 } try { $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json $actual = (Get-FileHash -LiteralPath $helper -Algorithm SHA256).Hash.ToLowerInvariant() } catch { - [Console]::Error.WriteLine("Boatstack could not verify the shared runtime; denying tool execution.") + Bs-Deny "Boatstack could not verify the shared runtime; denying tool execution." exit 2 } if (-not $manifest.binary_sha256 -or $actual -ne $manifest.binary_sha256.ToLowerInvariant()) { - [Console]::Error.WriteLine("Boatstack shared runtime checksum is invalid; rerun the verified tagged installer.") + Bs-Deny "Boatstack shared runtime checksum is invalid; rerun the verified tagged installer." exit 2 } & $helper bootstrap-safety-hook --host $HostName --repo $root diff --git a/boatstack/references/host-hook-contracts.md b/boatstack/references/host-hook-contracts.md index 0933438..8612e93 100644 --- a/boatstack/references/host-hook-contracts.md +++ b/boatstack/references/host-hook-contracts.md @@ -53,3 +53,38 @@ fingerprint. A delayed or duplicated completion cannot initiate work. Missing or uncertain completion becomes `RECONCILE_REQUIRED`, and safety output may expose only operation identity, state, attempt number, and whether reconciliation is required. + +## Denial rendering + +A denial is a guardrail, not a crash. Every human-facing denial is one structured +value (`denial.go`) rendered by the surface that shows it: + +- Hook decision `reason` (all hosts): a plain, multi-line message — a badge line + (`Blocked by Boatstack`), the guidance, a reassurance line, and the recovery + hint. This is the safe default every host displays. +- CLI errors and guard-script stderr: the same message as an ANSI soft-coral badge + when the stream is a real terminal, and the plain form (the literal `BLOCKED:` + prefix for CLI errors) when redirected. Controlled by `BOATSTACK_COLOR` + (`auto` default, `always`, `never`) and `NO_COLOR`. +- Structured object (opt-in): `BOATSTACK_DENIAL_RICH=1` adds a `boatstackDenial` + object next to the reason for a host that adopts rich denial rendering. + +### Unknown-key tolerance (rechecked 2026-07-26 against the sources above) + +Whether a host rejects unknown keys in the decision JSON governs the structured +object. None of the four host docs state that extra keys are rejected, and each +already defines additional optional fields (Claude `additionalContext` / +`updatedInput`; Gemini `systemMessage` / `continue`), which implies permissive +parsing — but none guarantees tolerance either. + +| Host | Documented extra fields | Rejects unknown keys? | Structured-object default | +| --- | --- | --- | --- | +| Claude Code | `additionalContext`, `updatedInput` (under `hookSpecificOutput`) | Not documented; lean tolerant | off (opt-in), nested in `hookSpecificOutput` | +| Cursor | — | Not documented; lean tolerant | off (opt-in) | +| Gemini CLI | `systemMessage`, `continue` | Not documented; lean tolerant | off (opt-in) | +| Codex | — | Not documented; treat as strict (portable-only host) | off (opt-in) | + +Because tolerance is unverified, the structured object is off by default and the +flat `reason` string is always complete on its own. Enable it per host only after +the host is confirmed to ignore unknown keys, and never add fields to the +Claude/Codex empty-allow path. diff --git a/boatstack/safety.go b/boatstack/safety.go index bb1a66d..b013474 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -3,7 +3,6 @@ package boatstack import ( "encoding/json" "errors" - "fmt" "os" "os/exec" "path/filepath" @@ -921,11 +920,17 @@ func decodeGeminiHook(value []byte) (string, any, error) { func structuredHookDeny(host string, finding SafetyFinding) ([]byte, error) { message := denialMessage(host, finding) - value, err := json.Marshal(map[string]any{ - "hookSpecificOutput": map[string]any{ - "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": message, - }, - }) + hookOutput := map[string]any{ + "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": message, + } + // Opt-in structured object for hosts that adopt rich denial rendering. Nested + // inside the host's existing container; the flat reason above is always the + // complete fallback for any host that ignores it. Off by default (no host + // documents tolerating unknown keys — see references/host-hook-contracts.md). + if denialRichEnabled() { + hookOutput["boatstackDenial"] = denialFor(host, finding).Structured() + } + value, err := json.Marshal(map[string]any{"hookSpecificOutput": hookOutput}) return append(value, '\n'), err } @@ -938,9 +943,13 @@ var hookHostContracts = map[string]hookHostContract{ }, deny: func(finding SafetyFinding) ([]byte, error) { message := denialMessage("cursor", finding) - value, err := json.Marshal(map[string]any{ + payload := map[string]any{ "continue": true, "permission": "deny", "user_message": message, "agent_message": message, - }) + } + if denialRichEnabled() { + payload["boatstackDenial"] = denialFor("cursor", finding).Structured() + } + value, err := json.Marshal(payload) return append(value, '\n'), err }, }, @@ -961,101 +970,23 @@ var hookHostContracts = map[string]hookHostContract{ return append(value, '\n'), err }, deny: func(finding SafetyFinding) ([]byte, error) { - value, err := json.Marshal(map[string]any{"decision": "deny", "reason": denialMessage("gemini", finding)}) + payload := map[string]any{"decision": "deny", "reason": denialMessage("gemini", finding)} + if denialRichEnabled() { + payload["boatstackDenial"] = denialFor("gemini", finding).Structured() + } + value, err := json.Marshal(payload) return append(value, '\n'), err }, }, } +// denialMessage renders the human-facing reason string embedded in a host's hook +// decision. It delegates to the structured Denial model (denial.go) and renders +// the plain, multi-line form — the safe default that every host displays. Richer +// treatments (markdown, ANSI, the structured object) are produced from the same +// Denial by the CLI/guard surfaces and the opt-in rich path. func denialMessage(host string, finding SafetyFinding) string { - if finding.Category == "malformed-tool-input" { - name := strings.ToUpper(strings.TrimSpace(host)) - if name == "" { - name = "HOST" - } - message := "Boatstack could not inspect the " + name + " hook event (HOST_PAYLOAD_MALFORMED:" + finding.Reason + "). No unsafe operation was detected; execution is denied because the intended command or tool call is unavailable. Retry once with an explicit non-empty command. If this repeats, stop shell and tool retries and preserve current edits." - if strings.EqualFold(host, "cursor") { - message += " Start a new Cursor task and run `.product-loop/bin/boatstack-helper diagnose-hook --host cursor --repo .` from an external terminal. Do not reinstall Boatstack unless it separately reports a missing, drifted, unsafe, or checksum-invalid runtime." - } else { - message += " Run `.product-loop/bin/boatstack-helper diagnose-hook --host " + strings.ToLower(host) + " --repo .` from an external terminal before changing the installation." - } - return message - } - if finding.Category == "workflow-state-invalid" { - return "Boatstack denied publication because managed delivery state cannot be verified. Re-run the active Boatstack operation or repair the installation before publishing." - } - if finding.Category == "workflow-state-tamper" { - return "Boatstack denied a direct write to managed runtime authority under .git/boatstack/. Change it only through the command that owns it: a build, test, review, or ship transition for delivery state, or publish-update-pr for a version update. If this is a false positive, run `boatstack-helper diagnose-hook` from an external terminal." - } - if finding.Category == "workflow-phase-bypass" { - target := "the saved Boatstack plan" - if finding.BlockingFeature != "" { - target = fmt.Sprintf("Boatstack feature %q", finding.BlockingFeature) - } - path := "" - if finding.AttemptedPath != "" { - path = " Attempted path: " + finding.AttemptedPath + "." - } - next := finding.NextOperation - if next == "" { - next = "repair-state" - } - return fmt.Sprintf("Boatstack denied product mutation because %s is at %s.%s Continue with %s; unrelated task completions do not authorize implementation.", target, finding.WorkflowStage, path, next) - } - if finding.Category == "workflow-publication-bypass" { - target := "the active managed delivery" - if finding.BlockingFeature != "" { - target = fmt.Sprintf("managed delivery %q", finding.BlockingFeature) - } - relation := "" - if finding.BranchRelation == "unrelated" { - relation = " It is unrelated to the current branch." - } else if finding.BranchRelation == "ambiguous" { - relation = " More than one delivery may be blocking publication." - } - context := "" - if finding.BlockingSlice != "" { - context += " slice=" + finding.BlockingSlice - } - if finding.BranchRelation != "" { - context += " relation=" + finding.BranchRelation - } - if finding.ParentDelivery != "" { - context += " parent=" + finding.ParentDelivery - } - if finding.NextOperation != "" { - context += " next=" + finding.NextOperation - } - if context != "" { - context = " Recovery context:" + context + "." - } - return "Boatstack denied the publication bypass because " + target + " still owns publication authority." + relation + context + " Resolve the reported change through the managed recovery path; do not repeat this push or PR mutation manually." - } - if strings.HasPrefix(finding.Category, "operation-") { - context := "" - if finding.OperationID != "" { - context = fmt.Sprintf(" operation=%s state=%s attempt=%d", finding.OperationID, finding.OperationState, finding.AttemptNumber) - } - switch finding.Category { - case "operation-in-flight": - return "Boatstack is already supervising this exact operation." + context + ". Wait for its completion event; do not launch it again." - case "operation-already-succeeded": - return "Boatstack already observed this exact operation succeed." + context + ". Continue from the resulting repository state instead of repeating it." - case "operation-reconciliation-required": - return "Boatstack cannot yet distinguish success from an interrupted response." + context + ". Reconcile the expected postcondition with operation-status before any retry." - case "operation-retry-exhausted": - return "Boatstack exhausted the persistent retry budget for this operation." + context + ". Preserve current state and use the reported manual recovery; do not repeat the tool call." - default: - return "Boatstack could not verify the durable operation state." + context + ". Inspect operation-status before retrying." - } - } - if finding.Category == "git-history-destruction" { - return "Boatstack denied raw destructive Git cleanup. Use the project-local workspace-sync operation to checkpoint current state and align the exact branch; do not scan delivery artifacts or retry the destructive command." - } - if finding.Category == "workspace-sync-bypass" { - return "Boatstack denied an unverified workspace sync. Invoke only the exact project-local workspace-sync helper for the current repository." - } - 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." + return denialFor(host, finding).Render(RenderPlain) } func HookDecision(options SafetyHookOptions) ([]byte, bool) { diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index ee2bcdf..fa8276b 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`f671638f234faa96ea85336e9a76b164020df172`](https://github.com/operatorstack/intelligence-flow/tree/f671638f234faa96ea85336e9a76b164020df172/labs/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4`](https://github.com/operatorstack/intelligence-flow/tree/dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4/labs/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/public-claims.json b/docs/public-claims.json index 5ce0af3..eef6b4b 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "f671638f234faa96ea85336e9a76b164020df172", + "source_commit": "dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4", "statuses": ["verified", "observed", "still_being_evaluated"], "claims": [ { @@ -12,7 +12,7 @@ "readable_evidence": "why-these-steps.md#portable-workflow-and-state", "implementation": ["../boatstack/export.go", "../boatstack/references/artifacts.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go"], - "last_verified_version": "source:f671638f234faa96ea85336e9a76b164020df172" + "last_verified_version": "source:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" }, { "id": "human-decisions", @@ -23,7 +23,7 @@ "readable_evidence": "why-these-steps.md#human-decisions", "implementation": ["../boatstack/references/workflow.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:f671638f234faa96ea85336e9a76b164020df172" + "last_verified_version": "source:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" }, { "id": "validation-provenance", @@ -34,7 +34,7 @@ "readable_evidence": "why-these-steps.md#validation-provenance", "implementation": ["validation-and-evidence.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go"], - "last_verified_version": "source:f671638f234faa96ea85336e9a76b164020df172" + "last_verified_version": "source:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" }, { "id": "irreversible-operations", @@ -46,7 +46,7 @@ "readable_evidence": "why-these-steps.md#irreversible-operations", "implementation": ["safety.md", "../boatstack/safety.go", "../boatstack/hooks.go"], "verification": ["../boatstack/safety_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:f671638f234faa96ea85336e9a76b164020df172" + "last_verified_version": "source:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" }, { "id": "reviewer-ready-pr", @@ -57,7 +57,7 @@ "readable_evidence": "why-these-steps.md#reviewer-ready-pr", "implementation": ["../boatstack/pr.go", "getting-started.md"], "verification": ["../boatstack/pr_test.go"], - "last_verified_version": "source:f671638f234faa96ea85336e9a76b164020df172" + "last_verified_version": "source:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" }, { "id": "phase-scoped-delivery", @@ -68,7 +68,7 @@ "readable_evidence": "why-these-steps.md#phase-scoped-delivery", "implementation": ["../boatstack/delivery.go", "../boatstack/safety.go", "../boatstack/hooks.go", "../boatstack/references/workflow.md"], "verification": ["../boatstack/delivery_test.go", "../boatstack/pr_test.go"], - "last_verified_version": "source:f671638f234faa96ea85336e9a76b164020df172" + "last_verified_version": "source:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" }, { "id": "model-neutral-contract", @@ -79,7 +79,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:f671638f234faa96ea85336e9a76b164020df172" + "last_verified_version": "source:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" }, { "id": "cross-model-failures", @@ -90,7 +90,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:f671638f234faa96ea85336e9a76b164020df172" + "last_verified_version": "source:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" }, { "id": "lower-cost-outcomes", @@ -101,7 +101,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:f671638f234faa96ea85336e9a76b164020df172" + "last_verified_version": "source:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" }, { "id": "git-worktree-activation", @@ -112,7 +112,7 @@ "readable_evidence": "why-these-steps.md#git-worktree-activation", "implementation": ["../boatstack/runtime_cache.go", "../boatstack/hooks.go"], "verification": ["../boatstack/runtime_cache_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:f671638f234faa96ea85336e9a76b164020df172" + "last_verified_version": "source:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" }, { "id": "visible-updates", @@ -123,7 +123,7 @@ "readable_evidence": "why-these-steps.md#visible-updates", "implementation": ["../boatstack/update.go", "../boatstack/init.go"], "verification": ["../boatstack/update_test.go", "../boatstack/init_test.go", "../boatstack/export_test.go"], - "last_verified_version": "source:f671638f234faa96ea85336e9a76b164020df172" + "last_verified_version": "source:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index ca67568..d9b4c9f 100644 --- a/labs/diagram-json/plan.lock.json +++ b/labs/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "labs/diagram-json/plan.md", "plan_sha256": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "schema_version": 1, - "source_commit": "f671638f234faa96ea85336e9a76b164020df172", + "source_commit": "dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-07-26-calm-denials.md b/release-notes/2026-07-26-calm-denials.md new file mode 100644 index 0000000..a853dde --- /dev/null +++ b/release-notes/2026-07-26-calm-denials.md @@ -0,0 +1,19 @@ +### Denials read as a guardrail, not a crash + +A Boatstack denial used to arrive as one long red sentence. It looked like something +broke, even though nothing did. Every denial is now one message with a clear shape: a +`Blocked by Boatstack` badge, the reason, a line that tells you what happens next, and — +when the action was stopped before any change — a line that says nothing was written. + +The same message renders to fit each surface. In a coding host, the denial reason is a +short, calm, multi-line note. In a real terminal — the CLI and the guard scripts — it is +a soft-coral badge instead of alarm red. When output is piped or captured, the plain text +is emitted unchanged, so logs and scripts are not affected. + +Two settings control the terminal color: `BOATSTACK_COLOR` (`auto`, `always`, or `never`) +and the standard `NO_COLOR`. A new command, `boatstack-helper render-denial --demo`, prints +sample denials so you can preview the plain, Markdown, and terminal forms. + +The denial text carries the same information as before, including the recovery command and +the machine markers that tooling matches. This change is presentation only; no gate, +authority, verification, or recovery behavior changes.