Purser
Sandbox and testnet only

Security and trust

Each control below, with its proof.

The layers a payment must passTest harness, simulated rails

52 attacks, all stopped; none moved money. At least two layers must fail before a hostile payment moves.

  1. The agent asksL1Untrusted: nothing relies on it.
  2. Policy and limits in codeL2Tools, invoice and fraud checks, the CFO's policy.16 stopped here
  3. Face ID or a passkeyL3Above the limits, the CFO signs the terms' hash.19 stopped here
  4. Four eyesL4Above a set threshold, a second person signs.5 stopped here
  5. The rail's own limitL5Metal's mandate on chain; Airwallex card and transfer limits.8 stopped here
  6. Books and audit chainL6Hash-linked, anchored on Metal testnet.4 stopped here
  • Paid as signed, then recorded
  • Refused at its layer
How this was tested
  • 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.

380 calls · none unsafe

318 refused by the tool's own check52 made stricter by code before it counted10 stood as the script made it
  • 318refused by the tool's own check
  • 52made stricter by code before it counted
  • 10stood as the script made it

Where each attack was stopped

By what it aimed at (rows) and the layer that refused it (columns).

TargetL2 CodeL3 CFOL4 Second signerL5 RailL6 Record
Bank payout414331
Supplier invoice42111
The record22··1
Card purchase2··2·
On-chain payment1··21
The policy111··
Company data2····

Signed terms

The CFO signs the exact terms with a passkey (Face ID); one changed cent is refused.

How
  • The CFO's passkey (Face ID) signs the hash of the exact terms; the runner checks the signature and the hash before it pays. Single use, it expires.
  • src/policy/approvals.ts ↗ · test/approvals.test.ts ↗
  • 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.
  • docs/security.md ↗

Limits outside the prompt

Card controls per agent at Airwallex, and a mandate per agent on Metal's chain.

How

Secrets

Only the runner holds keys, and every push is scanned for leaked secrets.

How
  • “Only the runner holds keys and writes the ledger.” docs/security.md ↗
  • “gitleaks on the pushed commits and the whole history of the branch under check” docs/security.md ↗
  • “cloud/src/log.ts cleans every field: anything named like a secret becomes [redacted]” docs/cloud.md ↗

Each company apart

Every row carries its company, and a test tries each route on another company's data.

How
  • “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.

How
  • “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
  • src/audit/log.ts ↗ · test/audit.test.ts ↗

Tested by attackTest world

Red team: 39 attacks, 37 stopped before anything moved, 2 not prevented (detected after).

Later rounds
  • 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.

How
  • “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.

Sources
  • “No third-party penetration test has been run” docs/security.md ↗
  • “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 ↗