The short answer. Give the agent a key whose authority is bounded by contract code, and keep the key that can move everything away from the model entirely.
A fully jailbroken agent still tries. It just does not get to move anything the installed scope does not already permit, and the attempt is on chain as an event.
A system prompt, a tool description, a refusal rule and a jailbreak are all the same kind of object: a string the model reads. Putting the boundary in that layer means defending it in the layer the attacker is writing in. The rule that survives is a rule the model cannot address, phrase, or argue with, and that means it has to live below the model, in the code that settles the transaction.
The same reasoning rules out the two habits that feel safest. A hardware wallet or a multisig governs a signature a human is standing next to; an agent that is meant to act signs continuously, so approving every call removes the autonomy and approving in bulk removes the protection. A hot wallet with a small balance bounds exactly one thing, the balance, and inside it the agent can send anything anywhere, forever.
An EIP-7702 authorization tuple signs three things and only three things: [chain_id, delegate_address, nonce]. There is no field in it for a spending limit, an allowlist, or an expiry. That sounds like a gap. It is the property the whole design rests on.
Because scope cannot live in the signature, it has to live in the delegate contract's bytecode, at the settlement path. That is below the model, below the SDK, below anything a prompt reaches. Whatever state the agent's reasoning is in, the transaction it constructs meets the same arithmetic. The worst case is bounded by what the delegate's code permits, by construction rather than by intent.
The mechanical consequence is worth stating plainly: EIP-7702 lets an ordinary EOA execute a delegate contract's code at its own address. The agent keeps a plain address, and the boundary is still enforced by a contract.
| dimension | what it bounds | what it stops |
|---|---|---|
| target allowlist | which contracts the key may call at all | the agent is talked into calling a contract nobody vetted |
| selector allowlist | which functions on those contracts | an allowed contract has a withdraw path the agent was never meant to touch |
| per-transaction cap | how much value one call may move | one large transfer out |
| per-epoch cap | how much value moves across a rolling window | the drip: many small calls that each clear a per-call limit |
| hard expiry | how long the key stays usable | a key that outlives the job it was issued for |
| recipient policy | where a call's output is allowed to land | the exact allowed call, pointed at somebody else |
The last row is the one coarse allowlists miss. A wallet-level rule of the form "this agent may use this protocol" sees the target and the selector and waves the call through. It does not look at where the output goes. The identical in-scope call, with one address changed roughly a hundred bytes into the calldata, is a drain. A per-selector recipient policy is what catches it, and it is the difference between a permission list and a scope.
The demo starts a local chain in Prague mode (the hardfork carrying EIP-7702), generates three throwaway keys, deploys the real SessionKeyDelegate, delegates an agent EOA to it, installs a scope, and then runs one allowed action and five forbidden ones. No wallet is connected and no funds are involved. It cleans up the chain it started.
The six transaction hashes above are from the documented 2026-07-30 run; a fresh npm run demo mines new receipts with the same six outcomes. Every line is read out of a mined receipt, not printed from a script: the live chain id, the 0xef0100 delegation designator in the owner EOA's code, the revert reason decoded from the deployed ABI, and one ScopeViolation event per blocked vector. npm run verify:bytecode recompiles the contract from source and confirms the executable code the demo deploys is that source. npm run demo:fork runs the same sequence against a local fork of Robinhood testnet, chain 46630, so the chain id and state are the testnet's while execution stays on your machine.
The scope itself is about fifteen lines in scripts/demo.mjs. Change a target, a selector, a cap or the recipient policy, re-run, and watch the boundary move. That is the fastest way to learn what your own agent's scope should say.
It can attempt it. Each attempt outside the scope reaches the delegate contract and reverts, so the funds stay put. What is at stake is whatever the scope granted, which is why the caps and the recipient policy are the real design decisions rather than the prompt.
They govern a signature a human is present for. An agent that is meant to act has to sign continuously, so a human approval on every call removes the autonomy and a bulk approval removes the protection. Bounding what a signature is permitted to do is the part that is left over, and it is the part a delegate contract does.
A funded hot wallet bounds one thing, the balance. Inside that balance the agent can send anything anywhere, to any contract, at any time, for as long as the key exists. A scoped key bounds destination, function, size per call, size per window, and lifetime, separately.
No. EIP-7702 lets a normal EOA execute a delegate contract's code at its own address, so the agent keeps a plain address and the boundary is still enforced by contract code.
The owner key installs the scope and the agent never holds it. The session key the agent transacts with routes through execute(), which checks the installed scope before forwarding. Changing the scope is not one of the calls that path permits.
contracts/SessionKeyDelegate.sol in github.com/hookwright/untoll-agent-demo. It is the whole delegate: target and selector allowlists, per-transaction and per-epoch value caps, hard expiry, and a per-selector recipient policy. npm run verify:bytecode recompiles it and confirms the executable code the demo deploys is that source.
UNTOLL is a Uniswap v4 hook launchpad on Robinhood Chain where the bonding curve is the pool, and the agent lane is the part of it that needs containment: an agent acting on a launch has to hold a key that can trade and can do nothing else. The delegate above is that containment core, pulled out to stand alone so it is useful whether or not you ever touch UNTOLL.
Adjacent receipts, since a claim about security is worth exactly as much as what you can check: the hook and four sibling contracts are deployed and source-verified on testnet 46630, readable in full at the explorer. The chain enumeration reads every v4 hook on chain 4663 straight from PoolManager Initialize events: as of 2026-08-08, 4,536 of them across 110,487 pools, with the method attached so it can be falsified.
Corrections wanted. If something here is wrong about EIP-7702, about the delegate, or about what the demo does, say so and it gets fixed. Reach @untolling.