AI News HubLIVE
站內改寫6 分鐘閱讀

待翻譯:Interlock: A runtime firewall for AI agents that assumes injection won

AI 服務暫時不可用,以下為來源摘要,待恢復後補全翻譯:On building an exfiltration detector, the 46.7% false positive rate that forced a rewrite, and why I publish a list of things my own tool cannot catch. Here's a sentence that would have meant nothing to me two years ago…

來源Hacker News AI作者: yxshwanthreddy

AI 服務暫時不可用,以下為來源正文,待恢復後補全翻譯。

On building an exfiltration detector, the 46.7% false positive rate that forced a rewrite, and why I publish a list of things my own tool cannot catch. Here's a sentence that would have meant nothing to me two years ago: my agent read a credentials file, absorbed a poisoned GitHub issue, and tried to post both to a webhook, and a piece of software I wrote watched all three of those things happen, proved the bytes were the same bytes, and killed the process. That software is called Interlock. This is the long version of what it does and why the design is shaped the way it is. TL;DR for people who skim (correct instinct): Interlock does not try to stop prompt injection, it assumes injection succeeds and watches for the thing injection is for Detection runs on two planes: an MCP proxy on the JSON-RPC wire, and an eBPF sensor on syscalls Hard enforcement fires only on proof of byte overlap between a tainted secret and outbound traffic, never on session shape alone Measured: 100% EXFIL-tier detection (31/31 non-gap malicious), 0% EXFIL-tier false positives (0/37 benign), 18.9% any-trip soft alerts There is a public ledger of what it structurally cannot catch, and that ledger is the part I'd want you to read first The problem AI agents are useful for exactly one structural reason: they can read your stuff and act on your behalf. They read files, call APIs, browse, and plug into external tools through MCP (Model Context Protocol, essentially a power strip for agent capabilities). That's the whole pitch. Give the model hands. The problem is that three capabilities were never supposed to sit in the same room: Access to private data Exposure to untrusted content The ability to communicate externally Simon Willison named this the lethal trifecta. Any one leg alone is ordinary product behavior. All three lit in the same session is, structurally, the shape of data walking out. And it usually walks out through tool poisoning, which is less dramatic than it sounds. Attacker instructions arrive inside a tool result. The model treats that result as trusted context, because language models do not have a clean mechanism for separating "text I should read" from "text I should obey." A later, individually authorized tool call carries your secret out. Every step in that chain looks reasonable in isolation. That's what makes it hard, and, I'll admit, kind of beautiful in the way a lockpick is beautiful. The decision the whole project hangs on I did not try to solve prompt injection. Injection is a property of mixing untrusted text with privileged instructions in the same context. No static allowlist of tools and no pre-call scanner of tool schemas removes that mixing once the agent is running and reading live results. Treating "stop the model from being influenced" as your primary control is signing up to win an arms race against natural language, permanently, with no finish line and no scoreboard. So I moved the control plane. Interlock treats injection as the ambient condition of agent systems and asks a narrower question at the sink: Did the actual bytes of a secret, or a registered encoding of those bytes, appear in outbound traffic? That is a much smaller claim than "was this behavior suspicious," and small claims are the ones you can actually keep. Byte overlap is decidable without putting another model on the hot path. Vibes are not decidable. The cost of that choice is explicit and permanent: semantic paraphrase of a secret is out of scope forever, by construction, and I'd rather say that in the README than have someone discover it in an incident review. Two variants, because there are exactly two places bytes are visible VariantA Variant = "A_chained_tool" // caught by proxy hold-before-forward VariantB Variant = "B_server_channel" // caught by eBPF sensor Variant A is the chained tool call. A poisoned result causes a later tools/call whose arguments contain the secret. That traffic is JSON-RPC on a wire Interlock can sit on, so the proxy evaluates before forwarding and can refuse the call outright. Variant B is the side channel. An MCP server process (or a child it spawns) opens its own socket and writes the secret directly. It never appears as tool arguments. The proxy is structurally blind to it. That's what the kernel sensor is for. Two planes, and what each one cannot see Plane 1 (MCP proxy)Plane 2 (eBPF sensor) Sees JSON-RPC methods, tool args, tool results, tags connect / write / writev / sendto / sendmsg / DNS / openat for filtered PIDs and cgroups Can prevent Yes, hold-before-forward on Variant A Repeat connect() only, via optional LSM hook Blind to Raw sockets from server processes, anything outside the framed MCP stream MCP semantics: which tool, which tags, untrusted excerpts Remove it and you lose Variant A prevention, tool tagging, untrusted content for content-bind Every side channel that never touches tools/call Two planes is not architectural flourish, it's what the threat model requires the moment you draw both exfil paths on the same page. One plane means half the attack surface is invisible and you don't get to pick which half. Hold-before-forward, and why the latency is the point The proxy calls EvaluateRequest before it writes the frame to the child process. On deny, it synthesizes a JSON-RPC error and the call never happens. On allow, it forwards, waits for the response, and runs IngestResult on the way back before the agent ever sees the result. The alternative, forward-then-inspect, is cheaper and also useless: a blocking firewall that inspects after the secret already reached the child has already lost Variant A. So the agent waits on evaluate plus a backend round trip. I pay latency to keep the word "prevented" honest, and I think that trade is correct, though I did stare at the p99 numbers for longer than I'd like to admit. The trifecta state machine Per-session state tracks three legs: LegLights when SensitiveSourceTouched Sensitive-tagged tool result, remote taint, or a sensor openat on a sensitive path UntrustedContentPresent Non-sensitive tool results, and stores excerpts for later content binding ExternalSinkInvoked An external-sink tool call, or non-allowlisted egress observed by eBPF Legs decay. Default leg TTL is 30 minutes, or 32 calls, whichever hits first. A session that read one config file at 9am should not still be soft-tripping at noon. Why three, and not two, and not four. An earlier design used sticky legs with no content requirement, which produced the number in the next section. And "relevance," "encoding form," and "container abort" are deliberately not legs, they're evaluation-time qualifiers inside the classifier. Promoting them to legs would reintroduce sticky content-blind state, which is the exact thing that broke version zero. The number that forced the rewrite Version zero worked the obvious way. Three sticky booleans. Sensitive data touched, set a flag. Untrusted content seen, set a flag. External sink invoked, set a flag. All three lit, block the call and kill the process. It is a completely reasonable first design. It is also content-blind: it never asks whether the secret actually moved. It only asks whether the session had, at some point, held all three capabilities. Measured on a benign corpus: 46.7% any-trip false positive rate. Nearly half of legitimate sessions tripped. And of course they did. Think about what a normal coding-agent session looks like on a Tuesday: it reads ~/.config/something.yaml to figure out your project setup (sensitive leg), it fetches a docs page or reads a GitHub issue (untrusted leg), it calls an API or resolves a hostname (sink leg). Three legs. Nothing left the building. Nothing was even close to leaving. Hard-blocked anyway. A false positive rate is not an aesthetic metric for a blocking control. It's an existence metric. A firewall that blocks half of legitimate work gets uninstalled in week one, and then the false negative rate is 100% because the product isn't running. The fix: verdict and action are two different questions The rewrite separated two things that were previously one enum: Verdict answers: what did we conclude about this sink event? Action answers: what did enforcement actually do? Collapsing them breaks three behaviors that turn out to be load-bearing: monitor mode, soft detection without hard blocking, and the distinction between preventing a leak and containing one after the first packet. The classifier, verbatim, because the ordering is the design: // internal/engine/engine.go - classifyTrip if overlap != nil { return model.VerdictExfil, 0.95, true // before any AllLit check } if !state.Legs.AllLit() { return "", 0, false } // … soft SUSPICIOUS gates only after this … Read that carefully. EXFIL is overlap alone. All three legs can have fully decayed and EXFIL still fires, as long as taint is retained and the bytes match. Conversely, all three legs lit with no byte proof is SUSPICIOUS, which is an alert, not a kill. Action tables: Variant A (proxy, can prevent): Verdictenforcement: blockenforcement: monitor EXFILDeny, preventedAllow, allowed_monitor SUSPICIOUSAllow, allowed_monitorAllow, allowed_monitor Variant B (sensor, can only contain): VerdictDecision EXFILDeny, contained_by_kill SUSPICIOUSAllow, detected_only The vocabulary gap between prevented and contained_by_kill matters more than it looks. One means the packet never left. The other means it left and then the process died. Calling both "caught" would erase the only distinction an incident responder actually cares about. Results after the rewrite: MetricValue EXFIL-tier detection (non-gap malicious)100.0% (31/31) EXFIL-tier false positives (benign)0.0% (0/37) Any-trip soft alerts (benign)18.9% (7/37) Those remaining 7 are pinned by design, operator-visible soft alerts, not hard enforcement failures. Proving the bytes moved "Overlap" is doing a lot of work in this post, so here's what it actually is. When a sensitive result comes back, Interlock extracts tainted values (Stripe-style keys, bearer tokens, PEM private key blocks, PuTTY key files, account IDs) and precomputes a closed set of canonical encodings for each: literal, base64, hex, url_encoded, reversed, base64_hex, hex_base64, base64_url, base64_reversed, gzip_base64, brotli_base64, zstd_base64, lz4_base64 Closed set, not unbounded transform search. That's a product decision, not laziness: an open-ended search space is how you end up with a detector that fires on compressed JPEGs. Custom ciphers are a named permanent gap and I'd rather name it than paper over it. On a miss, a bounded recursive decoder unwraps base64 and hex up to depth 5 (clamped to [3, 5], because letting operators set decode depth to infinity is offering attackers a CPU sink with a config flag). Depth 5 was chosen because EXFIL-tier false positives stayed at 0.0% across depths 3, 4, and 5, and decode-miss latency stayed around 380 to 390 microseconds. There's also cross-call fragment reassembly (a secret split across paginated results still gets caught when the halves abut), egress flow reassembly for fragmented DNS and writes, container descent into ZIP/gzip/zlib/tar with hard bomb caps, and chunk matching for long secrets whose full form gets truncated by the kernel capture window but whose 32-byte body chunks still appear. Each of those closes a specific attack shape, costs CPU or memory or false positive surface, and leaves a named residual. That's the honest accounting, every mitigation is a trade and I tried to write down which one I made each time. The kernel side, and one thing I refuse to overclaim The sensor attaches syscall tracepoints and drains two separate ring buffers: RingSizeCarries Routine256 KiBconnect, openat Critical256 KiBwrite, writev, sendto, sendmsg, LSM denies Segregated so a connect() storm cannot starve the ring carrying actual [truncated for AI cost control]