Skip to content
Writing

A Quarter of My Detector Does Nothing

July 29, 20269 min read
Technical
Eighteen months ago I published a result I didn't enjoy: behavioural anomaly detection cannot catch structurally legitimate misuse. Not does not yet. Cannot. Every call an agent makes is individually permitted; the harm is in the aggregate; and a monitor reading the trace afterwards can only ever describe what already happened. I wrote that up in When the Threat Model Is the Model and left it there, which is the comfortable place to leave an argument. The uncomfortable question is the next one. If after-the-fact monitoring is the wrong shape, what's the right one? And if you build it, does it actually work? So I built it. chokepoint is a policy-enforcing proxy for MCP tool servers, in Go, public and Apache-2.0. It sits between an agent and its tools, and it scores sessions in the request path — while the calls are happening, when refusing them still means something. Then I measured it against the least sophisticated attacker I could imagine, and found that a quarter of the detector is dead weight. This post is both halves. The build is the easy half.
The architectural claim in the original research was narrow: the problem is when you look, not how well. A trace auditor is reading history. History is not actionable. Move the same computation upstream of the decision and it becomes a control. Concretely, chokepoint runs the MCP server as a child process and speaks JSON-RPC in both directions. Every tool call gets evaluated against a CEL policy before it reaches the server. Alongside that, the proxy maintains a running 20-feature behavioural vector for the session — the same feature space from the research — and scores it for decomposition: many small legitimate calls that sum to something you'd never have approved as one request. Three design decisions carried more weight than the rest. Messages keep their original bytes. Anything forwarded unmodified is forwarded verbatim. This sounds pedantic until you consider what decode-and-re-encode does to a protocol message: it reorders keys, drops fields your struct doesn't know about, and renumbers floats. A proxy that quietly rewrites traffic is a proxy that breaks servers for reasons nobody can reproduce. Denials are well-formed protocol errors. When policy refuses a call, the agent receives a legitimate JSON-RPC error, not a dropped connection. A policy decision should never present to the caller as an outage. If your security control is indistinguishable from a crash, it will be removed by whoever is on call. The two directions are asymmetric. Client-close means "no more requests." It does not mean "discard the answers already in flight." I got this wrong initially and the bug was invisible in unit tests — every package passed. An end-to-end run against a real subprocess showed 40 of 41 replies being dropped on the floor. There's a test named TestInFlightRepliesSurviveClientClose now, and it exists because the unit tests were all green while the thing did not work.
Unit tests check that your components do what you think. Only an end-to-end run checks whether what you think is the system.

The scoring is a weighted sum: target breadth at 0.40, transition novelty at 0.25, action entropy at 0.20, low repetition at 0.15. Nothing scores below eight calls, because the first few calls of any session are all novel and all distinct, and a naive detector flags every agent the moment it starts working. The first draft shipped a policy threshold of 0.7. It felt right. Seventy percent suspicious, block it. Then I wrote a calibration test — six session shapes, three benign and three not, scored and printed as a table:
shapescore
poll-one-file0.007benign
focused-debug0.214benign
build-pipeline0.225benign
cyclic-sweep0.608suspicious
exfil-crawl0.648suspicious
randomised-sweep0.666suspicious
Benign peaks at 0.225. Suspicious bottoms at 0.608. The usable band is everything between, and my confident 0.7 sat above the entire band. It would have shipped looking authoritative and never fired once. Not on a single one of my own malicious fixtures. The threshold is 0.45 now, and the calibration test fails the build if it ever drifts outside the measured band. That's the pattern I'd defend hardest out of this whole project: every magic number gets a table behind it and a test that breaks when the table moves. A number chosen by intuition and never checked is not a conservative default. It's a decoration.
Here's where the honest half starts. Look at that table again, but at the novelty column instead of the score. transition_novelty — the fraction of tool-to-tool transitions seen exactly once — reads 0.000 for five of the six shapes. The sixth reads 0.062. It holds a quarter of the total weight. Across my entire calibration suite, it contributes essentially nothing. I'd documented a soft version of this in the README: novelty is "informative for short sessions and large tool vocabularies, and close to inert otherwise." True, and vague enough to be comfortable. So I went and measured the actual shape of the failure, running the feature over synthetic sessions at increasing lengths:
session lengthdistinct tool every callvocabulary of 20, looping
201.0001.000
401.0000.050
601.0000.000
2401.0000.000
It isn't a decay. It's a cliff. The moment a session cycles its tool vocabulary about twice, every transition has been seen more than once, and the feature reads exactly zero — forever, for the rest of the session, no matter how long it runs or how much it touches. Now think about what that means for an adversary. The evasion is not clever. There is no crafted payload, no timing attack, no knowledge of my thresholds. The attacker just writes the sweep the way you would naturally write a sweep: a loop over a fixed set of tools. That alone zeroes 25% of the score. The consequences compound. A looping attacker's maximum achievable score is 0.75, not 1.0 — and that's why every "suspicious" row in my calibration table clusters between 0.608 and 0.666 rather than approaching the top. They're all periodic, so they all forfeit the same quarter. Which means my 0.45 threshold, which I chose as a comfortable midpoint of a 0–1 range, actually sits at about 60% of the range a real looping attacker can reach.
A feature that any repeating sequence sets to zero is not measuring intent. It is measuring whether the attacker bothered to write a loop.

