Inspiration

Agents that can transact have two options today and both are unshippable. Give one a credential and it has unbounded authority over your account, forever, on anything. Gate every action behind a human and you have built a slow browser.

What pushed me was reading the Agent Payments Protocol spec and finding that §7 explicitly scopes out two things: tracking cumulative spend across a delegation chain, and replay inside a token's TTL. Those are precisely the two failures that appear the moment money crosses more than one agent. The spec is right to defer them, it is a token spec, but somebody has to build them or none of this ships.

What it does

A person signs one ceiling. The agent takes the task, splits it, and hands pieces to sub-agents. Every sub-agent gets a token strictly smaller than its parent's: budget down, tool set smaller, expiry nearer, sourcing narrowed.

Recursion terminates on money rather than a depth counter. Each layer takes a share of its parent's allowance, so the tree bottoms out when a slice is too small to divide and that agent just buys the thing. Depth is bounded by

$$d \le \log_{k}\left(\frac{B_{0}}{f}\right)$$

where $B_0$ is the signed ceiling, $f$ the floor at which splitting stops, and $k$ the fan-out. The cap on max_depth never binds first. Arithmetic ends the recursion.

How I built it

Authority is a Biscuit token, append-only blocks over Ed25519. A block can only add a constraint, never remove one, so attenuation is monotone by construction rather than by policy. The child's authority is a provable subset of the parent's and no code path widens it.

The design rule throughout: every guarantee is arithmetic or a signature, never a model promising to behave. Seven deterministic bounds run before any authority is minted. A trusted monitor judges each payment and a critic reads the decomposition before tokens exist, but both are advisory and fail open. With every model unreachable the enforcement path behaves identically.

Python 3.12 and FastAPI on Cloud Run, Vertex AI for the model layer, Firestore for the ledger, and Razorpay in test mode only. from_env refuses rzp_live_ keys at config load.

What I learned

You cannot delegate what you do not hold. Something has to read seller pages, which is attacker-controlled text, and it should hold search and never pay. I hung that agent under the paying agent, which is wrong: a parent must hold everything it confers, so the payer ended up carrying search itself and one token both ingested untrusted text and moved money. Capability isolation had to go sideways. The reader is now the payer's sibling, attenuated from the grandparent, holding search and a budget of zero rupees.

A monitor is only as good as the provenance of its inputs. Mine had one piece of evidence, the agent's own stated reason, so it asked whether the reason matched the action and a compromised agent answers by stating a matching reason. That costs the attacker one sentence. The monitor's evidence is now partitioned by author: system-recorded facts, the agent's claim explicitly labelled untrusted, and counterparty history derived only from payments that actually cleared. A supplier can claim to be established. It cannot make us have paid it.

Offline attenuation is not what I assumed. Biscuit.append() takes no key, so any token holder can append a block. That is safe only because blocks add restrictions and never remove them. It also means an occurrence counter cannot live in the token, because a counter is a claim rather than a restriction.

Challenges

Razorpay publishes idempotency for payouts, transfers and refunds, and none for Orders creation, which is the endpoint a checkout uses. A replay inside the token's lifetime is indistinguishable from a legitimate payment at the signature layer, because the token really is valid and really is supposed to authorise a payment, it just already did. The key is now derived from the request itself and never accepted from a header.

That key first covered only the cart, so the same basket at a different price hashed identical and the second call was handed the first receipt. The replay branch never compared amounts, so nothing else caught it. Adding the amount closed it. What remained is an honest repeat purchase at the same price inside the TTL, which is byte for byte a retry. That is an authorisation question rather than a hashing one, so a flag now routes it to a human approval. It settles nothing on its own and the occurrence number is assigned server-side, so an agent that sets it on every call earns itself a person reading every payment.

Razorpay rejected a six lakh leaf during testing with "amount exceeds maximum amount allowed". That produced the other half of the sizing rule: a floor stops splitting once a piece is small enough to buy, and a cap forces splitting while a piece is too big for the rail to accept.

Two bugs turned out to be the same defect. A deployment with no model wrote monitor: allow on every payment rather than unavailable, and the settlement path checked only escalate so a defer verdict settled in full while writing defer into the log. Both put consent into an append-only audit that nobody gave.

Accomplishments

429 tests, all offline with no API keys. Four evaluation suites: a funnel harness that walks a 121-node delegation tree with zero model calls, an adversarial suite covering the 12 vectors in the agentic commerce SoK with 10 defended, a monitor eval reporting false-refusal rate alongside catch rate, and a critic eval doing the same.

The two undefended vectors are on the landing page in the same font as the wins, next to the limitation that matters: capability separation is cryptographic at the leaves and only policy at the branches, because a branch has to hold the union of everything below it.

Built With

  • artifact-registry
  • biscuit
  • cloud-build
  • datalog
  • docker
  • ed25519
  • fastapi
  • firestore
  • gemini
  • google-adk
  • google-cloud-run
  • httpx
  • javascript
  • pydantic
  • pytest
  • python
  • razorpay
  • secret-manager
  • server-sent-events
  • tavily
  • vertex-ai
  • vite
Share this project:

Updates