dtwo Policy Store

Intercom: Keep Agent Help Center Articles in Draft

Keeps agent-authored Intercom Help Center articles in draft so a human reviews them before they go live on the public Help Center.

Direction
ingress
Rego package
intercom.ingress.deny_article_publish
App
intercom
Bundle
soc2
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:44188b457f7d849e6aec7c84844617e9827e6764822199b8826edfbbcba00f27

intercomdeny-public-exposureingressarticleshelp-centerpublicationgovernancesoc2

What this policy does

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)

{
  "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": "<p>...</p>", "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)

{
  "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": "<p>rewritten</p>", "state": "published" }
    }
  }
}

allow = false, same reason.

Allowed (agent creates or edits an article as a draft)

{
  "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": "<p>...</p>", "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)

{
  "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": "<p>...</p>", "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.

Policy source (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 <object>` iterates the object's VALUES — an accidental
    # fail-open. A bare-string or object `groups` is now ignored (fails closed),
    # matching the documented "no group → not exempt" contract.
    is_array(groups)
    some g in groups
    lower(g) == content_admin_group
}

# Tool arguments, null-safe: missing payload or args yields {}.
args := object.get(object.get(input, "payload", {}), "args", {})

# The publish flag, read defensively. Intercom defaults an unset state to
# "draft", so a missing or empty value is treated as draft (allowed). Compared
# case-insensitively with surrounding whitespace stripped so a padded value like
# " Published " cannot slip past the check. The deny fires only on an explicit
# publish.
is_publish if {
    state := trim_space(lower(object.get(args, "state", "")))
    state == "published"
}

# -----------------------------------------------------------------------------
# ALLOW: everything that isn't an article write, plus article writes that are
# not an explicit publish, plus explicit publishes by a content-admin caller.
# -----------------------------------------------------------------------------
allow if {
    not is_article_write
}

allow if {
    is_article_write
    not is_publish
}

allow if {
    is_article_write
    is_publish
    caller_is_content_admin
}

# The only deny path: an explicit publish by a non-content-admin caller.
reasons contains "This Intercom write publishes an article to your public Help Center (state: \"published\"), putting agent-authored content live and externally visible immediately. Agent-initiated publishing is blocked. Create or update the article as a draft instead (omit state, or set state to \"draft\") and ask a member of the content-admin team to review and publish it. Contact your admin if you believe this is a false positive." if {
    is_article_write
    is_publish
    not caller_is_content_admin
}

reason := joined if {
    count(reasons) > 0
    reason_list := sort([r | some r in reasons])
    joined := concat("; ", reason_list)
}

Canonical source: policy.md on GitHub · raw · raw on this site (.md)