This is the same finding as the original paper, arriving from the other direction, and I did not expect that. The research said: behavioural detection catches agents whose behaviour is unusual. It fails on agents whose behaviour is individually ordinary and only collectively harmful. My conclusion then was that detection catches sloppy agents and misses patient ones. Now I've built the enforcement side, and it reproduces the limit in miniature. Novelty catches the agent that flails — that reaches for a new tool every call, leaving a trail of once-seen transitions. It is blind to the agent that settles into a rhythm. Patience, in this feature space, is literally indistinguishable from safety. Repetition reads as routine. I could patch it. Normalise novelty by vocabulary size, or window it, or weight transitions by recency. Some of that will help. But I want to be careful about the reflex, because the reflex is exactly the mistake the original paper was written against: treating a structural limit as a tuning problem. You can always add a feature that catches the specific evasion you just thought of. That is not the same as closing the class, and the difference between those two things is most of what I care about in this work. The current weights aren't derived from labelled data. They're derived from a paper's characterisation of what decomposed misuse looks like, which is one step better than intuition and several steps worse than evidence. Fixing that properly means finding labelled agent-trace corpora — malicious and benign, at session granularity. I've started looking. I'm not confident they exist in usable form, and if they don't, that absence is itself worth writing down.
The README carries a limitations section, and I keep it accurate because a security tool that oversells itself is worse than no tool. The current honest list:
  • 4 of 20 features aren't computed. Three need a peer or historical baseline the process doesn't have on a cold start; one, privilege deviation, has no representation in the protocol at all. That count is machine-checked by a test, because an earlier draft claimed 8 of 20, which was wrong, and it reached a CV before anyone caught it.
  • Target extraction is heuristic. It keys on conventional argument names, so a server using unusual ones under-reports breadth.
  • Padding defeats the score. Repeat calls lower breadth ratio and entropy together. This raises the attacker's cost; it does not close the class.
  • stdio transport only. Streamable HTTP is the largest functional gap, and it's next.
And now the novelty cliff, quantified rather than hedged.
There's an obvious argument for staying quiet about the 25%. I've just handed a reader the cheapest possible evasion for a tool I wrote. I don't think that argument survives contact with the threat model. Anyone motivated to evade chokepoint will read the source — it's Apache-2.0, the weights are in a struct, the calibration table is in the README. The evasion is discoverable in about ten minutes of reading, and it's discoverable by accident by anyone who happens to write their sweep as a loop, which is most people. The only party kept in the dark by my silence is the operator deciding whether to trust it. Negative results in this area are hard to fund and easy to skip, and they're the ones that determine whether a control is worth mandating. A monitoring regime adopted because it looked rigorous, by people who never saw its evasion surface, is worse than no regime — it converts an open problem into a solved one on paper while leaving it open in fact. So: chokepoint enforces policy inline, refuses tool calls for what they are and for what they add up to, and sets its thresholds from a table it re-derives on every build. It also has a quarter of its score turned off against any attacker patient enough to repeat themselves, and I'd rather you knew that before you put it in front of anything.
Build the guard. Then go looking for the door it doesn't cover, and publish the door.
Source: github.com/BipinRimal314/chokepoint. The calibration table regenerates with go test ./internal/detect -run Calibration -v.