--- name: Guard Vendor Banking and Tax-ID Changes tags: - quickbooks - vendor-banking - anti-bec - ingress - sox publishedAt: 2026-07-12 description: | # quickbooks / guard-vendor-banking **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `quickbooks.ingress.guard_vendor_banking` ## What it does Blocks `create_vendor` and `update_vendor` calls whose arguments carry a vendor's **payment coordinates** — bank account number, routing / ACH branch details — or its **tax identity** — the EIN/SSN used for 1099 reporting. Any vendor create/update that touches one of these fields is denied at ingress, before it reaches the QuickBooks MCP server, so the change never lands in the books of record. Silently repointing a vendor's bank account is the core **business-email- compromise (BEC)** vector: an injected agent instruction that rewrites a vendor's ACH details quietly reroutes every future payment to that vendor to an attacker-controlled account. Because the mutation looks like an ordinary vendor edit, it is easy to miss in a review of agent activity — so the gateway refuses it outright and points the caller at the human, dual-approval path in QuickBooks. Non-banking vendor edits — display name, print-on-check name, payment terms, email, phone, billing address — pass through unchanged. Vendor deletes/deactivations are **out of scope here**; they are covered by the companion `freeze-destructive-ops` policy. ## Compliance alignment - **SOX — Exchange Act Rule 13a-15(f)(3) (17 CFR §240.13a-15), safeguarding of assets.** Preventing unauthorized change to a vendor's payment coordinates is a direct "prevent or timely detect unauthorized … disposition of assets" control on the agent path — a rerouted ACH account is asset disposition to an unauthorized party. The gateway logs every attempt with a deny reason. - **SOX — COSO 2013 Principle 10 (segregation of duties).** Blocking the agent from setting vendor banking/tax details keeps the "who can change where money goes" step in a human, dual-approval lane rather than letting an agent both initiate and effect it. - **SOC 2 CC6.1 (logical access controls) / PI1.5 (integrity of stored records).** Denying agent-initiated changes to a vendor's payment coordinates and tax identity is a logical-access boundary that prevents unauthorized modification of financial master data over the agent channel, supporting the integrity of the vendor records QuickBooks holds. This policy addresses the PF-10 family (`guard-vendor-banking`) row of the coverage matrix (SOX §2.5, Rule 13a-15(f)(3)). ## Tool name matching The policy matches the vendor create/update tools by suffix on the **separator-normalized** tool name — `input.resource.name` lowercased with underscores, hyphens, and spaces stripped: - `*createvendor` (matches `create_vendor`, `createVendor`, `create-vendor`) - `*updatevendor` (matches `update_vendor`, `updateVendor`, `update-vendor`) Normalizing the tool name means a server that uses camelCase or hyphenated tool names cannot silently bypass the policy (a plain `endswith` on `create_vendor` would miss `createVendor` and no-op the whole policy). Matching the `createvendor` / `updatevendor` verb+entity suffix still targets the **`_vendor` entity** while `create_vendor_credit` / `update_vendor_credit` (a *different* entity, normalizing to `...vendorcredit`, ending in `credit`) is naturally excluded, and vendor **reads** (`get_vendor`, `search_vendors`) and **deletes** (`delete_vendor`) fall through to `allow` — deletes are handled by `freeze-destructive-ops`, not here. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `qbo-mcp-create_vendor`), and that prefix is not standardized — suffix matching keeps the policy portable. Verify the exact names your gateway sends with a live `tools/list` (or the dump-input debug technique) before relying on this in production. ## Argument shape The policy inspects **field names** in `input.payload.args`, recursively (including nested objects), and normalizes each key (lowercase, underscores / hyphens / spaces removed) so it matches both server conventions: - **Intuit official server** — snake_case wrapper keys, e.g. `bank_account_number`, `routing_number`, `tax_identifier`, `vendor_payment_bank_detail`. - **LibreChat community server** — raw-QBO PascalCase keys, e.g. `BankAccountNumber`, `BankBranchIdentifier`, `TaxIdentifier`, `VendorPaymentBankDetail`. Normalization collapses both to the same token (`bankaccountnumber`, `taxidentifier`, …), so the single `sensitive_fields` allowlist covers both shapes. As a defense-in-depth second branch, the policy also denies when any string value in the payload is shaped like a US tax identifier (SSN `123-45-6789` or EIN `12-3456789`) — this catches a tax ID smuggled under a benign key. > **The exact QBO Vendor bank/tax field keys are not verified** in the app > landscape note. Treat `sensitive_fields` as a documented **candidate > allowlist to confirm against a live `tools/list`** for your server, and tune > it to the keys your deployment actually emits (see Known limitations). ## Examples ### Allowed — non-banking vendor edit ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "qbo-mcp-update_vendor", "type": "tool" }, "payload": { "name": "qbo-mcp-update_vendor", "args": { "id": "56", "display_name": "Acme Supplies", "terms_ref": "NET30" } } } } ``` `allow = true`, no reason. ### Denied — bank account on a vendor create ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "qbo-mcp-create_vendor", "type": "tool" }, "payload": { "name": "qbo-mcp-create_vendor", "args": { "display_name": "New Vendor LLC", "bank_account_number": "000123456789", "routing_number": "021000021" } } } } ``` `allow = false`, reason names the BEC risk and the dual-approval path. ## Composition This policy is single-purpose. Useful companions on the same QuickBooks gateway: - [`freeze-destructive-ops`](../../../bundles/sox/README.md) — denies `delete_vendor` (deactivation) and other destructive verbs. - `gate-money-movement` (PF-09) — caps/denies `create_payment` / `create_bill_payment` so a mis-set vendor cannot be paid at scale. - `role-gate-writes` (PF-12) — restricts all vendor writes to a finance IdP group as the least-privilege baseline. - An egress PII/DLP policy that redacts SSN/EIN/bank-account values from `get_vendor` / `search_vendors` responses. ## Known limitations - **Field-name allowlist is unverified.** The exact QuickBooks Vendor bank/tax field keys are not confirmed in the landscape note. `sensitive_fields` is a candidate list — confirm it against a live `tools/list` and add any keys your server uses (some servers may nest bank details under a container object with a name not in the list). Missing a key means that field is **not** blocked. - **Shapeless values in free-text fields.** A bank account number pasted into a benign free-text field (e.g. `print_on_check_name`, `notes`, a QBO `CustomField` `StringValue`, or a stringified-JSON blob whose keys are not real object keys) has no fixed shape and no sensitive key name, so it is **not** caught — only the tax-ID value branch (SSN/EIN shapes) inspects values, and the field-name branch inspects only real object keys, not the contents of a string. Pair with an egress DLP policy for the read path if this residual matters. - **Field-name matching is exact on the normalized token, not substring.** The banking-synonym list was broadened after red-team review (adds `bankaccountno`, `accountno`, `aba`/`abanumber`/`abaroutingnumber`, `wireroutingnumber`, `iban`, `swift`/`swiftcode`, `bic`, `sortcode`), but a key must normalize to a token that is *exactly* in the set — a novel key such as `vendor_bank_acct_number` (normalizes to `vendorbankacctnumber`) will not match. Confirm the keys your server actually emits and extend the list. - **Tax-ID value branch can over-block.** The dash-delimited value regex will also fire on a benign value that happens to share the SSN (`\d{3}-\d{2}-\d{4}`) or EIN (`\d{2}-\d{7}`) grouping — e.g. a foreign registration number or an oddly-formatted reference. Because this is a deny policy the over-block is fail-safe (the caller is pointed at finance), but tune the pattern or the scope if legitimate dash-delimited values in your data collide. - **Tax-ID value regex is US-shaped and dash-delimited only.** The value branch matches the **dash-delimited** US SSN (`123-45-6789`) and EIN (`12-3456789`) formats only. A tax ID written **without separators** (`123456789`) or with **spaces** (`123 45 6789`) under a benign free-text key is **not** caught by the value branch — matching bare 9-digit runs would over-block every order number, phone, and quantity, so the pattern is deliberately conservative. Non-US tax identifiers are likewise caught only by field name. This is defense-in-depth behind the field-name allowlist, which remains the primary control; pair with an egress DLP policy if the read path matters. - **Parameterized / mega-tool servers are not covered.** This policy matches on the `create_vendor` / `update_vendor` tool-name suffix, which fits the Intuit official server and the LibreChat community server (`verb_entity` naming). It does **not** cover servers that expose a single parameterized tool and carry the verb+entity in an argument — e.g. the archived `hvkshetry/quickbooks-mcp` `party` tool called as `party(operation="update", party_type="vendor", …)`. Such a call has a tool name (`party`) that matches neither suffix, so banking and tax fields in its arguments pass through unblocked. If your deployment uses a parameterized server, add a companion policy that inspects the `operation` / `party_type` (or equivalent) arguments; a suffix-matching policy alone cannot see the verb. - **No identity-based exemptions.** All callers are subject to the same check. To allow a break-glass finance controller to set banking details via the agent, add an `allow if` branch gated on `input.subject.claims.groups` (group names are placeholders — replace with your IdP's group name at import time). - **Reads and deletes are out of scope.** Vendor reads pass through; vendor deletes/deactivations are governed by `freeze-destructive-ops`. > **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: ingress apps: - quickbooks industries: [] bundles: - sox experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```rego package quickbooks.ingress.guard_vendor_banking # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Normalized (lowercase, no separators) vendor field-name tokens that carry # banking or tax-identity data. CANDIDATE LIST — the exact QBO Vendor keys are # not verified; confirm against a live tools/list and tune per deployment. # Normalization collapses snake_case (official server) and PascalCase # (LibreChat raw-QBO) to the same token, so one list covers both conventions. sensitive_fields := { # --- Banking / ACH payment coordinates --- "bankaccountnumber", "accountnumber", "bankaccount", "routingnumber", "bankroutingnumber", "achroutingnumber", "bankbranchidentifier", # QBO routing/branch identifier "vendorpaymentbankdetail", # QBO nested bank-detail container "bankaccountdetail", "achenabled", # Common banking-identifier synonyms / abbreviations and intl. equivalents # (added after red-team review — all are bank routing/account identifiers, # not plausible benign vendor field names). "bankaccountno", "accountno", "aba", "abanumber", "abaroutingnumber", "wireroutingnumber", "iban", "swift", "swiftcode", "bic", "sortcode", # --- Tax identity (EIN/SSN for 1099) --- "taxidentifier", # QBO TaxIdentifier -> tax_identifier "taxid", "taxidentificationnumber", "taxregistrationnumber", "ein", "ssn", "tin", } # US tax-identifier value shapes: SSN 123-45-6789 or EIN 12-3456789. # Anchored with word boundaries to stay conservative (won't match a longer # digit run). Catches a tax ID smuggled under a non-sensitive key name. tax_id_value_pattern := `\b(\d{3}-\d{2}-\d{4}|\d{2}-\d{7})\b` # Tool arguments, safely defaulted so a missing `args` yields an empty object # rather than a rule-body failure. args := object.get(input.payload, "args", {}) # Normalize a field name: lowercase and strip underscores, hyphens, spaces so # `bank_account_number` and `BankAccountNumber` compare equal. normalize(key) := lower(regex.replace(key, `[_\-\s]`, "")) # Vendor create/update tools. We match on the SEPARATOR-NORMALIZED tool name # (lowercase + underscores/hyphens/spaces stripped) so `create_vendor`, # `createVendor`, and `create-vendor` all match — otherwise a server using # camelCase or hyphenated tool names would silently bypass the whole policy. # Matching the `createvendor` / `updatevendor` suffix targets the `_vendor` # entity and still naturally excludes `create_vendor_credit` / # `update_vendor_credit` (normalizes to `...vendorcredit`, ends in `credit`) # and `delete_vendor` / `get_vendor` / `search_vendors`. is_vendor_write if { endswith(normalize(input.resource.name), "createvendor") } is_vendor_write if { endswith(normalize(input.resource.name), "updatevendor") } # True if any argument key (at any depth) is a banking/tax-identity field. banking_field_present if { walk(args, [path, _]) some key in path is_string(key) sensitive_fields[normalize(key)] } # True if any string value (at any depth) is shaped like a US tax identifier. tax_id_value_present if { walk(args, [_, value]) is_string(value) regex.match(tax_id_value_pattern, value) } # Allow anything that isn't a vendor create/update call (reads, deletes, # vendor-credit tools, and every non-vendor tool). allow if { not is_vendor_write } # Allow vendor create/update only when no banking/tax field or tax-ID-shaped # value is present. allow if { is_vendor_write not banking_field_present not tax_id_value_present } reasons contains "Creating or updating a vendor with bank-account, routing/ACH, or tax-identity (EIN/SSN) fields is blocked at the gateway. Silently repointing a vendor's payment coordinates is the primary business-email-compromise (BEC) vector: a rerouted bank account diverts every future ACH payment. Change vendor banking or tax-ID details directly in QuickBooks under dual approval, or ask your finance/AP administrator to make the change or grant an exception." if { is_vendor_write banking_field_present } reasons contains "This vendor create/update carries a value shaped like a US tax identifier (SSN or EIN). Tax IDs for 1099 vendors must be set in QuickBooks under finance review, not through the agent. Contact your finance/AP administrator if this change is legitimate." if { is_vendor_write tax_id_value_present } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ```