dtwo Policy Store

Block Irreversible Docusign Void and Workflow Kills

Denies the irreversible destructive operations on the Docusign agent path:

Direction
ingress
Rego package
docusign.ingress.freeze_destructive_ops
App
docusign
Bundle
soc2
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:f6a8b10e38a3096a1f8dcd1b83a5d59f645b680015160eda7711e4d05effe476

docusignfreeze-destructive-opsingresssoc2

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny on match, allow otherwise Package: docusign.ingress.freeze_destructive_ops

What it does

Denies the irreversible destructive operations on the Docusign agent path:

  • updateEnvelope void attempts — any call whose body carries status: "voided", or that carries a voidedReason field at all (that field only exists on void requests, so its presence signals void intent even when the status key is missing or obfuscated). Voiding a sent envelope permanently invalidates a legally significant signed record. Non-void updateEnvelope calls (sending a draft, editing the email subject or blurb) pass through.
  • cancelWorkflowInstance — kills a running Maestro workflow instance. Denied outright.
  • pauseNewWorkflowInstances — suspends a workflow for the whole account. The blast radius is far beyond any single agent task. Denied outright.

Callers whose input.subject.claims.groups include contract-ops are exempt from all three blocks, so a designated remediation team can still void an erroneous envelope or stop a runaway workflow through the agent path. Everyone else — including callers with no identity claims at all — is denied (the exemption fails closed).

All other tool calls, on Docusign or any other server behind the same gateway, pass through unchanged.

Compliance alignment

  • SOC 2 PI1.5 — supports integrity of stored records: signed envelopes and running workflow state survive agent error or prompt injection. A voided envelope is a destroyed record of an executed agreement, and a killed or account-wide-paused workflow disrupts the state that keeps those records accurate; blocking agent-initiated voids and workflow kills keeps them intact on the MCP path.

Why ingress and not egress

Voiding an envelope and killing a workflow instance are irreversible server-side state changes. Once the call reaches Docusign the record is invalidated and counterparties may already have been notified. Egress inspection would only see the confirmation; ingress denial is the only placement that actually prevents the destruction.

Tool name matching

The policy matches tool names by suffix, case-insensitively, on input.resource.name:

  • *updateenvelope — the official server's updateEnvelope (Envelopes:update). Suffix matching deliberately does not catch updateEnvelopeRecipients, which is a different tool with a different risk profile (see Composition).
  • *cancelworkflowinstance — the official server's cancelWorkflowInstance.
  • *pausenewworkflowinstances — the official server's pauseNewWorkflowInstances.

The DTwo gateway prefixes tool names with the configured MCP server name (e.g. docusign-updateEnvelope for a server named docusign), and that prefix is not standardized, so the policy matches suffixes to stay portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production.

These names come from the official Docusign MCP Server catalog (camelCase, verified against the developer docs as of 2026-02). The community luthersystems/mcp-server-docusign server exposes no void or workflow tools, so it has no surface for this policy to guard.

Argument shape

updateEnvelope mirrors the eSignature Envelopes:update REST body: envelopeId plus body fields such as status ("voided" to void, "sent" to send a draft), voidedReason, emailSubject, emailBlurb. The policy anchors on two signals, both matched case-insensitively against the top-level keys of input.payload.args:

  1. a status key whose value (trimmed, lowercased) is voided;
  2. the presence of a voidedReason key with any value, including empty — a legitimate void requires a non-empty voidedReason, so a request carrying that key is a void attempt regardless of what the status field says.

The workflow tools are denied by name alone; their arguments are not inspected.

Examples

Allowed — sending a draft envelope

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "docusign-updateEnvelope", "type": "tool" },
    "payload": {
      "name": "docusign-updateEnvelope",
      "args": { "envelopeId": "abc-123", "status": "sent" }
    }
  }
}

allow = true, no reason — not a void attempt.

Denied — voiding a sent envelope

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "docusign-updateEnvelope", "type": "tool" },
    "payload": {
      "name": "docusign-updateEnvelope",
      "args": { "envelopeId": "abc-123", "status": "voided", "voidedReason": "sent to wrong signer" }
    },
    "subject": { "sub": "auth0|agent", "claims": { "groups": ["engineering"] } }
  }
}

allow = false, reason = "Voiding a Docusign envelope permanently invalidates a legally significant signed record...".

Allowed — contract-ops remediation

The same void request with "claims": { "groups": ["contract-ops"] } is allowed.

Composition

This policy is single-purpose. Useful companions on the Docusign path:

  • An ingress transform that rewrites createEnvelope status: "sent" to "created" so agents prepare drafts and humans dispatch them.
  • An ingress deny on updateEnvelopeRecipients / envelope creation when a signer email is outside your counterparty allowlist.
  • An ingress allowlist for triggerWorkflow pinned to approved workflow IDs.
  • An egress redaction policy on listRecipients / getEnvelope / getAgreementDetails for tab values and extracted terms.

