Files
PaperClipAI/server/src/services/gmail-tool-governance.test.ts
T
DottaandPaperclip a959e47508 fix(apps): reduce Google Chat scopes and block unread filters (#13820)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Apps give agents controlled access to external services.
> - Google Chat uses OAuth profiles and reviewed MCP tools.
> - Those profiles request membership and read-state access that the
supported feature set does not need.
> - Removing read-state access also requires us to block unread search
filters, including on existing connections.
> - This pull request reduces both OAuth methods and enforces the
reduced search contract before dispatch.
> - Users retain conversation lookup, message history, ordinary search,
and approved message sending.

## Linked Issues or Issue Description

**What happened?**

Both Google Chat profiles request membership and read-state scopes. The
supported tool set does not include membership listing or read-state
updates. Message search still advertises an unread filter. Related work:
Refs #12619.

**Expected behavior**

Managed and customer-owned OAuth request only the scopes needed for
supported features. Unsupported unread filters fail clearly before any
provider call. Existing cached catalogs and broader grants must not
bypass that policy.

**Steps to reproduce**

Start Google Chat OAuth from either connection method and inspect the
requested scopes. Inspect the message-search tool schema, then submit a
search with `searchParameters.isUnread` set to true or false.

**Paperclip version or commit**

The scope change is based on master at `110d176fc`.

**Deployment mode**

Managed Cloud and self-hosted instances with Google Chat Apps enabled.

## What Changed

- Remove `chat.memberships.readonly` and `chat.users.readstate.readonly`
from shared profiles and all four Chat connection methods.
- Hide unsupported read-state fields and instructions in agent and board
Test tool schemas.
- Reject explicit unread filters, including false, null, snake-case
fields, and encoded filter objects, before provider dispatch.
- Recheck previously approved calls and support existing profile-bound
and URL-only Chat connections.
- Add scope, signed broker request, OAuth URL, allowlist, schema, and
dispatch regression tests.
- Document coordinated app/broker rollout, existing-grant reconnects,
and the remaining deployment checks.

## Verification

- All seven focused OAuth and Chat gateway test files pass: 498 tests on
the rebased branch.
- `pnpm -r typecheck` and `pnpm build` pass, using pinned pnpm 9.15.4.
- The complete sharded CI test matrix passes, including general server,
Chat, workspace, serialized server, Runner, and all eight browser e2e
shards. The duplicate unsharded local `pnpm test:run` was stopped after
CI passed; it did not complete locally.
- `git diff --check` passes.
- `node scripts/ingest-app-definitions.mjs` succeeds and leaves the
branch unchanged. Google Workspace JSON is the durable reviewed input
used by the generator.
- All current-head CI checks pass at
`7ee755371714dc036fff1c7da844776fee3f2ec1`, including typecheck, build,
and canary dry run. Greptile is 5/5 with zero unresolved threads. The
generator concern was withdrawn after review of the source and
regeneration evidence.
- No production deployment or live Google consent test was performed.
After coordinated deployment, verify reduced consent scopes, normal
search/history, message sending, and rejection of unread filters.

## Risks

- Coordinate deployment with the companion Cloud broker scope change.
Mixed versions can reject exact-scope requests.
- Existing tokens are not narrowed or revoked. Grants with old scopes
need new consent. Do not revoke a shared Google client to migrate one
profile.
- Explicit unread filters now return an error instead of being sent to
Google. Ordinary search and the approved send tool remain available.
- No database, UI, lockfile, or workflow changes.

## Model Used

OpenAI Codex, a GPT-5-based coding agent, with tool use and code
execution. The exact runtime model ID and context window were not
exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-22 15:12:30 -05:00

54 lines
2.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
classifyRisk,
isGmailToolPermanentlyBlocked,
isGoogleWorkspaceToolAllowed,
} from "./tool-access.js";
describe("Gmail tool governance", () => {
it("allows reviewed reads and draft creation while keeping delivery and destructive actions blocked", () => {
expect(isGmailToolPermanentlyBlocked({ name: "search_threads" })).toBe(false);
expect(isGmailToolPermanentlyBlocked({ name: "get_message" })).toBe(false);
expect(classifyRisk({ name: "create_draft" }, "gmail")).toBe("write");
expect(isGmailToolPermanentlyBlocked({ name: "create_draft" })).toBe(false);
expect(isGmailToolPermanentlyBlocked({ name: "send_message" })).toBe(true);
expect(isGmailToolPermanentlyBlocked({ name: "trash_thread" })).toBe(true);
expect(isGmailToolPermanentlyBlocked({ name: "mark_as_spam" })).toBe(true);
expect(isGmailToolPermanentlyBlocked({ name: "update_labels" })).toBe(true);
});
});
describe("Google Workspace tool governance", () => {
it("limits each capability profile to its reviewed reads and writes", () => {
expect(isGoogleWorkspaceToolAllowed("drive.read", { name: "search_files" })).toBe(true);
expect(isGoogleWorkspaceToolAllowed("drive.read", { name: "create_file" })).toBe(false);
expect(isGoogleWorkspaceToolAllowed("drive.write", { name: "create_file" })).toBe(true);
expect(isGoogleWorkspaceToolAllowed("gmail.draft", { name: "create_draft" })).toBe(true);
expect(isGoogleWorkspaceToolAllowed("gmail.draft", { name: "send_message" })).toBe(false);
expect(isGoogleWorkspaceToolAllowed("calendar.write", { name: "delete_event" })).toBe(true);
expect(isGoogleWorkspaceToolAllowed("calendar.write", { name: "publish_calendar" })).toBe(false);
});
it("normalizes namespaced provider tool names and denies unknown preview tools", () => {
expect(isGoogleWorkspaceToolAllowed("docs.read", { name: "google.docs/read_doc" })).toBe(true);
expect(isGoogleWorkspaceToolAllowed("sheets.write", { name: "sheets.updateValues" })).toBe(true);
expect(isGoogleWorkspaceToolAllowed("people.read", { name: "delete_contact" })).toBe(false);
});
it("keeps Chat search, history, and sending but excludes membership and read-state tools", () => {
for (const profile of ["chat.read", "chat.write"] as const) {
for (const name of ["search_conversations", "list_messages", "search_messages"]) {
expect(isGoogleWorkspaceToolAllowed(profile, { name })).toBe(true);
}
for (const name of ["list_memberships", "mark_as_read", "mark_as_unread"]) {
expect(isGoogleWorkspaceToolAllowed(profile, { name })).toBe(false);
}
}
expect(isGoogleWorkspaceToolAllowed("chat.read", { name: "send_message" })).toBe(false);
expect(isGoogleWorkspaceToolAllowed("chat.write", { name: "send_message" })).toBe(true);
});
});