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.Why a chat agent is allowed to exist at all
Admission toCHAT_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.Round 2 · an allowlisted route is not an allowlisted call
Round 2 · an allowlisted route is not an allowlisted call
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 3 · so the body check became an allowlist too
Round 3 · so the body check became an allowlist too
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 4 · the read surface, which nothing was checking
Round 4 · the read surface, which nothing was checking
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.
Round 5 · what defeated the read check
Round 5 · what defeated the read check
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.
Round 6 · what defeated the response filter
Round 6 · what defeated the response filter
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 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:- the graph the browser can drive has no write path;
- the proxy refuses any call outside a positive allowlist, judged on the body;
- two distinct humans approve, and neither may be the person who filed the document;
- the writer accepts a posting only against a mandate the server minted, and re-reads the record that mandate names.
Where it is enforced
Related
- The posting mandate - the five rounds behind the server-side half
- Two gates, three people - the human control this boundary protects
- Tenancy and read-only sessions - the Postgres-level layer underneath
- The Typewriter - the propose-only agent the browser drives
- Worked cases: the Auditor - the read-only guarantee, fingerprinted on every run