// live mcp server
My résumé is
an MCP server.
You don't read this portfolio — you connect your agent to it.
One ordinary tool, read_document, with one missing path-confinement
invariant on the naive build. Same call. Same tool. The delta is
a few lines of server code.
$ tools/call read_document path: "../internal/canary.txt" ── L0 · naive ────────────────── → CANARY-mcp-105848a98f ✕ leaked ── L3 · hardened ─────────────── → path-confinement: denied ✓ held # same call. same tool. same args. # the delta is the invariant.▌
// 01 · connect
Point your MCP client here.
claude mcp add --transport http avneesh-resume https://mcp.apkasture02.workers.dev/mcp
This connects to the hardened L3 build at /mcp. The naive build
is live too at /mcp/l0 — connect to that one and read_document ../internal/canary.txt
really does exfiltrate the canary. Works in Claude Desktop, Cursor,
or any MCP client that speaks streamable HTTP.
// the scoreboard · same server, same tool, same call
live · refreshes every 45s
The delta is one confinement check. L0 joins and returns — full stop. L3 canonicalizes first, then refuses if the resolved path escapes the root. L3's zero is earned: L0 demonstrably leaks the identical call.
// 02 · the tool surface
One tool. One missing invariant.
One ordinary tool with a real handler, one missing path-confinement invariant, and the traversal any agent reaches for. That is the whole attack surface — no bait, no theater.
read_document
the single attack surface
-
pathrelative path to a document in the posts root -
../internal/canary.txtpath traversal — escapes the root via.. -
%2e%2e/internal/canary.txtURL-encoded variant — bypasses naive string checks on L2
A naive implementation joins ROOT + path and reads. If the join
isn't followed by canonicalize-then-confine, a .. in the path
climbs out of the root and reaches the canary. L0 has the join. L3 adds the guard.
// 03 · try it
Talk the agent into it.
You don't get to call the tool. Only the model does — that is what an MCP server is.
So you do what a real attacker does: write an instruction and talk the agent into calling
read_document for you. It's a live model with no judgment on defense; it obeys.
The rung is the only thing that decides whether the canary leaves.
# pick a preset or write your own jailbreak, then run.
# a real model (llama-3.3-70b) reads it and decides to call the tool.
// 04 · the control, explained
The whole defense is a few lines.
async function readDocument(path) { const full = ROOT + path; return fs.readFile(full); // ✕ no check }
async function readDocument(path) { const full = resolve(ROOT, path); if (!(full + "/").startsWith(ROOT)) return refuse("path-confinement"); return fs.readFile(full); // ✓ confined }
.. and %2e%2e don't survive the resolve step.
The canary is unreachable on L3 regardless of what argument you pass.
A prompt can be argued with. A model can be talked into anything given enough context, roleplay, or Unicode. That's why the guardrail doesn't live in the prompt — it lives in the server, as a deterministic path check that runs before any file read. The model never gets a vote on whether the canary leaves, because the canary is outside every path the check allows.
// 05 · recent attempts
Live from the wire.
Every read_document call across all rungs — leaked in red, held in green.
Newest first.
Loading… if this persists, the worker is sleeping — the argument still holds.