Skip to main content
Glama

AdversaryGate (v3.0.16)

High AI usage ≠ high confidence. The cost of an agentic coding pipeline is not the model's intelligence — it is the pipeline's self-deception.

Uso alto de IA ≠ confiança alta. O custo de um pipeline com agentes de código não está na inteligência do modelo, está no autoengano do pipeline.

An evidence-based fail-closed verification gate for AI coding agents where uncertainty is a first-class result (INCONCLUSIVE) instead of a silent approval.

Measures Python/pytest repositories today. On other stacks it runs your suite via --test-command but answers INCONCLUSIVE, never MERGE — see Language support.

pip install adversary-gate

Document

What it is

📄 CHANGELOG

what changed — and which versions actually have a tag

🛡️ SECURITY

report a fail-open. A bug in this repo is a security bug, because a wrong MERGE is the whole failure mode

📋 Findings AG-001…AG-046 and the 3.0.x fixes

every finding, reproduced by real exit code before being fixed — and what is still open


🎬 Watch it decide (60 seconds)

AdversaryGate — demonstração ao vivo

B and C are the same patch. Same code, same tests, same execution. The only difference is that C brought evidence (--diff + --coverage-json); B brought none.

Patch

Coverage evidence

Decision

Exit

A

breaks the test

declared untrusted

BLOCK

1

B

clean

none — never measured

INCONCLUSIVE

2

C

clean

measured, both artefacts SHA-256'd

MERGE

0

That middle row is the whole product. An unproduced measurement is not a measurement, so it cannot clear a floor — and a number nobody can re-derive is an opinion with a false precision label.

python3 demo/demo.py          # runs the three scenarios; exits non-zero if one regresses
python3 demo/render_gif.py    # rebuilds demo/demo.gif from the captured frames

demo/demo.py is an acceptance test, not a screenshot: it builds the fixtures, runs the real CLI as a subprocess, reads the real exit codes, and fails if any of the three decisions changes. Its evidence artefacts are checked in under demo/out/.

INCONCLUSIVE is not an error. It is the gate saying "I did not measure that", and it is exit 2 so CI treats it as "do not merge yet", not as "pass". See How diff_coverage gets its value for the one-step path from INCONCLUSIVE to MERGE.


Related MCP server: agent-guardrail

🎯 The Thesis

  1. High AI usage ≠ high confidence. The more a team leans on agents in its real engineering flow, the more the cost of "looks right" shows. Agents write fluent, plausible code, and naive pipelines that collapse infrastructure errors into "pass" approve patches whose tests are broken or never ran.

  2. Switching models is a symptom. Teams move between models looking for PRs they can trust. What is missing is deterministic verification, not a better model. AdversaryGate runs the same test harness with no LLM in the verifier, which is what makes patches from different models comparable at all.

  3. The loss is concrete. Merged regressions, tasks reported done that are not, rollbacks, and reviewer hours spent re-reading the same PR.

  4. Less pipeline self-deception. The product does not sell a smarter AI. It sells a pipeline that says "I did not measure that" instead of a green check. How to count that honestly is in Provenance & metrics — and the counter this README used to point at is zero by construction on the gate's own logs (AG-029), so it is no longer the pitch.


🔒 The Fail-Closed Model & Double-Filter Rigor

A single invariant governs the entire system:

The gate only reports what a healthy test harness actually executed. Unproduced proof is never proof of clean code.

State

Outcome

Meaning

Executed & Passed

Outcome.VERIFIED

Test ran to completion and evidence confirms clean execution.

Executed & Failed

Outcome.REFUTED

Test ran to completion and evidence condemns the patch.

Harness / Error

Outcome.UNVERIFIED

Collection error, syntax error, missing file, timeout or flaky signal. Never mergeable.

Patch Decision Matrix

  • Decision.MERGE: Requires every verdict to be VERIFIED, a measured diff_coverage >= 80%, a suite strength whose 80 % lower confidence bound is >= 0.75 when strength was measurable (suite_strength_lower), and the full repository test suite to pass on the patch side.

  • Decision.BLOCK: Triggered if any claim is REFUTED, or if the patch fails the full test suite that the baseline passed (collateral regression). Checked before the coverage floor: direct evidence of breakage outranks missing evidence.

  • Decision.INCONCLUSIVE: Triggered on UNVERIFIED outcomes, open circuit breakers, coverage that was never measured, a weak test suite (suite_strength < 0.75), a strength that could not be measured even though source changed, or a full suite that was already red before the patch. Never merges.

Precedence is BLOCK > INCONCLUSIVE > MERGE, and direct evidence always outranks missing evidence: a patch whose claim could not be executed but whose collateral run broke the suite is BLOCK, not a shrug.

A passing claim says how it passed (AG-030). VERIFIED comes with one of three classifications: fixed — the test failed on the baseline and passes on the patch, the strongest evidence there is that the patch fixes what the test checks (SWE-bench's FAIL_TO_PASS); no_regression — it passed on both sides, which only says nothing it checks broke; discarded — a test the patch added, with no "before". The output carries claims_fixed and fix_proven, so "this patch fixes what it says it fixes" is a field, not an inference — and MERGE with fix_proven: false is a patch that broke nothing and proved nothing about the bug it was for.

The patch does not get to write its own answer — the baseline does. If the claim's test file already existed on the baseline and its bytes differ on the patch, the patch's copy is never run for the verdict. The gate copies the patch tree, puts the baseline's test file back, and runs the claim there: the patch's code answers to the test that existed before it (the baseline oracle; the artefact records "oracle": "baseline"). So a bug hidden behind a rewritten assertion is REFUTED → BLOCK, and an honest refactor of the test file is VERIFIED instead of stuck. A test the patch added to an existing file has no baseline copy: it is judged like any added test, but only after every test the baseline shipped in that file has passed on the patch's code ("oracle": "baseline-file") — so the new test cannot be the cover for an old one the patch bent. The oracle covers three more places a test's answer can hide:

  • Helpers. Every baseline file that is a test file is put back, not only the claim's — test_*, *_test.py, conftest.py, anything under tests/ or __tests__/, and *.test.*, *.spec.*, *_test.go, *Test.java, *_spec.rb. A helper whose path says nothing (testing_utils.py at the root) cannot be told apart from code under test — numpy.testing is public API — so it is declared: --test-support testing_utils.py (repeatable, a glob; Action input test-support). Declared paths are test files everywhere: out of diff coverage and mutation, and restored from the baseline. Any change to a baseline test file engages the oracle, even with the claim's own file untouched.

  • The collateral suite. When the patch changed tests the baseline had, the full-suite run also executes the baseline's tests against the patch's code (full_suite_oracle in the artefact). A test that is no claim's, bent to agree with a bug, is a collateral regression → BLOCK.

  • --test-command claims. The test-id of a command suite is a label, so the transplant always applies: the command runs from the baseline's copy of the files.

When the baseline's copy of a Python test cannot be parsed, the old rule stands: a passing rewritten test is UNVERIFIED. A test file the patch adds is judged as before. A legitimate behaviour change that rewrites its tests therefore lands on BLOCK — the old tests fail on the new code — which is the gate saying "the contract changed; a person signs that". Nor does it get to configure the runner that judges it (AG-032): every test-harness file in the tree — the five config names pytest 9 reads (pytest.ini, .pytest.ini, pytest.toml, .pytest.toml, tox.ini), plus pyproject.toml, setup.cfg, any conftest.py, sitecustomize.py, usercustomize.py and *.pth, at any depth — must be byte-for-byte the baseline's, or the claim is UNVERIFIED. The trees are compared directly, so a harness change the --diff leaves out is still caught. The paths named by --diff are also checked against the protected-path policy (conftest.py, pytest.ini, pyproject.toml, …), not only the ones passed with --changed-path. A directory holding pyvenv.cfg is a virtualenv and is skipped by the tree comparison, so from 3.0.7 a pyvenv.cfg in the diff is a protected path: a patch cannot turn a directory of its own into one and hide harness files there. Nor can it put a module where it would be imported in place of the real one (AG-048): python -m pytest searches the tree's root first, pytest adds the directory of every test module and conftest.py that is not a package, and a pythonpath setting or a test's sys.path.insert can add more. So in every directory that is not a package, an inspect.py, a json.py or a pytest/ package the patch adds or changes — any standard-library name, or a module pytest itself loads — is treated as a harness change. A directory without __init__.py does not count (a namespace never wins over a real module), virtualenvs are skipped, and a module on pytest's path is also known to the AG-047 check by its bare name.

How diff_coverage gets its value

Coverage is an evidence question, not a parameter. There are exactly three ways it can be supplied, and the artefact records which one was used (diff_coverage_source):

--coverage-source

Inputs

Meaning

computed (via auto)

--diff + --coverage-json

The gate parses the unified diff and the coverage.py JSON report itself, and records the SHA-256 of both. This is the only measured option.

untrusted

--coverage-ratio

A number the caller asserts. Accepted only when you say so out loud; the artefact marks it untrusted.

none (via auto)

neither

No evidence. diff_coverage_ratio is null and the floor is not cleared → INCONCLUSIVE.

Test files are not part of the ratio: diff_coverage is covered added lines over added lines of source files. A test executes by construction, so counting its lines let ten unexecuted source lines plus forty test lines read as 0.8. The artefact records test_lines_excluded and test_files_excluded next to changed_lines.

Documents (README, LICENSE, CHANGELOG and the like) are not code and are left out. A patch that changes documents or configuration and no source line has nothing to cover: its ratio is not measured, so the decision cannot be MERGE on coverage. A patch of tests alone keeps the vacuous 1.0 it always had. That closes a gap in the earlier 3.0.0 code, where deleting one line of a README measured 1.0, and a claim that read the README then passed. Every changed source line counts. coverage.py reports a statement on its first line only, so the second line of a statement is covered when its statement ran and counts against the patch when it did not (E); the gate reads the patch's file to tell which statement a line belongs to. A docstring line counts and is never covered. A Python file that git diff shows only as Binary files ... differ (its .gitattributes says -diff or binary) is refused with exit 3, because its changed lines are not in the diff at all (C). The evidence script diffs with --text --no-textconv, so the GitHub Action does not reach that case.

A bare --coverage-ratio with no --coverage-source is exit 3 (usage error). Before v2.1.0 the flag defaulted to 1.0, the gate never read a diff or a coverage report, and the GitHub Action passed neither — so diff_coverage >= 80% was satisfied by a default value on every run.

--coverage-floor 0 disables the requirement explicitly; it is not a way to satisfy it.

How suite_strength is measured

It is a mutation score — mutants killed / mutants executable — produced by breaking the lines the patch changed and re-running the claims — their node ids, not their whole test files (AG-039) — against them. Fewer tests per mutant can only lower the score; a whole file let a failing test outside the claims count every mutant as killed. Each claim meets the mutants with the copy of its test file that judged it: when the patch rewrote a test the baseline had, that is the baseline's copy (until 3.0.3 the mutants ran the patch's, and asserts the patch added to a rewritten test killed mutants the verdict never ran). It is not derived from coverage or from parsing pytest output.

