dtwo Policy Store

Freeze Notion Full-Page Content Overwrites

Denies notion-update-page calls whose command argument is replace content — the one edge on Notion's hosted MCP server that overwrites a page's entire body…

Direction
ingress
Rego package
notion.ingress.freeze_content_overwrite
App
notion
Bundle
soc2
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:bdd14752bb4b39bd321e170e1024a0814a647479fd985b5050f1cf9697ac17e7

notionfreeze-destructive-opsrecord-integrityingresssoc2

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny on match, allow otherwise Package: notion.ingress.freeze_content_overwrite

What it does

Denies notion-update-page calls whose command argument is replace_content — the one edge on Notion's hosted MCP server that overwrites a page's entire body in a single call. The overwrite is recoverable only through Notion page history, and it happens silently from the agent's viewpoint: the tool reports success, and neither the agent nor the user sees that the previous content is gone. The additive commands pass through untouched, so agents can still append content (insert_content_after) and edit page properties (update_properties) without a human in the loop.

This freezes the only silently-destructive write in the hosted server's 18-tool surface — the hosted server exposes no delete, archive, or trash tool at all, so replace_content is where the PF-06 record-destruction risk lives on this target. A prompt-injected or simply mistaken agent that "cleans up" a page with replace_content destroys meeting notes, HR trackers, or finance runbooks in one call; with this policy attached, the worst it can do is append.

There is deliberately no identity exemption: page bodies must survive agent error and prompt injection regardless of who is driving the agent. The deny reason steers the agent to insert_content_after; a legitimate full rewrite belongs in the Notion UI, where page history and human eyes are both present.

Compliance alignment

  • SOC 2 PI1.5 — supports integrity of stored records by removing the agent's unilateral ability to replace a page's full body.
  • HIPAA §164.312(c) — supports the integrity standard (protection of ePHI recorded in Notion pages — care notes, intake trackers — from improper alteration/destruction); §164.530(c) — supports privacy safeguards over those records.
  • GDPR Art. 5(1)(d) — supports accuracy by preventing mass corruption of personal-data records: one replace_content call can wipe every fact a page holds about data subjects.

Tool name matching

The DTwo gateway prefixes tool names with the configured MCP server name (e.g. notion-notion-update-page for a server named notion), and that prefix is deployment-specific, so the policy matches case-insensitively by suffix:

  • *-update-page — the hosted server's notion-update-page (verified against Notion's supported-tools documentation).

The suffix also happens to match the legacy official local server's update-page once the gateway prefixes it (e.g. notion-update-page) — harmless, because that tool takes no command argument and therefore always passes (see Known limitations). Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production.

Argument shape

The hosted notion-update-page takes page_id, a command ∈ {replace_content, insert_content_after, update_properties}, and command-specific content payloads (verified from the landscape research as of mid-2026). Every read goes through object.get:

  • args.command is read as object.get(object.get(input.payload, "args", {}), "command", ""), then trimmed and lowercased before comparison, so Replace_Content, REPLACE_CONTENT, and whitespace-padded variants (replace_content, replace_content\n) cannot slip past a server that strips/normalizes the command before dispatch.
  • A call with no args object or no command key is not treated as an overwrite and passes through — the server itself rejects a malformed call; this policy only freezes confirmed full-body overwrites.
  • A non-string command (array, object, number) never compares equal to replace_content and passes through; the server's own schema validation rejects such calls anyway.

Examples

Allowed — appending content (the recommended alternative)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "notion-notion-update-page", "type": "tool" },
    "payload": {
      "name": "notion-notion-update-page",
      "args": {
        "page_id": "1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809",
        "command": "insert_content_after",
        "new_str": "## Follow-ups\n- Circulate the draft"
      }
    }
  }
}

allow = true, no reason.

Denied — full-page overwrite

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "notion-notion-update-page", "type": "tool" },
    "payload": {
      "name": "notion-notion-update-page",
      "args": {
        "page_id": "1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809",
        "command": "replace_content",
        "new_str": "Cleaned up!"
      }
    }
  }
}

allow = false, reason = "Full-page overwrites are blocked (...)".

Allowed — property edit on the same tool

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "notion-notion-update-page", "type": "tool" },
    "payload": {
      "name": "notion-notion-update-page",
      "args": {
        "page_id": "1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809",
        "command": "update_properties",
        "properties": { "Status": "In review" }
      }
    }
  }
}

allow = true — property edits are additive-class and left for other policies to govern.

Composition

