Guarded and proved
A stalwart-agents agent has two checks around its model, each decided by deterministic native code rather than by a model, and its graph is proved before it runs. Both checks run on your own host: in the licensed runtime the stalwart-agents wheel carries, or in the qls-router you run in your environment. The hosted service decides nothing at run time. Where neither the runtime nor a router answers, a plan or a check raises SelectorUnavailableError: the call is blocked, never let through.
The router decides whether a model is called
Every turn is read before any model sees it, and each step it is read into goes one of four ways:
- A tool. The step goes to the guarded tool node, with no model call.
ask. The turn ends with a fixed question.declined. The turn ends with a refusal, and the model is never visited.unread. The agent asks the person to say it again, once; after that,on_unreaddecides:refuse,model,askoranswer.
No model is ever called when the agent has no model, on_unread: refuse, no hands_off_to, and replies and answers written by composer: template and composer: extract.
Every tool call is checked
Every tool call is checked before it runs, whether the router or the model chose it. The first rule that applies decides:
- Catalogue. The tool must exist and be in the agent's
allowed_tools. On a lapsed licence, writes are refused. - History. The signed chain over the session's history must verify, or the call is refused as
history_invalid. - Arguments. They must match the tool's JSON schema.
- Destinations. A URL or other destination is refused unless the tool is declared
sends_out. - Order rules.
after,not_afterandat_most, evaluated over the verified history. - Compensation. An undo must bind to the step it undoes, and is spent once.
- Your rules. The rulebook registered with the catalogue -- an agent's Colang rulebook or your rule sheet -- with conditions such as
amount_overcomputed from the call's own arguments, so the host cannot override them. - Taint. A write carrying input less trusted than the tool requires is confirmed by default, or refused where your settings say so.
- Signed confirmation. A signed token covering exactly this tool and these arguments allows the call.
- Default. Any other write is confirmed. A write is never allowed silently.
The answer is one of three:
allow. The tool runs once, under an idempotency key.confirm. The person is asked, and the call is checked again with a signed token.refuse. Nothing runs. Where earlier steps of the run wrote something, those writes are undone, last first.
A write waits for the person, and runs only with the arguments they saw:
from decimal import Decimal
from stalwart_agents import Agent, tool
def get_order(order_id: str) -> dict: ... # a read: no words needed
@tool(writes=True, undo="refund_card")
def charge_card(order_id: str, amount: Decimal) -> dict: ...
@tool(writes=True)
def refund_card(order_id: str, amount: Decimal) -> dict: ...
desk = Agent("desk", tools=[get_order, charge_card, refund_card], rulebook="policy.co")
reply = desk.chat("charge order 4411 30 pounds", user="u-17")
for waiting in reply.waiting: # the write waits: decided `confirm`
print(waiting.kind, waiting.wording) # confirm Run charge_card?
reply = desk.confirm("u-17", "approve") # checked again, under the signed token
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 or rewritten without detection. No model is called unless the configuration routes a turn to one.
- Not guaranteed. The check judges names, schemas, labels and history, not what a reply means. A tool reachable outside the agent can be called directly, around the check, unless a gateway sits in front of it.
verify=Falseskips the proof below.
The rules it is proved against are the rules it runs under
An agent's rulebook is the Colang file (1.0 or 2.x) its graph is proved against. At start-up, and on every stalwart verify, it is registered with the catalogue (POST /v1/catalogues, field rulebook), where the service compiles it into the record the tool check enforces. The record names the file's own digest, so an agent whose rulebook is held already registers nothing, and one whose rulebook changed registers it again before it runs. Where it cannot be registered, the agent does not run: it raises RulebookNotHeld. A tenant whose own build emits IR registers the record (rulebook_ir) instead.
agents:
desk:
tools: [get_order, charge_card, refund_card]
rulebook: ./policy.co # proved against, and registered for the tool check
from stalwart_agents.client import Client
# What an agent's start-up does where the newest catalogue does not hold its rulebook:
with open("policy.co", encoding="utf-8", newline="") as policy:
Client().register_catalogue(tools, agent="desk", rulebook=policy.read())
An agent's rulebook and your rule sheet are one record on the catalogue: an agent that declares a rulebook registers it again at its next start, in place of a sheet saved since.
Proving an agent before it runs
The agent compiles to a graph export (qls-graph/1) and its catalogue. The solver turns every obligation into a violation and asks whether any path through the graph reaches it:
- Reachable. A finding, with a witness path mapped back to the line of
agent.yamlthat declared it. - Unreachable. The obligation holds, up to the number of turns checked.
- Undecided. Reported as undecided, never as clean.
Automatically, before the first turn. verify=True is the default: the graph is proved in the hosted graph mode before it runs, once per graph, and any Vulnerability finding raises NotVerified with nothing run.
from stalwart_agents import Agent, NotVerified
desk = Agent.from_file("agent.yaml")
try:
desk.chat("refund order 4411", user="u-17")
except NotVerified as refused: # nothing ran
for finding in refused.findings:
print(finding["rule"], finding.get("action")) # QG012 issue_refund
As a gate in CI. stalwart verify agent.yaml runs the code checks on your machine, registers the catalogue with its rulebook, and has the service's bug hunter hunt the graph and the rulebook. It prints the findings by severity and ends with Gate: passed or Gate: failed; a failed gate exits 1. --replay replays each finding's witness with every tool stubbed, to confirm it is real. The next section is the command in full.
$ stalwart verify agent.yaml --replay
stalwart verify agent.yaml
Registered as catalogue version 7, with the rulebook policy.co: the tool check enforces the rules verified here.
Lints at registration: none
Code checks: no finding in 12 file(s) (rules 3f9a1c2b7d40).
QG012 High on issue_refund (agent.yaml:7)
Replay: reproduced
The instruction entered one agent and the privileged call is made by another, after a hand-off: each
agent did what it was asked, and the less trusted one steered the more trusted one.
OWASP LLM01:2026, ASI07:2026, ASI08:2026
Fix: Bind the tool to the guarded tool node in the acting agent, so a call carrying a label from the
handing agent is confirmed or refused; or route the hand-off through a confirmation.
Gate: failed
$ echo $?
1
Over the API. POST /v1/solves with mode: graph takes the export and the catalogue, an optional rulebook, and session_steps as an operand; Client().verify_graph(...) sends it. A LangGraph graph you built yourself is exported with export_graph(app, catalogue, model_nodes=[...]).
import json
from stalwart_agents import Agent
from stalwart_agents.client import Client
desk = Agent.from_file("agent.yaml")
with open("desk.graph.json", "w") as graph, open("desk.catalogue.json", "w") as catalogue:
json.dump(desk.export(), graph)
json.dump(desk.catalogue(), catalogue)
result = Client().verify_graph("desk.graph.json", catalogue="desk.catalogue.json",
rulebook="policy.co", session_steps=2)
for finding in result.findings:
print(finding.severity, finding.rule)
{"mode": "graph",
"inputs": {"export": {"upload_id": "…"}, "catalogue": {"upload_id": "…"},
"rulebook": {"upload_id": "…"}},
"operands": {"session_steps": 2}}
Checking an agent in CI: stalwart verify
stalwart verify agent.yaml is the same proof as a gate a build can fail on. It runs in four steps, and only the first runs on your machine:
- The code checks, on your machine: the project's Python, JavaScript, TypeScript and Go are read for network-security and credential problems, and only the counts by rule leave it. A finding fails the gate.
- The registration: the catalogue, with the rulebook the graph is proved against, as a new version the tool check enforces.
- The hunt, on Stalwart's servers: the graph, its catalogue and the rulebook in one
huntjob, under its quality gate. The solver never runs on your machine, and nothing besides the package is installed. - The report: each finding with its rule, its severity, the line of
agent.yamlit comes from, its OWASP entries and its fix, thenGate: passedorGate: failed.
It needs what every command but stalwart licence needs: QLS_API_KEY, and a licence activated on the machine. A CI runner is a new machine each time, so the job activates it first and releases its place last, whatever happened in between:
# .github/workflows/agent.yml
name: agent
on: [pull_request]
jobs:
verify:
runs-on: ubuntu-latest
env:
QLS_API_KEY: ${{ secrets.QLS_API_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: "3.13"}
- run: pip install stalwart-agents
- run: stalwart licence activate
- run: stalwart verify agent.yaml --replay --keep findings/ --dependencies uv.lock
- if: always()
run: stalwart licence release
- if: always()
uses: actions/upload-artifact@v4
with: {name: findings, path: findings/}
A clean agent passes, and its rulebook is the one the tool check now enforces:
$ stalwart verify agent.yaml
stalwart verify agent.yaml
Registered as catalogue version 8, with the rulebook policy.co: the tool check enforces the rules verified here.
Lints at registration: none
Code checks: no finding in 12 file(s) (rules 3f9a1c2b7d40).
No findings.
Gate: passed
The same check from Python, for a pipeline that is not a shell:
from stalwart_agents import render, verify
result = verify("agent.yaml", replay=True, dependencies="uv.lock")
print(render(result))
raise SystemExit(result.exit_code) # 0 passed, 1 failed
| Option | What it does |
|---|---|
--replay |
replays each finding's witness on your agents, every tool a stub; a finding that does not reproduce fails the gate |
--keep DIR |
writes the export, the catalogue and the findings into the directory |
--dependencies FILE |
the lock file of the packages you install -- requirements.txt, pyproject.toml, uv.lock or poetry.lock -- checked against the advisory registry |
--export-only FILE |
writes the agents' graph, as the verifier reads it, to the file, and stops |
| Exit status | What it means |
|---|---|
| 0 | the gate passed |
| 1 | the gate failed: a finding the gate holds to, a code-check finding, or a replay that did not reproduce |
| 2 | the file cannot be read, or there is no key or no licence |
| 3 | the hunt could not run, so nothing was checked -- never a clean result |
What is proved
| Rule | Proves that no path exists where |
|---|---|
| QG001 | a tool is reachable unchecked, or a confirmed write can be reached without its confirmation |
| QG014 | an after, not_after or at_most order rule can be escaped |
| QG011 | a write follows an untrusted read with no confirmation |
| QG012 | a cascading injection, or a write after a hand-off from a lower-privilege agent |
| QG003 | any of the above holds within one turn but breaks across turns |
| QG017–QG023 | model-written values reach code, SQL, paths, URLs, stores or outbound calls, or a loop has no budget |
| QG013, QG105 | reported as a Bug and a Hotspot; neither stops Agent(...) |
Limits. Model nodes are read as choosing anything they like, and nodes that are not Stalwart's are opaque. The automatic proof covers one turn; pass session_steps to go deeper. The proof covers the graph and the catalogue, not the tools' own code, which the code checks read.