Skip to content

guardrails: hooks exit 1 with no diagnostic when they abort, so a blocking guard silently enforces nothing #3528

Description

@kyle-sexton

Summary

guardrails hook scripts have no abort boundary. Every explicit exit in cli-flag-verify.sh and block-windows-drive-tmp.sh is 0 or 2, yet both were recorded exiting 1 with no stderr in a live session. For block-windows-drive-tmp.sh — a PreToolUse blocking guard — exit 1 is a non-blocking error: it enforced nothing, and nobody was told.

Evidence

Reported by claude-ops' unsurfaced-hook-failure detector at session stop:

2 hook failure record(s) in this session's transcript were never surfaced:
  PostToolUse:Write  [Verifying CLI flags...]                  (1x; completed non-zero exit; exit 1; last stderr: (none))
  PreToolUse:Bash    [Checking for Windows drive-root /tmp writes...] (1x; completed non-zero exit; exit 1; last stderr: (none))

Both hooks are plugins/guardrails/hooks/, registered in that plugin's hooks.json. Installed version 0.29.22.

Neither script can produce exit 1 from its own logic. Every explicit exit:

cli-flag-verify.sh:        exit 0 (x7), and a final `exit 0` at line 418
block-windows-drive-tmp.sh: exit 0 (x5), exit 2 (x4), and a final `exit 0` at line 329

Both use set -uo pipefail — no set -e — so an internal command failure does not itself terminate the script.

Reproduction attempted, not achieved

I could not reproduce exit 1 on demand. Both hooks behaved correctly across the payloads I tried (own script file, a markdown file, a nonexistent path, an empty file_path, a malformed payload with no tool_input, and empty stdin; for the Bash guard also a benign command, a /tmp write, and a D:/tmp write):

cli-flag-verify.sh          all payloads -> rc=0, no stderr
block-windows-drive-tmp.sh  benign       -> rc=0
                            /tmp write   -> rc=2 with the correct 385-byte diagnostic
                            D:/tmp write -> rc=2 with the correct 385-byte diagnostic
                            malformed    -> rc=0

So the trigger is intermittent and most likely environmental. This host shows severe process-creation cost (0.3–0.9 s per spawn; see #3508), and a spawn-time failure is a plausible source of a generic exit 1 with no script output. That does not make the report less actionable — see below.

Why this is a defect regardless of the trigger

The root cause of the exit code may be environmental, but the silence is a design gap in the hooks. A blocking guard has three possible outcomes and currently conflates two of them:

outcome intended signal what actually happened
allow exit 0 exit 0
block exit 2 + stderr exit 2 + stderr
could not run (nothing defined) exit 1, no stderr, enforcement silently skipped

There is no trap on ERR or EXIT, so any abort between source hook-utils.sh (line 32/36) and the final exit 0 exits with whatever status the shell last had and writes nothing. From the harness's point of view that is indistinguishable from an environmental kill, and PreToolUse treats it as non-blocking and proceeds.

This is the same failure class ADR 0004 documents for killed hooks, and the same one disk-hygiene's destructive_guard.py closes explicitly with a fail-closed boundary that converts any unexpected exception into a deliberate exit 2 plus a diagnostic. The guardrails hooks have no equivalent.

Suggested fix

For every guardrails hook, and especially the PreToolUse blocking ones:

  1. Add an EXIT trap that fires when the script exits with a status it did not choose, and have it emit a one-line diagnostic to stderr naming the hook and the status. A guard that could not run should say so.
  2. Decide the policy for that case deliberately per hook — fail-open with a visible diagnostic, or fail-closed — rather than inheriting "exit 1, silent, proceed" by accident. Note that exit 1 is the one status that guarantees the operator learns nothing.
  3. Consider whether source hook-utils.sh failing should be fatal-and-loud rather than falling through into undefined hook:: calls.

A shared helper in hook-utils.sh would cover the whole hook set at once, which matters given 17 PreToolUse and 22 PostToolUse registrations.

Verification expected of a fix

  • A test that forces an abort mid-hook (for example by making a sourced helper fail) and asserts the hook exits with its declared policy status and writes a diagnostic naming itself.
  • Confirmation that claude-ops' unsurfaced-failure detector reports zero records for these hooks over a session that includes the forced-abort case.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: securitySecurity-relevant: vulnerability, hardening, or disclosure follow-up.priority: highSignificant impact, or blocks an imminent release; staff this cycle.work-class: scopedA briefed fix or small feature; blast radius bounded by the brief, tests exist.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions