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.

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

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.

L0 · naive leaked
L3 · hardened 0 leaked

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.

Lab run: 105 reps at the L3 wall via mcploitable, 0 leaks.

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
  • path relative path to a document in the posts root
  • ../internal/canary.txt path traversal — escapes the root via ..
  • %2e%2e/internal/canary.txt URL-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.

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.

agent @ L0 · workers-ai 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
async function readDocument(path) {
  const full = ROOT + path;
  return fs.readFile(full);  // ✕ no check
}
L3 · hardened — holds
async function readDocument(path) {
  const full = resolve(ROOT, path);
  if (!(full + "/").startsWith(ROOT))
    return refuse("path-confinement");
  return fs.readFile(full);  // ✓ confined
}
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 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.

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.