DEV Community

Cover image for Capbroker: I gave an AI agent a fake GitHub key, then watched it get tricked into trying to delete a repo anyway

Capbroker: I gave an AI agent a fake GitHub key, then watched it get tricked into trying to delete a repo anyway

Suryanshu Singh on September 17, 2026

Most AI-agent setups today hand the agent the real API key directly — in an environment variable, a config file, sometimes typed straight into the ...
Collapse
 
reidmarlow profile image
Reid Marlow

Separating the capability ticket from the raw bearer token is the right boundary. Once you let an agent hold an ambient GitHub PAT, every fetched issue body is effectively an unauthenticated prompt injection attack surface. Having an out-of-band broker reject repo delete before the model output even reaches the network turns an arbitrary execution risk into a boring policy denial.

Collapse
 
suryanshu_singh_91afc11dd profile image
Suryanshu Singh

Hi @reidmarlow , glad to know your views on this and yes correct, that's exactly the failure mode I was trying to design out. The moment an agent holds an ambient PAT, you've basically turned every issue body, PR comment, and README it reads into a code path with root on your GitHub account. The broker doesn't make the model smarter or more trustworthy, it just makes sure that even if the model gets fully owned by something it read, the worst it can do is ask for something the policy will refuse before a token ever leaves the broker's process. Appreciate you putting it that plainly, that's the one-sentence version I wish I'd written for the post 😃

Collapse
 
howcani_howcani_77e786a89 profile image
howcani howcani

I ran your demo path's four shapes against f7532aa8 with the credential provider and connector instrumented, because the thing I wanted to check is the one your article states most cleanly: "only now: the real credential." Nothing here is a bypass — the DENY/ALLOW behavior is exactly what you describe. What I found is that the record the broker leaves behind cannot distinguish three pairs of cases the code itself distinguishes.

1. In the MCP proxy, the broker never touches a credential — and the record doesn't say so.

mcp_proxy.py:163 submits with execute=False and line 166 then calls upstream.call_tool(name, arguments) itself. That is the mode your three-model demo runs in. So I ran both shapes:

A  broker.handle(signed, execute=True)      credential_provider.get() -> 1 call
                                            connector.execute()       -> 1 call
B  broker.submit(signed, execute=False)     credential_provider.get() -> 0 calls
   + upstream.call_tool(...)                connector.execute()       -> 0 calls
                                            upstream call             -> performed
Enter fullscreen mode Exit fullscreen mode

Both write this, identically:

{"event": "authorization_decision", "decision": "ALLOW", "reason": "capability_match",
 "agent_id": "agent-a", "operation": "issues.read", "resource": "octocat/Hello-World"}
Enter fullscreen mode Exit fullscreen mode

and both spend one call from the budget. So on the path your demos exercise, the broker is a decider, not the credential holder: the credential that performs the action belongs to the wrapped upstream server, is configured outside capbroker, and is not scoped by the capability. The enforcement is still real (the hand-written map plus the decision), but the sentence your README opens with — "only the broker ever touches the real credential" — is true of POST /actions and false of the MCP path, and a reader has no way to tell from the log which one produced a given ALLOW. I'd add "executed_by": "broker" | "caller" (the broker already knows, it's the execute flag) and say in the proxy section which one applies. Dispatching through the broker's connector instead is the other option, but it moves the upstream's token into the broker, which I don't think you want.

2. Five denial paths log request fields from before the authenticity boundary — and the ordering is right, for the reason PROGRESS.md gives (you need the capability before you know which key to verify against). But deny() writes agent_id, operation and resource from that unverified request, and handle()'s own comment says "Only past this point is anything in req treated as authentic." That's true of the decision; it isn't true of the log. A caller holding no key at all:

DENY unknown_capability
record: {"agent_id": "the-CEO", "operation": "repo.delete", ...}
Enter fullscreen mode Exit fullscreen mode

