Development record
`~/.abcd/path-entry` decides which binary the hook shims execute. It is read
~/.abcd/path-entry decides which binary the hook shims execute. It is read
without the two checks that the other two home-scoped declaration files require,
and it is the one of the three where the consequence is code execution rather
than a location choice.
What the siblings do
~/.abcd/trusted-roots and ~/.abcd/local-transcript-roots are each read behind
the same three-part guard, on the stated ground that "a declaration this process
does not own, or one anyone can write, is not the caller's word and re-admits
nothing":
- a regular file (
Lstat, not a symlink or device), Perm()&0o022 == 0, so not writable by group or other,ownerUID(path) == os.Getuid().
See internal/core/rules/root.go:300-311 and
internal/core/history/location.go:222-231.
What path-entry does
readPathEntry (internal/core/ahoy/owned_copy.go:99) reads it through
fsutil.ReadGuarded, which enforces O_NOFOLLOW, regular-file and a size cap
and nothing else: no owner check, no permission check. The shell rung in
hooks/hooks.json is weaker still. It carefully establishes that the candidate
binary resolves absolutely, does not live inside the working tree, and sits in a
directory that is not world-writable, and then parses the file that names that
binary with a bare while IFS= read -r ln loop, with no check on the file at
all.
Why it is not covered by the recorded residual
iss-2609012039107700 records the accepted residual as "home-scoped and
same-UID writable", and the v0.8.0 changelog repeats it. That covers an attacker
who already has the user's uid. It does not cover a path-entry that is
group- or other-writable, which lets a different local uid name a binary of
their choosing in a directory they own at mode 755: every shell check passes,
and the hook executes it on every prompt, tool call and compaction. That is the
case Perm()&0o022 exists to refuse, and the two siblings do refuse it.
Shape of the fix
One canonical primitive rather than a fourth bespoke copy: the three-part guard
is currently written twice, in rules and in history. Lift it into fsutil
beside ReadGuarded as the shared declaration-file read, use it from
readPathEntry, and migrate the two existing callers onto it. The shell shims
need the same two facts checked before the file is read: ls -ld gives both the
owner name to compare against id -un and the mode string to test for group and
other write.
Acceptance
- Given a
~/.abcd/path-entrythat is group- or world-writable, when any shim resolves anabcdon PATH, then the record is ignored and the shim takes its existing loud refusal path. - Given a
~/.abcd/path-entryowned by another uid, when the Go reader loads it, then it reports not-ok exactly as a truncated record does. - Given a correctly owned, correctly permissioned record, when either reader loads it, then behaviour is unchanged from v0.8.0.
Grounds
- pursued: we expect ownership and write-exclusivity on the declaration to be the property that matters, because the declaration's whole job is to vouch for a binary and a record anyone can rewrite vouches for nothing; it is shown wrong if the directory holding it is writable by another account, which this does not close
Frontmatter
| id | iss-2609091927085132 |
|---|---|
| type | issue |
| lifecycle | resolved |
| severity | major |
.abcd/work/issues/resolved/iss-2609091927085132-the-path-entry-declaration-is-honoured.md
open on GitHub ↗ · commit history ↗
mentions1
- 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.