mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
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>
This commit is contained in:
1 parent
9a76390cfa
commit
a959e47508
12 files changed
+352
-23
No files matched your search
@@ -37,4 +37,17 @@ describe("Google Workspace tool governance", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertGoogleChatToolArgumentsSupported,
|
||||
googleChatToolDescription,
|
||||
googleChatToolInputSchema,
|
||||
} from "./google-chat-tool-policy.js";
|
||||
|
||||
const chat = { config: { sourceTemplateKey: "google-chat" } };
|
||||
|
||||
describe("Google Chat search without read-state permission", () => {
|
||||
it.each([true, false, null, "true"])("rejects explicit unread filters (%s)", (isUnread) => {
|
||||
for (const tool of ["search_messages", "chat.searchMessages", "google/chat/search-messages"]) {
|
||||
for (const args of [
|
||||
{ searchParameters: { isUnread } },
|
||||
{ searchParameters: { is_unread: isUnread } },
|
||||
{ search_parameters: { isUnread } },
|
||||
{ search_parameters: { is_unread: isUnread } },
|
||||
{ isUnread },
|
||||
]) {
|
||||
expect(() => assertGoogleChatToolArgumentsSupported(chat, tool, args)).toThrow(
|
||||
expect.objectContaining({ status: 400, details: { code: "google_chat_unread_filter_unsupported" } }),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("covers legacy profile-bound and URL-only connections without inspecting granted scopes", () => {
|
||||
for (const config of [
|
||||
{ oauth: { connectorProfile: "chat.read" } },
|
||||
{ oauth: { connectorProfile: "chat.write" } },
|
||||
{ url: "https://chatmcp.googleapis.com/mcp/v1" },
|
||||
]) {
|
||||
expect(() => assertGoogleChatToolArgumentsSupported({ config }, "search_messages", {
|
||||
searchParameters: { isUnread: true },
|
||||
})).toThrow("read/unread filtering is not supported");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects encoded filters instead of forwarding an invalid searchParameters value", () => {
|
||||
for (const searchParameters of ['{"isUnread":true}', "is:unread", [], null, true]) {
|
||||
expect(() => assertGoogleChatToolArgumentsSupported(chat, "search_messages", { searchParameters }))
|
||||
.toThrow("searchParameters must be an object");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves supported search filters and unrelated providers/tools", () => {
|
||||
const args = { searchParameters: {
|
||||
keywords: ["release", "unread"], conversationId: "spaces/example", sender: "users/me",
|
||||
startTime: "2026-09-22T00:00:00Z", hasLink: true, mentionsMe: true,
|
||||
conversationIncludesUser: "users/example", spaceDisplayNames: ["Engineering"],
|
||||
}, pageSize: 10, pageToken: "next" };
|
||||
expect(() => assertGoogleChatToolArgumentsSupported(chat, "search_messages", args)).not.toThrow();
|
||||
expect(() => assertGoogleChatToolArgumentsSupported(chat, "send_message", { text: "isUnread" })).not.toThrow();
|
||||
const gmail = { config: { sourceTemplateKey: "gmail" } };
|
||||
expect(() => assertGoogleChatToolArgumentsSupported(gmail, "search_messages", {
|
||||
searchParameters: { isUnread: true },
|
||||
})).not.toThrow();
|
||||
});
|
||||
|
||||
it("removes unsupported fields from inline and referenced schemas without mutating the cached schema", () => {
|
||||
const filters = { type: "object", required: ["isUnread", "keywords"], properties: {
|
||||
isUnread: { type: "boolean" }, is_unread: { type: "boolean" },
|
||||
keywords: { type: "array", items: { type: "string" } },
|
||||
} };
|
||||
const schema = { type: "object", properties: {
|
||||
searchParameters: filters,
|
||||
search_parameters: { anyOf: [{ $ref: "#/$defs/SearchParameters" }, { type: "null" }] },
|
||||
pageSize: { type: "integer" },
|
||||
}, $defs: { SearchParameters: filters } };
|
||||
const reduced = googleChatToolInputSchema(chat, "search_messages", schema);
|
||||
const expectedFilters = { ...filters, required: ["keywords"], properties: { keywords: filters.properties.keywords } };
|
||||
expect(reduced).toMatchObject({
|
||||
properties: { searchParameters: expectedFilters, pageSize: { type: "integer" } },
|
||||
$defs: { SearchParameters: expectedFilters },
|
||||
});
|
||||
expect(JSON.stringify(reduced)).not.toMatch(/isUnread|is_unread/);
|
||||
expect(schema.properties.searchParameters.properties.isUnread).toEqual({ type: "boolean" });
|
||||
expect(googleChatToolInputSchema({ config: { sourceTemplateKey: "gmail" } }, "search_messages", schema)).toBe(schema);
|
||||
});
|
||||
|
||||
it("replaces upstream unread-search instructions with supported search guidance", () => {
|
||||
expect(googleChatToolDescription(chat, "search_messages", "Search unread messages."))
|
||||
.toContain("Read/unread filtering is not supported.");
|
||||
expect(googleChatToolDescription(chat, "list_messages", "Read history.")).toBe("Read history.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { badRequest } from "../errors.js";
|
||||
|
||||
type Connection = { config: Record<string, unknown> };
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function isChatSearch(connection: Connection, toolName: string): boolean {
|
||||
const leaf = (toolName.split(/[.:/]/).pop() ?? toolName)
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase();
|
||||
if (leaf !== "search-messages") return false;
|
||||
const config = connection.config;
|
||||
const profile = record(config.oauth).connectorProfile;
|
||||
if (config.sourceTemplateKey === "google-chat" || profile === "chat.read" || profile === "chat.write") return true;
|
||||
// Also cover existing URL-only connections to the official Chat server.
|
||||
try {
|
||||
return new URL(String(config.url)).hostname === "chatmcp.googleapis.com";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const UNREAD_FIELDS = new Set(["isUnread", "is_unread"]);
|
||||
|
||||
export function assertGoogleChatToolArgumentsSupported(
|
||||
connection: Connection,
|
||||
toolName: string,
|
||||
parameters: unknown,
|
||||
): void {
|
||||
if (!isChatSearch(connection, toolName)) return;
|
||||
const supplied = record(parameters);
|
||||
// Reject presence, including false/null, rather than silently changing the
|
||||
// requested search. Accept neither proto JSON nor snake_case read-state keys.
|
||||
for (const candidate of [supplied, record(supplied.searchParameters), record(supplied.search_parameters)]) {
|
||||
if ([...UNREAD_FIELDS].some((key) => Object.hasOwn(candidate, key))) {
|
||||
throw badRequest(
|
||||
"Google Chat read/unread filtering is not supported. Omit isUnread and search by keywords, conversation, sender, or dates instead.",
|
||||
{ code: "google_chat_unread_filter_unsupported" },
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const key of ["searchParameters", "search_parameters"]) {
|
||||
if (Object.hasOwn(supplied, key) && record(supplied[key]) !== supplied[key]) {
|
||||
throw badRequest("Google Chat searchParameters must be an object, not encoded JSON or text.", {
|
||||
code: "google_chat_search_parameters_invalid",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withoutUnreadFields(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(withoutUnreadFields);
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
// Walk inline schemas, composition branches, and $defs/definitions so a
|
||||
// provider changing schema layout cannot advertise the unsupported filter.
|
||||
return Object.fromEntries(Object.entries(record(value)).map(([key, child]) => {
|
||||
if (key === "properties") {
|
||||
return [key, Object.fromEntries(Object.entries(record(child))
|
||||
.filter(([name]) => !UNREAD_FIELDS.has(name))
|
||||
.map(([name, schema]) => [name, withoutUnreadFields(schema)]))];
|
||||
}
|
||||
if (key === "required" && Array.isArray(child)) {
|
||||
return [key, child.filter((name) => !UNREAD_FIELDS.has(name))];
|
||||
}
|
||||
return [key, withoutUnreadFields(child)];
|
||||
}));
|
||||
}
|
||||
|
||||
export function googleChatToolInputSchema(
|
||||
connection: Connection,
|
||||
toolName: string,
|
||||
schema: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return isChatSearch(connection, toolName)
|
||||
? withoutUnreadFields(schema) as Record<string, unknown>
|
||||
: schema;
|
||||
}
|
||||
|
||||
export function googleChatToolDescription(
|
||||
connection: Connection,
|
||||
toolName: string,
|
||||
description: string | null,
|
||||
): string | null {
|
||||
return isChatSearch(connection, toolName)
|
||||
? "Search Google Chat messages by keywords, conversation, sender, mentions, links, or dates. Read/unread filtering is not supported."
|
||||
: description;
|
||||
}
|
||||
@@ -79,6 +79,23 @@ describe("Paperclip Cloud connector", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["chat.read", "chat.write"] as const)("signs reduced Chat scopes for %s", async (profile) => {
|
||||
const keys = config();
|
||||
const scopes = ["chat.spaces.readonly", "chat.messages.readonly", ...(profile === "chat.write" ? ["chat.messages.create"] : [])]
|
||||
.map((scope) => `https://www.googleapis.com/auth/${scope}`);
|
||||
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body));
|
||||
const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8"));
|
||||
expect(claims.prf).toBe(profile);
|
||||
expect(claims.scp).toEqual(scopes);
|
||||
return Response.json({ confirmationUrl: "https://my.example.test/connections/confirm?session=chat", expiresAt: "2099-01-01T00:00:00Z" });
|
||||
});
|
||||
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
|
||||
await connector.startAuthorization({ subject, companyId, profile,
|
||||
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", returnState: "chat-state" });
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("starts a signed session with exact endpoint audience and scope contract", async () => {
|
||||
const keys = config();
|
||||
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
|
||||
@@ -225,6 +225,7 @@ import {
|
||||
narrowestScopeBindings,
|
||||
profileIdsInBindingOrder,
|
||||
} from "./tool-profile-binding-precedence.js";
|
||||
import { assertGoogleChatToolArgumentsSupported, googleChatToolDescription, googleChatToolInputSchema } from "./google-chat-tool-policy.js";
|
||||
import {
|
||||
recordToolRuntimeAuditWriteFailure,
|
||||
TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC,
|
||||
@@ -1282,7 +1283,9 @@ export function projectConnectionMethodToolInputSchema(
|
||||
export function projectedConnectionToolArguments(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
parameters: unknown,
|
||||
toolName: string,
|
||||
): Record<string, unknown> {
|
||||
assertGoogleChatToolArgumentsSupported(connection, toolName, parameters);
|
||||
const sourceTemplateKey =
|
||||
typeof connection.config.sourceTemplateKey === "string"
|
||||
? connection.config.sourceTemplateKey
|
||||
@@ -1300,7 +1303,9 @@ export function projectedConnectionToolArguments(
|
||||
export function projectedConnectionToolInputSchema(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
inputSchema: Record<string, unknown>,
|
||||
toolName: string,
|
||||
): Record<string, unknown> {
|
||||
inputSchema = googleChatToolInputSchema(connection, toolName, inputSchema);
|
||||
const sourceTemplateKey =
|
||||
typeof connection.config.sourceTemplateKey === "string"
|
||||
? connection.config.sourceTemplateKey
|
||||
@@ -1692,7 +1697,9 @@ function toCatalogEntryForConnection(
|
||||
inputSchema: projectedConnectionToolInputSchema(
|
||||
connection,
|
||||
rawCatalogEntry.inputSchema ?? {},
|
||||
row.toolName,
|
||||
),
|
||||
description: googleChatToolDescription(connection, row.toolName, rawCatalogEntry.description),
|
||||
};
|
||||
if (
|
||||
connection.transport === "local_stdio" &&
|
||||
|
||||
@@ -105,6 +105,7 @@ import {
|
||||
projectedConnectionToolArguments,
|
||||
projectedConnectionToolInputSchema,
|
||||
} from "./tool-access.js";
|
||||
import { assertGoogleChatToolArgumentsSupported, googleChatToolDescription } from "./google-chat-tool-policy.js";
|
||||
import { parseRemoteHttpEndpoint } from "./remote-http-endpoint-guard.js";
|
||||
import {
|
||||
guardedRemoteHttpFetch,
|
||||
@@ -1284,6 +1285,7 @@ export function createToolGatewayService(
|
||||
const inputSchema = projectedConnectionToolInputSchema(
|
||||
connection,
|
||||
catalogEntry.inputSchema ?? {},
|
||||
catalogEntry.toolName,
|
||||
);
|
||||
const outputSchema = catalogEntry.outputSchema ?? null;
|
||||
const annotations = catalogEntry.annotations ?? {};
|
||||
@@ -1314,7 +1316,7 @@ export function createToolGatewayService(
|
||||
name: gatewayToolName,
|
||||
displayName: catalogEntry.title ?? catalogEntry.toolName,
|
||||
description:
|
||||
catalogEntry.description ??
|
||||
googleChatToolDescription(connection, catalogEntry.toolName, catalogEntry.description) ??
|
||||
`Connected MCP tool ${catalogEntry.toolName} from ${connection.name}.`,
|
||||
parametersSchema: inputSchema,
|
||||
pluginId: `mcp:${applicationKey ?? application.id}`,
|
||||
@@ -4723,8 +4725,8 @@ export function createToolGatewayService(
|
||||
parameters: unknown,
|
||||
): Promise<unknown> {
|
||||
if (tool.providerType !== "mcp_remote_http") return parameters;
|
||||
const { connection } = await resolveConnectedRemoteTool(session, tool);
|
||||
return projectedConnectionToolArguments(connection, parameters);
|
||||
const { connection, entry } = await resolveConnectedRemoteTool(session, tool);
|
||||
return projectedConnectionToolArguments(connection, parameters, entry.toolName);
|
||||
}
|
||||
|
||||
async function approvedManagedArgumentsRemainCurrent(
|
||||
@@ -5792,6 +5794,9 @@ export function createToolGatewayService(
|
||||
session,
|
||||
tool,
|
||||
);
|
||||
// Recheck immediately before dispatch, including previously approved calls
|
||||
// and connections whose stored grant/catalog predates scope reduction.
|
||||
assertGoogleChatToolArgumentsSupported(connection, entry.toolName, parameters);
|
||||
if (useDefaultTimeout && isRailwayConnection(connection) && entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command`) {
|
||||
ms = railwayCommandBudgetMs(parameters);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user