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

> Source: <https://yashwanthreddymali.com/blog/interlock-exfiltration-at-runtime/>
> Published: 2026-07-26 04:01:57+00:00

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 tagged`external_sink`

. An untagged write-shaped tool sitting on a`sensitive_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 production`fail_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).
