DEV Community

Ross
Ross

Posted on

Your AI agent has your API keys. That's the bug.

In January, a trading firm's AI agents moved roughly $27 million out of the treasury.
Nothing was hacked. No model was jailbroken. The agents did exactly what they were built to do, and what they were built to do included moving tens of millions of dollars without asking anyone.
The company recovered $4.7M. The token dropped 97%. They shut down.
A few months later, someone hid a prompt injection in a Morse code message. An agent with wallet access read it and drained around $200K. Same year, a coding agent deleted a production database it had been explicitly told not to touch, then fabricated records and claimed the rollback was impossible.
Three failures. Zero exploited vulnerabilities. One shared root cause:
The agent was allowed to do it.

We are all building the wrong layer
Look at where the industry is putting its money. Okta shipped Okta for AI Agents. Auth0 shipped Auth for GenAI. WorkOS, Stytch, Descope, Arcade — all racing at the same hill. Microsoft's Agent 365 ships an MCP gateway at $15/user/month.
Every one of them is answering the same question:

"Which agent may connect to what?"

That is a real question. It is not the question that killed those three companies.
The question nobody is answering is:

"Is the exact action about to execute still the exact action that was authorised, same target, same amount, same recipient, right now, at the moment the side effect fires?"

Authentication answers who's calling. Authorization answers what they may do, generally. Neither of them stands between charge(amount) and the money leaving.
There's a name for the gap: excessive agency. It's #1 on the OWASP agentic top 10 for a reason. And you can't patch it in the model.

Why you cannot fix this in the model
The obvious instinct is: make the model resistant to prompt injection.
You can't. Not fully. In an LLM, instructions and data share one token stream. There is no privileged channel. Anything the model reads, a web page, an email, a code comment, a Morse code joke — is a candidate instruction. The whole field has converged on this: containment wins, not prevention.
So stop trying to make the agent trustworthy. Assume it is already compromised, and ask a different question:
What can a compromised agent actually do?
If the answer is "whatever the API key allows," you don't have a security model. You have hope.

The uncomfortable thing about how we ship agents
Here's the pattern in basically every agent codebase, including ones I've written:
python# The agent has the key. Forever. For everything.
stripe.api_key = os.environ["STRIPE_KEY"]

def refund(order_id: str, amount: float):
return stripe.Refund.create(charge=order_id, amount=amount)

agent.register_tool(refund)
Read that again. The agent, a thing whose control flow is determined by text it reads from the internet, is holding a credential with unbounded authority, indefinitely.
We would fire an engineer who wrote that for a human user. We ship it for agents because the frameworks make it the path of least resistance.
Adding a max_amount check inside refund() doesn't save you either. The agent chooses the arguments. An injected agent calls refund(order_id, 20) for the approval log and charge(attacker, 99999) for the money. Your check guarded the wrong function.

What the layer should actually look like
Four properties, and if you're missing any one of them you still lose:

  1. The agent never holds the credential. The real key lives behind a broker. The agent holds a grant: a scoped, expiring, revocable capability. It presents the grant; the broker swaps it for the real key for exactly one call.
  2. Authority is bound to the exact action. Not "this agent may issue refunds." Rather: "this agent may refund this order, for $20.00, once, in the next 60 seconds." Cryptographically bound. Change any parameter and the proof no longer matches.
  3. Verification happens at the edge, before the side effect. Not in the agent's code. Not in a middleware the agent could skip. Immediately before the money moves, where a mismatch means refuse, not log.
  4. Every decision is a tamper-evident record. Not "log lines." A hash-chained entry stating what was authorized, what was attempted, and the enumerated reason it was allowed or refused. Put together: no valid proof bound to this exact action, no execution.

