Ir para o conteúdo

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

  1. 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).
  2. 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.
  3. 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.
  4. 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 QG023 diz que o laço do balcão não tem orçamento além do limite de passos da execução, e QG105 diz que refund_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: passed ou failed. 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; QG023 e QG105 nã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:

  • QG001 Missing 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.
  • QG002 Escapable 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.
  • QG003 Multi-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.
  • QG011 Confused 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 como reads_outside a ferramenta que lê o exterior, onde ela ainda não estiver marcada.
  • QG012 Cascading 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.
  • QG013 Irreversible 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.
  • QG014 Order rule escapable: uma ferramenta declarada after, not_after ou at_most roda 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.
  • QG015 Undeclared route: veja uma rota não declarada.
  • QG016 Unmodelled 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).
  • QG105 Unrecoverable 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 seu undo, ou marque-a irreversible para 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. 1.0 __start__: chega o turno da pessoa.
  2. 1.1 model: o modelo o lê e pede uma ferramenta.
  3. 1.2 tools: o passo de ferramentas roda read_inbox. O que ela devolve vem de fora, e é marcado untrusted:read_inbox.
  4. 1.3 model: o modelo lê o e-mail e pede refund_card. O seu pedido carrega a marca.
  5. 1.4 tools: refund_card roda, 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_card nunca roda carregando untrusted:read_inbox sem confirmação";
  • detail.fix: a mudança que fecha o caminho;
  • reproduction: not_yet_replayed até 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"
}
  • total e decided: quantas perguntas a caça fez a este grafo, e quantas decidiu. Onde diferem, as restantes são listadas em scan.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 argumento sql é declarado sql e é escrito pelo modelo;
  • get_customer, confidential: subject com subject_argument: customer_id, cujo registro o send_receipt pode levar;
  • search_help, sobre um armazenamento multi_tenant, delimitado por um tenant_id que o modelo escreve, servido por um servidor MCP registrado sem versão nem digest;
  • remember_note, persists: notes, alcançável depois de read_inbox, e recall_notes, que lê as notas de volta antes de refund_card;
  • o balcão billing, transformado num agente remoto sobre a2a sem 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.