Skip to main content
Glama
jigonyoo

mcp-permission-server

by jigonyoo
README.md
# mcp-permission-server

**A permission layer in front of MCP tools, and a log that can prove what it decided.**

> **One of five.** This repo is the permission grant + audit log layer. If your agent needs more than
> one of them, see [*Part of a set*](#part-of-a-set) at the bottom.

Every audit trail records what happened. Almost none record enough to recompute
**why it was allowed**, and most record only what succeeded — so when somebody
asks "how did the agent reach that file", the log cannot answer. It can only
repeat the assertion that a decision was made.

```
                                 NAIVE     GUARDED
calls in the session                17          17
calls that must be denied            8           8
EXECUTED WITHOUT A GRANT             7           0
LEGITIMATE CALLS REFUSED             0           0      ← no over-blocking
DECISIONS THAT CANNOT BE            16           0      ← the number nobody reports
  RECONSTRUCTED FROM THE LOG
denials that left no trace           1           0
secrets sitting in the log           2           0
entry removable unnoticed            1           0
```

`NAIVE` is the permission model an MCP server ships with when permissions were
not the point: a list of tool names that are allowed, and a log of what
succeeded. Every call in this session except one passes it.

```bash
python3 demo.py
python3 gate.py --log audit.json     # run the session
python3 gate.py --verify audit.json  # re-decide every entry from the log alone
```

```
chain:  OK - the chain is intact
replay: 17/17 entries recompute to the verdict they record
secrets in the log: none
```

Exit code: `0` all allowed, `1` something denied, `2` the log does not verify.

> **What this layer looks like with each check switched off.** GuardStack (bottom of this
> file) merges these five repos onto one hash-chained log, and its benchmark publishes the
> ablation as a file: **[evidence report](https://claude.ai/code/artifact/b9435c65-2173-4e40-90d7-54eb67a080fa)**. It removes `grant`,
> `scope` and `expiry` separately — each drops 6/6 stopped to 5/6, so each is catching one
> case no other check catches; all three off is 3/6. Measured on shipped fixtures, and
> labelled as such.

---

## A grant is four things at once

Which is four separate ways for a **live** grant to still not cover a call —
and the reason *"does this caller have permission for this tool"* is the wrong
question to ask.

```json
{"id": "G1", "tool": "fs.read", "scope": "/data/reports",
 "purpose": "q3-report", "expires_at": 100, "max_uses": 10}
```

| the call | what is wrong with it | what else would have allowed it |
|---|---|---|
| `fs.read /data/reports-private/salaries.csv` | a different directory that starts with the same characters | tool ✓ purpose ✓ expiry ✓ |
| `fs.read /data/reports/../reports-private/salaries.csv` | the same directory, reached through the granted one | tool ✓ purpose ✓ expiry ✓ |
| `fs.read /data/reports/q4.csv` at `t=140` | the grant ran out forty ticks ago. **Nothing about the call is wrong** | tool ✓ scope ✓ purpose ✓ |
| the third `fs.write` against a grant good for two | a grant is a budget as well as a permission | tool ✓ scope ✓ purpose ✓ |
| `fs.read /data/reports/q3.csv` for `marketing-export` | **identical to the first call in the session except for why it is being made** | tool ✓ scope ✓ expiry ✓ |
| `render_template /templates/leak.md` | the template pulls in `/etc/service-token`, which the caller has no grant for and never named | tool ✓ scope ✓ purpose ✓ expiry ✓ |
| `secrets.get DB_ADMIN_PASSWORD` | holding a grant for one key is not holding the keyring | tool ✓ purpose ✓ expiry ✓ |

There is a test that, for each of these, turns off **only** the check it names
and requires the call to get through. A denial that several checks catch is a
denial that proves nothing about any of them.

---

## The confused deputy

`render_template` is granted. `/templates/leak.md` is inside the granted scope.
The template contains `{{include:/etc/service-token}}`.

The server holds rights the caller does not, and is being asked to spend them
on a resource the caller never named. A permission layer that checks the
resource **in the arguments** is checking the wrong thing for exactly the tools
where it matters.

So the check looks at what a call *reaches*, before it reaches it — and then
still says yes to `/templates/report.md`, which reaches
`/data/reports/q3.csv`, which this caller does hold a grant for. **Half of a
permission check is the yes.** A rule that blocks every template because
templates can include things is not a rule.

---

## Path containment, which is where this is usually got wrong

Both mistakes are one line long.

```python
path.startswith(scope)     # /data/reports-private is "inside" /data/reports
".." in path               # and this breaks ordinary callers to catch nothing
```

The second one deserves its own case. `/data/reports/../reports/q3.csv`
resolves back **inside** the granted scope, and it is in the corpus as a call
that must be **allowed**. Banning the characters is the cheap fix; resolving
the path catches everything banning it would, and nothing it would not.

Resolution here is lexical and never touches a filesystem — there is a test
that greps for `realpath`, `os.stat` and `open(`. The decision runs before the
call, on a path the caller supplied, and it must not be answerable differently
depending on what happens to exist.

---

## The log is the product

Two properties make it evidence rather than a diary.

**It records denials.** A log of what succeeded cannot answer "what did the
agent try", which is the question anybody actually asks afterwards. Here, eight
of the seventeen entries are refusals, each with the check that made it and the
reason in plain English.

**It can be replayed.** Every entry carries the grant it was decided under and
the policy version, so the decision can be recomputed from the log alone and
compared to the verdict it records:

```
python3 gate.py --verify audit.json
replay: 17/17 entries recompute to the verdict they record
```

With the `replay` field off, that number is **0/17** — the entries still say
what was decided and give nobody any way to check it.

And the entries are hash-chained, so a removed or edited one is visible:

```
entry 6 does not follow the one before it -- something was removed or edited
```

**Secrets never enter it.** An audit trail holding the secret is a second copy
of the secret, in a file more people can read. `secrets.get` is allowed in this
session and its value appears nowhere in the log; the naive log contains two.

---

## The nine checks, and the proof that each earns its place

```
check         what turning it off lets through
grant          1 more unauthorised, 1 more unreconstructable, 1 more egress
scope          3 more unauthorised, 3 more unreconstructable
expiry         2 more unauthorised, 2 more unreconstructable
purpose        1 more unauthorised, 1 more unreconstructable
deputy         1 more unauthorised, 1 more unreconstructable
redact         1 more secret in the log
deny_log       8 more denials that left no trace
replay        17 more decisions that cannot be reconstructed
chain          1 more entry removable unnoticed
```

```bash
python3 ablate.py
```

One correction worth recording: the first version of the `scope` ablation did
not turn the check off — it substituted the string-prefix comparison, which is
a **different check** rather than the absence of one, and it quietly kept
denying a call that existed to prove the check was needed. **Turning a check
off has to mean turning it off**, or the ablation is measuring a swap.

The same session also exposed a grant sized so that its last call was denied
for being one use too many rather than for naming a key it did not cover. It
was testing the wrong check and passing. The fix was in the fixture, and the
test that found it is the one that requires each denial to isolate its own
check.

---

## Known limits, stated rather than hidden

- **This is the decision layer, not a transport.** It decides, executes against
  an in-process tool table, and logs. Wiring it to a real MCP transport is
  ordinary work; what is here is the part that is usually missing, and it runs
  with no network and no key.
- **Grants are written down, not elicited.** A real deployment asks a person
  and stores the answer. What that answer has to contain — tool, scope,
  purpose, expiry, use count — is the part this argues about.
- **`purpose` is declared by the caller and taken at face value.** A caller
  that lies about its purpose defeats the check. It still buys something real:
  the declared purpose is in the log, so the lie is on the record and the
  grant's scope did not widen to accommodate it.
- **The deputy check knows one tool's reach.** `render_template` parses its
  includes. A tool whose reach the server cannot compute cannot be checked this
  way, and the honest answer for such a tool is a narrower grant rather than a
  wider check.
- **The chain proves ordering and integrity, not custody.** Anyone who can
  rewrite the whole file can rewrite the whole chain. It makes a *selective*
  edit visible, which is the realistic threat; an append-only store or an
  external anchor is what makes the rest visible, and that is deployment work.

---

## Layout

```
fixtures/spec.py     the session, the grants, the world, and the answer key
build_fixtures.py    renders session.json, policy.json, world.json, truth.json
mcpgate/scope.py     is this path inside that scope
mcpgate/policy.py    a grant is four things at once
mcpgate/tools.py     the tool table, and what a call actually reaches
mcpgate/audit.py     the hash-chained, redacted, replayable log
mcpgate/server.py    naive() and guarded(), and replay()
gate.py              the CLI: run a session, or verify a log
score.py             every number above
ablate.py            each check off, one at a time
tests/               42 tests
```

## Run it

```bash
python3 demo.py                        # both servers + ablation
python3 gate.py --log audit.json       # the product
python3 gate.py --verify audit.json    # the proof
python3 -m pytest tests -q             # 42 tests
```

Docker, with the network switched off:

```bash
docker compose run --rm gate
```

## Tests

One asserts that the allowlist still lets almost everything through. One
replays every logged decision and requires the same verdict. One requires each
denial to survive **only** because of the check it names, so the ablation
numbers mean what they say. One greps the path resolver for filesystem calls.

---

## Part of a set

This repo is one of five agent-safety layers I maintain. All MIT, all free, and
staying that way.

| Layer | Repo |
|---|---|
| Input / output guard | [`llm-guardrails`](https://github.com/jigonyoo/llm-guardrails) |
| Permission grants + audit log | [`mcp-permission-server`](https://github.com/jigonyoo/mcp-permission-server) ← you are here |
| Approval gate | [`agent-approval-gate`](https://github.com/jigonyoo/agent-approval-gate) |
| Retry / timeout / circuit breaker | [`agent-reliability-kit`](https://github.com/jigonyoo/agent-reliability-kit) |
| Read-only enforcement | [`readonly-guard`](https://github.com/jigonyoo/readonly-guard) |

They were built as five separate demos, so they **do not compose**: each carries its
own config, its own audit log, and its own idea of what "denied" means. Wiring them
into one agent is a real job.

**[GuardStack](https://buy.polar.sh/polar_cl_Zgd01SZaW8RwTEc8j7MMpWryCwLBFmoeMoPt53a4yoV)** is that job, already done — five gates in a
fixed order, one call each, writing to **one shared hash-chained audit log**, so
*"what did the agent try, and under which rule was it allowed"* is answerable from a
single file. It ships with framework adapters (OpenAI-compatible wrapper, FastAPI
middleware), a test suite written against the composition rather than the five demos,
and a benchmark you re-run **on your own corpus**, with an ablation that prices gates
1-5 (input, permission, approval, reliability, output) and labels which numbers came
from your traffic and which from the shipped fixtures. The current test count and every
other figure live in the [evidence report](https://claude.ai/code/artifact/b9435c65-2173-4e40-90d7-54eb67a080fa),
which is regenerated from the benchmark — this file deliberately does not repeat them,
because a version-dependent number copied into five repos is a number that goes stale in
five places at once. It did, twice.

It also ships `docs/LIMITS.md`, which is the part worth reading first: the input guard
stops **27/27 of our corpus and 5/42 of a corpus written to break it** — and that
second corpus ships in the box, so the bad number is one command away rather than a
sentence you have to take on trust. Budgets are per-process, and the audit log assumes
a single writer. Gates 2, 3 and 5 are where it earns its keep. You should know that
before you pay, not after.

**$49.** Assembling the five yourself is a legitimate choice, and the repos above are
the right place to start — this is the two weeks of wiring you skip.