dtwo Policy Store

Freeze Destructive and Series-Wide Calendar Changes

Denies irreversible Google Calendar mutations on the agent channel:

Direction
ingress
Rego package
google_calendar.ingress.freeze_destructive_events
App
google-calendar
Bundle
soc2
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:30219dff7784e6056034b2c9b69142fd9ec18b6c07a5dce22ee64af368102f74

google-calendarfreeze-destructive-opsingressintegritysoc2

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny on match, allow otherwise Package: google_calendar.ingress.freeze_destructive_events

What it does

Denies irreversible Google Calendar mutations on the agent channel:

  1. Event deletes — the dedicated destructive tools (delete_event / delete-event) and taylorwilsdon's consolidated manage_event tool when its action argument is a destructive verb (delete, remove, cancel, and related synonyms) — for every caller outside the placeholder calendar-admins group. manage_event is the known trap here: a single tool name spans write and destructive operations, so the policy inspects the action argument rather than trusting the name.
  2. Series-wide recurring-event changes — any create/update/delete whose modificationScope is not a single instance (e.g. all, thisAndFollowing, future) is denied for all callers, including calendar-admins, because recurring-series-wide edits and deletes can silently wipe or move standing meetings and Calendar offers no MCP-level undo.
  3. Fail closed on manage_event ambiguity — a manage_event call whose action argument is absent (or not a string) cannot be distinguished from a delete and is denied for non-admins; a manage_event call whose modificationScope is absent (or not a string) has an unverifiable series blast radius and is denied for everyone.

The check runs at ingress, before the call reaches the Calendar MCP server, so a blocked delete or series rewrite never executes. This preserves record integrity against both agent error and prompt injection.

Compliance alignment

  • SOC 2 PI1.5 — supports integrity of stored records by preventing agent-driven destruction and mass rewrite of calendar entries. CC6.7 — supports the restriction on removal of information by refusing irreversible agent-driven deletes and series-wide rewrites on the calendar path.

Tool name matching

The DTwo gateway prefixes tool names with the configured MCP server name, and Calendar servers disagree on delimiters (delete_event — official Google server, snake_case — vs delete-event — nspady, kebab-case). The policy lowercases the tool name, normalizes - to _, and matches by suffix:

  • *delete_event — dedicated destructive tools (Google delete_event, nspady delete-event)
  • *manage_event — taylorwilsdon's consolidated create/update/delete tool
  • *create_event, *update_event — write tools, inspected only for the series-wide modificationScope check

Read tools (list_events, get-event, search-events, respond_to_event, …) do not match any suffix and pass through. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production.

Argument shape

  • input.payload.args.actionmanage_event's operation selector. The policy treats any string containing a destructive verb (delete, remove, cancel, trash, purge, destroy; case-insensitive) as destructive. Missing/non-string → fail closed (deny for non-admins).
  • modificationScope (recurring-series blast radius, documented on nspady's update-event) — the policy normalizes the argument key the same way it normalizes tool names (lowercase, strip -/_), so modificationScope, modification_scope, modification-scope, and ModificationScope are all treated as the same key. Every string value under any matching key is normalized (case and -/_ stripped) and checked against a single-instance allowlist (single, thisEventOnly). If any provided value is not single-instance, the call is series-wide and denied — so a caller cannot pair a safe value under one spelling with a series-wide value under another to slip past a server that reads the other spelling. On manage_event, a call with no usable scope value under any spelling is denied outright (fail closed); on dedicated create/update/delete tools a missing value passes the scope check, since single-instance is the servers' default and create calls normally have no scope argument.
  • futureStartDate (also future_start_date or any delimiter/case variant, matched with the same key normalization) — nspady's alternate "this-and-following" split control. A non-empty string value is treated as series-wide and denied for everyone on any event-mutation tool, the same as a series-wide modificationScope.

Identity

Callers whose input.subject.claims.groups contains calendar-admins (case-insensitive) are exempt from the delete rules (1 and the non-admin half of 3) but not from the series-wide rules. Missing claims fail closed: no groups claim means no exemption.

Examples

Allowed — single-instance update

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "google-calendar-mcp-update-event", "type": "tool" },
    "payload": {
      "name": "google-calendar-mcp-update-event",
      "args": { "eventId": "abc123", "summary": "Standup (moved)", "modificationScope": "thisEventOnly" }
    }
  }
}

allow = true, no reason.

Denied — non-admin delete

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "google-calendar-mcp-delete-event", "type": "tool" },
    "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } },
    "payload": {
      "name": "google-calendar-mcp-delete-event",
      "args": { "calendarId": "primary", "eventId": "abc123" }
    }
  }
}

allow = false, reason = "Deleting calendar events through the agent is limited to the calendar-admins group (...)".

Denied — series-wide edit, even for admins

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "google-calendar-mcp-update-event", "type": "tool" },
    "subject": { "sub": "google-apps|admin@example.com", "claims": { "groups": ["calendar-admins"] } },
    "payload": {
      "name": "google-calendar-mcp-update-event",
      "args": { "eventId": "abc123", "modificationScope": "all" }
    }
  }
}

