dtwo Policy Store

Deny Agent Email Sends to External Recipients

Denies Gmail send-class tool calls when any recipient in to, cc, or bcc falls outside a documented corporate-domain allowlist.

Direction
ingress
Rego package
gmail.ingress.guard_external_send
App
gmail
Bundles
soc2hipaagdpr-ccpa
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:a356452d11eebac588da98a69bdd3b885549b7eedbb466b2b9042b4b0a54e7e7

gmailguard-external-sendingressemailsoc2hipaagdpr-ccpa

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny on match, allow otherwise Package: gmail.ingress.guard_external_send

What it does

Denies Gmail send-class tool calls when any recipient in to, cc, or bcc falls outside a documented corporate-domain allowlist. A denied agent is told to create a draft in Gmail instead, so a human reviews and sends the message — the same posture the official Google/Claude Gmail connector enforces by design (it ships no send tool at all).

The check is fail-closed: a send whose recipients are missing, empty, or in a shape the policy cannot parse is denied. Callers in a documented IdP group (placeholder: mcp-gmail-external-send) are exempt; a caller with no claims is never exempt.

Sending email is externally visible and unrecallable, so this must be an ingress policy — once the call reaches the Gmail MCP server, the message has left the organization. Draft creation (create_draft, draft_email, draft_gmail_message) passes through untouched as the sanctioned path.

Compliance alignment

  • SOC 2 CC6.7 — supports the restriction on transmission/movement of information outside the boundary: agent-driven mail to non-corporate domains is stopped before it leaves; P6.1 — supports limits on personal information disclosure to third parties over the agent's email path.
  • HIPAA §164.530(c) — supports privacy safeguards by preventing an agent from mailing PHI-bearing content to addresses outside the covered entity's domains.
  • GDPR Art. 5(1)(f) / Art. 32 — supports security of processing on the agent's outbound-mail path; Arts. 44/46 — supports control over agent-visible cross-border transfers by pinning recipients to reviewed corporate domains.

Tool name matching

The policy matches send-class tools case-insensitively by suffix on the tool name — read from both the PARC input.resource.name and the still-populated legacy input.payload.name alias, so a send is caught if either field carries the suffix (they hold the same value on tool_pre_invoke; checking both means a call with resource.name absent still fails closed rather than slipping through as a non-send):

  • *send_email — GongRzhe/Gmail-MCP-Server ("Gmail AutoAuth MCP")
  • *send_gmail_message — taylorwilsdon/google_workspace_mcp

The DTwo gateway prefixes tool names with the configured MCP server name (e.g. gmail-mcp-send_email), and that prefix is not standardized — matching on the suffix keeps the policy portable. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production.

The official Google remote Gmail MCP server (the Claude Gmail connector surface) deliberately exposes no send tool — drafts must be sent by a human from Gmail — so nothing on that server matches this policy, and its create_draft tool passes untouched. Community draft tools (draft_email, draft_gmail_message) likewise do not match the send suffixes and pass.

Argument shape

Recipients are read from the to, cc, and bcc argument keys with object.get, handling both shapes seen in the wild:

  • arrays of address strings (GongRzhe send_email),
  • single strings, including comma- or semicolon-separated lists (taylorwilsdon send_gmail_message).

Each entry may be a bare address (user@example.com) or a display-name form (Name <user@example.com>). Parsing is deliberately conservative: an entry must contain exactly one @ to yield a domain — entries with zero or multiple @ signs (including several addresses smuggled into one entry) fail to parse and the send is denied, not skipped. A recipient field that is present but neither a string nor an array also denies the call.

The domain allowlist ships with placeholder values (example.com, example.org) — replace them with your organization's domains at import time.

Examples

Allowed — internal recipients only

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "gmail-mcp-send_email", "type": "tool" },
    "payload": {
      "name": "gmail-mcp-send_email",
      "args": {
        "to": ["alice@example.com"],
        "cc": ["bob@example.com"],
        "subject": "Q3 draft",
        "body": "..."
      }
    }
  }
}