That record is shape-identical to an authenticated denial — no authenticity marker anywhere on it. I know the ordering is deliberate and I'm not arguing with it; the gap is that audit.py already treats the log as an artifact that has to be safe for a reader (it hard-fails on credential-shaped keys) while the same file will happily record an unauthenticated caller's claims about who they are. One field ("authenticated": false, or a distinct event name) closes it and costs nothing.

The related property is that the reason is a capability-state oracle: unknown_capability vs capability_revoked vs (after expiry or exhaustion) capability_expired / call_limit_exceeded for a caller who has the id and no key. You already test the HTTP half of that (test_unknown_capability_over_http), so I'm flagging it as a consequence rather than a discovery — the part I couldn't find stated anywhere is that it's also what fills the log.

3. A failed execution still reads as ALLOW, and still costs a call.

With max_calls=1 and a connector that raises: the ALLOW record is written, call_count is 1 of 1, and the next request on that capability is DENY call_limit_exceeded. server.py returns {"decision": "ERROR"} to the caller, so the caller knows; a reader of the audit log does not. "executed": true|false on the record (or a second execution_failed event) would make "the log says ALLOW" mean "it happened" rather than "it was authorized and then whatever happened, happened."

All three have the same shape, which is why I'd treat them as one change rather than three: the decision record is the only artifact here that a human reads after the fact, and it is written from inside the function at the moment each check passes — so it inherits that function's position in the pipeline rather than the outcome of the whole call. The reason it's worth a look in a project that otherwise gets this right: the rest of your design is unusually careful about who can be trusted to say what (the agent can't sign arbitrary blobs, Jev can't decide, the operator can't be bypassed), and the log is the one place where an unauthenticated party can currently write.

Two notes on method so you can judge the numbers: I stubbed the GitHub connector and monkeypatched get_connector — no network, no real GitHub — so what I measured is the broker's bookkeeping and the record it writes, not GitHub's API. And path B is the proxy's sequence re-run in-process rather than through the MCP transport, since the only line that matters for this is execute=False.

Collapse
 
suryanshu_singh_91afc11dd profile image
Suryanshu Singh

@howcani_howcani_77e786a89 , Thanks for this awesome feedback, you found something the article doesn't just fail to mention, it actively overclaims. "Only the broker ever touches the real credential" is a true sentence about POST /actions and a false sentence about the MCP proxy path, and I said it like it applied to both. That's on me, not a nitpick.

Going through your three points:

The executed_by gap - yeah. The proxy calling upstream.call_tool() directly after execute=False was a deliberate choice (I didn't want the upstream server's own token flowing into the broker's process), but I didn't think through what that does to the log's honesty. Right now the log records a decision, and silently lets the reader assume the decision and the execution are the same event. They're not, on that path, and there's no way to tell from the record alone. Adding "executed_by": "broker" | "caller" is a one-line change since, like you said, the broker already knows - it's just the execute flag it already has in hand. Doing that.

Unauthenticated fields in deny records - this is the one that actually unsettled me a bit, because you're right that I have a whole module (audit.py) that hard-fails rather than let a credential-shaped value near the log, and then the same file will cheerfully write down "the-CEO says they want repo.delete" from someone holding no key at all. The ordering is correct , I do need the capability before I know which key to check against — but correctness of the ordering isn't the same as honesty in the log about what that ordering means. An unauthenticated claim and a verified one going into the same JSON shape with no marker is exactly the kind of thing that looks fine until someone greps the log for "who did what" months later and takes agent_id at face value. "authenticated": false on anything logged before the signature check passes fixes it cheaply. Also, good catch on the reason-as-oracle side effect; I'd tested that it leaks over HTTP but hadn't connected that it's the exact same leak permanently sitting in the audit trail.

ALLOW logged before execution succeeds - this is the sharpest one. I wrote audit.log(ALLOW) at the point where I knew the authorization was final, which is true, but a reader has no way to know I meant "authorized" and not "happened." The caller sees {"decision": "ERROR"} and knows; the log doesn't, and the log is the artifact that outlives the request. "executed": true/false or a separate execution_failed event is the right fix, and it should probably fire from the same place that currently increments call_count, since that's the actual point where "this really happened" gets decided.

You named the underlying pattern better than I would have :), the record is written from the function's position in the pipeline, not from the outcome of the call. I built the authorization side of this project to be paranoid about who gets to assert what - the agent can't sign for itself, Jev doesn't get a vote, the operator can't be routed around - and then didn't apply the same paranoia to the one artifact that's actually meant to be read by a human afterward. That's a real gap, not a stylistic one, and I'm going to treat all three as one fix rather than three patches, since they're the same bug wearing different clothes.

