diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8604436..c914f11 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/df798dfd69a002bb8b9970216adf4f8afbe2b6ca/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/76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75/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 d702a3c..a25d091 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 67135, - "estimated_tokens": 16784, + "characters": 73472, + "estimated_tokens": 18368, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "0404effd19cc9816e5aa60b4924c1cae5be7ebeb0470f3315d0e2388bc44699e", + "CONTRIBUTING.md": "0a0005c09ebc1aa11103648517f7a14cdc4a19f4de07ec8fb121c1597477ff9d", "README.md": "125b47671a68556df382f19756fb61fa18925606cbbaf54d6bc9df8872b36870", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -40,7 +40,7 @@ "boatstack/capture_test.go": "63fa1177738081f1e862364d7a4257f5e259f8e9c36276ba1775b8085b277105", "boatstack/changelog.go": "6b06be7cd9738de29ba6e87aa2569f3b027a2e618b04524f5abd7abaa17945bf", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/main.go": "3ffcaa8907113fa581c3c6500196b9c898427a07821f10fc4bee09393ad3eb6c", + "boatstack/cmd/boatstack-helper/main.go": "e5eea92ddc73622566c4b66b3d99c63b998268617f7b3fa77be953f75a971a75", "boatstack/cmd/boatstack-helper/main_test.go": "ff73003b6a5157202fa09ddf1129fb13c3d79702b2e05a8721ce5a11bf5ab779", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", @@ -54,9 +54,11 @@ "boatstack/export_test.go": "67eb890728d20630925d6e4e90d2a97ec025195ba5c54994c1b098ab72721dca", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", - "boatstack/hooks.go": "b88cedcd045e5217fedfac625ced2e4f691adb42e62155fcbc392a8f6d88366e", - "boatstack/hooks_test.go": "c5786bc6463cf6932a6612b26cbe65d035008ecbca5c0063bea253d857c2f622", - "boatstack/init.go": "b9b73449e889359d8a4e354e3df18ef4db20e1325b960976f543003563913769", + "boatstack/hooks.go": "0639eff2ec5ce50dcbe77ace0f7c25de1e9a68784a6ed70d6acc9984d049ef1a", + "boatstack/hooks_hydrate_test.go": "8b6317cbaa1f46e71505534672fa21ef9281e0920e9ecda18d2e4b47d5a8799e", + "boatstack/hooks_test.go": "fb75e3aabf2204871b3e6d16de98d26fb33b0ec19e41aae761cf1f34397c31f4", + "boatstack/hydrate_runtime_test.go": "dbd5eae2ba85701e4af0430ba3a0d70ea98e028b66992bd4fc05f3f582398627", + "boatstack/init.go": "1b2721ad64dcfba3e954b97bb46218238873f46ec29fc4dac327aa99980f9cf6", "boatstack/init_test.go": "5fdf687205e7a5984a98a87336b7127e4ae9b651d57e21ec2dc8ca7e653ee602", "boatstack/init_transaction.go": "112456c4e1c4db54c4137bcf4f7a9a9e63399a6f5971e9b3dc952d0c4b2aa4b6", "boatstack/installation_repair.go": "6574f7133a9644843c9260b9b9daede641a14438f7357bae42fb8ec188890446", @@ -80,26 +82,31 @@ "boatstack/planning_test.go": "c105a9c78c342be06614bf54d0bc1b661b0f7af64d63b79e43bd1fcc2769edd5", "boatstack/pr.go": "981a59288a0a90534f51a2378656b78bfdc4899810bce5fdeefaa6cde63eeffe", "boatstack/pr_test.go": "7f82954d94c1ceae848a581dda25e58af92251d78a5a94ed2d672bedf5a0349e", + "boatstack/provenance.go": "d44dcd5421306269326f1202ba1d52df8c252490550270ef9d022e8ec2b65210", "boatstack/provision.go": "4882d49681f99b11ba9d182ca13772131b7f9a11a6c2b560800654ca14f5111e", "boatstack/provision_test.go": "214e9edb991a66d5bbb696a7c1b63876d2f799f2cab4e3f40785f4e8f1eac57b", "boatstack/publication_ignored_repro_test.go": "b6f3aeb8ba22949ff9af7ac5afe8fb828385d9708d5d5893ef41f33a3de873e1", "boatstack/recovery.go": "6939747f3725a6dd2de933d0248571d7f21a9ee017568306fe11f08fdbba413e", "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", + "boatstack/reexec.go": "fed55416479d7bd3e0c3637057ffe8eb58a032f93fc358f76df906ab7acc677b", + "boatstack/reexec_unix.go": "ff86157a9aa20c82a56fcd859b70669b7eacf4e0a9f61a4546ef33808437939e", + "boatstack/reexec_windows.go": "f5335c8c28cb4e89048b058b1c4d12f78644f99acb4f6167ff60e622dfb9e742", "boatstack/references/artifacts.md": "5fa888ac519085d65cee1d04df5902761651bcf2d7af81711fa0f8ecd1fc0f59", "boatstack/references/config-schema.md": "0170b90f1d0a592f58e255ffeff642fa037676042443f74a0f1b6e39be5dbbb8", - "boatstack/references/failure-moves.md": "999dfc67e9d51eb180f9f14736825ce44fc022ab710ffea0f56cfdf044b71cbc", + "boatstack/references/failure-moves.md": "e1cdba05cb49817d8246eee45ab4a1ba4d691cd0ef3bd6325ee1d795ffa00b0e", "boatstack/references/host-hook-contracts.md": "d68ae1556e7b1e29e9ac7cb4db767809d510aabf0be52e60e44665ea7abb980e", "boatstack/references/irreversible-operation-boundary.md": "e0076f0fea3bf729b2e9bdf353eaeaaf7cdafabfaf26b8d9b27287e5414c2441", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "df04b136351d85e2e77be8d28895c8012f65d8992d22f9d0db8011b8f90f2511", + "boatstack/references/workflow.md": "e14a324f91b8c6012956168ff838cc6f64ae4cde61666d086174a2a4525dc683", "boatstack/release.go": "82dcb4ca59e8c79a68d5333d650f90e64abd448d04e0c6f504fdf07f42b5ed76", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/repair_state_test.go": "f3779ac47c3db3927175a545728d3b2e020dbc85f41394d8235753b52afc3739", "boatstack/run.go": "74967ad5b3ed3847baffec1231aae69a81a70f9cce3a9412fa05bcfdc4eca6d1", "boatstack/run_test.go": "5b291510fa90cefdc26eb89e18a3443385456a6ebc73408325ac1945b7c084d6", "boatstack/runtime.go": "d1e95895002ea2b27199b6e05b33c4c6e20f63455a44f63ca3cfeedecfc23420", - "boatstack/runtime_cache.go": "60c4eb0c7dde91d40d6ef3f05adc1a1282d17ff1ca12470d0a008454f7ca7489", + "boatstack/runtime_cache.go": "e40c8c43f410d781a7a4e7ebf68a1caa597ea9005190fd6cea02884f863e091e", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", + "boatstack/runtime_provenance_test.go": "1d52f1e6b0691cf4667729cc9b9f3c55c128f0aa3321f3a2843a9aa6fd0e73dc", "boatstack/safety.go": "9e1dd0a40524304b6eb3e0ad9f24ebe12bd1a72794b77bd74b4f79e81992765c", "boatstack/safety_test.go": "01f28bc3bfcb6bdd47b307e309e36bbc1921b6426ad0b777d81fe4131200c37e", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", @@ -124,24 +131,24 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "df054f49d532c8b1b7d94184810d1b3b5bf18cdc30eb985b4b6d0639162e341a", - "docs/evidence-engineered-coding.md": "55174d0bbfbe32a232cead4063e55ffe1a086e1143e2d449b72c54d628b653c7", + "docs/evidence-engineered-coding.md": "473afd9bd7f52f901d3046844279694d5259bfeaf708d5cf9eb859a1448f98e1", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "f314270c5ed1a55bbef5f3ddbcb5596693dbee9374e5f0d3df8838cefbd68052", - "docs/public-claims.json": "106fd639fd10e63bd13194c5668414eb6099c0bde3ce657aeb57462c56f85809", + "docs/public-claims.json": "e2758cfc1f3a089212a5fcdbb75db503c84833c7ea509f4c50a2a50c88939335", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", "docs/troubleshooting.md": "321d79a990de3eac0931efbf5446a3fbdb5f8fce06319bf2adf1e7aabf763519", "docs/validation-and-evidence.md": "e7d91ad49c6adb44784ebe7d94feceb6abd445857f9a0716f0758bf6b55296c5", "docs/why-these-steps.md": "cbe0d769db11ef15bb1dff888009378d6783776ad020e6f5139847a1dd62fa09", - "install.ps1": "6f5857ec0feb502683c5781b9bfbbe31ed39556cd13384ddc66da622a8423cb7", - "install.sh": "c76a3ac6c7a45e6eb8e0178e1c3458f72c4899426c6a801d058ee38938e4b477", + "install.ps1": "f48d0f26a26e806b780d10fa916c261ff9f84ab39758cf9e229f647836845e86", + "install.sh": "2575f82568b76b14e72fb88de4e8af677da1e77b9cf1a085b4ddfbbd766e67f9", "labs/diagram-json/README.md": "f56a120877c8a3b10daa49c6d951481c02e98b8b8bb3f28672e9e092d97a37bb", "labs/diagram-json/approval.md": "ec9f353dc2a923c8df2c7fe6e90f5b054bed1129a6a21e86351596ef2a5d4215", "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": "2da633c72d7c84eca9b715261565954f437763cb6902165e4e1aca7633af297a", + "labs/diagram-json/plan.lock.json": "3fbb0fbd8cead40c6d48e57ed88552a2ce2ef82b85c5983a4acf1d04f577947e", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -214,7 +221,9 @@ "release-notes/2026-07-23-shipped-feature-candidate-resolution.md": "bd8ee8e7f3f216b356b121a83ef10cb0c8131a90b9ab803edebf23b882d9cf89", "release-notes/2026-07-23-sync-title-contract.md": "2869d6d084ea60402e57ffe985d0fc4cd83ef9bb09958cc53e349157d3383202", "release-notes/2026-07-23-visual-evidence-external-host.md": "09edbe5e6e1bfc866cf5ee744a5001d678f7a67f0f330cf43bd5157eedf04276", + "release-notes/2026-07-24-auto-hydrate-missing-runtime.md": "b376f75f7c9132f30b362392e0b31c05715edcef58c14dfa20e1fc0a17836b41", "release-notes/2026-07-24-ignored-deliveries-publication-authority.md": "a25f8469316276101490c79a57c1a236c18072dfbb23682bb1778871d247067d", + "release-notes/2026-07-24-provenance-verified-binary-install.md": "b64f3a8dbd750afb28bf6964489eb0f5f8519efa65fcee871db64b9e60c2fdb2", "release-notes/2026-07-24-publication-nonblocking-control.md": "2b9d8ea817896783273a843ec183fbf00bb2f7ac420b4ce3409aeda7f59b7fb5", "release-notes/2026-07-24-repair-state-recovery.md": "daaa12deb51a5f647178d6164ea5b4bcd29bf5482b77d90002429f69e0da5dd0", "release-notes/2026-07-24-transactional-mutation-boundary.md": "38819a4811edbc99a9d8a77983aedbd0589bdbbf849da4991d3e21a1b319a65c" @@ -222,7 +231,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "df798dfd69a002bb8b9970216adf4f8afbe2b6ca", + "commit": "76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75", "path": "labs/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 1f77bb0..98ade9c 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -48,7 +48,7 @@ func failSafetyHook(err error) int { func initCommand(arguments []string) int { flags := flag.NewFlagSet("init", flag.ContinueOnError) repo := flags.String("repo", ".", "repository to initialize") - binary := flags.String("binary", "", "verified helper binary to install project-locally") + binary := flags.String("binary", "", "helper binary to install project-locally; must self-report this process's version (use the target binary's own update for a different version)") integrations := flags.String("integrations", "", "core, gstack, spec-kit, or both") yes := flags.Bool("yes", false, "accept the generated-file preview; optional integrations still default to core") if err := flags.Parse(arguments); err != nil { @@ -64,7 +64,7 @@ func initCommand(arguments []string) int { func updateCommand(arguments []string) int { flags := flag.NewFlagSet("update", flag.ContinueOnError) repo := flags.String("repo", ".", "repository to update") - binary := flags.String("binary", "", "verified replacement helper binary") + binary := flags.String("binary", "", "helper binary to install; its own self-reported version is installed (cross-version updates re-exec it)") yes := flags.Bool("yes", false, "accept the generated-file preview") repair := flags.Bool("repair", false, "repair only fingerprinted Boatstack-owned control state") allowDowngrade := flags.Bool("allow-downgrade", false, "permit an explicitly repaired downgrade") @@ -885,6 +885,24 @@ func bootstrapSafetyHookCommand(arguments []string) int { return 0 } +// hydrateRuntimeCommand populates the version-keyed shared runtime slot (and +// this worktree's ignored bin/) from the RUNNING binary, without switching +// branches or touching any committed generated file. The safety guard invokes +// it — via the verified installer's hydrate mode — to self-heal a clone whose +// slot is empty after a version bump or a fresh checkout, so a teammate never +// sees a hard "shared runtime is missing" deny. +func hydrateRuntimeCommand(arguments []string) int { + flags := flag.NewFlagSet("hydrate-runtime", flag.ContinueOnError) + repo := flags.String("repo", ".", "worktree whose shared runtime slot should be populated") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if err := boatstack.RunHydrateRuntime(*repo); err != nil { + return fail(err) + } + 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") @@ -1229,6 +1247,8 @@ func run() int { return safetyHookCommand(os.Args[2:]) case "bootstrap-safety-hook": return bootstrapSafetyHookCommand(os.Args[2:]) + case "hydrate-runtime": + return hydrateRuntimeCommand(os.Args[2:]) case "check-safety": return checkSafetyCommand(os.Args[2:]) case "workspace-cut": diff --git a/boatstack/hooks.go b/boatstack/hooks.go index 29cd956..4c6981a 100644 --- a/boatstack/hooks.go +++ b/boatstack/hooks.go @@ -105,6 +105,24 @@ func DiagnoseHook(repoPath, hostName string) (HookDiagnostic, error) { return HookDiagnostic{Host: host, ContractStatus: "PASS", LiveEventObserved: false}, nil } +// runtimeHydrateCommandBash / runtimeHydrateCommandPowerShell are the single +// source of truth for the pinned, verified installer invocation that populates +// an absent shared-runtime slot. They mirror installationRepairRetryCommand but +// target the branch-free `hydrate` mode, so they are safe to run from any +// checkout on any branch without rewriting committed generated files. Each is +// used both as its guard's default auto-hydrate command and, embedded in the +// fail-closed deny message, as a human copy-paste self-heal. They are keyed by +// target shell (not the generating host's GOOS) because both guard scripts are +// generated on every platform. The bash form must contain no single quote: the +// guard wraps it in a single-quoted default. +func runtimeHydrateCommandBash(version string) string { + return `BOATSTACK_MODE=hydrate BOATSTACK_VERSION=` + version + ` BOATSTACK_REPO="$PWD" /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/` + version + `/install.sh)"` +} + +func runtimeHydrateCommandPowerShell(version string) string { + return `$env:BOATSTACK_MODE="hydrate"; $env:BOATSTACK_VERSION="` + version + `"; $env:BOATSTACK_REPO=(Get-Location).Path; irm https://raw.githubusercontent.com/operatorstack/boatstack/` + version + `/install.ps1 | iex` +} + func guardShellScript() []byte { return []byte(fmt.Sprintf(`#!/usr/bin/env bash # Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json. @@ -137,8 +155,45 @@ 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" +# Auto-hydrate a missing shared-runtime slot. A teammate who pulls a version +# bump or clones fresh inherits the committed pointers (this guard's baked +# version path) but an empty, gitignored slot, so without this the very next +# tool call would hard-deny before any Go runs. On an absent slot we run the +# tag-pinned, checksum-verifying installer in branch-free hydrate mode, serialize +# clone-wide with an atomic mkdir lock, and bound the attempt. This is purely +# additive: the existing missing/symlink/checksum gates below stay authoritative +# and fail-closed, so a disabled, timed-out, or failed hydration simply denies. +if [[ ! -x "$HELPER" && "${BOATSTACK_AUTO_HYDRATE:-1}" != "0" ]]; then + mkdir -p "$COMMON/boatstack" 2>/dev/null || true + HYDRATE_LOCK="$COMMON/boatstack/hydrate-%s.lock" + if mkdir "$HYDRATE_LOCK" 2>/dev/null; then + ( + cd "$ROOT" || exit 0 + export BOATSTACK_MODE=hydrate + export BOATSTACK_VERSION="%s" + export BOATSTACK_REPO="$ROOT" + HYDRATE_COMMAND="${BOATSTACK_HYDRATE_COMMAND:-}" + if [[ -z "$HYDRATE_COMMAND" ]]; then + HYDRATE_COMMAND='%s' + fi + if command -v timeout >/dev/null 2>&1; then + timeout 8 /bin/bash -c "$HYDRATE_COMMAND" + else + /bin/bash -c "$HYDRATE_COMMAND" + fi + ) >&2 || true + rmdir "$HYDRATE_LOCK" 2>/dev/null || true + else + # A peer is hydrating the shared slot; wait briefly for it to appear. + for _ in $(seq 1 8); do + [[ -x "$HELPER" ]] && break + sleep 1 + done + fi +fi if [[ ! -x "$HELPER" ]]; then - echo "Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone." >&2 + echo "Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone:" >&2 + echo " %s" >&2 exit 2 fi if [[ -L "$HELPER" || ! -f "$MANIFEST" || -L "$MANIFEST" ]]; then @@ -160,7 +215,7 @@ if [[ -z "$EXPECTED" || "$ACTUAL" != "$EXPECTED" ]]; then fi exec "$HELPER" bootstrap-safety-hook --host "$HOST" --repo "$ROOT" -`, Version, SourceCommit, Version, SourceCommit)) +`, Version, SourceCommit, Version, SourceCommit, Version, Version, runtimeHydrateCommandBash(Version), runtimeHydrateCommandBash(Version))) } func guardPowerShellScript() []byte { @@ -188,8 +243,42 @@ $arch = switch ($architecture) { } $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" +# Auto-hydrate a missing shared-runtime slot (see the bash guard for rationale): +# a teammate who pulls a version bump or clones fresh inherits the committed +# pointers but an empty, gitignored slot. On an absent slot we run the tag-pinned, +# checksum-verifying installer in branch-free hydrate mode, serialized clone-wide +# with an atomic directory lock. Purely additive: the gates below stay +# authoritative and fail-closed if hydration is disabled, fails, or is skipped. +if ((-not (Test-Path -LiteralPath $helper -PathType Leaf)) -and $env:BOATSTACK_AUTO_HYDRATE -ne "0") { + $bsCommon = Join-Path $common "boatstack" + New-Item -ItemType Directory -Path $bsCommon -Force -ErrorAction SilentlyContinue | Out-Null + $hydrateLock = Join-Path $bsCommon "hydrate-%s.lock" + $acquired = $false + try { New-Item -ItemType Directory -Path $hydrateLock -ErrorAction Stop | Out-Null; $acquired = $true } catch { $acquired = $false } + if ($acquired) { + try { + $env:BOATSTACK_MODE = "hydrate" + $env:BOATSTACK_VERSION = "%s" + $env:BOATSTACK_REPO = $root + $hydrateCommand = $env:BOATSTACK_HYDRATE_COMMAND + if (-not $hydrateCommand) { + $hydrateCommand = 'irm https://raw.githubusercontent.com/operatorstack/boatstack/%s/install.ps1 | iex' + } + & powershell -NoProfile -Command $hydrateCommand 2>&1 | ForEach-Object { [Console]::Error.WriteLine($_) } + } catch { + } finally { + Remove-Item -LiteralPath $hydrateLock -Recurse -Force -ErrorAction SilentlyContinue + } + } else { + for ($i = 0; $i -lt 8; $i++) { + if (Test-Path -LiteralPath $helper -PathType Leaf) { break } + Start-Sleep -Seconds 1 + } + } +} if (-not (Test-Path -LiteralPath $helper -PathType Leaf)) { - [Console]::Error.WriteLine("Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone.") + [Console]::Error.WriteLine("Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone:") + [Console]::Error.WriteLine(" %s") exit 2 } $helperInfo = Get-Item -LiteralPath $helper @@ -215,7 +304,7 @@ if (-not $manifest.binary_sha256 -or $actual -ne $manifest.binary_sha256.ToLower } & $helper bootstrap-safety-hook --host $HostName --repo $root exit $LASTEXITCODE -`, Version, SourceCommit, Version, SourceCommit)) +`, Version, SourceCommit, Version, SourceCommit, Version, Version, Version, runtimeHydrateCommandPowerShell(Version))) } func hookCommand(host string) string { diff --git a/boatstack/hooks_hydrate_test.go b/boatstack/hooks_hydrate_test.go new file mode 100644 index 0000000..2433e2a --- /dev/null +++ b/boatstack/hooks_hydrate_test.go @@ -0,0 +1,209 @@ +package boatstack + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" +) + +func requireBash(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash unavailable") + } +} + +// runGuard executes the installed bash guard for a host with a canonical, +// read-only event, returning its combined output and exit error. +func runGuard(t *testing.T, repo, host string, env ...string) (string, error) { + t.Helper() + guard := filepath.Join(repo, ".product-loop", "hooks", "guard.sh") + cmd := exec.Command("bash", guard, host) + cmd.Dir = repo + cmd.Stdin = strings.NewReader(`{"tool_name":"Bash","tool_input":{"command":"git status --short"}}`) + cmd.Env = append(os.Environ(), env...) + output, err := cmd.CombinedOutput() + return string(output), err +} + +// emptySharedSlot deletes the version-keyed shared runtime, modeling a teammate +// who pulled a version bump (or cloned fresh) and holds the committed pointers +// but no runtime bytes. +func emptySharedSlot(t *testing.T, repo string) (binaryPath, manifestPath string) { + t.Helper() + var err error + binaryPath, manifestPath, err = sharedRuntimePaths(repo, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Dir(binaryPath)); err != nil { + t.Fatal(err) + } + return binaryPath, manifestPath +} + +// stageVerifiedHelper empties the slot and stages a tiny, checksum-consistent +// helper + manifest in a backup directory, returning a shell command that +// restores them into the slot — exactly what the real installer produces after +// download and checksum verification. The staged helper answers the guard's +// bootstrap-safety-hook exec by emitting a sentinel and allowing. +func stageVerifiedHelper(t *testing.T, repo string) (binaryPath, manifestPath, restoreCommand string) { + t.Helper() + binaryPath, manifestPath = emptySharedSlot(t, repo) + backupDir := t.TempDir() + fakeHelper := []byte("#!/usr/bin/env bash\necho boatstack-guard-hydration-sentinel >&2\nexit 0\n") + backupHelper := filepath.Join(backupDir, filepath.Base(binaryPath)) + backupManifest := filepath.Join(backupDir, filepath.Base(manifestPath)) + if err := os.WriteFile(backupHelper, fakeHelper, 0o755); err != nil { + t.Fatal(err) + } + manifest := []byte(fmt.Sprintf(`{"binary_sha256":"%s"}`, SHA256Bytes(fakeHelper))) + if err := os.WriteFile(backupManifest, manifest, 0o644); err != nil { + t.Fatal(err) + } + restoreCommand = fmt.Sprintf("mkdir -p %q && cp -p %q %q && cp -p %q %q", + filepath.Dir(binaryPath), + backupHelper, binaryPath, + backupManifest, manifestPath) + return binaryPath, manifestPath, restoreCommand +} + +// TestGuardAutoHydratesMissingSharedRuntimeThenProceeds is the headline +// behavior: an absent slot self-heals through the verified hydrator and the +// guard clears every gate and reaches exec — a teammate never sees a deny. +func TestGuardAutoHydratesMissingSharedRuntimeThenProceeds(t *testing.T) { + requireBash(t) + repo := runtimeTestRepo(t) + binaryPath, _, restore := stageVerifiedHelper(t, repo) + + output, err := runGuard(t, repo, "claude", "BOATSTACK_HYDRATE_COMMAND="+restore) + if err != nil { + t.Fatalf("guard did not proceed after auto-hydration: err=%v output=%s", err, output) + } + if strings.Contains(output, "shared runtime is missing") { + t.Fatalf("guard reported a missing runtime despite successful hydration: %s", output) + } + if !strings.Contains(output, "boatstack-guard-hydration-sentinel") { + t.Fatalf("guard did not exec the hydrated helper: %s", output) + } + if _, statErr := os.Stat(binaryPath); statErr != nil { + t.Fatalf("shared slot was not populated: %v", statErr) + } +} + +// TestGuardFailsClosedWhenAutoHydrationFails proves hydration is purely +// additive: a failed installer never falls open; the existing missing-slot deny +// remains authoritative and now carries the exact self-heal command. +func TestGuardFailsClosedWhenAutoHydrationFails(t *testing.T) { + requireBash(t) + repo := runtimeTestRepo(t) + emptySharedSlot(t, repo) + + output, err := runGuard(t, repo, "claude", "BOATSTACK_HYDRATE_COMMAND=exit 1") + if err == nil { + t.Fatalf("guard did not fail closed after a failed hydration: %s", output) + } + if !strings.Contains(output, "shared runtime is missing") { + t.Fatalf("guard did not emit the fail-closed deny: %s", output) + } + if !strings.Contains(output, "BOATSTACK_MODE=hydrate BOATSTACK_VERSION="+Version) { + t.Fatalf("deny did not embed the pinned installer command: %s", output) + } +} + +// TestGuardSkipsAutoHydrationWhenDisabled proves the kill switch: with +// BOATSTACK_AUTO_HYDRATE=0 the guard denies immediately and never runs the +// hydrator. +func TestGuardSkipsAutoHydrationWhenDisabled(t *testing.T) { + requireBash(t) + repo := runtimeTestRepo(t) + emptySharedSlot(t, repo) + sentinel := filepath.Join(t.TempDir(), "invoked") + stub := fmt.Sprintf("touch %q", sentinel) + + output, err := runGuard(t, repo, "claude", "BOATSTACK_AUTO_HYDRATE=0", "BOATSTACK_HYDRATE_COMMAND="+stub) + if err == nil { + t.Fatalf("disabled guard did not deny: %s", output) + } + if !strings.Contains(output, "shared runtime is missing") { + t.Fatalf("disabled guard did not emit the deny: %s", output) + } + if _, statErr := os.Stat(sentinel); statErr == nil { + t.Fatal("auto-hydration ran despite BOATSTACK_AUTO_HYDRATE=0") + } +} + +// TestGuardAutoHydrationInvokesPinnedHydrator proves the hydrator is always +// invoked with the worktree's pinned provenance — never a floating version. +func TestGuardAutoHydrationInvokesPinnedHydrator(t *testing.T) { + requireBash(t) + repo := runtimeTestRepo(t) + emptySharedSlot(t, repo) + record := filepath.Join(t.TempDir(), "env.txt") + stub := fmt.Sprintf(`printf '%%s\n' "$BOATSTACK_MODE" "$BOATSTACK_VERSION" "$BOATSTACK_REPO" > %q`, record) + + // The stub records env then leaves the slot empty, so the guard denies after; + // we only assert on how the hydrator was invoked. + runGuard(t, repo, "claude", "BOATSTACK_HYDRATE_COMMAND="+stub) + + data, err := os.ReadFile(record) + if err != nil { + t.Fatalf("hydrator was not invoked: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 3 { + t.Fatalf("hydrator recorded incomplete provenance: %q", data) + } + if lines[0] != "hydrate" || lines[1] != Version { + t.Fatalf("hydrator invoked with wrong provenance: mode=%q version=%q (want hydrate/%s)", lines[0], lines[1], Version) + } + gotRepo, _ := filepath.EvalSymlinks(lines[2]) + wantRepo, _ := filepath.EvalSymlinks(repo) + if gotRepo != wantRepo { + t.Fatalf("hydrator repo = %q, want %q", lines[2], repo) + } +} + +// TestGuardAutoHydrationSerializesConcurrentFirstUse proves the clone-wide lock: +// two guards racing an absent slot invoke the hydrator at most once, and both +// still proceed. +func TestGuardAutoHydrationSerializesConcurrentFirstUse(t *testing.T) { + requireBash(t) + repo := runtimeTestRepo(t) + binaryPath, manifestPath, restore := stageVerifiedHelper(t, repo) + counter := filepath.Join(t.TempDir(), "count") + stub := fmt.Sprintf("echo x >> %q && %s", counter, restore) + _ = manifestPath + + var wg sync.WaitGroup + outputs := make([]string, 2) + errs := make([]error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + outputs[idx], errs[idx] = runGuard(t, repo, "claude", "BOATSTACK_HYDRATE_COMMAND="+stub) + }(i) + } + wg.Wait() + + for i := range errs { + if errs[i] != nil { + t.Fatalf("guard %d did not proceed under contention: err=%v output=%s", i, errs[i], outputs[i]) + } + } + if _, statErr := os.Stat(binaryPath); statErr != nil { + t.Fatalf("shared slot was not populated under contention: %v", statErr) + } + data, err := os.ReadFile(counter) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(string(data), "x"); got != 1 { + t.Fatalf("hydrator ran %d times under contention, want exactly 1", got) + } +} diff --git a/boatstack/hooks_test.go b/boatstack/hooks_test.go index 42fce01..a8c77b6 100644 --- a/boatstack/hooks_test.go +++ b/boatstack/hooks_test.go @@ -178,10 +178,17 @@ func TestMissingHelperLauncherFailsClosed(t *testing.T) { } command := exec.Command("bash", path, "cursor") command.Dir = repo + // Disable auto-hydration so this exercises the pure missing-slot deny without + // reaching for the network; auto-hydration has its own dedicated subtests. + command.Env = append(os.Environ(), "BOATSTACK_AUTO_HYDRATE=0") output, err := command.CombinedOutput() 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) } + // The deny is a one-line self-heal: it must embed the exact pinned installer. + if !strings.Contains(string(output), "BOATSTACK_MODE=hydrate BOATSTACK_VERSION="+Version) { + t.Fatalf("missing-slot deny did not embed the pinned installer command: %s", output) + } } func TestDiagnoseHookAcceptsCanonicalEventsForEveryHost(t *testing.T) { diff --git a/boatstack/hydrate_runtime_test.go b/boatstack/hydrate_runtime_test.go new file mode 100644 index 0000000..2228d9b --- /dev/null +++ b/boatstack/hydrate_runtime_test.go @@ -0,0 +1,91 @@ +package boatstack + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestRunHydrateRuntimePopulatesSlotIdempotentlyWithoutTouchingCommittedState +// models a teammate's clone after a version bump: the committed pointers exist +// but both the shared slot and the worktree bin are empty. Hydration must +// repopulate both from the running binary, be safe to repeat, and never rewrite +// any committed generated file (the property that separates it from `update`). +func TestRunHydrateRuntimePopulatesSlotIdempotentlyWithoutTouchingCommittedState(t *testing.T) { + repo := runtimeTestRepo(t) + binaryPath, _, err := sharedRuntimePaths(repo, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Dir(binaryPath)); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Join(repo, ".product-loop", "bin")); err != nil { + t.Fatal(err) + } + + before := readGeneratedLockBytes(t, repo) + + if err := RunHydrateRuntime(repo); err != nil { + t.Fatalf("hydration failed: %v", err) + } + if _, err := os.Stat(binaryPath); err != nil { + t.Fatalf("hydration did not populate the shared slot: %v", err) + } + if err := verifyLocalRuntime(repo); err != nil { + t.Fatalf("hydration did not populate a verified worktree runtime: %v", err) + } + if err := Doctor(repo); err != nil { + t.Fatalf("hydrated repository is unhealthy: %v", err) + } + + // Idempotent: a second cold hydration from a fully populated state is a no-op. + if err := RunHydrateRuntime(repo); err != nil { + t.Fatalf("second hydration was not idempotent: %v", err) + } + + if after := readGeneratedLockBytes(t, repo); string(before) != string(after) { + t.Fatalf("hydration mutated committed generated.lock.json:\nbefore=%s\nafter=%s", before, after) + } +} + +// TestRunHydrateRuntimeRefusesRunningVersusPinMismatch pins the incident- +// prevention invariant: hydration must never populate a version-keyed slot with +// a binary whose identity disagrees with the worktree's committed pin. +func TestRunHydrateRuntimeRefusesRunningVersusPinMismatch(t *testing.T) { + repo := runtimeTestRepo(t) + if err := os.RemoveAll(filepath.Join(repo, ".product-loop", "bin")); err != nil { + t.Fatal(err) + } + 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 := RunHydrateRuntime(repo); err == nil || !strings.Contains(err.Error(), "does not match this worktree's pin") { + t.Fatalf("expected a provenance refusal, got %v", err) + } +} + +func readGeneratedLockBytes(t *testing.T, repo string) []byte { + t.Helper() + value, err := os.ReadFile(filepath.Join(repo, ".product-loop", "generated.lock.json")) + if err != nil { + t.Fatal(err) + } + return value +} diff --git a/boatstack/init.go b/boatstack/init.go index 26cb4b6..40c175b 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -515,6 +515,20 @@ func RunInit(options InitOptions) (returnErr error) { return err } } + // Write-boundary provenance guard: never stamp this process's version onto a + // foreign binary. When an explicit -binary is installed it must self-report the + // identity we are about to record in the slot path and locks; otherwise the + // runtime would be mislabeled and fail-close the shared cache. A self-install + // (no -binary) matches by construction, so the check runs only for -binary. + if options.BinaryPath != "" { + version, sourceCommit, identityErr := readBinaryIdentity(helperSource) + if identityErr != nil { + return fmt.Errorf("cannot verify the helper binary before install: %w", identityErr) + } + if version != Version || sourceCommit != SourceCommit { + return fmt.Errorf("refusing to install a version-mismatched helper: %s reports %s (%s) but this process is %s (%s); run the %s binary's own update or init", helperSource, version, sourceCommit, Version, SourceCommit, version) + } + } if options.Update && options.Repair { currentRepair, classifyErr := ClassifyInstallationRepair(repo, config.Adapters, options.AllowDowngrade) if classifyErr != nil { @@ -764,6 +778,21 @@ func RunUpdate(options InitOptions) error { if options.Output == nil { options.Output = os.Stdout } + // Cross-version provenance guard. Each helper embeds its own generated bundle + // and version constants, so a running helper cannot correctly install a + // different version in-process — stamping the running version onto foreign + // bytes is exactly the corruption that fail-closes the shared runtime. If a + // passed -binary self-reports a different identity, hand the whole update to + // that binary, which carries its own bundle and constants. + if options.BinaryPath != "" { + version, sourceCommit, identityErr := readBinaryIdentity(options.BinaryPath) + if identityErr != nil { + return fmt.Errorf("cannot verify the replacement helper before update: %w", identityErr) + } + if version != Version || sourceCommit != SourceCommit { + return reexecUpdate(options.BinaryPath, options) + } + } config, _, configErr := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) if configErr != nil { return configErr diff --git a/boatstack/provenance.go b/boatstack/provenance.go new file mode 100644 index 0000000..93063a4 --- /dev/null +++ b/boatstack/provenance.go @@ -0,0 +1,54 @@ +package boatstack + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// readBinaryIdentity returns the (version, sourceCommit) a helper binary reports +// for itself. It is a package var so conformance tests can substitute a candidate +// identity without building real multi-version binaries (mirrors recoveryGh). +var readBinaryIdentity = execBinaryIdentity + +// execBinaryIdentity executes ` version` and parses the helper's own +// self-report. The generated bundle and version constants are embedded in each +// binary, so the only authoritative source of a binary's identity is the binary +// itself — never the running process's compile-time globals. +func execBinaryIdentity(path string) (string, string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", "", err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, absolute, "version").Output() + if err != nil { + return "", "", fmt.Errorf("run %q version: %w", absolute, err) + } + return parseVersionOutput(string(output)) +} + +// parseVersionOutput reads the `version` subcommand line, formatted exactly as +// `Boatstack ()`. +func parseVersionOutput(output string) (string, string, error) { + trimmed := strings.TrimSpace(output) + const prefix = "Boatstack " + if !strings.HasPrefix(trimmed, prefix) { + return "", "", fmt.Errorf("unrecognized helper version output: %q", trimmed) + } + rest := strings.TrimPrefix(trimmed, prefix) + open := strings.LastIndex(rest, " (") + if open < 0 || !strings.HasSuffix(rest, ")") { + return "", "", fmt.Errorf("unrecognized helper version output: %q", trimmed) + } + version := strings.TrimSpace(rest[:open]) + sourceCommit := strings.TrimSpace(rest[open+2 : len(rest)-1]) + if version == "" || sourceCommit == "" { + return "", "", fmt.Errorf("incomplete helper identity in version output: %q", trimmed) + } + return version, sourceCommit, nil +} diff --git a/boatstack/reexec.go b/boatstack/reexec.go new file mode 100644 index 0000000..d7dfa8d --- /dev/null +++ b/boatstack/reexec.go @@ -0,0 +1,40 @@ +package boatstack + +import ( + "os" + "path/filepath" +) + +// reexecProcess replaces (or, on Windows, spawns-and-exits) the current process +// with another binary. It is a package var so conformance tests can observe the +// hand-off without actually replacing the test process. +var reexecProcess = execReplaceProcess + +// reexecUpdate hands the entire update to a replacement helper binary. A running +// helper embeds its own generated bundle and version constants, so it cannot +// correctly install a *different* version in-process; the replacement binary must +// perform its own install so its bundle, constants, and durable operation receipt +// are the authoritative ones. The child runs `update -binary `, where its +// self-report matches its running identity, so it proceeds in-process and the +// hand-off terminates after exactly one hop. +func reexecUpdate(candidate string, options InitOptions) error { + absolute, err := filepath.Abs(candidate) + if err != nil { + return err + } + args := []string{absolute, "update"} + if options.Repo != "" { + args = append(args, "-repo", options.Repo) + } + args = append(args, "-binary", absolute) + if options.Yes { + args = append(args, "-yes") + } + if options.Repair { + args = append(args, "-repair") + } + if options.AllowDowngrade { + args = append(args, "-allow-downgrade") + } + return reexecProcess(absolute, args, os.Environ()) +} diff --git a/boatstack/reexec_unix.go b/boatstack/reexec_unix.go new file mode 100644 index 0000000..e858f23 --- /dev/null +++ b/boatstack/reexec_unix.go @@ -0,0 +1,11 @@ +//go:build !windows + +package boatstack + +import "syscall" + +// execReplaceProcess replaces the current process image with the target binary. +// On success it never returns; the replacement inherits stdio and environment. +func execReplaceProcess(path string, args []string, env []string) error { + return syscall.Exec(path, args, env) +} diff --git a/boatstack/reexec_windows.go b/boatstack/reexec_windows.go new file mode 100644 index 0000000..1ffde00 --- /dev/null +++ b/boatstack/reexec_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package boatstack + +import ( + "errors" + "os" + "os/exec" +) + +// execReplaceProcess spawns the target binary, mirrors its stdio, waits for it, +// and exits with its status. Windows has no execve, so this is the closest +// equivalent to replacing the current process; it never returns on success. +func execReplaceProcess(path string, args []string, env []string) error { + command := exec.Command(path, args[1:]...) + command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr + command.Env = env + if err := command.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode()) + } + return err + } + os.Exit(0) + return nil +} diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md index 57acd6e..5909135 100644 --- a/boatstack/references/failure-moves.md +++ b/boatstack/references/failure-moves.md @@ -21,11 +21,13 @@ Select a move only after locating the failure below its surface symptom. “Time | 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 | +| Cross-clone runtime-absence lockout | Only pointers (the guard's baked version path, the committed version pin) travel through Git; the version-keyed runtime bytes are gitignored and delivered out of band. So a teammate who pulls a merged version bump — or clones fresh — holds the new pointers but an empty slot, and the guard fail-closes ("shared runtime is missing") before any Go runs, stranding every teammate on every bump until each manually re-installs | The guard auto-hydrates an absent slot by running the tag-pinned, `.sha256`-verified installer in a branch-free, slot-only `hydrate-runtime` mode, serialized clone-wide by an atomic `mkdir` lock and bounded by a timeout, then falls through to the existing gates which stay authoritative and fail-closed; the deny message embeds the exact one-line self-heal, and `BOATSTACK_AUTO_HYDRATE=0` is the kill switch. Hydration refuses any running-vs-pin identity mismatch and touches no committed generated file | Running a fetched installer on cold start (bounded by tag pinning, HTTPS, sidecar verification, the guard's own checksum re-verify before `exec`, and the kill switch), or falling open — hydration is additive only, never a new authority for `exec` | | Post-publication correction routing | CI, review, or a denied push targets work already marked published | Resolve branch and recorded PR identity; append the observation; draft an independently approved corrective child | Treating PR creation as completion or asking the user to bypass the guard | | Unobserved side-effect completion | The same visible state could mean not started, executing, succeeded with a lost response, or failed | Durable operation receipt; exact lease; observe completion; reconcile the expected postcondition before retry | Conversation-scoped retry loops, duplicate PRs, or phantom success | | Unregistered malformed draft lockout | A hand-authored feature `plan.md` never passed through the helper, so a `CheckPlan` failure escalates to `INVALID_STATE` and the guard denies every product mutation, including the prescribed recovery | `repair-state` quarantines the draft out of `features/` and returns the workflow to `auto-plan`, refusing any directory with a lock, `pr.md`, delivery state, or tracked files | Loosening candidate selection so a genuinely invalid plan silently unblocks product edits | | Premature supervisory pointer advance | A durable supervisory pointer/state advances on request-success and revokes the correction actuator for a target whose postcondition (CI, merge) is not yet observed, so the stranded target can never be re-addressed | Separate the advance from correctability: keep a bounded in-place actuator for a non-terminal target (re-gate/re-publish the same open PR) and a bounded forward actuator once it is terminal (corrective child); resolve addressability network-free from a persisted terminal-state cache, never advance a supervisory pointer past an unobserved postcondition | Serializing legitimately-parallel work by refusing to advance, or persisting an identity/status that deadlocks the corrected retry | | Non-transactional multi-file promote | A managed artifact spans files that must land together (e.g. the compiled `tasks.json`, `test-matrix.json`, `evidence.md`, and the `plan.lock.json` that binds them), but independent non-atomic writes can leave a partial set on a crash or a failed post-write check | Promote the whole set through the transactional mutation boundary as one mutation: base-hash preconditions, supervisor-authority binding, atomic all-or-nothing write, post-write verification with automatic rollback, and a reversible receipt whose inverse bytes make the boundary closed under inversion — `undo` re-applies the inverse as a mutation (with redo as undo-of-the-undo), and a domain guard refuses reversal once a delivery gate would be stranded | Patching consistency after the fact with hash guards instead of making the promote atomic, persisting a rejected identity so a corrected retry deadlocks, or undoing an activation that strands live delivery state | +| Provenance-blind runtime install | A write path stamps an artifact's declared identity (version/commit) from one origin — the running process's compile-time globals — while binding its integrity proof (checksum) to a different origin — the passed bytes; every checksum gate passes because the lock is internally consistent, but the binary self-reports a third value and the version gate fail-closes (clone-wide when the runtime is shared). Symptom: `update -binary ` run by an older helper writes newer bytes into the older version's slot, then every worktree's guard denies at once | Derive the installed artifact's identity from the artifact itself (execute its `version` self-report) and enforce it at the *write* boundary: refuse to install a `-binary` whose self-report disagrees with the running process, and re-exec a cross-version candidate so it installs itself — running becomes installed, so its embedded bundle, constants, slot path, and receipts are all authoritative by construction. Re-hash the just-written slot against its manifest and roll back on mismatch | Executing an untrusted candidate (bounded, operator-invoked only), or converting a recoverable slot mismatch into a hard clone-wide refuse that blocks legitimate upgrades; a per-read self-report exec would tax every guard event, so identity is enforced where it is written, not on the hydration hot path | ## Lessons encoded from the benchmark campaign @@ -40,6 +42,8 @@ Select a move only after locating the failure below its surface symptom. “Time - **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. - **A retry needs a new observation.** Identical in-flight calls wait. Unknown non-idempotent calls enter `RECONCILE_REQUIRED`; Git, GitHub, filesystem, browser, and MCP boundaries must observe their exact postcondition before another attempt consumes the persistent budget. - **Preconditions run before leases.** Wrong branch, stale base, or invalid diff state returns a recovery operation without creating a durable attempt. A rejected precondition cannot consume retry budget or leave an identity that collides with the corrected invocation. +- **Identity is proven at the write, not inferred from a checksum.** A checksum proves *these bytes match this lock*; it never proves *this binary is the version it claims*. When `update -binary` stamped the running helper's version onto a newer binary's bytes, both checksum gates passed and the shared runtime fail-closed clone-wide. The fix makes running == installed by re-execing a cross-version candidate to install itself, and refuses a directly-passed mismatched binary — provenance is enforced at the one boundary that writes it. +- **When only pointers travel through Git, absence is the default state, not the exception.** The runtime bytes are gitignored and version-keyed, so every merged version bump and every fresh clone starts with an empty slot; a guard that only *denies* on absence strands every teammate on every bump. The fix lets the guard self-heal by running the pinned, verified installer in a branch-free hydrate mode — but strictly additively: the existing deny gates stay authoritative and fail-closed, hydration refuses any running-vs-pin mismatch, and a kill switch plus an embedded self-heal command keep the human in control. Convenience on cold start must never become a new authority for execution. ## Move proposal schema diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 0b53069..9e36769 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -399,6 +399,10 @@ After successful publication only, the publisher may use the ignored 24-hour rel For an available version, create `chore/update-boatstack-v` and download and checksum-verify the target helper before consulting the installed runtime. The target helper classifies hook fragments, generated locks, helper provenance, and marker-bounded interceptors. Exact installed state migrates automatically. Recoverable owned drift is fingerprinted and, interactively, offered as **Repair Boatstack-owned state and continue the update? [y/N]**; noninteractive updates stop with one `--repair` retry. Repair backs up the exact paths in Git-common state and remains in the same update PR. User-owned, mixed, malformed, symlinked, or product state stays blocked. Downgrades require both `--repair` and `--allow-downgrade`. +`update -binary ` installs the passed binary's **own self-reported version**, not the running helper's. Because each helper embeds its own version-bound generated bundle and compile-time constants, an older helper cannot correctly install a newer one in-process; when the passed binary self-reports a different identity, the whole update is re-executed by that binary so it installs itself — its bundle, constants, version-keyed shared-runtime slot, and durable receipt are then authoritative by construction, and the hand-off terminates in a single hop. The write boundary refuses to install a `-binary` whose self-report disagrees with the process running it, and re-hashes the freshly written slot against its manifest, rolling back on mismatch — so a runtime can never be labeled one version while carrying another's bytes. + +The runtime bytes never travel through Git — only the guard's baked version path and the committed version pin do — so a teammate who pulls a merged version bump, or clones fresh, starts with the new pointers but an **empty**, gitignored, version-keyed shared slot. Rather than fail-close every such teammate until they re-install by hand, the safety guard **auto-hydrates** an absent slot: it runs the tag-pinned, `.sha256`-verified installer in a branch-free, slot-only `hydrate` mode, serialized clone-wide by an atomic `mkdir` lock (peers wait briefly for the slot to appear) and bounded by a timeout, then falls through to the existing missing/symlink/manifest/checksum gates. Hydration is strictly additive: those gates remain the sole authority for execution and stay fail-closed, so a disabled, timed-out, or failed hydration simply denies — now with the exact one-line self-heal command embedded in the message. The `hydrate-runtime` helper subcommand it invokes rewrites no committed generated file and requires no dedicated branch; it refuses to populate a slot whose identity disagrees with the worktree's pin, and since the installer downloads the exact pinned version first, running equals installed by construction (the runtime-cache re-hash-and-rollback is the backstop). This is a deliberate posture change — the guard runs a fetched installer on cold start — bounded by tag pinning, HTTPS, sidecar verification, the guard's own checksum re-verify before `exec`, the clone-wide lock, the timeout, and the `BOATSTACK_AUTO_HYDRATE=0` kill switch (with a `BOATSTACK_HYDRATE_COMMAND` override). It never becomes a new authority for execution. + Before a durable update attempt is created, Boatstack verifies the dedicated branch, base commit, repair classification, and current diff. Invalid workspace state consumes no retry budget. The update transaction then reuses one semantic ownership projection for admission, mutation, final verification, staging, and preview. Generated files must match their prepared bytes, host-hook files must preserve their non-Boatstack JSON, and `.cursorrules`, `CLAUDE.md`, and `GEMINI.md` must preserve everything outside their single Boatstack marker boundary. The update transaction is a durable atomic-local operation. It preserves repository configuration, adapters, integrations, and unrelated host settings, then runs `doctor`. After installation, `prepare-update-pr` verifies that every changed path is Boatstack-owned and atomically stores the exact non-empty publication package in Git-common runtime state. Show release and repair provenance, the exact generated diff, checksums, changed paths, integration state, rollout, and rollback. diff --git a/boatstack/runtime_cache.go b/boatstack/runtime_cache.go index 54b83f0..76b8c30 100644 --- a/boatstack/runtime_cache.go +++ b/boatstack/runtime_cache.go @@ -158,6 +158,19 @@ func installSharedRuntime(source, repo string, integrations map[string]Integrati if err := atomicWriteMode(manifestPath, encoded, 0o644); err != nil { return runtimeManifest{}, err } + // Post-write integrity: the bytes that landed in the version-labeled slot must + // be exactly what the manifest attests. A mismatch means the atomic replace + // raced or the slot was tampered mid-install; remove the slot rather than leave + // a mislabeled runtime that would pass the checksum gate but drift at hydration. + writtenHash, err := SHA256File(binaryPath) + if err != nil { + return runtimeManifest{}, err + } + if writtenHash != manifest.BinarySHA256 { + _ = os.Remove(binaryPath) + _ = os.Remove(manifestPath) + return runtimeManifest{}, fmt.Errorf("installed runtime failed post-write verification: %s does not match its manifest checksum", binaryPath) + } return manifest, nil } @@ -185,7 +198,7 @@ func loadSharedRuntime(repo string) (runtimeManifest, string, error) { } 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") + return runtimeManifest{}, "", fmt.Errorf("shared Boatstack runtime provenance does not match this worktree; re-run the verified installer from any checkout in this Git clone to repopulate it") } 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) @@ -195,7 +208,7 @@ func loadSharedRuntime(repo string) (runtimeManifest, string, error) { return runtimeManifest{}, "", err } if hash != manifest.BinarySHA256 { - return runtimeManifest{}, "", fmt.Errorf("shared Boatstack runtime checksum does not match its manifest") + return runtimeManifest{}, "", fmt.Errorf("shared Boatstack runtime checksum does not match its manifest; the cached runtime is corrupted — re-run the verified installer to repair it") } return manifest, binaryPath, nil } @@ -300,6 +313,42 @@ func HydrateWorktree(repoPath string) error { return verifyLocalRuntime(repo) } +// RunHydrateRuntime populates the shared runtime slot (and this worktree's +// ignored bin/) from the RUNNING binary, without touching any committed +// generated files and without requiring a dedicated update branch. It is the +// slot-only primitive the safety guard invokes — via the verified installer's +// hydrate mode — when a version-pinned slot is absent on a teammate's clone or +// after a version bump. It is the cross-clone cousin of HydrateWorktree: that +// copies an existing slot into a worktree; this creates the slot itself. +// +// Because the guard downloads and runs the exact pinned release before calling +// this, the running binary equals the repo's committed pin by construction. The +// verifyGeneratedRuntime gate refuses to populate a slot for any other version, +// so hydration can never write a mislabeled runtime (the taxweave incident's +// invariant), and installSharedRuntime's own post-write verify+rollback is the +// backstop. The operation is idempotent and safe under concurrent first use. +func RunHydrateRuntime(repoPath string) error { + repo, err := ResolveRepository(repoPath) + if err != nil { + return err + } + if err := verifyGeneratedRuntime(repo); err != nil { + return fmt.Errorf("refusing to hydrate a runtime that does not match this worktree's pin: %w", err) + } + source, err := os.Executable() + if err != nil { + return err + } + config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + if err != nil { + return fmt.Errorf("load project configuration for runtime hydration: %w", err) + } + if _, err := installSharedRuntime(source, repo, config.Integrations); err != nil { + return fmt.Errorf("populate the repository-family Boatstack runtime: %w", err) + } + return HydrateWorktree(repo) +} + func verifyLocalRuntime(repo string) error { lockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json") if err := rejectSymlinkComponents(repo, lockPath); err != nil { diff --git a/boatstack/runtime_provenance_test.go b/boatstack/runtime_provenance_test.go new file mode 100644 index 0000000..3cf3d21 --- /dev/null +++ b/boatstack/runtime_provenance_test.go @@ -0,0 +1,257 @@ +package boatstack + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// These tests pin the two provenance boundaries introduced to close the +// "cross-origin identity/checksum split" failure mode: the update hand-off +// boundary (RunUpdate) and the install write boundary (RunInit). They override +// the stubbable package vars readBinaryIdentity/reexecProcess so no real +// multi-version helper binaries are required. Globals are mutated, so none of +// these run in parallel. + +// stubIdentity replaces readBinaryIdentity for the duration of a test and +// restores it afterwards. The returned identity is independent of the path so a +// single stub covers both the update and install boundaries. +func stubIdentity(t *testing.T, version, sourceCommit string) { + t.Helper() + original := readBinaryIdentity + readBinaryIdentity = func(string) (string, string, error) { return version, sourceCommit, nil } + t.Cleanup(func() { readBinaryIdentity = original }) +} + +// captureReexec replaces reexecProcess with a recorder that never replaces the +// test process. It returns pointers the caller can inspect after the exercised +// call. The recorder returns nil so RunUpdate treats the hand-off as complete. +func captureReexec(t *testing.T, called *bool, gotPath *string, gotArgs *[]string) { + t.Helper() + original := reexecProcess + reexecProcess = func(path string, args []string, _ []string) error { + *called = true + *gotPath = path + *gotArgs = args + return nil + } + t.Cleanup(func() { reexecProcess = original }) +} + +func candidateBinary(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "boatstack-helper-candidate") + if err := os.WriteFile(path, []byte("candidate bytes"), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +// TestRunUpdateHandsOffAcrossVersionInsteadOfInProcessInstall proves that a +// -binary self-reporting a different identity is re-executed rather than +// installed in-process by the running (old) helper. This is the exact hand-off +// that lets the candidate stamp its own version, closing the incident's root. +func TestRunUpdateHandsOffAcrossVersionInsteadOfInProcessInstall(t *testing.T) { + repo := runtimeTestRepo(t) + candidate := candidateBinary(t) + stubIdentity(t, "v9.9.9-candidate", "candidate-commit") + + var called bool + var gotPath string + var gotArgs []string + captureReexec(t, &called, &gotPath, &gotArgs) + + if err := RunUpdate(InitOptions{Repo: repo, BinaryPath: candidate, Yes: true, Output: &bytes.Buffer{}}); err != nil { + t.Fatalf("cross-version update should defer to the candidate, got error: %v", err) + } + if !called { + t.Fatal("cross-version update did not re-exec the candidate binary") + } + absoluteCandidate, err := filepath.Abs(candidate) + if err != nil { + t.Fatal(err) + } + if gotPath != absoluteCandidate { + t.Fatalf("re-exec target = %q, want the candidate %q", gotPath, absoluteCandidate) + } + joined := strings.Join(gotArgs, " ") + if !strings.Contains(joined, "update") || !strings.Contains(joined, "-binary "+absoluteCandidate) { + t.Fatalf("re-exec args did not re-issue update against the candidate: %v", gotArgs) + } +} + +// TestRunUpdateSameVersionDoesNotHandOff proves the hand-off only fires on an +// identity mismatch: a -binary self-reporting the running identity proceeds +// in-process (no re-exec), so ordinary same-version updates are unchanged. +func TestRunUpdateSameVersionDoesNotHandOff(t *testing.T) { + repo := runtimeTestRepo(t) + stubIdentity(t, Version, SourceCommit) + + var called bool + var gotPath string + var gotArgs []string + captureReexec(t, &called, &gotPath, &gotArgs) + + // os.Executable() is the running test binary; a matching identity must be + // handled in-process. The provenance guard decides the hand-off before the + // normal update path runs (which independently rejects the test binary's + // non-semver "dev" version), so the property under test is precisely that no + // re-exec occurs — the downstream update outcome is irrelevant here. + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + _ = RunUpdate(InitOptions{Repo: repo, BinaryPath: self, Yes: true, Output: &bytes.Buffer{}}) + if called { + t.Fatal("same-version update should not re-exec") + } +} + +// TestRunInitRefusesVersionMismatchedBinary proves the write boundary refuses to +// stamp the running process's version onto a foreign binary and writes nothing. +func TestRunInitRefusesVersionMismatchedBinary(t *testing.T) { + repo := planningRepo(t) + if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + candidate := candidateBinary(t) + stubIdentity(t, "v9.9.9-candidate", "candidate-commit") + + err := RunInit(InitOptions{Repo: repo, BinaryPath: candidate, IntegrationChoice: "core", Yes: true, Output: &bytes.Buffer{}}) + if err == nil { + t.Fatal("install of a version-mismatched -binary should be refused") + } + if !strings.Contains(err.Error(), "version-mismatched") { + t.Fatalf("refusal did not name the provenance mismatch: %v", err) + } + if _, statErr := os.Stat(filepath.Join(repo, ".product-loop", "bin", "install.lock.json")); !os.IsNotExist(statErr) { + t.Fatalf("refused install must leave no install lock behind, stat err = %v", statErr) + } +} + +// TestRunInitAdoptsMatchingBinaryIdentity proves an explicit -binary whose +// self-report matches the running identity installs and records that identity. +func TestRunInitAdoptsMatchingBinaryIdentity(t *testing.T) { + repo := planningRepo(t) + if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + stubIdentity(t, Version, SourceCommit) + + if err := RunInit(InitOptions{Repo: repo, BinaryPath: self, IntegrationChoice: "core", Yes: true, Output: &bytes.Buffer{}}); err != nil { + t.Fatalf("matching -binary install failed: %v", err) + } + lock := readInstallLock(t, repo) + if lock.BoatstackVersion != Version || lock.SourceCommit != SourceCommit { + t.Fatalf("install lock recorded %s (%s), want the verified identity %s (%s)", lock.BoatstackVersion, lock.SourceCommit, Version, SourceCommit) + } +} + +// TestSelfInstallBypassesProvenanceGuard proves a normal install (no -binary) +// is untouched by the guard: readBinaryIdentity is set to a poison stub that +// would fail any check, yet the self-install succeeds because the guard only +// runs for an explicit -binary. +func TestSelfInstallBypassesProvenanceGuard(t *testing.T) { + repo := planningRepo(t) + if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + original := readBinaryIdentity + readBinaryIdentity = func(string) (string, string, error) { return "poison", "poison", nil } + t.Cleanup(func() { readBinaryIdentity = original }) + + if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true, Output: &bytes.Buffer{}}); err != nil { + t.Fatalf("self-install should not consult the provenance guard: %v", err) + } + lock := readInstallLock(t, repo) + if lock.BoatstackVersion != Version { + t.Fatalf("self-install recorded %s, want the running version %s", lock.BoatstackVersion, Version) + } +} + +// TestReexecUpdatePreservesUpdateFlags proves the hand-off re-issues the update +// against the candidate itself and forwards the operator's flags verbatim, so a +// re-exec is a faithful continuation and terminates in a single hop. +func TestReexecUpdatePreservesUpdateFlags(t *testing.T) { + candidate := candidateBinary(t) + var called bool + var gotPath string + var gotArgs []string + captureReexec(t, &called, &gotPath, &gotArgs) + + if err := reexecUpdate(candidate, InitOptions{Repo: "some/repo", Yes: true, Repair: true, AllowDowngrade: true}); err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("reexecUpdate did not invoke the process replacement") + } + absoluteCandidate, err := filepath.Abs(candidate) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(gotArgs, " ") + for _, want := range []string{"update", "-repo some/repo", "-binary " + absoluteCandidate, "-yes", "-repair", "-allow-downgrade"} { + if !strings.Contains(joined, want) { + t.Fatalf("re-exec args missing %q: %v", want, gotArgs) + } + } + if gotArgs[0] != absoluteCandidate { + t.Fatalf("argv[0] = %q, want the candidate %q so it proceeds in-process", gotArgs[0], absoluteCandidate) + } +} + +func TestParseVersionOutput(t *testing.T) { + cases := []struct { + name string + input string + wantVer string + wantCommit string + wantErr bool + }{ + {name: "canonical", input: "Boatstack v0.7.57 (abc1234)\n", wantVer: "v0.7.57", wantCommit: "abc1234"}, + {name: "surrounding whitespace", input: " Boatstack v1.2.3 (deadbeef) ", wantVer: "v1.2.3", wantCommit: "deadbeef"}, + {name: "commit with spaces preserved by last-paren split", input: "Boatstack dev (unknown)", wantVer: "dev", wantCommit: "unknown"}, + {name: "missing prefix", input: "v0.7.57 (abc1234)", wantErr: true}, + {name: "missing parens", input: "Boatstack v0.7.57", wantErr: true}, + {name: "empty version", input: "Boatstack (abc1234)", wantErr: true}, + {name: "empty commit", input: "Boatstack v0.7.57 ()", wantErr: true}, + {name: "garbage", input: "not a version line", wantErr: true}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + version, commit, err := parseVersionOutput(testCase.input) + if testCase.wantErr { + if err == nil { + t.Fatalf("expected an error for %q, got (%q, %q)", testCase.input, version, commit) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", testCase.input, err) + } + if version != testCase.wantVer || commit != testCase.wantCommit { + t.Fatalf("parseVersionOutput(%q) = (%q, %q), want (%q, %q)", testCase.input, version, commit, testCase.wantVer, testCase.wantCommit) + } + }) + } +} + +func readInstallLock(t *testing.T, repo string) installLock { + t.Helper() + value, err := os.ReadFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json")) + if err != nil { + t.Fatal(err) + } + var lock installLock + if err := json.Unmarshal(value, &lock); err != nil { + t.Fatal(err) + } + return lock +} diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 78b549e..9c9e1d9 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -96,7 +96,7 @@ subject to acceptance criteria pass approval is current ``` -That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **16784 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 **18368 estimated tokens**, while host adapters point to one operation at a time. ## Control appears at transitions @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`df798dfd69a002bb8b9970216adf4f8afbe2b6ca`](https://github.com/operatorstack/intelligence-flow/tree/df798dfd69a002bb8b9970216adf4f8afbe2b6ca/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 [`76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75`](https://github.com/operatorstack/intelligence-flow/tree/76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75/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 3f360c9..9f19bd2 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "df798dfd69a002bb8b9970216adf4f8afbe2b6ca", + "source_commit": "76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75", "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:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" + "last_verified_version": "source:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" }, { "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:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" + "last_verified_version": "source:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" }, { "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:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" + "last_verified_version": "source:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" }, { "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:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" + "last_verified_version": "source:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" }, { "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:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" + "last_verified_version": "source:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" }, { "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:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" + "last_verified_version": "source:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" }, { "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:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" + "last_verified_version": "source:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" }, { "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:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" + "last_verified_version": "source:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" }, { "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:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" + "last_verified_version": "source:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" }, { "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:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" + "last_verified_version": "source:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" }, { "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:df798dfd69a002bb8b9970216adf4f8afbe2b6ca" + "last_verified_version": "source:76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75" } ] } diff --git a/install.ps1 b/install.ps1 index dce35c3..e49ac61 100644 --- a/install.ps1 +++ b/install.ps1 @@ -12,8 +12,8 @@ $targetRepo = if ($env:BOATSTACK_REPO) { $env:BOATSTACK_REPO } else { (Get-Locat $mode = if ($env:BOATSTACK_MODE) { $env:BOATSTACK_MODE } else { "install" } $repairRequested = $Repair -or $env:BOATSTACK_REPAIR -eq "1" $downgradeRequested = $AllowDowngrade -or $env:BOATSTACK_ALLOW_DOWNGRADE -eq "1" -if ($mode -notin @("install", "update")) { - throw "BLOCKED: BOATSTACK_MODE must be install or update" +if ($mode -notin @("install", "update", "hydrate")) { + throw "BLOCKED: BOATSTACK_MODE must be install, update, or hydrate" } $existingGeneratedLock = Test-Path -PathType Leaf (Join-Path $targetRepo ".product-loop/generated.lock.json") @@ -75,6 +75,18 @@ try { } } + # hydrate mode only populates the version-keyed shared runtime slot (and this + # worktree's ignored bin/) from the just-verified binary. It runs the binary + # as itself — running == installed — so no --binary handoff is needed, and it + # never touches committed generated files or requires a dedicated update branch. + if ($mode -eq "hydrate") { + & $binary hydrate-runtime --repo $targetRepo + if ($LASTEXITCODE -ne 0) { + throw "Boatstack runtime hydration failed with exit code $LASTEXITCODE" + } + return + } + $commandName = if ($mode -eq "update") { "update" } else { "init" } $arguments = @($commandName, "--repo", $targetRepo, "--binary", $binary) if ($mode -eq "install" -and $env:BOATSTACK_INTEGRATIONS) { diff --git a/install.sh b/install.sh index 4163c20..90a4cca 100644 --- a/install.sh +++ b/install.sh @@ -19,8 +19,8 @@ while [ "$#" -gt 0 ]; do done case "$mode" in - install|update) ;; - *) echo "BLOCKED: BOATSTACK_MODE must be install or update" >&2; exit 1 ;; + install|update|hydrate) ;; + *) echo "BLOCKED: BOATSTACK_MODE must be install, update, or hydrate" >&2; exit 1 ;; esac if [ "$mode" = "install" ] && { [ -f "$target_repo/.product-loop/generated.lock.json" ] || [ -f "$target_repo/.product-loop/bin/boatstack-helper" ] || [ -f "$target_repo/.product-loop/bin/boatstack-helper.exe" ]; }; then @@ -89,6 +89,14 @@ if [ "$mode" = "update" ]; then fi fi +# hydrate mode only populates the version-keyed shared runtime slot (and this +# worktree's ignored bin/) from the just-verified binary. It runs the binary as +# itself — running == installed — so no --binary handoff is needed, and it never +# touches committed generated files or requires a dedicated update branch. +if [ "$mode" = "hydrate" ]; then + exec "$binary" hydrate-runtime --repo "$target_repo" +fi + command_name="init" [ "$mode" = "update" ] && command_name="update" arguments=("$command_name" --repo "$target_repo" --binary "$binary") diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 211e1c4..38786e2 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": "df798dfd69a002bb8b9970216adf4f8afbe2b6ca", + "source_commit": "76a1339c3bfb8c7a75cc3e3f7a408cf8736b5e75", "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-24-auto-hydrate-missing-runtime.md b/release-notes/2026-07-24-auto-hydrate-missing-runtime.md new file mode 100644 index 0000000..f1e8095 --- /dev/null +++ b/release-notes/2026-07-24-auto-hydrate-missing-runtime.md @@ -0,0 +1,9 @@ +### The safety guard auto-hydrates a missing shared runtime, so a version bump no longer strands every teammate + +Boatstack never commits the runtime binary: only *pointers* travel through Git — the guard's baked version path and the committed version pin — while the bytes live in a gitignored, per-version shared slot under the Git common directory, delivered out of band by the tag-pinned, checksum-verified installer. The consequence was a clone-wide lockout: a teammate who pulled a merged version bump, or cloned fresh, held the new pointers but an **empty slot**, so their very next tool call hit the guard's `[[ ! -x "$HELPER" ]]` deny — "shared runtime is missing" — before any Go could run. Every version bump stranded every teammate until each manually re-ran the installer. It is the cross-clone cousin of the shared-runtime lockout. + +The guard now self-heals. On an absent slot it runs the pinned, checksum-verifying installer in a new branch-free `hydrate` mode, serialized clone-wide by an atomic `mkdir` lock (peers wait briefly for the slot to appear) and bounded by a timeout, then falls through to the existing checks. This is purely additive: the missing / symlink / manifest / checksum gates remain the sole authority for `exec` and stay fail-closed, so a disabled, timed-out, or failed hydration simply denies — now with the exact one-line self-heal command embedded in the message. + +Hydration is a new slot-only `hydrate-runtime` helper subcommand. Unlike `update`, it requires no dedicated branch and rewrites no committed generated file — it only populates the gitignored shared slot and the worktree's ignored `bin/`. It refuses to populate a slot whose identity disagrees with the worktree's committed pin, and because the installer downloads the exact pinned version before invoking it, running equals installed by construction; the runtime-cache write's own re-hash-and-rollback is the backstop. So a version-labeled slot can never durably hold another version's bytes. + +This is a deliberate security-posture change — the guard now runs a fetched installer on cold start — bounded by: the tag-pinned installer URL over HTTPS, the release `.sha256` sidecar verified inside the installer, the guard's own checksum gate re-verifying the slot **before** `exec`, the clone-wide lock, the timeout, and a `BOATSTACK_AUTO_HYDRATE=0` kill switch (plus a `BOATSTACK_HYDRATE_COMMAND` override). A conformance suite pins the boundaries: auto-hydration proceeds on success, fails closed on installer failure, is skipped when disabled, always invokes the hydrator with the pinned provenance, and runs at most once under concurrent first use; the `hydrate-runtime` primitive is idempotent, refuses a running-vs-pin mismatch, and touches no committed generated file. The failure-move catalog records the class as **Cross-clone runtime-absence lockout**. diff --git a/release-notes/2026-07-24-provenance-verified-binary-install.md b/release-notes/2026-07-24-provenance-verified-binary-install.md new file mode 100644 index 0000000..f4ffa7f --- /dev/null +++ b/release-notes/2026-07-24-provenance-verified-binary-install.md @@ -0,0 +1,14 @@ +### `update -binary` installs the passed binary's own verified version — a mislabeled runtime can no longer fail-close a whole clone + +`update -binary ` did not upgrade to the passed binary's version. It re-stamped the **running** helper's version onto the **passed** bytes: an operator running v0.7.54 who ran `update -binary ` got v0.7.57 bytes written into the v0.7.54 slot, under a `runtime.lock.json` declaring `boatstack_version: v0.7.54` with the v0.7.57 checksum. The lock was internally consistent, so **both** checksum gates passed — but the binary self-reports v0.7.57, so the version gate fail-closed. Because the verified runtime is shared across a Git clone, every worktree's guard denied at once, with no in-host way back (the guard denies before it can parse the recovery command). + +The root cause is a **cross-origin identity/checksum split**: the write path took the artifact's declared identity (version, commit) from one origin — the running process's compile-time globals — while binding its integrity proof (checksum) to a different origin — the passed bytes. A checksum proves *these bytes match this lock*; it never proves *this binary is the version it claims*. + +The fix moves provenance enforcement to the one boundary that writes it: + +- **Cross-version `update -binary` re-execs the candidate.** Each helper embeds its own version-bound generated bundle and constants, so an older helper cannot correctly install a newer one in-process. When the passed `-binary` self-reports a different identity (read by executing its `version` subcommand), the entire update is handed off to that binary, which installs *itself*. Running then equals installed, so its bundle, constants, version-keyed slot path, and durable receipt are authoritative by construction, and the hand-off terminates in a single hop. +- **`init`/`update -binary` refuse a directly-passed mismatched binary** rather than stamping this process's version onto foreign bytes, naming the mismatch and pointing at the target binary's own updater. +- **The runtime-cache write re-hashes the freshly written slot** against its manifest and rolls back (removing the slot) on any mismatch, so a version-labeled slot can never durably hold another version's bytes even under a mid-write race or tamper. +- Read-boundary messages now guide an operator to re-run the verified installer to repopulate or repair a bad slot. Identity is deliberately **not** re-derived by executing the cached binary on every guard event — that hydration path stays checksum-only for cost, because provenance is now guaranteed where it is written. + +A new `runtime_provenance_test.go` conformance suite pins the boundaries as named-property tests: cross-version `update` re-execs instead of installing in-process, a mismatched `init -binary` refuses and writes nothing, a matching `-binary` adopts the verified identity, normal self-install is unaffected, and `version`-output parsing is covered. The failure-move catalog records the class as **Provenance-blind runtime install**.