Gate QuickBooks Money-Movement by Finance Group
Gates the QuickBooks Online money-movement creation tools — create payment, create bill payment, create refund receipt, create transfer, and create deposit —…
- Direction
- ingress
- Rego package
quickbooks.ingress.gate_money_movement- App
- quickbooks
- Bundles
- soxpci-dss
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:81cc625d551e2bf5d26e006311471ffe6cc8d899cd65705fc59138d66269c0ce
quickbooksgate-money-movementingresssoxpci-dss
What this policy does
Direction: ingress (tool_pre_invoke)
Default: deny for money-movement tools unless the caller is in a finance group and the amount is under the ceiling; allow everything else
Package: quickbooks.ingress.gate_money_movement
What it does
Gates the QuickBooks Online money-movement creation tools — create_payment,
create_bill_payment, create_refund_receipt, create_transfer, and
create_deposit — behind two checks, applied in order:
- Finance-group gate. The call is denied unless the caller's IdP claims put
them in the
financeoraccountinggroup. Everyone else (including callers with no group claim at all) is denied with a reason that points at the AP/AR owner in Finance. - Amount ceiling. For in-group callers, the policy sums the transaction amount from the known candidate fields and denies when the total exceeds a configurable ceiling (default $5,000), so larger disbursements are routed through the human approval workflow instead of being initiated by an agent.
Reads (get_* / search_* / reports) and non-money-movement writes
(create_invoice, create_customer, update_*, etc.) pass through unchanged —
this policy only touches the five disbursement tools above. Compose it with the
finance-group write gate and the delete/void freeze for full coverage.
Fail-closed amount extraction
QBO money-movement payloads do not expose one canonical amount field. The official
create_invoice shape, for example, has no top-level total — the amount must be
summed from line_items[].qty * unit_price — and the payment tools' exact
amount-field names are not verified in the landscape note. So the policy reads
the amount from several candidate sources and takes the maximum of everything it
finds:
- top-level scalar keys:
amount,Amount,total,total_amt,TotalAmt,total_amount(covers the snake_case official wrapper and the raw-QBO PascalCase variants); - the official line-item sum:
sum(line_items[].qty * unit_price); - the raw-QBO line sum:
sum(Line[].Amount).
Taking the maximum is deliberate: as long as the real amount lands in one of the
recognized fields above, a caller cannot slip a large disbursement past the ceiling by
also including a small decoy amount field — the max still sees the real one. The
ceiling is applied to the largest absolute value among the candidates, so a
large negative amount (a reversal or credit that still moves money in magnitude)
is denied just like the equivalent positive — it cannot slip under the positive
ceiling. If
no parseable amount is found in any candidate source, the policy fails
closed — the call is denied and the caller is asked to route it through the approval
workflow.
This max-of-candidates guard has one residual (see Known limitations): if the
server's true amount lives in a field name this policy does not recognize and the
caller adds a small recognized decoy (e.g. amount: 1), extraction "succeeds" on the
decoy and the call is allowed while the real, larger amount is never counted. The
fail-closed deny only fires when no recognized field is present at all. This is why
amount_keys and the line-item paths must be reconciled against your server's live
schema before the ceiling can be trusted.
Compliance alignment
- SOX — ITGC access to programs & data (least-privilege access to financial systems, Enforceable via PF-09/PF-12): only finance/accounting identities can initiate money movement over the agent channel.
- SOX — Rule 13a-15(f)(3), safeguarding of assets (Enforceable via PF-09/PF-10): the amount ceiling caps agent-initiated disbursements, limiting the blast radius of a compromised or misdirected agent.
- SOX — Rule 13a-15(f)(2)(ii), transaction authorization (Partial via PF-09 thresholds / PF-15): disbursements above the ceiling are forced onto the human approval workflow rather than being auto-authorized by the agent.
- SOC 2 CC6.3 (role-based access, least privilege, segregation of duties, Enforceable via PF-09/PF-12): the finance-group gate enforces a role boundary on the highest-risk QBO writes.
- PCI DSS Req 7.2.1 / 7.2.2 — least-privilege access model. QuickBooks Online
can process and store cardholder data — customer card payments and card refunds
flow through
create_payment/create_refund_receipt— so confining these money-movement tools to the finance/accounting role enforces a role-based, least-privilege access boundary on the card-touching disbursement operations over the agent channel (matrix PF-09 → 7.2.x).
Tool name matching
The DTwo gateway prefixes tool names with the configured MCP server name (e.g.
quickbooks-mcp-create_payment), and that prefix is not standardized. Matching is
case-insensitive (lower(input.resource.name)) and suffix-based
(endswith) on the five verb_entity names, so it stays portable across the Intuit
official server, the Intuit Claude connector, and the LibreChat community server.
Verify the exact tool names your gateway emits with the dump-input debug technique
before relying on this in production.
Argument shape
Amounts are read from the candidate keys/paths listed under Fail-closed amount
extraction above, always via object.get(...) with defaults, and coerced with
to_number (so a non-numeric value simply doesn't count as a parseable amount).
Group membership is read via object.get(input.subject, "claims", {}) →
groups, and matched case-insensitively against finance / accounting; a missing
claims block yields no groups and therefore a deny.
Examples
Allowed — finance caller, under the ceiling
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "quickbooks-mcp-create_payment", "type": "tool" },
"subject": { "claims": { "groups": ["finance"] } },
"payload": {
"name": "quickbooks-mcp-create_payment",
"args": { "customer_ref": "42", "amount": 1200 }
}
}
}
allow = true, no reason.
Denied — caller not in a finance group
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "quickbooks-mcp-create_transfer", "type": "tool" },
"subject": { "claims": { "groups": ["sales"] } },
"payload": {
"name": "quickbooks-mcp-create_transfer",
"args": { "amount": 300 }
}
}
}
allow = false, reason points at the AP/AR owner.
Denied — finance caller, over the ceiling
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "quickbooks-mcp-create_bill_payment", "type": "tool" },
"subject": { "claims": { "groups": ["accounting"] } },
"payload": {
"name": "quickbooks-mcp-create_bill_payment",
"args": { "amount": 7500 }
}
}
}
allow = false, reason cites the $7500 total against the $5000 ceiling.
Composition
quickbooks/role-gate-writes(PF-12) — the broader finance-group gate on allcreate_*/update_*writes; this policy narrows the disbursement subset with an amount ceiling on top.quickbooks/freeze-destructive-ops(PF-06) — deny hard deletes/voids of transactions.- An egress PII policy on
get_employee/get_vendorto mask SSN/bank fields.
Known limitations
- Group names are placeholders — replace
finance/accountingwith your IdP's group names at import time. Membership is read frominput.subject.claims.groups; if your IdP emits roles under a different claim (e.g. Auth0permissions, or a namespacedhttps://acme.com/roles), adjustfinance_groupsand the claim path. - The ceiling is a constant. Edit
amount_ceilingin the Rego (default5000) to your organization's approval threshold; there is no per-request override. - Amount-field names are unverified. The candidate key list is derived from the
official server's snake_case wrapper and the raw-QBO PascalCase shapes described in
the landscape note, not from a verified live schema. Capture the live
tools/listand a sample payload and extendamount_keys/ the line-item paths if your server exposes the total under a different key. When a money-movement call carries no recognized amount field at all, extraction fails closed and the call is denied (safe). Residual bypass: if the real amount is in an unrecognized field and the caller also supplies a small recognized decoy (e.g.amount: 1), the decoy satisfies extraction and the call is allowed under the ceiling while the real amount goes uncounted. The max-of-candidates rule only defends across fields the policy already knows, so reconcileamount_keys/ the line-item paths with the live schema before relying on the ceiling. - Currency is ignored. The ceiling is compared as a bare number; multi-currency companies should normalize before relying on the threshold. The ceiling bounds the magnitude (absolute value) of the total, so both large positive and large negative amounts are denied; a zero total (or a set of lines that nets to zero) is treated as no movement and passes.
- Suffix list is snake_case only. Matching is
endswithon the fiveverb_entitynames (e.g.create_payment), which is portable across the surveyed servers (Intuit official, the Intuit Claude connector, and LibreChat — all snake_case). A server that named the same tool in camelCase (createPayment, no underscore) would not match the suffix and would pass through unchanged. This is not the case for any surveyed implementation, but confirm the exact tool strings your gateway emits with the dump-input technique before relying on this. - The no-amount denial message hardcodes "$5,000". If you change
amount_ceiling, update that string too — only the over-ceiling reason interpolates the constant viasprintf; the no-amount reason is a fixed literal. - Only the five
create_*disbursement tools are gated. The matchingupdate_*tools (update_payment,update_bill_payment,update_transfer,update_refund_receipt,update_deposit) can also alter payee or amount on an existing disbursement, but they pass through this policy unchanged — for any caller, not just finance. That is by design (this policy owns the ceiling; the finance-group boundary on all writes belongs torole-gate-writes), but it means this policy alone does not stop a non-finance caller from mutating a payment viaupdate_*. Deploy it together withquickbooks/role-gate-writes. - Parameterized servers not covered. The archived hvkshetry server exposes money
movement through a single
transactiontool with the verb in anoperationargument; a suffix match oncreate_*does not see it, so such a call passes through unchanged. Add an argument-level rule (inspectinput.payload.args.operation/entity_type) if that server is in scope.
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.gate_money_movement
# Deny-by-default: money-movement tools are only permitted by the explicit allow
# rules below. Non-money-movement tools are allowed by the pass-through rule.
default allow := false
# Configurable disbursement ceiling (USD). Edit to your approval threshold.
amount_ceiling := 5000
# IdP groups permitted to initiate money movement. Placeholders — replace with
# your IdP's group names at import time.
finance_groups := {"finance", "accounting"}
# Money-movement creation tools, matched case-insensitively by suffix so the
# gateway's server-name prefix (e.g. `quickbooks-mcp-`) doesn't matter.
money_movement_suffixes := [
"create_payment",
"create_bill_payment",
"create_refund_receipt",
"create_transfer",
"create_deposit",
]
# Top-level scalar keys that may carry the transaction total. Covers the official
# snake_case wrapper and the raw-QBO PascalCase variants; unverified, so extraction
# fails closed if none match.
amount_keys := ["amount", "Amount", "total", "total_amt", "TotalAmt", "total_amount"]
is_money_movement_tool if {
name := lower(input.resource.name)
some suffix in money_movement_suffixes
endswith(name, suffix)
}
# Caller is in a finance/accounting group. Reads claims via object.get and fails
# closed: no claims / no groups => not in group => denied.
caller_in_finance_group if {
claims := object.get(input.subject, "claims", {})
groups := object.get(claims, "groups", [])
some g in groups
finance_groups[lower(g)]
}
# --- Candidate amounts (a set; take the max as the enforced total) ---
# Scalar top-level amount fields.
candidate_amounts contains n if {
some key in amount_keys
raw := object.get(input.payload.args, key, null)
raw != null
n := to_number(raw)
}
# Official line-item sum: sum(qty * unit_price). Only lines with both fields present
# and numeric contribute; if none do, no candidate is produced (fail closed).
candidate_amounts contains total if {
items := object.get(input.payload.args, "line_items", [])
count(items) > 0
amounts := [(q * p) |
some item in items
raw_q := object.get(item, "qty", null)
raw_p := object.get(item, "unit_price", null)
raw_q != null
raw_p != null
q := to_number(raw_q)
p := to_number(raw_p)
]
count(amounts) > 0
total := sum(amounts)
}
# Raw-QBO line sum: sum(Line[].Amount).
candidate_amounts contains total if {
items := object.get(input.payload.args, "Line", [])
count(items) > 0
amounts := [a |
some item in items
raw_a := object.get(item, "Amount", null)
raw_a != null
a := to_number(raw_a)
]
count(amounts) > 0
total := sum(amounts)
}
amount_found if {
count(candidate_amounts) > 0
}
# The enforced total is the largest-MAGNITUDE candidate found: we take the max of
# the absolute values so a large negative amount (e.g. a reversal that still moves
# money) cannot slip under the positive ceiling. Undefined when the set is empty,
# so any rule that references it fails closed.
total_amount := max([abs(c) | some c in candidate_amounts])
# --- Allow rules ---
# Everything that isn't a money-movement tool passes through (reads, reports,
# non-money-movement writes).
allow if {
not is_money_movement_tool
}
# Money movement is allowed only for in-group callers whose parseable total is at
# or below the ceiling. If no amount was parsed, total_amount is undefined and this
# body fails => deny.
allow if {
is_money_movement_tool
caller_in_finance_group
total_amount <= amount_ceiling
}
# --- Deny reasons ---
reasons contains "This QuickBooks money-movement tool is limited to callers in the finance or accounting group. Ask the AP/AR owner in Finance to run this disbursement, or request finance-group membership from your IdP administrator if this is a mistake." if {
is_money_movement_tool
not caller_in_finance_group
}
reasons contains "This money-movement call was denied because no transaction amount could be read from the request, so the $5,000 approval ceiling cannot be verified. Route this disbursement through the human approval workflow, or resend with an explicit amount if you believe this is a false positive." if {
is_money_movement_tool
caller_in_finance_group
not amount_found
}
reasons contains msg if {
is_money_movement_tool
caller_in_finance_group
amount_found
total_amount > amount_ceiling
msg := sprintf("This money-movement call totals $%v, which exceeds the $%v ceiling for agent-initiated disbursements. Route amounts above the ceiling through the human approval workflow.", [total_amount, amount_ceiling])
}
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)
Used in these guides
Related policies
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
Block Destructive and Mutating Snowflake SQL
Inspects the SQL text that Snowflake MCP tools carry in their query argument and denies any statement in a mutating or destructive class — DROP, TRUNCATE,…
snowflakeguard-warehouse-sqlingresssqlreadonlysoc2pci-dsssox
Block Destructive SQL in BigQuery Queries
Inspects the raw GoogleSQL string carried by BigQuery write-capable query tools and denies any statement in a state-changing class — DML…
Cap Intercom Contact Enumeration
caller is a CRM admin); clamp page size on everything else; allow the rest
intercomcap-bulk-exportcontact-enumerationdlpingresssoc2hipaapci-dssgdpr-ccpa
Cap QuickBooks Bulk Search Exports
Clamps the bulk-read levers on every QuickBooks Online search tool so an agent cannot pull the entire general ledger — or a full customer, vendor, or…
quickbookscap-bulk-exportbulk-exportdlpingresssoc2pci-dssgdpr-ccpa