--- name: Mask Card Numbers in Email Content Read by Agents tags: - gmail - mask-pan-egress - egress - email - cardholder-data - dlp - soc2 - pci-dss - gdpr-ccpa publishedAt: 2026-07-12 description: | # gmail / mask-pan-egress **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) ```jsonc { "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) ```jsonc { "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. direction: egress apps: - gmail industries: [] bundles: - soc2 - pci-dss - gdpr-ccpa experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```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 } ```