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