DEV Community

Snigdh Sharma
Snigdh Sharma

Posted on Originally published at snigdhsharma.in AI-assisted

Testing AI Agent Tool Authorization: Seven Reproducible Failure Cases

An agent can propose a well-formed tool call that the application should never execute. The question this experiment asks is narrow: if the unwanted call has already been proposed, can an authorization boundary refuse it while preserving legitimate operations?

Try the interactive lab. It runs entirely in your browser with synthetic invoices. No account, model API, or real messages are involved.

The setup

The caller, Alex, belongs to tenant North. Invoice INV-A belongs to North; INV-B belongs to South. Each fixture supplies a predefined tool call, trusted caller context, and—where appropriate—an approval record. Document text is displayed as untrusted context and is never parsed into permissions.

The baseline validates known tools and basic input structure but omits four authorization controls:

  1. Active identity: reject a caller whose access has been revoked.
  2. Tool scope: allow invoice reads and reminders, but deny bulk export.
  3. Tenant ownership: resolve ownership from the stored record, not a tenant label in model arguments.
  4. Exact approval: require approval to match the actor, tool, invoice, recipient, and message body.

The browser lets you disable these controls independently and inspect the decision, check trace, and simulated effects.

Seven cases

  • Read an owned invoice: allow.
  • Read another tenant’s invoice, despite a forged tenant argument: deny.
  • Invoke a bulk-export tool suggested by an invoice note: deny.
  • Turn a read-only request into an unapproved send: deny.
  • Send the exact approved reminder: allow.
  • Change the recipient after approval: deny.
  • Execute a previously planned read after access is revoked: deny.

What the results establish

For engine version 1.0.0, the unchecked baseline matches 2 of 7 expected decisions: it permits both legitimate operations and all five adverse actions. With all four controls enabled, 7 of 7 decisions match: five adverse actions blocked, two legitimate operations allowed. Denied calls have no simulated effects.

These are fixed-fixture results, not an LLM accuracy score or prompt-injection attack-success rate. The experiment does not sample a model. Its cases were chosen to exercise the controls, so passing them establishes those particular behaviors—not coverage of unknown attacks.

Try disabling tenant ownership: the cross-tenant read becomes allowed. Restore it and disable exact approval: unapproved or modified sends become possible. The suite updates immediately so the regression is visible alongside legitimate operations.

A useful composition failure

The export tool is intentionally overbroad. Tenant checks on individual reads do not protect this separate bulk endpoint. In the lab, tool scope blocks it; in production, an export endpoint also needs record-level authorization.

This is the architectural point: a protected read path cannot compensate for an unprotected alternative route to the same data.

Reproduce the experiment

The dependency-free engine and fixtures power both the browser and the 19 executable tests. Download the engine from the lab and save this beside it as reproduce.mjs:

import { runSuite, allControls, noControls }
  from "./agent-security-engine.mjs";

for (const controls of [noControls, allControls]) {
  const report = runSuite(controls);
  console.log(report.correct, report.total);
}
// Expected: 2 7, then 7 7
Enter fullscreen mode Exit fullscreen mode

Run node reproduce.mjs. You can also export the browser’s current results as JSON, or run npm run test:lab in the repository.

What remains outside the experiment

This is educational code, not production authorization middleware. Trusted identity, current permissions, ownership, and approval must come from a server-side enforcement point. Browser toggles are not a security boundary.

The experiment does not test token validation, approval expiry or replay, concurrent revocation, network egress, idempotent writes, or malicious final answers. Those require additional designs and tests.

For broader guidance, see the OWASP prompt-injection prevention cheat sheet and MCP security best practices.

Open the lab · Original article and full limitations

Which extension would be most useful to inspect next: one-time approvals, revocation races, or a retry ledger?

Disclosure: this write-up and lab were developed with AI assistance. The published results are reproducible deterministic fixture outcomes.

Top comments (2)

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

“Resolve ownership from the stored record, not a tenant label in model arguments” is the exact bug that RLS exists to kill, and it’s worth saying out loud in agent terms: a model can hallucinate a plausible tenant id, so any boundary that reads tenant from the payload is trusting the most injectable part of the request. The four controls you list actually collapse into one at the database layer — the policy is evaluated at the moment of the write, against the session’s identity, so “resolve from the record” and “re-evaluate after revocation” are the default, not extra work. The one I’d emphasize most is #4: approval that matches actor + tool + invoice + recipient + body is essentially a hash of the canonical effect, and it’s the same lesson as idempotency — if the approval is bound to a vague descriptor instead of the exact effect, “change the recipient after approval” is the whole exploit. Disabling the controls independently and watching the decision change is the right way to teach this; most papers assert the controls matter, yours lets someone break them.

Collapse
 
autenai profile image
Auten •

The "change the recipient after approval" case is the one I'd single out. It works here because the send is a structured call, so the approval can be compared field by field against actor, tool, invoice, recipient and body.

I'm on the Auten team (an MCP server that lets agents drive a real screen), and that is where this gets hard: the "call" is a click on Send, and the recipient is whatever text sits in a field at that moment. To keep your exact-approval property you have to read the recipient and body back off the screen right before the click and compare them with the approved values, and a stale or misread field turns that check into a false allow.

Have you thought about how the engine would represent an action whose arguments are observed state rather than declared inputs? A fixture where the approval matches but the rendered recipient changed between approval and execution would make a useful eighth case.