Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions packages/gatekeeper-google/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ You'll need to enable the Google APIs that you want to use. Currently supported:
18. Click on **BigQuery API** in the results
19. Click **Enable**

The Google Drive API powers the Docs and Sheets resource pickers and the read-only Drive metadata bindings described below. Document reads and edits still go through the Google Docs API, and spreadsheet reads go through the Google Sheets API.
The Google Drive API powers the Docs and Sheets resource pickers, Drive discovery, and Drive scope checks. Native document or spreadsheet content opened from a Drive binding is read through the Google Docs or Google Sheets API. Direct Google Doc reads and edits still go through the Docs API, and direct spreadsheet reads go through the Sheets API.

### Step 3: Configure the OAuth Consent Screen

Expand All @@ -74,10 +74,10 @@ included). Across all resource types, the gatekeeper can request:

- `openid`, `userinfo.profile`, and `userinfo.email` to identify the connected account.
- `gmail.modify` for Gmail thread reads, organization, replies, forwards, and sending. This single scope already includes label access and sending.
- `documents` for Google Docs reads and edits.
- `drive.metadata.readonly` for the Docs and Sheets pickers, connected-account Drive metadata, and exact-file metadata.
- `drive.readonly` for the shared-drive resource. This is wider than anything the gatekeeper reads — it is a Google *restricted* scope conveying account-wide file content — but `drives.list` and `drives.get`, which the shared-drive picker and the binding's own scope lookup need, accept nothing narrower. OAuth scopes are held per connected account and only ever expand, so one shared-drive binding upgrades that account's token for good. The gatekeeper itself still exposes metadata only.
- `spreadsheets.readonly` to read metadata and cell values from selected Google spreadsheets.
- `documents` for direct Google Docs reads and edits; `documents.readonly` for native Docs opened from account-wide or exact-file Drive bindings.
- `drive.metadata.readonly` for the Docs and Sheets pickers, account-wide Drive discovery, exact-file metadata, and native-file scope checks.
- `drive.readonly` for the shared-drive picker and scope lookup, metadata search, and native Docs or Sheets reads within one shared drive. This restricted scope conveys account-wide file content and remains after the account expands consent, but Google accepts nothing narrower for `drives.list`/`drives.get` and accepts this Drive scope for the native APIs. The gatekeeper still enforces the shared-drive binding boundary.
- `spreadsheets.readonly` to read metadata and bounded cell ranges from directly selected spreadsheets or native Sheets opened from account-wide or exact-file Drive bindings.
- `calendar.calendarlist.readonly` so the resource picker can list calendars.
- `calendar.events` to manage selected calendar and check calendar availability.
- `bigquery` for BigQuery dry-runs and queries. This is intentionally broader than `bigquery.readonly` because dry-runs use `jobs.insert`; the gatekeeper enforces read-only SQL and resource scope checks before running queries.
Expand Down Expand Up @@ -156,9 +156,13 @@ Drive exposes three permanent resource URL forms:

Despite the `/folders/` URL, the second resource is a Google Workspace shared drive, not an individual folder. Google uses a shared drive's ID for its root folder too. The gatekeeper confirms the ID with `drives.get`, so it rejects ordinary folder IDs.

The agent-facing `GoogleDriveSession` returns metadata only. It can report the binding scope, list entries, run structured searches, and fetch one entry by ID. Listing and search return disposable RPC cursors. A parent filter means direct children only, never recursive descendants. The API does not expose raw Drive `q` strings, file contents, writes, shortcut traversal, native Docs or Sheets sessions, or Workers AI extraction. One caveat on "metadata only": the `fullTextContains` search filter compiles to Drive's `fullText contains`, which matches a file's indexed body text, description and OCR text. Results still carry metadata alone, but repeated queries are a content oracle over files the agent can never read directly.
The agent-facing `GoogleDriveSession` reports the binding scope, lists entries, runs structured searches, and fetches one entry by ID. Listing and search return disposable RPC cursors. A parent filter means direct children only, never recursive descendants. For a native Google Doc or Sheet, `openGoogleDoc()` or `openGoogleSheet()` returns an independently disposable, read-only nested session. Docs expose metadata and Markdown content; Sheets expose spreadsheet metadata and bounded A1 range reads. The API does not expose raw Drive `q` strings, file writes, shortcut traversal, arbitrary download or export, or Workers AI extraction. One caveat: the `fullTextContains` search filter compiles to Drive's `fullText contains`, which matches a file's indexed body text, description, and OCR text. Results carry metadata alone, but repeated queries remain a content oracle over files the agent cannot otherwise read.

