--- name: "Intercom: Keep Agent Help Center Articles in Draft" tags: - intercom - deny-public-exposure - ingress - articles - help-center - publication - governance - soc2 publishedAt: 2026-07-12 description: | # intercom / deny-article-publish **Direction:** ingress (`tool_pre_invoke`) **Default:** deny an explicit publish, allow everything else **Package:** `intercom.ingress.deny_article_publish` ## What it does Keeps agent-authored Intercom Help Center articles in **draft** so a human reviews them before they go live on the public Help Center. On the two Intercom article-write tools — `create_article` and `update_article` — the policy **denies** the call when its arguments set `state == "published"`, unless the caller is a member of a documented content-admin IdP group. Every other article write passes through untouched: a write that omits `state` (Intercom defaults it to `draft`) or explicitly sets `state: "draft"` is allowed, and so is every non-article tool on the Intercom surface (search, fetch, conversation/contact reads, `list_articles`, `get_article`, and so on). This policy owns exactly one surface: the publish flag on article writes. `create_article` with `state: "published"` puts agent-authored HTML on the public Help Center immediately, and `update_article` can silently rewrite — and re-publish — a live public doc. These are the only externally visible, defacement-class actions on the Intercom MCP surface (there are no delete or conversation-send tools), so the publish step belongs behind human review. The check runs at ingress, before the call reaches the Intercom MCP server, so a blocked publish never touches the public Help Center. ## Compliance alignment This policy instantiates the public-exposure-deny family (PF-27, `deny-public-exposure`) on Intercom's article-publication surface: it forces agent-authored public content through a human-review gate rather than letting the agent broadcast it unilaterally. - **SOC 2 CC8.1** — supports change management by preserving the human authorization step for a change to public-facing content. Publishing (or re-publishing via `update_article`) a Help Center article is an agent-initiated change to live, externally visible data; this policy denies the publish transition so the change is authorized and approved by a content-admin before it is implemented, rather than being broadcast unilaterally by the agent. Beyond the SOC 2 bundle, PF-27 also maps to FINRA Rule 2210(b)(1) (principal pre-approval of retail communications) and EU AI Act Art. 50(4) (human-review marker for published AI-generated text) — both **Partial** — neither of which is one of the five framework bundles (`soc2`, `hipaa`, `pci-dss`, `gdpr-ccpa`, `sox`). This Intercom instance therefore ships with the `soc2` framework bundle (per the CC8.1 citation above) plus its thematic tags. ## Tool name matching The gateway prefixes tool names with the configured MCP server name (e.g. `intercom-create_article` or `mcp-intercom-create_article`), and that prefix is not standardized. The policy matches on the lowercased tool-name **suffix** so it stays portable across server-name conventions, and it tolerates both the snake_case names the official server uses and a kebab-case separator alias in case a community server renames them: - creates: `*create_article`, `*create-article` - updates: `*update_article`, `*update-article` The official Intercom snake_case names (`create_article` / `update_article`) are **verified** against the app landscape note (Intercom developer docs + Speakeasy governance catalog). The kebab-case aliases are a **defensive, unverified** variant — no surveyed Intercom server ships article writes in kebab-case today (only one community server uses kebab-case, and solely for a read tool), but the community naming space diverges, so both separators are matched. Confirm the exact name your gateway sends with the dump-input debug technique before relying on this in production. If a server exposes a differently-named article-publish tool, add its suffix to `article_write_suffixes` in `policy.md`. ## Argument shape Read via `object.get`, so a missing key never crashes the rule: - `state` (`create_article` / `update_article`) — string, `"draft"` or `"published"`. Read defensively with `object.get(input.payload.args, "state", "")` and compared case-insensitively with surrounding whitespace stripped. **A missing or empty `state` is treated as draft (allowed):** Intercom defaults an unset `state` to `draft`, so the deny branch fires **only** on an explicit `state == "published"`. Any value other than `published` (including `draft`) passes through. The policy does not inspect `title`, `body`, `author_id`, `parent_id`, or any other field — publication scope is its only concern. ## Identity / exemption The content-admin exemption reads the caller's IdP-issued `groups` claim via `object.get(object.get(input.subject, "claims", {}), "groups", [])`. It fails **closed**: a caller with no `subject`, no `claims`, or no `content-admins` group is **not** exempt, so an explicit publish is denied. The claim must be a JSON **array** of group strings — a `groups` value shaped as an object or a bare string is ignored (`is_array` guard), so a malformed claim cannot accidentally grant the exemption. Only a caller whose `groups` **array** contains the content-admin group may publish directly. ## Examples ### Denied (agent tries to publish an article to the public Help Center) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-create_article", "type": "tool" }, "subject": { "sub": "google-apps|agent@acme.com", "claims": { "groups": ["support"] } }, "payload": { "name": "intercom-create_article", "args": { "title": "Refund policy", "author_id": "123", "body": "
...
", "state": "published" } } } } ``` `allow = false`, `reason = "This Intercom write publishes an article to your public Help Center ..."`. ### Denied (agent republishes a live public doc via update) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-update_article", "type": "tool" }, "subject": { "sub": "google-apps|agent@acme.com", "claims": { "groups": ["support"] } }, "payload": { "name": "intercom-update_article", "args": { "id": "art_42", "body": "rewritten
", "state": "published" } } } } ``` `allow = false`, same reason. ### Allowed (agent creates or edits an article as a draft) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-create_article", "type": "tool" }, "subject": { "sub": "google-apps|agent@acme.com", "claims": { "groups": ["support"] } }, "payload": { "name": "intercom-create_article", "args": { "title": "Draft: onboarding", "author_id": "123", "body": "...
", "state": "draft" } } } } ``` `allow = true`, no reason. A write that omits `state` entirely is likewise allowed (Intercom defaults it to draft). ### Allowed (content-admin publishes directly) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-create_article", "type": "tool" }, "subject": { "sub": "google-apps|kb-lead@acme.com", "claims": { "groups": ["content-admins"] } }, "payload": { "name": "intercom-create_article", "args": { "title": "Launch announcement", "author_id": "9", "body": "...
", "state": "published" } } } } ``` `allow = true`, no reason (content-admins keep full control). ## Composition Single-purpose by design. Useful companions on the Intercom surface: - An **egress PII/PAN redaction** policy (PF-01 / PF-02) on `*get_conversation`, `*search`, `*fetch`, and `*search_conversations` responses — conversations are raw customer free-text and are the dominant Intercom egress risk. - An **ingress contact-enumeration deny** (PF-08 / PF-23) on `*search_contacts` and `*search` with `object_type: "contacts"` to stop email-domain sweeps of the customer base. ## Known limitations - **Out-of-MCP writes are neither blocked nor visible.** This policy governs only the MCP path. An article created, edited, or published through the Intercom inbox/web UI, the Intercom REST API, or Fin's own actions is outside the gateway's reach — such writes are **not** blocked by this policy and do **not** appear in the DTwo audit pipeline. Treat this as an agent-channel control, not a complete Help Center publication gate. - **Group names are placeholders — replace `content-admins` with your IdP's group name at import time.** The exemption is only as trustworthy as the `groups` claim your IdP issues; if callers can self-assert group membership, remap it to a claim your IdP controls. `is_admin`, `teams`, and the nested `user` claim are stripped before policies see them and must not be used here. Community Intercom servers authenticate with a workspace-level access token and assert no per-user identity, so `subject.claims`-based gating only works when the gateway sits in front of an IdP-authenticated path. - **Publish detection is `state`-only, exact key, string value.** The deny fires on the string `state == "published"` read from the exact, case-sensitive argument key `state`. Three inputs therefore read the empty default and **pass through (allowed)**: (a) the publish flag under a case-variant or renamed key (`State`, `STATE`, or a future boolean `published: true` / separate publish tool); (b) a non-string `state` value (e.g. `true` or `["published"]`), on which the case-fold errors out and the check treats the write as a draft; (c) a missing `state` (see next bullet). None of these is an exploitable publish bypass against the **verified** official Intercom server, whose `create_article`/`update_article` contract takes `state` as a case-sensitive string enum (`"draft"`/`"published"`) — a wrong-case key or non-string value is not a valid publish there either, so such a call lands as a draft on both the policy side and the server side. The residual risk is a **non-conforming connector** that is case-insensitive on argument keys or coerces non-string values to `"published"`; if you deploy one, add the alternate key/value handling to `is_publish`. Regression tests pin the current pass-through behavior for the wrong-case key and non-string value so the decision stays conscious. - **The draft default is an assumption, not verified in the landscape note.** The allow-missing-`state` branch is safe **only if** Intercom defaults an unset `state` to `draft`. That is Intercom's documented Articles-API behavior, but the app landscape note lists `state ("draft"|"published")` without stating the default, so treat this as an **unverified** load-bearing assumption: if the connector you front actually defaults an omitted `state` to `published`, the missing-`state` branch becomes a fail-open publish and you must change it to deny when `state` is absent. Confirm your connector's default with the dump-input debug technique before relying on the allow-missing-`state` behavior. - **Suffix match only, kebab alias unverified.** Only tool names ending in `create_article` / `update_article` (either separator) match. A future tool whose name ends differently (e.g. camelCase `createArticle`, or `publish_article`) is not covered — add its suffix. The kebab-case aliases (`create-article` / `update-article`) are matched defensively but are not verified against any shipping server. - **No content inspection.** This policy governs publication scope (draft vs published), not what the article body contains. Pair it with a body-content policy if agent-authored HTML must also be scanned before a content-admin publishes it. > **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 experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```rego package intercom.ingress.deny_article_publish # Deny-by-default: only the explicit allow rules below permit a request. Every # tool that is not an Intercom article write passes through; an article write is # denied only when it sets state:"published" and the caller is not in the # content-admin group. All other article writes (missing state, or state:"draft") # are allowed. default allow := false # ----------------------------------------------------------------------------- # TOOL MATCHING. The gateway prefixes tool names with the configured MCP server # name, which is not standardized, so we match on the lowercased suffix to stay # portable. Both write tools are covered, and each is matched in snake_case (the # verified official spelling) and kebab-case (a defensive, unverified separator # alias) so a community server that renames them is still caught. # ----------------------------------------------------------------------------- article_write_suffixes := { "create_article", "create-article", "update_article", "update-article", } tool_name := lower(input.resource.name) is_article_write if { some suffix in article_write_suffixes endswith(tool_name, suffix) } # ----------------------------------------------------------------------------- # CONTENT-ADMIN EXEMPTION. Members of this IdP group may publish articles # directly. Placeholder — remap `content-admins` to your IdP's group name at # import time. Fail closed: a missing subject / claims / groups yields no # exemption, so an explicit publish is denied. # ----------------------------------------------------------------------------- content_admin_group := "content-admins" caller_is_content_admin if { subject := object.get(input, "subject", {}) groups := object.get(object.get(subject, "claims", {}), "groups", []) # Only an array of group strings grants the exemption. Without this guard a # malformed object-shaped claim (e.g. {"x":"content-admins"}) would exempt, # because `some g in