// live mcp server
My résumé is
an MCP server.
Connect your agent and it answers from its tools. One of them,
read_document, has no path-confinement check on the naive build, so
the same call that returns a post also reaches an inert canary. The delta is
a few lines of server code.
$ tools/call read_document path: "../internal/canary.txt" ── L0 · naive ────────────────── → CANARY-mcp-b7f3… ✕ leaked ── L3 · hardened ─────────────── → path-confinement: denied ✓ held # same call. same tool. same args. # one invariant: stay under the root.▌
// 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
runs at /mcp/l0; connect there and read_document ../internal/canary.txt
exfiltrates the canary. Works in Claude Desktop, Cursor,
or any MCP client that speaks streamable HTTP.
// the scoreboard
live · refreshes every 45s
One confinement check separates them. L0 joins the path and returns whatever it points at. L3 canonicalizes first, then refuses if the resolved path escapes the root.
// 02 · the tool surface
One tool. One missing invariant.
The handler, and the one path traversal any agent reaches for. That's the whole attack surface.
read_document
the single attack surface
-
pathrelative path to a document in the posts root -
../internal/canary.txtpath traversal — escapes the root via..
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.
Write an instruction and the agent calls read_document for you. It's a live
model, and defense isn't its job, so it reads whatever path you ask for. The rung
decides what comes back: L0 hands over the canary, L3 refuses the same call.
# 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.
function readDocument(path) { const full = normalize(ROOT + path); return store[full]; // ✕ reads whatever it resolves to }
function readDocument(path) { const full = normalize(ROOT + decode(path)); if (!(full + "/").startsWith(ROOT + "/")) return refuse("path-confinement"); return store[full]; // ✓ inside the root }
.. and %2e%2e don't survive the resolve step.
The canary stays unreachable on L3, whatever argument you pass.
You can talk a model into anything, given enough context, roleplay, or Unicode. So the check doesn't live in the prompt. It runs in the server before any read, and the canary sits outside every path it allows.
// 05 · recent attempts
Live from the wire.
Every read_document call across all rungs. Leaked in red, held in green,
newest first.
Loading recent attempts… if this stays, the worker is asleep; connect a real MCP client with the command above.