MAQPNADocs

DLP decision flow#

Data loss prevention (DLP) in MAQPNA runs inside the gateway, with deterministic, validated detectors from pkg/dlp and no external classifier. For every call the gateway resolves one profile, scans the request before anything else is decided, and scans the response before the agent sees it. Code: cmd/maqpna-gateway/dlp.go, pkg/dlp.

Choosing a profile#

flowchart TD
    S["Call"] --> K{"Model call (/llm)?"}
    K -- yes --> M1{"Agent spec.model.dlpProfile?"}
    M1 -- set --> P["Use it"]
    M1 -- unset --> M2{"dlp.models route?"}
    M2 -- set --> P
    M2 -- unset --> NS
    K -- "no (MCP, A2A, exec)" --> R1{"MCPServer spec.dlpProfile?"}
    R1 -- set --> P
    R1 -- unset --> R2{"dlp.servers server?"}
    R2 -- set --> P
    R2 -- unset --> NS{"dlp.namespaces namespace?"}
    NS -- set --> P
    NS -- unset --> DF{"dlp.defaultProfile?"}
    DF -- set --> P
    DF -- unset --> OFF["DLP off for this call"]
    P --> N{"Profile name"}
    N -- none --> OFF
    N -- unknown --> DENY["deny · dlp:profile_unknown"]
    N -- known --> SCAN["Scan with this profile"]

none disables DLP for that binding. A profile name that does not exist fails closed. With nothing bound at any level, DLP is off; the Helm chart ships no profile (dlp.profiles: {}).

Sequence#

sequenceDiagram
    autonumber
    participant Ag as Agent
    participant GW as Gateway
    participant D as DLP scanner
    participant PE as Policy engine
    participant Up as MCP server
    participant L as Audit ledger
    Ag->>GW: tools/call with arguments
    GW->>D: scan arguments (requestAction)
    alt deny hit
        D-->>GW: deny dlp:pan
        GW->>L: deny, ext.dlp, ext.dlpBlocked request:pan
        GW-->>Ag: -32001 reason dlp:pan
    else redact hit or clean
        D-->>GW: redacted arguments
        GW->>PE: decide on the redacted arguments
        GW->>Up: forward redacted arguments
        Up-->>GW: result
        GW->>D: scan the whole response (responseAction)
        alt response deny
            GW->>L: allow, reason dlp:det response_withheld
            GW-->>Ag: -32001 direction response
        else
            GW->>L: allow, ext.dlp, ext.argsRedactedSha256
            GW-->>Ag: result, matches redacted
        end
    end

Step by step#

  1. Resolve the profile as above.
  2. Scan the request. For tool calls, the JSON arguments are scanned in place (values and object keys; a matching key becomes [REDACTED:<detector>]#<n>). Each detector validates its match (Luhn, mod-97, ISO 7064, fixed prefixes) to keep false positives low.
  3. Apply the request action. requestAction (default redact), overridden for secret-class detectors by onSecret: - off: skip; audit: record and forward unchanged; - redact: replace each match with [REDACTED:<detector>], keeping JSON structure and key order; policy and the approval preview see the redacted arguments; - deny: return JSON-RPC -32001 with reason: dlp:<detector>; the upstream is never called.
  4. Handle oversize. Text beyond maxScanBytes (default 1 MiB) is blocked (dlp:oversize) when the action is redact or deny and onOversize is unset; an audit-only direction forwards it unscanned and records oversize:1.
  5. Decide and forward as in a governed tool call.
  6. Scan the response. Every MCP response is scanned, whatever the method, status code or content type: every member except jsonrpc, id and method, including JSON-RPC errors, server notifications on the SSE stream and batches. Non-streamed bodies are buffered and scanned whole.
  7. Apply the response action. Redact in place, or withhold: the result is replaced by -32001 with data.direction: "response". Because the tool already ran, the record is allow with reason dlp:<detector> response_withheld.
  8. Record evidence. argsSha256 is always the hash of the original arguments. ext.dlp lists detector IDs and counts (iban:2,email:1), plus ext.dlpAction, ext.dlpProfile, ext.dlpBlocked (request:<id> or response:<id>) and, after a redaction, ext.argsRedactedSha256. Matched values are never logged. Metric maqpna_gateway_dlp_hits_total{detector,direction,action}.

Other paths#

Path What is scanned On deny
Model request (/llm) The whole body except model selection and numeric controls HTTP 403, type maqpna_dlp_blocked
Model completion Every choice, including tool-call arguments, refusal and reasoning; logprobs dropped when a choice was redacted HTTP 403 (JSON) or an error event in the stream; usage is still metered
Streamed completion Per choice and field, holding back streamCarryBytes (default 256) so matches split across chunks are caught Error event maqpna_dlp_blocked, content stops
A2A Message parameters and results -32001
Code interpreter Code (the redacted code is executed), output, file listings and downloads; binary files with a finding are withheld (dlp:binary_redaction) Withheld
Memory Arguments of writes, with the memory store's profile Denied
Session result The agent's result summary HTTP 422
Approval preview Built-in detectors mask the preview even in full mode —

What you see#

{"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"}}}

A redacted argument reaches the MCP server as, for example, {"note":"card [REDACTED:pan] on file"}. Values are illustrative; the field layout follows the error envelope.

Failure modes and limits#

Situation Behaviour
Unknown profile Fail closed, dlp:profile_unknown
Oversized content Blocked when enforcing, dlp:oversize
A match longer than the stream carry window (a PEM key) Can be partly emitted before it is recognised
Per-rule DLP binding (rule.dlp) Not implemented: DLP runs before policy, so a rule cannot select a profile