Skip to content

hook-utils: a repo env block can disable every hook by starving the stdin read timeout #1883

Description

@kyle-sexton

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

  • A value below the documented floor is refused and the default applies, on the same reasoning
    the existing exact-zero rejection uses.
  • A regression test that fails against the current resolver.
  • The fix is synced across every hook-utils.sh materialization.

Related

Refs #1784
Refs #1865

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions