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.fieldsis 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/listas 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 korotovskyusers_searchname 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 extendprofile_tool_suffixes. Re-verify the suffix list againsttools/listafter every server upgrade. redact_fieldsmay not descend into serialized JSON. MCP tool output arrives aspayload.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 theredact_patternsemail/phone regexes fire on the serialized bytes. Email and phone values are therefore still masked, but non-PII-shaped custom fields carried underfields(e.g. title, manager) can survive. Do not rely on this policy to strip title/manager unless you have confirmed your gateway appliesredact_fieldsrecursively 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, messagetsvalues) 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. fieldsis a generic key. Any key namedfieldsin 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-opswith your IdP's group name at import time. The match is exact and case-sensitive (People-Opsdoes not qualify), and the exemption requiresgroupsto be an array of strings: every other shape (single string, object, number, null, or missing) fails closed to the redacted view. Confirm your IdP emitsgroupsas 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)
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