Recipe: Prompt PII redaction
The problem
Your agent needs to summarize support tickets, CRM notes, or incident reports. Those prompts often contain email addresses, phone numbers, or government IDs. Blocking every LLM call is too blunt; forwarding raw PII is too risky. You want the call to proceed with the sensitive spans replaced by stable redaction tokens.
The policy
version: '1'
settings:
default_action: deny
default_on_missing: deny
default_on_tamper: deny
rules:
- id: allow-llm-generate
allow: 'llm:generate'
reason: 'LLM calls are allowed after DLP masking runs on prompt arguments.'
dlp_rules:
- id: email
name: Email address
pattern: '\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b'
category: pii
action: mask
scopes: ['sdk']
- id: us-ssn
name: US SSN
pattern: '\b\d{3}-\d{2}-\d{4}\b'
category: pii
action: mask
scopes: ['sdk']
Why it works
The SDK loads custom DLP rules from the top-level dlp_rules: array. A rule
must carry a regex pattern; scopes: ['sdk'] keeps the rule on the SDK
path. When a DLP rule uses action: mask, the policy decision remains
allow, but the SDK returns masked_args with matching spans replaced by
value-free placeholders such as [REDACTED-EMAIL].
Forward decision.masked_args to the LLM, not the original prompt args.
from controlzero import Client
cz = Client(policy_file="policy.yaml")
args = {
"prompt": "Summarize ticket from jane@example.com. SSN: 123-45-6789."
}
decision = cz.guard("llm", method="generate", args=args)
if decision.denied:
raise RuntimeError(decision.reason)
safe_args = decision.masked_args or args
# pass safe_args["prompt"] to your model client
What gets redacted
| Prompt text | Decision | Forwarded prompt text |
|---|---|---|
email jane@example.com about renewal | allow | email [REDACTED-EMAIL] about renewal |
customer SSN is 123-45-6789 | allow | customer SSN is [REDACTED-US-SSN] |
jane@example.com has SSN 123-45-6789 | allow | [REDACTED-EMAIL] has SSN [REDACTED-US-SSN] |
summarize this public launch note | allow | summarize this public launch note |
Test it yourself
Assert on decision.masked_args from an in-process SDK call. The decision
stays allow; the raw args object is never mutated:
from controlzero import Client
cz = Client(policy_file="policy.yaml")
decision = cz.guard(
"llm",
method="generate",
args={"prompt": "Summarize ticket from jane@example.com. SSN: 123-45-6789."},
)
assert decision.effect == "allow"
assert decision.masked_args is not None
prompt = decision.masked_args["prompt"]
assert "jane@example.com" not in prompt
assert "123-45-6789" not in prompt
assert prompt == "Summarize ticket from [REDACTED-EMAIL]. SSN: [REDACTED-US-SSN]."
Caveats
action: maskis modify-and-proceed. If your compliance requirement is "never call the model when PII appears," useaction: blockinstead.- Masking only helps if the integration forwards
decision.masked_args. If the caller ignores it and forwards the original prompt, the policy decision cannot rewrite the upstream request for you. - Built-in DLP patterns are detect-only by default. Add explicit
dlp_rules:withaction: maskfor fields you want redacted. llm:call(andllm:generate) is a Gateway-level action, not one of the canonical agent-tool actions (Bash,Read, ...). The SDK prints a one-time advisory that the action is outside the canonical vocabulary; the rule still matches and masking still runs. In production the Gateway applies the samedlp_rulesto outbound LLM requests.