Skip to content

Security model

Threat model

The sidecar guards MCP exchanges between an LLM agent and upstream MCP servers. In scope:

  • Direct prompt injection in tool-call arguments (request side, TOOL role).
  • Indirect prompt injection in tool output, tool descriptions, resource contents and prompt bodies (response side, ASSISTANT role) — the primary frontline, since attacker-controlled text is exactly where instructions-to-the-LLM sneak back into the agent's context.
  • Secret / PII leakage in both directions (regex patterns + redaction).
  • Cross-call toxic flows: exfiltration sequences, retry storms, volumetric abuse, cumulative-budget bleed (Invariant rules).
  • Obfuscation evasion: hidden/zero-width Unicode, full-width forms, Cyrillic/Greek homoglyphs, base64-wrapped resource blobs, truncation padding.

Out of scope (by design):

  • Rewriting / normalisation as a policy primitive — other agentgateway modules' job (redaction excepted).
  • Network isolation — the deployer's responsibility. The sidecar binds plaintext h2c on :9001 with no authentication of its own; protect the gateway↔sidecar path with a Kubernetes NetworkPolicy, mesh mTLS, or namespace segmentation.

Trust assumption

Without network isolation, any workload in the same cluster can talk to the sidecar directly — bypassing the gateway, replaying or probing exchanges. In that posture the integrity of the Invariant trace windows and of individual scan verdicts must not be relied upon for cross-tenant guarantees. Deploy a NetworkPolicy (or mesh mTLS) restricting :9001 to agentgateway before treating audit verdicts as authoritative.

Failure modes

Failure Behaviour
Scanner raises / exceeds SCANNER_TIMEOUT_MS failClosed → BLOCK → aggregator denies → error oneof → agentgateway returns JSON-RPC -32001. failOpen → HUMAN_REVIEW → forwarded with audit warning.
Sidecar Pod unreachable agentgateway's mcp-guardrails processor fails closed → JSON-RPC -32603 to the agent (e2e case e).
PromptGuard model load failure Pod readiness probe stays NOT_SERVING; the Pod is removed from Service endpoints. No traffic reaches a half-initialised sidecar.
Malformed JSON-RPC payload Servicer returns AuthorizationError{INVALID} (fail-closed on parse failure).
Tool output > MAX_CONTENT_BYTES Balanced head/mid/tail windows (11KiB each by default) scanned; decision flagged truncated=true. Complete coverage with up to four windows when the payload fits; otherwise four samples with regions between them unscanned (bounded by SCAN_MAX_PAYLOAD_BYTES).
PromptGuard token-window ceiling Chunks beyond ~PG_MAX_WINDOWS*448 + 512 tokens (≈7.7K at the default) have unscanned middle regions — an injection padded past the scored windows can be missed. Defence-in-depth: balanced byte-level windows + the payload_size cap.
Payload > SCAN_MAX_PAYLOAD_BYTES (1MiB) Still scanned via the bounded windows, plus a payload_size HUMAN_REVIEW with scanned/total bytes — HUMAN_REVIEW_MODE=deny makes it a hard deny.
stdio upstream agentgateway forwards an empty header set for stdio upstreams. Do not rely on headers for authn/authz when upstream_transport == "stdio".

failOpen × redaction. Under failOpen a scanner exception becomes HUMAN_REVIEW. With the default REDACT_ON_REVIEW=1 the payload is still redacted before forwarding — failOpen trades away the block layer but keeps the masking layer. With REDACT_ON_REVIEW=0 (legacy) review payloads are forwarded in cleartext (with the audit warning), trading both layers away for availability.

Deny-reason generalisation. Wire-visible deny reasons are fixed generic strings (denied by content policy / denied by response policy, plus a short correlation ref; engine_error for internal failures) so a caller cannot iterate a payload against scanner feedback. The full internal reason — scanner, pattern, match fingerprint — lives only in the audit log. The structured deny body (AuthorizationError.mcp_error, code -32001) adds a generalised policy category (e.g. content_policy, tool_policy, tool_flow) and a generic remedy hint — never pattern names or rule internals. The request side also populates McpRequestResult.metadata with guardrail.scan_score, guardrail.rules_hit, guardrail.redactions, guardrail.exchange_id and guardrail.outcome so the dataplane can log/route on guardrail verdicts without parsing the audit stream. (McpResponseResult has no metadata field in the proto — response-side metadata is not emitted; header_mutation is deliberately unused.)

The fail-closed posture is deliberate: an agent that can call real tools is far more dangerous when a guardrail outage is silent than when it is loud. -32001 is loud.

Rule packs are code, not config

The Invariant rule pack (INVARIANT_RULES_PATH, mounted from the guardrail-rules ConfigMap in the reference manifests) is hot-loaded Python: on load and on every SIGHUP reload the sidecar reads the file fresh and runs it through compile() + exec() (guardrails/rules/__init__.py), then validates the exposed RULES list. There is no sandbox — the exec runs with the sidecar process's full privileges, network position and env vars.

The consequence is an explicit trust boundary: anyone who can write the rules file (or the ConfigMap behind it) can execute arbitrary code inside the sidecar — including exfiltrating LF_ALIGNMENT_API_KEY / AUDIT_HMAC_KEY, rewriting verdicts, or forging a consistent audit chain from that point on. Treat rule packs with exactly the controls you apply to application code:

  • RBAC on the ConfigMap. Restrict update/patch on guardrail-rules to the same role that may merge to the policy repo; a namespace-scoped deployer role must not implicitly carry rule-pack write.
  • Review as code. Four-eyes Git PR on rule-pack changes, gated pipeline for the ConfigMap/manifests (see Compliance §3).
  • Read-only everywhere. The ConfigMap volume is mounted readOnly, and the container runs with readOnlyRootFilesystem: true — keep both. Neither stops a malicious rule pack (the exec happens on load), but they stop persistence/payload staging on disk.
  • Image-baked rules for strict environments. Where ConfigMap write access cannot be locked down tightly enough, bake the rule pack into the image at build time and leave INVARIANT_RULES_PATH unset (the built-in default) or point it at an in-image path. That moves the trust boundary to the image supply chain, which is already signed/verified.

Planned direction (roadmap, not implemented): signed remote rule-pack distribution with staged rollout — packs fetched from a versioned artifact store, signature-verified at load, and rolled out canary-first. Until that lands, the file-path exec loader above is the only mechanism and the boundary above applies.

Known limitations

  • Method coverage. The sidecar only sees the MCP methods agentgateway forwards to it. The default methods mapping covers tools/call, tools/list, prompts/get and resources/read. Reverse-channel / other MCP methods — sampling/createMessage, elicitation/create, completion/complete, roots/list, notifications — are NOT covered unless the deployment's methods mapping is extended to forward them. If your threat model includes indirect injection via server-initiated sampling/elicitation, treat this sidecar as one layer of a broader policy.
  • Invariant trace scope. Only tools/call requests with a non-empty tool name advance toxic-flow state; other methods are scanned for content but do not advance the trace.
  • Scan coverage residuals. Unscanned middle regions on large payloads, multi-window split attacks, and the REDACTION_MAX_BYTES redaction skip — enumerated honestly in Scan coverage.
  • Multi-replica state. Trace windows and the audit hash chain are per-process; see Deployment.
  • Audit limitations. Best-effort local writes, hash chain is tamper-evident (not signed), no sidecar-side at-rest encryption, node-local clock — see Compliance.

Reporting a vulnerability

Do not open a public GitHub issue. Email the maintainer privately (see SECURITY.md in the repo) with a description, the version/image tag, the relevant configuration, and a repro if possible.