--- name: Gusto Cap Roster Export tags: - gusto - cap-bulk-export - pii - data-minimisation - ingress - gdpr-ccpa - soc2 publishedAt: 2026-07-12 description: | # gusto / cap-roster-export **Direction:** ingress (`tool_pre_invoke`) **Default:** allow (transform-only — never denies) **Package:** `gusto.ingress.cap_roster_export` ## What it does Throttles full-roster exfiltration on Gusto's two broad outbound list tools — `list_company_employees` and `list_company_contractors` — by rewriting their arguments before the call reaches the Gusto MCP server: - **Page-size clamp** — the docs-confirmed pagination arg `per` is clamped to a maximum of **25**. Any numeric `per` above 25 is rewritten to 25; a non-positive `per` (`0` or negative, which some servers treat as "unbounded") and a present-but-non-numeric `per` are also normalised to 25. - **Custom-field strip** — the docs-confirmed `include=custom_fields` expansion is removed from the `include` argument (string or array form), so the roster page comes back without the custom PII fields attached. Both rewrites apply only to callers whose IdP claims lack the placeholder group `hr-payroll-admins`. HR-payroll admins retain full pagination and field expansion. This is a **transform, not a deny** (`default allow := true`): it rewrites args with safe defaults rather than blocking, so ordinary single-employee lookups and small roster reads keep working while bulk pulls are curtailed. A call that already requests `per` ≤ 25 and does not ask for `custom_fields` passes through untouched. Every possibly-missing field is read with `object.get`, so malformed or minimal calls pass through rather than erroring. ## Why this shape is the risk The Gusto landscape note identifies exactly this pattern as the exfiltration channel: a broad list tool with a high `per` plus `include=custom_fields` pulls the entire employee roster — names, home addresses, custom PII fields — in a few calls, which can then leak through any other connector in the same session. Because the official Gusto server is read-only, bulk PII egress (not destructive writes) is the primary DTwo exposure, and clamping the list surface is the cheapest structural control over it. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement/removal of information by bounding how many employee/contractor records — and which fields — a single agent list call can move out of Gusto. - **GDPR Art. 5(1)(c)** — data minimisation on the agent channel: the page size and field expansion are minimised *before* the call reaches Gusto, so the agent retrieves the roster slice sized to the task rather than the whole company plus its custom fields. - **CCPA 11 CCR §7002** — supports proportionality: retrieval of employee PII (including custom fields) stays proportionate to the disclosed purpose rather than defaulting to full-roster export. ## Why ingress The over-broad request itself is the problem: once Gusto has returned a 200-row roster with custom fields, an egress policy can only mask fields — the volume has already been fetched, logged, and counted against rate limits. Rewriting `per` and `include` at ingress enforces minimisation before the query executes, which is the only place the record *count* and the *field expansion* can be controlled. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `gusto-mcp-list_company_employees`), so matching is by **case-insensitive suffix** to stay portable across deployments. Covered names (verified verbatim from the official Gusto MCP docs): - `list_company_employees` - `list_company_contractors` Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape - `per` — the docs-confirmed pagination arg on Gusto list tools (alongside `page`). Read as a top-level numeric argument. If `per` is **omitted** the call passes through unchanged — Gusto's documented default page size is 25, already at the cap. If your server defaults to a larger page when `per` is absent, extend the policy to inject `per: 25` on absence. - `include` — the docs-confirmed field-selection arg; `include=custom_fields` is the docs-confirmed expansion this policy strips. The policy handles both the comma-separated **string** form (`"custom_fields"`, `"jobs,custom_fields"`) and an **array** form (`["custom_fields", "jobs"]`), removing only the `custom_fields` token (case-insensitive) and leaving any other requested expansions intact. - All other arguments (`page`, entity-ID filters, date ranges) are preserved unchanged by the rewrite. ## Examples ### Passed through unchanged ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gusto-mcp-list_company_employees", "type": "tool" }, "subject": { "claims": { "groups": ["engineering"] } }, "payload": { "name": "gusto-mcp-list_company_employees", "args": { "company_uuid": "co-1", "per": 25, "include": "jobs" } } } } ``` `allow = true`, no transform — `per` is already within the cap and no `custom_fields` expansion was requested. ### Transformed (non-admin bulk pull) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gusto-mcp-list_company_employees", "type": "tool" }, "subject": { "claims": { "groups": ["engineering"] } }, "payload": { "name": "gusto-mcp-list_company_employees", "args": { "company_uuid": "co-1", "per": 200, "include": "custom_fields" } } } } ``` `allow = true`, transform rewrites the args to `{ "company_uuid": "co-1", "per": 25, "include": "" }` — page size clamped and the custom-field expansion stripped. ### Exempt (HR-payroll admin) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gusto-mcp-list_company_employees", "type": "tool" }, "subject": { "claims": { "groups": ["hr-payroll-admins"] } }, "payload": { "name": "gusto-mcp-list_company_employees", "args": { "company_uuid": "co-1", "per": 500, "include": "custom_fields" } } } } ``` `allow = true`, no transform — HR-payroll admins keep full pagination and field expansion. ## Composition This policy bounds roster *volume* and strips the *custom-field* expansion on the two broad list tools. It is not a complete Gusto guard on its own. Useful companions: - An ingress **default-deny allowlist** (PF-28) that pins the audited official tool names per tenant — this is what covers community kebab-case list tools (`get-all-employees`) and any StackOne/aggregator write surfaces that this suffix-matching transform does not. - An ingress **deny of compensation/payroll reads** by IdP group, and an egress **home-address / financial-identifier redaction** policy, so the records that *do* come back through the capped page are also content-masked. ## Known limitations - **Group names are placeholders — replace `hr-payroll-admins` with your IdP's group name at import time.** The exemption reads `input.subject.claims.groups` (an array). A caller with no claims, no `groups` claim, or a `groups` claim that is not an array is treated as **not** an HR-payroll admin and is clamped (fail-closed for the exemption). If your IdP emits groups as a space-delimited string rather than an array, adapt the `caller_is_hr_payroll_admin` helper. - **Official names only; community and aggregator servers are not covered.** Suffix matching anchors on the official `list_company_*` names. The community `Savinda96/gusto-mcp` server uses kebab-case (`get-all-employees`) and StackOne uses unified `hris_*`-style names (unverified) — neither matches this policy. Cover those surfaces with a per-server default-deny allowlist (see Composition), not by widening this transform. - **`per` absence is not injected.** If `per` is omitted the call passes through; this relies on Gusto's documented default page size being 25. Verify your server's default and inject `per: 25` on absence if it is larger. - **Argument keys are matched exactly (`per`, `include`, lowercase).** The clamp reads the top-level key `per` and the strip reads `include` verbatim — the docs-confirmed Gusto arg names. A case-variant key (`Per`, `PER`) or an aggregator/community server that names its page-size arg differently (`limit`, `maxResults`, `pageSize`) is **not** clamped: an unrecognised key is treated as `per` being absent, so the call falls back to the per-absence behaviour above (server default page size). This is safe against the official server (which uses lowercase `per`) but means the same per-server default caveat applies — if you wire Gusto through an aggregator with a different pagination arg, extend `per_value` / `covered_tool_suffixes` to that server's key, or front it with a default-deny allowlist (see Composition). - **`include` argument shape is docs-confirmed but the exact serialization is not verbatim.** The policy handles comma-separated string and array forms of `include`, and (as of the red-team hardening) also splits each string element of the array form on comma, so a token smuggled inside a single comma-joined array element (`["jobs,custom_fields"]`) is still stripped. Two residuals remain: (a) **only comma is treated as a delimiter** — if your server accepts a non-comma separator (semicolon, space), `"jobs;custom_fields"` passes through unstripped; (b) a server that nests field selection under a different key or non-string structure passes through unstripped. Confirm the live shape from `tools/list` and extend `is_custom_fields_token` / the delimiter if needed. - **Per-request caps do not stop patient pagination.** An agent that walks the `page` cursor page by page at `per: 25` can still enumerate the full roster — it just takes more calls. Detecting cursor-driven crawls requires cross-request state the policy engine does not have; use gateway audit logs / alerting to spot high-frequency paging. > **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: - gdpr-ccpa - soc2 experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```rego package gusto.ingress.cap_roster_export # Transform-only policy — never denies, only clamps page size and strips the # custom-field expansion on Gusto's broad roster list tools. default allow := true # Maximum records a single non-admin list call may request. max_per := 25 # --- Tool matching ----------------------------------------------------------- # The gateway prefixes tool names with the configured MCP server name, so we # match case-insensitively by suffix to stay portable. These are the official # Gusto MCP list tools (verified verbatim from the Gusto MCP docs). Community # kebab-case (`get-all-employees`) and aggregator (`hris_*`) list tools are not # matched here — cover those with a default-deny allowlist. See Known limitations. covered_tool_suffixes := [ "list_company_employees", "list_company_contractors", ] is_covered_tool if { some suffix in covered_tool_suffixes endswith(lower(input.resource.name), suffix) } # --- Identity exemption ------------------------------------------------------ # HR-payroll admins retain full pagination and field expansion. Missing/empty # claims fail closed for the exemption (no group -> not exempt -> clamped). # `hr-payroll-admins` is a placeholder — replace with your IdP group at import. claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) caller_is_hr_payroll_admin if { some g in groups lower(g) == "hr-payroll-admins" } # --- Argument access (object.get everywhere — fields may be missing) --------- args := object.get(input.payload, "args", {}) # --- Page-size clamp --------------------------------------------------------- per_value := object.get(args, "per", null) # Clamp when a numeric `per` exceeds the cap. needs_per_clamp if { is_number(per_value) per_value > max_per } # Clamp when a numeric `per` is below 1 (0 or negative). Some servers treat a # non-positive `per` as "unbounded" or fall back to a large default page, so # `per: 0` / `per: -1` would otherwise be a fail-open bypass of the cap. needs_per_clamp if { is_number(per_value) per_value < 1 } # Clamp when `per` is present but not a number (fail safe: replace an # unparseable value with the cap rather than letting the server default win). needs_per_clamp if { per_value != null not is_number(per_value) } default per_patch := {} per_patch := {"per": max_per} if needs_per_clamp # --- Custom-field strip ------------------------------------------------------ # `include=custom_fields` is the docs-confirmed expansion we remove. Handle both # the comma-separated string form and the array form; leave other tokens intact. raw_include := object.get(args, "include", null) is_custom_fields_token(tok) if { is_string(tok) lower(trim_space(tok)) == "custom_fields" } # String form contains custom_fields as one of its comma-separated tokens. include_has_custom_fields if { is_string(raw_include) some tok in split(raw_include, ",") is_custom_fields_token(tok) } # Array form contains a custom_fields entry. Each string element is also split # on comma before matching, so a caller cannot smuggle the token inside a single # comma-joined element (e.g. ["jobs,custom_fields"]) past the array branch. include_has_custom_fields if { is_array(raw_include) some elem in raw_include is_string(elem) some tok in split(elem, ",") is_custom_fields_token(tok) } # Rebuild the string include without the custom_fields token (order preserved, # empty tokens dropped). Result may be "" when custom_fields was the only token. stripped_include_string := concat(",", [trim_space(tok) | some tok in split(raw_include, ",") not is_custom_fields_token(tok) trim_space(tok) != "" ]) # Rebuild the array include without any custom_fields entries. Each string # element is split on comma so comma-joined elements are normalised into # individual tokens and any custom_fields token inside them is dropped; empty # tokens are removed. (Non-string elements are not expected in `include` and are # dropped — field selectors are strings.) stripped_include_array := [trim_space(tok) | some elem in raw_include is_string(elem) some tok in split(elem, ",") not is_custom_fields_token(tok) trim_space(tok) != "" ] default include_patch := {} include_patch := {"include": stripped_include_string} if { is_string(raw_include) include_has_custom_fields } include_patch := {"include": stripped_include_array} if { is_array(raw_include) include_has_custom_fields } # --- Transform --------------------------------------------------------------- # One combined transform: both the page-size clamp and the custom-field strip # can apply to the same call, so we union both patches into a single rewrite. any_change if needs_per_clamp any_change if include_has_custom_fields transform := {"transformed_payload": object.union(object.union(args, per_patch), include_patch)} if { input.action == "tool_pre_invoke" is_covered_tool not caller_is_hr_payroll_admin any_change } ```