Known limitations

  • Group names are placeholders — replace contract-ops with your IdP's group name at import time. The exemption reads input.subject.claims.groups and expects an array; a missing, empty, or string-valued groups claim means no exemption (fail closed).
  • Official-server argument shapes are inferred from the mapped REST endpoints, not an MCP schema dump — Docusign does not publish per-tool JSON schemas on a static page. Verify against a live tools/list or the dump-input technique before relying on exact field names.
  • Only top-level args keys are inspected for void signals. If your MCP server nests the envelope body under a wrapper key (none is documented for the official server), a void could evade the argument check — the two workflow tools are still blocked by name. Extend is_void_attempt if you observe a nested shape. (Covered by a test asserting the current pass-through behaviour.)
  • Void signals assume string-typed values. Signal 1 fires only when the status value equals voided after lowercasing and trimming, so a non-string value (e.g. status: ["voided"]) or a non-object args payload evades Signal 1. This is not an exploitable bypass: the Docusign eSignature REST body requires status to be a string, so a malformed shape is rejected server-side and no void occurs, and any well-formed void must carry a non-empty voidedReason that Signal 2 detects by key presence regardless of the status value's type. If a future server variant coerces such shapes into a real void, broaden Signal 1 accordingly. (Covered by a test asserting the current pass-through behaviour.)
  • Void is the only destructive updateEnvelope shape this policy detects. The mapped Envelopes:update endpoint also accepts a document-retention purgeState field (e.g. documents_and_metadata_queued) that irreversibly purges envelope documents — an irreversible destruction that also falls under the PF-06 anti-destruction scope this policy supports. That field is not listed in the verified Docusign landscape note's updateEnvelope argument shape, so this policy does not key on it (marking it unverified rather than inventing enforcement): a purgeState purge carrying no status: "voided" and no voidedReason currently passes through. If a live tools/list confirms updateEnvelope exposes purgeState, add a third void-detection signal that denies its presence. (Covered by a test asserting the current pass-through behaviour.)
  • Non-void updateEnvelope edits pass through, including status: "sent" (dispatching a draft emails real recipients). If that is too permissive for your environment, pair with the force-drafts companion above.
  • Web UI, native API, Connect webhooks, and admin console are out of reach — this policy governs only the MCP agent path.

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 docusign.ingress.freeze_destructive_ops

# Deny-by-default: only the explicit allow rules below permit the request.
default allow := false

# --- Tool matching ---
# The gateway prefixes tool names with the configured MCP server name
# (e.g. `docusign-updateEnvelope`), so we match on the suffix to stay
# portable. Suffix matching deliberately excludes `updateEnvelopeRecipients`,
# which is a different tool. Verify exact names on your gateway with the
# dump-input debug technique before relying on this in production.
is_update_envelope if {
    endswith(lower(input.resource.name), "updateenvelope")
}

is_cancel_workflow if {
    endswith(lower(input.resource.name), "cancelworkflowinstance")
}

is_pause_workflows if {
    endswith(lower(input.resource.name), "pausenewworkflowinstances")
}

is_guarded_tool if is_update_envelope

is_guarded_tool if is_cancel_workflow

is_guarded_tool if is_pause_workflows

# --- Identity exemption ---
# `contract-ops` is a placeholder group name — map it to your IdP group at
# import time. Missing subject/claims/groups fails closed: no group, no exemption.
caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])

is_contract_ops if {
    some g in caller_groups
    lower(sprintf("%v", [g])) == "contract-ops"
}

# --- Void detection ---
# Top-level tool arguments; keys are matched case-insensitively below.
args := object.get(object.get(input, "payload", {}), "args", {})

# Signal 1: an explicit `status: "voided"` body (the documented Envelopes:update
# void shape), tolerant of key/value casing and stray whitespace.
is_void_attempt if {
    some k in object.keys(args)
    lower(k) == "status"
    lower(trim_space(sprintf("%v", [args[k]]))) == "voided"
}

# Signal 2: a `voidedReason` key with any value (including empty). That field
# only exists on void requests, so its presence marks void intent even when
# the status key is missing or obfuscated.
is_void_attempt if {
    some k in object.keys(args)
    lower(k) == "voidedreason"
}

# --- Allow rules ---
# Pass through any tool this policy does not guard.
allow if {
    not is_guarded_tool
}

# contract-ops members may perform legitimate remediation.
allow if {
    is_guarded_tool
    is_contract_ops
}

# Non-void updateEnvelope calls (send draft, subject/blurb edits) pass through.
allow if {
    is_update_envelope
    not is_void_attempt
}

# --- Deny reasons ---
reasons contains "Voiding a Docusign envelope permanently invalidates a legally significant signed record, so agent-initiated voids are blocked. Prepare a correcting envelope instead, or ask a member of the contract-ops group to perform the void. Contact your administrator if this block looks wrong." if {
    is_update_envelope
    is_void_attempt
    not is_contract_ops
}

reasons contains "Cancelling a running Docusign Maestro workflow instance is irreversible, so agent-initiated cancellations are blocked. Ask a member of the contract-ops group to cancel the instance from the Docusign console. Contact your administrator if this block looks wrong." if {
    is_cancel_workflow
    not is_contract_ops
}

reasons contains "Pausing new Docusign Maestro workflow instances suspends the workflow account-wide, so agent-initiated pauses are blocked. Ask a member of the contract-ops group to pause the workflow from the Docusign console. Contact your administrator if this block looks wrong." if {
    is_pause_workflows
    not is_contract_ops
}

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)