Cap Intercom Contact Enumeration
caller is a CRM admin); clamp page size on everything else; allow the rest
- Direction
- ingress
- Rego package
intercom.ingress.cap_contact_enumeration- App
- intercom
- Bundles
- soc2hipaapci-dssgdpr-ccpa
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:016343b82b92baf587d0e6349c7bd3569ee9fdb63bda2b7e36bc2628d61b3011
intercomcap-bulk-exportcontact-enumerationdlpingresssoc2hipaapci-dssgdpr-ccpa
What this policy does
Direction: ingress (tool_pre_invoke)
Default: deny the bulk-enumeration query shape on contact search (unless the
caller is a CRM admin); clamp page size on everything else; allow the rest
Package: intercom.ingress.cap_contact_enumeration
What it does
Stops an agent from sweeping Intercom's entire customer base in one (or a few)
calls. It targets the two enumeration-capable surfaces the landscape note flags
as the highest-value bulk-PII exfiltration vector — search_contacts and the
generic search tool with object_type == "contacts" — and applies two levels
of control:
Deny the enumeration signature. On a contact search whose DSL filter either targets the email domain, or uses a broadening operator on
email,name, orphone—contains/~, starts-with (^), ends-with ($), a range (</>/<=/>=), or not-equals (!=/neq) — the call is denied. That shape is how you harvest the customer list (e.g. "every contact whose email is~ @acme.com", "every email that ends with@acme.com", "every contact whose email is!=one throwaway address", or "every name containinga") rather than look up one known person. Exact equality (=/eq) and set membership (in/nin) stay allowed — they name a known contact. A caller in a documented CRM-admin IdP group is exempt.Clamp page size on the rest. Contact searches that are not the enumeration shape (an exact
email = …lookup, an ID match, a free-textq) are allowed but have theirlimit/per_pageclamped to a bounded ceiling (default 50). The company and article listing tools are clamped to their documented maxima —*list_companiesper_page≤ 60,*list_articlesper_page≤ 150 — so a single unbounded request cannot page the whole workspace at once.
Denying the domain-sweep / broad-match shape is deliberately higher-value than a
pure limit clamp: search_contacts email-domain matching is the single most
efficient way to bulk-exfiltrate the customer base on this surface, so the
enumeration shape is blocked outright rather than merely rate-limited.
Every other tool call — conversation reads, single-record get_* / fetch,
article reads, non-Intercom tools — passes through untouched.
Compliance alignment
This policy instantiates family PF-08 (cap-bulk-export) for Intercom.
- SOC 2 CC6.7 — supports restricting the transmission, movement, and removal of confidential information by blocking the query shape that bulk-extracts the customer contact base and bounding page size on the remaining list/search paths.
- HIPAA §164.502(b) / §164.514(d) — supports the minimum-necessary standard: a support agent looks up the specific contact a case concerns, not the whole directory; enumeration is reserved for a documented CRM-admin role.
- PCI DSS 7.2.6 — supports restricting programmatic query access to repositories of stored account data by role, where Intercom contact custom attributes can carry plan/billing metadata: the enumeration deny blocks the bulk-query shape that would sweep that data, and the page-size clamp bounds the role-permitted queries so a single call cannot page the whole base; 7.2.1 — supports the least-privilege access model on the agent channel by reserving bulk contact access for a documented CRM-admin role.
- GDPR Art. 5(1)(c) — supports data minimisation by preventing the agent from pulling far more personal data than a support interaction requires; CCPA/CPRA 11 CCR §7002 — supports the proportionality principle (collection limited to what is reasonably necessary) on the agent channel.
Why ingress
Enumeration harm is fully determined by the request — the tool name, the DSL
filter shape, and the page size are all in input.payload.args. Blocking at
ingress means the sweep never reaches Intercom, so no bulk contact set is ever
returned to the agent (and nothing needs to be redacted on the way back). The
clamp likewise has to rewrite arguments before the call, so it is an ingress
transform.
Tool name matching
The DTwo gateway prefixes tool names with the configured MCP server name, so all
matching is on the lowercased suffix for portability. Both snake_case
(official / raoulbia) and kebab-case (community servers) separators are
tolerated:
- contact search:
*search_contacts/*search-contacts - generic search alias:
*search— gated whenobject_type == "contacts"or whenobject_typeis absent/empty (the landscape note describessearchas a universal search over conversations and contacts, so an omittedobject_typestill returns contacts and cannot be used to dodge the deny). An explicitobject_type:"conversations"is left out of the contacts path. - company listing:
*list_companies/*list-companies - article listing:
*list_articles/*list-articles
All 13 official Intercom tool names are verified against Intercom's developer
docs and the Speakeasy governance catalog (per the landscape note); the generic
search / object_type convention is verified there too. Verify the exact
prefixed names your gateway emits with the dump-input debug technique before
relying on this in production.
Argument shape
- The DSL filter is read defensively via
object.get(input.payload.args, "query", {}). The query is walked withwalk/2, so nestedAND/ORcompound clauses are inspected too. A leaf clause is an object carryingfieldandoperator. - Enumeration signature = any leaf clause where the
fieldreferences a domain (its lowercased name contains"domain", e.g.email_domain, any operator), or thefieldisemail/name/phonewith a broadening operator:contains/~, starts-with (^), ends-with ($), a range (</>/<=/>=), or not-equals (!=/neq). Exact equality (=/eq) and set membership (in/nin) are not enumeration operators — they identify a known contact, so they fall through to the clamp instead. The operator is lowercased before matching, so casing does not evade it. - A query that is missing, is a free-text
q(noqueryobject), or is reshaped (a string, a number, an empty object) yields no matching leaf clause, so it never trips the enumeration branch — it falls through to the clamp instead. - Page size is read from
limitandper_page; a numeric value above the ceiling is lowered to the ceiling, anything else is left as-is.
Identity
The CRM-admin exemption reads the caller's IdP groups via
object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])
and checks membership against crm_admin_groups (placeholder: {"crm-admins"}).
The gate fails closed: a caller with no groups claim (or no subject /
claims at all) has an empty group list, matches nothing, and is therefore
not exempt — the enumeration shape is denied for them. The exemption also
guards the claim shape with is_array / is_string, so a spoofed non-array
groups (e.g. a map {"role": "crm-admins"} whose value happens to equal a
gated group) cannot iterate its way into the exemption.
Examples
Denied — domain sweep on search_contacts
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "intercom-search_contacts", "type": "tool" },
"subject": { "claims": { "groups": ["support"] } },
"payload": {
"name": "intercom-search_contacts",
"args": { "query": { "field": "email", "operator": "~", "value": "@acme.com" } }
}
}
}
allow = false — a contains match on email is an enumeration signature.
Denied — enumeration via the generic search alias
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "intercom-search", "type": "tool" },
"subject": { "claims": { "groups": ["analytics"] } },
"payload": {
"name": "intercom-search",
"args": {
"object_type": "contacts",
"query": { "field": "name", "operator": "contains", "value": "a" }
}
}
}
}
allow = false — the search/object_type alias is covered, not just search_contacts.
Allowed — CRM admin is exempt
A caller whose groups include crm-admins running the same domain sweep is
allowed (page size is still clamped).
Allowed + clamped — exact lookup with an oversized page
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "intercom-search_contacts", "type": "tool" },
"payload": {
"name": "intercom-search_contacts",
"args": {
"query": { "field": "email", "operator": "=", "value": "bob@acme.com" },
"per_page": 500
}
}
}
}
allow = true; the transform rewrites per_page to 50.
Allowed — free-text search, no DSL filter
A search_contacts call with only a q string (no query object) is allowed;
it is not the enumeration shape.
Composition
Single-purpose. Useful companions on the same gateway:
apps/intercom/fence-contact-reads— role-gates the structured-PII read surface (get_contact,search_contacts,fetchofcontact_/company_IDs) so non-support callers get no contact profiles at all.apps/intercom/mask-pan-egress— masks card numbers in whatever contact/conversation data does come back.
This policy caps how many contacts one call can pull and blocks the sweep
shape; fence-contact-reads decides who may touch the surface at all.
Known limitations
- Bounded paging still works. This raises cost and creates an audit trail; it
does not make enumeration impossible. An attacker can still page through results
with repeated bounded calls (each clamped to the ceiling) — including an
unfiltered
search_contacts(empty or noquery), which lists the whole base a page at a time — or issue many exact lookups. Pair with rate limiting and the audit pipeline for detection. - Free-text
qis clamp-only, not denied. A contact search that filters via the free-textqargument (rather than a DSLquery) is treated as a lookup: it is bounded by the page-size clamp but never trips the enumeration deny, even when the keyword is a bare domain (q: "@acme.com"). This is deliberate —qis also how a legitimate agent finds one person by name, so it cannot be denied without breaking normal search. Each call still returns at most the ceiling; rely on the clamp plus rate limiting here. - Operator tokens are the note's plus inferred symbol forms. The broadening
operators are grounded in the landscape note's DSL list (
neq/gt/lt/contains); the symbol and affix forms (!=,<,>,^,$) are inferred from Intercom's search API. If your server names an equivalent operator differently, add it tobroad_match_operators. Verify with the dump-input debug technique. - Domain-field name is inferred. The email-domain signature matches any
DSL field whose name contains
"domain"(e.g.email_domain). Intercom's exact domain-filter field name is not pinned in the landscape note; if your workspace exposes domain matching purely asemail ~ @domainoremail $ @domain, that path is still caught by theemail+ broadening-operator branch. Review against your server's DSL vocabulary. - Only the identity fields are deny-gated. The enumeration deny fires on a
domain field or a broadening operator over
email/name/phone— the identity fields a support agent uses to find one person. A broadening filter on a custom attribute (plan ~ enterprise) or a timestamp range (created_at > …) also returns many contacts, but custom-attribute names are arbitrary and unknowable ahead of time, so those shapes are not denied — they fall through to the page-size clamp and rely on rate limiting plus the audit pipeline. Add the specific custom-attribute names your workspace treats as segmentation keys toenumeration_fieldsif you want them deny-gated too. - Non-numeric page sizes pass through. The clamp only lowers a numeric
limit/per_page; a string or object value is left untouched (Intercom would reject it upstream). The clamp is defence-in-depth — the enumeration deny is the primary control and is unaffected. fetchby ID is out of scope. Single-recordfetch/get_contactis a lookup, not enumeration; gate it withfence-contact-reads.- Group names are placeholders — replace
crm-adminsincrm_admin_groupswith your IdP's group name at import time. The exemption works only when the gateway has an IdP configured and the caller's JWT carries agroupsclaim. - Community-server coverage. No surveyed community Intercom server exposes a
search_contactstool today; the suffix patterns are written to tolerate kebab-case in case one appears.
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.ingress.cap_contact_enumeration
# Deny-by-default: the enumeration query shape on contact search is denied unless
# the caller is a CRM admin. Every other tool is permitted by the allow rule
# below; a separate transform clamps page size where relevant.
default allow := false
# Page-size ceiling for a (non-enumeration) contact search. A legitimate
# known-contact lookup returns very few rows, so this is intentionally well below
# Intercom's API maximum. Tune for your environment.
contacts_page_ceiling := 50
# Documented maxima for the listing tools (from the Intercom developer docs).
companies_page_ceiling := 60
articles_page_ceiling := 150
# IdP groups exempt from the enumeration deny. Placeholder — replace with your
# IdP's CRM-admin group name at import time.
crm_admin_groups := {"crm-admins"}
# --- Tool matching (suffix, tolerating snake_case and kebab_case) ---------------
is_search_contacts_tool if {
endswith(lower(input.resource.name), "search_contacts")
}
is_search_contacts_tool if {
endswith(lower(input.resource.name), "search-contacts")
}
# The generic search alias counts as a contact search when object_type says so.
is_generic_contacts_search if {
endswith(lower(input.resource.name), "search")
lower(object.get(input.payload.args, "object_type", "")) == "contacts"
}
# ...and also when object_type is absent/empty. The landscape note describes the
# generic `search` tool as a UNIVERSAL search over conversations AND contacts, so a
# call that omits object_type still returns contacts — closing the "just drop
# object_type" evasion. An explicit object_type:"conversations" (or any other
# non-empty value) is left out of the contacts path so conversation search is not
# over-blocked.
is_generic_contacts_search if {
endswith(lower(input.resource.name), "search")
lower(object.get(input.payload.args, "object_type", "")) == ""
}
is_contacts_search_tool if {
is_search_contacts_tool
}
is_contacts_search_tool if {
is_generic_contacts_search
}
is_list_companies_tool if {
endswith(lower(input.resource.name), "list_companies")
}
is_list_companies_tool if {
endswith(lower(input.resource.name), "list-companies")
}
is_list_articles_tool if {
endswith(lower(input.resource.name), "list_articles")
}
is_list_articles_tool if {
endswith(lower(input.resource.name), "list-articles")
}
# --- Enumeration signature detection --------------------------------------------
# A DSL query can be a single leaf clause or a nested AND/OR compound. walk/2
# visits every sub-value, so nested clauses are inspected too. A query that is
# missing, free-text, or reshaped yields no matching leaf clause.
enumeration_clause_present if {
query := object.get(input.payload.args, "query", {})
walk(query, [_, node])
is_object(node)
is_enumeration_clause(node)
}
# Signature (a): the filter targets an email domain field.
is_enumeration_clause(node) if {
field := lower(object.get(node, "field", ""))
contains(field, "domain")
}
# Signature (b): a broadening operator on email, name, or phone. Exact equality
# (= / eq) and set membership (in / nin) are lookups of already-known contacts and
# stay allowed; contains, starts-/ends-with, range, and not-equals all widen the
# filter into a sweep of the customer base.
is_enumeration_clause(node) if {
field := lower(object.get(node, "field", ""))
enumeration_fields[field]
broad_match_operators[lower(object.get(node, "operator", ""))]
}
enumeration_fields := {"email", "name", "phone"}
# Operators that turn an email/name filter into a base sweep rather than a
# single-record lookup. Grounded in the landscape note's DSL operators
# (neq | gt | lt | contains) plus the symbol / affix forms Intercom's search API
# uses for the same semantics. `$` (ends-with) is the operator form of a domain
# sweep — `email $ "@acme.com"` returns every contact on a domain, the exact
# vector this policy exists to stop — so it is an enumeration operator even
# though the landscape note describes domain matching only as a dedicated field.
# Exact equality (= / eq) and set membership (in / nin) are deliberately absent:
# they identify a known contact, not the whole base. Verify the exact operator
# tokens your server's DSL uses (see Known limitations).
broad_match_operators := {
"~", "contains",
"!=", "neq", "ne",
"<", "lt", "<=", "lte",
">", "gt", ">=", "gte",
"^", "starts_with", "startswith", "starts-with",
"$", "ends_with", "endswith", "ends-with",
}
# --- Identity -------------------------------------------------------------------
caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])
# Membership grant fails closed: `groups` must be an array of strings. A map- or
# scalar-shaped claim (spoofing surface) is not an array, so it grants nothing.
is_crm_admin if {
is_array(caller_groups)
some group in caller_groups
is_string(group)
crm_admin_groups[lower(group)]
}
# --- Decision -------------------------------------------------------------------
# The only thing this policy denies: an enumeration-shaped contact search by a
# non-CRM-admin caller.
deny_enumeration if {
is_contacts_search_tool
enumeration_clause_present
not is_crm_admin
}
# Allow everything that is not the enumeration deny.
allow if {
not deny_enumeration
}
reasons contains "This Intercom contact search uses a bulk-enumeration filter (an email-domain match, or a contains/~ operator on email or name) that can exfiltrate the customer base. Look up a specific contact by exact email, phone, or ID instead. If you genuinely need bulk contact access, ask your CRM administrator to run it or to add you to the CRM-admin group." if {
deny_enumeration
}
reason := joined if {
count(reasons) > 0
reason_list := sort([r | some r in reasons])
joined := concat("; ", reason_list)
}
# --- Page-size clamp (transform) ------------------------------------------------
# Contact searches that are allowed through get their page size bounded.
transform := {"transformed_payload": clamped} if {
is_contacts_search_tool
not deny_enumeration
clamped := clamp_args(input.payload.args, contacts_page_ceiling)
clamped != input.payload.args
}
# Company listing clamped to its documented maximum.
transform := {"transformed_payload": clamped} if {
is_list_companies_tool
clamped := clamp_key(input.payload.args, "per_page", companies_page_ceiling)
clamped != input.payload.args
}
# Article listing clamped to its documented maximum.
transform := {"transformed_payload": clamped} if {
is_list_articles_tool
clamped := clamp_key(input.payload.args, "per_page", articles_page_ceiling)
clamped != input.payload.args
}
# Clamp both page-size keys used by the search surface.
clamp_args(args, ceiling) := out if {
out := clamp_key(clamp_key(args, "limit", ceiling), "per_page", ceiling)
}
# Lower a numeric value above the ceiling; otherwise leave args unchanged.
clamp_key(args, key, ceiling) := object.union(args, {key: ceiling}) if {
clamp_needed(object.get(args, key, null), ceiling)
}
clamp_key(args, key, ceiling) := args if {
not clamp_needed(object.get(args, key, null), ceiling)
}
clamp_needed(value, ceiling) if {
is_number(value)
value > ceiling
} 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