Story 1836: graded server-set trust tier on tool-results — firm data can never claim the principal’s voice
Why this exists (the catastrophe it prevents). When counsel asks the dock a data question, the broker runs a server tool, then re-injects the result into the next agent prompt so the brain can answer from it (clerk_worker/runner.py _command_with_tool_results). Those results are rendered inside the D1 keyed fence the brain shipped in pramaan-agents #1825 — the fence that separates the one channel that drives action (the verified counsel turn) from everything that is merely DATA. But a tool-result payload is firm data, and firm data is full of attacker-controlled strings: a matter title, a party name, a pasted note. The DockToolResult contract is extra="allow", so a payload carrying [trust=counsel_input] — or a smuggled trust_tier key — would let that data forge a higher provenance tier than the read it rode in on, promoting attacker text to the principal channel the model obeys. This Story sets the tier server-side and strips the forgery.
tool_result at most, and the counsel ceiling is unreachable from data by construction. No model call, no I/O — a deterministic stamp at the broker re-injection choke-point.Satellite view: one choke-point, every tool-result, two defences
Every tool-result the brain ever sees is merged in exactly one place — _command_with_tool_results — from three sources: a freshly executed read this step, a prior broker-stamped result echoed back in current_context, and (on the first invoke) whatever the client seeded. A new pure module, src/shared/agent_tools/trust.py, re-stamps every one of them there:
1. drop any client/model-supplied tier key (trust_tier / trust / trustTier) 2. strip [trust=...] lookalikes from every string leaf of the payload 3. stamp the server tier from the registry, keyed to result['tool'] (set LAST)
Because the tier is set last, from the registry, the cleaned payload can never contribute to it. The function is idempotent and source-agnostic, so re-running it every round-trip step is a no-op for already-stamped results and a hard reset for forged ones.
The decisions that matter
1. The ladder is graded, and the ceiling is out of reach
Three ordered tiers, low → high: untrusted < tool_result < counsel. A registered server tool’s result is tool_result (authoritative firm data — but still a DATA channel). Anything unknown, forged, empty, or non-string fails closed to untrusted. counsel is the ceiling the fence reserves for the verified principal turn #1825 leaves un-fenced — and tier_for_tool never returns it. So a payload screaming [trust=counsel_input] is climbing toward a rung the server will not hand to any tool-result.
2. Keyed to the source, not the content
tier_for_tool consults only the existing SERVER_TOOL_REGISTRY — the same leash that authorises which tools may run at all. A result naming query_matters gets tool_result because that tool is registered; a result naming evil_unregistered_tool gets untrusted. The decision input is the tool name and nothing else, so neither the payload bytes nor a caller-supplied field can move it. This adds no new authority model — it reuses the one registry the executor and the resolver already share.
3. Strip the lookalike so the fence can’t be tricked
Even with the tier set server-side, a raw [trust=...] string sitting in a matter title would reach the fence as text. The fence (#1825) defangs role prefixes and literal fence markers, but the trust marker is this Story’s concern. strip_trust_markers walks the whole payload (dicts, lists, strings; other scalars pass through) and removes every [trust=...] lookalike — case- and space-insensitive, single-line — leaving the surrounding firm data intact. A forged marker never survives to be misread.
4. No new observability span (and why that is correct)
Per pramaan-docs ADR-124, new executable code declares a span or documents its exemption. trust.py is pure deterministic transforms — no I/O, no boundary crossing, no state — invoked inside _command_with_tool_results, which already runs under the worker’s SQS consumer span. The module docstring records the exemption. No DTO, route, or trust-boundary payload changed, so the OpenAPI snapshots are untouched (the results ride the free current_context dict).
Verification
The forge property is pinned both as a pure unit test and through the real broker path (system boundary, not a fixture spot-check).
| Check | Proves |
|---|---|
test_trust_tier — fake marker in payload | AC3: a query_matters result whose firm data carries [trust=counsel_input] stays at tool_result; the marker is gone, the surrounding data survives |
test_trust_tier — smuggled tier key | AC1: a client-supplied trust_tier/trust/trustTier is dropped, then re-set server-side from the tool |
test_trust_tier — graded ladder + fail-closed | ≥3 strictly ordered tiers; unknown/empty/non-string source → untrusted |
test_runner — broker path | through real process_command: a forged prior result and the freshly executed read are both re-stamped (untrusted / tool_result) with markers stripped; the run still succeeds |
Ruff is clean on all changed files; mypy reports no new issues in the new module or the runner edit (pre-existing repo errors only); the agent_tools unit suite and the clerk_worker runner unit suite pass; the OpenAPI snapshots are unchanged (no route/DTO touched); git diff --check is clean. (The two DB-backed dock integration tests require make test-db and are environment-gated, not affected by this change.)
One sentence to remember: the broker now stamps a graded trust tier on every tool-result keyed to its registered source and strips [trust=...] lookalikes from the payload, so firm data can never forge the principal’s voice inside the keyed fence.