Box: Redact PII from File Content on Egress
Scans the responses of Box content-returning tools and rewrites personally identifiable information to fixed redaction tokens before the response reaches the…
- Direction
- egress
- Rego package
box.egress.redact_pii- App
- box
- Bundles
- soc2hipaagdpr-ccpa
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:f54e1078b14091870e8122f18eb5a1938363bca29c5f9999a4c1492e397bf162
boxredact-piipiiphidlpredactionegresssoc2hipaagdpr-ccpa
What this policy does
Direction: egress (tool_post_invoke)
Default: allow (transform-only — never denies)
Package: box.egress.redact_pii
What it does
Scans the responses of Box content-returning tools and rewrites personally identifiable information to fixed redaction tokens before the response reaches the agent:
| Class | Detection | Token |
|---|---|---|
| US SSN | hyphenated XXX-XX-XXXX form |
[REDACTED-SSN] |
| Payment card | 16-digit 4×4 groups, Luhn-validated in Rego | [REDACTED-CC] |
| US bank routing number | label-anchored (routing/ABA + 9 digits), only when paired with an account number in the same block |
[REDACTED-BANK-ROUTING] |
| US bank account number | label-anchored (account/acct + 6–17 digits), only when paired with a routing number in the same block |
[REDACTED-BANK-ACCOUNT] |
| Email address | standard shape, only when paired with a phone number in the same block | [REDACTED-EMAIL] |
| US phone number | separator-formatted, only when paired with an email in the same block | [REDACTED-PHONE] |
Matches are replaced in place, leaving the surrounding structure intact so citations, extraction fields, and search snippets remain usable. 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.
Box is a system of record for contracts, HR files, and PHI/financials, so this is the primary minimum-necessary control on the MCP read path.
Group exemption
Callers whose IdP groups claim contains hr or finance (placeholder
names — see Known limitations) receive unredacted responses. The check
reads input.subject.claims.groups via object.get chains: a missing
subject, missing claims, or missing groups claim means the caller is not
exempt and redaction applies — the grant fails closed.
Compliance alignment
- SOC 2 CC6.7 — supports restricting the transmission/movement of confidential information by masking direct identifiers in Box content as it leaves the gateway toward the agent.
- SOC 2 C1.1 — supports identification and protection of confidential information on the read path; P4.1 — supports limiting personal information use to identified purposes by keeping direct identifiers out of agent context that doesn't need them.
- HIPAA §164.502(b) / §164.514(d) — supports minimum-necessary,
role-based access: only placeholder
hr/financegroup members see raw identifiers; everyone else gets working documents with identifiers masked. - HIPAA §164.514(a)–(b) — supports de-identification practice by stripping Safe-Harbor identifier classes (SSN, account numbers, email, phone) from responses.
- PCI DSS 3.4.1 — supports masking the primary account number when
displayed: Luhn-validated 16-digit card numbers in Box content are rewritten
to
[REDACTED-CC]before the response reaches the agent, so a PAN that lands in a Box document is not surfaced in full on the MCP read path. - GDPR Art. 5(1)(c) — data minimisation on agent reads of personal data; Art. 9 — reduces special-category exposure on the MCP path for documents where identifiers co-occur with health/financial content.
- CCPA/CPRA §1798.121 — supports limiting the use and disclosure of sensitive personal information (SSN, financial account credentials) on the agent channel.
Why egress
The PII already lives in Box — there is nothing to block at ingress, and denying reads outright would make the documents unusable. The leak happens when file-derived text is returned to the MCP client, so the response path is the only place to catch it while keeping the content useful.
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). Tools are matched case-insensitively by
suffix, so it works regardless of the MCP server name prefix the gateway
adds (box-mcp-…, box-prod-…, etc.). 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.
Official Box remote server (mcp.box.com — names verified against Box's docs):
get_file_content, get_file_preview, ai_qa_single_file,
ai_qa_multi_file, ai_qa_hub, ai_extract_freeform,
ai_extract_structured, ai_extract_structured_from_fields,
ai_extract_structured_from_fields_enhanced,
ai_extract_structured_from_metadata_template,
ai_extract_structured_from_metadata_template_enhanced,
search_files_keyword, search_files_metadata (search responses leak
matched snippets when the search scope includes file content).
Community server (box-community/mcp-server-box — names verified from repo
docs): box_file_text_extract_tool, box_search_tool.
Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and add suffixes for any other content-returning tools your deployment exposes.
Response shape
The policy reads input.payload.text — the MCP content-block array the
gateway populates on tool_post_invoke — and rewrites each string block.
Non-string blocks pass through unmodified. 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 preserved).
Examples
Redacted (content tool, non-exempt caller)
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "box-mcp-get_file_content", "type": "tool" },
"subject": { "sub": "auth0|u1", "claims": { "groups": ["marketing"] } },
"payload": {
"name": "box-mcp-get_file_content",
"text": ["Employee SSN: 123-45-6789, card 4111 1111 1111 1111"]
}
}
}
allow = true, with transform.transformed_payload.text =
["Employee SSN: [REDACTED-SSN], card [REDACTED-CC]"].
Passed through (exempt caller)
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "box-mcp-get_file_content", "type": "tool" },
"subject": { "sub": "auth0|u2", "claims": { "groups": ["hr"] } },
"payload": {
"name": "box-mcp-get_file_content",
"text": ["Employee SSN: 123-45-6789"]
}
}
}
allow = true, no transform — the hr group receives raw content.
Composition
Single-purpose transform policy (default allow := true); it composes
cleanly with deny policies on the same egress pipeline. Recommended
companions in apps/box:
- fence-sensitive-folders (ingress) — keeps agents out of sensitive
folder trees entirely, and covers the
get_download_urlbypass below. - An external-sharing guard on
create_collaboration/*shared_link*(ingress) so redacted-on-read content isn't simply shared out instead.
Known limitations
- Pattern-based detection is best-effort. Obfuscated, split-across-lines, spelled-out, base64-encoded, or image-embedded values are not caught; a Luhn-valid 16-digit number that is not a card can be over-redacted. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution.
get_download_urlbypass. That tool returns a URL rather than content, so files fetched out-of-band are never seen by egress scanning. This policy deliberately does not match it — pair withfence-sensitive-foldersto keep sensitive trees off the read path altogether.- Pair heuristics are conservative by design. A lone email address, a
lone phone number, a routing number without an account number (and vice
versa) are not redacted — this keeps corporate contact info and order
numbers usable. Bank labels are matched in plain text (
Routing number: 021000021), not as JSON keys ("routing_number": "…"won't match). - SSN matching is hyphenated-form only. Bare 9-digit runs collide with Box file IDs and are left alone.
- Group names are placeholders — replace
hrandfinancewith your IdP's group names at import time. The exemption is granted only for agroupsclaim shaped as an array of strings (a single bare string is also handled). Any other shape fails closed → redaction applies: a missing subject/claims/groups, an object/map (e.g. a namespaced or metadata claim like{"department": "finance"}— theis_arrayguard stops its values from being read as group names), and nested/non-string array elements are all treated as not exempt. If your IdP emits roles under a namespaced claim, adjustcaller_groupsto point at the array before matching. - 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. - Community-server AI tools not matched. The box-community server's AI module tool names are not individually verified in the landscape research, so they are not in the suffix list — add them if your deployment exposes them.
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 box.egress.redact_pii
# Transform-only egress policy: rewrites PII in Box content-returning tool
# responses to fixed redaction tokens before the response reaches the agent.
# Never denies. Callers in an exempt IdP group receive unredacted responses.
default allow := true
# -----------------------------------------------------------------------------
# Scope: Box tools whose responses carry file-derived content. Suffix matching
# keeps the policy portable across gateway server-name prefixes and covers both
# the official remote server (bare verb_noun names) and the community server
# (box_*_tool names). Verified against the Box landscape research.
# -----------------------------------------------------------------------------
content_tool_suffixes := {
# Official remote server (mcp.box.com)
"get_file_content",
"get_file_preview",
"ai_qa_single_file",
"ai_qa_multi_file",
"ai_qa_hub",
"ai_extract_freeform",
"ai_extract_structured",
"ai_extract_structured_from_fields",
"ai_extract_structured_from_fields_enhanced",
"ai_extract_structured_from_metadata_template",
"ai_extract_structured_from_metadata_template_enhanced",
"search_files_keyword",
"search_files_metadata",
# Community server (box-community/mcp-server-box)
"box_file_text_extract_tool",
"box_search_tool",
}
# 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_content_tool
# would silently 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 content-tool suffix — matching only a subset would let a gateway
# that populates a different surface slip file content past the scanner.
candidate_names contains lower(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", ""))
is_content_tool if {
is_egress
some suffix in content_tool_suffixes
some n in candidate_names
endswith(n, suffix)
}
# -----------------------------------------------------------------------------
# Group exemption — placeholder IdP groups whose members receive unredacted
# responses. Replace "hr" / "finance" with your IdP's group names at import
# time. object.get chains mean a missing subject/claims/groups claim is never
# exempt: the grant fails closed and redaction applies.
# -----------------------------------------------------------------------------
exempt_groups := {"hr", "finance"}
caller_groups := object.get(
object.get(object.get(input, "subject", {}), "claims", {}),
"groups",
[],
)
is_exempt if {
# Only an array of group strings grants the exemption. The is_array guard is
# load-bearing: `some g in caller_groups` over an OBJECT iterates its values,
# so a namespaced/metadata claim like {"department": "finance"} would else
# wrongly exempt the caller. is_string(g) keeps nested/non-string elements
# from matching. Anything but a clean array of strings fails closed → redact.
is_array(caller_groups)
some g in caller_groups
is_string(g)
lower(g) in exempt_groups
}
is_exempt if {
# Some IdPs emit a single group as a bare string rather than an array.
is_string(caller_groups)
lower(caller_groups) in exempt_groups
}
# -----------------------------------------------------------------------------
# Detection patterns — anchored and conservative to limit false positives.
# -----------------------------------------------------------------------------
# US SSN in the canonical hyphenated form only. Bare 9-digit runs are too
# collision-prone with Box file/folder IDs to redact safely.
ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b`
# 16-digit card-shaped runs in 4x4 groups with optional space/hyphen
# separators. Candidates are only redacted after passing a Luhn check below —
# a matching shape alone is not enough.
card_pattern := `\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b`
# Labeled US bank routing number (exactly 9 digits) and account number (6-17
# digits). Label-anchored so arbitrary digit runs are never touched; both must
# appear in the same content block before either is redacted (bank_pair).
routing_pattern := `(?i)\b(?:aba|routing)(?:\s+(?:no|num|number)\.?)?\s*[:#]?\s*\d{9}\b`
account_pattern := `(?i)\b(?:account|acct)(?:\s+(?:no|num|number)\.?)?\s*[:#]?\s*\d{6,17}\b`
# Email address and separator-formatted US phone number. Redacted only when
# both appear in the same content block (a contact-record signature) so lone
# corporate email addresses stay usable (contact_pair).
email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b`
phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)|\b\d{3})[-. ]\d{3}[-. ]\d{4}\b`
# -----------------------------------------------------------------------------
# Luhn check — validates card-shaped candidates so invoice/reference numbers
# that merely look like PANs are left alone.
# -----------------------------------------------------------------------------
digits_only(s) := regex.replace(s, `[^0-9]`, "")
luhn_contribution(d, parity) := d if { parity == 0 }
luhn_contribution(d, parity) := 2 * d if {
parity == 1
(2 * d) < 10
}
luhn_contribution(d, parity) := (2 * d) - 9 if {
parity == 1
(2 * d) >= 10
}
luhn_valid(digits) if {
chars := split(digits, "")
n := count(chars)
total := sum([v |
some i, c in chars
v := luhn_contribution(to_number(c), (n - 1 - i) % 2)
])
total % 10 == 0
}
# All card-shaped substrings of t that pass the Luhn check.
card_candidates(t) := {c |
some c in regex.find_n(card_pattern, t, -1)
luhn_valid(digits_only(c))
}
# -----------------------------------------------------------------------------
# Redaction steps — each is total over strings: it returns the input unchanged
# when its class doesn't apply, so the steps chain safely.
# -----------------------------------------------------------------------------
redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]")
bank_pair(t) if {
regex.match(routing_pattern, t)
regex.match(account_pattern, t)
}
redact_bank(t) := out if {
bank_pair(t)
routed := regex.replace(t, routing_pattern, "[REDACTED-BANK-ROUTING]")
out := regex.replace(routed, account_pattern, "[REDACTED-BANK-ACCOUNT]")
}
redact_bank(t) := t if { not bank_pair(t) }
redact_cards(t) := out if {
cands := card_candidates(t)
count(cands) > 0
# Candidates contain only digits, spaces, and hyphens, so joining them into
# an alternation of literals is regex-safe.
literal := concat("|", sort([c | some c in cands]))
out := regex.replace(t, literal, "[REDACTED-CC]")
}
redact_cards(t) := t if { count(card_candidates(t)) == 0 }
contact_pair(t) if {
regex.match(email_pattern, t)
regex.match(phone_pattern, t)
}
redact_contact(t) := out if {
contact_pair(t)
emailed := regex.replace(t, email_pattern, "[REDACTED-EMAIL]")
out := regex.replace(emailed, phone_pattern, "[REDACTED-PHONE]")
}
redact_contact(t) := t if { not contact_pair(t) }
# Order matters: SSNs first (so they can't be half-eaten by later patterns),
# then labeled bank pairs (so a labeled 16-digit account number is classified
# as a bank account, not a card), then Luhn-checked cards, then contact pairs.
redact_block(b) := redact_contact(redact_cards(redact_bank(redact_ssn(b)))) if {
is_string(b)
}
# Non-string content blocks (structured/JSON blocks) pass through unmodified.
redact_block(b) := b if { not is_string(b) }
# -----------------------------------------------------------------------------
# Transform — emitted only when in scope, the caller is not exempt, and at
# least one block actually changed. Otherwise the rule is undefined and the
# aggregator skips this policy, returning the response byte-identical.
# -----------------------------------------------------------------------------
text_blocks := object.get(input.payload, "text", [])
redacted_blocks := [out |
some block in text_blocks
out := redact_block(block)
]
transform := {
"transformed_payload": object.union(input.payload, {"text": redacted_blocks}),
} if {
is_content_tool
not is_exempt
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