Skip to main content

The security boundary

The Proof tab argues that the judgment layer is measured. This page argues the other half: that the measured layer cannot reach the book of record on its own, and that the claim has been attacked rather than merely written down. It is here rather than in Capabilities because it is evidence, and it is the same kind of evidence as a scoreboard: a list of things that were tried, and what happened.

The rule, in one line

A graph that can reach the ledger is never drivable from a browser.
Two named sets, in the UI’s proxy guard: Their intersection must be empty, and membership of the first set is the single source of truth for “is this agent drivable from a browser”.
And that invariant is executed, not commented.
It throws at import time, in every bundle that loads the guard, so the mistake cannot reach a running deployment.A comment saying “keep these in sync” is what let a previous mismatch ship: the Auditor was offered in the agent picker and absent from the allowlist, so every message it received returned 403. The agent picker now derives its visible set from CHAT_GRAPHS, and the disjointness throws.
Posting is reachable through exactly one path: the gated server-side approval endpoint.

Why a chat agent is allowed to exist at all

Admission to CHAT_GRAPHS has one rule: the graph must accept a conversational turn and have no write path. Two graphs pass it, for structural reasons rather than policy ones.

The Auditor

No submit or post tool. No interrupt. A deterministic scope denylist that refuses any post, fix or reverse request.And its database session is put read-only by Postgres and then read back to prove it before the connection is used, so the server rejects a write even if the graph attempted one.

The Typewriter

No database session at all: nothing in the package imports a driver, and no dependency holds a connection.No submit tool - the model returns data, and there is no tool it can call that has an effect. No interrupt: it runs to a terminal answer in one pass. No HTTP client.
The Typewriter’s four properties are asserted by a test, not by a docstring.test_typewriter_graph.py inspects the package’s imports, its source and its state keys, and fails if any of the four stops being true.A change that gives that graph an effect goes red in the test suite before it reaches the guard, with a message that names the remedy: remove it from CHAT_GRAPHS, because a browser-drivable agent with a write path is exactly what this boundary exists to prevent.That is why a propose-only agent can safely be the one the browser drives. It proposes; the human’s Save is the write, always.

Eleven executed attacks on the proxy

