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 withobject.get(input.payload.args, "state", "")and compared case-insensitively with surrounding whitespace stripped. A missing or emptystateis treated as draft (allowed): Intercom defaults an unsetstatetodraft, so the deny branch fires only on an explicitstate == "published". Any value other thanpublished(includingdraft) 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_conversationsresponses — conversations are raw customer free-text and are the dominant Intercom egress risk. - An ingress contact-enumeration deny (PF-08 / PF-23) on
*search_contactsand*searchwithobject_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-adminswith your IdP's group name at import time. The exemption is only as trustworthy as thegroupsclaim your IdP issues; if callers can self-assert group membership, remap it to a claim your IdP controls.is_admin,teams, and the nesteduserclaim 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, sosubject.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 stringstate == "published"read from the exact, case-sensitive argument keystate. 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 booleanpublished: true/ separate publish tool); (b) a non-stringstatevalue (e.g.trueor["published"]), on which the case-fold errors out and the check treats the write as a draft; (c) a missingstate(see next bullet). None of these is an exploitable publish bypass against the verified official Intercom server, whosecreate_article/update_articlecontract takesstateas 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 tois_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-
statebranch is safe only if Intercom defaults an unsetstatetodraft. That is Intercom's documented Articles-API behavior, but the app landscape note listsstate ("draft"|"published")without stating the default, so treat this as an unverified load-bearing assumption: if the connector you front actually defaults an omittedstatetopublished, the missing-statebranch becomes a fail-open publish and you must change it to deny whenstateis absent. Confirm your connector's default with the dump-input debug technique before relying on the allow-missing-statebehavior. - 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. camelCasecreateArticle, orpublish_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)
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