Mask Card Numbers in Email Content Read by Agents

Masks payment-card-number (PAN) shapes in email content returned to agents by Gmail mailbox-read tools.

On this page
  1. What it does
  2. Compliance alignment
  3. Tool name matching
  4. Patterns matched
  5. Response shape
  6. Examples
  7. Transformed (masked)
  8. Allowed unmasked (exempt group)
  9. Composition
  10. Known limitations
  11. Policy source (Rego)
  12. Related policies
Direction
egress (tool_post_invoke)
Default
allow (transform-only — never denies)
Rego package
gmail.egress.mask_pan
App
Gmail
Bundles
SOC 2PCI DSSGDPR / CCPA
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:879ee13a0594096f178508fd7569014f342d1e43e87ef96a86818554b85cbc7e

What it does

Masks payment-card-number (PAN) shapes in email content returned to agents by Gmail mailbox-read tools. When an agent reads a message, thread, or search result, any 13–19-digit card-number-shaped string in the response — with or without space/dash separators — is replaced with [PAN REDACTED] before the content reaches the agent.

The policy never blocks a call. It declares redact_patterns that the gateway applies over the response text (input.payload.text), so mailbox reads keep working and only the card numbers are masked.

Callers in a documented IdP group (placeholder: pci-full-pan) skip the transform and see unmasked content. The exemption is fail-closed: a caller with no claims, no groups claim, or a malformed groups claim is never exempt.

Compliance alignment

  • SOC 2 CC6.7 — supports the restriction on movement/removal of information: masking PANs before mailbox content reaches the agent keeps cardholder data from being relayed out of Gmail over the MCP path.
  • SOC 2 C1.1 — supports identification and protection of confidential information: card numbers are treated as confidential and masked on the agent channel by default, with full-PAN visibility limited to a defined role.
  • PCI DSS 3.4.1 — supports masking of PAN when displayed: the agent channel shows [PAN REDACTED] instead of full card numbers, with visibility of full PAN limited to a defined role (pci-full-pan).
  • PCI DSS 3.4.2 — supports preventing copy/relocation of PAN via remote access: an agent that never receives the full PAN cannot re-post it into tickets, chats, or files.
  • PCI DSS 12.5.2 / 12.10.7 — supports PCI scope control: masking on the mailbox-read path is a backstop against cardholder data creeping into agent context from email, one of the classic PAN-where-not-expected channels.
  • CCPA/CPRA §1798.150 — supports reducing exposure of nonredacted personal information: card numbers surfaced to agents from mailboxes are redacted by default.
  • Also maps to ISO 27001 A.8.11 (data masking) on the MCP path, if you track that framework.

Tool name matching

The policy matches Gmail mailbox-content read tools case-insensitively by suffix on input.resource.name, covering all three MCP-server vocabularies in real use:

  • *get_thread — Google official / Claude Gmail connector; its FULL_CONTENT format returns plaintextBody for every message in the thread, making this the main egress surface.
  • *search_threads — Google official / Claude connector; results include ~200-char body snippets.
  • *read_email, *search_emails — GongRzhe/Gmail-MCP-Server.
  • *get_gmail_message_content, *get_gmail_thread_content, *get_gmail_messages_content_batch, *get_gmail_threads_content_batch, *search_gmail_messages — taylorwilsdon/google_workspace_mcp, including the batch variants that return dozens of full bodies per call.

The DTwo gateway prefixes tool names with the configured MCP server name (e.g. gmail-mcp-get_thread), and that prefix is not standardized — suffix matching keeps the policy portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production, and add suffixes if your Gmail MCP server uses different read-tool names.

Patterns matched

Conservative PAN shapes only — each pattern is commented in the Rego:

  • 16-digit PANs grouped 4-4-4-4 with space or dash separators (Visa/Mastercard/Discover print format).
  • 15-digit American Express PANs grouped 4-6-5, constrained to the 34/37 IIN range.
  • Unseparated 13–19-digit runs (the ISO/IEC 7812 PAN length range).

Response shape

Egress transforms are applied by the gateway over the serialized response content blocks (input.payload.text). The patterns are byte-level and not JSON-aware, so they mask PANs wherever they appear in the response — plaintextBody fields, snippets, subjects — without the policy needing to parse the tool-specific JSON shape.

Examples

Transformed (masked)