Account and shared-drive bindings use per-file observer tracking because individual shared-drive items can carry narrower ACLs. They remember every file ID whose metadata a workspace has read. Before each collaborator opens the workspace, the gatekeeper requires that their own account explicitly consented to a Drive resource — a Drive grant is never inferred from held OAuth scopes, because the Docs and Sheets pickers request the same `drive.metadata.readonly` — and rechecks all remembered IDs with fresh batched `files.get` calls. Before a new result page is disclosed, it checks the page's IDs against every existing observer and excludes observers who cannot access them. Exact-file bindings perform the same fresh check for their single file on each share attempt. Google batch requests contain at most 100 `files.get` subrequests, and a binding is capped at 2,000 distinct file IDs; attempting to cross the limit refuses the read and asks the user to bind a narrower scope. There is deliberately no cached access verdict, so revoked access fails closed on the next open.
Every native open re-fetches Drive metadata, enforces the immutable account, shared-drive, or exact-file scope, and checks the exact MIME type before authorizing the observation. A folder, shortcut, non-native blob, wrong native type, or out-of-scope file cannot mint a content capability. Direct Google Doc bindings retain their existing editing API; Drive-opened Docs do not expose it.

Account-wide and exact-file Drive bindings request `documents.readonly` and `spreadsheets.readonly` in addition to `drive.metadata.readonly`. An older metadata-only connection is therefore prompted to expand consent before it is treated as granting either resource. Shared-drive bindings remain on `drive.readonly`, which Google accepts for native Docs and Sheets reads, so they do not request redundant scopes.

Account and shared-drive bindings use per-file observer tracking because individual shared-drive items can carry narrower ACLs. They remember every file ID whose metadata or native content a workspace has read. Before each collaborator opens the workspace, the gatekeeper requires that their own account explicitly consented to a Drive resource — a Drive grant is never inferred from held OAuth scopes — and rechecks all remembered IDs with fresh batched `files.get` calls. Before a new result page or native child capability is disclosed, it checks the file ID against every existing observer and excludes observers who cannot access it. Exact-file bindings perform the same fresh check for their single file on each share attempt. Google batch requests contain at most 100 `files.get` subrequests, and a binding is capped at 2,000 distinct file IDs; attempting to cross the limit refuses the read and asks the user to bind a narrower scope. There is deliberately no cached access verdict, so revoked access fails closed on the next open.

## Troubleshooting

Expand Down
26 changes: 25 additions & 1 deletion packages/gatekeeper-google/__tests__/configurator-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@
// `encodeURIComponent`, a normalization one side does and the other does not -- shows up here
// rather than as a resource the backend rejects after the user has filled the form.

import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";

vi.mock("@gadgets/configurator-ui", () => ({
h: (component: unknown, props: unknown, ...children: unknown[]) => ({
component, props, children,
}),
Autocomplete: "Autocomplete",
Field: "Field",
RadioCards: "RadioCards",
Section: "Section",
}));
import driveAccountConfigurator from "../src/configurator/drive-account-configurator-ui";
import driveFileConfigurator from "../src/configurator/drive-file-configurator-ui";
import gmailConfigurator from "../src/configurator/gmail-configurator-ui";
Expand Down Expand Up @@ -51,6 +61,8 @@ function valuesFromUrlPattern(resourceUrl: string, resourceUrlPattern: string) {
return out;
}

