--- name: Freeze Payroll Writes in Gusto tags: - gusto - freeze-destructive-ops - ingress publishedAt: 2026-07-12 description: | # gusto / freeze-payroll-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny write-shaped tools, allow reads **Package:** `gusto.ingress.freeze_payroll_writes` ## What it does Freezes every write and delete operation on a Gusto pipeline. Any tool call whose name looks write-shaped is denied at ingress, before it reaches the upstream MCP server, so a payroll, compensation, bank-account, or employee mutation initiated by an agent never executes. Gusto is unusual among tier-1 connectors: the **official Gusto MCP server** (`mcp.api.gusto.com`) is strictly **read-only** — all 36 of its tools are reads, and the docs state verbatim that "All tools provided by the Gusto MCP server are read-only." Against that server this policy is a **no-op**: no official tool name is write-shaped, so every call passes through untouched. Its value is the moment a tenant wires Gusto through a third-party **aggregator**. StackOne's Gusto connector exposes 72 actions — ~33 reads plus **15 create / 13 update / 9 delete** actions covering employees, contractors, compensations, benefits, **bank accounts**, pay schedules, time-off, and **payroll deletion**. Those are money-movement-adjacent and effectively irreversible once a pay run processes. From the instant that server is attached, this policy blocks all of them — no re-authoring required — because it matches on write-verb shape, not on a fixed official tool list. The policy normalizes camelCase word boundaries to an underscore, then matches case-insensitively: - **create / update / delete** appearing as a delimited verb token anywhere in the (server-prefixed) tool name, in the underscore, hyphen, **or dot** dialect **and in camelCase** (which is normalized to underscores first), and whether the verb **leads** the action id (`create_employee`, `hris_create_employee`, `createEmployee`) or **trails** it (`hris_employee_create`, `employeeCreate`). This covers `create_*`/`create-*`/`create.*`/`createX`, `update_*`/`update-*`/`update.*`/`updateX`, and `delete_*`/`delete-*`/`delete.*`/`deleteX`. - any name containing **`submit`** (`*submit*`) — payroll submission and re-submission are money-movement writes. `default allow := false`. A call is allowed only when it presents a **non-empty, non-write-shaped** tool name, so a call whose name is missing entirely is denied rather than passed. There is no group exemption: agent-initiated payroll mutations are out of policy for everyone, and the deny reason points the caller to the Gusto UI. ## Compliance alignment - **SOX §802 / 18 U.S.C. §1519** — anti-destruction/alteration of records: an agent cannot delete payrolls or mutate payroll/compensation/bank-account records on the MCP path, supporting the record-preservation obligation over financial data in Gusto (coverage-matrix §2.5, PF-06). - **SOX Rule 13a-15(f)(3)** — safeguarding of assets: freezing payroll-submit and bank-account create/delete on the agent channel supports the safeguarding-of-assets control; this policy is the destructive-freeze half of that posture and composes with a money-movement cap (PF-09) once aggregator write tool-name strings are verified per tenant. - **SOC 2 PI1.5** — integrity of stored records: preventing agent-initiated creation, update, and deletion of payroll records supports the stored-record-integrity criterion (coverage-matrix §2.1, PF-06). ## Tool name matching Matches case-insensitively on `input.resource.name`. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `gusto-mcp-create_employee`), so the policy detects the write verb as a **delimited token** (`(^|[._-])(create|update|delete)([._-]|$)`) rather than anchoring on the start of the full name. camelCase / PascalCase names are first normalized in two passes — an acronym→word split (`HRISCreateEmployee` → `HRIS_CreateEmployee`) then a lower/digit→upper split (`createEmployee` → `create_employee`, `v2CreateEmployee` → `v2_create_employee`) — so the same delimited-token match covers camelCase, acronym-prefixed, and digit-prefixed dialects. That keeps it portable across the known Gusto naming dialects: - **Official** (`snake_case`, no vendor prefix on most tools): every tool is a `list_*` / `get_*` read — none match, so the policy is a verified no-op there. - **StackOne aggregator** (`hris_*` unified action IDs): the exact tool-name strings are **not published verbatim** and are **unverified**, but StackOne's documented naming follows `hris_*` action IDs. The verb-token match catches the write/delete subset of those actions (`hris_create_*`, `hris_update_*`, `hris_delete_*`, and any `hris_*_create`/`_update`/`_delete` suffix form) while leaving `hris_get_*`/`hris_list_*` reads alone. Verify the exact strings your tenant's aggregator emits with the dump-input debug technique and pin them explicitly if you want name-exact denies. - **Community** (`kebab-case`, e.g. `get-all-employees`): the read tools do not match; the hyphen dialect of the write verbs (`create-`/`update-`/`delete-`) does. - **camelCase / dot-namespaced** (e.g. a Workato/Scalekit-style aggregator emitting `createEmployee`, `employeeCreate`, `HRISCreateEmployee`, `v2CreateEmployee`, or `svc.delete.payroll`): the camelCase / PascalCase boundary is normalized to an underscore — including where an acronym (`HRIS`) or version digit (`v2`) sits immediately before the verb's capital — and `.` is treated as a delimiter, so these write verbs are caught while camelCase reads (`getEmployee`, `HRISGetEmployee`, `listCreatedReports`) are not. The `submit` match is a substring (`*submit*`) because no official Gusto read tool contains that string; on write-capable servers it catches `submit_payroll`, `payroll_submit`, and `resubmit_payroll`. ## Argument shape This policy is **name-only** — it never inspects `input.payload.args`, so no argument key, encoding, or nesting can route a write past it. Every field it does read (`input.resource.name`) is fetched with `object.get` chains that resolve a missing resource or name to `""`, which fails closed to deny. ## Examples ### Allowed — official read tool, untouched ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gusto-mcp-list_company_payrolls", "type": "tool" }, "payload": { "name": "gusto-mcp-list_company_payrolls", "args": { "company_uuid": "abc" } } } } ``` `allow = true`, no reason. (No write verb, no `submit`.) ### Denied — aggregator payroll deletion ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stackone-hris_delete_payroll", "type": "tool" }, "payload": { "name": "stackone-hris_delete_payroll", "args": { "id": "pay_123" } } } } ``` `allow = false`, reason says payroll mutations are frozen and to use the Gusto UI. ### Denied — bank-account create (hyphen dialect) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gusto-mcp-create-bank_account", "type": "tool" }, "payload": { "name": "gusto-mcp-create-bank_account", "args": {} } } } ``` `allow = false`. ## Composition Single-purpose: this policy only freezes writes/deletes by tool-name shape. Useful companions on a Gusto pipeline: - **PF-09 money-movement cap** — a value-aware policy that denies/caps payroll runs and payouts above a ceiling or outside a finance IdP group. This freeze is the coarse destructive-ops half; the cap is the fine-grained transaction-authorization half. Compose them once the aggregator's write tool-name strings are verified per tenant so the cap can key on exact names and amount arguments. - **Egress PII/financial redaction** on Gusto read tools (salaries, home addresses, bank/routing numbers surfaced by community/aggregator servers). - **Ingress compensation/payroll read gating** by IdP group for need-to-know reads. ## Known limitations - **Aggregator tool names are unverified.** StackOne's exact MCP tool-name strings are not published verbatim; matching relies on the documented `hris_*` action-ID shape plus the create/update/delete verb tokens. If your aggregator uses a different verb vocabulary, the names slip past — verify with the dump-input technique and pin them. - **Verb vocabulary is scoped to create/update/delete/submit.** Other write-ish verbs (`void`, `cancel`, `approve`, `run`, `process`, `post`, `pay`, `remove`, `terminate`, `set`) are **not** matched. If your server exposes destructive actions under those verbs, add them to `write_verb_pattern` / the substring checks. This is deliberate: broadening the verb set raises false-positive risk against reads, so it is left as a per-tenant tuning step. (`submit` is caught, so `resubmit_payroll` is denied.) - **Delimiters and casing covered: `_`, `-`, `.`, camelCase, PascalCase, acronym- and digit-prefixed camelCase.** camelCase names are normalized to underscores before matching (a two-pass split that also breaks `acronym→word` and `digit→word` boundaries) and `.` counts as a delimiter, so `createEmployee`, `employeeCreate`, `HRISCreateEmployee`, `v2CreateEmployee`, and `svc.delete.payroll` are all denied. Residual slips remain for names where the verb is **fused with no word boundary at all** (e.g. `createbankaccount` — no delimiter and no case change after `create`) or where the tool name is **malformed** with an embedded/trailing newline (Go's `$` matches end-of-text only, so a trailing-position verb followed by `\n` escapes the `([._-]|$)` right anchor). Neither shape appears in any known Gusto server; if your aggregator produces them, pin exact tool names per tenant. - **Server-prefix collisions.** The verb-token match keys on delimiters, so an MCP server whose configured name itself contains `create`/`update`/`delete`/`submit` as a delimited token (e.g. a server literally named `gusto-update-mcp`) would match every call. Name your Gusto server without those verb tokens, or pin exact tool names. - **No identity exemption.** All callers are frozen equally. If you need a break-glass path for a finance/HR admin, add an `allow if` branch gated on `input.subject.claims.groups` (a placeholder group like `hr-payroll-admins`) — read it fail-closed with `object.get` chains so a missing claim never exempts. - **Name-only.** The policy does not inspect arguments, so it cannot distinguish a benign update from a destructive one within the same tool. That is intentional for a freeze — pair with PF-09 for value-aware allow/cap 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: - gusto industries: [] bundles: [] experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```rego package gusto.ingress.freeze_payroll_writes # Deny-by-default: only the explicit allow rule below permits the request. A # call whose name is missing entirely resolves to "" and never satisfies the # allow rule, so it is denied rather than passed. default allow := false # Raw (original-case) tool name; missing resource/name resolves to "" (denied). raw_tool_name := object.get(object.get(input, "resource", {}), "name", "") # Normalize camelCase / PascalCase word boundaries to an underscore BEFORE # lowercasing, so a camelCase dialect (createEmployee, employeeCreate, # updateCompensation) reduces to the same delimited-token form as the snake/kebab # dialects (create_employee, employee_create, update_compensation). Two passes are # required so that an ACRONYM or DIGIT sitting immediately before the verb's # capital letter still produces a boundary — a single `[a-z]->[A-Z]` pass leaves # the verb glued to the acronym/digit (HRISCreateEmployee -> hriscreateemployee, # v2CreateEmployee -> v2createemployee) and the write tool slips past the match: # 1. acronym -> word boundary (HRISCreateEmployee -> HRIS_CreateEmployee) # 2. lower/digit -> upper (HRIS_CreateEmployee -> HRIS_Create_Employee, # v2CreateEmployee -> v2_Create_Employee) # Reads with leading acronyms (HRISGetEmployee -> hris_get_employee) are split the # same way and still carry no write verb, so this adds no false positives. _split_acronym := regex.replace(raw_tool_name, `([A-Z]+)([A-Z][a-z])`, "${1}_${2}") tool_name := lower(regex.replace(_split_acronym, `([a-z0-9])([A-Z])`, "${1}_${2}")) # Write/destructive verb tokens. Matches create/update/delete as a DELIMITED # token anywhere in the (server-prefixed, camelCase-normalized) tool name — the # underscore, hyphen, or dot dialect, and whether the verb leads the action id # (create_employee, hris_create_employee) or trails it (hris_employee_create). # Anchored on start-of-string or a `.`/`-`/`_` delimiter on the left and a # delimiter or end-of-string on the right, so it will not match substrings like # "created" or "updated" (the trailing letter is not a delimiter). No official # Gusto read tool (all list_*/get_*) matches this. write_verb_pattern := `(^|[._-])(create|update|delete)([._-]|$)` is_write_shaped if { regex.match(write_verb_pattern, tool_name) } # Submit-shaped calls (payroll submission / money movement). Substring match # per the `*submit*` spec — catches submit_payroll, payroll_submit, and # resubmit_payroll. No official Gusto read tool contains "submit". is_write_shaped if { contains(tool_name, "submit") } # Allow only a present, non-write-shaped tool name. An empty/missing name # (tool_name == "") fails this and falls through to the default deny. allow if { tool_name != "" not is_write_shaped } # Denied because the call is write-shaped (create/update/delete/submit). reasons contains "Agent-initiated payroll writes and deletions are frozen by policy on this Gusto pipeline. Create, update, delete, and payroll-submit actions — including any wired through an aggregator such as StackOne — are blocked because they are money-movement-adjacent and effectively irreversible once a pay run processes. Make the change as a human in the Gusto UI. Contact your InfoSec team if this block is a false positive." if { is_write_shaped } # Denied because the call arrived without a recognizable tool name (fail-closed). reasons contains "This Gusto call was denied because it arrived without a recognizable tool name. Retry with a valid Gusto tool, or make the change as a human in the Gusto UI. Contact your InfoSec team if this block is a false positive." if { tool_name == "" } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ```