dtwo Policy Store

Force Docusign Envelopes to Draft

Rewrites Docusign envelope-creation calls so the envelope is staged as a (status: "sent").

Direction
ingress
Rego package
docusign.ingress.force_draft_envelopes
App
docusign
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:c1dba6662400ac826544f174979bb864b1bc3c5872db5141910e22ec6982cc2b

docusignforce-draft-envelopesesignhuman-in-the-loopingress

What this policy does

Direction: ingress (tool_pre_invoke) Default: allow (transform-only — never denies) Package: docusign.ingress.force_draft_envelopes

What it does

Rewrites Docusign envelope-creation calls so the envelope is staged as a draft (status: "created") instead of being dispatched (status: "sent"). With status: "sent", Docusign immediately emails real recipients a legally binding signature request under your company's Docusign brand — a hallucinated or injected send is a legal and reputational event, not a recoverable data event. The safe default is that agents may stage envelopes but never dispatch them.

Callers whose IdP groups claim includes esign-senders pass through unchanged, preserving the human-authorized dispatch path. Everyone else has status forced to "created" — whether it was "sent", missing (the community servers default to "sent" when omitted), or any other non-draft value. Calls whose status is already "created" pass through untouched. All other tools are unaffected.

Compliance alignment

  • SOC 2 CC6.3 — supports least privilege and segregation of duties on the agent channel: the agent holds only the initiate (draft) privilege and the dispatch privilege stays with humans in the esign-senders group, preserving the initiate-vs-approve separation for signature transactions (agents prepare, an authorized human sends).

Tool name matching

The policy matches envelope-creation tools by case-insensitive suffix:

  • *createenvelope — official Docusign MCP Server createEnvelope (verified from the developer-docs tool catalog)
  • *create_envelope_from_template and *create_envelope_from_documents — luthersystems community server (verified from source)

The DTwo gateway prefixes tool names with the configured MCP server name (e.g. docusign-createEnvelope), and that prefix is not standardized, so the policy matches on the suffix to stay portable. Both resource.name and the legacy payload.name alias are checked, so a call missing one of the two cannot slip past. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production.

Argument shape

The policy reads and rewrites the top-level status argument:

  • Official createEnvelope mirrors the eSignature Envelopes:create REST body — status at the top level ("sent" = dispatch now, "created" = draft), alongside emailSubject, documents[], recipients.signers[], or templateId + templateRoles[].
  • Community create_envelope_from_template / create_envelope_from_documents take a top-level status that defaults to "sent" when omitted, which is why a missing status is also rewritten to "created".

The transform preserves every other argument via object.union and only sets status.

Examples

Transformed (agent tried to dispatch)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "docusign-createEnvelope", "type": "tool" },
    "subject": { "sub": "agent@example.com", "claims": { "groups": ["staff"] } },
    "payload": {
      "name": "docusign-createEnvelope",
      "args": {
        "emailSubject": "Please sign: MSA",
        "status": "sent",
        "templateId": "tpl-1",
        "templateRoles": [{ "roleName": "Signer", "name": "Ana", "email": "ana@acme.com" }]
      }
    }
  }
}

allow = true; transform rewrites status to "created" and preserves all other arguments — the envelope lands in Drafts, no email goes out.

Allowed unchanged (authorized sender)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "docusign-createEnvelope", "type": "tool" },
    "subject": { "sub": "ops@example.com", "claims": { "groups": ["esign-senders"] } },
    "payload": {
      "name": "docusign-createEnvelope",
      "args": { "emailSubject": "Please sign: MSA", "status": "sent" }
    }
  }
}

allow = true, no transform — the human-authorized dispatch path is preserved.

Composition

This policy is single-purpose: it governs the create step only. To close the full dispatch surface, pair it with:

  • An ingress deny on updateEnvelope when the body carries status: "sent" (sending an existing draft) or status: "voided" (irreversible void) for callers outside esign-senders / contract-ops — without it, an agent can draft here and dispatch via updateEnvelope.
  • An ingress deny on sendReminder and updateEnvelopeRecipients for non-senders (both generate real email to counterparties).
  • A recipient-domain allowlist on envelope creation (blocks mis-sends and "add my personal email as a signer" exfiltration).

