From 3be18ce1b14b7489cf554210025aa57cbaf2ccd4 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Sun, 19 Jul 2026 12:41:12 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ 3c82bbed52b3 --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 25 ++--- boatstack/SKILL.md | 2 + boatstack/cmd/boatstack-helper/main.go | 25 ++++- boatstack/hooks.go | 81 ++++++++++++++++ boatstack/hooks_test.go | 85 +++++++++++++++++ boatstack/safety.go | 95 ++++++++++++++----- boatstack/safety_test.go | 54 ++++++++++- docs/evidence-engineered-coding.md | 2 +- docs/public-claims.json | 24 ++--- docs/troubleshooting.md | 10 ++ labs/diagram-json/plan.lock.json | 2 +- ...026-07-19-malformed-host-event-recovery.md | 3 + 13 files changed, 352 insertions(+), 58 deletions(-) create mode 100644 release-notes/2026-07-19-malformed-host-event-recovery.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 047595b..01283e3 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/9a20177c48c292c1c6e1b967e0fd710f2e410a7c/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/3c82bbed52b38acc02fd7f7acf353fe60b394440/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 3c17fa4..2930273 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,12 +12,12 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "486ff0833aa62521ba078542bc648f4cb868550bc5bd7372393e3befccbde35d", + "CONTRIBUTING.md": "c2c9eda3d29b2f5f5647f23e42aea531601e3a2c4316b609c39ff27c721936e2", "README.md": "a840004db57a129ecd361bb0421adfe02065d86751736dd91953c8a8b178e5ef", "assets/boatstack-journey.svg": "c1f7fe2741f5e9ca66bb3fe9b103e6364ba5acbca8b7a8054768ffd85cf325ea", "assets/boatstack-mark.svg": "ec96165583b15cfd446c27049d49217973f3e9b1defa5771cc08eec0c9542ce4", "assets/boatstack-portability.svg": "ce648f5581d16586d25824d3a8132ef1b3d88b73329179173120129d4f74fd24", - "boatstack/SKILL.md": "cee940821e2f972070126fc7e041655ff32695602013f1db041d2a125693b599", + "boatstack/SKILL.md": "a63f88eb039c06f34ba018444871b7a0b17c43bdb3ab62f0122732f50345bd6d", "boatstack/agents/openai.yaml": "68a30a60859556c5a26e16d184594ca243a6043d99c8cf7d66b5dd6d50a93cd1", "boatstack/assets/templates/adr.md": "c577a3c1c1319061f61deb053597e6e853657022185fe28b8f733327e2a78565", "boatstack/assets/templates/approval.md": "74b0b816703a6dce3c96c8f95f981af910b020b6908e7f76cf5630778637e9f5", @@ -33,7 +33,7 @@ "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", "boatstack/changelog.go": "c5e1f31440b44d61e6037ad27af0333540af3545d655e35819a0241cbbebd8ec", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/main.go": "03637dce9ffd5e36355ec8c09bfdf4a9c34769808cdd7d330634e79b5bb9ac77", + "boatstack/cmd/boatstack-helper/main.go": "c74b18681d3e7804de6888e35f63ad45baddf62e19d4a5b5188e421e55fb7d9d", "boatstack/cmd/boatstack-helper/main_test.go": "ff73003b6a5157202fa09ddf1129fb13c3d79702b2e05a8721ce5a11bf5ab779", "boatstack/delivery.go": "565bc8465252a798124a29874e1aeffff16e9027d1a86715e11b3e4bc0743bd2", "boatstack/delivery_test.go": "2545c26389d330a92c11e7b09fb2342101db5d09a825a2a9e81b4ea9eea9407b", @@ -41,8 +41,8 @@ "boatstack/export_test.go": "fbe19b8627c9a66d6e5231aa0e275dccb28f85046a3f2f242a2ca306bb6c2665", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", - "boatstack/hooks.go": "ed131c526f434d7c3309a388b97f3d28df2a6bdf9bc7322499405d5174ab8518", - "boatstack/hooks_test.go": "6153759d463203607c50c418eb74017d2dc71118891f7567dec81fef9bd380d0", + "boatstack/hooks.go": "2b7d899f92efef8a68a160c423a46bcd6fd37daa0eba2ac8ad44ffa3dc5f88c9", + "boatstack/hooks_test.go": "d71271c0b9ea59b907cc0407a7173d3542281fb2984e6e3c3062ac39b131ec59", "boatstack/init.go": "b0619af89a108442decb8848b670bb648e2a81726faa8e8aa22510a522248de5", "boatstack/init_test.go": "fa48be69d07691fa7842224ec831e5f290504d8b6565263c9735d2dabd9b43b8", "boatstack/init_transaction.go": "112456c4e1c4db54c4137bcf4f7a9a9e63399a6f5971e9b3dc952d0c4b2aa4b6", @@ -68,8 +68,8 @@ "boatstack/runtime.go": "504b7b68e550ed178c2c12bb815aa21e67300cad65cf2311bb5e8b2860747f81", "boatstack/runtime_cache.go": "60c4eb0c7dde91d40d6ef3f05adc1a1282d17ff1ca12470d0a008454f7ca7489", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", - "boatstack/safety.go": "6a70f6886a6a599dea60eaeea568c4a8df0f36c8a2f68f1f2c0fc2e28554584b", - "boatstack/safety_test.go": "cf07f67354e96437e72ddbe107a4071a39689bf2e50df4017e28909194be59e4", + "boatstack/safety.go": "6c38272b2ccca3ec634ca1d1b5a0eff1cdca1fef2e146a0127d0f18285a61163", + "boatstack/safety_test.go": "40b100f03d35f6b2d06634be026b482705f6f2898b41ec5491c934ab5f333b36", "boatstack/skill_frontmatter.go": "85ba7389e5d41f52a716b912ffb6b03c81e575b0b049b2cc11f7cd45c3da93c4", "boatstack/skill_frontmatter_test.go": "ad92021f44f09d99949bda6670a8e350af85254dee087eeb86f3815dbdf7f500", "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4", @@ -80,14 +80,14 @@ "docs/account-recovery-walkthrough.md": "676034974594a7d1a559b24dbed31d7ccc429eb81404b203ca07bbdaa19ec3d3", "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", - "docs/evidence-engineered-coding.md": "159f34fbd92328ea7f887db304b4fe2d5cbed178859318e3349b68a940615b5f", + "docs/evidence-engineered-coding.md": "e0c5585bfa55a3c938e8be418f551574f00361bd5e18d23a4e64049ecbe61f64", "docs/generated-files.md": "136422baf0c7fc2bd5100cfe0ebdb3d9d0705dfd7e7d54bf745dd1037e63492c", "docs/getting-started.md": "eacc814fdffdfa3c7d8052b7cd99a79c04da5c75d88d8b44f3fb68d9afec0316", - "docs/public-claims.json": "8e6ff12e30ae1bc6646814154e91dcd34646f5aad146dbea6f8ca3639dfe8224", + "docs/public-claims.json": "ec9904b1653b7bd761a0e419bf0523c4f482dbef1e8aab259b44c2c0fc44e5e3", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "d65c66e323037bda5d45aacef5d48afa6bf93da55901378891d235aca3a5684f", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", - "docs/troubleshooting.md": "744786a3df7ff3ff52cb3e64393d1858332f8d178108fdac7b773a75c5bcc313", + "docs/troubleshooting.md": "e74032051d286ab61e31cc73429cb3287601d52f02d3b98ca966f5c1f2e834e5", "docs/validation-and-evidence.md": "e7d91ad49c6adb44784ebe7d94feceb6abd445857f9a0716f0758bf6b55296c5", "docs/why-these-steps.md": "80957af13979070e8b2f2a8db78ce06d20d152bbc8ec41c3a8003f28393f6369", "install.ps1": "960b2b20b406bb2878a560e9ace53fe7226bc510be6ee8466ce4e608beb5625a", @@ -97,7 +97,7 @@ "labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71", "labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39", - "labs/diagram-json/plan.lock.json": "18d2b1c3b0045e8d43e28ad130ddafc2f0edefdea8ffcba437b2ecb9e9cc88e6", + "labs/diagram-json/plan.lock.json": "cf4ff61f97127f1701d4cf8a43be1947305d4dca823918f71c67acba7a3b4866", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -122,6 +122,7 @@ "release-notes/2026-07-18-stacked-bar-mark.md": "c4d5bd5fb89c280d7fba015384fd795fcb8c31ffe501078aa55a90cbcf66ba7b", "release-notes/2026-07-18-startup-recovery-routing.md": "c305a1c2b8347e1bc6d7fcbd4a8e629438cdeb4bcf6f181a7d6fc154a88f4318", "release-notes/2026-07-19-cross-harness-hook-conformance.md": "15dc22cc3b534c6d0bd89f34cd69cabf94a3610d445a039ba111fd8bce727e68", + "release-notes/2026-07-19-malformed-host-event-recovery.md": "b13835ae0d6cd6310c3de45ebadf47afd297b650e70ef2bf7a335945be305833", "release-notes/2026-07-19-minimal-dated-changelogs.md": "6f525e5c5cf26cc72969a161bbdaeb4a5c1f14759c5985335e97e3d3f45b1daf", "release-notes/2026-07-19-new-worktree-intent.md": "969beaf8ef7c0635fb45a816cf762a7f66e5ea20b45847a564ee53153863b120", "release-notes/2026-07-19-optional-repository-changelog.md": "7ae6bed436c269335ac5055e04438489451bef00836fecb3e5fcb06a18501853", @@ -130,7 +131,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "9a20177c48c292c1c6e1b967e0fd710f2e410a7c", + "commit": "3c82bbed52b38acc02fd7f7acf353fe60b394440", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index 8b102b1..6aafaa7 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -151,6 +151,8 @@ Before any product edit or explicit `repair`, run `next-status`. Repair requires If Cursor reports `MainThreadShellExec not initialized`, the host failed before Boatstack's hook process started. Keep the hook fail-closed and make **Developer: Reload Window** the primary recovery, then retry the operation. Recommend the verified installer only when Boatstack itself reports a missing, drifted, unsafe, or checksum-invalid helper/runtime. +If any host reports `HOST_PAYLOAD_MALFORMED`, Boatstack received an event it could not safely decode; no unsafe operation was detected. Retry once with an explicit non-empty command. If the same code repeats, stop shell and tool retries, preserve current edits, and run `.product-loop/bin/boatstack-helper diagnose-hook --host --repo .` from an external terminal. For Cursor, start a new task after the probe. The diagnostic proves the installed guard with a canonical event but cannot inspect the live event supplied by the host. Do not recommend reinstall or hydration unless Boatstack separately reports a missing, drifted, unsafe, or checksum-invalid runtime. + Same-intent repair resumes at the helper-reported stage and reuses the existing gates. A requirement amendment or ambiguous expected behavior blocks product edits and returns to a concise Plan Gate delta. Never edit `changes.md`, ignored delivery state, or receipts directly; those are emitted by controlled transitions. Conversation history is never workflow authority. A published delivery is immutable. Record the observation against it, then plan the correction under a new feature id whose structured plan sets `parent_delivery` to the published feature. Activation refuses to reset published slices; the corrective child receives its own lock and full gates. diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index bb592f7..a98b2f5 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -432,6 +432,27 @@ func doctorCommand(arguments []string) int { return 0 } +func diagnoseHookCommand(arguments []string) int { + flags := flag.NewFlagSet("diagnose-hook", flag.ContinueOnError) + host := flags.String("host", "", "cursor, claude, or codex") + repo := flags.String("repo", ".", "repository whose installed hook should be probed") + if err := flags.Parse(arguments); err != nil { + return 2 + } + diagnostic, err := boatstack.DiagnoseHook(*repo, *host) + if err != nil { + return fail(err) + } + fmt.Printf("HOOK_CONTRACT_%s=%s\nLIVE_HOST_EVENT=NOT_OBSERVED\n", strings.ToUpper(diagnostic.Host), diagnostic.ContractStatus) + if diagnostic.Host == "cursor" { + fmt.Println("NEXT=If Cursor still reports HOST_PAYLOAD_MALFORMED, preserve edits and start a new Cursor task; this probe cannot inspect Cursor's live event.") + } else { + name := strings.ToUpper(diagnostic.Host[:1]) + diagnostic.Host[1:] + fmt.Printf("NEXT=If %s still reports HOST_PAYLOAD_MALFORMED, preserve edits and start a new host session; this probe cannot inspect the live event.\n", name) + } + return 0 +} + func safetyHookCommand(arguments []string) int { flags := flag.NewFlagSet("safety-hook", flag.ContinueOnError) host := flags.String("host", "", "cursor, claude, or codex") @@ -578,7 +599,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] { @@ -622,6 +643,8 @@ func run() int { return publishPRCommand(os.Args[2:]) case "doctor": return doctorCommand(os.Args[2:]) + case "diagnose-hook": + return diagnoseHookCommand(os.Args[2:]) case "safety-hook": return safetyHookCommand(os.Args[2:]) case "bootstrap-safety-hook": diff --git a/boatstack/hooks.go b/boatstack/hooks.go index b68b540..c7db030 100644 --- a/boatstack/hooks.go +++ b/boatstack/hooks.go @@ -1,15 +1,96 @@ package boatstack import ( + "bytes" + "context" "encoding/json" "fmt" "os" + "os/exec" "path/filepath" + "runtime" "strings" + "time" ) const hookCommandMarker = ".product-loop/hooks/guard" +type HookDiagnostic struct { + Host string + ContractStatus string + LiveEventObserved bool +} + +func canonicalHookEvent(host string) ([]byte, error) { + switch host { + case "cursor": + return []byte(`{"hook_event_name":"beforeShellExecution","command":"git status --short"}`), nil + case "claude", "codex": + return []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`), nil + default: + return nil, fmt.Errorf("unsupported hook host %q; expected cursor, claude, or codex", host) + } +} + +func validateCanonicalHookOutput(host string, output []byte) error { + if host == "cursor" { + var decision map[string]any + if err := json.Unmarshal(bytes.TrimSpace(output), &decision); err != nil || stringValue(decision["permission"]) != "allow" { + return fmt.Errorf("cursor hook diagnostic returned a malformed or non-allow response") + } + return nil + } + if len(bytes.TrimSpace(output)) != 0 { + return fmt.Errorf("%s hook diagnostic returned unexpected allow output", host) + } + return nil +} + +var hookDiagnosticRunner = runInstalledHookDiagnostic + +func runInstalledHookDiagnostic(ctx context.Context, repo, host string, input []byte) ([]byte, error) { + var command *exec.Cmd + if runtime.GOOS == "windows" { + path := filepath.Join(repo, ".product-loop", "hooks", "guard.ps1") + command = exec.CommandContext(ctx, "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path, "-HostName", host) + } else { + path := filepath.Join(repo, ".product-loop", "hooks", "guard.sh") + command = exec.CommandContext(ctx, "bash", path, host) + } + command.Dir = repo + command.Stdin = bytes.NewReader(append(input, '\n')) + return command.CombinedOutput() +} + +// DiagnoseHook runs the installed guard with a canonical, read-only event. It +// proves the generated wrapper, shared runtime, decoder, and allow contract; it +// deliberately cannot observe the coding host's live event payload. +func DiagnoseHook(repoPath, hostName string) (HookDiagnostic, error) { + host := strings.ToLower(strings.TrimSpace(hostName)) + input, err := canonicalHookEvent(host) + if err != nil { + return HookDiagnostic{}, err + } + repo, err := ResolveRepository(repoPath) + if err != nil { + return HookDiagnostic{}, err + } + + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + output, runErr := hookDiagnosticRunner(ctx, repo, host, input) + if ctx.Err() != nil { + return HookDiagnostic{}, fmt.Errorf("%s hook diagnostic timed out", host) + } + if runErr != nil { + return HookDiagnostic{}, fmt.Errorf("%s hook diagnostic failed: %s", host, strings.TrimSpace(string(output))) + } + if err := validateCanonicalHookOutput(host, output); err != nil { + return HookDiagnostic{}, err + } + return HookDiagnostic{Host: host, ContractStatus: "PASS", LiveEventObserved: false}, nil +} + func guardShellScript() []byte { return []byte(fmt.Sprintf(`#!/usr/bin/env bash # Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json. diff --git a/boatstack/hooks_test.go b/boatstack/hooks_test.go index fd28e82..c48b6bc 100644 --- a/boatstack/hooks_test.go +++ b/boatstack/hooks_test.go @@ -1,7 +1,9 @@ package boatstack import ( + "context" "encoding/json" + "errors" "os" "os/exec" "path/filepath" @@ -173,6 +175,89 @@ func TestMissingHelperLauncherFailsClosed(t *testing.T) { } } +func TestDiagnoseHookAcceptsCanonicalEventsForEveryHost(t *testing.T) { + repo := safetyTestRepo(t) + previous := hookDiagnosticRunner + defer func() { hookDiagnosticRunner = previous }() + hookDiagnosticRunner = func(_ context.Context, _ string, host string, input []byte) ([]byte, error) { + if len(input) == 0 { + t.Fatal("diagnostic omitted canonical input") + } + if host == "cursor" { + return []byte(`{"continue":true,"permission":"allow"}`), nil + } + return nil, nil + } + for _, host := range []string{"cursor", "claude", "codex"} { + t.Run(host, func(t *testing.T) { + diagnostic, err := DiagnoseHook(repo, host) + if err != nil { + t.Fatal(err) + } + if diagnostic.Host != host || diagnostic.ContractStatus != "PASS" || diagnostic.LiveEventObserved { + t.Fatalf("unexpected diagnostic: %+v", diagnostic) + } + }) + } +} + +func TestDiagnoseHookRejectsUnsupportedHost(t *testing.T) { + repo := safetyTestRepo(t) + if _, err := DiagnoseHook(repo, "other"); err == nil || !strings.Contains(err.Error(), "unsupported hook host") { + t.Fatalf("unsupported host was not rejected: %v", err) + } +} + +func TestDiagnoseHookSupportsRepositoryPathsWithSpaces(t *testing.T) { + repo := safetyTestRepo(t) + renamed := repo + " with spaces" + if err := os.Rename(repo, renamed); err != nil { + t.Fatal(err) + } + previous := hookDiagnosticRunner + defer func() { hookDiagnosticRunner = previous }() + hookDiagnosticRunner = func(_ context.Context, observedRepo, _ string, _ []byte) ([]byte, error) { + observedInfo, observedErr := os.Stat(observedRepo) + wantedInfo, wantedErr := os.Stat(renamed) + if observedErr != nil || wantedErr != nil || !os.SameFile(observedInfo, wantedInfo) { + t.Fatalf("diagnostic repo = %q, want %q", observedRepo, renamed) + } + return []byte(`{"continue":true,"permission":"allow"}`), nil + } + if _, err := DiagnoseHook(renamed, "cursor"); err != nil { + t.Fatal(err) + } +} + +func TestHookDiagnosticRejectsMalformedAllowOutput(t *testing.T) { + for _, test := range []struct { + host, output string + }{ + {"cursor", `{}`}, + {"cursor", `not-json`}, + {"claude", `{}`}, + {"codex", `unexpected`}, + } { + if err := validateCanonicalHookOutput(test.host, []byte(test.output)); err == nil { + t.Fatalf("%s malformed output was accepted: %q", test.host, test.output) + } + } +} + +func TestDiagnoseHookReportsGuardRuntimeFailures(t *testing.T) { + repo := safetyTestRepo(t) + previous := hookDiagnosticRunner + defer func() { hookDiagnosticRunner = previous }() + for _, message := range []string{"Boatstack shared runtime is missing", "Boatstack shared runtime checksum is invalid"} { + hookDiagnosticRunner = func(_ context.Context, _ string, _ string, _ []byte) ([]byte, error) { + return []byte(message), errors.New("exit status 2") + } + if _, err := DiagnoseHook(repo, "cursor"); err == nil || !strings.Contains(err.Error(), message) { + t.Fatalf("runtime failure %q was not diagnosed: %v", message, err) + } + } +} + func TestGuardRejectsTamperedSharedRuntimeBeforeExecution(t *testing.T) { if _, err := exec.LookPath("bash"); err != nil { t.Skip("bash unavailable") diff --git a/boatstack/safety.go b/boatstack/safety.go index 34f81f1..f91e407 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -2,7 +2,7 @@ package boatstack import ( "encoding/json" - "fmt" + "errors" "os" "os/exec" "path/filepath" @@ -30,6 +30,16 @@ type SafetyHookOptions struct { Input []byte } +type hookDecodeError struct { + code string +} + +func (err hookDecodeError) Error() string { return err.code } + +func malformedHookInput(code string) error { + return hookDecodeError{code: code} +} + 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 { @@ -177,7 +187,7 @@ func invokedRepositoryFiles(repo, command string) []string { 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"}} + return []SafetyFinding{{Category: "malformed-tool-input", Reason: "empty-command", Source: "tool-input"}} } if deliveryStatePathPattern.MatchString(command) && !isPureReadOnlyCommand(command) { return []SafetyFinding{{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state"}} @@ -233,7 +243,7 @@ func ClassifyTool(repo, name string, input any) []SafetyFinding { } value, err := json.Marshal(input) if err != nil { - return []SafetyFinding{{Category: "malformed-tool-input", Reason: "tool arguments could not be inspected", Source: "tool-input"}} + return []SafetyFinding{{Category: "malformed-tool-input", Reason: "invalid-tool-input", Source: "tool-input"}} } combined := name + " " + string(value) findings := classifySafetyText(combined, "tool-input") @@ -283,9 +293,12 @@ type hookHostContract struct { } func decodeJSONObject(host string, value []byte) (map[string]any, error) { + if len(strings.TrimSpace(string(value))) == 0 { + return nil, malformedHookInput("empty-input") + } var event map[string]any if err := DecodeJSON("parse "+host+" hook event", "stdin", value, &event); err != nil { - return nil, err + return nil, malformedHookInput("invalid-json") } return event, nil } @@ -293,16 +306,16 @@ func decodeJSONObject(host string, value []byte) (map[string]any, error) { func cursorMCPInput(value any) (any, error) { if text, ok := value.(string); ok { if strings.TrimSpace(text) == "" { - return nil, fmt.Errorf("Cursor beforeMCPExecution tool_input is empty") + return nil, malformedHookInput("empty-tool-input") } var decoded any if err := json.Unmarshal([]byte(text), &decoded); err != nil { - return nil, fmt.Errorf("Cursor beforeMCPExecution tool_input is not valid JSON: %w", err) + return nil, malformedHookInput("invalid-tool-input-json") } return decoded, nil } if value == nil { - return nil, fmt.Errorf("Cursor beforeMCPExecution is missing tool_input") + return nil, malformedHookInput("missing-tool-input") } return value, nil } @@ -319,13 +332,19 @@ func decodeCursorHook(value []byte) (string, any, error) { switch eventName { case "beforeShellExecution": - if command == "" { - return "", nil, fmt.Errorf("Cursor beforeShellExecution is missing command") + if _, present := event["command"]; !present { + return "", nil, malformedHookInput("missing-command") + } + if strings.TrimSpace(command) == "" { + return "", nil, malformedHookInput("empty-command") } return "Bash", map[string]any{"command": command}, nil case "beforeMCPExecution": - if toolName == "" { - return "", nil, fmt.Errorf("Cursor beforeMCPExecution is missing tool_name") + if _, present := event["tool_name"]; !present { + return "", nil, malformedHookInput("missing-tool-name") + } + if strings.TrimSpace(toolName) == "" { + return "", nil, malformedHookInput("empty-tool-name") } input, inputErr := cursorMCPInput(toolInput) if inputErr != nil { @@ -338,7 +357,7 @@ func decodeCursorHook(value []byte) (string, any, error) { // as the requested tool operation. if toolName != "" && toolInput != nil { if command != "" { - return "", nil, fmt.Errorf("legacy Cursor hook input is ambiguous between shell and MCP execution") + return "", nil, malformedHookInput("ambiguous-event") } input, inputErr := cursorMCPInput(toolInput) if inputErr != nil { @@ -349,9 +368,9 @@ func decodeCursorHook(value []byte) (string, any, error) { if command != "" && toolName == "" { return "Bash", map[string]any{"command": command}, nil } - return "", nil, fmt.Errorf("legacy Cursor hook input has no unambiguous shell command or MCP tool") + return "", nil, malformedHookInput("missing-command-or-tool") default: - return "", nil, fmt.Errorf("unsupported Cursor hook event %s", eventName) + return "", nil, malformedHookInput("unsupported-event") } } @@ -362,18 +381,24 @@ func decodePreToolUseHook(host string, value []byte) (string, any, error) { } eventName := stringValue(event["hook_event_name"]) if eventName != "" && eventName != "PreToolUse" { - return "", nil, fmt.Errorf("unsupported %s hook event %s", host, eventName) + return "", nil, malformedHookInput("unsupported-event") } 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) + if _, present := event["tool_name"]; !present { + return "", nil, malformedHookInput("missing-tool-name") + } + if strings.TrimSpace(name) == "" { + return "", nil, malformedHookInput("empty-tool-name") + } + if input == nil { + return "", nil, malformedHookInput("missing-tool-input") } return name, input, nil } -func structuredHookDeny(finding SafetyFinding) ([]byte, error) { - message := denialMessage(finding) +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, @@ -390,7 +415,7 @@ var hookHostContracts = map[string]hookHostContract{ return append(value, '\n'), err }, deny: func(finding SafetyFinding) ([]byte, error) { - message := denialMessage(finding) + message := denialMessage("cursor", finding) value, err := json.Marshal(map[string]any{ "continue": true, "permission": "deny", "user_message": message, "agent_message": message, }) @@ -399,15 +424,30 @@ var hookHostContracts = map[string]hookHostContract{ }, "claude": { decode: func(value []byte) (string, any, error) { return decodePreToolUseHook("claude", value) }, - allow: func() ([]byte, error) { return nil, nil }, deny: structuredHookDeny, + allow: func() ([]byte, error) { return nil, nil }, + deny: func(finding SafetyFinding) ([]byte, error) { return structuredHookDeny("claude", finding) }, }, "codex": { decode: func(value []byte) (string, any, error) { return decodePreToolUseHook("codex", value) }, - allow: func() ([]byte, error) { return nil, nil }, deny: structuredHookDeny, + allow: func() ([]byte, error) { return nil, nil }, + deny: func(finding SafetyFinding) ([]byte, error) { return structuredHookDeny("codex", finding) }, }, } -func denialMessage(finding SafetyFinding) string { +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." } @@ -425,7 +465,7 @@ func HookDecision(options SafetyHookOptions) ([]byte, bool) { contract, supported := hookHostContracts[host] if !supported { finding := SafetyFinding{Category: "unsupported-host", Reason: "unknown host is denied by the fail-closed guard", Source: "hook"} - value, _ := structuredHookDeny(finding) + value, _ := structuredHookDeny("codex", finding) return value, true } repo, err := ResolveRepository(options.Repo) @@ -436,7 +476,12 @@ func HookDecision(options SafetyHookOptions) ([]byte, bool) { } name, input, err := contract.decode(options.Input) if err != nil { - finding := SafetyFinding{Category: "malformed-tool-input", Reason: err.Error(), Source: "hook"} + reason := "invalid-event" + var decodeErr hookDecodeError + if errors.As(err, &decodeErr) { + reason = decodeErr.code + } + finding := SafetyFinding{Category: "malformed-tool-input", Reason: reason, Source: "hook"} value, _ := contract.deny(finding) return value, true } diff --git a/boatstack/safety_test.go b/boatstack/safety_test.go index 6434274..f37d2ba 100644 --- a/boatstack/safety_test.go +++ b/boatstack/safety_test.go @@ -215,22 +215,66 @@ func TestHostContractsNormalizeCanonicalInputs(t *testing.T) { func TestMalformedHostPayloadsDenyWithoutLeakingInput(t *testing.T) { repo := safetyTestRepo(t) - for _, test := range []struct{ host, input string }{ - {"cursor", `{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__cloud__delete_database","tool_input":"secret-not-json"}`}, - {"cursor", `{"hook_event_name":"unknown","command":"secret-command"}`}, - {"claude", `{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"secret-command"}}`}, - {"codex", `{"hook_event_name":"PreToolUse","tool_name":"Bash"}`}, + for _, test := range []struct{ host, input, reason string }{ + {"cursor", ``, "empty-input"}, + {"cursor", `{`, "invalid-json"}, + {"cursor", `{"hook_event_name":"beforeShellExecution"}`, "missing-command"}, + {"cursor", `{"hook_event_name":"beforeShellExecution","command":""}`, "empty-command"}, + {"cursor", `{"hook_event_name":"beforeMCPExecution","tool_input":{}}`, "missing-tool-name"}, + {"cursor", `{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__cloud__delete_database","tool_input":"secret-not-json"}`, "invalid-tool-input-json"}, + {"cursor", `{"hook_event_name":"unknown","command":"secret-command"}`, "unsupported-event"}, + {"claude", `{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"secret-command"}}`, "unsupported-event"}, + {"codex", `{"hook_event_name":"PreToolUse","tool_name":"Bash"}`, "missing-tool-input"}, } { output, denied := HookDecision(SafetyHookOptions{Host: test.host, Repo: repo, Input: []byte(test.input)}) if !denied { t.Fatalf("%s malformed payload was allowed", test.host) } + body := string(output) + if !strings.Contains(body, "HOST_PAYLOAD_MALFORMED:"+test.reason) { + t.Fatalf("%s malformed payload did not expose safe reason %s: %s", test.host, test.reason, body) + } + if !strings.Contains(body, "No unsafe operation was detected") || strings.Contains(body, "denied an irreversible operation") { + t.Fatalf("%s malformed payload was misattributed: %s", test.host, body) + } + if strings.Contains(body, "run the verified installer") || strings.Contains(body, "hydrate") { + t.Fatalf("%s malformed payload recommended runtime repair: %s", test.host, body) + } if strings.Contains(string(output), "secret") { t.Fatalf("%s denial leaked input: %s", test.host, output) } } } +func TestCursorMalformedPayloadGuidesOneRetryThenExternalDiagnosis(t *testing.T) { + repo := safetyTestRepo(t) + output, denied := HookDecision(SafetyHookOptions{Host: "cursor", Repo: repo, Input: []byte(`{"hook_event_name":"beforeShellExecution"}`)}) + if !denied { + t.Fatal("missing Cursor command was allowed") + } + body := string(output) + for _, expected := range []string{"Retry once", "stop shell and tool retries", "preserve current edits", "Start a new Cursor task", "diagnose-hook --host cursor", "Do not reinstall Boatstack"} { + if !strings.Contains(body, expected) { + t.Fatalf("Cursor recovery omitted %q: %s", expected, body) + } + } +} + +func TestEveryHostUsesStableEmptyCommandReason(t *testing.T) { + repo := safetyTestRepo(t) + inputs := map[string]string{ + "cursor": `{"hook_event_name":"beforeShellExecution","command":""}`, + "claude": `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":""}}`, + "codex": `{"hook_event_name":"PreToolUse","tool_name":"Shell","tool_input":{"command":""}}`, + } + for _, host := range []string{"cursor", "claude", "codex"} { + output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(inputs[host])}) + if !denied || !strings.Contains(string(output), "HOST_PAYLOAD_MALFORMED:empty-command") { + t.Fatalf("%s did not return stable empty-command reason: %s", host, output) + } + } +} + func TestBlockedHookNeverCreatesSentinelSideEffect(t *testing.T) { repo := safetyTestRepo(t) sentinel := filepath.Join(repo, "sentinel") diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index c2cf4fa..040118c 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 [`9a20177c48c292c1c6e1b967e0fd710f2e410a7c`](https://github.com/operatorstack/intelligence-flow/tree/9a20177c48c292c1c6e1b967e0fd710f2e410a7c/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 [`3c82bbed52b38acc02fd7f7acf353fe60b394440`](https://github.com/operatorstack/intelligence-flow/tree/3c82bbed52b38acc02fd7f7acf353fe60b394440/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 7690006..dc327f4 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "9a20177c48c292c1c6e1b967e0fd710f2e410a7c", + "source_commit": "3c82bbed52b38acc02fd7f7acf353fe60b394440", "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:9a20177c48c292c1c6e1b967e0fd710f2e410a7c" + "last_verified_version": "source:3c82bbed52b38acc02fd7f7acf353fe60b394440" }, { "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:9a20177c48c292c1c6e1b967e0fd710f2e410a7c" + "last_verified_version": "source:3c82bbed52b38acc02fd7f7acf353fe60b394440" }, { "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:9a20177c48c292c1c6e1b967e0fd710f2e410a7c" + "last_verified_version": "source:3c82bbed52b38acc02fd7f7acf353fe60b394440" }, { "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:9a20177c48c292c1c6e1b967e0fd710f2e410a7c" + "last_verified_version": "source:3c82bbed52b38acc02fd7f7acf353fe60b394440" }, { "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:9a20177c48c292c1c6e1b967e0fd710f2e410a7c" + "last_verified_version": "source:3c82bbed52b38acc02fd7f7acf353fe60b394440" }, { "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:9a20177c48c292c1c6e1b967e0fd710f2e410a7c" + "last_verified_version": "source:3c82bbed52b38acc02fd7f7acf353fe60b394440" }, { "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:9a20177c48c292c1c6e1b967e0fd710f2e410a7c" + "last_verified_version": "source:3c82bbed52b38acc02fd7f7acf353fe60b394440" }, { "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:9a20177c48c292c1c6e1b967e0fd710f2e410a7c" + "last_verified_version": "source:3c82bbed52b38acc02fd7f7acf353fe60b394440" }, { "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:9a20177c48c292c1c6e1b967e0fd710f2e410a7c" + "last_verified_version": "source:3c82bbed52b38acc02fd7f7acf353fe60b394440" }, { "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:9a20177c48c292c1c6e1b967e0fd710f2e410a7c" + "last_verified_version": "source:3c82bbed52b38acc02fd7f7acf353fe60b394440" }, { "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:9a20177c48c292c1c6e1b967e0fd710f2e410a7c" + "last_verified_version": "source:3c82bbed52b38acc02fd7f7acf353fe60b394440" } ] } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fa75aae..7039c10 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -29,6 +29,16 @@ If the worktree expects a different Boatstack version or source commit, update o This is a Cursor host initialization failure: Boatstack's hook process did not start. Keep the hook fail-closed, run **Developer: Reload Window** in Cursor, and retry the Boatstack operation. Do not reinstall Boatstack for this error alone. Reinstall only when Boatstack itself reports a missing, drifted, unsafe, or checksum-invalid helper or shared runtime. +## A host reports `HOST_PAYLOAD_MALFORMED` + +Boatstack received a hook event without a decodable command or tool call. It fails closed, but no unsafe operation was detected. Retry once with an explicit non-empty command. If the same code repeats, stop agent shell and tool retries, preserve edits, and run this from a normal terminal outside the blocked agent path: + +```bash +.product-loop/bin/boatstack-helper diagnose-hook --host cursor --repo . +``` + +Replace `cursor` with `claude` or `codex` for those hosts. A passing probe proves the installed wrapper, shared runtime, decoder, and canonical allow response; it cannot reveal the live payload emitted by the coding host. For Cursor, start a new task after a passing probe. Do not reinstall or hydrate Boatstack unless it separately reports a missing, drifted, unsafe, or checksum-invalid runtime. + ## `/repair` says there is no active delivery Repair compares an exact requested change with an activated, approved baseline. If no Boatstack feature has started, save the host Plan-mode file and run `/auto-plan`. If a draft or approved feature already exists, run the one planning or build operation reported by `/boatstack-next`; do not create or clear delivery state manually. diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 56a6300..e8f9ede 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": "9a20177c48c292c1c6e1b967e0fd710f2e410a7c", + "source_commit": "3c82bbed52b38acc02fd7f7acf353fe60b394440", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-07-19-malformed-host-event-recovery.md b/release-notes/2026-07-19-malformed-host-event-recovery.md new file mode 100644 index 0000000..13da403 --- /dev/null +++ b/release-notes/2026-07-19-malformed-host-event-recovery.md @@ -0,0 +1,3 @@ +### Diagnose malformed coding-host events without blaming the command + +Boatstack now distinguishes an undecodable coding-host hook event from a detected irreversible operation. Agents retry once, then stop repeated shell attempts, preserve edits, and use a canonical external hook diagnostic that separates healthy Boatstack installation state from a broken live host payload without exposing commands or secrets.