Skip to content
Draft
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
12 changes: 8 additions & 4 deletions packages/gatekeeper-google/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ User — see Step 4.)

You can also see your connected accounts and add and remove them in the settings (accessed through the account menu in the upper-right).

## Google Drive read-only bindings
## Google Drive bindings

Drive exposes three permanent resource URL forms:

Expand All @@ -156,11 +156,15 @@ 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` reports the binding scope, lists entries, runs structured metadata 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 agent-facing Drive sessions report the binding scope, list entries, run structured metadata searches, and fetch 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.

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. The Drive API exposes no raw `q` strings, writes, shortcut traversal, arbitrary download or export, or Workers AI extraction. Direct Google Doc bindings retain their existing editing API; Drive-opened Docs do not expose it.
Account and shared-drive bindings also queue creation of blank native Google Docs, blank native Google Sheets, and folders. An omitted destination means the My Drive root for an account binding or the bound shared-drive root; callers may instead name an in-scope writable folder by immutable ID. Exact-file bindings remain read-only, even when the selected file is a folder.

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.
Each create call returns an asynchronous handle and submits a manual approval that names the destination. The new item contains no initial content and inherits the destination folder's permissions. `getCreationResult()` reports pending, rejected, failed, created, or reverted state. Revert moves an item to Drive trash; it never permanently deletes it.

Every native open and creation destination lookup re-fetches Drive metadata, enforces the immutable account, shared-drive, or exact-file scope, and checks the exact MIME type and current capability before authorizing the observation. A folder, shortcut, non-native blob, wrong native type, or out-of-scope file cannot mint a content capability. The Drive API exposes no agent-authored raw `q` strings, generic upload or write primitive, shortcut traversal, arbitrary download or export, or Workers AI extraction. Direct Google Doc bindings retain their existing editing API; Drive-opened Docs do not expose it.

Account bindings request `drive.metadata.readonly`, `documents.readonly`, `spreadsheets.readonly`, and `drive.file`. Shared-drive bindings request `drive.readonly` plus `drive.file`. The new scope limits write access to files this app creates or the user explicitly opens with it; the gatekeeper further restricts creation to an authorized destination in the bound scope. Older account and shared-drive connections are prompted to expand consent. Exact-file bindings remain on the three read-only scopes, and direct Google Doc and Spreadsheet grants are unchanged.

Account and shared-drive bindings remember every file ID whose metadata or native content a workspace has read. Before each collaborator opens the workspace, the gatekeeper requires an explicit Drive grant 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; there is deliberately no cached access verdict, so revoked access fails closed on the next open.

Expand Down
9 changes: 5 additions & 4 deletions packages/gatekeeper-google/__tests__/configurator-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,17 @@ describe("Drive configurator URLs", () => {
expect(parseResourceUrl(url)).toEqual({ kind: "driveAccount" });
});

it("explains native Doc and Sheet reads at every Drive scope", () => {
it("advertises creation only for account and shared-drive bindings", () => {
expect(renderedCopy(driveAccountConfigurator)).toContain(
"Returns metadata for every item and read-only content sessions for native Docs and Sheets.",
"approved blank-item creation",
);
expect(renderedCopy(sharedDriveConfigurator)).toContain(
"Search its files and read native Google Docs and Sheets.",
"create blank Docs, Sheets, and folders",
);
expect(renderedCopy(driveFileConfigurator)).toContain(
"A selected native Google Doc or Sheet also provides read-only content.",
"one read-only file binding",
);
expect(renderedCopy(driveFileConfigurator)).not.toContain("create blank");
});

it("round-trips an encoded shared-drive ID", () => {
Expand Down
221 changes: 220 additions & 1 deletion packages/gatekeeper-google/__tests__/drive-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ const jsonResponse = (body: unknown, status = 200) =>

const api = (token = "tok") => new DriveApi(async () => token);

const CREATION_REQUEST_ID = "123e4567-e89b-42d3-a456-426614174000";
const DRIVE_FILE_ITEM_FIELDS = [
"id", "name", "mimeType", "modifiedTime", "size", "parents", "driveId",
"owners(displayName,emailAddress)", "webViewLink",
"shortcutDetails(targetId,targetMimeType)", "trashed", "appProperties",
"capabilities(canAddChildren,canTrash)",
].join(",");

function batchResponse(results: { status: number; body?: string }[]): Response {
let boundary = "drive_test_boundary";
let body = results.map((result, index) => [
Expand All @@ -53,7 +61,10 @@ function batchResponse(results: { status: number; body?: string }[]): Response {
return new Response(body, { headers: { "Content-Type": `multipart/mixed; boundary=${boundary}` } });
}

afterEach(() => { vi.unstubAllGlobals(); });
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

describe("escapeDriveQueryLiteral", () => {
it("leaves an ordinary value alone", () => {
Expand Down Expand Up @@ -247,6 +258,214 @@ describe("metadata lookup", () => {
});
});

describe("creation mutations", () => {
it.each([
["Google Doc", "application/vnd.google-apps.document"],
["Google Sheet", "application/vnd.google-apps.spreadsheet"],
["folder", "application/vnd.google-apps.folder"],
] as const)("creates a metadata-only %s in one resolved parent", async (name, mimeType) => {
let created = {
id: `created-${name}`, name, mimeType, parents: ["parent-1"], trashed: false,
appProperties: { gadgetsCreationRequestId: CREATION_REQUEST_ID },
capabilities: { canAddChildren: mimeType.endsWith("folder"), canTrash: true },
};
let calls = stubFetch([jsonResponse(created)]);

await expect(api().createFile({
name, mimeType, parentId: "parent-1", requestId: CREATION_REQUEST_ID,
})).resolves.toEqual(created);

expect(calls).toHaveLength(1);
expect(calls[0].url.pathname).toBe("/drive/v3/files");
expect(calls[0].method).toBe("POST");
expect(calls[0].headers.get("Content-Type")).toBe("application/json");
expect(calls[0].url.searchParams.get("supportsAllDrives")).toBe("true");
expect(calls[0].url.searchParams.get("ignoreDefaultVisibility")).toBe("true");
expect(calls[0].url.searchParams.get("fields")).toBe(DRIVE_FILE_ITEM_FIELDS);
expect(JSON.parse(calls[0].body ?? "")).toEqual({
name,
mimeType,
parents: ["parent-1"],
appProperties: { gadgetsCreationRequestId: CREATION_REQUEST_ID },
});
expect(calls[0].body).not.toContain("driveId");
});

it("uses a finite timeout for create requests", async () => {
let timeout = vi.spyOn(AbortSignal, "timeout");
stubFetch([jsonResponse({ id: "created-1", name: "Plan" })]);

await api().createFile({
name: "Plan", mimeType: "application/vnd.google-apps.document",
parentId: "parent-1", requestId: CREATION_REQUEST_ID,
});

expect(timeout).toHaveBeenCalledWith(30_000);
});

it("refreshes once on a create 401 and replays the exact metadata body", async () => {
let drive = new DriveApi(async opts => opts?.forceRefresh ? "fresh" : "stale");
let calls = stubFetch([
new Response("expired", { status: 401 }),
jsonResponse({ id: "created-1", name: "Plan" }),
]);

await drive.createFile({
name: "Plan", mimeType: "application/vnd.google-apps.document",
parentId: "parent-1", requestId: CREATION_REQUEST_ID,
});

expect(calls.map(call => call.headers.get("Authorization")))
.toEqual(["Bearer stale", "Bearer fresh"]);
expect(calls[0].body).toBe(calls[1].body);
});

it.each([429, 503])("does not transiently replay a create after HTTP %i", async status => {
let calls = stubFetch([new Response("retry later", { status })]);

await expect(api().createFile({
name: "Plan", mimeType: "application/vnd.google-apps.document",
parentId: "parent-1", requestId: CREATION_REQUEST_ID,
})).rejects.toThrow(`Google Drive API request failed: ${status}`);
expect(calls).toHaveLength(1);
});

it("does not replay a create after a network failure", async () => {
let calls = stubFetch(() => { throw new Error("network unavailable"); });

await expect(api().createFile({
name: "Plan", mimeType: "application/vnd.google-apps.document",
parentId: "parent-1", requestId: CREATION_REQUEST_ID,
})).rejects.toThrow("network unavailable");
expect(calls).toHaveLength(1);
});

it("rejects malformed create metadata instead of trusting it", async () => {
stubFetch([jsonResponse({ id: 42, name: "Plan" })]);

await expect(api().createFile({
name: "Plan", mimeType: "application/vnd.google-apps.document",
parentId: "parent-1", requestId: CREATION_REQUEST_ID,
})).rejects.toThrow("Invalid Google Drive file response");
});

it("rejects malformed JSON without exposing its contents", async () => {
stubFetch([new Response("not-json-with-secret-prose")]);

await expect(api().createFile({
name: "Plan", mimeType: "application/vnd.google-apps.document",
parentId: "parent-1", requestId: CREATION_REQUEST_ID,
})).rejects.toThrow("Invalid Google Drive JSON response");
});

it("bounds a create response before parsing it", async () => {
let pulls = 0;
let cancelled = false;
let body = new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1;
if (pulls <= 3) controller.enqueue(new Uint8Array(3_000_000));
else controller.close();
},
cancel() { cancelled = true; },
});
stubFetch([new Response(body)]);

await expect(api().createFile({
name: "Plan", mimeType: "application/vnd.google-apps.document",
parentId: "parent-1", requestId: CREATION_REQUEST_ID,
})).rejects.toThrow("Google Drive response was too large");
expect(cancelled).toBe(true);
expect(pulls).toBeLessThan(4);
});

it("finds one prior create only through its private generated marker", async () => {
let found = {
id: "created-1", name: "Plan", mimeType: "application/vnd.google-apps.document",
parents: ["parent-1"], trashed: false,
appProperties: { gadgetsCreationRequestId: CREATION_REQUEST_ID },
capabilities: { canTrash: true },
};
let calls = stubFetch([jsonResponse({ files: [found] })]);

await expect(api().findFileByCreationRequestId(CREATION_REQUEST_ID)).resolves.toEqual(found);

let params = calls[0].url.searchParams;
expect(calls[0].method).toBeUndefined();
expect(params.get("q")).toBe(
`appProperties has { key='gadgetsCreationRequestId' and value='${CREATION_REQUEST_ID}' }`,
);
expect(params.get("pageSize")).toBe("2");
expect(params.get("spaces")).toBe("drive");
expect(params.get("supportsAllDrives")).toBe("true");
expect(params.get("includeItemsFromAllDrives")).toBe("true");
expect(params.get("fields")).toBe(`nextPageToken,files(${DRIVE_FILE_ITEM_FIELDS})`);
});

it("follows short marker pages until the result set is exhausted", async () => {
let found = { id: "created-1", name: "Plan" };
let calls = stubFetch([
jsonResponse({ files: [found], nextPageToken: "next-page" }),
jsonResponse({ files: [] }),
]);

await expect(api().findFileByCreationRequestId(CREATION_REQUEST_ID)).resolves.toEqual(found);
expect(calls).toHaveLength(2);
expect(calls[1].url.searchParams.get("pageToken")).toBe("next-page");
});

it("fails closed when marker matches are split across pages", async () => {
let calls = stubFetch([
jsonResponse({ files: [{ id: "created-1", name: "Plan" }], nextPageToken: "next-page" }),
jsonResponse({ files: [{ id: "created-2", name: "Plan" }] }),
]);

await expect(api().findFileByCreationRequestId(CREATION_REQUEST_ID))
.rejects.toThrow("Multiple Google Drive files matched one creation request");
expect(calls).toHaveLength(2);
});

it("returns no prior create when the generated marker is absent", async () => {
stubFetch([jsonResponse({ files: [] })]);
await expect(api().findFileByCreationRequestId(CREATION_REQUEST_ID))
.resolves.toBeUndefined();
});

it("fails closed when more than one file has the generated marker", async () => {
stubFetch([jsonResponse({
files: [{ id: "created-1", name: "Plan" }, { id: "created-2", name: "Plan" }],
})]);

await expect(api().findFileByCreationRequestId(CREATION_REQUEST_ID))
.rejects.toThrow("Multiple Google Drive files matched one creation request");
});

it("rejects a non-generated marker before issuing a query", async () => {
let calls = stubFetch([]);
await expect(api().findFileByCreationRequestId("x' or trashed = false"))
.rejects.toThrow("Invalid Google Drive creation request ID");
expect(calls).toEqual([]);
});

it("trashes with a metadata-only shared-drive PATCH", async () => {
let calls = stubFetch([jsonResponse({ id: "created/1", name: "Plan", trashed: true })]);

await expect(api().trashFile("created/1")).resolves.toBeUndefined();

expect(calls[0].url.pathname).toBe("/drive/v3/files/created%2F1");
expect(calls[0].method).toBe("PATCH");
expect(calls[0].url.searchParams.get("supportsAllDrives")).toBe("true");
expect(calls[0].url.searchParams.get("fields")).toBe(DRIVE_FILE_ITEM_FIELDS);
expect(JSON.parse(calls[0].body ?? "")).toEqual({ trashed: true });
});

it("rejects a trash response whose postcondition is false", async () => {
stubFetch([jsonResponse({ id: "created-1", name: "Plan", trashed: false })]);
await expect(api().trashFile("created-1"))
.rejects.toThrow("Google Drive did not trash the requested file");
});
});

describe("bulk access verification", () => {
it("maps fresh files.get outcomes back to the requested ID order", async () => {
let calls = stubFetch([batchResponse([
Expand Down
Loading
Loading