allow = false, reason = "Series-wide recurring-event changes are blocked for all callers (...)".

Composition

This policy is single-purpose (destructive/series-wide freeze). Useful companions in the same app directory:

  • guard-external-attendees — blocks invite-based exfiltration to external domains.
  • guard-public-exposure — blocks visibility: public and guest-privilege delegation.
  • redact-attendee-pii — egress redaction of attendee emails and meeting links on read tools.

Known limitations

  • Group names are placeholders — replace calendar-admins with your IdP's group name at import time. The exemption reads input.subject.claims.groups; if your IdP emits roles under a different claim, adjust is_calendar_admin.
  • manage_event argument schema is partially unverified. taylorwilsdon's README verifies that manage_event consolidates create/update/delete behind an action argument, but the exact argument key (action) and its value enum are not published in the landscape research; the modificationScope key on manage_event is likewise unverified (it is documented on nspady's update-event). The policy fails closed when the action or scope argument is missing or non-string, so that class of schema mismatch shows up as a deny. It does not fail closed on a present action string that the server maps to a delete but that contains none of the known destructive verbs (delete/remove/cancel/trash/purge/destroy): such a call is treated as a non-destructive create/update and allowed for non-admins. Verify your server's action enum with the dump-input technique and extend destructive_action_verbs if it uses a delete verb outside this set. The series-wide freeze (rule 4) is unaffected by this residual, and admins remain exempt from the delete rule regardless.
  • Single-instance scope allowlist is conservative. Only single and thisEventOnly (after normalization) pass; nspady's exact enum values are unverified, so legitimate single-instance spellings not on the list will be denied. Extend single_instance_scopes for your server.
  • Non-string modificationScope value on dedicated tools fails open. The argument key is normalized (case and -/_ stripped), so an alternate key spelling no longer slips past the series-wide check. What still fails open is a recognized scope key carrying a non-string value (array, number, null) on a dedicated create/update/delete tool: it passes the scope check (the server will typically reject such a value anyway). Only manage_event fails closed on an unusable scope value. The action key on manage_event is matched by its exact name; an alternate-cased action key reads as a missing action and therefore fails closed for non-admins (rule 3), so it is not a bypass.
  • Series-wide freeze applies to calendar-admins too. Series-wide changes must be made in the Google Calendar UI, by design. This includes calls carrying a futureStartDate split control, which is treated as series-wide.
  • Tool-name source. The policy matches on input.resource.name, falling back to the legacy input.payload.name alias if the former is empty. Both are populated on tool hooks per the input schema; the fallback is defence in depth against a caller that populates only the legacy field.
  • Deletes are still possible outside MCP. Web-UI and native-API deletes are out of the gateway's reach; this policy only freezes the agent channel.

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

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

# --- Tool matching -----------------------------------------------------------
# The gateway prefixes tool names with the configured MCP server name, and
# Calendar servers disagree on delimiters (`delete_event` vs `delete-event`),
# so we lowercase, normalize `-` to `_`, and match by suffix.
# Prefer the PARC `resource.name`, but fall back to the legacy `payload.name`
# alias so a call that only populates the latter still gets matched (defence
# in depth — both are populated on tool hooks per the input schema).
raw_resource_name := object.get(object.get(input, "resource", {}), "name", "")

effective_name := raw_resource_name if raw_resource_name != ""

effective_name := object.get(object.get(input, "payload", {}), "name", "") if raw_resource_name == ""

normalized_name := replace(lower(effective_name), "-", "_")

# Dedicated destructive tools: Google official `delete_event`, nspady `delete-event`.
is_delete_tool if endswith(normalized_name, "delete_event")

# taylorwilsdon's consolidated tool — one name spans create/update/delete, so
# the destructive check below inspects the action argument, not the name.
is_manage_event if endswith(normalized_name, "manage_event")

# The full event-mutation family this policy inspects (`[-_]event$` verbs).
is_event_mutation_tool if is_delete_tool

is_event_mutation_tool if is_manage_event

is_event_mutation_tool if endswith(normalized_name, "create_event")

is_event_mutation_tool if endswith(normalized_name, "update_event")

# --- Identity ----------------------------------------------------------------
# `calendar-admins` is a placeholder group name — replace it with your IdP's
# group at import time. Missing claims fail closed: no groups, no exemption.
is_calendar_admin if {
    claims := object.get(object.get(input, "subject", {}), "claims", {})
    some g in object.get(claims, "groups", [])
    lower(g) == "calendar-admins"
}

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

# manage_event's operation selector. Only usable when it is a non-empty string;
# anything else fails closed via the deny rules below.
action_raw := object.get(args, "action", "")

manage_action := lower(action_raw) if is_string(action_raw)

has_usable_action if {
    is_string(action_raw)
    action_raw != ""
}

