---
name: "Stripe: Redact Customer PII from Bulk Reads"
tags:
  - stripe
  - redact-pii
  - pii
  - dlp
  - redaction
  - egress
  - soc2
  - gdpr-ccpa
publishedAt: 2026-07-12
description: |
  # stripe / redact-pii-egress-customer

  **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)

  ```jsonc
  {
    "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)

  ```jsonc
  {
    "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 the `stripe_api_write` write escape hatch.
  - **`gate-money-movement-refund-cap`** — caps refunds on the money-out
    path.
  - A **`cap-bulk-export`-style ingress guard** (PF-08) clamping `limit` on
    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 `finance` with your IdP's group
    name at import time.** The exemption expects the `groups` claim as an
    array of strings (a single bare string is also handled); if your IdP
    emits roles under a namespaced claim, adjust `caller_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 `name` is 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 *containing* `phone` or `address`
    (case-insensitively, so `billing_address`/`customer_phone_number` are
    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"`), `last4` outside 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 bare `4242` in 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`, and **`list_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 in `bulk_read_suffixes` are in scope; extend it if your deployment
    exposes these and your posture requires it.
  - **Non-official servers break suffix symmetry.** The community
    `atharvagupta2003/mcp-stripe` server uses inverted `noun_verb` names
    (e.g. `customer_list`) and Composio uses `STRIPE_*` slugs across ~415
    tools — neither matches this suffix set. Pin your deployment's actual
    tool names in `bulk_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;
    `last4` is the only card identifier present and it is masked.
  - **The generic email pattern can over-match** `user:password@host`
    substrings 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_payload` replaces 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.
direction: egress
apps:
  - stripe
industries: []
bundles:
  - soc2
  - gdpr-ccpa
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```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
}
```