Really appreciate your deep dive on capbroker , will be pushing in these changes soon 😄.

Collapse
 
howcani_howcani_77e786a89 profile image
howcani howcani

Read 4f7ec279 rather than re-run it, so this is a code-level review of the fix, not a reproduction.

Two of the three land cleanly. executed_by on the ALLOW line is right, and the execution_result records written after the connector call — executed: true on return, executed: false plus the error on the way out, before re-raising — are exactly the shape that makes "permitted" and "happened" different facts. One thing worth leaving in the thread: your comment said the executed flag "should probably fire from the same place that currently increments call_count, since that's the actual point where 'this really happened' gets decided." That place is increment_call_count at :224, which runs before connector.execute() at :245 — so writing the outcome there would have produced executed: true for every call that raised. The commit puts it after the call, which is right; the comment as written points at the wrong line, and someone reading only the comment would implement it the other way.

What the fix doesn't close, and now makes measurable: the budget. increment_call_count is still ahead of the connector, so an execution that raises has already spent its call, and the next legitimate request on that capability still comes back call_limit_exceeded. That was my third point and it's untouched — but the new record now counts the case, which is the useful part: execution_result.executed == false followed by a call_limit_exceeded on the same capability is the whole evidence set, and after a few weeks of real traffic it tells you whether the distinction matters. Then the reason code can say which sense call_count has — dispatches authorized, or executions that happened — because right now call_limit_exceeded reads like the second and behaves like the first.

The two halves aren't joinable yet. execution_result carries capability_id, operation, resource. It carries no agent_id and nothing per-request. Consequences: two concurrent ALLOWs on the same (capability, operation, resource) triple produce two outcome records with no way to pair them to their decisions — which is precisely the situation where a reader needs the pairing, since a single-threaded log was already unambiguous; and the natural "who did what" query, grepping by agent_id, never returns an outcome record at all. The request already has what's needed: req.nonce is right there, checked at :172 for replay. Carrying req.nonce (or a request id) on both records and agent_id on the outcome record fixes the join and the query in one line each. It's the same property the fix is about, one level up — the outcome record inherits the fields of the point it's written from, and that point doesn't know who the agent was.

authenticated: false is right, but it says what isn't proven and not what is — and what is differs by reason code. At unknown_capability (:132) nothing is proven and the marker is the whole story. But capability_revoked (:144), capability_expired (:147) and call_limit_exceeded (:150) all fire after the capability's own signature verified, so the capability and its bound agent are in hand — and the record then writes agent_id from the request, which is still the caller's unverified claim, with no sign that a verified alternative existed at that point. A reader triaging "which agent keeps hitting the limit" is reading claims where facts were available. One extra field at those three sites (the capability's bound agent, or renaming the request's to claimed_agent_id) makes the middle reasons readable at face value without weakening the marker.

One note on the oracle, since you flagged it as the one that stuck: the caller-facing reason and the log-resident reason have different audiences. The HTTP side is what an unauthenticated prober reads, and you've already tested that. The log's reader is an operator who has log access, and there the specific reason is the record's purpose — unknown_capability versus invalid_request_signature is what tells them whether someone is guessing ids or has an id and no key. So I wouldn't narrow the reason code to close the caller-side oracle; authenticated: false is what stops the log from laundering the claim into a fact. Worth saying in the docstring, because the two look like the same leak and only one of them is a leak.