{
  "input": {
    "action": "tool_post_invoke",
    "mode": "output",
    "resource": { "name": "gmail-mcp-get_thread", "type": "tool" },
    "payload": {
      "name": "gmail-mcp-get_thread",
      "text": ["{\"messages\":[{\"plaintextBody\":\"Card: 4111 1111 1111 1111, exp 12/27\"}]}"]
    },
    "subject": { "sub": "google-apps|casey@acme.com", "claims": { "groups": ["support"] } }
  }
}

allow = true; transform emits the PAN patterns with replacement [PAN REDACTED], so the agent sees Card: [PAN REDACTED], exp 12/27.

Allowed unmasked (exempt group)

{
  "input": {
    "action": "tool_post_invoke",
    "mode": "output",
    "resource": { "name": "gmail-mcp-get_thread", "type": "tool" },
    "payload": {
      "name": "gmail-mcp-get_thread",
      "text": ["{\"messages\":[{\"plaintextBody\":\"Card: 4111 1111 1111 1111\"}]}"]
    },
    "subject": { "sub": "google-apps|pci-analyst@acme.com", "claims": { "groups": ["pci-full-pan"] } }
  }
}

allow = true, no transform — the caller is in the pci-full-pan group.

Composition

This policy masks card numbers only. Useful companions:

  • A companion Gmail PII redaction egress policy (PF-02 redact-pii-egress) for SSNs, national IDs, and credentials/secrets in mailbox content — one policy, one job; card data and broader PII are separate concerns with separate exemption groups.
  • apps/gmail/cap-bulk-export (PF-08) for volume control on the batch read tools (get_gmail_messages_content_batch, get_gmail_threads_content_batch, search maxResults) — masking does not stop mass harvesting of masked content.
  • apps/gmail/guard-external-send and apps/gmail/guard-mailbox-persistence for the outbound and persistence sides of the mailbox.

