{"slug": "interlock-proving-a-secret-moved-instead-of-guessing-that-it-might", "title": "Interlock: Proving a secret moved, instead of guessing that it might", "summary": "Interlock, a new open-source security tool, catches secret exfiltration by monitoring agent tool calls at runtime rather than scanning tool definitions beforehand. The tool, built by an unnamed developer, uses a protocol-aware MCP proxy and an eBPF sensor to detect the 'lethal trifecta' of private data access, untrusted content, and external communication, which enabled the mid-2025 Supabase/Cursor breach. Interlock is model-agnostic and enforces a verdict-action separation, blocking only when it proves a secret moved (EXFIL) while logging suspicious sessions without disruption.", "body_md": "Every MCP security tool I could find scans tool *definitions* before an agent is\nallowed to use them. None of them can see the attack that actually matters in production:\na sequence of individually-authorized tool calls that chains into an exfiltration\npipeline.\n\nRead a ticket. Follow the instruction hidden in the ticket body. Send a message.\n\nThree approved calls. One breach. No individual call is anomalous.\n\nSimon Willison named the shape. The **lethal trifecta**: access to private\ndata, exposure to untrusted content, and the ability to communicate externally. Any one\nleg is safe. All three live in one session is how data walks out. The mid-2025\nSupabase/Cursor breach was exactly this: privileged data access, attacker-supplied input,\nan external channel.\n\nStatic scanners run before the session. The trifecta assembles during it. That gap is where Interlock lives.\n\n## Won't better models just stop falling for this?\n\nSometimes. Probably. For a while.\n\nThat is the bet I did not want to build on. NIST's adversarial ML taxonomy (AI 100-2e2025) is blunt about the state of it: current mitigations cannot fully prevent these attacks, and there is no foolproof defense available today. Model-level injection resistance is a probability, and it decays the second someone finds a phrasing the training didn't cover.\n\nSo I assumed the injection already won, and built for the step after it: catching the secret on its way out.\n\nThat is the real argument for stacking, and it isn't just \"defense in depth\" as a\nslogan. **The two defenses fail independently.** Injection resistance is a\nproperty of training data. Exfil detection is a property of a byte comparison inside a\nproxy. A phrasing clever enough to defeat the first has no purchase on the second,\nbecause the second never reads the prompt. It reads the outbound arguments.\n\nWhich is why Interlock is model-agnostic on purpose. It sits between the agent and its MCP servers, so it does not care whether that is Claude, GPT, or something someone wired up locally at 2am. It watches what the agent does, not how the agent thinks. The model can be as smart as it wants. Interlock is downstream of the reasoning.\n\n## Approach\n\nTwo observation planes, one correlation engine.\n\n**Plane 1: a protocol-aware MCP proxy (Go).** Not a byte pipe. It terminates\n`initialize`\n\n, `tools/list`\n\n, and `ping`\n\nitself, spawns\nevery configured server as a child, and builds a tool-name to server routing table. The\nagent sees one endpoint.\n\n**Plane 2: an eBPF sensor.** Tracepoints on `connect`\n\n,\n`write`\n\n, `sendto`\n\n, `openat`\n\n, scoped to the proxy's PID\nsubtree via a BPF hash map. This exists because a poisoned server does not have to use\nthe MCP channel to exfiltrate. It can just open a socket. The proxy is blind to that. The\nkernel is not.\n\n**The engine** owns one state machine per session. Three legs, lit by events\nfrom either plane, correlated by PID and a monotonic timestamp.\n\nThe design decision everything else hangs on: **verdict and action are separate\ndimensions.**\n\nVerdict is what was concluded. Action is what was done about it. They are not the same axis, and collapsing them is how security tools get uninstalled.\n\n| Verdict | Means | Hard enforcement |\n|---|---|---|\n`EXFIL` (0.95) |\nA registered tainted value appears in the sink args or egress payload | Yes, blocked or contained |\n`SUSPICIOUS` (0.60) |\nAll three legs lit, and the sink shares a long byte substring with untrusted content | No, evidence only |\n\n`EXFIL`\n\nis the bar for *we proved a secret moved*.\n`SUSPICIOUS`\n\nis *this session has the shape of risk*. Only the first one\nis allowed to break anything.\n\n## Implementation\n\n**Hold-before-forward.** Enforcement hooks at the exact point where the\nproxy has parsed a `tools/call`\n\n, extracted the tool name and args, and\nresolved the target server. The engine evaluates there. On block, the frame is never\nwritten to the child's stdin. The proxy synthesizes a JSON-RPC error using the same\nresponse-synthesis path it already uses for `tools/list`\n\n. The agent gets a\nlegible failure instead of a hang.\n\n**Proof is byte overlap, not model judgment.** When a tool tagged\n`sensitive_source`\n\nreturns data, the engine extracts candidate secrets and\nprecomputes canonical encodings: literal, base64, hex, URL-encoding, reversal, closed\ndepth-2 nests, `gzip_base64`\n\n. At sink time it scans the args for any of them.\nOn a miss it tries same-call JSON string reassembly, then a bounded recursive decoder\n(base64/hex, depth 3 or less). A session-level fragment buffer closes cross-call splits,\nwhere the secret arrives in abutting halves across two paginated results.\n\nTainted values are stored hashed and masked, never raw: `sk-...a9f2`\n\n.\nComparison happens against raw values in memory only. Every output file is scrubbed\nbefore writing. A tool that logs the token it is protecting is itself the exfil path.\n\n**Kernel side is detect-and-contain, not prevent.** The sensor observes. It\ndoes not block in-kernel. Containment is userspace kill-on-detect, with a ~100 ms\ndeferred window after a suspicious `connect`\n\nso a correlated\n`write`\n\ncan land and prove payload overlap.\n\nThat trade-off, stated plainly: **for the eBPF variant, the first packet may\nalready be gone when the kill fires.** Interlock severs the channel and kills the\nprocess before further exfiltration. It does not prevent the first byte. The proxy\nvariant is true prevention. The kernel variant is containment. Calling both \"blocking\"\nwould be a lie that shows up in someone's incident review six months later. LSM/KRSI\nin-kernel blocking is the fix, and it is not shipped.\n\n## Results\n\nA 55-scenario corpus (25 malicious, 30 benign) drives the engine directly. No proxy, no kernel, no network, so it runs in CI without root or BTF.\n\n| Metric | Value |\n|---|---|\n| Detection rate (EXFIL-tier, non-gap malicious) | 100.0% (21/21) |\n| False positives, any trip, benign | 13.3% (4/30) |\n| False positives, EXFIL-tier, benign | 0.0% (0/30) |\n| Known-gap misses (catalogued, pinned by tests) | 4 |\n\nThe 13.3% deserves the scrutiny it is going to get. Those four are all soft\n`SUSPICIOUS`\n\nwith action `allowed_monitor`\n\n. A fetch-heavy agent\nquotes a long product blurb outbound, the blurb byte-binds to the sink, the tripwire logs\nit. Nothing is blocked. That is the system working as designed at the current bind\nthreshold, and the tuning knob is `content_bind_min_len`\n\n, not widening\nenforcement to cover it.\n\nAt the tier that actually breaks your agent, the false-positive rate is zero, including on scenarios built to look encoding-adjacent: base64 of unrelated data, masked previews, ticket-ID references.\n\n**What this deliberately does not measure: injection success rate.** There\nis no attack-success benchmark here, because if I benchmarked whether the agent falls for\nthe poisoned ticket, I would be grading the model, not the tool. Out-arguing a prompt\ninjection is the model's fight. The question a firewall has to answer is narrower and\nentirely mine: when the secret tries to leave, does it get caught.\n\n**Overhead**, measured as engine delta (same HTTP stack, engine on versus\npassthrough):\n\nOverhead\n\n- ~536 µs on sensitive reads\n- ~118 µs on sink checks\n\nWhich is backwards from what I expected, and the reason is the interesting part. The sink\npath runs the full trifecta and the overlap scan, and it is cheap. An early match is ~79\nns regardless of taint-map size. The read path runs taint extraction and precomputes\nfive-plus canonical encodings per secret, and that dominates. **Per-call cost lives\non reads, not writes.** Agents read constantly. The common path is the expensive\none.\n\nThat number is for a fixture with 2 secrets, and it scales linearly with secrets-per-result. \"~0.5 ms\" means \"~0.5 ms for a 2-secret read,\" not a ceiling.\n\n## What it does not catch\n\nThe elephant, stated first because you would find it anyway:\n\n**Semantic exfil.** The agent reads a secret and then writes prose that\nconveys it without any registered byte form.\n\n\"The live Stripe key from that ticket starts with sk-live-51Tx and ends in abcdef…\"\n\nNot detected at EXFIL tier. Pinned as a known-gap scenario with a passing benign twin, so it stays visible instead of quietly rotting.\n\nThe reason is a product decision, not an oversight. Interlock's proof is overlap against precomputed encodings. Semantic detection means putting another model on the hot path: trusting a judge that can be talked out of its judgment, exploding the false-positive surface, and still losing to a clever rewrite. I would rather be excellent and honest at programmatic exfil and name the gap than ship something that \"understands\" outbound prose and gets rolled back.\n\nAlso catalogued\n\n- Depth-4+ encoding nests and compressors other than gzip\n- Secrets past the eBPF capture window, IPv6, and\n`sendmsg`\n\n/`writev`\n\nare untraced - Operator trap:\n`EvaluateRequest`\n\nonly gates tools tagged`external_sink`\n\n. An untagged write-shaped tool sitting on a`sensitive_source`\n\nserver is invisible to the engine, so every tool that can egress needs the tag, regardless of which server hosts it - Default posture is fail-open with loud\n`[SECURITY]`\n\nwarnings on stderr; a production`fail_closed`\n\nmode is on the roadmap, not in the build\n\n## What I'd do differently\n\n**Build the false-positive corpus before the state machine.** I built the\ntrifecta logic first and the corpus second, and the corpus immediately rewrote the logic.\nThe original version lit `untrusted_content_present`\n\non sensitive-source\nresults, which meant one sensitive read made every subsequent sink call suspicious\nforever. Sticky legs. On paper it was thorough. In practice it was a tool nobody would\nkeep installed past week two. The fix was obvious the moment I had benign scenarios to\nrun it against, and invisible before that: untrusted no longer lights on sensitive\nresults, `SUSPICIOUS`\n\nadditionally requires byte-level content-bind, and hard\nenforcement is reserved for `EXFIL`\n\n.\n\n**Make verdict and action separate types on day one.** I added the\n`Action`\n\ndimension late, retrofitting it through the evidence model. It should\nhave been in the first version of the data model. The moment you have two enforcement\nplanes with genuinely different capabilities, one that prevents and one that can only\ncontain, a single \"blocked\" boolean is already lying to you.\n\n**Measure before optimizing the obvious thing.** I spent time on the overlap\nscan because it was the part that felt expensive. It wasn't. Taint ingestion on reads is\nroughly 5x the cost, and I only learned that from the benchmark.\n\nInterlock is Go plus eBPF via `cilium/ebpf`\n\n, runs against MCP over STDIO or\nStreamable HTTP, and ships a sensor-only Kubernetes DaemonSet with PID to pod\nattribution. Every trip writes an evidence record, rendered by a read-only local HTML\nviewer: the injected instruction, the sensitive read, the attempted send, down to the\nsyscall.\n\nScanners check what tools claim. Interlock watches what agents do.\n\nIf you've got a scenario you think breaks the trifecta:\n[me@yashwanthreddymali.com](mailto:me@yashwanthreddymali.com).", "url": "https://wpnews.pro/news/interlock-proving-a-secret-moved-instead-of-guessing-that-it-might", "canonical_source": "https://yashwanthreddymali.com/blog/interlock-exfiltration-at-runtime/", "published_at": "2026-07-26 04:01:57+00:00", "updated_at": "2026-07-26 04:22:18.333676+00:00", "lang": "en", "topics": ["ai-safety", "ai-agents", "ai-infrastructure", "ai-tools", "developer-tools"], "entities": ["Interlock", "Simon Willison", "Supabase", "Cursor", "NIST", "MCP"], "alternates": {"html": "https://wpnews.pro/news/interlock-proving-a-secret-moved-instead-of-guessing-that-it-might", "markdown": "https://wpnews.pro/news/interlock-proving-a-secret-moved-instead-of-guessing-that-it-might.md", "text": "https://wpnews.pro/news/interlock-proving-a-secret-moved-instead-of-guessing-that-it-might.txt", "jsonld": "https://wpnews.pro/news/interlock-proving-a-secret-moved-instead-of-guessing-that-it-might.jsonld"}}