Skip to content

29 · The bug hunter and the selector together

The selector decides what your agent does with a turn: which tool, with which values, or nothing. The bug hunter finds every way your rules leave to an action, or finds none. Together they answer a question neither answers alone: does every tool call go through the selector? This chapter is that search on the shop's refund, and the gate that keeps it true when someone adds a way round it.

Language support

The selector reads turns written in English and in Brazilian Portuguese (pt-BR). The language is chosen, never guessed from the turn: the console's Settings page (Language the selector reads), language on a plan request, or the settings.language of your vocabulary file; with none of them, English. A Portuguese turn may be typed with its accents or without them. European Portuguese is not a grammar the selector has, and a turn in any other language is unread.

Why they combine

A guardrail policy that calls a tool decides, in its own flows, when the call runs. The selector's check (chapter 24) decides each call by your catalogue and rulebook -- but only for the calls that are sent to it. A flow that calls the tool directly never asks. So the selector is only as good as the policy's promise to ask it every time, and that promise is exactly what the hunter searches: a path to an action that must not run without a control.

The rails

The shop's refund behind the two NeMo actions of chapter 27: stalwart_route reads the user's message into a plan and declines what the plan declines, and stalwart_check_tool checks the refund before the flow runs it. This is the SDK's own Colang 1.0 snippet, with the shop's intent and tool:

# The shop's refund behind Stalwart's input rail and execution guard, from
# stalwart_agents.langchain.nemo.snippet("1.0"), with the shop's intent and tool.

define user request refund
  "refund my order"
  "I want my money back"

define bot refuse stalwart declined
  "I can't do that: it is not something this assistant may do."

define bot refuse stalwart tool
  "I can't do that: the check did not allow it."

define bot confirm refund
  "The refund is issued."

define flow stalwart route input
  user request refund
  execute stalwart_route
  if $stalwart_declined
    bot refuse stalwart declined
    stop

define flow stalwart guarded refund
  user request refund
  execute stalwart_check_tool
  if $stalwart_allowed
    execute refund_card
    bot confirm refund
  else
    bot refuse stalwart tool

The obligation

The team writes the promise as one row of its property table (chapter 11): the refund must not run without $stalwart_allowed, which only the check sets.

{
  "policy": "rails.co",
  "properties": [
    {
      "id": "SEL-01",
      "description": "A refund runs without the selector's check allowing it.",
      "action": "refund_card",
      "context": "stalwart_allowed",
      "pins": {},
      "expect": "UNSAT",
      "gate": "hard"
    }
  ]
}

Sent with the scan (properties), the row joins the hunter's own questions as the team's, and is asked of every flow that reaches the refund:

result = client.hunt("rails.co", properties="properties.json", gate=True)
print("gate passed:", result.result["gate"]["passed"])
for finding in result.findings:
    print(f"{finding.rule} {finding.raw['title']} ({finding.raw['type']}) on {finding.raw['action']}")
gate passed: True
QG101 Unchecked action (hotspot) on stalwart_route
QG101 Unchecked action (hotspot) on stalwart_check_tool
gate passed: False | no_new_unshippable: QG005 42d4d03a70a3
QG002 Escapable guard at rails.hurried.co:28 (stalwart guarded refund) | row SEL-01
   The policy gates 'refund_card' on 'stalwart_allowed', and there is a way past it.
QG005 Unshippable policy at rails.hurried.co:40 (quick refund) 
   line 40: tool action 'refund_card' is registered from flow 'quick refund' as well as from 'stalwart guarded refund'

The first scan passes. Its two findings are hotspots, review items that never fail the gate: stalwart_route and stalwart_check_tool run under no control, because they are the control, run on every matching turn by design. The SDK's snippet says so in its own comments.

The gate that keeps it true

A teammate adds a refund straight from the intent:

# Added in a hurry: a refund straight from the intent.
define user ask quick refund
  "just refund it"

define flow quick refund
  user ask quick refund
  execute refund_card
result = client.hunt("rails.hurried.co", properties="properties.json", gate=True)
print("gate passed:", result.result["gate"]["passed"], "|", "; ".join(result.result["gate"]["failed"]))
for finding in result.findings:
    if finding.raw["type"] != "hotspot":
        where = finding.location
        print(f"{finding.rule} {finding.raw['title']} at {where['file']}:{where['line']} ({where['flow']})",
              f"| row {finding.raw['obligation']}" if finding.raw["obligation"] else "")
        print("  ", finding.raw["message"].split(";")[0])

The second scan, in the output above, fails the gate. QG002 names the row it breaks, SEL-01, at the guarded line the team reads as safe: the refund is gated on the check, and there is now a way past it. QG005 adds that the refund is registered from two flows, so the weaker one decides, and a policy that cannot ship is what fails the gate; QG002 alone, on a write that is not critical, would be a warning. Run in CI by the GitHub Action on a project (chapter 28), this is the pull request's failed check, before the flow reaches anyone.

A Part I agent needs no row: the graph agent.yaml builds puts every tool on the guarded tool step, and stalwart verify searches every path for one that reaches a tool around it (chapter 31). Chapter 27 shows the same proof failing when a step around the check is added by hand.

What a clean scan means, and what it does not

  • It found no way to the refund around the selector's check: no flow, and no sequence of turns the scan unrolls, reaches refund_card without $stalwart_allowed.
  • It says nothing of whether the selector chose well. Whether the check allowed the right call for the turn is the router's decision, read by its rules from what your tools declare -- chapter 20's limits, which a scan cannot lift.
  • It covers the obligations it was given. A tool with no row is reported as a hotspot where nothing guards it, not proved; write a row for every tool that must go through the selector.