Several things must hold before a score exists, and each one that fails leaves the strength not measured. The claims must pass on the unmutated patch, the reference: if they do not, a mutant that fails the same way is not a kill (D). A Python file the patch adds that cannot be paired with a baseline file, because the baseline is over 400 files or 5000 lines, is never mutated (B). Every changed Python file must be UTF-8: one that is not cannot be mutated, and it used to be skipped in silence while the other files' score stood for the patch. And every claim runs on a neutral copy of the patch: one comment per changed line, a space after each token a mutant would swap, and one no-op assignment — deleted on the same line, so it never shows in locals() or a class's vars() — in the block of each changed statement (in front of it, or after a docstring or the from __future__ imports, which must stay first). A claim that fails on that copy reads the code's text or structure instead of what it does (A). The copy catches a claim that reads the file, a piece of a changed line, its whole tree or the bytecode of the changed function; one that reads a smaller piece of the tree, such as a single expression, or that normalizes the text before comparing, can still pass. If that copy cannot be built for a changed file, nothing is measured either.

Small changes: why a correct one-line fix is INCONCLUSIVE

The strength floor reads the lower bound of a confidence interval, and a small change has few mutation sites: 1 killed out of 1 bounds the strength at 0.38, and the default floor (0.75 at 0.80 confidence) takes at least 5 killed mutants. When every mutant was killed and there were simply too few, the reason says "the change is too small to measure at this floor" and gives the count — weak tests look different: a survivor, named in next_steps. More tests cannot add mutants; only a bigger change can. What remains is the operator's choice, never the patch's — which is why next_steps, which an agent reads, names no flag:

  • accept the answer: the fix is proven (fix_proven: true), only the strength is unmeasured, and a human reviews it;

  • --census-min-mutants N, below: judge a complete census of the change by its exact ratio;

  • --mutate-constants: also break integer literals, which adds sites on some lines.

--census-min-mutants N (experimental, off by default). Below the mutant budget the gate breaks every mutable site of the change — a census, not a sample. With this flag, when the census covers the whole change (every changed executable line has a site; statement-level sites are added for lines with no operator), nothing was stillborn, every mutant died, and there are at least N, the exact ratio is judged instead of the Wilson lower bound. Off by default: "1 killed out of 1 is not strong" stays the rule unless you opt in. A first version let a real bug through (one surviving operator mutant diluted by easy statement-level kills); the ledger has the case.

next_steps. Every answer that is not MERGE lists what would make it one, as actions a coding agent can take: cover (file, lines), kill_mutant (file, line, mutation), add_test, fix_code, human_review_test_edit, raise_limit, see_reason. It never suggests editing a baseline test, a harness file, a floor or the policy — those are the oracle.

  • Mutants are limited to lines the patch actually wrote; mutating untouched lines would let tests covering unrelated code inflate the score.

  • A branch that shares its line with its guard gets a mutant of its own. Coverage is per line, so return 999 if x == 0 else x + 1 reads as covered when only the else ran — and the guard's operator mutant (== → !=) sends the tested input into the hidden arm, so the test kills it and the kill used to count as strength: on 3.0.3 that patch merged at 5/5 with value(0) changed from 1 to 999. Now a conditional expression is also replaced by each of its arms, an and/or loses its last operand, and an if or case whose body sits on the header's line is switched off. These mutants (kind: "branch") come first on their line. One that survives means a branch on a changed line that no claim takes. It is listed in mutation.blind_spots, suite_strength_unverified turns true and the decision is INCONCLUSIVE, whatever the other mutants scored — the ratio itself stays what the operator mutants measured — and next_steps names the line. Killed, they do not enter the ratio, so they cannot pad it.

  • Added, modified and deleted source files all count as changes. A deleted file has nothing left to mutate, so it is reported separately as mutation.deleted_files and cannot contribute a score: whenever deleted_files is non-empty the patch lands on INCONCLUSIVE, never MERGE. That includes a mixed patch where the surviving file measured 1.0 — that score covered only what was left after the deletion, so suite_strength_unverified goes true rather than let one file's number authorise the change as a whole.

  • A mutant that no longer runs at all (syntax/collection error) is stillborn and excluded from both sides of the ratio rather than counted as a kill.

  • suite_strength: null means not measured — there was no source change to judge. It is never reported as 1.0, and it does not block.

  • If source did change and no score could be produced, that is unknown, not strong: the decision is INCONCLUSIVE.

  • The floor reads a confidence bound, not the ratio (AG-023). A ratio from one mutant is 1.0 and evidence of almost nothing. The gate computes a two-sided Wilson interval around killed / counted and applies --suite-strength-floor to its lower bound (--strength-confidence, default 0.80; 0 restores the raw ratio). At the default, 5 of 5 killed mutants clear 0.75 (lower bound 0.753) and 4 of 4 do not (0.709). The artefact records suite_strength (the ratio), suite_strength_lower, mutation.interval and mutation.confidence. Consequence, said plainly: a patch whose changed lines hold fewer than five mutable operators cannot reach MERGE at the default — it is INCONCLUSIVE, because the gate cannot tell a strong suite from a lucky one on that sample.

  • Every changed line is broken at least once — with listed exceptions. With its first operator site, or — on a line no operator reaches — with a statement-level mutant: a return gives None, a condition is negated, a loop iterates nothing, any other statement is deleted. A changed decorator is replaced by lambda f: f (not cache, lru_cache or wraps, whose removal no test can see), a changed default value by None, a changed base class by object — these lines run on import, so coverage counts them as run with no test looking; until 3.0.7 they had no mutant. Not broken, and listed in mutation.unmutated_lines: a call on its own line (a log call, say), an assert, an import, and a header with nothing of that kind to break. A listed line does not stop a MERGE: it is judged only by the claims and the full suite, so read the list. A statement-level mutant that survives is a blind spot too, like a branch mutant; killed, it stays out of the ratio. mutation.mutants_needed records how many mutants breaking every line once took. The remaining sites fill the sample, one per line before any line gets a second, files and lines in order, up to 12 mutants. Until 3.0.3 the 12 were simply the first 12 changed lines: three untested boundary changes after twelve tested lines merged without ever being broken, and return a → return b merged beside a tested line. --mutation-max (default 60) is the ceiling: a change that needs more mutants than that to break every line once is not measured, and the reason says how far to raise it.

