--- name: Cap QuickBooks Bulk Search Exports tags: - quickbooks - cap-bulk-export - bulk-export - dlp - ingress - soc2 - pci-dss - gdpr-ccpa publishedAt: 2026-07-12 description: | # quickbooks / cap-bulk-export **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: - **`fetchAll` is stripped and the limit is pinned to the cap.** When `fetchAll` is truthy, it is removed from the criteria object and `limit` is set to the ceiling (default **50**). `fetchAll: true` overrides `limit` on the QBO API, so removing it and imposing a bounded page is what actually stops the whole-ledger pull. - **An oversized `limit` is lowered.** When no `fetchAll` is present but `limit` is a number above the cap, it is lowered to the cap. This applies both to a `limit` *inside* `criteria` (the official advanced-object shape) and to a top-level `limit` sibling of `criteria` (the LibreChat shape). - **A top-level `fetchAll` sibling of `criteria` is also stripped.** The LibreChat shape hoists paging params (`limit`) to the top level, so the same fetchAll clamp is applied there: a truthy top-level `fetchAll` is removed and the top-level `limit` is pinned to the cap, mirroring the in-`criteria` behavior. This closes a bypass where a whole-ledger `fetchAll: true` carried as a sibling of `criteria` (rather than inside it) would otherwise pass through unclamped. - **Smaller explicit limits are left untouched.** A `limit` of 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: 1. **Advanced object** (official server) — `{filters, asc, desc, limit, offset, count, fetchAll}`. The `fetchAll` and `limit` keys on this object are clamped as described above; `filters`, `asc`, `desc`, `offset`, and `count` are preserved untouched. 2. **Array of clauses** (LibreChat) — `criteria: [{field, value, operator, ...}]` with `limit` as a **top-level sibling** of `criteria`. The landscape note records LibreChat carrying `limit` alongside `criteria` rather than inside it, so the top-level `limit`/`fetchAll` clamp (see "What it does") is what actually bounds a LibreChat search. The per-element clamp (`criteria[].limit`, per-clause `fetchAll`) is applied defensively as well, so a `limit`/`fetchAll` carried inside a clause is also capped/stripped. Non-object array entries are left as-is (array length is never changed). 3. **Bare field map** — `{DisplayName: "Acme"}`. A plain `{field: value}` match carries no `limit` / `fetchAll` key, 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 `fetchAll` keeps 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`, oversized `limit`) 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 - `criteria` is read defensively via `object.get(input.payload.args, "criteria", null)`. A missing `criteria`, or one reshaped to a string/number, yields no clamp and the call passes through. - `fetchAll` is treated as set only when it equals `true`; `fetchAll: false` is already bounded and is left in place. - `limit` is clamped only when it is a **number** above the cap; a non-numeric `limit` (string, object) is left untouched — QBO would reject it upstream. ## Examples ### Clamped — `fetchAll` stripped, limit pinned to the cap ```jsonc { "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 ```jsonc { "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) ```jsonc { "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_customers` output 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` / `fetchAll` that is *present*; it does not *inject* a `limit` onto a search that carries neither. A `search_*` call with no `limit` and no `fetchAll` passes 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 inject `limit: cap` when a search carries no bounding knob. - **Only a boolean `fetchAll: true` is 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-boolean `fetchAll` upstream, so this is not a live bypass on them; if you front a server that coerces truthy non-booleans, harden `has_fetch_all` to cover those forms. - **`fetchAll: false` is left in place.** Only a truthy `fetchAll` is stripped; an explicit `false` is already bounded and is preserved. - **Literal `limit` / `fetchAll` field names.** The clamp acts on any `criteria` object carrying those keys. QuickBooks exposes no searchable entity field named `limit` or `fetchAll`, 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_entity` vocabulary as Intuit's open-source server. Capture the live `tools/list` through your gateway and confirm the `search_*` names before relying on this in production. - **Match requires the `search_` underscore verb.** The tool matcher keys on `search_` at a word boundary, so it covers the `verb_entity` snake_case vocabulary (Intuit official + assumed connector) and the LibreChat server. It does **not** match a camelCase `searchInvoices` (no underscore) nor the archived hvkshetry server's 6 mega-tools (`transaction`, `report`, … carry the read verb in an `operation` argument, with no `search_` 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. direction: ingress apps: - quickbooks industries: [] bundles: - gdpr-ccpa - pci-dss - soc2 experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```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 `search_` (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 } ```