---
name: Human-Only Stripe Dispute Submission
tags:
  - stripe
  - require-human-approval
  - disputes
  - separation-of-duties
  - transform
  - ingress
  - sox
publishedAt: 2026-07-12
description: |
  # stripe / require-human-approval-dispute-submit

  **Direction:** ingress (`tool_pre_invoke`)
  **Default:** allow (transform-only — never denies)
  **Package:** `stripe.ingress.require_human_approval_dispute_submit`

  ## What it does

  Strips the irreversible `submit` flag from Stripe `*update_dispute` tool
  calls. Filing dispute evidence with the card network is **one-shot**: once
  `update_dispute` is called with `submit: true`, the evidence is submitted and
  cannot be amended or resubmitted. This policy keeps that consummating step
  off the agent path while leaving the drafting step fully functional — the
  initiate-vs-approve separation.

  Concretely: when an `*update_dispute` call carries a `submit` key in its
  arguments, the policy emits a transform that removes the key and passes the
  rest of the call (the `dispute` ID and the `evidence` draft —
  `cancellation_policy_disclosure`, `duplicate_charge_explanation`,
  `uncategorized_text`) through unchanged. The agent's evidence draft lands on
  the dispute; a human then reviews and submits it in the Stripe dashboard,
  where the submitting person is recorded. Calls without a `submit` key, and
  every other tool, pass through untouched with no transform.

  There is **no identity-group exemption**: evidence filing is human-gated for
  every caller, including privileged users, because the point of the gate is
  that the irreversible decision is taken by a person, not by whichever
  service identity the agent happens to run as.

  ## Compliance alignment

  - **SOC 2 CC6.3** — supports role-based access with separation of duties on
    the agent path: the actor that drafts dispute evidence cannot also be the
    actor that files it with the card network over MCP.
  - **SOX SoD (COSO Principle 10)** — supports the initiate-vs-approve
    separation on a financially consequential transaction: the agent
    initiates (drafts evidence), a human approves (submits in the dashboard).
  - **SOX 13a-15(f)(2)(ii)** — supports transaction authorization by keeping
    the authorizing action (irreversible submission of dispute evidence, which
    determines whether disputed funds are recovered) off the automated actor.
  - **SOX / PCAOB AI human-in-the-loop** — supports a draft-only agent posture
    for dispute consummation, consistent with human-oversight expectations for
    AI acting on financial records.

  ## Tool name matching

  The DTwo gateway prefixes tool names with the configured MCP server name as
  `<server-name>-<tool-name>` (e.g. `stripe-mcp-update_dispute`), and that
  prefix is not standardized across deployments. The policy therefore matches
  case-insensitively on `lower(input.resource.name)` by **suffix**
  (`endswith`), against one verified name:

  - `*update_dispute` — the legacy per-resource tool from `@stripe/mcp`
    v0.8.x / the Claude Desktop `.dxt` manifest (verified from the `stripe/ai`
    repo history). Its argument shape is
    `{ dispute, evidence?: { cancellation_policy_disclosure?,
    duplicate_charge_explanation?, uncategorized_text? }, submit?: boolean }`.

  Suffix matching is deliberately broad for a control gate: a hypothetical
  `bulk_update_dispute`, or an aggregator slug like `STRIPE_UPDATE_DISPUTE`
  (unverified — Composio-style naming), also ends with the suffix and is
  also transformed, which is the intended fail-safe direction. Verify the
  exact name your gateway sends with the dump-input debug technique before
  relying on this in production.

  ## Argument shape

  The policy reads `input.payload.args` via `object.get` at every step, so a
  missing `payload`, missing `args`, or missing `submit` key simply means the
  transform never fires and the call passes through — there is nothing to
  strip, and a transform-only policy has nothing to deny.

  The transform fires on **presence of the `submit` key, not on
  `submit == true`**. Stripe's form-encoded API treats string encodings like
  `"true"` as truthy, so matching only the boolean would leave an encoding
  bypass; and removing an explicit `submit: false` is a semantic no-op
  (`false` is Stripe's default). Stripping on presence closes the bypass
  without changing behavior for compliant callers.

  When it fires, the transform emits `transformed_payload` =
  `object.remove(args, ["submit"])` — the original arguments minus the flag,
  with the `dispute` ID and the entire `evidence` object preserved verbatim.

  ## Examples

  ### Allowed untouched — drafting evidence without submitting

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "stripe-mcp-update_dispute", "type": "tool" },
      "payload": {
        "name": "stripe-mcp-update_dispute",
        "args": {
          "dispute": "dp_1OABCD2eZvKYlo2C",
          "evidence": { "duplicate_charge_explanation": "Two distinct orders; receipts attached." }
        }
      }
    }
  }
  ```

  `allow = true`, no transform — the draft reaches Stripe as sent.

  ### Transformed — submit flag stripped

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "stripe-mcp-update_dispute", "type": "tool" },
      "payload": {
        "name": "stripe-mcp-update_dispute",
        "args": {
          "dispute": "dp_1OABCD2eZvKYlo2C",
          "evidence": { "uncategorized_text": "Customer signed the cancellation policy on 2026-05-02." },
          "submit": true
        }
      }
    }
  }
  ```

  `allow = true`, `transform.transformed_payload = { "dispute": "dp_1OABCD2eZvKYlo2C", "evidence": { ... } }`
  — the evidence draft is saved, the filing is not; a human submits from the
  Stripe dashboard.

  ## Composition

  This policy is single-purpose — it removes only the dispute-submission
  consummation step. Pair it with:

  - An **API-write allowlist / escape-hatch policy on `*stripe_api_write`** —
    the current official Stripe MCP server routes *all* writes through the
    `stripe_api_write` meta-tool, including dispute updates. A dispute
    submission made through that tool never matches `*update_dispute`, so
    this transform cannot see it (see Known limitations). Denying or
    endpoint-allowlisting `stripe_api_write` is what closes that route.
  - A **default-deny-unknown-tools allowlist (PF-28)** — catches renamed or
    aggregator-specific dispute tools this suffix match cannot anticipate.
  - A **refund-cap policy on `*create_refund`** — the other irreversible
    money-out surface in the legacy Stripe tool set.
  - **Stripe Restricted API Key (RAK) scoping** — layer, don't substitute:
    a key without dispute-write permission is the control that also covers
    non-MCP access.

  ## Known limitations

  - **The `stripe_api_write` escape hatch is invisible here.** On the current
    official server (mcp.stripe.com and the v0.9+ `@stripe/mcp` proxy), all
    writes — including `POST /v1/disputes/{id}` with `submit: true` — go
    through the `stripe_api_write` meta-tool, whose name does not end in
    `update_dispute`. This policy's verified target is the legacy per-resource
    `update_dispute` tool (v0.8.x installs, the `.dxt` manifest, and
    `@stripe/agent-toolkit` embeddings). Deployments on the current server
    must pair this with an allowlist/deny on `*stripe_api_write` or the gate
    is decorative.
  - **Renamed tools slip past.** A `noun_verb` server (e.g. a community
    server's `dispute_update`) or a trailing-token variant
    (`update_disputes`, `update_dispute_v2`) does not end with the exact
    suffix and passes through untouched. Rely on a PF-28 allowlist to fail
    unknown names closed.
  - **Suffix breadth (prefix side) is intentional.** Any tool name *ending*
    in `update_dispute` is transformed, including hypothetical bulk variants.
    For a human-approval gate this over-inclusion is the safe direction; if a
    legitimate tool is caught, escalate to your gateway admin.
  - **`trim_space` normalizes only standard whitespace.** A tool name ending
    in a zero-width or format character (U+200B, U+FEFF, U+2060) after
    `update_dispute` does not match the suffix and passes through untouched.
    Any strip list can itself be evaded, so this is not chased in the Rego;
    the PF-28 allowlist is the backstop.
  - **Presence-based stripping also removes `submit: false`.** Semantically a
    no-op (false is Stripe's default), but the call Stripe receives differs
    byte-for-byte from the call the agent sent. This is the cost of closing
    the truthy-string-encoding bypass.
  - **Treasury preview tools are out of scope.** Stripe's agentic-finance
    preview tool names are not published; nothing here matches them, and no
    policy in this store should guess at them.
  - **MCP path only.** The Stripe dashboard, direct API keys, and webhooks
    are outside the gateway's reach — which is exactly why the human submits
    from the dashboard. Pair with RAK scoping for the non-MCP surface.
  - **No identity-based exemptions — by design.** There is no break-glass
    group, so there are no placeholder group names to replace at import time.
    Submission over MCP is stripped for everyone; humans submit in the
    dashboard where their identity is recorded.

  > **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:
  - stripe
industries: []
bundles:
  - sox
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```rego
package stripe.ingress.require_human_approval_dispute_submit

# Transform-only policy: never denies. When an *update_dispute call carries
# the irreversible `submit` flag, the transform strips it so the evidence
# draft still lands on the dispute but the one-shot filing with the card
# network stays human-actuated (initiate-vs-approve separation).
default allow := true

# Tool arguments; a missing payload or args resolves to {} so the transform
# condition simply never fires on malformed input (nothing to strip).
args := object.get(object.get(input, "payload", {}), "args", {})

# Ingress pre-invoke gate. `action` is the PARC field; `kind` is its populated
# legacy alias (same value). Accept EITHER: a build that populates only `kind`
# (or a PARC revision that drops `action`) would otherwise fail the match and
# pass a submit:true call straight through — a fail-open submission of the
# one-shot filing. Restricting to pre-invoke also keeps the transform off
# egress hooks, whose payload carries `text`, not `args`.
is_pre_invoke if object.get(input, "action", "") == "tool_pre_invoke"

is_pre_invoke if object.get(input, "kind", "") == "tool_pre_invoke"

# Tool name, lowercased and whitespace-trimmed; a missing resource/name
# resolves to "" (matches nothing). trim_space closes a suffix-match evasion:
# a name with a trailing space/tab/newline would otherwise fail endswith and
# carry its submit flag through untouched.
tool_name := trim_space(lower(object.get(object.get(input, "resource", {}), "name", "")))

# The verified legacy per-resource tool `update_dispute`, matched by suffix
# for portability across the gateway's `<server-name>-<tool-name>` prefixing.
# Suffix breadth is intentional: any name ending in update_dispute (bulk or
# aggregator variants) is also a dispute write and gets the same treatment.
is_update_dispute_tool if {
    endswith(tool_name, "update_dispute")
}

# Fire on presence of the `submit` key, not on `submit == true`: Stripe's
# form-encoded API treats string encodings like "true" as truthy, and
# removing an explicit `submit: false` is a semantic no-op (false is the
# API default), so presence-matching closes the encoding bypass without
# changing behavior for compliant callers.
has_submit_key if {
    "submit" in object.keys(args)
}

# Strip the submit flag; everything else (dispute ID, evidence draft) passes
# through verbatim. The human consummates submission in the Stripe dashboard.
transform := {"transformed_payload": object.remove(args, ["submit"])} if {
    is_pre_invoke
    is_update_dispute_tool
    has_submit_key
}
```
