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, or the
qls-router you run yourself -- 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 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.
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 neither the runtime nor your router can 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 | 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 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 |
| 8 | 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 |
| 9 | 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 |
| 10 | 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 |
| 11–12 | 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) is what its graph is proved against, and it is registered with the
catalogue for the tool check to enforce, so the rules proved are the rules the agent runs under:
stalwart verifyregisters the catalogue with the rulebook, compiled into the record the tool check evaluates (chapter 46).- At start-up, before its first turn, an agent compares the newest registered version's digest of its rulebook with the SHA-256 of the file's bytes. Equal, nothing is registered; different or absent, the catalogue is registered again with the rulebook before the agent runs.
- With no
rulebook:, the agent still registers its catalogue at start-up where the newest version does not hold its tools, each a read or a write as declared, so the tool check knows them. - Where it cannot be registered -- no service, a rulebook the compiler refuses (
rulebook_unsupported, naming the line), or an organisation that registers rulebooks as 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 registered for the tool check
The first start registers it; the second finds it held:
desk = Agent.from_file("agent.yaml")
desk.chat("show me order 4411", user="u-17") # before this first turn, the rulebook is registered
with open("policy.co", "rb") as policy:
digest = hashlib.sha256(policy.read()).hexdigest()
with Client() as client:
held = client.catalogue() # the newest catalogue version
print("version", held.version, "holds policy.co:", held.rulebook_ir["policy_sha256"] == digest)
again = Agent.from_file("agent.yaml") # a second start of the same agent
again.chat("show me order 4412", user="u-18")
with Client() as client:
print("version", client.catalogue().version, "after a second start")
And the tool check decides under it, with the context your application supplies:
with Client() as client:
billing = client.session("billing")
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", ""))
version 1 holds policy.co: True
version 1 after a second start
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. A bot line is something the
bot says, not a tool call: a flow that only says bot execute_refund_card governs no tool.
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.yamlregisters, 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
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
$ 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 | yes | yes |
QG019 |
Confidential data leaves | Vulnerability | yes | yes |
QG020 |
Retrieval not bound to the user | Vulnerability | yes | yes |
QG021 |
Untrusted content persisted | Vulnerability | yes | 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.