nobashtest_mutation
August 14, 2026 · View on GitHub
What it blocks
A Bash command that mutates an EXISTING test file in place — sed -i,
awk -i inplace, tee, dd of=, or a truncating/appending redirect (>,
>>) — targeting a file that already exists on disk and looks like a test.
Why this shape is worth a gate
no_test_tampering.py watches the Edit tool for a test being weakened;
no_bash_test_deletion.py watches for the test file disappearing entirely.
Neither sees a test mutated in place via the shell — sed -i rewrites the
file directly, bypassing the Edit tool (and its tamper guard) altogether.
This is not a hypothetical: a worker subagent in this project's history
silently deleted a whole test class, assertions included, via a bash-side
mechanism, and it sailed past no_test_tampering.py because that hook never
saw an Edit event for the change at all — the file just changed underneath
it. This hook closes that specific hole.
Scope — read this before the examples below
Bash matcher, no engine_dirs gate. One env var turns this hook off
completely: GUARDRAILS_INTEGRATOR_ROLE=1 bypasses the whole hook
unconditionally, before the event is even parsed — no marker, no reason,
no trace in the block message, because nothing ever gets far enough to
produce one. The rationale stated in the hook's own docstring is that the
integrator owns test edits at merge time, so this hook's job is already done
by that point. No other env vars are read. If you're reasoning about whether
this hook is actually protecting a given shell session, check that variable
first — everything else on this page is moot if it's set.
(This env var carried a different name in older copies of this hook, before
this release's rename. Verified live, from the repo root: the retired name
now does nothing at all — setting it has zero effect, it's just an ordinary
unrecognized variable to this code. Only GUARDRAILS_INTEGRATOR_ROLE=1
bypasses the hook. If you have automation or documentation elsewhere still
setting the old name, it silently stopped working and needs updating to
GUARDRAILS_INTEGRATOR_ROLE.)
BLOCKED
$ mkdir -p .scratch/tests && touch .scratch/tests/test_foo.py
$ echo '{"tool_input":{"command":"sed -i '"'"'s/x/y/'"'"' tests/test_foo.py"},"cwd":".scratch"}' \
| python3 .claude/hooks/no_bash_test_mutation.py
BLOCKED: this Bash command mutates an EXISTING test file in place.
- sed -i -> tests/test_foo.py
...
$ echo $?
2
Run from the repo root; cwd is a relative path where tests/test_foo.py
actually exists on disk — the existence check is what distinguishes
"mutating a real test" from "creating a new one".
ALLOWED
The nearest legitimate thing is not the same command with a marker slapped on a real test file — a bare marker there is explicitly its own violation (see below). The genuine near-miss is the identical marker syntax on a target that was never a test file to begin with:
$ echo '{"tool_input":{"command":"sed -i '"'"'s/x/y/'"'"' notes.txt # test-mutate-ok"},"cwd":".scratch"}' \
| python3 .claude/hooks/no_bash_test_mutation.py
$ echo $?
0
Both commands above were run against this tree this session, from the repo
root. A second, structurally-guaranteed near-miss: sed -i on a test path
that does not yet exist on disk is always allowed — creating a
brand-new test file is not a mutation.
The escape marker
# test-mutate-ok: <reason>, same reason-required structure as its deletion
sibling — a bare # test-mutate-ok on a command that would otherwise block
produces a distinct "needs a reason" message rather than silently passing
(same 2026-07-22 hardening as no_bash_test_deletion.py, closing the same
self-grantable-escape-hatch shape).
How we know it fires
test_bash_marker_reason_required.py, test_mutation_* functions (5 of the
file's 10 — the other 5 are its no_bash_test_deletion.py sibling's
test_deletion_* functions). Run this session, from the repo root:
$ python3 -m pytest .claude/hooks/test_bash_marker_reason_required.py -q
.......... [100%]
10 passed in 0.21s
test_mutation_marker_with_reason_allowed runs
sed -i 's/x/y/' tests/test_foo.py # test-mutate-ok: matches new evaluator arg
against an existing tests/test_foo.py fixture and asserts returncode == 0 — proving the reasoned marker actually clears a real, existing-file
mutation, not just a no-op case.
Known limits
Mutation detection targets a specific, named set of shell mechanisms
(sed/gsed -i, awk/gawk/mawk -i inplace, tee, dd of=, >/>>
redirects). A mutation mechanism outside that list — a Python one-liner that
opens and rewrites the file, a language-specific file-write call inside a
bash -c "python3 -c '...'" wrapper, an editor macro — is invisible to this
hook. It shares no_bash_test_deletion.py's per-simple-command splitting
limitation (crude compound-command parsing, not full shell semantics) and
its fixed test-path naming convention (_looks_like_test_path, same rule as
the deletion hook — a repo with a different test-file convention gets no
coverage here either). Concretely, that regex recognizes
test_*.py/*_test.py/*.Tests.ps1/conftest.py but not Go's *_test.go
suffix — docs/hook-manifest.yaml flags this hook's Go gap explicitly
(fixed_for_go: false). Verified live this session, from the repo root:
sed -i 's/x/y/' modules/foo/bar_test.go against an existing bar_test.go
fixture returns rc == 0 — the mutation is not recognized as touching a
test file at all, unless the Go file happens to sit under a /tests//
/test/ path segment.
And, worth repeating from the Scope section because it's the single most
important fact on this page: GUARDRAILS_INTEGRATOR_ROLE=1 is a full,
unconditional off-switch for this hook — anyone able to set that env var
in the agent's shell environment disables test-mutation protection
entirely, by design (the integrator role is trusted to own test edits at
merge). That trust boundary lives outside this hook; the hook itself does
not verify who set the variable, and produces no message or telemetry
signal when the bypass fires — it returns before load_event() is ever
called.