# Destructive-action verbs on the consolidated manage_event tool. The exact
# enum is unverified (see Known limitations), so we match a set of destructive
# synonyms as a substring rather than trusting only the literal `delete` — a
# `cancel`/`remove`/`purge` action is as irreversible as a delete.
destructive_action_verbs := {"delete", "remove", "cancel", "trash", "purge", "destroy"}

is_destructive_action if {
    some verb in destructive_action_verbs
    contains(manage_action, verb)
}

# Recurring-series blast radius. Servers spell this argument key differently
# (`modificationScope`, `modification_scope`, and plausibly kebab/Pascal/all-
# lowercase variants), so we normalize the KEY exactly as we normalize tool
# names — lowercase and strip `-`/`_` — and collect every value whose normalized
# key is `modificationscope`. Inspecting EVERY matching key (not a first-key-wins
# precedence) means a caller cannot pair a safe value under one spelling with a
# series-wide value under another to slip past a server that reads the other
# spelling.
scope_values := [v |
    some k, raw in args
    replace(replace(lower(k), "-", ""), "_", "") == "modificationscope"
    is_string(raw)
    raw != ""
    v := raw
]

has_usable_scope if count(scope_values) > 0

# Scope values that touch exactly one instance (normalized). Anything else —
# `all`, `thisandfollowing`, `future`, unknown spellings — is treated as
# series-wide and denied: a deliberate fail-closed allowlist.
single_instance_scopes := {"single", "thiseventonly"}

# Any provided scope value (across either key) that is not single-instance makes
# the change series-wide.
has_series_wide_scope if {
    some v in scope_values
    normalized := replace(replace(lower(v), "-", ""), "_", "")
    not single_instance_scopes[normalized]
}

# nspady's `futureStartDate` (also `future_start_date`, or any delimiter/case
# variant) is an alternate series blast-radius control ("this and following"
# from a split date); its presence means the mutation is not confined to a
# single instance, so treat it as series-wide too. The key is matched with the
# same normalization as the scope key, so no alternate spelling fails open.
has_future_start if {
    some k, v in args
    replace(replace(lower(k), "-", ""), "_", "") == "futurestartdate"
    is_string(v)
    v != ""
}

# --- Allow rules -------------------------------------------------------------

# Pass through every tool outside the event-mutation family (reads, freebusy,
# respond_to_event, and all non-Calendar tools).
allow if {
    not is_event_mutation_tool
}

# Allow event mutations only when no deny condition fired.
allow if {
    is_event_mutation_tool
    count(reasons) == 0
}

# --- Deny reasons ------------------------------------------------------------

# 1. Dedicated delete tools are admin-only: deletes have no MCP-level undo.
reasons contains "Deleting calendar events through the agent is limited to the calendar-admins group because Google Calendar offers no MCP-level undo. Ask a calendar administrator to remove the event, or contact your IT team if you believe this is a false positive." if {
    is_delete_tool
    not is_calendar_admin
}

# 2. manage_event acting as a destructive op — same restriction as a dedicated
#    delete. Matches any destructive verb, not just the literal `delete`.
reasons contains "Deleting calendar events through the agent is limited to the calendar-admins group because Google Calendar offers no MCP-level undo. Ask a calendar administrator to remove the event, or contact your IT team if you believe this is a false positive." if {
    is_manage_event
    is_destructive_action
    not is_calendar_admin
}

# 3. manage_event with no usable action cannot be distinguished from a delete —
#    fail closed for non-admins.
reasons contains "This manage_event call did not include a usable action argument, so it cannot be distinguished from a delete and was denied. Retry with an explicit action such as create or update, or contact your IT team if you believe this is a false positive." if {
    is_manage_event
    not has_usable_action
    not is_calendar_admin
}

# 4. Explicit series-wide scope on any event mutation — denied for everyone,
#    including calendar-admins: series rewrites can silently wipe standing
#    meetings.
reasons contains "Series-wide recurring-event changes are blocked for all callers because they can silently move or wipe standing meetings with no MCP-level undo. Retry with modificationScope set to a single instance, or make series-wide changes in the Google Calendar UI." if {
    is_event_mutation_tool
    has_series_wide_scope
}

# 4b. A `futureStartDate` (this-and-following split) is series-wide too — denied
#     for everyone.
reasons contains "Series-wide recurring-event changes are blocked for all callers because they can silently move or wipe standing meetings with no MCP-level undo. Retry with modificationScope set to a single instance, or make series-wide changes in the Google Calendar UI." if {
    is_event_mutation_tool
    has_future_start
}

# 5. manage_event without a usable modificationScope has an unverifiable series
#    blast radius — fail closed for everyone.
reasons contains "This manage_event call did not include a usable modificationScope argument, so its recurring-series blast radius cannot be verified and it was denied. Retry with modificationScope set to a single instance, or contact your IT team if you believe this is a false positive." if {
    is_manage_event
    not has_usable_scope
}

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)