---
name: "Box: Redact PII from File Content on Egress"
tags:
  - box
  - redact-pii
  - pii
  - phi
  - dlp
  - redaction
  - egress
  - soc2
  - hipaa
  - gdpr-ccpa
publishedAt: 2026-07-12
description: |
  # box / redact-pii-egress

  **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`/`finance` group 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)

  ```jsonc
  {
    "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)

  ```jsonc
  {
    "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_url` bypass 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_url` bypass.** 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 with `fence-sensitive-folders` to
    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 `hr` and `finance` with your
    IdP's group names at import time.** The exemption is granted **only** for a
    `groups` claim 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"}` — the `is_array` guard 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, adjust `caller_groups` to point at the array before matching.
  - **Egress `transformed_payload` replaces 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.
direction: egress
apps:
  - box
industries: []
bundles:
  - soc2
  - hipaa
  - gdpr-ccpa
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```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
}
```
