Glean: Redact PII from Read-Tool Responses
Scans the responses of Glean's content-returning read tools and rewrites high-confidence PII to fixed redaction tokens before the response reaches the…
- Direction
- egress
- Rego package
glean.egress.redact_pii- App
- glean
- Bundles
- soc2hipaagdpr-ccpa
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:e4da085ccb65113ff3cda00d4de010f996117a6e89efb8f77c143991554bf915
gleanredact-piipiidlpredactionegresssoc2hipaagdpr-ccpa
What this policy does
Direction: egress (tool_post_invoke)
Default: allow (transform-only — never denies)
Package: glean.egress.redact_pii
What it does
Scans the responses of Glean's content-returning read tools and rewrites high-confidence PII to fixed redaction tokens before the response reaches the agent's context:
| Class | Detection | Token |
|---|---|---|
| US SSN | hyphenated XXX-XX-XXXX form |
[REDACTED-SSN] |
| PAN (payment card) | Luhn-shaped card numbers — 4-4-4-4 grouped, 15-digit Amex 4-6-5, or an unseparated 13–19-digit run | [REDACTED-PAN] |
| Bank account / IBAN | IBAN-shaped strings (2-letter country + 2 check digits + 11–30 alphanumerics) | [REDACTED-BANK] |
Each class is matched independently — a lone SSN, a lone card number, or a
lone IBAN is redacted on its own. Matches are replaced in place, so the
surrounding text (search snippets, chat synthesis, document body, mail bodies,
transcript lines) stays usable and the agent keeps working context over the
non-sensitive parts. The policy is transform-only: it never denies a call,
and responses with no matches — and all out-of-scope tools — pass through
byte-identical. Every response field is read via object.get, so missing or
oddly-shaped payloads are never an error; they simply pass through.
Why egress, and why the response is the choke point
Glean is an aggregation layer: a single Glean call fans out across Drive,
Confluence, Slack, mail, Gong, HR systems, and everything else the tenant has
indexed. The response is the one place where every source's content
converges, which makes egress the correct choke point for what the agent can
actually exfiltrate — distinct from what the user is permitted to see.
Glean enforces source-system permissions on the read, but "what the user may
see" ≫ "what the agent should stream into model context." The PII already
lives in the indexed systems, so there is nothing to block at ingress, and
denying search/chat/read_document outright would make the agent useless
for everyday work. Catching identifiers on the response path keeps the content
useful while stripping the direct identifiers out of it.
This policy is defense-in-depth alongside — not a replacement for — ingress fences on Glean (datasource fencing, bulk-export caps, transcript gating): those decide which sources and how much a caller may read; this one strips direct identifiers out of whatever content they are allowed to read.
Compliance alignment
Instantiates egress PII redaction (family PF-02) for Glean and supports alignment with:
- SOC 2 CC6.7 — supports restricting the transmission/movement of confidential information by masking direct identifiers in Glean content as it leaves the gateway toward the agent; C1.1 — supports identification and protection of confidential information on the read path; P4.1 — supports limiting personal-information use to identified purposes; P6.1 — supports controls over personal-information disclosure by keeping raw identifiers out of agent context that does not need them.
- HIPAA §164.502(b) / §164.514(d) — supports minimum-necessary, role-based limits by masking direct identifiers that co-occur with clinical or benefits content surfaced through Glean's cross-source search; §164.514(a)–(b) — supports de-identification practice by stripping Safe-Harbor identifier classes (SSN, account numbers) from responses; §164.530(c) — supports privacy safeguards on the agent channel.
- PCI DSS 3.4.1 — supports masking PAN on display: Luhn-shaped payment-card
numbers surfaced in Glean read-tool responses are rewritten to
[REDACTED-PAN]before they reach the agent context, so a full PAN is not streamed into the model; 3.3.1 — supports keeping sensitive account data out of what the agent can move off the card-data path (best-effort, shape-matched — see Known limitations). - GDPR Art. 5(1)(c) — data minimisation on agent reads of personal data; Art. 9 — reduces special-category exposure on the MCP path where identifiers co-occur with health/HR content in indexed sources; Art. 5(1)(f) / Art. 32 — supports security of processing.
- CCPA/CPRA §1798.121 — supports limiting the use and disclosure of sensitive personal information (SSN, financial-account numbers) on the agent channel; §1798.150 — reduces nonredacted-PI breach exposure.
Tool name matching
Applies on the output path — scoped when either input.mode == "output" or
input.action == "tool_post_invoke" holds, so redaction still fires on a
gateway build that populates only one of the two (keying on mode alone would
fail open if it were unset). The tool name is read from all three egress
surfaces — input.resource.name, input.tool_metadata.name, and
input.payload.name — and a suffix hit on any of them puts the call in
scope, so a gateway that populates a different surface can't slip content past
the scanner. Matching is case-insensitive.
The remote managed Glean server exposes bare, generic tool names
(search, chat, read_document, gmail_search, outlook_search,
meeting_lookup); the gateway prefixes each with the configured MCP server
name (observed as glean-…). Because search and chat are too generic to
match blindly, the two name classes are handled differently:
- Distinctive names —
read_document,gmail_search,outlook_search,meeting_lookup— match bare, or after any server-prefix separator (-,_,.,:,/). None is a suffix of another Glean tool, so this is safe. - Generic names —
search,chat— match only bare or after a hyphen-class separator (-,.,:,/), deliberately excluding underscore. This is what keeps the baresearchentry from swallowing the underscore-joined compound tools Glean also ships but that are out of scope here —employee_searchandcode_search— and from double-firing ongmail_search/outlook_search(which have their own entries). Soglean-searchandglean.chatmatch;glean-employee_search,glean-code_search, andglean-gmail_searchdo not match the genericsearchentry (the last is matched by its distinctive entry instead).
Verify the exact names your gateway emits with the dump-input debug technique
before relying on this in production. These names are verified for the
remote managed server (Glean landscape note, tool inventory). The deprecated
local server used different names (company_search, people_profile_search);
add those suffixes only if a tenant still runs the archived package.
Response shape
The policy reads input.payload.text — the MCP content-block array the
gateway populates on tool_post_invoke — and rewrites each block. It handles
the two content-block shapes a gateway realistically emits:
- Plain-string blocks (
"text": ["…result…"]) are redacted directly, including string blocks that carry serialized JSON, since the regexes run over the serialized text. - MCP-standard structured text blocks (
{"type":"text","text":"…"}) have their innertextstring redacted while every other key (type,annotations, …) is preserved. Without this branch, body delivered as content-block objects — the canonical MCP wire shape — would slip past a string-only redactor untouched.
Any other block (an object with no string text field, or a non-string /
non-object value such as a nested array) passes through unmodified — the
policy makes no claim over arbitrary structured data whose PII sits under
other keys. When at least one block changes, the policy emits
transform.transformed_payload containing the original payload with the
rewritten text array (all other payload keys, including name, preserved).
When nothing changes, no transform is emitted and the response passes through
byte-identical. The text field must be an array: a gateway that returns
a bare scalar string under payload.text (off the documented shape) is not
rewritten — see Known limitations.
Examples
Redacted (in-scope search response)
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "glean-search", "type": "tool" },
"payload": {
"name": "glean-search",
"text": ["Vendor record: SSN 123-45-6789, card 4111 1111 1111 1111, IBAN GB82WEST12345698765432"]
}
}
}
allow = true, with transform.transformed_payload.text =
["Vendor record: SSN [REDACTED-SSN], card [REDACTED-PAN], IBAN [REDACTED-BANK]"].
Passed through (out-of-scope tool)
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "glean-employee_search", "type": "tool" },
"payload": {
"name": "glean-employee_search",
"text": ["SSN 123-45-6789"]
}
}
}
allow = true, no transform — employee_search is not in the matched set
(see Tool name matching), so nothing is rewritten.
Composition
Single-purpose transform policy (default allow := true); it composes cleanly
with deny/transform policies on the same egress pipeline. Recommended
companions in apps/glean:
- An ingress datasource fence / bulk-export cap on
*searchso the agent only reaches sources it is entitled to and cannot bulk-export. This egress redactor is defense-in-depth behind that fence, not a substitute for it. - Default-deny-unknown-tools (PF-28) on the Glean server — Glean's tool inventory is admin-mutable (agents-as-tools, gateway-proxied writes), so an allowlist keeps unreviewed tools from appearing.
- A transcript-gating ingress policy on
*meeting_lookup(extract_transcript/peer).
Known limitations
- Pattern-based detection is best-effort — and this is regex over returned
text. Obfuscated, spelled-out, split-across-blocks, base64-encoded, or
image-embedded identifiers are not caught. Non-ASCII digit forms also
escape — RE2's
\dmatches ASCII0–9only, so a full-width rendering of an SSN/PAN (e.g.123-45-6789) is not redacted even though a model reads it as digits. Word-adjacent identifiers escape too: the SSN and PAN-run patterns are\b-anchored (deliberately, so they never fire inside longer alphanumeric IDs), so an identifier abutting a word character — a letter, digit, or underscore — on either side is not matched. An SSN wrapped in Markdown italics underscores (_123-45-6789_) or a run-on likeid123-45-6789streams through unredacted. Tune the pattern set per tenant. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - PAN is shape-matched, not Luhn-validated. The card-number patterns match
the digit lengths and groupings a Luhn-valid PAN uses (13–19-digit ISO/IEC
7812 range, 4-4-4-4 grouping, 15-digit Amex 4-6-5), but pure regex cannot
compute the Luhn checksum — matches are card-number shapes, not verified
PANs. A conforming-shape non-card number (e.g. a 16-digit order ID or a
13–19-digit bank account number) is redacted as
[REDACTED-PAN], and a card number typed in an unusual grouping may be missed. This favors over-redaction (safe) over disclosure. - Bank/IBAN detection is IBAN-shaped and contiguous. It matches the
canonical compact IBAN shape (country letters + 2 check digits + 11–30
alphanumerics, all contiguous). IBANs printed with spaces every four
characters (
GB82 WEST 1234 5698 7654 32) are not matched (the groups break contiguity), and lowercase IBANs are not matched (country codes are conventionally uppercase). Bare domestic account/routing numbers with no IBAN structure are only caught if they happen to fall in the 13–19-digit PAN run (then tagged[REDACTED-PAN]); shorter US routing/account numbers are not matched, and a contiguous run of 20 or more digits is likewise outside the 13–19-digit PAN window (its only word boundaries are the two ends, and 20+ exceeds the ceiling) so it passes through unredacted too. Add tenant-specific account-number shapes if your result sets carry them. - Block coverage and the
text-array assumption. Redaction applies to plain-string entries ofinput.payload.text(including serialized-JSON strings) and to MCP-standard structured text blocks ({"type":"text","text":"…"}; innertextredacted, other keys preserved). Blocks that are objects with no stringtextfield (a custom{"field":"ssn","value":"…"}shape, a standard embedded-resource block carrying its body under nestedresource.text, or an off-spec block whosetextvalue is itself a non-string — e.g.{"type":"text","text":["…"]}with an array-valuedtext— whichblock_textrejects via itsis_stringguard), and blocks that are themselves nested arrays of sub-blocks, pass through unmodified and stream any embedded identifiers verbatim. If your gateway build emits bodies as embedded-resource or nested-array blocks underpayload.text(the documented contract is a flat array of strings — confirm yours with the dump-input technique), extendblock_text/redact_blockto descend intoresource.textand nested arrays, or fence those tools at ingress. Separately, thetextfield is assumed to be an array: a gateway that returns a bare scalar string underpayload.textfails theis_arraytransform guard and the response is not rewritten (a fail-open residual on an off-spec shape). - Tool-name coverage is the verified remote-server surface only. Only the
six read tools named above are in scope. Glean's other content-returning
read tools that this policy deliberately does not match —
employee_search,code_search,user_activity,knowledge_graph_query, and the memory read tool (memory/read_memorywithaction:"read", whoseExplicitMemoriescategory can hold user-entered identifiers) — stream their content unredacted; add their suffixes if your deployment treats them as PII-bearing. (Memory writes are governed separately by the deny-memory-writes ingress companion, but that policy does not redact what a memory read returns — this redactor does not cover it either.) The schema/graph introspection tools (memory_schema,knowledge_graph_schema) return structure, not record content, and are intentionally excluded. Gateway-proxied third-party tools and agents-as-tools have arbitrary org-specific names (naming pattern undocumented per the Glean landscape note) and are not matched here — govern them with the default-deny-unknown-tools companion. Genericsearch/chatmatching anchors on a hyphen-class server prefix; a deployment that joins the server name with an underscore (glean_search) would not match the generic entries and would fail open — pin the exact prefix per tenant after confirming it with the dump-input technique. - No identity-based exemptions. Every caller's Glean responses are
redacted uniformly; this policy has no full-PII group carve-out. If you need
one (e.g. a fraud-investigations group that must see raw PANs), add a
separate group-gated
allow/exemption branch keyed oninput.subject.claims— do not rely on the stripped ContextForge-internalis_admin/teams/userclaims for it. - Egress
transformed_payloadreplaces the response payload wholesale. Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline.
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 glean.egress.redact_pii
# Transform-only egress policy: rewrites high-confidence PII (US SSN,
# Luhn-shaped PAN, IBAN/bank-account-shaped strings) in Glean read-tool
# responses to fixed redaction tokens before the response reaches the agent.
# Never denies. Glean is an aggregation layer, so the response is the single
# point where every indexed source's content converges — the correct egress
# choke point for what the agent can exfiltrate.
default allow := true
# -----------------------------------------------------------------------------
# Egress scope: match the post-invoke/output path on either mode or action. If
# we keyed on input.mode alone and a gateway build left it unset, is_egress
# would fail and redaction would no-op (fail open, leaking content). Ingress
# (tool_pre_invoke / mode "input") satisfies neither branch, so it stays out of
# scope.
# -----------------------------------------------------------------------------
is_egress if { input.mode == "output" }
is_egress if { input.action == "tool_post_invoke" }
# The tool name is exposed on egress under resource.name (PARC), tool_metadata.name
# (legacy), and payload.name (tool-hook canonical). Collect all three and match if
# ANY carries a read-tool suffix — matching only a subset would let a gateway that
# populates a different surface slip content past the scanner.
candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", ""))
candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", ""))
candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", ""))
# -----------------------------------------------------------------------------
# Scope: the verified remote-managed Glean read tools whose responses carry
# cross-source body / snippet / synthesis content. The gateway prefixes bare
# tool names with the configured server name (observed as `glean-`), so we match
# by suffix. Two classes, matched differently:
#
# * Distinctive names — none is a suffix of another Glean tool, so they match
# after ANY separator (incl. underscore) or bare.
# * Generic names (`search`, `chat`) — matched only bare or after a
# hyphen-class separator (NOT underscore), so the bare `search` entry never
# swallows the underscore-joined compound tools that are out of scope here
# (`employee_search`, `code_search`) or double-fires on `gmail_search` /
# `outlook_search` (matched by their own distinctive entries).
# -----------------------------------------------------------------------------
distinctive_suffixes := {"read_document", "gmail_search", "outlook_search", "meeting_lookup"}
generic_names := {"search", "chat"}
# Separators a gateway may insert between the server prefix and the tool name.
word_seps := {"-", "_", ".", ":", "/"}
# Hyphen-class separators only — underscore excluded (see comment above).
hyphen_seps := {"-", ".", ":", "/"}
# name equals the bare tool name, or ends with <sep><name> for some sep in seps.
matches_suffix(n, suf, _) if { n == suf }
matches_suffix(n, suf, seps) if {
some s in seps
endswith(n, concat("", [s, suf]))
}
is_glean_read_tool if {
is_egress
some n in candidate_names
some suf in distinctive_suffixes
matches_suffix(n, suf, word_seps)
}
is_glean_read_tool if {
is_egress
some n in candidate_names
some g in generic_names
matches_suffix(n, g, hyphen_seps)
}
# -----------------------------------------------------------------------------
# Detection patterns — anchored and conservative to limit false positives.
# -----------------------------------------------------------------------------
# US SSN in the canonical hyphenated form only. Bare 9-digit runs collide with
# ordinary identifiers, so they are deliberately not matched.
ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b`
# PAN (payment-card) shapes. Pure regex cannot Luhn-validate; these match the
# lengths/groupings a Luhn-valid card uses (see Known limitations).
# 16-digit PANs grouped 4-4-4-4 with space or dash separators.
pan_grouped_pattern := `\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{4}\b`
# 15-digit American Express PANs grouped 4-6-5, constrained to the 34/37 IIN.
pan_amex_pattern := `\b3[47]\d{2}[ -]\d{6}[ -]\d{5}\b`
# Unseparated 13–19 digit runs — the ISO/IEC 7812 PAN length range. There is no
# word boundary inside a longer digit run, so this cannot partially mask a
# longer identifier, and (no leading \b before a letter) it never fires inside
# an IBAN's trailing digits.
pan_run_pattern := `\b\d{13,19}\b`
# IBAN-shaped strings: 2-letter country code + 2 check digits + 11–30 further
# alphanumerics, contiguous (no spaces). Country codes are uppercase.
iban_pattern := `\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b`
# -----------------------------------------------------------------------------
# Redaction steps — each is total over strings: it returns the input unchanged
# when its class doesn't apply, so the steps chain safely. Order: SSN (3-2-4
# hyphen groups), then PAN shapes, then IBAN. The classes are disjoint on the
# shapes above, so order does not change the result.
# -----------------------------------------------------------------------------
redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]")
redact_pan(t) := out if {
g := regex.replace(t, pan_grouped_pattern, "[REDACTED-PAN]")
a := regex.replace(g, pan_amex_pattern, "[REDACTED-PAN]")
out := regex.replace(a, pan_run_pattern, "[REDACTED-PAN]")
}
redact_bank(t) := regex.replace(t, iban_pattern, "[REDACTED-BANK]")
redact_text(t) := redact_bank(redact_pan(redact_ssn(t)))
# Helper: the inner `text` string of an MCP structured content block
# ({"type":"text","text":"..."}); undefined for anything else.
block_text(b) := t if {
is_object(b)
t := object.get(b, "text", null)
is_string(t)
}
# Plain-string content blocks: redact in place.
redact_block(b) := redact_text(b) if {
is_string(b)
}
# MCP-standard structured text content blocks {"type":"text","text":"..."}:
# redact the inner `text` string and preserve every other key. Without this
# branch, body delivered as content-block OBJECTS (the canonical MCP wire shape)
# would slip past a string-only redactor untouched.
redact_block(b) := object.union(b, {"text": redact_text(bt)}) if {
not is_string(b)
bt := block_text(b)
}
# Any other block — an object with no string `text` field, or a non-string /
# non-object value (e.g. a nested array) — passes through unmodified. The policy
# makes no claim over arbitrary structured data whose PII lives under other keys.
redact_block(b) := b if {
not is_string(b)
not block_text(b)
}
# -----------------------------------------------------------------------------
# Transform — emitted only when in scope, `text` is an array, and at least one
# block actually changed. Otherwise the rule is undefined and the aggregator
# skips this policy, returning the response byte-identical.
# -----------------------------------------------------------------------------
response_payload := object.get(input, "payload", {})
text_blocks := object.get(response_payload, "text", [])
redacted_blocks := [out |
some block in text_blocks
out := redact_block(block)
]
transform := {
"transformed_payload": object.union(response_payload, {"text": redacted_blocks}),
} if {
is_glean_read_tool
is_array(text_blocks)
redacted_blocks != text_blocks
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)
Used in these guides
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