--- name: Lock Direct Journal-Entry Ledger Writes tags: - quickbooks - protect-closed-periods - ingress - sox publishedAt: 2026-07-12 description: | # quickbooks / protect-closed-periods-journal-entries **Direction:** ingress (`tool_pre_invoke`) **Default:** deny direct journal-entry writes unless the caller is in the `controller` group; allow everything else **Package:** `quickbooks.ingress.protect_closed_periods_journal_entries` ## What it does Denies the QuickBooks Online tools `create_journal_entry` and `update_journal_entry` at ingress for **every** caller except those whose IdP claims include the `controller` group. It also denies the archived `hvkshetry` mega-tools `transaction` and `reference` when their `operation` argument is `create` or `update` against a journal-entry `entity_type`. All other create/update tools, all reads, and all deletes pass through unchanged. A direct journal entry restates the general ledger without flowing through a normal transaction workflow (invoice, bill, payment, etc.). It is the single write external auditors scrutinize first and the classic vector for period-end manipulation — a manual debit/credit that moves numbers between accounts with no operational document behind it. Because the check runs at ingress, a blocked entry never reaches QuickBooks and never posts to the ledger, so an over-broad OAuth grant, an agent error, or a prompt-injection attempt cannot restate the books on behalf of a caller who is not a controller. Separating who may post a manual journal entry from everyone else is core segregation-of-duties (SoD) territory: the caller's IdP group, not the breadth of their QuickBooks role, decides whether a direct ledger restatement is permitted over MCP. ## Compliance alignment - **SOX §802 / 18 U.S.C. §1519 (anti-destruction/alteration of records)** — supports the prohibition on altering financial records by blocking agent-driven manual journal entries — the most direct ledger-alteration surface — for everyone outside the controller role. - **SOC 2 PI1.5 (integrity of stored records)** — supports processing integrity by keeping the agent channel from restating posted ledger balances through direct journal entries. - Reinforces the **segregation-of-duties** posture behind **SOX COSO Principle 10** and **SOC 2 CC6.3** (role-based access / least privilege / SoD): manual journal entries — the write most associated with period-end manipulation — are confined to a named controller role rather than every OAuth-connected user. ## Tool name matching The gateway prefixes tool names with the configured MCP server name (e.g. `qbo-mcp-create_journal_entry`), and that prefix is not standardized, so all matching is on the `_journal_entry` **entity suffix** of `lower(input.resource.name)`: - `*create_journal_entry` — Intuit official server (snake_case `verb_entity`) and the LibreChat raw-QBO build (same `verb_entity` tool names, PascalCase *arguments*). The Intuit-published Claude connector's tool names are **unverified** (not published on the connector page — see the landscape note); given Intuit's OSS server they are expected to use the same vocabulary, but confirm with the dump-input debug technique before relying on this in production. - `*update_journal_entry` — the matching update tool on the same servers. The entity suffix is matched together with the `create`/`update` verb, so `delete_journal_entry` (a destructive op — see **Composition**, PF-06) and the read tools `get_journal_entry` / `search_journal_entries` are deliberately **not** caught by this policy. For the archived `hvkshetry/quickbooks-mcp` server, which collapses the API into six mega-tools with an `operation` argument, the policy matches tool names ending in `transaction` or `reference` and denies only when the request is a journal-entry create/update (see Argument shape). ## Argument shape - **Direct tools** (`create_journal_entry` / `update_journal_entry`) — the policy keys only on the tool name; it does not inspect the entry's line items. Denying the whole tool is the intended behavior. - **Mega-tools** (`transaction` / `reference`) — the verb lives in an argument, not the tool name. The policy reads `operation` and `entity_type` from `input.payload.args`, matching case-insensitively: both the `operation` and `entity_type` values are normalized (stringified, lowercased, and with **every non-alphanumeric character** stripped — not merely spaces/underscores/hyphens). The `operation` is caught when its normalized form contains the `create` or `update` token, and the `entity_type` when its normalized form contains `journalentry`, so `JournalEntry`, `journal_entry`, `journal entry`, and even a punctuated `Journal.Entry` all match. Substring — not exact — matching is deliberate: it keeps a value smuggled as a wrapped or decorated shape (e.g. `entity_type: ["JournalEntry"]`, which normalizes to `journalentry`, or `operation: ["create"]`) from slipping past the gate, while non-journal-entry entities and non-write operations (delete/void/deactivate) contain neither token and pass through. The argument container is also read from `input.payload.arguments` and merged, so the gate works whichever key the gateway populates; each is coerced to `{}` when a gateway supplies a scalar, so a non-object container can neither fail the merge open nor evade the gate. A mega-tool call with no readable `operation`, a non-create/update `operation`, or a non-journal-entry `entity_type` is **not** a direct journal-entry restatement and passes through this policy (a `delete`/`void` operation is out of scope here — see PF-06 under Composition). ## Identity The controller gate reads `claims := object.get(input.subject, "claims", {})` through `object.get` chains, so a missing subject, missing `claims`, or empty `groups` deterministically **fails closed**: no `controller` group → the caller is not exempt → the journal-entry write is denied. Group membership is compared case-insensitively and requires the exact group name `controller`; near-misses such as `controllers` or `financial-controller` do not match. ## Examples ### Allowed — controller posts a journal entry ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "qbo-mcp-create_journal_entry", "type": "tool" }, "subject": { "claims": { "groups": ["controller"] } }, "payload": { "name": "qbo-mcp-create_journal_entry", "args": { "line_items": [{ "amount": 500, "detail_type": "JournalEntryLineDetail" }] } } } } ``` `allow = true`, no reason. ### Allowed — a non-journal-entry create tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "qbo-mcp-create_invoice", "type": "tool" }, "subject": { "claims": { "groups": ["ap"] } }, "payload": { "name": "qbo-mcp-create_invoice", "args": { "customer_ref": "12" } } } } ``` `allow = true` — creating an invoice is outside this policy's scope. ### Denied — non-controller creates a journal entry ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "qbo-mcp-create_journal_entry", "type": "tool" }, "subject": { "claims": { "groups": ["ap"] } }, "payload": { "name": "qbo-mcp-create_journal_entry", "args": { "line_items": [{ "amount": 999999 }] } } } } ``` `allow = false`, `reason = "Direct journal-entry writes to the QuickBooks general ledger are restricted to the controller role. ..."`. ### Denied — mega-tool journal-entry create by a non-controller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "qbo-mcp-transaction", "type": "tool" }, "subject": { "claims": { "groups": ["ap"] } }, "payload": { "name": "qbo-mcp-transaction", "args": { "operation": "create", "entity_type": "JournalEntry" } } } } ``` `allow = false`, same reason. ## Composition This policy is single-purpose (lock the manual journal-entry surface). Useful companions for QuickBooks: - **`freeze-destructive-ops`** (PF-06) — deny `delete_*` (QBO transaction deletes are hard deletes) and mega-tool `delete`/`void` operations, which this policy deliberately leaves alone. - **`role-gate-writes`** (PF-12) — read-only-by-default per app; gate the remaining `create_*`/`update_*` tools (invoice, bill, payment, etc.) that are out of scope here. - **`gate-money-movement`** (PF-09) — cap/deny `create_payment`, `create_bill_payment`, `create_transfer`, `create_refund_receipt`. - **`guard-vendor-banking`** (PF-10) — deny vendor bank/payment-detail mutations (anti-BEC). - **`default-deny-unknown-tools`** (PF-28) — for the Intuit connector, whose exact tool names are unverified, and for any future upstream tool drift. ## Known limitations - **No period-state check — this locks the journal-entry *tool*, not a *closed period*.** Editing posted transactions inside an accounting-closed period cannot be detected from tool arguments: no MCP implementation surveyed exposes a period-status field over the wire. This policy approximates closed-period protection by confining the highest-risk restatement surface — manual journal entries — to the controller role. A controller can still post a journal entry into a genuinely closed period, and edits to *other* posted transaction types (invoices, bills, payments) are out of scope here. Pair with in-QuickBooks period-close/closing-date-password locking for the true control, and with PF-12/PF-09 for the other write surfaces. - **Group name is a placeholder — replace `controller` with your IdP's group name at import time.** It is matched against `input.subject.claims.groups`; if your IdP emits roles under a different claim (e.g. `roles`, or a namespaced claim like `https://acme.com/roles`), adjust the `caller_in_controller_group` rule accordingly. Many IdPs (including Auth0) require explicit configuration before group information reaches the token; if the claim never arrives, every caller fails closed and is denied. - **Intuit connector tool names are unverified.** The Claude connector directory does not publish the connector's tool names; this policy assumes the Intuit OSS server's `verb_entity` vocabulary (`create_journal_entry` / `update_journal_entry`). Capture the live `tools/list` through the gateway and confirm before relying on it, and layer `default-deny-unknown-tools` (PF-28) to catch names that don't match the `_journal_entry` suffix. - **Mega-tool argument path assumption.** For the archived `hvkshetry` server the verb lives in an argument. The DTwo gateway surfaces tool arguments under `input.payload.args`; the upstream MCP wire shape nests them under `params.arguments`. This policy reads `payload.args` (merged with `payload.arguments` for portability). If your gateway populates a different key, capture it with the dump-input technique and extend the accessor. - **Mega-tool name match is broad by design.** `transaction`/`reference` are matched by suffix, but the deny fires only when `operation` is create/update **and** `entity_type` normalizes to `journalentry`, so a same-named tool on an unrelated server without those arguments is not affected. - **Mega-tool with no readable `operation` passes through (documented residual).** The deny fires only when a write-class `operation` token is present *and* the entity normalizes to a journal entry. A `transaction`/ `reference` call that carries `entity_type: JournalEntry` but no `operation` at all cannot be classified as a create/update and is allowed. On the hvkshetry server `operation` is a required discriminator, so such a call fails at the server rather than posting a ledger write; if a future implementation defaults a missing `operation` to a write, tighten this rule. - **Ingress write-gate only.** This policy does not restrict *reading* journal entries or other ledger data; use egress redaction policies for that. > **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.protect_closed_periods_journal_entries # Deny-by-default: direct journal-entry writes are blocked unless the caller is # explicitly in the controller group. Every non-journal-entry call is allowed by # the first `allow` rule below. default allow := false # IdP group permitted to post/alter manual journal entries. Placeholder — # remap to the tenant's IdP group name at import time. Compared case-insensitively. controller_groups := {"controller"} # Tool name, lowercased and defensively defaulted. The gateway prefixes the # configured server name, so we match on the _journal_entry entity suffix. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Coerce a value to an object, defaulting to {} when it is not one. Keeps # object.union below from type-erroring (which would leave `qb_args` undefined # and silently disable the gate — a fail-open) if a gateway populates an # argument container with a scalar instead of an object. as_object(x) := x if is_object(x) as_object(x) := {} if not is_object(x) # Argument container: merge the generic gateway key (`args`, which the DTwo # gateway schema documents and which the mega-tool verb lands in) with the # `arguments` key some gateways populate, `arguments` winning on conflict. Only # the mega-tool branch reads arguments; the direct tools key on name alone. qb_args := object.union( as_object(object.get(object.get(input, "payload", {}), "args", {})), as_object(object.get(object.get(input, "payload", {}), "arguments", {})), ) # --- Direct journal-entry write tools (Intuit official / LibreChat) --- # Match the _journal_entry entity suffix together with the create/update verb, # so delete_journal_entry (PF-06) and get_/search_ reads are NOT caught. is_direct_je_write if { endswith(tool_name, "create_journal_entry") } is_direct_je_write if { endswith(tool_name, "update_journal_entry") } # --- hvkshetry mega-tools (transaction / reference) --- # The verb lives in the `operation` argument; the entity in `entity_type`. is_mega_tool if { endswith(tool_name, "transaction") } is_mega_tool if { endswith(tool_name, "reference") } # operation argument: stringified, lowercased, and with every non-alphanumeric # character stripped. Stripping all punctuation/whitespace (not just spaces) so a # verb split or decorated with separators — "cre-ate", "cre ate", "create " — # still exposes the "create"/"update" token to the substring check below and # cannot slip past the gate. "" when absent. mega_operation := regex.replace(lower(sprintf("%v", [object.get(qb_args, "operation", "")])), `[^a-z0-9]`, "") # entity_type argument normalized: stringified, lowercased, and with every # non-alphanumeric character stripped (not merely spaces/underscores/hyphens) so # JournalEntry, journal_entry, "journal entry", a punctuated "Journal.Entry", and # a wrapped ["JournalEntry"] all normalize to a string containing "journalentry". mega_entity := regex.replace(lower(sprintf("%v", [object.get(qb_args, "entity_type", "")])), `[^a-z0-9]`, "") # Write-class operation. Substring match (not equality) so an operation smuggled # as a wrapped/decorated value still trips the gate: an array ["create"] renders # via sprintf as `["create"]`, an object {"op":"create"} as `{"op": "create"}`, # and "createdraft" all contain the "create" token. Non-write ops (delete, void, # deactivate, get, search) contain neither "create" nor "update" and pass through. mega_op_is_write if contains(mega_operation, "create") mega_op_is_write if contains(mega_operation, "update") # Journal-entry entity discriminator, likewise substring not equality so a wrapped # value — e.g. ["JournalEntry"] which normalizes to `["journalentry"]`, or an # object carrying the name — still matches. No other QBO entity name contains the # "journalentry" token, so this does not over-catch. mega_entity_is_je if contains(mega_entity, "journalentry") # A mega-tool journal-entry create/update = a direct ledger restatement. is_mega_je_write if { is_mega_tool mega_op_is_write mega_entity_is_je } # Any direct journal-entry restatement, via either server style. is_journal_entry_write if { is_direct_je_write } is_journal_entry_write if { is_mega_je_write } # True only when the caller carries the controller group claim. Reads claims via # object.get chains so a missing subject/claims/groups fails closed (not-a-controller). caller_in_controller_group if { claims := object.get(object.get(input, "subject", {}), "claims", {}) some group in object.get(claims, "groups", []) is_string(group) controller_groups[lower(group)] } # Allow anything that isn't a direct journal-entry write. allow if { not is_journal_entry_write } # Allow journal-entry writes only for controller callers. allow if { is_journal_entry_write caller_in_controller_group } # Deny a journal-entry write when the caller is not a controller. reasons contains "Direct journal-entry writes to the QuickBooks general ledger are restricted to the controller role. A manual journal entry restates the ledger without flowing through a normal transaction workflow, so it is gated for segregation of duties. Ask a controller to post the entry, or request the controller group from your finance systems administrator if you believe this is a false positive." if { is_journal_entry_write not caller_in_controller_group } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ```