Security·terminal·MIT
Assertion Guardian
Refuses test edits that drop assertions, add skips or change expected values.

Assertion Guardian refuses edits that get a failing test to pass by weakening the test instead of fixing the code. Before Claude Code changes a test file, the mod compares the whole file before and after, and blocks the change if checks were lost or loosened. The model is told to fix the code under test or ask you, and a band above the prompt lets you allow the change once.

What it does
It refuses a change when any of these got weaker:
- Checks removed. Fewer test cases or fewer assertions than before, or a line that held an assertion coming back as a comment. Cases are declarations such as
it(...),test(...),def test_*,func Test*,@Testor[Fact]; assertions are calls such asexpect(...),assert,assertThat,XCTAssert*,t.Error,#expectorshould. - Expected values. Every literal value an exact matcher pins down must still be pinned after the edit, with the same polarity and a matcher at least as strict. That catches
toBe(180)changed totoBe(190),toBe(3)totoBeGreaterThanOrEqual(3),toStrictEqualtotoEqual,toHaveBeenCalledWith(1)totoHaveBeenCalled(),toBeCloseTo(3)totoBeCloseTo(3, 0),.toBe(3)to.not.toBe(3), andassert f() == 1to== 2or!= 1. It reads Jest and Playwright matchers, Nodeassert, chai, RSpec, unittest, pytest’s plainassert, JUnit and AssertJ, XCTest and Swift Testing, Rust’sassert_eq!and PHPUnit. - Looser matchers. Fewer exact matchers while the number of assertions holds, or more vague ones than new assertions account for:
toBeTruthy,toBeDefined,toBeFalsy,toMatchObject,expect.any,expect.anything,assert.ok,XCTAssertNotNil. A change in the number of negations (.not,notEqual,assert_ne!) that new assertions don’t explain counts too. - Tests switched off. A new skip, todo or expected failure (
it.skip,xit,it.todo,test.fail,this.skip(),@pytest.mark.skiporxfail,@Disabled,@Ignore,t.Skip,XCTSkip,#[ignore]and more), a new.onlyorfitthat drops every other test from the run, or assertions put behindif (false)or after a barereturn. - Assertions that can’t fail. New constant assertions such as
expect(3).toBe(3),assert Trueorassert.strictEqual(3, 3). Swallowed failures: atryaround an assertion whosecatch,exceptorrescueneither rethrows nor fails,pytest.raises(AssertionError), orexpect(() => expect(...)).toThrow(). - Data rows. A row of literal values removed or changed in a table of cases, such as a
test.eachtable or a Go table test. - Snapshots. Any edit to an existing
.snapfile or a file under__snapshots__/or__image_snapshots__/, and shell commands that accept every snapshot at once:jest -u,npm test -- -u,playwright test --update-snapshots,cargo insta accept,INSTA_UPDATE=1,loki update,backstop approve,chromatic --auto-accept-changes. Wrappers such asenv,sudo,npx,pnpm exec,pnpm --filter,python -mandbash -care looked through. - Shell edits. Any command that writes, moves, deletes or rolls back a test file or test folder, even harmlessly, since the mod can’t see what it would change:
rm,mv,cponto a test file,sed -i,perl -i,>redirects,tee,git rm,git checkout <rev> -- file,git restore --source,find ... -delete, or apython -cornode -eone-liner that names a test file. Restoring the committed version withgit checkout -- filepasses.
Refactors that keep every check pass: renaming, reformatting, reordering, moving assertions between tests in the same file, adding tests, switching quote style, swapping in a stricter matcher, and changing toBe(null) to toBeNull().
Install
Needs Claude Code 2.1.287 or later.
claude plugin marketplace add ccdwyer/claude-mods
claude plugin install assertion-guardian@ccdwyer-mods
The README gives the same pair as /plugin commands to run inside Claude Code, followed by /reload-plugins. The marketplace is the author’s catalogue of mods, which also lists Loop Breaker; the second command installs only Assertion Guardian. There is nothing to start. When you want a refused change to go through, press allow once in the band or run /allow-test-change, then let the model retry.
How it works
Everything happens in a tool.call hook, which sees each tool call before it runs, a subagent’s included. The mod checks four tools: Edit, Write and NotebookEdit when the target is a test file, and Bash for every command.
A file counts as a test by its name: *.test.js, *.spec.ts and their other JavaScript and TypeScript variants, test_*.py, tests.py, *_test.py, *_test.go, *_test.rb, *_spec.rb, PriceTest.java, PriceTests.swift and similar. A code file inside a __tests__, test, tests or spec folder counts too, unless it sits under fixtures, support, helpers, factories, __mocks__, mocks, data or testdata. Snapshot files always count.
For an Edit, the mod reads the file, applies the edit the way the tool would, and compares the whole file before and after. A Write is compared with the file it replaces. If the file is missing, the baseline is the version in git’s HEAD, fetched with a local git show and a five-second timeout, so deleting a test and writing a weaker one from scratch doesn’t get around the check. A file git has never seen is compared with an empty one. For a notebook, only the edited code cell is compared, and deleting a code cell or turning it into markdown counts as removing its tests.
Before counting anything, a small scanner separates code from comments and string literals, so an expect( in a comment, a docstring or a fixture string never counts. If the baseline can’t be read, or git’s answer can’t be trusted (a timeout, a safe.directory error, truncated output), the change is refused and the refusal says why. The same happens when a notebook edit names a cell that isn’t there.
A refusal reaches the model in place of the tool result. It names the file, lists every signal that fired, and tells the model to fix the code under test instead of making the tests easier to pass. If the model believes the test itself is wrong, because the spec changed or the assertion was buggy, it’s told to stop and ask you, explaining why. When nothing is refused, the mod adds nothing to the conversation.
The band shows the last refused change: the file name, the first signal, and the allow once and dismiss buttons. Allowing it, from the band or with /allow-test-change (which runs immediately, even mid-turn), lets that exact call through one time. The call is identified by a SHA-256 hash of the tool name and every argument except its description and IDs, so the model has to retry the identical change; a different edit to the same file is checked from scratch. /allow-test-change takes an optional file name and allows nothing if it doesn’t match. Only the latest refusal can be allowed, and dismiss hides the band without allowing anything.
The mod keeps that last refusal (path, signals and hash) and the allowed hashes in its state inside Claude Code, and drops an allowance once it’s used. It writes no files and makes no network requests. Its only outside command is the local git call above, and the author states it collects no telemetry.
Limitations
- It checks what a tool call says it will do. A script that changes a test file without naming it, or a tool other than Edit, Write, NotebookEdit and Bash, gets through. The author calls it a guardrail, not a sandbox.
- It can’t tell a fair change from a cheat. Deleting an obsolete test, moving tests to another file or updating a value after a spec change is refused like anything else, and needs
allow once. - Only literal expected values are tracked. When an assertion compares against a variable, changing the variable’s value isn’t caught unless it’s a row in a table of literal cases.
- Tests that live in ordinary source files, such as Rust unit tests under
src/, aren’t recognized as test files. - The author shows the band only in the terminal. Where Claude Code runs mods without drawing them, such as the VS Code chat panel or
claude -p, refusals still happen but there is no band.
More mods
The whole directory →anchorwatch-mod
Blocks rm -rf, force pushes, DROP TABLE, curl | sh and cat .env before they run.
blast-radius
Shows what rm -rf or a force push would change and waits for Proceed or Cancel.
collision-guard
Asks before Claude edits a file another open chat just changed.
env
Claude manages your .env keys and asks you for values it never gets to see.

