Relates to: #1308, #1315, #1334, #1337, #1366, #1381, #1383
Note on staleness: citations are against 738628132. PR #1366 later moved the Google code from packages/plugins/google/src/sdk/* to packages/plugins/openapi/src/providers/google/* and made the default UI add one Discovery URL per product. That move does not close the gap: the relocated discovery.ts still merges every document into one paths map, and the credential construction in packages/core/sdk is untouched. Re-verify line numbers before starting.
Problem:
Seven Google Workspace integrations in one workspace each expose an identical 239-tool superset spanning eight unrelated API families, while each connection holds a narrow four-scope grant. Calling any tool outside the granted family, for instance drive.files.list on the Gmail integration, dispatches the connection's real token and gets a 403 back from Google.
The distinct tool counts, unioned over eight generic search verbs and paged to exhaustion:
| Integration |
Distinct tools |
Families |
google_gmail |
239 |
drive 55, gmail 69, webmasters 8, calendar 31, youtube 56, sheets 15, docs 3, searchconsole 2 |
google_sheets |
239 |
identical to above |
google_cloud_resource_manager |
62 |
cloudresourcemanager 62 |
Once the integration and connection prefix is stripped, the Gmail and Sheets tool sets are byte-identical. The Cloud Resource Manager row is the control: it returns only its own family, so the namespace filter is working and these integrations genuinely differ in stored content. This is not a search bug.
The mechanism is that the tool catalog is compiled from the union of every Discovery document in a bundle. Each document's operations merge into one shared map, with no product boundary in scope at that point:
// packages/plugins/google/src/sdk/discovery.ts:1117
const paths: Record<string, Record<string, OpenApiOperationObject>> = {};
...
for (const info of infos) {
A tool's address comes from the document's own method identifier, not the slug it was filed under (discovery.ts:974, methodToolPath(service, methodId)). Nothing correlates slug with urls in addBundle.
Underneath that sits the real defect: no code anywhere compares an operation's required scope to the connection's granted scope, at catalog build or at invoke. ToolInvocationCredential (packages/core/sdk/src/plugin.ts:310) has no scope field, and the credential built immediately before dispatch does not copy one from the connection row, though the row is in scope one line earlier and carries oauth_scope:
// packages/core/sdk/src/executor.ts:3617
const values = yield* resolveConnectionValues(connectionRow);
const integrationRow = yield* findIntegrationRow(parsed.integration);
const credential: ToolInvocationCredential = {
owner: parsed.owner,
integration: parsed.integration,
connection: parsed.connection,
template: AuthTemplateSlug.make(connectionRow.template),
value: values[PRIMARY_INPUT_VARIABLE] ?? null,
values,
config: integrationRow ? decodeJsonColumn(integrationRow.config) : undefined,
};
Per-operation scopes do exist upstream of this, embedded in the compiled spec's security and x-google-scopes (discovery.ts:935), but they never survive extraction. ExtractedOperation and OperationBinding carry no scope field, and toBinding whitelists only method, servers, path template, parameters, and bodies. There is no plumbing to hang a check on.
That the union is a deliberate feature is not in dispute: googleOpenApiBundlePreset names it explicitly. The point is that a per-product integration is meant to carry only that product's tools, and nothing enforces it.
Unresolved: how these seven integrations came to hold the full union is not known. They already carried it at creation. No updateBundle call was ever made against them, and the React flow at AddGoogleSource.tsx creates a single google bundle rather than seven product-named ones. updateBundle (plugin.ts:271) looks like it could produce a similar end state by widening an existing narrow integration's urls, since nextConfig spreads ...current and so preserves the auth template, but that is inspection, not observation, and it is not the path this workspace took. Please do not treat the mechanism as settled. Determining what created these, an older UI, a script, or a direct API caller, would help but is not required to act on the missing invariant, which holds regardless of how the grant came to be narrow.
Reproduction:
Needs a workspace with two or more Google integrations connected. The enumeration is a lower bound, not a census, because a namespace cannot be listed exhaustively (see #1383).
const QUERIES = ["list", "get", "create", "update", "delete", "query", "send", "search"];
const enumerateNamespace = async (namespace: string) => {
const seen = new Set<string>();
for (const query of QUERIES) {
let offset = 0;
for (let page = 0; page < 6; page++) {
const res = await tools.search({ namespace, query, limit: 50, offset });
for (const item of res.items) seen.add(item.path);
if (!res.hasMore) break;
offset = res.nextOffset;
}
}
return [...seen];
};
// Identical once the integration/owner/connection prefix is stripped.
const gmail = await enumerateNamespace("google_gmail");
const sheets = await enumerateNamespace("google_sheets");
// Control: returns only its own family.
const crm = await enumerateNamespace("google_cloud_resource_manager");
// Exposed under google_gmail, whose grant holds no Drive scope.
const foreign = await tools.google_gmail.<owner>.<connection>.drive.files.list({ pageSize: 1 });
The foreign call returns:
{
"ok": false,
"code": "connection_rejected",
"status": 403,
"details": { "upstream": { "details": { "error": {
"status": "PERMISSION_DENIED",
"details": [{ "reason": "ACCESS_TOKEN_SCOPE_INSUFFICIENT",
"metadata": { "service": "drive.googleapis.com" } }]
}}}}
}
Same-family calls on each integration succeed, so the connections themselves are configured correctly.
Expected vs actual:
Expected: a tool requiring a scope the connection never received is absent from the catalog, or rejected locally before any network call. The exposed tool set is a subset of what the connection may actually do.
Actual: the catalog is the union of every document in the bundle, addressed by operation identity rather than slug. A cross-family call is dispatched with the connection's real token and rejected by Google with a 403. It never succeeds, but the rejection is remote, late, and (see #1381) mislabelled as a re-authentication problem.
Proposed solution:
Three options, increasing in strength, not mutually exclusive.
Cheapest is a creation-path guard: in addBundle and updateBundle, reject a slug matching a single-product preset id unless every resolved URL belongs to that product. It misses a deliberately mis-slugged bundle, so it is defense in depth, not the fix.
The real fix is to carry scope through. Add a required-scopes field to ExtractedOperation and OperationBinding, populate it from operation.security during extraction, thread it through toBinding, and add grantedScopes to ToolInvocationCredential, sourced from connectionRow.oauth_scope. Containment is not string-set membership: a broad granted Google scope does not textually contain the narrow scope an operation declares, so comparison needs a plugin-supplied predicate, and should ship advisory-only at first, annotating the eventual 403 with the missing scope rather than blocking locally.
With both scopes available, catalog projection follows: filter a connection's tool list to what its grant satisfies, failing open when the granted scope is unknown so non-OAuth connections are unaffected.
Two decisions belong to a maintainer. Whether a deliberate multi-product bundle should fail open on scope checks, since the user consented broadly up front. And whether existing over-wide catalogs get a remediation pass or wait for a re-sync. The closed per-service redesign stack changed how integrations are created but never added this invariant, so it is worth reading before starting, if only so the invariant is not skipped a second time on the assumption it is already covered.
Relates to: #1308, #1315, #1334, #1337, #1366, #1381, #1383
Note on staleness: citations are against
738628132. PR #1366 later moved the Google code frompackages/plugins/google/src/sdk/*topackages/plugins/openapi/src/providers/google/*and made the default UI add one Discovery URL per product. That move does not close the gap: the relocateddiscovery.tsstill merges every document into onepathsmap, and the credential construction inpackages/core/sdkis untouched. Re-verify line numbers before starting.Problem:
Seven Google Workspace integrations in one workspace each expose an identical 239-tool superset spanning eight unrelated API families, while each connection holds a narrow four-scope grant. Calling any tool outside the granted family, for instance
drive.files.liston the Gmail integration, dispatches the connection's real token and gets a 403 back from Google.The distinct tool counts, unioned over eight generic search verbs and paged to exhaustion:
google_gmailgoogle_sheetsgoogle_cloud_resource_managerOnce the integration and connection prefix is stripped, the Gmail and Sheets tool sets are byte-identical. The Cloud Resource Manager row is the control: it returns only its own family, so the
namespacefilter is working and these integrations genuinely differ in stored content. This is not a search bug.The mechanism is that the tool catalog is compiled from the union of every Discovery document in a bundle. Each document's operations merge into one shared map, with no product boundary in scope at that point:
A tool's address comes from the document's own method identifier, not the slug it was filed under (
discovery.ts:974,methodToolPath(service, methodId)). Nothing correlatesslugwithurlsinaddBundle.Underneath that sits the real defect: no code anywhere compares an operation's required scope to the connection's granted scope, at catalog build or at invoke.
ToolInvocationCredential(packages/core/sdk/src/plugin.ts:310) has no scope field, and the credential built immediately before dispatch does not copy one from the connection row, though the row is in scope one line earlier and carriesoauth_scope:Per-operation scopes do exist upstream of this, embedded in the compiled spec's
securityandx-google-scopes(discovery.ts:935), but they never survive extraction.ExtractedOperationandOperationBindingcarry no scope field, andtoBindingwhitelists only method, servers, path template, parameters, and bodies. There is no plumbing to hang a check on.That the union is a deliberate feature is not in dispute:
googleOpenApiBundlePresetnames it explicitly. The point is that a per-product integration is meant to carry only that product's tools, and nothing enforces it.Unresolved: how these seven integrations came to hold the full union is not known. They already carried it at creation. No
updateBundlecall was ever made against them, and the React flow atAddGoogleSource.tsxcreates a singlegooglebundle rather than seven product-named ones.updateBundle(plugin.ts:271) looks like it could produce a similar end state by widening an existing narrow integration'surls, sincenextConfigspreads...currentand so preserves the auth template, but that is inspection, not observation, and it is not the path this workspace took. Please do not treat the mechanism as settled. Determining what created these, an older UI, a script, or a direct API caller, would help but is not required to act on the missing invariant, which holds regardless of how the grant came to be narrow.Reproduction:
Needs a workspace with two or more Google integrations connected. The enumeration is a lower bound, not a census, because a namespace cannot be listed exhaustively (see #1383).
The foreign call returns:
{ "ok": false, "code": "connection_rejected", "status": 403, "details": { "upstream": { "details": { "error": { "status": "PERMISSION_DENIED", "details": [{ "reason": "ACCESS_TOKEN_SCOPE_INSUFFICIENT", "metadata": { "service": "drive.googleapis.com" } }] }}}} }Same-family calls on each integration succeed, so the connections themselves are configured correctly.
Expected vs actual:
Expected: a tool requiring a scope the connection never received is absent from the catalog, or rejected locally before any network call. The exposed tool set is a subset of what the connection may actually do.
Actual: the catalog is the union of every document in the bundle, addressed by operation identity rather than slug. A cross-family call is dispatched with the connection's real token and rejected by Google with a 403. It never succeeds, but the rejection is remote, late, and (see #1381) mislabelled as a re-authentication problem.
Proposed solution:
Three options, increasing in strength, not mutually exclusive.
Cheapest is a creation-path guard: in
addBundleandupdateBundle, reject a slug matching a single-product preset id unless every resolved URL belongs to that product. It misses a deliberately mis-slugged bundle, so it is defense in depth, not the fix.The real fix is to carry scope through. Add a required-scopes field to
ExtractedOperationandOperationBinding, populate it fromoperation.securityduring extraction, thread it throughtoBinding, and addgrantedScopestoToolInvocationCredential, sourced fromconnectionRow.oauth_scope. Containment is not string-set membership: a broad granted Google scope does not textually contain the narrow scope an operation declares, so comparison needs a plugin-supplied predicate, and should ship advisory-only at first, annotating the eventual 403 with the missing scope rather than blocking locally.With both scopes available, catalog projection follows: filter a connection's tool list to what its grant satisfies, failing open when the granted scope is unknown so non-OAuth connections are unaffected.
Two decisions belong to a maintainer. Whether a deliberate multi-product bundle should fail open on scope checks, since the user consented broadly up front. And whether existing over-wide catalogs get a remediation pass or wait for a re-sync. The closed per-service redesign stack changed how integrations are created but never added this invariant, so it is worth reading before starting, if only so the invariant is not skipped a second time on the assumption it is already covered.