--- name: Force Docusign Envelopes to Draft tags: - docusign - force-draft-envelopes - esign - human-in-the-loop - ingress publishedAt: 2026-07-12 description: | # docusign / force-draft-envelopes **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) ```jsonc { "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) ```jsonc { "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. direction: ingress apps: - docusign industries: [] bundles: [] experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```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 } ```