Development record
GHSA-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.
GHSA-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 and dcn-2608010000000007 (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.
DESIGN DECISION NEEDED. Options:
A. Owned-only PATH rung (RECOMMENDED; the advisory's suggestion and the iss-377 seam): each shim resolves p=$(command -v abcd) and accepts it only when ~/.abcd/path-entry exists and its path= equals $p (no hashing on the fast path, per adr-46); a foreign PATH abcd degrades to the existing loud one-line refusal (PreToolUse: UNGUARDED, exit 1, never a silent 0). Contract change: the one-line install writes NO path-entry today, so the documented rescue stops working unless both one-liners also write path= and binary_sha256= (they know both; plugin_root is optional per readPathEntry). Touches hooks/hooks.json, hooks_selfprovision_test.go (invert the fallback test into owned-runs / foreign-refused / PreToolUse-says-UNGUARDED / SessionStart-unchanged), docs/how-to/install.md (both one-liners and the hooks paragraph), brief 01-ahoy.md section 9, 03-configuration.md, and a DECISIONS entry closing the spc-21-versus-iss-254 contradiction.
B. Drop the PATH rung from PreToolUse only — the guard is the one consumer where a wrong binary fails open on every command; the prompt-router, precompact and session-end shims keep it. Smallest change; leaves the prompt channel and transcript_path exposed; still needs the DECISIONS entry.
C. Accept the residual: PATH is the operator's environment (the same shims resolve sh, find and printf from PATH; dcn-2608290000000002 (2026-08-29) and dcn-2608010000000008 (2026-08-01) already say so for gitleaks and curl); document it in the brief and adr-46, close won't-fix.
No mechanical hardening keeps the documented one-liner rescue AND refuses a foreign binary, because the one-liner leaves no ownership record — that is the decision. Sibling sites: only the four hooks.json events; commands/*.md invoke abcd through the harness's shell by design; hooks/bootstrap.sh never executes a PATH abcd. The strict hardening that keeps the contract intact (a PATH abcd that resolves inside the working tree or from a world-writable directory is never used) is captured and fixed separately and leaves this record open for the decision. Cross-lane: iss-323 (salvage timeouts) edits the same command strings.
Grounds
- pursued: the advisory's own reproduction — a hijack directory first on PATH holding an abcd that exits 0, no plugin-root binary — must now leave PreToolUse at exit 1 with UNGUARDED and the planted binary unexecuted, while an abcd the record names still runs; what would show it wrong is a field report of the documented one-liner rescue failing after this release, or a PATH binary being accepted that no path-entry names
Frontmatter
| id | iss-2609012039107700 |
|---|---|
| type | issue |
| lifecycle | resolved |
| severity | major |
.abcd/work/issues/resolved/iss-2609012039107700-ghsa-gx3m-hook-shim-path-fallback.md
open on GitHub ↗ · commit history ↗
mentions15
- 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-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-26082900000000022026-08-29 — The gitleaks adapter admits a binary under an allow-shape, not a deny-list (GHSA-fg9r-3f8g-89m6, iss-2608291807456485): absolute path, resolved OUTSIDE the repository both lexically and after symlink resolution, regular, executable — applied identically to a committed `path` and to the PATH lookup result. Refusal is `ErrConfiguredPathRefused`, as loud as not-found, and a refused configured path never falls back to PATH. PATH itself stays trusted as the operator's environment (abcd already resolves git, gh and grep from it; a hostile checkout cannot set it); what the admission rule closes is a PATH entry or config that reaches INTO the checkout. Rejected: a fixed safe search path for the fallback (would break Homebrew/asdf/custom installs for no gain against an operator-level PATH compromise) and executing with an isolated env (the env is the operator's, like PATH — gitleaks does honour GITLEAKS_CONFIG, but a checkout cannot set it; cwd is pinned to the private temp dir instead). Containment routes through `fsutil.PathWithin` with the case-folding predicate AND an `os.SameFile` ancestor walk, because a byte-exact compare admitted a case-variant or NFD respelling of the root on APFS.
- 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-254The hook surface has a single point of provisioning and an unactionable failure mode: only the SessionStart chain runs bootstrap.sh, so when that one hook does not fire (or runs without CLAUDE_PLUGIN_ROOT — its guard then exits 0 silently), no other hook ever attempts provisioning and the session is permanently degraded; meanwhile every UserPromptSubmit/PreToolUse/SessionEnd invocation fails as a raw '/bin/sh: .../abcd: No such file or directory' — noisy on every tool call, actionable on none. Observed live in the iss-253 gate failure: hours of hook errors, zero provisioning attempts outside session start, zero SessionEnd transcript captures. Fix direction: each binary-invoking hook guards on the plugin root, attempts a rate-limited silent bootstrap salvage when the binary is absent (bootstrap already has the concurrency lock, bounded curl timeouts, and a one-stat fast path), and on continued absence emits one plain actionable line instead of the exec error. Detector: a hooks-shape test asserting the salvage-and-fallback pattern on every binary-invoking hook, plus an executed-shell fixture test; acceptance: with an empty plugin root and a working bootstrap fixture, the prompt-router hook provisions and runs; with a failing bootstrap it prints one actionable line and exits non-blocking.
- 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-377Demote hooks/bootstrap.sh to a cold-start trampoline once abcd update ships (itd-130 follow-on rung). Trigger: itd-130 shipped. When any abcd binary is resolvable, the hook delegates provisioning to the Go core (abcd update); the raw curl fetch survives only for the no-binary-anywhere cold start — the one state a Go updater cannot serve. Seam hardening carried from itd-130's Decisions: delegate only to a binary passing an ownership check, keep the raw fetch as the fallback when delegation fails, and freeze the release-asset layout as a contract so an old PATH binary provisioning a new plugin root keeps working across cuts. Goal: one canonical downloader instead of three (bootstrap.sh, the README one-liner, the Go verb).
- iss-2609012039117381Sub-finding of GHSA-gx3m-3224-qqcv that does not need the design decision: the hook shims' PATH rung accepts an `abcd` that `command -v` resolves from inside the working tree or from a world-writable directory. With a dot or empty PATH entry — or a PATH entry naming a directory under the checkout — `command -v abcd` in `hooks/hooks.json` (UserPromptSubmit, PreToolUse, PreCompact, SessionEnd) returns a file the checkout controls, so a hostile clone carrying an executable `abcd` becomes the guard and rules loader for a session whose plugin-root binary is missing; a world-writable PATH directory hands the same role to any local user. The documented contract (docs/how-to/install.md: plugin root first, then an abcd on PATH, the one-liner installs into `~/.local/bin`) never places the binary in either location, so refusing both keeps the documented rescue intact. The fix must establish, for all four events, that a PATH abcd whose directory is under the shim's working directory or is world-writable is not executed, that the shim degrades to its existing loud line (PreToolUse: UNGUARDED, exit 1) plus one line saying the PATH binary was ignored and why, and that an abcd in an ordinary PATH directory still serves as the fallback. SessionStart is unchanged (no PATH rung). The full owned-only rung stays with the parent record.
- iss-2609091927085132`~/.abcd/path-entry` decides which binary the hook shims execute. It is read
- iss-2609151703088754A security record names its advisory only in its slug and prose (ghsa-gx3m-..., 'GHSA-gx3m-3224-qqcv (CWE-426...)'), so nothing can match a resolved record to the draft advisory it fixes without reading the text. The publication step the advisory-handling pilot note names as its target (the GHSAs publish after the cut) needs a typed field on the issue record, e.g. advisory: GHSA-xxxx-xxxx-xxxx, admitted by issueschema, written by capture through a flag, carried through resolve and wontfix unchanged, and read by the release workflow to publish or close the advisory. Today GHSA-gx3m-3224-qqcv is fixed in v0.8.0 (record iss-2609012039107700 resolved 2026-09-08) and still an unpublished draft, which is the gap a mechanical match would have closed. Split out of the publication intent at the 2026-09-15 routing so the field can land first on its own.
- iss-2609151847117977The home-scoped cache attestation (GHSA-4q78-ccfv-f374) binds exactly two
- itd-105A fresh plugin install just works: the hook binary survives install and every update
- spc-21a-fresh-plugin-install-just-works-the-hook-binary-survives