---
name: Freeze Destructive Microsoft 365 Operations
tags:
  - ms365
  - freeze-destructive-ops
  - record-integrity
  - ingress
  - sox
  - soc2
publishedAt: 2026-07-12
description: |
  # ms365 / freeze-destructive-ops

  **Direction:** ingress (`tool_pre_invoke`)
  **Default:** deny on match, allow otherwise
  **Package:** `ms365.ingress.freeze_destructive_ops`

  ## What it does

  Denies every Microsoft 365 tool call whose verb segment is `delete-` or `cancel-` unless the caller's IdP token carries the placeholder group `m365-admin`. All other tool calls pass through unchanged.

  Deletes and cancels are the irreversible-leaning end of the M365 tool surface: a deleted mail message, OneDrive file, group, Excel range, or SharePoint list item may be unrecoverable, and a cancelled calendar event notifies every attendee. An agent acting on a hallucinated instruction or an injected prompt must not be able to destroy records — so the destructive verb family is frozen for everyone except an explicitly designated admin group. The check runs at ingress, before the call reaches the MCP server, so a blocked delete never executes.

  Verified tools this catches (softeria `ms-365-mcp-server`, observed live behind a gateway with the `ms365-` prefix): `delete-mail-message`, `delete-mail-folder`, `delete-onedrive-file`, `delete-drive-item-permission`, `delete-excel-range`, `delete-excel-table-row`, `delete-sharepoint-list-item`, `delete-sharepoint-list-column`, `delete-group`, `delete-team-channel`, `delete-calendar`, `delete-calendar-event`, `delete-online-meeting`, `delete-subscription`, `delete-mail-rule`, `delete-onenote-page`, `delete-todo-task`, `cancel-calendar-event`. The verb matcher is deliberately broader than this list: any current or future tool whose verb segment is `delete-` or `cancel-` (e.g. `delete-outlook-contact`, `delete-planner-bucket`, `delete-contact-folder`) is gated without a policy update.

  ## Compliance alignment

  - **SOX §802 / 18 U.S.C. §1519** — supports the anti-destruction/alteration-of-records requirement: financial records on the agent channel cannot be deleted by a non-admin caller, whether by agent error or by injected instruction. **Rule 2-06** — supports retention and legal-hold posture on the same paths.
  - **SOX EUC/spreadsheet integrity** — `delete-excel-range` and `delete-excel-table-row` on SOX-critical workbooks are admin-gated.
  - **SOC 2 PI1.5** — supports integrity of stored records by removing the agent's unilateral ability to destroy them.
  - **HIPAA §164.312(c)** — supports the integrity standard (protection of ePHI from improper destruction); **§164.530(c)** — supports privacy safeguards over records held in mailboxes and drives.
  - **GDPR Art. 5(1)(d)** — supports accuracy by preventing mass-deletion/corruption of personal-data records through the agent channel.

  ## Tool name matching

  The softeria server names tools verb-first (`delete-mail-message`, `cancel-calendar-event`), and the DTwo gateway prepends the configured MCP server name (observed live as `ms365-`). Because the prefix is deployment-specific, the policy matches the verb segment rather than exact names, case-insensitively:

  - contains `-delete-` or `-cancel-` (prefixed deployments, e.g. `ms365-delete-mail-message`)
  - starts with `delete-` or `cancel-` (unprefixed/local deployments)

  Underscores in the tool name are normalized to hyphens before matching, so a snake_case-named variant (`delete_mail_message`) is gated too. The name is read from both the PARC field (`input.resource.name`) and the legacy alias (`input.payload.name`) via `object.get` chains, and the two are matched **independently** — a request missing the `resource` block, or one carrying a malformed (non-string) value in either field, still cannot skip the match. Each field is coerced to a lowercased string (a number, null, array, or object resolves to the empty string), so a non-string value in one field can never suppress a genuine `delete-`/`cancel-` verb in the other.

  Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. The match is substring-based, so a hypothetical tool with `delete`/`cancel` in a noun position would also be gated — for a record-integrity policy, over-matching is the safe direction.

  ## Argument shape

  None. The decision uses only the tool name (`input.resource.name`, with the legacy `input.payload.name` as fallback) and the caller's identity (`input.subject.claims.groups`); arguments are not inspected.

  Group membership is read via `object.get`-chained access to `input.subject.claims.groups` and fails closed: a missing subject, missing claims, missing `groups`, or a non-array `groups` value all mean "not admin", so the destructive call is denied.

  ## Examples

  ### Allowed — read tool, any caller

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "ms365-list-mail-messages", "type": "tool" },
      "payload": { "name": "ms365-list-mail-messages", "args": {} }
    }
  }
  ```

  `allow = true`, no reason.

  ### Allowed — delete by an admin

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "ms365-delete-mail-message", "type": "tool" },
      "subject": { "sub": "admin@example.com", "claims": { "groups": ["m365-admin"] } },
      "payload": { "name": "ms365-delete-mail-message", "args": { "messageId": "AAMk..." } }
    }
  }
  ```

  `allow = true`.

  ### Denied — delete by a non-admin

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "ms365-delete-onedrive-file", "type": "tool" },
      "subject": { "sub": "user@example.com", "claims": { "groups": ["finance"] } },
      "payload": { "name": "ms365-delete-onedrive-file", "args": { "driveId": "b!x", "driveItemId": "01A" } }
    }
  }
  ```

  `allow = false`, `reason = "This delete/cancel operation is blocked because it is irreversible (...)"`.

  ## Composition

  This policy is single-purpose: it freezes the `delete-`/`cancel-` verb family and nothing else. Pair it with:

  - a role-gate policy for the wider write surface (`remove-group-member`, `remove-team-member`, `add-group-owner`, `upload-file-content` overwrites, `clear-excel-range`) — those verbs are destructive-adjacent but intentionally out of scope here,
  - an ingress deny on `graph-batch` (and, for Lokka deployments, on non-GET `Lokka-Microsoft` calls) — arbitrary Graph passthrough can issue DELETE requests without ever touching a `delete-*` tool name,
  - a mail-rule/subscription tampering policy (`create-mail-rule`, `update-mail-rule`, `create-subscription`).

  ## Known limitations

  - **Group names are placeholders — replace `m365-admin` with your IdP's group name at import time.** The gate reads `input.subject.claims.groups`; confirm your IdP actually emits a `groups` claim (Auth0 and Entra ID both require explicit configuration) before relying on the admin exemption. With no `groups` claim, the policy still fails closed: destructive calls are denied for everyone.
  - **`graph-batch` and Lokka bypass name matching.** A batched Graph request or Lokka's single passthrough tool can perform DELETE operations without a `delete-*` tool name. Gate those tools with the companion policies above.
  - **Destruction by overwrite is not covered.** `update-*` tools and `upload-file-content` can effectively destroy content by replacing it; `clear-excel-range` and `remove-*` membership tools are also out of scope. Those belong to the write-gating companion policy — this policy stays single-job on the delete/cancel verb family.
  - **Anthropic's hosted M365 connector is out of reach.** It runs Anthropic-hosted and does not traverse a customer gateway, so no gateway policy applies to it.
  - **Recoverability varies by workload.** Some deletes go to recoverable-items/recycle-bin stages with retention windows; others (permission deletes, Excel ranges, subscriptions) are effectively immediate. The policy treats the whole verb family as irreversible rather than modeling per-workload recovery.

  > **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:
  - sox
  - soc2
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```rego
package ms365.ingress.freeze_destructive_ops

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

