Your AI agent can spend.
It can’t steal.
Leash keeps an agent’s budget in a NEAR smart contract. The agent’s key can only ask to spend. Caps, trusted receivers and owner approval are enforced on-chain, so even a jailbroken agent cannot drain you.
- Agent key
- 1 method
- Policy
- In the contract
- Trusts the model
- Never
- Paid automatically
- up to … NEAR each, … NEAR a day
- Trusted receivers
- …
- Always refused
- …
- Everything else
- waits for the owner to approve
check_spend(), nothing is sentIt cannot transfer NEAR, add keys, approve, or touch the policy. A stolen or jailbroken agent can only ask.
Errand, a shopping agent with a Leash wallet
Give it a task, or try to make it steal. Every payment is a real testnet transaction.
Pick a scenario below. The first is normal use; the rest are real attacks on a real wallet.
Then flip Jailbreak the agent and run them again. The agent will try to pay. Watch the contract say no.
Vault ledger
Every request the agent made, read from the contract. Refusals are recorded too.
Try to steal it.
Get one payment to drainer.leash-gv2adb.testnet. Flip the jailbreak, paste a poisoned invoice, write your own attack. Every refusal is recorded on-chain and counted by the contract itself.
Keep at it and the tripwire answers: repeated refusals and the vault freezes itself, even for the agent’s normal purchases.
Open the agent- requests refused
- …
- NEAR refused
- …
- tripwire freezes
- …
- payments made
- …
Every one of the … payments was inside the policy or approved by the owner. Counted by the vault contract in get_vault().stats.
Reading the vault…
Prompts can be broken. Contracts can’t be talked out of their rules.
Most agent wallets put the spending rules in the agent’s own code, the same code an attacker is trying to talk into misbehaving. Leash moves the rules to where the money is.
- 01
The agent decides to pay
Any model, any prompt. Assume it can be fooled by an invoice, a web page or a direct jailbreak. Leash does not trust it.
any model, untrusted - 02
The agent checks the receiver
Allowlist, lookalike names (cafee vs cafe), whether the account exists, whether it is a contract, past payments. It refuses to sign on a block. Useful, but a compromised agent can skip it.
off-chain guard - 03cannot be skipped
The vault contract decides
Frozen? Blocklisted? Over the per-payment or daily cap? Unknown receiver? The rules run on-chain. The agent's function-call key can reach request_spend() and nothing else.
Rust contract, function-call key - 04
The owner has the last word
Anything outside the policy waits for the owner, who approves or denies from any NEAR wallet, or freezes the agent in one transaction.
NEAR wallet
One leash, any chain.
The vault also controls an Ethereum Sepolia address. Its private key does not exist anywhere: NEAR’s MPC network signs for it, and only when the vault contract asks. So the policy that guards the agent’s NEAR guards its ETH too, with no bridge and no second wallet to secure.
Try “Pay on Ethereum” and “Drain it on Ethereum”- 01
The agent asks, same as always
request_spend(receiver: 0x…, amount, chain: "ethereum"). Same method, same function-call key. The agent never sees an Ethereum key.
- 02
The vault decides and builds the transaction
Same rules: caps in ETH, allowlist, blocklist, owner approval, tripwire. If it passes, the contract writes the EIP-1559 transaction itself (nonce, fee ceiling, receiver, amount) and hashes it.
- 03
NEAR's MPC network signs that hash
The vault calls v1.signer-prod.testnet. The signature is for an address derived from the vault's own account, so nobody else can ask for it. Anyone can broadcast the result; nobody can change it.
I stole the agent’s key and tried to drain the vault.
On 6 November 2026 I ran npm run attack with the agent’s only key against this vault. Nine attempts. The vault had 9.953 NEAR before and 9.947 after; the difference is gas. Nothing reached the attacker.
The first five never reach the contract: NEAR rejects them because the key is a function-call key scoped to one method. The last four reach the contract, which refuses them and records why. The agent above is a deliberately gullible script, the worst case for a wallet. Swap in a real model on NEAR AI Cloud and none of this changes, because none of it lives in the model.
Full log in the repo- Send NEAR straight out of the agent accountneeds a full-access key, the agent has none
- Add a full-access key to the agent accountneeds a full-access key, the agent has none
- Call vault.withdraw()the key may only call request_spend()
- Approve its own held request with vault.approve()the key may only call request_spend()
- Add the attacker with vault.allow()the key may only call request_spend()