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…
- Direction
- ingress
- Rego package
quickbooks.ingress.cap_bulk_export- App
- quickbooks
- Bundles
- gdpr-ccpapci-dsssoc2
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:fb6b6ca97595f62307f8d3a606eb375ea5e1a513eab466cf602dfa8a7b90439f
quickbookscap-bulk-exportbulk-exportdlpingresssoc2pci-dssgdpr-ccpa
What this policy does
Direction: ingress (tool_pre_invoke)
Default: allow (transform-only — never denies)
Package: quickbooks.ingress.cap_bulk_export
What it does
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
employee list — into its context in a single call. It targets the two knobs the
landscape note flags as the bulk-exfiltration levers on QBO search: the
fetchAll "give me everything" flag and an oversized limit.
On a search_* call (e.g. search_invoices, search_customers, search_bills,
search_employees, search_accounts) the policy rewrites the request arguments
before they reach the MCP server:
fetchAllis stripped and the limit is pinned to the cap. WhenfetchAllis truthy, it is removed from the criteria object andlimitis set to the ceiling (default 50).fetchAll: trueoverrideslimiton the QBO API, so removing it and imposing a bounded page is what actually stops the whole-ledger pull.- An oversized
limitis lowered. When nofetchAllis present butlimitis a number above the cap, it is lowered to the cap. This applies both to alimitinsidecriteria(the official advanced-object shape) and to a top-levellimitsibling ofcriteria(the LibreChat shape). - A top-level
fetchAllsibling ofcriteriais also stripped. The LibreChat shape hoists paging params (limit) to the top level, so the same fetchAll clamp is applied there: a truthy top-levelfetchAllis removed and the top-levellimitis pinned to the cap, mirroring the in-criteriabehavior. This closes a bypass where a whole-ledgerfetchAll: truecarried as a sibling ofcriteria(rather than inside it) would otherwise pass through unclamped. - Smaller explicit limits are left untouched. A
limitof 10 stays 10; a search with no bulk knobs at all passes through with no transform applied.
Every non-search_* tool — single-record get_*, all create_* / update_* /
delete_* writes, and the whole-company report tools — passes through
completely unchanged.
Criteria shapes handled
The QBO search_* argument criteria arrives in three shapes; the transform
handles all of them and corrupts none:
- Advanced object (official server) —
{filters, asc, desc, limit, offset, count, fetchAll}. ThefetchAllandlimitkeys on this object are clamped as described above;filters,asc,desc,offset, andcountare preserved untouched. - Array of clauses (LibreChat) —
criteria: [{field, value, operator, ...}]withlimitas a top-level sibling ofcriteria. The landscape note records LibreChat carryinglimitalongsidecriteriarather than inside it, so the top-levellimit/fetchAllclamp (see "What it does") is what actually bounds a LibreChat search. The per-element clamp (criteria[].limit, per-clausefetchAll) is applied defensively as well, so alimit/fetchAllcarried inside a clause is also capped/stripped. Non-object array entries are left as-is (array length is never changed). - Bare field map —
{DisplayName: "Acme"}. A plain{field: value}match carries nolimit/fetchAllkey, so nothing is clamped and the call passes through untouched.
Compliance alignment
This policy instantiates family PF-08 (cap-bulk-export) for QuickBooks
Online.
- SOC 2 CC6.7 — supports restricting the transmission, movement, and removal
of confidential information: bounding page size and disabling
fetchAllkeeps a single agent call from lifting the whole ledger or the entire customer/employee base out over the MCP path. - PCI DSS 3.4.2 / 7.2.6 — QuickBooks Online can store cardholder data on the
customer-payment and refund transactions the ledger records. Clamping bulk-read
levers (
fetchAll, oversizedlimit) supports restricting the copy/relocation of stored payment data through the agent channel (3.4.2) and the least-privilege restriction of programmatic queries against repositories of stored cardholder data (7.2.6), so a single call cannot relocate the whole transaction set to an unmanaged destination (matrix PF-08 → 3.4.2 / 7.2.6). - GDPR Art. 5(1)(c) — supports data minimisation by preventing the agent from reading far more personal and financial data than the task in hand requires.
- CCPA/CPRA 11 CCR §7002 — supports the proportionality principle (collection and processing limited to what is reasonably necessary) on the agent channel.
Why ingress and transform
The bulk-read harm is fully determined by the request — the tool name and the
criteria shape are all in input.payload.args. Rewriting the arguments at
ingress means the unbounded query never reaches QuickBooks, so the whole result
set is never returned to the agent and there is nothing to redact on the way
back. Because the fix is to rewrite arguments before the call, it is an ingress
transform rather than a deny.
Tool name matching
The DTwo gateway prefixes tool names with the configured MCP server name, so the
match is on the search_ verb rather than an exact name. A tool is treated as a
QBO search when its lowercased name contains search_ at the start or
immediately after a non-letter separator — this matches search_invoices,
quickbooks-search_customers, quickbooks-online-mcp-search_bills, and the like,
across the official (snake_case) and LibreChat servers, while not matching a
word that merely embeds the substring mid-token (e.g. a hypothetical
research_*).
The official server's search_* names are taken from Intuit's open-source tool
inventory; the Claude-connector tool names are not published and could not be
verified (see Known limitations). Confirm the exact prefixed names your gateway
emits with the dump-input debug technique before relying on this in production.
Argument shape
criteriais read defensively viaobject.get(input.payload.args, "criteria", null). A missingcriteria, or one reshaped to a string/number, yields no clamp and the call passes through.fetchAllis treated as set only when it equalstrue;fetchAll: falseis already bounded and is left in place.limitis clamped only when it is a number above the cap; a non-numericlimit(string, object) is left untouched — QBO would reject it upstream.
Examples
Clamped — fetchAll stripped, limit pinned to the cap
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "quickbooks-search_invoices", "type": "tool" },
"payload": {
"name": "quickbooks-search_invoices",
"args": { "criteria": { "fetchAll": true } }
}
}
}
allow = true; the transform rewrites criteria to { "limit": 50 }.
Clamped — oversized limit lowered, filters preserved
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "quickbooks-search_customers", "type": "tool" },
"payload": {
"name": "quickbooks-search_customers",
"args": {
"criteria": {
"filters": [{ "field": "DisplayName", "operator": "LIKE", "value": "A%" }],
"limit": 500,
"fetchAll": true
}
}
}
}
}
allow = true; criteria becomes
{ "filters": [{...}], "limit": 50 } — fetchAll removed, limit pinned to 50,
filters intact.
Clamped — top-level limit sibling (LibreChat shape)
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "quickbooks-search_customers", "type": "tool" },
"payload": {
"name": "quickbooks-search_customers",
"args": {
"criteria": [{ "field": "Active", "operator": "=", "value": "true" }],
"limit": 999
}
}
}
}
allow = true; the top-level limit is lowered to 50, criteria untouched.
Passthrough — small explicit limit
A search_bills call with criteria: { "limit": 25 } is allowed with no
transform applied.
Passthrough — non-search tool
A get_invoice or create_invoice call is never inspected and passes through
untouched.
Composition
Single-purpose. Useful companions on the same gateway:
- A role policy gating the whole-company report tools (P&L, Balance Sheet,
General Ledger, Trial Balance). Those return the full ledger by design and
take no
criteria/limit, so this policy cannot bound them — restrict them by IdP group instead (PF-12 / PF-20). - A PII-redaction egress policy on
search_employees/search_customersoutput so the (now bounded) rows that do come back have SSN, bank-account, and home-address fields masked for callers outside HR/finance. - A money-movement / delete freeze ingress policy for the write and destructive surfaces (PF-06 / PF-09).
This policy caps how many records one search call can pull; the companions decide who may touch a surface and what is returned.
Known limitations
- Bounded paging still works. This raises cost and creates an audit trail; it does not make bulk export impossible. An agent can still page through results with repeated bounded calls (each clamped to the cap). Pair with rate limiting and the audit pipeline for detection.
- Report tools are out of scope by design. Whole-company financial reports
(P&L, Balance Sheet, General Ledger, Trial Balance) return the full ledger in
one call and take no
criteria/limit, so there is nothing here to clamp. Gate them with a separate role policy — do not rely on this one to bound them. - A limitless search still returns QBO's default page. The policy clamps a
limit/fetchAllthat is present; it does not inject alimitonto a search that carries neither. Asearch_*call with nolimitand nofetchAllpasses through untouched and QBO returns its own default page size (which can exceed the cap of 50). The whole-ledger lever (fetchAll) is still stripped, so the residual is a single default-sized page, not the full ledger. If you need a hard ceiling on every search, pair this with a role/rate-limit policy or extend the transform to injectlimit: capwhen a search carries no bounding knob. - Only a boolean
fetchAll: trueis stripped. A non-boolean truthy value ("true",1) is not treated as set and passes through unchanged. The surveyed servers (Intuit official and LibreChat) are Zod-typed and reject a non-booleanfetchAllupstream, so this is not a live bypass on them; if you front a server that coerces truthy non-booleans, hardenhas_fetch_allto cover those forms. fetchAll: falseis left in place. Only a truthyfetchAllis stripped; an explicitfalseis already bounded and is preserved.- Literal
limit/fetchAllfield names. The clamp acts on anycriteriaobject carrying those keys. QuickBooks exposes no searchable entity field namedlimitorfetchAll, so a bare{field: value}map cannot collide with them in practice; if a future field ever used those names, the clamp would rewrite it. - Claude-connector tool names are unverified. Intuit's connector page does not
publish its tool names; this policy assumes the same
verb_entityvocabulary as Intuit's open-source server. Capture the livetools/listthrough your gateway and confirm thesearch_*names before relying on this in production. - Match requires the
search_underscore verb. The tool matcher keys onsearch_at a word boundary, so it covers theverb_entitysnake_case vocabulary (Intuit official + assumed connector) and the LibreChat server. It does not match a camelCasesearchInvoices(no underscore) nor the archived hvkshetry server's 6 mega-tools (transaction,report, … carry the read verb in anoperationargument, with nosearch_in the name). Those shapes need a separate argument-level policy — confirm your server's tool names with the dump-input technique. The boundary anchor also means a name that merely embeds the substring mid-token (e.g.research_*) is correctly not matched. - No identity gating. All callers get the same clamp. This is a proportionality control, not an access-control one; combine with a role policy for who-may-read decisions.
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.cap_bulk_export
# Transform-only ingress policy: it never denies, it only clamps the bulk-read
# parameters on QuickBooks `search_*` tools. Every other tool passes through.
default allow := true
# Maximum rows a single search_* call may request. A search that legitimately
# needs more than this should page explicitly (leaving an audit trail) rather than
# pull the whole ledger / customer list into agent context at once. Tune for your
# environment.
limit_cap := 50
# --- Tool matching --------------------------------------------------------------
# Any QuickBooks search_* tool, regardless of the server-name prefix the gateway
# prepends. Tool names are `<server-prefix>search_<entity>` (e.g.
# `quickbooks-search_invoices`), so match `search_` at the start of the name or
# immediately after a non-letter separator. Anchoring on a non-letter boundary
# avoids matching a word that merely embeds "search_" mid-token (e.g. `research_*`).
is_search_tool if {
regex.match(`(^|[^a-z])search_`, lower(input.resource.name))
}
# --- Transform ------------------------------------------------------------------
# Rewrite the arguments of a search_* call, but only when clamping actually changes
# them. When nothing needs clamping the rewritten args equal the originals and this
# rule is undefined, so the aggregator skips this policy for that request.
transform := {"transformed_payload": rewritten} if {
is_search_tool
rewritten := rewrite_args(input.payload.args)
rewritten != input.payload.args
}
# Rewrite the args in two independent passes: first clamp `limit`/`fetchAll`
# *inside* `criteria`, then clamp a top-level `limit` sibling. The LibreChat
# server carries `limit` alongside `criteria` (not inside it), so a criteria-only
# clamp would miss its bulk lever entirely. Both passes are total, so rewrite_args
# is always defined for a search_* call; the caller only emits a transform when the
# result actually differs from the original args.
rewrite_args(args) := clamp_top_limit(clamp_criteria_in_args(args))
# Replace `criteria` with its clamped form when `criteria` is a clampable
# object/array; otherwise return args untouched. `criteria` is removed before the
# union so the clamped value replaces it wholesale — object.union deep-merges,
# which would otherwise re-introduce the original sub-keys (e.g. a stripped
# fetchAll).
clamp_criteria_in_args(args) := object.union(object.remove(args, {"criteria"}), {"criteria": clamp_criteria(crit)}) if {
crit := object.get(args, "criteria", null)
is_clampable(crit)
}
clamp_criteria_in_args(args) := args if {
not is_clampable(object.get(args, "criteria", null))
}
is_clampable(crit) if is_object(crit)
is_clampable(crit) if is_array(crit)
# Clamp the top-level bulk-read levers that sit as siblings of `criteria` — the
# LibreChat search shape hoists paging params to the top level. Mirrors the
# per-object clamp so the top level has no weaker rule than `criteria`:
# 1. truthy top-level fetchAll -> drop it and pin the top-level limit to the cap
# (fetchAll is the whole-ledger lever; leaving it at the top level was a
# bypass on servers that honour a top-level fetchAll).
# 2. no fetchAll, top-level limit a number above the cap -> lower it to the cap.
# 3. otherwise -> unchanged (small / non-numeric top-level limit preserved).
clamp_top_limit(args) := object.union(object.remove(args, {"fetchAll"}), {"limit": limit_cap}) if {
has_fetch_all(args)
}
clamp_top_limit(args) := object.union(args, {"limit": limit_cap}) if {
not has_fetch_all(args)
limit_exceeds(args)
}
clamp_top_limit(args) := args if {
not has_fetch_all(args)
not limit_exceeds(args)
}
# `criteria` shapes handled:
# - advanced object {filters, asc, desc, limit, offset, count, fetchAll}
# - array of clauses (LibreChat) [{field, value, operator, limit, ...}, ...]
# - bare field map {DisplayName: "Acme"} -> no limit/fetchAll, returned as-is
clamp_criteria(crit) := clamp_object(crit) if {
is_object(crit)
}
clamp_criteria(crit) := [clamp_element(e) | some e in crit] if {
is_array(crit)
}
# Array entries are usually clause objects; tolerate anything else by leaving
# non-objects untouched so the array length is never changed.
clamp_element(e) := clamp_object(e) if {
is_object(e)
}
clamp_element(e) := e if {
not is_object(e)
}
# Clamp one criteria object. Three mutually exclusive cases:
# 1. truthy fetchAll present -> drop fetchAll and pin limit to the cap.
# 2. no fetchAll, but limit is a number above the cap -> lower it to the cap.
# 3. otherwise -> unchanged (small explicit limits are preserved).
clamp_object(o) := out if {
has_fetch_all(o)
out := object.union(object.remove(o, {"fetchAll"}), {"limit": limit_cap})
}
clamp_object(o) := object.union(o, {"limit": limit_cap}) if {
not has_fetch_all(o)
limit_exceeds(o)
}
clamp_object(o) := o if {
not has_fetch_all(o)
not limit_exceeds(o)
}
has_fetch_all(o) if {
object.get(o, "fetchAll", false) == true
}
limit_exceeds(o) if {
l := object.get(o, "limit", 0)
is_number(l)
l > limit_cap
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)
Related policies
Airtable: Redact PII in Record Reads
Scans the responses of the Airtable record-read tools — the calls that return row fields values — and rewrites high-confidence PII shapes to a fixed…
Asana: Redact PII in Task & Comment Reads
On the Asana MCP read path, this transform scans the free-text business fields that ride back in task, comment/story, and status-update responses — notes,…
BigQuery: Redact PII in Query Results
Scans the content returned by BigQuery's result-returning tools and rewrites high-confidence PII shapes to fixed, non-recoverable redaction tokens before the…
Block Agent Email to External Recipients
Blocks agent-initiated Microsoft 365 email sends when any recipient address falls outside a corporate-domain allowlist.
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