31 · Verificando um agente antes de ele rodar¶
Idiomas
O seletor entende turnos escritos em inglês. O português é a sua próxima gramática e ainda não foi lançado; até lá, um turno escrito em português não é lido como português.
stalwart verify agent.yaml verifica um agente antes de ele atender alguém. Ele lê o seu código na sua
máquina, registra o catálogo do agente com o seu rulebook, e faz com que toda rota pelos seus agentes seja
vasculhada nos nossos servidores em busca de um caminho até uma ferramenta sem a sua verificação, a sua
confirmação ou o passo que deve vir antes dela. Ele imprime cada achado com a sua regra, a linha do
agent.yaml de onde ele vem, as suas entradas OWASP e a sua correção, e sai com código diferente de zero
quando o portão reprova, de modo que funciona na CI como funciona na sua máquina.
O que ele faz, em ordem¶
- As verificações de código, aqui. Todo arquivo Python da pasta em que está o
agent.yamlé lido na sua máquina em busca de problemas de rede e de credenciais, com as ferramentas que o arquivo nomeia tomadas como as funções cujos parâmetros são argumentos de ferramenta. Qualquer achado reprova o portão. Só as suas contagens por regra saem da sua máquina, com o registro (capítulo 46). - O registro. O catálogo -- toda ferramenta com as suas palavras -- é registrado como uma nova versão,
com o
rulebook:do arquivo compilado nas regras que a verificação de ferramentas aplica. Assim, as regras contra as quais o agente é verificado aqui são as regras sob as quais ele roda. O registro dá lints; nada falha por um lint. - A caça, nos nossos servidores. O grafo dos agentes, o catálogo e o rulebook são varridos pelo caçador
de bugs como um único job
hunt, sob o seu portão. A busca roda nos servidores da Stalwart, nunca na sua máquina. Uma caça que não pôde rodar é um erro, nunca um resultado limpo. - O relatório. Os achados são impressos um por bloco, os mais graves primeiro.
O agente da loja do capítulo 30, verificado (o relatório é impresso em inglês):
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
Leia de cima para baixo:
- Registered as catalogue version 1, with the rulebook policy.co: a versão que a verificação de ferramentas passa a aplicar. Sem chave, a linha diz que o catálogo não foi registrado, e os lints vêm do runtime como o registro os daria.
- Lints at registration: o que o catálogo deixa de dizer, como uma escrita sem desfazer. Cada um nomeia a ferramenta e o que acrescentar; nenhum faz nada falhar.
- Code checks: os arquivos lidos e a versão das regras com que foram lidos.
- Cada achado: a regra, a sua severidade e a ferramenta, com o arquivo e a linha que a declararam; o que
foi encontrado, numa frase; as suas entradas OWASP; e a correção. Aqui
QG023diz que o laço do balcão não tem orçamento além do limite de passos da execução, eQG105diz querefund_cardé uma escrita sem desfazer, numa rota com outra escrita. - Rules with nothing to read: regras que não levantaram nada porque nada do que elas leem foi declarado (abaixo).
- Gate:
passedoufailed. Um achado sob uma regra que barra o portão o reprova, assim como qualquer achado das verificações de código. A página de cada regra diz se ela barra por padrão;QG023eQG105não barram, então o agente da loja passa.
O status com que o comando sai, gerado a partir do próprio comando:
| Status | Significa |
|---|---|
0 |
concluído: para o verify, o portão se manteve; para o redteam, nada foi realizado |
1 |
o portão reprovou, ou uma campanha realizou um ataque |
2 |
o arquivo ou o comando não pôde ser lido |
3 |
um verificador ou um serviço necessário não pôde ser alcançado, então nada foi verificado |
--keep DIR escreve numa pasta o que foi verificado: agent.graph.json, o grafo dos agentes como o caçador
o leu; agent.catalogue.json, o catálogo; e agent.findings.json, o documento de achados. Todas as opções:
| Opção | O que o produto passa a fazer |
|---|---|
--keep |
escreve a exportação, o catálogo e os achados no diretório |
--dependencies |
o arquivo de lock dos pacotes que você instala (requirements.txt, pyproject.toml, uv.lock ou poetry.lock), conferido contra o registro de avisos |
--replay |
reproduz a testemunha de cada achado de grafo nos seus agentes, com toda ferramenta substituída por um stub; um achado que não se reproduz reprova o portão |
--export-only |
escreve o grafo dos agentes, como o verificador o lê, no arquivo, e para |
As perguntas que ele responde para cada ferramenta¶
Para cada ferramenta, em cada rota pelos seus agentes, até a profundidade declarada, a caça pergunta:
- Ela pode rodar sem a sua verificação, ou a verificação pode ser contornada? Ao longo de vários turnos, uma aprovação dada num turno pode ser gasta em outro?
- Uma escrita pode rodar carregando uma marca de fora -- um e-mail, uma página da web, outro agente com menos privilégio -- sem ninguém confirmá-la?
- Um passo irreversível pode vir antes de passos que serão desfeitos se a execução falhar, sem que a sua confirmação diga isso?
- Uma ferramenta pode rodar fora da ordem declarada?
- Uma rota pode ir a qualquer lugar, porque nada declara aonde ela pode ir?
- Uma escrita pode ficar sem ser desfeita, porque nada a desfaz e nada diz que ela não pode ser desfeita?
Cada pergunta é uma regra, e cada regra tem a sua página:
| Regra | Título | Tipo | Severidade (como publicada) | OWASP |
|---|---|---|---|---|
QG001 |
Missing check | Vulnerabilidade | by action | LLM03:2026, ASI02:2026 |
QG002 |
Escapable guard | Vulnerabilidade | by action | LLM03:2026, ASI02:2026 |
QG003 |
Multi-turn escape | Vulnerabilidade | by action, one level up | LLM03:2026, ASI03:2026 |
QG011 |
Confused deputy | Vulnerabilidade | by action | LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026 |
QG012 |
Cascading injection | Vulnerabilidade | by action | LLM01:2026, ASI07:2026, ASI08:2026 |
QG013 |
Irreversible before undoable | Bug | High | LLM03:2026, ASI08:2026 |
QG014 |
Order rule escapable | Vulnerabilidade | by action | LLM03:2026, ASI02:2026 |
QG015 |
Undeclared route | Bug | Medium | ASI01:2026 |
QG016 |
Unmodelled path | Vulnerabilidade | by action, at least High | ASI10:2026 |
QG105 |
Unrecoverable write | Ponto de atenção de segurança | review | LLM03:2026, ASI08:2026 |
Por que cada uma importa, e como corrigi-la:
QG001Missing check: a ferramenta é alcançável e nada nas regras a relaciona com a sua verificação, então uma conversa que chega a ela chega a ela não importa o que a verificação diria. Acrescente a verificação como condição em toda rota até a ferramenta.QG002Escapable guard: a verificação existe, e outra coisa a satisfaz: um segundo fluxo até a mesma ferramenta, um ramo que escapa. Aplique o patch que o achado nomeia dentro de todo fluxo que ele nomeia.QG003Multi-turn escape: uma verificação lida uma vez e usada depois. Leia a verificação no mesmo turno da chamada, ou leve-a junto com a chamada que ela aprovou, e verifique de novo na mesma profundidade.QG011Confused deputy: um valor de fora chegou ao modelo, e uma rota deixa esse modelo chamar uma ferramenta privilegiada sem que uma pessoa a confirme. Coloque a ferramenta no passo de ferramentas protegido, para que uma chamada que carrega a marca seja confirmada ou recusada; marque comoreads_outsidea ferramenta que lê o exterior, onde ela ainda não estiver marcada.QG012Cascading injection: o mesmo, quando a marca entrou num agente e a chamada é feita por outro depois de uma transferência. Coloque a ferramenta no passo de ferramentas protegido do agente que age, ou confirme a transferência.QG013Irreversible before undoable: se um passo posterior falhar, a execução é desfeita, e o passo irreversível continua feito; a pessoa que o aprovou não foi avisada. Mova o passo irreversível para o fim, ou deixe que a sua confirmação diga que os passos depois dele não podem desfazê-lo.QG014Order rule escapable: uma ferramenta declaradaafter,not_afterouat_mostroda onde a sua regra não é verificada. Coloque a ferramenta no passo de ferramentas protegido, onde as regras de ordem são verificadas sobre o histórico da sessão.QG015Undeclared route: veja uma rota não declarada.QG016Unmodelled path: uma campanha de ataque fez uma chamada privilegiada em agentes que a caça achou limpos, então o que roda e o que foi verificado discordam. Nada que este produto relata é mais urgente: verifique de novo a partir do que roda, e compare o rastro do atacante com o grafo verificado (capítulo 33).QG105Unrecoverable write: uma escrita sem desfazer, não marcada como irreversível, numa rota com outra escrita; uma execução que falha não consegue desfazê-la, e nada diz isso. Declare o seuundo, ou marque-airreversiblepara que uma pessoa a confirme sabendo disso (capítulo 34).
Um grafo descrito por agent.yaml coloca toda ferramenta no passo de ferramentas protegido, onde cada
chamada é verificada e cada escrita é confirmada, então a maioria dessas regras não encontra nada nele; elas
encontram o que os passos escritos por uma equipe deixam aberto
(capítulo 27). As regras sobre o que as suas declarações
dizem estão abaixo.
Lendo um achado e a sua testemunha¶
A testemunha de um achado é a execução que chega à ferramenta: os passos, em ordem, que a caça
encontrou. O documento de achados que o --keep escreve a guarda sob detail. Aqui está o QG011 que o
grafo próprio do E16 levanta em refund_card, com os campos que esta seção lê:
{
"rule": "QG011",
"title": "Confused deputy",
"severity": "high",
"action": "refund_card",
"control": "a confirmation",
"obligation": "taint:refund_card:untrusted:read_inbox",
"reproduction": "not_yet_replayed",
"detail": {
"witness_path": [
"1.0 __start__",
"1.1 model",
"1.2 tools",
"1.3 model",
"1.4 tools"
],
"code_flow": [
{
"message": "step 1.0: the graph visits __start__"
},
{
"message": "step 1.1: the graph visits model"
},
{
"message": "step 1.2: the graph visits tools"
},
{
"message": "step 1.3: the graph visits model"
},
{
"message": "step 1.4: the graph visits tools"
}
],
"fix": "Bind 'refund_card' to the guarded tool node so a call carrying the label (untrusted:read_inbox) is confirmed or refused, or add a confirmation before it."
}
}
Leia passo a passo. O número de um passo é turno.passo: 1.2 é o segundo passo do primeiro turno.
1.0 __start__: chega o turno da pessoa.1.1 model: o modelo o lê e pede uma ferramenta.1.2 tools: o passo de ferramentas rodaread_inbox. O que ela devolve vem de fora, e é marcadountrusted:read_inbox.1.3 model: o modelo lê o e-mail e pederefund_card. O seu pedido carrega a marca.1.4 tools:refund_cardroda, carregando a marca, e nada a confirma: o passo de ferramentas próprio desta equipe não é o protegido.
detail.code_flow dá uma mensagem por passo, na linha que declarou o passo, para que uma tela de code
scanning mostre o mesmo caminho. Os outros campos:
control: o que faltou no caminho -- aqui, uma confirmação;obligation: o que a caça decidiu, com nome:taint:refund_card:untrusted:read_inboxé "refund_cardnunca roda carregandountrusted:read_inboxsem confirmação";detail.fix: a mudança que fecha o caminho;reproduction:not_yet_replayedaté a testemunha ser reproduzida (abaixo).
A correção fecha o caminho no seu último passo: uma pessoa vê o reembolso, com cada argumento e de onde veio o seu valor, e a frase de que ele foi proposto depois de ler um e-mail de fora (capítulo 33).
A profundidade da verificação¶
Um resultado limpo vale até uma profundidade declarada, e o documento de achados a declara, sob
scan.obligations.graph. Para o agente da loja:
verify("agent.yaml", keep="kept") # as `stalwart verify agent.yaml --keep kept`
kept = json.loads(Path("kept/agent.findings.json").read_text(encoding="utf-8"))
print(json.dumps(kept["scan"]["obligations"]["graph"], indent=2))
{
"total": 12,
"decided": 12,
"steps": 60,
"turns": 3,
"replay": "not replayed: the hunter reads the export, not the code that built it; each finding's witness_path is the run to reproduce"
}
totaledecided: quantas perguntas a caça fez a este grafo, e quantas decidiu. Onde diferem, as restantes são listadas emscan.undecided, e nada é afirmado sobre elas.steps: o máximo de passos que uma execução pode dar, 60 aqui, o limite de passos do agente. Toda rota é vasculhada até ele.turns: por quantos turnos da pessoa uma rota é seguida, 3 aqui. Uma aprovação dada num turno e gasta num turno posterior é encontrada dentro deles (QG003).
Então "nenhum caminho" significa nenhum caminho dentro de 60 passos e três turnos. A Verificação de Grafos
de Agentes, que verifica um grafo que você mesmo envia, declara a profundidade que vasculhou na sua linha
[UNROLL] (capítulo 47).
Reproduzindo uma testemunha¶
stalwart verify --replay reproduz a testemunha de cada achado de grafo nos seus próprios agentes, com toda
ferramenta substituída por um stub que registra as suas chamadas e devolve dados de exemplo, e um modelo
roteirizado que faz exatamente as escolhas da testemunha. As verificações rodam como rodam em produção. Um
achado é reproduced quando a execução segue o caminho da testemunha e a sua ferramenta é chamada. Um que
não se reproduz significa que a caça e os agentes que rodam discordam: ele é listado primeiro, e reprova
o portão. Cada reprodução é registrada como digests e o seu resultado, nunca o seu rastro.
O que você pode declarar sobre as suas ferramentas¶
Cada declaração é opcional, e cada uma é lida por uma ou mais regras. Uma regra cuja declaração falta não levanta nada -- veja Uma declaração ausente esconde a sua regra.
| Declaração | Em | O que diz | Lida por |
|---|---|---|---|
argument_kinds: {<arg>: code, shell, sql, query, path, url ou template} |
uma ferramenta | como a ferramenta interpreta o argumento | QG017, QG018 |
sandboxed: true |
uma ferramenta com argumento code ou shell |
a sua afirmação de que a ferramenta roda código isolado | a severidade de QG017 |
sanitised_by: {<arg>: "<como>"} |
uma ferramenta | a sua afirmação de que o argumento é validado antes do uso | QG017, QG018 |
confidential: subject ou internal |
uma ferramenta de leitura | subject: pode ser mostrado à pessoa de quem é o registro; internal: a ninguém fora da empresa |
QG019 |
subject_argument: <arg> |
uma ferramenta marcada confidential: subject |
o argumento que escolhe de quem é o registro lido | QG019 |
stores: {<nome>: {multi_tenant, persistent}} |
o arquivo | os armazenamentos que as suas ferramentas leem e escrevem | QG020, QG021 |
retrieves: {store, scope_argument} |
uma ferramenta de leitura | uma recuperação de um armazenamento, e o argumento que a delimita | QG020, QG021 |
persists: <armazenamento> |
uma ferramenta de escrita | a ferramenta escreve nesse armazenamento | QG021 |
source: {package, version, digest} ou server: {url, version, digest} |
uma ferramenta | de onde vem o código da ferramenta | QG106 |
remote: {transport, authentication} |
um agente | como trafega uma transferência para ele | QG107 |
budget: {max_steps, max_tool_calls, max_model_calls} |
um agente, ou a execução | o teto da execução | QG023 |
No agent.yaml, as palavras ficam sob o nome da ferramenta; em Python, são argumentos de @tool(...). O
registro de um servidor MCP é uma entrada de tools que nomeia o servidor e as ferramentas que ele serve,
com a version ou o digest do servidor quando você o fixa.
No registro, essas declarações recebem quatro lints, e nenhum deles faz nada falhar. O registro precisa
da sua chave em QLS_API_KEY; sem ela o catálogo não é registrado, a primeira linha do relatório diz
isso, e os lints vêm do runtime como o registro os daria -- a execução do E16 abaixo foi feita assim.
Outros lints leem o que todo catálogo declara, e não essas declarações: uma escrita sem esquema de
resultado ou de argumentos, uma ferramenta irreversível que não pede confirmação, duas ferramentas que o
seletor não consegue distinguir, um nome que o leitor toma por gramática, uma função TypeScript marcada
sem @read nem @write, e código de ferramenta reprovado nas verificações de código. Os quatro últimos
são erros: a versão é registrada, e recusada para o roteamento até que uma versão corrigida o seja.
| Lint | Levantado quando |
|---|---|
store_undeclared |
retrieves ou persists nomeia um armazenamento que o arquivo não declara |
confidential_subject_unbound |
uma ferramenta marcada confidential: subject não tem subject_argument |
argument_kind_unknown |
um tipo fora dos sete, ou um argumento que o esquema não tem |
argument_kind_suggested |
apenas informativo: um parâmetro chamado sql, query, command, cmd, script, code, path, url ou template sem tipo declarado |
As regras que as leem¶
Cada regra decide uma pergunta sobre todo caminho que o grafo ou a política permite, e declara o seu limite: o que decide e o que não decide.
| Regra | Levantada quando | O seu limite |
|---|---|---|
QG017 O modelo escreve código que roda |
um argumento declarado como código ou comando de shell pode receber um valor escrito pelo modelo, sem confirmação | o caçador decide para onde vai um valor escrito pelo modelo, não o que a sua ferramenta faz com ele |
QG018 O modelo escreve uma consulta ou um destino |
um argumento declarado como SQL, consulta, caminho, URL ou template pode receber um valor escrito pelo modelo, sem limite e sem confirmação | o caçador decide para onde vai um valor escrito pelo modelo, não o que a sua ferramenta faz com ele |
QG019 Dados confidenciais saem |
um valor de uma leitura confidencial pode chegar a uma ferramenta de saída sem confirmação, ou à resposta quando o seu público o proíbe | decidido pelas chamadas de ferramenta e respostas que o grafo roteia, não pelo que o modelo pode dizer com outras palavras |
QG020 Recuperação não vinculada ao usuário |
uma recuperação de um armazenamento multi-inquilino pode rodar com o seu escopo não tirado do principal autenticado | decidido pela forma como a recuperação é delimitada, não por como o armazenamento aplica o escopo que recebe |
QG021 Conteúdo não confiável persistido |
uma escrita num armazenamento persistente pode rodar com uma marca de contaminação e sem confirmação | decidido sobre as escritas que o grafo roteia, não sobre um armazenamento envenenado por um caminho fora do agente |
QG023 Laço sem limite |
um ciclo que chama uma ferramenta ou o modelo pode se repetir além do orçamento, ou não tem orçamento além do limite de recursão | decide se o orçamento se sustenta, não se ele é sensato |
QG106 Origem de ferramenta sem fixação |
uma ferramenta, um servidor MCP ou um módulo de ações não tem versão fixada nem digest | aponta a fixação que falta; não julga a versão fixada |
QG107 Canal entre agentes sem autenticação |
uma transferência para um agente remoto trafega por um canal declarado sem autenticação | se o outro lado é quem diz ser; se a contaminação atravessa é decidido em outra regra |
QG022, o rail com um aviso publicado, está no capítulo 28.
Toda entrada OWASP que um achado carrega é uma afirmação no nível da política: a parte do risco que as
suas regras e o seu catálogo controlam. Uma varredura limpa sob uma entrada significa nenhum achado no nível
da política, não que o risco esteja ausente. A única entrada que nenhuma regra cobre é LLM05:2026 Data
and Model Poisoning: uma propriedade dos dados de treinamento e dos pesos do modelo, que nenhuma política
de guardrail decide (capítulo 28).
Exemplo E16: o agente da loja cresce¶
O agente da loja ganha novas ferramentas, cada uma com a sua declaração:
run_report, cujo argumentosqlé declaradosqle é escrito pelo modelo;get_customer,confidential: subjectcomsubject_argument: customer_id, cujo registro osend_receiptpode levar;search_help, sobre um armazenamentomulti_tenant, delimitado por umtenant_idque o modelo escreve, servido por um servidor MCP registrado sem versão nem digest;remember_note,persists: notes, alcançável depois deread_inbox, erecall_notes, que lê as notas de volta antes derefund_card;- o balcão
billing, transformado num agente remoto sobrea2asem autenticação; - e nenhum orçamento no laço do balcão.
agents:
desk:
model: "anthropic:claude-sonnet-5-5"
tools: [get_order, run_report, get_customer, send_receipt, search_help, read_inbox, remember_note, recall_notes, refund_card]
hands_off_to: [billing]
billing:
privilege: high
tools: [issue_refund]
remote: {transport: a2a, authentication: none}
tools:
- name: get_order
schema: {type: object, properties: {order_id: {type: string}}}
- name: run_report
argument_kinds: {sql: sql}
schema: {type: object, properties: {sql: {type: string}}}
- name: get_customer
confidential: subject
subject_argument: customer_id
schema: {type: object, properties: {customer_id: {type: string}}}
- name: send_receipt
writes: true
sends_out: true
schema: {type: object, properties: {order_id: {type: string}, body: {type: string}}}
- mcp: https://help.internal/mcp
tools: [search_help]
- name: search_help
retrieves: {store: help, scope_argument: tenant_id}
schema: {type: object, properties: {tenant_id: {type: string}, query: {type: string}}}
- name: read_inbox
reads_outside: true
schema: {type: object, properties: {folder: {type: string}}}
- name: remember_note
writes: true
persists: notes
schema: {type: object, properties: {note: {type: string}}}
- name: recall_notes
retrieves: {store: notes}
schema: {type: object, properties: {topic: {type: string}}}
- name: refund_card
writes: true
requires_privilege: high
schema: {type: object, properties: {order_id: {type: string}}}
- name: issue_refund
writes: true
schema: {type: object, properties: {order_id: {type: string}}}
stores:
help: {multi_tenant: true}
notes: {persistent: true}
O stalwart verify lê o arquivo, monta os agentes que ele descreve -- o modelo do balcão, o passo de
ferramentas protegido em que estão as suas ferramentas e uma transferência para billing -- e imprime (o
caçador imprime em inglês):
stalwart verify agent.yaml
The catalogue was not registered: set QLS_API_KEY to register it with the service.
Lints at registration (nothing fails on a lint):
argument_kind_suggested (info) on search_help: query: the name suggests an argument the tool interprets; declare its kind in argument_kinds if it is one
write_without_compensation (warning) on issue_refund: a write neither compensated nor marked irreversible
write_not_retry_safe (warning) on issue_refund: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on issue_refund: a write with no result schema, so its outcome could not be reported
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_without_compensation (warning) on remember_note: a write neither compensated nor marked irreversible
write_not_retry_safe (warning) on remember_note: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on remember_note: a write with no result schema, so its outcome could not be reported
write_without_compensation (warning) on send_receipt: a write neither compensated nor marked irreversible
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
Code checks: no finding in 1 file(s) (rules 4b48b0081360).
QG018 High on run_report (agent.yaml:4)
'run_report' can run with its sql argument 'sql' written by the model or carrying a trust label, and nothing confirms the call
OWASP LLM10:2026, LLM01:2026
Fix: Bound 'sql' in the tool's schema with an enum, a const or a pattern; or take it from the user's typed text, the host's context or an earlier tool's result, and bind it so in the export's `arguments`; or put the call behind a confirmation -- the guarded tool node or an interrupt().
QG019 High on reply
the reply can carry a confidential value to someone its audience does not include: a record for nobody outside the company, or one whose subject the host's context did not choose -- through the tool calls and replies the policy routes
OWASP LLM02:2026, LLM08:2026
Fix: Bind each confidential read's subject argument to the authenticated principal in the host's context, or remove the confidential fields from the context before the reply.
QG020 High on search_help (agent.yaml:4)
'search_help' retrieves from the multi-tenant store 'help' with its scope taken from something other than the authenticated principal
OWASP LLM09:2026, LLM02:2026
Fix: Take the scope argument of 'search_help' from the host's context -- the authenticated tenant or user -- and bind it so in the export's `arguments`.
QG023 Medium on cycle:desk_model (agent.yaml:2)
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 issue_refund (agent.yaml:8)
the write 'issue_refund' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'issue_refund' in the catalogue, or mark it irreversible.
QG105 Review on refund_card (agent.yaml:4)
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 remember_note (agent.yaml:4)
the write 'remember_note' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'remember_note' in the catalogue, or mark it irreversible.
QG105 Review on send_receipt (agent.yaml:4)
the write 'send_receipt' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'send_receipt' in the catalogue, or mark it irreversible.
QG106 Review on search_help
'search_help' runs code from the MCP server https://help.internal/mcp with no version and no digest: it can change without the catalogue changing
OWASP LLM04:2026, ASI04:2026
Fix: Pin the MCP server https://help.internal/mcp to an exact version or record its digest in the catalogue, or accept the hotspot with the reason.
QG107 Review on desk -> billing (agent.yaml:2)
the hand-off from 'desk' to 'billing' reaches 'billing' over a2a with authentication: none: nothing shows the far side is the agent it claims to be
OWASP ASI07:2026
Fix: Declare and use mtls, oauth2 or signed_messages on the channel to 'billing', or accept the hotspot with the layer that authenticates it.
Rules with nothing to read, so nothing raised:
QG017: no argument is declared as code or a shell command
QG022: no Colang policy and no rails config was scanned
Gate: failed
Os agentes que o agent.yaml descreve colocam toda ferramenta no passo de ferramentas protegido, onde uma
escrita só roda com a sua confirmação. Por isso eles não levantam QG021 nem QG019 por meio de
send_receipt: a confirmação é a correção que essas regras pedem, e ela já está lá. Uma equipe que escreve
os seus próprios passos, com as mesmas ferramentas num passo de ferramentas que não verifica nada, as vê.
Aqui está o mesmo catálogo com as ferramentas do balcão num passo assim:
stalwart verify desk graph
QG001 High on remember_note
'remember_note' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'remember_note' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 High on refund_card
'refund_card' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'refund_card' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 High on send_receipt
'send_receipt' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'send_receipt' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG011 High on refund_card
the privileged 'refund_card' can run carrying the label untrusted:read_inbox with no confirmation or refusal
OWASP LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026
Fix: Bind 'refund_card' to the guarded tool node so a call carrying the label (untrusted:read_inbox) is confirmed or refused, or add a confirmation before it.
QG011 High on remember_note
the privileged 'remember_note' can run carrying the label untrusted:read_inbox with no confirmation or refusal
OWASP LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026
Fix: Bind 'remember_note' to the guarded tool node so a call carrying the label (untrusted:read_inbox) is confirmed or refused, or add a confirmation before it.
QG011 High on send_receipt
the privileged 'send_receipt' can run carrying the label untrusted:recall_notes with no confirmation or refusal
OWASP LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026
Fix: Bind 'send_receipt' to the guarded tool node so a call carrying the label (untrusted:recall_notes) is confirmed or refused, or add a confirmation before it.
QG011 High on refund_card
the privileged 'refund_card' can run carrying the label untrusted:recall_notes with no confirmation or refusal
OWASP LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026
Fix: Bind 'refund_card' to the guarded tool node so a call carrying the label (untrusted:recall_notes) is confirmed or refused, or add a confirmation before it.
QG011 High on remember_note
the privileged 'remember_note' can run carrying the label untrusted:recall_notes with no confirmation or refusal
OWASP LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026
Fix: Bind 'remember_note' to the guarded tool node so a call carrying the label (untrusted:recall_notes) is confirmed or refused, or add a confirmation before it.
QG011 High on send_receipt
the privileged 'send_receipt' can run carrying the label untrusted:read_inbox with no confirmation or refusal
OWASP LLM01:2026, LLM03:2026, ASI01:2026, ASI02:2026
Fix: Bind 'send_receipt' to the guarded tool node so a call carrying the label (untrusted:read_inbox) is confirmed or refused, or add a confirmation before it.
QG018 High on run_report
'run_report' can run with its sql argument 'sql' written by the model or carrying a trust label, and nothing confirms the call
OWASP LLM10:2026, LLM01:2026
Fix: Bound 'sql' in the tool's schema with an enum, a const or a pattern; or take it from the user's typed text, the host's context or an earlier tool's result, and bind it so in the export's `arguments`; or put the call behind a confirmation -- the guarded tool node or an interrupt().
QG019 High on send_receipt
'send_receipt' sends data out of the host while the session holds a confidential value, and nothing confirms the call -- through the tool calls and replies the policy routes
OWASP LLM02:2026, LLM08:2026
Fix: Confirm the call to 'send_receipt' -- on the guarded tool node or with an interrupt() -- or remove the confidential fields from the context before it.
QG019 High on reply
the reply can carry a confidential value to someone its audience does not include: a record for nobody outside the company, or one whose subject the host's context did not choose -- through the tool calls and replies the policy routes
OWASP LLM02:2026, LLM08:2026
Fix: Bind each confidential read's subject argument to the authenticated principal in the host's context, or remove the confidential fields from the context before the reply.
QG020 High on search_help
'search_help' retrieves from the multi-tenant store 'help' with its scope taken from something other than the authenticated principal
OWASP LLM09:2026, LLM02:2026
Fix: Take the scope argument of 'search_help' from the host's context -- the authenticated tenant or user -- and bind it so in the export's `arguments`.
QG021 High on remember_note
'remember_note' can write to the persistent store 'notes' while the session carries content from outside, and nothing confirms the write
OWASP ASI06:2026, LLM01:2026
Fix: Confirm the write to 'notes', strip or refuse labelled values before it, or declare the store not persistent if it is not.
QG001 Medium on get_customer
'get_customer' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'get_customer' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 Medium on get_order
'get_order' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'get_order' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 Medium on read_inbox
'read_inbox' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'read_inbox' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 Medium on run_report
'run_report' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'run_report' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 Medium on recall_notes
'recall_notes' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'recall_notes' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG001 Medium on search_help
'search_help' can run on a plain tool node, without the tool check
OWASP LLM03:2026, ASI02:2026
Fix: Bind 'search_help' to the guarded tool node (StalwartToolNode) in place of the plain tool node, so every call to it is checked.
QG023 Medium on cycle:model (run_e16.py:48)
the cycle through 'model' calls the model or a tool, and nothing declares a budget: the framework's recursion limit of 25 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
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 remember_note
the write 'remember_note' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'remember_note' in the catalogue, or mark it irreversible.
QG105 Review on send_receipt
the write 'send_receipt' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'send_receipt' in the catalogue, or mark it irreversible.
QG106 Review on search_help
'search_help' runs code from the MCP server https://help.internal/mcp with no version and no digest: it can change without the catalogue changing
OWASP LLM04:2026, ASI04:2026
Fix: Pin the MCP server https://help.internal/mcp to an exact version or record its digest in the catalogue, or accept the hotspot with the reason.
Rules with nothing to read, so nothing raised:
QG017: no argument is declared as code or a shell command
QG022: no Colang policy and no rails config was scanned
QG107: no agent in the catalogue declares a remote channel
Gate: failed
O QG011 em refund_card por meio de recall_notes vem do armazenamento: até que toda escrita em notes
seja provada limpa, o que recall_notes lê de volta é não confiável, e a chamada privilegiada que ele
alimenta é um confused deputy (capítulo 33).
Se você já usa LangGraph
A segunda varredura é de um StateGraph montado à mão cujas ferramentas ficam num ToolNode simples,
exportado com export_graph. O passo de ferramentas protegido é o StalwartToolNode. O
capítulo 27 mostra como exportar e verificar um grafo
seu.
Uma declaração ausente esconde a sua regra¶
O mesmo agente sem o tipo sql em run_report:
stalwart verify agent.yaml
The catalogue was not registered: set QLS_API_KEY to register it with the service.
Lints at registration (nothing fails on a lint):
argument_kind_suggested (info) on run_report: sql: the name suggests an argument the tool interprets; declare its kind in argument_kinds if it is one
argument_kind_suggested (info) on search_help: query: the name suggests an argument the tool interprets; declare its kind in argument_kinds if it is one
write_without_compensation (warning) on issue_refund: a write neither compensated nor marked irreversible
write_not_retry_safe (warning) on issue_refund: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on issue_refund: a write with no result schema, so its outcome could not be reported
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_without_compensation (warning) on remember_note: a write neither compensated nor marked irreversible
write_not_retry_safe (warning) on remember_note: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on remember_note: a write with no result schema, so its outcome could not be reported
write_without_compensation (warning) on send_receipt: a write neither compensated nor marked irreversible
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
Code checks: no finding in 0 file(s) (rules 4b48b0081360).
QG019 High on reply
the reply can carry a confidential value to someone its audience does not include: a record for nobody outside the company, or one whose subject the host's context did not choose -- through the tool calls and replies the policy routes
OWASP LLM02:2026, LLM08:2026
Fix: Bind each confidential read's subject argument to the authenticated principal in the host's context, or remove the confidential fields from the context before the reply.
QG020 High on search_help (agent.yaml:4)
'search_help' retrieves from the multi-tenant store 'help' with its scope taken from something other than the authenticated principal
OWASP LLM09:2026, LLM02:2026
Fix: Take the scope argument of 'search_help' from the host's context -- the authenticated tenant or user -- and bind it so in the export's `arguments`.
QG023 Medium on cycle:desk_model (agent.yaml:2)
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 issue_refund (agent.yaml:8)
the write 'issue_refund' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'issue_refund' in the catalogue, or mark it irreversible.
QG105 Review on refund_card (agent.yaml:4)
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 remember_note (agent.yaml:4)
the write 'remember_note' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'remember_note' in the catalogue, or mark it irreversible.
QG105 Review on send_receipt (agent.yaml:4)
the write 'send_receipt' has no compensation, is not irreversible, and runs on a path with another write
OWASP LLM03:2026, ASI08:2026
Fix: Declare a compensation for 'send_receipt' in the catalogue, or mark it irreversible.
QG106 Review on search_help
'search_help' runs code from the MCP server https://help.internal/mcp with no version and no digest: it can change without the catalogue changing
OWASP LLM04:2026, ASI04:2026
Fix: Pin the MCP server https://help.internal/mcp to an exact version or record its digest in the catalogue, or accept the hotspot with the reason.
QG107 Review on desk -> billing (agent.yaml:2)
the hand-off from 'desk' to 'billing' reaches 'billing' over a2a with authentication: none: nothing shows the far side is the agent it claims to be
OWASP ASI07:2026
Fix: Declare and use mtls, oauth2 or signed_messages on the channel to 'billing', or accept the hotspot with the layer that authenticates it.
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
QG022: no Colang policy and no rails config was scanned
Gate: failed
Nenhum QG018: a regra não tem nada para ler. O registro avisa que pode faltar uma declaração
(argument_kind_suggested), e o resumo lista a regra como não tendo tido nada para ler. Uma declaração
ausente esconde a sua regra -- a varredura fica limpa daquilo que não lhe foi dito, e diz isso.
Uma rota não declarada¶
Uma rota é para onde uma execução pode ir depois de um passo. No agent.yaml toda rota é declarada: um
agente vai às suas ferramentas, volta ao seu modelo, ou vai a um agente que ele nomeia em hands_off_to, e
a nenhum outro lugar. Um passo que uma equipe escreve em código pode escolher para onde ir em seguida com
uma função que lê o que o modelo disse. Se nada declara os lugares que ela pode escolher, a caça precisa
supor que ela pode escolher qualquer passo -- passando por cima de toda verificação que o desenho põe no
caminho. Isso é QG015, e todo caminho que ela abre a nomeia, então uma única correção fecha todos:
declare aonde a rota pode ir.
Se você já usa LangGraph
Uma rota não declarada é uma aresta condicional sem path_map, ou um nó que devolve Command sem a
anotação Command[Literal[...]]. Declare os destinos, ou use stalwart_edge com o conjunto permitido
(capítulo 27).
O portão na CI¶
O stalwart verify sai com 1 quando o portão reprova e com 3 quando nada pôde ser verificado, então um
passo de CI que o roda para em qualquer dos dois. Guarde os achados com --keep e envie-os como o registro
da execução; o capítulo 46 tem o workflow.
Os limites¶
- Uma varredura limpa prova que nenhum caminho chega a uma ferramenta sem a sua verificação, a sua confirmação ou o passo que deve vir antes, até a profundidade declarada. Ela não prova que o modelo escolhe bem, e as suas próprias funções são lidas como capazes de fazer qualquer coisa.
- A profundidade é a que o documento de achados declara: o limite de passos da execução e o número de turnos. Um caminho mais longo do que qualquer dos dois não é vasculhado.
- O caçador decide para onde vai um valor escrito pelo modelo, não o que a sua ferramenta faz com ele.
- Uma regra cuja declaração falta não levanta nada; o resumo a lista como não tendo tido nada para ler.
- Uma declaração sua --
sandboxed,sanitised_by, uma autenticação num canal -- é aceita como declarada e listada entre as suposições da varredura; o caçador não a verifica.