Development record
Sub-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.
Sub-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.
Grounds
- pursued: the documented install never puts the binary in a relative location, inside a checkout, or in a world-writable directory, so refusing those three shapes narrows the rung without touching the contract; requiring every PATH binary to be vouched for by ~/.abcd/path-entry would break the documented one-liner rescue and stays open on the parent record GHSA-gx3m-3224-qqcv (iss-2609012039107700)
Correction, 2026-09-09
Two claims in this record were true when it was written and are false at this tip. Both concern the parent record's rung, not this record's own fix, which still stands exactly as the resolution describes it.
The resolution's closing clause — "TestBinaryHooksFallBackToAPathBinary still
pins the documented rescue through an ordinary directory" — names a test that no
longer exists. c637a734 ("fix: the hook shims run only the abcd this machine
recorded") deleted it and put three tests in its place, which are what pins the
rung now: TestBinaryHooksRunAnOwnedPathBinary (the documented rescue, through
a directory ~/.abcd/path-entry names), TestBinaryHooksRefuseAnUnrecordedPathBinary
(the advisory's own reproduction) and TestBinaryHooksRefuseAPathBinaryTheRecordDoesNotName,
all in internal/surface/cli/hooks_selfprovision_test.go. The three shapes this
record closed — TestBinaryHooksRefuseAPathBinaryInsideTheWorkingTree,
TestBinaryHooksRefuseARelativePathEntry and
TestBinaryHooksRefuseAWorldWritablePathBinary — are untouched by that commit
and still pin what this record fixed.
The ## Grounds bullet says requiring ~/.abcd/path-entry to vouch for a PATH
binary "would break the documented one-liner rescue and stays open on the parent
record". Neither half holds. The parent record, iss-2609012039107700, was
resolved by that same commit under its option A, and the rescue was not broken
but rewritten: the install one-liners in README.md and both forms in
docs/how-to/install.md now write path= and binary_sha256= into
~/.abcd/path-entry, so a binary the one-liner installs is a binary the record
names. The cost of that is stated on the parent, which is why it is breaking
rather than fix: a PATH copy installed by an earlier one-liner carries no
record and stops being accepted until the one-liner is re-run.
Nothing here changes this record's disposition. Its own narrowing — absolute resolution, outside the shim's working directory, not world-writable — is the first half of the rung the parent's ownership check now sits behind.
Frontmatter
| id | iss-2609012039117381 |
|---|---|
| type | issue |
| lifecycle | resolved |
| severity | major |
.abcd/work/issues/resolved/iss-2609012039117381-hook-shim-path-rung-accepts-abcd-inside.md
open on GitHub ↗ · commit history ↗
mentions3
- 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-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-2609261034583909Eight resolved records name, in their resolution notes, a test no longer in the tree; each was true when written, and RS006 reads only records entering a terminal folder, so none is refused, but a reader following the note finds nothing. Verified at b7646ff1, with the commit that removed each test: iss-184 names TestTokenizeRejectsUnterminatedHeredoc (16d50feb renamed it TestTokenizeFlagsUnterminatedHeredoc and changed the behaviour: an unterminated heredoc is a verdict, not ErrUnparsableCommand, so the note's part (2) is stale too); iss-2608301808193750 names TestIsAbsentValueIsASpellingTestNotANullTest (3eb4b549 replaced the spelling predicate with a class-based one, TestAbsenceIsDecidedByClassNotBySpelling, so the note's 'deliberately NOT widened' is superseded); iss-2608311632382737 names TestPreflightRunsBothEvalLanes (the test is TestPreflightRunsEveryTaggedEvalLane, which derives the lanes from the Makefile; the one clean rename of the eight); iss-2608311632439831 names TestComparativeRefusesToAssemble (3b62c967 made the comparative position assemble from the widening run's items, so the refusal the note describes no longer exists); iss-2609012039117381 names TestBinaryHooksFallBackToAPathBinary and iss-275 names TestGuardShimFallsBackToPATH (c637a734 removed the PATH rung those tests pinned: the shims run only the abcd the machine recorded); iss-354 names TestWorkflowGoVersionsMatchSubstitutions (removed in df377ee6, replaced by TestEverySetupGoResolvesTheToolchainFromGoMod under iss-2609090951291799); iss-2609090951291799 names TestWorkflowGoVersionsMatchSubstitutions only to say it was replaced, which stays accurate. Six of the eight need an amendment note stating what superseded the test and the claim, not a rename, so the set is not a mechanical fix.