--- name: Fence Intercom Contact & Company PII Reads tags: - intercom - fence-sensitive-scopes - contact-reads - pii - ingress - soc2 - hipaa - pci-dss - gdpr-ccpa publishedAt: 2026-07-12 description: | # intercom / fence-contact-reads **Direction:** ingress (`tool_pre_invoke`) **Default:** deny the structured-PII read surface for callers outside a support/CRM group; allow everything else **Package:** `intercom.ingress.fence_contact_reads` ## What it does Gates Intercom's structured-PII read surface — customer **contact** and **company** profiles — by IdP group. A caller whose JWT groups do not include a documented support/CRM group is denied access to: - `*get_contact` — full PII profile (email, phone, location, activity timestamps, custom attributes) - `*search_contacts` — contact search, including the email-**domain** enumeration path - `*get_company` — full company record - `*list_companies` — company listing - the generic `*search` tool **when `object_type == "contacts"`** — the connector-convention alias for `search_contacts` - the generic `*fetch` tool **when the target ID is `contact_…` or `company_…`-prefixed** — the connector-convention alias for `get_contact` / `get_company` Every other tool call passes through: conversation reads (`*get_conversation`, `*search_conversations`, `*search` with `object_type: "conversations"`, `*fetch` of a `conversation_…` ID), Help Center article tools, and any non-Intercom tool. Analytics and other non-support roles therefore keep conversation access but are steered away from raw customer profiles — enforcing minimum-necessary and least-privilege on the agent channel. The check runs at ingress, before the call reaches the Intercom MCP server, so a denied read never executes and no contact PII is returned to the agent. ### Why both the typed and generic paths are covered The contacts read surface is reachable two ways. A rule that only named `search_contacts` / `get_contact` would be trivially bypassed by calling the generic `search` with `object_type: "contacts"`, or the generic `fetch` with a `contact_`-prefixed ID. This policy fences the typed tools **and** both generic aliases so neither path leaks. ## Compliance alignment - **SOC 2 C1.1** — supports identifying and protecting confidential information by restricting the customer-profile read surface to roles that need it; **P4.1** — supports limiting personal-information use to identified purposes (support/CRM), keeping customer profiles out of analytics and other roles' reach. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard by scoping structured-PII reads to the support/CRM workforce; **§164.308(a)(4)** — supports information-access management (role-based authorization of access to protected data); **§164.522(a)** — supports enforcing agreed-to access restrictions on the agent channel. - **PCI DSS 7.2.6** — supports restricting programmatic query access to stored account data by role, where contact custom attributes carry plan/billing metadata; **7.2.1** — supports the least-privilege access model on the agent channel. - **GDPR Art. 9** — supports guarding special-category-adjacent profile data behind a role gate; **Art. 5(1)(b)** — supports purpose limitation (profiles reachable only for support/CRM purposes); **CCPA/CPRA §1798.121** — supports the right to limit use of sensitive personal information by fencing the profile surface. ## Tool name matching Tool names are matched **case-insensitively by suffix** on `input.resource.name`, because the DTwo gateway prefixes every tool with the configured MCP server name (e.g. `intercom-get_contact`) and that prefix is not standardized: - `endswith(name, "get_contact")`, `endswith(name, "search_contacts")`, `endswith(name, "get_company")`, `endswith(name, "list_companies")` — the typed surface. - `endswith(name, "search")` — the generic search tool. Only fenced when the `object_type` argument (trimmed + lower-cased) **starts with** `contact` — this catches `contacts`, the singular alias `contact`, and whitespace-padded `"contacts "`, while `conversations` (which does not start with `contact`) passes through. Note `search_contacts` ends in `contacts`, not `search`, so it is caught by its own typed rule, not this one. - `endswith(name, "fetch")` — the generic fetch tool. Only fenced when the `id` argument (lower-cased) **contains** a `contact_` or `company_` token — a bare prefixed ID (`contact_123`) or a workspace URL that embeds one (`…/users/contact_123`) are both caught. See Known limitations for the bare-URL (no embedded token) residual. All six tool names (`get_contact`, `search_contacts`, `get_company`, `list_companies`, `search`, `fetch`) are **verified** against Intercom's developer docs and the Speakeasy governance catalog per the app landscape note. ## Argument shape - Generic `search`: reads `object.get(input.payload.args, "object_type", "")`, lower-cases and `trim_space`s it, then checks it starts with `contact`. If `object_type` is present but **not a string** (an array/number/object), the call is fenced (fail closed) rather than slipping through — see below. - Generic `fetch`: reads `object.get(input.payload.args, "id", "")`, lower-cases it, and checks whether it contains a `contact_` / `company_` token (so an embedded-in-URL prefixed ID is caught, not only a bare prefix). If `id` is present but **not a string**, the fetch is fenced (fail closed). ## Identity gate Authorization reads the caller's IdP groups: `object.get(object.get(input.subject, "claims", {}), "groups", [])`. A caller is authorized only if at least one of those groups is in `allowed_groups` (placeholder: `{"support", "crm"}`). The gate **fails closed**: a caller with no `groups` claim (or no `subject`/`claims` at all) has an empty group list, matches no allowed group, and is denied the PII surface. ## Examples ### Allowed — support-group caller reads a contact ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-get_contact", "type": "tool" }, "subject": { "claims": { "groups": ["support"] } }, "payload": { "name": "intercom-get_contact", "args": { "id": "contact_123" } } } } ``` `allow = true`. ### Allowed — analytics caller reads a conversation ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-search", "type": "tool" }, "subject": { "claims": { "groups": ["analytics"] } }, "payload": { "name": "intercom-search", "args": { "object_type": "conversations", "query": "state=open" } } } } ``` `allow = true` — conversation access is unaffected. ### Denied — non-support caller enumerates contacts via the generic search alias ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-search", "type": "tool" }, "subject": { "claims": { "groups": ["analytics"] } }, "payload": { "name": "intercom-search", "args": { "object_type": "contacts", "query": "email~@acme.com" } } } } ``` `allow = false`, with the role-gate reason. ## Composition This policy is single-purpose (role-gate the contact/company read surface). Useful companions: - **Egress custom-attribute strip** on `*get_contact` / `*search_contacts` / `*fetch` contact responses — remove billing/tier `custom_attributes` even for authorized callers. - **Egress PII/PAN redaction** on `*get_conversation` / `*search` / `*fetch` conversation bodies, since conversations remain readable here and carry raw customer free-text. - **`cap-bulk-export`** to clamp `limit` / `per_page` on the searches this policy still allows for authorized callers. ## Known limitations - **Group names are placeholders — replace `support` / `crm` in `allowed_groups` with your IdP's group name at import time.** The gate only works when the gateway has an IdP configured and the caller's JWT carries a `groups` claim. - **Single-token community servers expose no per-user identity.** Community Intercom servers (e.g. `raoulbia-ai/mcp-server-for-intercom`, `fabian1710/mcp-intercom`) authenticate with a single workspace-wide `INTERCOM_ACCESS_TOKEN` and expose no per-user identity, so this group gate only functions when the caller identity reaches the gateway via IdP claims. Those servers also do not expose `get_contact` / `get_company`, so the typed rules simply never match there. - **`fetch` bare-URL (no embedded prefix token) residual.** The generic `fetch` tool takes a prefixed ID or an Intercom URL, under the `id` argument (the OpenAI/Anthropic connector convention). The rule now fences any `id` value that **contains** a `contact_`/`company_` token, so a workspace URL that embeds the prefixed ID (`…/users/contact_123`) is caught. A URL that references the resource **only** by a bare numeric ID with no `contact_`/`company_` token (or that passes the target under a different argument key) is **not** detected and will pass through for non-support callers — a known residual bypass (see the tests.yaml case). The exact URL/argument shape is unverified in the app landscape note; verify it with the dump-input debug technique and, if your server uses bare-numeric URLs, add an explicit URL-path matcher (`/contacts/`, `/companies/`) or a per-server key before relying on this in production. - **Generic `search` without `object_type` is treated as non-contacts.** A `search` call that omits `object_type` is allowed (assumed conversation search). If your server defaults `search` to contacts when `object_type` is absent, tighten the `is_generic_search_contacts` rule accordingly. - **Non-string `object_type` / `id` fail closed (red-team fix).** A `search` whose `object_type` (or a `fetch` whose `id`) arrives as a non-string — an array such as `["contacts"]`, a number, or an object — cannot be lower-cased, so the primary match rule would be *undefined* and the call would otherwise slip through the allow fall-through. The policy fences any present-but-non-string `object_type`/`id` and requires support/CRM authorization for it (fail closed). A side effect: a malformed conversation search/fetch that wraps its type/id in an array is denied for non-support callers with the contact role-gate reason — acceptable, since such input is malformed per the DSL and erring toward deny is the intended posture. Absent `object_type`/`id` still uses the documented string default and is unaffected. - **`groups` claim must be an array.** The identity gate iterates `claims.groups` as a list. If your IdP emits a single group as a scalar string rather than a one-element array, the gate fails closed (an authorized support user is denied, not wrongly allowed) — normalize the claim to an array at the gateway, or add a string-handling branch. - **Out of scope by design.** The Intercom web UI, REST API scripts, and Fin's own actions do not traverse the gateway and are unaffected by this policy. > **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: ingress apps: - intercom industries: [] bundles: - soc2 - hipaa - pci-dss - gdpr-ccpa experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```rego package intercom.ingress.fence_contact_reads # Deny-by-default: the structured-PII read surface is only reachable by # callers whose IdP groups authorize it. Everything else falls through the # `not is_pii_read_surface` allow rule. default allow := false # IdP groups permitted to read customer contact/company profiles. # PLACEHOLDER — replace with your organization's support/CRM group names at import time. allowed_groups := {"support", "crm"} # --- Identity gate (fail closed) --- # Missing subject/claims/groups yields an empty list, which matches no # allowed group, so a caller with no groups claim is not exempt. caller_groups := object.get(object.get(input.subject, "claims", {}), "groups", []) caller_authorized if { some g in caller_groups allowed_groups[g] } # --- The structured-PII read surface --- # Typed tools: get_contact / search_contacts / get_company / list_companies. # Matched by suffix so the gateway's server-name prefix does not matter. is_typed_pii_tool if { endswith(lower(input.resource.name), "get_contact") } is_typed_pii_tool if { endswith(lower(input.resource.name), "search_contacts") } is_typed_pii_tool if { endswith(lower(input.resource.name), "get_company") } is_typed_pii_tool if { endswith(lower(input.resource.name), "list_companies") } # Generic search aliasing search_contacts: `search` with object_type == "contacts". # `search_contacts` ends in "contacts" (not "search"), so it is not matched here. is_generic_search_contacts if { endswith(lower(input.resource.name), "search") # trim_space + startswith("contact") so whitespace padding ("contacts ") # and a singular alias ("contact") cannot slip past strict equality. # "conversations" does not start with "contact", so it is unaffected. startswith(trim_space(lower(object.get(input.payload.args, "object_type", ""))), "contact") } # Defensive (fail closed): a non-string object_type (array/number/object) # cannot be safely lower-cased — `lower` would error and the rule above would # be *undefined*, letting the call slip through the `not is_pii_read_surface` # allow fall-through (type-confusion bypass, e.g. object_type: ["contacts"]). # Treat any present-but-non-string object_type as the contacts surface and # fence it. Absent object_type yields the "" default (a string), so the # documented "search without object_type => pass-through" behaviour is intact. is_generic_search_contacts if { endswith(lower(input.resource.name), "search") not is_string(object.get(input.payload.args, "object_type", "")) } # Generic fetch aliasing get_contact / get_company: `fetch` of an ID (or # workspace URL) that embeds a contact_ / company_ token. `contains` (not # `startswith`) so a URL like `.../users/contact_123` is also caught; a # `conversation_` id/URL contains neither token and passes through. is_fetch_pii_id if { endswith(lower(input.resource.name), "fetch") contains(lower(object.get(input.payload.args, "id", "")), "contact_") } is_fetch_pii_id if { endswith(lower(input.resource.name), "fetch") contains(lower(object.get(input.payload.args, "id", "")), "company_") } # Defensive (fail closed): a non-string id (array/number/object) cannot be # safely inspected for a contact_/company_ token — `lower` would error and the # rules above would be *undefined*, letting the call slip through the allow # fall-through (type-confusion bypass, e.g. id: ["contact_123"]). Fence any # fetch whose id is present but not a string. Absent id yields the "" default # (a string), so a fetch with no id is not fenced (it is inert at the server). is_fetch_pii_id if { endswith(lower(input.resource.name), "fetch") not is_string(object.get(input.payload.args, "id", "")) } is_pii_read_surface if { is_typed_pii_tool } is_pii_read_surface if { is_generic_search_contacts } is_pii_read_surface if { is_fetch_pii_id } # --- Allow rules --- # Anything that is not the structured-PII read surface passes through # (conversations, articles, non-Intercom tools). allow if { not is_pii_read_surface } # The PII read surface is allowed only for authorized support/CRM callers. allow if { is_pii_read_surface caller_authorized } # --- Deny reason --- reasons contains "Intercom contact and company profiles are limited to support and CRM roles. Other roles keep conversation access. Ask your admin to add you to the support or CRM IdP group if you need customer-profile access, or contact your security team if this is a false positive." if { is_pii_read_surface not caller_authorized } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ```