V4 lock addendum (DSE-1539): The existing inline tools/list drift gate also compares complete annotation/output-schema hashes for schema-level-4 baselines. Legacy locks retain description/input-schema coverage until reviewed and re-pinned. The same strict/audit-only/opt-out semantics apply; annotations grant no permissions.
Status: v0.3 security contract. Implementation-ready. Extends — does not replace — the
v0.2 contract below; v0.2 statements hold except where a v0.3 note overrides them (default
posture in §5; proxy hardening in GUARD_PROXY_V3.md).
Commands: mcp-warden guard <server-cmd...> (live proxy) ·
mcp-warden inspect <trace.jsonl> (offline analyzer)
What this is.
guardis a transparent stdio proxy that sits between an MCP client and an MCP server, running the result-inspection catalog (RESULT_INSPECTION.md) ontools/callresponses and the v0.1 argument policy (POLICY_MODEL.md) ontools/callrequests. As of v0.3 the deterministic tier blocks by default (WRD-RES-ANSI,WRD-RES-SECRET-ECHO,WRD-RES-EXFIL-DOMAIN, thetools/list_changeddrift gate, and argument-policy denials — the council established its field false-positive rate is ~0); the fuzzyWRD-RES-INJECT-PHRASEtier stays monitor-only / opt-in. v0.3 also adds the proxy-hardening contract (cancellation/progress passthrough + subprocess-lifecycle edge cases) inGUARD_PROXY_V3.md. v0.2 shipped shadow-default (nothing blocked without--block-*). v0.3 is the first release that actively blocks by default; the availability/UX risk and the opt-out flags that restore observe-first behavior are stated honestly in §5 andTHREAT_MODEL_V2.md§8.What this is not. It is not a full agent firewall. It does not understand model behavior, does not correlate across calls, and does not defend
T-BEHAVE(THREAT_MODEL_V2.md). It is a narrow, honest, frame-disciplined result/argument inspector on the stdio wire.
client (agent / host) <== stdio ==> mcp-warden guard <== stdio ==> server (child)
^ | ^
| guard's stdin/stdout | spawned as argv array |
| ARE the client-facing pipe | (NEVER via a shell) |
- The client launches
mcp-warden guard <server-cmd...>exactly where it would have launched the server.guardspawns<server-cmd...>as its child (argv array, no shell — same rule aspin/check,WARDEN_LOCK_SCHEMA.md§4.1). guard's own stdin carries client→server frames; its stdout carries server→client frames (two directions, one process).guard's child's stderr is passed through toguard's stderr untouched (§4.5).
The proxy's safety comes from doing almost nothing. Everything below is normative.
guard parses just enough of each JSON-RPC frame to read its method (requests) or to
correlate a result to its originating request id (responses). It then:
| Frame | Action |
|---|---|
tools/call request |
inspect arguments against the runtime argument policy (§6); pass through (shadow) or block (opt-in) |
tools/call response (a result whose request was a tools/call) |
inspect result content against RESULT_INSPECTION.md; pass through (shadow) or block (opt-in) |
tools/list_changed notification |
gated against warden.lock (§6.3) |
| everything else | passed through byte-for-byte, unmodified |
"Everything else" explicitly includes initialize, ping, resources/*, prompts/*,
logging/*, completion/*, sampling/*, progress notifications, cancellations, and any
method guard does not recognize. Unknown methods are forwarded verbatim — guard
fails open on methods it does not model.
v0.3 (normative):
notifications/cancelledandnotifications/progressMUST pass through untouched — never inspected, blocked, buffered, or reordered, even while atools/callis in flight. The exact interleaving rule vs the single-event-loop frame discipline (§2.3) — closing the hole where a result-block on an in-flight call could stall a racing cancel/progress frame — is inGUARD_PROXY_V3.md§1.
guardMUST forwardinitialize(request and response) and the negotiated capabilities completely untouched. It MUST NOT add, remove, or alter any capability, protocol version, server info, or instructions field. Tampering with the handshake can break the session or silently change negotiated behavior — out of scope and forbidden.guardrecords the negotiatedprotocolVersion(read-only) for logging and for thetools/list_changedgate, but does not act on it.- Enforcement begins only when the first
tools/callappears. Before that,guardis a pure pass-through. This bounds the proxy's blast radius to exactly the two inspected frame types.
guardruns a single async event loop (the project already depends onanyio).- Each direction (client→server, server→client) is read by one reader that yields one complete JSON-RPC frame at a time. There is no multi-threaded read loop, no concurrent partial-frame readers on the same stream. Concurrency is via the event loop's two direction-tasks, not threads sharing a buffer.
- Rationale: a multi-threaded reader on a byte stream races on frame boundaries and is the classic source of frame-interleaving / smuggling bugs. One reader, one framer, one frame.
MCP stdio uses newline-delimited JSON (one JSON object per line). Some transports use
LSP-style Content-Length: headers. guard MUST support both:
- Newline framing: read until
\n; the line (minus the newline) is one JSON-RPC frame. - Content-Length framing: read the header block (CRLF-terminated headers ending in a
blank line), read exactly
Content-Lengthbytes as the body. - The framing mode is detected from the first bytes of each stream (a
Content-Length:header prefix ⇒ header framing; otherwise newline framing) and is fixed per stream for the session. The two directions are framed independently but in practice match. guardre-emits frames in the same framing mode it received them, so the peer sees the framing it expects. When a frame is passed through unmodified, the original bytes are forwarded (no re-serialization) to avoid canonicalization differences; only an inspected-and-modified frame (a block result, see §7) is re-serialized.
- Result inspection runs incrementally over the decoded result text as it is read,
never by buffering an unbounded result fully in memory first. The deterministic rules
(
WRD-RES-ANSI,WRD-RES-EXFIL-DOMAIN,WRD-RES-INJECT-PHRASE) are streamable: codepoint scan, host-token scan, and normalized-substring scan all work over a sliding window. - A configurable max-frame cap (
--max-frame-bytes, default 8 MiB) bounds memory. A frame exceeding the cap is passed through unmodified with aWRD-RES-FRAME-ERRORnote (fail-open on resource limits — availability over inspection;RESULT_INSPECTION.md§5.3).
guardspawns the child in its own process group (start_new_session/setpgid).- Signals received by
guard(SIGINT,SIGTERM,SIGHUP) are explicitly forwarded to the child's process group, thenguarddrains in-flight frames and exits. - On child exit,
guardflushes any buffered output to the client, closes the client-facing pipes cleanly, and exits with the child's exit code (so the client sees the real server's exit status). Aguard-internal fatal error exits2(consistent with v0.1pin/checkIO-error code). - On client EOF (client closed its end),
guardforwards EOF to the child and shuts down. - No orphaned children: the process-group + signal-forwarding discipline guarantees the child is reaped on every exit path.
v0.3 (normative): edge cases v0.2 left under-specified — server crash mid-call, client disconnect/EOF, partial frame at EOF, oversized frame beyond
--max-frame-bytes— are defined inGUARD_PROXY_V3.md§2 and supersede any ambiguity here.
- The process-group / signal-forwarding model above is POSIX. On Windows,
guardis experimental: it uses job objects +CTRL_BREAK_EVENTbest-effort, and the contract's lifecycle guarantees are not asserted. v0.2 supports POSIX (Linux/macOS) as the contract surface; Windows is documented as experimental and untested for the lifecycle guarantees.
v0.3 (normative): Windows remains EXPLICITLY EXPERIMENTAL — no POSIX process groups, different signal model, no parity claim. Precise degradation + fail-safe in
GUARD_PROXY_V3.md§3.
mcp-warden inspect <trace.jsonl> runs the same RESULT_INSPECTION.md catalog over a
recorded session, with no live processes.
- Input: a JSONL trace where each line is one recorded JSON-RPC frame, in observed order.
A minimal record is the raw JSON-RPC object; an enriched record MAY wrap it as
{"direction": "c2s"|"s2c", "ts": <rfc3339>, "frame": <json-rpc>}.inspectaccepts both (bare frame ⇒ direction inferred frommethod/result+idcorrelation). inspectcorrelatesresultframes to theirtools/callrequests byidexactly asguarddoes, then runs the identical catalog. No blocking (offline; there is nothing to block) —inspectis always report-only: SARIF + JSONL + exit code.guardMAY optionally record its observed frames to a trace (--record <trace.jsonl>) so a live session can be re-analyzed offline withinspectand the two MUST agree.- Exit code:
0if no BLOCK-tier finding; non-zero if any BLOCK-tier (WRD-RES-ANSI/-SECRET-ECHO/-EXFIL-DOMAIN) finding is present (soinspectis usable as a CI assertion over a captured trace),2on read/parse error of the trace file itself. MONITOR-tier findings alone do not failinspect(they are warnings).--audit-onlyforces exit0regardless of findings.
When a tools/call request crosses client→server, guard runs the v0.1 argument policy
(POLICY_MODEL.md) against the call's arguments, using the same shape recognition,
constraint vocabulary, and fail-closed defaults defined there — now applied at runtime to
the live call, which POLICY_MODEL.md §4.3 / THREAT_MODEL.md T-RUNTIME-PROXY named as
the deferred v0.2 work.
- The policy file is supplied via
--policy <file>(optional; absent ⇒ argument policy inactive, result inspection still runs). - The DNS-name limitation from
POLICY_MODEL.md§2.3 is lifted only as far as the literal host:guardstill does not resolve DNS (no network from the proxy). IP-literal hosts are matched against SSRF ranges; DNS-name hosts emitPOL-HTTP-DNS-UNRESOLVED(note) exactly as design-time. (Resolution-time SSRF remains a v0.3 concern.) - A policy deny follows the same default posture as the deterministic result rules (§5):
in v0.3 a deny blocks by default (when
--policyis supplied) per §7;--no-block-policydemotes it to shadow (logs the deny, passes the call through). (v0.2: shadow + opt-in.)
When the correlated result crosses server→client, guard extracts inspectable text
(RESULT_INSPECTION.md §1) and runs the full catalog. Per-tool precision uses the pinned
tool's declarations from warden.lock (--lock <file>; absent ⇒ fail-safe defaults,
RESULT_INSPECTION.md §6).
guardforwardstools/listrequests/responses untouched (it does not re-pin).- On a
notifications/tools/list_changed, and on the nexttools/listresponse that follows it,guardrecomputes the tool surface digest (reusingpin's capture + hashing,WARDEN_LOCK_SCHEMA.md§3–§6) and compares towarden.lock.- If it matches the lock ⇒ forward normally.
- If it diverges from the lock ⇒ mid-session drift (
MCP-DRIFTat runtime), a BLOCK-tier condition. In v0.3 the divergenttools/listresponse is blocked by default (§7) so the client never sees the rug-pulled surface;--no-block-list-changeddemotes it to shadow (log + forward).--lockrequired; absent ⇒ the drift gate is INACTIVE (no lock to diff against; list responses forwarded untouched, no per-frame note — surfaced once at startup by the posture banner §8, not per frame). (v0.2: shadow + opt-in.)
- This closes
T-TOCTOU-CALLpartially: a server that lists a clean surface topinand then swaps it mid-session is caught when it announces or returns the changed list.
guard maintains an in-memory id → method map for in-flight requests so it can tell which
result frames belong to tools/call. The map is bounded (LRU by --max-inflight, default
1024) and entries are dropped on response or on the bound. A result with no known request
id (or a result for a non-tools/call method) is passed through uninspected.
The child's stderr is forwarded to guard's stderr unmodified and uninspected. stderr
is the server's diagnostic channel, not the JSON-RPC channel; guard never parses or blocks
it. (Operators who want stderr scanned can pipe it to inspect-adjacent tooling; that is out
of scope here.)
This is the behavioral contract. It mirrors RESULT_INSPECTION.md §8 exactly.
The v0.2 → v0.3 posture change (read this first). v0.2 shipped shadow-default (nothing blocked without
--block-*). v0.3 promotes the deterministic tier to block-by-default (council-established field false-positive rate ~0) — the first mcp-warden release that actively alters or drops frames out of the box. Honest trade (perTHREAT_MODEL_V2.md§8): a default-block can interrupt a session a shadow build would have left alone. Mitigation: every category is individually opt-OUT-able (§5.2) and--audit-only(§5.3) restores full shadow in one flag for observe-first operators.
The following are enforced by default — a match neutralizes the frame on the wire per §7 unless an opt-out (§5.2) is set:
| Default-blocking category | Rule / gate | Wire behavior (§7) |
|---|---|---|
| ANSI / control escapes | WRD-RES-ANSI |
redacted-content (strip in place) |
| Secret echo | WRD-RES-SECRET-ECHO |
error-replacement (redact mode opt-in) |
| Exfil domain | WRD-RES-EXFIL-DOMAIN |
error-replacement |
| Mid-session tool-surface drift | tools/list_changed gate (§4.3) |
error-replacement — only when --lock is supplied |
| Argument-policy deny | runtime policy (§4.1) | error-response — only when --policy is supplied |
- The
tools/list_changedgate and the argument policy are default-on only when their required input is supplied (--lock/--policy) — supplying it arms them; once armed they block by default. No separate enable flag exists in v0.3. - The fuzzy
WRD-RES-INJECT-PHRASE(MONITOR tier) is NOT default-on (no field false-positive data yet); it stays monitor-only/opt-in (§5.2). Must-not-deviate: the fuzzy tier never becomes default-block in v0.3.
The full v0.3 flag scheme, naming rule, and worked combinations live in
GUARD_PROXY_V3.md §4. Summary:
- Opt-OUT →
--no-block-<category>demotes that category to shadow (still detects/logs/SARIF, forwards unmodified,properties.action: "shadowed"):--no-block-{ansi,secret-echo,exfil-domain,list-changed,policy}and--no-block-deterministic(whole tier + both gates).--allow-exfil-domainis the sole affirmative alias (==--no-block-exfil-domain). - Opt-IN the fuzzy tier →
--block-inject-phrase(WRD-RES-INJECT-PHRASE; never default). - Deprecated no-ops: v0.2 enable flags (
--block-ansi…--block-deterministic) are accepted but inert (categories already block); a one-line stderr note fires.
Precedence: --audit-only > per-category --no-block-* opt-out (and
--allow-exfil-domain) > default-block (deterministic tier) / --block-inject-phrase
(fuzzy tier).
--audit-only forces every detection to a warning and disables all blocking/mutation
regardless of any other flag (ANSI stripping becomes a warning, not a mutation) — the
single-flag way to restore full v0.2-style shadow ("shadow mode, locked on"). Absent it, each
deterministic category blocks unless its --no-block-* opt-out (or --no-block-deterministic)
is present; the fuzzy tier blocks only with --block-inject-phrase. Contradictory-combo
resolution (e.g. opt-out vs deprecated no-op enable) is spelled out in GUARD_PROXY_V3.md §4.
The v0.1 POLICY_MODEL.md constraints (fs-write path allow/deny, shell-exec
deny-by-default + metachar denial, http-request SSRF ranges, sql-query leading-keyword)
evaluate against the live tools/call arguments. Fail-closed defaults are unchanged and
are normative: shell_exec.allow=false, http_request.deny_private=true,
sql_query.allow_readonly_only=true, empty allow_paths = deny-all. Deny overrides allow.
RESULT_INSPECTION.md rules evaluate against the live result. Per-tool precision from
warden.lock §11 applies; absent ⇒ fail-safe.
Defined in §4.3. Requires --lock; this is the only runtime check that reuses the full
pin capture+hash pipeline.
When guard blocks, the client MUST receive a well-formed JSON-RPC frame so its session
does not hang or crash. The behavior depends on which side is blocked.
7.1 Blocking a tools/call request (argument-policy deny — default-on in v0.3 when --policy supplied)
guard does not forward the request to the server. It synthesizes a JSON-RPC error
response to the client for that request id:
The server never sees the call. The client sees a normal error response and can proceed.
A result is already produced by the server; guard chooses how to neutralize it. The
mode is per-category:
(a) Redacted-content mode (default for WRD-RES-ANSI, and for WRD-RES-SECRET-ECHO when
--redact-secret-echo is set): guard forwards a modified result with the offending
content neutralized in place, preserving the result shape so the agent still gets a usable
(sanitized) answer:
WRD-RES-ANSI: the disallowed codepoints are stripped from the text block(s); the rest of the result is intact.WRD-RES-SECRET-ECHO(redact mode): the matched secret substring is replaced with its redactionfirst4 + "…" + "(len=N)"(theCHECKS.mdredaction rule), in place.
The modified result carries a marker so the change is auditable:
{
"jsonrpc": "2.0",
"id": <id>,
"result": {
"content": [ { "type": "text", "text": "<sanitized text>" } ],
"isError": false,
"_meta": { "warden": { "modified": true, "rules": ["WRD-RES-ANSI"] } }
}
}(b) Error-replacement mode (default for WRD-RES-EXFIL-DOMAIN, and for
WRD-RES-SECRET-ECHO unless redact mode is chosen): guard drops the original result
and sends a JSON-RPC error response for that id (same error shape as §7.1, with
"stage": "response" and the WRD-RES-* rule id). The agent gets an error, not the poisoned
content. This is the safe default for exfil URLs and full secret echoes, where partial
redaction could still leak structure.
Default mapping (normative):
WRD-RES-ANSI→ redacted-content (strip control chars; the answer is still useful).WRD-RES-EXFIL-DOMAIN→ error-replacement (do not hand the agent any exfil URL).WRD-RES-SECRET-ECHO→ error-replacement by default; redacted-content only if--redact-secret-echois explicitly set.WRD-RES-INJECT-PHRASE→ never blocks by default (MONITOR, opt-in in v0.2 and v0.3). If opted in via--block-inject-phrase, it uses error-replacement.
guard replaces the divergent tools/list result with an error response
("stage": "list_changed", "reason": "tool surface diverged from warden.lock"), so the
client never ingests the rug-pulled tool surface. (It does not synthesize a fake clean
list — fabricating a surface would itself be a tampering risk.)
-32001= policy/result block (this section).-32002= transport/lifecycle synthetic error (server-exit / EOF-teardown,GUARD_PROXY_V3.md§2.6). Both are in the server-error range-32000..-32099and carrydata.warden: true;data.stage+data.rulemake blocks unambiguous to clients and log scrapers.- Any secret value referenced in an error
data.reasonMUST be redacted with theCHECKS.mdrule. An error object explaining aWRD-RES-SECRET-ECHOblock MUST NOT contain the raw secret.
A framing/parse/inspection error (WRD-RES-FRAME-ERROR) is not a block: the frame
passes through unmodified and a note is logged (§9). Only a policy/result-rule match in a
category that blocks (default-on deterministic tier, or the opted-in fuzzy tier) produces an
on-the-wire block.
| Flag | Purpose | Default |
|---|---|---|
--lock <file> |
enables per-tool precision (§11 of lock schema) + the tools/list_changed gate |
absent ⇒ fail-safe defaults; drift gate INACTIVE (surfaced by the startup posture banner) |
--policy <file> |
enables runtime argument policy (POLICY_MODEL.md) |
absent ⇒ argument policy inactive (surfaced by the startup posture banner) |
--exfil-denylist <file> |
org "never-callback" domains, merged with the seed list | seed list only |
--inject-phrases <file> |
org exact injection phrases, merged with the seed list | seed list only |
--no-block-* / --allow-exfil-domain / --no-block-deterministic (§5.2–§5.3, GUARD_PROXY_V3.md §4) |
opt a default-blocking category (or the whole tier) back to shadow | off (blocks by default) |
--block-inject-phrase (§5.2) |
opt-in block for the fuzzy phrase tier | off (monitor-only) |
--block-* (v0.2 enable flags) |
deprecated no-ops in v0.3; emit a stderr note | accepted, no effect |
--audit-only |
force warnings, disable all blocking (highest precedence) | off |
--sarif <file> / --json <file> |
report sinks | stderr/log only |
--record <trace.jsonl> |
record observed frames for later inspect |
off |
--max-frame-bytes <N> |
per-frame memory cap (pass-through over cap) | 8 MiB |
--max-inflight <N> |
request-correlation map bound | 1024 |
--quiet / --no-banner |
suppress the one-shot startup posture banner (clean stderr for tooling integrations) | off (banner ON) |
All denylist/phrase files are literal entries (domains / exact phrases), never regex
(RESULT_INSPECTION.md §9). Startup posture banner: after config resolution, before the
child's first frame, guard writes a one-shot stderr banner — BLOCKING vs MONITOR-only vs
INACTIVE for the run (e.g. drift gate / argument policy INACTIVE without --lock / --policy);
derived entirely from the resolved config, names no server; suppress with --quiet / --no-banner.
A bug or malformed input in the proxy framing / inspection layer MUST NOT kill the
session. On any framing error, parse error, decode error, inspection exception, or
resource-limit hit: the frame is forwarded unmodified (pass-through), a
WRD-RES-FRAME-ERROR note is logged + emitted, and the session continues.
This is the deliberate asymmetry stated in RESULT_INSPECTION.md §5.3: policy verdicts are
fail-closed; inspector failures are fail-open. The user's MCP session is never broken by a
warden defect. In v0.3 the only frames altered/dropped are those that (a) matched a rule and
(b) are in a blocking category (per §5.3). An oversized frame beyond --max-frame-bytes fails
open (GUARD_PROXY_V3.md §2.4).
guard and inspect emit the same SARIF 2.1.0 + JSONL shape as v0.1 (CHECKS.md §2):
ruleId == the WRD-RES-* / POL-* id verbatim, level per the severity→level mapping.
Each finding records direction (s2c/c2s), the JSON-RPC id, the tool name, the
content-block index (result rules), the tier, and properties.action
(blocked|shadowed|modified|passed); all secret snippets are redacted (CHECKS.md rule).
- Pass everything through untouched except
tools/callrequest/response (and thetools/list_changedgate). Never rewriteinitialize/capabilities. Enforcement starts only at the firsttools/call. - Single event loop, one complete frame at a time per direction. No multi-threaded read loops sharing a buffer.
- Support both Content-Length and newline framing; pass-through forwards original bytes (no re-serialization); only modified frames are re-serialized.
- Incremental scan; bounded by
--max-frame-bytes; over-cap frames pass through. - Spawn child as argv array, never via a shell (same as v0.1). Own process group + explicit signal forwarding; exit with the child's exit code.
- v0.3 default posture (replaces v0.2 shadow-default). The deterministic tier
(
WRD-RES-ANSI,WRD-RES-SECRET-ECHO,WRD-RES-EXFIL-DOMAIN, thetools/list_changedgate when--locksupplied, argument-policy denials when--policysupplied) blocks by default; each is opt-OUT-able via--no-block-<category>(alias--allow-exfil-domain) and--no-block-deterministicshadows the whole tier. The fuzzyWRD-RES-INJECT-PHRASEtier never default-blocks (opt-in via--block-inject-phrase).--audit-onlyoverrides all. v0.2--block-*enable flags are accepted no-ops. Full scheme:GUARD_PROXY_V3.md§4. - "Block" on the wire is a well-formed JSON-RPC frame: error-response for blocked
requests; redacted-content or error-replacement for blocked results, per the §7.2
default mapping; error-response for a blocked divergent
tools/list. Reserved error code-32001,data.warden: true. Never hang the client. - Framing/inspection errors PASS THROUGH (fail-open); they never block or kill the session. Policy verdicts remain fail-closed.
guardandinspectrun the identicalRESULT_INSPECTION.mdcatalog and MUST agree on the same bytes.- Secret redaction (
CHECKS.mdrule) applies to every output and every errordatafield. No raw secrets, ever. - Windows is experimental (lifecycle guarantees POSIX-only); v0.3 states the precise
degradation + fail-safe in
GUARD_PROXY_V3.md§3 and does not claim parity. - v0.3 proxy hardening (
GUARD_PROXY_V3.md) is normative:notifications/cancelled/notifications/progresspass through untouched even mid-tools/call(never inspected, blocked, buffered, or reordered); lifecycle edge cases (server crash, client EOF, partial frame at EOF, oversized frame → fail-open per §9) follow §2 of that doc.
{ "jsonrpc": "2.0", "id": <the request id>, "error": { "code": -32001, // mcp-warden reserved app error (see §7.4) "message": "mcp-warden: tools/call blocked by argument policy", "data": { "warden": true, "stage": "request", "rule": "POL-HTTP-SSRF", // the deny code from POLICY_MODEL.md §5 "tool": "call_api", "reason": "host 169.254.169.254 is in deny_private range 169.254.0.0/16 (link-local)" } } }