--mutation-max N is the ceiling on the cost (0 disables the measurement entirely, which is recorded as such in the evidence artefact). It is the strength floor's escape hatch, the way --coverage-floor 0 is the coverage floor's: the requirement is disabled, not satisfied. What it does not switch off is the check for source this engine cannot judge — that is a property of the patch, not of your budget, and it still forces INCONCLUSIVE with the budget at zero (AG-014).


🌐 Language support — read this if your repo is not Python

Today the gate measures Python. Three layers are Python-specific, and each one is a different kind of "no":

Layer

What it does

Non-Python

Test execution

python -m pytest <path::id>

never runs; pytest reports no tests collected (exit 5)

Coverage

parses coverage json (files → executed_lines)

no report in that shape to parse

Mutation

tokenizes Python and swaps operators

cannot break a .cpp or a .rs

Everything above those layers — decision precedence, the evidence questions, the SHA-256'd artefact, exit codes — is language-agnostic. The brain is portable; the senses are not.

What you get today on a non-Python patch

INCONCLUSIVE, never MERGE — and that is deliberate, not a limitation that happens to work out.

Until AG-012 it was worse than useless, it was wrong. The mutation scanner only walked *.py, so a patch whose entire effect was in calculator.cpp reported changed_files: [] and the reason "no non-test source file changed between baseline and patch" — a statement that was simply false. With a passing Python test and a coverage artefact, that patch reached MERGE while the only thing that had executed anywhere was assert True:

$ adversary-gate --baseline b --patch p --test-path test_ok.py --test-id test_ok \
      --diff change.diff --coverage-json cov.json
decision: merge        # changed calculator.cpp from `a - b` to `a * b`
suite_strength: null   # reason: "no non-test source file changed"

It now records what it actually observed and refuses to judge what it cannot:

decision: inconclusive                                        exit 2
suite_strength: null | suite_strength_unverified: true
mutation.foreign_changed_files: ["calculator.cpp"]
mutation.reason: "1 non-Python source file(s) changed that suite strength
                   cannot judge: calculator.cpp"

INCONCLUSIVE here is the product working: the patch changed code we did not execute, so we are not saying it is clean. The alternative — a green checkmark over a test suite that never looked at the change — is the exact failure this project exists to prevent.

The mixed patch, which is the one that hides

The pure-C++ case is easy to spot; the dangerous one is Python + C++ in the same patch, because there the Python half does measure:

$ adversary-gate ... --diff change.diff --coverage-json cov.json
decision: merge                                                 # before
coverage: 0.80   suite_strength: 1.0   suite_strength_unverified: false
mutation.foreign_changed_files: ["vec.cpp"]      # recorded, never acted on

A 1.0 next to an untouched bug reads as a clean patch. Coverage often masks this by accident — an unexecuted .cpp line drags the ratio down — but that is a coincidence of arithmetic, not a guarantee, so the case is now pinned directly: any non-Python source in the patch makes suite_strength_unverified fire, and the artefact says what the score is a score for:

decision: inconclusive                                         exit 2
suite_strength: 1.0 | suite_strength_unverified: true
mutation.reason: "score covers the 1 Python file(s) mutated only; 1 non-Python
                  source file(s) in this patch were never judged"

Which files count as "source we cannot judge"

NON_SOURCE_SUFFIXES in src/adversary_gate/verifiers/strength.py — a denylist, not an allowlist. Anything that is not Python, not a test, and not on the list of things that are plainly not code (.md, .yml, .json, images, archives, object files) counts as source we cannot judge. That direction is deliberate: the previous shape was an allowlist of languages somebody had remembered to type, and every suffix outside it was invisible. Compiled code a runtime loads (.so, .pyd, .dll, .dylib, .pyc, .jar, .class, .wasm, .exe) is code: Python imports rules.so before rules.py. Until 3.0.7 it was on the not-code list. A project that builds extensions in place now gets INCONCLUSIVE when the built files differ from the baseline; build them outside the tree, or into build/. The tests also never read bytecode from the tree's __pycache__.

That is not a hypothetical. Until AG-013, a patch whose entire effect was in schema.sql reported:

decision: merge                                   # changed schema.sql
suite_strength: null
mutation.reason: "no source file changed between baseline and patch"

— the exact false statement AG-012 was opened to remove, still reachable through a suffix nobody had listed. .proto, .pyi, .vue, .sol, .tf and .r were all in the same hole, and the mixed patch (a measured calc.py plus an unmeasured schema.sql) merged with suite_strength: 1.0 and suite_strength_unverified: false.

A list of remembered languages has an end; a list of what is not code does not. Unrecognised now fails closed.

Two sources are unioned, so either alone being wrong cannot open a hole:

Source

Catches

directory scan (denylist)

changes a hand-written or partial diff left out

--diff paths (authoritative)

anything the scan cannot see — generated files, paths absent from both trees

A documentation-only patch is still unaffected, because .md is on the list.

A test's name is confirmed against who imports it (from 3.0.6). Tests are left out of coverage and mutation, and the oracle puts them back from the baseline, so a product module under a test path — a test_*/*_test.py name, or a tests directory — used to be invisible: a change to it was never judged. Now a test-pathed Python module that a non-test module imports is judged as source. A helper imported only by other tests stays a test; conftest.py and --test-support are never reclassified. The detection reads imports from the AST, so a module reached only through an import path it cannot match, or a dynamic import, is not caught (SECURITY.md); declare a real helper with --test-support, and never put product code under a test path.

What shipped, and what still requires

1. Execution on any stack — --test-command (shipped).

adversary-gate --baseline before --patch after \
      --test-path run_tests.sh --test-command "./run_tests.sh"

Replaces pytest with a command you supply, on the baseline side and the patch side. The convention is the table below with the pytest names stripped out:

Exit

Meaning

ExecState

0

passed

PASS

1

failed

FAIL — the only code that counts as evidence

2/3/4

the harness itself broke

UNRUNNABLE → UNVERIFIED

timeout

gave up

TIMED_OUT → UNVERIFIED

--test-id is no longer required when --test-command is given: a suite that is one command has no node IDs to name, so the claim is labelled (test-command). The collateral full-suite run uses the same command unless --full-suite-command says otherwise — declaring a non-pytest suite does not silently cost you the collateral-regression check.

