dtwo Policy Store

Slack: Deny DM and Private-Conversation Reads and Search

Denies the agent read reach into Slack DMs and private conversations on the paths below — the workspace's highest concentration of PII/PHI (HR issues, health…

Direction
ingress
Rego package
slack.ingress.guard_dm_privacy
App
slack
Bundles
soc2gdpr-ccpa
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:0c759aa4beabbeace568b66ab311bf1e1d5b305ceb521e898868e53035d25397

slackprivacydmaccess-controlingresssoc2gdpr-ccpa

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny on match, allow otherwise Package: slack.ingress.guard_dm_privacy

What it does

Denies the agent read reach into Slack DMs and private conversations on the paths below — the workspace's highest concentration of PII/PHI (HR issues, health disclosures, credentials, M&A chatter). Three independent deny branches (see Known limitations for read surfaces outside these branches):

  1. Private-scope search tools — any tool whose (lowercased) name ends with _search_public_and_private. The official Slack MCP server splits private scope into this dedicated tool name, so the name alone is sufficient to detect the private reach.
  2. DM-filtered message search — the korotovsky community server's conversations_search_messages when its filter_in_im_or_mpim argument is set truthy (boolean true, the number 1, or the strings "true" / "1" / "yes").
  3. DM history reads — the history/read tool family (slack_read_channel, slack_read_thread, conversations_history, conversations_replies, slack_get_channel_history, slack_get_thread_replies) when the channel_id argument starts with D (a 1:1 DM channel ID) or @ (the korotovsky @username_dm alias).

Callers whose IdP groups claim contains slack-private-ok are exempt. Missing identity fails closed: no subject, no claims, or no matching group means no exemption.

Public-channel reads and search (slack_search_public, slack_search_channels, history reads on C-prefixed channel IDs) pass through untouched. This closes the read-side gap left by deny-direct-messages, which only blocks DM sends.

Compliance alignment

This policy fences the agent's read reach into DMs and private conversations — the workspace's highest concentration of personal and special-category data — behind an explicit, IdP-asserted group, supporting minimum-necessary and access-management controls on the MCP path:

  • SOC 2 CC6.3 — supports role-based least privilege: DM and private-conversation reads require the explicit slack-private-ok group, with a read-only-public default for everyone else; C1.1 — supports identifying and protecting confidential information held in private conversations.
  • HIPAA §164.502(b) / §164.514(d) — supports the minimum-necessary standard when DMs and private channels carry health-related disclosures; §164.308(a)(4) — supports information access management by restricting which conversations the agent may read.
  • GDPR Art. 9 / Art. 5(1)(c) — supports limiting access to special-category data (health and HR disclosures common in DMs) and data minimisation on the agent channel; CPRA §1798.121 — supports the consumer's right to limit use of sensitive personal information by keeping private-conversation content out of agent context absent an explicit role.

Why ingress

The private reach is fully visible in the request (tool name, filter argument, channel ID), so the call can be stopped before any DM content ever leaves Slack. Egress redaction would already have pulled the private content into the gateway; ingress denial means it is never fetched.

Tool name matching

All matching is case-insensitive and by suffix, because the DTwo gateway prefixes tool names with the configured MCP server name (e.g. slack-mcp-slack_read_channel) and that prefix is not standardized:

  • *_search_public_and_private — official server private-scope search.
  • *conversations_search_messages — korotovsky message search (denied only when the DM filter is set).
  • History suffixes: *slack_read_channel, *slack_read_thread (official); *conversations_history, *conversations_replies (korotovsky); *slack_get_channel_history, *slack_get_thread_replies (archived reference server).

Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production, and extend history_tool_suffixes if your Slack MCP server exposes additional history readers.

Argument shape

  • Branch 2 reads input.payload.args.filter_in_im_or_mpim (korotovsky).
  • Branch 3 reads input.payload.args.channel_id — the key used by all six history tools listed above. All argument access goes through object.get; a missing argument simply doesn't match (see Known limitations for the fail-open consequence).
  • The exemption reads input.subject.claims.groups via object.get chains, so missing claims deterministically deny.

