diff --git a/.abcd/development/brief/02-constraints/03-invariants.md b/.abcd/development/brief/02-constraints/03-invariants.md index f2ada937..32c1cda4 100644 --- a/.abcd/development/brief/02-constraints/03-invariants.md +++ b/.abcd/development/brief/02-constraints/03-invariants.md @@ -34,7 +34,7 @@ The following are non-negotiable invariants — any architectural choice that vi 11. **Record ids are collision-proof by construction and capture-stable** — the mint never allocates by looking at the current maximum (two minters sharing a stale view must still produce distinct ids); an id, once minted, is never renumbered; the grammar stays numeric so every id-consuming surface holds one parser; and a configured forge allocator allocates numbers only — offline it falls back to the native scheme loudly, and capture never blocks on network. Per [adr-45](../../decisions/adrs/0045-record-ids-are-timestamp-numeric-and-capture-stable.md); implemented by itd-114; asserted by the record-lint uniqueness detectors, which stay armed as the scheme's fail-safe. -12. **Persisting the hook binary never weakens the verification posture** — the spc-21 fetch-time invariants (same-origin checksums pinned to one release, HTTPS-only with `-q`-first curl and the proxy/CA scrub, no environment seam for origins) hold wherever the artefact lands; every promotion of a persisted artefact — cache → plugin root, cache → PATH, PATH refresh, migration seed — re-verifies against its recorded `binary_sha256` and refuses loudly on a mismatch; the cache's own trust is established against the release's published `checksums.txt` when online (its co-located record proves corruption, never tamper) and falls to corruption-evidence only offline, the success notice naming which trust it rests on; and ownership of a persisted file is recorded provenance, never content-guessing, reachable without any harness variable so the terminal verbs can prove it; and SessionEnd performs no network work — the exit path never bootstraps, fetches, or refreshes anything (landed with iss-2608210934566223, pinned by `TestSessionEndNeverBootstraps`). Per [adr-46](../../decisions/adrs/0046-persistence-never-weakens-the-verification-posture.md); consumed by itd-132 (spc-35) and every later verb that moves the artefact. +12. **Persisting the hook binary never weakens the verification posture** — the spc-21 fetch-time invariants (same-origin checksums pinned to one release, HTTPS-only with `-q`-first curl and the proxy/CA scrub, no environment seam for origins) hold wherever the artefact lands; every promotion of a persisted artefact — cache → plugin root, cache → PATH, PATH refresh, migration seed — re-verifies against its recorded `binary_sha256` and refuses loudly on a mismatch; the cache's own trust is established against the release's published `checksums.txt` when online (its co-located record proves corruption, never tamper) and falls to corruption-evidence only offline, the success notice naming which trust it rests on; and ownership of a persisted file is recorded provenance, never content-guessing, reachable without any harness variable so the terminal verbs can prove it; and SessionEnd performs no network work — the exit path never bootstraps, fetches, or refreshes anything (landed with iss-2608210934566223, pinned by `TestSessionEndNeverBootstraps`); and the cache reaches PATH only through a home-scoped attestation the environment cannot write — the bootstrap writes `~/.abcd/cache-attestation` (data dir, manifest-authenticated hash, trust) only after establishing manifest trust, never offline, and the owned-copy promotion accepts a data dir, whichever route named it, only when that record names the directory and the hash its co-located record carries (GHSA-4q78-ccfv-f374, amended 2026-09-15). Per [adr-2609151706587280](../../decisions/adrs/2609151706587280-the-cache-reaches-path-only-through-a-home-scoped-attestatio.md), which supersedes [adr-46](../../decisions/adrs/0046-persistence-never-weakens-the-verification-posture.md) and carries its five decisions forward; consumed by itd-132 (spc-35) and every later verb that moves the artefact. 13. **Decoration never reaches a machine-consumed stream, and untrusted text never carries ANSI** — ornament (colour, block art, attribute sequences) renders only on an interactive TTY, from trusted-static content (compiled-in art and tables, build-time-baked identity text); non-TTY stdout, `--json`, hook injections, and quiet modes receive no decoration bytes; every runtime-read string is `termsafe`-sanitised before it joins a rendered line; and `--no-color`/NO_COLOR strip colour without stripping content — degraded output is never blank. Per [adr-49](../../decisions/adrs/0049-terminal-emission-discipline.md); implemented first by itd-112, consumed by itd-110 and every later decorated surface. diff --git a/.abcd/development/decisions/adrs/0046-persistence-never-weakens-the-verification-posture.md b/.abcd/development/decisions/adrs/0046-persistence-never-weakens-the-verification-posture.md index dbca2796..81ce8258 100644 --- a/.abcd/development/decisions/adrs/0046-persistence-never-weakens-the-verification-posture.md +++ b/.abcd/development/decisions/adrs/0046-persistence-never-weakens-the-verification-posture.md @@ -1,10 +1,10 @@ --- id: adr-46 slug: persistence-never-weakens-the-verification-posture -status: accepted +status: superseded date: 2026-08-21 supersedes: null -superseded_by: null +superseded_by: adr-2609151706587280 related_intents: [itd-105, itd-132] related_rfcs: [] related_adrs: [adr-38] diff --git a/.abcd/development/decisions/adrs/2609151706587280-the-cache-reaches-path-only-through-a-home-scoped-attestatio.md b/.abcd/development/decisions/adrs/2609151706587280-the-cache-reaches-path-only-through-a-home-scoped-attestatio.md new file mode 100644 index 00000000..ae131be4 --- /dev/null +++ b/.abcd/development/decisions/adrs/2609151706587280-the-cache-reaches-path-only-through-a-home-scoped-attestatio.md @@ -0,0 +1,170 @@ +--- +id: adr-2609151706587280 +slug: the-cache-reaches-path-only-through-a-home-scoped-attestatio +status: accepted +date: 2026-09-15 +supersedes: adr-46 +superseded_by: null +related_intents: [itd-105, itd-132] +related_rfcs: [] +related_adrs: [adr-38, adr-46] +--- + +# ADR-2609151706587280: The cache reaches PATH only through a home-scoped attestation the environment cannot write + +## Context + +[adr-46](0046-persistence-never-weakens-the-verification-posture.md) bound the +persistent cache's trust to the release's published manifest (decision 3) and +made ownership a home-scoped recorded provenance (decision 4). Both held at the +promotions the bootstrap performs. The one promotion the bootstrap does not +perform — cache → owned PATH copy, which `ahoy install` performs — inherited +only decision 2: it re-hashed the artefact against the `binary-meta` beside it. + +GHSA-4q78-ccfv-f374 (CWE-345, iss-2609012039102770) is what that leaves open. +`pluginDataDir` takes `CLAUDE_PLUGIN_DATA` from the environment as given, and +the record it re-verifies against lives in the same directory as the artefact, +equally writable by whoever chose that directory. So the check is the very +shape adr-46 decision 3 rejects for the bootstrap — a file agreeing with its +own neighbour — and it was reproduced at v0.7.0: with the variable and +`ABCD_PLUGIN_ROOT` pointed at attacker-chosen directories, `ahoy install --yes +--adopt` installed a one-byte file 0755 as `~/.local/bin/abcd`, recorded its +hash and the fake root in `~/.abcd/path-entry`, and the classifier then vouched +for it as an owned copy. + +No mechanical fix closes the class without moving a documented contract. spc-35 +says the data dir is taken from the harness and never derived; adr-38 says +implicit checks are disk-only; the ahoy brief documents `install` as a +no-network verb. The record held three options open for a ruling: + +- **A.** Authenticate at install time against the published `checksums.txt`, + putting a network GET on `ahoy install`. +- **B.** Bind the cache to a record the environment does not choose alone: the + bootstrap, which runs with the harness's real data dir and has just + authenticated the cache against the manifest, writes a home-scoped + attestation, and the PATH promotion accepts a data dir only when it matches. +- **C.** Accept the finding as a documented residual — environment control is + already a PATH foothold — and record it beside adr-46's offline residual. + +The maintainer ruled **B** on 2026-09-15 at an interactive question. A +mechanical partial was rejected in the same record so nobody re-derives it: +cross-checking the recorded hash against the plugin-root binary defeats the +literal recipe only and breaks dogfood installs, because a source checkout used +as the plugin root with a locally built binary never matches the cache. + +An ADR is never amended, always superseded, so this record carries adr-46's +five decisions forward unchanged and adds the sixth. adr-46 is retained rather +than pruned because later records cite its numbered decisions. + +## Decision + +Decisions 1–5 are adr-46's, in force exactly as written there: + +1. **The fetch-time posture carries over verbatim, wherever the download + lands.** Same-origin checksums pinned to one release, the HTTPS pin on every + call, `-q`-first curl with the proxy/CA scrub, and no environment seam for + origins. +2. **Every promotion of a persisted artefact re-verifies against its recorded + `binary_sha256` and refuses loudly on a mismatch.** Cache → plugin root, + cache → PATH copy, PATH-copy refresh, and the migration seed each re-hash + the bytes being moved; a mismatch installs nothing and leaves the evidence. +3. **The cache's own trust is established against the release's published + manifest, not against its co-located record.** Online, the cached digest is + checked against the published `checksums.txt` before the cache is preferred + over a download; offline, the cache is trusted at corruption-evidence only, + and the success notice names which trust it rests on. +4. **Ownership is recorded provenance, never content-guessing**, and the record + lives in a home-scoped, abcd-owned location reachable without any harness + variable, because the verbs that consult it run from a terminal. +5. **SessionEnd performs no network work.** + +6. **The cache reaches PATH only through a home-scoped attestation the + environment cannot write.** The bootstrap — the one process that holds the + harness's real data dir and has just established manifest trust for the + bytes in the cache, whether by authenticating a cache hit or by verifying a + fresh download against the same-origin manifest — writes + `~/.abcd/cache-attestation`, a sibling of `path-entry` in the same key=value + form: `data_dir`, the manifest-authenticated `binary_sha256`, `cache_trust` + (the bootstrap's own vocabulary; only `manifest` is ever written), and + `attested_at`. It is written whole into a sibling temp file and renamed in, + mode 0600, after the root install's own re-hash succeeded, and only on + manifest trust: an offline run, which proved corruption evidence and nothing + more, neither creates nor rewrites it, and a run that could not authenticate + leaves any existing attestation exactly as it found it. `ahoy install` + promotes a cache into the owned PATH copy only when three things agree — + the attestation names the directory being promoted from, the co-located + `binary-meta` carries the attested hash, and the artefact hashes to it + (decision 2) — whichever route named the directory, the hook's environment + variable or the plugin root's `.data-dir` stamp. Either route is a route and + neither is trust. A present cache the attestation does not bind is refused + with a note that names which of the three failed, in tilde paths only, and + the install degrades to the pinned symlink exactly as it does with no cache + at all; detection offers the owned-copy heal on the same predicate, so the + two surfaces never disagree about one directory. The attestation binds the + **cache**, never the plugin-root binary: a source checkout as the plugin + root with a locally built binary is untouched. + + The trust floor for this promotion moves from "a value the environment + supplies" to "a write into the caller's own home" — the same floor decision + 4 already sets for ownership. An attacker who can write `~/.abcd` already + owns `path-entry` and everything it vouches for, so the attestation grants + nothing that authority did not already hold; an attacker who can only set + the environment, including through a committed project settings file's + environment block should a harness honour one, reaches the data dir and + nothing in the home. + +## Alternatives Considered + +- **A. Authenticate at install time against the published manifest.** + Rejected by the ruling: it puts a network GET on a verb the brief documents + as disk-only (adr-38), so offline it must either degrade to the symlink + fallback anyway or invent a second trust vocabulary; it also re-fetches what + the bootstrap has already fetched and proved in the same session. +- **C. Accept as a documented residual.** Rejected by the ruling: "environment + control is already a PATH foothold" is weaker than it reads, because a + harness that honours a committed project settings file's environment block + lets a hostile checkout set the variable for its own sessions, and the + owned-copy claim is what the hook shims trust before running an `abcd` off + PATH — a foothold that is also a vouched-for binary is not a residual of the + same size. +- **Cross-check the recorded hash against the plugin-root binary.** Rejected + as a mechanical partial: it defeats the literal reproduction and breaks + dogfood installs, whose plugin-root binary is a local build the cache never + matches. The attestation binds the cache, not the root, for this reason. +- **A terminal route to the cache through the attestation alone** (when the + environment is unset and the root's stamp is missing). Not adopted, although + it falls out of the same record: it would turn a dogfood checkout's stable + symlink into a `symlink.legacy` gap healed to a release copy on the next + `ahoy install`, which is the behaviour change the rejected partial was + refused for. The stamp remains the terminal's route; the attestation binds + whatever the stamp names. +- **Extend `path-entry` with the attestation's fields** instead of a sibling + record. Rejected: `path-entry` records exactly one thing, the owned copy, + and is parsed by the hook shims on every PATH resolution; the attestation + describes the cache, is written by a different process at a different time, + and must survive an uninstall that removes `path-entry`. The + `~/.abcd/trusted-roots` record follows the same sibling idiom for the same + reason. + +## Consequences + +- `hooks/bootstrap.sh` writes the attestation in step 9b, after the plugin + root's re-hashed install and the `.data-dir` stamp, and only when the run + established manifest trust; the notice reports an attestation it could not + write. `internal/core/ahoy/cache_attestation.go` is the one reader, through + the guarded bounded read `path-entry` uses; `cacheBindingProblem` is the one + predicate, consumed by `cacheSourceReady` for both detection and install. +- A cache provisioned offline, or migrated from a pre-cache root, is not + promoted to PATH until a session with network access re-authenticates it and + attests; the install says so and degrades to the symlink meanwhile. This is + the availability cost of the ruling and it is stated rather than hidden. +- spc-35's data-dir contract is revised in place, dated: the data dir is still + taken from the harness or the root's stamp and never derived, and it now + reaches PATH only through the attestation. Brief invariant 12 gains the + clause and cites this record. +- adr-46 is superseded and retained; every citation of "adr-46 decision N" + in code and records keeps its meaning, because decisions 1–5 are carried + here under the same numbers. +- Any future route to the cache — a repair verb, a multi-machine sync, a + terminal rung — inherits decision 6 as a requirement: a route is never + trust, and only a manifest-authenticating run may attest. diff --git a/.abcd/development/decisions/adrs/README.md b/.abcd/development/decisions/adrs/README.md index 26f4f569..bb0e9a4d 100644 --- a/.abcd/development/decisions/adrs/README.md +++ b/.abcd/development/decisions/adrs/README.md @@ -150,7 +150,7 @@ The intent lint (a Go implementation) extends to verify these reciprocally. | [adr-43](0043-inbound-equals-outbound-and-the-org-role-ladder.md) | Contributions are inbound = outbound MIT, and the trust boundary is the organisation's role ladder | accepted | 2026-08-19 | | [adr-44](0044-remote-mutation-and-caller-identity-trust-rules.md) | abcd never mutates a remote uninvited, and identity derives from caller-local facts | accepted | 2026-08-19 | | [adr-45](0045-record-ids-are-timestamp-numeric-and-capture-stable.md) | Record ids are timestamp-numeric, collision-proof by construction, and capture-stable | accepted | 2026-08-20 | -| [adr-46](0046-persistence-never-weakens-the-verification-posture.md) | Persisting the hook binary never weakens the verification posture — every promotion re-verifies, and SessionEnd performs no network work | accepted | 2026-08-21 | +| [adr-46](0046-persistence-never-weakens-the-verification-posture.md) | Persisting the hook binary never weakens the verification posture — every promotion re-verifies, and SessionEnd performs no network work (superseded by adr-2609151706587280, which carries decisions 1–5 forward and binds the cache-to-PATH promotion to a home-scoped attestation; retained because later records cite its numbered decisions) | superseded | 2026-08-21 | | [adr-47](0047-abcdev-app-rendered-from-this-repository-alone.md) | abcdev.app is rendered from this repository alone | accepted | 2026-08-22 | | [adr-48](0048-website-deploys-on-release-not-on-merge.md) | The website deploys on release, not on merge | accepted | 2026-08-22 | | [adr-49](0049-terminal-emission-discipline.md) | Terminal emission discipline — decoration only on interactive TTYs, machine streams undecorated, untrusted text always sanitised | accepted | 2026-08-22 | @@ -162,3 +162,4 @@ The intent lint (a Go implementation) extends to verify these reciprocally. | [adr-2609091014087993](2609091014087993-a-tool-never-creates-directories-in-user-owned-project-space.md) | A tool never creates directories in user-owned project space; agent and session scratch is machine-scoped (superseded by adr-2609091248200336, which states the split: the store's location binds now, its verbs bind when the store ships) | superseded | 2026-09-09 | | [adr-2609091248200336](2609091248200336-a-tool-never-creates-directories-in-user-owned-project-space.md) | A tool never creates directories in user-owned project space; the store's location binds now and its verbs bind when the store ships (supersedes adr-2609091014087993) | accepted | 2026-09-09 | | [adr-2609091248201071](2609091248201071-the-transcript-corpus-is-a-sibling-store-that-creates-itself.md) | The transcript corpus is a sibling store that creates itself through the canonical directory primitive (supersedes adr-2609090717039680) | accepted | 2026-09-09 | +| [adr-2609151706587280](2609151706587280-the-cache-reaches-path-only-through-a-home-scoped-attestatio.md) | The cache reaches PATH only through a home-scoped attestation the environment cannot write (supersedes adr-46; GHSA-4q78-ccfv-f374, option B as ruled 2026-09-15) | accepted | 2026-09-15 | diff --git a/.abcd/development/specs/closed/spc-35-hook-binary-to-persistent-data-dir.md b/.abcd/development/specs/closed/spc-35-hook-binary-to-persistent-data-dir.md index 077dabd8..4abce80f 100644 --- a/.abcd/development/specs/closed/spc-35-hook-binary-to-persistent-data-dir.md +++ b/.abcd/development/specs/closed/spc-35-hook-binary-to-persistent-data-dir.md @@ -52,7 +52,15 @@ On a missing root binary (fresh install or fresh post-update root): silent fallback (AC 7). Deriving the documented path shape from `CLAUDE_PLUGIN_ROOT` is deliberately not attempted: the derivation is documented but not endorsed, and a wrong guess plants a trusted artefact - in an untracked location. + in an untracked location. **Revised 2026-09-15 (GHSA-4q78-ccfv-f374, + adr-2609151706587280):** the data dir is taken from the harness (or, from + a terminal, the plugin root's `.data-dir` stamp) and never derived — but + being taken is a route, not trust. The bootstrap, once it has established + manifest trust for the cache, writes `~/.abcd/cache-attestation` naming + the data dir, the manifest-authenticated `binary_sha256` and the trust; + the cache → PATH promotion in Design 3 accepts a data dir only when that + record names it and the hash its `cache/binary-meta` carries. An offline + run never writes or rewrites the attestation. 2. Take the relocated lock in the data dir (per-root locks cannot serialise two roots writing one cache). The per-root `.bootstrap.attempt` throttle in the hook commands stays as-is — it only rate-limits invocation. @@ -95,7 +103,11 @@ On a missing root binary (fresh install or fresh post-update root): - `installPinnedSymlink` becomes `installOwnedCopy`: re-verify the cache artefact against `binary_sha256`, copy to the target (default `~/.local/bin/abcd`) as a regular file 0755, record path + hash in the - provenance record (AC 4, AC 5). + provenance record (AC 4, AC 5). **Revised 2026-09-15:** the re-verify is + bound first — the data dir must be the one `~/.abcd/cache-attestation` + names and its recorded hash the attested one, or the cache is refused + loudly and install degrades to the pinned symlink as with no cache + (Design 2 step 1's revision; adr-2609151706587280 decision 6). - **Ownership is recorded provenance, never content-guessing**: a regular file at the target matching the record's hash is owned (idempotent / refreshable); anything else classifies foreign and is refused exactly as @@ -239,3 +251,13 @@ the surviving findings are iss-2608210934566228 (cache re-verify was corruption-only), iss-2608210934566229 (migration `mv`-onto-directory), and iss-2608210934566230 (owned copy broke terminal root resolution). adr-46 is amended to match. + +## Revision (2026-09-15) + +GHSA-4q78-ccfv-f374 (iss-2609012039102770): the Design 3 promotion re-verified +the cache only against its co-located record, which whoever chose the data dir +through the environment also wrote. Option B was ruled: the bootstrap writes a +home-scoped attestation after manifest authentication, and the promotion is +bound to it (Design 2 step 1 and Design 3 above, each marked). adr-46 is +superseded by adr-2609151706587280, which carries its decisions forward and +adds the binding as decision 6. diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 4f5c752a..cdd49e55 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2490,3 +2490,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-09 — itd-2609091034175565, the claim intent, is split into three records (maintainer, ruling after two adversarial reviews of the widened draft, design/feasibility and record-discipline). The reviews found the expensive parts unsound as drafted: a `claimed_by` stamp on an issue record is invisible to any peer on an older binary, because `issueschema.Known` is a closed allow-list whose reader refuses and skips a record carrying an unknown key and `record_schema` mirrors the refusal into the gate, and version skew is the steady state here (five plugin-cache vintages beside a `go run` checkout); the write-verb refusals fire after the fix is written, because `AGENTS.md` puts `capture resolve` in the same change as the fix; no staleness threshold is safe in both directions for two sessions in one worktree, since the worktree-exists and branch-merged tests are identical for both and a host may fire session-end on a context clear with the human still present; and the pushed half costs a merge-queue pass per claim, measured at fifteen to sixteen minutes on this repository's merge-group `ci` leg. Every collision on record is one of two claim-free shapes. The ruling: itd-2609091416295622 carries the read-only sibling-worktree ledger diff over `git worktree list --porcelain` (no claim, no lease, no hook, no threshold; the piece that ships soonest); itd-2609091416304128 carries `capture resolve` and `capture wontfix` refusing a record terminal at the local `origin/main` ref as last fetched, stating the ref's age and fetching nothing, with the same judgement rendered read-only on `abcd `; itd-2609091034175565 keeps the claim, the lease and the refusals as a draft marked not ready, carrying the refusal-surface, liveness and pushed-price questions as open questions and the two-release stamp migration as a hard constraint. The existing record stays the claim so the `promoted_from` trail from iss-2609020716570699 — whose remedy is the claim and the pushed half — stays true rather than being retitled onto a listing the issue never proposed; the two new records reach the issue through `related_intents` on the issue and on themselves, and every relation the schema cannot type is stated in prose as a prose cross-reference (iss-2609091256264547). All three sit in `drafts/`; adoption is the interview's. Rejected: re-scoping the existing record to the listing, which keeps the trail mechanically and breaks it semantically; a fifth relation word for "split from", which is the vague form the decomposition principle forbids. - 2026-09-15 — itd-200 / spc-70 ship with five implementation rulings the spec left open, taken by the implementing session and reversible by the maintainer (recorded here so none is silent). (1) The status-line offer is its own consent category, `status-line`, advisory and never approved by `--yes`, exactly as the git-identity pin is: it rewrites a harness-wide user setting and takes element choices, so only an answered prompt writes it, and `yes |` answers it. A decline is never persisted; the offer returns on the next install until the user-level setting exists. (2) Harness detection is positive evidence only: the harness's user settings file exists and parses (its directory from `$CLAUDE_CONFIG_DIR`, else the harness home); no file means nothing offered and nothing written. Ownership of a status command is decided by shape (` statusline`), which is what makes a dangling entry decidable when the binary is gone. (3) The two record counts on the row are FOLDER counts (files named `iss-*.md` in the open ledger; `itd-*.md` in drafts plus planned), not the board's parsing readers, because the verb runs on every status refresh and a parse over a clone-controlled record measured 0.96 s at twenty thousand records; folder membership is the record's own status signal, so the number differs only where a parser would skip an unreadable record. The spec's Scope text is corrected to say so. (4) Two guards the reviews required and the spec did not name: the previous-command fallback refuses to run when its own marker is already in the environment and marks its child, so a recorded command that reaches `abcd statusline` in any spelling terminates instead of forking without bound, and the install refuses to record such a command at all; and every write to the harness settings re-reads the file immediately before the act and merges into the fresh document, refusing when the status-line key changed under the prompts (itd-193's rule applied to a user file a live harness also writes). (5) `abcd mode ` prints the owed-answer line only when the user-level setting is absent or disabled — the setting is the only evidence of a status surface abcd has — and `managed` prints nothing; the bare board renders the presence line even when the line is disabled, because the switch silences the line and the board is the fallback. Rejected: making the offer a config-change gap (one consent for the PATH entry and the status line would let the reason paragraph be skipped); counting through the board's readers with a cache (a second mechanism to keep correct for the cheapest surface in the tree). The fidelity audit rcp-636b39d5541a records five criteria met with concerns, all disclosed on the shipped record; the one the record could close is closed: the GRILL rule domain now tells an agent to set the mode before it stops for a verdict. - 2026-09-15 — The roles and loopback design workstream lands on main, executing the 2026-09-01 ruling that the branch waits for the mint verb and takes its ids at the merge. `abcd decide` now exists, so the two decisions the branch numbered 0055 and 0056 are re-minted as adr-2609151528057260 (three roles, who each artefact addresses, and when the loop stops) and adr-2609151528057131 (abcd owns the product thinker's surface), content, status and date unchanged; every citation that meant the roles decisions is re-pointed (rfc-3, the phase-8 page, the roles page, the out-of-scope list, three intent drafts, and iss-168, whose 29 August extension cited them by the colliding numbers), and main's own 0055 and 0056 keep their ids. The branch's twelve hand-numbered intent drafts (itd-165 to itd-176) collide with nothing and keep their ids, as adr-45's grandfathering allows. Occasion: itd-200 shipped today refining the two roles decisions, so their citations on main pointed at the wrong records until this landed. +- 2026-09-15 — GHSA-4q78-ccfv-f374 (iss-2609012039102770) is closed by OPTION B, ruled by the maintainer at an interactive question: bind the cache to a record the environment does not choose alone. The owned PATH-copy promotion re-verified the cache only against the `binary-meta` beside it, and `CLAUDE_PLUGIN_DATA` is taken from the environment as given, so whoever chose the directory wrote both the bytes and the record that "verified" them — reproduced at v0.7.0 as a one-byte file installed 0755 as `~/.local/bin/abcd` with provenance recorded. Now the bootstrap, the one process holding the harness's real data dir that has just established manifest trust for the cache (an authenticated cache hit, or a fresh download verified against the same-origin manifest), writes `~/.abcd/cache-attestation` — `data_dir`, the manifest-authenticated `binary_sha256`, `cache_trust=manifest`, `attested_at`; 0600, temp-and-rename, beside `path-entry` — and `ahoy install` promotes a cache only when that record names the directory, the co-located record carries the attested hash, and the artefact hashes to it, whichever route (environment or the root's `.data-dir` stamp) named the directory; detection offers the heal on the same predicate. An offline run neither writes nor rewrites the attestation, so a cache provisioned offline waits for a networked session before it reaches PATH, said out loud. Why B: the trust floor moves from a value the environment supplies to a write into the caller's own home, which adr-46 decision 4 already treats as the ownership root, so the attestation grants nothing that authority did not hold and costs `ahoy install` no network (adr-38 stands). Rejected: A (a manifest GET on a disk-only verb, re-fetching what the session already proved); C (a documented residual — weaker than it reads, since a harness honouring a committed settings file's environment block lets a hostile checkout set the variable, and the owned-copy claim is what the hook shims trust); the mechanical partial of cross-checking against the plugin-root binary (breaks dogfood installs whose root binary is a local build); and a terminal rung through the attestation alone (it would heal a dogfood checkout's stable symlink into a release copy). adr-46 is superseded by adr-2609151706587280 (never amended, always superseded; retained because its numbered decisions are cited), spc-35 Design 2 step 1 and Design 3 are revised in place and dated, and brief invariant 12 gains the clause. diff --git a/.abcd/work/issues/open/iss-2609012039102770-ghsa-4q78-owned-copy-verification-self-referential-under-env-data-dir.md b/.abcd/work/issues/resolved/iss-2609012039102770-ghsa-4q78-owned-copy-verification-self-referential-under-env-data-dir.md similarity index 64% rename from .abcd/work/issues/open/iss-2609012039102770-ghsa-4q78-owned-copy-verification-self-referential-under-env-data-dir.md rename to .abcd/work/issues/resolved/iss-2609012039102770-ghsa-4q78-owned-copy-verification-self-referential-under-env-data-dir.md index 17c34141..d1085a19 100644 --- a/.abcd/work/issues/open/iss-2609012039102770-ghsa-4q78-owned-copy-verification-self-referential-under-env-data-dir.md +++ b/.abcd/work/issues/resolved/iss-2609012039102770-ghsa-4q78-owned-copy-verification-self-referential-under-env-data-dir.md @@ -9,6 +9,8 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/owned_copy.go" +resolution: "Option B, ruled by the maintainer on 2026-09-15 at an interactive question: the bootstrap, which runs with the harness's real data dir and has just authenticated the cache against the published release manifest, writes a home-scoped attestation (~/.abcd/cache-attestation: data_dir, the manifest-authenticated binary_sha256, cache_trust=manifest, attested_at; 0600, atomic, beside path-entry) and only on manifest trust — an offline run neither writes nor rewrites it. installOwnedEntry promotes a cache only when the attestation names that directory, the co-located binary-meta carries the attested hash, and the artefact hashes to it; an env data dir with no attestation or a mismatching one is refused loudly (tilde paths only) and install degrades to the pinned symlink exactly as with no cache. The plugin root's .data-dir route gets the same binding, and detection offers the symlink.legacy heal on the same predicate. The attestation binds the CACHE, never the plugin-root binary, so a source checkout as plugin root with a local build is untouched (the rejected partial's failure mode). The terminal route through the attestation alone is deliberately not added: it would turn a dogfood checkout's stable symlink into a release copy. Successor adr-2609151706587280 (supersedes adr-46, which is retained because later records cite it), spc-35 revised, invariant 12 amended." +impact: fix --- GHSA-4q78-ccfv-f374 (CWE-345, advisory severity low): the owned PATH-copy verification is self-referential under an env-supplied CLAUDE_PLUGIN_DATA. `internal/core/ahoy/data_dir.go:pluginDataDir` is `os.Getenv("CLAUDE_PLUGIN_DATA")` and nothing else; `owned_copy.go:cacheRecordedSHA` reads `binary_sha256` from a `binary-meta` beside the artefact under that same directory; `apply.go:installOwnedEntry` hashes the artefact, compares it to that record, writes the bytes 0755 as the owned PATH entry and records provenance in `~/.abcd/path-entry`. The bootstrap's manifest authentication (`hooks/bootstrap.sh`, cache_trust=manifest, iss-2608210934566228) covers only the cache-to-plugin-root promotion; the cache-to-PATH promotion re-verifies against the co-located record only, which an attacker who writes both satisfies trivially — adr-46 decision 3 states exactly this principle for the bootstrap. Reproduced at v0.7.0: with CLAUDE_PLUGIN_DATA and ABCD_PLUGIN_ROOT pointed at attacker-chosen directories, `ahoy install --yes --adopt` installed a one-byte file as `~/.local/bin/abcd`, recorded its hash and the fake plugin root in path-entry, and `classifyBinTarget` then vouched for it as an owned copy. @@ -22,3 +24,7 @@ B. Bind the cache to a record the env does not choose alone (RECOMMENDED). The b C. Accept as a documented residual (env control is at least a PATH foothold), record it in adr-46 beside the offline residual, close won't-fix — consistent with the DECISIONS.md entries that keep PATH as the operator's environment. Rejected mechanical partial, so nobody re-derives it: cross-checking the recorded hash against the plugin-root binary only defeats the literal recipe and breaks dogfood installs (a source checkout as ABCD_PLUGIN_ROOT with a locally built binary never matches the cache). One caveat on the premise: if the harness honours a committed project settings file's environment block, a hostile checkout can set this variable for its own sessions, and "env control is already a strong foothold" is weaker than the advisory assumes; not verified here. The strict hardening that does not depend on the choice (a data dir that is relative, inside the repository, or world-writable is never used as a cache) is captured and fixed separately and keeps this record open for the decision. + +## Grounds + +- pursued: the class closes because the trust floor for the cache-to-PATH promotion moves from a value the environment supplies alone to a write into the caller's own home, which adr-46 decision 4 already treats as the ownership root — an attacker who can write ~/.abcd already owns path-entry and everything it vouches for, so the attestation grants nothing new. What would show it wrong: a route by which environment or repository control reaches the owned copy without a home write (a harness honouring a committed project settings file's environment block only reaches the data dir, not the home, so it is covered; a bypass of readCacheAttestation's directory-plus-hash equality, or a bootstrap path that attests on offline trust, would not be). diff --git a/docs/how-to/install.md b/docs/how-to/install.md index 3069f4b2..91a0cf06 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -46,7 +46,11 @@ authenticating the cached hash against the release's published `checksums.txt` when online, or noting in its success line that it provisioned from an unauthenticated cache when offline. Only an empty, stale, or unavailable cache falls back to downloading the release binary and `checksums.txt` and verifying -the binary's SHA-256 against the manifest. A mismatch, a manifest that doesn't +the binary's SHA-256 against the manifest. Whenever a run has established that +manifest trust — an authenticated cache hit, or a fresh verified download — it +also writes `~/.abcd/cache-attestation`, a small home-scoped record naming the +data directory, the manifest-verified SHA-256, and the trust it rests on; an +offline run writes nothing there and leaves an existing record as it was. A mismatch, a manifest that doesn't list the platform, or a platform outside the released matrix (darwin and linux on amd64 and arm64) installs nothing and says why in plain language. A plugin root that already holds the binary costs one file test and no network. @@ -75,7 +79,12 @@ outside the one the session is working in, that is not world-writable, **and** machine. The [install](#cli) one-liner writes that record, and so does abcd's own install verb — whichever entry it leaves on `PATH`: the copy of the verified release binary it prefers, the symlink it degrades to when there is no -verified copy to make, and the track-latest shim `--dev` writes. Uninstalling +verified copy to make, and the track-latest shim `--dev` writes. The copy is +made only from a cache that `~/.abcd/cache-attestation` vouches for — the +directory it names, holding the hash it names — so a data directory pointed at +by an environment variable alone is never promoted onto `PATH`; the install +says which record is missing or disagrees and degrades to the symlink until a +session with network access re-authenticates the cache. Uninstalling takes the record away with the entry, so nothing that lands in that directory later inherits the claim. A binary nothing recorded is ignored with one line naming it and the reason, and the hook takes its degraded path instead; an diff --git a/hooks/bootstrap.sh b/hooks/bootstrap.sh index 85c5b554..2395ae9f 100755 --- a/hooks/bootstrap.sh +++ b/hooks/bootstrap.sh @@ -88,6 +88,7 @@ tmp='' root_tmp='' path_tmp='' auth_tmp='' +attest_tmp='' # staged records that provisioning BEGAN, and terminal that the run has already # had its last word (a notice or a refusal). Together they are the contract this @@ -106,6 +107,7 @@ cleanup() { [ -n "$root_tmp" ] && rm -rf "$root_tmp" [ -n "$path_tmp" ] && rm -f "$path_tmp" [ -n "$auth_tmp" ] && rm -rf "$auth_tmp" + [ -n "$attest_tmp" ] && rm -f "$attest_tmp" [ -n "$lock" ] && rm -rf "$lock" return 0 } @@ -492,8 +494,17 @@ fi # - Resolve answers a different tag -> download path. # The accepted gap: a release cut with no plugin update never triggers a fetch # here — the version-skew notice surfaces it, `abcd update` is the explicit path. +# +# attest records whether this run established MANIFEST trust for the bytes the +# cache holds — the equal-tag authentication below, or the download path's +# verification of a fresh artefact against the same-origin checksums.txt. Only +# such a run may write the home-scoped cache attestation (§9b): an offline run +# proves corruption evidence only and attests nothing, and leaves any existing +# attestation exactly as it found it — the record moves on evidence, never on +# a run that could not check. use_cache='' cache_trust='' +attest='' stale_note='' if [ -n "$cached_sha" ]; then if [ -z "$resolved_tag" ]; then @@ -510,6 +521,7 @@ if [ -n "$cached_sha" ]; then if [ -n "$published" ] && [ "$published" = "$cached_sha" ]; then use_cache=yes cache_trust=manifest + attest=yes else # Mismatch or an unlisted asset leaves use_cache empty: the cache # is tampered or stale, so it is discarded and the download path @@ -534,6 +546,7 @@ cache_note='' path_note='' from_note='' stamp_note='' +attest_note='' if [ -n "$use_cache" ]; then release_tag="$cached_tag" @@ -631,6 +644,12 @@ else cache_note=' (the cache provenance record could not be written because its path is occupied by something that is not a regular file, so the next update may re-download)' elif ! mv -f "$tmp/binary-meta" "$cache_meta" 2>/dev/null; then cache_note=' (the cache provenance record could not be written, so the next update may re-download)' + else + # The artefact AND the record that names its manifest-verified hash + # are both in the cache now: this is the state the attestation + # describes, and only this state. A record that failed to land + # leaves nothing `ahoy install` could bind, so nothing is attested. + attest=yes fi # Refresh the abcd-owned PATH copy in the same run: a NEW release just @@ -753,6 +772,67 @@ if [ -n "$cache_mode" ] && { [ -n "$use_cache" ] || [ "$expected_sha" != unknown fi rm -rf "$root_tmp" root_tmp='' + + # 9b. Attest the cache, in the HOME. The data dir above came from the + # environment, and the cache's binary-meta sits beside the artefact it + # vouches for, so an `ahoy install` that trusted those two alone could + # be pointed at a directory of anyone's choosing holding a pair that + # agree with each other (GHSA-4q78-ccfv-f374). This run is the one + # process that holds the harness's REAL data dir and has just proved + # the cached bytes against the published release manifest — and, in §9, + # re-hashed the very copy it installed against that hash — so it writes + # what the environment cannot: a home-scoped record naming the data + # dir, the manifest-authenticated hash and the trust established. The + # PATH promotion accepts a cache only when this record names its + # directory and its recorded hash (adr-46 decision 4's ownership root, + # the home write, now also the cache's trust floor). + # + # Written only on manifest trust (attest, above): an offline run wrote + # nothing into the cache and authenticated nothing about it, so it + # neither creates nor rewrites this record. Written whole into a + # sibling temp file and renamed in, mode 0600 — it is the reader's own + # record — and a directory squatting the path is reported, not renamed + # into. The path is rendered nowhere: the note carries no home path. + # + # The temp file comes from mktemp, never from a name this script can + # predict: `> "$dir/.name.$$"` and a chmod by that name both FOLLOW a + # symlink pre-planted there, so a same-UID writer could have this run + # write the record's bytes and mode onto a file of their choosing and + # then rename the planted link itself into place as the attestation + # (found in the security review of the first cut). mktemp creates a + # fresh exclusive regular file under a name nobody could plant, and the + # chmod is by the name it returned. + # + # The data dir is an environment value written into a line-oriented + # record, so the control characters meta_field strips on READ are + # stripped before the WRITE: a value carrying a newline would otherwise + # inject key=value lines of its own, and the Go reader parses last-wins. + if [ -n "$attest" ] && [ -n "$home_dir" ]; then + attest_dir="$home_dir/.abcd" + attest_path="$attest_dir/cache-attestation" + if [ -e "$attest_path" ] && [ ! -f "$attest_path" ]; then + attest_note=' (the cache attestation could not be written because its path is occupied by something that is not a regular file, so `ahoy install` will not promote this cache to an owned PATH copy)' + else + attest_data_dir=$(printf '%s' "$data_dir" | tr -d '\000-\037\177') + if mkdir -p "$attest_dir" 2>/dev/null && + attest_tmp=$(mktemp "$attest_dir/.cache-attestation.XXXXXX" 2>/dev/null) && + [ -n "$attest_tmp" ] && + { + printf 'data_dir=%s\n' "$attest_data_dir" + printf 'binary_sha256=%s\n' "$expected_sha" + printf 'cache_trust=manifest\n' + printf 'attested_at=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + } > "$attest_tmp" 2>/dev/null && + chmod 0600 "$attest_tmp" 2>/dev/null && + mv -f "$attest_tmp" "$attest_path" 2>/dev/null; then + attest_tmp='' + else + [ -n "$attest_tmp" ] && rm -f "$attest_tmp" 2>/dev/null + attest_tmp='' + attest_note=' (the cache attestation could not be written, so `ahoy install` will not promote this cache to an owned PATH copy)' + fi + fi + fi else # Degraded install (no usable data dir, or a hash that failed to parse): # the spc-21 per-root path, verbatim — the artefact was verified in a temp @@ -840,5 +920,5 @@ fi # # The path is wrapped in SINGLE quotes (binary_quoted, defined at the top) for # the reason given there: this string is printed to be pasted into a shell. -notice "$(printf 'abcd bootstrap: installed the checksum-verified abcd binary (release %s) into the plugin root, so the abcd hooks are live for this session.%s%s%s%s%s%s%s For the abcd command in your own terminal, run this once — the path is absolute because abcd is not on your PATH yet, which is exactly what the command fixes: %s ahoy install' \ - "$release_tag" "$from_note" "$stale_note" "$path_note" "$meta_note" "$cache_note" "$stamp_note" "$degrade_note" "$binary_quoted")" +notice "$(printf 'abcd bootstrap: installed the checksum-verified abcd binary (release %s) into the plugin root, so the abcd hooks are live for this session.%s%s%s%s%s%s%s%s For the abcd command in your own terminal, run this once — the path is absolute because abcd is not on your PATH yet, which is exactly what the command fixes: %s ahoy install' \ + "$release_tag" "$from_note" "$stale_note" "$path_note" "$meta_note" "$cache_note" "$stamp_note" "$attest_note" "$degrade_note" "$binary_quoted")" diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index bc648489..f23a5b16 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -977,10 +977,25 @@ func (a *applyCtx) stepSymlink() { // whose provenance a copy could record. The cache is reached through the // hook's CLAUDE_PLUGIN_DATA or, from the terminal the bootstrap's notice sends // the reader to, through the plugin root's .data-dir stamp -// (iss-2609012111168716); the re-verification below is the same either way. +// (iss-2609012111168716). Both are ROUTES, not trust: the cache is promoted +// only when ~/.abcd/cache-attestation — written by the bootstrap after it +// authenticated the cache against the published release manifest — names that +// directory and the hash its record carries (cacheBindingProblem, +// GHSA-4q78-ccfv-f374); the re-verification below is then the same either way. func (a *applyCtx) installOwnedEntry(target string, kind binTargetKind) { look := pluginDataDir(a.det.pluginRoot) - if reason := dataDirHazard(look.dir, a.cwd); reason != "" { + // The three verdicts are taken ONCE, in order, and the promotion below acts + // on these locals alone: nothing under the data dir is consulted again + // after the binding is checked, except the artefact bytes themselves, + // which are hashed against the attested value. Re-reading the co-located + // record after the binding is the window the first cut left open. + var ( + hazard = dataDirHazard(look.dir, a.cwd) + present = hazard == "" && cachePresent(look.dir, a.cwd) + att cacheAttestation + unbound string + ) + if reason := hazard; reason != "" { // Said before the degradation below, so the operator learns both that // the cache was not used and why this one could never have been the // harness's directory. The story names which source proposed it — the @@ -988,22 +1003,40 @@ func (a *applyCtx) installOwnedEntry(target string, kind binTargetKind) { // same either way but the thing to repair is not. a.refuse("ignored the plugin data directory (" + look.story + "): " + reason + ". The harness's persistent data directory never has that shape, so nothing in it was trusted as a verified release artefact.") + } else if present { + if att, unbound = cacheBindingProblem(look.dir); unbound != "" { + // A cache is there, and it is exactly what an attacker who chose the + // directory would plant: an artefact and a record that agree with + // each other. The attestation is what the environment cannot write, + // so its absence or disagreement is the refusal, said in full. + a.refuse("ignored the cache in the plugin data directory (" + look.story + "): " + unbound + + ". A cache is promoted to the PATH copy only when the attestation the hooks write after authenticating it against the published release manifest names that directory and that hash, so nothing in it was trusted as a verified release artefact. Start a session with network access so the hooks re-authenticate the cache and attest it, then re-run `abcd ahoy install`.") + } } - if !cacheSourceReady(look.dir, a.cwd) { + if !present || unbound != "" { if kind != binTargetOwnedSymlink { // Notes is the loud channel (see refuse): the degradation must be // SAID, because a symlink into the plugin root dies at the next // plugin update and a silent fallback would hide why — and it names // every source tried, so the reader knows which one to restore. - a.refuse("no verified release artefact is available in the persistent plugin data directory (" + look.explainMissingCache() + + why := look.explainMissingCache() + if unbound != "" { + why = look.story + ", whose cache no attestation binds (above)" + } + a.refuse("no verified release artefact is available in the persistent plugin data directory (" + why + "), so the PATH entry was written as a symlink to the plugin-root binary — it will stop working when a plugin update replaces that directory. Start a session so the hooks provision the cache and record its location in the plugin root, then re-run `abcd ahoy install` to upgrade it to an owned copy.") } a.installPinnedSymlink(target, kind) return } dataDir := look.dir + if afterCacheBound != nil { + afterCacheBound(dataDir) + } artefact := cacheAssetPath(dataDir) - want := cacheRecordedSHA(dataDir) + // The ATTESTED hash, never the record beside the artefact: that record was + // compared to the attestation above and has no say after it. + want := att.sha data, err := fsutil.ReadGuarded(artefact, maxBinaryArtefactBytes) if err != nil { a.refuse("could not read the cached release artefact " + displayPath(artefact) + ": " + errText(err)) diff --git a/internal/core/ahoy/cache_attestation.go b/internal/core/ahoy/cache_attestation.go new file mode 100644 index 00000000..2fdf59ec --- /dev/null +++ b/internal/core/ahoy/cache_attestation.go @@ -0,0 +1,121 @@ +package ahoy + +import ( + "os" + "path/filepath" + "strings" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// The cache attestation (GHSA-4q78-ccfv-f374, iss-2609012039102770, option B +// as ruled on 2026-09-15): a home-scoped record binding the persistent data +// dir's cache to a trust the environment cannot supply. +// +// CLAUDE_PLUGIN_DATA is read from the environment as given, and the cache's +// binary-meta sits beside the artefact it vouches for, equally writable by +// whoever chose the directory. So re-hashing the artefact against that record +// proved only that a file matched its own neighbour: with the variable pointed +// at a directory of their choosing, an attacker's self-consistent pair was +// promoted 0755 as the owned PATH copy, provenance recorded. The bootstrap is +// the one process that runs with the harness's real data dir and, when +// online, has just authenticated the cache against the published release +// manifest (adr-46 decision 3), so it records that fact where the environment +// does not reach — a sibling of ~/.abcd/path-entry, the home write adr-46 +// decision 4 already treats as the ownership root. A promotion out of the +// cache now needs three things to agree: the attestation names the directory +// being promoted from, the co-located record carries the attested hash, and +// the artefact hashes to it. The trust floor moves from "the environment" to +// "a write into the caller's own home". +// +// The record is a claim of manifest trust and nothing weaker: the bootstrap +// writes it only after the manifest check passed for the very bytes now in +// the cache, and an offline run — which trusts the cache at corruption +// evidence only — neither writes nor rewrites it. It binds the CACHE, never +// the plugin-root binary: a source checkout as the plugin root with a locally +// built binary is untouched, which is why the rejected cross-check against +// that binary is not what this does. + +// cacheAttestationFile is the record's name under ~/.abcd. +const cacheAttestationFile = "cache-attestation" + +// afterCacheBound is a test seam, nil in production: it runs after the +// binding check has accepted a data dir and before the artefact is read, which +// is the window a writer in the attested directory can use to swap the +// artefact and its record for a self-consistent forgery. The promotion is +// correct only if nothing read in that window decides anything — the artefact +// is hashed against the ATTESTED value, never against the record beside it — +// and the seam is how a test occupies the window deterministically. +var afterCacheBound func(dataDir string) + +// cacheAttestation is the parsed record: the data dir the bootstrap was +// handed by the harness, the hash the published manifest vouched for, and the +// trust vocabulary the bootstrap's cache_trust already uses. +type cacheAttestation struct { + dataDir string + sha string + trust string +} + +// userCacheAttestationPath is ~/.abcd/cache-attestation, beside path-entry +// and for the same reason: `ahoy install` runs from a terminal as well as +// from a hook, and the record must be readable wherever the promotion runs. +// Empty when the home directory cannot be resolved (every caller then reads +// "no attestation"). +func userCacheAttestationPath() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".abcd", cacheAttestationFile) +} + +// readCacheAttestation loads the record through the same guarded, bounded +// read path-entry uses, reporting ok only for a well-formed manifest-trust +// attestation: an absolute data_dir, a full lowercase-hex binary_sha256, and +// cache_trust=manifest. Anything less — absent, truncated, over the record +// cap, a symlinked leaf, a relative directory, an offline trust — vouches for +// nothing, so it reads as no attestation at all. +func readCacheAttestation() (cacheAttestation, bool) { + path := userCacheAttestationPath() + if path == "" { + return cacheAttestation{}, false + } + raw, err := fsutil.ReadGuarded(path, maxPathEntryBytes) + if err != nil { + return cacheAttestation{}, false + } + var rec cacheAttestation + for _, line := range strings.Split(string(raw), "\n") { + k, v, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok { + continue + } + switch k { + case "data_dir": + rec.dataDir = v + case "binary_sha256": + rec.sha = v + case "cache_trust": + rec.trust = v + } + } + if !filepath.IsAbs(rec.dataDir) || hasControlChar(rec.dataDir) || !hexDigestOK(rec.sha) || rec.trust != "manifest" { + return cacheAttestation{}, false + } + return rec, true +} + +// hasControlChar reports whether s carries a byte the bootstrap strips before +// it writes a value into a line-oriented record (\000-\037 and \177). No +// record the bootstrap wrote holds one, so a data_dir that does was written +// by something else and is refused rather than parsed — the same class the +// script's meta_field strips on read. +func hasControlChar(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] == 0x7f { + return true + } + } + return false +} diff --git a/internal/core/ahoy/cache_attestation_test.go b/internal/core/ahoy/cache_attestation_test.go new file mode 100644 index 00000000..bde468d5 --- /dev/null +++ b/internal/core/ahoy/cache_attestation_test.go @@ -0,0 +1,373 @@ +package ahoy + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" +) + +// GHSA-4q78-ccfv-f374 (iss-2609012039102770), option B as ruled on 2026-09-15: +// the persistent data dir reaches the owned PATH copy only through a +// home-scoped ATTESTATION the bootstrap writes after authenticating the cache +// against the published release manifest. CLAUDE_PLUGIN_DATA is taken from +// the environment as given, and the cache's binary-meta sits beside the +// artefact it vouches for, so whoever chooses the directory chooses both the +// bytes and the record — the old promotion re-hashed one against the other +// and called that verification. The attestation is the record the environment +// does not choose: `ahoy install` promotes a cache only when the attestation +// names that very directory AND the co-located record carries the attested +// hash, and the artefact still hashes to it. + +// attestationBody renders the record the bootstrap writes for a cache holding +// body under dataDir. +func attestationBody(dataDir string, body []byte) string { + sum := sha256.Sum256(body) + return "data_dir=" + dataDir + "\nbinary_sha256=" + hex.EncodeToString(sum[:]) + + "\ncache_trust=manifest\nattested_at=2026-09-15T00:00:00Z\n" +} + +// writeUserCacheAttestation writes the home-scoped attestation verbatim. +func writeUserCacheAttestation(t *testing.T, body string) { + t.Helper() + p := userCacheAttestationPath() + if p == "" { + t.Fatal("no home-scoped cache-attestation location resolved") + } + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +// attestDataCache writes the attestation the bootstrap would have written for +// a cache holding body under data. +func attestDataCache(t *testing.T, data string, body []byte) { + t.Helper() + writeUserCacheAttestation(t, attestationBody(data, body)) +} + +// assertNoOwnedCopy: the target is not a regular file holding the cache +// artefact, and no path-entry vouches for the artefact's digest. +func assertNoOwnedCopy(t *testing.T, target string, res InstallResult) { + t.Helper() + if fi, err := os.Lstat(target); err == nil && fi.Mode().IsRegular() { + if got, _ := os.ReadFile(target); string(got) == string(cacheArtefact) { + t.Fatalf("install promoted the cache artefact into %s without a binding attestation; notes: %v", target, res.Notes) + } + } + if rec, ok := readPathEntry(); ok { + sum := sha256.Sum256(cacheArtefact) + if rec.sha == hex.EncodeToString(sum[:]) { + t.Fatalf("install recorded the unattested cache artefact's digest as this machine's abcd; notes: %v", res.Notes) + } + } +} + +// TestInstallRefusesUnattestedEnvDataDir is the advisory's reproduction: an +// environment-supplied data dir holding a self-consistent artefact plus +// binary-meta pair, and no attestation in the home. The old promotion hashed +// the artefact against its neighbour and installed it 0755 as the owned PATH +// copy with provenance recorded. Now nothing is promoted, nothing vouches for +// it, and the note says which record is missing. +func TestInstallRefusesUnattestedEnvDataDir(t *testing.T) { + home, _ := setupUserScope(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + data := t.TempDir() + seedDataCacheAt(t, data, cacheArtefact) + t.Setenv("CLAUDE_PLUGIN_DATA", data) + + res, err := Install(adoptableRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(binDir, "abcd") + assertNoOwnedCopy(t, target, res) + joined := notesJoined(res.Notes) + if !strings.Contains(joined, cacheAttestationFile) { + t.Errorf("the refusal must name the missing attestation; notes = %v", res.Notes) + } + if !strings.Contains(joined, "CLAUDE_PLUGIN_DATA") { + t.Errorf("the refusal must say which source proposed the directory; notes = %v", res.Notes) + } + if strings.Contains(joined, home) { + t.Errorf("notes must render home paths in tilde form, never absolute; notes = %v", res.Notes) + } + // Install degrades exactly as it does with no cache at all: the pinned + // symlink, said out loud. + if fi, err := os.Lstat(target); err != nil || fi.Mode()&os.ModeSymlink == 0 { + t.Errorf("an unattested cache must degrade to the spc-21 symlink: %v (%v)", fi, err) + } + if !strings.Contains(joined, "symlink") { + t.Errorf("the degradation must be named; notes = %v", res.Notes) + } +} + +// TestInstallRefusesEnvDataDirAttestedElsewhere: the attestation names the +// harness's real directory; an environment pointing at another directory +// holding the same bytes and record is still refused — the binding is to the +// directory, not merely to a hash the attacker can copy. +func TestInstallRefusesEnvDataDirAttestedElsewhere(t *testing.T) { + home, _ := setupUserScope(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + real := t.TempDir() + seedDataCacheAt(t, real, cacheArtefact) + attestDataCache(t, real, cacheArtefact) + attacker := t.TempDir() + seedDataCacheAt(t, attacker, cacheArtefact) + t.Setenv("CLAUDE_PLUGIN_DATA", attacker) + + res, err := Install(adoptableRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + assertNoOwnedCopy(t, filepath.Join(binDir, "abcd"), res) + joined := notesJoined(res.Notes) + if !strings.Contains(joined, cacheAttestationFile) || !strings.Contains(joined, "names a different") { + t.Errorf("the refusal must say the attestation names a different directory; notes = %v", res.Notes) + } +} + +// TestInstallRefusesAttestedHashMismatch: the attestation names this very +// directory, but the co-located record no longer carries the attested hash — +// the cache was rewritten after the bootstrap authenticated it. Refused, and +// the mismatch is named. +func TestInstallRefusesAttestedHashMismatch(t *testing.T) { + home, _ := setupUserScope(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + data := t.TempDir() + seedDataCacheAt(t, data, cacheArtefact) + attestDataCache(t, data, []byte("the release the bootstrap authenticated")) + t.Setenv("CLAUDE_PLUGIN_DATA", data) + + res, err := Install(adoptableRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + assertNoOwnedCopy(t, filepath.Join(binDir, "abcd"), res) + joined := notesJoined(res.Notes) + if !strings.Contains(joined, cacheAttestationFile) || !strings.Contains(joined, "binary_sha256") { + t.Errorf("the refusal must name the hash mismatch against the attestation; notes = %v", res.Notes) + } +} + +// TestInstallPromotesAttestedCacheByEitherRoute: a data dir the attestation +// names, whose record carries the attested hash, is promoted into the owned +// copy — reached through the hook's environment or through the plugin root's +// .data-dir stamp from a terminal. The same two routes without an attestation +// are refused: the stamp is a route, and the attestation is the trust. +func TestInstallPromotesAttestedCacheByEitherRoute(t *testing.T) { + routes := map[string]func(t *testing.T, pluginRoot, data string){ + "env": func(t *testing.T, _ string, data string) { + t.Setenv("CLAUDE_PLUGIN_DATA", data) + }, + "stamp": func(t *testing.T, pluginRoot, data string) { + t.Setenv("CLAUDE_PLUGIN_DATA", "") + if err := os.WriteFile(filepath.Join(pluginRoot, dataDirStampFile), []byte("data_dir="+data+"\n"), 0o644); err != nil { + t.Fatal(err) + } + }, + } + for name, route := range routes { + for _, attested := range []bool{true, false} { + label := name + "/unattested" + if attested { + label = name + "/attested" + } + t.Run(label, func(t *testing.T) { + home, pluginRoot := setupUserScope(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + data := t.TempDir() + seedDataCacheAt(t, data, cacheArtefact) + if attested { + attestDataCache(t, data, cacheArtefact) + } + route(t, pluginRoot, data) + + res, err := Install(adoptableRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(binDir, "abcd") + if !attested { + assertNoOwnedCopy(t, target, res) + if !strings.Contains(notesJoined(res.Notes), cacheAttestationFile) { + t.Errorf("the refusal must name the attestation; notes = %v", res.Notes) + } + return + } + fi, err := os.Lstat(target) + if err != nil { + t.Fatalf("install did not create %s: %v (notes %v)", target, err, res.Notes) + } + if fi.Mode()&os.ModeSymlink != 0 { + t.Fatalf("an attested cache must be promoted to the owned copy, not degraded; notes %v", res.Notes) + } + if got, err := os.ReadFile(target); err != nil || string(got) != string(cacheArtefact) { + t.Errorf("the owned copy must hold the attested artefact; got %q (%v)", got, err) + } + rec, ok := readPathEntry() + sum := sha256.Sum256(cacheArtefact) + if !ok || !sameEntry(rec.path, target) || rec.sha != hex.EncodeToString(sum[:]) { + t.Errorf("path-entry must record the promoted copy; got %+v (ok=%v)", rec, ok) + } + if joined := notesJoined(res.Notes); strings.Contains(joined, cacheAttestationFile) || strings.Contains(joined, "symlink") { + t.Errorf("no refusal may be reported for an attested cache; notes = %v", res.Notes) + } + }) + } + } +} + +// TestDetectOffersNoHealFromUnattestedCache: the symlink.legacy gap promises a +// heal to the owned copy, so it is offered only when install would actually +// perform it — never from a cache no attestation binds, or detection and +// install would disagree about the same directory. +func TestDetectOffersNoHealFromUnattestedCache(t *testing.T) { + home, pluginRoot := setupUserScope(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + linkOwned(t, filepath.Join(binDir, "abcd"), pluginRoot) + data := t.TempDir() + seedDataCacheAt(t, data, cacheArtefact) + t.Setenv("CLAUDE_PLUGIN_DATA", data) + + det, err := Detect(managedRepo(t)) + if err != nil { + t.Fatal(err) + } + if g := gapByID(det.Gaps, "symlink.legacy"); g != nil { + t.Fatalf("detection offers a heal from an unattested cache: %+v", *g) + } + + attestDataCache(t, data, cacheArtefact) + det, err = Detect(managedRepo(t)) + if err != nil { + t.Fatal(err) + } + if g := gapByID(det.Gaps, "symlink.legacy"); g == nil { + t.Fatalf("once attested, the same cache must be offered as the heal: %+v", det.Gaps) + } +} + +// TestReadCacheAttestationIgnoresMalformed: the record is read through the +// guarded bounded read like path-entry, and anything short of a well-formed +// manifest-trust attestation vouches for nothing — a truncated, relative, +// offline, oversize, or symlinked record all read as absent. +func TestReadCacheAttestationIgnoresMalformed(t *testing.T) { + sum := sha256.Sum256(cacheArtefact) + sha := hex.EncodeToString(sum[:]) + good := "data_dir=/harness/data\nbinary_sha256=" + sha + "\ncache_trust=manifest\n" + cases := map[string]string{ + "relative data_dir": "data_dir=harness/data\nbinary_sha256=" + sha + "\ncache_trust=manifest\n", + "no data_dir": "binary_sha256=" + sha + "\ncache_trust=manifest\n", + "short hash": "data_dir=/harness/data\nbinary_sha256=" + sha[:63] + "\ncache_trust=manifest\n", + "uppercase hash": "data_dir=/harness/data\nbinary_sha256=" + strings.ToUpper(sha) + "\ncache_trust=manifest\n", + "offline trust": "data_dir=/harness/data\nbinary_sha256=" + sha + "\ncache_trust=offline\n", + // A control character in the directory is a record no bootstrap wrote: + // the script strips the class before writing, and a value that + // smuggled one in (a hand-edited record, an older writer) is refused + // rather than parsed — its path could never name the harness's + // directory anyway. + "control character in data_dir": "data_dir=/harness/da\x01ta\nbinary_sha256=" + sha + "\ncache_trust=manifest\n", + "escape in data_dir": "data_dir=/harness/data\x1b[31m\nbinary_sha256=" + sha + "\ncache_trust=manifest\n", + "no trust": "data_dir=/harness/data\nbinary_sha256=" + sha + "\n", + "empty": "", + "oversize": good + strings.Repeat("padding=x\n", maxPathEntryBytes/10+1), + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + setupHermetic(t) + writeUserCacheAttestation(t, body) + if rec, ok := readCacheAttestation(); ok { + t.Errorf("a malformed attestation must read as absent, got %+v", rec) + } + }) + } + + t.Run("absent", func(t *testing.T) { + setupHermetic(t) + if rec, ok := readCacheAttestation(); ok { + t.Errorf("no record must read as absent, got %+v", rec) + } + }) + + t.Run("symlinked record", func(t *testing.T) { + home, _ := setupHermetic(t) + real := filepath.Join(t.TempDir(), "elsewhere") + if err := os.WriteFile(real, []byte(good), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(home, ".abcd"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(real, userCacheAttestationPath()); err != nil { + t.Fatal(err) + } + if rec, ok := readCacheAttestation(); ok { + t.Errorf("a symlinked record must not be followed, got %+v", rec) + } + }) + + t.Run("well-formed", func(t *testing.T) { + setupHermetic(t) + writeUserCacheAttestation(t, good+"attested_at=2026-09-15T00:00:00Z\n") + rec, ok := readCacheAttestation() + if !ok || rec.dataDir != "/harness/data" || rec.sha != sha || rec.trust != "manifest" { + t.Errorf("a well-formed attestation must parse; got %+v (ok=%v)", rec, ok) + } + }) +} + +// TestInstallRefusesPairFlippedAfterBinding is the window the security review +// of the first cut reproduced with a flipper goroutine in 0.25 s: the binding +// check compared the co-located binary-meta with the attestation, and the +// promotion then re-read that same attacker-writable record to decide what the +// artefact must hash to. A writer in the attested directory who swaps the +// (artefact, binary-meta) pair for a self-consistent forgery between the two +// reads had the forgery promoted. The seam stands in for the race +// deterministically: it runs after the binding is established and before the +// artefact is read. The promotion must hash the artefact against the ATTESTED +// value — the record beside it decides nothing once the binding is checked — +// so the flipped pair is refused and nothing is written. +func TestInstallRefusesPairFlippedAfterBinding(t *testing.T) { + home, _ := setupUserScope(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + data := seedDataCache(t, cacheArtefact) // attested for cacheArtefact + forged := []byte("#!/bin/sh\n# forged after the binding check\nexit 0\n") + saved := afterCacheBound + t.Cleanup(func() { afterCacheBound = saved }) + afterCacheBound = func(dir string) { + if dir != data { + t.Fatalf("the seam fired for %q, want %q", dir, data) + } + seedDataCacheAt(t, data, forged) // artefact AND binary-meta, self-consistent + } + + res, err := Install(adoptableRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(binDir, "abcd") + if got, err := os.ReadFile(target); err == nil && string(got) == string(forged) { + t.Fatalf("the pair flipped after the binding check was promoted: the promotion trusted the co-located record instead of the attested hash; notes %v", res.Notes) + } + if _, err := os.Lstat(target); !os.IsNotExist(err) { + t.Errorf("a refused promotion must write no PATH entry at all: %v", err) + } + if _, err := os.Stat(userPathEntryPath()); !os.IsNotExist(err) { + t.Errorf("no provenance may be recorded for a refused promotion: %v", err) + } + if !strings.Contains(notesJoined(res.Notes), "SHA-256") { + t.Errorf("the refusal must name the checksum mismatch; notes = %v", res.Notes) + } +} diff --git a/internal/core/ahoy/data_dir.go b/internal/core/ahoy/data_dir.go index 517bffdd..6b1252da 100644 --- a/internal/core/ahoy/data_dir.go +++ b/internal/core/ahoy/data_dir.go @@ -50,6 +50,10 @@ func (l dataDirLookup) explainMissingCache() string { // directory; the artefact it leads to is re-verified by every caller that // copies it. // +// Either source is a ROUTE to the cache and neither is trust: a promotion out +// of the directory returned here happens only when the home-scoped +// cache-attestation binds it (cacheBindingProblem), whichever source named it. +// // The documented path shape is deliberately never derived from the plugin // root, and the harness's own configuration is never read: a wrong guess // would plant a trusted artefact in an untracked location. With no source @@ -104,9 +108,10 @@ func metaField(path, key string) string { // in-checkout value is committed bytes, a world-writable cache is any local // user's — bless their own bytes as the owned PATH binary (sub-finding of // GHSA-4q78-ccfv-f374). The harness never produces these shapes, so refusing -// them costs a real install nothing; binding the cache to an attestation the -// env cannot supply is the parent record's open decision and is not attempted -// here. +// them costs a real install nothing. This is the shape check only; the trust +// binding — the cache is promoted only when ~/.abcd/cache-attestation names +// the directory and its recorded hash — is cacheBindingProblem, and a +// directory that passes here is still not promoted without it. func dataDirHazard(dataDir, cwd string) string { if dataDir == "" { return "" diff --git a/internal/core/ahoy/owned_copy.go b/internal/core/ahoy/owned_copy.go index bccfca45..eb32cbcb 100644 --- a/internal/core/ahoy/owned_copy.go +++ b/internal/core/ahoy/owned_copy.go @@ -231,13 +231,26 @@ func ownedCopySourceReady(cwd, pluginRoot string) bool { return cacheSourceReady(pluginDataDir(pluginRoot).dir, cwd) } -// cacheSourceReady reports whether dataDir holds an artefact for this platform +// cacheSourceReady reports whether dataDir holds a cache that may be promoted: +// present (cachePresent) AND bound by the home-scoped attestation +// (cacheBindingProblem). Detection offers the owned-copy heal on exactly this +// predicate and install performs it on exactly this predicate, so the two can +// never disagree about the same directory. +func cacheSourceReady(dataDir, cwd string) bool { + if !cachePresent(dataDir, cwd) { + return false + } + _, problem := cacheBindingProblem(dataDir) + return problem == "" +} + +// cachePresent reports whether dataDir holds an artefact for this platform // together with a parseable recorded hash to re-verify it against. An empty // dataDir is no source at all, and neither is one of a shape the harness never // produces (see dataDirHazard) — the check applies wherever the path came // from, the plugin root's stamp included, because neither source examines the -// value it hands back. -func cacheSourceReady(dataDir, cwd string) bool { +// value it hands back. Presence is not trust: see cacheBindingProblem. +func cachePresent(dataDir, cwd string) bool { if dataDir == "" || dataDirHazard(dataDir, cwd) != "" { return false } @@ -247,6 +260,37 @@ func cacheSourceReady(dataDir, cwd string) bool { return fileExists(cacheAssetPath(dataDir)) } +// cacheBindingProblem reports why the home-scoped attestation does not bind +// dataDir's cache, or "" when it does: the attestation exists and is +// well-formed, it names this very directory, and the cache's co-located +// binary-meta carries the attested hash. Any of the three failing means the +// directory and its record were chosen by something other than the bootstrap +// run that authenticated them — an environment variable, a rewritten cache — +// and nothing in it is a verified release artefact (GHSA-4q78-ccfv-f374). Every +// path in the reason is rendered in tilde form. +// +// On success the attestation itself is handed back, and it is the ONLY record +// a caller may act on afterwards: the co-located binary-meta is compared here +// and never read again, because a writer in the attested directory can swap +// the artefact and that record for a self-consistent forgery in the window +// between this check and the promotion (found in the security review of the +// first cut, reproduced in 0.25 s). The promotion hashes the artefact against +// the attested value, so a pair flipped after the binding fails the hash. +func cacheBindingProblem(dataDir string) (cacheAttestation, string) { + record := "~/.abcd/" + cacheAttestationFile + att, ok := readCacheAttestation() + if !ok { + return cacheAttestation{}, "no " + record + " record binds it — a session that authenticates the cache against the published release manifest writes one" + } + if resolvePath(att.dataDir) != resolvePath(dataDir) { + return cacheAttestation{}, record + " names a different directory (" + displayPath(att.dataDir) + "), so this one was chosen by something other than the session that authenticated the cache" + } + if cacheRecordedSHA(dataDir) != att.sha { + return cacheAttestation{}, "its recorded binary_sha256 is not the one " + record + " attests, so the cache changed after it was authenticated" + } + return att, "" +} + // RefreshPathEntryDigest re-records the provenance hash for the owned PATH // copy after `abcd update` swapped the file at target — the one other verb // that legitimately changes those bytes, and only after proving the new diff --git a/internal/core/ahoy/owned_copy_test.go b/internal/core/ahoy/owned_copy_test.go index f10c9286..bdc46b70 100644 --- a/internal/core/ahoy/owned_copy_test.go +++ b/internal/core/ahoy/owned_copy_test.go @@ -38,18 +38,24 @@ func writeUserPathEntry(t *testing.T, body string) { } } -// seedDataCache provisions a persistent data dir holding the verified cache — -// artefact plus binary-meta — and points CLAUDE_PLUGIN_DATA at it. +// seedDataCache provisions a persistent data dir the way a bootstrap run that +// authenticated it would have left it: the verified cache — artefact plus +// binary-meta — with the home-scoped attestation binding that directory and +// that hash, and CLAUDE_PLUGIN_DATA pointed at it. It is the harness's own +// data dir as `ahoy install` meets it from a hook. func seedDataCache(t *testing.T, body []byte) string { t.Helper() data := t.TempDir() seedDataCacheAt(t, data, body) + attestDataCache(t, data, body) t.Setenv("CLAUDE_PLUGIN_DATA", data) return data } // seedDataCacheAt writes the self-consistent cache (artefact plus binary-meta) -// under data, wherever the caller chose to put it; it sets no environment. +// under data, wherever the caller chose to put it; it sets no environment and +// writes no attestation — it is exactly what whoever controls the directory +// can produce on their own (GHSA-4q78-ccfv-f374). func seedDataCacheAt(t *testing.T, data string, body []byte) { t.Helper() if err := os.MkdirAll(filepath.Join(data, "cache"), 0o755); err != nil { diff --git a/internal/surface/cli/bootstrap_cache_test.go b/internal/surface/cli/bootstrap_cache_test.go index 183a4697..b3857b95 100644 --- a/internal/surface/cli/bootstrap_cache_test.go +++ b/internal/surface/cli/bootstrap_cache_test.go @@ -4,7 +4,9 @@ import ( "crypto/sha256" "encoding/hex" "os" + "os/exec" "path/filepath" + "strconv" "strings" "sync/atomic" "testing" @@ -911,3 +913,356 @@ func TestBootstrapRefreshesAOneLinerInstalledPathCopy(t *testing.T) { t.Errorf("the refresh must stamp the live plugin root onto a record that carried none; got %q", entryRaw) } } + +// GHSA-4q78-ccfv-f374 (iss-2609012039102770), option B: the bootstrap is the +// one process that runs with the harness's real CLAUDE_PLUGIN_DATA and, when +// online, has just authenticated the cache against the published release +// manifest. It records that fact in a HOME-scoped attestation — +// ~/.abcd/cache-attestation, beside path-entry — naming the data dir, the +// manifest-authenticated binary_sha256 and the trust it established. `ahoy +// install` promotes a cache into the owned PATH copy only when the +// attestation names that directory and that hash, so an environment variable +// alone can no longer bless attacker-chosen bytes. The record is written only +// after authentication: an offline run, which trusts the cache at +// corruption-evidence only, never writes or upgrades it. + +// homeCacheAttestation is $HOME/.abcd/cache-attestation. +func homeCacheAttestation(home string) string { + return filepath.Join(home, ".abcd", "cache-attestation") +} + +// attestationValues parses the attestation, failing when it is absent. +func attestationValues(t *testing.T, home string) map[string]string { + t.Helper() + raw, err := os.ReadFile(homeCacheAttestation(home)) + if err != nil { + t.Fatalf("the bootstrap must write the cache attestation after authenticating the cache: %v", err) + } + out := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + if k, v, ok := strings.Cut(line, "="); ok { + out[k] = v + } + } + return out +} + +func sha256Hex(body []byte) string { + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:]) +} + +// TestBootstrapAttestsCacheAfterManifestAuthentication: an online cache hit +// authenticates the cached hash against the published checksums.txt and then +// writes the attestation — exactly its declared fields, mode 0600, naming the +// data dir the harness supplied and the hash the manifest vouched for. +func TestBootstrapAttestsCacheAfterManifestAuthentication(t *testing.T) { + root := bootstrapRoot(t) + data := t.TempDir() + home := t.TempDir() + cached := []byte("#!/bin/sh\n# cached artefact\nexit 0\n") + seedBootstrapCache(t, data, bootstrapTag, cached) + fx := bootstrapServer(t, []byte("served, never installed"), bootstrapManifest(cached)) + + out, code := runBootstrapWithDataHome(t, root, data, home, fx, "") + if code != 0 { + t.Fatalf("the authenticated cache hit must install, got %d (output %q)", code, out) + } + got := attestationValues(t, home) + if got["data_dir"] != data { + t.Errorf("the attestation must name the data dir the harness supplied; got %q, want %q", got["data_dir"], data) + } + if got["binary_sha256"] != sha256Hex(cached) { + t.Errorf("the attestation must carry the manifest-authenticated hash; got %q", got["binary_sha256"]) + } + if got["cache_trust"] != "manifest" { + t.Errorf("the attestation must record the trust the bootstrap established; got %q", got["cache_trust"]) + } + raw := strings.TrimSpace(mustReadFile(t, homeCacheAttestation(home))) + lines := strings.Split(raw, "\n") + if len(lines) != 4 { + t.Fatalf("the attestation must hold exactly its four declared fields, got %d lines: %q", len(lines), raw) + } + for i, key := range []string{"data_dir", "binary_sha256", "cache_trust", "attested_at"} { + if !strings.HasPrefix(lines[i], key+"=") { + t.Errorf("line %d must be %s=…, got %q", i+1, key, lines[i]) + } + } + fi, err := os.Stat(homeCacheAttestation(home)) + if err != nil || fi.Mode().Perm() != 0o600 { + t.Errorf("the attestation must be mode 0600: %v (%v)", fi, err) + } + if strings.Contains(out, home) { + t.Errorf("the notice must not carry the home path raw; output %q", out) + } +} + +// TestBootstrapAttestsFreshlyDownloadedCache: the download path verifies the +// artefact against the same-origin manifest before publishing it into the +// cache, so that provision is manifest-authenticated too and is attested. +func TestBootstrapAttestsFreshlyDownloadedCache(t *testing.T) { + root := bootstrapRoot(t) + data := t.TempDir() + home := t.TempDir() + body := []byte("#!/bin/sh\n# fresh download\nexit 0\n") + fx := bootstrapServer(t, body, bootstrapManifest(body)) + + out, code := runBootstrapWithDataHome(t, root, data, home, fx, "") + if code != 0 { + t.Fatalf("the download-into-cache provision must install, got %d (output %q)", code, out) + } + got := attestationValues(t, home) + if got["data_dir"] != data || got["binary_sha256"] != sha256Hex(body) || got["cache_trust"] != "manifest" { + t.Errorf("a freshly downloaded cache must be attested with the manifest hash; got %v", got) + } +} + +// TestBootstrapOfflineCacheHitNeverAttests: offline, no published manifest is +// reachable, so the cache is trusted at corruption-evidence only — and an +// attestation is a claim of manifest trust, so none is written, and one that +// already exists is left exactly as it was, whatever it names. The record +// moves only on evidence. +func TestBootstrapOfflineCacheHitNeverAttests(t *testing.T) { + cached := []byte("#!/bin/sh\n# cached artefact\nexit 0\n") + + t.Run("none written", func(t *testing.T) { + root := bootstrapRoot(t) + data := t.TempDir() + home := t.TempDir() + seedBootstrapCache(t, data, bootstrapTag, cached) + fx := bootstrapServer(t, []byte("never served"), bootstrapManifest([]byte("never served"))) + atomic.StoreInt32(fx.failLatest, 1) + + out, code := runBootstrapWithDataHome(t, root, data, home, fx, "") + if code != 0 { + t.Fatalf("an offline cache hit must still install, got %d (output %q)", code, out) + } + if _, err := os.Stat(homeCacheAttestation(home)); !os.IsNotExist(err) { + t.Errorf("an offline run authenticated nothing and must attest nothing: %v", err) + } + }) + + t.Run("existing left untouched", func(t *testing.T) { + root := bootstrapRoot(t) + data := t.TempDir() + home := t.TempDir() + seedBootstrapCache(t, data, bootstrapTag, cached) + prior := "data_dir=/some/other/data\nbinary_sha256=" + strings.Repeat("a", 64) + "\ncache_trust=manifest\nattested_at=2026-09-01T00:00:00Z\n" + if err := os.MkdirAll(filepath.Join(home, ".abcd"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(homeCacheAttestation(home), []byte(prior), 0o600); err != nil { + t.Fatal(err) + } + fx := bootstrapServer(t, []byte("never served"), bootstrapManifest([]byte("never served"))) + atomic.StoreInt32(fx.failLatest, 1) + + out, code := runBootstrapWithDataHome(t, root, data, home, fx, "") + if code != 0 { + t.Fatalf("an offline cache hit must still install, got %d (output %q)", code, out) + } + if got := mustReadFile(t, homeCacheAttestation(home)); got != prior { + t.Errorf("an offline run must not rewrite an existing attestation; got %q, want %q", got, prior) + } + }) +} + +// TestBootstrapDiscardedCacheAttestsThePublishedHash: a poisoned self-consistent +// cache is rejected by the manifest check and replaced by the real download; +// the attestation then names the published hash, never the poison's. +func TestBootstrapDiscardedCacheAttestsThePublishedHash(t *testing.T) { + root := bootstrapRoot(t) + data := t.TempDir() + home := t.TempDir() + poison := []byte("#!/bin/sh\n# forged payload\nexit 0\n") + published := []byte("#!/bin/sh\n# the real release\nexit 0\n") + seedBootstrapCache(t, data, bootstrapTag, poison) + fx := bootstrapServer(t, published, bootstrapManifest(published)) + + out, code := runBootstrapWithDataHome(t, root, data, home, fx, "") + if code != 0 { + t.Fatalf("the replaced cache must install, got %d (output %q)", code, out) + } + got := attestationValues(t, home) + if got["binary_sha256"] == sha256Hex(poison) { + t.Fatal("the attestation vouches for the POISONED hash the manifest rejected") + } + if got["binary_sha256"] != sha256Hex(published) || got["data_dir"] != data { + t.Errorf("the attestation must name the published hash under the harness's data dir; got %v", got) + } +} + +// TestBootstrapDegradedInstallWritesNoAttestation: with no data dir there is +// no cache to attest; a stale attestation from an earlier provision is left as +// it is, because this run authenticated nothing about it. +func TestBootstrapDegradedInstallWritesNoAttestation(t *testing.T) { + root := bootstrapRoot(t) + home := t.TempDir() + body := []byte("#!/bin/sh\nexit 0\n") + fx := bootstrapServer(t, body, bootstrapManifest(body)) + + out, code := runScript(t, bootstrapFixtureScript(t, fx.base), root, append(fx.env(), "HOME="+home), "") + if code != 0 { + t.Fatalf("the degraded install must succeed, got %d (output %q)", code, out) + } + if _, err := os.Stat(homeCacheAttestation(home)); !os.IsNotExist(err) { + t.Errorf("a degraded install has no cache to attest: %v", err) + } +} + +// runBootstrapWithDataHomeAtPid runs the bootstrap so that the test learns the +// script's process id BEFORE the script starts: a wrapper shell records its +// own $$ to pidFile, waits for goFile to appear, then execs the script, and +// exec keeps the pid. plant is called with that pid between the two, so a +// test can pre-place a file at any name the script derives from $$. +func runBootstrapWithDataHomeAtPid(t *testing.T, root, data, home string, fx *bootstrapFixture, plant func(pid int)) (string, int) { + t.Helper() + bootstrapRequires(t) + script := bootstrapFixtureScript(t, fx.base) + dir := t.TempDir() + pidFile := filepath.Join(dir, "pid") + goFile := filepath.Join(dir, "go") + wrapper := filepath.Join(dir, "wrapper.sh") + body := "#!/bin/sh\nprintf '%s' \"$$\" > \"$1\"\nwhile [ ! -e \"$2\" ]; do sleep 0.02; done\nexec \"$3\"\n" + if err := os.WriteFile(wrapper, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command(wrapper, pidFile, goFile, script) + cmd.Env = dedupEnvKeepLast(append([]string{ + "PATH=" + os.Getenv("PATH"), + "CLAUDE_PLUGIN_ROOT=" + root, + }, append(fx.env(), "CLAUDE_PLUGIN_DATA="+data, "HOME="+home)...)) + var out strings.Builder + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + var pid int + for { + raw, err := os.ReadFile(pidFile) + if err == nil && len(raw) > 0 { + if n, perr := strconv.Atoi(strings.TrimSpace(string(raw))); perr == nil { + pid = n + break + } + } + if time.Now().After(deadline) { + t.Fatal("the wrapper never reported its pid") + } + time.Sleep(10 * time.Millisecond) + } + if pid != cmd.Process.Pid { + t.Fatalf("the wrapper's $$ (%d) is not the child pid (%d), so exec would not preserve it", pid, cmd.Process.Pid) + } + plant(pid) + if err := os.WriteFile(goFile, nil, 0o644); err != nil { + t.Fatal(err) + } + err := cmd.Wait() + code := 0 + if err != nil { + if e, ok := err.(*exec.ExitError); ok { + code = e.ExitCode() + } else { + t.Fatalf("running the bootstrap: %v (output %s)", err, out.String()) + } + } + return out.String(), code +} + +// TestBootstrapAttestationTempIgnoresAPlantedSymlink: the first cut wrote the +// attestation to `$HOME/.abcd/.cache-attestation.$$` with `>` and a chmod by +// name, both of which follow a symlink pre-planted at that predictable name — +// so a same-UID writer could have the run write the record's bytes and mode +// onto a file of their choosing, and then rename the symlink itself into place +// as the attestation. The temp must be created by mktemp (fresh, exclusive, +// unpredictable), so the planted link is never opened: its target keeps its +// bytes and mode, and the attestation that lands is a regular file. +func TestBootstrapAttestationTempIgnoresAPlantedSymlink(t *testing.T) { + root := bootstrapRoot(t) + data := t.TempDir() + home := t.TempDir() + cached := []byte("#!/bin/sh\n# cached artefact\nexit 0\n") + seedBootstrapCache(t, data, bootstrapTag, cached) + fx := bootstrapServer(t, cached, bootstrapManifest(cached)) + victim := filepath.Join(t.TempDir(), "victim") + const victimBody = "the victim's own bytes" + if err := os.WriteFile(victim, []byte(victimBody), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(home, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + + out, code := runBootstrapWithDataHomeAtPid(t, root, data, home, fx, func(pid int) { + planted := filepath.Join(home, ".abcd", ".cache-attestation."+strconv.Itoa(pid)) + if err := os.Symlink(victim, planted); err != nil { + t.Fatal(err) + } + }) + if code != 0 { + t.Fatalf("the authenticated cache hit must install, got %d (output %q)", code, out) + } + if got := mustReadFile(t, victim); got != victimBody { + t.Errorf("the planted symlink's target was written through: got %q, want %q", got, victimBody) + } + if fi, err := os.Stat(victim); err != nil || fi.Mode().Perm() != 0o644 { + t.Errorf("the planted symlink's target had its mode changed: %v (%v)", fi, err) + } + fi, err := os.Lstat(homeCacheAttestation(home)) + if err != nil { + t.Fatalf("the attestation must still be written: %v (output %q)", err, out) + } + if !fi.Mode().IsRegular() { + t.Fatalf("the attestation must be a regular file, not the planted symlink renamed into place: %v", fi.Mode()) + } + if got := attestationValues(t, home); got["binary_sha256"] != sha256Hex(cached) || got["data_dir"] != data { + t.Errorf("the attestation must carry the authenticated cache; got %v", got) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("the attestation must be mode 0600, got %v", fi.Mode().Perm()) + } +} + +// TestBootstrapAttestationStripsControlCharactersFromDataDir: the data dir is +// an environment value, and the record is line-oriented. A value carrying a +// newline would write extra key=value lines into a record whose Go reader +// parses last-wins — a forged binary_sha256 line after the real one wins — so +// the class meta_field strips on read is stripped before the write, and the +// record holds exactly its four declared fields whatever the variable held. +func TestBootstrapAttestationStripsControlCharactersFromDataDir(t *testing.T) { + root := bootstrapRoot(t) + home := t.TempDir() + // A directory whose NAME carries a newline and a forged record line after + // it: legal on POSIX, and exactly the injection shape. + data := filepath.Join(t.TempDir(), "data\nbinary_sha256="+strings.Repeat("f", 64)) + if err := os.MkdirAll(data, 0o755); err != nil { + t.Fatal(err) + } + cached := []byte("#!/bin/sh\n# cached artefact\nexit 0\n") + seedBootstrapCache(t, data, bootstrapTag, cached) + fx := bootstrapServer(t, cached, bootstrapManifest(cached)) + + out, code := runBootstrapWithDataHome(t, root, data, home, fx, "") + if code != 0 { + t.Fatalf("the authenticated cache hit must install, got %d (output %q)", code, out) + } + raw := strings.TrimSpace(mustReadFile(t, homeCacheAttestation(home))) + lines := strings.Split(raw, "\n") + if len(lines) != 4 { + t.Fatalf("a data dir carrying a newline must not inject lines: got %d lines %q", len(lines), raw) + } + for i, key := range []string{"data_dir", "binary_sha256", "cache_trust", "attested_at"} { + if !strings.HasPrefix(lines[i], key+"=") { + t.Errorf("line %d must be %s=…, got %q", i+1, key, lines[i]) + } + } + if strings.ContainsAny(lines[0], "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x7f\t") { + t.Errorf("the data_dir value must carry no control character; got %q", lines[0]) + } + if got := attestationValues(t, home); got["binary_sha256"] != sha256Hex(cached) { + t.Errorf("the forged line must not reach the parsed hash; got %q", got["binary_sha256"]) + } +} diff --git a/internal/surface/cli/hooks_install_shapes_test.go b/internal/surface/cli/hooks_install_shapes_test.go index db90fe73..e7f70d3f 100644 --- a/internal/surface/cli/hooks_install_shapes_test.go +++ b/internal/surface/cli/hooks_install_shapes_test.go @@ -70,6 +70,33 @@ func seedInstallShapeCache(t *testing.T, body []byte) string { return data } +// attestInstallShapeCache writes, under the sandbox home, the attestation the +// bootstrap leaves after authenticating the cache against the published +// release manifest: `ahoy install` promotes a cache into the owned copy only +// when that record names the data dir and the hash its binary-meta carries +// (GHSA-4q78-ccfv-f374). The hash is read off the seeded record rather than +// recomputed, so the fixture attests exactly what it seeded. +func attestInstallShapeCache(t *testing.T, home, data string) { + t.Helper() + meta, err := os.ReadFile(filepath.Join(data, "cache", "binary-meta")) + if err != nil { + return // no cache seeded: nothing to attest, and install degrades as documented + } + sha := "" + for _, line := range strings.Split(string(meta), "\n") { + if v, ok := strings.CutPrefix(line, "binary_sha256="); ok { + sha = v + } + } + if err := os.MkdirAll(filepath.Join(home, ".abcd"), 0o700); err != nil { + t.Fatal(err) + } + body := "data_dir=" + data + "\nbinary_sha256=" + sha + "\ncache_trust=manifest\nattested_at=2026-09-15T00:00:00Z\n" + if err := os.WriteFile(filepath.Join(home, ".abcd", "cache-attestation"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + // sandboxHome returns a HOME for a test that hands it to a process abcd does not // control, and it is deliberately NOT t.TempDir(). // @@ -109,6 +136,9 @@ func runAhoyInstall(t *testing.T, dev bool, dataDir string) (home, binDir string t.Setenv("ABCD_PLUGIN_ROOT", root) t.Setenv("CLAUDE_PLUGIN_ROOT", "") t.Setenv("CLAUDE_PLUGIN_DATA", dataDir) + if dataDir != "" { + attestInstallShapeCache(t, home, dataDir) + } t.Setenv("ABCD_BIN_TARGET", "") t.Setenv("PATH", binDir+string(os.PathListSeparator)+"/usr/bin:/bin")