Claude Modsunofficial directory

Security·terminal·MIT

Assertion Guardian

Refuses test edits that drop assertions, add skips or change expected values.

Assertion Guardian refusing an edit to a test's expected value, with the blocked band and allow once above the prompt
Screenshots from the assertion-guardian repository, MIT license

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.

Assertion Guardian in the author's recording: a change to an expected test value is refused, then the real bug is fixed

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*, @Test or [Fact]; assertions are calls such as expect(...), assert, assertThat, XCTAssert*, t.Error, #expect or should.
  • 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 to toBe(190), toBe(3) to toBeGreaterThanOrEqual(3), toStrictEqual to toEqual, toHaveBeenCalledWith(1) to toHaveBeenCalled(), toBeCloseTo(3) to toBeCloseTo(3, 0), .toBe(3) to .not.toBe(3), and assert f() == 1 to == 2 or != 1. It reads Jest and Playwright matchers, Node assert, chai, RSpec, unittest, pytest’s plain assert, JUnit and AssertJ, XCTest and Swift Testing, Rust’s assert_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.skip or xfail, @Disabled, @Ignore, t.Skip, XCTSkip, #[ignore] and more), a new .only or fit that drops every other test from the run, or assertions put behind if (false) or after a bare return.
  • Assertions that can’t fail. New constant assertions such as expect(3).toBe(3), assert True or assert.strictEqual(3, 3). Swallowed failures: a try around an assertion whose catch, except or rescue neither rethrows nor fails, pytest.raises(AssertionError), or expect(() => expect(...)).toThrow().
  • Data rows. A row of literal values removed or changed in a table of cases, such as a test.each table or a Go table test.
  • Snapshots. Any edit to an existing .snap file 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 as env, sudo, npx, pnpm exec, pnpm --filter, python -m and bash -c are 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, cp onto a test file, sed -i, perl -i, > redirects, tee, git rm, git checkout <rev> -- file, git restore --source, find ... -delete, or a python -c or node -e one-liner that names a test file. Restoring the committed version with git checkout -- file passes.

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.