dtwo Policy Store

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.

Direction
egress
Rego package
gmail.egress.mask_pan
App
gmail
Bundles
soc2pci-dssgdpr-ccpa
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:879ee13a0594096f178508fd7569014f342d1e43e87ef96a86818554b85cbc7e

gmailmask-pan-egressegressemailcardholder-datadlpsoc2pci-dssgdpr-ccpa

What this policy does

Direction: egress (tool_post_invoke) Default: allow (transform-only — never denies) Package: gmail.egress.mask_pan

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)