const renderedCopy = (configurator: { render?: (context: never) => unknown }) =>
JSON.stringify(configurator.render!({ values: {}, setValues() {}, ui: noUi } as never));
describe("Gmail configurator URLs", () => {
it.for([
["the whole mailbox", { mode: "all" }, { kind: "gmail" }],
Expand Down Expand Up @@ -114,6 +126,18 @@ describe("Drive configurator URLs", () => {
expect(parseResourceUrl(url)).toEqual({ kind: "driveAccount" });
});

it("explains native Doc and Sheet reads at every Drive scope", () => {
expect(renderedCopy(driveAccountConfigurator)).toContain(
"Returns metadata for every item and read-only content sessions for native Docs and Sheets.",
);
expect(renderedCopy(sharedDriveConfigurator)).toContain(
"Search its files and read native Google Docs and Sheets.",
);
expect(renderedCopy(driveFileConfigurator)).toContain(
"A selected native Google Doc or Sheet also provides read-only content.",
);
});

it("round-trips an encoded shared-drive ID", () => {
let values = { driveId: "shared/id with spaces" };
let url = configurableUrl(sharedDriveConfigurator, values);
Expand Down
118 changes: 116 additions & 2 deletions packages/gatekeeper-google/__tests__/drive-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { ObservationDescription } from "@gadgets/workshop-shared/gatekeeper";
import { DriveSessionCore, driveFileToEntry } from "../src/drive-session";
import type { DriveFile, DriveListFilesOptions } from "../src/drive-api";
import type { ObserverCheck } from "../src/observers";

const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder";

Expand All @@ -23,6 +24,7 @@ function core(overrides: {
files: DriveFile[];
nextPageToken?: string;
}>;
prepareObservation?: (ids: string[]) => Promise<ObserverCheck<string>>;
authorize?: (description: ObservationDescription) => Promise<void>;
} = {}) {
let listFiles = vi.fn(overrides.listFiles ?? (async () => ({ files: overrides.files ?? [file()] })));
Expand All @@ -35,14 +37,14 @@ function core(overrides: {
let session = new DriveSessionCore({
api: { listFiles, getFile, getDrive },
scope: overrides.scope ?? { kind: "account" },
prepareObservation: async (ids: string[]) => {
prepareObservation: overrides.prepareObservation ?? (async (ids: string[]) => {
prepared.push(ids);
return {
excludeObservers: ["excluded"],
pendingSets: ids,
commit: () => events.push("commit"),
};
},
}),
authorize: async (description: ObservationDescription) => {
authorizations.push(description);
events.push("authorize");
Expand Down Expand Up @@ -317,6 +319,118 @@ describe("Drive parent folder probe", () => {
});
});

describe("Drive native sessions", () => {
const docMime = "application/vnd.google-apps.document";
const sheetMime = "application/vnd.google-apps.spreadsheet";

it.each([
["account Doc", { kind: "account" } as const, docMime, "Google Doc"],
["account Sheet", { kind: "account" } as const, sheetMime, "Google Sheet"],
["shared-drive Doc", { kind: "sharedDrive", driveId: "drive-1" } as const,
docMime, "Google Doc"],
["shared-drive Sheet", { kind: "sharedDrive", driveId: "drive-1" } as const,
sheetMime, "Google Sheet"],
["exact-file Doc", { kind: "file", fileId: "file-1" } as const,
docMime, "Google Doc"],
["exact-file Sheet", { kind: "file", fileId: "file-1" } as const,
sheetMime, "Google Sheet"],
])("opens an in-scope native %s", async (_name, scope, mimeType, description) => {
let { session, getFile } = core({
scope,
getFile: async id => file({
id,
mimeType,
...(scope.kind === "sharedDrive" ? { driveId: scope.driveId } : {}),
}),
});

await expect(session.openNativeFile("file-1", mimeType, description))
.resolves.toBe("file-1");
expect(getFile).toHaveBeenCalledWith("file-1");
});

it("rejects another exact-file ID before calling Google", async () => {
let { session, getFile } = core({ scope: { kind: "file", fileId: "file-1" } });

await expect(session.openNativeFile("file-2", docMime, "Google Doc"))
.rejects.toThrow(/outside this Drive binding/);
expect(getFile).not.toHaveBeenCalled();
});

it("rejects a foreign shared-drive file without authorizing or tracking it", async () => {
let { session, prepared, authorizations } = core({
scope: { kind: "sharedDrive", driveId: "drive-1" },
getFile: async id => file({ id, driveId: "drive-2", mimeType: docMime }),
});

await expect(session.openNativeFile("foreign", docMime, "Google Doc"))
.rejects.toThrow(/outside this Drive binding/);
expect(prepared).toEqual([]);
expect(authorizations).toEqual([]);
});

it.each([
["wrong native type", sheetMime, undefined],
["folder", "application/vnd.google-apps.folder", undefined],
["blob", "application/pdf", undefined],
["shortcut", "application/vnd.google-apps.shortcut", { targetId: "target-1" }],
])("observes a %s before rejecting its MIME type", async (_name, mimeType, shortcutDetails) => {
let { session, prepared, authorizations, events } = core({
getFile: async id => file({ id, mimeType, shortcutDetails }),
});

await expect(session.openNativeFile("file-1", docMime, "Google Doc"))
.rejects.toThrow(/not a Google Doc/);
expect(prepared).toEqual([["file-1"]]);
expect(authorizations).toEqual([expect.objectContaining({ excludeObservers: ["excluded"] })]);
expect(events).toEqual(["authorize", "commit"]);
});

it("never follows a shortcut target implicitly", async () => {
let getFile = vi.fn(async (id: string) => file({
id,
mimeType: "application/vnd.google-apps.shortcut",
shortcutDetails: { targetId: "target-1", targetMimeType: docMime },
}));
let { session } = core({ getFile });

await expect(session.openNativeFile("shortcut-1", docMime, "Google Doc"))
.rejects.toThrow(/not a Google Doc/);
expect(getFile).toHaveBeenCalledTimes(1);
expect(getFile).toHaveBeenCalledWith("shortcut-1");
});

it("forwards observer exclusions and commits only after authorization", async () => {
let { session, authorizations, events } = core({
getFile: async id => file({ id, mimeType: docMime }),
});

await session.openNativeFile("file-1", docMime, "Google Doc");

expect(authorizations).toEqual([expect.objectContaining({
title: "Open Google Doc from Google Drive",
excludeObservers: ["excluded"],
})]);
expect(events).toEqual(["authorize", "commit"]);
});

it("leaves a denied file observation pending rather than observed", async () => {
let state = "unknown";
let { session } = core({
getFile: async id => file({ id, mimeType: docMime }),
prepareObservation: async ids => {
state = "pending";
return { pendingSets: ids, commit: () => { state = "observed"; } };
},
authorize: async () => { throw new Error("denied"); },
});

await expect(session.openNativeFile("file-1", docMime, "Google Doc"))
.rejects.toThrow("denied");
expect(state).toBe("pending");
});
});

describe("Drive search validation", () => {
it("requires at least one populated search filter", async () => {
let { session } = core();
Expand Down
Loading
Loading