MAQPNADocs

DLP and taint#

Two controls in the gateway watch the content of traffic rather than who sent it:

  • Data loss prevention (DLP) finds sensitive data, such as keys, card numbers and national ID numbers, in tool arguments, tool results, prompts and completions, and redacts or denies it before it leaves the gateway.
  • Taint is a label on a session that has read untrusted content (a web page, a public issue, an untrusted MCP server). Rules then deny, or require approval for, the tools that could leak data.

Together they answer prompt injection the way a runtime can: the model may be fooled, but a fooled agent cannot quietly send your data out.

DLP#

The gateway scans traffic in-process, with deterministic, validated detectors (checksums, structure or context keywords). Nothing is sent to an external classifier, and the ledger records detector IDs and counts, never the matched values.

Detector class Detectors
Financial iban (mod-97), pan (card numbers, Luhn)
Personal data de_tax_id, fr_nir, nl_bsn, it_codice_fiscale, es_dni, uk_nino, us_ssn; opt-in email, phone_e164
Infrastructure opt-in ipv4_private
Secrets aws_access_key, gcp_sa_key, gcp_api_key, github_token, slack_token, openai_api_key, anthropic_api_key, jwt, pem_private_key; opt-in high_entropy_secret
Custom Your own RE2 regular expressions (custom[{id, regex, secret}])

A profile (dlpProfiles.<name>) picks detectors and actions:

Action Effect
off No scanning in that direction
audit Record the hit, forward unchanged
redact Replace each match with [REDACTED:<detector>] (default for requests and responses)
deny Refuse the call (dlp:<detector>) or withhold the result

onSecret overrides the action for secret-class detectors. Content larger than maxScanBytes (1 MiB by default) is blocked (dlp:oversize) when the action is redact or deny, so padding cannot hide a secret.

Where DLP is bound. The profile for a call is, in order: the MCP server's MCPServer.spec.dlpProfile, or for model calls the agent's spec.model.dlpProfile and then dlp.models[route]; then dlp.servers[server]; then dlp.namespaces[namespace]; then dlp.defaultProfile. none turns DLP off for that binding, and an unknown profile name fails closed (dlp:profile_unknown). With no profile bound at any level, DLP is off: the Helm chart ships dlp.profiles: {}, so you enable it by defining a profile.

Where DLP runs. Tool-call arguments (before policy, so policy and approvers see the redacted arguments); every MCP response, including errors, server notifications and the SSE stream; A2A messages; model prompts and completions, including streamed ones; code-interpreter code, output and files; memory writes; and the session result summary.

Taint#

A taint label is a string such as untrusted-input. A session gets one:

  1. From tool labels. A policy's toolLabels[].taints marks tools as taint sources. After such a tool is called (whatever the upstream status: an error page can carry untrusted content too), the session carries the label.
  2. From untrusted MCP servers. Tools of an MCPServer with trust: untrusted (the default) taint the session with untrusted-input, unless the server's toolLabels say otherwise.
  3. From untrusted A2A peers and from the built-in maqpna-web fetch tool (untrusted-input).
  4. From injection guards, which label the session prompt-injection-suspected when a classifier flags content.
  5. From data classes. Calling a server with dataClass: confidential or restricted adds data-class:<class>.
  6. From the parent, when a session is forked from a snapshot.

Taint on its own blocks nothing. Rules use it: a rule with sessionTaints: {any: [untrusted-input]} and sinks: [external] matches calls to exfiltration sinks once untrusted content entered the context. This is how you close the "lethal trifecta" of private data, untrusted content and an outbound channel:

rules:
  - name: sinks-after-untrusted-input
    tools: ["*"]
    sessionTaints: { all: [untrusted-input, data-class:confidential] }
    sinks: [external]
    action: require_approval

Labels are removed only by cleaner tools (toolLabels[].clearTaints, after a 2xx response), by an admin (POST /v1/sessions/{id}/taint:clear, audited) or by expiry, taintRetentionSeconds (default 24 hours) after the last tainted call, never before the session's token expires.

flowchart LR
    subgraph Before
      A1["get_issue on github<br/>(toolLabels taints: untrusted-input)"]
    end
    A1 -- "after the call" --> T["session team-a/fix-4821<br/>taints: untrusted-input"]
    T --> Q{"Next call: send_email<br/>sinks: external"}
    Q -- "rule sessionTaints any untrusted-input<br/>+ sinks external" --> H["require_approval<br/>(or deny)"]
    Q -- "no such rule" --> OK["policy decides as usual"]

What you see#

A DLP denial reaches the agent as a JSON-RPC error with the detector in the reason; the upstream is never called:

{"jsonrpc":"2.0","id":7,"error":{"code":-32001,"message":"denied by policy dlp rule eu-strict: dlp:github_token","data":{"domain":"maqpna.com","policy":"dlp","reason":"dlp:github_token","rule":"eu-strict"}}}

The ledger record carries ext.dlp (for example iban:2,email:1), ext.dlpAction, ext.dlpProfile and, after a redaction, ext.argsRedactedSha256. maqpna taint list shows tainted sessions and maqpna taint clear removes labels; see maqpna taint list and maqpna taint clear.