Skip to content

guardrails/block-hook-bypass: guard fails open on its own crash; isolated-session remedy is a documented dead end #3130

Description

@kyle-sexton

From a /plugin-quality:audit run (packet guardrails-block-hook-bypass/20260823T014416Z). 9 findings — 1 Medium-High, 4 Medium, 4 Low. Cross-plugin findings from the same run: #3128. Prior hardening pass: #912.

How this surfaced. The hook fired in a real session and correctly blocked a python3 - <<PY inline-code write to .gitignore; the agent complied via the Edit tool on the first try. It then blocked two more agents during the audit itself, neither of which routed around it. The guard worked, three times. Everything below is about the surrounding design.

Explicitly NOT a finding — recorded so it is not re-raised

The python3 - <<PY vs python3 <<PY distinction is intentional and correct. block-hook-bypass.sh:1311 requires a literal - token; lines 1303–1310 give the rationale (matching a bare trailing interpreter would break legitimate cat s.py | python3 and echo … | python3); both the residual and its cost are regression-pinned. The audit's initial suspicion that this was a one-character bypass was wrong, and line 1311 was independently confirmed twice.

Findings

F4 (Medium-High) — the isolated-session dead end. The block message says: "In an isolated session, Write or Edit may be refused for paths in the main checkout — that remedy is then unavailable." In that state the agent has no compliant path: the shell form is blocked and the recommended tool is refused. grep -rn "isolated session" over the entire installed plugin returns exactly one hit — the message line itself. Nothing in README, CHANGELOG, or any other hook. A documented dead end, and the very next sentence enumerates nine unmonitored write forms.

F5 (Medium) — the guard fails open on its own crash. set -uo pipefail with no -e and no trap: any internal script error falls through to exit 0, or aborts rc 1, which the docs make a non-blocking error. A blocking safety control degrades to allow, with no "guard did not run" signal to agent or operator. Note the posture is split, not uniform — it fails closed on stdin timeout and NUL payload.

F1 (Medium) — the evasion menu lands in the wrong context. The scope note's stated purpose is preventing human over-crediting, but exit-2 stderr is documented to reach Claude only. So the enumeration of nine unmonitored forms is delivered to the blocked agent, while the README already carries it verbatim for the humans it was written for.

F3 (Medium) — channel mismatch. "The switch is not actionable by the blocked agent" is emitted on the agent-only channel. The plugin owns systemMessage helpers and its own doctrine requires both channels; block_bypass() uses neither.

F2 (Medium) — broadest remedy surfaced first. block_hook_bypass_enabled is user-global and durable across every repository. Two strictly narrower levers exist and go unmentioned: the harness's session-scoped --settings, and the plugin's own target-scoped block_hook_bypass_scratch_roots.

F6 (Low-Medium) — the 60 s hooks.json timeout is a documented unconditional fail-open the plugin's docs never acknowledge, though its internal stall handling fails closed.

F7 (Low) — any option value other than the exact string true silently disables the guard, in contrast to the strict-and-loud reasoning already applied in hook::resolve_read_timeout.

F8/F9 (Low) — MCP-provided shell/file-write tools fall outside the Bash|PowerShell matcher and are not named as a residual; no default scratch exemption means ordinary temp writes block out of the box.

Remediations, cheapest first

1–3 are message-text only: give the isolated-session case an actual fallback (or say plainly none exists and what to do); surface --settings and scratch_roots ahead of the global switch; move the operator-facing sentence to systemMessage.
4. Tighten option parsing to strict-and-loud, matching the plugin's own precedent.
5. Document the 60 s timeout fail-open.
6. Name MCP write tools as a known residual.
7. The crash guard (F5) needs its own decision record. Failing closed on a hook sitting on the session's hottest path carries real availability risk — a trade-off, not a bug fix.

Scope escapes — separate work

  • F5 and F7 are almost certainly fleet-wide. Seven sibling hooks share the matcher, hook::check_enabled, and the same set -uo pipefail-without--e shape. Reasoned from shared code, not separately audited per hook.
  • The PowerShell lane was never exercised. The CHANGELOG head records six recent evasions there and the classifier concedes a regex stack "cannot keep up". That lane is materially weaker than the Bash lane and warrants its own dispatch. This audit says nothing about it.

Caveats

No bypass was ever executed — every evasion-surface finding is read from matcher source, deliberately. Remediation 1 depends on systemMessage working alongside exit 2, which the docs support in principle (line 782 + line 905; PreToolUse not in the discard list) but never show worked.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.priority: mediumReal value, no hard deadline; normal backlog flow.

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions