CI gating¶
mylonite gate runs the whole flow — find an exploit, write a regression test,
validate it against the differential oracle, and (opt-in) open a PR that gates
CI on it.
The end-to-end flow (local)¶
Against your own app this drives a real agent and makes real model calls, so budget minutes and API spend rather than seconds.
Sizing --max-llm-calls
Every seed is guaranteed a floor of the budget before the rest is shared, so a
large tool surface cannot let the first few seeds drain the pool and leave the
others untried. Probes that chain two tools cost two or three calls each, so a
server with many egress or action tools wants a larger budget than the default
of 50. If the budget still runs out, the whole scan aborts (exit code 3) and
each seed that had not finished becomes a NOT TESTED row with
MYL-NT-019, so its class cannot read resisted.
An attack module that fails to load is reported the same way, with
MYL-NT-015, so the gate does not pass on a
scan that never ran its classes.
--max-llm-calls is therefore a floor-adjusted budget, not a hard ceiling.
Worst case is cap + (seeds - 1) × max(2, cap ÷ seeds) — with --max-llm-calls
50 against a surface that synthesises 100 seeds, up to roughly 250 calls. That
is deliberate: the alternative is that the seeds which happen to start last
make no call at all, and a probe that never ran is indistinguishable from a
target that resisted. Size CI spend against the worst case, not the flag value.
The budget bounds only gate's scan phase. --help already says so ("LLM
call budget for the scan phase"). Validation — re-driving each kept finding
against both twins, --iterations times, plus a metamorphic robustness pass —
happens after the scan and has no call budget of its own; on most runs it
makes the majority of the calls.
The formula depends on which kind of target is being gated:
- The bundled reference agent —
iterationsfull differential drives (raw twin + guarded twin, so× 2), plus 7 built-in metamorphic re-paraphrasings of the same attack — each also driven against both twins (another× 2) to check the finding survives rewording, not just the literal payload. So one finding costs roughly(iterations + 7) × 2re-drives, each itself several LLM calls (customiser, planner, judge). At the default--iterations 3that's(3 + 7) × 2 = 20re-drives for ONE finding — a two-finding scan is roughly 40, not one. - Your own target — there is no in-repo guarded twin and no
metamorphic pass, so the cost is flatter:
iterationsre-drives of the real target, plus anotheriterationsre-drives of a boundary-guarded twin only when a guard adapter is configured (the default unless--fast). At the default--iterations 3that's 3 re-drives per finding with no guard configured, or 6 with one.
Either way, every finding a scan proves is gated now, not just the first,
so this cost scales with findings, not just --iterations. Size CI
spend against the whole gate run — scan plus validation — not against
--max-llm-calls alone.
For a hard limit, set MYLONITE_MAX_LLM_REQUESTS (or pass the global
--max-llm-requests N option before gate). It counts every request the
whole gate run sends — scan, validation and retries — and never sends one
past the limit. Hitting it stops the run with exit 3 and a NOT TESTED
result: no test is generated or validated past that point, and the run
never reads as a pass. Size it from a run's llm: lines plus headroom for
retries.
scan, validate and gate each print one line estimating this before
any call is sent — a call-count range built from this same formula (seed
count after filters, --iterations, twins, metamorphic re-drives), plus
the ceiling above when one is set. It never prices the run: no provider
carries a price yet, so the line says cost is unknown rather than
guessing one. A --dry-run scan makes no call and skips the line. Just
above it, a short run plan names the model, the system-prompt source, the
consequential tools the run may drive and the never_call list, then says
to run against a test instance, never production (see
scan).
# against the bundled reference agent
mylonite gate reference:vulnerable
# against your own MCP app
mylonite scan --command "python" --arg "-m" --arg "your.server" --scaffold target.yaml
mylonite gate --target-file target.yaml --authorize custom --open-pr
gate does not auto-wire a seed_arm. scan does: for a target that declares W2
without one, scan looks for a store-and-recall pair on the live server and uses it.
gate refuses the same target instead, because W2 has nothing to plant with. For such a
target, run mylonite scan --target-file target.yaml --authorize <family> first. When it
wires a seed_arm, it writes the wired target to .mylonite/scans/<timestamp>/target.yaml;
pass that file to gate --target-file.
Gating every finding¶
A scan often turns up more than one weakness. gate generates and validates
every finding the scan proves, in deterministic order (sorted by pattern id),
and commits every KEPT one's test to a single branch behind a single PR:
gate never commits an unproven finding. A finding whose validation passed
but had no passing differential or effect leg (or a black-box keep that rests on
the LLM judge alone) reads STABLE, NOT PROVEN. gate treats it as a
candidate:
- no test, exploit or report for it is written to the gate directory or
committed; its evidence goes to the
-rejfolder next to--out, with its validation report; - the console prints one line per candidate, with the reason and how to get it
proven: declare
control_envand drop--fast, or declare aneffect_probe, then re-rungate. A finding on a black-boxresttarget can't be proven this way, sogatenever commits one; - when another finding was kept,
PR_BODY.mdlists the candidates under "Candidates (not proven, not committed)"; - an earlier kept test, exploit and fixtures with the same id stay in place, byte for byte;
- with nothing kept, no PR is opened and
gateexits10.
3 findings: validating each (about 3x the single-finding validation cost)
1 kept, 1 not proven (candidates, not committed), 1 rejected
Exit codes¶
gate's exit code tells a CI script what the run produced:
| Code | Meaning |
|---|---|
0 |
The scan ran and found nothing to gate. |
9 |
At least one proven finding was kept; its gate test is written (and committed with --open-pr). |
10 |
Nothing was kept, but at least one finding is a STABLE, NOT PROVEN candidate. No test was written. |
5 |
Every finding that reached a verdict was rejected. |
An aborted scan still wins over all of these (see Budget exhaustion and findings). The full table is in Reading the results.
Upgrading: gate used to exit 0 both for a kept finding and for a clean
run. A kept finding now exits 9. A CI script that treated 0 as "gated" needs
to check for 9; one that treats any non-zero exit as a failure now fails when
gate keeps a finding, which is the point of a gate. The scaffolded discovery
workflow and gate-action read these codes: 0, 9 and 10 are a successful
run (a ::notice:: for 0/9, a ::warning:: for 10 naming how to prove each
candidate), and only 1-8 fails the job — an infrastructure problem (config,
budget, provider, the PR step itself), not a result of the scan. gate-action
also exposes the raw exit-code and a result output (clean/kept/
candidates), and an opt-in fail-on input (none by default, or kept/
candidates) for a caller that wants a red signal on a specific result — see
The reusable Action.
A finding that was generated and validated but not kept is still named in
PR_BODY.md, with the reason, under "Other findings (not gated)" — it isn't
silently dropped the way it used to be. If a generator or validator failure hits
one finding specifically, the rest are still gated; only a run where every
finding fails the same way falls back to the old single-finding exit codes (6
generate failed, 7 validate failed).
The branch name reflects how many findings it carries: gating exactly one kept
finding keeps the historical mylonite/gate-<pattern_id>; gating several uses
mylonite/gate-<hash>, a short stable hash of the kept pattern ids. Both keep the
mylonite/gate- prefix, so anything that matches on it (a branch-protection rule,
a script) keeps working either way.
A finding you haven't fixed yet¶
gate commits the test for a finding that still works on your app as a
pending fix, so the gate PR doesn't turn CI red the day it merges. The test
carries one extra line, @testkit.pending_fix(...), and moves through three
states:
- Not fixed yet. The attack still lands, so the test is an expected
failure (
xfail) and the check stays green.pytest -ralists it asXFAIL ... pending fix: .... - Fixed. No re-drive attempt lands and resistance is confirmed on at
least one of them (3 attempts by default), so the test passes and the
check fails on purpose with: The attack did not land
on any re-drive attempt this run. If your fix has landed, remove the
@testkit.pending_fix(...)line above this test. An attack that lands rarely can still resist three attempts, so if you haven't shipped a fix, re-run the check first. If some attempts were inconclusive (the agent made no tool calls), the summary also shows aRedriveInconclusiveWarningsaying on how many attempts resistance was confirmed; treat that pass as thinner and re-run before removing the marker. Underfilterwarnings = ["error"]that warning fails the test; see Re-drive attempts for the one-line filter that keeps it a warning. - Marker removed. The test is a regular gate. It passes while the fix holds and fails the check if the attack works again.
This applies to findings on your own target that the gate re-drives with
testkit.assert_target_resists. A control-efficacy test
(assert_control_holds) and a test against the bundled reference agent
already pass on your current build, so they are committed without the marker.
pytest -m mylonite_pending_fix lists every test still waiting on a fix. A
check that never reached a verdict (a missing fixture, an unresolved model, a
target that didn't start) still fails; only the attack landing counts as "not
fixed yet". The PR_BODY.md "How this is gated" section says which tests are
pending.
Budget exhaustion and findings¶
--max-llm-calls bounds the scan phase (see the sizing box above). If the
budget runs out mid-scan but the scan already proved a real finding, gate still
generates, validates, and opens or prints the PR for that finding — nothing is
thrown away. The run's exit code is still the scan's own: 3, not 0 or 9 — the
same "abort always wins" rule scan follows on its own (see
Reading the results). Treat exit 3 from
gate as "check PR_BODY.md before you decide this was just an infrastructure
failure", not as a reason to discard the run.
The error line carries reason code [MYL-ABT-001]. The other abort and
incomplete-coverage errors gate prints carry codes the same way; look each one
up in Reason codes.
If a custom target does not come up while gate is validating a finding, gate
stops with one [MYL-ABT-006] line and exit 2. The line says how many runs had
finished, that later findings were not validated, and where the scan results are.
W3 or W4 with no effect_probe¶
On a custom target that declares W3 or W4 and no effect_probe, nothing reads the
target's own state, so each of those classes reads NOT TESTED
(MYL-NT-017) unless an attempt fires. A gate that finds
nothing there exits 2 ("Coverage was incomplete"), not 0 ("nothing to gate").
Upgrading: a gate you committed against such a target before this change turns red.
Add an effect_probe to the target file (re-run mylonite scan --scaffold to get a
proposed one), or remove W3/W4 from weakness_classes.
Pre-flight order¶
gate runs its pre-flight checks in this order, all before any LLM call:
- The repository checks, each exiting
8: a--basethat is empty, contains whitespace or starts with-; with--open-pror--workflows, an output directory outside the repository; with--open-pr, a working tree with staged files or uncommitted changes to tracked files. - On Windows without long paths, the output path check, exiting
2(see Windows path length). - The target,
--authorize, model, provider key and uncoverable-class checks, each exiting2.
So when both kinds of problem are present, the exit-8 error is the one you see first.
For a custom (--target-file) target, gate then calibrates the declared
effect_probe — real writes proving it can see a change — once, before its scan
phase runs. This is the same calibration scan/validate/ablate each run on
their own path; see Calibration for what
calibration.controls permits and Reason codes for what an
uncalibrated probe reads as.
What gate touches¶
By default, nothing outside its own output directory. gate writes
.mylonite/gate/ — the generated test, the exploit JSON, the validation report,
your target.yaml and PR_BODY.md — and then prints the exact git and gh
commands to commit and open the PR yourself, with every path rendered using /
(on Windows too) so the printed git add/--body-file paths run as printed in
cmd, PowerShell or bash alike. With neither flag below, your repository is not
modified: no branch, no commit, no workflow files.
Each finding gets a short, stable id: its weakness class and six hex digits
of a hash of its pattern id, for example w2-1a2b3c. Its test is
test_w2-1a2b3c.py and the exploit it loads is exploit_w2-1a2b3c.json. The
full pattern id is in the test's docstring and the exploit JSON, gate prints
which pattern each test gates, and a multi-finding PR body has a table mapping
each pattern id to its test.
One finding is written straight into .mylonite/gate/, and on the reference
target its replay recordings sit beside it in .mylonite/gate/fixtures/. With
two or more, each kept finding gets its own folder, .mylonite/gate/<id>/,
holding its test, exploit JSON, validation report and, on the reference target,
the fixtures/ its test replays. With --target-file, each finding folder also
gets its own redacted target.yaml, because the test loads the file from its
own folder. pytest .mylonite/gate/ runs every kept test; on the reference
target it runs offline, with no provider key. A rejected finding's files,
recordings included, move to .mylonite/gate-rej/<id>/ and are never
committed.
Gate dirs written by earlier versions, with folders and tests named after the
full pattern id, keep replaying unchanged. When you upgrade, re-run gate with
--workflows, or change the mylonite== pin in your committed gate workflows
to the new version, before committing new tests: an older pin cannot replay
the new recordings, so those tests would fail in CI on their first run. gate
prints a warning naming the workflow file when it finds an older pin. A new
run into the same --out writes the short-id layout beside the old (full
pattern id) folders and leaves them alone, so pytest .mylonite/gate/ then
runs both; delete the old folders once the new tests are committed.
Reusing --out for a finding this run keeps again is safe: nothing is lost.
If the same finding id does not keep this time, its earlier KEPT test, exploit
and recordings are put back exactly as they were, never overwritten and never
left half-rewritten. If a different finding happens to land on the same short
id in one run, the second one gets a numbered folder (w2-1a2b3c-2) instead of
colliding with the first.
gate never deletes anything from --out, including a finding this run
never finds at all. "Not found again" is not evidence a finding is fixed —
a flaky planner miss, a NOT TESTED or aborted attempt, or a target file that
now declares a narrower weakness_classes can all make a real, still-live
finding vanish from one run's own result without the weakness being gone.
Instead, each earlier kept finding this run did not touch — identified by its
own naming and an exploit_*.json record inside, never a file of yours that
merely sits nearby — is named in a report at the end of the run: "left in
place, not re-proven this run." When the scan itself was partial or aborted,
the report says so, since that run may simply not have reached the finding
yet. Delete a leftover's folder (or, for a single finding, its root-level
test and exploit files) yourself once you've confirmed the weakness is
fixed. A manual git add .mylonite/gate can still pick up a leftover you
haven't deleted — stage with the printed command instead (see below).
Windows path length¶
gate refuses an output directory Windows cannot hold before it calls a model
or starts your target. Without long paths enabled, Windows caps a file path at
259 characters. gate works out the longest path it could write under --out
(a folder per finding, rejected findings included) and, if that is over the
limit, prints one line naming the path, its length and how many characters to
cut from --out, then exits 2. After the scan it checks again with the real
findings, before any validation. Linux, macOS and Windows with long paths on
skip the check.
With the default .mylonite/gate, a project path of up to 200 characters fits:
the deepest file, .mylonite/gate-rej/<id>/fixtures/<recording>.json, uses 58
of the 259. CI runs a two-finding gate from a 200-character project root with
long paths off. To go deeper, pass a shorter --out or enable long paths
(LongPathsEnabled in the registry, plus git config core.longpaths true for
teammates who clone the gate). Replay recordings are named by the first 12 hex
digits of their key (17 characters); recordings made by earlier versions keep
their 64-digit names and still replay.
gate redacts secret-shaped values in the exploit JSON, the validation report
and PR_BODY.md (its evidence lines and the optional LLM suggestion) before it
writes them. A key the target echoed into its reply, a tool result or a
validator error shows up there as ***REDACTED***. The recorded replay files
under fixtures/ (written for the bundled reference agent) are redacted the
same way before they are written, and they still replay offline. These files
use cache_key_version: 3 in fixtures/_meta.json: a stored reply, and the
target's echo of a value the model introduced, are keyed in redacted form, and
every other secret-shaped value in the conversation (including a key the target
leaked and the model then forwarded) is keyed by its content through a slow
salted hash. Two runs whose tool output differs only inside a secret-shaped
value never share a recording, and neither the files nor their names hold a
secret-shaped value or a fast hash of one. A short or unshaped credential is
hashed into the key like any other text; see
Limitations.
A re-run of gate replaces the finding's own fixtures/ folder and puts the
earlier folder, test and exploit back if the run does not keep or stops on an
error. A fixtures/ that is a symlink or junction fails that finding with one
line; gate never follows or deletes it. validate checks its fixtures/
folder before the first model call and exits 2 with one line when the folder
was recorded in an older format (cache_key_version 1 or 2), names a version
Mylonite does not know, has a damaged _meta.json, or holds recordings with
no _meta.json; delete that folder and run again.
An older folder replays when its _meta.json names its cache_key_version.
One that holds recordings and no _meta.json fails replay with a message
rather than guessing how it was keyed: add {"cache_key_version": 2} (1
for the oldest sets) as its _meta.json, or re-record it. Fixtures recorded
before this release were not redacted; re-gate them, or review them before
you share them.
Separately from redaction, any target-controlled text quoted in
PR_BODY.md — a tool name, an argument, an evidence value, a validator's
evidence detail, the "Located at" locus — is quoted as a Markdown inline
code span the value's own content cannot break out of: the fence is always
longer than the longest backtick run already in the value, a value
starting or ending with a backtick gets a padding space, and control
characters are stripped first. A value containing its own backtick can no
longer close the span early and render the rest of the value as Markdown.
The same quoting carries through to the SARIF message and the JSON
bundle's label field, since both render from the same locus/evidence
data PR_BODY.md does. A few sites quote a target-controlled tool name
inside an existing fence or indented block instead of giving it its own
span — a recommendation's invariant line, and the W1 description-pin's
YAML config_snippet, which a real control parses back out and must keep
the tool name as a literal key — so those strip only control characters
(in particular the newline, which could otherwise de-indent the line or
start one that closes the surrounding fence) rather than adding a nested
span.
Two flags opt in to the rest, independently:
--open-prcreates the branch, commits the gate directory, pushes, and opens the PR viagh. Ifghis missing or unauthenticated it still commits and prints the remaining two commands. Once the commit exists,gatechecks out the branch you started on (or, on a detached HEAD, the same commit), whether the PR opened or the push orghstep failed. The gate branch is kept, and the output names it: "The gate branch 'mylonite/gate-…' is kept with the committed gate output; you are back on 'main'." The committed gate output now lives on that branch, so it leaves your working tree; rungit checkout <gate branch>to look at it.--workflowsscaffolds the two.github/workflows/templates described below — this writes to disk whether or not--open-pris also set, but only once this run has kept at least one finding. A workflow wires in the testgatejust committed, so a run that keeps nothing (a clean scan, every finding rejected, or only STABLE, NOT PROVEN candidates) writes no workflow file at all; the summary says so plainly, naming why, instead of exiting as if nothing had been asked. When something is kept, the printed summary lists the workflow file(s) it wrote, rather than claiming nothing outside the output directory changed.
Both are off by default. Writing into someone's repository is an action you ask for, not one you opt out of.
Changed in 0.8.5.
gatepreviously created a branch and a commit on every run, and scaffolded the workflow templates unless you passed--no-workflows. If you relied on that, add--open-prand--workflowsexplicitly.
The PR body is itself a result surface (see Reading the results).
Every finding section follows one order — verdict, impact, fix, proof — so the facts
a reviewer needs most read top to bottom instead of being scattered by finding shape. It
states the guarded-twin claim only for a KEPT validation; any other verdict is named
with its reason, and "your own safeguard stopped it" is said only when the differential
measured a real control_env, never Mylonite's boundary stand-in. It carries:
- A verdict, a severity and a one-sentence impact —
KEPT/STABLE, NOT PROVEN/REJECTEDwith why;High/Medium/Low; and a deterministic, plain-language sentence naming what an attacker gets from this weakness class (the same sentence for every finding of that class). A multi-finding gate PR lists the most severe finding first. - Located at — the exact locus to fix (which tool description / returned content / action handler / system-prompt line).
- The proven fix — an evidence-anchored recommendation naming the actual tool and
argument that landed the exploit (your own tool for a
--target-fileapp; the reference app's tool for the bundledreference:*targets), as a fenced code sketch (never a diff — Mylonite doesn't assert it knows your file layout) tiered deterministic/probabilistic/detective. It is always a suggestion — Mylonite proves and gates the weakness; it never claims to have patched your code. - The differential proof — the fires/resists numbers and the
keptformula, so a reviewer sees why the test is trustworthy, not just that it exists. The per-seed kill matrix marks a seed this test never ran as "not run" rather than lumping it in with a genuine miss, with a legend explaining each mark. At--iterations 1the flakiness leg reads "not measured (1 run per build)", never a pass, because one run per build has nothing to compare; the default--iterations 3measures it. - A reviewer checklist — four lines to confirm against your own app before merging: the proof level, the planner model, whether the prompt was declared or Mylonite's generic default, and the run counts behind the proof.
- Compliance — the OWASP-LLM/ASI · MITRE ATLAS · NIST tags.
- Inline annotations — a best-effort GitHub check-run annotation on the offending prompt line, when the AI layer is a committed file.
- Which repository secrets to add — when the target file has secret-shaped
fields (headers,
env:), the committedtarget.yamlreplaces them with${MYLONITE_TARGET_...}placeholders; the PR body and the console both name the repository secrets those placeholders need before the scaffolded gate workflow (or your own) can load the file in CI.
The recommendation's confidence is degraded, not silently kept at full strength, when the
effect probe didn't settle the finding on its own: unprobed (no effect_probe
declared), errored, deferred or false (the probe confirmed nothing on a finding the
tool-call trace decided), and unattributed (the state change, or its absence, could not
be tied to that attempt — see Effect attribution)
each lower it one step, so a PR body never reads a weaker signal as a fully-confirmed one.
For a custom target the gate proves the finding differentially by default (the
control-efficacy check). --fast skips that leg, so gate keeps a finding only when
an effect_probe proves it; otherwise the finding is a candidate, no test is written,
and gate exits 10.
Adopting it in GitHub CI¶
Add one secret — MYLONITE_API_KEY (your provider key) — and the two
scaffolded workflows. Both map that one secret to the credential variable
your chosen model's provider actually needs (ANTHROPIC_API_KEY,
OPENAI_API_KEY, …), computed when gate --workflows wrote them — see
Provider keys below for what that means if you later
switch providers. If your target file has secret-shaped fields (a header,
an env: entry), add one repository secret per ${MYLONITE_TARGET_...}
placeholder too — gate names the exact variables in its console output and
PR_BODY.md the first time it writes a redacted target.yaml. The
scaffolded workflows already map each one to ${{ secrets.<NAME> }} in the
step that runs the gate. GitHub renders a secret you haven't added as an empty
string, and an empty value counts as set when the target file is expanded, so
on its own a missing secret would launch your server with an empty credential
and the gate test could pass for the wrong reason. To stop that, each workflow
runs a "Check the target secrets are set" step first, which fails the job on
the first empty one with secret <NAME> is empty - add it under repository
secrets.
Outside these workflows (a local run, another CI system), an empty
MYLONITE_TARGET_* variable is used as given, so export real values.
-
mylonite-gate.ymlruns on every PR. It re-drives your agent (bounded: deterministic effect-probe, small model, 1 iteration) and fails the check on a regression. It setsMYLONITE_LIVE_TARGET=1for you (see The validation engine) — without that variable the committed test for a custom target is skipped, not run. It also setsMYLONITE_REQUIRE_GATE_RUN=1, which makes the job fail if a Mylonite gate test was skipped or none was collected, so the check can only go green by running the gate.pytest -raprints the reason if it does. A pending-fix test that ran and failed as expected counts as a run, not a skip. A@pytest.mark.xfailyou add by hand does not: it can hide a check that never reached a verdict, so it still fails the job.The re-drive carries a hard call budget and a wall-clock timeout. It is scoped to the single already-known pattern, so a healthy run is a customiser call, a few planner turns and a judge call; overrunning the bound means something is wrong — a hung MCP server, a stalled provider — not that the work was large. Without those bounds the only backstop is your CI platform's job cap, which on GitHub-hosted runners is six hours.
Two things worth knowing before you make this a required check. It calls a live model, so it is not bit-for-bit deterministic the way a unit test is — budget for the occasional re-run rather than treating a single red as proof of regression. And it is the one path in the product that spends money on every PR; the nightly discovery job is where the expensive work belongs. -
mylonite-discovery.ymlruns nightly or on demand. It does the expensive full discovery and opens a fresh gating PR when it finds a new exploit. It also needs a repository variable —vars.MYLONITE_AUTHORIZE— set to the same value your own--authorizewould need (the target's declaredscope, or itsfamilyif no scope is declared; see target.yaml); the workflow passes it tomylonite gate --authorizethrough an environment variable, so a value holding a quote or;stays one argument.
Both workflows are hardened the same way every workflow in this
repository is. Every action is pinned by commit SHA, with the tag as a
comment (a moved tag would run new code in your CI the next time the job
fires). Each job sets concurrency: — the per-PR gate cancels a
superseded run on the same ref, the nightly discovery job serializes
instead so one run never races a PR it is in the middle of opening — and a
timeout-minutes: so a hung provider or MCP server fails the job instead of
riding your CI platform's much larger default cap. Each job also sets
MYLONITE_REDRIVE_ATTEMPTS explicitly rather than leaving it to the
default: 1 on mylonite-gate.yml (the per-PR job already explained
above spends real money on every PR, so it takes the cheaper, less
conclusive bound) and 3 on mylonite-discovery.yml (the nightly job,
where the fuller signal is worth the extra re-drives). See What a live
gate costs for the trade-off either number makes.
Neither workflow leaves a write-capable credential on disk while your
target runs. Both checkouts set persist-credentials: false, so
actions/checkout's default token never lands in .git/config where your
target's own process (an npx/uvx MCP server, started by mylonite gate
in the same job) could read it. mylonite-gate.yml never pushes, so it
needs nothing further. mylonite-discovery.yml does push the gate branch
and open the PR, in the same step that runs the target — right before that
push, it runs gh auth setup-git, which points git's credential helper at
gh rather than writing a token to disk; gh itself reads GH_TOKEN (the
built-in secrets.GITHUB_TOKEN), already scoped to that one step, only at
the moment a push actually needs it.
If your target launches with npx/node or uvx/uv, the scaffolded
workflow adds a Node or uv setup step automatically — read from the
target file's own command: at the time gate --workflows ran. A hosted
runner has neither preinstalled the way it has Python, so without this the
job would fail to launch your target with no clearer message than "command
not found".
A bundled MCP target (mcp:<family>[:scope], no --target-file) refuses
--open-pr/--workflows outright, before the scan starts. It has no
target.yaml of its own, so the committed test's target.yaml load would
fail every time CI re-drives it. gate checks for this up front — before any
model call or target launch — rather than spend a whole run only to refuse at
the end. Run mylonite scan --scaffold first to write one, then gate with
--target-file <path> instead. Dropping --open-pr/--workflows still
gates a bundled target and writes its artefacts locally, unchanged.
No default model, so both workflows pin the one gate --workflows used —
but what that controls is NOT the same in each. There is no default
provider or model — a live command stops rather than guessing one (see "No
model chosen at all?" in the CLI reference). Both
workflows set MYLONITE_MODEL: ${{ vars.MYLONITE_MODEL || '<model>' }},
where <model> is the exact value the run that scaffolded them resolved,
but only mylonite-discovery.yml actually reads that variable:
its mylonite gate --target-file ... --open-pr step is a LIVE invocation,
and MYLONITE_MODEL is exactly what chooses its model (the same env var
--model/mylonite.yaml would otherwise need to win over). Add a
repository variable named MYLONITE_MODEL to change the model the
discovery workflow drives, without editing the file; leave it unset and
the baked-in literal keeps that job working exactly as scaffolded.
mylonite-gate.yml's per-PR job does NOT read MYLONITE_MODEL at all.
It runs pytest against the already-committed test, which re-drives the
finding against the model recorded with it at gate/generate time (the
exploit's own execution-context metadata, or a sibling scan_report.json)
— never an environment variable. The variable is still set in that
workflow's env: block (for consistency, and in case a future committed
test ever needs it as a fallback), but today it is inert there. Re-run
gate --workflows after choosing a different --model to re-bake the
discovery workflow's literal; a per-PR test's model only changes when the
finding itself is re-proven against a different one (mylonite validate
--model ..., then re-committing).
Both workflows install the Mylonite release that wrote them:
pip install "mylonite==X.Y.Z", where X.Y.Z is the version that ran
gate --workflows. A new Mylonite release never changes what your CI runs until
you change that line, or re-run gate --workflows with the new version.
Prerequisites gate --open-pr assumes¶
mylonite gate --open-pr shells out to git/gh directly (no GitHub API
client), so it inherits a few real preconditions the scaffolded workflows
satisfy automatically but a local or non-GitHub run must provide itself:
- A git repository.
gate --open-pr(and--workflows) resolves the repo root withgit rev-parse --show-toplevel, so it works from any subdirectory — the gate output and the scaffolded workflows land at the repo root either way, not wherever you happened to run it from. Outside a git repository entirely, it fails fast with a named error, before any scan/LLM spend. - A clean working tree.
--open-prswitches branch and runsgit commit, which commits everything staged. Sogaterefuses, before any LLM call, when files are staged or tracked files have uncommitted changes; commit, stash or unstage them first. Untracked files are fine:gatestages only the files it wrote. - The right PR base. The PR targets your repository's default branch:
origin/HEAD, else the branch your current branch tracks, elsemain. Pass--base <branch>to target another branch. The printedgh pr createcommand uses the same base. Running from a feature branch? Pass--base: the gate branch is cut from the commit you have checked out, so against the default branch the PR also carries your feature branch's unmerged commits. - A gate branch from an earlier run. If the gate branch (
mylonite/gate-…) already exists — a re-run that re-found the same finding —gatedoes not touch it: no checkout, no new commit, no push, no delete. It checksoriginfirst (git ls-remote --exit-code --heads), before creating anything: a nightly discovery job is almost always a fresh clone, so the branch from an earlier run usually lives only there. It also still checks for the branch locally, for a persistent workspace (a self-hosted runner) that already has it. Either way it reports the finding as already proposed and exits9, the same as a freshly kept one; see that branch (or its PR) instead of opening a new one. A missingoriginor an unreachable remote is never read as "already proposed" — it falls through to the normal flow. Acheckout -borpushfailure for any OTHER reason (a locked ref, a hook, a real conflict) still stopsgateon exit code8. .mylonite/gate/(or your configured--out) must actually be committed.gatewrites the test, the exploit, and yourtarget.yamlthere, then commits and pushes them as part of the PR — but if your repo's own.gitignorehas a blanket.mylonite/rule (a natural pattern to add, and what Mylonite's own repo uses for its dev artefacts),git addrefuses the explicitly-named ignored path andgate --open-prfails loudly with a git error (rolling back the half-created branch) rather than opening an empty PR — verified againstgate.pr.open_or_print_prdirectly. Confusing either way: make sure your.gitignoredoes not ignore the gate output directory.
A failure in any of these is reported as a named error on exit code 8, not
a traceback, and the findings, the generated test and the validation report are
all written to --out before any git command runs — so a git failure costs
you no evidence. If the failure comes after the commit (the push or gh
step), the evidence is on the kept gate branch the error names, and you are
back on your original branch. Re-run the printed git/gh commands by hand
once the precondition is fixed.
The reusable Action¶
- uses: Abidemialade/mylonite/gate-action@v0.12.1
with:
target-file: .mylonite/gate/target.yaml
authorize: ${{ vars.MYLONITE_AUTHORIZE }} # your target's scope, or family if no scope
model: anthropic/claude-haiku-4-5 # no default -- pick one: see "Choose a model"
api-key: ${{ secrets.MYLONITE_API_KEY }} # mapped automatically to the model's provider
open-pr: "true"
# llm-headers: ${{ secrets.MYLONITE_LLM_HEADERS }} # optional
The action also configures a git identity before it calls mylonite gate
--open-pr (a hosted runner has none by default, so the commit would
otherwise fail), and sets up Node or installs uv on its own when your
target file's command: needs one.
The action's own step succeeds on gate's exit 0, 9 and 10 — the
same mapping the scaffolded discovery workflow uses — and fails only on 1-8
(an infrastructure problem). It prints a ::notice:: for a clean or kept run
and a ::warning:: for a candidates-only one, and sets two outputs: exit-code
(the raw code) and result (clean, kept or candidates; unset on an
infrastructure failure, which fails the step directly). A caller that wants a
red check on a specific result reads the output —
steps.<id>.outputs.result == 'kept' — or sets the optional fail-on input
(none by default, or kept/candidates) to have the step itself exit
non-zero on that result:
- uses: Abidemialade/mylonite/gate-action@v0.12.1
id: gate
with:
target-file: .mylonite/gate/target.yaml
authorize: ${{ vars.MYLONITE_AUTHORIZE }}
model: anthropic/claude-haiku-4-5
api-key: ${{ secrets.MYLONITE_API_KEY }}
fail-on: kept # turn this job red when a proven finding was gated
- run: echo "${{ steps.gate.outputs.result }}"
The tag is the release. The action lives in this repository
(gate-action/action.yml), so every Mylonite release tag vX.Y.Z is also an
action tag, and gate-action@vX.Y.Z installs exactly mylonite==X.Y.Z. Tags
before v0.10.2 predate the pin and install the latest release. To upgrade,
change the tag. scripts/prepare_release.py bumps the action's pin and
the tag on this page with the version, and the release job refuses a tag whose
pin does not match.
mode and runs-on are deprecated. mode was never read; runs-on fed
--runs-on, but the action never passes --workflows, so it had nothing to
scaffold a runner label into. Both inputs still exist for backward
compatibility — the action does not write workflow files in CI — but a
non-default value now logs a ::warning:: in the job log instead of being
silently accepted and silently ignored.
Other CI systems (Jenkins, GitLab, …)¶
The committed gate is a plain pytest file with no GitHub dependency, so it should run
anywhere that meets the preconditions below. GitHub Actions is the only configuration we
test, so treat other systems as supported-but-unverified.
The environment variable is required. Without it the live test is skipped and pytest
exits 0. mylonite generate prints this exact command for that reason. To make the job
fail rather than pass when the gate test did not run, also set
MYLONITE_REQUIRE_GATE_RUN=1 in the CI job — the same check the scaffolded GitHub workflow
enables:
With it set, the session fails if any mylonite_security test was skipped or if none was
collected; tests without that marker are never inspected. Leave it unset for local runs,
where a keyless skip is the intended default. You also need:
myloniteandpytestinstalled- the scan artefacts (the exploit JSON and
target.yaml) co-located with the test:exploit_<id>.jsonin a gate dir,exploit_<pattern_id>.jsonfromgenerate - a provider key
- network egress to both the model provider and your MCP server
Only the bundled reference/replay test runs offline unconditionally; a gate against your own app always re-drives the real target.
What a live gate costs¶
A live gate test re-drives your app up to 3 times. It fails on the first attempt the attack lands on. An inconclusive attempt (the agent made no tool calls, say) uses up one attempt and the next one runs. The test passes when no attempt landed and at least one resisted, and errors if every attempt was inconclusive, so a passing check is the expensive case:
| Test | Attack lands on attempt 1 | Every attempt resists |
|---|---|---|
assert_target_resists |
1 re-drive | 3 re-drives |
assert_control_holds |
1 raw + 1 guarded | 1 raw + 3 guarded |
Each re-drive is one seed: about a customiser call, a few agent turns and a judge call, capped at 12 model calls and 180 seconds. To spend less on every pull request and keep the full check on a schedule, set the number of attempts per job:
# every pull request: one attempt
MYLONITE_LIVE_TARGET=1 MYLONITE_REDRIVE_ATTEMPTS=1 pytest .mylonite/gate
# nightly: the default 3
MYLONITE_LIVE_TARGET=1 pytest .mylonite/gate
The scaffolded mylonite-gate.yml/mylonite-discovery.yml already set this
for you — 1 on the per-PR job, 3 on the nightly one — so you only need
the commands above on another CI system or a local override.
One attempt proves less: an attack that lands 40% of the time resists a single re-drive
60% of the time. A test that passes attempts= itself keeps that number whatever the
variable says.
What does not port: opening the gating PR, inline check-run annotations, and Security-tab
SARIF upload all use the gh CLI and GitHub APIs. On other CI, run the test as the gate and
surface results through your own reporting — mylonite report --sarif still emits standard
SARIF 2.1.0 if your platform ingests it.
Provider keys¶
There is no default provider. The scaffolded workflows map the one
MYLONITE_API_KEY secret to the credential variable the model gate
--workflows resolved actually needs — ANTHROPIC_API_KEY for an
Anthropic model, OPENAI_API_KEY for an OpenAI one, and so on, read from
the approved-provider registry at the time the workflow was written, never
hardcoded to one provider.
That mapping is baked in, not re-computed at CI time. Setting a
repository MYLONITE_MODEL variable to a model from a different
provider (see above) changes which model the discovery workflow drives,
but not which credential variable it exports — the workflow still only
ever sets the one baked in when it was scaffolded. Switching providers
needs a fresh gate --workflows run (with the new model) to re-bake both
the model fallback and the key mapping together; a repository variable
alone is enough only for staying on the same provider's other models.
A model whose provider needs more than one credential variable (Bedrock's
access-key pair, or Vertex, which has no bare key at all) makes gate
--workflows refuse to scaffold at all, naming why — a single
MYLONITE_API_KEY secret can't express either case. Write that workflow by
hand instead. The same holds for a local model (Ollama, vLLM): there is
no key to map, and a hosted GitHub runner can't reach it regardless, so
scaffolding would only produce CI that can never pass.
A provider that needs more than its bare key — but still has exactly
one — is not refused; the extra variables are emitted alongside it.
Azure needs its endpoint and API version beyond AZURE_API_KEY: the
scaffolded workflow exports both as repository variables
(${{ vars.AZURE_API_BASE }}, ${{ vars.AZURE_API_VERSION }} — a URL and
a version string aren't secret-shaped), read from the same approved-
provider registry as the key mapping. Set both repository variables
before the workflow's first live run.
Supply-chain pinning¶
Both the scaffolded workflows and gate-action cap and pin their LiteLLM
install: pyproject.toml bounds the floor-only range Mylonite itself
depends on, and a mylonite-constraints.txt (vendored next to the
templates and the action) pins the exact tested release the install step
applies with pip install -c mylonite-constraints.txt. This is an exact
pin, not a hash lock (--require-hashes needs a network resolve across
every package in the tree) — raise the pin alongside pyproject.toml's own
floor when you upgrade deliberately, not by letting the range drift.
Optional: extra LLM request headers¶
Set a MYLONITE_LLM_HEADERS repository secret (NAME=VALUE pairs,
comma- or newline-separated — e.g. a provider workspace id your account
needs on every request) and both scaffolded workflows, and the reusable
Action's llm-headers input, pass it straight through as an environment
variable, never through a command-line flag. Leave it unset if you don't
need one.
Surfacing findings in the Security tab¶
Emit SARIF from a scan or validation and upload it so AI-layer findings land in the GitHub Security tab alongside every other code-scanning result:
- run: mylonite report .mylonite/generated/<dir> --sarif mylonite.sarif
- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: mylonite.sarif }
Behind a corporate network? See Enterprise & air-gapped networking.
Where to go next¶
- Quickstart — install, the commands that work today, and the scan → generate → validate flow.
- The validation engine — why a validated test is worth committing, and why the CI gate runs offline.
- Enterprise & air-gapped networking — self-hosted runners, internal model gateways, and TLS-inspecting proxies.