2. A coverage adapter — not yet. llvm-cov export, grcov, cargo-llvm-cov and cargo tarpaulin all emit different shapes; normalise them to {files: {path: {executed_lines: [...]}}} and covered_diff_ratio needs no change at all.

3. A mutation adapter — not yet, and this one is the hard one. cargo-mutants is mature for Rust; for C++ the options (mull, LLVM pass-based) are much thinner. Without it suite_strength stays null and every non-Python patch is INCONCLUSIVE, so this is a requirement for MERGE, not an optimisation.

The distinction between (1) and (3) matters. --test-command turns "the gate cannot run my suite" into "the gate ran it", which converts UNVERIFIED into a genuine PASS or FAIL — a real BLOCK becomes possible. Only a mutation adapter converts INCONCLUSIVE into MERGE. Until it exists the honest answer for a non-Python repo stays INCONCLUSIVE, and that is the product working rather than failing.


📊 Measured Pytest Exit Code Taxonomy

Exit Code

Pytest Meaning

ExecState

Gate Behavior

0

Tests passed

PASS

Evaluated against baseline comparison

1

Tests failed

FAIL

The ONLY exit code counted as evidence

2

Collection error (import crash)

UNRUNNABLE

UNVERIFIED $\rightarrow$ INCONCLUSIVE

3

Internal error (harness crash)

UNRUNNABLE

UNVERIFIED $\rightarrow$ INCONCLUSIVE

4

Usage error (bad path / node ID)

UNRUNNABLE

UNVERIFIED $\rightarrow$ INCONCLUSIVE

5

No tests collected

UNRUNNABLE

UNVERIFIED $\rightarrow$ INCONCLUSIVE

-1

Sandbox timeout

TIMED_OUT

UNVERIFIED $\rightarrow$ INCONCLUSIVE


📦 Installation

pip install adversary-gate

Check what PyPI is serving before you trust it — pip index versions adversary-gate. Versions before 3.0.4 are unsupported: each lacks fail-open fixes (a patch reaching MERGE it should not) that came after it. PyPI gets a release when a signed tag is pushed (Trusted Publishing, AG-008 closed), and the GitHub Release below gets the same build. Every other route works too, and none of them needs PyPI:

# straight from the repository
pip install git+https://github.com/Sanflow10/adversary-gate.git
# or clone it
git clone https://github.com/Sanflow10/adversary-gate.git
cd adversary-gate && pip install .
# or don't install it at all
PYTHONPATH=src python3 -m adversary_gate --help

Route

Follows

Needs

pip install git+https://…adversary-gate.git

main

network

git clone + pip install .

main

git

PYTHONPATH=src python3 -m adversary_gate --help

main

nothing

uses: Sanflow10/adversary-gate@main

main

GitHub Actions

GitHub Release wheel (below)

3.0.16 — every fix

nothing but pip

pip install adversary-gate (PyPI)

whatever PyPI has — check it first

network

Install a released wheel

git+… always follows main. For a fixed, versioned build there are two, and today they carry the same code:

# PyPI -- 3.0.16. Versions before 3.0.4 are unsupported: each lacks fixes the
# CHANGELOG lists after it.
pip install adversary-gate==3.0.16
# GitHub Release -- 3.0.16: every fix in this document.
# The tag carries the "v", the filename does not.
pip install https://github.com/Sanflow10/adversary-gate/releases/download/v3.0.16/adversary_gate-3.0.16-py3-none-any.whl

The second is a plain public URL — no PyPI, no GitHub login, no git — and it needs a GitHub Release for that tag to exist: if it answers 404, the Release for v3.0.16 has not been published yet. A release takes two steps, in this order, and only the first is done by hand:

  1. A person pushes a signed tag: git tag -s vX.Y.Z -m "X.Y.Z" and git push origin vX.Y.Z. The push starts Publish to PyPI, which builds the tag's own tree — the core and the MCP package, adversary-gate-mcp — and uploads both through Trusted Publishing, so every file carries a PyPI provenance attestation. It refuses a tag whose pyproject.toml names another version, and it reads no API token. Before it builds anything it checks the tag's signature with git verify-tag against the key in the repository variable RELEASE_ALLOWED_SIGNERS (an allowed_signers line, kept outside the tree so a push cannot bring its own key): a lightweight tag, an annotated tag with no signature, a signature from another key, or no variable at all, and nothing is published.

  2. Actions → Release → Run workflow. It refuses to run without that tag, or with one the same signature check refuses, and refuses while PyPI lacks either package. Then it runs the full test suite and refuses to publish if it fails, builds the wheel and sdist, installs the wheel into a clean interpreter, attaches them to the release, and moves the floating tag of the release's major line (v3) — only forward, so re-releasing an old version does not drag it back.

Publish to PyPI → Run workflow with a tag finishes a run that stopped halfway: files already on PyPI are skipped, never replaced.

Until 3.0.3 the Release workflow created the tag itself, with the workflow's token, and uploaded the core alone with an API token. Events from that token start no other workflow, so for 3.0.2 and 3.0.3 the PyPI publisher never ran: uvx adversary-gate-mcp kept serving 3.0.0, the core files carry no attestation, and those tags are not signed. Like every version before 3.0.4, they are unsupported.

PyPI

Versions before 3.0.4 are unsupported. The audit this project grew from was run against 2.0.1, which predates every fix in this document: --diff and --coverage-json did not exist yet (AG-002).

AG-008 is closed (2026-10-04). The Trusted Publisher is registered in the PyPI project settings (Sanflow10 / adversary-gate / pypi-publish.yml, no environment), and 2.4.0 went up through it — OIDC, no token — with PyPI provenance attestations attached to the wheel and the sdist. Until then the upload step had failed on every tag with invalid-publisher: valid token, but no corresponding publisher, and 2.2.0/2.3.0 never reached PyPI at all.


🚀 Quick Start (CLI & GitHub Action)

First run, in one minute

From the root of a git repository whose agent's change sits on top of main (new files tracked: git add -N them), with pip install adversary-gate coverage:

BASE=main                                         # the commit before the agent's change
W=$(mktemp -d)
git worktree add -q "$W/before" "$BASE"           # the baseline, exactly as committed
git diff --no-ext-diff "$BASE" > "$W/changes.diff"  # baseline -> your working tree
coverage run -m pytest -q && coverage json -q -o "$W/coverage.json"
adversary-gate --baseline "$W/before" --patch . \
  --claim tests/test_shop.py::test_discount_never_negative \
  --diff "$W/changes.diff" --coverage-json "$W/coverage.json" > "$W/answer.json"
echo "exit $?"                                    # 0 MERGE, 1 BLOCK, 2 INCONCLUSIVE
python -c "import json,sys; a=json.load(open(sys.argv[1])); print(a['decision'], '-', a['reason'])" "$W/answer.json"
git worktree remove --force "$W/before"

Replace the --claim with the test that proves your fix (path::test). On a correct one-line fix, expect this first answer:

exit 2
inconclusive - every claim held and the fix is proven, but suite strength's lower bound is 0.38,
below the 0.75 floor (1/1 mutants killed): the change is too small to measure at this floor,
which takes at least 5 killed mutants; see next_steps

That is not a failure: the fix is proven, and the gate says plainly that it could not measure how strong the tests are on a change this small. See Small changes.

Command-Line Usage

adversary-gate \
  --baseline /path/to/before \
  --patch /path/to/after \
  --test-path tests/test_auth.py \
  --test-id test_token_expiry \
  --diff changes.diff \
  --coverage-json coverage.json \
  --model "${AGENT_MODEL_NAME}" \
  --evidence-log evidence.jsonl \
  --report

Produce the two artefacts in your own pipeline, for example:

git diff --no-ext-diff baseline...patch > changes.diff
coverage json -o coverage.json          # after: coverage run -m pytest

Which tests? Name several, or let coverage say

One run verifies any number of claims, and the decision is over all of them (one REFUTED blocks; one UNVERIFIED makes it INCONCLUSIVE):