Known limitations

  • Regex cannot Luhn-validate. Rego pattern matching is pure regex, so matches are card-number shapes, not verified PANs, and fixed-string replacement masks the entire match — BIN+last4 preservation (the usual PCI display format) is not achievable here; treat [PAN REDACTED] as the display format on the agent channel.
  • Byte-level over-matching. The patterns run over the serialized response bytes, so digit runs that merely look like PANs are masked too: 13-digit Unix millisecond timestamps, 13–19-digit order/tracking/account numbers, and digit-only 4-4-4-4 groups inside UUID-like identifiers. The grouped patterns require separators and the run pattern stops at 19 digits to limit this, but false positives are inherent — test against representative mailbox data.
  • Obfuscated PANs are missed. Card numbers split across content blocks, separated by characters other than space/dash (dots, unicode spaces), spelled out, inside images, or base64-encoded in MIME parts do not match. Runs of 20+ digits also do not match by design. Three regex edges are worth calling out explicitly, because a red-team review found them and they are inherent to the pattern set, not bugs:
    • ASCII digits only. The patterns use \d, which matches only [0-9]. Fullwidth (4111…) or Arabic-Indic (٤١١١…) digit variants are not masked.
    • Only two grouped shapes. Separated PANs are caught only in the 4-4-4-4 (16-digit) and 4-6-5 (Amex) print groupings. A PAN typed with spaces in some other arrangement (e.g. 41111111 11111111, an 8+8 split, or 4111 111111111111, a 4+12 split) matches neither the grouped patterns nor the unseparated-run pattern (the space breaks the run into sub-13-digit pieces).
    • Word-boundary anchoring. The unseparated-run pattern requires a word boundary on both sides, so a digit run glued directly to letters (card4111111111111111x) is not masked. Real mailbox content almost always surrounds numbers with quotes/spaces/punctuation, so this is a contrived rather than common evasion — but it is a true residual.
  • Only mailbox-body read tools are matched — draft/label/filter reads are not. The suffix allowlist deliberately covers message/thread/search content tools only. The Google/Claude connector's list_drafts, and the community servers' label/filter listing reads (list_email_labels, list_filters, get_filter, list_gmail_labels, list_gmail_filters), are not matched, so a card number sitting in a draft body (e.g. a human-composed draft the agent reads back via list_drafts) is returned unmasked. This is a scoping choice, not a fix: draft/label/filter response shapes vary and list_drafts may return only metadata on some servers. If drafts routinely carry cardholder data in your environment, either add list_drafts (and your server's draft-read suffix) to mailbox_read_suffixes after verifying its response shape with the dump-input technique, or gate draft reads at ingress with a group-gated deny.
  • Attachment content is not maskable. get_gmail_attachment_content (taylorwilsdon) and download_attachment (GongRzhe) return base64/binary data that byte-level regex cannot reliably mask — deliberately out of scope here. Gate those tools at ingress instead (e.g. a group-gated deny policy on attachment-content tools).
  • Egress masking only. The card number still exists in the mailbox and in Gmail's own UI; this policy controls what the agent sees on the MCP path.
  • Group names are placeholders — replace pci-full-pan with your IdP's group name at import time. The exemption reads input.subject.claims.groups; confirm your IdP emits a groups claim (see the DTwo identity-claims documentation).

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 gmail.egress.mask_pan

# Transform-only policy — never denies, only masks card-number shapes
# in mailbox content returned to the agent.
default allow := true

# Conservative PAN-shaped patterns. These are applied byte-level over the
# serialized response, so each is anchored with \b word boundaries to limit
# over-matching. Pure regex cannot Luhn-validate — matches are card-number
# shapes, not verified PANs (see Known limitations).
pan_patterns := [
	# 16-digit PANs grouped 4-4-4-4 with space or dash separators
	# (Visa / Mastercard / Discover print format, e.g. 4111 1111 1111 1111).
	# Separators are required here; unseparated PANs are caught by the
	# digit-run pattern below.
	`\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{4}\b`,
	# 15-digit American Express PANs grouped 4-6-5 with space or dash
	# separators, constrained to the 34/37 IIN range (e.g. 3782 822463 10005).
	`\b3[47]\d{2}[ -]\d{6}[ -]\d{5}\b`,
	# Unseparated 13-19 digit runs — the ISO/IEC 7812 PAN length range
	# (Visa 13/16, Mastercard 16, Amex 15, Discover 16, JCB 16-19).
	# Runs of 20+ digits never match: there is no word boundary inside a
	# digit run, so this cannot partially mask a longer identifier.
	`\b\d{13,19}\b`,
]

# Gmail mailbox-content read tools across the three MCP-server vocabularies
# in real use (Google official / Claude connector, GongRzhe, taylorwilsdon).
# The gateway prefixes tool names with the configured server name, so we
# match on the suffix to stay portable. Attachment-content tools are
# deliberately absent — base64/binary output is not regex-maskable; gate
# those at ingress instead.
mailbox_read_suffixes := [
	# Google official / Claude connector — FULL_CONTENT returns plaintextBody
	# for every message in the thread (the main egress surface).
	"get_thread",
	# Google official / Claude connector — results carry body snippets.
	"search_threads",
	# GongRzhe/Gmail-MCP-Server.
	"read_email",
	"search_emails",
	# taylorwilsdon/google_workspace_mcp, incl. high-volume batch variants.
	"get_gmail_message_content",
	"get_gmail_messages_content_batch",
	"get_gmail_thread_content",
	"get_gmail_threads_content_batch",
	"search_gmail_messages",
]

is_mailbox_read_tool if {
	name := lower(input.resource.name)
	some suffix in mailbox_read_suffixes
	endswith(name, suffix)
}

# Callers in the placeholder full-PAN group see unmasked content.
# Fail-closed: missing subject, missing claims, missing groups, or a
# malformed groups claim all leave this rule undefined, so the transform
# applies. The is_array guard is load-bearing: without it a groups claim
# shaped as an object (e.g. {"role":"pci-full-pan"}) would iterate its
# *values* and match, granting the exemption to a caller who never held
# the group in an array. Requiring an array keeps every non-array shape
# (string, object, number, null) fail-closed.
# Replace "pci-full-pan" with your IdP's group name at import time.
caller_may_view_full_pan if {
	claims := object.get(object.get(input, "subject", {}), "claims", {})
	groups := object.get(claims, "groups", [])
	is_array(groups)
	some group in groups
	group == "pci-full-pan"
}

# Mask PAN shapes in mailbox-read responses for non-exempt callers.
transform := {
	"redact_patterns": pan_patterns,
	"replacement": "[PAN REDACTED]",
} if {
	input.mode == "output"
	is_mailbox_read_tool
	not caller_may_view_full_pan
}

Canonical source: policy.md on GitHub · raw · raw on this site (.md)

Updated

to navigate to open