45 · Guarded and proved¶
A stalwart-agents agent is guarded while it runs and proved before it runs. Two checks stand around its
model, both decided by native code on your machine -- the licensed runtime the package carries -- and never
by a model: the router decides whether the model is called at all,
and the tool check decides every call. Before the agent's first turn, the bug hunter searches its graph for a
way past either. This chapter is how the two checks decide, what that guarantees, and how the proof runs.
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.
Two checks and a proof¶
- The router reads every turn before any model sees it (below).
- The tool check decides every tool call, whether the router or the model chose it (below).
- The proof searches every route through your agents for a way to a tool without its check, its confirmation or the step it must follow (below).
The hosted API decides nothing while your agent runs. Where the runtime cannot answer, a plan or a check
raises SelectorUnavailableError and the call is not made: the agent never falls back to an
unchecked call.
The router: whether a model is called¶
Every turn is read into a plan against what your tools declare. Where the turn goes, generated from the router itself:
| What the router read | Where the turn goes |
|---|---|
| a tool step that resolves | straight to the guarded tool step, with the call written from the person's words: no model call |
an ask whose missing values the catalogue's listing tools produce (chain) |
to the guarded tool step with those calls, a round at a time, and then the plan their results make, in place of the question |
| an ask | the turn ends with a fixed question, and the model is not called |
a write put to the person (confirm) |
to the guarded tool step with the call the person's yes would make; its confirmation is the one question asked |
| declined | the turn ends with a refusal naming the rule; the model is never visited |
| a calculation | once the reads it draws on have run, the agent's own model writes the function and the sandbox runs it, on your machine |
| a calculation this machine cannot run | declined, its reason starting compute off: |
unread (unread) |
first the one request to say it again; then what on_unread says |
A turn nothing reads is asked to be said again once. After that, the agent's on_unread decides:
| Setting | What a turn nothing reads does |
|---|---|
refuse |
the turn ends with a refusal; no model is called |
model |
the turn goes to the agent's model, and every call it asks for is checked |
ask |
the person is asked whether the model may have the turn, and it goes to the model only on their yes |
answer |
a question is answered from your documents (chapter 44); with no grounded answer, the turn is refused |
Where an agent says nothing, each conversation takes the setting of your console's Settings page at its first
turn, or refuse.
When no model is ever called¶
The router answers the turns it reads without a model. A model is called only where your settings send a turn to one, so no model is ever called when all of these hold:
- the agents name no
model; on_unreadisrefuse, oranswerwith theextractcomposer (chapter 44);- no agent
hands_off_toanother; - replies are your phrasings:
replies: {composer: template}(chapter 42).
The tool check: every call, whoever chose it¶
The check runs in the runtime, once per call, with the catalogue, the rulebook and the session's history, and the first rule that applies decides. The rules, in the order the runtime takes them (chapter 32 gives each its example):
| Order | What is checked | Reasons it answers with | Example |
|---|---|---|---|
| 1 | the tool is in the catalogue and this agent may reach it; with several agents, the call names its agent; under a lapsed licence, a write is refused, or every call where the tenant's setting says refuse |
unknown_tool, agent_required, licence_lapsed |
delete_order, which the catalogue does not have: refuse, unknown_tool |
| 2 | the session's history verifies, entry by entry, by the signature each entry carries over the ones before it | history_invalid |
a history whose first entry was edited by hand: refuse, history_invalid |
| 3 | the arguments against the tool's schema; an argument the schema does not declare is refused | argument_schema |
refund_card with a note it does not declare: refuse, argument_schema at /note |
| 4 | a destination -- an address, a URL, a host -- in an argument of a tool not declared sends_out |
undeclared_destination |
refund_card with https://refunds.invalid/4411 as its order: refuse, undeclared_destination |
| 5 | the tool's order rules over the verified history: after (and an argument bound to that call's result), not_after, at_most |
order_rule |
a tool declared after: [get_order], called before any get_order ran in the session: refuse, order_rule, naming after get_order |
| 6 | an undo names the step it undoes, that step ran, the undo is the one declared for it, each bound argument comes from that step, and the step is undone once | compensation_spent, compensation_mismatch |
refund_card sent a second time as the undo of a charge already undone: refuse, compensation_spent |
| 7 | the tool's own checks on the call's arguments and the results earlier tools returned, its limits per period (at_most: {count, per}) and what it may spend (spend), against what the host counted; never on an undo |
condition_failed, condition_unknown, usage_unknown, limit_reached, spend_unknown, spend_exceeded |
create_order with quantity: 7 where checks says at_most: {from: check_stock.in_stock} and the stock is 5: refuse, condition_failed; a fourth order on a day limited to 3: refuse, limit_reached |
| 8 | the rulebook's gate, as the tenant's compiled rulebook decides the call | the rulebook's rule that refused it | a refund the rulebook's flow refuses: refuse, with that rule as the reason and its flow in the detail |
| 9 | marks less trusted than the tool requires, on a write; refused here where the tenant's taint_policy is refuse |
tainted_call |
under taint_policy: refuse, a refund proposed after reading the inbox: refuse, tainted_call |
| 10 | a confirmation token that covers exactly this call, with these arguments, at this point of the history | confirmed |
the same refund, presented with the token the person approved: allow, confirmed |
| 11 | every reason a write needs a person, together; a write with none of them is still confirmed, for write |
gate_clarify, argument_origin, tainted_call, irreversible_pivot, schema_partial, write |
the refund with an amount the model wrote: confirm, argument_origin: amount; the same amount typed by the person: confirm, write |
| 12–13 | an undo that passed every step above; otherwise, a call nothing above held back | compensation, allow |
get_order, a read: allow; refund_card as the undo a failed run sends: allow, compensation |
It gives one of three answers:
allow: the tool runs once, with an idempotency key.confirm: a person is asked, shown each argument and where its value came from; the call is checked again with the signed token their approval carries (chapter 35).refuse: nothing runs. Where earlier steps of the run had written something, those writes are undone, last first (chapter 34).
What is guaranteed, and what is not¶
Guaranteed. A refused call runs nothing. A confirmed call runs only with the exact arguments the person
saw. The history cannot be dropped, edited or reordered without the check saying history_invalid. No model
is called unless your settings route a turn to one.
Not guaranteed. The check judges names, schemas, marks and the history, never what a reply means
(chapter 42 holds replies to what was done). A tool reachable outside the agent can be called
around the check, unless the gateway stands in front of it. Agent(..., verify=False) skips the
proof.
The rulebook it runs under¶
An agent's rulebook: -- Colang 1.0 or 2.x, a rule sheet, or the folder stalwart rules compile wrote -- is what
its graph is proved against and what its tool check enforces, so the rules proved are the rules the agent runs
under:
- At start-up, before its first turn, the agent compiles its rulebook into the record the tool check
evaluates (
POST /v1/rulebooks/compile, orPOST /v1/rule-sheets/compilefor a sheet), once for its client; a folderstalwart rules compilewrote is read as it is, with no call. Nothing is kept on the service: every check carries the record beside the agent's catalogue. stalwart verifycompiles it the same way, and proves the graph against it (chapter 46).- Where it cannot be compiled -- no service, a rulebook the compiler refuses (
rulebook_unsupported, naming the line), or an organisation that sends compiled records only (policy_input_refused) -- the agent does not run. It raisesRulebookNotHeld, saying why.
The shop's two desks, with a rulebook that lets a refund run only for an order that is paid:
define user ask refund
"refund order 4411"
"give the customer their money back"
define flow refunds
user ask refund
if $order_paid
execute refund_card
else
bot refuse
# The shop's two desks, with a rulebook that decides when a refund may run.
agents:
desk:
model: "anthropic:claude-sonnet-5-5"
tools: [get_order, charge_card, send_receipt, read_inbox]
hands_off_to: [billing]
on_unread: refuse
billing:
privilege: high
tools: [refund_card]
tools:
- python: shop.tools
rulebook: ./policy.co # proved against, and compiled for the tool check
The tool check decides under it, with the context your application supplies:
desk = Agent.from_file("agent.yaml")
catalogue = desk.catalogue() # both desks' tools, made on this machine
rulebook = desk.rules().record # policy.co, compiled once for this agent
with Client() as client:
billing = client.session("billing", catalogue=catalogue, rulebook=rulebook)
billing.read("refund order 4411 40.00")
for paid in (False, True):
check = billing.check_tool_call("refund_card", {"order_id": "4411", "amount": 40.00},
origins={"order_id": "typed", "amount": "typed"},
context={"order_paid": paid})
print(f"order_paid {paid}:", check.decision, ", ".join(check.reasons), check.detail.get("flow", ""))
order_paid False: refuse refuse refunds
order_paid True: confirm write
The unpaid order's refund is refused by the rulebook's refunds flow; the paid one passes the rulebook and is
confirmed, as every write is.
A rulebook's actions name tools by execute <tool>, as the refunds flow does, or by bot execute_<tool>, the
same call spelled as the registry names it. Any other bot line is something the bot says, not a tool call.
The rulebook's facts¶
A rule such as if $order_paid reads a fact: true or false, by name, sent with each check. A fact nothing
gives is false, so the rule refuses. There are two ways to give one, and an agent may use both:
- Your code says it, where your application knows it:
Agent.from_file("agent.yaml", context=...), orAgent(..., context=...), takes the facts as a mapping, or as a function of the user that returns them, called at each check so it gives them as they stand then. A check made through the client or a session takescontext=on each call (chapter 24). - The agent's own tool says it:
facts:inagent.yaml, orAgent(..., facts=...), reads a fact from a field of a tool's result, in the latest answer of that tool in the conversation. The shop reads both of its facts fromget_order(chapter 30):
facts:
card_on_file: {from: get_order.card, same: order_id}
order_paid: {from: get_order.status == paid, same: order_id}
from is <tool>.<field>, true when the field is true, a number other than 0, or a text, list or mapping
that is not empty; or <tool>.<field> == <value>, true when the field equals the value. Until the tool has
answered, the fact is false, so the shop's charge is allowed only once show me order 4411 has shown a card.
same: order_id ties the fact to the call being checked: only an answer to get_order for the order the
call names counts, so one order's status never allows another order's refund. Leave it out only for a fact
about the conversation as a whole, such as the user's account. A fact is never read from a tool that
reads_outside: anyone could write what it returns. Where your code and the file both give a fact, the
file's stands.
If you already use LangGraph
The guard middleware, build_stalwart_graph and Guard.call_with take context= too, and
stalwart_agents.facts.Context(facts=agent.facts) reads the file's facts: from the tools' answers for
them (chapter 27).
The proof¶
The agents compile to a graph (qls-graph/1) and its catalogue. Each obligation -- this tool never runs
without its check, this write never runs unconfirmed after reading from outside -- is a question the hunter
answers over every route:
- reachable: a finding, with a witness path mapped back to the line of
agent.yaml(chapter 31); - unreachable: the obligation holds, to the depth stated (chapter 31);
- undecided: reported as undecided, never as clean.
It runs three ways:
- Automatically.
Agent(..., verify=True), the default, proves the graph on our service before the first turn, once per graph: a rebuild of the same graph is answered from the cache. A finding of type Vulnerability raisesNotVerified, with the findings, and nothing runs. A proof that could not run raises too, never a clean result. - As a CI gate.
stalwart verify agent.yamllints, compiles, hunts and prints the report, and exits 1 when the gate fails (chapter 46). The shop agent of chapter 30:
$ stalwart verify agent.yaml
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.
$ echo $?
0
- Over the API.
Client().verify_graph(graph, catalogue=..., rulebook=..., session_steps=...)sends the graphdesk.export()returns and the cataloguedesk.catalogue()returns, as agraphjob (chapter 47).
The rules it proves¶
Every rule the hunt of an agent's graph and rulebook raises, generated from the hunter's catalogue. Fails the
gate is what stalwart verify holds a build to; stops Agent(...) is what the automatic proof refuses to
run with:
| Rule | Title | Type | Fails the gate | Stops Agent(...) |
|---|---|---|---|---|
QG001 |
Missing check | Vulnerability | yes | yes |
QG002 |
Escapable guard | Vulnerability | yes | yes |
QG003 |
Multi-turn escape | Vulnerability | yes | yes |
QG004 |
Permissive by default | Bug | no | no |
QG005 |
Unshippable policy | Bug | yes | no |
QG006 |
Unverified region | Bug | no | no |
QG007 |
Read before written | Vulnerability | no | yes |
QG008 |
Call without execute | Vulnerability | no | yes |
QG009 |
Undefined bot message | Vulnerability | no | yes |
QG010 |
Dead action | Vulnerability | no | yes |
QG011 |
Confused deputy | Vulnerability | yes | yes |
QG012 |
Cascading injection | Vulnerability | yes | yes |
QG013 |
Irreversible before undoable | Bug | no | no |
QG014 |
Order rule escapable | Vulnerability | yes | yes |
QG015 |
Undeclared route | Bug | no | no |
QG016 |
Unmodelled path | Vulnerability | yes | yes |
QG017 |
Model writes code that runs | Vulnerability | yes | yes |
QG018 |
Model writes a query or a destination | Vulnerability | no | yes |
QG019 |
Confidential data leaves | Vulnerability | no | yes |
QG020 |
Retrieval not bound to the user | Vulnerability | no | yes |
QG021 |
Untrusted content persisted | Vulnerability | no | yes |
QG022 |
Rail with a published advisory | Vulnerability | yes | yes |
QG023 |
Unbounded loop | Bug | no | no |
QG101 |
Unchecked action | Security hotspot | no | no |
QG104 |
Check missing against its peers | Security hotspot | no | no |
QG105 |
Unrecoverable write | Security hotspot | no | no |
QG106 |
Unpinned tool source | Security hotspot | no | no |
QG107 |
Unauthenticated agent channel | Security hotspot | no | no |
QG312 |
Engine disagreement | Bug | yes | no |
Each rule's page says what it proves and what it does not.
The limits¶
- A model chooses freely. The proof reads a model step as able to choose any tool and any argument it is given; the checks hold what it chooses, and nothing proves its choices good.
- Steps that are not ours are opaque. A step your team writes is read as able to do anything its declared routes allow (chapter 31).
- A clean proof holds to the depth it states: the run's step limit and the number of turns the findings document names.
- The proof covers the graph, the catalogue and the rulebook, not your tools' own code; the code checks of chapter 46 read that code for network and credential problems only.
- The rulebook decides only the tools its flows call by
execute. A tool no flow governs is decided by the rest of the check.