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#
- Resolve the profile as above.
- 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. - Apply the request action.
requestAction(defaultredact), overridden for secret-class detectors byonSecret: -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-32001withreason: dlp:<detector>; the upstream is never called. - Handle oversize. Text beyond
maxScanBytes(default 1 MiB) is blocked (dlp:oversize) when the action isredactordenyandonOversizeis unset; an audit-only direction forwards it unscanned and recordsoversize:1. - Decide and forward as in a governed tool call.
- Scan the response. Every MCP response is scanned, whatever the method, status code or content type: every member except
jsonrpc,idandmethod, including JSON-RPC errors, server notifications on the SSE stream and batches. Non-streamed bodies are buffered and scanned whole. - Apply the response action. Redact in place, or withhold: the result is replaced by
-32001withdata.direction: "response". Because the tool already ran, the record isallowwith reasondlp:<detector> response_withheld. - Record evidence.
argsSha256is always the hash of the original arguments.ext.dlplists detector IDs and counts (iban:2,email:1), plusext.dlpAction,ext.dlpProfile,ext.dlpBlocked(request:<id>orresponse:<id>) and, after a redaction,ext.argsRedactedSha256. Matched values are never logged. Metricmaqpna_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 |