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 -- 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

  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 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_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 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, or POST /v1/rule-sheets/compile for a sheet), once for its client; a folder stalwart rules compile wrote 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 verify compiles 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 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 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=...), or Agent(..., 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 takes context= on each call (chapter 24).
  • The agent's own tool says it: facts: in agent.yaml, or Agent(..., 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 from get_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 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 lints, 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 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 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.