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, lints the agent's catalogue and compiles 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 reads turns written in English and in Brazilian Portuguese (pt-BR). The language is chosen, never guessed from the turn: the console's Settings page (Language the selector reads), language on a plan request, or the settings.language of your vocabulary file; with none of them, English. A Portuguese turn may be typed with its accents or without them. European Portuguese is not a grammar the selector has, and a turn in any other language is unread.

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 hunt (chapter 46).
  2. The catalogue and the rules, here. The catalogue -- every tool with its words -- is made from agent.yaml and linted by the runtime on your machine, and the file's rulebook: is compiled into the rules the tool check enforces, as the agent compiles it at its start. So the rules the agent is checked against here are the rules it runs under. No lint blocks the agent, and nothing of the agent is kept on the service.
  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

Compiled the rulebook policy.co: the tool check enforces the rules verified here.
Lints from the runtime (none of these blocks the agent):
  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_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_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 d656f9ab32d3).

Warnings: 2 findings that do not block this agent. Look at each and weigh it; the agent can run as it is.

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: Give 'refund_card' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), 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. Nothing blocks this agent: the 2 warnings above are for you to look at and weigh.

Read it from the top:

  • Compiled the rulebook policy.co: the rules the tool check enforces, compiled as the agent compiles them at its start. A rulebook the tool check cannot build a gate from is a lint instead, naming its line.
  • Lints from the runtime: what the catalogue leaves unsaid, such as a write with no undo, and two tools the selector cannot tell apart. 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 blocks the agent only where it fails the gate: by default, a new Critical finding -- such as a missing or escapable check on a tool that is irreversible or that requires_privilege: high -- and any code-check finding. Every other finding is a warning: it does not block the agent, and is there for you to look at and weigh. The report lists what blocks first, under its own heading, then the warnings, and its last line says in so many words whether anything blocks the agent. The shop agent's QG023 and QG105 are warnings, so it passes.

The command's exit status, generated from the command itself:

Status Means
0 done: for verify and redteam, the gate held; with --graph or --mcp, the proof held
1 the gate failed, or the proof found a way through the checks
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
--no-register checks without registering the catalogue -- no catalogue version is used, and the service keeps the version registered last; the lints come from the runtime, without the three only registration runs

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 -- which may be the tool itself with its arguments swapped -- or undo: none where a failed run may leave it done, 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": "critical",
  "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 where a run stops: max_steps its steps; max_tool_calls and max_model_calls what one turn calls, the call past one not made. The export says the run enforces them, and the check holds the graph to them QG023

In agent.yaml the words go under a tool's name; in Python they are arguments of @tool(...). An MCP server's entry is a tools entry that names the server and the tools it serves, with the server's version or digest where you pin one.

The runtime gives these declarations four lints, none of which fails anything.

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: fix them before the agent serves anyone.

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

Lints from the runtime (none of these blocks the agent):
  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_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_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 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_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 d656f9ab32d3).

Warnings: 10 findings that do not block this agent. Look at each and weigh it; the agent can run as it is.

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: Give 'issue_refund' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), 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: Give 'refund_card' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), 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: Give 'remember_note' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), 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: Give 'send_receipt' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), or mark it irreversible.

QG106 Review on the MCP server https://help.internal/mcp (agent.yaml:24)
  'search_help' runs code from the MCP server https://help.internal/mcp with no version and no digest, so it can change without the catalogue changing
  OWASP LLM04:2026, ASI04:2026
  Fix: Pin the MCP server https://help.internal/mcp: give its `mcp:` entry in agent.yaml a `version:` (an exact version) or a `digest:`, or its `server` 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: passed. Nothing blocks this agent: the 10 warnings above are for you to look at and weigh.

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

Blocking: 3 findings that block this agent until fixed, or accepted with its reason:

QG001 Critical 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.

QG011 Critical 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 Critical 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.

Warnings: 22 findings that do not block this agent. Look at each and weigh it; the agent can run as it is.

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 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 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 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: Give 'refund_card' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), 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: Give 'remember_note' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), 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: Give 'send_receipt' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), or mark it irreversible.

QG106 Review on the MCP server https://help.internal/mcp
  'search_help' runs code from the MCP server https://help.internal/mcp with no version and no digest, so it can change without the catalogue changing
  OWASP LLM04:2026, ASI04:2026
  Fix: Pin the MCP server https://help.internal/mcp: give its `mcp:` entry in agent.yaml a `version:` (an exact version) or a `digest:`, or its `server` 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. This agent is blocked by the 3 blocking findings above. The warnings do not block it.

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

Lints from the runtime (none of these blocks the agent):
  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
  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
  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_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 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_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 d656f9ab32d3).

Warnings: 9 findings that do not block this agent. Look at each and weigh it; the agent can run as it is.

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: Give 'issue_refund' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), 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: Give 'refund_card' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), 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: Give 'remember_note' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), 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: Give 'send_receipt' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), or mark it irreversible.

QG106 Review on the MCP server https://help.internal/mcp (agent.yaml:23)
  'search_help' runs code from the MCP server https://help.internal/mcp with no version and no digest, so it can change without the catalogue changing
  OWASP LLM04:2026, ASI04:2026
  Fix: Pin the MCP server https://help.internal/mcp: give its `mcp:` entry in agent.yaml a `version:` (an exact version) or a `digest:`, or its `server` 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: passed. Nothing blocks this agent: the 9 warnings above are for you to look at and weigh.

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 found no path to a tool without its check, its confirmation or the step it must follow, to the depth stated. It does not show 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.