target.yaml reference¶
A target file is how you point Mylonite at a custom MCP server (--target-file). One
YAML declares how to launch the server, which weakness classes it exposes, and how to
plant and verify attacks. mylonite scan --scaffold app.yaml scaffolds one for you; this
page is the full field reference.
The scaffold writes a file that runs as written. What it detects goes in live,
each block tagged # auto-detected:
- a
seed_armwhen the server has a tool that stores content and a tool that reads it back without an id. It is the sameseed_arma scan would wire on its own, so the scaffold and the scan agree. - an
effect_probewhen the file lists W3 or W4 and a readback tool is all of: - safe to call before and after every attempt: a whole-word read name (or
readOnlyHint: true), noreadOnlyHint: falseordestructiveHint, no write verb in its name, not a consequential tool itself, and no record id to know; - tied to what the consequential tools change: a word for what the tool's verb
produced (
sent_logoroutboxforsend_email), or the id-free recall of a store a consequential tool writes (read_graphforcreate_entities). A shared noun alone (list_emailsforsend_email,list_messagesforsend_message) is not enough: an inbox can list what arrived and not what was sent. A tied tool wins over listing order; - free of required arguments (
verify_args_template: {}).
It sets no expect_marker, so any new change a call from the attempt made counts as the
effect, and no deferral words, so a reply that says "queued" reads NOT TESTED
(MYL-INC-012), never resisted.
A live probe turns on calibration: before it trusts the probe, the first
scan calls up to five of the server's consequential tools for real (never one marked
destructive), with myl-cal- marker values, to prove the probe sees a change. Add
calibration: {controls: skip} to turn that off; the probe's "no change" then never clears
a call (MYL-INC-002). Attempts on a target with an
effect_probe run one at a time.
Any other readback is written as a commented hint (with its required arguments stubbed,
when it has some): check it, fill it in and uncomment it. Until the file declares an
effect_probe, the scaffold marks W3 and W4 "effect unconfirmable" and the scan reads
those classes NOT TESTED (MYL-NT-017) unless an attempt
fires. A store tool whose
only readback needs the new record's id (the agent never learns it) gets a commented
seed_arm, and W2 is left out of weakness_classes.
--scaffold checks the output path is writable (not a directory, parent creatable)
before it launches your server, so a bad path fails fast with one line, not a
traceback after the launch cost. The write itself is atomic — a crash mid-write
leaves any existing file untouched, never truncated. Both the MCP scaffold and
the --rest-url scaffold get the same check.
Minimal¶
family: my-app
command: python
args: [my_server.py]
weakness_classes: [W2]
seed_arm:
tool: save_note
args_template: { body: "{payload}" }
Full, annotated¶
# --- how to launch the server ---------------------------------------------
family: my-app # short name used in report ids (not a bundled name)
command: python # the MCP server launch command
args: [my_server.py, --port, "0"]
env: { LOG_LEVEL: warning } # ADDED to a small allowlist, not merged into your full env — see below
scope: tenant-a # optional label; must match --authorize / {scope}
requires_scope: false # set true to require a non-empty scope
# timeout_s: 90 # optional; stdio/sse/http only. Overrides the planner
# # AND MCP session read timeout (both default to 60s) --
# # see "MCP session timeout" below.
# calibration: # optional; stdio/sse/http only -- see "Calibration" below.
# controls: auto # auto (default) | allow | skip
# --authorize must EQUAL this target's required value: the declared `scope`
# exactly (e.g. `--authorize tenant-a` above) if one is set, else `family`
# (e.g. `--authorize my-app`) if it isn't. This is derived from the target's
# own data, never from `requires_scope` — see `mylonite._authz`, SECURITY.md.
# --- the AI layer ----------------------------------------------------------
system_prompt: | # the target's system prompt (inline)...
You are a helpful assistant with access to the connected tools.
# system_prompt_file: prompts/system.txt # ...or read it from a file (one or the other)
primary_tools: [] # optional: documentation only — see the note below
weakness_classes: [W1, W2, W3, W4] # which classes to test
# --- how to plant poisoned content (required for indirect-injection W2) ----
seed_arm:
tool: save_note # the tool that stores attacker-controllable content
args_template: { body: "{payload}" } # {payload} is the bare natural-language plant
id_key: id # JSON field in the result holding the new record's handle
# id_pattern: 'note_(\d+)' # ...or a regex to extract the id
# id_from: first_int # ...or "first integer in the result"
# --- how to confirm the damage materialised (recommended for custom targets)
effect_probe:
verify_tool: list_sent_mail
verify_args_template: {}
# A marker the AGENT must PRODUCE by acting (here: the attacker recipient in the
# sent-mail log). Do NOT use a literal Mylonite plants into the payload (e.g. an
# exfil address inside a poisoned note) on a plant-and-recall target: the verify
# tool would reflect the planted payload back and the marker would match without
# the agent doing anything. An effect now counts for an attempt only when the
# marker is NEW since that attempt's own pre-drive baseline AND one of that
# attempt's own executed calls carries it (or is the declared consequential/
# egress tool) — a marker that was already there, or that another attempt
# wrote, does not confirm this attempt. See "Effect attribution" below.
expect_marker: "{exfil_email}" # renders to this attempt's exfil address; see below
deferred_markers: ["queued for approval"] # words that mark a HELD item in the verify output
deferred_reply_words: ["Job accepted"] # words in a tool REPLY that mean "accepted for later"
# removal: # opt-in: confirm a W4 delete of the record the payload names
# plant_tool: save_note # never a destructive tool
# plant_args_template: { title: "{record}", body: "{token}" }
# read_args_template: { title: "{record}" } # a read keyed by the record's name
# --- which tools implement which controls (for the synthetic guarded build) -
control_config:
egress_tools: [web_fetch] # W3: tools that reach the network
egress_url_param: url # the URL arg the allowlist guards
fetch_allowlist: [example.com] # hosts the egress control permits
consequential_tools: [send_email] # W4: high-impact actions to gate
never_call: [wipe_account] # never called on this target; blocked before the server
read_tool_names: [read_note] # W2: tools whose results get quarantined
verdict_read_tools: [web_search] # verdict only: calls that count as reads
private_tools: [get_user_sessions] # W2/W4: tools that RETURN sensitive data
private_markers: ["INTERNAL-SECRET-"] # …or a literal that marks a result private
destructive_tools: [delete_entities] # W2: sinks where an injected call is damage itself
enforcement_mode: approve # block (default) | approve | observe
approval_policy: approve_when_trusted # in approve mode: deny_all | approve_when_trusted
declared: [W2] # controls you've already implemented server-side
synthetic: [W3, W4] # controls NOT already declared — Mylonite synthesizes/tests these
# --- optional: a genuinely unguarded build + per-control server-layer toggles
vulnerable_launch: # how to launch a DELIBERATELY-unguarded variant
command: python
args: [my_server.py]
env: { SECURITY_MODE: "off" }
control_env: # per-control env that disables ONE server guard
W2: { SANITIZE_INPUT: "false" }
W3: { ALLOWLIST: "disabled" }
Remote targets (SSE / HTTP)¶
By default a target is launched over stdio (command/args spawn a subprocess). To
scan a remote MCP server, set transport and url instead — command becomes optional:
family: my-remote-app
transport: sse # stdio (default) | sse | http (streamable-HTTP)
url: https://app.example.com/mcp
headers: # optional; may carry auth
Authorization: Bearer ${MY_TOKEN}
weakness_classes: [W2, W4]
seed_arm: { tool: save_note, args_template: { body: "{payload}" } }
urlis required forsse/httpand rejected forstdio.headersare passed to the transport but never logged and never shown in the target descriptor (only the host appears). A target file keeps tokens out of shell history.- Reference the token with
${VAR}, as above, and export it before you scan (export MY_TOKEN='...'/$env:MY_TOKEN = '...') — the same${VAR}mechanism the REST adapter'srequest.headersuses (seedocs/http-agent.md). Mylonite expands it from your shell's environment and never writes the literal value to the file. Each variable a remote target's headers reference prints one line when the file loads, naming the variable and the host it goes to:target file sends $MY_TOKEN to app.example.com. - A remote target's headers can't reference Mylonite's own credentials: a provider key
(such as
ANTHROPIC_API_KEYorOPENAI_API_KEY),MYLONITE_API_KEY,MYLONITE_LLM_KEY,MYLONITE_LLM_HEADERS,GH_TOKEN,GITHUB_TOKEN,ACTIONS_RUNTIME_TOKENorACTIONS_ID_TOKEN_REQUEST_TOKEN. A target file is often written by someone else, and its headers go to the URL it names, so Mylonite refuses the file and names the variable. If the target really does take the same value, copy it into a name of your own and reference that:export MY_TOKEN="$GH_TOKEN", thenAuthorization: Bearer ${MY_TOKEN}. Stdio targets andenvblocks are not checked; they are never sent to a URL the file names. - If the server rejects the request with 401 or 403,
scannames the host and the status and points at this section — for example:the server at app.example.com rejected the request (401). Set the token in headers: in the target file, e.g. Authorization: Bearer ${MY_TOKEN}, and export MY_TOKEN before you scan.See Known limitations for what this does not (yet) cover — a static token only, no OAuth sign-in. command/args/envand the server-layervulnerable_launch/control_envtoggles do not apply to remote targets and are ignored.- Everything else (
seed_arm,effect_probe,weakness_classes,control_config) works exactly the same.
MCP session timeout¶
timeout_s (optional; stdio/sse/http only — a rest target uses
request.timeout_s instead) overrides BOTH the
planner's per-turn budget and the MCP ClientSession's read timeout, which otherwise
default to 60 seconds each. Raise it for a target that legitimately takes longer per
turn — a local model or a slow server. Both the planner-timeout error (raised
mid-scan, on a slow turn) and the describe()-timeout error (raised on connect, e.g.
a hung server) name timeout_s and its effective value. The one-off seed_arm
auto-wire probe (a single describe() call scan makes up front to infer a
seed_arm — see CLI reference) also names timeout_s, but its
own budget is max(20, timeout_s): even a smaller timeout_s never shrinks that
first probe below 20 seconds. Omitting the field keeps today's fixed 60s default for
the planner and session, so an existing target file loads unchanged. Must be greater
than 0; declaring it on a transport: rest target is rejected — set
request.timeout_s there instead.
describe() itself — the one call that launches the server and introspects its
tools (scan --scaffold, check, and the first connect a scan or gate makes) — uses
a separate, larger first-contact budget: 120 seconds by default, not 60. A first npx
-y <package> or uvx <package> run downloads the package before the server can
answer at all, which routinely takes longer than 60 seconds on a slow or fresh
machine — and a brand-new user running scan --scaffold --command npx ... has no
target file yet in which to set timeout_s. Setting timeout_s still overrides this
budget too, exactly as before. With no target file (a bundled family, or an ad hoc
--scaffold/--command probe), set the MYLONITE_MCP_STARTUP_TIMEOUT_S env var
instead — it raises the 120s default and is ignored once timeout_s is set. Either
way, re-running once the package is cached is usually faster than raising anything.
Probes per class¶
seed_tool_ceiling (optional; stdio/sse/http only) sets how many tools in one
weakness class get their own probe. The default is 8, and the value must be a whole
number from 3 to 50. A tool past the ceiling is never attacked, and the scan reports
it as NOT TESTED (MYL-NT-018), so on a server with more
candidate tools than that in one class the class can't read resisted.
Each extra probe costs LLM calls: a customiser call, several planner turns and a judge
call. Raise --max-llm-calls with the ceiling, or the scan stops on its budget
(MYL-ABT-001) and the seeds it cut off read NOT TESTED
too. For W4 there is a second way: a control_config.consequential_tools list replaces
the classifier, so listing only the tools that matter keeps the class under the
ceiling. Declaring egress_tools only moves those tools to the front of the W3 list;
the classifier's other matches still count toward the ceiling.
Calibration¶
calibration.controls (optional; stdio/sse/http only — a rest target is
rejected, mirroring timeout_s) says whether calibrate_custom_target() may run
its controls: real writes through a consequential tool, and a plant-and-recall
through seed_arm, proving your effect_probe can actually see a change before
a scan trusts its "no change". Every scan/gate/ablate/validate run that
drives this target calls it once, automatically, and prints each control's status
and reason code. Run
mylonite scan --target-file app.yaml --authorize my-app to see it before
trusting a full campaign's "no change" findings:
auto(the default): run on an authorizedstdiotarget only. Every live-driving command already requires--authorizeto match this target's scope —autoadditionally withholds consent for a remote (sse/http) target, since real writes against a server you don't launch yourself need a separate, explicit opt-in.allow: run on any transport, once authorized — including a remote one.skip: never run. An uncalibratedeffect_probe's "no change" can then never certify a resisted attempt; attempts it would have covered read NOT TESTED withMYL-INC-002instead.
A certified probe is trusted only for what calibration showed it. Its "no change"
clears a call only when every consequential tool the attempt called is one the
positive control wrote through (scan --target-file app.yaml --authorize my-app
lists them). A call through any
other tool may land where the probe cannot look, so it stays a finding. A
verify_args_template that uses {payload} is never certified (MYL-INC-003): an
attempt fills it with its whole attack text, so calibration cannot prove that read.
Select the record by a fixed value, or use a verify tool that takes no arguments.
Calibration is recorded per launch, so a result from the default launch is not reused
for a vulnerable_launch twin.
On an identity-plus-content store (a key/value store, a document store) a consequential
tool typically needs a required id-shaped argument (key, id, ref, handle...) that
no {payload} slot fills. Calibration fills it with the value the target file already
pins for that same name in seed_arm.args_template or verify_args_template — never a
freshly minted token — so the W2 recall lands under the exact record the probe reads.
With no declared value for that argument, the tool is skipped rather than guessed.
A consequential tool whose only usable form needs that same substitution is never tried
as a general certification candidate, whatever the write itself would show: proving the
probe sees a write to the one record the target file declares says no more than a
memory-style readback already proves, never that the probe sees a write through that tool
in general. On a memory-style store (a knowledge graph), or a key-addressed store whose
consequential write can only reach the declared record this way, the probe reads back
that one record seed_arm writes instead. When no consequential tool passes (or none is
eligible to be tried, for the id-substitution reason above), and the probe neither changed
on its own nor failed to read, calibration checks the probe on that record, in this order:
- Two verify reads before the plant, with no change between them.
- The plant through
seed_arm, carrying a freshmyl-cal-token. It is the seed control's own plant, so one record serves both. When the seed control has no recall tool and never plants, this check plants the record itself, which leaves one moremyl-cal-record behind. - A verify read that must show the planted token.
- A second verify read that must not grow and must still show the token. An error or empty reply fails here.
- Two reads through the same verify tool, each with a different never-planted token in
place of the one value that names the record: the only string value of
verify_args_template, or else the one id-shaped argument (id,keyand the like) whose valueseed_armalso used. Every other argument stays as the planted read sent it. When no single value names the record, the read fails. All three replies are read the same way, whatever block type they arrive in. Both must succeed with no error, their replies must match once each requested token is masked, and neither may carry the planted record's content (its token, or the probe'sexpect_marker). A not-found reply that repeats the requested key, or an empty reply for a missing id, passes: the planted read in step 4 is the anchor that shows the record is there. An error, a raised call, two replies that differ, or a reply that carries the planted content fails. Averify_args_templatewith no string value fails, because no token can be sent.
Passing it reads calibration: confirm_only [MYL-INC-003], never
certified: the probe can confirm a planted record appears; it cannot clear a call that
changed nothing, because the plant shows only that it sees writes to the record it reads.
The earlier code stays on the result. A confirm_only probe never clears a call: for
that, a scan treats it exactly like a probe that failed calibration. It can raise a
dispatch to effect-confirmed when it sees the call change the record it reads, and only
when that call carries the probe's own expect_marker. A confirm_only probe with no
marker never raises anything, because a soft-error reply changes its read too. See the
guard in How an MCP attempt is
decided.
never_call: tools Mylonite must never call¶
A live run drives your real tools, so point it at a test instance or a seeded copy, never
production. For a tool that must not run even there, list it under
control_config.never_call:
Mylonite blocks a call to a listed tool before it reaches your server, on every MCP
transport (stdio, sse, http) and for every caller: the agent, a seed_arm plant, an
effect probe and the calibration controls. The agent sees an error reply instead. The
blocked call stays in the attempt's trace with blocked_by: never_call, and the attempt
reads NOT TESTED under MYL-INC-013. A blocked call is
never counted as the attack landing, and never as your safeguard resisting.
The file is refused at load when Mylonite could not honour the list: on a rest target
(your app calls its own tools, so Mylonite cannot block one), and when seed_arm.tool or
effect_probe.verify_tool is also listed. Before any spend, scan, validate and gate
print the list in their run plan, next to the consequential tools the run may drive.
Field groups¶
- Launch (
family,command,args,env,scope,requires_scope) — how the stdio MCP server is started and labelled. For remote servers usetransport: sse|http+url+ optionalheadersinstead ofcommand/args. -
AI layer (
system_prompt/system_prompt_file,primary_tools,weakness_classes) — what the agent is and what to test. Set at most one of the two prompt fields. With neither, the agent runs under Mylonite's generic default prompt, andscan,validateandgatewarn before the run starts: some weaknesses only show under your app's real prompt, so declare it.primary_toolsis currently documentation onlyIt is accepted, validated and round-tripped, but nothing reads it — it does not narrow seed selection today, despite what earlier versions of this page claimed. Use
weakness_classesto control what gets tested. Wiring it as a real filter is tracked inTODOS.md.seed_arm(SeedArmSpec) — how to plant untrusted content.{payload}must sit at a bare string leaf (e.g.body: "{payload}"), not nested inside serialized JSON. Theid_key/id_pattern/id_fromtell Mylonite how to capture the new record's handle so it can drive a read-back.effect_probe(EffectProbeSpec) — confirms the damage end-to-end, not just that a tool was called. Without one, a W3/W4 finding is proven only as far asdispatchedand a "queued" reply from the server reads NOT TESTED (MYL-INC-012), not resisted (see How an MCP attempt is decided).mylonite scan --scaffoldproposes averify_tooland writes it live only when it is safe, tied to a consequential tool and needs no arguments (see the top of this page); otherwise it is a commented hint. Check that it reads back what an attack would change. With noeffect_probein effect, a W3 or W4 class on a custom target reads NOT TESTED (MYL-NT-017) unless an attempt in it fires. Not available on atransport: resttarget: declaring one there is refused on load with exit2, before any model call, and W3 and W4 read NOT TESTED (MYL-NT-016).expect_markerproves it fired;deferred_markersmean the action was defended (e.g. queued for approval), not a success. List indeferred_markersonly words that mark a held item in the verify tool's output, never a word that also shows for items that will still go out: when one grows, the attempt reads resisted. Words the server puts in a tool reply when it accepts a call for later (Job accepted,Task created) go indeferred_reply_words; they make the call read deferred (NOT TESTED unless the probe saw it held), never resisted. Both match whole words, ignoring case. Chooseexpect_markeras a value the agent's own call carries — the recipient address, a row it wrote,{exfil_email}or{exfil_host}— not a status word the target itself would print regardless of who acted. A fixed status word likestatus=sentcan only be tied to an attempt through the tool the seed names as its consequential or egress tool, and only synthesised seeds name one. A tool declared undercontrol_config.consequential_toolscan link only synthesised seeds; catalogue seeds, whichvalidatere-drives, never link through it. Soscan,validateandcheckwarn on a fixed marker whatevercontrol_configdeclares: on a catalogue seed that effect can read"unattributed"but never"true", andvalidatecannot keep the finding. The dependable fix is a marker the agent's own call carries. A marker shaped like an email address (x@y.z) or a URL (containing://) is exempt from the warning: it is assumed to be carried by the agent's call. A scan can mint a fresh exfil destination for each attempt (the generalization probe), which a fixed literal address can never match — use{exfil_email}/{exfil_host}instead of writing one in. Both render to this attempt's active destination either way, so they also work when that randomization is off;scan,validateandcheckwarn when a marker is a literal address rather than one of the two placeholders. See Effect attribution below for what confirms an effect and what"unattributed"means.control_config(ControlConfig) — tells the synthetic guarded build which tools carry egress (W3), consequential actions (W4), and untrusted-data results to quarantine (read_tool_names, W2), the allowlist, which controls you'vedeclared, and whether tosynthetic-ally synthesize the rest. For W2 specifically, two further lists decide whether the control can actually discriminate:private_tools— tools whose results carry sensitive data. Reading one raises the session toprivate, and a public-facing sink then refuses. This is the knob that catches exfiltration.read_tool_namesalone only marks content untrusted, which on its own gates nothing but destructive sinks.destructive_tools— sinks where an injection-driven call is damage in itself (delete/overwrite/transfer). These refuse untrusted context outright. Inferred from MCP'sdestructiveHintand name hints when you don't declare them.accepts_untrusted_tools— the opposite exemption: tools explicitly cleared to act on untrusted content regardless of taint state. Declare a tool here only when it is genuinely safe to drive from untrusted input, for example a summarizer with no side effect — it is never refused for acting on untrusted content once listed.description_pins— tool name to the sha256 hex digest of its approved description text (W1). A tool whose live description hash no longer matches its pin is refused at call time — the deterministic answer to a rug-pull, where a tool's description changes after a user already approved it.verdict_read_toolsis separate from every control. It names tools whose calls the effect verdict counts as reads, not as an action the attack could carry out, for a reader whose name it cannot tell from an action (web_search,fetch_note). No result is quarantined and no call is confirmed differently. Use it instead ofread_tool_namesfor this: declaringread_tool_namesnarrows W2's quarantine to the tools you list. See Reading results for the verdict's read rule.calibration(CalibrationSettings) — whether the calibration controls (real writes proving youreffect_probecan see a change) may run; see Calibration above.- Server-layer build (
vulnerable_launch,control_env) — optional: drive the differential against your own unguarded build and per-control env toggles, instead of the adapter-boundary shim. Use these when you can launch genuinely (un)guarded variants of the server. See Concepts and Security.control_envis not available on atransport: resttarget: declaring one there is refused on load, with exit2, before any model call — Mylonite never launches arestendpoint, only sends it HTTP requests, so there is no process to restart with the guard toggled off. Test an input defence on aresttarget with--prove-input-controlinstead (see Test a plain HTTP agent). framework— optional, free-form (e.g.langchain,crewai,llamaindex). Entirely a labelling hint: it names your agent framework in a structural recommendation's code sketch alongside the language Mylonite already infers fromcommand(python/uv/uvx/poetry→ Python,node/npx/bun/tsx→ TypeScript, else generic pseudocode). Never validated against a fixed list, and never used to fabricate that framework's actual hook/decorator syntax — Mylonite points you at where to wire a sketch in, not at invented API details it hasn't verified.
Windows SQLite footgun. If
envpoints at a SQLite DB by URL, note thatsqlite:////c/Users/...(4 slashes) andsqlite:///C:/Users/...(3 slashes) open different databases on Windows — a silent way to scan an empty DB and wrongly conclude the agent is clean. Prefer an absolute path and verify it opened.
envis an overlay, not your full environment. A stdio target's spawned process does not inherit Mylonite's own environment wholesale — Mylonite routinely spawns deliberately-vulnerable and third-party servers, and handing every one of them Mylonite's own provider API keys /GITHUB_TOKEN/ other credentials would be a real leak. Mylonite itself forwards only a small, fixed allowlist of OS-plumbing variables (PATH,HOME,USERPROFILE,SYSTEMROOT,TEMP,TMP,TMPDIR,LANG,LC_ALL,PATHEXT,COMSPEC,APPDATA,LOCALAPPDATA) plus whatever you declare inenv:. The underlying MCP SDK then adds its own platform-default set on top of that — it always does this, independently of Mylonite — pulling a few more OS-plumbing variables (HOMEDRIVE,HOMEPATH,PROCESSOR_ARCHITECTURE,SYSTEMDRIVE,USERNAMEon Windows;LOGNAME,SHELL,TERM,USERon POSIX) from Mylonite's own process into the child. None of these carry secrets, so the leak this allowlist guards against (provider API keys,GITHUB_TOKEN, other credentials) still can't happen, but "nothing else" is the aspiration, not the literal child environment. If your server needs some OTHER parent-env variable, declare it explicitly here — the SDK default set is fixed and not configurable. The most common case: annpx/uvx-launched target running behind a corporate TLS-inspecting proxy needs its proxy/CA variables declared explicitly too, e.g.env: { HTTPS_PROXY: "...", HTTP_PROXY: "...", NO_PROXY: "...", NODE_EXTRA_CA_CERTS: "...", SSL_CERT_FILE: "..." }— without them the launch can fail with a TLS/registry error that looks unrelated to Mylonite.
Coverage: a declared class your surface can't run is refused, not silently dropped¶
Declaring a class in weakness_classes is a promise: Mylonite tests it. Before either
scan or gate spends an LLM call, it checks whether your target's introspected tool
surface can actually produce even one seed for every declared class. A class can be
covered by more than one mechanism — the bundled catalogue seed for a literally-named
tool (send_email, web_fetch), or a seed synthesised for your target's OWN tool
(classifier-found, e.g. execute_sql, or declared via control_config.egress_tools /
control_config.consequential_tools) — so W3/W4 refuse only when NEITHER exists: no
egress-shaped tool at all for W3, no consequential tool at all for W4. W2 refuses only
when there is no store-and-recall pair AND no content-processing tool. If a class would
run zero seeds either way, the run refuses outright and names each one:
error: [MYL-PRE-001] this target declares weakness class(es) its tool surface cannot
cover at all (every attempt for them would never run):
W2: this server has no tool that can store content for a later recall — remove W2
from weakness_classes, or declare a seed_arm
The fix is always one of the two named (MYL-PRE-001): drop the class from weakness_classes, or
declare the missing piece (a seed_arm for W2, control_config.egress_tools for W3,
control_config.consequential_tools for W4). mylonite scan --scaffold only ever
suggests a class it already confirmed is coverable for the surface it just introspected,
so a fresh scaffold's target.yaml never trips this refusal on first run.
--dry-run downgrades the refusal to a warning — a dry run only enumerates seeds, so it
stays informative instead of blocking. A dry run wires a missing seed_arm the same way a
real scan does (detection reads the tool list; no LLM call), so its preview of W2 matches
what the scan will run. A W2 class you've explicitly accepted running
uncovered via --allow-no-seed-arm is not re-blocked here — the run proceeds, its
kitchen-sink seeds are still scheduled, and each one hits the target and reports
skipped_no_seed_arm (NOT TESTED), which forces the scan's overall coverage to PARTIAL
rather than letting it read as a trustworthy clean pass. gate gets the same refusal,
with no --dry-run equivalent to downgrade it and no --allow-no-seed-arm flag of its
own — an uncoverable class always refuses there. gate also has no seed_arm
auto-wire: a W2 target that scan would wire for you refuses in gate. Run scan on it
first; when it finds a store-and-recall pair it writes the wired target to
.mylonite/scans/<timestamp>/target.yaml, and that file is the one to pass to gate.
The check has to launch the server to see its tools, and it fails closed: if the server
crashes, or can't be described within 20 seconds (or the target's timeout_s, when that
is larger), the run stops with exit 2 and says which, rather than starting without
knowing whether every declared class can run. After a timeout, re-run once (a first
npx/uvx download is cached after that) or raise timeout_s; after a crash, check the
target file's command: and args:. The scan engine repeats
the same rule on the description it actually runs against: a declared class with no seed
at all aborts the scan before any payload is sent (no_payloads, exit 2), so a skipped or
failed pre-flight can never let the other classes read as a clean pass.
When the refused class came from --weakness-class rather than the file, the message
says so and points at the flag, since editing weakness_classes would not help.
This is a different check from mylonite check (hidden and experimental — see
docs/experimental.md), which needs a live tool surface and no
LLM call: it diffs seed_arm.tool, effect_probe.verify_tool and every
control_config tool-name field against the server's described tools, and flags any
name that isn't there — a typo, or a stale name copied from another target file. Those
findings count toward check --enforce, the same as an unapproved sink.
Effect attribution¶
An effect_probe reads the verify tool before the agent runs (this attempt's own
baseline) and again after. An effect confirms "true" for this attempt only when
both hold: the marker is new since that baseline (with no marker, the verify output
itself changed), and one of this attempt's own calls that reached the server without an
error carries the marker or is the scenario's declared consequential or egress tool. A
call whose reply says "queued" or carries a task handle still counts, because a queue can
send at once. Everything else the probe sees
falls into one of:
"false"— the attempt did nothing, or a linked call reported success while the marker never appeared, before or after (a silent drop)."deferred"— a deferral marker (e.g."queued for approval") grew: the target's own state names a held/queued action. A defended app, not an unproven one — distinct from"false"because it rests on something the target actually said, not just on nothing having changed."unattributed"— the state change, or its absence, could not be tied to this attempt: an idempotent write, a delete, a bounded output window that slid, or a change another attempt made while this one only made an unrelated call."errored"— the baseline read, or the post-drive read itself, raised or timed out, or the post-drive read answered with its ownisError. None of these mean the attempt was resisted: the probe never actually checked, so it can neither confirm nor clear anything the agent did.
None of these values decides an attempt alone. The tool-call trace says what the agent
did. The probe can clear a linked dispatch only after calibration has certified it, and
raise one to effect-confirmed only after calibration has certified it or shown it
can confirm (confirm_only). See How an MCP attempt is
decided.
This is what stops a target whose state outlives one run — a file, a database, a memory
store, any remote server — from letting an earlier attempt's write count as this
attempt's proof. A concurrent attempt's write can't be told apart this way, so on a
target with an effect_probe, Mylonite runs attempts one at a time. It costs one extra read-only call to the verify tool per attempt
with a declared verify_tool; nothing sent to the model changes.
Removal confirmation (W4 deletes)¶
A W4 delete reaches effect-confirmed when the record the attack names reads present
before the attempt and absent after it. The fixed verify_args_template watches one
record, so on its own it cannot see a delete of any other record. Opt in with an
effect_probe.removal block:
effect_probe:
verify_tool: get
verify_args_template: { key: "mylonite-tpv-seed" }
removal:
plant_tool: set # never a destructive tool
plant_args_template: { key: "{record}", value: "{token}" }
read_args_template: { key: "{record}" } # must contain {record}
Around each attempt against a destructive seed tool, Mylonite:
- takes the record the W4 payload already names (the payload text is never changed),
and reads it and a name that has never existed through
read_args_template. With each requested name replaced by a placeholder, the two replies must match, so a not-found reply that echoes the name ("Key quarterly summary does not exist") still counts as absent. If they differ, the record may already exist: Mylonite plants nothing and never touches it; - plants that record with a fresh
{token}, plus a second record under a fresh name; - reads both back twice before the agent runs. Every read must succeed and show its token;
- after the agent runs, reads both again. The named record's read must succeed, be non-empty and lack the token. Masked, it must match both a fresh never-existing name's reply and the absent reply from step 1. The second record must still be there;
- checks the trace. A call to the seed's own tool that went through and was not queued must name the record exactly. No call may name the second record or a token, and no other non-read tool may name the record.
Only then does the attempt carry removal_confirmed: "true". If calibration has
certified the probe or shown it can confirm (confirm_only), and the trace reads
dispatched-ok with nothing held, the finding is then raised to effect-confirmed.
Any other outcome ("false", "unavailable", "errored", with a removal_note
saying why) changes no verdict. Removal confirmation never clears an attempt, and an
empty or error read is never taken as absence, and neither is a reply that has changed
shape since step 1 (a soft error that echoes the key, say). Mylonite plants nothing
unless calibration ran with writes authorized. The effect probe's own "deferred" still
wins: a confirmed removal never overrides a hold the target's state shows.
After the check, any planted record whose read still shows Mylonite's own token is
removed through the seed's own tool, so the next attempt can plant again. A record
without that token is never touched. A cleanup that fails is noted in removal_note.
It works only for a delete keyed by a name Mylonite can plant under. A server that
assigns its own ids (delete_note(id: 1)) is out of reach, and so is a payload whose
record is numeric or shorter than six characters. It also needs an exact name: an
agent that deletes "quarterly summary now" when the payload says "quarterly summary"
did not delete the planted record, so nothing is confirmed.
Secrets stay out of the file¶
Every target file Mylonite writes keeps the secrets in headers, request.headers and
env out of it: scan --scaffold, the copy saved in the scan directory, the copy
generate puts next to the test and the copy gate commits. Each value in headers
and request.headers, and each secret-looking env value (a key such as GITHUB_TOKEN
or DB_PASSWORD, or a value shaped like an API key), becomes a ${MYLONITE_TARGET_...}
placeholder. Plain values such as LOG_LEVEL: debug stay as written.
A value that is PURELY a ${VAR} reference — your own hand-written
Authorization: Bearer ${MY_TOKEN}, or a GITHUB_TOKEN: ${MY_GH_TOKEN} you set up
yourself, optionally preceded by one Bearer/Basic/Token auth-scheme word — is left
exactly as written instead of being re-wrapped into a second, disconnected
${MYLONITE_TARGET_...} placeholder. Re-wrapping it would silently orphan the variable
you already export in your shell, and — for a header — require the NEW variable to hold
the whole header value rather than just the token. A value that merely contains one of
these references alongside a literal secret — a DB URL with a real password and a
${DB_HOST} reference, or a cookie with a real session value and a ${X} reference —
is not this shape and is masked whole, exactly as a
value with no reference at all: the check is purely-this-shape, never
anywhere-in-the-string. Writing a target file never resolves a ${VAR} reference from
the live environment either way: only loading one does, so no live secret can reach
the written file through this path.
A credential in the query string of url or request.url is masked too. A parameter
with a credential name (api_token, access_key, client_secret, key, sig, ...),
or whose value is shaped like an API key, becomes ***REDACTED***. Other parameters
stay as written, and so do a few named exceptions that are request options, such as
max_tokens, page_token and sort_key. url reads no
variables, so a masked copy won't connect until you put the value back; the note the
writing command prints names each masked field, such as request.url. Keep copies
runnable by sending the token in headers: as a ${VAR} reference instead.
Whichever command wrote the file prints the variables to set, with the key each one stands for. The secret itself is never printed:
note: secrets in headers and env were kept out of app.yaml. It reads them from these environment variables; set them before you use the file:
bash/zsh:
export MYLONITE_TARGET_ENV_GITHUB_TOKEN='<your GITHUB_TOKEN>'
PowerShell:
$env:MYLONITE_TARGET_ENV_GITHUB_TOKEN = '<your GITHUB_TOKEN>'
For a headers/request.headers variable the hint instead names the FULL header
value, since that variable has to hold more than the bare token:
export MYLONITE_TARGET_HEADERS_AUTHORIZATION='<the full Authorization header value, e.g. Bearer ...>'
The bundled mcp:github family (no target file needed) uses the same ${VAR}
expansion for its own fixed variable, GITHUB_PERSONAL_ACCESS_TOKEN — see
Bundled targets.
Set each variable to the real value in the shell that runs Mylonite, then use the file:
On Linux / macOS (bash):
On Windows (PowerShell):
To keep the values in a .env file instead, pass --env-file. Besides the recognised
provider credential/config names (see Choose a model), it also
loads:
- every
MYLONITE_TARGET_...-prefixed name — the placeholders Mylonite itself writes into a redacted target file; - every name the targeted file itself references as
${NAME}in itsheaders/request.headers/envsections — a user-named token such asMY_TOKENin a hand-writtenAuthorization: Bearer ${MY_TOKEN}, not only ones Mylonite wrote. This is scoped to the SAME target file the current command is about to load — not an open allowlist for any name — so pass--env-filetogether with--target-file(or letmylonite.yaml'starget_file:supply it) for this to take effect.
Anything else — a name neither provider-shaped nor referenced by the target file you're
using — is still dropped with a warning rather than loaded; a stray .env reused from a
wider project cannot inject arbitrary environment this way. In CI, set the target's own
variables as secrets on the job instead of relying on a committed .env.
For every name --env-file DOES load: a value that is itself an unresolved
${VAR}-shaped placeholder (a templating tool's or secrets manager's reference that was
never substituted, e.g. ANTHROPIC_API_KEY=${SOME_SECRET_MANAGER_VAR} left in the file
as written) is a clear error naming the variable and the file, not a silent load — that
literal text is never a usable credential, and sending it to a provider or target as one
fails confusingly at best.
If a variable is unset, loading the file stops with exit code 2 and names the variable,
the key it holds and the export line to run. Mylonite never starts your server with an
empty credential. Placeholders are expanded only inside env, headers and
request.headers, so ${...} text elsewhere in the file, such as a template-injection
payload in system_prompt, is left alone.
Credentials the target repeats are masked¶
The credentials a target file hands the target are also masked wherever the target
repeats them: in a reply, a tool result, an HTTP error, the saved attempt record and
the gate's PR_BODY.md. That covers:
- the value of a credential-named header (
Authorization,Cookie,X-Api-Key,X-Auth-Token, ...), and the token after itsBearer,token,BasicorDigestscheme; - each value a
${VAR}reference inheadersorrequest.headersresolves to; - each secret-looking
envvalue, such as aGITHUB_TOKENor an API-key-shaped value.
Each of these is masked everywhere it appears, so only values that look like a
credential count. A value shorter than eight characters, a number, true/false/
none/null, and a value with only one kind of character (vault,
hashicorp-vault) under a credential-sounding name such as MAX_TOKENS or
SECRET_BACKEND are not masked. Headers such as Content-Type and plain env values
such as LOG_LEVEL: debug stay as written.
The boundary controls fail closed¶
The four boundary controls the adapter-boundary shim synthesizes (W1 description pinning, W2 information-flow control, W3 egress allowlist, W4 confirm-gate) each answer one question about a tool call: does this control apply to this tool? For W2/W3/W4, that answer is decided in this order:
- An explicit list in
control_config(read_tool_names/egress_tools/consequential_tools) — you said so, and this is always the final word for that tool name. - The tool's own MCP annotations (
readOnlyHint,destructiveHint,openWorldHint) — the protocol's standard risk vocabulary. Ranked below your declaration on purpose: the MCP spec is explicit that annotations are hints from a possibly-untrusted server. - Structural evidence: W3 only — a call with a URL, a bare hostname, or an IP-literal argument is treated as egress regardless of what the tool is called.
- A name heuristic — whole-word tokens like
read/fetch/listfor W2,fetch/http/webfor W3 egress,send/delete/payfor W4 consequential actions. A convenience, never the gate. Matching is on tokens, not substrings, soget_postal_codeis not treated as consequential because ofpost. - Otherwise: guarded. A tool that matches none of the above is still treated as in-scope for the control.
This means a W2/W3/W4 tool your target exposes that doesn't match any hint, and isn't
declared, is now guarded by default instead of silently passed through unguarded:
- W3 — an egress call with no destination Mylonite can identify is refused:
refused: ... no destination argument could be identified.
- W4 — an unconfirmed consequential call requires out-of-band approval. The
decision is made by an ApprovalPolicy supplied by whoever runs the scan (or a
human), not by the model: an earlier design refused the call and asked the model
to re-supply a server-minted confirm_token, which no model completed in practice
because the advertised tool schema never declared that argument.
- W2 — content read into the session carries two independent labels, following
FIDES:
- integrity — a read tool's result is untrusted. Untrusted context is refused
only at destructive sinks (delete/overwrite/transfer, or anything the server
marks destructiveHint), where an injection-driven call is damage in itself.
- confidentiality — public by default; private for a tool you list in
private_tools. A public-facing sink refuses to run in a private context, which
is what stops exfiltration.
Labels combine most-restrictive-wins across the session. Crucially, ordinary read-then-act work is not blocked: reading a document and emailing a summary is allowed, while reading a secret and emailing it is refused. (An earlier single-axis version tainted the whole session on any read and refused every subsequent sink call, so "the guard resisted" was true by construction.)
The first time this fires for a given tool name in a run, Mylonite logs a warning
(once per tool name) with the exact control_config snippet to paste to classify it
precisely — either to confirm it with the right list/argument name, or (by omitting it
from a non-empty declared list) to exempt it entirely. W1 has no comparable warning:
it sanitizes every tool description unconditionally, regardless of name, so there is
nothing to classify or exempt.
If your custom target has a read/egress/consequential tool with an unusual name (e.g.
materialise_record, dispatch_widget, relay_message), declare it up front so the
run doesn't spend a cycle discovering it:
control_config:
read_tool_names: [materialise_record]
egress_tools: [dispatch_widget]
egress_url_param: destination
consequential_tools: [relay_message]
Path containment¶
target.yaml is a shareable, PR-editable document — a teammate can mail you one, or
a pull request can edit the one already in your repo. Two fields resolve to a real
filesystem path, and both are contained, not just shape-checked:
system_prompt_fileresolves relative to the directory the target YAML itself lives in (itssource_dir), never the current working directory of whoever runsmylonite. A value like../../../../etc/passwd— or a symlink that points outside that directory — is refused before the file is ever opened; it cannot be used to read an arbitrary file off your disk. Seemylonite._paths.resolve_contained.mcp:filesystem:<scope>(the sandbox path handed to@modelcontextprotocol/server-filesystem) must be a real, existing, non-root directory:/,C:\, your home directory, and any path containing..are all rejected outright, and the directory must actually exist. SetMYLONITE_FS_SCOPE_ROOTto an absolute directory to additionally require every filesystem scope stay inside that root — an opt-in hard ceiling for CI/shared-runner environments that launch scans against target files they didn't author.
Both checks fail loud (PathEscapesBase / InvalidTargetScope) rather than silently
reading or sandboxing the wrong thing. See SECURITY.md for what a target.yaml you
received from someone else can and cannot do.
command and args resolve against the target file's own directory¶
A relative command or a relative path inside args resolves against the directory the
target YAML itself lives in — the same base system_prompt_file above uses — not against
whatever directory the process running mylonite happens to be in. A target file with
command: python, args: [server.py] finds server.py next to the YAML wherever you run
mylonite from: mylonite scan --target-file configs/app.yaml from the repo root, where
server.py sits next to configs/app.yaml, works the same as running it from inside
configs/. The same file now behaves identically for mylonite scan run from your
project root and mylonite gate run from CI's checkout root
(#187).
An inline mcp:custom target built from --command/--arg flags (no --target-file,
so no YAML to anchor to) keeps resolving command/args against your shell's current
directory, exactly as before.
The scaffold's relative-path check — a SQLite database path that looks relative (#18 — a
relative SQLite path can silently open a different or empty database on Windows) — now also
looks at args, not only env, and now runs every time a target file loads, not only when
scan --scaffold first writes one: check, scan, generate, validate and gate warn
against a file you hand-edited too. It is always a warning, never a refusal.
scan --scaffold also writes system_prompt_file relative to the scaffolded file's own
directory, the same base the loader uses, instead of the directory --scaffold ran from —
so the two never disagree, even when --scaffold and the target YAML end up in different
places. A system_prompt_file has to stay inside the target file's own directory either
way (see Path containment above); when the path given on the command
line can't be reached from there at all, the scaffold says so immediately instead of
leaving you to find out only when a later scan fails to load it.
A credential in args is written in plain text — and now warns¶
headers, request.headers and env are the only credential-bearing fields Mylonite
masks (see Secrets stay out of the file below) — args is
an unstructured string list with no key name to mask by, so a value embedded there (for
example args: [--api-key, sk-live-...], or a URL with ?access_token=...) still survives
byte-for-byte into every copy Mylonite writes: the scan directory, generate's co-located
copy, and the gate PR. scan --scaffold, and every later command that loads the file
(check, scan, generate, validate, gate), now warns when an args entry looks
credential-shaped — naming its position (args[N]) and withholding the value, the same
pattern the relative-SQLite-path warning above uses:
warning: args[0] looks like it carries a credential (value withheld). Move it to env: in
the target file, or set it via --env-file, instead of a launch argument -- see
docs/target-file.md#a-credential-in-args-is-written-in-plain-text-and-now-warns.
See SECURITY.md
for the full policy. If a target's launch needs a credential, pass it via env (most
subprocess CLIs also accept the value from an environment variable) or, for a remote
server, headers — never as a literal args entry.
--env, --command and --arg refuse once --target-file is set¶
scan accepts --target-file alongside the mcp:custom flags (--command, --arg,
--env) that build an inline target spec. Once --target-file is given, those three
flags would have no effect — the file's own command/args/env always win — so
passing any of them together with --target-file now refuses outright, naming exactly
the flag(s) you passed:
error: scan: --arg, --env have no effect together with --target-file -- the file's own
command/args/env always win. Put the launch override in the target file's env: block
instead, or drop --target-file to build the target from these flags.
Put any launch override in the target file's env: block instead of trying to pass it
on the command line alongside --target-file, or drop --target-file and build the
inline target from the flags alone. --scaffold is the one case where --command/
--arg/--env and --target-file can appear together without refusing: --scaffold
builds the file FROM those flags and never reads an existing --target-file.