Exclude Personal Drives from Agent Reads
metadata lookups and unparseable governed responses)
On this page
What it does
Keeps the personal Drive of anyone at the company out of agent reach,
leaving shared-drive material readable. Agents searching or browsing
Drive through the gateway see only material held in shared drives:
anything sitting in an individual's own Drive is removed from search and
recent-file listings, and a direct metadata lookup on such a file is
refused outright. Anything withheld from a listing is marked with a
notice, so the agent can tell files were held back by policy rather
than absent from Drive.
Ownership is the signal. Files held in a shared drive come back with
no owner field; files in someone's personal Drive carry one. The policy
withholds any file whose owner is at a listed domain, which covers every
colleague's personal Drive as well as the operator's own, at any folder
depth, with no maintenance as people join. This holds for a colleague's
file shared into the caller's Drive too — that file still lives in the
colleague's personal Drive.
The personal_owner_domains entry (example.com) is a placeholder —
replace it with your own workspace domain(s) at import time.
Tool name matching
Only the Drive tools whose responses identify the file they describe are
governed, matched case-insensitively by suffix on input.resource.name
in both naming styles:
*-search-files,*-list-recent-files,*-get-file-metadata— Google official Drive MCP server (kebab-case tool names).*-search_files,*-list_recent_files,*-get_file_metadata— snake_case variant.
The content tools (read-file-content, download-file-content) are
deliberately absent: their responses carry only file content, with
nothing identifying whose Drive the file sits in, so this policy has
nothing to test them against (see Known limitations).
Response shape
- search-files / list-recent-files: a top-level
filesarray — excluded entries are dropped, the rest returned, and thenoticeadded. Unchanged blocks pass through byte-identical. - get-file-metadata: one flat file object rather than a list. There is nothing to filter a single record down to, so an excluded file is denied outright with a reason.
A governed response that carries content but where no block parses as JSON is denied rather than returned unfiltered (the signature of an upstream schema change).
Examples
With personal_owner_domains := {"example.com"}, a search returning a
board deck owned by alice@example.com alongside a shared-drive handbook
comes back with only the handbook plus the notice; get-file-metadata on
the board deck is refused. The @ is part of the compared suffix, so a
lookalike domain such as notexample.com does not match.
Composition
apps/google-drive/cap-bulk-export— volume control on the same listing surface.apps/google-drive/redact-pii-egress— content-level redaction for the read path this policy deliberately leaves open.apps/google-drive/fence-restricted-folders— ID-based ingress fencing for specific files/folders, complementary to this ownership rule.
Known limitations
- Content reads are not governed. An agent already holding a file's ID can still read its contents — content responses carry no owner for the policy to test. Pair with an ingress fence (or gate content tools by ID) if that path matters in your environment.
- The owner field is the entire signal. A Drive surface that returns owner data under a different key, or omits it for personal files, defeats the test — verify with the dump-input technique.
- Domain-suffix matching is exact. Secondary workspace domains must each be listed.
- Validation status. Validated live against the Google official Drive MCP server (kebab-case names) behind a DTwo gateway; the snake_case variants come from documentation and have not been exercised against a live server.
Policy source (Rego)
package google_drive.egress.exclude_personal_drives
# Withholds Drive results that live in a listed domain's personal Drives,
# leaving shared-drive material readable.
# --- Configuration -----------------------------------------------------------
# Domains whose members' personal Drives are off limits. Placeholder — replace
# with your workspace domain(s) at import time; add entries to cover secondary
# domains.
personal_owner_domains := {"example.com"}
default allow := true
filter_notice := "Files in a personal Drive were removed by gateway policy."
# --- Scope ---------------------------------------------------------------------
# Only the Drive tools whose responses identify the file they describe are
# governed. Matched case-insensitively as a suffix including the separator:
# the gateway prefixes tool names with the MCP server's configured name
# (drive, gdrive, ...), and the leading hyphen stops an unrelated tool whose
# name merely ends in these characters from matching. Both kebab-case and
# snake_case vocabularies are covered.
#
# The content tools are deliberately absent: their responses carry only file
# content, with no owner, so this policy has nothing to test them against.
drive_located_suffixes := {
"-search-files",
"-search_files",
"-list-recent-files",
"-list_recent_files",
"-get-file-metadata",
"-get_file_metadata",
}
metadata_suffixes := {
"-get-file-metadata",
"-get_file_metadata",
}
tool_name := lower(object.get(object.get(input, "resource", {}), "name", ""))
is_governed if {
some suffix in drive_located_suffixes
endswith(tool_name, suffix)
}
is_metadata_tool if {
some suffix in metadata_suffixes
endswith(tool_name, suffix)
}
# --- Exclusion test ---------------------------------------------------------------
# An `owner` field means the file sits in that person's personal Drive: files
# held in a shared drive come back with no owner at all. Matching on the domain
# rather than a list of addresses covers every person at the company, including
# ones who join later, and reaches any depth of folder without enumerating one.
#
# This holds for a colleague's file shared into the caller's Drive as well —
# such a file still lives in the colleague's personal Drive, so it is withheld
# too.
#
# The "@" is part of the compared suffix so that a lookalike domain such as
# notexample.com does not match.
is_excluded(f) if {
raw := object.get(f, "owner", "")
is_string(raw)
addr := lower(raw)
some domain in personal_owner_domains
endswith(addr, concat("", ["@", lower(domain)]))
}
# --- Response payload ------------------------------------------------------------
response_payload := object.get(input, "payload", {})
text_blocks := object.get(response_payload, "text", [])
# --- Per-block filtering -----------------------------------------------------------
# search-files / list-recent-files shape: files[]. Excluded entries are dropped
# and the rest returned. Defined only when something actually changes, so an
# unaffected block passes through byte-identical via kept_block below.
filtered_block(b) := out if {
is_governed
is_string(b)
parsed := json.unmarshal(b)
is_object(parsed)
files_in := object.get(parsed, "files", [])
is_array(files_in)
count(files_in) > 0
kept := [f |
some f in files_in
not is_excluded(f)
]
count(kept) < count(files_in)
out := json.marshal(object.union(parsed, {
"files": kept,
"notice": filter_notice,
}))
}
kept_block(b) := filtered_block(b)
kept_block(b) := b if {
not filtered_block(b)
}
kept_blocks := [out |
some block in text_blocks
out := kept_block(block)
]
transform := {
"transformed_payload": object.union(response_payload, {"text": kept_blocks}),
} if {
input.mode == "output"
is_governed
is_array(text_blocks)
kept_blocks != text_blocks
}
# --- Deny path -------------------------------------------------------------------
# get-file-metadata returns one flat file object rather than a files[] list.
# There is nothing to filter down to, so an excluded file is denied outright.
metadata_is_excluded if {
is_metadata_tool
some b in text_blocks
is_string(b)
parsed := json.unmarshal(b)
is_object(parsed)
count(object.get(parsed, "files", [])) == 0
is_excluded(parsed)
}
allow := false if {
metadata_is_excluded
}
# A governed response that carries content but where nothing parses cannot be
# filtered at all. That is the signature of an upstream schema change, so it is
# denied rather than returned unfiltered.
parseable_count := count([b |
is_governed
some b in text_blocks
is_string(b)
obj := json.unmarshal(b)
is_object(obj)
])
allow := false if {
is_governed
count(text_blocks) > 0
parseable_count == 0
}
reason := "Blocked: this Drive result is in a personal Drive, or could not be inspected to determine whose Drive it is in." if not allow Canonical source: policy.md on GitHub · raw · raw on this site (.md)