Intercom: Mask Card Numbers in Conversation Responses
Masks payment-card numbers (PANs) in Intercom conversation content returned to agents by the conversation- and free-text-returning read tools.
- Direction
- egress
- Rego package
intercom.egress.mask_pan- App
- intercom
- Bundles
- soc2pci-dssgdpr-ccpa
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:0b4b075b2bc299e37c0c3c0c9d9ba4490009be9685104d546eb6abb35b64a8c4
intercommask-pan-egressegresscardholder-datadlpsoc2pci-dssgdpr-ccpa
What this policy does
Direction: egress (tool_post_invoke)
Default: allow (transform-only — never denies)
Package: intercom.egress.mask_pan
What it does
Masks payment-card numbers (PANs) in Intercom conversation content returned
to agents by the conversation- and free-text-returning read tools. Support
chat is a well-known place for customers to paste a full card number into a
message, and get_conversation returns every conversation part verbatim, so
a read of that thread would otherwise place the full PAN into the agent's
context. This makes conversation reads the cardholder-data egress path for
Intercom.
The policy Luhn-validates every 13–19-digit card-shaped sequence in the
response (tolerating single spaces or hyphens between digit groups) and
rewrites each match to BIN-plus-last4: the first six digits (the issuer
BIN) and the last four are kept, and every digit in between becomes *, e.g.
4111 1111 1111 1111 → 411111******1111. BIN+last4 is the maximum display
format PCI DSS permits for personnel without a business need to see the full
PAN. The Luhn check is mandatory here: it keeps false positives (order
numbers, ticket IDs, conversation IDs) from being mangled.
The policy never blocks a call. When at least one Luhn-valid PAN is found the
response content blocks are rewritten via transformed_payload; when nothing
matches, the transform rule is undefined and the response passes through
byte-identical.
Callers whose input.subject.claims.groups contains the documented
placeholder group pci-full-pan receive unmasked responses, so fraud and
chargeback staff who genuinely need the complete number still see it. The
exemption is fail-closed: a caller with no subject, no claims, no groups
claim, or a malformed groups claim is never exempt and always gets masked
output.
Why egress and not ingress
Egress transform (default allow) is the right posture: the PAN already lives in Intercom (a customer typed it into the chat), so there is no write to block — the only enforceable action on the MCP path is masking the agent-visible copy of the number as it is read back. Ingress cannot help, because the sensitive data flows out of Intercom, not in.
Tool name matching
The policy scopes to conversation-content responses on
input.resource.name, matched case-insensitively after normalizing _ to
- so both underscore (as the official server publishes them) and
hyphenated (community search-conversations) forms match:
*get_conversation— the official Intercom MCP server's full-thread read; returns every conversation part body verbatim. The main egress surface.*search_conversations(official, snake_case) and*search-conversations(fabian1710/mcp-intercom, kebab-case) — filtered conversation search whose results carry conversation-part text.*list_conversations,*search_conversations_by_customer,*search_tickets_by_customer, and*search_tickets_by_status(raoulbia-ai/mcp-server-for-intercom, the most-listed community server) — conversation and ticket reads that return full customer free-text bodies. Ticket bodies are support-chat content and carry the same paste-a-card-number risk as conversations, so they are in scope. Matched unconditionally like the other typed reads.
Two generic connector aliases are handled specially. The official server
also exposes the OpenAI/Anthropic-convention search and fetch tools that
alias the typed tools (search with object_type: "conversations", or
fetch resolving a conversation_…-prefixed ID both return conversation
bodies). A policy that matched only search_conversations and not these two
would be trivially bypassed. Because an egress policy sees only the
response — not the request's object_type / fetch-ID argument — *search
and *fetch are brought into scope only when the response itself carries a
conversation marker (a conversation_-prefixed ID or a
"type":"conversation" object), which is the egress-observable proxy for
"this was a conversation search / fetch". Contact, company, and article
responses from search/fetch carry no such marker and pass through
untouched.
Structured PII tools (get_contact, search_contacts, get_company,
articles) are deliberately out of scope — card data in a contact profile is
a separate concern with a different exemption group; see Composition.
The DTwo gateway prefixes tool names with the configured MCP server name
(e.g. intercom-mcp-get_conversation), and that prefix is not standardized —
suffix matching keeps the policy portable. The official server's names are
verified against Intercom's developer docs and the Speakeasy governance
catalog; verify the exact names your gateway sends with the dump-input debug
technique before relying on this in production.
Patterns matched
Conservative, anchored PAN shapes only — each pattern is commented in the Rego, and every candidate must also pass the Luhn check before it is masked:
- 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). Runs of 20+ digits never match: there is no word boundary inside a digit run, so a longer identifier is never partially masked.
Compliance alignment
- PCI DSS 3.4.1 — supports masking of PAN when displayed: the agent
channel shows at most BIN+last4, with full-PAN visibility limited to a
defined role (
pci-full-pan). - PCI DSS 3.4.2 — supports preventing copy/relocation of PAN via remote-access technologies: an agent that only ever receives the masked form cannot re-post the full PAN into tickets, other chats, or files.
- PCI DSS 12.5.2 / 12.10.7 — supports PCI scope control and PAN-where-not-expected incident procedures: support chat is a classic not-expected location for cardholder data, and the gateway's decision/transform audit events for this policy give the incident process a concrete trigger to work from.
- CCPA/CPRA §1798.150 — supports reducing nonredacted-PI breach exposure: card numbers surfaced to agents from customer conversations are masked by default.
- SOC 2 CC6.7 — supports restricting the transmission, movement, and removal of confidential information: masking cardholder data in the agent-visible copy of conversation reads keeps the full PAN from leaving the gateway toward the agent.
- Also maps to ISO 27001 A.8.11 (data masking) on the MCP path, if you track that framework.
Response shape
Egress tool output arrives as content blocks in input.payload.text (an
array; entries are typically strings of plain text, markdown, or serialized
JSON). The policy scans each string block, replaces every Luhn-valid match
with its own BIN+last4 form, and emits transform.transformed_payload with
the original payload's text replaced by the masked blocks. Non-string
blocks pass through unmodified. Because matching is string-level, PANs are
masked wherever they appear in a block — part body text, search snippets,
serialized-JSON conversation objects — without parsing Intercom's specific
conversation schema.
Examples
Transformed (masked)
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "intercom-mcp-get_conversation", "type": "tool" },
"payload": {
"name": "intercom-mcp-get_conversation",
"text": ["{\"conversation_parts\":[{\"body\":\"my card is 4111 1111 1111 1111\"}]}"]
},
"subject": { "sub": "google-apps|casey@acme.com", "claims": { "groups": ["support"] } }
}
}
allow = true; the agent sees
{"conversation_parts":[{"body":"my card is 411111******1111"}]}.
Allowed unmasked (exempt group)
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "intercom-mcp-get_conversation", "type": "tool" },
"payload": {
"name": "intercom-mcp-get_conversation",
"text": ["{\"conversation_parts\":[{\"body\":\"my card is 4111 1111 1111 1111\"}]}"]
},
"subject": { "sub": "google-apps|fraud-analyst@acme.com", "claims": { "groups": ["pci-full-pan"] } }
}
}
allow = true, no transform — the caller is in the pci-full-pan group.
Passthrough (no PAN / not a conversation)
A Luhn-invalid digit run (a ticket ID, an order number) produces no
transform. A search/fetch response with no conversation marker (a
contact or company result) is out of scope and passes through
byte-identical.
Composition
One policy, one job. Useful companions:
- A PII redaction egress policy (PF-02
redact-pii-egress) for SSNs, national IDs, emails, phones, and credentials in conversation and contact responses — broader PII is a separate concern from cardholder data, with a different exemption group. apps/intercom/cap-bulk-export(PF-08) for volume control onsearch_contacts/search_conversations— masking does not stop mass harvesting of masked content.- An ingress role-gate (PF-04/PF-12 style) on
get_contact/search_contacts/fetchwithcontact_/company_-prefixed IDs, so analytics users get conversations but not full customer profiles.
Known limitations
- Luhn-valid non-card numbers are masked too. The Luhn check eliminates most timestamps and IDs, but some non-card identifiers (certain IMEIs and other checksummed numbers) are Luhn-valid and will be masked. The masked form keeps first-six/last-four, so such false positives usually stay recognizable.
- A PAN split across conversation parts is missed.
get_conversationreturns each part as its own body, and the gateway delivers them as separate content blocks. A single card number typed across two parts (e.g.4111 1111in one message and1111 1111in the next) leaves no block with 13+ contiguous card digits, so neither block matches and the PAN is not masked. Matching is per-block by design (cross-block concatenation would produce spurious matches from unrelated adjacent numbers). A test case pins this residual. - Obfuscated PANs are missed. Card numbers separated by characters other
than a single space or dash (dots, unicode spaces, non-digit filler such as
4111.1111.1111.1111or4111x1111x1111x1111), split across lines, spelled out in words, or base64-encoded do not match. Card numbers typed with non-ASCII digits (e.g. Unicode fullwidth4111 …) also do not match: the RE2\dclass is ASCII-only. Grouped formats other than 4-4-4-4 and Amex 4-6-5 (e.g. 19-digit 4-4-4-4-3 print format) match only in their unseparated form. - A PAN glued directly to a word character is missed. Every pattern is
\b-anchored, and the underscore counts as a word character in RE2, so a digit run immediately preceded or followed by a letter, digit, or underscore with no separator (e.g.conversation_4111111111111111inside a serialized-JSON token value) has no word boundary and is not masked. This is the deliberate cost of the same\banchoring that stops a 20+-digit identifier from being partially masked. Punctuation- or whitespace- delimited PANs (the normal human-typed case) are unaffected. - Generic
search/fetchscoping depends on a conversation marker in the response. Because egress cannot see the request'sobject_type/ fetch ID,*searchand*fetchare masked only when the response contains aconversation_-prefixed ID or a"type":"conversation"object. The typed*get_conversationand*search_conversationstools are masked unconditionally, so this affects only the generic aliases: if a server's conversationsearch/fetchresponse omits both markers, those responses are not masked. Verify your server's response shape with the dump-input technique. - Structured (non-string) content blocks and non-array
textare not masked — fail-open. The policy scans and rewrites only string entries ofinput.payload.text, and only whentextis a JSON array. A PAN carried inside a content block delivered as a JSON object (an MCP typed{"type":"text","text":"…"}block), or apayload.textdelivered as a bare string, passes through unmasked. In the DTwo egress shape observed to date tool output arrives as an array of string blocks, and serialized JSON inside a string block is scanned and masked; only native object shapes and non-arraytextevade it. Confirm with the dump-input technique that your gateway/server delivers string blocks before relying on this policy against servers that emit typed content objects. - Only surveyed servers' conversation/ticket reads are in scope. Scope is
the union of the official, fabian1710, and raoulbia read tools that return
conversation or ticket bodies. A conversation-returning tool from another
(or future) community server whose suffix is not in the list — e.g.
evolsb/fast-intercom-mcp's
sync_conversations, or any renamed upstream tool — is not masked. Add its suffix totyped_conversation_suffixes(or, for a genericsearch/fetchalias, rely on the response conversation marker). Confirm your gateway's actual tool names with the dump-input technique. - MCP path only. The full card number still exists in Intercom and in Intercom's own inbox/web UI; this policy controls what the agent sees on the MCP path. Reads made outside MCP (the Intercom inbox, REST API scripts, Fin's own actions) are out of the gateway's reach.
- Group names are placeholders — replace
pci-full-panwith your IdP's group name at import time. The exemption readsinput.subject.claims.groupsand requires it to be an array of strings; every other shape (string, object, number, null, or missing) fails closed to masked output. Confirm your IdP emits agroupsclaim as a string array for your tenant before relying on the exemption.
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 intercom.egress.mask_pan
# Transform-only policy — never denies, only masks Luhn-valid card numbers
# in Intercom conversation responses to BIN+last4.
default allow := true
# -----------------------------------------------------------------------------
# Tool matching — conversation-content read tools across the Intercom MCP
# server vocabularies in real use. The gateway prefixes tool names with the
# configured server name, so we match on the suffix to stay portable. The
# incoming name is normalized `_` -> `-` first so both `search_conversations`
# (official snake_case) and `search-conversations` (fabian1710 kebab) match.
# -----------------------------------------------------------------------------
normalized_name := replace(lower(input.resource.name), "_", "-")
# Typed conversation-returning tools — always in scope. Their responses are
# conversation-part bodies, so no response-content check is needed.
typed_conversation_suffixes := [
# Official Intercom MCP server — full-thread read (all parts verbatim).
"get-conversation",
# Official (search_conversations) and fabian1710 (search-conversations).
"search-conversations",
# raoulbia-ai/mcp-server-for-intercom — the most-listed community server.
# Its conversation and ticket reads return full customer free-text bodies,
# the same PAN egress surface, so they are in scope unconditionally.
"list-conversations",
"search-conversations-by-customer",
"search-tickets-by-customer",
"search-tickets-by-status",
]
is_typed_conversation_read if {
some suffix in typed_conversation_suffixes
endswith(normalized_name, suffix)
}
# Generic OpenAI/Anthropic-convention aliases that the official server exposes
# alongside the typed tools. `search` (object_type "conversations") and `fetch`
# (a conversation_ ID) both return conversation bodies. An egress policy cannot
# see the request's object_type / fetch-ID argument, so these are scoped by a
# conversation marker in the RESPONSE instead (see response_is_conversation).
is_generic_alias if {
some suffix in ["search", "fetch"]
endswith(normalized_name, suffix)
}
# -----------------------------------------------------------------------------
# Response content inspection.
# -----------------------------------------------------------------------------
text_blocks := object.get(input.payload, "text", [])
# True when any string block carries an Intercom conversation marker: a
# `conversation_`-prefixed ID (the fetch/search ID convention) or a
# `"type":"conversation"` object (conversation / conversation_part). This is
# the egress-observable proxy for "this search/fetch was over conversations".
response_is_conversation if {
some block in text_blocks
is_string(block)
regex.match(`(?i)(conversation_|"type"\s*:\s*"conversation)`, block)
}
# A response is in scope if it is a typed conversation read, or a generic
# search/fetch whose response looks like conversation data.
in_scope if is_typed_conversation_read
in_scope if {
is_generic_alias
response_is_conversation
}
# -----------------------------------------------------------------------------
# PAN candidate shapes — anchored with \b word boundaries so digit runs inside
# longer identifiers are never partially matched. Every candidate must also
# pass the Luhn check below before it is masked.
# -----------------------------------------------------------------------------
pan_pattern := concat("|", [
# 16-digit PANs grouped 4-4-4-4 with space or dash separators
# (Visa / Mastercard / Discover print format, e.g. 4111 1111 1111 1111).
`\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.
# 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`,
])
# -----------------------------------------------------------------------------
# Luhn check — filters card-shaped candidates so timestamps, order numbers,
# ticket IDs, and other digit runs that merely look like PANs are left alone.
# -----------------------------------------------------------------------------
digits_only(s) := regex.replace(s, `[^0-9]`, "")
luhn_contribution(d, parity) := d if { parity == 0 }
luhn_contribution(d, parity) := 2 * d if {
parity == 1
(2 * d) < 10
}
luhn_contribution(d, parity) := (2 * d) - 9 if {
parity == 1
(2 * d) >= 10
}
luhn_valid(digits) if {
chars := split(digits, "")
n := count(chars)
total := sum([v |
some i, c in chars
v := luhn_contribution(to_number(c), (n - 1 - i) % 2)
])
total % 10 == 0
}
# All card-shaped substrings of t that pass the Luhn check.
pan_candidates(t) := {c |
some c in regex.find_n(pan_pattern, t, -1)
luhn_valid(digits_only(c))
}
# -----------------------------------------------------------------------------
# Masking — each match is rewritten to BIN+last4: first six digits (issuer
# BIN) and last four kept, everything between masked with `*`. Separators are
# dropped in the masked form (e.g. `4111 1111 1111 1111` -> `411111******1111`).
# -----------------------------------------------------------------------------
mask_pan(c) := masked if {
d := digits_only(c)
n := count(d)
masked := concat("", [
substring(d, 0, 6),
# Replace every middle digit with `*` (RE2 has no repeat builtin, so we
# mask the middle substring char-by-char instead of building a `*` run).
regex.replace(substring(d, 6, n - 10), `\d`, "*"),
substring(d, n - 4, 4),
])
}
# Rewrite every Luhn-valid candidate in a string block to its masked form.
mask_block(b) := out if {
is_string(b)
replacements := {c: mask_pan(c) | some c in pan_candidates(b)}
count(replacements) > 0
out := strings.replace_n(replacements, b)
}
mask_block(b) := b if {
is_string(b)
count(pan_candidates(b)) == 0
}
# Non-string content blocks (structured/JSON blocks) pass through unmodified.
mask_block(b) := b if { not is_string(b) }
# -----------------------------------------------------------------------------
# Full-PAN exemption — callers in the placeholder group see unmasked content.
# Fail-closed: missing subject, missing claims, missing groups, or a malformed
# groups claim all leave this rule undefined, so masking 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"
}
# -----------------------------------------------------------------------------
# Transform — emitted only when in scope, the caller is not exempt, and at
# least one block actually changed. Otherwise the rule is undefined and the
# aggregator skips this policy, returning the response byte-identical.
# -----------------------------------------------------------------------------
masked_blocks := [out |
some block in text_blocks
out := mask_block(block)
]
transform := {
"transformed_payload": object.union(input.payload, {"text": masked_blocks}),
} if {
input.mode == "output"
in_scope
not caller_may_view_full_pan
is_array(text_blocks)
masked_blocks != text_blocks
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)
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