Development record
The 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.
The 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.
Frontmatter
| id | iss-254 |
|---|---|
| type | issue |
| lifecycle | resolved |
| severity | major |
.abcd/work/issues/resolved/iss-254-hooks-single-provisioning-point-and.md
open on GitHub ↗ · commit history ↗
mentions4
- dcn-26081600000000082026-08-16 — the v0.5.1 release gate ruled PROMOTE ×2 at content commit
- 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-253The Cut A §4 manual gate FAILED on assertions 3 and 4 (transcript: .abcd/.work.local/scratch/2026-08-16-s4-transcript.txt, machine: a no-Go Mac with a fresh harness install). After /plugin marketplace add + /plugin install, NO bootstrap line ever appeared at any of three session starts, the plugin root (…/plugins/cache/abcd-marketplace/abcd/<sha>/) contained a full source checkout but no provisioned binary, and every UserPromptSubmit and PreToolUse hook errored 'No such file or directory' for the entire evening — the exact failure family the gate checks for zero of. The command surface functioned only via rung two (the PATH binary the separate one-liner installed), so the no-Go promise is unproven; ahoy install itself reports plugin.root_missing as resolvable:false. Assertions 1, 2 and 5 passed (checksum-verified one-liner with no sudo, abcd v0.5.0; transcript captured; non-interactive install with a clean, path-scrubbed receipt — including a correct abort in a non-repo directory). Per the gate's own rule this BLOCKS Cut B. Environment notes for reproduction: the harness never visibly ran the plugin's SessionStart chain after install (bootstrap.sh emitted nothing — neither success nor refusal, despite loud-staging), and the machine's home dirs are cloud-synced (anomalous stat results, 65535 link counts, multi-minute tree walks in the clone). Detector: the §4 checklist; acceptance: a fresh-machine plugin install yields one bootstrap success line, a provisioned plugin-root binary, and /abcd answering in about a second with no Go.
- 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.