mcp-google-multi 6.0.0-beta.3 → 6.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +5 -5
  2. package/dist/accounts.d.ts +3 -3
  3. package/dist/accounts.js +18 -31
  4. package/dist/arg-normalize.js +15 -35
  5. package/dist/arg-strict.js +8 -28
  6. package/dist/auth.d.ts +1 -1
  7. package/dist/auth.js +5 -10
  8. package/dist/boot-gates.d.ts +1 -1
  9. package/dist/boot-gates.js +3 -5
  10. package/dist/cimd-cache.js +5 -11
  11. package/dist/client-config.d.ts +2 -2
  12. package/dist/client-config.js +8 -14
  13. package/dist/client.js +0 -0
  14. package/dist/compose.d.ts +8 -1
  15. package/dist/compose.js +11 -11
  16. package/dist/config-file.js +7 -12
  17. package/dist/discover.js +4 -9
  18. package/dist/discovery-client.js +14 -33
  19. package/dist/doctor.d.ts +3 -3
  20. package/dist/doctor.js +12 -18
  21. package/dist/env-load.js +2 -5
  22. package/dist/executor.js +2 -4
  23. package/dist/fanout.js +8 -17
  24. package/dist/fs-atomic.d.ts +0 -3
  25. package/dist/fs-atomic.js +6 -10
  26. package/dist/hint-provider.js +4 -10
  27. package/dist/http-config.d.ts +2 -2
  28. package/dist/http-config.js +3 -7
  29. package/dist/http-transport.d.ts +5 -5
  30. package/dist/http-transport.js +39 -92
  31. package/dist/identity.d.ts +2 -2
  32. package/dist/identity.js +6 -9
  33. package/dist/index.js +19 -31
  34. package/dist/master-key.d.ts +3 -3
  35. package/dist/master-key.js +11 -22
  36. package/dist/mcp-token.d.ts +3 -13
  37. package/dist/mcp-token.js +16 -32
  38. package/dist/metrics-cli.d.ts +1 -1
  39. package/dist/metrics-cli.js +1 -1
  40. package/dist/metrics-tap.js +2 -5
  41. package/dist/migrate-config.d.ts +1 -2
  42. package/dist/migrate-config.js +3 -5
  43. package/dist/net-tuning.js +2 -4
  44. package/dist/oauth-as.d.ts +2 -2
  45. package/dist/oauth-as.js +32 -73
  46. package/dist/oauth-consent.js +4 -10
  47. package/dist/outbound-allowlist.js +4 -9
  48. package/dist/reauth-hint.js +2 -5
  49. package/dist/registry-transfer.d.ts +1 -1
  50. package/dist/registry-transfer.js +5 -13
  51. package/dist/registry.d.ts +1 -9
  52. package/dist/registry.js +38 -66
  53. package/dist/scope-catalog.d.ts +5 -0
  54. package/dist/scope-catalog.js +17 -7
  55. package/dist/scope-observability.d.ts +2 -3
  56. package/dist/scope-observability.js +8 -14
  57. package/dist/services.d.ts +3 -0
  58. package/dist/services.js +5 -10
  59. package/dist/ssrf-guard.js +11 -22
  60. package/dist/tenant-purge.js +2 -4
  61. package/dist/token-store.js +0 -0
  62. package/dist/tools/_coerce.d.ts +4 -4
  63. package/dist/tools/_errors.js +22 -39
  64. package/dist/tools/_local-files.js +4 -8
  65. package/dist/tools/account-wizard.d.ts +4 -3
  66. package/dist/tools/account-wizard.js +48 -54
  67. package/dist/tools/accounts-tool.d.ts +1 -1
  68. package/dist/tools/accounts-tool.js +1 -1
  69. package/dist/tools/admin.js +2 -3
  70. package/dist/tools/analytics.js +4 -5
  71. package/dist/tools/calendar.js +4 -7
  72. package/dist/tools/chat.js +2 -3
  73. package/dist/tools/contacts.js +8 -9
  74. package/dist/tools/docs.js +4 -9
  75. package/dist/tools/drive.js +18 -27
  76. package/dist/tools/forms.js +2 -3
  77. package/dist/tools/gmail-mime.d.ts +1 -1
  78. package/dist/tools/gmail-mime.js +7 -12
  79. package/dist/tools/gmail.d.ts +2 -2
  80. package/dist/tools/gmail.js +26 -40
  81. package/dist/tools/google-api.js +5 -7
  82. package/dist/tools/meet.js +2 -3
  83. package/dist/tools/searchconsole.js +2 -3
  84. package/dist/tools/sheets.js +2 -3
  85. package/dist/tools/slides.js +2 -3
  86. package/dist/tools/tasks.js +2 -3
  87. package/dist/trim.js +2 -3
  88. package/dist/usage-metrics.js +5 -12
  89. package/dist/write-control.d.ts +4 -8
  90. package/dist/write-control.js +3 -4
  91. package/package.json +21 -21
