dtwo Policy Store

Freeze Payroll Writes in Gusto

Freezes every write and delete operation on a Gusto pipeline.

Direction
ingress
Rego package
gusto.ingress.freeze_payroll_writes
App
gusto
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:d03af4d3df6423483d9c51dcf2dccde53973122ad2d2fda6e8ffc2b4a64fecbe

gustofreeze-destructive-opsingress

What this policy does

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 (HRISCreateEmployeeHRIS_CreateEmployee) then a lower/digit→upper split (createEmployeecreate_employee, v2CreateEmployeev2_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

{
  "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

{
  "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)

{
  "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.

Policy source (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)
}

Canonical source: policy.md on GitHub · raw · raw on this site (.md)

Used in these guides