INDEPENDENT NOTES. PRACTICAL KNOW-HOW.Follow via RSS
← All articlesCloud Security

I pentested my own AI-agent guardrails. One character got a DELETE through.

An open-source policy library for MCP tool calls passed its own unit tests, then failed a real attack. What the bug was, why guards and databases disagree, and how to try the fix.

Cloud Security cover reading 'Block the call. Explain why.', with a padlock inside a shield and the command opa eval data.mcp.main.decision.

An AI agent that can call tools can open pull requests, write files and run SQL. Whether a given call should run is an authorization question. I didn't want the answer to depend on the model behaving well, so I built a library of policies that decide it before the call reaches the server.

Then I attacked my own library. A single non-ASCII character got a DELETE through a guard that was supposed to be read-only. This is what happened, and what it teaches about any guard that reads text before something else runs it.

What the library does

mcp-policy-library is a set of OPA policy packs for MCP (Model Context Protocol) servers. A gateway sends each tool call to OPA as JSON. OPA answers allow, deny, or "ask a human", and every refusal carries a stable reason code. There are packs for the filesystem, GitHub and Postgres servers.

agent --> gateway --> OPA --> decision --> gateway --> MCP server
                    (the policy packs)

It is policy content only. It is not a gateway or a proxy, so you pair it with whatever already sits between your agents and their tools. Here is the smallest useful demo, an agent trying to push straight to main:

Terminal output of opa eval for a push to the main branch. The decision has allow set to false and a deny entry with the code github.protected_branch and the message that branch main is protected and a pull request should be opened instead.
The refusal says why, with a code you can alert on: github.protected_branch.

Unit tests said it was fine

Version 0.1.0 had a few hundred unit tests, and they all passed. That proves the rules do what I wrote. It says nothing about whether I wrote the right rules. So I ran the policies behind a real gateway (agentgateway), with real MCP servers and a real Postgres, and tried to get around them.

The bug: the guard and the database disagreed

The Postgres pack is meant to be read-only. It reads the SQL as text, refuses writes, and refuses anything it can't read with confidence.

PostgreSQL lets you write a string between dollar signs: $tag$ ... $tag$. The tag follows the rules of an unquoted identifier, so it can hold non-ASCII letters, and $é$ is a valid tag. My scanner didn't know that. It didn't see $é$ as a quote, so a single quote character inside it opened a string for the scanner only. Everything after that looked like string content, and the scanner skipped it. PostgreSQL read the same text differently:

The text SELECT $é$'$é$; DELETE FROM orders -- ' shown twice. The v0.1.0 scanner treated everything after the first quote as a string and skipped it. PostgreSQL read $é$'$é$ as a string and then ran DELETE FROM orders.
Same text, two readers. The guard saw a string where PostgreSQL saw a second statement.

Through the gateway and the real postgres-mcp server, a DELETE hidden this way removed every row of a table. You can see the difference without a database. Take examples/postgres-transaction-escape-denied/input.json from the repository and set arguments.sql to:

SELECT $é$'$é$; DELETE FROM orders -- '

Evaluate it against the v0.1.0 and v0.1.1 policies (git checkout v0.1.0, or git archive v0.1.0 into a folder). The same input goes to both releases:

Two terminal windows, one above the other, running the same opa eval command on the same input. Version 0.1.0 returns allow true with no deny reasons. Version 0.1.1 returns allow false with the deny code postgres.unparseable_sql.
v0.1.0 allows the statement. v0.1.1 refuses it with postgres.unparseable_sql.

This is the general lesson. When a guard parses input differently from the system it protects, an attacker lives in the gap. You rarely close that gap with a cleverer parser. You close it by refusing what you can't read. The pack now refuses any $ outside a string, a comment or a quoted name, except $1-style parameters. A parameter's tag can't start with a digit, so it can't hide anything.

It was not the only gap

The same round found more:

  • A vertical tab. PostgreSQL treats it as whitespace and my scanner didn't, which hid a call to pg_read_file(.
  • Functions that take SQL as a string. dblink and pg_cron's cron.schedule run or schedule SQL the scanner never sees as SQL. They're now on a blocked list of 137 functions.
  • A merge without a push. On GitHub, an agent could open a pull request from main into its own branch, then call update_pull_request_branch. That merged into a protected branch without pushing to it.
  • A silent config mistake. "protected_branches": "main" instead of ["main"] switched the deny list off, and a push to main went through. Every setting is now type-checked, and a malformed config refuses every call.

What I did about it

  • Fixed all of it and released v0.1.1.
  • Published a GitHub security advisory, GHSA-2g75-x7cr-hv92, so anyone on v0.1.0 knows to upgrade.
  • Added a regression test for every bypass, then ran mutation testing: break the rules one at a time and see whether any test notices. The score went from 73.7% to 87.6%. I checked each of the 66 surviving mutants against 121 inputs, and none turned a refusal into an allow.
  • Signed the release bundle with a build provenance attestation.
Terminal output of make test ending with PASS: 378/378.
The suite today. Passing tests were never the problem. The attack was.

What it doesn't do

It's a security tool, so the limits matter:

  • It is one layer. Keep least-privilege credentials underneath: a read-only database role and a scoped GitHub token. The Postgres pack reads SQL as text and can't catch everything.
  • Only agentgateway has been tested live. Other gateways need an adapter.
  • It is early. Defaults and input fields may change before 1.0.

Try it

You need make and curl on macOS or Linux:

git clone https://github.com/Ravi041/mcp-policy-library.git
cd mcp-policy-library
make tools
export PATH="$PWD/.bin:$PATH"

opa eval --format=pretty \
  --data policies/ \
  --data packs/github/data.example.json \
  --input examples/github-push-to-main-denied/input.json \
  'data.mcp.main.decision'

make tools downloads pinned OPA and Regal binaries into .bin/ and checks their SHA-256 checksums. Nothing is installed system-wide. You should see "allow": false and the code github.protected_branch. Then run make test to run the whole suite. The gateway runbook puts the same policies behind a real gateway in about ten minutes.

One thing to try this week

If you run agents against Postgres, GitHub or the filesystem, write down the one tool call you would never want an agent to make without asking. Then ask whether anything outside the model would stop it. If the honest answer is "the prompt tells it not to", that's the gap.

If you try the library, I'd like to know which rule you wish existed, or which one you can break. Open an issue or discussion. Report a bypass through the repository's private security reporting.

I built this with Claude Code, including the penetration tests, and reviewed the results myself.

References

Found this useful?

Get the next field note in your RSS reader ↗Share feedback with Ravindra on LinkedIn ↗