dtwo Policy Store

Redact Attendee PII and Meeting Links in Calendar Reads

Scrubs sensitive fields from the responses of Google Calendar read tools before they reach the agent, for callers who lack the placeholder calendar-full-read…

Direction
egress
Rego package
google_calendar.egress.redact_attendee_pii
App
google-calendar
Bundles
soc2hipaagdpr-ccpa
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:915129cd94fef27c4fc7a88e9ebc024996de582aa6d4639bab2f6be3a2ad7a33

google-calendarredact-piipiiphidlpredactionegresssoc2hipaagdpr-ccpa

What this policy does

Direction: egress (tool_post_invoke) Default: allow (transform-only — never blocks the read) Package: google_calendar.egress.redact_attendee_pii

What it does

Scrubs sensitive fields from the responses of Google Calendar read tools before they reach the agent, for callers who lack the placeholder calendar-full-read IdP group. It is a response transform, not a block: the read still executes and returns, but what the agent sees is redacted.

It redacts three classes of content:

  1. Attendee identifiers — the email field wherever it appears (attendees[].email, organizer.email, creator.email), the displayName field on those same objects (an attendee's or organizer's name is attendee PII too and would otherwise survive email-only redaction), and flat attendeeEmails[] arrays (the shape suggest_time-style tools use).
  2. Meeting join links — the whole conferenceData object is removed (its entryPoints[].uri values are live meeting links that grant join access to anyone who reads them), and the top-level hangoutLink field is redacted too — Google populates hangoutLink with the Meet URL independently of conferenceData, so a link would otherwise survive when only conferenceData is stripped. A conservative redact_patterns entry also catches conferencing URLs (Meet / Zoom / Teams / Webex hosts) pasted into description or location free text.
  3. Free-text PII/PHI in description / location / summary bodies — matched by conservative regex (SSN, email, phone, and a small set of health-context terms). The Calendar landscape note observes these bodies routinely carry health appointments, candidate interviews, and M&A meeting names.

Because nspady/google-calendar-mcp supports multi-account merge, a single read can span every calendar the OAuth grant covers — so egress scrubbing enforces minimum-necessary against that widened blast radius, not just the caller's own calendar.

Why egress and not ingress

The sensitive data lives in the response, not the request: a read tool's arguments (timeMin, calendarId, a search query) don't reveal attendee lists, meeting URLs, or private event bodies — only the returned events do. Ingress can't see what a read will surface, so redaction has to happen on the way back. The read itself is harmless and is allowed to proceed.

Compliance alignment

  • SOC 2 CC6.7 — supports the restriction on transmission/movement of confidential information by masking attendee PII, meeting links, and health/deal context on the agent read path (PF-02). C1.1 / P4.1 / P6.1 — supports identifying and protecting confidential info, limiting personal information to identified purposes, and constraining PI disclosure to third parties (here, the agent) — all Partial on the MCP path.
  • HIPAA §164.502(b) / §164.514(d) — supports the minimum-necessary standard by returning only the non-identifying slice of a calendar read to callers outside the calendar-full-read group. §164.514(a)–(b) — supports de-identification by stripping Safe-Harbor identifier classes (email, phone, and health-context free text). §164.530(c) — supports administrative privacy safeguards on the agent channel.
  • GDPR Art. 5(1)(c) — supports data minimisation by scrubbing identifiers not needed for the agent's task. Art. 9 — supports the special-category (health) restriction via the PHI-context patterns. Art. 5(1)(f) / Art. 32 — supports security of processing. CCPA/CPRA §1798.121 — supports the consumer right to limit use of sensitive personal information; §1798.150 — reduces nonredacted-PI breach exposure.

Tool name matching

Calendar read tools across the four servers in scope share an [-_]events?$ suffix, so matching is suffix-based for portability rather than pinned to exact fully-qualified names (the gateway prepends its own configured server-name prefix, which is not standardized):

  • list_events / list-events / get_events (Google, community, taylorwilsdon)
  • get_event / get-event (Google, nspady)
  • search-events (nspady)
  • gcal_list_events (Claude connector — also ends in _events)

A second rule matches the Claude connector's gcal_ segment ((^|[-_])gcal_) so connector reads are covered even if a future connector tool name doesn't end in the events? suffix. Verify the exact tool name your gateway sends with the dump-input debug technique before relying on this in production.

The policy is scoped to the egress path when either input.mode == "output" or input.action == "tool_post_invoke" holds, so redaction still fires on a gateway build that populates only one of the two (keying on mode alone would fail open if it were unset). The tool name is read from all three egress surfaces — input.resource.name, input.tool_metadata.name, and input.payload.name — and a calendar-read hit on any of them puts the call in scope, so a gateway that populates a different surface can't slip a read past the scanner.

Argument / response shape

This is an egress policy: it inspects nothing in the request. Redaction is expressed structurally (redact_fields on JSON key names, applied recursively and case-insensitively) plus redact_patterns (regex over the serialized response text). redact_fields catches the structured attendee and conference fields regardless of nesting; redact_patterns catches PII/PHI that appears in free-text bodies.

