--- name: Redact Attendee PII and Meeting Links in Calendar Reads tags: - google-calendar - redact-pii - pii - phi - dlp - redaction - egress - soc2 - hipaa - gdpr-ccpa publishedAt: 2026-07-12 description: | # google-calendar / redact-attendee-pii **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`) ```jsonc { "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`) ```jsonc { "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. direction: egress apps: - google-calendar industries: [] bundles: - soc2 - hipaa - gdpr-ccpa experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```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 } ```