Skip to content

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

  1. The code checks, here. Every Python file of the folder agent.yaml is 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).
  2. 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.
  3. The hunt, on our servers. The agents' graph, the catalogue and the rulebook are scanned by the bug hunter as one hunt job, 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.
  4. 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 QG023 says the desk's loop has no budget but the run's step limit, and QG105 says refund_card is 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: passed or failed. 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; QG023 and QG105 do 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:

  • QG001 Missing 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.
  • QG002 Escapable 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.
  • QG003 Multi-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.
  • QG011 Confused 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 outside reads_outside where it is not.
  • QG012 Cascading 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.
  • QG013 Irreversible 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.
  • QG014 Order rule escapable: a tool declared after, not_after or at_most runs where its rule is not checked. Put the tool on the guarded tool step, where order rules are checked over the session's history.
  • QG015 Undeclared route: see an undeclared route.
  • QG016 Unmodelled 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).
  • QG105 Unrecoverable 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 its undo, or mark it irreversible so 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. 1.0 __start__: the person's turn arrives.
  2. 1.1 model: the model reads it, and asks for a tool.
  3. 1.2 tools: the tool step runs read_inbox. What it returns comes from outside, and is marked untrusted:read_inbox.
  4. 1.3 model: the model reads the e-mail and asks for refund_card. Its request carries the mark.
  5. 1.4 tools: refund_card runs, 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_inbox is "refund_card never runs carrying untrusted:read_inbox unconfirmed";
  • detail.fix: the change that closes the path;
  • reproduction: not_yet_replayed until 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"
}
  • total and decided: how many questions the hunt asked of this graph, and how many it decided. Where they differ, the rest are listed under scan.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, whose sql argument is declared sql and is written by the model;
  • get_customer, confidential: subject with subject_argument: customer_id, whose record send_receipt can carry;
  • search_help, over a multi_tenant store, scoped by a tenant_id the model writes, served by an MCP server registered with no version or digest;
  • remember_note, persists: notes, reachable after read_inbox, and recall_notes, which reads the notes back before refund_card;
  • the billing desk, moved to a remote agent over a2a with 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.