Development record
The cache reaches PATH only through a home-scoped attestation the environment cannot write
Context
adr-46 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 onahoy 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:
- 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. - Every promotion of a persisted artefact re-verifies against its recorded
binary_sha256and 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. - 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.txtbefore 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. - 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.
- SessionEnd performs no network work.
- 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 ofpath-entryin the same key=value form:data_dir, the manifest-authenticatedbinary_sha256,cache_trust(the bootstrap's own vocabulary; onlymanifestis ever written), andattested_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 installpromotes a cache into the owned PATH copy only when three things agree — the attestation names the directory being promoted from, the co-locatedbinary-metacarries 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-dirstamp. 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
abcdoff 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.legacygap healed to a release copy on the nextahoy 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-entrywith the attestation's fields instead of a sibling record. Rejected:path-entryrecords 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 removespath-entry. The~/.abcd/trusted-rootsrecord follows the same sibling idiom for the same reason.
Consequences
hooks/bootstrap.shwrites the attestation in step 9b, after the plugin root's re-hashed install and the.data-dirstamp, and only when the run established manifest trust; the notice reports an attestation it could not write.internal/core/ahoy/cache_attestation.gois the one reader, through the guarded bounded readpath-entryuses;cacheBindingProblemis the one predicate, consumed bycacheSourceReadyfor 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.
Frontmatter
| id | adr-2609151706587280 |
|---|---|
| type | adr |
| status | accepted |
| date | 2026-09-15 |
.abcd/development/decisions/adrs/2609151706587280-the-cache-reaches-path-only-through-a.md
open on GitHub ↗ · commit history ↗
References5
- adr-38Implicit checks are disk-only — the network answers only an explicit askrelated
- adr-46Persisting the hook binary never weakens the verification posture — every promotion re-verifies, and SessionEnd performs no network workrelated
- adr-46Persisting the hook binary never weakens the verification posture — every promotion re-verifies, and SessionEnd performs no network worksupersedes
- itd-105A fresh plugin install just works: the hook binary survives install and every updaterelated
- itd-132The hook binary survives plugin updatesrelated
Referenced by1
- adr-2610241057124151abcd installs the tools it needs from downloads pinned in its own releaserelated
mentions6
- adr-2609292012006845Now, Next and Later list an intent in a lane under Now only; phases stay retired
- adr-2611011813195749abcd never runs a foreign executable under its own name, and no code that writes records is loaded at run time
- dcn-26091500000000102026-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.
- iss-2609012039102770GHSA-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.
- iss-2610241228359748Which abcd runs is confusing because several installs coexist and nothing says which one wins: the plugin can be installed at user scope and at project scope at once, each with its own plugin-root binary, and there is also a PATH binary (ahoy install) and, in abcd's own checkout, a source build (go run). Observed 2026-10-24: the person updated the plugin to v0.15.1, but the update landed as a new project-scope install for this repository while the user-scope install stayed at 0.13.3, and the session went on loading the user-scope copy (command pages still named .../abcd/0.13.3/abcd, its hooks bootstrapped there at 14:23); the 0.15.1 directory had never bootstrapped and held no binary; and the binary inside the 0.13.3 directory reported v0.14.1, so even the directory name did not say which version ran. Nothing in the board, ahoy or the command pages surfaced any of this. The person wants it unified (for example: does a repository-scope plugin win over a user-scope one, and how does the CLI on PATH relate to either?). Earlier rulings each settled one rung on its own (dcn-2609080000000001 the owned-only PATH rung for the hook shims, adr-2609151706587280 the cache-to-PATH attestation, iss-2610070547057816 dogfooding a source build in abcd's own checkout) without addressing the underlying problem: no single, stated precedence across plugin scopes, the plugin-root binary, the PATH binary and a source build.
- spc-35hook-binary-to-persistent-data-dir