cd /news/ai-safety/interlock-proving-a-secret-moved-ins… · home topics ai-safety article
[ARTICLE · art-73939] src=yashwanthreddymali.com ↗ pub= topic=ai-safety verified=true sentiment=· neutral

Interlock: Proving a secret moved, instead of guessing that it might

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.

read9 min views1 publishedJul 26, 2026

Every MCP security tool I could find scans tool definitions before an agent is allowed to use them. None of them can see the attack that actually matters in production: a sequence of individually-authorized tool calls that chains into an exfiltration pipeline.

Read a ticket. Follow the instruction hidden in the ticket body. Send a message.

Three approved calls. One breach. No individual call is anomalous.

Simon Willison named the shape. The lethal trifecta: access to private data, exposure to untrusted content, and the ability to communicate externally. Any one leg is safe. All three live in one session is how data walks out. The mid-2025 Supabase/Cursor breach was exactly this: privileged data access, attacker-supplied input, an external channel.

Static scanners run before the session. The trifecta assembles during it. That gap is where Interlock lives.

Won't better models just stop falling for this? #

Sometimes. Probably. For a while.

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

So I assumed the injection already won, and built for the step after it: catching the secret on its way out.

That is the real argument for stacking, and it isn't just "defense in depth" as a slogan. The two defenses fail independently. Injection resistance is a property of training data. Exfil detection is a property of a byte comparison inside a proxy. A phrasing clever enough to defeat the first has no purchase on the second, because the second never reads the prompt. It reads the outbound arguments.

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

Approach #

Two observation planes, one correlation engine.

Plane 1: a protocol-aware MCP proxy (Go). Not a byte pipe. It terminates initialize

, tools/list

, and ping

itself, spawns every configured server as a child, and builds a tool-name to server routing table. The agent sees one endpoint.

Plane 2: an eBPF sensor. Tracepoints on connect

, write

, sendto

, openat

, scoped to the proxy's PID subtree via a BPF hash map. This exists because a poisoned server does not have to use the MCP channel to exfiltrate. It can just open a socket. The proxy is blind to that. The kernel is not.

The engine owns one state machine per session. Three legs, lit by events

from either plane, correlated by PID and a monotonic timestamp. The design decision everything else hangs on: verdict and action are separate dimensions.

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

| Verdict | Means | Hard enforcement |

|---|---|---|
`EXFIL` (0.95) |

A registered tainted value appears in the sink args or egress payload | Yes, blocked or contained | SUSPICIOUS (0.60) | All three legs lit, and the sink shares a long byte substring with untrusted content | No, evidence only |

EXFIL

is the bar for we proved a secret moved. SUSPICIOUS

is this session has the shape of risk. Only the first one is allowed to break anything.

Implementation #

Hold-before-forward. Enforcement hooks at the exact point where the proxy has parsed a tools/call

, extracted the tool name and args, and resolved the target server. The engine evaluates there. On block, the frame is never written to the child's stdin. The proxy synthesizes a JSON-RPC error using the same response-synthesis path it already uses for tools/list

. The agent gets a legible failure instead of a hang.

Proof is byte overlap, not model judgment. When a tool tagged sensitive_source

returns data, the engine extracts candidate secrets and precomputes canonical encodings: literal, base64, hex, URL-encoding, reversal, closed depth-2 nests, gzip_base64

. At sink time it scans the args for any of them. On a miss it tries same-call JSON string reassembly, then a bounded recursive decoder (base64/hex, depth 3 or less). A session-level fragment buffer closes cross-call splits, where the secret arrives in abutting halves across two paginated results.

Tainted values are stored hashed and masked, never raw: sk-...a9f2

. Comparison happens against raw values in memory only. Every output file is scrubbed before writing. A tool that logs the token it is protecting is itself the exfil path.

Kernel side is detect-and-contain, not prevent. The sensor observes. It does not block in-kernel. Containment is userspace kill-on-detect, with a ~100 ms deferred window after a suspicious connect

so a correlated write

can land and prove payload overlap.

That trade-off, stated plainly: for the eBPF variant, the first packet may already be gone when the kill fires. Interlock severs the channel and kills the process before further exfiltration. It does not prevent the first byte. The proxy variant is true prevention. The kernel variant is containment. Calling both "blocking" would be a lie that shows up in someone's incident review six months later. LSM/KRSI in-kernel blocking is the fix, and it is not shipped.

