Development record
Persisting the hook binary never weakens the verification posture — every promotion re-verifies, and SessionEnd performs no network work
Context
itd-105 (spc-21) bought the hook binary's provenance dearly: same-origin
checksums.txt pinned to one resolved release, HTTPS-only transport on every
fetch including redirects, -q-first curl with the proxy and CA environment
scrubbed before any request, and — after two adversarial-review defeats — no
environment seam of any kind for the fetch origins. Two properties bounded the
residual risk: the artefact lived in the harness's commit-stamped plugin directory,
which every plugin update replaced, so a tampered binary survived at most one
update cycle; and every trusted byte had just come off the verified download
path.
itd-132 (spc-35) moves the artefact into the harness's persistent per-plugin
data directory, where it survives every update and is deleted only on full
uninstall. Persistence is the point — it is what ends the re-download and the
missing-binary window on every update — but it changes the threat shape twice
over: the at-rest tampering window extends from one update cycle to unbounded,
and the artefact now travels again after fetch time, promoted from the cache
into fresh plugin roots, onto PATH, and (at migration) from a plugin root into
the cache. Each promotion is a fresh opportunity for the trusted path to pick
up bytes the verification never saw. The trust rule outlives any one feature
— it binds the bootstrap, ahoy install, abcd update, and every later verb
that moves the artefact — so per the itd-84 SPLIT routing it lives here rather
than in the intent's prose (iss-2608210934566226).
Decision
- 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 are invariants of fetching the binary, not of the directory it lands in. Relocating the artefact's home changes none of them. - 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 one-way migration seed (plugin root → cache) each re-hash the bytes being moved against the recorded provenance before anything lands in a trusted location; a mismatch installs nothing and leaves the mismatching artefact in place as evidence. This catches corruption and substitution in transit between two trusted points — a truncated copy, a half-written rename. - The cache's own trust is established against the release's published
manifest, not against its co-located record. The recorded
binary_sha256lives in the same data directory as the artefact it vouches for and is equally writable by any same-UID process; re-hashing the artefact against that record proves only that the file matches its own neighbour, which an attacker who writes both satisfies trivially. So before the cache is preferred over a fresh download, its recorded digest is checked against the publishedchecksums.txtfor the resolved release — the same same-origin, HTTPS-pinned manifest fetch spc-21 already trusts, and the tag is already being resolved on the cache path, so this is one extra small GET, not a new network posture. Online, this makes the cache tamper-evident against everything short of control of the release origin itself. Offline, no published manifest is reachable, so the cache is trusted at corruption-evidence only (rule 2): this is the deliberate availability trade — a machine that cannot reach the origin still boots its guard from a cache that has not been corrupted in place — and it is stated here rather than hidden, because the whole point of this ADR is that the posture is never silently weaker than it claims. The success notice says which trust it rests on: verified against the published manifest, or provisioned from an unauthenticated cache while offline. - Ownership is recorded provenance, never content-guessing. A persisted
artefact or PATH entry is abcd's to refresh or remove only when a recorded
hash vouches for its exact bytes; anything that stopped matching is
foreign and is never replaced, refreshed, or deleted. The provenance record
must be reachable wherever the verb that consults it runs —
ahoy install,ahoy uninstall, andabcd updaterun from a terminal, whereCLAUDE_PLUGIN_DATAis not exported, so a record readable only from a hook process cannot establish ownership at all and silently reclassifies abcd's own binary as foreign. The record lives in a home-scoped, abcd-owned location derivable without the harness environment. - SessionEnd performs no network work. The exit path never bootstraps, fetches, or refreshes anything — a download belongs to session start and the explicit verbs, never to the moment the user is leaving (iss-2608210934566223). This clause becomes true in the tree only when that fix lands; the brief invariant that mirrors it is accepted together with the SessionEnd hook change, never ahead of it.
Alternatives Considered
- Trust the persistent location because the fetch was verified. Rejected: that inference held (weakly) when the harness deleted the directory every update; a persistent directory extends the at-rest window without bound, so the recorded hash must be re-checked at every point where the artefact is promoted into a place something executes from.
- Trust the cache on its co-located record alone (re-hash artefact against
the neighbouring
binary-meta). Rejected: this was the first shape, and adversarial review (2026-08-21, iss-2608210934566228) demonstrated it end-to-end — an attacker who writes both the artefact and its record passes the check, and because the cache is now preferred over the network across every update, the implant persists rather than healing at the next update as it did under spc-21. The co-located record proves corruption, never tamper; authenticating the cache against the published manifest (decision 3) is what closes it online, and the offline residual is named rather than papered over. - Re-verify on every execution (hash in the hook fast path). Rejected: the steady-state contract is one file test and no network (spc-21, kept by spc-35); hashing ~11 MB on every hook event buys little — the promotion points are where bytes move into trusted locations — and would be paid on every prompt.
- Relocate execution into the data dir instead of copying per root. Rejected in adversarial review (2026-08-21, recorded in itd-132): one shared mutable binary across roots creates cross-root skew and locking semantics, collides with the dev shim, and widens the at-rest tampering blast radius to every session at once.
Consequences
- spc-35's bootstrap,
ahoy install's owned PATH copy, and the migration seed each carry an explicit re-verify step; the corrupt-cache refusal is pinned by tests at every promotion point, and the cache-tamper case (a poisoned artefact whose co-located record matches it) is pinned by a test that rewrites artefact and record together and asserts the published manifest check rejects it online. abcd updateremains the one verb that legitimately changes the PATH copy's bytes, and it re-stamps the recorded digest only after proving the new content against the release's own checksums.- The corresponding brief invariant is invariant 12 in
02-constraints/03-invariants.md. Its cache-authentication and promotion clauses are accepted with this record; its SessionEnd clause (decision 5) is accepted only when the SessionEnd hook change (iss-2608210934566223) lands, and the two are sequenced so the invariant is never present-tense-false in the tree. That condition has since been met: the fix landed inbaf0551, is pinned byTestSessionEndNeverBootstraps, and the register asserts the clause. - Any future verb that moves the persisted artefact (a repair verb, a multi-machine sync) inherits rules 2–4 as requirements, not suggestions.
Frontmatter
| id | adr-46 |
|---|---|
| type | adr |
| status | superseded |
| date | 2026-08-21 |
.abcd/development/decisions/adrs/0046-persistence-never-weakens-the.md
open on GitHub ↗ · commit history ↗
References3
Referenced by2
- adr-2609151706587280The cache reaches PATH only through a home-scoped attestation the environment cannot writerelated
- adr-2609151706587280The cache reaches PATH only through a home-scoped attestation the environment cannot writesuperseded by
mentions14
- adr-2609292012006845Now, Next and Later list an intent in a lane under Now only; phases stay retired
- dcn-26082200000000012026-08-22 — Bug-hunt round 7, branch bughunt-b/round-7. Baseline (make preflight
- dcn-26090800000000012026-09-08 — The hook shims' PATH rung is OWNED-ONLY, and the 2026-08-01 itd-105 / spc-21 entry above ("Rejected: a PATH fallback in the hook commands (spc-21 forbids it)" and "A PATH fallback is forbidden by the intent's Decisions and is NOT added") is superseded by this one (maintainer, ruling option A on iss-2609012039107700 / GHSA-gx3m-3224-qqcv, CWE-426). iss-254 added the rung in 20968bdf (v0.5.1) with no entry reversing spc-21, so the ledger and the tree have disagreed since; what is true from here is that a rung EXISTS and is narrow. A hook takes an `abcd` from PATH only when the lookup yields an absolute path, out of a directory that is neither under the shim's working directory nor world-writable (iss-2609012039117381), AND `~/.abcd/path-entry` records that exact path as this machine's installed binary — a `path=` string comparison, no hashing, because adr-46 rejects hashing ~11 MB on every hook event and this rung fires on every prompt and every tool call. What spc-21 was protecting is what this restores: before the ownership check, a plausible `abcd` earlier on PATH than the operator's became the session's rules loader and, through PreToolUse's pass-through of the guard's 0/1/2 verdict, an approver of every shell command it was asked about. A refused binary now degrades to the shim's existing loud line naming it and the reason; for PreToolUse that is UNGUARDED and exit 1, never the exit 0 the harness reads as approval. SessionStart is unchanged — it has no PATH rung and fails closed. THE CONTRACT CHANGES WITH IT: the install one-liners in README.md and docs/how-to/install.md write `path=` and `binary_sha256=` into `~/.abcd/path-entry` (they know both values; `plugin_root` is optional per readPathEntry), because the documented rescue — "the install one-liner restores it, after which the hooks resolve the PATH binary" — would otherwise stop working the moment the rung became owned-only, and a one-liner run after this change re-points the record at `~/.local/bin` even where `ahoy install --bin-dir` had put the binary elsewhere. Rejected: option B, dropping the rung from PreToolUse alone (smallest change, but it leaves the prompt channel and the transcript path exposed to the same foreign binary); option C, accepting the residual as the operator's own PATH (the same shims do resolve sh, find and printf from PATH, but none of those is handed the guard's verdict, and the advisory is a CWE-426 with a working reproduction). Residual, named rather than papered over: the record is home-scoped and equally writable by any same-UID process, so ownership is provenance against a hijacked PATH, not against a compromised account — the same bound adr-46 decision 4 already states, and the reason the check is called ownership and not verification. Pinned by the four inverted cases in internal/surface/cli/hooks_selfprovision_test.go (owned-runs, unrecorded-refused, record-names-another-file-refused, SessionStart-unchanged), with the PreToolUse exit code asserted as exactly 1.
- 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.
- durable-state-lives-where-the-platform-says-it-survivesDurable state lives where the platform says it survives
- iss-2608210934566223The SessionEnd hook attempts the blocking bootstrap download before falling back to a PATH binary. After a plugin update lands a fresh binary-less cache dir, an update-then-quit exits through SessionEnd, which starts the ~11MB download, gets cancelled by the harness during shutdown ('Hook cancelled'), and silently drops that session's transcript capture — observed 2026-08-21, session 8db3dbd6 lost. Exit is the one hook with no time budget: reorder to plugin-root binary, then PATH binary, then loud give-up, and never bootstrap at SessionEnd. The persistent-data-dir relocation shrinks the exposure to true first-install-then-quit, but the ordering fix stands on its own.
- iss-2608210934566226ADR to draft at itd-132 planning (SPLIT-routed trust rule): binary persistence must not weaken the spc-21 verification posture. Whatever shape itd-132 takes (relocate-execution or download-cache+copy), the fetch-time invariants carry over verbatim — same-origin checksums.txt, HTTPS pin with -q-first curl, proxy/CA env unset, no environment seam for origins — AND the at-rest window is addressed: under the cache model a tampered binary survived at most one update cycle; a persistent location extends that window unboundedly unless the recorded binary_sha256 is re-verified at adoption/refresh points. Companion brief invariant: SessionEnd performs no network work. Route: ADR + brief invariant, filed with the itd-132 spec.
- iss-2608210934566228spc-35 BLOCK (security review): the cache promotion 're-verify against recorded binary_sha256' is a corruption check, not a tamper check. The cached artefact and its binary-meta (holding the expected hash) both live in ${CLAUDE_PLUGIN_DATA}/cache, equally same-UID-writable. An attacker writing BOTH — payload plus a meta recording its hash and the current release_tag — passes the promotion gate and gets an unverified binary installed at the guard path and onto PATH, with the success notice asserting 'checksum-verified'. Demonstrated end-to-end vs live v0.6.1. This is exactly adr-46's central claim: on main a poisoned plugin-root binary healed at the next update (harness re-clones a fresh root -> bootstrap re-downloads+verifies from GitHub); here the poisoned cache is preferred over the network indefinitely and survives every update. adr-46, invariant 12, and the notice assert a protection the code does not provide. Fix: in cache mode (which already resolves the latest tag over the network) GET that release's checksums.txt and verify cached_sha against the PUBLISHED digest before use_cache=yes — restores tamper-evidence online, offline stays corruption-only (document honestly). Plus the missing test: TestBootstrapCorruptCacheRefusesLoudly rewrites only the artefact, never the meta, so it never exercises the case that succeeds.
- 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-2609012039107700GHSA-gx3m-3224-qqcv (CWE-426, advisory severity medium): the hook shims' PATH fallback executes an unverified `abcd` as the guard and rules loader. In `hooks/hooks.json`, UserPromptSubmit, PreToolUse and PreCompact carry `if [ -z "$g" ] && command -v abcd; then g=abcd; fi` and SessionEnd `exec abcd hook session-end` when the plugin-root binary is missing; PreToolUse's fence passes exit 0/1/2 through, so a foreign binary exiting 0 approves every shell command. SessionStart has no PATH rung and fails closed. Reproduced at v0.7.0 with the shipped PreToolUse command, a failing bootstrap and a hijack directory first on PATH: the planted binary received `guard hook` plus the tool input and its exit 0 was accepted as armed. The rung was introduced by iss-254 (20968bdf, v0.5.1), is pinned as desired behaviour by `internal/surface/cli/hooks_selfprovision_test.go:TestBinaryHooksFallBackToAPathBinary`, and is a documented contract (docs/how-to/install.md: "the plugin root first, then an abcd on PATH … the install one-liner restores it"; brief 04-surfaces/01-ahoy.md section 9; brief 05-internals/03-configuration.md). The record contradicts itself: the 2026-08-01 decisions [dcn-2608010000000005](../../decisions/dcn-2608010000000005-itd-105-spc-21-implementation-the-plugin.md) and [dcn-2608010000000007](../../decisions/dcn-2608010000000007-itd-105-spc-21-hook-ordering-assumption.md) (itd-105 / spc-21) state "Rejected: a PATH fallback in the hook commands (spc-21 forbids it)" and "A PATH fallback is forbidden by the intent's Decisions and is NOT added"; iss-254 later added it with no DECISIONS entry reversing that.
- iss-2609012111167476Two itd-132 criteria are met with a narrower reading than their words. ac-1 says provisioning a fresh plugin root from the cache involves 'no network fetch'; delivered is no artefact fetch: the online equal-tag path still resolves releases/latest once and fetches checksums.txt to authenticate the cache (adr-46 decision 3), and only the offline path is network-free. ac-3 says a steady-state hook costs 'one file test and no network'; SessionStart always runs bootstrap.sh, whose cache-mode fast path adds a .binary-meta stat, and the cache-mode steady state of a cache-provisioned root has no direct zero-network test. Both are deliberate designs recorded in adr-46; the criteria's wording is what lags. Surfaced by the itd-132 fidelity audit (receipt rcp-acde3e9ce729). Fix is a record edit or a test, not a code change.
- itd-84An Intent Is Decomposed Before It Is Filed, And A Reversal Is Only Ever Flagged
- spc-21a-fresh-plugin-install-just-works-the-hook-binary-survives
- spc-35hook-binary-to-persistent-data-dir