Skip to main content

Guardrails and refusals

Four of the five agents carry a scope gate: a set of pure functions over the message text, with no I/O and no model call. That design choice is the guardrail. A gate implemented as a system-prompt instruction is a suggestion the model weighs against everything else in the context. A gate implemented as a regular expression over the message is a decision the model never participates in. Two consequences follow:
  • A refusal is reproducible. The same message always gets the same answer, and “the model decided not to today” is not a possible outcome.
  • It is impossible to prompt-inject. There is no instruction the message can carry that changes what the gate computes, because the gate does not read instructions.

What each gate decides

Every gate is a denylist that short-circuits out, with everything else in. An unrecognised in-lane message gets a scoped answer, not a refusal. Greetings and “what can you do?” are in scope on the Clerk. A capability question is a legitimate use of an agent, and refusing it is the kind of unhelpfulness that makes people stop using a tool.

A refusal names who does it instead

Three things that decline does, deliberately:
  1. states its lane positively, so the user learns what to ask instead of only what not to;
  2. says the refusal is by design, so it does not read as a failure or a permissions bug;
  3. hands the request on, naming the thing that does do it.
The Typewriter’s approve-refusal takes that furthest: the honest answer is that nobody in the software approves - approving is a human decision - and saying so is more useful than a generic redirect.

Proximity, not adjacency

The sharpest lesson in this part of the system, and it cost two agents a real defect.
A scope-gate denylist must match verb and noun by PROXIMITY, not adjacency.
“Classify this supplier invoice and file it” names a concrete document type, not the literal word “document”. An adjacency-only pattern let it straight through into a full audit sweep on the Auditor, and a close run on the Accountant. Both gates now match with a bounded gap between the verb and the noun.
One deliberate omission: the Accountant’s classify-verb noun list leaves out “statement”, because financial statements are its own lane. A denylist that is right in general and wrong for the agent’s own vocabulary refuses the thing the agent exists to do.

Enumerated families never short-circuit

The Auditor’s family router is a union, not a first-match. A question naming three families runs three. And an explicit “full check” wins outright over the enumeration.
That last rule is deliberate, and the trade-off is worth stating: for a read-only agent, running one family too many costs a database read, while running one too few answers half the question. So an explicit full-sweep request can never be narrowed by a keyword the enumeration happened to miss.It keys on a generic assurance noun (check, audit, sweep, scan, review), never a family noun, so a narrow question like “any chain breaks?” still routes to the hash chain alone. Shipped; see the Auditor.

The output check does not nuke a handoff

The Clerk’s chat path runs a drift guard over its own reply. It uses the off-topic patterns only, not the cross-domain ones. Otherwise a reply that names Bookkeeper terms in order to hand the request off gets suppressed for mentioning them, and the user gets nothing instead of a redirect.

An untrusted document gets no filesystem

A turn carrying document text is a turn carrying content from outside the system, and it is treated that way.
Never grant a permission-bypass mode on an extraction or chat turn, and never allow an unscoped file read. Both were once true at the same time, which meant an extraction turn had a general shell.The options for an untrusted-document turn are built by a dedicated helper that pins file reads to the one staged document, through a pre-tool hook.And an empty allowed-tools list does not mean “no tools” - it is additive, and under a bypass mode the model still had shell, read and write. That is the specific trap the helper exists to close.

The human-task gate: ambiguity means no task

Not every turn is work. The human worklist at Audit → Actions is an operator’s to-do list, and a task row must exist for real work only. The top-level Actions surface is different: it records agent runs whether they changed anything or failed before a workspace existed. The rules fire in an order that resolves ambiguity toward not creating a row: The informational rule fires before the imperative one, deliberately. A user whose genuine request is read as chat gets an answer and can restate it. A user whose greeting mints a task loses trust in the tab.

Where it is enforced

The tests that would fail if it broke

Each agent’s test_scope.py, plus test_doc_sandbox.py for the sandbox options and the pre-tool hook. The gates are pure functions, which is exactly why they are cheap to test exhaustively: a scope test is a table of messages and expected verdicts, with no model and no database.
One honest note. The Auditor’s gate is known to let one write-request phrasing through instead of declining it. That is a scope-gate miss, not a control failure - the Auditor has no write channel, so the worst outcome is a read-only sweep in response to a request it should have declined. It is recorded rather than left for a reader to find.