adversary-gate ... --test-path tests/test_auth.py --test-id test_expiry --test-id test_refresh
adversary-gate ... --claim tests/test_auth.py::test_expiry --claim tests/test_api.py::TestLogin::test_ok

Or name none, and let the gate verify the tests that executed a changed source line — read from coverage.py's per-test contexts, not guessed:

printf '[run]\ndynamic_context = test_function\n' > contexts.coveragerc
coverage run --rcfile=contexts.coveragerc -m pytest
coverage json --show-contexts -o coverage.json
adversary-gate --baseline before --patch after \
      --diff changes.diff --coverage-json coverage.json --discover-claims

A test file the patch rewrote is still picked, and judged with the baseline's copy of it (the baseline oracle, above); it is named under discovery (rewritten_test_files_judged_by_baseline), next to anything that could not be resolved to a node id, and the cap (--max-claims, default 10) with how many it cut. From 3.0.9 a cut test is not left unjudged: with the full suite off (--full-suite-path ""), a patch whose kept claims would merge is INCONCLUSIVE instead, and the reason says to raise the cap or run the suite. A report recorded without contexts is a usage error (exit 3), not "no tests".

Your project's interpreter, its limits, its environment

adversary-gate ... --python .venv/bin/python \
      --timeout 120 --cpu-seconds 60 --memory 2G \
      --pass-env PATH --env PYTEST_DISABLE_PLUGIN_AUTOLOAD=1

Flag

Default

--python

the gate's own

the interpreter pytest runs on — the one your dependencies are in

--timeout

30

wall-clock seconds per claim or mutant run; none removes it

--cpu-seconds

10

CPU seconds per claim or mutant run; none removes it

--full-suite-timeout

900

wall-clock seconds for the collateral full-suite run

--full-suite-cpu-seconds

none

CPU seconds for the collateral full-suite run

--full-suite-memory

4G; none on Android

address space for the collateral full-suite run

--memory

512M; none on Android

address space per run (K/M/G); none — the JVM and Node cannot start under one

--pass-env NAME

—

copy a variable into the runs (they start from a minimal environment)

--env NAME=VALUE

—

set one

--serial-sides

off

run the baseline and patch sides of a check one after the other instead of together, and the full suite after the claims and mutants instead of alongside the claims; forces --mutation-workers 1 (small machines; tests that share a fixed /tmp path or a port). The mutants never run beside the suite either way (AG-043)

--mutation-workers N

1

mutants at once, each on its own copy of the tree. Only for claims that share nothing outside the repository: two runs that meet on the same /tmp path, port or test database make one of them fail, and a failing run counts as a killed mutant (AG-043). Not with --census-min-mutants

The same choices apply to every run — claims, mutants and the collateral suite — except time and memory: the whole suite gets its own limits, because ones sized for a single test killed every suite of real size on both sides (out of time, or pytest dying with MemoryError under 512M of address space): never a MERGE (two failing sides are unverifiable), but the regression the suite would have caught came back INCONCLUSIVE instead of BLOCK (AG-033). The output records them under execution, with the names of the variables and never their values. A run the memory limit killed (MemoryError, Fatal process out of memory, Could not reserve enough space…) is reported as the harness dying, not as a failing test: it says nothing about the patch.

Not a pytest suite, or running a patch you have not seen?

# 1. run your own command instead of pytest (any language, any runner)
adversary-gate --baseline before --patch after \
      --test-path run_tests.sh --test-command "./run_tests.sh"

# 2. wrap every run in bubblewrap: no network, private PID//tmp,
#    read-only system, only the repo writable
adversary-gate ... --sandbox bwrap

# 3. both together -- the usual shape for a patch from someone else
adversary-gate ... --test-command "make check" --sandbox bwrap \
      --require-network-isolation

--test-command needs no --test-id (there are no node IDs to name), and it feeds the collateral full-suite run too unless you pass --full-suite-command. --sandbox bwrap needs bubblewrap installed and refuses to start without it — see Execution boundary for exactly what it does and does not contain.

Exit Codes for CI Integration:

  • 0 — MERGE: Every claim executed cleanly and cleared coverage & suite strength floors.

  • 1 — BLOCK: Regressions or collateral suite failures detected.

  • 2 — INCONCLUSIVE: Infrastructure error, missing test, missing coverage evidence, or weak suite (blocks merge).

  • 3 — USAGE_ERROR: Bad arguments, malformed JSON, or a coverage claim that was not declared as untrusted.


GitHub Action Integration (action.yml)

One ref in. base-sha derives the baseline, the diff and the coverage report itself, so the first thing you install is not a INCONCLUSIVE:

name: Verification Gate

on: [pull_request]

jobs:
  verify-agent-patch:
    runs-on: ubuntu-latest
    steps:
      # base-sha is a commit in history: this is not optional.
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # Your Python, your dependencies -- the tests run here.
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install -r requirements.txt pytest coverage

      - name: Run AdversaryGate
        uses: Sanflow10/adversary-gate@v3
        with:
          base-sha: ${{ github.event.pull_request.base.sha }}
          evidence-log: 'evidence.jsonl'

No test is named: the Action records coverage per test and verifies the tests that executed the lines the pull request changed. Name them yourself with claims (one path::test per line) or test-path + test-id (one id per line); either one turns discovery off unless discover-claims: 'true'.

Any non-zero exit fails the step, and that is the safe default. BLOCK (1), INCONCLUSIVE (2) and a usage or harness error (3/4) all turn the check red. Do not add continue-on-error and stop there: that lets a BLOCK through along with everything else. If some pull requests are INCONCLUSIVE by nature (a change to C++ or SQL, which the gate runs but cannot measure), keep the step from failing and branch on the exit-code output instead (2.13.0 and later):

      - name: Run AdversaryGate
        id: gate
        uses: Sanflow10/adversary-gate@v3
        continue-on-error: true
        with:
          base-sha: ${{ github.event.pull_request.base.sha }}

      - name: Act on the decision
        if: always()
        env:
          EXIT_CODE: ${{ steps.gate.outputs.exit-code }}
        run: |
          case "$EXIT_CODE" in
            0) echo "MERGE" ;;
            2) echo "::warning::INCONCLUSIVE: the gate could not measure this change; a human decides" ;;
            1) echo "::error::BLOCK"; exit 1 ;;
            *) echo "::error::the gate did not finish (exit '${EXIT_CODE}')"; exit 1 ;;
          esac

Branch on exit-code, not on decision: a run that printed no verdict (exit 3/4) still reports decision: inconclusive, and an empty exit-code means the Action stopped before the gate ran. Let 2 pass only where you know the gate cannot measure the change, for example with paths: on a separate workflow; everywhere else, leave the default.

It runs your tests on your Python. The gate itself lives in a private venv on its own 3.12, and your job's python is the same before and after the step. The tests run on the first python/python3 on PATH that can import pytest — the one your setup-python step put there — or on the interpreter you name with python:. When there is none, they run on the gate's own and the run says so. timeout, cpu-seconds, memory, pass-env and env are the CLI flags above.

Which ref? v3.0.16 carries every fix, and the Release workflow that publishes it also moves the floating v3 tag, which follows every 3.x release. Versions before 3.0.4 are unsupported. @main follows main and picks up whatever lands next. For anything that matters, pin the full commit SHA instead (uses: Sanflow10/adversary-gate@<full-sha>), which cannot be moved under you.

What that does, and where each piece comes from:

Artefact

Derived from

baseline

<base-sha>'s tree through a private index (read-tree + checkout-index) — every file as committed, no worktree leftovers; not git archive, which applies the repository's export attributes (AG-044)

diff

git diff --no-ext-diff <base-sha>...HEAD

coverage-json

coverage-command, run in the checkout under the gate's own boundary: the sandbox input, the minimal environment plus pass-env/env, and one fresh writable directory outside the repository (until 3.0.7 it ran as a bare shell step with the job's whole environment)

