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.

read_document — L0 vs L3 fig. — read_document
$ 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.

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.

L0 · naive leaks
L3 · hardened by construction

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.

This wall is one rung of mcploitable, the full L0–L3 lab.

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
  • path relative path to a document in the posts root
  • ../internal/canary.txt path 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.

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.

agent @ L0 · workers-ai session fig. — live session

# 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.

llama-3.3-70b · no judgment on defense · the rung decides

The whole defense is a few lines.

L0 · naive — leaks
function readDocument(path) {
  const full = normalize(ROOT + path);
  return store[full];  // ✕ reads whatever it resolves to
}
L3 · hardened — holds
function readDocument(path) {
  const full = normalize(ROOT + decode(path));
  if (!(full + "/").startsWith(ROOT + "/"))
    return refuse("path-confinement");
  return store[full];  // ✓ inside the root
}
Canonicalize first, confine after. The naive build joins and reads. The hardened build resolves the path to its canonical form, then checks that it stays 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.

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.