aegis.runtime
← aegis.runtime

Your agent's permissions are tighter than your policy thinks

Literal argument matching eliminates path traversal for single values. It says nothing at all about lists — and that gap doesn't announce itself.

Obeng Buo · · Aegis

I've been building a capability-enforcement layer for AI agents that call tools over MCP. The model is simple: before an agent runs, generate a policy listing the exact tools it may call and the exact argument values it may call them with. Evaluate every call against that policy. Deny anything not listed.

Literal matching only. No globs, no regex, no path normalisation. That was a deliberate choice, and the reasoning was that literal matching eliminates the pattern-bypass class entirely rather than trying to sanitise it. You cannot traverse out of /sandbox/notes.txt if the only permitted value is the string /sandbox/notes.txt.

I wrote that claim in the design document. It was wrong, and it took me three months to find out why.

The setup

A user asks an agent to read three files and summarise them. The policy generated for that task looks like this:

servers:
  filesystem:
    tools:
      read_text_file:
        args:
          path:
            must_match_one_of:
              - /sandbox/meeting-notes.txt
              - /sandbox/agenda.txt
              - /sandbox/action-items.txt

Three exact paths. Nothing else permitted. A call for any fourth file is refused.

This works. I have a test that runs a real model against a real filesystem MCP server, with a prompt injection planted in one of the three files instructing the agent to read a fourth. The agent follows the injection. The call is refused before the MCP server sees it.

Then I changed one thing about the task, and everything broke.

What actually happens when an agent reads three files

It doesn't make three calls.

The reference filesystem MCP server exposes read_multiple_files, which takes a list. Asked to read three files, the model batched them:

read_multiple_files(paths=["/sandbox/meeting-notes.txt",
                           "/sandbox/agenda.txt",
                           "/sandbox/action-items.txt"])

The policy has no entry for read_multiple_files, so the call was refused. Correctly — deny by default worked. But for the wrong reason, and the refusal landed on exactly the calls the user had asked for.

So I did what any operator would do. I added the tool.

And that is where it falls apart, because the constraint cannot be expressed. A list of paths cannot be matched against a list of permitted literal values the way a single path can. must_match_one_of compares a value against permitted values. The value here is ["/a", "/b", "/c"]. There is no sensible literal to put on the other side.

The path of least resistance is to leave it unconstrained:

      read_multiple_files:
        args:
          paths:          # no constraint — a list can't be matched literally

Which produces this:

policy: read_multiple_files, paths unconstrained
call:   read_multiple_files(paths=["/etc/shadow"])
result: ALLOW

Path scoping for that tool is now entirely gone.

Why this is worse than a normal bug

The policy still looks tight. Three exact paths sit right above it. Anyone reviewing that file sees a carefully scoped policy with one entry that looks unremarkable.

Nothing in the policy file says path scoping is absent for that tool. Nothing in the decision says it. Nothing in the audit record says it. The call is recorded as an ordinary allow, indistinguishable from a call that was genuinely checked against a permitted value.

And the mechanism generalises past this one tool:

Any MCP server offering both a singular and a plural form of an operation creates this. Read, write, delete, fetch. The plural form gets added for efficiency, usually by someone who is not thinking about authorization, and it becomes a bypass nobody wrote down.

The second one, which is worse

While fixing that I found a related failure with a nastier shape.

The policy engine stringified argument values before comparison. That is a reasonable implementation choice when you need to compare heterogeneous types — integers, floats, strings, paths — against permitted values written as strings.

It also means this:

policy: paths must_match_one_of ["['/sandbox/a.txt', '/sandbox/b.txt']"]
call:   paths = "['/sandbox/a.txt', '/sandbox/b.txt']"    ← a string
result: ALLOW

The constraint was written with a list in mind. It is satisfied by a string that happens to render identically. str(value) on both sides makes a list and its string representation indistinguishable.

An attacker who controls a string-valued argument can satisfy a constraint the policy author wrote for a collection. More generally: an engine that cannot distinguish the type of what it is evaluating cannot make sound claims about what it permits.

The fix

Three changes, all in the direction of denying what the engine cannot reason about precisely.

There is an explicit escape hatch, because sometimes an operator genuinely needs an unconstrained argument and forcing them to fake a constraint is worse than letting them say so:

      read_multiple_files:
        args:
          paths:
            allow_any: true

That is not a free pass. It is off by default, it has to be written deliberately, and the resulting decision carries a distinct rule identifier in the audit record. A waiver shows up in a query for waivers. It does not hide inside the ordinary allows.

That last property is the one I care about most. The original bug was not that an unconstrained argument permitted everything — an operator might legitimately want that. The bug was that it permitted everything silently, in a policy that looked tight, with no trace in the record.

Why literal matching still isn't wrong

The Week 3 reasoning was sound about what it was reasoning about. Globs and regex genuinely do introduce the pattern-bypass class, and literal matching genuinely does eliminate it.

What the reasoning missed is that literal matching eliminates bypass surface for scalars, and the policy format permitted non-scalars without saying what happened to them. The claim was not too strong for the values it covered. It was silently applied to values it had never considered.

That distinction seems like the useful thing to take away. Not "literal matching is insufficient" but "a constraint mechanism should refuse the inputs it cannot constrain, rather than passing them through."

On the taxonomy

Both findings are filed with the OWASP MCP Top 10 project (issue #62).

The interesting part is where they don't fit. MCP02 covers Privilege Escalation via Scope Creep, and its text says permissions "are expanded over time — intentionally for convenience or accidentally through configuration drift."

Neither of these involves drift. Both were wrong at the moment the policy was written, in a system whose author believed it was correct. Nothing changes after deployment. The recommended mitigations — automated scope expiry, periodic access reviews — don't catch either, because the scope was never correct to begin with and a reviewer looking at that policy sees three tight paths and one unremarkable entry.

Also worth noting: neither is addressed by the OAuth 2.1 and resource-indicator guidance that dominates MCP authorization discussion right now. That guidance establishes which tools an agent may call. Both failures occur one layer down, in what arguments those tools may be called with, and that layer is where file paths, database targets, and API endpoints are actually bounded.

How I found them

Not by attacking the system. By writing a document claiming what it did and then checking every claim against the code.

That has now caught four things across three passes: a runnable example in the integration guide that didn't run, an injection scenario documented as firing a rule it never fired, a correlation-ID claim that was false, and these two bypasses. Each was written by someone — me — who believed it at the time.

The habit generalises past documentation. Write down what you think your system guarantees, in specific terms, then go and check each sentence against the code rather than against your memory of the code. The gap between the two is where the interesting bugs live.

The implementation is open source (MIT): github.com/obengbuo/aegis. Both fixes have regression tests.

If you're running agents with real tool access and want to compare notes on how you're bounding them, I'd genuinely like to hear it — particularly if you've hit either of these and solved it differently. obengbuo@aegisruntime.dev