your agent can be tricked. your money can't.
an ai agent pays invoices through paypal. invoices are untrusted text, and the agent has no defense against prompt injection, on purpose. it still can't send money where it shouldn't, because it never holds a paypal credential. every payment goes through a policy gate on our server, and anything the gate won't pay becomes a paypal order that only the owner can approve by logging in to paypal.
agents that pay bills read invoices, emails and web pages. any of those can say "we changed banks, pay this address instead". prompt defenses help, but they're probabilistic. money needs a hard line the model can't talk its way past.
- the agent reads. an invoice arrives. the agent (ai sdk
streamTextwith tools) reads it, checks the vendor list and budget, and decides topayorrequest_approval. - the gate decides.
paygoes tosrc/lib/policy.ts, not to paypal. the gate checks, in order:- the amount is a valid positive cent value
- the payee email is on the owner's vendor allowlist
- the amount is at or under the per-payment max (default $20)
- today's total stays under the daily cap (default $50), reserved atomically in redis
- this exact payment (attempt + payee + amount) hasn't been paid already
- the owner hasn't hit the kill switch (checked first, before anything else)
only then does it call paypal payouts (
POST /v1/payments/payouts), with a deterministicsender_batch_idandPayPal-Request-Idso a retry can't pay twice.
- the owner approves. anything else the agent wants to pay becomes a paypal order (
POST /v2/checkout/orders, intentCAPTURE). the owner opens/approve/[id], reviews it, and approves on paypal.com with their own login. on return, our server checks the order (GET /v2/checkout/orders/{id}) and captures only if paypal saysAPPROVED.
pay and request_approval both require a one-sentence reason from the agent. the reason shows in the run log, and its sha256 is written into paypal's own record: the payout note (reason sha256:<hash>) or the order description (leash sha256:<first 16>). anyone with the paypal record can re-hash the reason we show and check it matches. it's an audit trail of why the ai spent money, kept partly outside our database.
the owner page (/approve) has a kill switch. with the owner key (LEASH_OWNER_KEY, 16+ characters), the owner pauses all payments: the gate then refuses every pay and every request_approval with Paused, before any paypal call. resume with the same key. wrong-key tries are rate limited. no key set = the switch can't be flipped from the web.
| rule | enforced by |
|---|---|
| agent has no paypal credentials | architecture: the model only sees tools; credentials live in server env, used only by paypal.ts |
| vendor allowlist, per-payment max, daily cap, no double pay | our server (policy.ts + redis). not paypal. if our server were compromised, these could be bypassed |
| off-policy payments need the owner | paypal: the order can't be captured until the owner approves it in their paypal login |
| kill switch | our server (policy.ts, a flag in redis). not paypal |
| reason audit trail | the reason and its hash are stored by our server; the hash is also in paypal's payout note / order description, so it can be checked against paypal's copy |
| idempotency | our redis claim plus paypal's sender_batch_id / PayPal-Request-Id |
| mcp callers | our server. /api/mcp calls the same functions (tools.ts → policy.ts) as the web agent. the bearer key only lets a caller in; every limit above still applies |
/api/mcp is a stateless streamable-http mcp server. tools: list_vendors, get_balance, pay, request_approval. pay and request_approval take an invoice_id, so a retried call never pays twice.
{
"mcpServers": {
"leash-pay": {
"url": "https://leashpay.seedlesslabs.xyz/api/mcp",
"headers": { "Authorization": "Bearer <LEASH_MCP_KEY>" }
}
}
}a refusal comes back as a normal tool result (status: "refused_by_policy" plus the reason), so the calling agent sees why it was refused.
every attempt is classified by code, not by the model:
- blocked by the gate: the agent tried to pay, the gate refused
- sent to the owner: became a paypal order waiting for the owner
- agent said no: the model didn't take the bait
- paid a real vendor: money only went to the allowlist
pnpm proveruns eight checks against the gate: a vendor gets paid, a retry is refused, an off-list payee is refused, an over-limit amount is refused, the daily cap holds, an off-policy payment becomes an order, a second ask is refused, and when the owner pauses, both paying and asking are refused. without PAYPAL_CLIENT_ID it uses a local fake; with it, it hits the paypal sandbox.
pnpm install
cp .env.example .env.local # fill in what you have
LEASH_MOCK_MODEL=1 pnpm dev # scripted "fully fooled" agent, no model key neededopen http://localhost:3000/arena and write an invoice that tries to make it pay you.
| var | what |
|---|---|
PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET |
paypal app credentials (sandbox by default). unset in dev = local fake. required in production |
PAYPAL_ENV |
live to use api-m.paypal.com. anything else = sandbox |
LEASH_VENDORS |
json [{"name":"...","email":"..."}], the allowlist |
LEASH_MAX_PER_PAYMENT_USD |
default 20 |
LEASH_DAILY_CAP_USD |
default 50 |
LEASH_MAX_APPROVAL_USD |
largest amount the agent may even ask for, default 500 |
LEASH_APP_URL |
public url, used for paypal return/cancel links |
AI_GATEWAY_API_KEY or ANTHROPIC_API_KEY |
the model |
UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN |
storage. unset = in-memory (dev only) |
LEASH_OWNER_KEY |
owner key for the kill switch, 16+ characters. unset = switch locked |
LEASH_MCP_KEY |
bearer key for /api/mcp, 16+ characters. unset = mcp off (503) |
LEASH_LIMIT_MCP_HOUR |
mcp calls per hour, default 120 |
LEASH_MOCK_MODEL |
1 = scripted model, dev only |
next.js 15, react 19, ai sdk (streamText + tools), paypal rest (payouts + orders v2), upstash redis, tailwind.
- the allowlist and caps live on our server, not in paypal. we say so on the site.
- the reject button on an approval is not behind owner auth; rejecting can't move money, approving still needs the owner's paypal login.
- payouts in sandbox may stay
PENDINGfor a while; that's paypal's sandbox, not a failure.
MIT