Examples

Redacted (caller lacks calendar-full-read)

{
  "input": {
    "action": "tool_post_invoke",
    "mode": "output",
    "resource": { "name": "google-calendar-mcp-list-events", "type": "tool" },
    "subject": { "sub": "google-apps|agent@dtwo.ai", "claims": { "groups": ["sales"] } },
    "payload": {
      "name": "google-calendar-mcp-list-events",
      "text": ["{\"attendees\":[{\"email\":\"cfo@target.com\"}],\"summary\":\"Project Atlas M&A sync\"}"]
    }
  }
}

allow = true, transform present — email / attendeeEmails / conferenceData fields and any matching PII/PHI substrings are replaced with [REDACTED].

Passed through unredacted (caller in calendar-full-read)

{
  "input": {
    "action": "tool_post_invoke",
    "mode": "output",
    "resource": { "name": "google-calendar-mcp-list-events", "type": "tool" },
    "subject": { "sub": "google-apps|exec@dtwo.ai", "claims": { "groups": ["calendar-full-read"] } },
    "payload": { "name": "google-calendar-mcp-list-events", "text": ["{}"] }
  }
}

allow = true, no transform — the exempt group sees the full response.

Untouched (non-event tool)

A list-calendars, get-freebusy, or manage-accounts response does not end in the events? suffix and carries no gcal_ segment, so transform is undefined and the aggregator skips this policy for that call.

Composition

Single-purpose. Useful companions from the Calendar candidate set:

  • An ingress external-attendee guard on create-event / update-event so the write side is controlled too.
  • An ingress sendUpdates transform that defaults agent writes to silent.
  • A generic egress PAN mask (PF-01) if calendar bodies ever carry card data.

These stay separate policies so each is independently testable; egress transforms attached to the same direction compose in pipeline order.

Known limitations

  • Group names are placeholders — replace calendar-full-read with your IdP's group name at import time. The exemption reads input.subject.claims.groups via object.get chains; if the gateway has no IdP configured or the claim is absent, the caller is treated as not exempt and the response is scrubbed (fail-closed for the grant). The exemption is granted only when groups is an array of strings (a single bare string is also handled). Any other shape fails closed → redaction applies: a missing subject/claims/groups, and — critically — an object/map claim such as {"role": "calendar-full-read"} (the is_array guard stops its values from being read as group names). If your IdP emits roles under a namespaced claim, adjust caller_groups to point at the array before matching.
  • Regex over serialized text, not field-scoped. redact_patterns runs byte-level over the whole response, so PII/PHI is caught wherever it appears, not only in description/location/summary. Phone/SSN patterns are anchored with separators and word boundaries to avoid eating the RFC3339 timestamps that fill calendar payloads, but tune them against representative data before publishing.
  • Free-text meeting-link coverage is host-scoped. The structured conferenceData and hangoutLink fields are always removed, but a join URL pasted into description/location free text is only caught if its host matches the conferencing allowlist in redact_patterns (meet.google.com, zoom.us, teams.microsoft.com, webex.com). Links on other conferencing hosts (or bare goo.gl/bit.ly shorteners) in free text are not matched — add their hosts to the pattern for your environment.
  • Semantic content is not fully caught. A regex cannot reliably recognize "candidate interview" or an M&A code name as sensitive; the health-context term list is a small, conservative signal and redacts only the matched term, not the surrounding sentence. Field-level redaction (attendee email / displayName, conference links) is the high-confidence part of this control; free-text pattern matching is best-effort. A person's name is only redacted where it sits in the structured displayName field — a name written into a summary/description free-text body (e.g. "1:1 with Jane Roe") is not caught unless it also trips a pattern.
  • Free/busy reads are out of scope (residual attendee-email leak). The tool rule matches only the [-_]events?$ and gcal_ families, so availability tools — nspady get-freebusy, taylorwilsdon query_freebusy, the official suggest_time — match neither branch and emit no transform. Their responses key busy blocks by calendar ID, which for a person calendar is an email address ({"calendars":{"a@corp.com":...}}), so a non-calendar-full-read caller sees those addresses unscrubbed. The leak is bounded (the caller supplied those IDs in the request, and the Calendar landscape note does not list free/busy among the attendee-list leak channels), so it is documented rather than force-fit into an events-shaped matcher. If free/busy exposure matters in your environment, add a free[-_]?busy branch to is_calendar_read_tool — the email redact_patterns entry then scrubs the calendar-ID keys.
  • gcal_ prefix over-matches by design. The connector rule also matches reads like gcal_find_my_free_time; those responses carry no attendee or conference fields, so redaction is a harmless no-op there.
  • The [-_]events?$ suffix also matches write/destructive event tools. create-event / create_event, update-event, delete-event, respond-to-event, and the consolidated manage_event all end in -event, so their responses are scrubbed on egress too. This is intentional and harmless: the policy is transform-only and never blocks the write — it only masks attendee PII, join links, and PHI/PII free text in the echoed-back event, which is consistent with minimum-necessary. Control the write path with a separate ingress policy (see Composition); this policy governs only what a non-calendar-full-read caller sees returned.
  • Unverified connector tools. Beyond gcal_list_events / gcal_find_my_free_time, Anthropic does not publish the connector's full tool list (per the landscape note); any other gcal_* read is matched by the prefix rule but its response shape is unverified.
  • Output shape assumption. Redaction assumes the tool returns JSON (or JSON-ish text) in payload.text. If a server returns an unusual envelope, confirm the shape with the dump-input technique.

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.egress.redact_attendee_pii