# Placeholder IdP group allowed to run delete/cancel operations.
# Replace "m365-admin" with your IdP's group name at import time.
admin_group := "m365-admin"

# --- Destructive verb matching ---
# The softeria ms-365-mcp-server names tools verb-first (delete-mail-message,
# cancel-calendar-event) and the gateway prepends the configured MCP server
# name (observed live as `ms365-`), so the verb segment appears after a
# hyphen in prefixed deployments and at the start in unprefixed ones.
# Verify the exact names on your gateway with the dump-input debug technique.

# Tool name is read via object.get chains from BOTH the PARC field
# (input.resource.name) and the legacy alias (input.payload.name), so a
# request that somehow omits the resource block still cannot skip matching
# (red-team hardening: missing resource must not fail open).
# name_of coerces to a lowercased string. A missing OR non-string value
# (number, null, array, object) resolves to "" rather than leaving the rule
# undefined — critical, because an undefined name would make the set literal
# in is_destructive_tool undefined and skip matching entirely (fail-open).
name_of(key) := lower(v) if {
    v := object.get(object.get(input, key, {}), "name", "")
    is_string(v)
}

name_of(key) := "" if {
    v := object.get(object.get(input, key, {}), "name", "")
    not is_string(v)
}

resource_name := name_of("resource")

payload_name := name_of("payload")

# Both names are checked independently. Reading the two fields into a set and
# iterating would re-couple them; keeping separate branches means a malformed
# (non-string) value in one field cannot suppress a real delete/cancel verb in
# the other.
is_destructive_tool if {
    # Normalize underscores to hyphens so a snake_case-named variant of the
    # verb family (e.g. delete_mail_message) is still gated. Over-matching
    # is the safe direction for a record-integrity freeze.
    destructive_verb(replace(resource_name, "_", "-"))
}

is_destructive_tool if {
    destructive_verb(replace(payload_name, "_", "-"))
}

# Verb segment after a hyphen (prefixed deployments, e.g. ms365-delete-*).
destructive_verb(name) if contains(name, "-delete-")

destructive_verb(name) if contains(name, "-cancel-")

# Verb segment at the start (unprefixed/local deployments).
destructive_verb(name) if startswith(name, "delete-")

destructive_verb(name) if startswith(name, "cancel-")

# --- Admin gate ---
# Reads the groups claim through object.get chains so a missing subject,
# missing claims, missing groups, or non-array groups value fails closed:
# the caller is simply not an admin and the destructive call is denied.
caller_is_admin if {
    claims := object.get(input.subject, "claims", {})
    groups := object.get(claims, "groups", [])
    some group in groups
    group == admin_group
}

# Allow any tool outside the delete/cancel verb family.
allow if {
    not is_destructive_tool
}

# Allow delete/cancel tools only for members of the admin group.
allow if {
    is_destructive_tool
    caller_is_admin
}

reasons contains "This delete/cancel operation is blocked because it is irreversible: records must survive agent error and prompt injection. Move the item to another folder or archive it instead of deleting. For legitimate admin cleanup, ask a member of your Microsoft 365 admin group (placeholder: m365-admin) to run it, or ask your InfoSec team to add you to that group." if {
    is_destructive_tool
    not caller_is_admin
}

reason := joined if {
    count(reasons) > 0
    reason_list := sort([r | some r in reasons])
    joined := concat("; ", reason_list)
}
```