Examples

Denied (official private-scope search)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "slack-mcp-slack_search_public_and_private", "type": "tool" },
    "payload": {
      "name": "slack-mcp-slack_search_public_and_private",
      "args": { "query": "salary review" }
    }
  }
}

allow = false, reason = "Searching Slack DMs and private conversations is not permitted through this gateway. ...".

Denied (DM history read)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "slack-mcp-slack_read_channel", "type": "tool" },
    "payload": {
      "name": "slack-mcp-slack_read_channel",
      "args": { "channel_id": "D0123456789", "limit": 50 }
    }
  }
}

allow = false, reason = "Reading Slack DM and private-conversation history is not permitted through this gateway. ...".

Allowed (public search; public-channel history)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "slack-mcp-slack_search_public", "type": "tool" },
    "payload": {
      "name": "slack-mcp-slack_search_public",
      "args": { "query": "deploy schedule" }
    }
  }
}

allow = true, no reason. Same for slack_read_channel with "channel_id": "C0123456789".

Allowed (exempt caller)

A caller whose input.subject.claims.groups contains slack-private-ok may run any of the calls above.

Composition

Single-purpose; composes with the other Slack ingress policies:

Known limitations

  • Group-DM and private-channel history reads are not caught by branch 3's ID-shape check. Branch 3 denies history reads only on D (1:1 DM) and the korotovsky @username_dm alias, per this policy's spec. Group DMs / legacy private channels carry a G prefix, and Slack now assigns newly created private channels the same C prefix as public channels — neither is distinguishable from a public read by ID shape here, so a direct history read on a G- or C-prefixed private conversation passes through. Use the deny-read-search-summarize-sensitive-channels companion policy to pin specific private/group channel IDs. korotovsky also accepts a #channel-name string alias as channel_id; a private channel referenced by #name is likewise not caught by the D/@ shape check and falls under the same companion-policy pinning. Branches 1 and 2 still cover private channels and group DMs for search, because those surfaces declare their scope (dedicated tool name / filter_in_im_or_mpim).
  • Read surfaces beyond the six history tools are not covered. Branch 3 matches only the six enumerated history/thread readers on a DM-shaped channel_id. Other read tools that can surface DM/private content are out of scope by design: the korotovsky conversations_unreads (unread messages across all conversations, DMs included under a browser-token deployment) and saved_list (saved messages, which may include saved DM messages), and the official slack_read_canvas (a canvas that may live in a private channel or DM). None of these takes a DM-shaped channel_id this policy can key on, so each passes through. If these surfaces are in scope for your deployment, add the tool to a companion deny policy or pair with a group-scoped egress redaction policy on their responses.
  • Unfiltered korotovsky search may still surface DM content. Branch 2 denies conversations_search_messages only when filter_in_im_or_mpim is set. Under a browser-token deployment the community server inherits the human user's full visibility, so a search without the filter can still return DM/mpim matches server-side. This policy trusts the filter as the DM-scope signal (per the landscape research); if your deployment returns private matches on unfiltered search, pair this with an egress redaction policy on search responses.
  • Missing channel_id fails open on branch 3. A history tool called with no channel_id (or with the target under a different key) is not matched. The six covered tools all take channel_id per the mid-2026 landscape research; re-verify if your server differs.
  • Official tool names are observed, not contractual. Slack publishes exact names only at runtime (tools/list is the source of truth); the names here are corroborated from mid-2026 research but may change.
  • ID matching is exact-case. Slack channel IDs are uppercase; a lowercase d… value is not a valid Slack ID and is not matched.
  • Group name is a placeholder — replace slack-private-ok with your IdP's group name at import time. The exemption fails closed when the caller has no groups claim.

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 slack.ingress.guard_dm_privacy

# Deny-by-default: only the explicit allow rules below permit the request.
default allow := false

# Placeholder IdP group whose members may reach DMs and private conversations.
# Replace "slack-private-ok" with your IdP's group name at import time.
private_ok_group := "slack-private-ok"