# Transform-only egress policy: it never blocks the read, it only scrubs the
# response. Default allow is true so unrelated tools pass through untouched and
# a missing transform condition means "nothing to redact", not "deny".
default allow := true

# --- Egress scope -------------------------------------------------------------
# Match the post-invoke/output path on EITHER mode or action. Keying on
# input.mode alone would fail open (no redaction) on a gateway build that leaves
# mode unset; requiring either keeps the scanner from silently no-opping.
# Ingress (tool_pre_invoke / mode "input") satisfies neither branch.
is_egress if {
	input.mode == "output"
}

is_egress if {
	input.action == "tool_post_invoke"
}

# --- Tool matching ------------------------------------------------------------
# The tool name is exposed on egress under resource.name (PARC),
# tool_metadata.name (legacy), and payload.name (tool-hook canonical). Collect
# all three (lower-cased) and match if ANY carries a calendar-read signature, so
# a gateway that populates a different surface can't slip a read past the
# scanner. object.get chains keep a missing surface from failing the rule.
candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", ""))

candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", ""))

candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", ""))

# Calendar read tools across the servers in scope share an [-_]events? suffix:
#   list_events / list-events / get_events (Google, community, taylorwilsdon)
#   get_event / get-event                  (Google, nspady)
#   search-events                          (nspady)
#   gcal_list_events                       (Claude connector — also ends _events)
# The gateway prepends its configured server-name prefix, so we match on the
# suffix, never on an exact fully-qualified name.
is_calendar_read_tool if {
	some n in candidate_names
	regex.match(`[-_]events?$`, n)
}

# The Claude connector prefixes its read tools with `gcal_`. Match that segment
# too, so connector reads are covered even if a future connector tool name does
# not end in the events? suffix.
is_calendar_read_tool if {
	some n in candidate_names
	regex.match(`(^|[-_])gcal_`, n)
}

# --- Identity exemption -------------------------------------------------------
# Callers whose IdP groups include the placeholder `calendar-full-read` see the
# unredacted response. object.get chains fail closed: no subject / no claims /
# no groups -> not exempt -> the response is scrubbed.
caller_groups := object.get(
	object.get(object.get(input, "subject", {}), "claims", {}),
	"groups",
	[],
)

# Only a clean array of group strings grants the exemption. The is_array guard
# is load-bearing: `some g in caller_groups` over an OBJECT iterates its values,
# so a namespaced/metadata claim like {"role": "calendar-full-read"} would else
# wrongly exempt the caller. is_string(g) keeps nested/non-string elements out.
# Anything but an array of strings fails closed -> redaction applies.
caller_has_full_read if {
	is_array(caller_groups)
	some g in caller_groups
	is_string(g)
	lower(g) == "calendar-full-read"
}

# Some IdPs emit a single group as a bare string rather than an array.
caller_has_full_read if {
	is_string(caller_groups)
	lower(caller_groups) == "calendar-full-read"
}

# --- Redaction transform ------------------------------------------------------
# Applies only to calendar reads, only on egress, only for non-exempt callers.
# When any condition is false the rule is undefined and the aggregator skips it.
transform := {
	"redact_fields": [
		"email", # attendees[].email, organizer.email, creator.email
		"displayName", # attendees[].displayName / organizer.displayName / creator.displayName — a person name is attendee PII too and survives email-only redaction
		"attendeeEmails", # flat email arrays (suggest_time-style shapes)
		"conferenceData", # entryPoints[].uri join links = live meeting access
		"hangoutLink", # top-level Meet URL — populated independently of conferenceData
	],
	"redact_patterns": [
		`\b\d{3}-\d{2}-\d{4}\b`, # US SSN (word-bounded; not 4-2-2 date shape)
		`[\w.+-]+@[\w-]+\.[\w.-]+`, # email address in free-text bodies
		`\b\d{3}[-.\s]\d{3}[-.\s]\d{4}\b`, # NANP phone with separators
		`\+\d{6,15}\b`, # E.164 international phone
		`(?i)\b(?:diagnosis|prognosis|biopsy|chemo(?:therapy)?|oncolog\w*|psychiatr\w*|dialysis|colonoscopy|prescription)\b`, # PHI / health-context terms
		`(?i)https?://[\w.-]*(?:meet\.google\.com|zoom\.us|teams\.microsoft\.com|webex\.com)/\S*`, # conferencing join links pasted into description/location free text
	],
	"replacement": "[REDACTED]",
} if {
	is_egress
	is_calendar_read_tool
	not caller_has_full_read
}

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