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

On Claude Code and Gemini CLI, the Python SDK deterministically masks matching spans in place and lets the call proceed. It loads custom DLP rules from the top-level dlp_rules: array; scopes: ['sdk'] keeps the rule on the SDK path. When a rule uses action: mask, the policy decision remains allow, and 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.
  • Modify-and-proceed masking is Python-SDK-only on Claude Code and Gemini CLI. Cursor, Codex CLI, and Kiro CLI deny a mask match instead. Kiro IDE does not enforce.