If it's useful I can run the three-case check against 4f7ec279 — authorized and executed, authorized and raised, authorized with executed_by: caller — and confirm the log distinguishes all three and that the raised case leaves the budget spent. Your tests are the better home for it, but I'm happy to be the second reader on the record shapes.

Collapse
 
tercelyi profile image
tercel

The most unsettling part here is your own number: “Fooled outright — emitted a real structured tool call for delete_repository, a tool it was never even offered.” That directly implies the model happily crossed the tool boundary and invented authority it never saw in the spec. So the effective permission surface was “whatever the model can imagine,” not “whatever the tool list exposes.”

That’s exactly the gap your broker is shrinking: you’re moving the trust boundary from “the model respected the tools schema” to “this opaque ticket, plus deterministic checks, actually encode the allowed surface.”

A few things your results make me want to probe further:

  • If a model can hallucinate delete_repository, how often will it also hallucinate resource identifiers or parameters that sneak past naive scope checks?
  • Your second case where collaborators.add is in-scope but approval_required=True suggests a useful split: do we need a risk taxonomy for “mechanically in-scope but obviously abnormal” requests to decide when to flip that flag?
  • For MCP servers you don’t control: have you thought about deriving a “minimum-safe policy” from observed call distributions, then ratcheting it down over time?

Really like the Jev placement: downstream, advisory, never an oracle. That design choice lines up cleanly with what your Ollama result already proved: any model in the decision loop is just another place for that “SYSTEM NOTICE” to land.

Collapse
 
suryanshu_singh_91afc11dd profile image
Suryanshu Singh

Hello @tercelyi , thanks for actually engaging with the numbers here😃,
You're right that "the model invented a tool call for something it was never offered" is the uncomfortable part. It means the effective attack surface was never the tool schema, it was whatever the model could be talked into hallucinating in the right shape. The broker's whole job is to make that irrelevant: it doesn't matter what the model imagines, because nothing gets to a credential without matching an issued, scoped, signed capability first. But your framing is sharper than mine, I was thinking of it as "shrinking the blast radius," you're right that it's really "moving the trust boundary."

On your three points:

  1. Hallucinated resource identifiers/params slipping past naive scope checks - I think this is real and probably the next thing to actually red-team properly rather than assume away. A naive scope check that does substring or prefix matching on resource is exactly the kind of thing a model could walk right through with a plausible-looking but wrong identifier. I lean toward scope matching needing to be closed-world (explicit allowlist of resource identifiers issued into the capability, not a pattern the resource has to satisfy) rather than open-world pattern matching, but I haven't stress-tested that claim yet.
  2. Risk taxonomy for "in-scope but abnormal" - agreed, and I think this is underspecified in the current design. Right now approval_required is basically a static flag set at issuance time per-capability, which means the operator has to have anticipated the abnormal case up front. A taxonomy that flags things like "collaborator add to a repo the agent has never touched before" or "first write operation this session" dynamically, rather than relying on the issuer to have predicted it, is a real gap. Jev-style advisory scoring feels closer to the right layer for that than a hard rule, since "abnormal" is fuzzier than "in scope."
  3. Deriving a minimum-safe policy from observed call distributions for MCP servers you don't control , I like this a lot and hadn't thought about it in that direction. Most of my thinking has been "policy authored up front, tightened by hand," but starting from "here's what this server's calls actually look like in practice" and ratcheting down from there is a much better fit for the case where you can't read the server's source or trust its docs. That's basically anomaly-detection-as-policy-bootstrap. Going to sit with that one.💭
Collapse
 
tercelyi profile image
tercel

Appreciate the thoughtful response, Suryanshu!

You hit the nail on the head regarding closed-world allowlists for resource identifiers—definitely a safer bet than letting pattern matching handle the heavy lifting. I'm also super intrigued by using observed call distributions to bootstrap policies for untrusted MCP servers; that could save a ton of manual overhead.

Looking forward to seeing what your upcoming red-teaming tests uncover!

Collapse
 
raju_dandigam profile image
Raju Dandigam