package/dist/mcp-token.js CHANGED
@@ -1,8 +1,5 @@
1
- // B13 token model (oauth-authorization-server.md §Data model): the MCP access
2
- // token (HS256 JWT, jose-only — AS==RS so no JWKS), the signed self-contained
3
- // `state` and authorization code, the in-memory single-use replay guard, and
4
- // the rotated refresh-token store. Keyed by the provisioned MCP_JWT_KEY
5
- // (B5), so tokens survive a restart as long as the key persists.
1
+ // Token model: the MCP access token (HS256 JWT, jose-only), signed state and
2
+ // authorization code, the replay guard, and the rotated refresh-token store.
6
3
  import { createHash, createHmac, hkdfSync, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
7
4
  import { existsSync, readFileSync } from 'node:fs';
8
5
  import { basename } from 'node:path';
@@ -126,7 +123,7 @@ export class ReplayGuard {
126
123
  }
127
124
  /** Record a jti as spent. A full guard refuses instead of evicting a live
128
125
  * jti: a flood of spends would otherwise make a spent artifact usable again,
129
- * such as a callback the browser binding refused (H4 F1). `artifactExpMs`
126
+ * such as a callback the browser binding refused. `artifactExpMs`
130
127
  * keeps the record alive as long as the artifact itself, even when the clock
131
128
  * stepped back between its signing and this spend. */
132
129
  spend(jti, ttlMs, nowMs, artifactExpMs = 0) {
@@ -165,10 +162,8 @@ export class ReplayGuard {
165
162
  if (exp < this.earliest)
166
163
  this.earliest = exp;
167
164
  }
168
- // Expired entries are dropped only here, when the map is at the cap: a refusal
169
- // costs O(1), and a sweep runs only once something can have expired, at most
170
- // once a second. Walking the map from its head on every call is not cheap: a
171
- // Map iterator steps over every deleted slot until the table is rebuilt.
165
+ // Swept only when at the cap (a refusal otherwise costs O(1)), and at most
166
+ // once a second: a Map iterator steps over every deleted slot until rebuilt.
172
167
  room(nowMs) {
173
168
  if (this.seen.size < this.cap)
174
169
  return true;
@@ -190,18 +185,16 @@ export class ReplayGuard {
190
185
  }
191
186
  /** Store files this release writes; an absent `format` is an earlier release's. */
192
187
  const REFRESH_FORMAT = 2;
193
- // r1 token: "r1." + base64url(fid 16 || gen uint32 BE || rand 16 || HMAC-SHA256
194
- // of those 36 bytes). The dot is outside the base64url alphabet, so an r1
195
- // token can never be read as an earlier release's 43-character token.
188
+ // r1 token: "r1." + base64url(fid 16 || gen uint32 BE || rand 16 || HMAC-SHA256).
189
+ // The dot is outside the base64url alphabet, so it can't be read as a legacy 43-char token.
196
190
  const R1_PREFIX = 'r1.';
197
191
  const R1_LENGTH = 94;
198
192
  const R1_BODY = 36;
199
193
  const R1_BYTES = 68;
200
194
  const R1_MAC_DOMAIN = Buffer.from('mcp-google-multi refresh r1\n');
201
195
  const R1_KEY_INFO = 'mcp-google-multi:refresh-mac:v1';
202
- // Every r1 token carries a MAC under a key from MASTER_KEY, so a passphrase
203
- // key (deriveKey's bare-sha256 branch) would let one leaked token test
204
- // guesses offline at hash speed. scrypt gives that branch a work factor.
196
+ // A passphrase key (deriveKey's bare-sha256 branch) would otherwise let one
197
+ // leaked r1 token test guesses offline at hash speed; scrypt adds a work factor.
205
198
  const R1_SCRYPT_SALT = 'mcp-google-multi:refresh-mac:scrypt:v1';
206
199
  const R1_SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
207
200
  const GEN_MAX = 0xffffffff;
@@ -304,9 +297,8 @@ function checkFormat(file, d) {
304
297
  throw formatError(file, d.format);
305
298
  }
306
299
  }
307
- /** Only well-formed own entries survive, so no lookup lands on an inherited
308
- * key and no malformed record reaches a liveness test or the token codec.
309
- * `dirty` says something was dropped. */
300
+ // Only well-formed own entries survive, so no malformed record reaches a
301
+ // liveness test or the token codec; `dirty` says something was dropped.
310
302
  function decodeData(d) {
311
303
  const data = { families: {}, active: {}, spent: {} };
312
304
  const x = (d ?? {});
@@ -378,10 +370,10 @@ function lifetimeMs(name, v) {
378
370
  * Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
379
371
  * use, and a token carries its family and generation under a MAC, so
380
372
  * presenting any generation the family has rotated past is REUSE: the family
381
- * is revoked for the rest of its life (#7 / C14, H4 F2). The store keeps one
373
+ * is revoked for the rest of its life. The store keeps one
382
374
  * record per family, whatever its rotation count. Every read-modify-write goes
383
375
  * through a file lock so concurrent stdio+HTTP processes can't lost-update or
384
- * double-spend (#12). See docs/internals.md.
376
+ * double-spend. See docs/internals.md.
385
377
  */
386
378
  export class RefreshStore {
387
379
  path;
@@ -504,20 +496,12 @@ export class RefreshStore {
504
496
  this.log(line);
505
497
  }
506
498
  catch {
507
- // The rotation is already on disk; a log sink must not lose its token.
499
+ // log sink failure ignored
508
500
  }
509
501
  }
510
502
  }
511
- /** Evict `sub`'s own least-recently-used sessions (both formats) so at
512
- * most `limit` of its live sessions remain; dropping one here is the same
513
- * as deleting it from `data.families` / `data.active` on a revoke, so it
514
- * can never be presented again (reuse detection for every OTHER family is
515
- * untouched). Only `sub`'s own entries are ever inspected.
516
- *
517
- * `keepKey` is the family just issued: it is excluded from the ranking so
518
- * a clock step back, or a concurrent rotate's `usedAt = max(usedAt, nowMs)`
519
- * racing ahead of it under the lock, can never make the brand-new family
520
- * look like the least recently used one and drop itself. */
503
+ // Evicts sub's own least-recently-used sessions down to `limit`; keepKey (the
504
+ // family just issued) is excluded from ranking. See docs/internals.md.
521
505
  enforceFamilyCap(data, sub, limit, lines, keepKey) {
522
506
  const entries = [];
523
507
  for (const [k, f] of Object.entries(data.families)) {
@@ -15,7 +15,7 @@ interface PromotionRow {
15
15
  calls: number;
16
16
  kind: 'curation-candidate' | 'visibility-failure' | 'generation-gap';
17
17
  }
18
- /** The promotion-queue view: ranked evidence, a human decides (spec section 5). */
18
+ /** The promotion-queue view: ranked evidence, a human decides. */
19
19
  export declare function classifyPromotion(merged: DayAgg, data: PromotionData): PromotionRow[];
20
20
  export declare function runMetricsCli(argv: string[], deps: MetricsCliDeps): number;
21
21
  export {};
@@ -87,7 +87,7 @@ function renderReport(merged, days, out) {
87
87
  }
88
88
  }
89
89
  const tail = (id) => id.split('.').slice(1).join('.');
90
- /** The promotion-queue view: ranked evidence, a human decides (spec section 5). */
90
+ /** The promotion-queue view: ranked evidence, a human decides. */
91
91
  export function classifyPromotion(merged, data) {
92
92
  const curated = new Set();
93
93
  for (const id of data.curatedIds) {
@@ -1,10 +1,7 @@
1
1
  import { isSchemaValidationText } from './arg-normalize.js';
2
2
  const PENDING_CAP = 1_000;
3
- // The detector lives next to the rewriter that consumes the same frames, so
4
- // the two can never drift apart. This tap sees the ORIGINAL prose:
5
- // withValidationEnvelope sits BELOW it and converts that same frame into an
6
- // envelope for the client, so classification here and the contract on the wire
7
- // stay independent.
3
+ // This tap sees the ORIGINAL prose; withValidationEnvelope sits BELOW it and
4
+ // converts the same frame for the client, so the two stay independent.
8
5
  export { isSchemaValidationText };
9
6
  /**
10
7
  * @param isKnownTool membership test against the REGISTERED tool set. The
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Synthesize config.json accounts{} from the v5 env (GOOGLE_ACCOUNTS +
3
3
  * GOOGLE_ADMIN_ACCOUNTS). Never deletes or edits env; idempotent (a re-run
4
- * that changes nothing says so). Scope-profile and preference fields are
5
- * migrated by their own slices' commands.
4
+ * that changes nothing says so).
6
5
  */
7
6
  export declare function runMigrateConfig(env?: NodeJS.ProcessEnv): void;
@@ -6,8 +6,7 @@ function parseCsv(value) {
6
6
  /**
7
7
  * Synthesize config.json accounts{} from the v5 env (GOOGLE_ACCOUNTS +
8
8
  * GOOGLE_ADMIN_ACCOUNTS). Never deletes or edits env; idempotent (a re-run
9
- * that changes nothing says so). Scope-profile and preference fields are
10
- * migrated by their own slices' commands.
9
+ * that changes nothing says so).
11
10
  */
12
11
  export function runMigrateConfig(env = process.env) {
13
12
  const raw = env.GOOGLE_ACCOUNTS;
@@ -35,7 +34,7 @@ export function runMigrateConfig(env = process.env) {
35
34
  }
36
35
  desired[alias] = { email };
37
36
  }
38
- // Legacy global scopes (BC7) become an explicit named profile every account
37
+ // Legacy global scopes become an explicit named profile every account
39
38
  // points at, so removing the env var later changes nothing.
40
39
  const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
41
40
  let legacyProfile;
@@ -59,8 +58,7 @@ export function runMigrateConfig(env = process.env) {
59
58
  const filePath = configFilePath(env);
60
59
  const before = loadConfigFile(filePath);
61
60
  // Merge per-alias: env owns the alias set and emails, but file-only fields
62
- // (scopeProfile, admin) survive for retained aliases — mirroring runtime
63
- // precedence, where unset GOOGLE_ADMIN_ACCOUNTS preserves file admin flags.
61
+ // (scopeProfile, admin) survive for retained aliases.
64
62
  const existing = before?.accounts ?? {};
65
63
  const dropped = Object.keys(existing).filter((a) => !(a in desired));
66
64
  for (const [alias, entry] of Object.entries(desired)) {
@@ -1,8 +1,6 @@
1
1
  import net from 'node:net';
2
- // Node's happy-eyeballs gives each address family 250ms per connect attempt on
3
- // every LTS line (raised to 500ms only in v25.2+); on high-latency or
4
- // broken-IPv6 links that aborts EVERY Google call while curl works. Raise the
5
- // process default unless the user tuned it themselves. Why: docs/internals.md.
2
+ // Node's happy-eyeballs 250ms-per-attempt default aborts EVERY Google call on
3
+ // a high-latency or broken-IPv6 link while curl works. See docs/internals.md.
6
4
  export const CONNECT_ATTEMPT_TIMEOUT_MS = 2000;
7
5
  const USER_FLAGS = ['--network-family-autoselection-attempt-timeout', '--no-network-family-autoselection'];
8
6
  /** Pure decision: the timeout to apply, or null to leave Node's setting alone. */
@@ -60,7 +60,7 @@ export interface GoogleIdentity {
60
60
  sub?: string;
61
61
  hd?: string;
62
62
  }
63
- /** Only trust the id_token identity when Google marks the email verified (#8).
63
+ /** Only trust the id_token identity when Google marks the email verified.
64
64
  * The token comes straight from Google's token endpoint, so its claims are
65
65
  * read unsigned. A malformed sub or hd is dropped, never passed on. */
66
66
  export declare function verifiedIdentityFromIdToken(idToken?: string | null): GoogleIdentity | undefined;
@@ -146,7 +146,7 @@ export interface AuthServer {
146
146
  * start a re-auth nor learn which aliases exist or what they hold. */
147
147
  reauthLink: (alias: string) => string;
148
148
  }
149
- /** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
149
+ /** Exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
150
150
  * the fixed claude.ai callback. */
151
151
  export declare function redirectAllowed(redirectUri: string, docRedirectUris: string[]): boolean;
152
152
  export declare function buildAuthServer(config: AuthServerConfig, deps?: AuthServerDeps): AuthServer;
package/dist/oauth-as.js CHANGED
@@ -1,10 +1,5 @@
1
- // B13: the minimal OAuth 2.1 Authorization Server + Resource Server
2
- // (oauth-authorization-server.md). Federate-and-hold: the server mints its own
3
- // audience-bound HS256 token for the MCP client (leg A), gates on an owner
4
- // Google login (leg B, tokens discarded), and separately holds per-alias Google
5
- // tokens (leg C, unchanged). Mounts into B12's HttpTransportHost as `routes` +
6
- // an `authenticate` for POST /mcp. All Google/CIMD I/O is injectable so the
7
- // whole surface is exercised by an offline harness.
1
+ // The minimal OAuth 2.1 Authorization Server + Resource Server: federate-and-hold
2
+ // (own token / discarded owner login / held per-alias tokens). See docs/internals.md.
8
3
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
9
4
  import { isIPv4 } from 'node:net';
10
5
  import { decodeJwt } from 'jose';
@@ -13,21 +8,17 @@ import { fetchCimdDocument } from './ssrf-guard.js';
13
8
  import { CimdClientCache } from './cimd-cache.js';
14
9
  import { logSafe } from './trim.js';
15
10
  const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
16
- // DCR (/register) is public + unauthenticated, so its in-memory store must be
17
- // bounded like every sibling store (the ReplayGuard cap) or a request loop
18
- // OOMs the process. Cap client count (FIFO-evict the oldest) and
19
- // the per-request redirect_uris shape so one entry can't be arbitrarily large.
11
+ // /register is public and unauthenticated, so its store must stay bounded
12
+ // (FIFO-evict the oldest) like every sibling store, or a request loop OOMs.
20
13
  export const DCR_MAX_CLIENTS = 1000;
21
14
  export const DCR_MAX_REDIRECT_URIS = 10;
22
15
  const DCR_MAX_URI_LEN = 2048;
23
16
  const OIDC_SUB = /^[\x21-\x7e]{1,255}$/;
24
17
  const HOSTED_DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i;
25
- // RFC 6749's error_description charset: %x20-21 / %x23-5B / %x5D-7E (printable
26
- // ASCII minus the backslash and double-quote that would need escaping in the
27
- // query string). A resolveSubject denial outside this set is dropped rather
28
- // than forwarded unvalidated (H3 PR1).
18
+ // RFC 6749 error_description charset (%x20-21/%x23-5B/%x5D-7E); a denial
19
+ // description outside it is dropped rather than forwarded unvalidated.
29
20
  const ERROR_DESCRIPTION_RE = /^[\x20-\x21\x23-\x5b\x5d-\x7e]{1,200}$/;
30
- /** Only trust the id_token identity when Google marks the email verified (#8).
21
+ /** Only trust the id_token identity when Google marks the email verified.
31
22
  * The token comes straight from Google's token endpoint, so its claims are
32
23
  * read unsigned. A malformed sub or hd is dropped, never passed on. */
33
24
  export function verifiedIdentityFromIdToken(idToken) {
@@ -90,7 +81,7 @@ function isLoopbackHostname(h) {
90
81
  // prefix (which would let "127.evil.com" masquerade as loopback).
91
82
  return isIPv4(host) && host.split('.')[0] === '127';
92
83
  }
93
- /** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
84
+ /** Exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
94
85
  * the fixed claude.ai callback. */
95
86
  export function redirectAllowed(redirectUri, docRedirectUris) {
96
87
  if (redirectUri === CLAUDE_AI_FIXED_CALLBACK)
@@ -118,10 +109,7 @@ export function redirectAllowed(redirectUri, docRedirectUris) {
118
109
  return isLoopbackHostname(du.hostname) && du.protocol === r.protocol && du.pathname === r.pathname;
119
110
  });
120
111
  }
121
- // The owner_gate browser binding (docs/internals.md). One cookie per flow, so
122
- // two sign-ins started in one browser do not overwrite each other. __Host-
123
- // requires Secure, which an http base (the loopback owner) cannot rely on in
124
- // every browser, so an http base gets a plain host-only name instead.
112
+ // Owner-gate browser binding: one cookie per flow; see docs/internals.md.
125
113
  const BIND_HASH = /^[A-Za-z0-9_-]{43}$/;
126
114
  function bindCookieName(base, bind) {
127
115
  return `${base.startsWith('https:') ? '__Host-' : ''}mgm-og-${bind.slice(0, 16)}`;
@@ -193,10 +181,7 @@ export function buildAuthServer(config, deps = {}) {
193
181
  const base = config.base;
194
182
  const resource = config.resourceUri;
195
183
  const secret = config.secret;
196
- // States, approvals and codes live minutes, and their single use rests on an
197
- // in-memory replay guard that a restart empties. So they are signed with a
198
- // key only this AuthServer holds: a restart invalidates every one in flight,
199
- // instead of letting a spent or unreserved one pass the new, empty guard.
184
+ // States/approvals/codes are signed with a per-process secret; see docs/internals.md.
200
185
  const flowSecret = new Uint8Array(randomBytes(32));
201
186
  const accessTtl = config.accessTtlSec ?? ACCESS_TTL_DEFAULT;
202
187
  const cimd = deps.fetchCimd ?? ((clientId) => fetchCimdDocument(clientId));
@@ -256,7 +241,7 @@ export function buildAuthServer(config, deps = {}) {
256
241
  const reg = registered.get(clientId);
257
242
  if (reg)
258
243
  return { redirect_uris: reg.redirect_uris, dcr: true };
259
- // C7/#5: validate the issuer host + scheme BEFORE any outbound fetch, so we
244
+ // Validate the issuer host + scheme BEFORE any outbound fetch, so we
260
245
  // only ever fetch an allowlisted issuer (no arbitrary-URL SSRF surface).
261
246
  let issuerHost;
262
247
  let scheme;
@@ -273,15 +258,15 @@ export function buildAuthServer(config, deps = {}) {
273
258
  log(`CIMD rejected: issuer=${logSafe(`${scheme}//${issuerHost}`, 80)} not allowlisted client_id_len=${clientId.length}`);
274
259
  return { error: 'E_CIMD_INVALID', message: 'invalid client' };
275
260
  }
276
- // #15: cached only once it validates; the cache logs a failed fetch.
261
+ // Cached only once it validates; the cache logs a failed fetch.
277
262
  const got = await cimdClients.resolve(clientId, (doc) => doc.client_id === clientId && Array.isArray(doc.redirect_uris) && doc.redirect_uris.length > 0);
278
263
  if ('busy' in got)
279
264
  return got;
280
- // #13: never echo internal fetch details to the caller (blind-SSRF oracle).
265
+ // Never echo internal fetch details to the caller (blind-SSRF oracle).
281
266
  if ('error' in got)
282
267
  return { error: got.error, message: 'invalid client' };
283
268
  const uris = got.doc.redirect_uris;
284
- // C6 #14: warn on a localhost-only redirect set (loopback impersonation),
269
+ // Warn on a localhost-only redirect set (loopback impersonation),
285
270
  // once per fetch rather than on every sign-in the cache serves.
286
271
  if (got.fresh && uris.every((u) => { try {
287
272
  return isLoopbackHostname(new URL(u).hostname);
@@ -299,7 +284,7 @@ export function buildAuthServer(config, deps = {}) {
299
284
  /** `incoming`: the artifact spent to get here. If no slot is left for the
300
285
  * new state it is unspent, so retrying it works once a slot frees. */
301
286
  async function toGoogle(res, sp, incoming) {
302
- // H4 F1: without this, a Google URL minted here and forwarded to someone
287
+ // Without this, a Google URL minted here and forwarded to someone
303
288
  // else signs THEM in and hands their subject to the starting client.
304
289
  const headers = {};
305
290
  if (sp.flow === 'owner_gate') {
@@ -323,8 +308,6 @@ export function buildAuthServer(config, deps = {}) {
323
308
  : `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
324
309
  return redirect(res, authUrl, headers);
325
310
  }
326
- // GET /authorize (validate + interstitial for DCR clients); POST /authorize
327
- // (a DCR consent approval — verify the signed pending token, then proceed).
328
311
  const authorize = async (req, res, url) => {
329
312
  if (req.method === 'POST') {
330
313
  let form;
@@ -341,7 +324,6 @@ export function buildAuthServer(config, deps = {}) {
341
324
  catch {
342
325
  return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
343
326
  }
344
- // Only the client leg signs pendings, and it is always the owner sign-in.
345
327
  // Defence in depth: a pending from an earlier release, which could carry
346
328
  // flow=alias_reauth, was signed with MCP_JWT_KEY and no longer verifies.
347
329
  if (pending.flow !== 'owner_gate') {
@@ -364,10 +346,8 @@ export function buildAuthServer(config, deps = {}) {
364
346
  const resourceParam = q.get('resource') ?? '';
365
347
  const clientState = q.get('state') ?? undefined;
366
348
  const aliasParam = q.get('alias') ?? undefined;
367
- // Server-minted alias_add link: the whole request identity (tenant, alias,
368
- // bundles) lives inside the SIGNED state — verify, spend it (single-use),
369
- // then re-sign fresh for the Google leg. A tampered tenantId breaks the
370
- // signature; a caller cannot supply one at all.
349
+ // alias_add: tenant/alias/bundles live inside the SIGNED state, verified
350
+ // and spent single-use before re-signing for Google; a caller can't tamper with them.
371
351
  if (q.get('flow') === 'alias_add' && !clientId) {
372
352
  let minted;
373
353
  try {
@@ -388,11 +368,7 @@ export function buildAuthServer(config, deps = {}) {
388
368
  void _spent;
389
369
  return toGoogle(res, sp, minted.jti);
390
370
  }
391
- // In-call re-auth (BV-1): a user-clicked `flow=alias_reauth` link has NO
392
- // client leg — no code is delivered to any client, the refreshed token is
393
- // written server-side for the alias — so it skips the client_id/redirect/
394
- // PKCE requirements. The link must be one this server signed (reauthLink),
395
- // and /callback still binds the completing identity to the alias's email.
371
+ // alias_reauth skips client_id/redirect/PKCE (no client leg); see docs/internals.md.
396
372
  if (q.get('flow') === 'alias_reauth' && !clientId) {
397
373
  if (config.legacyAliasReauth === false) {
398
374
  // Alias-only, tenant-ambiguous: disabled on multi-tenant deployments.
@@ -413,22 +389,18 @@ export function buildAuthServer(config, deps = {}) {
413
389
  return clientUnavailable(res);
414
390
  if ('error' in client)
415
391
  return json(res, 400, { error: 'invalid_client', message: client.message, iss: base }, issHeader);
416
- // (2) exact redirect_uri match BEFORE minting anything (open-redirect defense)
392
+ // Exact redirect_uri match BEFORE minting anything (open-redirect defense).
417
393
  if (!redirectUri || !redirectAllowed(redirectUri, client.redirect_uris)) {
418
394
  return errorPage(res, 400, 'E_REDIRECT_URI_MISMATCH', 'redirect_uri is not an allowed callback for this client');
419
395
  }
420
- // (3) PKCE S256 required
421
396
  if (!codeChallenge || method !== 'S256') {
422
397
  return json(res, 400, { error: 'invalid_request', message: 'code_challenge with S256 required', iss: base }, issHeader);
423
398
  }
424
- // (4) resource must be the canonical resource URI
425
399
  if (resourceParam !== resource) {
426
400
  return json(res, 400, { error: 'invalid_request', message: `resource must be ${resource}`, iss: base }, issHeader);
427
401
  }
428
- // (5) only after full validation do we build the request artifact.
429
- // A client leg is always the owner sign-in. Re-auth has its own signed
430
- // link: accepting flow=alias_reauth here let any client (a self-registered
431
- // one, or a public CIMD client_id) start it and read the alias's scopes.
402
+ // Only after full validation, and always as owner_gate: a caller-supplied
403
+ // flow here would let any client read an alias's scopes via alias_reauth.
432
404
  const statePayload = {
433
405
  flow: 'owner_gate',
434
406
  client_id: clientId,
@@ -437,12 +409,8 @@ export function buildAuthServer(config, deps = {}) {
437
409
  client_state: clientState,
438
410
  resource,
439
411
  };
440
- // Confused-deputy defense (#3): a self-registered DCR client can pick an
441
- // arbitrary redirect_uri, so before we send the owner to Google (whose
442
- // consent screen names only THIS server, not the requesting app), show an
443
- // approval interstitial naming the client + destination. CIMD clients skip
444
- // it — their redirect_uris come from an issuer-allowlisted document a rogue
445
- // cannot forge, so there is nothing to spoof.
412
+ // DCR clients get a consent interstitial naming the client + destination
413
+ // (confused-deputy defense); CIMD clients skip it. See docs/internals.md.
446
414
  if (client.dcr) {
447
415
  const pendingToken = await signPending(statePayload, base, flowSecret, nowSec());
448
416
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "frame-ancestors 'none'", 'X-Frame-Options': 'DENY' });
@@ -458,7 +426,6 @@ export function buildAuthServer(config, deps = {}) {
458
426
  }
459
427
  return toGoogle(res, statePayload);
460
428
  };
461
- // GET /callback
462
429
  const callback = async (req, res, url) => {
463
430
  const code = url.searchParams.get('code') ?? '';
464
431
  const stateToken = url.searchParams.get('state') ?? '';
@@ -529,20 +496,16 @@ export function buildAuthServer(config, deps = {}) {
529
496
  if (st.flow === 'alias_reauth') {
530
497
  if (!st.alias)
531
498
  return errorPage(res, 400, 'invalid_request', 'alias_reauth without an alias');
532
- // C13 / identity binding (#2/#6): only overwrite the alias's tokens if the
533
- // completing Google identity IS that alias's configured account — else an
534
- // attacker who crafts an alias_reauth link and logs in with their OWN
535
- // account could inject their tokens (account swap). The alias emails (leg
536
- // C) are distinct from MCP_OWNER_EMAILS (leg B), so bind to the alias.
499
+ // Identity binding: only overwrite the alias's tokens if the completing
500
+ // Google identity matches the alias's configured account (else account swap).
537
501
  const gotEmail = (exchanged.email ?? '').toLowerCase();
538
502
  const expected = (deps.aliasEmail?.(st.alias) ?? '').toLowerCase();
539
503
  if (!expected || !gotEmail || gotEmail !== expected) {
540
504
  log(`alias_reauth identity mismatch for "${logSafe(st.alias, 64)}" (got ${logSafe(gotEmail || 'none', 254)})`);
541
505
  return errorPage(res, 403, 'access_denied', 'the Google account you signed in with is not the one configured for this alias');
542
506
  }
543
- // A completion without a refresh token (a Google URL built without
544
- // offline access, say) would replace a working credential with one that
545
- // dies within the hour. Keep the stored token instead.
507
+ // A completion without a refresh token would replace a working credential
508
+ // with one that dies within the hour; keep the stored token instead.
546
509
  const refreshToken = exchanged.tokens.refresh_token;
547
510
  if (typeof refreshToken !== 'string' || refreshToken === '') {
548
511
  log(`alias_reauth for "${logSafe(st.alias, 64)}" returned no refresh token; stored token kept`);
@@ -551,9 +514,8 @@ export function buildAuthServer(config, deps = {}) {
551
514
  const granted = typeof exchanged.tokens.scope === 'string' ? exchanged.tokens.scope : undefined;
552
515
  const missing = deps.missingScopes?.(st.alias, granted) ?? [];
553
516
  const listed = missing.map((m) => `<code>${escapeHtml(m)}</code>`).join(', ');
554
- // A narrower grant replaces nothing that works. The link can be reused
555
- // for an hour and the Google URL's scope is not signed, so a narrower
556
- // grant is not necessarily the account holder's choice.
517
+ // A narrower grant replaces nothing that works: the link is reusable for
518
+ // an hour and the scope isn't signed, so it may not be the holder's choice.
557
519
  if (missing.length > 0 && deps.hasToken?.(st.alias)) {
558
520
  log(`alias_reauth for "${logSafe(st.alias, 64)}" granted ${missing.length} fewer scope(s); stored token kept`);
559
521
  res.writeHead(400, { 'Content-Type': 'text/html' });
@@ -570,9 +532,8 @@ export function buildAuthServer(config, deps = {}) {
570
532
  res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${escapeHtml(st.alias)}". You can close this window.</p>${gap}`);
571
533
  return true;
572
534
  }
573
- // owner_gate: resolve the verified email to its subject (default = the
574
- // owner allowlist -> 'owner'), discard Google tokens. The resolved sub is
575
- // signed into the authz code and flows into every token minted from it.
535
+ // owner_gate: resolve the verified email to its subject (default: owner
536
+ // allowlist -> 'owner'), discard Google tokens; the sub signs into the authz code.
576
537
  const email = (exchanged.email ?? '').toLowerCase();
577
538
  const iss = encodeURIComponent(base);
578
539
  const resolved = email ? resolveSubject(email, { sub: exchanged.sub, hd: exchanged.hd }) : null;
@@ -590,7 +551,6 @@ export function buildAuthServer(config, deps = {}) {
590
551
  const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
591
552
  return redirect(res, `${st.redirect_uri}${sep}code=${encodeURIComponent(authzCode)}${s}&iss=${iss}`);
592
553
  };
593
- // POST /token
594
554
  const token = async (req, res) => {
595
555
  let form;
596
556
  try {
@@ -677,8 +637,7 @@ export function buildAuthServer(config, deps = {}) {
677
637
  }
678
638
  return json(res, 400, { error: 'unsupported_grant_type', message: 'authorization_code or refresh_token only' });
679
639
  };
680
- // POST /register (minimal DCR, D2). Parse the raw JSON body directly (the
681
- // redirect_uris array must survive intact).
640
+ // Parse the raw JSON body directly; the redirect_uris array must survive intact.
682
641
  const register = async (req, res) => {
683
642
  let parsed;
684
643
  try {
@@ -1,12 +1,8 @@
1
1
  import { OAuth2Client } from 'googleapis-common';
2
2
  import http from 'node:http';
3
3
  import { URL } from 'node:url';
4
- // Shared loopback OAuth consent (leg C of cc-auth), used by the account wizard
5
- // (B7) and the `auth --account` CLI. Live-server-safe: typed errors instead of
6
- // process.exit, and a timeout so a never-completed consent can't wedge a tool
7
- // call forever. The listener binds an EPHEMERAL loopback port (RFC 8252 §7.3;
8
- // Google Desktop clients accept any http://localhost:<port> redirect), so a
9
- // second local process on a fixed port can never break auth.
4
+ // Shared loopback OAuth consent, used by the account wizard and `auth
5
+ // --account`; a timeout keeps a never-completed consent from wedging a tool call forever.
10
6
  /** Google authorize-URL options for the owner's HTTP authorization server.
11
7
  * alias_reauth asks for the alias's scopes plus openid/email, which /callback
12
8
  * needs to bind the returned identity to the alias. It carries no login_hint:
@@ -44,10 +40,8 @@ export class ConsentDeniedError extends Error {
44
40
  super(`E_CONSENT_DENIED: ${reason}`);
45
41
  }
46
42
  }
47
- // Surfaced after every successful consent: the expiry is invisible until the
48
- // token dies a week later as reauth_required, so the moment of success is the
49
- // one place the warning is guaranteed to be seen. Full walkthrough in
50
- // docs/google-cloud-setup.md.
43
+ // Surfaced after every successful consent: the moment of success is the one
44
+ // place this warning is guaranteed to be seen, before the token dies a week later as reauth_required.
51
45
  export const TESTING_MODE_WARNING = 'Heads-up: while your OAuth client\'s Publishing status is "Testing", Google expires refresh tokens after 7 days (weekly re-auth for every account). ' +
52
46
  'When your setup works, set it to "In production" at https://console.cloud.google.com/auth/audience. ' +
53
47
  'No verification review is needed for personal use; see docs/google-cloud-setup.md.';
@@ -1,8 +1,5 @@
1
- // Opt-in outbound recipient allowlist for unattended deployments: a
2
- // prompt-injection blast-radius control (a hijacked agent cannot mail, invite
3
- // or share outside the operator's list), a safety feature first and an
4
- // enterprise checkbox second. FREE-core forever (ee-definition-v0 handoff).
5
- // Off by default: an unset/empty GOOGLE_OUTBOUND_ALLOWLIST gates nothing.
1
+ // Opt-in outbound recipient allowlist: a prompt-injection blast-radius
2
+ // control (a hijacked agent cannot mail/invite/share outside the operator's list). Off by default.
6
3
  import { hints } from './hint-provider.js';
7
4
  /** Parse GOOGLE_OUTBOUND_ALLOWLIST: comma-separated addresses and @domain
8
5
  * suffixes ("a@b.com, @company.com"). null = feature off (nothing gated). */
@@ -59,10 +56,8 @@ export function checkOutbound(kind, emails, account, env = process.env) {
59
56
  const blocked = outboundViolations(emails, env);
60
57
  return blocked.length > 0 ? outboundDeniedEnvelope(kind, blocked, account, env) : null;
61
58
  }
62
- // Escape-hatch / generated-tool enforcement. Structured bodies are inspected
63
- // for the known recipient fields; RAW compose methods cannot be inspected
64
- // (base64 RFC 822), so with the allowlist active they are refused outright in
65
- // favor of the curated tools that enforce it.
59
+ // RAW compose methods can't be inspected (base64 RFC 822), so with the
60
+ // allowlist active they're refused outright in favor of the curated tools that enforce it.
66
61
  const RAW_SEND_METHODS = new Set(['gmail.users.messages.send', 'gmail.users.drafts.send', 'gmail.users.drafts.create', 'gmail.users.messages.insert', 'gmail.users.messages.import']);
67
62
  export function collectBodyRecipients(body) {
68
63
  const out = [];
@@ -1,8 +1,5 @@
1
- // B13 in-call re-auth (BV-1): a dead/missing per-account Google token is a
2
- // leg-C failure. Over stdio the fix is the `auth --account` CLI; over HTTP the
3
- // user cannot run a CLI, so the hint becomes a clickable, server-signed link
4
- // into the AS's clientless alias_reauth flow (AuthServer.reauthLink). The HTTP
5
- // bootstrap installs the linker when the AS starts; stdio never does.
1
+ // Over stdio the fix is the `auth --account` CLI; over HTTP it's a clickable,
2
+ // server-signed link instead, installed by the AS bootstrap. See docs/internals.md.
6
3
  import { hints } from './hint-provider.js';
7
4
  let httpReauthLink = null;
8
5
  export function setHttpReauthLink(linker) {
@@ -1,4 +1,4 @@
1
- /** D11 (refuse-only for v1): export/import operate on the FLAT single-owner
1
+ /** Refuse-only for v1: export/import operate on the FLAT single-owner
2
2
  * store. A tenant-namespaced layout means the bundle would either miss every
3
3
  * tenant's tokens or exfiltrate all of them in one file — refuse instead.
4
4
  * Returns the offending dir, or null when the store is flat. */
@@ -1,9 +1,5 @@
1
- // B19: encrypted registry export / import (account-registry.md). Moves a whole
2
- // setup to another machine. The bundle is encrypted under a user PASSPHRASE
3
- // (portable across machines with different MASTER_KEYs) via the token-store AES
4
- // primitives; the bundled <alias>.enc token files stay encrypted under the
5
- // SOURCE MASTER_KEY (double-locked), so the target needs the same MASTER_KEY to
6
- // use them. Secrets (MASTER_KEY / client secret) are never in the bundle.
1
+ // Encrypted registry export/import: the bundle is passphrase-encrypted, but
2
+ // the bundled <alias>.enc files stay double-locked under the SOURCE MASTER_KEY.
7
3
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
8
4
  import * as readline from 'node:readline';
9
5
  import path from 'node:path';
@@ -11,7 +7,7 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:
11
7
  import { atomicWriteFileSync, atomicWriteWithLock } from './fs-atomic.js';
12
8
  import { ALIAS_RE, configFilePath, CONFIG_VERSION, tenantsDir } from './config-file.js';
13
9
  import { getTokenDir } from './accounts.js';
14
- /** D11 (refuse-only for v1): export/import operate on the FLAT single-owner
10
+ /** Refuse-only for v1: export/import operate on the FLAT single-owner
15
11
  * store. A tenant-namespaced layout means the bundle would either miss every
16
12
  * tenant's tokens or exfiltrate all of them in one file — refuse instead.
17
13
  * Returns the offending dir, or null when the store is flat. */
@@ -61,12 +57,8 @@ export function buildTransferBundle(exportedAt, deps = {}) {
61
57
  }
62
58
  return { manifest: { v: 1, exportedAt, aliases: [...aliasSet].sort() }, config, tokens };
63
59
  }
64
- // Bundle envelope v2: a REAL passphrase KDF. The v1 envelope reused the
65
- // token store's deriveKey, whose passphrase branch is one unsalted sha256 —
66
- // an exported bundle could be brute-forced offline (CodeQL
67
- // js/insufficient-password-hash). scrypt with a per-bundle random salt fixes
68
- // that; v1 bundles (a prerelease-only format, never produced by any stable
69
- // release) are refused with a re-export hint rather than kept decryptable.
60
+ // v2: a REAL passphrase KDF (scrypt, per-bundle salt). v1 reused the token
61
+ // store's unsalted-sha256 passphrase branch (CodeQL js/insufficient-password-hash) and is refused outright.
70
62
  const BUNDLE_VERSION = 2;
71
63
  const SCRYPT_PARAMS = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
72
64
  function bundleKey(passphrase, salt) {
@@ -51,8 +51,6 @@ export declare class ToolRegistry {
51
51
  private readonly revealed;
52
52
  private readonly jsonSchemaCache;
53
53
  private readonly argShapeCache;
54
- /** Memoized isUngrantable verdict per tool: the answer depends on the frozen
55
- * bundle catalog only, so it never changes within a process. */
56
54
  private readonly ungrantable;
57
55
  private readonly compactOutput;
58
56
  private registeringMeta;
@@ -106,7 +104,7 @@ export declare class ToolRegistry {
106
104
  /** discover_all: advertise the full curated set at once. Idempotent. */
107
105
  expand(): boolean;
108
106
  /** discover_reset: back to the lean meta-only surface. Clears reveals too.
109
- * BV-8: if a client mishandles a SHRINKING tools/list, this degrades to a
107
+ * If a client mishandles a SHRINKING tools/list, this degrades to a
110
108
  * no-op for that session — tools stay callable regardless (graceful
111
109
  * dispatch), zero correctness impact. */
112
110
  collapse(): boolean;
@@ -128,11 +126,5 @@ export declare class ToolRegistry {
128
126
  };
129
127
  installListHandler(): void;
130
128
  private toToolJson;
131
- /** Advertise the CURRENT alias enum on every tools/list: account validation
132
- * is (or is becoming) live, so a cached boot-time enum would go stale the
133
- * moment account_add lands mid-session. The structural schema stays cached;
134
- * only the account property's values are refreshed per call. Skipped for
135
- * the subject-taking account tools (their `account` is the OPERAND, e.g. a
136
- * brand-new alias name, not an identity to enumerate). */
137
129
  private withLiveAccountEnum;
138
130
  }