--- name: Block Agent Email to External Recipients tags: - ms365 - guard-external-send - ingress - email - dlp - soc2 - hipaa - gdpr-ccpa publishedAt: 2026-07-12 description: | # ms365 / guard-external-send **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `ms365.ingress.guard_external_send` ## What it does Blocks agent-initiated Microsoft 365 email sends when any recipient address falls outside a corporate-domain allowlist. On the send-class mail tools (send, reply, reply-all, forward, send-draft, shared-mailbox send), the policy extracts every recipient it can see in the tool arguments and denies if any address is outside the `allowed_domains` set. Callers in the `external-comms` identity group are exempt. All non-send tools pass through unchanged. The posture on matched tools is strictly fail-closed: if a send-class call carries no recipients in its arguments (e.g. `send-draft-message`, or a reply whose recipients live server-side on the thread), or a recipient entry has no readable `emailAddress.address`, the check cannot run and the call is **denied**, not waved through. Malformed addresses (no `@`, multiple `@`, unknown subdomains) are treated as external. The check runs at ingress, before the call reaches the MCP server — a blocked email never leaves the tenant, which matters because sent mail is instantly external and unrecallable. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of information outside the organization's boundary by stopping agent email to non-corporate recipients on the MCP path; **P6.1** — supports limiting personal information disclosure to third parties over the agent's email channel. - **HIPAA §164.530(c)** — supports privacy safeguards by preventing an agent from mailing mailbox content (which routinely contains PHI) to addresses outside the covered entity's domains. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing by containing agent-driven personal-data egress to approved domains; **Arts. 44/46** — supports cross-border transfer discipline for agent-visible flows: an agent cannot mail data to arbitrary external (potentially third-country) recipients. ## Why ingress and not egress Sending email is a write with irreversible external side effects — once Graph accepts the `sendMail` action, the message has left the tenant. Egress policies could only mask the API response, not the delivery. Ingress denial is the only placement that actually prevents the disclosure. ## Tool name matching The policy matches the softeria `ms-365-mcp-server` send-class mail tools by suffix (names verified from a live gateway deployment): - `*-send-mail` - `*-reply-mail-message` - `*-reply-all-mail-message` - `*-forward-mail-message` - `*-send-draft-message` - `*-send-shared-mailbox-mail` The DTwo gateway prefixes tool names with the configured MCP server name (observed live as `ms365-`), and that prefix is not standardized — matching on the suffix keeps the policy portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. Draft-creation tools (`*-create-draft-email`, `*-create-reply-draft`, `*-create-forward-draft`, `*-create-shared-mailbox-draft`) are deliberately not matched: a draft does not leave the tenant until something sends it, and drafts are the recommended fallback workflow when this policy denies. ## Argument shape Two documented recipient shapes are read (both from the live softeria schemas), and every hop is read with `object.get` so a missing field can never crash a rule open: 1. `send-mail` (and shared-mailbox send) nest the message under a `Message` wrapper, per the Graph `sendMail` action: `body.Message.toRecipients[].emailAddress.address` — same shape for `ccRecipients` and `bccRecipients`. All three lists are checked. 2. `forward-mail-message` uses a **top-level** field instead: `body.ToRecipients[].emailAddress.address`. **Casing is not trusted.** Microsoft Graph binds OData property names case-insensitively, so an agent can send `body.Message.BccRecipients` (capital B), a lowercase `message` wrapper, or a top-level lowercase `toRecipients` and Graph will still deliver the mail. The policy therefore lowercases every wrapper key (`message`) and every recipient-list key (`toRecipients` / `ccRecipients` / `bccRecipients`) before matching, and gathers recipient lists from **both** the top level of `body` and any `message`-style wrapper. This closes the casing trap the landscape note warns about: a hidden capital-cased BCC can no longer ride alongside a visible internal recipient. Both shapes are extracted on every matched tool, so a forward that carries a full `body.Message` is also covered. Addresses are compared lowercase against `allowed_domains`; the domain match is exact, so subdomains you use must be listed explicitly. ## Examples ### Allowed — all recipients on corporate domains ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-send-mail", "type": "tool" }, "payload": { "name": "ms365-send-mail", "args": { "body": { "Message": { "subject": "Q3 numbers", "toRecipients": [ { "emailAddress": { "address": "cfo@example.com" } } ] } } } } } } ``` `allow = true`, no reason. ### Denied — external recipient in bcc ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-send-mail", "type": "tool" }, "payload": { "name": "ms365-send-mail", "args": { "body": { "Message": { "toRecipients": [ { "emailAddress": { "address": "cfo@example.com" } } ], "bccRecipients": [ { "emailAddress": { "address": "partner@outside.io" } } ] } } } } } } ``` `allow = false`, `reason = "This message addresses recipients outside the approved corporate domains: partner@outside.io. (...)"`. ## Composition This policy is single-purpose. Useful companions: - A `graph-batch` deny policy — `*-graph-batch` can invoke the Graph `sendMail` endpoint directly and bypasses every per-tool rule, including this one. - `guard-share-links` (PF-05) — email is only one egress lane; anonymous OneDrive/ SharePoint share links are the other. - A mail-rule persistence block on `*-create-mail-rule` / `*-update-mail-rule` — a forwarding rule is the classic way to leak mail without ever calling a send tool. ## Known limitations - **Placeholders — replace at import time.** The `allowed_domains` set ships with `example.com` / `example.org`; replace it with your organization's real email domains (including any subdomains you use — matching is exact). The exemption group name `external-comms` is a placeholder — replace it with your IdP's group name. The policy reads `input.subject.claims.groups`; a caller with no groups claim is simply not exempt (fails closed). - **Server-side recipients are invisible.** `send-draft-message` sends by `messageId` only, and the reply tools can inherit recipients from the thread — in both cases Graph resolves the audience server-side and the gateway never sees it. A draft addressed to an external party in the Outlook web UI is not visible here. The residual is partly handled with a deny-when-args-absent posture: a covered call with **no** argument-visible recipient is denied outright, so these tools cannot be used as a *silent* (recipient-free) blind external channel, and comment-only replies plus all draft sends are denied for non-exempt callers (route those through the `external-comms` group or the create-draft + human-send workflow). - **Reply / reply-all can still leak to thread externals (accepted residual).** The deny-when-args-absent posture does **not** fully close the reply channel. Graph's `reply` / `replyAll` actions **add** any argument-supplied recipients to the thread's existing audience rather than replacing it, so a non-exempt caller who supplies a single *internal* recipient in the `Message` wrapper satisfies the check (one visible recipient, none external) and is **allowed** — while Graph still delivers the reply to every server-side thread participant, including external ones the gateway never sees (see the reply-all decoy test in `tests.yaml`). In other words, `reply-mail-message` / `reply-all-mail-message` can egress to thread externals even when this policy allows the call; a decoy internal recipient is enough. `forward-mail-message` and `send-mail` build a fresh message with an argument-visible audience and are fully checked — this residual is specific to the two thread-reply tools. Where reply-to-external- threads is unacceptable, pair this policy with a human-in-the-loop control on reply/reply-all, or deny those two tools outright for non-exempt callers. - **Teams egress is out of scope.** `send-chat-message` has no recipient-domain argument (audience is a `chatId`), so chat egress to federated tenants cannot be checked by this policy. Treat Teams as a separate control surface. - **`graph-batch` bypass.** See Composition — pair this policy with a batch deny. - **Shared-mailbox send shape assumed.** `send-shared-mailbox-mail` is matched by suffix and its arguments are assumed to follow the same capital-M `body.Message` shape as `send-mail`; if a deployment nests them differently the policy fails closed (no recipients visible → deny) rather than open. - **Recipient-key casing is normalized, but wholly undocumented keys are not.** The policy matches wrapper and recipient-list keys case-insensitively (`message`, `to/cc/bccRecipients`), so capitalization tricks no longer hide a recipient. The residual: a recipient list carried under an entirely different key that Graph still honours (not one of the `*Recipients` names, not inside a `message` wrapper) would be invisible. If such a list is the *only* recipient source the call fails closed (no visible recipients → deny); the unclosed edge is a hidden list riding alongside a separately-visible internal recipient under a truly novel key. The reply / reply-all / shared-mailbox recipient shapes are assumed to follow the documented `Message`-wrapper convention (only `send-mail` and `forward-mail-message` shapes are landscape-verified). - **Name-based matching only.** Generic passthrough servers (e.g. Lokka's single `Lokka-Microsoft` tool) do not expose per-action tool names and are not covered. > **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: - ms365 industries: [] bundles: - soc2 - hipaa - gdpr-ccpa experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```rego package ms365.ingress.guard_external_send # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --- Configuration ------------------------------------------------------------ # Corporate domains recipients may belong to. PLACEHOLDERS — replace with your # organization's real email domains at import time. Matching is exact and # lowercase; subdomains you use must be listed explicitly. allowed_domains := { "example.com", "example.org", } # IdP group whose members may email external recipients. PLACEHOLDER — replace # with your IdP's group name at import time. exempt_group := "external-comms" # --- Tool matching -------------------------------------------------------------- # Send-class mail tools (softeria ms-365-mcp-server names, verified from a live # gateway deployment). The gateway prefixes tool names with the configured MCP # server name (e.g. `ms365-`), so match by suffix to stay portable. send_tool_suffixes := [ "-send-mail", "-reply-mail-message", "-reply-all-mail-message", "-forward-mail-message", "-send-draft-message", "-send-shared-mailbox-mail", ] is_send_tool if { some suffix in send_tool_suffixes endswith(lower(input.resource.name), suffix) } # --- Recipient extraction -------------------------------------------------------- # Every hop uses object.get so a missing field yields an empty default instead of # silently killing an allow rule — missing data must end in deny, not crash-open. request_args := object.get(object.get(input, "payload", {}), "args", {}) # request_body must be an object; anything else (string, array, missing) collapses # to {} so the key iteration below never crashes and simply surfaces no recipients # — which fails closed on a send-class tool. request_body := body if { body := object.get(request_args, "body", {}) is_object(body) } request_body := {} if { not is_object(object.get(request_args, "body", {})) } # Recipient-list field names, compared lowercase. Microsoft Graph binds OData # property names case-insensitively, so we must NOT trust the exact casing an # agent sends: `body.Message.BccRecipients` (capital B) still delivers a BCC even # though the documented shape is `bccRecipients`. The landscape note flags this # Message/ToRecipients-vs-toRecipients casing trap explicitly — Rego must handle # every casing, not just the two documented spellings. recipient_field_names := {"torecipients", "ccrecipients", "bccrecipients"} # Recipient lists appear either at the top level of `body` (forward-mail-message's # `ToRecipients`) or nested inside a `Message`/`message` wrapper (send-mail, reply, # reply-all, shared-mailbox send — the Graph sendMail/reply action shapes). Both # the wrapper key and the field keys are matched case-insensitively so a # capitalization trick (`BccRecipients`, a lowercase `message` wrapper, a # top-level `toRecipients`, etc.) cannot smuggle a hidden external recipient past # the check while a visible internal recipient keeps the send allowed. top_level_entries := [entry | some key, val in request_body lower(key) in recipient_field_names is_array(val) some entry in val ] message_wrappers := [val | some key, val in request_body lower(key) == "message" is_object(val) ] nested_entries := [entry | some wrapper in message_wrappers some key, val in wrapper lower(key) in recipient_field_names is_array(val) some entry in val ] recipient_entries := array.concat(top_level_entries, nested_entries) # A recipient entry is readable only when emailAddress.address is a non-empty # string; anything else (missing key, wrong type) makes the entry unreadable and # the request denied below. readable_address(entry) := addr if { is_object(entry) addr := lower(object.get(object.get(entry, "emailAddress", {}), "address", "")) addr != "" } recipient_addresses := {addr | some entry in recipient_entries addr := readable_address(entry) } some_recipient_unreadable if { some entry in recipient_entries not readable_address(entry) } # The domain must match an allowlisted domain exactly. Malformed addresses (no # `@`, more than one `@`, empty domain) never satisfy this and count as external. domain_allowed(addr) if { parts := split(addr, "@") count(parts) == 2 parts[1] in allowed_domains } external_recipients := {addr | some addr in recipient_addresses not domain_allowed(addr) } # --- Identity exemption -------------------------------------------------------- # Missing subject / claims / groups all fail closed: no groups claim, not exempt. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) exempt_caller if { some group in caller_groups lower(group) == exempt_group } # --- Allow rules ---------------------------------------------------------------- # Any tool that is not a send-class mail tool passes through unchanged. allow if { not is_send_tool } # Members of the exemption group may email anyone. allow if { is_send_tool exempt_caller } # Send-class calls are allowed only when at least one recipient is visible in the # arguments, every recipient entry is readable, and none is external. allow if { is_send_tool count(recipient_entries) > 0 not some_recipient_unreadable count(external_recipients) == 0 } # --- Deny reasons ---------------------------------------------------------------- reasons contains msg if { is_send_tool not exempt_caller count(external_recipients) > 0 msg := sprintf( "This message addresses recipients outside the approved corporate domains: %s. Remove the external addresses or save a draft for a human to review and send. Contact your InfoSec team if an external domain should be approved.", [concat(", ", sort([addr | some addr in external_recipients]))], ) } reasons contains "No recipient addresses are visible in this request, so the corporate-domain check cannot run and the send is blocked. Include explicit recipients in the call, or create a draft and let a human send it from Outlook. Contact your InfoSec team if this blocks a legitimate workflow." if { is_send_tool not exempt_caller count(recipient_entries) == 0 } reasons contains "A recipient in this request is missing a readable email address, so the corporate-domain check cannot run and the send is blocked. Provide every recipient as emailAddress.address, or create a draft for human review. Contact your InfoSec team if this was a false positive." if { is_send_tool not exempt_caller some_recipient_unreadable } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ```