Found by an independent fresh-context audit of #1865 (the #1784 P1 fix), which verified the
lane-stop gate's own config channel and then found the same class of hole one key over.
The defect
lib/hook-utils.sh resolves the stdin idle bound from the bare environment:
hook::resolve_read_timeout() {
local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}"
if [[ "$t" != "2" ]]; then
local probe
probe=$(read -r -t "$t" discard </dev/null 2>&1)
if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then
t=2
fi
fi
printf '%s' "$t"
}
The guard rejects 0 and 0.0 on the stated grounds that they are "a silent disable" — but it
accepts any positive value below the read's practical resolution, which is the same silent disable.
stdin_read_timeout is not declared in most plugins' userConfig (only actionlint, claude-ops
and guardrails declare it), so for the rest it is permanently an unconfigured key — and by
docs/conventions/hook-config-delivery fact 4, a watched repository's own .claude/settings.json
env block then owns it outright.
Impact
Every hook built on hook::buffer_stdin — the whole fleet, including PreToolUse guards — can be
disengaged by a watched repository setting one variable in its own settings file. The hook exits
before it reads any config, so a hook that would otherwise surface a not-honored notice stays
silent too.
Measured against the lane-stop gate on the #1865 branch, with the gate enabled through its
trusted channel and stdin pre-filled (the case most favourable to the gate):
CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT |
runs |
blocked |
disengaged |
0.0000001 |
5 |
0 |
5 |
0.000001 |
5 |
0 |
5 |
0.00001 |
5 |
5 |
0 |
This is #1784's P1 through a different key: #1865 moved the gate's values onto trusted channels,
but the evaluate-at-all decision is still reachable on channel B.
Why it is filed separately from #1784
It is pre-existing on main, it is not the enable flag #1784 names, and it is far broader than one
hook. lib/hook-utils.sh is materialized into 16 plugin copies behind the hook-utils-sync gate,
so the fix is a fleet-wide change that deserves its own review rather than riding a lane-stop-gate
PR.
Suggested fix
A floor in hook::resolve_read_timeout — the inconsistency is internal to that function, which
already decided that a disabling value must be refused. Reject anything below a documented minimum
rather than only exact zero, then sync the 16 copies. Worth also re-reading whether the key belongs
on channel B at all given what it can do.
Acceptance criteria
Related
Refs #1784
Refs #1865
Found by an independent fresh-context audit of #1865 (the #1784 P1 fix), which verified the
lane-stop gate's own config channel and then found the same class of hole one key over.
The defect
lib/hook-utils.shresolves the stdin idle bound from the bare environment:The guard rejects
0and0.0on the stated grounds that they are "a silent disable" — but itaccepts any positive value below the read's practical resolution, which is the same silent disable.
stdin_read_timeoutis not declared in most plugins'userConfig(onlyactionlint,claude-opsand
guardrailsdeclare it), so for the rest it is permanently an unconfigured key — and bydocs/conventions/hook-config-deliveryfact 4, a watched repository's own.claude/settings.jsonenvblock then owns it outright.Impact
Every hook built on
hook::buffer_stdin— the whole fleet, including PreToolUse guards — can bedisengaged by a watched repository setting one variable in its own settings file. The hook exits
before it reads any config, so a hook that would otherwise surface a not-honored notice stays
silent too.
Measured against the lane-stop gate on the #1865 branch, with the gate enabled through its
trusted channel and stdin pre-filled (the case most favourable to the gate):
CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT0.00000010.0000010.00001This is #1784's P1 through a different key: #1865 moved the gate's values onto trusted channels,
but the evaluate-at-all decision is still reachable on channel B.
Why it is filed separately from #1784
It is pre-existing on
main, it is not the enable flag #1784 names, and it is far broader than onehook.
lib/hook-utils.shis materialized into 16 plugin copies behind thehook-utils-syncgate,so the fix is a fleet-wide change that deserves its own review rather than riding a lane-stop-gate
PR.
Suggested fix
A floor in
hook::resolve_read_timeout— the inconsistency is internal to that function, whichalready decided that a disabling value must be refused. Reject anything below a documented minimum
rather than only exact zero, then sync the 16 copies. Worth also re-reading whether the key belongs
on channel B at all given what it can do.
Acceptance criteria
the existing exact-zero rejection uses.
hook-utils.shmaterialization.Related
Refs #1784
Refs #1865