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:

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:

  1. Catalogue. The tool must exist and be in the agent's allowed_tools. On a lapsed licence, writes are refused.
  2. History. The signed chain over the session's history must verify, or the call is refused as history_invalid.
  3. Arguments. They must match the tool's JSON schema.
  4. Destinations. A URL or other destination is refused unless the tool is declared sends_out.
  5. Order rules. after, not_after and at_most, evaluated over the verified history.
  6. Compensation. An undo must bind to the step it undoes, and is spent once.
  7. Your rules. The rulebook registered with the catalogue -- an agent's Colang rulebook or your rule sheet -- with conditions such as amount_over computed from the call's own arguments, so the host cannot override them.
  8. Taint. A write carrying input less trusted than the tool requires is confirmed by default, or refused where your settings say so.
  9. Signed confirmation. A signed token covering exactly this tool and these arguments allows the call.
  10. Default. Any other write is confirmed. A write is never allowed silently.

The answer is one of three:

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

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:

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:

  1. 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.
  2. The registration: the catalogue, with the rulebook the graph is proved against, as a new version the tool check enforces.
  3. The hunt, on Stalwart's servers: the graph, its catalogue and the rulebook in one hunt job, under its quality gate. The solver never runs on your machine, and nothing besides the package is installed.
  4. The report: each finding with its rule, its severity, the line of agent.yaml it comes from, its OWASP entries and its fix, then Gate: passed or Gate: 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.