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:
- Attendee identifiers — the
emailfield wherever it appears (attendees[].email,organizer.email,creator.email), thedisplayNamefield on those same objects (an attendee's or organizer's name is attendee PII too and would otherwise survive email-only redaction), and flatattendeeEmails[]arrays (the shapesuggest_time-style tools use). - Meeting join links — the whole
conferenceDataobject is removed (itsentryPoints[].urivalues are live meeting links that grant join access to anyone who reads them), and the top-levelhangoutLinkfield is redacted too — Google populateshangoutLinkwith the Meet URL independently ofconferenceData, so a link would otherwise survive when onlyconferenceDatais stripped. A conservativeredact_patternsentry also catches conferencing URLs (Meet / Zoom / Teams / Webex hosts) pasted intodescriptionorlocationfree text. - Free-text PII/PHI in
description/location/summarybodies — 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-readgroup. §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-eventso the write side is controlled too. - An ingress
sendUpdatestransform 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-readwith your IdP's group name at import time. The exemption readsinput.subject.claims.groupsviaobject.getchains; 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 whengroupsis 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"}(theis_arrayguard stops its values from being read as group names). If your IdP emits roles under a namespaced claim, adjustcaller_groupsto point at the array before matching. - Regex over serialized text, not field-scoped.
redact_patternsruns byte-level over the whole response, so PII/PHI is caught wherever it appears, not only indescription/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
conferenceDataandhangoutLinkfields are always removed, but a join URL pasted intodescription/locationfree text is only caught if its host matches the conferencing allowlist inredact_patterns(meet.google.com,zoom.us,teams.microsoft.com,webex.com). Links on other conferencing hosts (or baregoo.gl/bit.lyshorteners) 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 structureddisplayNamefield — a name written into asummary/descriptionfree-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?$andgcal_families, so availability tools — nspadyget-freebusy, taylorwilsdonquery_freebusy, the officialsuggest_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-readcaller 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 afree[-_]?busybranch tois_calendar_read_tool— the emailredact_patternsentry then scrubs the calendar-ID keys. gcal_prefix over-matches by design. The connector rule also matches reads likegcal_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 consolidatedmanage_eventall 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-readcaller 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 othergcal_*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)
Related policies
Airtable: Redact PII in Record Reads
Scans the responses of the Airtable record-read tools — the calls that return row fields values — and rewrites high-confidence PII shapes to a fixed…
Asana: Redact PII in Task & Comment Reads
On the Asana MCP read path, this transform scans the free-text business fields that ride back in task, comment/story, and status-update responses — notes,…
BigQuery: Redact PII in Query Results
Scans the content returned by BigQuery's result-returning tools and rewrites high-confidence PII shapes to fixed, non-recoverable redaction tokens before the…
Block Agent Email to External Recipients
Blocks agent-initiated Microsoft 365 email sends when any recipient address falls outside a corporate-domain allowlist.
Block BigQuery Exfiltration and Cross-Project Writes
Inspects the raw GoogleSQL string carried by BigQuery SQL tools and denies any statement that moves data out of the tenant's own project — even when the call…
bigqueryguard-warehouse-exportingresssqlexfiltrationsoc2pci-dssgdpr-ccpa
Block Bulk Export & External Staging (Snowflake)
Blocks Snowflake SQL-execution tool calls whose query text moves whole tables off the Snowflake perimeter — bulk export to cloud storage or a stage, and…
snowflakeguard-warehouse-sqlexportexfiltrationingresssoc2pci-dssgdpr-ccpa