noswallowederrors
August 14, 2026 · View on GitHub
What it blocks
An Edit/Write/MultiEdit that adds an exception handler whose entire
body is a bare pass or ... (Python), an empty/masking catch {} or
-ErrorAction SilentlyContinue/Ignore (PowerShell), or one of several
error-discard shapes (Go) — anywhere in the neighborhood of the edit, not
just the lines actually touched.
Why this shape is worth a gate
A caught-and-ignored exception is a failure that still happens, just quietly.
The caller believes the operation succeeded; downstream code proceeds on that
false premise, and by the time the actual consequence surfaces — bad data,
a stuck queue, a state machine wedged in an impossible corner — the original
exception is long gone from any log. This is a specific, high-leverage
dishonesty pattern for an agent under pressure to look done: except: pass makes an error message disappear from the terminal in one keystroke,
with no acknowledgment that the underlying problem is still there.
BLOCKED
$ cat ev.json # {"tool_name":"Write","tool_input":{"file_path":"src/foo.py","content":"def foo():\n try:\n risky()\n except Exception:\n pass\n"}}
$ CLAUDE_PROJECT_DIR=$PWD python3 .claude/hooks/no_swallowed_errors.py < ev.json
BLOCKED: this edit hides a problem instead of solving it.
- line 4: `except Exception:` silently swallows an error. Handle it,
re-raise it, or let it propagate — do not bury it. ...
$ echo $?
2
ALLOWED
The nearest legitimate thing: the identical handler, marked as a deliberate degrade rather than an unexamined swallow:
$ cat ev.json # same file, except line now reads:
# except Exception: # swallow-ok: deliberate degrade-to-default
$ CLAUDE_PROJECT_DIR=$PWD python3 .claude/hooks/no_swallowed_errors.py < ev.json
$ echo $?
0
Both commands above were run against this tree this session, from the repo
root, with CLAUDE_PROJECT_DIR=$PWD set only so _common.emit_event's
telemetry writer has a project dir to append to (it lands in the gitignored
.claude/hooks/state/harness_events.jsonl — harmless scratch state, not
something that ships). It does not affect the engine_dirs scope decision
itself — that's resolved relative to _common.py's own on-disk location,
not CLAUDE_PROJECT_DIR (see Scope below). A genuinely unrelated near-miss: a
handler that logs and re-raises is never flagged at all —
except Exception:\n logger.warning(...)\n raise — because the AST
tier matches only the bare pass/... body shape (see Known limits).
The escape marker
# swallow-ok: <reason> (PowerShell <# ... #>, Go //). Handler-aware: it
can sit on the except line, the sole pass/... body line, or a comment
line between them — any of the three natural places a developer would
annotate. For the brace-delimited Go and PowerShell shapes it may also sit on
its own line anywhere the matched block spans. A bare # swallow-ok with
nothing after the colon does not clear the block (SWALLOW_OK requires a
non-whitespace captured group).
A comment that is not a marker clears nothing, in any tier. That is worth
saying plainly because it was not true until 2026-08-14. Every hard-block
pattern in the Go and PowerShell tiers was anchored so that any trailing or
interior comment stopped it matching at all — so the marker check was never
reached, and an arbitrary // whatever silently cleared the block exactly as
a well-formed marker would. Five patterns had it: GO_DISCARD_ERR,
GO_EMPTY_ERR_CHECK, GO_SWALLOWED_RETURN_NIL, PS_EMPTY_CATCH,
PS_NULL_CATCH. The Python tier is AST-based and was never affected.
The fix separates what the bug had conflated: the pattern matches the SHAPE, treating comments as filler, and the marker check is the only thing that clears a hit. If you are reading this against an older checkout, verify rather than trust the text — the tests pinning it are named below.
Scope
ENGINE-QUALITY for Python/PowerShell: gated on is_engine_path(path), which
reads the engine_dirs list from docs/audit/audit-scope.yaml. In this tree
that list is the literal placeholder ["src"] — this is why every probe in
this doc uses a src/... path. An edit outside engine_dirs is allow()ed
before any pattern ever runs. Go gets a separate, narrower scope check
(_is_go_generated: _bindata.go suffix or a // Code generated ... DO NOT EDIT. marker are exempted) that does not consult engine_dirs at all —
Go coverage is scoped by "is this a .go production file", full stop.
Two env vars change behavior: GUARDRAILS_STRICT=1 promotes excuse-comment
soft hits (# TODO: good enough, # known issue) to hard blocks;
GUARDRAILS_SWALLOW_NEIGHBORS=N (default 2) widens or narrows the
sibling-function scan radius.
If engine_dirs doesn't match your repo's real source layout, this hook
silently covers zero code. Nothing errors, nothing warns — an edit outside
the configured directories is indistinguishable, from the outside, between
"clean" and "never checked." The only way to notice is to deliberately plant
a violation outside engine_dirs and confirm it's allow()ed when you
expected a block — which is exactly what
test_engine_scope_gate_both_directions does (see below).
How we know it fires
test_no_swallowed_errors.py, filed specifically because this hook shipped
in an earlier delivery of this tree with zero test coverage. Run this
session:
$ python3 -m pytest .claude/hooks/test_no_swallowed_errors.py -q
............. [100%]
13 passed in 0.43s
test_bare_pass_swallow_blocked plants the bare-pass shape and asserts
rc == 2; test_swallow_ok_marker_on_except_line_allowed (and its
pass-line/comment-line siblings) assert rc == 0;
test_engine_scope_gate_both_directions plants the identical violating
content at src/foo.py (blocked) and other/foo.py (allowed) in one test
function, so the scope-gate pairing can't drift apart into two tests that
silently stop agreeing;
test_neighborhood_scan_blocks_sibling_swallow_not_touched plants a swallow
in a sibling function the edit never touches and confirms the neighborhood
scan still blocks it.
Known limits
The AST tier flags only a handler whose entire body is a lone
pass/... — a handler that does anything else (logs, sets a fallback
value, re-raises with different text) is never flagged by this check, even
if the log message is uninformative or the fallback is silently wrong. This
is a deliberate, narrow shape, stated in the hook's own comments — it is not
a general "does this except block handle the error well" reviewer.
The Go regex tier operates on added text only, with no neighborhood scan
— unlike the Python AST tier, an edit that builds a Go swallow around a
touched line (leaving the swallow itself just outside added) is not
caught. Go's ignored-second-return shape (x, _ := call()) is soft-only
(a warning, not a block) because the hook cannot tell, from source text
alone, whether the callee returns (T, error) (a real dropped error) or
(T, bool) (the ordinary "found" idiom) — this is the one place in the
pack where a warn tier is used deliberately rather than as a shortcut (see
no_test_tampering.md for the corresponding, differently-scoped
_ = err-shaped discard on the tampering-hook side). Go's _ = err bare
discard, by contrast, is hard-blocked here, but only when the discarded
identifier's name contains the substring "err" (case-insensitive) — _ = count is never flagged.
engine_dirs is a topology-coupled config, not a scan of your repo (see
Scope above) — the placeholder value in this tree (["src"]) is a
common-convention guess. If a licensee's source lives under backend/,
app/, or lib/ instead, this hook (and no_type_checking_stub.py, which
shares the same config) cover nothing until that list is corrected.