MCP fixture indirection
This page is rendered from docs/mcp-fixture-indirection.md in the open-source AgentShield repository at commit 4c6f8a9.
Problem it solves (issue #2925). MCP detection rules need true-positive fixtures
that contain the very phrasing the rule detects (exfil relays, prompt injection,
av-evasion, …). When an AI agent authors a dense batch of these rules in one
session, the accumulated attack phrasing trips Claude Code's session
safety-classifier — a harness-level meta-classifier, distinct from AgentShield
and the permission prompt. Once tripped, it refuses all go execution for the
rest of the session (go build, go test, even go version), so the agent can't
verify its work locally. This has caused real overnight work loss on our
highest-moat rule development.
Fix. Keep the raw payload out of the session transcript. Instead of writing a
test case's arguments inline, store them as an opaque base64 blob in an args_b64
field. The committed rule — and every later read of it — carries only the blob, so
the batch never accumulates enough readable attack phrasing to cross the
classifier threshold.
args_b64 is test-only: at runtime, tool-call arguments come from the MCP
proxy, never from a rule's tests: block. Encoding fixture args cannot affect live
policy evaluation.
Field
tests:
tp:
- tool: "write_file"
args_b64: "eyJwYXRoIjoiL2V0Yy9zaGFkb3cifQ==" # base64 of {"path":"/etc/shadow"}
tn:
- tool: "write_file"
args: {"path": "/workspace/README.md"} # inline is still fine for benign TNs
args_b64is base64 of a JSON object — the same map you would have written underargs:.argsandargs_b64are mutually exclusive on a single case (setting both is a hard error).- Numbers decode as
float64(JSON semantics). Immaterial for the string-valued tool arguments (paths, content, URLs) these rules match on — use plain inlineargsfor the rare numeric-argument case. - Only the sensitive cases need encoding. Benign TNs can stay inline for readability; mix freely within one rule.
Authoring workflow
Use the encode-fixture helper so the raw phrasing appears at most once,
transiently, and never lands in a committed file:
# Encode: JSON args on stdin -> base64 for the args_b64 field
echo '{"path":"/etc/shadow","content":"…"}' | go run ./cmd/encode-fixture
# Decode (for review/debugging): base64 on stdin -> pretty JSON
go run ./cmd/encode-fixture -d <<< "eyJwYXRoIjoiL2V0Yy9zaGFkb3cifQ=="
For an agent operating under the classifier: write the args JSON to a scratch file it will not re-read (or pipe it straight in), encode once, and paste only the resulting base64 into the rule YAML.
How it decodes
internal/mcp.MCPTestCase.ResolvedArgs() is the single canonical decode path. The
inline-test harness (internal/mcp/rule_yaml_test.go) calls it for every TP/TN
case, so inline args and args_b64 are behaviorally identical — verified by
TestFixtureIndirection_EndToEnd in internal/mcp/fixture_indirection_test.go.