From ff43a2f0ff65e007321442a7231ff4d7a25ff111 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:11:55 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow 09388a6c92ec --- CONTRIBUTING.md | 2 +- README.md | 3 + UPSTREAM.json | 38 +-- boatstack/cmd/boatstack-helper/main.go | 23 ++ boatstack/hooks.go | 92 ++++++- boatstack/hooks_test.go | 24 +- boatstack/init.go | 28 +- boatstack/planning.go | 40 +-- boatstack/references/failure-moves.md | 2 + boatstack/runtime_cache.go | 340 +++++++++++++++++++++++++ boatstack/runtime_cache_test.go | 237 +++++++++++++++++ docs/evidence-engineered-coding.md | 4 +- docs/generated-files.md | 8 +- docs/getting-started.md | 8 +- docs/public-claims.json | 29 ++- docs/troubleshooting.md | 6 +- docs/why-these-steps.md | 10 + examples/diagram-json/plan.lock.json | 2 +- 18 files changed, 809 insertions(+), 87 deletions(-) create mode 100644 boatstack/runtime_cache.go create mode 100644 boatstack/runtime_cache_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08ba404..2f0095c 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/9bfb96de291e2552d307fa3c1b23e67771bcb86d/examples/12-product-engineering-loop). +Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/09388a6c92ece15283a8d0dc2b7edef3d2cc3aba/examples/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/README.md b/README.md index 7b07172..1c48f04 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ They come from real coding failures we observed—not guesses. For every safegua | “Tests passed” was used to support claims the tests did not cover | It links each promised outcome to the check that can disprove it | Plan compiler and coverage tests | | A failed external write led to an invented reset path | It denies high-confidence destructive recovery before execution | Host-hook fixtures; overall benefit still being evaluated | | A PR lost the decisions and gaps behind the change | It builds a review brief from the approved scope, actual diff, and recorded evidence | PR projection and stale-preview tests | +| A new worktree had the safety hook but not its ignored helper | It restores the verified local runtime from the clone before judging the first command | Real linked-worktree and tamper tests | [Read what happened, what is tested, and what remains open](docs/why-these-steps.md). The machine-readable [claim record](docs/public-claims.json) keeps the public wording tied to its sources. @@ -37,6 +38,8 @@ Install Boatstack in this repository from https://github.com/operatorstack/boats Install Boatstack in its own infrastructure PR and merge that PR before starting a feature. This keeps one-time repository setup out of later product diffs. +Install once per Git clone. Linked Git worktrees reuse the verified runtime and restore their ignored local helper automatically on first use. + ## Start with two moves 1. Create and save a plan in your coding tool's Plan mode. diff --git a/UPSTREAM.json b/UPSTREAM.json index 9a58dc3..cb5084f 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 32685, - "estimated_tokens": 8172, + "characters": 33244, + "estimated_tokens": 8311, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,8 +12,8 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "03ac814fc7ad16af762ddfd019b9d07227a1afda535fbcea8ba1653818123002", - "README.md": "9dcc7ba6fd7032b2a11d66891a353508f2f23ffa8fb8969a32a94a5e6db1a3aa", + "CONTRIBUTING.md": "fbc9acf97597aaad553fc0a1b78af6ba70b92eb4174c61d46718595f765765bd", + "README.md": "9e82e07dd95021ea9cb307e0e13a14747852be051d8a9b1261db15b24b919879", "assets/boatstack-journey.svg": "c1f7fe2741f5e9ca66bb3fe9b103e6364ba5acbca8b7a8054768ffd85cf325ea", "assets/boatstack-mark.svg": "c46e935f06fcfde3b37abfd579c1963b765b2337a0fa993f9538c9b652297e39", "assets/boatstack-model-choice.svg": "979952c2fc6220d41426f9072186fca000fb3f388a4e775cc09cc1e830ebdde4", @@ -31,27 +31,29 @@ "boatstack/assets/templates/test-plan.md": "6db8a9f27dd171fb80222a501cae50eb051e7278c04703fa43b5ff86dd4d2df4", "boatstack/atomic_unix.go": "89f2723361591de2bb8bd22ce7e34ec529d3278509f0df78fd5c4a7d4140fbe9", "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", - "boatstack/cmd/boatstack-helper/main.go": "93a3eda3c6216018d83e0e419e201da7a046aab8f36f9dc6cb2f8b0842335242", + "boatstack/cmd/boatstack-helper/main.go": "32d73f6ac8e87cbe7ebd8ee74e63dfe6f0b8b35da1d3200e23dcf011be9c19ea", "boatstack/export.go": "c53c5ec83dcea392d2e360c6819202b5f7cf9b3ae64510087bd627c4aaee82e3", "boatstack/export_test.go": "f95be9c458645e9b150f25921a0452c9e10e6a94809567ee5ddbc8e833e4f040", "boatstack/go.mod": "57c377eccea51372d6664de4169e2ca45806b046f7e8a98a1e35a9eb454b4b8d", - "boatstack/hooks.go": "3030ca262a39b5bbef8509bb1395b3b9635719dd9ce0b196f72c596922509e92", - "boatstack/hooks_test.go": "5b8852e6176d96315c983f261d8503b57298063eb251283088eb103e42d7ec0d", - "boatstack/init.go": "74e8f1dda5761c36d62c6679ee8e935138115b930f654c9b9d04a153af2642a7", + "boatstack/hooks.go": "aed9cee6d3e3fb42e5e98288eade420793a07a4b071e78a613fc888c74521406", + "boatstack/hooks_test.go": "a5298b7f46709bce617913085fe3b597a4bb4f730a5b5b51f459be85adbefcbb", + "boatstack/init.go": "ddee0cfd8ab9f3416fb895afd99d1682ca09550d2d9b2ad3cd11f5e06438c585", "boatstack/init_test.go": "b761ada1f5a04c0a27225a6f1eb99baf5477a424c5a9748a3267f07ba5a84605", "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", "boatstack/plan.go": "519fe7a782c0384d62fda228c58145d36e01a3941691b1839a2d1476528c27b7", "boatstack/plan_test.go": "006cdc6681f77e579c5a0f709e30ede759c337132d4f2f5193b7b79b29bd7149", - "boatstack/planning.go": "9485eedde503f54141388ea91d6d7a17f5d59663a38db2af9fddcd933f057571", + "boatstack/planning.go": "3a26417a295e5dfc2b6dcac702287c04b6053e7b74215858a4ea11cf9f9dadfe", "boatstack/planning_test.go": "4662908c1ec063aa8ef6f91db52247864303d9b91ef2363a8f68b41082fe383f", "boatstack/pr.go": "6fbfd1e673c55e8358090693b20edb9bc6e8efb8913de9ffcaa5143ca24f0947", "boatstack/pr_test.go": "f200a3a860e3da22798ec17a8eaf335724885b09d48b36bf0c350acdd3cc3ab7", "boatstack/references/artifacts.md": "0a72961aa7a942056f3185417f545d205a3ce21856e602e367141660298c9410", - "boatstack/references/failure-moves.md": "5ac4bbc279a1f7c2b420c15b0f9bc73fb15c37c8f2ec08c45e1acd3aae46b75a", + "boatstack/references/failure-moves.md": "1d35126348d0b681976e8819665e16fd745fd65eca271492603cb80aab75bf49", "boatstack/references/irreversible-operation-boundary.md": "2a695f2d7de95cfc8750f107bef9c86581712aa1f02e7233b69b850d8c2af42e", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", "boatstack/references/workflow.md": "0a32f00c12ea1d92db2e3b29ce5cfdcdd5a013c50ae67f2fc56f0ebde6951988", "boatstack/runtime.go": "b988d57ec14e15fc6a57949a995879fc0e0d6bfa9a7b62935e7754df0b85d87a", + "boatstack/runtime_cache.go": "ab0fbb7f8a2eb8d8428e928fbc3d8866c84104e7cb330a901c121dfec82cddb3", + "boatstack/runtime_cache_test.go": "4cbca9dec7800d7df6e3ec0d74c7ecbe1508e5c5a288d863f35fc8d22986c308", "boatstack/safety.go": "fbf30c34642db6ac18e0e15abbf78cbcd9177cc7aa678b44b4eaabc0202f5bd5", "boatstack/safety_test.go": "62375fd640d543ab8875c7b31fd935ac7f5385830f625123f44508e629b4ff08", "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4", @@ -62,22 +64,22 @@ "docs/account-recovery-walkthrough.md": "acd3558a95f48004f18a0590670de496e1cc9f0cd1d187f924615497f57e1d6f", "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", - "docs/evidence-engineered-coding.md": "3fb731119f5d11bc9340e61a8d5a6535709a777379d1d1269764e6be2e9e08ef", - "docs/generated-files.md": "040149341dceb192ba240edc250f8d3e4124b9b9444d0628df42dab667192db6", - "docs/getting-started.md": "432b64d3de11ffe56204f6eb12712c714eca31620d51afead482be70c76a2f35", - "docs/public-claims.json": "60f57b0c796c0f662ef72929288f537ea9414da69437bff9f7fd106af047063c", + "docs/evidence-engineered-coding.md": "7240e06a5f1ee2fc6cecf2777f6438a5b7ebdaae96fe8f7ef626886dcab6f666", + "docs/generated-files.md": "33ea0799eb01af6e035fb1322c6a58c39ecc5dcc95169ce0adbd7933a0bfbee1", + "docs/getting-started.md": "9741947c4b072c0838d0ee3a578215d5fac1d72cf5e73075136d0a9c95db2bbb", + "docs/public-claims.json": "a011f7fc31dc656428f3544b61e34ebef2806f376635151cb44571b31e5422f7", "docs/public-surface.md": "53d741f04b2928a6ee8c006d647a6d675a215e863412e5862cd67d48433bff76", "docs/research-and-design.md": "d65c66e323037bda5d45aacef5d48afa6bf93da55901378891d235aca3a5684f", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", - "docs/troubleshooting.md": "a9f28e156702a970792421766d38cab3fb99a6a9ce497bd058ee4026608798ff", + "docs/troubleshooting.md": "27e73986a30df5d011b33c3d4701ce30610359e32c496c50d1b783ef5adf5c69", "docs/validation-and-evidence.md": "a9fe9274f3dc22b152094a307feda5d8c3ab099755100aef77bda13024cc3166", - "docs/why-these-steps.md": "dc633f3edcc0c9d94ee7ea3feb57220987ad63ee8fb08b3df6e438dec866cacd", + "docs/why-these-steps.md": "e8f8918ad42b74314cf84974f6f4a7b4e79af4fc4571b6df7554e3c95f9e0da7", "examples/diagram-json/README.md": "061b583180e43bbd26618bbd9d3d79af4b75d7c8f37c66475640745a97328fbc", "examples/diagram-json/approval.md": "bc421a825349923512d5cb0ce489310d3a4d7cbac35e661a693b4a32eec263d1", "examples/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "examples/diagram-json/compiled/tasks.json": "f040696f1f8bcedc4a8ed9816a61a49edbda970ec0cc3b28175ba37b73bbc896", "examples/diagram-json/compiled/test-matrix.json": "6c6895c509271e4337f3c91d9f62ee3a2b34e768e78513784cb012506a328ecf", - "examples/diagram-json/plan.lock.json": "4103a084bb610e1fc2fdf3257fd2ca2bb4f82f181da6f71faea73e1275d333a7", + "examples/diagram-json/plan.lock.json": "fe627c500ffccf639c113ec7eda6e1d20b39aaf643672ca7721e518b5d8f6ebe", "examples/diagram-json/plan.md": "3ad35cc3cbe48306e7ee401bd9e9047d25e46c8a6fe9679aa1b3f5e96ceea292", "examples/diagram-json/questions.md": "1a0050041cac0a8d53e6ebfe04cbec4a298cdc8c50efeeb6fa15aeb663c5ec76", "examples/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -90,7 +92,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "9bfb96de291e2552d307fa3c1b23e67771bcb86d", + "commit": "09388a6c92ece15283a8d0dc2b7edef3d2cc3aba", "path": "examples/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 0a9a151..8dc22bd 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -260,6 +260,27 @@ func safetyHookCommand(arguments []string) int { return 0 } +func bootstrapSafetyHookCommand(arguments []string) int { + flags := flag.NewFlagSet("bootstrap-safety-hook", flag.ContinueOnError) + host := flags.String("host", "", "cursor, claude, or codex") + repo := flags.String("repo", ".", "worktree protected by the hook") + if err := flags.Parse(arguments); err != nil { + return 2 + } + input, err := io.ReadAll(os.Stdin) + if err != nil { + input = nil + } + if err := boatstack.HydrateWorktree(*repo); err != nil { + return fail(fmt.Errorf("worktree runtime activation failed: %w", err)) + } + value, _ := boatstack.HookDecision(boatstack.SafetyHookOptions{Host: *host, Repo: *repo, Input: input}) + if len(value) > 0 { + fmt.Print(string(value)) + } + return 0 +} + func checkSafetyCommand(arguments []string) int { flags := flag.NewFlagSet("check-safety", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose operational diff should be checked") @@ -398,6 +419,8 @@ func run() int { return doctorCommand(os.Args[2:]) case "safety-hook": return safetyHookCommand(os.Args[2:]) + case "bootstrap-safety-hook": + return bootstrapSafetyHookCommand(os.Args[2:]) case "check-safety": return checkSafetyCommand(os.Args[2:]) case "version": diff --git a/boatstack/hooks.go b/boatstack/hooks.go index da045cd..8efbfac 100644 --- a/boatstack/hooks.go +++ b/boatstack/hooks.go @@ -11,7 +11,7 @@ import ( const hookCommandMarker = ".product-loop/hooks/guard" func guardShellScript() []byte { - return []byte(`#!/usr/bin/env bash + return []byte(fmt.Sprintf(`#!/usr/bin/env bash # Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json. set -u @@ -22,18 +22,54 @@ if [[ -z "$ROOT" ]]; then exit 2 fi -HELPER="$ROOT/.product-loop/bin/boatstack-helper" +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 + exit 2 +fi + +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 ;; +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 ;; +esac + +HELPER="$COMMON/boatstack/runtimes/%s/%s/${OS_NAME}-${ARCH}/boatstack-helper${EXTENSION}" +MANIFEST="$COMMON/boatstack/runtimes/%s/%s/${OS_NAME}-${ARCH}/runtime.lock.json" if [[ ! -x "$HELPER" ]]; then - echo "Boatstack safety helper is missing; rerun the installer from the repository root." >&2 + echo "Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone." >&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 + exit 2 +fi +EXPECTED="$(sed -n 's/.*"binary_sha256"[[:space:]]*:[[:space:]]*"\([0-9a-f]\{64\}\)".*/\1/p' "$MANIFEST" | head -n 1)" +if command -v sha256sum >/dev/null 2>&1; then + ACTUAL="$(sha256sum "$HELPER" | awk '{print $1}')" +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 + exit 2 +fi +if [[ -z "$EXPECTED" || "$ACTUAL" != "$EXPECTED" ]]; then + echo "Boatstack shared runtime checksum is invalid; rerun the verified tagged installer." >&2 exit 2 fi -exec "$HELPER" safety-hook --host "$HOST" --repo "$ROOT" -`) +exec "$HELPER" bootstrap-safety-hook --host "$HOST" --repo "$ROOT" +`, Version, SourceCommit, Version, SourceCommit)) } func guardPowerShellScript() []byte { - return []byte(`# Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json. + 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" $root = (& git rev-parse --show-toplevel 2>$null) @@ -41,14 +77,50 @@ if (-not $root) { [Console]::Error.WriteLine("Boatstack safety guard could not resolve the repository; denying tool execution.") exit 2 } -$helper = Join-Path $root ".product-loop/bin/boatstack-helper.exe" +$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.") + exit 2 +} +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +$arch = switch ($architecture) { + "x64" { "amd64" } + "arm64" { "arm64" } + default { + [Console]::Error.WriteLine("Boatstack safety guard found an unsupported architecture; denying tool execution.") + exit 2 + } +} +$helper = Join-Path $common "boatstack/runtimes/%s/%s/windows-$arch/boatstack-helper.exe" +$manifestPath = Join-Path $common "boatstack/runtimes/%s/%s/windows-$arch/runtime.lock.json" if (-not (Test-Path -LiteralPath $helper -PathType Leaf)) { - [Console]::Error.WriteLine("Boatstack safety helper is missing; rerun the installer from the repository root.") + [Console]::Error.WriteLine("Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone.") + 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.") + 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.") + 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.") + 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.") exit 2 } -& $helper safety-hook --host $HostName --repo $root +& $helper bootstrap-safety-hook --host $HostName --repo $root exit $LASTEXITCODE -`) +`, Version, SourceCommit, Version, SourceCommit)) } func hookCommand(host string) string { diff --git a/boatstack/hooks_test.go b/boatstack/hooks_test.go index 268e56d..782a500 100644 --- a/boatstack/hooks_test.go +++ b/boatstack/hooks_test.go @@ -76,11 +76,33 @@ func TestMissingHelperLauncherFailsClosed(t *testing.T) { command := exec.Command("bash", path, "cursor") command.Dir = repo output, err := command.CombinedOutput() - if err == nil || !strings.Contains(string(output), "helper is missing") { + if err == nil || !strings.Contains(string(output), "shared runtime is missing") { t.Fatalf("missing helper did not fail closed: err=%v output=%s", err, output) } } +func TestGuardRejectsTamperedSharedRuntimeBeforeExecution(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash unavailable") + } + repo := runtimeTestRepo(t) + binaryPath, _, err := sharedRuntimePaths(repo, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(binaryPath, []byte("tampered"), 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(repo, ".product-loop", "hooks", "guard.sh") + command := exec.Command("bash", path, "claude") + command.Dir = repo + command.Stdin = strings.NewReader(`{"tool_name":"Bash","tool_input":{"command":"git status --short"}}`) + output, runErr := command.CombinedOutput() + if runErr == nil || !strings.Contains(string(output), "checksum is invalid") { + t.Fatalf("tampered shared helper was not denied before execution: err=%v output=%s", runErr, output) + } +} + func TestHookFragmentsAreValidJSON(t *testing.T) { for _, host := range []string{"cursor", "claude", "codex"} { value, err := hookFragmentJSON(host) diff --git a/boatstack/init.go b/boatstack/init.go index 3472235..2282660 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -186,12 +186,8 @@ func copyHelper(source, repo string) (string, string, error) { if err != nil { return "", "", err } - name := "boatstack-helper" - if runtime.GOOS == "windows" { - name += ".exe" - } - destination := filepath.Join(repo, ".product-loop", "bin", name) - if err := writeFile(destination, value, 0o755); err != nil { + destination := filepath.Join(repo, ".product-loop", "bin", helperName()) + if err := atomicWriteMode(destination, value, 0o755); err != nil { return "", "", err } return destination, SHA256Bytes(value), nil @@ -216,7 +212,7 @@ func writeInstallLock(repo, binaryPath, binaryHash string, integrations map[stri if err != nil { return err } - return writeFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json"), value, 0o644) + return atomicWriteMode(filepath.Join(repo, ".product-loop", "bin", "install.lock.json"), value, 0o644) } func readInstalledIntegrations(repo string, config ProjectConfig) (map[string]IntegrationState, error) { @@ -400,6 +396,16 @@ func RunInit(options InitOptions) error { return fmt.Errorf("installation cancelled before writing files") } } + helperSource := options.BinaryPath + if helperSource == "" { + helperSource, err = os.Executable() + if err != nil { + return err + } + } + if _, err := installSharedRuntime(helperSource, repo, config.Integrations); err != nil { + return fmt.Errorf("cannot install the repository-family Boatstack runtime: %w", err) + } if err := os.WriteFile(configPath, rawConfig, 0o644); err != nil { return err } @@ -409,7 +415,7 @@ func RunInit(options InitOptions) error { if err := InstallHostHooks(repo, config.Adapters); err != nil { return err } - binaryPath, binaryHash, err := copyHelper(options.BinaryPath, repo) + binaryPath, binaryHash, err := copyHelper(helperSource, repo) if err != nil { return err } @@ -422,6 +428,9 @@ func RunInit(options InitOptions) error { if err != nil { return err } + if _, err := installSharedRuntime(helperSource, repo, states); err != nil { + return fmt.Errorf("cannot finalize the repository-family Boatstack runtime: %w", err) + } if err := writeInstallLock(repo, binaryPath, binaryHash, states); err != nil { return err } @@ -482,7 +491,8 @@ func RunInit(options InitOptions) error { fmt.Fprintln(options.Output, " git commit -m \"chore: install Boatstack\"") fmt.Fprintln(options.Output, " git push -u origin chore/install-boatstack") } - fmt.Fprintln(options.Output, "The platform helper and local install lock under .product-loop/bin/ are ignored; rerun the installer on a fresh clone.") + fmt.Fprintln(options.Output, "The verified runtime is shared by worktrees in this Git clone; each worktree hydrates its ignored .product-loop/bin/ files automatically on first use.") + fmt.Fprintln(options.Output, "A separate fresh clone still requires one verified installer run.") if options.Update { fmt.Fprintln(options.Output, "\nAfter the update PR is merged, reload Cursor, Codex, or Claude.") } else { diff --git a/boatstack/planning.go b/boatstack/planning.go index 283d015..d2cf224 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -1,7 +1,6 @@ package boatstack import ( - "encoding/json" "fmt" "os" "path/filepath" @@ -53,7 +52,7 @@ func rejectSymlinkComponents(root, target string) error { return statErr } if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("refusing symlinked planning path: %s", current) + return fmt.Errorf("refusing symlinked path: %s", current) } } return nil @@ -174,6 +173,7 @@ func RecordApproval(options ApprovalRecordOptions) error { type installLock struct { BoatstackVersion string `json:"boatstack_version"` SourceCommit string `json:"source_commit"` + Platform string `json:"platform"` BinaryPath string `json:"binary_path"` BinarySHA256 string `json:"binary_sha256"` Integrations map[string]IntegrationState `json:"integrations,omitempty"` @@ -199,6 +199,12 @@ func Doctor(repoPath string) error { if err := CheckHostHooks(repo, config.Adapters); err != nil { return err } + if err := verifyGeneratedRuntime(repo); err != nil { + return err + } + if _, _, err := loadSharedRuntime(repo); err != nil { + return err + } for _, host := range []string{"cursor", "claude", "codex"} { if !contains(config.Adapters, host) { continue @@ -216,38 +222,12 @@ func Doctor(repoPath string) error { return fmt.Errorf("%s safety hook did not fail closed on malformed input", host) } } - lockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json") - value, err := os.ReadFile(lockPath) - if err != nil { - return fmt.Errorf("missing local install lock: %w", err) - } - var lock installLock - if err := json.Unmarshal(value, &lock); err != nil { - return fmt.Errorf("invalid local install lock: %w", err) - } - if lock.BoatstackVersion != Version || lock.SourceCommit != SourceCommit { - return fmt.Errorf("helper version drift: installed %s (%s), expected %s (%s)", lock.BoatstackVersion, lock.SourceCommit, Version, SourceCommit) - } - binaryPath, err := resolveRepositoryRelativePath(repo, lock.BinaryPath) - if err != nil { - return fmt.Errorf("invalid Boatstack helper path in install lock: %w", err) - } - if err := checkNonEmptyFile(binaryPath, "Boatstack helper"); err != nil { - return err - } - hash, err := SHA256File(binaryPath) - if err != nil { - return err - } - if hash != lock.BinarySHA256 { - return fmt.Errorf("Boatstack helper checksum does not match the install lock") - } - return nil + return verifyLocalRuntime(repo) } func DoctorRepairHint(err error) error { if err == nil { return nil } - return fmt.Errorf("%w; repair: rerun the Boatstack installer from the repository root, then reload the coding host", err) + return fmt.Errorf("%w; repair: rerun the verified Boatstack installer once from any checkout in this Git clone, then reload the coding host", err) } diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md index a96f391..aab7699 100644 --- a/boatstack/references/failure-moves.md +++ b/boatstack/references/failure-moves.md @@ -18,6 +18,7 @@ Select a move only after locating the failure below its surface symptom. “Time | Integration/deploy | Local pass but runtime fails | Environment parity; canary; health checks; rollback | Treating staging as identical to production | | Documentation drift | Durable behavior and docs disagree | Update source-of-truth artifact; drift check | Growing instructions with unverified rules | | Irreversible recovery escalation | A failed external operation causes authority/target broadening or an invented reset | Immutable pre-execution deny; preserve state; read-only diagnosis; transactional retry or fix forward | False denial of legitimate isolated development operations | +| Worktree bootstrap deadlock | A linked worktree inherits fail-closed hooks but not the ignored runtime required to evaluate them | Versioned Git-common runtime; atomic first-use hydration; provenance check | Cross-version execution or weakened failure behavior | ## Lessons encoded from the benchmark campaign @@ -29,6 +30,7 @@ Select a move only after locating the failure below its surface symptom. “Time - **Do not discard near-correct work.** Repair attempts can wash or regress, so retain prior evidence and compare states. - **Model changes relocate the bottleneck.** The same harness exposed different binding modes on Gemini and Qwen. Route moves by measured failure population, not by a universal “best loop.” - **Tool failure must not create recovery authority.** The sanitized database incident moved from a partial schema apply failure to an invented reset path. The irreversible-operation guard is `PROPOSED`, not promoted: evaluate its deny corpus, safe corpus, latency, and workflow regressions against the unguarded baseline. +- **Fail-closed controls need an available evaluator.** A linked worktree copied the safety hook but not its ignored helper, so the guard also denied its own repair command. Share only the verified runtime within the Git clone and hydrate local ignored state before judging the original event. ## Move proposal schema diff --git a/boatstack/runtime_cache.go b/boatstack/runtime_cache.go new file mode 100644 index 0000000..c5b97f7 --- /dev/null +++ b/boatstack/runtime_cache.go @@ -0,0 +1,340 @@ +package boatstack + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +type runtimeManifest struct { + SchemaVersion int `json:"schema_version"` + BoatstackVersion string `json:"boatstack_version"` + SourceCommit string `json:"source_commit"` + Platform string `json:"platform"` + BinarySHA256 string `json:"binary_sha256"` + ReleaseChecksumsSHA256 string `json:"release_checksums_sha256"` + Integrations map[string]IntegrationState `json:"integrations,omitempty"` +} + +type generatedRuntimeLock struct { + BoatstackVersion string `json:"boatstack_version"` + Runtime struct { + SourceCommit string `json:"source_commit"` + } `json:"runtime"` +} + +func helperName() string { + name := "boatstack-helper" + if runtime.GOOS == "windows" { + name += ".exe" + } + return name +} + +func platformKey() string { return runtime.GOOS + "-" + runtime.GOARCH } + +func safeCacheSegment(value, label string) (string, error) { + value = strings.TrimSpace(value) + if value == "" || value == "." || value == ".." || filepath.Base(value) != value || + strings.ContainsAny(value, `/\\`) { + return "", fmt.Errorf("invalid %s for shared runtime cache", label) + } + return value, nil +} + +func gitCommonDir(repo string) (string, error) { + value := gitOutput(repo, "rev-parse", "--path-format=absolute", "--git-common-dir") + if value == "" { + value = gitOutput(repo, "rev-parse", "--git-common-dir") + } + if value == "" { + return "", fmt.Errorf("cannot resolve the Git common directory") + } + if !filepath.IsAbs(value) { + value = filepath.Join(repo, value) + } + absolute, err := filepath.Abs(value) + if err != nil { + return "", err + } + return filepath.Clean(absolute), nil +} + +func sharedRuntimeDirectory(repo, version, sourceCommit string) (string, error) { + version, err := safeCacheSegment(version, "Boatstack version") + if err != nil { + return "", err + } + sourceCommit, err = safeCacheSegment(sourceCommit, "source commit") + if err != nil { + return "", err + } + common, err := gitCommonDir(repo) + if err != nil { + return "", err + } + return filepath.Join(common, "boatstack", "runtimes", version, sourceCommit, platformKey()), nil +} + +func sharedRuntimePaths(repo, version, sourceCommit string) (string, string, error) { + directory, err := sharedRuntimeDirectory(repo, version, sourceCommit) + if err != nil { + return "", "", err + } + return filepath.Join(directory, helperName()), filepath.Join(directory, "runtime.lock.json"), nil +} + +func atomicWriteMode(path string, content []byte, mode fs.FileMode) error { + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0o755); err != nil { + return err + } + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing symlinked runtime path: %s", path) + } else if err != nil && !os.IsNotExist(err) { + return err + } + temporary, err := os.CreateTemp(directory, ".boatstack-runtime-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(mode); err != nil { + temporary.Close() + return err + } + if _, err := temporary.Write(content); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return replaceFile(temporaryPath, path) +} + +func installSharedRuntime(source, repo string, integrations map[string]IntegrationState) (runtimeManifest, error) { + value, err := os.ReadFile(source) + if err != nil { + return runtimeManifest{}, err + } + manifest := runtimeManifest{ + SchemaVersion: 1, BoatstackVersion: Version, SourceCommit: SourceCommit, + Platform: platformKey(), BinarySHA256: SHA256Bytes(value), + ReleaseChecksumsSHA256: ChecksumsSHA256, Integrations: integrations, + } + binaryPath, manifestPath, err := sharedRuntimePaths(repo, Version, SourceCommit) + if err != nil { + return runtimeManifest{}, err + } + common, err := gitCommonDir(repo) + if err != nil { + return runtimeManifest{}, err + } + for _, path := range []string{binaryPath, manifestPath} { + if err := rejectSymlinkComponents(common, path); err != nil { + return runtimeManifest{}, err + } + } + // This exact provenance path is Boatstack-owned. A verified installer is the + // repair surface for an interrupted or corrupted cache population, so it may + // atomically replace the cached bytes after the symlink checks above. + if err := atomicWriteMode(binaryPath, value, 0o755); err != nil { + return runtimeManifest{}, err + } + encoded, err := MarshalJSON(manifest) + if err != nil { + return runtimeManifest{}, err + } + if err := atomicWriteMode(manifestPath, encoded, 0o644); err != nil { + return runtimeManifest{}, err + } + return manifest, nil +} + +func loadSharedRuntime(repo string) (runtimeManifest, string, error) { + binaryPath, manifestPath, err := sharedRuntimePaths(repo, Version, SourceCommit) + if err != nil { + return runtimeManifest{}, "", err + } + common, err := gitCommonDir(repo) + if err != nil { + return runtimeManifest{}, "", err + } + for _, path := range []string{binaryPath, manifestPath} { + if err := rejectSymlinkComponents(common, path); err != nil { + return runtimeManifest{}, "", err + } + } + value, err := os.ReadFile(manifestPath) + if err != nil { + return runtimeManifest{}, "", fmt.Errorf("shared Boatstack runtime is missing; run the verified installer once from any checkout in this Git clone: %w", err) + } + var manifest runtimeManifest + if err := json.Unmarshal(value, &manifest); err != nil { + return runtimeManifest{}, "", fmt.Errorf("shared Boatstack runtime manifest is invalid: %w", err) + } + if manifest.SchemaVersion != 1 || manifest.BoatstackVersion != Version || + manifest.SourceCommit != SourceCommit || manifest.Platform != platformKey() { + return runtimeManifest{}, "", fmt.Errorf("shared Boatstack runtime provenance does not match this worktree") + } + if info, err := os.Lstat(binaryPath); err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return runtimeManifest{}, "", fmt.Errorf("shared Boatstack runtime is missing or unsafe: %s", binaryPath) + } + hash, err := SHA256File(binaryPath) + if err != nil { + return runtimeManifest{}, "", err + } + if hash != manifest.BinarySHA256 { + return runtimeManifest{}, "", fmt.Errorf("shared Boatstack runtime checksum does not match its manifest") + } + return manifest, binaryPath, nil +} + +func verifyGeneratedRuntime(repo string) error { + value, err := os.ReadFile(filepath.Join(repo, ".product-loop", "generated.lock.json")) + if err != nil { + return fmt.Errorf("missing generated Boatstack runtime provenance: %w", err) + } + var lock generatedRuntimeLock + if err := json.Unmarshal(value, &lock); err != nil { + return fmt.Errorf("invalid generated Boatstack runtime provenance: %w", err) + } + if lock.BoatstackVersion != Version || lock.Runtime.SourceCommit != SourceCommit { + return fmt.Errorf("this worktree expects Boatstack %s (%s), but the runtime is %s (%s); update or rebase its Boatstack infrastructure", + lock.BoatstackVersion, lock.Runtime.SourceCommit, Version, SourceCommit) + } + return nil +} + +func acquireHydrationLock(repo string) (func(), error) { + lockPath := filepath.Join(repo, ".product-loop", "bin", ".hydrate.lock") + if err := rejectSymlinkComponents(repo, lockPath); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return nil, err + } + for attempt := 0; attempt < 100; attempt++ { + file, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err == nil { + if _, writeErr := fmt.Fprintf(file, "%d\n", os.Getpid()); writeErr != nil { + file.Close() + os.Remove(lockPath) + return nil, writeErr + } + if closeErr := file.Close(); closeErr != nil { + os.Remove(lockPath) + return nil, closeErr + } + return func() { _ = os.Remove(lockPath) }, nil + } + if !os.IsExist(err) { + return nil, err + } + // Another process may have completed hydration while this process + // waited. Avoid acquiring and rewriting state that is already valid. + if verifyErr := verifyLocalRuntime(repo); verifyErr == nil { + return func() {}, nil + } + if info, statErr := os.Lstat(lockPath); statErr == nil && time.Since(info.ModTime()) > 30*time.Second { + _ = os.Remove(lockPath) + continue + } + time.Sleep(25 * time.Millisecond) + } + return nil, fmt.Errorf("timed out waiting for another Boatstack worktree activation; retry the original command") +} + +func HydrateWorktree(repoPath string) error { + repo, err := ResolveRepository(repoPath) + if err != nil { + return err + } + if err := verifyGeneratedRuntime(repo); err != nil { + return err + } + manifest, sharedBinary, err := loadSharedRuntime(repo) + if err != nil { + return err + } + // Guards call this operation for every agent event. Once the local runtime is + // current and intact, verification is enough; avoid rewriting ignored state + // on every safe command. A missing, stale, or tampered local runtime falls + // through to verified shared-cache hydration. + if err := verifyLocalRuntime(repo); err == nil { + return nil + } + release, err := acquireHydrationLock(repo) + if err != nil { + return err + } + defer release() + if err := verifyLocalRuntime(repo); err == nil { + return nil + } + value, err := os.ReadFile(sharedBinary) + if err != nil { + return err + } + localBinary := filepath.Join(repo, ".product-loop", "bin", helperName()) + if err := rejectSymlinkComponents(repo, localBinary); err != nil { + return err + } + if err := atomicWriteMode(localBinary, value, 0o755); err != nil { + return err + } + if err := writeInstallLock(repo, localBinary, manifest.BinarySHA256, manifest.Integrations); err != nil { + return err + } + return verifyLocalRuntime(repo) +} + +func verifyLocalRuntime(repo string) error { + lockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json") + if err := rejectSymlinkComponents(repo, lockPath); err != nil { + return err + } + value, err := os.ReadFile(lockPath) + if err != nil { + return fmt.Errorf("missing local install lock: %w", err) + } + var lock installLock + if err := json.Unmarshal(value, &lock); err != nil { + return fmt.Errorf("invalid local install lock: %w", err) + } + if lock.BoatstackVersion != Version || lock.SourceCommit != SourceCommit { + return fmt.Errorf("helper version drift: installed %s (%s), expected %s (%s)", lock.BoatstackVersion, lock.SourceCommit, Version, SourceCommit) + } + if lock.Platform != runtime.GOOS+"/"+runtime.GOARCH { + return fmt.Errorf("helper platform drift: installed %s, expected %s/%s", lock.Platform, runtime.GOOS, runtime.GOARCH) + } + binaryPath, err := resolveRepositoryRelativePath(repo, lock.BinaryPath) + if err != nil { + return fmt.Errorf("invalid Boatstack helper path in install lock: %w", err) + } + if err := rejectSymlinkComponents(repo, binaryPath); err != nil { + return err + } + if err := checkNonEmptyFile(binaryPath, "Boatstack helper"); err != nil { + return err + } + hash, err := SHA256File(binaryPath) + if err != nil { + return err + } + if hash != lock.BinarySHA256 { + return fmt.Errorf("Boatstack helper checksum does not match the install lock") + } + return nil +} diff --git a/boatstack/runtime_cache_test.go b/boatstack/runtime_cache_test.go new file mode 100644 index 0000000..5610649 --- /dev/null +++ b/boatstack/runtime_cache_test.go @@ -0,0 +1,237 @@ +package boatstack + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +func runtimeTestRepo(t *testing.T) string { + t.Helper() + repo := planningRepo(t) + if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true}); err != nil { + t.Fatal(err) + } + return repo +} + +func TestHydrateWorktreeRestoresIgnoredRuntime(t *testing.T) { + repo := runtimeTestRepo(t) + localDirectory := filepath.Join(repo, ".product-loop", "bin") + if err := os.RemoveAll(localDirectory); err != nil { + t.Fatal(err) + } + if err := HydrateWorktree(repo); err != nil { + t.Fatal(err) + } + if err := Doctor(repo); err != nil { + t.Fatal(err) + } +} + +func TestHydrateWorktreeIsSafeUnderConcurrentFirstUse(t *testing.T) { + repo := runtimeTestRepo(t) + if err := os.RemoveAll(filepath.Join(repo, ".product-loop", "bin")); err != nil { + t.Fatal(err) + } + const workers = 8 + errors := make(chan error, workers) + var group sync.WaitGroup + for index := 0; index < workers; index++ { + group.Add(1) + go func() { + defer group.Done() + errors <- HydrateWorktree(repo) + }() + } + group.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + if err := Doctor(repo); err != nil { + t.Fatal(err) + } +} + +func TestHydrateWorktreeRecoversInterruptedActivationLock(t *testing.T) { + repo := runtimeTestRepo(t) + localDirectory := filepath.Join(repo, ".product-loop", "bin") + if err := os.RemoveAll(localDirectory); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(localDirectory, 0o755); err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(localDirectory, ".hydrate.lock") + if err := os.WriteFile(lockPath, []byte("interrupted\n"), 0o600); err != nil { + t.Fatal(err) + } + stale := time.Now().Add(-time.Minute) + if err := os.Chtimes(lockPath, stale, stale); err != nil { + t.Fatal(err) + } + if err := HydrateWorktree(repo); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(lockPath); !os.IsNotExist(err) { + t.Fatalf("stale activation lock was not removed: %v", err) + } +} + +func TestSharedRuntimeTamperingAndWorktreeVersionDriftFailClosed(t *testing.T) { + repo := runtimeTestRepo(t) + binaryPath, _, err := sharedRuntimePaths(repo, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(binaryPath, []byte("tampered"), 0o755); err != nil { + t.Fatal(err) + } + if err := HydrateWorktree(repo); err == nil || !strings.Contains(err.Error(), "checksum") { + t.Fatalf("expected shared checksum failure, got %v", err) + } + if _, err := installSharedRuntime(os.Args[0], repo, nil); err != nil { + t.Fatalf("verified installer could not repair the corrupt cache: %v", err) + } + if err := HydrateWorktree(repo); err != nil { + t.Fatalf("repaired shared cache did not hydrate: %v", err) + } + + repo = runtimeTestRepo(t) + lockPath := filepath.Join(repo, ".product-loop", "generated.lock.json") + value, err := os.ReadFile(lockPath) + if err != nil { + t.Fatal(err) + } + var lock map[string]any + if err := json.Unmarshal(value, &lock); err != nil { + t.Fatal(err) + } + lock["boatstack_version"] = "v99.0.0" + value, err = MarshalJSON(lock) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(lockPath, value, 0o644); err != nil { + t.Fatal(err) + } + if err := HydrateWorktree(repo); err == nil || !strings.Contains(err.Error(), "expects Boatstack") { + t.Fatalf("expected generated-runtime drift failure, got %v", err) + } +} + +func TestLocalRuntimePlatformDriftFailsClosed(t *testing.T) { + repo := runtimeTestRepo(t) + lockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json") + value, err := os.ReadFile(lockPath) + if err != nil { + t.Fatal(err) + } + var lock map[string]any + if err := json.Unmarshal(value, &lock); err != nil { + t.Fatal(err) + } + lock["platform"] = "different/architecture" + value, err = MarshalJSON(lock) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(lockPath, value, 0o644); err != nil { + t.Fatal(err) + } + if err := verifyLocalRuntime(repo); err == nil || !strings.Contains(err.Error(), "platform drift") { + t.Fatalf("expected local platform drift failure, got %v", err) + } + if err := HydrateWorktree(repo); err != nil { + t.Fatalf("shared cache did not repair platform-drifted local state: %v", err) + } +} + +func TestSharedRuntimeRejectsSymlinkedBinary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs elevated Windows permissions") + } + repo := runtimeTestRepo(t) + binaryPath, _, err := sharedRuntimePaths(repo, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "replacement") + if err := os.WriteFile(target, []byte("replacement"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Remove(binaryPath); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, binaryPath); err != nil { + t.Fatal(err) + } + if err := HydrateWorktree(repo); err == nil || !strings.Contains(err.Error(), "symlinked path") { + t.Fatalf("expected symlink rejection, got %v", err) + } +} + +func TestHydrateWorktreeRejectsSymlinkedRuntimeDirectories(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation needs elevated Windows permissions") + } + repo := runtimeTestRepo(t) + localDirectory := filepath.Join(repo, ".product-loop", "bin") + if err := os.RemoveAll(localDirectory); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), localDirectory); err != nil { + t.Fatal(err) + } + if err := HydrateWorktree(repo); err == nil || !strings.Contains(err.Error(), "symlinked path") { + t.Fatalf("expected local runtime-directory symlink rejection, got %v", err) + } + + repo = runtimeTestRepo(t) + common, err := gitCommonDir(repo) + if err != nil { + t.Fatal(err) + } + cacheRoot := filepath.Join(common, "boatstack") + if err := os.RemoveAll(cacheRoot); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), cacheRoot); err != nil { + t.Fatal(err) + } + if err := HydrateWorktree(repo); err == nil || !strings.Contains(err.Error(), "symlinked path") { + t.Fatalf("expected shared runtime-directory symlink rejection, got %v", err) + } +} + +func TestSharedRuntimePathsKeepVersionsAndSourcesSeparate(t *testing.T) { + repo := planningRepo(t) + current, _, err := sharedRuntimePaths(repo, "v0.6.0", "current-source") + if err != nil { + t.Fatal(err) + } + olderVersion, _, err := sharedRuntimePaths(repo, "v0.5.0", "older-source") + if err != nil { + t.Fatal(err) + } + if current == olderVersion { + t.Fatal("versioned worktrees must select separate shared runtimes") + } + if !strings.Contains(current, filepath.Join("v0.6.0", "current-source", platformKey())) { + t.Fatalf("current runtime path lacks provenance: %s", current) + } + if !strings.Contains(olderVersion, filepath.Join("v0.5.0", "older-source", platformKey())) { + t.Fatalf("older runtime path lacks provenance: %s", olderVersion) + } +} diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index e8802a5..ee2f9e4 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -90,7 +90,7 @@ subject to acceptance criteria pass approval is current ``` -That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **8172 estimated tokens**, while host adapters point to one operation at a time. +That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **8311 estimated tokens**, while host adapters point to one operation at a time. ## Control appears at transitions @@ -140,6 +140,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`9bfb96de291e2552d307fa3c1b23e67771bcb86d`](https://github.com/operatorstack/intelligence-flow/tree/9bfb96de291e2552d307fa3c1b23e67771bcb86d/examples/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`09388a6c92ece15283a8d0dc2b7edef3d2cc3aba`](https://github.com/operatorstack/intelligence-flow/tree/09388a6c92ece15283a8d0dc2b7edef3d2cc3aba/examples/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/generated-files.md b/docs/generated-files.md index cce61cc..3070162 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -16,7 +16,7 @@ Boatstack creates installation state once and feature evidence repeatedly. Keepi | `.cursor/`, `.agents/`, and `.claude/` Boatstack adapters | Portable host commands and skills | Commit | | `.github/PULL_REQUEST_TEMPLATE/boatstack.md` | Fallback PR structure | Commit | | `.cursor/hooks.json`, `.claude/settings.json`, `.codex/hooks.json` | Boatstack fragments merged with existing host settings | Review and commit | -| `.product-loop/bin/` | Verified machine-local helper | Never commit; it is ignored | +| `.product-loop/bin/` | Verified worktree-local helper and install lock | Never commit; it is ignored and hydrates automatically | The installer prints the exact staging command and runs `doctor`. Put this state in `chore/install-boatstack`, review it once, and merge it before feature work. @@ -45,9 +45,11 @@ When Boatstack improves a branch that did not use the full workflow, it stores t The preview's frontmatter is publication metadata; the remaining Markdown is the exact GitHub body. The preview is excluded from its own product-diff fingerprint, but any other diff or evidence change makes it stale. -## Fresh clones and updates +## Worktrees, fresh clones, and updates -Committed adapters survive a clone; the ignored helper does not. Rerun the installer to restore it. +One verified runtime is cached under the clone's Git common directory and keyed by Boatstack version, source commit, operating system, and architecture. Linked worktrees share that cache. Their first guarded command atomically restores the ignored local helper and install lock, then evaluates the original command. Hydration uses no network and produces no tracked diff. + +Independent clones do not share a Git common directory. Committed adapters survive a clone, but the ignored helper and repository-family cache do not; run the installer once in the new clone. For an update, run `/boatstack-update` from a clean, current default branch. Boatstack creates `chore/update-boatstack-v`, verifies the tagged release and checksum, preserves integrations, and shows the exact generated diff before asking for `open update PR`. Release-check state in `.product-loop/bin/update-state.json` and the platform helper remain ignored; the adapters, generated lock, hook fragments, and merged host settings belong in the update PR. diff --git a/docs/getting-started.md b/docs/getting-started.md index adaa3ba..3a5ff5c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -5,7 +5,7 @@ **For:** a product builder or engineer using Cursor, Codex, or Claude Code. **Outcome:** install Boatstack in one infrastructure PR, then take one ordinary request through approval, build, evidence, review, and PR preparation. -Boatstack is repository-local. Install it once and commit the shared workflow before starting product work. +Boatstack is repository-local. Install it once per Git clone and commit the shared workflow before starting product work. Linked Git worktrees reuse the clone's verified runtime automatically. ## 1. Install it separately @@ -33,6 +33,12 @@ Choose `core` unless you already want gstack, GitHub Spec Kit, or both. Confirm Review and commit the paths printed by the installer. Merge this infrastructure PR before creating a feature branch. Later feature PRs then contain the product change and its evidence rather than one-time setup noise. +### Git worktrees + +The installer keeps a versioned, verified runtime under Git's common directory. A linked worktree still starts without the ignored `.product-loop/bin/` directory, but its first guarded Cursor, Codex, or Claude call restores that local runtime automatically before evaluating the original command. This performs no download and changes no tracked files. + +Different Boatstack versions use separate cached runtimes, so an older worktree is not silently run with a newer helper. A separate clone has a different Git common directory and still needs one installer run. + ## 2. Start with the idea Create a feature branch from the base containing Boatstack. Enter your host's Plan mode and describe the outcome in normal product language: diff --git a/docs/public-claims.json b/docs/public-claims.json index f5d4737..80ba968 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "9bfb96de291e2552d307fa3c1b23e67771bcb86d", + "source_commit": "09388a6c92ece15283a8d0dc2b7edef3d2cc3aba", "statuses": ["verified", "observed", "still_being_evaluated"], "claims": [ { @@ -12,7 +12,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:9bfb96de291e2552d307fa3c1b23e67771bcb86d" + "last_verified_version": "source:09388a6c92ece15283a8d0dc2b7edef3d2cc3aba" }, { "id": "validation-provenance", @@ -23,7 +23,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:9bfb96de291e2552d307fa3c1b23e67771bcb86d" + "last_verified_version": "source:09388a6c92ece15283a8d0dc2b7edef3d2cc3aba" }, { "id": "irreversible-operations", @@ -35,7 +35,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:9bfb96de291e2552d307fa3c1b23e67771bcb86d" + "last_verified_version": "source:09388a6c92ece15283a8d0dc2b7edef3d2cc3aba" }, { "id": "reviewer-ready-pr", @@ -46,7 +46,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:9bfb96de291e2552d307fa3c1b23e67771bcb86d" + "last_verified_version": "source:09388a6c92ece15283a8d0dc2b7edef3d2cc3aba" }, { "id": "model-neutral-contract", @@ -57,7 +57,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:9bfb96de291e2552d307fa3c1b23e67771bcb86d" + "last_verified_version": "source:09388a6c92ece15283a8d0dc2b7edef3d2cc3aba" }, { "id": "cross-model-failures", @@ -68,7 +68,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:9bfb96de291e2552d307fa3c1b23e67771bcb86d" + "last_verified_version": "source:09388a6c92ece15283a8d0dc2b7edef3d2cc3aba" }, { "id": "lower-cost-outcomes", @@ -79,7 +79,18 @@ "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:9bfb96de291e2552d307fa3c1b23e67771bcb86d" + "last_verified_version": "source:09388a6c92ece15283a8d0dc2b7edef3d2cc3aba" + }, + { + "id": "git-worktree-activation", + "public_claim": "Boatstack installs one verified runtime per Git clone and automatically restores ignored local runtime state for linked worktrees before evaluating their first guarded command.", + "status": "verified", + "originating_observation": "A linked Claude Code worktree inherited committed fail-closed hooks but not the ignored helper, causing every shell call—including installation—to be denied.", + "safeguard": "Versioned Git-common-directory runtime cache, generated provenance selection, atomic local hydration, and fail-closed mismatch handling.", + "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:09388a6c92ece15283a8d0dc2b7edef3d2cc3aba" }, { "id": "visible-updates", @@ -90,7 +101,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:9bfb96de291e2552d307fa3c1b23e67771bcb86d" + "last_verified_version": "source:09388a6c92ece15283a8d0dc2b7edef3d2cc3aba" } ] } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8435919..c82dda4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -19,7 +19,9 @@ If a safe diagnostic was denied, keep the denial output and report the smallest ## The safety helper or hook is missing -The hook fails closed. Rerun the official installer, run `doctor`, reload the coding host, and confirm the repository is trusted and hooks are enabled. Keep least-privilege external credentials; hooks are defense in depth, not a complete sandbox. +The hook fails closed. In a linked worktree, the first guarded call should restore the ignored local helper from the verified repository-family cache. If Boatstack reports that the shared runtime is missing, run the official installer once from any checkout belonging to that Git clone, run `doctor`, and reload the coding host. Do not copy an executable without its verified runtime manifest. + +If the worktree expects a different Boatstack version or source commit, update or rebase its committed Boatstack infrastructure. Boatstack will not run a newer cached helper against an older worktree contract. ## Cursor cannot find a slash command @@ -62,7 +64,7 @@ Boatstack detects common package-manager tests, `scripts/check.sh`, Go, Rust, Ma ## A fresh clone has no helper -This is expected: `.product-loop/bin/` is machine-local and ignored. Rerun the installer from the repository root. A matching version and configuration should restore the helper without changing committed adapters. +This is expected: the repository-family cache lives inside that clone's Git common directory and `.product-loop/bin/` is ignored. Run the installer once from the repository root. Future linked worktrees of that clone hydrate automatically without another download. ## `/boatstack-update` is postponed diff --git a/docs/why-these-steps.md b/docs/why-these-steps.md index d8d96bf..1d29ee4 100644 --- a/docs/why-these-steps.md +++ b/docs/why-these-steps.md @@ -75,6 +75,16 @@ The paired product evaluation will use the same feature, lower-cost model, budge **Status:** release notification and update preparation behavior are verified in automated tests. This is not a claim that updates install themselves or may be merged without review. +## Git worktree activation + +**What happened.** A Claude Code worktree contained the committed fail-closed hook but not `.product-loop/bin/`, which Git intentionally ignores. Every shell call was denied because the helper was absent, including the installer command that could have repaired it. + +**What Boatstack does.** One verified, versioned runtime is stored under the clone's Git common directory. On the first guarded call in a linked worktree, that runtime checks the worktree's generated provenance, atomically restores its ignored local helper and lock, and then evaluates the original event. It performs no download and does not share trust across unrelated clones. + +**How we check it.** Real linked-worktree tests cover safe first use, destructive first use, paths with spaces, concurrent activation, version and source drift, checksum tampering, symlinks, malformed events, and clean Git state after hydration. + +**Status:** bootstrap deadlock observed; repository-family activation behavior verified in automated tests. Fresh independent clones still require one verified installer run. + ## What the experiments do and do not support The current research covers thousands of locally available benchmark result records, preregistered comparisons, product-repository studies, and targeted trajectory inspection. It supports the mechanisms that Boatstack is designed to address. It does **not** yet support a claim that Boatstack improves feature success, cost, or delivery speed. diff --git a/examples/diagram-json/plan.lock.json b/examples/diagram-json/plan.lock.json index eb7b24b..26a8953 100644 --- a/examples/diagram-json/plan.lock.json +++ b/examples/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "examples/diagram-json/plan.md", "plan_sha256": "3ad35cc3cbe48306e7ee401bd9e9047d25e46c8a6fe9679aa1b3f5e96ceea292", "schema_version": 1, - "source_commit": "9bfb96de291e2552d307fa3c1b23e67771bcb86d", + "source_commit": "09388a6c92ece15283a8d0dc2b7edef3d2cc3aba", "source_plan_path": "examples/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "examples/diagram-json/spec.md",