allow = true, no reason.

Denied — external recipient in bcc

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "gmail-mcp-send_email", "type": "tool" },
    "payload": {
      "name": "gmail-mcp-send_email",
      "args": {
        "to": ["alice@example.com"],
        "bcc": ["competitor@evil.example.net"],
        "subject": "Q3 draft",
        "body": "..."
      }
    }
  }
}

allow = false, reason instructs the agent to create a Gmail draft for human review instead.

Composition

This policy is single-purpose. Useful companions:

  • A transform policy that strips bcc and the from_name / from_email send-as alias arguments from send and draft tools — no hidden recipients, no display-name impersonation.
  • guard-mailbox-persistence (PF-17) denying Gmail filter creation — this policy stops direct external sends, but a mail filter with an auto-forward action is an equivalent exfiltration path that outlives the session.
  • A default-deny-unknown-tools (PF-28) allowlist policy, so a send tool with an unanticipated name cannot slip past suffix matching.
  • An AI-sender disclosure policy (PF-19) for the sends that are allowed.

Known limitations

  • Send-as spoofing and forwarding are not covered. send_gmail_message also accepts from_name / from_email (send-as alias — spoofable display identity) and forward_message_id (which pulls a prior message's content and attachments into the send). This policy only inspects recipient domains; strip/deny those arguments with a companion policy (see Composition).
  • Official-server draft field names are unverified. Google's published reference does not include parameter schemas for create_draft; this policy does not inspect draft arguments at all, so nothing breaks, but companion policies that do inspect drafts must verify field names first.
  • Placeholders. The domain allowlist entries (example.com, example.org) and the exemption group name (mcp-gmail-external-send) are placeholders — replace them with your corporate domains and your IdP's group name at import time.
  • Suffix matching only covers known vocabularies. A Gmail MCP server exposing a differently named send tool (or a raw-API escape hatch) will not match; pair with a PF-28 allowlist policy for deny-by-default coverage.
  • Display names containing commas or semicolons cause a false-positive deny. In the string-shaped recipient form the policy splits on , and ; to separate addresses, so a legitimate internal recipient written as "Doe, Alice <alice@example.com>" is split into "Doe" and " Alice <alice@example.com>"; the "Doe" fragment has no parseable domain, so the send is denied. This is fail-closed (over-deny, never over-allow): a comma/semicolon can never hide an external address, because any unparseable fragment is itself treated as external. Send such recipients as separate array entries, or drop the display name, to avoid the false positive. Note the array-shaped form is not comma-split — a single array entry smuggling two addresses ("a@example.com,b@evil.com") parses as two @ signs and is denied as unverifiable, not silently accepted.

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 gmail.ingress.guard_external_send

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

# Corporate email domain allowlist — PLACEHOLDER values. Replace with your
# organization's sending domains at import time.
allowed_domains := {
    "example.com",
    "example.org",
}

# IdP group whose members may send external email through the agent.
# PLACEHOLDER — replace with your IdP's group name at import time.
exempt_group := "mcp-gmail-external-send"

# Recipient argument keys used by the community Gmail MCP servers.
recipient_fields := ["to", "cc", "bcc"]

# Tool arguments, defaulting safely when payload/args are missing entirely.
args := object.get(object.get(input, "payload", {}), "args", {})

# Tool-name candidates: the PARC resource.name plus the (deprecated but still
# populated on tool hooks) payload.name alias. Both are read with object.get and
# a send is matched if EITHER carries a send suffix. Keying only on
# input.resource.name would fail OPEN for a send whose resource.name is absent —
# is_send_tool would be undefined and `allow if not is_send_tool` would permit
# the send. Checking both fields (they carry the same value on tool_pre_invoke)
# closes that gap at zero cost to legitimate traffic.
tool_names contains lower(name) if {
    name := object.get(object.get(input, "resource", {}), "name", "")
    name != ""
}

tool_names contains lower(name) if {
    name := object.get(object.get(input, "payload", {}), "name", "")
    name != ""
}

# Send-class Gmail tools, matched case-insensitively by suffix so the
# gateway's configured server-name prefix doesn't matter:
#   *send_email          — GongRzhe/Gmail-MCP-Server
#   *send_gmail_message  — taylorwilsdon/google_workspace_mcp
# The official Google/Claude Gmail connector has no send tool by design, so
# its create_draft (and the community draft_email / draft_gmail_message)
# pass through untouched as the sanctioned human-review path.
is_send_tool if {
    some name in tool_names
    endswith(name, "send_email")
}

is_send_tool if {
    some name in tool_names
    endswith(name, "send_gmail_message")
}

# Allow anything that is not a Gmail send tool (drafts, reads, labels, ...).
allow if {
    not is_send_tool
}

# Exempt callers in the documented IdP group. Missing subject, claims, or
# groups means no exemption — the grant fails closed.
caller_exempt if {
    subject := object.get(input, "subject", {})
    claims := object.get(subject, "claims", {})
    groups := object.get(claims, "groups", [])
    some group in groups
    group == exempt_group
}

allow if {
    is_send_tool
    caller_exempt
}

# Allow a send only when no recipient field is malformed, at least one
# recipient was parsed, and every recipient resolves to an allowlisted
# domain. Anything less fails closed to deny.
allow if {
    is_send_tool
    not malformed_recipient_field
    count(recipients) > 0
    every recipient in recipients {
        is_internal(recipient)
    }
}

# --- Recipient extraction ---

# Array shape (GongRzhe send_email): to/cc/bcc are arrays of entries.
recipients contains recipient if {
    some field in recipient_fields
    value := object.get(args, field, [])
    is_array(value)
    some recipient in value
}

# String shape (taylorwilsdon send_gmail_message and others): a single
# address or a comma/semicolon-separated list.
recipients contains recipient if {
    some field in recipient_fields
    value := object.get(args, field, "")
    is_string(value)
    some part in regex.split(`[,;]`, value)
    recipient := trim_space(part)
    recipient != ""
}

# A recipient field that is present but neither a string nor an array cannot
# be checked — treat the whole call as unverifiable (fail closed).
malformed_recipient_field if {
    some field in recipient_fields
    value := object.get(args, field, null)
    value != null
    not is_string(value)
    not is_array(value)
}

# Extract the domain of one recipient entry. Deliberately conservative: the
# entry must contain exactly one "@" — entries with zero or multiple "@"
# signs (e.g. several addresses smuggled into one entry) yield no domain,
# so is_internal fails and the send is denied.
recipient_domain(recipient) := domain if {
    is_string(recipient)
    parts := split(lower(trim_space(recipient)), "@")
    count(parts) == 2
    # Strip the closing bracket (and stray spaces) of a "Name <user@domain>" form.
    domain := trim(parts[1], "> ")
}

is_internal(recipient) if {
    allowed_domains[recipient_domain(recipient)]
}

has_external_recipient if {
    some recipient in recipients
    not is_internal(recipient)
}

recipients_unverifiable if {
    count(recipients) == 0
}

recipients_unverifiable if {
    malformed_recipient_field
}

reasons contains "One or more recipients (to, cc, or bcc) are outside the corporate email domain allowlist or could not be parsed. Do not send this email; create a Gmail draft instead so a human can review and send it. If every recipient should be internal, ask your InfoSec team to add the domain to the allowlist." if {
    is_send_tool
    not caller_exempt
    has_external_recipient
}

reasons contains "This send call has no recipients the policy can verify: to, cc, and bcc are missing, empty, or in an unrecognized format. Create a Gmail draft instead so a human can review and send it. If this is a false positive, contact your InfoSec team." if {
    is_send_tool
    not caller_exempt
    recipients_unverifiable
}

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)

Used in these guides