Stripe: Redact Customer PII from Bulk Reads
Masks customer PII in the responses of Stripe's bulk PII egress channels before they reach the agent.
- Direction
- egress
- Rego package
stripe.egress.redact_pii_customer- App
- stripe
- Bundles
- soc2gdpr-ccpa
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:6b7b47948dab7d6f274c4c8060234b1b6b47d4f62e8de7118651649518a05919
striperedact-piipiidlpredactionegresssoc2gdpr-ccpa
What this policy does
Direction: egress (tool_post_invoke)
Default: allow (transform-only — never denies)
Package: stripe.egress.redact_pii_customer
What it does
Masks customer PII in the responses of Stripe's bulk PII egress channels
before they reach the agent. On responses from *list_customers,
*search_stripe_resources, *fetch_stripe_resources, and
*stripe_api_read, the policy rewrites these fields to fixed redaction
tokens:
| Field | Where it appears | Token |
|---|---|---|
email |
customer objects, billing_details, receipts |
[REDACTED-EMAIL] |
any *phone* key |
customer objects, billing_details, shipping, dispute evidence (customer_phone_number, phone_number) |
[REDACTED-PHONE] |
any *address* key |
customer objects, billing_details, shipping, dispute evidence (billing_address, shipping_address) — object or string value |
[REDACTED-ADDRESS] |
last4 |
cards, payment methods, bank accounts | [REDACTED-LAST4] |
The phone and address rewrites match any JSON key whose name contains
phone or address (case-insensitively), so compound keys reached through
the meta-tools (fetch_stripe_resources/stripe_api_read can return dispute
objects whose evidence carries billing_address, shipping_address, and
customer_phone_number) are masked, not just the bare phone/address
keys. Over-matching a benign *address* key on egress is over-redaction, not
disclosure.
A generic email-address pattern also runs over the response text, so an
email embedded in prose (e.g. a description string) is masked even when it
is not under an email key.
The policy is transform-only (default allow := true): it never denies a
call, so a legitimate customer lookup still succeeds — it just comes back
with identifiers masked and the record structure (IDs, created timestamps,
subscription status, currency, amounts) intact. Responses with no matches,
and all out-of-scope tools, pass through byte-identical. Every field is read
via object.get, so a missing or oddly-shaped payload is never an error —
it simply passes through.
Scope note: Stripe never returns raw PANs — PCI scope for card numbers
stays with Stripe. What these channels do leak is linkable PII: a customer
list pairs name + email + phone + address, and card records add last4,
which together identify and profile real people. This policy targets that
linkable set, not PAN (see the companion mask-pan-egress family for PAN
masking on apps that can return card numbers).
Group exemption
Redaction is gated by IdP group. Callers whose groups claim contains
finance (a placeholder name — see Known limitations) receive the response
unmodified. The check reads input.subject.claims.groups via
object.get chains: a missing subject, missing claims, or missing groups
claim means the caller is not in finance and receives the redacted view —
the grant fails closed, toward redaction. That failure mode is safe: a
caller whose claims fail to arrive gets over-redaction, never disclosure.
Compliance alignment
- SOC 2 CC6.7 — supports restricting the transmission/movement of confidential information by masking direct identifiers in Stripe reads as they leave the gateway toward the agent.
- SOC 2 C1.1 — supports identification and protection of confidential information on the payments read path; P4.1 — supports limiting personal-information use to identified purposes (agents get working records without identifiers they don't need); P6.1 — supports controls over personal-information disclosure by keeping raw identifiers out of agent context.
- GDPR Art. 5(1)(c) — data minimisation on agent reads of customer personal data: non-finance callers see the record, not the identifiers; Art. 5(1)(f) / Art. 32 — supports security of processing on the agent channel.
- CCPA/CPRA §1798.150 — reduces nonredacted-PI breach exposure if agent context or downstream logs are later compromised.
Why egress
The PII already lives in Stripe — there is nothing to block at ingress, and denying customer reads outright would make the agent useless for everyday billing-support work. The leak happens when the customer list or fetched object is returned to the MCP client, so the response path is the only place to catch it while keeping the result useful. Gating which tools and endpoints can be called at all is a separate concern handled by companion ingress policies.
Tool name matching
Applies on the output path (input.mode == "output") to the bulk PII
egress channels identified in the Stripe landscape review, matched
case-insensitively by suffix from input.resource.name with
input.tool_metadata.name as a fallback. Suffix matching keeps the policy
portable across the gateway server-name prefix (which is not standardised —
different deployments name the Stripe MCP server differently).
*list_customers— legacy per-resource customer listing (v0.8.x@stripe/mcp, Claude Desktop.dxt, pre-migration agent-toolkit embeds)*search_stripe_resources— official server cross-object search (customers, charges, invoices, …)*fetch_stripe_resources— official server fetch-any-object-by-ID*stripe_api_read— official server execute-any-GET meta-tool
All four names are verified from docs.stripe.com/mcp and the stripe/ai
repo history. Verify the exact names your gateway emits with the dump-input
debug technique before relying on this in production.
Response shape
The policy reads input.payload.text — the MCP content-block array the
gateway populates on tool_post_invoke — and rewrites each string block.
Stripe MCP tools return serialized JSON API objects in those blocks, so the
field rewrites use key-anchored patterns ("email": "…",
"address": {…}, "last4": "…") that replace only the value and keep the
surrounding JSON valid and parseable. Non-string blocks pass through
unmodified. When at least one block changes, the policy emits
transform.transformed_payload containing the original payload with the
rewritten text array (all other payload keys preserved). When nothing
changes, no transform is emitted and the response passes through
byte-identical.
Examples
Redacted (in-scope tool, non-finance caller)
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "stripe-list_customers", "type": "tool" },
"subject": { "sub": "auth0|u1", "claims": { "groups": ["support"] } },
"payload": {
"name": "stripe-list_customers",
"text": ["{\"id\": \"cus_9s6XKzkNRiz8i3\", \"name\": \"Jane Diaz\", \"email\": \"jane@acme.com\", \"phone\": \"+15551234567\", \"address\": {\"city\": \"Seattle\", \"line1\": \"1 Main St\"}}"]
}
}
}
allow = true, with transform.transformed_payload.text =
["{\"id\": \"cus_9s6XKzkNRiz8i3\", \"name\": \"Jane Diaz\", \"email\": \"[REDACTED-EMAIL]\", \"phone\": \"[REDACTED-PHONE]\", \"address\": \"[REDACTED-ADDRESS]\"}"].
Passed through (finance caller)
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "stripe-search_stripe_resources", "type": "tool" },
"subject": { "sub": "auth0|u2", "claims": { "groups": ["finance"] } },
"payload": {
"name": "stripe-search_stripe_resources",
"text": ["{\"email\": \"jane@acme.com\"}"]
}
}
}
allow = true, no transform — the finance group receives the raw
response.
Composition
Single-purpose transform policy (default allow := true); it composes
cleanly with deny/transform policies on the same pipeline. Recommended
companions for apps/stripe:
deny-escape-hatches-api-write— this policy masks the read path; that one closes thestripe_api_writewrite escape hatch.gate-money-movement-refund-cap— caps refunds on the money-out path.- A
cap-bulk-export-style ingress guard (PF-08) clampinglimiton list/search calls, bounding the blast radius of any redaction miss. - A
role-gate-writes-style ingress policy (PF-12) keeping the agent read-only for non-finance groups in the first place.
Layer with Stripe Restricted API Key (RAK) scoping — DTwo policy and key scoping are complementary control planes, not either/or.
Known limitations
- Group names are placeholders — replace
financewith your IdP's group name at import time. The exemption expects thegroupsclaim as an array of strings (a single bare string is also handled); if your IdP emits roles under a namespaced claim, adjustcaller_groups. Missing claims always mean the redacted view — the failure mode is over-redaction, never disclosure. Never rely on stripped ContextForge-internal claims (is_admin,teams,user) for the exemption. - Customer
nameis not redacted. The policy masks the fields that make a name linkable and contactable (email, phone, address, last4); a bare name with no other identifiers is left so results stay usable for support workflows. Add a"name"key pattern if your posture requires masking it too. - Key-anchored patterns assume Stripe's serialized-JSON response shape.
The field rewrites match Stripe's serialized JSON keys as its API emits
them:
"email"exactly, any key containingphoneoraddress(case-insensitively, sobilling_address/customer_phone_numberare covered), and"last4"exactly. What is not matched: a value under a differently-worded key (e.g. a mobile number under"mobile"or a location under"location"),last4outside the"last4"key, and PII in reformatted prose (e.g.Email — jane@acme.com, which is covered for email only via the generic email pattern). A bare4242in prose is not matched — four digits alone would over-fire on amounts and dates. Note that a compound email key (customer_email_address) is masked, but with the[REDACTED-ADDRESS]token rather than[REDACTED-EMAIL]because the address rewrite runs first — the value is still fully redacted, only the token label differs. - Other Stripe read surfaces are out of scope.
get_stripe_account_info(account business email),stripe_report(report runs can embed customer columns), and the remaining legacy list tools (list_invoices,list_payment_intents,list_subscriptions, andlist_disputes— whose dispute objects carry the most customer PII of the legacy read tools:customer_name,customer_email_address,billing_address,shipping_address) are not matched, so a whole-list read through one of those tool names passes through unredacted. Only the four verified bulk channels inbulk_read_suffixesare in scope; extend it if your deployment exposes these and your posture requires it. - Non-official servers break suffix symmetry. The community
atharvagupta2003/mcp-stripeserver uses invertednoun_verbnames (e.g.customer_list) and Composio usesSTRIPE_*slugs across ~415 tools — neither matches this suffix set. Pin your deployment's actual tool names inbulk_read_suffixes. Treasury "agentic finance" preview tool names are unpublished (unverified) and therefore not matched. - PAN is a non-issue on this surface, by Stripe's design. The Stripe
API never returns full card numbers, so no PAN masking is attempted here;
last4is the only card identifier present and it is masked. - The generic email pattern can over-match
user:password@hostsubstrings inside connection-string-shaped values. On egress this is over-redaction (safe), not disclosure. - Non-string content blocks pass through unmodified. Redaction applies
to string entries of
input.payload.text(including serialized-JSON strings). If your gateway emits structured non-string blocks for Stripe results, verify their shape with the dump-input technique. - Egress
transformed_payloadreplaces the response payload wholesale. Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline.
Compliance note. This policy supports alignment with the cited framework controls on the MCP path only. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it.
Policy source (Rego)
package stripe.egress.redact_pii_customer
# Transform-only egress policy: masks customer email, phone, address, and card
# last4 in the responses of Stripe's bulk PII egress channels (list_customers,
# search_stripe_resources, fetch_stripe_resources, stripe_api_read) before the
# response reaches the agent. Never denies — a legitimate lookup still
# succeeds, just with identifiers masked. Stripe never returns raw PANs (PCI
# scope stays with Stripe), so this targets linkable PII (name + email +
# last4), not PAN. Callers in the placeholder `finance` IdP group receive
# unmodified responses; the group check fails closed, so a caller with missing
# claims gets the redacted view, never disclosure.
default allow := true
# -----------------------------------------------------------------------------
# Scope: the bulk PII egress channels. The gateway prefixes tool names with the
# configured MCP server name (not standardised), so we match by suffix,
# case-insensitively. All four names are verified from docs.stripe.com/mcp and
# the stripe/ai repo history (legacy v0.8.x tool set). Community/aggregator
# servers use different shapes (customer_list, STRIPE_*) — pin your
# deployment's names here. See the policy's Known limitations.
# -----------------------------------------------------------------------------
bulk_read_suffixes := {
# Legacy per-resource customer listing (v0.8.x @stripe/mcp, .dxt manifest)
"list_customers",
# Official server — cross-object search (customers, charges, invoices, ...)
"search_stripe_resources",
# Official server — fetch any Stripe object by ID
"fetch_stripe_resources",
# Official server — execute any Stripe API GET method
"stripe_api_read",
}
is_bulk_pii_tool if {
input.mode == "output"
some suffix in bulk_read_suffixes
endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), suffix)
}
is_bulk_pii_tool if {
# Egress hooks also expose the tool name under tool_metadata.name — check
# both so we match regardless of which surface the gateway populates.
input.mode == "output"
some suffix in bulk_read_suffixes
meta := object.get(input, "tool_metadata", {})
endswith(lower(object.get(meta, "name", "")), suffix)
}
# -----------------------------------------------------------------------------
# Group exemption — placeholder IdP group whose members receive unmodified
# responses. Replace "finance" with your IdP's group name at import time.
# object.get chains mean a missing subject/claims/groups claim is never
# exempt: the grant fails closed and redaction applies.
# -----------------------------------------------------------------------------
finance_groups := {"finance"}
caller_groups := object.get(
object.get(object.get(input, "subject", {}), "claims", {}),
"groups",
[],
)
is_finance if {
some g in caller_groups
lower(g) in finance_groups
}
is_finance if {
# Some IdPs emit a single group as a bare string rather than an array.
is_string(caller_groups)
lower(caller_groups) in finance_groups
}
# -----------------------------------------------------------------------------
# Redaction steps. Stripe MCP tools return serialized JSON API objects in the
# response content blocks, so the field rewrites are anchored to Stripe's
# lowercase snake_case JSON keys and replace only the value (the ${1} capture
# keeps the key), leaving the surrounding JSON valid and parseable. Each step
# is total over strings: it returns its input unchanged when its pattern
# doesn't apply, so the steps chain safely.
# -----------------------------------------------------------------------------
# `"...address...": {...}` — customer/billing_details/shipping address objects.
# The key match accepts any JSON key that *contains* `address` (case-
# insensitive) so compound keys like `billing_address` / `shipping_address`
# — which appear in dispute evidence and Checkout/PaymentIntent shapes reached
# via fetch_stripe_resources / stripe_api_read — are covered, not just the bare
# `address` key. Stripe address objects are flat ({city, country, line1, line2,
# postal_code, state}), so a non-nested {...} match suffices. Null addresses
# carry no PII and are left alone. Over-matching a non-PII "*address*" key on
# egress is over-redaction (safe), never disclosure.
redact_address_object(t) := regex.replace(
t,
`(?i)("[a-z0-9_]*address[a-z0-9_]*"\s*:\s*)\{[^{}]*\}`,
`${1}"[REDACTED-ADDRESS]"`,
)
# `"...address...": "..."` — string-valued address fields (dispute-evidence
# `billing_address`/`shipping_address` free text, metadata copies, etc.).
redact_address_string(t) := regex.replace(
t,
`(?i)("[a-z0-9_]*address[a-z0-9_]*"\s*:\s*)"[^"]*"`,
`${1}"[REDACTED-ADDRESS]"`,
)
# `"email": "..."` — customer, billing_details, and receipt email fields. Other
# email-bearing keys (`receipt_email`, `customer_email_address`, prose) are
# caught by the generic email sweep below.
redact_email_field(t) := regex.replace(
t,
`("email"\s*:\s*)"[^"]*"`,
`${1}"[REDACTED-EMAIL]"`,
)
# `"...phone...": "..."` — customer, billing_details, shipping, and dispute-
# evidence phone fields. The key match accepts any JSON key that *contains*
# `phone` (case-insensitive) so `customer_phone_number` / `phone_number` are
# covered, not just the bare `phone` key. Over-redaction on egress is safe.
redact_phone_field(t) := regex.replace(
t,
`(?i)("[a-z0-9_]*phone[a-z0-9_]*"\s*:\s*)"[^"]*"`,
`${1}"[REDACTED-PHONE]"`,
)
# `"last4": "..."` — card / payment-method / bank-account last-four digits.
redact_last4_field(t) := regex.replace(
t,
`("last4"\s*:\s*)"[^"]*"`,
`${1}"[REDACTED-LAST4]"`,
)
# Bare email addresses anywhere in the text (word-boundary anchored:
# local part, "@", domain, TLD of at least two letters) — catches emails
# embedded in prose/description strings outside an "email" key.
redact_email_text(t) := regex.replace(
t,
`\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b`,
"[REDACTED-EMAIL]",
)
# Order: the key-anchored field rewrites first (their tokens contain no "@",
# braces, or quoted digits, so no later step can re-match an emitted token),
# then the generic email sweep over whatever text remains.
redact_block(b) := redact_email_text(
redact_last4_field(
redact_phone_field(
redact_email_field(
redact_address_string(redact_address_object(b)),
),
),
),
) if {
is_string(b)
}
# Non-string content blocks (structured blocks) pass through unmodified.
redact_block(b) := b if { not is_string(b) }
# -----------------------------------------------------------------------------
# Transform — emitted only when in scope, the caller is not in the finance
# group, and at least one block actually changed. Otherwise the rule is
# undefined and the aggregator skips this policy, returning the response
# byte-identical.
# -----------------------------------------------------------------------------
response_payload := object.get(input, "payload", {})
text_blocks := object.get(response_payload, "text", [])
redacted_blocks := [out |
some block in text_blocks
out := redact_block(block)
]
transform := {
"transformed_payload": object.union(response_payload, {"text": redacted_blocks}),
} if {
is_bulk_pii_tool
not is_finance
is_array(text_blocks)
redacted_blocks != text_blocks
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)
Used in these guides
Related policies
Airtable: Redact PII in Record Reads
Scans the responses of the Airtable record-read tools — the calls that return row fields values — and rewrites high-confidence PII shapes to a fixed…
Asana: Redact PII in Task & Comment Reads
On the Asana MCP read path, this transform scans the free-text business fields that ride back in task, comment/story, and status-update responses — notes,…
BigQuery: Redact PII in Query Results
Scans the content returned by BigQuery's result-returning tools and rewrites high-confidence PII shapes to fixed, non-recoverable redaction tokens before the…
Block Agent Email to External Recipients
Blocks agent-initiated Microsoft 365 email sends when any recipient address falls outside a corporate-domain allowlist.
Block BigQuery Exfiltration and Cross-Project Writes
Inspects the raw GoogleSQL string carried by BigQuery SQL tools and denies any statement that moves data out of the tenant's own project — even when the call…
bigqueryguard-warehouse-exportingresssqlexfiltrationsoc2pci-dssgdpr-ccpa
Block Bulk Export & External Staging (Snowflake)
Blocks Snowflake SQL-execution tool calls whose query text moves whole tables off the Snowflake perimeter — bulk export to cloud storage or a stage, and…
snowflakeguard-warehouse-sqlexportexfiltrationingresssoc2pci-dssgdpr-ccpa