31 · Checking an agent before it runs¶
stalwart verify agent.yaml checks an agent before it serves anyone. It reads your code on your machine,
registers the agent's catalogue with its rulebook, and has every route through your agents searched on our
servers for a way to a tool without its check, its confirmation or the step it must follow. It prints each
finding with its rule, the line of agent.yaml it comes from, its OWASP entries and its fix, and exits
non-zero when the gate fails, so it stands in CI as it stands on your machine.
Language support
The selector understands turns written in English. Portuguese is its next grammar, and has not shipped yet; until it does, a turn written in Portuguese is not read as Portuguese.
What it does, in order¶
- The code checks, here. Every Python file of the folder
agent.yamlis in is read on your machine for network and credential problems, with the tools the file names taken as the functions whose parameters are tool arguments. Any finding fails the gate. Only its counts by rule leave your machine, with the registration (chapter 46). - The registration. The catalogue -- every tool with its words -- is registered as a new version, with
the file's
rulebook:compiled into the rules the tool check enforces. So the rules the agent is checked against here are the rules it runs under. Registration gives lints; nothing fails on a lint. - The hunt, on our servers. The agents' graph, the catalogue and the rulebook are scanned by the bug
hunter as one
huntjob, under its gate. The search runs on Stalwart's servers, never on your machine. A hunt that could not run is an error, never a clean result. - The report. The findings are printed one per block, the most severe first.
The shop agent of chapter 30, checked:
stalwart verify agent.yaml
Registered as catalogue version 1, with the rulebook policy.co: the tool check enforces the rules verified here.
Lints at registration (nothing fails on a lint):
write_not_retry_safe (warning) on charge_card: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on charge_card: a write with no result schema, so its outcome could not be reported
compensation_not_retry_safe (warning) on charge_card: 'refund_card' undoes it and is not retry-safe
write_without_compensation (warning) on refund_card: a write neither compensated nor marked irreversible
write_not_retry_safe (warning) on refund_card: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on refund_card: a write with no result schema, so its outcome could not be reported
write_not_retry_safe (warning) on send_receipt: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on send_receipt: a write with no result schema, so its outcome could not be reported
compensation_out_of_reach (warning) on charge_card: agent 'desk' can call it but not 'refund_card', which undoes it, so a run of that agent cannot undo it
Code checks: no finding in 4 file(s) (rules 4b48b0081360).
QG023 Medium on cycle:desk_model (agent.yaml:3)
the cycle through 'desk_model' calls the model or a tool, and nothing declares a budget: the framework's recursion limit of 60 is the run's only ceiling
OWASP LLM06:2026, ASI08:2026
Fix: Declare a budget for the agent or the run, or add an exit the model cannot avoid -- a counter in the state, declared on the edge as exit_after.
QG105 Review on refund_card (agent.yaml:10)
the write 'refund_card' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'refund_card' in the catalogue, or mark it irreversible.
QG105 Review on refund_card (policy.co:19)
the write 'refund_card' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'refund_card' in the catalogue, or mark it irreversible.
Rules with nothing to read, so nothing raised:
QG017: no argument is declared as code or a shell command
QG018: no argument is declared as SQL, a query, a path, a URL or a template
QG019: no tool is declared confidential
QG020: no retrieval from a store declared multi-tenant
QG021: no tool is declared to write a store declared persistent
QG106: no tool declares a source or a server, and the catalogue declares no modules
QG107: no agent in the catalogue declares a remote channel
Gate: passed
Read it from the top:
- Registered as catalogue version 1, with the rulebook policy.co: the version the tool check now enforces. With no key, the line says the catalogue was not registered, and the lints come from the runtime as registration would give them.
- Lints at registration: what the catalogue leaves unsaid, such as a write with no undo. Each names the tool and what to add; none fails anything.
- Code checks: the files read and the version of the rules they were read with.
- Each finding: the rule, its severity and the tool, with the file and line that declared it; what was
found, in one sentence; its OWASP entries; and the fix. Here
QG023says the desk's loop has no budget but the run's step limit, andQG105saysrefund_cardis a write with no undo, on a route with another write. - Rules with nothing to read: rules that raised nothing because nothing they read was declared (below).
- Gate:
passedorfailed. A finding under a rule that gates fails it, and so does any code-check finding. Each rule's page says whether it gates by default;QG023andQG105do not, so the shop agent passes.
The command's exit status, generated from the command itself:
| Status | Means |
|---|---|
0 |
done: for verify, the gate held; for redteam, nothing was realised |
1 |
the gate failed, or a campaign realised an attack |
2 |
the file or the command could not be read |
3 |
a checker or a service needed could not be reached, so nothing was checked |
--keep DIR writes what was checked into a folder: agent.graph.json, the agents' graph as the hunter
read it; agent.catalogue.json, the catalogue; and agent.findings.json, the findings document. Every
option:
| Option | What the product then does |
|---|---|
--keep |
writes the export, the catalogue and the findings into the directory |
--dependencies |
the lock file of the packages you install (requirements.txt, pyproject.toml, uv.lock or poetry.lock), checked against the advisory registry |
--replay |
replays each graph finding's witness on your agents, every tool a stub; a finding that does not reproduce fails the gate |
--export-only |
writes the agents' graph, as the verifier reads it, to the file, and stops |
The questions it answers for every tool¶
For every tool, on every route through your agents, to the depth stated, the hunt asks:
- Can it run without its check, or can the check be got past? Over several turns, can an approval given at one turn be spent at another?
- Can a write run carrying a mark from outside -- an e-mail, a web page, another agent with less privilege -- with nobody confirming it?
- Can an irreversible step come before steps that will be undone if the run fails, without its confirmation saying so?
- Can a tool run out of its declared order?
- Can a route go anywhere, because nothing declares where it may go?
- Can a write fail to be undone, because nothing undoes it and nothing says it cannot be?
Each question is a rule, and each rule has its page:
| Rule | Title | Type | Severity (as published) | OWASP |
|---|---|---|---|---|
QG001 |
Missing check | Vulnerability | by action | LLM03:2026, ASI02:2026 |
QG002 |
Escapable guard | Vulnerability | by action | LLM03:2026, ASI02:2026 |
QG003 |
Multi-turn escape | Vulnerability | by action, one level up | LLM03:2026, ASI03:2026 |
QG011 |
Confused deputy | Vulnerability | by action | LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026 |
QG012 |
Cascading injection | Vulnerability | by action | LLM01:2026, ASI07:2026, ASI08:2026 |
QG013 |
Irreversible before undoable | Bug | High | LLM03:2026, ASI08:2026 |
QG014 |
Order rule escapable | Vulnerability | by action | LLM03:2026, ASI02:2026 |
QG015 |
Undeclared route | Bug | Medium | ASI01:2026 |
QG016 |
Unmodelled path | Vulnerability | by action, at least High | ASI10:2026 |
QG105 |
Unrecoverable write | Security hotspot | review | LLM03:2026, ASI08:2026 |
Why each matters, and how to fix it:
QG001Missing check: the tool is reachable and nothing in the rules relates it to its check, so a conversation that reaches it reaches it whatever the check would say. Add the check as a condition on every route to the tool.QG002Escapable guard: the check exists, and something else satisfies it: a second flow to the same tool, a branch that falls through. Apply the patch the finding names inside every flow it names.QG003Multi-turn escape: a check read once and relied on later. Read the check in the same turn as the call, or carry it with the call it approved, and check again at the same depth.QG011Confused deputy: a value from outside reached the model, and a route lets that model call a privileged tool with no person confirming it. Put the tool on the guarded tool step, so a call carrying the mark is confirmed or refused; mark the tool that reads the outsidereads_outsidewhere it is not.QG012Cascading injection: the same, where the mark entered one agent and the call is made by another after a hand-off. Put the tool on the acting agent's guarded tool step, or confirm the hand-off.QG013Irreversible before undoable: if a later step fails, the run is undone, and the irreversible step stays done; the person who approved it was not told. Move the irreversible step last, or let its confirmation say that the steps after it cannot undo it.QG014Order rule escapable: a tool declaredafter,not_afterorat_mostruns where its rule is not checked. Put the tool on the guarded tool step, where order rules are checked over the session's history.QG015Undeclared route: see an undeclared route.QG016Unmodelled path: an attacker campaign made a privileged call on agents the hunt found clean, so what runs and what was checked disagree. Nothing this product reports is more urgent: check again from what runs, and compare the attacker's trace with the checked graph (chapter 33).QG105Unrecoverable write: a write with no undo, not marked irreversible, on a route with another write; a failed run cannot undo it, and nothing says so. Declare itsundo, or mark itirreversibleso a person confirms it knowing that (chapter 34).
A graph agent.yaml describes puts every tool on the guarded tool step, where each call is checked and
each write is confirmed, so most of these rules find nothing on it; they find what a team's own steps
leave open (chapter 27). The rules on what your
declarations say are below.
Reading a finding and its witness¶
A finding's witness is the run that reaches the tool: the steps, in order, that the hunt found. The
findings document --keep writes holds it under detail. Here is the QG011 that E16's own graph
raises on refund_card, with the fields this section reads:
{
"rule": "QG011",
"title": "Confused deputy",
"severity": "high",
"action": "refund_card",
"control": "a confirmation",
"obligation": "taint:refund_card:untrusted:read_inbox",
"reproduction": "not_yet_replayed",
"detail": {
"witness_path": [
"1.0 __start__",
"1.1 model",
"1.2 tools",
"1.3 model",
"1.4 tools"
],
"code_flow": [
{
"message": "step 1.0: the graph visits __start__"
},
{
"message": "step 1.1: the graph visits model"
},
{
"message": "step 1.2: the graph visits tools"
},
{
"message": "step 1.3: the graph visits model"
},
{
"message": "step 1.4: the graph visits tools"
}
],
"fix": "Bind 'refund_card' to the guarded tool node so a call carrying the label (untrusted:read_inbox) is confirmed or refused, or add a confirmation before it."
}
}
Read it step by step. A step's number is turn.step: 1.2 is the second step of the first turn.
1.0 __start__: the person's turn arrives.1.1 model: the model reads it, and asks for a tool.1.2 tools: the tool step runsread_inbox. What it returns comes from outside, and is markeduntrusted:read_inbox.1.3 model: the model reads the e-mail and asks forrefund_card. Its request carries the mark.1.4 tools:refund_cardruns, carrying the mark, and nothing confirms it: this team's own tool step is not the guarded one.
detail.code_flow gives one message per step, at the line that declared the step, so a code-scanning view
shows the same path. The other fields:
control: what was missing on the way -- here, a confirmation;obligation: what the hunt decided, named:taint:refund_card:untrusted:read_inboxis "refund_cardnever runs carryinguntrusted:read_inboxunconfirmed";detail.fix: the change that closes the path;reproduction:not_yet_replayeduntil the witness is replayed (below).
The fix closes the path at its last step: a person sees the refund, with each argument and where its value came from, and the sentence that it was proposed after reading an e-mail from outside (chapter 33).
The depth of the check¶
A clean result holds to a stated depth, and the findings document states it, under
scan.obligations.graph. For the shop agent:
verify("agent.yaml", keep="kept") # as `stalwart verify agent.yaml --keep kept`
kept = json.loads(Path("kept/agent.findings.json").read_text(encoding="utf-8"))
print(json.dumps(kept["scan"]["obligations"]["graph"], indent=2))
{
"total": 12,
"decided": 12,
"steps": 60,
"turns": 3,
"replay": "not replayed: the hunter reads the export, not the code that built it; each finding's witness_path is the run to reproduce"
}
totalanddecided: how many questions the hunt asked of this graph, and how many it decided. Where they differ, the rest are listed underscan.undecided, and nothing is claimed for them.steps: the most steps one run may take, 60 here, the agent's step limit. Every route is searched up to it.turns: how many of the person's turns a route is followed over, 3 here. An approval given at one turn and spent at a later one is found within them (QG003).
So "no way through" means no way within 60 steps and three turns. Agent Graph Verification, which checks a
graph you send it yourself, states the depth it searched on its [UNROLL] line
(chapter 47).
Replaying a witness¶
stalwart verify --replay replays each graph finding's witness on your agents themselves, with every tool
a stub that records its calls and returns fixture data, and a scripted model that makes exactly the
witness's choices. The checks run as they run in production. A finding is reproduced when the run takes
the witness's path and its tool is called. One that does not reproduce means the hunt and the agents that
run disagree: it is listed first, and fails the gate. Each replay is recorded as digests and its
outcome, never its trace.
What you can declare about your tools¶
Each declaration is optional, and each is read by one or more rules. A rule whose declaration is missing raises nothing -- see A missing declaration hides its rule.
| Declaration | On | What it says | Read by |
|---|---|---|---|
argument_kinds: {<arg>: code, shell, sql, query, path, url or template} |
a tool | what the tool interprets the argument as | QG017, QG018 |
sandboxed: true |
a tool with a code or shell argument |
your claim that the tool runs code in isolation | QG017's severity |
sanitised_by: {<arg>: "<how>"} |
a tool | your claim that the argument is validated before use | QG017, QG018 |
confidential: subject or internal |
a read tool | subject: may be shown to the person the record is about; internal: to nobody outside the company |
QG019 |
subject_argument: <arg> |
a tool marked confidential: subject |
the argument that selects whose record is read | QG019 |
stores: {<name>: {multi_tenant, persistent}} |
the file | the stores your tools read and write | QG020, QG021 |
retrieves: {store, scope_argument} |
a read tool | a retrieval from a store, and the argument that scopes it | QG020, QG021 |
persists: <store> |
a write tool | the tool writes to that store | QG021 |
source: {package, version, digest} or server: {url, version, digest} |
a tool | where the tool's code comes from | QG106 |
remote: {transport, authentication} |
an agent | how a hand-off to it travels | QG107 |
budget: {max_steps, max_tool_calls, max_model_calls} |
an agent, or the run | the run's ceiling | QG023 |
In agent.yaml the words go under a tool's name; in Python they are arguments of @tool(...). An MCP
server's registration is a tools entry that names the server and the tools it serves, with the
server's version or digest where you pin one.
At registration these declarations get four lints, none of which fails anything. Registration needs
your key in QLS_API_KEY; without one the catalogue is not registered, the report's first line says so,
and the lints come from the runtime as registration would give them -- the E16 run below was made that way.
Other lints read what every catalogue states rather than these declarations: a write with no result or
argument schema, an irreversible tool that asks for no confirmation, two tools the selector cannot tell
apart, a name the reader takes for grammar, a TypeScript function tagged neither @read nor @write, and
tool code that failed the code checks. The last four are errors: the version is registered, and refused
for routing until a fixed one is.
| Lint | Raised when |
|---|---|
store_undeclared |
retrieves or persists names a store the file does not declare |
confidential_subject_unbound |
a tool marked confidential: subject has no subject_argument |
argument_kind_unknown |
a kind outside the seven, or an argument the schema does not have |
argument_kind_suggested |
information only: a parameter named sql, query, command, cmd, script, code, path, url or template with no declared kind |
The rules that read them¶
Each rule decides one question over every path the graph or the policy allows, and states its limit: what it decides, and what it does not.
| Rule | Raised when | Its limit |
|---|---|---|
QG017 Model writes code that runs |
an argument declared as code or a shell command can take a value the model wrote, unconfirmed | the hunter decides where a model-written value goes, not what your tool does with it |
QG018 Model writes a query or a destination |
an argument declared as SQL, a query, a path, a URL or a template can take a value the model wrote, unbounded and unconfirmed | the hunter decides where a model-written value goes, not what your tool does with it |
QG019 Confidential data leaves |
a value from a confidential read can reach an egress tool unconfirmed, or the reply where its audience forbids it | decided through the tool calls and replies the graph routes, not what the model may say in other words |
QG020 Retrieval not bound to the user |
a retrieval from a multi-tenant store can run with its scope not taken from the authenticated principal | decided by how the retrieval is scoped, not how the store enforces the scope it is given |
QG021 Untrusted content persisted |
a write to a persistent store can run with a taint latch set and no confirmation | decided over the writes the graph routes, not a store poisoned by a path outside the agent |
QG023 Unbounded loop |
a cycle that calls a tool or the model can repeat beyond the budget, or has no budget but the recursion limit | decides whether the budget holds, not whether it is wise |
QG106 Unpinned tool source |
a tool, an MCP server or an action module has no version pin or digest | reports the missing pin; it does not judge the version pinned |
QG107 Unauthenticated agent channel |
a hand-off to a remote agent travels over a channel declared without authentication | whether the far side is who it claims to be; whether taint crosses is decided elsewhere |
QG022, the rail with a published advisory, is in chapter 28.
Every OWASP entry a finding carries is a statement at the policy level: the part of the risk that your
rules and your catalogue control. A clean scan under an entry means no finding at the policy level, not that
the risk is absent. The one entry no rule covers is LLM05:2026 Data and Model Poisoning: a property of
training data and model weights, which no guardrail policy decides (chapter 28).
Worked example E16: the shop agent grows¶
The shop agent takes on new tools, each with its declaration:
run_report, whosesqlargument is declaredsqland is written by the model;get_customer,confidential: subjectwithsubject_argument: customer_id, whose recordsend_receiptcan carry;search_help, over amulti_tenantstore, scoped by atenant_idthe model writes, served by an MCP server registered with no version or digest;remember_note,persists: notes, reachable afterread_inbox, andrecall_notes, which reads the notes back beforerefund_card;- the
billingdesk, moved to a remote agent overa2awith no authentication; - and no budget on the desk's loop.
agents:
desk:
model: "anthropic:claude-sonnet-5-5"
tools: [get_order, run_report, get_customer, send_receipt, search_help, read_inbox, remember_note, recall_notes, refund_card]
hands_off_to: [billing]
billing:
privilege: high
tools: [issue_refund]
remote: {transport: a2a, authentication: none}
tools:
- name: get_order
schema: {type: object, properties: {order_id: {type: string}}}
- name: run_report
argument_kinds: {sql: sql}
schema: {type: object, properties: {sql: {type: string}}}
- name: get_customer
confidential: subject
subject_argument: customer_id
schema: {type: object, properties: {customer_id: {type: string}}}
- name: send_receipt
writes: true
sends_out: true
schema: {type: object, properties: {order_id: {type: string}, body: {type: string}}}
- mcp: https://help.internal/mcp
tools: [search_help]
- name: search_help
retrieves: {store: help, scope_argument: tenant_id}
schema: {type: object, properties: {tenant_id: {type: string}, query: {type: string}}}
- name: read_inbox
reads_outside: true
schema: {type: object, properties: {folder: {type: string}}}
- name: remember_note
writes: true
persists: notes
schema: {type: object, properties: {note: {type: string}}}
- name: recall_notes
retrieves: {store: notes}
schema: {type: object, properties: {topic: {type: string}}}
- name: refund_card
writes: true
requires_privilege: high
schema: {type: object, properties: {order_id: {type: string}}}
- name: issue_refund
writes: true
schema: {type: object, properties: {order_id: {type: string}}}
stores:
help: {multi_tenant: true}
notes: {persistent: true}
stalwart verify reads the file, builds the agents it describes -- the desk's model, the guarded tool step
its tools are on, and a hand-off to billing -- and prints:
stalwart verify agent.yaml
The catalogue was not registered: set QLS_API_KEY to register it with the service.
Lints at registration (nothing fails on a lint):
argument_kind_suggested (info) on search_help: query: the name suggests an argument the tool interprets; declare its kind in argument_kinds if it is one
write_without_compensation (warning) on issue_refund: a write neither compensated nor marked irreversible
write_not_retry_safe (warning) on issue_refund: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on issue_refund: a write with no result schema, so its outcome could not be reported
write_without_compensation (warning) on refund_card: a write neither compensated nor marked irreversible
write_not_retry_safe (warning) on refund_card: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on refund_card: a write with no result schema, so its outcome could not be reported
write_without_compensation (warning) on remember_note: a write neither compensated nor marked irreversible
write_not_retry_safe (warning) on remember_note: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on remember_note: a write with no result schema, so its outcome could not be reported
write_without_compensation (warning) on send_receipt: a write neither compensated nor marked irreversible
write_not_retry_safe (warning) on send_receipt: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on send_receipt: a write with no result schema, so its outcome could not be reported
Code checks: no finding in 1 file(s) (rules 4b48b0081360).
QG018 High on run_report (agent.yaml:4)
'run_report' can run with its sql argument 'sql' written by the model or carrying a trust label, and nothing confirms the call
OWASP LLM10:2026, LLM01:2026
Fix: Bound 'sql' in the tool's schema with an enum, a const or a pattern; or take it from the user's typed text, the host's context or an earlier tool's result, and bind it so in the export's `arguments`; or put the call behind a confirmation -- the guarded tool node or an interrupt().
QG019 High on reply
the reply can carry a confidential value to someone its audience does not include: a record for nobody outside the company, or one whose subject the host's context did not choose -- through the tool calls and replies the policy routes
OWASP LLM02:2026, LLM08:2026
Fix: Bind each confidential read's subject argument to the authenticated principal in the host's context, or remove the confidential fields from the context before the reply.
QG020 High on search_help (agent.yaml:4)
'search_help' retrieves from the multi-tenant store 'help' with its scope taken from something other than the authenticated principal
OWASP LLM09:2026, LLM02:2026
Fix: Take the scope argument of 'search_help' from the host's context -- the authenticated tenant or user -- and bind it so in the export's `arguments`.
QG023 Medium on cycle:desk_model (agent.yaml:2)
the cycle through 'desk_model' calls the model or a tool, and nothing declares a budget: the framework's recursion limit of 60 is the run's only ceiling
OWASP LLM06:2026, ASI08:2026
Fix: Declare a budget for the agent or the run, or add an exit the model cannot avoid -- a counter in the state, declared on the edge as exit_after.
QG105 Review on issue_refund (agent.yaml:8)
the write 'issue_refund' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'issue_refund' in the catalogue, or mark it irreversible.
QG105 Review on refund_card (agent.yaml:4)
the write 'refund_card' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'refund_card' in the catalogue, or mark it irreversible.
QG105 Review on remember_note (agent.yaml:4)
the write 'remember_note' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'remember_note' in the catalogue, or mark it irreversible.
QG105 Review on send_receipt (agent.yaml:4)
the write 'send_receipt' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'send_receipt' in the catalogue, or mark it irreversible.
QG106 Review on search_help
'search_help' runs code from the MCP server https://help.internal/mcp with no version and no digest: it can change without the catalogue changing
OWASP LLM04:2026, ASI04:2026
Fix: Pin the MCP server https://help.internal/mcp to an exact version or record its digest in the catalogue, or accept the hotspot with the reason.
QG107 Review on desk -> billing (agent.yaml:2)
the hand-off from 'desk' to 'billing' reaches 'billing' over a2a with authentication: none: nothing shows the far side is the agent it claims to be
OWASP ASI07:2026
Fix: Declare and use mtls, oauth2 or signed_messages on the channel to 'billing', or accept the hotspot with the layer that authenticates it.
Rules with nothing to read, so nothing raised:
QG017: no argument is declared as code or a shell command
QG022: no Colang policy and no rails config was scanned
Gate: failed
The agents agent.yaml describes put every tool on the guarded tool step, where a write runs only on its
confirmation. That is why they raise no QG021 and no QG019 through send_receipt: the confirmation is
the fix those rules ask for, and it is already there. A team that writes its own steps, with the same tools
on a tool step that checks nothing, sees them. Here is the same catalogue with the desk's tools on such a
step:
stalwart verify desk graph
QG001 High on remember_note
'remember_note' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'remember_note' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 High on refund_card
'refund_card' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'refund_card' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 High on send_receipt
'send_receipt' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'send_receipt' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG011 High on refund_card
the privileged 'refund_card' can run carrying the label untrusted:read_inbox with no confirmation or refusal
OWASP LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026
Fix: Bind 'refund_card' to the guarded tool node so a call carrying the label (untrusted:read_inbox) is confirmed or refused, or add a confirmation before it.
QG011 High on remember_note
the privileged 'remember_note' can run carrying the label untrusted:read_inbox with no confirmation or refusal
OWASP LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026
Fix: Bind 'remember_note' to the guarded tool node so a call carrying the label (untrusted:read_inbox) is confirmed or refused, or add a confirmation before it.
QG011 High on send_receipt
the privileged 'send_receipt' can run carrying the label untrusted:recall_notes with no confirmation or refusal
OWASP LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026
Fix: Bind 'send_receipt' to the guarded tool node so a call carrying the label (untrusted:recall_notes) is confirmed or refused, or add a confirmation before it.
QG011 High on refund_card
the privileged 'refund_card' can run carrying the label untrusted:recall_notes with no confirmation or refusal
OWASP LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026
Fix: Bind 'refund_card' to the guarded tool node so a call carrying the label (untrusted:recall_notes) is confirmed or refused, or add a confirmation before it.
QG011 High on remember_note
the privileged 'remember_note' can run carrying the label untrusted:recall_notes with no confirmation or refusal
OWASP LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026
Fix: Bind 'remember_note' to the guarded tool node so a call carrying the label (untrusted:recall_notes) is confirmed or refused, or add a confirmation before it.
QG011 High on send_receipt
the privileged 'send_receipt' can run carrying the label untrusted:read_inbox with no confirmation or refusal
OWASP LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026
Fix: Bind 'send_receipt' to the guarded tool node so a call carrying the label (untrusted:read_inbox) is confirmed or refused, or add a confirmation before it.
QG018 High on run_report
'run_report' can run with its sql argument 'sql' written by the model or carrying a trust label, and nothing confirms the call
OWASP LLM10:2026, LLM01:2026
Fix: Bound 'sql' in the tool's schema with an enum, a const or a pattern; or take it from the user's typed text, the host's context or an earlier tool's result, and bind it so in the export's `arguments`; or put the call behind a confirmation -- the guarded tool node or an interrupt().
QG019 High on send_receipt
'send_receipt' sends data out of the host while the session holds a confidential value, and nothing confirms the call -- through the tool calls and replies the policy routes
OWASP LLM02:2026, LLM08:2026
Fix: Confirm the call to 'send_receipt' -- on the guarded tool node or with an interrupt() -- or remove the confidential fields from the context before it.
QG019 High on reply
the reply can carry a confidential value to someone its audience does not include: a record for nobody outside the company, or one whose subject the host's context did not choose -- through the tool calls and replies the policy routes
OWASP LLM02:2026, LLM08:2026
Fix: Bind each confidential read's subject argument to the authenticated principal in the host's context, or remove the confidential fields from the context before the reply.
QG020 High on search_help
'search_help' retrieves from the multi-tenant store 'help' with its scope taken from something other than the authenticated principal
OWASP LLM09:2026, LLM02:2026
Fix: Take the scope argument of 'search_help' from the host's context -- the authenticated tenant or user -- and bind it so in the export's `arguments`.
QG021 High on remember_note
'remember_note' can write to the persistent store 'notes' while the session carries content from outside, and nothing confirms the write
OWASP ASI06:2026, LLM01:2026
Fix: Confirm the write to 'notes', strip or refuse labelled values before it, or declare the store not persistent if it is not.
QG001 Medium on get_customer
'get_customer' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'get_customer' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 Medium on get_order
'get_order' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'get_order' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 Medium on read_inbox
'read_inbox' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'read_inbox' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 Medium on run_report
'run_report' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'run_report' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 Medium on recall_notes
'recall_notes' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'recall_notes' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 Medium on search_help
'search_help' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'search_help' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG023 Medium on cycle:model (run_e16.py:48)
the cycle through 'model' calls the model or a tool, and nothing declares a budget: the framework's recursion limit of 25 is the run's only ceiling
OWASP LLM06:2026, ASI08:2026
Fix: Declare a budget for the agent or the run, or add an exit the model cannot avoid -- a counter in the state, declared on the edge as exit_after.
QG105 Review on refund_card
the write 'refund_card' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'refund_card' in the catalogue, or mark it irreversible.
QG105 Review on remember_note
the write 'remember_note' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'remember_note' in the catalogue, or mark it irreversible.
QG105 Review on send_receipt
the write 'send_receipt' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'send_receipt' in the catalogue, or mark it irreversible.
QG106 Review on search_help
'search_help' runs code from the MCP server https://help.internal/mcp with no version and no digest: it can change without the catalogue changing
OWASP LLM04:2026, ASI04:2026
Fix: Pin the MCP server https://help.internal/mcp to an exact version or record its digest in the catalogue, or accept the hotspot with the reason.
Rules with nothing to read, so nothing raised:
QG017: no argument is declared as code or a shell command
QG022: no Colang policy and no rails config was scanned
QG107: no agent in the catalogue declares a remote channel
Gate: failed
The QG011 on refund_card through recall_notes comes from the store: until every write into notes is
proved clean, what recall_notes reads back is untrusted, so the privileged call it feeds is a confused
deputy (chapter 33).
If you already use LangGraph
The second scan is of a hand-built StateGraph whose tools sit on a plain ToolNode, exported with
export_graph. The guarded tool step is StalwartToolNode. Chapter 27
shows how to export and check a graph of your own.
A missing declaration hides its rule¶
The same agent with the sql kind removed from run_report:
stalwart verify agent.yaml
The catalogue was not registered: set QLS_API_KEY to register it with the service.
Lints at registration (nothing fails on a lint):
argument_kind_suggested (info) on run_report: sql: the name suggests an argument the tool interprets; declare its kind in argument_kinds if it is one
argument_kind_suggested (info) on search_help: query: the name suggests an argument the tool interprets; declare its kind in argument_kinds if it is one
write_without_compensation (warning) on issue_refund: a write neither compensated nor marked irreversible
write_not_retry_safe (warning) on issue_refund: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on issue_refund: a write with no result schema, so its outcome could not be reported
write_without_compensation (warning) on refund_card: a write neither compensated nor marked irreversible
write_not_retry_safe (warning) on refund_card: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on refund_card: a write with no result schema, so its outcome could not be reported
write_without_compensation (warning) on remember_note: a write neither compensated nor marked irreversible
write_not_retry_safe (warning) on remember_note: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on remember_note: a write with no result schema, so its outcome could not be reported
write_without_compensation (warning) on send_receipt: a write neither compensated nor marked irreversible
write_not_retry_safe (warning) on send_receipt: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on send_receipt: a write with no result schema, so its outcome could not be reported
Code checks: no finding in 0 file(s) (rules 4b48b0081360).
QG019 High on reply
the reply can carry a confidential value to someone its audience does not include: a record for nobody outside the company, or one whose subject the host's context did not choose -- through the tool calls and replies the policy routes
OWASP LLM02:2026, LLM08:2026
Fix: Bind each confidential read's subject argument to the authenticated principal in the host's context, or remove the confidential fields from the context before the reply.
QG020 High on search_help (agent.yaml:4)
'search_help' retrieves from the multi-tenant store 'help' with its scope taken from something other than the authenticated principal
OWASP LLM09:2026, LLM02:2026
Fix: Take the scope argument of 'search_help' from the host's context -- the authenticated tenant or user -- and bind it so in the export's `arguments`.
QG023 Medium on cycle:desk_model (agent.yaml:2)
the cycle through 'desk_model' calls the model or a tool, and nothing declares a budget: the framework's recursion limit of 60 is the run's only ceiling
OWASP LLM06:2026, ASI08:2026
Fix: Declare a budget for the agent or the run, or add an exit the model cannot avoid -- a counter in the state, declared on the edge as exit_after.
QG105 Review on issue_refund (agent.yaml:8)
the write 'issue_refund' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'issue_refund' in the catalogue, or mark it irreversible.
QG105 Review on refund_card (agent.yaml:4)
the write 'refund_card' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'refund_card' in the catalogue, or mark it irreversible.
QG105 Review on remember_note (agent.yaml:4)
the write 'remember_note' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'remember_note' in the catalogue, or mark it irreversible.
QG105 Review on send_receipt (agent.yaml:4)
the write 'send_receipt' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'send_receipt' in the catalogue, or mark it irreversible.
QG106 Review on search_help
'search_help' runs code from the MCP server https://help.internal/mcp with no version and no digest: it can change without the catalogue changing
OWASP LLM04:2026, ASI04:2026
Fix: Pin the MCP server https://help.internal/mcp to an exact version or record its digest in the catalogue, or accept the hotspot with the reason.
QG107 Review on desk -> billing (agent.yaml:2)
the hand-off from 'desk' to 'billing' reaches 'billing' over a2a with authentication: none: nothing shows the far side is the agent it claims to be
OWASP ASI07:2026
Fix: Declare and use mtls, oauth2 or signed_messages on the channel to 'billing', or accept the hotspot with the layer that authenticates it.
Rules with nothing to read, so nothing raised:
QG017: no argument is declared as code or a shell command
QG018: no argument is declared as SQL, a query, a path, a URL or a template
QG022: no Colang policy and no rails config was scanned
Gate: failed
No QG018: the rule has nothing to read. Registration says a declaration may be missing
(argument_kind_suggested), and the summary lists the rule as having had nothing to read. A missing
declaration hides its rule -- the scan is clean of what it was not told about, and says so.
An undeclared route¶
A route is where a run may go after a step. In agent.yaml every route is declared: an agent goes to its
tools, back to its model, or to an agent it names in hands_off_to, and nowhere else. A step a team writes
in code can choose where to go next with a function that reads what the model said. If nothing declares the
places it may choose, the hunt has to assume it can choose any step -- past every check the design puts
in the way. That is QG015, and every way through it opens names it, so one fix closes them all: declare
where the route may go.
If you already use LangGraph
An undeclared route is a conditional edge with no path_map, or a node returning Command with no
Command[Literal[...]] annotation. Declare the destinations, or use stalwart_edge with the allowed
set (chapter 27).
The gate in CI¶
stalwart verify exits 1 when the gate fails and 3 when nothing could be checked, so a CI step that runs it
stops on either. Keep the findings with --keep and upload them as the run's record; chapter 46
has the workflow.
The limits¶
- A clean scan proves that no path reaches a tool without its check, its confirmation or the step it must follow, to the depth stated. It does not prove the model chooses well, and your own functions are read as able to do anything.
- The depth is the one the findings document states: the run's step limit and the number of turns. A path longer than either is not searched.
- The hunter decides where a model-written value goes, not what your tool does with it.
- A rule whose declaration is missing raises nothing; the summary lists it as having had nothing to read.
- A declaration you make --
sandboxed,sanitised_by, an authentication on a channel -- is taken as stated and listed under the scan's assumptions; the hunter does not verify it.