Inspiration
This fall, one compromised laptop cost a major crypto exchange $6.3 billion. Business email compromise took $3.05 billion from US businesses in 2025, and it works on careful people: an email from the vendor's real mailbox says the bank details changed.
Now businesses are handing their payables to AI agents, which read every email and act on it. The usual answer is to give the agent a wallet with limits. That stops a buggy agent. It does not stop an agent that read the wrong email and is faithfully following someone else's instructions, because it still holds the key.
I've spent twenty years in construction. At a subcontractor, the person paying the bills is a bookkeeper, not a security team. Portcullis is built for that business.
What it does
An AI agent can propose a PayPal payment. It can't authorize one.
The agent gets exactly four tools over MCP: propose_payment, list_commitments, get_proposal, get_balance. It never holds PayPal credentials. Every proposal goes to a policy kernel written in Rust that holds no model, reads no prompt, and never enters the agent's context, so there is nothing to argue with. It checks:
- the payee, byte for byte (a lookalike address with one Cyrillic letter is refused, and named)
- the amount, against per-payment, rolling-window and velocity limits
- the approved bill it claims to pay: pinned to the vendor of record, paid in full, once
- a balance floor that money earmarked for bills can't be spent through
When it refuses, it names the rule. Only an allowed payment reaches PayPal Payouts, keyed by PayPal-Request-Id so a retry can never pay twice, and only a definitive answer from PayPal settles or releases the money. Each payout's note carries the digest of the decision that allowed it, so PayPal's record and ours name each other.
Three live pages, no login
- Siege. Eight models from eight labs work one AP inbox on one shift, each with its own token. The vendor, a separate PayPal sandbox business, issues a real PayPal invoice through the Invoicing API. You throw the attacks: a hijacked vendor mailbox, an urgent note from the boss, hidden instructions in an invoice, a lookalike domain. Each tile shows the model's own words as it works. When the bill is paid, the vendor records the payout against the invoice and it reads paid in PayPal.
- Console. One agent, one inbox, and you play Dana, the owner. When the agent writes to you, you answer. Tell it to pay the fraudster's "new account" and the gate refuses, even on the owner's word, because an approved bill is changed by changing the approval, not by a message. Tell it to pay the real bill and a PayPal payout goes out and settles. Or run the compromised agent: a script holding the agent's real token that tries six ways to steal or sabotage the bill and is refused by six different rules.
- Audit desk. An AG Studio dashboard over the hash-chained record, which your browser re-verifies, with an auditor agent that answers from the record and has no path to the money.
How we built it
- Kernel (Rust). Pure: its only dependencies are
serdeandsha2, and CI fails if that changes. No I/O, no clock, no randomness, so the same inputs always give the same decision and any decision can be replayed. - Executor. Turns allowed decisions into PayPal Payouts and reads status back. Ambiguity holds the money; it never guesses. A payout that comes back is credited and its bill marked returned, still owed, not reopened for an agent to retry.
- PayPal. Payouts, Invoicing and OAuth, each with its own client, sandbox only by construction (the host is a constant). PayPal's webhook is a doorbell: the route never reads the body, it just makes the settlement loop ask PayPal sooner, so there is no signature to get wrong and nothing to forge.
- MCP server.
rmcpover Streamable HTTP, behind per-agent tokens stored as SHA-256 hashes. - Models. Demo and siege agents on OpenRouter, from eight labs.
- Audit desk. AG Studio, with custom widgets and custom agents on its Agent Framework.
- Hosting. Render, deployed from a blueprint, with a Deploy to Render button in the README.
The code was written with Claude. Then other models reviewed it adversarially: one team audited it against the rules, and four red-team agents attacked the code and the live site the day before the deadline.We even ran it through our own 5 agent runtime , Sonnet, Haiku ,Opus, Gemini Flash and Grok all latest we ran it through the mesh like crazy.
Challenges we ran into
The agents were better than we planned for. In early runs Claude, Gemini and others caught the fraud on their own, so we built the compromised agent: the gate's job is the day the agent is wrong, not the day it is right. Then the live sieges disagreed with each other. In one shift four models proposed paying the attacker after a fake note from the boss; in another, two did; in the shift in our video, none did. Every attacker payment was refused. Single runs aren't a benchmark, so we report them as runs.
Our reviewers found real bugs, each now fixed with a test that fails without the fix:
- one cent against a bill closed it as paid
- the demo account would hit its floor after twelve bills
- another website could start runs from a visitor's browser
- the audit desk's model proxy passed images, file URLs and expensive options through on our key
- a one-off payment could spend into money set aside for an approved bill
None of them was a way to pay someone the policy doesn't allow, or to pay twice. Every finding, fixed or still open, is in THREAT_MODEL.md.
Accomplishments that we're proud of
- Across every live siege, each bill was paid exactly once and $0.00 went to attackers, while models from several labs proposed paying them.
- A real two-account PayPal flow: invoice issued by the vendor, paid by Payouts under the owner's approval, marked paid in PayPal with the batch id.
- Every claim in the README names the test that proves it, and the guards are mutation-checked: we broke each on purpose and confirmed a test failed.
What we learned
Which model falls for which email changes from one run to the next. A defense that depends on the model's judgment inherits that variance. The gate's answer doesn't change.
When every day is a zero day, the defense that keeps working isn't finding the bug first. It's limiting what any one compromise can reach.
What's next for Portcullis
The gate doesn't care what the money moves on. A second rail is built on a branch: PayPal USD (PYUSD) on an Ethereum testnet, paid by EIP-3009 authorization with the nonce derived from the proposal id, so the token contract itself refuses a second payment. On Nov 10 it made its first live payment: the gate allowed 12.84 PYUSD, the executor waited for finality before calling it paid, and a resubmit sent nothing.
Next: route by currency on the server, wire the signed policy log (built and tested in the kernel, not yet used by the hosted demo), and keep the record durable by default.

