Guard Vendor Banking and Tax-ID Changes
Blocks create vendor and update vendor calls whose arguments carry a vendor's payment coordinates — bank account number, routing / ACH branch details — or…
On this page
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(matchescreate_vendor,createVendor,create-vendor)*updatevendor(matchesupdate_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_fieldsas a documented candidate allowlist to confirm against a livetools/listfor your server, and tune it to the keys your deployment actually emits (see Known limitations).
Examples
Allowed — non-banking vendor edit
{
"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
{
"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— deniesdelete_vendor(deactivation) and other destructive verbs.gate-money-movement(PF-09) — caps/deniescreate_payment/create_bill_paymentso 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_vendorsresponses.
Known limitations
- Field-name allowlist is unverified. The exact QuickBooks Vendor
bank/tax field keys are not confirmed in the landscape note.
sensitive_fieldsis a candidate list — confirm it against a livetools/listand 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 QBOCustomFieldStringValue, 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 asvendor_bank_acct_number(normalizes tovendorbankacctnumber) 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_vendortool-name suffix, which fits the Intuit official server and the LibreChat community server (verb_entitynaming). It does not cover servers that expose a single parameterized tool and carry the verb+entity in an argument — e.g. the archivedhvkshetry/quickbooks-mcppartytool called asparty(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 theoperation/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 ifbranch gated oninput.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.
Policy source (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)
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)