Skip to content

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

  1. The router reads every turn before any model sees it (below).
  2. The tool check decides every tool call, whether the router or the model chose it (below).
  3. 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_unread is refuse, or answer with the extract composer (chapter 44);
  • no agent hands_off_to another;
  • 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 verify registers 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 raises RulebookNotHeld, 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 raises NotVerified, 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.yaml registers, 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 graph desk.export() returns and the catalogue desk.catalogue() returns, as a graph job (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.