patch

the checkout itself

If the ref is not in your local history the Action stops with exit 4 and tells you fetch-depth: 0 rather than measuring against a baseline it invented. coverage-command defaults to coverage run -m pytest + coverage json (with per-test contexts when claims are discovered), and coverage in it is coverage.py on your interpreter — installed beside it, not into it, when it is missing. Override it to point at your own suite, or pass coverage-json to supply the artefact yourself.

Everything it derived is one git diff you could have run by hand. When you would rather assemble it yourself — a monorepo, a generated diff, coverage from a non-coverage.py tool — pass the paths explicitly instead:

      - name: Collect coverage evidence
        run: |
          git diff --no-ext-diff ${{ github.event.pull_request.base.sha }}...HEAD > changes.diff
          python -m pip install pytest coverage
          coverage run -m pytest
          coverage json -o coverage.json

      - name: Run AdversaryGate
        uses: Sanflow10/adversary-gate@main
        with:
          baseline: './baseline'
          patch: './patch'
          test-path: 'tests/test_token_expiry.py'
          test-id: 'test_token_expiry'
          diff: 'changes.diff'
          coverage-json: 'coverage.json'
          coverage-floor: '0.80'
          suite-strength-floor: '0.75'
          model: '${{ matrix.model }}'
          evidence-log: 'evidence.jsonl'

Without diff + coverage-json (and without base-sha to derive them) the Action reports INCONCLUSIVE, because there is no coverage evidence to clear the floor with. If you deliberately want to assert the number instead, set coverage-ratio and coverage-source: 'untrusted'.


🤖 Coding agents: Hermes (MCP) and Jev (triage)

Two integrations, one rule: nothing an integration says can make the gate more lenient.

An agent that cannot grade itself — MCP server (Hermes, any MCP client)

pip install "adversary-gate[mcp]"          # in an environment the agent cannot write to
# or, without installing anything:  uvx adversary-gate-mcp  (the same server, as its own PyPI package)
adversary-gate-mcp                         # stdio MCP server
adversary-gate-mcp --print-hermes-skill    # SKILL.md for ~/.hermes/skills/adversary-gate/
adversary-gate-mcp --print-hermes-config   # the mcp_servers block for ~/.hermes/config.yaml

