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.