Skip to main content

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 textDecisionForwarded prompt text
email jane@example.com about renewalallowemail [REDACTED-EMAIL] about renewal
customer SSN is 123-45-6789allowcustomer SSN is [REDACTED-US-SSN]
jane@example.com has SSN 123-45-6789allow[REDACTED-EMAIL] has SSN [REDACTED-US-SSN]
summarize this public launch noteallowsummarize 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: mask is modify-and-proceed. If your compliance requirement is "never call the model when PII appears," use action: block instead.
  • 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: with action: mask for fields you want redacted.
  • llm:call (and llm: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 same dlp_rules to outbound LLM requests.