Data-loss prevention
Every model call a coding CLI makes carries what the agent read: .env files, cloud keys, tokens pasted into the chat. Tool results bring web pages and files into the model with your user’s authority. With gateway.dlp, halo-proxy and halo-kong scan each request for secrets and each tool result for prompt-injection signals, act per ring and org unit, and record every finding without ever storing the secret. Design and limits: ADR-0033.
Turn it on
Section titled “Turn it on”Start in monitor everywhere. It changes nothing, counts and audits:
dlp: secrets: action: monitor injection: action: monitorThen tighten by ring or unit once the counts look right:
dlp: secrets: action: redact # everyone: secrets become [REDACTED:<detector>:<hash8>] patterns: - id: acme-deploy-token # your own token formats (RE2) regex: 'acme_dpl_[a-z0-9]{32}' injection: action: monitor rules: # first match wins - units: [payments] # payments and its child units refuse secrets outright secrets: block - rings: [ring1-canary] # try injection blocking on the canary ring first injection: blockhalo validate checks the block (actions, detector ids, RE2 patterns, rule rings and units). Publish as usual: halo gateway compile and roll the snapshot.
| Action | Secrets | Prompt injection |
|---|---|---|
monitor (default) |
forwarded unchanged; counted, logged, audited | same |
redact |
each secret replaced with [REDACTED:<detector>:<hash8>]; the rest of the JSON is byte-for-byte unchanged |
n/a: model content is never modified |
block |
HTTP 400 in the CLI’s own error format | HTTP 400 when a tool result scores at or above threshold (default 4) |
off |
not scanned | not scored |
The 400 names the detector, marker and JSON path (for example github-token, [REDACTED:github-token:5f0c9e2a] at messages[12].content[0].content), so the developer knows what to remove. A conversation re-sends its history every turn: under block it stays refused until the secret is out of the conversation (rewind or clear), and the secret should be rotated.
Detectors
Section titled “Detectors”aws-access-key-id, github-token, gitlab-token, slack-token, slack-webhook, stripe-key (live keys), anthropic-api-key, openai-api-key, jwt, private-key (PEM), gcp-service-account, and generic-secret (a secret-like key name assigned a long, mixed-case, high-entropy value). Turn one off with secrets.disable: [jwt]. The ADR lists exactly what each matches and why it is high precision.
Injection signals (each counts once per tool result): instruction overrides (3), a secret inside a URL (3), invisible Unicode (tag characters 3, zero-width or bidi 2), role reassignment (2), fake conversation turns (2), imperatives in HTML comments (2), Markdown-image exfiltration URLs (2), and permission-bypass flags such as --dangerously-skip-permissions (2). The default threshold of 4 needs two independent signals. Only tool output and documents are scored, never what the user typed.
Try it locally
Section titled “Try it locally”halo dlp test runs the same scan on a file without sending anything. A JSON model-call body is scanned as a request; anything else is scanned as a tool result:
halo dlp test ~/.aws/credentialshalo dlp test --policy-dir . --unit checkout captured-request.jsonhalo dlp test --secrets redact --show-redacted captured-request.jsonhalo --output json dlp test suspicious-readme.mdIt prints each finding (kind, detector, hash, JSON path, injection score and signals) and what the gateway would do. Exit code 0 means nothing was found, 2 means something was, so it works in a pre-commit hook or CI step.
Evidence
Section titled “Evidence”Audit and SIEM. Point the gateways at halo-server and every finding becomes an audit entry (dlp.secret or dlp.injection, actor the gateway its token names, target the verified user, details detector, hash, location, action, ring, unit, harness), which the SIEM export forwards with everything else:
mkdir dlp-tokensopenssl rand -hex 32 > dlp-tokens/proxy-eu # one token per gateway, named for itopenssl rand -hex 32 > dlp-tokens/kong-ushalo-server ... --gateway-dlp-tokens-dir dlp-tokenshalo-proxy ... --dlp-url https://halo-server/api/v1/gateway/dlp --dlp-token-file dlp-tokens/proxy-euThe actor of each entry is the gateway whose token sent it (gateway:proxy-eu); a batch naming another gateway in its body is refused. With one token every gateway shares (--gateway-dlp-token-file or the shared gateway token), any gateway could write as any other, so entries are recorded as gateway:unverified with the name the body claims in details.claimedGateway.
halo-kong: dlp_url and dlp_token ({vault://env/HALO_DLP_TOKEN}) in the plugin config. Without a URL, findings are still counted and logged. One finding is audited once an hour per user even though the history repeats it on every turn.
Metrics (halo-proxy /metrics):
| Metric | Labels |
|---|---|
halo_proxy_dlp_findings_total |
kind, detector, action, direction (request, response) |
halo_proxy_dlp_scans_total |
direction, outcome (clean, finding, redacted, blocked, truncated) |
halo_proxy_dlp_scan_seconds_total |
time spent scanning request bodies |
halo_proxy_dlp_audit_events_total |
outcome (sent, dropped, deduped) |
halo_proxy_dlp_audit_failures_total |
failed posts to halo-server (retried) |
halo-kong exports halo_kong_dlp_findings_total{kind,detector,action}. Alert on rate(halo_proxy_dlp_findings_total{kind="secret"}[1h]) > 0 per detector, and on halo_proxy_dlp_scans_total{outcome="truncated"} growing (raise maxScanBytes).
Request log. A dlp attribute lists kind detector action at <path> #<hash8> per finding. Session ledger: each session record carries dlpSecrets and dlpInjections (session ledger).
The secret itself is never in any of these: only the detector id, the first 8 hex of its SHA-256 (to correlate one secret across events) and where it was.
Responses
Section titled “Responses”halo-proxy also watches streamed responses for secrets the model repeats back (direction="response"). This is monitor only: the stream reaches the CLI byte for byte, never delayed or cut. halo-kong does not scan responses. Turn it off with scanResponses: false.
Limits
Section titled “Limits”- Pattern-based: a secret with no recognisable format and no key name next to it is not found. Add org
patternsfor your own token formats. - Injection scoring is a tripwire and an audit trail, not a guarantee; the CLIs’ permission prompts and the guardrails stay the control on what an agent may do.
maxScanBytes(default 4 MiB) and a 250 ms budget bound each scan. Undermonitororredactthe rest of a larger body passes unscanned and is counted astruncated. Under ablocksecret action the request is refused rather than forwarded unscanned: HTTP 413 pastmaxScanBytes, HTTP 400 (dlp_scan_incomplete) past the time budget, counted astruncated(halo_proxy_dlp_scans_total{outcome="truncated"}andoutcome="blocked";halo_kong_dlp_truncated_total{outcome="blocked"}). The user sees the message in the CLI;/compactor a new session gets under the cap. A 200 KB request costs about 5 ms at p99.