mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
Add opt-in Gmail archive capability (#16331)
## Thinking Path > - Paperclip separates Gmail capabilities by reviewed OAuth scope and tool policy. > - Existing Gmail connections support reading and draft creation but not bounded archiving. > - Gmail archive requires `gmail.modify`, so it must not widen existing read or draft grants. > - This change adds an opt-in archive profile and limits it to removing only the `INBOX` label. > - A final pre-dispatch check preserves that boundary for approved and replayed calls. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves the existing Gmail connection and tool-governance behavior. **Subsystem affected** Cross-cutting: `packages/shared` and `server`. **Current behavior** Paperclip can read Gmail and create drafts. It does not provide an opt-in, bounded action for archiving a thread. **Proposed behavior** Paperclip offers a separate Read & archive connection. It exposes `unlabel_thread` only for that connection and accepts only `threadId` with `labelIds: ["INBOX"]`. **Reason and benefit** Users can archive processed Gmail threads while sending, trashing, and other label changes remain disabled. **Breaking changes** None. Existing read and draft methods keep their current scopes and defaults. Related work: #15545 adds a unified Google Workspace connector and keeps Gmail draft-only. #15412 adds a separate Gmail REST server without label mutations. Neither PR adds this bounded archive capability. ## What Changed - Added the `gmail.archive` connector profile. - Added managed and customer-owned archive connection methods. - Added an Inbox-only schema and final pre-dispatch argument guard. - Classified archive as destructive and added Ask-first defaults for the archive methods. - Added permission-review records and focused tests. ## Verification - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/server build` - `pnpm --filter @paperclipai/shared typecheck` - Focused Vitest suites: 42 tests passed. - App-definition ingestion: 103 definitions and 0 review flags. - `git diff --check` ## Risks The main risk is accidental scope or operation expansion. The new profile is opt-in. Existing profiles retain their scopes. The gateway validates the exact Inbox-only payload immediately before dispatch. Production rollout requires separate OAuth configuration and fresh consent. ## Model Used Unverified — reliable runtime model metadata was not available. Confirm the model attribution before publishing. ## Checklist - [x] I have included a concise design rationale - [x] I have specified that model attribution is unverified - [x] I have checked ROADMAP.md and found no Gmail archive entry - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in this PR - [x] I have not referenced internal or instance-local issues - [x] My branch name describes the change - [x] Local tests and builds pass - [x] I have added or updated tests - [x] I have updated relevant documentation and permission records - [x] I have documented risks - [ ] All hosted CI gates are green - [ ] Greptile and human review are complete --------- Co-authored-by: Leia Marshall <leia@paperclip.ing>
This commit is contained in:
1 parent
afe5b3418d
commit
e4ce989099
11 files changed
+384
-8
No files matched your search
@@ -651,6 +651,42 @@
|
||||
"reviewedAt": "2026-09-30",
|
||||
"liveProof": "not-run"
|
||||
},
|
||||
{
|
||||
"app": "gmail",
|
||||
"method": "paperclip-archive",
|
||||
"auth": "oauth",
|
||||
"policy": "explicit",
|
||||
"requestedScopes": [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.modify"
|
||||
],
|
||||
"capability": "write",
|
||||
"supportedActions": "Read Gmail and archive a thread by removing only the INBOX label.",
|
||||
"evidence": [
|
||||
"https://developers.google.com/workspace/gmail/api/reference/mcp/tools_list/unlabel_thread",
|
||||
"https://gmailmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1"
|
||||
],
|
||||
"reviewedAt": "2026-10-14",
|
||||
"liveProof": "not-run"
|
||||
},
|
||||
{
|
||||
"app": "gmail",
|
||||
"method": "customer-archive-oauth",
|
||||
"auth": "oauth",
|
||||
"policy": "explicit",
|
||||
"requestedScopes": [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.modify"
|
||||
],
|
||||
"capability": "write",
|
||||
"supportedActions": "Read Gmail and archive a thread by removing only the INBOX label.",
|
||||
"evidence": [
|
||||
"https://developers.google.com/workspace/gmail/api/reference/mcp/tools_list/unlabel_thread",
|
||||
"https://gmailmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1"
|
||||
],
|
||||
"reviewedAt": "2026-10-14",
|
||||
"liveProof": "not-run"
|
||||
},
|
||||
{
|
||||
"app": "google-calendar",
|
||||
"method": "paperclip-read",
|
||||
|
||||
@@ -51,6 +51,15 @@ const GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS = [
|
||||
scopes: [googleScope("gmail.readonly"), googleScope("gmail.compose")],
|
||||
writeTools: ["create_draft"],
|
||||
},
|
||||
{
|
||||
profile: "gmail.archive",
|
||||
appSlug: "gmail",
|
||||
serverUrl: "https://gmailmcp.googleapis.com/mcp/v1",
|
||||
capability: "archive",
|
||||
riskTier: "S4",
|
||||
scopes: [googleScope("gmail.readonly"), googleScope("gmail.modify")],
|
||||
writeTools: ["unlabel_thread"],
|
||||
},
|
||||
{
|
||||
profile: "drive.read",
|
||||
appSlug: "google-drive",
|
||||
@@ -214,7 +223,7 @@ const GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS = [
|
||||
profile: GoogleWorkspaceConnectorProfileId;
|
||||
appSlug: string;
|
||||
serverUrl: string;
|
||||
capability: "read" | "write" | "draft";
|
||||
capability: "read" | "write" | "draft" | "archive";
|
||||
riskTier: "S3" | "S4";
|
||||
scopes: readonly string[];
|
||||
writeTools: readonly string[];
|
||||
@@ -763,10 +772,10 @@ describe("AppDefinition catalog", () => {
|
||||
APP_DEFINITIONS.find((app) => app.slug === "linear")?.methods[0]?.defaults
|
||||
?.scopesHint,
|
||||
).toEqual(["read", "write"]));
|
||||
it("defaults every new connection action to allowed except Enterpret writes", () => {
|
||||
it("limits Ask-first defaults to reviewed high-risk connection methods", () => {
|
||||
for (const app of APP_DEFINITIONS)
|
||||
for (const method of app.methods) {
|
||||
if (app.slug === "enterpret") {
|
||||
if (app.slug === "enterpret" || (app.slug === "gmail" && method.capabilityProfile?.key === "archive")) {
|
||||
expect(recommendedDefaultsForApp(app, method.key)).toEqual({
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
@@ -788,6 +797,8 @@ describe("AppDefinition catalog", () => {
|
||||
expect(getAvailableConnectionMethod(gmail)?.key).toBe(
|
||||
"customer-draft-oauth",
|
||||
);
|
||||
expect(gmail.methods.filter((method) => method.capabilityProfile?.key === "archive")
|
||||
.map((method) => method.key)).toEqual(["paperclip-archive", "customer-archive-oauth"]);
|
||||
expect(
|
||||
getRecommendedConnectionMethod(
|
||||
drive.methods.filter((candidate) =>
|
||||
|
||||
@@ -262,7 +262,15 @@ export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string
|
||||
// concrete app/method. Connection finalization remains a configure-authorized,
|
||||
// audited operation, and Ask first stays available as an operator-selected
|
||||
// policy for any action after the connection is made.
|
||||
void methodKey;
|
||||
const method = methodKey
|
||||
? app.methods.find((candidate) => candidate.key === methodKey)
|
||||
: null;
|
||||
if (app.slug === "gmail" && method?.capabilityProfile?.key === "archive") {
|
||||
return {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
};
|
||||
}
|
||||
// Enterpret's run_graph_query self-reports readOnlyHint but executes Cypher
|
||||
// and has not been established as read-only. Classify it as write (server
|
||||
// classifyRisk) and require Ask first so new token connections do not ship
|
||||
|
||||
@@ -177,6 +177,83 @@
|
||||
"Google Workspace MCP servers are in Developer Preview."
|
||||
],
|
||||
"riskTier": "S4"
|
||||
},
|
||||
{
|
||||
"key": "paperclip-archive",
|
||||
"label": "Connect with Paperclip",
|
||||
"transport": "mcp_remote",
|
||||
"auth": "oauth",
|
||||
"oauthStrategy": "paperclip_cloud_connector",
|
||||
"connectorProfile": "gmail.archive",
|
||||
"capabilityProfile": {
|
||||
"key": "archive",
|
||||
"label": "Read & archive",
|
||||
"description": "Read Gmail and archive threads by removing only the Inbox label."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
],
|
||||
"whenToUse": "Use Paperclip-managed Google OAuth to read Gmail and archive threads.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://gmailmcp.googleapis.com/mcp/v1",
|
||||
"scopesHint": [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.modify"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Gmail to search and read mail and archive threads. Archive removes only the Inbox label; sending, trashing, and other label changes remain disabled.",
|
||||
"warnings": [
|
||||
"Archiving requires approval and only removes the Inbox label.",
|
||||
"Google Workspace MCP servers are in Developer Preview."
|
||||
],
|
||||
"riskTier": "S4"
|
||||
},
|
||||
{
|
||||
"key": "customer-archive-oauth",
|
||||
"label": "Use your own Google OAuth app",
|
||||
"transport": "mcp_remote",
|
||||
"auth": "oauth",
|
||||
"capabilityProfile": {
|
||||
"key": "archive",
|
||||
"label": "Read & archive",
|
||||
"description": "Read Gmail and archive threads by removing only the Inbox label."
|
||||
},
|
||||
"grantKinds": [
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Use a customer-owned Google OAuth client to read Gmail and archive threads.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://gmailmcp.googleapis.com/mcp/v1",
|
||||
"authorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"tokenEndpoint": "https://oauth2.googleapis.com/token",
|
||||
"metadataUrl": "https://accounts.google.com/.well-known/openid-configuration",
|
||||
"scopesHint": [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.modify"
|
||||
],
|
||||
"oauthAuthorizationParams": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
}
|
||||
},
|
||||
"guidanceMd": "Enable the Gmail and Gmail MCP APIs in your Google Cloud project, then register Paperclip's callback URI. Archive removes only the Inbox label.",
|
||||
"consoleLinks": {
|
||||
"register": "https://console.cloud.google.com/auth/clients",
|
||||
"docs": "https://developers.google.com/workspace/guides/configure-mcp-servers"
|
||||
},
|
||||
"warnings": [
|
||||
"Archiving requires approval and only removes the Inbox label.",
|
||||
"Google Workspace MCP servers are in Developer Preview."
|
||||
],
|
||||
"riskTier": "S4"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export const GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS = [
|
||||
"gmail.read", "gmail.draft", "drive.read", "drive.write", "docs.read", "docs.write",
|
||||
"gmail.read", "gmail.draft", "gmail.archive", "drive.read", "drive.write", "docs.read", "docs.write",
|
||||
"sheets.read", "sheets.write", "slides.read", "slides.write", "calendar.read",
|
||||
"calendar.write", "chat.read", "chat.write", "people.read", "workspace-search.read",
|
||||
] as const;
|
||||
@@ -16,6 +16,7 @@ export const GOOGLE_WORKSPACE_CONNECTOR_PROFILES: Readonly<Record<GoogleWorkspac
|
||||
}>> = {
|
||||
"gmail.read": def("gmail", "https://gmailmcp.googleapis.com/mcp/v1", [auth("gmail.readonly")]),
|
||||
"gmail.draft": def("gmail", "https://gmailmcp.googleapis.com/mcp/v1", [auth("gmail.readonly"), auth("gmail.compose")], ["create_draft"]),
|
||||
"gmail.archive": def("gmail", "https://gmailmcp.googleapis.com/mcp/v1", [auth("gmail.readonly"), auth("gmail.modify")], ["unlabel_thread"]),
|
||||
"drive.read": def("google-drive", "https://drivemcp.googleapis.com/mcp/v1", [auth("drive.readonly")]),
|
||||
"drive.write": def("google-drive", "https://drivemcp.googleapis.com/mcp/v1", [auth("drive.readonly"), auth("drive.file")], ["copy_file", "create_file"]),
|
||||
"docs.read": def("google-docs", "https://docsmcp.googleapis.com/mcp/v1", [auth("documents.readonly")]),
|
||||
|
||||
@@ -5363,7 +5363,11 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
).toBe(false);
|
||||
expect(
|
||||
nonPilotGmail.methods.map((method: { key: string }) => method.key),
|
||||
).toEqual(["customer-read-oauth", "customer-draft-oauth"]);
|
||||
).toEqual([
|
||||
"customer-read-oauth",
|
||||
"customer-draft-oauth",
|
||||
"customer-archive-oauth",
|
||||
]);
|
||||
|
||||
const pilot = await request(
|
||||
createRouteApp(
|
||||
@@ -5384,6 +5388,34 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
"paperclip-read",
|
||||
"customer-read-oauth",
|
||||
"customer-draft-oauth",
|
||||
"customer-archive-oauth",
|
||||
]);
|
||||
|
||||
const archivePilotConnector = fakeGoogleWorkspaceConnector(
|
||||
company.id,
|
||||
userId,
|
||||
"gmail.archive",
|
||||
);
|
||||
const archivePilot = await request(
|
||||
createRouteApp(
|
||||
db,
|
||||
boardSessionActor(company.id, "owner", userId),
|
||||
undefined,
|
||||
{ paperclipCloudConnector: archivePilotConnector },
|
||||
),
|
||||
).get(`/api/companies/${company.id}/tools/gallery`);
|
||||
expect(archivePilot.status).toBe(200);
|
||||
const archivePilotGmail = archivePilot.body.apps.find(
|
||||
(app: { slug: string }) => app.slug === "gmail",
|
||||
);
|
||||
expect(archivePilotGmail.ownershipAvailability.platform_shared).toBe(true);
|
||||
expect(
|
||||
archivePilotGmail.methods.map((method: { key: string }) => method.key),
|
||||
).toEqual([
|
||||
"customer-read-oauth",
|
||||
"customer-draft-oauth",
|
||||
"paperclip-archive",
|
||||
"customer-archive-oauth",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assertGmailArchiveToolArgumentsSupported,
|
||||
gmailArchiveToolDescription,
|
||||
gmailArchiveToolInputSchema,
|
||||
} from "./gmail-archive-tool-policy.js";
|
||||
|
||||
const archive = { config: {
|
||||
sourceTemplateKey: "gmail",
|
||||
connectionMethodKey: "paperclip-archive",
|
||||
oauth: { connectorProfile: "gmail.archive" },
|
||||
} };
|
||||
|
||||
describe("Gmail archive tool policy", () => {
|
||||
it("accepts only removal of the Inbox label from one thread", () => {
|
||||
expect(() => assertGmailArchiveToolArgumentsSupported(archive, "unlabel_thread", {
|
||||
threadId: "thread-123",
|
||||
labelIds: ["INBOX"],
|
||||
})).not.toThrow();
|
||||
|
||||
for (const parameters of [
|
||||
{ threadId: "thread-123", labelIds: ["STARRED"] },
|
||||
{ threadId: "thread-123", labelIds: ["INBOX", "UNREAD"] },
|
||||
{ threadId: "thread-123", label_ids: ["INBOX"] },
|
||||
{ threadId: "", labelIds: ["INBOX"] },
|
||||
{ threadId: "thread-123", labelIds: ["INBOX"], extra: true },
|
||||
"encoded",
|
||||
]) {
|
||||
expect(() => assertGmailArchiveToolArgumentsSupported(archive, "unlabel_thread", parameters))
|
||||
.toThrow(expect.objectContaining({
|
||||
status: 400,
|
||||
details: { code: "gmail_archive_arguments_unsupported" },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
it("requires an explicit archive connection even for the official Gmail endpoint", () => {
|
||||
for (const config of [
|
||||
{ sourceTemplateKey: "gmail", connectionMethodKey: "paperclip-read", oauth: { connectorProfile: "gmail.read" } },
|
||||
{ sourceTemplateKey: "gmail", connectionMethodKey: "paperclip-draft", oauth: { connectorProfile: "gmail.draft" } },
|
||||
{ url: "https://gmailmcp.googleapis.com/mcp/v1" },
|
||||
]) {
|
||||
expect(() => assertGmailArchiveToolArgumentsSupported({ config }, "unlabel_thread", {
|
||||
threadId: "thread-123", labelIds: ["INBOX"],
|
||||
})).toThrow(expect.objectContaining({
|
||||
status: 400,
|
||||
details: { code: "gmail_archive_profile_required" },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes an Inbox-only schema without mutating the provider schema", () => {
|
||||
const providerSchema = { type: "object", properties: {
|
||||
threadId: { type: "string" },
|
||||
labelIds: { type: "array", items: { type: "string" } },
|
||||
} };
|
||||
const projected = gmailArchiveToolInputSchema(archive, "gmail/unlabel_thread", providerSchema);
|
||||
expect(projected).toMatchObject({
|
||||
properties: { labelIds: { items: { enum: ["INBOX"] }, minItems: 1, maxItems: 1 } },
|
||||
required: ["threadId", "labelIds"],
|
||||
additionalProperties: false,
|
||||
});
|
||||
expect(providerSchema.properties.labelIds.items).toEqual({ type: "string" });
|
||||
});
|
||||
|
||||
it("describes archive narrowly and leaves unrelated tools unchanged", () => {
|
||||
expect(gmailArchiveToolDescription(archive, "unlabel_thread", "Remove labels."))
|
||||
.toContain("removing only its Inbox label");
|
||||
expect(gmailArchiveToolDescription(archive, "search_threads", "Search mail."))
|
||||
.toBe("Search mail.");
|
||||
expect(() => assertGmailArchiveToolArgumentsSupported(
|
||||
{ config: { sourceTemplateKey: "other" } },
|
||||
"unlabel_thread",
|
||||
{ anything: true },
|
||||
)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
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 leafName(name: string): string {
|
||||
return (name.split(/[.:/]/).pop() ?? name)
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.replace(/_/g, "-")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isOfficialGmailConnection(connection: Connection): boolean {
|
||||
const config = connection.config;
|
||||
const profile = record(config.oauth).connectorProfile;
|
||||
if (
|
||||
config.sourceTemplateKey === "gmail"
|
||||
|| profile === "gmail.read"
|
||||
|| profile === "gmail.draft"
|
||||
|| profile === "gmail.archive"
|
||||
) return true;
|
||||
try {
|
||||
return new URL(String(config.url)).hostname === "gmailmcp.googleapis.com";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isArchiveConnection(connection: Connection): boolean {
|
||||
const config = connection.config;
|
||||
return record(config.oauth).connectorProfile === "gmail.archive"
|
||||
|| (config.sourceTemplateKey === "gmail"
|
||||
&& (config.connectionMethodKey === "paperclip-archive"
|
||||
|| config.connectionMethodKey === "customer-archive-oauth"));
|
||||
}
|
||||
|
||||
function isUnlabelThread(connection: Connection, toolName: string): boolean {
|
||||
return isOfficialGmailConnection(connection) && leafName(toolName) === "unlabel-thread";
|
||||
}
|
||||
|
||||
export function assertGmailArchiveToolArgumentsSupported(
|
||||
connection: Connection,
|
||||
toolName: string,
|
||||
parameters: unknown,
|
||||
): void {
|
||||
if (!isUnlabelThread(connection, toolName)) return;
|
||||
if (!isArchiveConnection(connection)) {
|
||||
throw badRequest("Gmail archiving requires an explicit Read & archive connection.", {
|
||||
code: "gmail_archive_profile_required",
|
||||
});
|
||||
}
|
||||
const supplied = record(parameters);
|
||||
const keys = Object.keys(supplied).sort();
|
||||
if (
|
||||
keys.length !== 2
|
||||
|| keys[0] !== "labelIds"
|
||||
|| keys[1] !== "threadId"
|
||||
|| typeof supplied.threadId !== "string"
|
||||
|| supplied.threadId.trim().length === 0
|
||||
|| !Array.isArray(supplied.labelIds)
|
||||
|| supplied.labelIds.length !== 1
|
||||
|| supplied.labelIds[0] !== "INBOX"
|
||||
) {
|
||||
throw badRequest("Gmail archive accepts only threadId and labelIds: [\"INBOX\"].", {
|
||||
code: "gmail_archive_arguments_unsupported",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function gmailArchiveToolInputSchema(
|
||||
connection: Connection,
|
||||
toolName: string,
|
||||
schema: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (!isUnlabelThread(connection, toolName) || !isArchiveConnection(connection)) return schema;
|
||||
return {
|
||||
type: "object",
|
||||
properties: {
|
||||
threadId: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
description: "The Gmail thread ID to archive.",
|
||||
},
|
||||
labelIds: {
|
||||
type: "array",
|
||||
items: { type: "string", enum: ["INBOX"] },
|
||||
minItems: 1,
|
||||
maxItems: 1,
|
||||
description: "Must be exactly [\"INBOX\"].",
|
||||
},
|
||||
},
|
||||
required: ["threadId", "labelIds"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function gmailArchiveToolDescription(
|
||||
connection: Connection,
|
||||
toolName: string,
|
||||
description: string | null,
|
||||
): string | null {
|
||||
return isUnlabelThread(connection, toolName) && isArchiveConnection(connection)
|
||||
? "Archive a Gmail thread by removing only its Inbox label. Requires human approval."
|
||||
: description;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ describe("Gmail tool governance", () => {
|
||||
expect(isGmailToolPermanentlyBlocked({ name: "get_message" })).toBe(false);
|
||||
expect(classifyRisk({ name: "create_draft" }, "gmail")).toBe("write");
|
||||
expect(isGmailToolPermanentlyBlocked({ name: "create_draft" })).toBe(false);
|
||||
expect(classifyRisk({ name: "unlabel_thread" }, "gmail")).toBe("destructive");
|
||||
expect(isGmailToolPermanentlyBlocked({ name: "unlabel_thread" })).toBe(false);
|
||||
|
||||
expect(isGmailToolPermanentlyBlocked({ name: "send_message" })).toBe(true);
|
||||
expect(isGmailToolPermanentlyBlocked({ name: "trash_thread" })).toBe(true);
|
||||
@@ -28,6 +30,10 @@ describe("Google Workspace tool governance", () => {
|
||||
|
||||
expect(isGoogleWorkspaceToolAllowed("gmail.draft", { name: "create_draft" })).toBe(true);
|
||||
expect(isGoogleWorkspaceToolAllowed("gmail.draft", { name: "send_message" })).toBe(false);
|
||||
expect(isGoogleWorkspaceToolAllowed("gmail.archive", { name: "search_threads" })).toBe(true);
|
||||
expect(isGoogleWorkspaceToolAllowed("gmail.archive", { name: "unlabel_thread" })).toBe(true);
|
||||
expect(isGoogleWorkspaceToolAllowed("gmail.archive", { name: "label_thread" })).toBe(false);
|
||||
expect(isGoogleWorkspaceToolAllowed("gmail.archive", { name: "send_message" })).toBe(false);
|
||||
expect(isGoogleWorkspaceToolAllowed("calendar.write", { name: "delete_event" })).toBe(true);
|
||||
expect(isGoogleWorkspaceToolAllowed("calendar.write", { name: "publish_calendar" })).toBe(false);
|
||||
});
|
||||
|
||||
@@ -253,6 +253,7 @@ import {
|
||||
profileIdsInBindingOrder,
|
||||
} from "./tool-profile-binding-precedence.js";
|
||||
import { assertGoogleChatToolArgumentsSupported, googleChatToolDescription, googleChatToolInputSchema } from "./google-chat-tool-policy.js";
|
||||
import { gmailArchiveToolDescription, gmailArchiveToolInputSchema } from "./gmail-archive-tool-policy.js";
|
||||
import {
|
||||
recordToolRuntimeAuditWriteFailure,
|
||||
TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC,
|
||||
@@ -1343,6 +1344,7 @@ export function projectedConnectionToolInputSchema(
|
||||
const managed = honchoManagedArguments(connection, inputSchema);
|
||||
if (managed) inputSchema = stripManagedToolArgumentSchema(inputSchema, managed);
|
||||
inputSchema = googleChatToolInputSchema(connection, toolName, inputSchema);
|
||||
inputSchema = gmailArchiveToolInputSchema(connection, toolName, inputSchema);
|
||||
const sourceTemplateKey =
|
||||
typeof connection.config.sourceTemplateKey === "string"
|
||||
? connection.config.sourceTemplateKey
|
||||
@@ -1737,7 +1739,11 @@ function toCatalogEntryForConnection(
|
||||
rawCatalogEntry.inputSchema ?? {},
|
||||
row.toolName,
|
||||
),
|
||||
description: googleChatToolDescription(connection, row.toolName, rawCatalogEntry.description),
|
||||
description: gmailArchiveToolDescription(
|
||||
connection,
|
||||
row.toolName,
|
||||
googleChatToolDescription(connection, row.toolName, rawCatalogEntry.description),
|
||||
),
|
||||
};
|
||||
if (
|
||||
connection.transport === "local_stdio" &&
|
||||
@@ -2409,6 +2415,10 @@ export function classifyRisk(
|
||||
// safe. Treat it as write so Ask-first defaults can restrict it.
|
||||
if (sourceTemplateKey === "enterpret" && normalizedToolName === "run-graph-query")
|
||||
return "write";
|
||||
// Gmail archive removes a system label. Keep it behind the archive method's
|
||||
// destructive Ask-first default even when the preview provider omits hints.
|
||||
if (sourceTemplateKey === "gmail" && normalizedToolName === "unlabel-thread")
|
||||
return "destructive";
|
||||
if (sourceTemplateKey === "posthog" && normalizedToolName === "exec")
|
||||
return "destructive";
|
||||
// Superagent mirrors its REST API, and several mutations use verbs the
|
||||
@@ -2460,6 +2470,7 @@ export function classifyRisk(
|
||||
export function isGmailToolPermanentlyBlocked(
|
||||
tool: McpToolDescriptor,
|
||||
): boolean {
|
||||
if (googleWorkspaceToolLeafName(tool.name) === "unlabel-thread") return false;
|
||||
const riskLevel = classifyRisk(tool, "gmail");
|
||||
return (
|
||||
verbMatches(
|
||||
|
||||
@@ -124,6 +124,7 @@ import {
|
||||
projectedConnectionToolInputSchema,
|
||||
} from "./tool-access.js";
|
||||
import { assertGoogleChatToolArgumentsSupported, googleChatToolDescription } from "./google-chat-tool-policy.js";
|
||||
import { assertGmailArchiveToolArgumentsSupported, gmailArchiveToolDescription } from "./gmail-archive-tool-policy.js";
|
||||
import { parseRemoteHttpEndpoint } from "./remote-http-endpoint-guard.js";
|
||||
import {
|
||||
guardedRemoteHttpFetch,
|
||||
@@ -1470,7 +1471,11 @@ export function createToolGatewayService(
|
||||
name: gatewayToolName,
|
||||
displayName: catalogEntry.title ?? catalogEntry.toolName,
|
||||
description:
|
||||
googleChatToolDescription(connection, catalogEntry.toolName, catalogEntry.description) ??
|
||||
gmailArchiveToolDescription(
|
||||
connection,
|
||||
catalogEntry.toolName,
|
||||
googleChatToolDescription(connection, catalogEntry.toolName, catalogEntry.description),
|
||||
) ??
|
||||
`Connected MCP tool ${catalogEntry.toolName} from ${connection.name}.`,
|
||||
parametersSchema: inputSchema,
|
||||
pluginId: `mcp:${applicationKey ?? application.id}`,
|
||||
@@ -5972,6 +5977,7 @@ export function createToolGatewayService(
|
||||
// Recheck immediately before dispatch, including previously approved calls
|
||||
// and connections whose stored grant/catalog predates scope reduction.
|
||||
assertGoogleChatToolArgumentsSupported(connection, entry.toolName, parameters);
|
||||
assertGmailArchiveToolArgumentsSupported(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