Warden is a drop-in MCP proxy in front of PayPal's own MCP server. Agents keep the same PayPal tools; they lose the ability to pay anyone the owner didn't authorise. Against 21 attacks on real sandbox PayPal, an unguarded agent sent all 21 and $2,093 to the attacker. With Warden, one got through: four slices of a split invoice totalling $50, exactly the owner's per-payment cap. 95% of legitimate payments went straight through. With a real model as the agent (gpt-oss-20b), 4 of 6 attacks paid the attacker without Warden and none with it. We publish every layer's numbers and every miss.
Inspiration
Agents are starting to move money, not just recommend things. Once an agent can send a payout or a refund, everything it reads becomes a way to steer it. An invoice email that says "our bank details have changed, pay this account instead" is enough. That is business email compromise, which already costs businesses billions a year, now aimed at software that doesn't get suspicious.
Better prompts don't fix it, because the model that reads the email is the one holding the wallet. In our own runs, gpt-oss-20b ignored a blatant hidden "note for the AI" but paid realistic "we changed banks" invoices from lookalike addresses. That is ordinary fraud, not a clever jailbreak.
So we started from the opposite assumption: the agent will be fooled sometimes. The job is to make sure being fooled doesn't move money. Warden is for the small business owner who wants an agent to handle supplier invoices, contractor payouts and refunds, and needs a guarantee that holds when the model is wrong.
What it does
You point your agent at Warden instead of PayPal. For any MCP client that takes a URL, that's a one-line change. The agent sees the same PayPal tools as before. Warden checks every call that could move money, and the agent never holds PayPal credentials.
What the owner does
- Grants a mandate, in plain words. "Pay Arjun's and Lena's approved invoices, up to $100 each and $200 in total, until tonight." A model turns that into a form, but it can only narrow it: a payee, id or amount is kept only if it appears in the owner's own words, and nothing applies until the owner presses Grant. Mandates can also be issued as Ed25519-signed tokens (compact JWS, public keys on a JWKS endpoint), so another service can check what the agent may do without asking Warden.
- Decides on what is held. A payment outside the mandate is held, not just refused. The dashboard shows it as a cheque: who, how much, the paper trail and why it was held, with Countersign and Refuse buttons. It can also be approved from Telegram. No answer within the time limit means no payment.
What Warden checks on every payment
- Policy (Cedar rules, each with a reason code): the mandate's payees, tasks and expiry, per-payment cap, daily budget, allow and deny lists, payment rate, repeats of a recent payment, and split payments that add up past the cap.
- Provenance. Warden also proxies the agent's other MCP servers, such as its inbox, and records every payee, amount and PayPal id the agent read there. A payout to an address that appeared only in an email, and nowhere in what the owner wrote, is held with
PAYEE_FROM_UNTRUSTED_CONTENT. An amount that only came from an email and is more than the owner wrote is held withAMOUNT_FROM_UNTRUSTED_CONTENT. The hold names the message:read_email id=msg-1043. - PayPal facts. For a refund, Warden asks PayPal who actually paid the original capture, so a refund to someone other than the customer the owner named is held, even inside an open refund mandate. A refund with no amount (which PayPal treats as "refund everything") is priced from the capture before policy sees it.
- Intent judge. An LLM checks whether the payment serves the mandate. It gets the mandate, the payment's fields, the PayPal facts and a yes or no on whether each value came from untrusted content. It never receives the email text, and it can only tighten a decision.
What the owner sees
- The paper trail: which email had this payee or amount, what the agent then asked for, and which checks held it.
- A note built only from Warden's own facts, including a lookalike check ("resembles Sam's authorised address but isn't it"). It never quotes anything the agent read, and it can't change a verdict.
- The activity grid (AG Grid): every call the agent made, filterable by payee, reason code and the layer that stopped it.
- The attack lab: write the scam email yourself, hidden text included, and run the agent with and without Warden side by side.
- The ledger: every call, decision and PayPal result, hash-chained and append-only, with a verify button.
How Warden is different
Most spend guards in this space wrap an agent in caps and an AI reviewer. Warden does five things we haven't seen together:
- It traces a payment to its source, across MCP servers. Warden sits in front of the agent's inbox as well as PayPal, so it knows the attacker's address came from
msg-1043and from nowhere the owner wrote. A cap can't tell a $96 scam from a $96 invoice; provenance can. - It fronts PayPal's real MCP server. It isn't a lookalike API. The agent calls
@paypal/mcptools through Warden, and Warden adds what the toolkit lacks: payouts with an idempotency key, and capture lookup to price and check refunds. - Every layer is measured on its own. Most entries report "with vs without". We report what each layer adds (policy, then provenance, then the judge) in attacks and in dollars, on real sandbox payments, with five held-out scenarios written after the last rule change and every miss published.
- The AI never sees the attacker's words when it decides or advises. The judge and the owner's note work from structured facts only. Both can only make a decision stricter.
- It's built to pay exactly once. Each payment is a durable Temporal workflow. Budget is reserved under a per-agent lock. The forward to PayPal is never retried. Events go through a transactional outbox. Every check fails closed. These matter when the network fails halfway through a payout, not just in a demo.
How we built it
Seven TypeScript services (NestJS, gRPC with Protobuf), event-driven over Kafka, with Temporal and Postgres:
- edge-gateway: the MCP endpoint agents connect to with a key. It sorts every PayPal tool into a risk tier (read, write, commit, money out), turns money calls into a payment intent, and records what the agent read from untrusted tools.
- guard-worker: each payment is a Temporal workflow: provenance and judge → policy and budget hold → wait for the owner if needed → forward to PayPal exactly once → settle or release.
- policy-service: Cedar rules, mandates and budget holds, decided under a per-agent Postgres advisory lock so parallel calls can't spend the same headroom twice.
- judge-service: the provenance index (built from gateway events) and the LLM intent judge, behind a timeout and circuit breaker.
- paypal-connector: the only service holding PayPal credentials. Runs
@paypal/mcpand adds payouts and capture lookup over REST. - ledger-service and api + dashboard (React, Vite, Tailwind, TanStack Query, AG Grid): read models built from the event stream.
The decision rule is deliberately simple: each check returns allow, escalate or deny; the result is the most severe one; a check that throws counts as deny. So the AI layers can add a hold but never remove one. Each ledger entry hashes the one before it, and a database trigger blocks updates and deletes.
There are about 260 tests (Vitest, with Testcontainers for Postgres and Kafka), and every policy rule has an allow case and a block case. CI builds every service image. OpenTelemetry traces one payment across six services, and Prometheus and Grafana show decisions by layer.
Benchmark
We didn't want to just claim it works. bench/ has 35 scenarios: 14 legitimate (4 of them outside the mandate, so they should be held) and 21 attacks (payee redirection, inflated amounts, unrequested actions, split payments, attacks inside the mandate). Each one runs four ways against real sandbox PayPal. Refunds go against real card-paid sandbox captures made for each run. The scripted agent obeys every injected instruction, so this is the worst case.
| no guard | policy only | + provenance | full Warden | |
|---|---|---|---|---|
| attack scenarios where money reached the attacker | 21 / 21 | 5 / 21 | 3 / 21 | 1 / 21 |
| attack dollars that reached PayPal (of $2,093 tried) | $2,093 | $392 | $153 | $50 |
| legitimate payments allowed straight through | 100% | 95% | 95% | 95% |
| out-of-mandate payments correctly held for the owner | 0% | 100% | 100% | 100% |
| median time to allow a payment | 4 ms | 1.9 s | 2.2 s | 5.9 s |
Each layer catches what the one before it can't. Provenance catches the inflated invoices. The judge catches refunds that PayPal says were paid by someone else. All five held-out scenarios came out right.
What still gets through: four slices of an invoice split eight ways, $50 in all, which is the owner's own per-payment cap, so no single payment exceeded what the owner allowed. One legitimate payment is held: a new contractor under a mandate that allows any payee, which is indistinguishable from an attacker's invoice. The LLM judge adds about four seconds to an allowed payment. It is optional, and policy plus provenance alone stop 18 of 21.
With a real model as the agent, gpt-oss-20b paid the attacker in 4 of 6 attack scenarios with no guard ($260), and in none with Warden ($0 of $400 tried). qwen3.7-flash fell for the same four. Careful models like Claude Haiku 5.5 refused almost everything, but a firewall is for the day the model gets it wrong.
We also tested the judge alone. Our first prompt held 4 of 17 legitimate payments because it wanted "evidence the invoice was approved", which it can never see. Telling it what it can't see brought that to 0 of 17, and it still caught every inflated amount and unrequested action.
Challenges we ran into
- PayPal's MCP server reports failures as successes. Failed calls come back with
isError: falseand an error in the body. Warden reads the body to decide whether money moved. - The hosted sandbox MCP server failed every tool for us (
PAYPAL_API_SETUP_ERROR), so the connector runs@paypal/mcplocally over stdio, with the hosted one behind a switch. - No payouts tool in the toolkit. The connector adds
send_payoutover the Payouts API, with the call id asPayPal-Request-Id, so a replay can't pay twice. - "Refund" with no amount means "refund everything". A policy that only reads the arguments would see $0, so the gateway prices it from the capture first.
- A lost answer is not a failure. If PayPal's response is lost, the money may have moved. Warden keeps the budget hold and never retries.
- Holds could starve a mandate. Early on, held payments reserved budget, so an attacker could use up a mandate just by triggering holds. Provenance now runs before policy, so a held payment never draws on the mandate.
- Cedar skips a policy that errors and can still return allow. We treat any evaluation error as deny.
- Sandbox account country. Our first business account couldn't take card payments or send payouts (
PAYER_CANNOT_PAY,PAYOUT_NOT_AVAILABLE) until we switched to US sandbox accounts.
Accomplishments that we're proud of
- An unguarded agent paid a scammer $96 on real sandbox PayPal (payout
GRA6NVN33EX2Q). The same agent, through Warden, was held, and the hold named the email it came from. - Every layer measured on real sandbox money, with held-out scenarios and the misses in the README, not just the wins.
- Fronting PayPal's own MCP server, with two real bugs found and worked around on the way.
- A complete product, not a proof of concept: plain-language mandates, approvals on the web and on Telegram, a dashboard designed around the cheque the owner signs, an attack lab, a verifiable ledger, and one command to start it all.
What we learned
- Fail-closed is easy to say and hard to keep. Every new check, timeout and retry had to answer "what happens if this errors?".
- An LLM is most useful in a payment path when it gets less input. Telling the judge what it can't see removed all its false positives.
- Real models resist obvious injections but fall for realistic fraud, and they vary a lot from run to run. A guard has to work when the model is fooled, not rely on it noticing.
- Benchmarks keep you honest. Ours found a false positive, a budget bug and a sandbox account that had quietly run out of payout balance.
What's next
- A hold-only second reviewer that reads the emails. In a side experiment, a reader caught more attacks (28 of 30 vs 13 of 30 for the judge alone) and a hidden "approve this" note didn't change its verdicts. Because reviewers can only hold, adding it can't loosen anything.
- An adaptive red team that reads Warden's refusals and rewrites its email each round (the harness is built).
- More trials per scenario across more agent models.
- Helm chart, Redis-backed rate windows, and an Istio profile that locks the connector's egress to PayPal.
Built With
- ag-grid
- apache-kafka
- cedar
- docker
- grpc
- mcp
- nestjs
- node.js
- openrouter
- opentelemetry
- paypal
- paypal-agent-toolkit
- paypal-payouts
- paypal-rest-api
- postgresql
- protobuf
- react
- tailwindcss
- tanstack-query
- telegram
- temporal
- testcontainers
- typescript
- vite
- vitest