--- name: "Slack: Redact Profile PII from User Lookups" tags: - slack - pii - redaction - privacy - egress - soc2 - gdpr-ccpa publishedAt: 2026-07-12 description: | # slack / redact-profile-pii **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) ```jsonc { "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) ```jsonc { "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`](../guard-dm-privacy/policy.md) — ingress gate on DM/private-channel reach; this policy covers the profile-directory surface that gate does not. - [`redact-sensitive-info`](../redact-sensitive-info/policy.md) — ingress redaction on outbound messages; pairing both keeps PII masked in both directions. - [`block-secrets`](../block-secrets/policy.md) — 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. direction: egress apps: - slack industries: [] bundles: - slack - soc2 - gdpr-ccpa experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```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 } ```