A spending limit written in a prompt can be talked out of, so Purser enforces it outside the model, in layers that do not trust each other.
Every attack here starts with part of the system already in the attacker's hands, and a test on the real code shows that a layer outside the attacker's reach still stops the money.
Every attack ran in this repository's test harness: Purser's own code is the real code; Airwallex, Metal, the model and the phones are simulated. Nothing here attacked a live rail.
The audit chain and the books were anchored on Metal testnet on 13 November; check it with npm run verify-anchor. docs/runs/2026-11-13-anchor.md ↗
A hostile model, against the codeTest data
A script in the agents' place made the worst tool call it could, every turn.
On Metal testnet, 12 Nov: a passkey account changed an agent's mandate only with both owners' passkeys, and checked them on chain; the second owner was a labelled virtual test passkey.
If the approver is unavailable
Away or out of reach: nothing above the limits is paid. The payment waits for the signature and expires unsigned; payments inside the limits go on.
No deputy, no delegation: neither is built. A second person can be enrolled beforehand; where four eyes does not apply either passkey approves, where it applies both must sign. Our runs had one person: the second signature came from a test passkey.
A lost phone: the passkey is replaced, not recovered. The old one counts until the new one is confirmed, and is revoked then.
Who may enrol a new passkey, on the runner: only the person at the runner, who opens a ten-minute window with a one-time code and confirms the new passkey's fingerprint.
Who may enrol, in Purser Cloud (built, not live): a signed-in owner or an API key with the admin scope starts a person's enrolment; an owner's passkey, once any owner has one, only a signed-in owner. An owner who lost the passkey gets a one-time recovery key from Purser's platform admin after an out-of-band check; using it removes the old passkeys and ends their sessions.
Airwallex: The CFO policy's limits, merchant categories, currency and end date are written to each agent's card as Airwallex's own controls and read back. src/policy/cards.ts ↗ · test/cards.test.ts ↗
“Models: no key, URL, credential or tool. They read text and return typed data that code checks” docs/security.md ↗
In CI, a script in place of the payables agents, on 62 test invoices: 380 hostile tool calls, 370 refused by code, 0 unsafe by the ledger verdict. docs/evals/ap-hostile.json ↗
Secrets
Only the runner holds keys, and every push is scanned for leaked secrets.
“Every row carries the company (tenant_id); routes only reach data through a tenant-scoped layer (cloud/src/tenant.ts), and a test tries every route against another company's objects.” docs/cloud.md ↗
“On sensitive settings a session must also show a fresh passkey check” docs/cloud.md ↗
“test/cloud-route-auth.test.ts fails when a route names none” docs/cloud.md ↗
Audit trail
Every step goes into a hash-chained log that anyone can re-check: npm run verify-log.
“Each row's hash is SHA-256 over the previous row's hash and the row itself (src/audit/log.ts), so changing, removing or reordering any row breaks every hash after it.” docs/audit/README.md ↗
The 30 October run's log is in the repository, 194 rows. Check it offline, with no keys: npm run verify-log -- --jsonl docs/audit/friday-1-audit.jsonl
31 October, three runs in a row: 120 attacks, 112 stopped before anything moved, 6 not prevented (detected after), 2 inconclusive. The runs ↗
2 November, tests written by Claude Opus as the attacker: 119 run, 111 blocked, 5 not blocked by code's verdict (checked by hand: none is a breach), 3 inconclusive. The record ↗
Every round ran in a throwaway test world on simulated rails, with test data: not live.
Money and dataTest money only
Each company's money stays in its own Airwallex account, and on chain there is no personal data.
“The money sits in the business's own Airwallex account and Metal account. Purser asks those rails to move it” docs/compliance.md ↗
In the sandbox run a payout was made on behalf of a test company's own connected account, with test money.
In this build the runner keeps the testnet owner key. docs/claims.md ↗
Every card is an Airwallex sandbox test card and every amount is test money.
Metal's testnet is public: each agent's mandate and every payment can be seen on its explorer.
“Mandate changes anchor the approval on chain (the new credential's hash is bound)” docs/archive/overview.md ↗
“the customer is simulated and Purser holds no personal data on chain” docs/metal-award.md ↗
Not yet
No SOC 2 report, no ISO 27001 audit and no third-party penetration test yet; sandbox and testnet only; Purser's independent security review is pending.
“This is not a SOC 2 report: no auditor has tested these controls, and the build runs on the Airwallex sandbox and Metal testnet only.” docs/controls.md ↗
The SME product, still to do before real customer data: “An independent security review of this code after these fixes, and a red-team run against a deployed staging copy.” docs/cloud.md ↗