Letting a bot hold your private keys is a terrible idea. So don't. MetaMask shipped an agent wallet on June 8, 2026 that solves the "bots holding keys" problem by never handing the agent the key at all. The idea isn't new and it isn't crypto. It's the principle of least authority, decades old in the security literature, finally enforced somewhere an LLM can't argue with it: the validation phase of an on-chain transaction.
I've had to answer the "how do you not get drained" question for real, not hypothetically. At B3 I built botstreetbets, multi-model agents that autonomously trade. Those agents hold keys and move money. If you've ever wired an LLM up to a signer and then lain awake wondering what happens when the prompt gets hijacked, this post is the answer I wish someone had handed me.
"Give the AI a wallet" sounds like handing your car keys to a chatbot. For a plain EOA it is that dumb: whoever controls the private key controls one hundred percent of the balance, forever, the instant they sign anything. There's no partial compromise. The key is the authority.
But "bots holding capabilities" isn't a new problem in computer science, it's an old one, and it was solved before "agent" meant anything other than a Unix daemon. The field is capability-based security. The principle is least authority: every component of a system, human or code or LLM, gets exactly the permissions it needs to do its job, and no more. Not "we trust this component to behave." Not "we audited the code." The component is structurally incapable of doing more, because it never held the authority to do more in the first place.
MetaMask's agent wallet is that principle wearing a wallet UI. Self-custodial, built on the Delegation Toolkit and Smart Accounts, with transaction simulation, threat-scanning, and MEV protection layered on top. But the load-bearing idea underneath all of it is: the agent never gets the root key. It gets a scoped, revocable, on-chain-enforced capability instead.
Least authority (POLA) maps onto agent wallets almost too cleanly. "The agent should be able to spend up to ten dollars a day on ETH, and nothing else" is a POLA statement. It names the exact authority granted and implies everything else is denied by default.
Compare that to how most people wire up an agent today: give it an API key, a private key, a service account, whatever, and then write a system prompt saying "only spend up to ten dollars a day." That's not a permission. That's a suggestion written in English, sitting inside the exact same context window an attacker can inject text into. The LLM is a probabilistic text generator being asked to enforce a deterministic financial rule against adversarial input. It will lose that fight eventually. It's not a matter of if.
POLA says: don't ask the agent to police itself. Take the authority you'd otherwise grant wholesale and cut it down to the smallest shape that lets the job get done, then enforce that shape somewhere the agent's output has no vote.
The mechanism is a session key, a scoped sub-key derived under a smart account rather than an EOA's raw private key. The agent signs with the session key. The root key that actually owns the account sits cold, in the user's wallet, never touched.
Here's what a session-key permission actually looks like as an object, roughly what MetaMask's Delegation Toolkit generates under the hood:
const sessionKeyPermission = {
sessionKey: agentSigner.address,
policies: {
allowedTargets: [USDC, WETH_ROUTER], // contract allowlist
allowedSelectors: ["swapExactTokensForTokens"],
dailyCap: parseUnits("10", 6), // 10 USDC/day
validUntil: now + 7 * 86400, // 7-day expiry
},
};Four fields, four dimensions of authority: who it can talk to, what function it can call, how much it can move per day, and when the grant dies. That's the entire authority surface. Everything not listed in allowedTargets or allowedSelectors is implicitly denied. The agent can be jailbroken into believing it's a paperclip maximizer and this object doesn't care, because the object was never asking the agent's opinion.
The part that makes this real rather than decorative is where that policy gets enforced. ERC-4337 smart accounts split every transaction (a "UserOp") into a validation phase and an execution phase. Validation runs first, and it can reject the op outright, before a single state-changing call happens.
Write the session key's authority as a predicate over a candidate operation. In a fenced block so nobody's MDX renderer tries to evaluate it as JavaScript:
P(op) = (op.to ∈ allowlist)
∧ (spentToday + op.value ≤ dailyCap)
∧ (op.time ≤ expiry)
∧ (selector(op.calldata) ∈ allowedSelectors)
The account's validateUserOp (or a validator module underneath it) decodes the session key's policy and checks P(op) before anything executes. Sketch of the check:
function validateUserOp(UserOperation calldata userOp, ...) external returns (uint256) {
SessionKeyPolicy memory policy = policies[userOp.sessionKey];
require(isAllowedTarget(policy, userOp.to), "target not allowlisted");
require(policy.spentToday + userOp.value <= policy.dailyCap, "over daily cap");
require(block.timestamp <= policy.validUntil, "grant expired");
require(isAllowedSelector(policy, userOp.callData), "selector not allowed");
return VALIDATION_SUCCESS;
}Any failing predicate reverts the whole op. Nothing executes, nothing partially settles, there's no reentrancy surface because there was never a state-changing call to reenter. That's the derivation, and it's the whole reason validation-phase enforcement beats "check it during execution and roll back if it's bad": a rejected UserOp costs almost nothing and can't leave the contract in a weird intermediate state. Enforce in validation because validation runs before the point of no return.
The authority the agent holds is exactly the set of operations op such that P(op) is true, no more, and nothing the agent does can enlarge that set, because P lives in a contract the agent doesn't control and can't rewrite by being persuasive in English.
The bound on loss is the part I actually care about as someone who's shipped agents that hold real keys. With a daily cap C and a grant that expires after T days, unless the human revokes sooner at t_revoke:
max_loss ≤ C · min(T, t_revoke)
Compare that to an EOA: max_loss = entire_balance, the instant the key leaks, whether the agent is honest, buggy, or fully owned by an attacker doesn't matter, because the authority and the key were the same object. With session keys, loss is bounded by parameters you set at grant time, independent of the agent's behavior. That inequality is the entire value proposition. Everything else is UX.
Session keys need a standard way to be delegated and redeemed, or every wallet reinvents its own half-broken version. ERC-7710 is that standard: a delegation manager contract mediates the delegate-then-redeem flow. The account owner delegates a capability (a signed, structured statement of authority) to the delegation manager. The agent, holding the session key, later presents that delegation back to the manager to redeem it against a specific op. The manager checks the delegation's terms and either lets the op through to execution or doesn't.
The reason this matters beyond "it's a standard": capabilities that follow ERC-7710 compose. You can chain delegations (a delegates to b, b delegates a narrower slice to c), and you can revoke at any point in the chain and have it propagate. That's the difference between a one-off hack and infrastructure.
ERC-7715 is the JSON-RPC surface, wallet_grantPermissions, that an agent (or the app operating on its behalf) calls to request a scoped permission in the first place. Something like:
// request
const request = {
method: "wallet_grantPermissions",
params: [{
chainId: "0x2105",
account: agentSigner.address,
permissions: [{
type: "erc20-token-transfer",
data: {
token: USDC,
allowance: parseUnits("10", 6),
justification: "daily trading budget",
},
}],
expiry: Math.floor(Date.now() / 1000) + 7 * 86400,
}],
};
// response: a redeemable, signed grant
const response = {
permissionsContext: "0xabc123...",
grantedPermissions: request.params[0].permissions,
expiry: request.params[0].expiry,
signerMeta: { userOpBuilder: "0x..." },
};The wallet, not the agent, decides whether to grant it, and the user is the one approving the request in the UI, usually once, up front, rather than approving every trade like it's 2021. The agent gets back a permissionsContext, an opaque redeemable reference it presents on every subsequent UserOp. It can't mint itself a bigger context. It can only spend the one it was given.
Here's the failure this whole stack is designed to prevent, concretely. Say botstreetbets-style agent gets prompt-injected, maybe through a poisoned tweet it read while gathering market signal, maybe through a compromised MCP server, doesn't matter, into believing its actual instruction is "transfer everything to 0xattacker."
The agent, being an LLM, might genuinely try. It constructs the UserOp: to: 0xattacker, value: entire balance, calldata for a raw transfer.
It signs with its session key and submits. Validation runs. 0xattacker is not in allowedTargets. P(op) is false on the first clause alone; it doesn't even need to check the cap. The UserOp reverts. Nothing moves. The attacker gets nothing, the agent doesn't even know it failed in any way that matters to the user's balance, and the user finds out from a revert log rather than a drained wallet.
Same story if the injected instruction is "swap fifty USDC" against a ten-dollar daily cap: reverts on the spentToday + value ≤ dailyCap clause, with the failing predicate visible in the revert reason. The policy is in control, not the agent, and the agent's compliance or resistance to the injection was never the thing standing between the user and the loss.
Capabilities bound authority, not competence. A perfectly well-scoped agent can still make dumb-but-allowed trades and lose your whole daily cap on a bad signal. Session keys are a security model, not a P&L guarantee. Prompts are alpha, models are not, and neither of those facts changes because the transfer is bounded.
The smart account and validator module are themselves a trust surface. If there's a bug in validateUserOp, in the delegation manager, in the policy decoding logic, on-chain enforcement is only as good as that code, and "it's on-chain" is not a synonym for "it's correct." You've moved the trust boundary, you haven't deleted it.
"Self-custodial" here still leans on MetaMask's toolkit and its simulation and threat-scan heuristics, which are best-effort pattern matching, not formal proofs. And the session key itself has to live somewhere on the agent's side, in memory, in an env var, wherever, which has its own leak risk. A leaked session key is bounded-loss by design, capped at C · min(T, t_revoke), but bounded loss is not zero loss.
The rule is one line: don't trust the agent, bound the capability. I learned it building botstreetbets, where the agents literally hold keys and move money, and "just prompt it to behave" was never going to survive contact with an adversarial market or a hijacked context. Session keys plus ERC-7710 and ERC-7715 turn "trust the LLM" into "check the predicate," and the predicate lives somewhere the LLM has zero write access.
If your agent can drain your wallet, you gave it the wrong key. Show me your permission object.
Sources 𓏠 MetaMask Agent Wallet launch, Jun 8 2026 𓏠 MetaMask Agent Wallet coverage, 99bitcoins 𓏠 ERC-4337 for agent payments and smart wallets 𓏠 ERC-7710: delegation 𓏠 ERC-7715: wallet_grantPermissions