The UI proxies the browser to the graph runtime, authenticated with the server’s own key. The only thing originally in front of it was a session check - which is a login check, not an authorisation check. Six rounds of adversarial review followed. Each one executed the attack before writing the fix.
Every authenticated user had the full SDK surface against the deployment, including the posting graph.Since the Bookkeeper’s only gate was interrupt_before, and everything the post node inspected was state the caller wrote, three SDK calls put an arbitrary, immutable, attacker-attributed journal entry in the general ledger.Fix: a positive route allowlist - the chat surface and nothing else - with update_state denied by name, and any run on a graph outside CHAT_GRAPHS denied outright.The allowlist is positive on purpose: a new runtime endpoint arrives denied and has to be allowed deliberately, rather than arriving exposed and having to be noticed.
Two attacks went straight through the route allowlist.3. Thread creation was allowed with no body inspection - and the creation call takes supersteps, which is a state write, plus a graph_id that assigns the thread’s graph. The Bookkeeper’s thread id is derived deterministically from an intake id every user is handed, so one call seeded lines onto a posting thread before the document was approved. A real reviewer’s approval then posted the attacker’s entry under the reviewer’s name.4. An allowed Clerk run pointed at the Bookkeeper’s thread id overwrote that thread’s channels. The proposal vanished, the paused gate could never be resumed, and the approved document became permanently unpostable - one call per document, from any authenticated browser.Fix: body checks on creation, and a run must target a thread its own graph owns.The lesson: every route that carries a body has to be judged on the body.
Round 2 fixed the two fields that had been abused. Two more arrived wearing a third field.5. if_not_exists: "create". The run routes take the thread id in the path, so denying a client-chosen thread id at creation denied nothing: one Clerk run on the Bookkeeper’s computable thread id claimed that id first. The ownership lookup answered “missing”, and missing was an automatic allow - which is only true if the body cannot ask the server to create it.6. command: {goto, update} - which is a state write wearing a third route. The proxy rewrote one field and never looked at command, so a real intake row was filed under a name the browser chose, defeating the three-person separation of duties by forging one of the three.Fix: the body check became positive, like the route check. A run body may carry only listed keys; everything else - including whatever the next SDK release adds - arrives denied. “Missing” became a denial.
Round 3’s allowlist held: a third re-verification could not get a single write to a posting thread through it.What it found instead was that the ownership check only ever ran on writes.7. A posting thread’s state, history, stream and checkpoints were readable by any logged-in browser. That state is a document’s proposed journal lines, amounts and counterparties, plus the signed mandate behind its approval. Thread ids are computable, and the search endpoint enumerates them anyway.This is confidentiality, not a path to the ledger - a leaked mandate is inert from a browser, because every write is denied and the mandate is bound to one org, one record, one checker and one verdict. But a layer that is claimed to exist has to exist.8. Two body-supplied fields that on paper aim a run at a thread the URL did not name. Executed against a real server, upstream ignores both - which means the control was owned by someone else’s undocumented implementation detail. They are now pinned on the run routes, and the remaining gap on the non-run routes is recorded rather than claimed closed, because the honest statement of what a control covers is the thing these rounds kept finding wrong.
9. A URL-encoded path separator stayed inside one segment, so the guard saw an allowlisted read of a thread while the ownership lookup landed on a different upstream endpoint - one whose array response has no graph field, so a posting thread was classified “unassigned” and allowed. The full state, mandate included, came back 200.Two locks now: the request is refused before a URL is built from the segment, and any non-object lookup payload is refused as unknown.10. The thread search endpoint performed no org scoping and returned every thread on the server, including other graphs’ posting threads. It is now filtered by response.
11. The filter added in round 5 reads two fields of each row - and the search endpoint lets the caller choose which fields a row has. Omitting one parameter and moving the data under a key the filter never looked at brought every posting thread’s values and signed mandate back with a 200.The same capability round 5 removed, reached through the control that removed it. Round 5’s own documentation meanwhile asserted the leak was closed.Two locks now, and the documentation says only what they buy: a search body carrying either parameter is denied, and any row that cannot be classified is dropped.
The lesson these six rounds kept producing, in the reviewer’s own words:
A control written the same day it is claimed is the one claimed too broadly, because nothing has attacked it yet.
Six rounds found the same failure six times, and every time it was a sentence about a control, not the control.That is why this page describes gaps that are open (attack 8) alongside the ones that are closed.

The identity the control is enforced against

One thing is added to a request rather than filtered from it: the verified caller is stamped onto a Clerk run as the document’s submitter, and any submitter the browser sent is dropped. That identity is one of the three people separation of duties is enforced between, and it was the missing one. The browser sent nothing, the Clerk defaulted to the literal string "submitter", and the backend’s checker is not submitter comparison was comparing an email address against a constant on every document ever filed.
An identity a control is enforced against may never come from the party the control constrains.
The same rule applies to the acting identity on the API proxy. See two gates, three people for what the rule became once it had three real people in it.

Not load-bearing alone

Everything above stops the attack being reachable. A separate layer stops it working.The browser-side guard could be bypassed, misconfigured, or the graph runtime port reached directly - which, inside the network, it can be.So the backend enforces the same boundary independently, over every security-relevant field: the post node reads the org, the entry kind, the record id, the checker and the verdict out of a server-signed mandate no browser can mint, re-reads the record those ids name, and refuses a checkpoint that contradicts them or that claims an outcome the graph never produced.That control took five rounds of its own, and two of its round-5 repros still fail on round-4 source - which is the only real evidence that a control of this kind exists. A security test that passes on the vulnerable version is testing something else.

The whole boundary, in order

Four independent things have to hold, and none of them is the model:
  1. the graph the browser can drive has no write path;
  2. the proxy refuses any call outside a positive allowlist, judged on the body;
  3. two distinct humans approve, and neither may be the person who filed the document;
  4. the writer accepts a posting only against a mandate the server minted, and re-reads the record that mandate names.

Where it is enforced