Tools: verify_repo(repo, claims?, pytest_args?, base_ref?) and gate_policy(). verify_repo judges the repository's working tree — committed or not, untracked files included — against a baseline: it writes the baseline commit's tree into a scratch directory, every file as committed (not with git archive, which applies the repository's export attributes — AG-044), writes the diff, runs coverage.py with per-test contexts (data file in a scratch directory, nothing written into the repo), discovers the claims when none are named, and runs the gate. The answer carries decision, mergeable, fix_proven, every claim's oracle, and the full artefact.

The agent says what to judge; the operator says how strictly, and against what. The tool arguments name evidence only. Everything that decides the answer comes from the server's environment, set in the agent's MCP config by whoever runs it:

Variable

Why the agent may not set it

ADVERSARY_GATE_POLICY

gate flags (--sandbox bwrap, --triage jev, floors). An agent that can pass --coverage-floor 0 grades itself. Evidence flags here are refused.

ADVERSARY_GATE_BASE_REF

the baseline is the oracle: an agent could commit a rewritten test and name that commit as the base. Unpinned, the agent's base_ref (default HEAD) must already be on a remote-tracking branch — a commit the agent made locally is refused with inconclusive and nothing runs (3.0.11; before, any commit was taken) — and the answer says "baseline_chosen_by": "agent". An agent that can push, or write .git/refs/remotes, gets past that check. Pinning names the baseline; it is not a defence against an agent that can write the repository's .git (its refs, objects or configuration) — run such an agent where it cannot, as with any code that runs with your privileges.

ADVERSARY_GATE_PYTHON

the interpreter's site-packages are harness too — a plugin installed there runs inside pytest. Use one the agent cannot write to.

ADVERSARY_GATE_REQUIRE_PINNED_BASE

1 makes the pin above mandatory: without ADVERSARY_GATE_BASE_REF, verify_repo answers inconclusive and runs nothing, rather than judge against a baseline the agent named. Off by default, so servers that rely on base_ref keep working; gate_policy reports it as baseline_pin_required.

pytest_args accepts test paths only; an option (-p evil) is refused. The shipped Hermes skill tells the agent to call the gate before saying "done", to fix code rather than tests on block, to never report inconclusive as success, and to promote a self-written skill only on merge.

Risk triage with Jev (TypeSafe AI) — advice that can only tighten

export TYPESAFE_API_KEY=sk-...
adversary-gate ... --diff change.diff --triage jev     # --triage-threshold 0.70 by default

Jev is a calibrated decision model: text and a schema in, a typed answer with a probability out, in ~100 ms. The gate asks it one choice (low / medium / high risk) and one yes/no (does this change a contract callers rely on?). A confident high raises this run's floors — strength confidence to 0.95, coverage floor to 0.90 — so a 5-of-5 mutant score that merges an ordinary change is INCONCLUSIVE for a risky one. A low, a medium, an unconfident high, an error or a timeout change nothing; there is no path from Jev's answer to a more lenient decision, which is what makes it safe to feed it text the patch's author wrote. The answer is recorded in execution.triage either way.

  • The diff leaves the machine (truncated to 64 KiB, https only). That is why it is opt-in.

  • Jev is early-access (launched 2026-09-15). The client follows TypeSafe's published request/response shape and is tested against a local server speaking it — not against the live API from this repository.


🛡️ Execution boundary (read this before trusting it with untrusted code)

src/adversary_gate/sandbox/runner.py applies POSIX resource limits on every run: RLIMIT_CPU, RLIMIT_AS, RLIMIT_FSIZE, RLIMIT_NOFILE, RLIMIT_NPROC, plus a timeout that kills the process group. By itself that is a resource-limited runner, not a security sandbox — it bounds how much a test can do, not what.

Android / Termux. On Android, RLIMIT_AS (--memory, --full-suite-memory) aborts some processes at start, because of how the runtime reserves address space; in the runs reported in issue #4, no finite address-space limit worked there. Since 3.0.5 neither limit is set on Android unless you pass one: the gate recognises it (sys.platform == "android", or sys.getandroidapilevel present) and the artefact records execution.android. The other limits still apply.

--sandbox bwrap — opt-in, and what it honestly buys

adversary-gate ... --sandbox bwrap

Wraps every claim run and the collateral suite in bubblewrap:

Property

Effect

--unshare-net

no network at all — exfiltration has nowhere to go

--unshare-pid

the guest cannot see or signal host processes

--unshare-ipc, --unshare-uts

no shared memory or message queues with the host, its own hostname

--unshare-user-try, --cap-drop ALL

a user namespace where the kernel allows one, and no capability inside it

--tmpfs /tmp + HOME=/tmp

nothing persists between runs, nothing lands in the repo

system tree read-only

/usr, /bin, /lib, /etc cannot be written

only the repo is writable

a test cannot touch anything outside the patch

If bwrap is missing the gate refuses to start (exit 3) rather than quietly running unsandboxed — asking for isolation and not getting it is the failure mode the flag exists to prevent.

What it is still not. There is no seccomp profile, the repository itself is writable, and the kernel is the host's: a kernel bug is reachable from inside. Outside the repository, the guest sees only the read-only system tree, the interpreter's prefix, and the user site-packages and scripts directory (not the rest of ~/.local). It contains casual and opportunistic damage; it is not a defence against code whose purpose is to escape. For that, and for anything where a compromise would matter, use the outer sandbox below — this flag is for the case where you have no infrastructure and the patch is probably fine, not the case where the patch is known hostile.

--require-network-isolation now has something real to point at: under --sandbox bwrap the network genuinely is gone, so ADVERSARY_NETWORK_ISOLATED=1 becomes a statement you can make honestly instead of a wish. Outside bwrap it remains a declaration, not a mechanism — an assertion by you about your environment, which the tool cannot verify about itself.

What no mode of this tool provides

Network namespaces beyond bwrap, seccomp, chroot, containers, an unprivileged user, or any restriction on what the test code may do by design. The test code runs arbitrary code from the repository, with whatever privileges you gave the process.

Run hostile patches inside an outer sandbox you control — rootless container, VM, or an ephemeral isolated runner. AdversaryGate verifies test outcomes; it does not contain hostile code.

The GitHub Action already runs on an ephemeral runner: the job's VM is thrown away when it ends, which is that outer sandbox, as long as the job holds no secret the patch could read. Off GitHub, the repository's image is a starting point. It runs as an unprivileged user (uid 10001), never as root. Your project's dependencies are not in it, so extend it:

FROM adversary-gate-mcp            # docker build -t adversary-gate-mcp .
USER root
COPY requirements.txt /tmp/
RUN pip install --no-cache-dir -r /tmp/requirements.txt
USER 10001

Then run the gate with no network, no capabilities, a read-only root and a throwaway copy of the repository (a fresh clone, never your working tree: the tests write into it):

docker run --rm --network none --cap-drop ALL --security-opt no-new-privileges \
  --read-only --tmpfs /tmp:rw,exec --tmpfs /home/gate:rw \
  --pids-limit 512 --memory 4g \
  -v "$PWD/throwaway":/work --entrypoint adversary-gate my-project-gate \
  --baseline /work/baseline --patch /work/patch \
  --diff /work/changes.diff --coverage-json /work/coverage.json --discover-claims

--network none is the boundary --require-network-isolation asks you to vouch for; inside it the declaration is true. A container shares the host's kernel: for code known to be hostile, a VM is the stronger boundary.

What a test cannot see, by construction

Two limits that no runner setting closes, written down so nobody has to discover them:

  • The code under test runs in the same process as the test runner. A source module the tests import can reach into pytest itself — rewrite a report, patch assert's helpers, swap a fixture — and nothing in the evidence would show it. AG-032 takes the configuration away from the patch; it cannot take the interpreter away from the code being tested.

  • Code can detect that it is under test. if "pytest" in sys.modules: (or a check on the environment, the call stack, the clock) lets a module behave in the test run and misbehave in production. Mutation testing does not help when the branch that matters is the one the tests never take.

Both are deliberate deception, not accidents, and both are reasons MERGE means "permission for a human to look", never "ship it".

Two more that the branch mutants narrow but do not close, measured on the case they were built for:

  • A special case kept in data, not in syntax. return {0: 999}.get(x, x + 1) has no conditional for a branch mutant to remove, and the gate never changes a literal. If no claim passes 0, nothing the gate does reaches the 999: next to honest, tested changes that bring the sample to five kills, it merges.

  • A hidden branch whose body has its own line, with an operator on it. Coverage sees that body — it counts against the ratio — and the default floor (--coverage-floor 0.80) tolerates up to one changed line in five that no claim executed. A body with no operator (return 999) is broken outright and blocks; one with an operator (return x + 998) is a single surviving operator mutant in the ratio, which enough honest kills elsewhere can outweigh (eleven against one, at the defaults). --coverage-floor 1.0 refuses any changed line that never ran.


🤝 What this decision is, and what it is not

MERGE means every claim the gate was asked to check executed, and the evidence it could produce cleared the floors you configured. That is a statement about measurements, and nothing else.

It is not:

  • a judgement about design, naming, security posture, or whether the change should exist;

  • a substitute for human review — MERGE is permission for your team to look, not an instruction to merge;

  • proof about code the gate could not execute. Where it could not measure, it said INCONCLUSIVE rather than guessing, and that silence is the product working.

The decision to merge belongs to the person who signs the PR. The gate's job is to make sure that person is not being lied to by their own pipeline. If a MERGE here becomes "the gate said yes, ship it", you have rebuilt the exact self-deception this project exists to remove — just with a better audit trail.


📈 Provenance & metrics

Every execution logs ctx_model and ctx_commit into the audit trail. Running patches through the same harness allows compare_models() to report verification rates side by side — which model's patches the gate could verify, and how often the evidence condemned them.

self_deception_index (unverified_merges / merge_count) is still computed, and is always 0 on logs this gate wrote (AG-029): decide() never returns MERGE with an unverified claim, so the numerator cannot grow. It only means something when the decision records come from a pipeline that can merge unverified work — another gate, or a hand-built log of what actually shipped. The metrics that would say whether MERGE means anything — overrides (PR merged while the gate said BLOCK/INCONCLUSIVE), reverts within N days of a MERGE, INCONCLUSIVE resolved to MERGE — need data from outside the gate and are not implemented. See docs/AUDITORIA_PRODUTO_v2.1.1.md §4.4.


⏱️ Performance

python3 scripts/benchmark.py --runs 5          # add --sandbox bwrap to include it

Measured on this machine (Linux 6.8, x86_64, CPython 3.12.3, no sandbox), on the same fixture the CI Action smoke test uses — 3 repetitions, one warm-up discarded:

median

20,7 s

p95

21,1 s

range

20,7 – 21,1 s

decision

exit 0 / merge

Read the breakdown before you judge that number. One decision is 8 pytest invocations, not 3:

1 baseline   1 patch   4 stability rounds (--rounds-used)   ≥1 mutant   1 collateral full-suite

Each one pays Python + pytest startup — 2,29 s here, because this machine auto-loads 9 pytest plugins (seleniumbase, pytest-html, xdist, metadata, rerunfailures, ordering…). The test itself executes in 0,01 s. So:

8 runs × 2,29 s = 18,3 s  → environment, not gate
                    ≈2,4 s  → AdversaryGate

Disabling plugin autoload takes a single start from 2,47 s to 0,53 s on this box — that step is measured; a ~5–6 s total on a clean machine is arithmetic, not a measurement, and is labelled as such.

The runner hands the child a minimal environment by design, so a variable in your shell does not reach it. Since 2.3.0 you pass it on purpose: --env PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 (Action: env:) takes the plugin cost out of all eight runs, and the artefact records that you did. Until then, on a machine with a heavy plugin set, the gate paid the full cost eight times over with no way to opt out — which is why the environment has to be printed next to the number.

This is not a CI performance gate. Timing on a shared runner is noisy enough that any threshold is either too loose to test anything or tight enough to go red on somebody else's load. Run it by hand, and keep the machine next to the figure. A latency number with no environment attached is not a measurement — it is an advertisement.


🧭 Compatibility & deprecation

What counts as breaking. Anything a caller or a parser depends on:

Surface

Why it is a contract

exit codes 0 / 1 / 2 / 3

CI branches on them

decision and outcome values

dashboards and status checks match on them

field names in evidence.jsonl and the decision artefact

they are parsed, diffed and archived

CLI flags and action.yml inputs

pipelines pass them

the 0 / 1 / 2/3/4 test-command convention

every adapter is written against it

What is not. Prose, argument help, comments, module layout inside src/, and anything the docs never promised.

How a deprecation happens. At least one minor release with the old surface still working and a warning naming the replacement, then removal in the following minor release. A breaking change is a major bump.

The rule that matters here, and it is the opposite of the usual one:

A deprecated input must fail loudly — exit 3, with the replacement named — and must never be quietly ignored.

That is not etiquette, it is the same thesis as everything else in this repository. A flag that stops being read while the CLI keeps accepting it means someone passes --coverage-ratio 0.95, gets no coverage floor, and reaches MERGE on evidence they thought they had gated. Silent deprecation is fail-open, wearing the costume of good manners. So an unknown or retired flag is a usage error, always.


📄 License

Copyright © 2026 Sanflow (Sanflow10).

Releases from 3.0.0 on are distributed under the GNU Affero General Public License v3.0 (AGPL-3.0-only), with the additional terms that section 7 of that license allows — see NOTICE. The 2.x line, up to and including 2.14.2, was published under the MIT License, and copies obtained under it keep that license. Only the latest release is distributed: the tags, GitHub Releases and PyPI files of earlier versions are no longer published.

For a commercial license without the AGPL obligations, write to sanflow086@gmail.com.

Available Tools

2 tools
gate_policyShow the gate policyA
Read-onlyIdempotent

Show the verification policy this server enforces: floors, baseline, interpreter, and what the caller may set.

When to call: before ``verify_repo``, to learn whether the baseline is
pinned by the operator or chosen by you, which interpreter runs the tests,
and which floors a merge has to clear -- or after an inconclusive or block
answer, to explain it.

Read-only and fast: it runs no tests and changes nothing. The policy comes
from the server's environment (ADVERSARY_GATE_POLICY, _BASE_REF, _PYTHON),
set by whoever configured the server; no tool can change it.

Returns ``version``, ``operator_flags``, ``defaults``, ``baseline``,
``python`` and ``agent_controls`` (the arguments of ``verify_repo`` you
decide). An invalid operator policy returns ``error`` instead.
NameRequiredDescriptionDefault

No parameters

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.8/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Goes well beyond the readOnly/idempotent/non-destructive annotations by disclosing that no tests run, nothing changes, the policy is sourced from server environment variables (ADVERSARY_GATE_POLICY, _BASE_REF, _PYTHON), no tool can mutate it, and an invalid operator policy yields an 'error' payload instead. That is exactly the kind of context annotations cannot convey.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Front-loaded purpose sentence followed by clearly labeled 'When to call', read-only, and returns sections — scannable and mostly waste-free. The final enumeration of return keys largely duplicates the existing output schema, a small redundancy.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a zero-parameter read tool with full annotations and an output schema, this covers every decision an agent must make: when to call it, that it is safe and fast, and where the values come from. Nothing needed to call it correctly is missing.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The tool takes zero parameters, so the baseline of 4 applies. The description compensates by explaining where the policy values originate (operator environment) rather than from caller input, clarifying why no arguments exist.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Opens with a specific verb+resource — 'Show the verification policy this server enforces' — and enumerates the substance of that policy (floors, baseline, interpreter, agent-settable knobs). It is immediately distinguishable from its only sibling, verify_repo, which runs tests rather than reporting policy.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Contains an explicit 'When to call' clause naming two concrete triggers: before verify_repo to learn the baseline/interpreter/floors, or after an inconclusive/block answer to explain it. The alternative (verify_repo) and the condition selecting this tool are stated outright.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

verify_repoVerify a repository changeA

Judge the working tree of a git repository against a baseline, by running its tests.

When to call: after changing code in ``repo`` and before reporting the task
as done. Call ``gate_policy`` first if you need to know the floors or
whether the baseline is pinned. Python/pytest only; changes in other
languages come back inconclusive.

What it does: writes the baseline commit's tree (every file as committed),
diffs it against the working tree (uncommitted and untracked files
included), runs the suite under coverage.py, then runs the gate: the
claims on the baseline and on the patch, the baseline's copy of any test
the patch rewrote, and mutation testing on the changed lines. Nothing is
committed or reverted.

Side effects: the repository's tests execute -- its code runs, so they may
do whatever they do when you run pytest yourself. The coverage run uses the
repository as working directory; the gate's own files go to a temporary
directory that is removed afterwards. It can take minutes. A run longer
than ``timeout_seconds`` (default 3600: the gate's own full-suite run may
take 900 alone) returns inconclusive, never a pass, and its
``next_steps`` names ``timeout_seconds`` as the limit to raise.

Arguments: ``repo`` is a path inside the git repository. ``claims`` are
pytest node ids (``path::test``) to verify; empty -> discovered from the
tests that executed a changed line. ``pytest_args`` are test paths or node
ids the coverage run collects (default: the whole suite); options such as
``-k`` are refused. ``base_ref`` is used only when the operator did not pin
ADVERSARY_GATE_BASE_REF.

Returns ``decision`` (merge / block / inconclusive), ``mergeable``,
``fix_proven``, ``reason``, per-claim results with the oracle used,
``diff_coverage``, ``suite_strength``, ``next_steps`` and the full
artefact. merge permits a human review, not a release; block means fix the
code, not the tests; inconclusive means something was not measured -- say
what. ``next_steps`` lists what would earn a MERGE (cover these lines, kill
this surviving mutant, add a new test, fix the code); do them and call
again. It never asks you to edit an existing test, a harness file or a
floor, and doing so does not help: the baseline's tests are the oracle.
NameRequiredDescriptionDefault
repoYes
claimsNo
base_refNoHEAD
pytest_argsNo
timeout_secondsNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.9/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations only declare the safety profile (readOnly=false, destructive=false, openWorld=true, idempotent=false); the description adds far more: repo test code actually executes with its own side effects, coverage runs with the repo as cwd, gate files land in a temp dir that is deleted, nothing is committed or reverted, the run can take minutes, and a timeout yields inconclusive rather than a pass.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Long but dense and well front-loaded (purpose, when-to-call, what-it-does, side effects, arguments, returns). Nearly every sentence carries operational weight, though the Returns paragraph partially restates what the output schema already exposes and could be tightened.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a slow, side-effecting, open-world verification tool, the description covers invocation timing, prerequisites, cost, failure modes (timeout -> inconclusive), the semantics of the decision output, and what next_steps will and will not ask for. Nothing an agent needs to call it correctly is missing.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description carries the full burden and does: repo is a path inside the git repo, claims are pytest node ids (path::test) with an empty->discovered rule, pytest_args defines what the coverage run collects and refuses options like -k, base_ref only applies when ADVERSARY_GATE_BASE_REF is unpinned, and timeout_seconds has default 3600 with the 900s full-suite note.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a concrete verb+resource+method: judge a git working tree against a baseline by running its tests. It also scopes the tool (Python/pytest only) and implicitly separates it from the sibling gate_policy, which only reports floors/pinning.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicit when-to-call (after changing code, before declaring done), an explicit alternative and its trigger (call gate_policy first if you need floors or pin status), and an explicit exclusion (non-Python changes come back inconclusive). Nothing is left to inference.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 1 tool updatev3.0.5
    • Changedverify_repo1 field changed
      • changedInput schema / properties / timeout_seconds / default
        Previous value: -900New value: +3600
  2. 2 tool updatesv2.8.0
    • First observedgate_policy
    • First observedverify_repo

TDQS

A4.7/5.0

Scored across 2 tools

Disambiguation5/5

verify_repo runs the gate and returns a decision, while gate_policy is a fast read-only dump of the enforced policy; the descriptions explicitly frame one as the action and the other as the pre-flight/explanation lookup. There is no plausible way to misselect between them.

Naming Consistency5/5

Both names use a consistent snake_case verb_noun pattern (verify_repo, gate_policy) and share the server's 'gate' vocabulary. No mixing of camelCase, prefixes, or verb styles.

Tool Count3/5

Two tools is thin, but the server has a deliberately narrow remit: enforce one verification gate. Still, the policy/verification split is the only structure present, so the count is borderline rather than well-scoped.

Completeness4/5

The pair covers the whole stated workflow: learn the policy, then run the gate, with arguments for claims, pytest args, base_ref, and timeout. Minor gaps remain (no read-only way to inspect baseline commit/tree or preview discovered claims without executing the suite), but agents can work around them.

Maintenance

ActivityNo data
ResponsivenessResponsive

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    Enables verification of AI coding agent self-reports against git diff truth and a deterministic gate, producing pass/regenerate/reject directives to ensure claimed work matches actual changes.
    6
    22 PyPI
    AGPL 3.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    A runtime gate for coding agents. Blocks the tool calls that wreck a repo (force-push main, rm -rf, secret exfiltration, CI wipe) and lets normal build and commit work through. Machine-checked git-branch core (z3); the rest is high-precision heuristics. Tested on 3,790 real CI commands, 0 false blocks.
    1
    MIT