Known limitations

  • Dispatch via other tools is not covered. updateEnvelope (status: "sent" on a draft), sendReminder, and triggerWorkflow can still cause external email; attach the companion policies above. This policy deliberately does one job: force the create step to a draft.
  • Official-server argument shape is documented, not schema-dumped. The official tools mirror their documented REST bodies (top-level status), but Docusign does not publish per-tool MCP JSON schemas on a static page — verify against a live tools/list before relying on exact field paths. If your server nests the envelope definition (e.g. under envelopeDefinition), extend the args accessor accordingly.
  • Nested-status decoy (server-shape dependent, red-team residual). The policy reads and pins only the top-level status. If a server actually honours a nested envelopeDefinition.status, two crafted shapes evade the draft-forcing: (a) a decoy top-level status:"created" plus a nested envelopeDefinition.status:"sent" — the top-level "created" trips is_explicit_draft, so the call passes through untouched and the nested "sent" survives; (b) no top-level status plus a nested "sent" — the transform pins top-level status:"created" but the nested "sent" is preserved. Both are covered by tests as documented residuals. This does not affect the official server or the luthersystems community server, whose create tools take status at the top level (per the landscape note); it only bites a server that nests the envelope definition. If yours does, extend the accessor to read and pin the nested status too, and pair with the recipient-domain allowlist companion policy.
  • Non-canonical status key casing leaves a decoy key. JSON keys are case-sensitive, so a Status:"sent" / STATUS:"sent" argument is not the top-level status the policy inspects; the transform therefore fires and injects the canonical lowercase status:"created" (the Docusign REST body uses lowercase status, which wins). The original mixed-case key is left in the payload as an inert decoy. A hypothetical case-insensitive server that preferred the decoy over the injected canonical key is the residual; the official and community servers use lowercase status and are safe. Covered in tests.
  • Non-object args pass through. If args arrives as a non-object (e.g. a bare string), the transform is undefined and the call passes through unmodified; such a call carries no valid envelope definition and fails at the Docusign server (documented residual, covered in tests).
  • A pre-existing status: "created" is trusted case-insensitively. "Created"/"CREATED" are treated as already-draft and left unchanged; Docusign either accepts them as a draft or rejects the call — neither path sends email.
  • Group name is a placeholder. Replace esign-senders with your IdP's group name at import time. Missing subject/claims/groups fail closed for the exemption (no group → not an authorized sender → forced to draft).
  • Suffix matching misses a trailing segment after the create verb. Tool names are matched case-insensitively with endswith, so a name that carries a trailing segment after the create verb (e.g. a version suffix ...createEnvelope-v2) would not match and would pass through unmodified with status:"sent" intact. No documented Docusign create tool names tools this way — the official server exposes createEnvelope, the luthersystems community server create_envelope_from_template / _from_documents, and the gateway only prepends the configured server name — so this does not affect the real servers. Confirm your gateway's exact tool names with the dump-input debug technique and add any trailing-suffixed variant to envelope_create_suffixes. Covered in tests.
  • CData community server is out of scope — it is read-only SQL and has no envelope-creation surface.

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.force_draft_envelopes

# Transform-only policy: allow everything, and rewrite envelope-creation
# calls to status "created" (draft) unless the caller is an authorized
# sender. Agents stage envelopes; humans dispatch them. Never denies.
default allow := true

# ---------------------------------------------------------------------------
# Configuration placeholders — replace at import time
# ---------------------------------------------------------------------------

# IdP group whose members may dispatch envelopes (status "sent" passes
# through unmodified). PLACEHOLDER: replace with your IdP group name.
esign_senders_group := "esign-senders"

# Envelope-creation tool suffixes. Lower-case; matched case-insensitively.
# - "createenvelope": official Docusign MCP Server createEnvelope (verified
#   from the developer-docs tool catalog)
# - "create_envelope_from_template" / "create_envelope_from_documents":
#   luthersystems community server (verified from source; its status
#   argument DEFAULTS to "sent" when omitted)
envelope_create_suffixes := {
    "createenvelope",
    "create_envelope_from_template",
    "create_envelope_from_documents",
}

# ---------------------------------------------------------------------------
# Shared accessors — every possibly-missing field is read via object.get
# ---------------------------------------------------------------------------

args := object.get(object.get(input, "payload", {}), "args", {})

# Ingress pre-invoke gate. The PARC field is `action`; `kind` is its populated
# legacy alias (same value). Accept EITHER via object.get: if a gateway build
# ever populates only the legacy `kind` (or PARC drops `action`), keying solely
# off `input.action` would silently fail the match and pass a `status:"sent"`
# call straight through — a fail-open dispatch. Restricting to pre-invoke keeps
# the transform off egress hooks, whose payload has `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"

# Envelope-creation call. The gateway prefixes tool names with the configured
# MCP server name, so match by suffix for portability. Case-insensitive so a
# mixed-case tool name can't slip past. Match on resource.name OR the legacy
# payload.name alias (both populated on tool hooks, same value): a call that
# arrived with an absent resource.name would otherwise miss the match and
# dispatch real signature-request email — a fail-open leak. Reading both via
# object.get also means a missing `resource` object can't error the rule.
is_envelope_create_call if {
    is_pre_invoke
    some suffix in envelope_create_suffixes
    endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), suffix)
}

is_envelope_create_call if {
    is_pre_invoke
    some suffix in envelope_create_suffixes
    endswith(lower(object.get(object.get(input, "payload", {}), "name", "")), suffix)
}

# True when the caller is in the authorized-senders group. Missing subject /
# claims / groups fail closed (no group -> not an authorized sender -> the
# envelope is forced to draft). A groups claim emitted as a bare string is
# not iterated by `some g in`, so it also fails closed.
is_authorized_sender if {
    claims := object.get(object.get(input, "subject", {}), "claims", {})
    some g in object.get(claims, "groups", [])
    g == esign_senders_group
}

# True only when the caller already asked for an explicit draft. Anything
# else — "sent", a missing status (the community default is "sent"), padded
# or unexpected values — gets rewritten. trim_space + lower so "Created "
# still counts as a draft; a non-string status is never treated as a draft.
is_explicit_draft if {
    status := object.get(args, "status", "")
    is_string(status)
    lower(trim_space(status)) == "created"
}

# ---------------------------------------------------------------------------
# Transform: force status "created" on unauthorized envelope creation
# ---------------------------------------------------------------------------

# Preserves every other argument (documents, recipients, templateId,
# emailSubject, ...) and only pins status to "created": the agent's envelope
# lands in Drafts and no recipient is emailed. If args is a non-object the
# object.union is undefined and the call passes through unmodified — such a
# call carries no valid envelope definition and fails at the Docusign server
# (documented residual).
transform := {"transformed_payload": object.union(args, {"status": "created"})} if {
    is_envelope_create_call
    not is_authorized_sender
    not is_explicit_draft
}

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