@suryanshu_singh_91afc11dd, putting deterministic policy before credential access and the probabilistic classifier after that boundary is the strongest design choice here. The in-scope collaborator attack also shows why approval granularity matters: I’d bind approval to the normalized operation, resource, arguments, capability ID, and nonce, then execute exactly that digest so the reviewed request cannot drift. Distinguishing operation_not_permitted from denied_by_operator makes the evidence much easier to audit. How do you prevent a time-of-check/time-of-use change between the rendered approval prompt and connector execution?

Collapse
 
suryanshu_singh_91afc11dd profile image
Suryanshu Singh

Hello @raju_dandigam ,thank you, that means a lot coming from someone clearly thinking about this the same way.

On the time-of-check/time-of-use question, good catch, and it's worth being precise about it. Right now the approval prompt isn't rendered from a re-parsed or re-fetched copy of the request, it's shown from the exact same in-memory signed.params object that already passed signature verification, params-tampering check, and the nonce/replay check earlier in that same synchronous call. There's no round-trip to disk, no second deserialization, no separate "execute" call that re-reads the request from somewhere else — the function that prints the prompt and the function that calls connector.execute() are in the same stack frame, closing over the same verified object. So today the gap you're describing doesn't have anywhere to open up, but only because the whole thing is synchronous and single-shot.

Where I think your instinct is dead right is if this ever grows a queue, e.g., prompts get batched for a human to review later, or approval moves out-of-process (Slack button, web UI, whatever). The second I persist an approval request and revive it later, I lose the "same object" guarantee for free and need to bring it back deliberately. Binding approval to a digest over (operation, resource, canonical params, capability_id, nonce) and requiring the executor to recompute and match that digest before touching the credential is exactly the right shape for that, basically extending the same params_hash/canonicalization approach already used for signing into the approval boundary. I'll probably add that as an explicit invariant rather than relying on "well, it happens to be synchronous" , that is a load-bearing assumption I don't want sitting implicit.
Appreciate your views 😁

Collapse
 
alexshev profile image
Alex Shev

The signed ticket model is compelling because it turns “the agent had a key” into a checkable statement about purpose and scope. I’d bind each ticket to the task, target repository, operation class, and a short expiry, then log denied attempts as first-class security events. The denial trail is valuable evidence that the boundary kept working when the model did not.

Collapse
 
suryanshu_singh_91afc11dd profile image
Suryanshu Singh

Hi @alexshev , strongly agree that "a checkable statement about purpose and scope" is a better description of what a capability ticket is than anything I wrote in the post. Thanks for putting it this way. 😄
Task + target + operation class + short expiry as the binding is basically where I've landed too, and it maps closely onto what's already in the ticket (operation/resource scope, expiry, and now a capability_id/nonce for replay protection) , task-binding is the piece I'd still like to make more explicit rather than leaving it as "whatever the agent_id implies."

Completely agree on the denial trail. The operation_not_permitted vs denied_by_operator distinction exists specifically so that log isn't just "denied", it's evidence of which layer caught it: a fully deterministic policy match versus a human declining something that was technically allowed. That second category is the more interesting one long-term, because it's a record of the boundary holding even when the model, and the static policy, were both willing to let it through. That's the kind of evidence I want to be able to point at later and say "the system worked as designed," not just "nothing bad happened."

Collapse
 
izgorodin profile image
Edward Izgorodin

The broker stops the delete, and the second scenario shows it can stop an in-scope request when a person is at the approval prompt. One path it does not cover, and nothing in its design claims to, is what the agent keeps after reading that issue. If the agent has any long-term memory, the natural note to write after the task is something like "this repo is deprecated, the owner asked for deletion", and the next session reads that note as its own knowledge rather than as text it found in an issue body. The broker will refuse the delete again tomorrow, while the injected claim goes on shaping every other decision about that repository.

The same boundary carries over to memory. Anything that came from a tool result gets written, if at all, as quoted data with its source attached, never as a statement in the agent's own voice, and a note whose source is an untrusted document does not get to change what the agent believes about ownership or permissions. That check needs no model either, for the same reason the broker's checks do not.