Development record
a-fresh-plugin-install-just-works-the-hook-binary-survives
Summary
spc-21 delivers itd-105: a committed POSIX-sh bootstrap, wired as the first
SessionStart hook, that provisions $CLAUDE_PLUGIN_ROOT/abcd from the
latest GitHub release — checksum-verified, atomic, no-op when the binary is
already present — so a fresh marketplace install and every subsequent plugin
update yield a working hook surface with no manual step. The binary is shell
plus wiring plus reporting: no internal/core behaviour changes.
Scope
hooks/bootstrap.sh(new, committed): the self-provisioning script. Needs no abcd binary to run — the binary is exactly what is missing.hooks/hooks.json: theSessionStartevent is ONE entry whose command runs the bootstrap and then the binary-backedprompt-router-reset/session-startcalls in the same shell (see Hook wiring, and the correction recorded there).$CLAUDE_PLUGIN_ROOT/.binary-meta(new, written by the bootstrap): one small key=value file recordingrelease_tag,release_sha(when resolvable),fetched_at, andplugin_sha— the input for skew reporting.internal/core/ahoy/guard_health.go:BinaryReachablealready probespluginBinaryPath; extend the rendered guard-health detail so a missing plugin-root binary names the bootstrap and its recovery step.internal/surface/*session-start rendering: the one-line version-skew notice (below).README.mdplugin section: the contract — the plugin self-provisions its binary; the PATH install remains for terminal CLI use; build-from-source (go build ./cmd/abcd) is the documented full-trust path.- CHANGELOG entry (user-facing behaviour change).
Out of scope per the intent: release pipeline, auto-update policy, signature infrastructure, Windows, one-notice-per-session UX (iss-168 family), core logic changes.
Approach
The bootstrap script
Behaviour, in order:
- Fast path.
[ -x "$CLAUDE_PLUGIN_ROOT/abcd" ] && exit 0. Steady-state sessions pay one file test and no network (intent AC). - Platform gate.
uname -s/uname -mnormalised to{darwin,linux} × {amd64,arm64}. Anything else prints the plain-language unsupported-platform message (naming the supported matrix and the build-from-source path) and exits 0 without changing anything — exit 0 because an unsupported platform is a reported condition, not a hook error to retry every session. - Concurrency lock.
mkdir "$CLAUDE_PLUGIN_ROOT/.bootstrap.lock"as the mutex (mkdir is atomic on POSIX). A second concurrent session that loses the race exits quietly; a stale lock older than 10 minutes is removed and retaken. The lock is removed on every exit path (trap). - Download.
curl -fsSL --max-time 120fetchesreleases/latest/download/abcd-<os>-<arch>andchecksums.txtinto a temp dir under the plugin root (same filesystem, so the final rename is atomic). The latest-release tag is read from curl's effective URL (-w '%{url_effective}'on the redirect);release_shais resolved from the GitHub API when reachable and recorded asunknownotherwise — the meta file never guesses. - Verification. The
checksums.txtline for the platform binary is checked withshasum -a 256 -c(fallbacksha256sum -c). A mismatch or an absent manifest line deletes the temp dir and fails loudly naming the mismatch — a corrupted or unpublished artefact never reaches the binary path (intent AC). Same-origin checksums are the accepted bar per the intent's warrants; the failure message names build-from-source as the full-trust alternative. - Install.
chmod 0755thenmv(rename, same filesystem) onto$CLAUDE_PLUGIN_ROOT/abcd; write.binary-meta; suggest — once, in this success message —abcd ahoy installfor terminal PATH setup (the PATH story stays owned by ahoy's existing symlink logic, per the intent's Decisions). - Failure message. Every failure path emits one plain-language message: what is missing, why it matters (hooks cannot run; shell guard is inactive), and the one command that fixes it (retry online / manual README install / build from source). No raw "No such file or directory" ever reaches the user from this script. Per the intent's Decisions the other hooks keep failing loudly during the window — that is today's behaviour and is deliberately left in place for this slice.
Hook wiring
hooks.json declares ONE SessionStart entry, whose command runs the
bootstrap and then the two binary-backed calls in a single shell:
{"type": "command", "timeout": 240, "command": "… bootstrap.sh …; i=$(cat); … abcd hook session-start; … abcd hook prompt-router-reset"}
Two properties of that chain are load-bearing and easy to lose. The payload is
read once and piped to each call separately. Every hook verb takes its input
with io.ReadAll over the whole of stdin (readHookInput), so two calls sharing
one stdin leave the second reading EOF — hook session-start would fail to
unmarshal and take its silent return nil path, disabling both of its notices
in every session. session-start runs before prompt-router-reset, because
the reset ends with an unconditional "abcd rules: reset session" diagnostic on
stderr, and whichever call runs first owns the only line the transcript renders.
The exit precedence (bootstrap, then session-start, then the reset) is
computed from saved status codes and does not depend on that order.
Correction (2026-08-12, iss-204 / iss-208). This section wired the
bootstrap as the first of THREE sibling SessionStart entries on the warrant
that "ordering within one event's hook list is preserved by the harness, so
the binary-backed session hooks run after the bootstrap in the same event".
That warrant is false, and it was load-bearing: the harness runs every hook
matching an event in parallel (the hooks reference says so verbatim). Both
gated entries raced the ~10.7 MB download, lost, printed "the plugin binary is
not installed", and genuinely did not run — on every fresh install and every
plugin update, since an update lands in a fresh commit-stamped cache directory
with no binary. The three entries are collapsed into the one command above, so
the sequencing is OWNED by the manifest rather than assumed of the harness; the
two gated messages become one; and the bootstrap's own message is emitted
first, so the single line the transcript renders is the success rather than a
missing-binary complaint (iss-208). Nothing else in this spec rests on hook
ordering. Do not restore the ordering warrant.
The UserPromptSubmit/PreToolUse/PreCompact/SessionEnd commands are
unchanged — on the first-ever event before any SessionStart completed they
fail as today (fail-open guard with UNGUARDED warning, per the intent's
guard-window decision).
Version-skew notice
At session start, when .binary-meta exists and plugin_sha (the cache
directory's commit stamp) differs from release_sha, the session-start
surface appends one line: surface commit, binary release tag, and that newer
merged fixes may not yet be in the binary. When release_sha is unknown
no claim is made — visibility never becomes guesswork. The comparison and
rendering live in the existing session-start hook path (surface tier);
.binary-meta parsing is a few lines and carries no core semantics.
Guard health
GuardHealth.BinaryReachable already probes pluginBinaryPath(pluginRoot).
The rendered detail for an unreachable binary is extended to name the
bootstrap (hooks/bootstrap.sh runs at session start; retry online or install per the README) so abcd ahoy answers "why is the guard down and what do I
do" in one place (intent AC).
Testing
- Script tests (Go,
internal/surface/clior a dedicatedhooks/bootstrap_test.gorunner): executebootstrap.shwithCLAUDE_PLUGIN_ROOTpointed at a temp dir and the download URL overridden to anhttptestserver (ABCD_BOOTSTRAP_BASE_URL, test-only override, mirroringABCD_BIN_TARGET's pattern). Cases, each watched fail first: fast path (no server hit when binary exists); happy path installs and is executable; checksum mismatch installs nothing and names the mismatch; absent manifest line refuses; unsupported platform (overridden uname via PATH shim) changes nothing, exits 0, names the matrix; lock contention (pre-created lock) exits quietly; stale lock is broken. - Guard-health test: missing plugin-root binary renders the recovery detail.
- Skew test: meta with differing
plugin_sha/release_sharenders the one-line notice;release_sha=unknownrenders nothing. - CI smoke: the existing smoke job additionally runs
sh hooks/bootstrap.shagainst a fixture server on both OS runners — the fresh-install AC exercised end to end without touching GitHub.
Acceptance-criteria mapping
| Intent AC | Delivered by |
|---|---|
| Fresh install, online → all hooks work | bootstrap steps 1–6 + wiring |
| Update into fresh cache dir heals | same path; nothing persists but what bootstrap re-creates |
| Checksum mismatch / missing manifest → refuse, name it | step 5 |
| Unavailable binary → actionable message, fail-open guard, ahoy reports | step 7 + guard health |
| Unsupported platform → plain statement, no change | step 2 |
| Surface newer than binary → surfaced | .binary-meta + skew notice |
| Present binary → no network, no latency | step 1 |
Frontmatter
| id | spc-21 |
|---|---|
| type | spec |
| lifecycle | closed |
.abcd/development/specs/closed/spc-21-a-fresh-plugin-install-just-works-the.md
open on GitHub ↗ · commit history ↗
References1
- itd-105A fresh plugin install just works: the hook binary survives install and every updateimplements
mentions25
- adr-46Persisting the hook binary never weakens the verification posture — every promotion re-verifies, and SessionEnd performs no network work
- dcn-26080100000000052026-08-01 — itd-105 / spc-21 implementation: the plugin provisions its own binary from a committed POSIX-sh `hooks/bootstrap.sh` wired as the FIRST `SessionStart` hook, per spc-21's Decisions (fast path on an executable binary, darwin/linux × amd64/arm64 gate, atomic `mkdir` lock with a 10-minute stale break, same-origin SHA-256 against the release `checksums.txt`, atomic rename install, `.binary-meta` provenance). Three judgment calls a reviewer should see. (1) The script's TEST-ONLY overrides are TWO env vars, not one: `ABCD_BOOTSTRAP_BASE_URL` (download base, as spc-21 names) plus `ABCD_BOOTSTRAP_API_URL` (release-commit lookup base) — the release-sha resolution hits a different URL shape than the asset download, and folding both behind one variable would have made the fixture server lie about which endpoint was called. Both mirror `ABCD_BIN_TARGET`'s pattern. (2) `release_sha` is read from the commits API (`/commits/<tag>`), NOT the releases API's `target_commitish`: this repo tags annotated (`git tag -a`) and creates the release from an existing tag, so `target_commitish` is the default branch name rather than a commit — a value that would have made the skew notice compare a branch name against a SHA. A value that is not 40 hex is recorded as `unknown` and renders nothing. (3) spc-21's CI-smoke line ("the existing smoke job additionally runs `sh hooks/bootstrap.sh` against a fixture server on both OS runners") is satisfied WITHOUT touching workflow YAML: the script-driving test lives in `internal/surface/cli/bootstrap_test.go`, which `go test ./...` already runs on both legs of ci.yml's ubuntu+macos `check` matrix, and it shells out to the committed script against an `httptest` fixture exactly as the AC describes. A bespoke workflow step would have duplicated that coverage while adding background-process management to CI. Rejected: a PATH fallback in the hook commands (spc-21 forbids it); signature verification (out of scope, same-origin checksums are the accepted bar); guessing `plugin_sha` from `git rev-parse` when the plugin root is not a commit-stamped cache directory (the meta file never guesses).
- dcn-26080100000000062026-08-01 — itd-105 / spc-21, FIX pass after two fresh adversarial pre-PR reviews (one security, one correctness) that converged independently on the same top items. Detector-first throughout; each behavioural fix has a test watched failing before it and passing after. Four claims of the entry above are superseded. (1) `ABCD_BOOTSTRAP_BASE_URL` / `ABCD_BOOTSTRAP_API_URL` were bare `${VAR:-default}` reads, which made them a code-execution primitive rather than a test seam: the binary AND the `checksums.txt` that verifies it are fetched from the same base, so whoever set the variable supplied both the payload and the manifest that "verified" it — verification became vacuous — and the installed binary is then run unattended as the Bash shell guard on every tool call. Both are now honoured for `http://127.0.0.1:*` / `http://localhost:*` only and REFUSED otherwise (refusing beats ignoring: a silently dropped override reads as a passing test), and the production calls pin `--proto =https --proto-redir =https` so no redirect can downgrade the transport or point at `file://`. (2) `release_tag` was scraped from `%{url_effective}` after `-L` on the asset download, which was verified against the real repository to land on `release-assets.githubusercontent.com/<numbers>?<signed query>` — no `/releases/download/<tag>/` segment exists there, so in production the tag was ALWAYS `unknown`, `release_sha` was always `unknown` behind it, and the entire version-skew notice (an intent AC) could never fire. The tag is now resolved BEFORE any download, from `%{redirect_url}` on an unfollowed request to `/releases/latest` (302 -> `/releases/tag/<tag>`), and both the asset and `checksums.txt` are then fetched from the TAG-PINNED path — which also closes the narrower hole that a release cut between the two downloads would have checked a new manifest against an old binary. The fixture was the reason this passed review-blind: it redirected to a conveniently tag-shaped URL, so it now mimics the real CDN shape and serves `checksums.txt` only from the pinned path. Consequence accepted: an unresolvable tag is now a refusal rather than a silent `unknown`, because the alternative is an unpinned pair the checksum step cannot honestly verify. (3) The bootstrap's two messages for a person — the unsupported-platform statement and the one-time `abcd ahoy install` suggestion — went to stdout with exit 0, which on `SessionStart` means model context and nothing the human ever sees; both now go to stderr with exit 2, matching `cli.go`'s existing "non-zero so SessionStart shows it; SessionStart never blocks" convention. This deviates from spc-21's Approach step 2, which specified exit 0 for the unsupported platform: the spec's reason (a reported condition, not a hook fault to retry) is preserved — nothing is changed, nothing is retried — but exit 0 also meant the statement was unreadable by its only audience. (4) `trap cleanup EXIT HUP INT TERM` where `cleanup` returns does not terminate on a signal; POSIX RESUMES the script, verified empirically here, so a SIGTERM mid-run deleted the temp dir and then carried on to report a checksum mismatch that never happened. Now `trap cleanup EXIT` plus `trap 'cleanup; exit 1' HUP INT TERM`, cleanup being idempotent. Smaller, same pass: `.binary-meta` is written through the temp dir and renamed in (a crash mid-write otherwise left a truncated `release_sha` that still parsed) and carries the verified `binary_sha256` so a later check can tell the binary that was verified from one that replaced it; `resolvedSHA` now requires forty lowercase hex rather than merely "not the literal unknown"; the skew notice is non-directional, since comparing two commits establishes that they differ and never which is ahead; the fast path requires a regular file, `[ -x ]` alone being true of a directory too; a run holding the lock sweeps `.bootstrap.tmp.*` orphaned by a SIGKILL (which runs no trap); `hooks/*.sh` is pinned `eol=lf`, a CRLF checkout of this one breaking the script that repairs the hooks rather than merely a hook.
- dcn-26080100000000072026-08-01 — itd-105 / spc-21, hook-ordering assumption DISCLOSED and defensively mitigated rather than verified. spc-21's Approach asserts "ordering within one event's hook list is preserved by the harness, so the binary-backed session hooks run after the bootstrap in the same event". That is an assumption about the harness, and nothing in this repository verifies it; if it is false, a fresh install's first session runs `abcd hook prompt-router-reset` and `abcd hook session-start` before any binary exists, and the intent's AC ("never a raw shell 'No such file or directory'") is missed by the two commands the bootstrap is supposed to protect. A PATH fallback is forbidden by the intent's Decisions and is NOT added. Instead both commands gain an inline existence check in `hooks/hooks.json` — the same shape as the `PreToolUse` guard entry's existing inline wrapper, which turns a bad exit code into a clean message — so if the assumption turns out to be false the failure is at least a plain-language message and a non-blocking exit 2 rather than a raw shell error, with no fallback binary resolution anywhere. This mitigates the SYMPTOM; the assumption itself still wants verifying against the real harness, and the maintainer should treat it as open. In the same file the bootstrap entry gains `"timeout": 240` (seconds): the three curl budgets plus the tag resolution total 180s worst case, which the harness's 60s default would have killed mid-run — which is precisely how the trap bug above and an orphaned temp dir would both have been reached in production. Deferred, deliberately, and bounded: two racers can both proceed when one breaks the other's ten-minute-stale lock, which last-write-wins resolves into a verified binary either way; and the trap is installed just after the lock is taken, leaving a signal window bounded by that same stale-lock recovery. Both are cheaper to state than to fix, and neither can install an unverified binary.
- dcn-26080100000000082026-08-01 — itd-105 / spc-21, THIRD-round fix pass: the environment-driven URL seam in `hooks/bootstrap.sh` is REMOVED rather than patched a third time. Two independent adversarial reviews had already returned BLOCK on two successive allowlist attempts at the same defect — first a bare `${VAR:-default}` read, then a `case http://127.0.0.1:*|http://localhost:*` glob — and both were defeated empirically by fresh reviewers: URL userinfo (`http://127.0.0.1:1@attacker.example`, where everything before the `@` is a username and the real host is the attacker's) walked straight through the glob, and the override path also cleared the `--proto '=https' --proto-redir '=https'` pin, so a redirect could downgrade the transport on top. The pattern was the finding, not the instances: an allowlist over URL syntax is a parser-differential surface, and the thing it guards is a code-execution primitive — the binary AND the `checksums.txt` that verifies it come from the same origin, so whoever names the origin supplies both the payload and the manifest that "verifies" it, and what is installed then runs unattended as the Bash shell guard on every tool call. So `ABCD_BOOTSTRAP_BASE_URL` and `ABCD_BOOTSTRAP_API_URL` are gone; `grep ABCD_BOOTSTRAP hooks/bootstrap.sh` now returns nothing, the script's only environment read at all is `${CLAUDE_PLUGIN_ROOT:-}` (a filesystem destination, never a fetch origin), and the two transport flags are literal on all four curl calls with no variable to clear. This supersedes claim (1) of the fix-pass entry above and the `ABCD_BOOTSTRAP_BASE_URL` mention in spc-21's Testing section, which named a seam that no longer exists. THE TEST SEAM MOVED TO THE TEST SIDE. `internal/surface/cli/bootstrap_test.go` reads the committed script's bytes, replaces the two quoted origin literals with an `httptest` base, writes the result to a temp file, `chmod +x`es it and execs THAT — the shipped bytes are what runs on a user's machine, with no test-only branch anywhere in them. Three properties make the copy trustworthy rather than a fixture that hides the bug (a failure this item already shipped once, when a friendlier fixture concealed that the release tag was always `unknown` in production): the match is the QUOTED, complete assignment value, so a longer origin cannot be prefix-rewritten into a mangled URL; each literal must appear exactly once before and zero times after, or the helper `t.Fatal`s naming the drift; and the happy path additionally asserts the fixture served at least one request, so a substitution that silently missed cannot pass by testing the real GitHub. The fixture speaks TLS (`httptest.NewTLSServer`) because the pin is unconditional, and trust is supplied through `CURL_CA_BUNDLE`/`SSL_CERT_FILE` — curl's own variables, which only ever ADD an issuer curl will accept and can never name a different server, so they are not the class of seam that was removed. Reviewer-facing residue, stated rather than implied: an actor who already controls the hook process's environment can still influence curl through curl's OWN variables (a proxy setting, a CA bundle), which is true of every curl invocation on the machine and is not something a shell script can close — such an actor can also replace `curl` on `PATH`; what is closed is the seam this script itself honoured. Detector-first throughout, each fix reverted in place and watched to fail: the reverted script reproduces the round-2 bypass verbatim (recorded fetch to `http://127.0.0.1:1@abcd-bootstrap.invalid/releases/latest`, no HTTPS pin), and against a live attacker-controlled release server on loopback the pre-fix script took four requests from it and installed its payload while the fixed script took zero and installed the genuine ELF release. Four smaller items in the same pass. (1) `plugin_sha`'s forty-hex gate rests on itd-105's unverified warrant that the harness names each plugin cache directory for its source commit; if that stops holding, every install records `unknown`, the skew notice goes silent permanently, and nothing anywhere says why — so the RAW basename is now recorded as `plugin_root_basename` beside the gated field, never compared and never rendered, purely so the failure is diagnosable from the file rather than only from a comment. Control characters are stripped from it and it is capped at 120 characters, since a directory name may contain a newline (forging a `key=value` line) or enough bytes to push `.binary-meta` past the guarded read and silence the notice by a second route. (2) `mkdir "$lock"` failing was indistinguishable from losing the race and took the same silent `exit 0`; it now refuses loudly when no lock DIRECTORY exists afterwards, because that case is permanent and repeats every session while the race case is transient and correctly quiet. (3) Both message sinks strip control characters from the values they echo, and the skew notice sanitises `release_tag` through `termsafe` like every other untrusted rendered string in this repo — the tag is read out of an HTTP redirect and is the one value in that line with no shape check. (4) `binary_sha256` is recorded and re-checked by nothing; the comment now says so plainly instead of implying an automatic check, because the fast path's whole contract is one file test and adding a SHA-256 recomputation to every session start would trade the AC away for a check nothing yet consumes. Two known asymmetries re-confirmed as disclosed rather than accidental: only the two binary-backed `SessionStart` entries carry the inline existence wrapper — `UserPromptSubmit`, `PreCompact` and `SessionEnd` still invoke the binary bare, which is itd-105's "every hook reports, for now" testing posture and not an oversight; and the unsupported-platform notice's move to exit 2 means it now repeats EVERY session for as long as the platform is unsupported, which is the accepted cost of that same posture (the one-notice-per-session aspiration is iss-168's). Not fixed, noted: the orphan sweep could still delete a live racer's temp directory in the already-accepted >10-minute stale-lock race, and the transcript scanner's `local_username` hard-fails on this script are the same pre-existing false-positive class already firing on `main` for `README.md`.
- dcn-26080100000000092026-08-01 — itd-105 / spc-21, FOURTH round: two fresh independent reviews of the seam-removal fix pass above (one security, one correctness) both returned MERGE, each naming a short list of cheap items rather than a blocker — three rounds of BLOCK was not treated as licence to manufacture a fourth by inflating what these were. All were landed on this branch directly rather than deferred, since none needed a fresh review cycle to verify. (1) The structural regression test guarding the seam removal, `TestBootstrapFetchOriginsAreConstants`, was a DENYLIST — it scanned for `${`, `$ABCD`, `$HTTP`, `$CURL` — so a bypass renamed to any other variable (`$GH_MIRROR` was the reviewer's example) would satisfy it while still redirecting a fetch; reproduced by planting exactly that shape against the committed script and watching the old test pass regardless. Replaced with an ALLOWLIST: every `$NAME`/`${NAME}` reference in the script, after subtracting names the script assigns to itself, must be `CLAUDE_PLUGIN_ROOT` and nothing else — verified this catches the planted `$GH_MIRROR` bypass and passes on the real script. (2) `safe()`'s `tr -d` range stripped ESC and other C0 controls but not CR (`\015`) or DEL (`\177`); a bare CR overprints from column zero, the exact attack class `internal/termsafe`'s own doc comment names, and DEL is the same family. Range widened to `\000-\010\013-\037\177` (keeps only tab and newline, as before); the existing control-character test now plants CR and DEL alongside ESC in the same specimen and watches both survive the pre-fix range before the widened one strips them. (3) `mv -f` onto an existing DIRECTORY at `$binary` moves the downloaded file INTO the directory rather than replacing it — reproduced: the script reported success, exited 2, wrote a truthful-looking `.binary-meta`, and the binary landed at `$binary/abcd-<os>-<arch>` while the hooks stayed broken forever, since the fast path's `[ -f ]` check never becomes true. A guard now refuses before any download when `$binary` exists and is not a regular file; the existing directory-fast-path test was tightened to assert the actual refusal (it had previously accepted a substring of the SUCCESS message as if it proved a refusal happened, which is why the bug had a green test). (4) `plugin_root_basename`'s control-character stripping and 120-byte cap (added in the prior round) had no detector: deleting the sanitisation left the full suite green, and a directory literally named `aa\nrelease_sha=<40 hex>` forged a second `release_sha` line that `skew.go`'s last-wins map parsing would have preferred over the genuine one. Two new cases prove the fix is load-bearing: an embedded-newline specimen asserting `.binary-meta` holds exactly its six declared lines and the genuine value survives, and an oversized-basename specimen asserting the recorded value is exactly 120 bytes. Noted, not landed: the structural allowlist test is still a snapshot of the *shape* the script must hold, not a guarantee against every conceivable future evasion of that shape — it is the cheapest check that catches the two concrete bypasses two independent reviewers actually constructed, not a formal proof.
- dcn-26080100000000102026-08-01 — itd-105 / spc-21, MERGE-GATE fix (concurrent session A): a fresh merge-gate security reviewer found a real gap in that same allowlist test, not in the shipped script. `TestBootstrapFetchOriginsAreConstants` subtracted any name the script "assigns to itself" before checking it against the allowlist, so a SELF-referential seam (`mirror="${mirror:-}"`, then `[ -n "$mirror" ] && repo_url="$mirror"`) read as script-local and the test passed green while a curl shim recorded a fetch to an attacker-named host. Reproduced by planting exactly that shape against the committed script before fixing. The allowlist now taints any name whose own assignment line references itself, on every line where it is assigned, so neither the self-reference nor any later use of the name reads as safe — verified this catches the planted bypass and still passes on the real script. One more merge-gate MINOR landed in the same pass: `mkdir -p "$tmp"` (the temp-download directory) succeeds on a directory that reappears, symlinked, in the window since the preceding `rm -rf`; plain `mkdir` (no `-p`) turns that same-name race into a refusal instead of a curl write through a planted symlink. Both merge-gate reviews (fresh, independent, full-PR-diff) had already returned MERGE before these were found — landed anyway per the merge gate's "fix everything raised" step rather than waiting for a fifth review round, since both are narrow, each has a watched-fail-then-pass test, and neither changes the shipped script's externally observable behaviour except the second (a same-name TOCTOU race becoming a loud refusal, strictly safer).
- dcn-26080100000000112026-08-01 — itd-105 / spc-21, FIFTH round (concurrent session B, landed alongside the entry above): a fresh security review returned BLOCK on a bypass it reproduced, and a fresh correctness review returned MERGE with minor findings; both are closed here on the same branch. (1) BLOCKER — none of `hooks/bootstrap.sh`'s four `curl` calls passed `-q`, so curl loaded its own configuration surface: `$CURL_HOME/.curlrc` (curl >= 7.73, falling back to `$HOME/.curlrc`), where a single `connect-to` or `resolve` line re-points the connection while the URL on the command line still literally reads `https://github.com/…`. The transport pin `--proto '=https' --proto-redir '=https'` therefore still HELD and proved nothing, and because the binary and the `checksums.txt` that verifies it travel the same route, the SHA-256 check was vacuous — the poisoned config supplied both. Reproduced end to end before the fix: a `.curlrc` carrying `connect-to` plus its own `cacert` diverted all five requests to a second local server and installed the forged binary at the guard path, where it would run unattended as the Bash shell guard on every tool call. Fixed by passing `-q` as the FIRST argument of every call (curl honours it in no other position) and by removing, before any fetch, the other names curl reads without being told to: `HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy CURL_HOME` and `CURL_CA_BUNDLE SSL_CERT_FILE SSL_CERT_DIR`. ACCEPTED COST, deliberately: a machine that can only reach the network through a proxy no longer bootstraps automatically — `refuse()` already names the manual-install and build-from-source ways out, and a proxy plus an attacker-chosen CA is precisely the pairing that makes the redirection succeed on TLS, so the seam is not worth keeping for the convenience. (2) MAJOR — the suite could not have caught (1): it only exercised the removed `ABCD_BOOTSTRAP_*` script variables, never curl's own config surface. `TestBootstrapIgnoresCurlsOwnConfigSurface` now runs the fixture-pointed copy with a poisoned `.curlrc` under both `CURL_HOME` and `HOME`, and asserts the published bytes are installed and the forger is contacted zero times; it was watched failing against the pre-fix script in exactly the shape described above. `TestBootstrapFetchOriginsAreConstants` gained the structural half — every `curl -` line must carry `curl -q ` as well as the literal transport pin, and both scrub lines must be present exactly once — verified by deleting a `-q` and shortening the scrub list and watching it fail. The one test-side concession is that `bootstrapFixtureScript` neutralises the CA-scrub line in its throwaway COPY, because `CURL_CA_BUNDLE` is the only way a real curl trusts the self-signed local fixture; that widens what curl will ACCEPT and can never name a different server, the `CURL_HOME`/proxy scrub stays intact in the copy (which is what makes the poisoned-config case meaningful), and the shipped bytes are held by the structural test. (3) MINOR — the `.binary-meta` install had no regular-file guard, so a directory at that path swallowed the record as `.binary-meta/binary-meta` (the same `mv -f` hazard `$binary` already refuses) while the success notice claimed provenance had been recorded. The install itself is genuine there, so this REPORTS rather than refuses: the notice now says the path exists and is not a regular file, and `TestBootstrapReportsAnObstructedBinaryMeta` proves the record is never moved into the obstruction. NOT FIXED, deliberately: the stale-lock double-break race (already disclosed and accepted above), the "120 bytes" comment wording and the `checksums.txt` text-vs-binary-mode parsing convention (cosmetic, not worth the churn at a merge gate), and the hook-ordering assumption (already disclosed, no code change).
- dcn-26080100000000122026-08-01 — itd-105 / spc-21, coordination note: two independently-running instances of the v0.5.0 loop each reviewed PR #184 at its merge gate concurrently, found different real findings (a self-referential allowlist bypass; a curl config-surface bypass), and pushed both fix commits to `v050/itd-105` minutes apart — rebased together here rather than either overwriting the other. Same collision class the state issue's round-9 comment already disclosed for `v050/iss-159`; recorded again per that precedent, no new action taken beyond the rebase.
- dcn-26080100000000132026-08-01 — itd-105 / spc-21, MERGE-GATE fix (sixth round, test-hardening only): a fresh merge-gate security reviewer reproduced a TWO-HOP bypass of the same fetch-origin allowlist test, and this entry also corrects the claim two entries above. That entry said the allowlist "taints any name whose own assignment line references itself", which is exactly what it did — and exactly why it closed only the ONE-hop case, not "the self-referential bypass class" the fix commit's message implied. Two names that reference each OTHER (`repo_url="${GH_MIRROR:-$gh_default}"` then `GH_MIRROR="$repo_url"`) leave neither line self-referential, so the per-line check passed the planted script green while `GH_MIRROR=https://attacker.invalid/x` genuinely redirected the fetch — the reviewer verified that against the real script logic with a curl-argv-recording shim. `TestBootstrapFetchOriginsAreConstants`'s allowlist is now computed by `bootstrapSafeNames`, which propagates taint to a FIXPOINT over the assignment graph instead of scanning each line once: a name is script-local only if every assignment of it draws on constants, on `CLAUDE_PLUGIN_ROOT`, or on names already safe AT THAT POINT in the file, and the pass repeats until no name is newly tainted. What that closes is chains of self- and mutual reference of ANY length — one hop, the reviewer's two, and the three-hop laundering variant are all detector cases now, alongside the reproduction spliced into the committed script's own bytes. What it does NOT claim is completeness: it is a fixpoint over `$NAME`/`${NAME}` references in `name=` assignments, not a shell parser, so a value laundered through a construct that scan does not model (an `eval`, an eval-time expansion, a name assembled at runtime) is outside what it proves. The reference scan deliberately stays order-INSENSITIVE on the usage side, because a shell function body runs long after the line defining it and "used before assigned" is not a defect there. Second finding in the same pass: the curl scan filtered lines on the substring `curl -`, so any invocation whose first argument is not a flag (`curl "$url" -o /dev/null`) was skipped ENTIRELY — it could be missing `-q` or the transport pin and never be looked at, while the call count stayed consistent with itself because the scan was its own counter. curl is matched as a command WORD now (line head, after a shell operator, command substitution, path prefix, or keyword), with mentions — `command -v curl`, the refusal message's prose, `.curlrc` — held out as non-invocations by their own detector cases. Detector-first on both, each pre-fix behaviour restored in place and watched to fail: the two-hop, three-hop and planted-script cases reported nothing under the old taint rule, and five curl shapes (flags after the URL, no flags at all, an absolute path, after `if`, downstream of a pipe) were invisible to the old substring filter. `hooks/bootstrap.sh` is UNCHANGED — the reviewer's finding is that the regression guard was weaker than its own comment claimed, not that the shipped bytes have a live seam. Left alone deliberately, out of scope for this pass and unfixed: `SSLKEYLOGFILE`/`QLOGDIR` are not scrubbed, the lock and temp `mkdir`s take the ambient umask, and every tool the script runs is PATH-resolved (an actor who can write the PATH can replace `curl` outright, which no shell script closes).
- 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.
- iss-168abcd's presence should be visible in the host harness's status line / command line: a managed repo shows an 'abcd-managed' indicator (and possibly guard health) so the user can tell at a glance whether the current session is under abcd management without running a command. Needs a per-host adapter (e.g. a statusline hook) with the usual basics-built-in stance.
- iss-204hooks/hooks.json puts bootstrap.sh and the two binary-backed SessionStart hooks in ONE event group and relies on list order, but the harness runs all matching hooks in PARALLEL — the Claude Code hooks reference states verbatim 'All matching hooks run in parallel.' spc-21 asserts the opposite: 'Ordering within one event's hook list is preserved by the harness, so the binary-backed session hooks run after the bootstrap in the same event.' That warrant is false, and it is load-bearing. On a fresh install the two gated hooks evaluate [ -f "$CLAUDE_PLUGIN_ROOT/abcd" ] while bootstrap.sh is still downloading the ~10.7MB release binary (timeout 240), lose the race, and both print 'the plugin binary is not installed'. Observed on the first manual marketplace install (2026-08-10): both errors printed BEFORE bootstrap's own success notice, which is direct evidence of parallel execution. Consequence is not only noise — 'hook prompt-router-reset' and 'hook session-start' genuinely do not run for that session, and itd-105 AC#1 ('every hook executes successfully — no No such file or directory, no unguarded-shell warning') fails on every fresh install AND every plugin update, since each update lands in a fresh commit-stamped cache directory with no binary. Fix direction: collapse the three entries into ONE SessionStart command that runs bootstrap.sh and then the two binary calls in a single shell, so the sequencing is owned here rather than assumed of the harness.
- iss-208A successful bootstrap install is rendered to the user as a third 'SessionStart:startup hook error', indistinguishable from the two genuine failures above it. hooks/bootstrap.sh's notice() exits 2 deliberately, and its comment gives the sound reason: a SessionStart hook's stdout becomes model context, while only a non-zero exit puts stderr in front of the human. But the harness renders ANY non-zero SessionStart exit as a '<hook name> hook error' notice — the docs confirm exit 2 'renders in the transcript as a <hook name> hook error notice, the same way a non-blocking error does'. So there is no 'notice' channel distinct from 'error', and the checksum-verified happy path is labelled a fault. Observed on the first manual install (2026-08-10): three consecutive hook-error lines, of which the third was the install SUCCEEDING. Fix direction: fold this into the single chained SessionStart entry proposed in iss-204 and lead the visible first line with the success, since the transcript shows only the first line of stderr; the honest-failure posture for the genuinely-no-binary window is unaffected.
- iss-323hook salvage entries invoke bootstrap.sh with no timeout budget below its 180s curl worst case so a slow-link provision is killed and suppressed for ten minutes
- iss-2608210934566221Relocate the hook binary and .binary-meta from the plugin cache dir to the harness persistent data dir (CLAUDE_PLUGIN_DATA, ~/.claude/plugins/data/abcd-abcd-marketplace/). The harness re-clones every update into a fresh commit-stamped cache dir and its docs state extra files there are NOT preserved across updates, so the ~11MB binary is re-fetched on every plugin update by design. The data dir survives updates and is deleted only on full uninstall. Fixes in one move: the per-update re-download, the SessionEnd bootstrap-cancellation window, per-version binary disk copies (orphaned cache dirs are GC'd ~14 days after .orphaned_at), and the PATH-symlink rot captured separately. Reworks the itd-105/spc-21 bootstrap fast path and every pluginBinaryPath consumer.
- 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-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-2609012111150528itd-130's Decisions section records that 'the persistent-data-dir alternative is rejected, not deferred' (grilled 2026-08-20), and one day later itd-132/spc-35 adopted exactly that shape as the download cache with a copied PATH file, superseding spc-21's re-fetch criterion. itd-130's text was never revised, so the shipped record now carries a decision its successor reversed without a typed link between them. Surfaced by the itd-130 fidelity audit (receipt rcp-264f7b144576) as a diverged item. The fix is a record change, not code: itd-132 should carry a typed 'reverses' link to the itd-130 decision, or itd-130's Decisions should note the supersession with the date, and the reversal is a human's to confirm.
- iss-2609012111168716pluginDataDir reads CLAUDE_PLUGIN_DATA from the environment, which the harness exports only to hook processes. The bootstrap's success notice and docs/how-to/install.md direct the user to run '<plugin-root>/abcd ahoy install' from their terminal, where that variable is unset, so ahoy install finds no cache and degrades (loudly) to the spc-21 pinned symlink into the plugin root, the exact shape spc-35 replaced because it dangles after the next plugin update. Every install that follows the documented instruction therefore lands the legacy shape. Surfaced by the itd-132 fidelity audit (receipt rcp-acde3e9ce729) against ac-4. Directions, none adopted: ahoy install resolves the data dir from recorded provisioning metadata (the bootstrap knows the path and could stamp it into .binary-meta or path-entry) rather than the environment; or the notice tells the user to run it via the hook path; or the docs state the degradation.
- iss-2609290448510918The predecessor-store qualifier sweep reaches past the six intents iss-2609261536147903 named: outside drafts/ and those six, 192 citation sites across about forty intents name a spc-N at or below spc-70 that is not the citing intent's own spec, with no '(predecessor store)' on the line. Some are live cross-references (the cold-reading family's intents cite each other's live specs), and some are the predecessor store's (itd-4, itd-6, itd-29, itd-47 and itd-49 describe pre-rebuild work in its terms), so each site needs the same reading against the live spec it collides with; the specs charter's Two spc-N Namespaces section is the rule. Found while sweeping the pattern of iss-2609261536147903 in lane drainDrift3; a census script over intents/{planned,shipped,disciplines} reproduces the count.
- itd-4Nothing You Notice Gets Lost
- itd-132The hook binary survives plugin updates
- spc-35hook-binary-to-persistent-data-dir