--- name: "QuickBooks: Redact Employee & Vendor PII on Read" tags: - quickbooks - redact-pii - pii - redaction - dlp - egress - gdpr-ccpa - soc2 publishedAt: 2026-07-12 description: | # quickbooks / redact-pii-egress-employee **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `quickbooks.egress.redact_pii_employee` ## What it does On the read path, this policy masks sensitive identifiers in the responses of four QuickBooks Online (QBO) name-entity read tools — `get_employee`, `search_employees`, `get_vendor`, and `search_vendors` — before the response reaches the agent. QBO Employee records carry an SSN, a home address, and pay data; QBO Vendor records can carry bank-account and tax-ID (EIN) details used for ACH bill-pay and 1099 reporting. Those fields are rewritten to a fixed redaction token (`[REDACTED]`) in the response so an out-of-group agent never receives them. The redaction happens **only in the response returned to the caller** — the underlying QBO record is untouched. All other tools, and responses with none of the targeted fields, pass through byte-identical. The policy is transform-only (`default allow := true`): it never denies a call. ### Group exemption (fails closed) Callers whose IdP `groups` claim contains `hr` or `finance` (placeholder names — see Known limitations) receive the **unredacted** response. Group membership is read via an `object.get` chain rooted at `object.get(input.subject, "claims", {})`: a missing subject, missing claims, or missing/`non-array` `groups` claim means the caller is *not* exempt and redaction applies. The grant **fails closed** — no group means redaction. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in QBO Employee/Vendor records as they leave 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; **P6.1** — supports controlling disclosure of personal information by masking it before it reaches the agent channel. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data: only placeholder `hr`/`finance` members see raw identifiers, everyone else gets working records with SSN/address/bank/EIN masked. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing by keeping high-value identifiers (SSN, bank account, EIN) out of the agent channel. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information; under CPRA, SSN and financial-account numbers are expressly sensitive PI. ## Why egress The PII already lives in QBO — there is nothing to block at ingress, and denying the read outright would make Employee/Vendor records unusable for legitimate agent tasks (e.g. reconciling a vendor by name). The leak happens when the record is returned to the MCP client, so the response path is the only place to mask the identifiers while keeping the rest of the record usable. ## Tool name matching Applies on the output path — scoped when **any** of the three egress signals holds: `input.mode == "output"`, the PARC `input.action == "tool_post_invoke"`, or the legacy `input.kind == "tool_post_invoke"`. Keying on only a subset fails open (redaction no-ops, leaking PII) on a build that populates a different one — an older gateway near the minimum version may emit only the legacy `kind`. Tools are matched case-insensitively **by suffix**, so the policy works regardless of the MCP server-name prefix the gateway adds (`quickbooks-mcp-…`, `qbo-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. Matched suffixes (Intuit official `verb_entity` vocabulary; the Claude connector is assumed to share it — see Known limitations): - `get_employee` - `search_employees` - `get_vendor` - `search_vendors` Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. ## Response / field shape Redaction is applied by the gateway from this policy's `transform` object, which combines two mechanisms (see the DTwo transform reference): - **`redact_fields`** — QBO object keys matched case-insensitively and recursively, so listing a top-level key (e.g. `PrimaryAddr`) also masks its nested values (`Line1`, `City`, `PostalCode`, …). Covers SSN, home address, and pay data on Employee; tax ID, bank account, and ACH bank detail on Vendor. - **`redact_patterns`** — field-name-agnostic regex backstops for the two highest-signal identifier shapes (US SSN `XXX-XX-XXXX`, US EIN `XX-XXXXXXX`), so a value carried under an unexpected key is still masked. Both are keyed to the exact QBO object shapes, which the landscape research does **not** verify — confirm the field names against a live sample response before production use (see Known limitations). ## Examples ### Redacted (Employee read, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "quickbooks-mcp-get_employee", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["sales"] } }, "payload": { "name": "quickbooks-mcp-get_employee", "text": ["{\"Employee\":{\"SSN\":\"123-45-6789\",\"PrimaryAddr\":{\"Line1\":\"1 Main St\"}}}"] } } } ``` `allow = true`, with `transform` present: the gateway masks the `SSN` and `PrimaryAddr` fields (and the SSN pattern) to `[REDACTED]`. ### Passed through (exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "quickbooks-mcp-get_vendor", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["finance"] } }, "payload": { "name": "quickbooks-mcp-get_vendor", "text": ["{\"Vendor\":{\"TaxIdentifier\":\"12-3456789\"}}"] } } } ``` `allow = true`, no `transform` — the `finance` group receives the raw record. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny policies on the same egress pipeline. Recommended companions in `apps/quickbooks`: - **A finance-group write gate** (ingress) so redacted-on-read records aren't simply re-created or exfiltrated through a write. - **A bulk-export throttle** on `search_*` (ingress) that strips `fetchAll` and caps `limit`, so a non-exempt caller can't pull the entire employee/vendor roster in one call and dilute the value of per-record masking. ## Known limitations - **QBO field names are unverified.** The `redact_fields` list uses the QBO v3 object shapes (`SSN`, `PrimaryAddr`, `BillRate`, `TaxIdentifier`, `AcctNum`, `BankAccountNumber`, `BankBranchIdentifier`, `VendorPaymentBankDetail`), but the landscape note does not verify the JSON keys the MCP server actually returns. **Confirm the field paths against a live sample response before production use** and add any deployment-specific keys. The SSN/EIN `redact_patterns` are a shape-based backstop for values under unexpected keys, but they only catch the canonical hyphenated forms. The field list is intentionally scoped to **high-value identifiers** (SSN, home address, pay, tax ID, bank/ACH detail). Other personal data these records carry — employee/vendor **email** (`PrimaryEmailAddr`), **phone** (`PrimaryPhone`/`Mobile`), and **date of birth** (`BirthDate`) — is *not* in `redact_field_names` and does not match the SSN/EIN patterns, so it passes through to a non-exempt caller. This is a minimum-necessary layer, not blanket PII redaction; add those keys to `redact_field_names` if your minimisation obligation requires masking them too. - **Redaction is scoped to four name-entity read tools.** Field- *and* pattern-redaction fire only for `get_employee` / `search_employees` / `get_vendor` / `search_vendors`. The same SSN/EIN/bank identifier reaching the agent through a **different** read surface — a financial report (General Ledger, Vendor Expenses), `get_company_info`, or `get_attachable`/`search_attachables` (attachment notes can embed a scanned W-9/W-4 with an SSN/EIN) — is **not** masked, because the transform (and therefore the pattern backstop) is never emitted for out-of-scope tools. Pair this policy with a report/attachable read gate or a broader all-tools `redact_patterns` egress policy if those surfaces are reachable. - **Tool names assumed for the Claude connector.** The Intuit official server uses the `verb_entity` names above; the Anthropic-directory "Intuit QuickBooks" connector does not publish its tool names, so they are treated as *unverified* — capture the live `tools/list` through the gateway and add exact suffixes if they differ. The parameterized community server (`hvkshetry/quickbooks-mcp`, archived) exposes a single `party` tool with a `party_type` argument and is **not** matched by these suffixes. - **Pattern detection is best-effort.** Obfuscated, spelled-out, split, or non-hyphenated identifiers are not caught by `redact_patterns`; over-broad matches (a 9-digit EIN-shaped run that is not an EIN) can be over-redacted. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **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 claim like `{"department": "finance"}`), 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 redaction only.** This masks what the agent reads; it does not stop an exempt caller from re-sharing raw data, nor does it touch the web-UI or native-API paths into QBO. > **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: - quickbooks industries: [] bundles: - gdpr-ccpa - soc2 experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```rego package quickbooks.egress.redact_pii_employee # Transform-only egress policy: rewrites Employee/Vendor PII in QuickBooks # Online name-entity read responses to a fixed redaction token before the # response reaches the agent. Never denies. Callers in an exempt IdP group # (hr/finance) receive unredacted responses. default allow := true # ----------------------------------------------------------------------------- # Scope: QBO name-entity read tools whose responses carry Employee/Vendor PII. # Suffix matching keeps the policy portable across gateway server-name prefixes. # Names follow the Intuit official `verb_entity` vocabulary; the Claude # connector is assumed to share it (unverified — see Known limitations). # ----------------------------------------------------------------------------- pii_read_suffixes := { "get_employee", "search_employees", "get_vendor", "search_vendors", } # Egress scope: match the post-invoke/output path on ANY of the three egress # signals the gateway may populate — mode ("output"), the PARC action, or the # legacy `kind` alias. Keying on only a subset fails open (redaction no-ops, # leaking PII) on a build that populates a different one: an older gateway near # the minimum version may emit only the legacy `kind` while leaving `action`/ # `mode` unset. Ingress (tool_pre_invoke / mode "input") satisfies no branch. is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } is_egress if { input.kind == "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 targeted suffix — matching only a subset would let a gateway that # populates a different surface slip a record 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_pii_read_tool if { is_egress some suffix in pii_read_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. Group membership is read via an object.get chain rooted at # object.get(input.subject, "claims", {}): 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(input.subject, "claims", {}), "groups", []) is_exempt if { # Only a flat 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. 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 } # ----------------------------------------------------------------------------- # Redaction instruction. QBO object keys carrying Employee/Vendor PII, matched # case-insensitively and recursively by the gateway (so a top-level key also # masks its nested values, e.g. PrimaryAddr.{Line1,City,PostalCode}). These key # names are the QBO v3 object shapes and are NOT verified in the landscape note # — confirm against a live sample response before production use. # ----------------------------------------------------------------------------- redact_field_names := [ # Employee PII "SSN", # Social Security Number "PrimaryAddr", # home / primary address (structured sub-object) "BillRate", # pay / billing rate # Vendor PII "TaxIdentifier", # EIN / tax ID (1099) "AcctNum", # vendor-assigned account number "BankAccountNumber", # ACH bank account "BankBranchIdentifier", # ACH routing / branch "VendorPaymentBankDetail", # ACH bank-detail sub-object ] # Field-name-agnostic backstops for the two highest-signal identifier shapes, # so a value carried under an unexpected key is still masked. Anchored to the # canonical hyphenated forms to limit false positives. redact_patterns_list := [ `\b\d{3}-\d{2}-\d{4}\b`, # US SSN, canonical XXX-XX-XXXX form `\b\d{2}-\d{7}\b`, # US EIN, canonical XX-XXXXXXX form ] # Transform — emitted only when this is a targeted read tool on the egress path # and the caller is not exempt. Otherwise the rule is undefined and the # aggregator skips this policy, returning the response unchanged. transform := { "redact_fields": redact_field_names, "redact_patterns": redact_patterns_list, "replacement": "[REDACTED]", } if { is_pii_read_tool not is_exempt } ```