dtwo Policy Store

Slack: Redact Profile PII from User Lookups

Redacts personally identifiable information — email addresses, phone numbers, and Slack custom profile fields (which commonly carry phone, title, and…

Direction
egress
Rego package
slack.egress.redact_profile_pii
App
slack
Bundles
slacksoc2gdpr-ccpa
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:bd0befc2d1cabab7a7758095cedce3349e10bdf79e7a59b069fc9c2a7877c3ce

slackpiiredactionprivacyegresssoc2gdpr-ccpa

What this policy does

Direction: egress (tool_post_invoke) Default: allow (transform-only — never denies) Package: slack.egress.redact_profile_pii

What it does

Redacts personally identifiable information — email addresses, phone numbers, and Slack custom profile fields (which commonly carry phone, title, and manager) — from the responses of Slack user-lookup tools before they reach the agent. Display name and user_id are left intact, so agent workflows that resolve mentions or look up who to notify keep working; the agent just no longer receives a PII directory it does not need.

Callers whose IdP groups claim contains people-ops receive unredacted profiles. Anyone else — including callers with missing, empty, or malformed identity claims — gets the redacted view (the exemption fails closed).

The policy addresses the PII-directory-harvesting surface: a single agent session can otherwise sweep slack_search_users / slack_read_user_profile across the workspace and assemble an email + phone directory of every employee.

Compliance alignment

  • SOC 2 CC6.7 — supports the restriction on transmission/movement of information by masking personal contact data on the agent read path; C1.1 — supports identifying and protecting confidential information (employee contact data in workspace profiles); P4.1 — supports limiting personal-information use to identified purposes: mention resolution keeps working, directory harvesting does not.
  • HIPAA §164.502(b) — supports the minimum-necessary standard: agents resolving users do not need workforce emails and phone numbers; §164.514(b) — supports de-identification by removing Safe-Harbor identifier classes (email addresses, telephone numbers) from responses.
  • GDPR Art. 5(1)(c) — supports data minimisation on the agent channel; CPRA §1798.121 — supports the consumer's right to limit use of sensitive personal information by keeping contact PII out of agent context unless the caller has a people-ops role.

Tool name matching

The policy targets the user-lookup tools of the three Slack MCP servers in real use (official, korotovsky community, archived reference), matched by suffix:

Suffix Server / tool
_read_user_profile official slack_read_user_profile
_get_user_profile archived reference slack_get_user_profile
_search_users official slack_search_users
_get_users archived reference slack_get_users
users_search korotovsky users_search

The DTwo gateway prefixes tool names with the configured MCP server name (e.g. slack-mcp-slack_read_user_profile), and that prefix is not standardized. Before matching, the policy lowercases the name and normalizes - to _ (and trims stray surrounding whitespace), then matches on the suffix — so it works whether your gateway joins with hyphens or underscores. Three name surfaces are checked: input.resource.name, input.tool_metadata.name, and the legacy input.payload.name alias. Egress is detected by input.mode == "output" with a fallback to the tool_post_invoke action/kind identifier, so a response is still redacted if a gateway leaves mode unset. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production.

Response shape

Each server returns its own JSON shape for profiles, and the official server documents tool names/shapes as runtime-discoverable rather than contractual. The policy therefore does not parse the response; it hands the gateway a redaction transform that works on any shape:

  • redact_fields: ["email", "phone", "fields"] — structured JSON keys, matched case-insensitively and recursively. fields is the container Slack uses for custom profile fields (commonly phone, title, manager).
  • redact_patterns — email and phone regexes applied to the serialized response, catching PII that appears under other keys or in plain text.

display_name, real_name, name, and id/user_id keys are not in the redaction list and survive intact (unless their values are email/phone shaped — see Known limitations).

Identity exemption

Callers with "people-ops" in input.subject.claims.groups (exact, case-sensitive match) bypass redaction. The check uses safe object.get chains plus an is_array guard: a missing subject, missing claims, missing groups, or a groups value that is not an array (a string, an object such as {"role":"people-ops"}, a number, or null) all fail closed to the redacted view.

Examples

Redacted (default)

{
  "input": {
    "action": "tool_post_invoke",
    "mode": "output",
    "resource": { "name": "slack-mcp-slack_read_user_profile", "type": "tool" },
    "subject": { "sub": "google-apps|dev@corp.example", "claims": { "groups": ["engineering"] } },
    "payload": {
      "name": "slack-mcp-slack_read_user_profile",
      "text": ["{\"user_id\":\"U024BE7LH\",\"display_name\":\"jane\",\"email\":\"jane@corp.example\",\"phone\":\"+1 555 123 4567\"}"]
    }
  }
}

allow = true, and the policy emits a transform. After the gateway applies it, email and phone values read [REDACTED]; user_id and display_name are untouched.

Unredacted (people-ops exemption)

{
  "input": {
    "action": "tool_post_invoke",
    "mode": "output",
    "resource": { "name": "slack-mcp-slack_read_user_profile", "type": "tool" },
    "subject": { "sub": "google-apps|hrbp@corp.example", "claims": { "groups": ["people-ops"] } },
    "payload": { "name": "slack-mcp-slack_read_user_profile", "text": ["..."] }
  }
}

allow = true, no transform — the caller sees the full profile.

Composition

Transform-only and default allow := true, so it composes cleanly with deny policies on the same egress pipeline. Useful companions:

  • guard-dm-privacy — ingress gate on DM/private-channel reach; this policy covers the profile-directory surface that gate does not.
  • redact-sensitive-info — ingress redaction on outbound messages; pairing both keeps PII masked in both directions.
  • block-secrets — ingress deny for credential-shaped message bodies.

Known limitations

  • Response shapes are observed, not contractual. Slack documents the official server's tool names/shapes as runtime-discoverable ("use tools/list as the source of truth; names can change"). The suffix list and field keys here match the mid-2026 landscape; re-verify after server updates. The korotovsky users_search name is verified from that project's README, but your gateway's full prefixed name should be confirmed with the dump-input technique. Tool-name drift fails open: matching is by a fixed suffix allowlist, so a renamed, versioned, or newly added profile-returning tool whose suffix is not in the list (e.g. slack_read_user_profile_v2, or the not-yet-verified emoji / channel-member-listing tools the landscape note leaves unnamed) passes through unredacted until you extend profile_tool_suffixes. Re-verify the suffix list against tools/list after every server upgrade.
  • redact_fields may not descend into serialized JSON. MCP tool output arrives as payload.text, an array of content-block strings. When a server returns the profile as a JSON string inside that array (the common shape), redact_fields — which matches structured object keys — may not reach keys that live inside the string; in that case only the redact_patterns email/phone regexes fire on the serialized bytes. Email and phone values are therefore still masked, but non-PII-shaped custom fields carried under fields (e.g. title, manager) can survive. Do not rely on this policy to strip title/manager unless you have confirmed your gateway applies redact_fields recursively into stringified JSON; pair with a purpose-built transform if you need that guarantee. (This is a downstream transform-engine behavior and is not exercised by the policy test runner, which asserts only that the transform is emitted.)
  • Pattern over-match. The phone regex matches bare 10-digit runs, so Unix timestamps in profile responses (e.g. updated, message ts values) may be redacted too — cosmetic, but visible. A display name whose value is email-shaped will be redacted despite the intent to keep display names intact.
  • Pattern under-match. Phone numbers written without +, country code, or separators in non-NANP local formats may survive redaction. PII in free-text profile fields that is not email/phone shaped (e.g. a street address in a status line) is out of scope.
  • fields is a generic key. Any key named fields in a matched tool's response is redacted, not only Slack's custom-field container. Scope is limited to the five user-lookup suffixes, so collateral impact is confined to profile responses.
  • Other surfaces can leak profile data. Message search/history tools (slack_search_public*, slack_read_channel, …) may return messages that quote someone's email or phone; those tools are outside this policy's scope — pair with a general PII-redaction egress policy if you need workspace-wide coverage.
  • Group names are placeholders — replace people-ops with your IdP's group name at import time. The match is exact and case-sensitive (People-Ops does not qualify), and the exemption requires groups to be an array of strings: every other shape (single string, object, number, null, or missing) fails closed to the redacted view. Confirm your IdP emits groups as a string array for your tenant before relying on the exemption.

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.egress.redact_profile_pii

# Transform-only policy — never denies, only redacts profile PII from Slack
# user-lookup responses. Every other tool and every caller in the people-ops
# group passes through untouched.
default allow := true

# -----------------------------------------------------------------------------
# Scope: Slack tools that return user-profile content, across the three MCP
# servers in real use (official, korotovsky community, archived reference).
# The gateway prefixes tool names with the configured MCP server name, so we
# match by suffix. Names are normalized first (lowercase, "-" -> "_") because
# gateways join the prefix with hyphens while Slack tool names use
# underscores. Verify exact names with the dump-input debug technique.
# -----------------------------------------------------------------------------

profile_tool_suffixes := {
    "_read_user_profile", # official Slack MCP server: slack_read_user_profile
    "_get_user_profile",  # archived reference server: slack_get_user_profile
    "_search_users",      # official Slack MCP server: slack_search_users
    "_get_users",         # archived reference server: slack_get_users
    "users_search",       # korotovsky/slack-mcp-server: users_search
}

# Normalize a raw tool name: trim surrounding whitespace (a stray newline or
# space around the name would otherwise defeat the suffix match), lowercase,
# and fold the gateway's "-" join char to "_".
normalize(raw) := replace(lower(trim_space(raw)), "-", "_")

# Tool name from the PARC resource surface, normalized.
candidate_names contains name if {
    name := normalize(object.get(object.get(input, "resource", {}), "name", ""))
    name != ""
}

# Egress hooks also expose the tool name under tool_metadata.name, and the
# legacy payload.name alias is populated on tool hooks too. Check all three so
# we match regardless of which surface the gateway populates.
candidate_names contains name if {
    name := normalize(object.get(object.get(input, "tool_metadata", {}), "name", ""))
    name != ""
}

candidate_names contains name if {
    name := normalize(object.get(object.get(input, "payload", {}), "name", ""))
    name != ""
}

is_profile_tool if {
    some name in candidate_names
    some suffix in profile_tool_suffixes
    endswith(name, suffix)
}

# Egress detection. The gateway sets mode=="output" on post-invoke hooks; we
# also accept the tool_post_invoke action/kind identifier so a profile response
# is still redacted if a gateway leaves mode unset (fail closed — redact rather
# than leak). Ingress pre-invoke hooks match none of these, so request
# arguments are never touched.
is_output if input.mode == "output"

is_output if object.get(input, "action", "") == "tool_post_invoke"

is_output if object.get(input, "kind", "") == "tool_post_invoke"

# -----------------------------------------------------------------------------
# Exemption: people-ops sees unredacted profiles. `people-ops` is a
# placeholder — replace it with your IdP's group name at import time. Safe
# object.get chains make missing subject/claims/groups fail closed (no group
# -> not exempt -> redacted). The is_array guard is load-bearing: without it a
# groups claim shaped as an object (e.g. {"role":"people-ops"}) would iterate
# its *values* and match, granting the exemption to a caller who never held the
# group in an array. Requiring an array keeps every non-array shape (string,
# object, number, null) fail-closed.
# -----------------------------------------------------------------------------

caller_is_exempt if {
    claims := object.get(object.get(input, "subject", {}), "claims", {})
    groups := object.get(claims, "groups", [])
    is_array(groups)
    some g in groups
    g == "people-ops"
}

# -----------------------------------------------------------------------------
# Redaction transform. Response JSON shapes differ per server and are
# documented as observed rather than contractual, so we do not parse the
# response: redact_fields handles the structured keys (case-insensitive,
# recursive) and redact_patterns catches email/phone values under any other
# key or in plain text. display_name / real_name / name / id / user_id are
# not listed, so mention-resolution workflows keep working.
# -----------------------------------------------------------------------------

transform := {
    "redact_patterns": [
        # Email addresses
        `[\w.-]+@[\w.-]+\.[\w.-]+`,
        # NANP (US/CA) phone numbers, with or without separators/country code
        `\+?1?[- .]?\(?\d{3}\)?[- .]?\d{3}[- .]?\d{4}`,
        # Bare international E.164 numbers (+ followed by 7-15 digits)
        `\+\d{7,15}`,
        # International numbers with separators (+CC, then grouped digits)
        `\+\d{1,3}[- .]\d{1,4}(?:[- .]\d{2,5}){1,4}`,
    ],
    # `fields` is the container Slack uses for custom profile fields, which
    # commonly carry phone, title, and manager.
    "redact_fields": ["email", "phone", "fields"],
    "replacement": "[REDACTED]",
} if {
    is_output
    is_profile_tool
    not caller_is_exempt
}

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