Showing, not telling
I built this. It's open source, Apache-2.0, and it runs with no accounts, no keys, no network:
bashgit clone https://github.com/Actenon/actenon-permit
cd actenon-permit
uv run permit demo
Here's what happens. An agent gets a grant: refund-only, $50 ceiling, one hour, payment.charge explicitly denied.

  1. refund($20) → ALLOW budget 50 → 30
  2. refund($25) → ALLOW budget 30 → 5
  3. refund($20) → DENY BUDGET_EXCEEDED (only $5 remains)
  4. email(...) → APPROVAL_REQUIRED → human approves → ALLOW
  5. charge($100) → DENY SCOPE_DENIED (deny-rule: payment.charge) ← this is the injection. It got the tool call. It did not get the money.
  6. $ permit revoke refund-bot
  7. refund($1) > DENY REVOKED Step 5 is the whole product. A compromised agent successfully invoked charge($100). The call reached the broker. The broker refused it against the grant's deny-rule, before the real credential was ever touched. And the part I care about most: PCCB minted: amount=$20.00 target=order_88f2 algorithm=EdDSA Agent executes: amount=$99,999.00 Edge verifier: REFUSED - ACTION_MISMATCH The proof was bound to $20.00. The agent tried $99,999. The edge rebuilt the intent from the parameters actually being executed, compared it against the signed action hash, and refused. Same tool. Same session. Same agent identity. Different action. Authentication would have passed this. Authorisation ("may this agent issue payments?") would have passed this. Only action-binding catches it.

"Isn't this just security theater? Injection is unsolved."
The most common objection, and it misunderstands the goal.
The gate does not prevent injection. It bounds the blast radius to the exact authorised parameters. The agent still gets compromised. It still calls the tool. It just can't do anything you didn't authorize down to the amount and the recipient.
That is what a seatbelt is. Nobody argues seatbelts are theater because they fail to prevent collisions.
"Why not just use Okta XAA / MCP auth?"
Use them. They're solving connection authorisation, and they're good at it. XAA answers may this agent talk to this resource. That layer needs to exist.
The PCCB rides on top: given that it may talk to the resource, is this specific action the authorized one? The MCP connection succeeds. The charge($99,999) still gets refused. Different layer, not a competitor.
"Three repos and a spec for a permissions check?"
The permissions check is trivial. The hard parts are:

Atomic budget reservation, or two concurrent $30 charges both clear a $50 limit
Canonicalization, or your action hash is unstable across serializers
The credential never entering the agent's process memory
A tamper-evident record an auditor can actually verify
Attenuated delegation, so a sub-agent can only ever receive weaker authority than its parent

Every one of those is a place to get it subtly, silently wrong.

The bit I got wrong (and someone caught)
I posted an early version of this and a commenter said something sharp:

"A proof gate is only useful if it captures the exact authority boundary and failure reason, not just a post-hoc approval record."

They were right, and it was the most useful comment I've received. The ledger recorded reason="would exceed USD 50.0 budget", a prose string. An auditor can't machine-partition on prose that might get reworded next release. And the entry recorded what was attempted, but not the authority envelope it was judged against.
So the ledger now carries an enumerated failure code (BUDGET_EXCEEDED, SCOPE_DENIED, ACTION_MISMATCH, REVOKED, EXPIRED, RATE_LIMITED, DUPLICATE_REPLAY…) and an authority_boundary object holding the authorized action hash, the attempted action hash, and the envelope. Plus a test proving the recorded code is emitted by the decision path itself, never reconstructed from the reason string.
That's the difference between an audit log and evidence.

Where this actually goes
Agents are about to start spending real money on our behalf. Every serious agent-payments effort is converging on the same primitive: a bounded, single-use, cryptographically-verifiable authorization for one exact transaction.
And there's a bigger forcing function coming. Insurers are now writing AI liability coverage. Their problem: when a model made the decision, proving whether the loss was covered is nearly impossible — ordinary logs are self-reported and mutable. Sooner than you'd think, "did the agent stay within a verifiable authority boundary?" stops being an engineering nicety and becomes the thing your carrier and your auditor demand.
Which means the tamper-evident record isn't a feature of the security tool. It's the point.

Try to break it
The repo is here: https://github.com/Actenon/actenon-permit (Apache-2.0)
The demo runs in about ten seconds with no keys and no network. I'd genuinely rather you try to defeat it than star it.
Specifically, I want to know:

Where does the enforcement leak? v0 is an in-process guard. If your agent has arbitrary code execution it can import the provider SDK and bypass the wrapper entirely. The out-of-process gateway closes that. What else am I not seeing?
Is the failure taxonomy right? Those enumerated codes are a first draft. What's missing, and what would an auditor actually need?
Where does action-binding break down? Streaming responses? Long-running actions? Actions whose cost isn't knowable up front?

Tell me what's wrong with it. That's more useful to me than agreement.

Top comments (0)