This policy is single-purpose: it freezes the full-body overwrite command and nothing else. Pair it with:

  • a structural-write role gate on -move-pages, -update-data-source, and -update-view — schema rewrites and workspace restructuring are the other alteration surfaces on the hosted server, and they are intentionally out of scope here,
  • a directory-harvest gate on -get-users, which returns workspace member and guest emails,
  • an egress redaction policy on the read surface (-search, -fetch, -query-data-sources) for regulated data leaving the workspace.

Known limitations

  • Hosted-server scope — by design. This policy targets Notion's hosted MCP server (the surface behind the Claude connector), which has no delete/archive tool; replace_content is its only silently-destructive write. The legacy official local server's delete-block and update-page-markdown, and the awkoy community server's archive/delete operations behind its notion_execute meta-tool, are different surfaces and are not covered — prefer blocking those servers in gateway config and standardizing on the hosted target.
  • Legacy local update-page matches the suffix but always passes. Behind a server named notion, the legacy local server's update-page appears as notion-update-page and matches *-update-page — but it takes no command argument, so this policy never denies it. Its content-overwrite sibling update-page-markdown does not match the suffix and is out of scope per the previous point.
  • No identity exemption — by design. There is no group that may overwrite page bodies through the agent channel; records must survive agent error and prompt injection for every caller. Legitimate full rewrites belong in the Notion UI. If your organization truly requires an agent-channel break-glass, add a groups-gated allow branch per the identity-placeholder conventions — but understand it reopens the injection surface this policy closes.
  • Page history is the recovery path, not a guarantee. Notion page history has plan-dependent retention (shorter on lower plans). This policy prevents the overwrite from happening at all, which is stronger — but anything that does slip through a misconfigured deployment depends on history retention for recovery.
  • Appending is still writing. insert_content_after can append misleading or injected content; update_properties can flip statuses and retitle pages. Those are visible, reversible edits — governed by companion policies, not this one.
  • Command normalization covers case + surrounding whitespace only. The command value is trim_space-d and lowercased, defeating casing and padding tricks. It does not normalize Unicode homoglyphs, zero-width characters, or interior whitespace (e.g. replace _content). Such a value passes this policy — but it also fails the hosted server's exact-string command dispatch, so no overwrite occurs. If a future server variant does fuzzy command matching, extend requested_command accordingly.
  • Malformed calls fail open here. A -update-page call with a missing or non-string command passes the policy and is left for the server's own schema validation to reject. This is intentional (object.get defaults): a benign call is never misclassified as an overwrite.
  • Argument vocabulary is research-verified as of mid-2026. The command key and its three values come from the landscape research against Notion API version 2026-03-11. If Notion adds another destructive command value, it would pass this policy until the matcher is extended.

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 notion.ingress.freeze_content_overwrite

# Deny-by-default: only the explicit allow rule below permits the request.
default allow := false

# --- Tool matching ---
# Hosted Notion MCP update tool is `notion-update-page`; the gateway prepends
# the configured MCP server name (e.g. `notion-notion-update-page`), so match
# by suffix, case-insensitively. Verify the exact name your gateway sends with
# the dump-input debug technique.
is_update_page_tool if {
    endswith(lower(input.resource.name), "-update-page")
}

# Safe read of the `command` argument: a missing args object or a missing
# command key yields "" (never treated as an overwrite). The value is trimmed
# and lowercased before comparison, so `Replace_Content`, `REPLACE_CONTENT`,
# or a whitespace-padded ` replace_content `/`replace_content\n` cannot slip
# past a server that strips/normalizes the command before dispatch. A
# non-string command leaves requested_command undefined, which also means
# "not an overwrite" — the server's schema validation rejects such calls.
requested_command := lower(trim_space(command_value)) if {
    command_value := object.get(object.get(input.payload, "args", {}), "command", "")
    is_string(command_value)
}

# `replace_content` overwrites the entire page body — recoverable only via
# Notion page history, and silently from the agent's viewpoint. The additive
# commands (`insert_content_after`, `update_properties`) are not matched.
is_content_overwrite if {
    is_update_page_tool
    requested_command == "replace_content"
}

# Allow every call that is not a confirmed full-page overwrite.
allow if {
    not is_content_overwrite
}

reason := "Full-page overwrites are blocked: notion-update-page with command \"replace_content\" replaces the entire page body, recoverable only through Notion page history and invisibly from the agent's viewpoint. Append with command \"insert_content_after\" instead, or make the full rewrite by hand in the Notion UI. Contact your InfoSec team if a full overwrite is legitimately required." if {
    not allow
}

Canonical source: policy.md on GitHub · raw · raw on this site (.md)