# Tool arguments, safe against a missing payload/args.
args := object.get(object.get(input, "payload", {}), "args", {})

# -----------------------------------------------------------------------------
# Exemption — fails closed: no subject, no claims, or no groups → not exempt.
# -----------------------------------------------------------------------------

caller_exempt if {
    claims := object.get(object.get(input, "subject", {}), "claims", {})
    groups := object.get(claims, "groups", [])
    some g in groups
    g == private_ok_group
}

# -----------------------------------------------------------------------------
# Branch 1 — official Slack MCP server: the dedicated private-scope search
# tool. The server splits DM/private reach into its own tool name, so the
# name alone identifies the private scope.
# -----------------------------------------------------------------------------

private_scope_search if {
    endswith(lower(input.resource.name), "_search_public_and_private")
}

# -----------------------------------------------------------------------------
# Branch 2 — korotovsky/slack-mcp-server: conversations_search_messages
# scopes the search into DMs/group DMs via the filter_in_im_or_mpim argument.
# -----------------------------------------------------------------------------

dm_filtered_search if {
    endswith(lower(input.resource.name), "conversations_search_messages")
    dm_filter_set
}

# Boolean form of the filter.
dm_filter_set if {
    object.get(args, "filter_in_im_or_mpim", false) == true
}

# String forms of the filter ("true", "1", "yes") — defensive against clients
# that serialize booleans as strings.
dm_filter_set if {
    v := object.get(args, "filter_in_im_or_mpim", "")
    is_string(v)
    lower(v) in {"true", "1", "yes"}
}

# Numeric form of the filter (1) — defensive against clients that serialize the
# flag as a JSON number rather than a boolean or string.
dm_filter_set if {
    object.get(args, "filter_in_im_or_mpim", false) == 1
}

# -----------------------------------------------------------------------------
# Branch 3 — history/read tools targeting a direct conversation. Covers the
# official server, korotovsky, and the archived reference server. Matched by
# suffix because the gateway prefixes tool names with the MCP server name.
# -----------------------------------------------------------------------------

history_tool_suffixes := {
    "slack_read_channel",        # official
    "slack_read_thread",         # official
    "conversations_history",     # korotovsky
    "conversations_replies",     # korotovsky
    "slack_get_channel_history", # archived reference server
    "slack_get_thread_replies",  # archived reference server
}

is_history_tool if {
    name := lower(input.resource.name)
    some suffix in history_tool_suffixes
    endswith(name, suffix)
}

# D-prefixed value → 1:1 DM channel ID (Slack IDs are uppercase).
private_history_read if {
    is_history_tool
    startswith(object.get(args, "channel_id", ""), "D")
}

# @-prefixed value → korotovsky's @username_dm alias for a DM.
private_history_read if {
    is_history_tool
    startswith(object.get(args, "channel_id", ""), "@")
}

# -----------------------------------------------------------------------------
# Decision
# -----------------------------------------------------------------------------

denied if private_scope_search

denied if dm_filtered_search

denied if private_history_read

# Anything that doesn't reach into DMs/private conversations passes through.
allow if {
    not denied
}

# Members of the exemption group may reach private conversations.
allow if {
    caller_exempt
}

reasons contains "Searching Slack DMs and private conversations is not permitted through this gateway. Use the public-channel search tool instead, or ask your InfoSec team for the slack-private-ok group if your role requires private-scope access." if {
    private_scope_search
    not caller_exempt
}

reasons contains "Slack message search scoped to DMs and group DMs (filter_in_im_or_mpim) is not permitted through this gateway. Re-run the search without the DM filter, or ask your InfoSec team for the slack-private-ok group if your role requires it." if {
    dm_filtered_search
    not caller_exempt
}

reasons contains "Reading Slack DM and private-conversation history is not permitted through this gateway. Read public channels instead, or ask your InfoSec team for the slack-private-ok group if your role requires DM access. Contact your InfoSec team if this block is a false positive." if {
    private_history_read
    not caller_exempt
}

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)

Used in these guides