A worked example, not a mockup

Adversarial Issues Register — magus-opensecmcp.

Three independent adversarial reviews of our own open-source gateway, published in full: sixteen findings with severities and reproductions, and six that are still open, because a register that only lists wins is marketing.

A worked example. This is the artifact produced by an Adversarial Issues Register engagement, run here against our own code so it can be published in full. On a client engagement the findings would be yours and this document would be private; the format, the severity discipline and the treatment of what stays unfixed are the same.

Subject: magus-opensecmcp — a local execution firewall for MCP agents (Rust). Reviews: three independent adversarial passes, February–August 2026. Status: current as of commit 235b9f3.


What a register is for

Most security reviews produce a list of things to fix. That is the easy half. The harder and more useful half is a defensible record of what remains unresolved, how severe it is, and whether a solution path exists — because that is what you need when someone asks you to justify shipping.

A register is therefore written to be read by three people: the engineer who has to fix it, the person deciding whether to ship, and whoever asks about it in six months.


Method

Four rules, applied to every finding.

Reproduce, do not reason. A finding is not accepted until it has been demonstrated against the running system. Every entry below is labelled accordingly, and reasoned-only findings are marked as such rather than presented as confirmed. Three findings in these reviews were reported and then downgraded or reframed when reproduction did not support the reported version.

Ask what a test would catch, not whether it passes. Five defects in this codebase had their load-bearing property protected only by tests that would have passed for other reasons — decay tests fed literals the classifier could never produce; a corpus that asserted control tokens fire and never that benign files don't; a collision test exercising the boundary where the fix already held. Every one had a green suite.

Fix the layer the problem lives in. A calibration number cannot fix a measurement primitive's blind spot; a policy concept does not belong in an observation module. Several findings here were initially scoped to the wrong layer and re-scoped before work started.

A guard is not a guard until you have watched it fail. Introduce the violation the guard exists to catch and confirm it catches it.


Register

Severity reflects real-world impact under realistic deployment, not theoretical maximum.

IDSeverityFindingStatus
C1CriticalProvenance state machine permanently blocked all High-risk actions after the first real responseClosed
R3-1HighSession risk budget bricked legitimate sessions after ~46 calls, un-tunableClosed
H2HighModel-control-token rules poisoned sessions on ordinary source code and model configsClosed
H3HighOne Unicode confusable defeated the entire signature layerClosed
F1HighProvenance decay driven by an agent-controlled value ("decay bombing")Closed
F2HighRegex rules evaluated only the first match per responseClosed
M4MediumHash-pin enforcement — the headline defence — defaulted to offClosed
F3MediumMulti-server tool-name collisions resolved silently by config orderClosed
F4MediumTrusted-server tool descriptions were scanned but never sanitisedClosed
F5MediumNo timeout on downstream RPC reads; one stalled server wedged the gatewayClosed
R3-2Medium-HighDescription-injection defence inert against plain language; inputSchema never scannedOpen
R3-3MediumResponse scanner never inspects JSON object keysOpen
R3-4MediumDetection mechanically evadable, so the capability gate rarely engagesOpen
R3-5MediumA documented deterministic guarantee is unreachable at runtimeOpen
L7LowAdvisory delivery outcome computed and discarded; failures unrecordedClosed
L8LowHasher violated its own stated collision-resistance disciplineClosed
F7LowNo state persistence across restartsOpen, deferred
LowReplay protection unreachable by construction; its only live effect was a session brickClosed
LowRejection audit records log declared rather than effective risk classOpen

Selected entries, worked

Four chosen to show the range: a critical one, one that was reported wrongly, one found while investigating something else, and one that stays open.

C1 — the gate that never opened (Critical, closed)

Reproduced. Against a normally-behaving, never-compromised server, every High-risk action was refused permanently after the first tool call.

compute_new_state structurally could not return Clean for any response shape a real MCP server produces. Once a session reached Elevated — which happened on essentially the first call — a High-risk action was escalated to Critical, and Critical requires Clean. Decay could not recover it either: it could only earn progress from responses that were themselves Clean, and no real response could ever be.

Why the tests passed anyway. Every decay test hand-fed the tracker a Clean literal the classifier could never actually produce, and none chained through both tracker methods the way production does. The mechanism was correct in isolation and unreachable in place.

Layer. Three coordinated changes, none of them a threshold tweak: the classifier stopped treating response shape as an anomaly, decay progress was gated on each tier's own target, and the Elevated → Critical bump was removed.

R3-2 — reported one way, worse in another (Medium-High, open)

The reported finding was that description scanning missed natural-language injections. Reproduction confirmed it and found the mechanism is structural: every rule capable of recognising an instruction is scoped to tool output, so on a tool description only four control-token rules can fire at all. A plainly-worded injection matches nothing.

It also refuted our own prior conclusion. An earlier register entry had reasoned that the capability was "intact three other ways," one of which was a strict-scanning mode. Reproduction showed strict mode escalates a hit — and a plain-language description produces no hit to escalate. The mitigation we had recorded does not exist.

Separately, inputSchema is forwarded to the agent verbatim and never scanned, which is a documented MCP attack vector left fully open.

Still open. Fixing it requires a scoping decision with a real false-positive cost, not a patch.

Replay protection — unreachable by construction (Low, closed)

Found while investigating an unrelated availability defect.

Proposal identifiers are minted by the gateway itself. They are never supplied by the agent, the downstream server, or the request. A duplicate is therefore impossible and the replay check could never fire. Its only live effect was a 100,000-entry cache that eventually refused every call, reads included.

Its tests passed because they used fixed string identifiers and inserted duplicates by hand — exercised with input production cannot generate. Before removing the rejection codes we grepped 11,297 real audit records to confirm neither had ever been emitted, rather than asserting it.

This is the third mechanism found unreachable in production. That pattern is now a tracked work item in its own right.

R3-4 — the honest one (Medium, open)

The threat model's central claim is that detection is a signal layer and the capability gate is the thing to trust. Reproduction showed the gate is downstream of detection: a session only becomes gated if a rule fires, and a single comma inserted into a canonical injection phrase defeats the rule that would have fired.

We had shipped a fix, weeks earlier, closing character substitution evasion. Character insertion defeats the same rule. The earlier claim was true and narrower than it read.

Still open, and possibly permanently. No finite signature list closes paraphrase. The honest resolution may be a documentation change rather than a code change.


What is still open

Stated plainly, because a register that only lists wins is marketing.

  • Description and schema injection (R3-2). Plain-language injections in tool descriptions are not detected; inputSchema is not scanned at all.
  • Object keys are never scanned (R3-3). An entire syntactic position the model reads is invisible to the detection layer.
  • Detection is mechanically evadable (R3-4). And the capability gate is downstream of it.
  • A documented deterministic guarantee is unreachable (R3-5). One of two "no false-positive class" poison triggers cannot fire in the live pipeline.
  • No state persistence across restarts (F7). A Poisoned verdict does not survive a client restart.
  • Rejection audit records mislabel effective risk. The field exists to record the post-modulation value and records the declared one, on every rejection.

What this register does not license you to conclude

That the closed items are closed correctly in your environment. Every fix here was verified against this codebase, driven end-to-end against a real downstream server. That is evidence about this system, not a general guarantee.

That the open items are the only open items. A register records what was found by the passes that were run. The correct reading of "four findings in one week, three found by accident" is that the count is found, not exists.

That severity is transferable. These ratings assume this deployment. The same defect in a system with different blast radius is a different severity.


Produced by VaHive Systems Lab. If you want one of these for a system you actually run, the engagement is described under Services.