Results #

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

| Metric | Value |

|---|---|
| Detection rate (EXFIL-tier, non-gap malicious) | 100.0% (21/21) |

| False positives, any trip, benign | 13.3% (4/30) |

| False positives, EXFIL-tier, benign | 0.0% (0/30) |
| Known-gap misses (catalogued, pinned by tests) | 4 |

The 13.3% deserves the scrutiny it is going to get. Those four are all soft SUSPICIOUS

with action allowed_monitor

. A fetch-heavy agent quotes a long product blurb outbound, the blurb byte-binds to the sink, the tripwire logs it. Nothing is blocked. That is the system working as designed at the current bind threshold, and the tuning knob is content_bind_min_len

, not widening enforcement to cover it.

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

What this deliberately does not measure: injection success rate. There is no attack-success benchmark here, because if I benchmarked whether the agent falls for the poisoned ticket, I would be grading the model, not the tool. Out-arguing a prompt injection is the model's fight. The question a firewall has to answer is narrower and entirely mine: when the secret tries to leave, does it get caught.

Overhead, measured as engine delta (same HTTP stack, engine on versus

passthrough): Overhead

  • ~536 µs on sensitive reads
  • ~118 µs on sink checks

Which is backwards from what I expected, and the reason is the interesting part. The sink path runs the full trifecta and the overlap scan, and it is cheap. An early match is ~79 ns regardless of taint-map size. The read path runs taint extraction and precomputes five-plus canonical encodings per secret, and that dominates. Per-call cost lives on reads, not writes. Agents read constantly. The common path is the expensive one.

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

What it does not catch #

The elephant, stated first because you would find it anyway:

Semantic exfil. The agent reads a secret and then writes prose that conveys it without any registered byte form.

"The live Stripe key from that ticket starts with sk-live-51Tx and ends in abcdef…"

Not detected at EXFIL tier. Pinned as a known-gap scenario with a passing benign twin, so it stays visible instead of quietly rotting.

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

Also catalogued

  • Depth-4+ encoding nests and compressors other than gzip
  • Secrets past the eBPF capture window, IPv6, and sendmsg

/writev

are untraced - Operator trap: EvaluateRequest

only gates tools taggedexternal_sink

. An untagged write-shaped tool sitting on asensitive_source

server 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

[SECURITY] warnings on stderr; a productionfail_closed

mode is on the roadmap, not in the build

What I'd do differently #

Build the false-positive corpus before the state machine. I built the trifecta logic first and the corpus second, and the corpus immediately rewrote the logic. The original version lit untrusted_content_present

on sensitive-source results, which meant one sensitive read made every subsequent sink call suspicious forever. Sticky legs. On paper it was thorough. In practice it was a tool nobody would keep installed past week two. The fix was obvious the moment I had benign scenarios to run it against, and invisible before that: untrusted no longer lights on sensitive results, SUSPICIOUS

additionally requires byte-level content-bind, and hard enforcement is reserved for EXFIL

.

Make verdict and action separate types on day one. I added the Action

dimension late, retrofitting it through the evidence model. It should have been in the first version of the data model. The moment you have two enforcement planes with genuinely different capabilities, one that prevents and one that can only contain, a single "blocked" boolean is already lying to you.

Measure before optimizing the obvious thing. I spent time on the overlap scan because it was the part that felt expensive. It wasn't. Taint ingestion on reads is roughly 5x the cost, and I only learned that from the benchmark.

Interlock is Go plus eBPF via cilium/ebpf

, runs against MCP over STDIO or Streamable HTTP, and ships a sensor-only Kubernetes DaemonSet with PID to pod attribution. Every trip writes an evidence record, rendered by a read-only local HTML viewer: the injected instruction, the sensitive read, the attempted send, down to the syscall.

Scanners check what tools claim. Interlock watches what agents do.

If you've got a scenario you think breaks the trifecta:
[me@yashwanthreddymali.com](mailto:me@yashwanthreddymali.com).
── more in #ai-safety 4 stories · sorted by recency
── more on @interlock 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/interlock-proving-a-…] indexed:0 read:9min 2026-07-26 ·