@trops/dash-core 0.1.561 → 0.1.563

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.
@@ -2397,9 +2397,27 @@ var mountTokenRegistry = { register: register$1, lookup, unregister: unregister$
2397
2397
  * Conversion: registry keys are scoped (`@trops/google-drive`); grant
2398
2398
  * keys are dotted (`trops.google-drive.GDriveFileList`). The mapping
2399
2399
  * for each entry is `@<scope>/<name>` + `<componentName>` →
2400
- * `<scope>.<name>.<componentName>`. Bare-name lookup uses the LAST
2401
- * dot-segment of the input widgetId so 3-part scope.pkg.component ids
2402
- * resolve correctly.
2400
+ * `<scope>.<name>.<componentName>`.
2401
+ *
2402
+ * Resolution strategy:
2403
+ *
2404
+ * 1. SCOPED PATH (preferred): a 3-part widgetId carries the package
2405
+ * identity in its first two segments. We rebuild `@scope/pkg` from
2406
+ * those segments and look the package up directly in the registry.
2407
+ * That keeps two packages exporting the same bare component name
2408
+ * from cross-contaminating each other's grant scope.
2409
+ *
2410
+ * 2. LEGACY BARE-NAME FALLBACK: for legacy widget ids that don't
2411
+ * carry scope (1- or 2-segment ids), walk the registry and match
2412
+ * on the last dot-segment. Honours `entry.packageId` when present
2413
+ * so the returned sibling set is correctly mapped.
2414
+ *
2415
+ * The scoped-first strategy was added after a bare-name collision
2416
+ * caused JIT consent for a widget in `@ai-built/prompt-validation` to
2417
+ * resolve siblings under `@trops/google-drive` (both packages
2418
+ * happened to ship a `GoogleDriveRecentFiles` component). The grant
2419
+ * subsequently landed on the wrong package's widgets entirely. Don't
2420
+ * regress this — the test file pins the collision case explicitly.
2403
2421
  */
2404
2422
 
2405
2423
  const FALLBACK = (widgetId) => ({
@@ -2412,6 +2430,18 @@ function _bareNameFromWidgetId(widgetId) {
2412
2430
  return idx >= 0 ? widgetId.slice(idx + 1) : widgetId;
2413
2431
  }
2414
2432
 
2433
+ /**
2434
+ * Pull `@scope/pkg` out of a 3-part dotted widgetId. Returns null for
2435
+ * any input that isn't exactly 3 dot-segments — 1- and 2-segment ids
2436
+ * fall through to the legacy bare-name path.
2437
+ */
2438
+ function _packageIdFromScopedWidgetId(widgetId) {
2439
+ const parts = widgetId.split(".");
2440
+ if (parts.length !== 3) return null;
2441
+ if (!parts[0] || !parts[1] || !parts[2]) return null;
2442
+ return `@${parts[0]}/${parts[1]}`;
2443
+ }
2444
+
2415
2445
  function _toGrantId(packageId, componentName) {
2416
2446
  // "@trops/google-drive" + "GDriveFileList" → "trops.google-drive.GDriveFileList"
2417
2447
  // Strip leading "@" and replace the first "/" with ".".
@@ -2432,6 +2462,26 @@ function _entries(snapshot) {
2432
2462
  return [];
2433
2463
  }
2434
2464
 
2465
+ /**
2466
+ * Look up an exact registry entry by packageId (either the registry
2467
+ * key OR the entry's own `packageId` field). Returns the entry or
2468
+ * null. We accept both shapes because some registry persistence
2469
+ * paths historically keyed by `name` instead of `packageId`.
2470
+ */
2471
+ function _findEntryByPackageId(packageId, registrySnapshot) {
2472
+ for (const [key, entry] of _entries(registrySnapshot)) {
2473
+ if (key === packageId) return entry;
2474
+ if (
2475
+ entry &&
2476
+ typeof entry.packageId === "string" &&
2477
+ entry.packageId === packageId
2478
+ ) {
2479
+ return entry;
2480
+ }
2481
+ }
2482
+ return null;
2483
+ }
2484
+
2435
2485
  /**
2436
2486
  * @param {string} widgetId — grant-keyed widget id
2437
2487
  * @param {Map<string, object>|object} registrySnapshot — getWidgetRegistry().widgets
@@ -2440,6 +2490,30 @@ function _entries(snapshot) {
2440
2490
  function resolveSiblings$3(widgetId, registrySnapshot) {
2441
2491
  if (typeof widgetId !== "string" || !widgetId) return FALLBACK(widgetId);
2442
2492
 
2493
+ // SCOPED PATH — the widgetId is already scoped, so we know its
2494
+ // package directly. No cross-package matching, no collision risk.
2495
+ const derivedPackageId = _packageIdFromScopedWidgetId(widgetId);
2496
+ if (derivedPackageId) {
2497
+ const entry = _findEntryByPackageId(derivedPackageId, registrySnapshot);
2498
+ const names = Array.isArray(entry?.componentNames)
2499
+ ? entry.componentNames
2500
+ : null;
2501
+ if (entry && names && names.length > 0) {
2502
+ return {
2503
+ packageId: derivedPackageId,
2504
+ siblingWidgetIds: names.map((n) => _toGrantId(derivedPackageId, n)),
2505
+ };
2506
+ }
2507
+ // Package is known by id but the registry entry is missing or
2508
+ // empty (race: package just installed, registry write hasn't
2509
+ // landed). Don't fall through to bare-name search — that would
2510
+ // cross-contaminate by matching another package whose component
2511
+ // happens to share the bare name. Return the single widget.
2512
+ return FALLBACK(widgetId);
2513
+ }
2514
+
2515
+ // LEGACY BARE-NAME FALLBACK — only for widget ids that don't carry
2516
+ // scope (1- or 2-segment ids).
2443
2517
  const bareName = _bareNameFromWidgetId(widgetId);
2444
2518
  if (!bareName) return FALLBACK(widgetId);
2445
2519
 
@@ -3528,14 +3602,60 @@ function expandHome(p) {
3528
3602
  /**
3529
3603
  * Parse a widget's package.json contents into a normalized permissions
3530
3604
  * object. Returns null if no `dash.permissions.mcp` block exists.
3605
+ *
3606
+ * When `componentName` is provided AND the package ships a
3607
+ * `dash.permissions.mcpByComponent` block, the parser returns the
3608
+ * declaration for that specific widget instead of the package-level
3609
+ * union. This is what closes the cross-pollination gap: multi-widget
3610
+ * packages give each widget its own server/tools list, scanned from
3611
+ * the widget's own component file.
3612
+ *
3613
+ * If `componentName` is omitted (or the per-component block doesn't
3614
+ * exist), we fall back to the package-level `mcp` block — the
3615
+ * back-compat path for older packages that haven't been re-scanned.
3531
3616
  */
3532
- function parseManifestPermissions(packageJson) {
3617
+ function parseManifestPermissions(packageJson, componentName = null) {
3533
3618
  if (!packageJson || typeof packageJson !== "object") return null;
3619
+
3620
+ const byComponent = packageJson?.dash?.permissions?.mcpByComponent;
3621
+ const hasByComponent =
3622
+ byComponent &&
3623
+ typeof byComponent === "object" &&
3624
+ Object.keys(byComponent).length > 0;
3625
+
3626
+ // If the package ships a per-component breakdown, that's
3627
+ // authoritative for any widget we're asking about. Widgets not
3628
+ // listed there are widgets the scanner found NO MCP usage in —
3629
+ // they declare nothing, not the package-level union. Falling
3630
+ // back to the package union for an unlisted widget would defeat
3631
+ // the whole point of the breakdown (re-merging every sibling's
3632
+ // tools into each unlisted widget).
3633
+ if (componentName && hasByComponent) {
3634
+ if (byComponent[componentName]) {
3635
+ return _normalizeServers(byComponent[componentName]?.servers);
3636
+ }
3637
+ // Component exists in this package but the scanner found no
3638
+ // MCP usage in its source. Declare nothing.
3639
+ return null;
3640
+ }
3641
+
3642
+ // No per-component data (older package) OR no componentName
3643
+ // supplied by the caller — fall back to the package-level union
3644
+ // for back-compat.
3534
3645
  const mcp = packageJson?.dash?.permissions?.mcp;
3535
3646
  if (!mcp || typeof mcp !== "object") return null;
3647
+ return _normalizeServers(mcp);
3648
+ }
3536
3649
 
3650
+ /**
3651
+ * Normalize a `{server: {tools, readPaths, writePaths}}` block into
3652
+ * the standard `{servers: {...}}` shape used by the gate and panel.
3653
+ * Filters non-string entries and expands `~/` paths.
3654
+ */
3655
+ function _normalizeServers(rawServers) {
3656
+ if (!rawServers || typeof rawServers !== "object") return null;
3537
3657
  const servers = {};
3538
- for (const [serverName, raw] of Object.entries(mcp)) {
3658
+ for (const [serverName, raw] of Object.entries(rawServers)) {
3539
3659
  if (!raw || typeof raw !== "object") continue;
3540
3660
  const tools = Array.isArray(raw.tools)
3541
3661
  ? raw.tools.filter((t) => typeof t === "string")
@@ -3548,7 +3668,6 @@ function parseManifestPermissions(packageJson) {
3548
3668
  : [];
3549
3669
  servers[serverName] = { tools, readPaths, writePaths };
3550
3670
  }
3551
-
3552
3671
  return { servers };
3553
3672
  }
3554
3673
 
@@ -3591,15 +3710,36 @@ function resolveWidgetPackagePath(widgetId) {
3591
3710
  return path$f.join(widgetsRoot, ...parts, "package.json");
3592
3711
  }
3593
3712
 
3713
+ /**
3714
+ * Pull the bare component name (last dot-segment) from a dotted
3715
+ * widget id. Returns null for npm-form ids (`@scope/pkg`) or 1-segment
3716
+ * ids — those have no per-widget component to scope by.
3717
+ */
3718
+ function _componentNameFromWidgetId(widgetId) {
3719
+ if (typeof widgetId !== "string" || !widgetId) return null;
3720
+ if (widgetId.startsWith("@")) return null;
3721
+ const parts = widgetId.split(".");
3722
+ if (parts.length < 3) return null;
3723
+ return parts[parts.length - 1] || null;
3724
+ }
3725
+
3594
3726
  /**
3595
3727
  * Read and parse a widget's MCP permissions. Returns null if:
3596
3728
  * - the widget directory doesn't exist
3597
3729
  * - package.json is unreadable / malformed
3598
3730
  * - the widget hasn't declared dash.permissions.mcp
3599
3731
  *
3600
- * Result is cached per widgetId for the lifetime of this process.
3732
+ * For 3-part scoped widget ids (`scope.pkg.component`), prefers the
3733
+ * package's `dash.permissions.mcpByComponent[component]` block when
3734
+ * present. Falls back to the package-level `mcp` block when the
3735
+ * per-component breakdown isn't available — preserves the previous
3736
+ * behavior for older packages that haven't been re-scanned.
3737
+ *
3738
+ * Result is cached per widgetId for the lifetime of this process —
3739
+ * each component gets its own cache entry, so a 10-widget package
3740
+ * has 10 entries, each with its own server/tools subset.
3601
3741
  */
3602
- function getWidgetMcpPermissions$1(widgetId) {
3742
+ function getWidgetMcpPermissions$2(widgetId) {
3603
3743
  if (_cache.has(widgetId)) return _cache.get(widgetId);
3604
3744
  const pkgPath = resolveWidgetPackagePath(widgetId);
3605
3745
  if (!pkgPath || !fs$c.existsSync(pkgPath)) {
@@ -3609,7 +3749,8 @@ function getWidgetMcpPermissions$1(widgetId) {
3609
3749
  try {
3610
3750
  const raw = fs$c.readFileSync(pkgPath, "utf8");
3611
3751
  const pkg = JSON.parse(raw);
3612
- const perms = parseManifestPermissions(pkg);
3752
+ const componentName = _componentNameFromWidgetId(widgetId);
3753
+ const perms = parseManifestPermissions(pkg, componentName);
3613
3754
  _cache.set(widgetId, perms);
3614
3755
  return perms;
3615
3756
  } catch (e) {
@@ -3629,7 +3770,7 @@ function clearCache() {
3629
3770
  }
3630
3771
 
3631
3772
  var widgetPermissions = {
3632
- getWidgetMcpPermissions: getWidgetMcpPermissions$1,
3773
+ getWidgetMcpPermissions: getWidgetMcpPermissions$2,
3633
3774
  parseManifestPermissions,
3634
3775
  dottedComponentIdToPackageId,
3635
3776
  expandHome,
@@ -4037,15 +4178,85 @@ function requireScanWidgetPackagePermissions () {
4037
4178
  return out;
4038
4179
  }
4039
4180
 
4181
+ /**
4182
+ * Locate the package's `widgets/` directory and enumerate every
4183
+ * `<Name>.js` paired with a `<Name>.dash.js` config. That pair is the
4184
+ * canonical widget shape across the repo (see
4185
+ * `widgetCompiler.findWidgetsDir`). Returns
4186
+ * [{componentName, componentFilePath}].
4187
+ *
4188
+ * Files without a sibling `.dash.js` (utils, helpers, contexts) are
4189
+ * skipped — they're not widgets, so attributing their MCP usage to a
4190
+ * specific component is ambiguous.
4191
+ */
4192
+ function _findComponentFiles(packageDir) {
4193
+ const candidates = [
4194
+ path.join(packageDir, "widgets"),
4195
+ path.join(packageDir, "src", "widgets"),
4196
+ ];
4197
+ const out = [];
4198
+ for (const widgetsDir of candidates) {
4199
+ if (!fs.existsSync(widgetsDir)) continue;
4200
+ let entries;
4201
+ try {
4202
+ entries = fs.readdirSync(widgetsDir, { withFileTypes: true });
4203
+ } catch (_) {
4204
+ continue;
4205
+ }
4206
+ for (const entry of entries) {
4207
+ if (!entry.isFile()) continue;
4208
+ const m = entry.name.match(/^(.+)\.dash\.(?:js|jsx|ts|tsx)$/);
4209
+ if (!m) continue;
4210
+ const componentName = m[1];
4211
+ // Prefer .js, then .jsx, .ts, .tsx — first-match wins.
4212
+ for (const ext of [".js", ".jsx", ".ts", ".tsx"]) {
4213
+ const componentFilePath = path.join(
4214
+ widgetsDir,
4215
+ `${componentName}${ext}`,
4216
+ );
4217
+ if (fs.existsSync(componentFilePath)) {
4218
+ out.push({ componentName, componentFilePath });
4219
+ break;
4220
+ }
4221
+ }
4222
+ }
4223
+ }
4224
+ return out;
4225
+ }
4226
+
4040
4227
  /**
4041
4228
  * Walk packageDir, scan every source file, return the canonical
4042
4229
  * `dash.permissions.mcp` block. Returns `{}` when no MCP usage found.
4230
+ *
4231
+ * Per-file pairing — providers and tools are paired at the source-file
4232
+ * level, not unioned across the whole package. The vast majority of
4233
+ * widget source files use a single `useMcpProvider("type")` hook and
4234
+ * call its tools in the same file; pairing per file produces the
4235
+ * correct provider→tools mapping for that case.
4236
+ *
4237
+ * Multi-widget packages (one package shipping N widgets, each with a
4238
+ * different provider) used to be miscompiled by the old whole-package
4239
+ * union: every provider in the package got credit for every tool any
4240
+ * sibling widget called. The result was a manifest where, e.g., the
4241
+ * `slack` server claimed `list_pull_requests` and `list_directory`
4242
+ * tools simply because the same package also shipped a GitHub widget
4243
+ * and a Filesystem widget. Per-file pairing eliminates that
4244
+ * cross-pollination because each file's tools attach only to that
4245
+ * file's providers.
4246
+ *
4247
+ * Files containing multiple providers (a widget that orchestrates two
4248
+ * MCP servers in the same component) attribute their tools to each
4249
+ * provider in that file — an acceptable over-grant within the single
4250
+ * widget's own scope, and a rare-enough case that we don't try to
4251
+ * parse expression scoping. Tools called in files with NO provider
4252
+ * hook (e.g. a helper module called from a parent widget) are ignored
4253
+ * — a runtime hint without static evidence of which provider it
4254
+ * targets falls through to the gate's JIT consent.
4043
4255
  */
4044
4256
  function scanWidgetPackagePermissions(packageDir) {
4045
4257
  if (typeof packageDir !== "string" || !fs.existsSync(packageDir)) return {};
4046
4258
 
4047
- const allProviders = new Set();
4048
- const allTools = new Set();
4259
+ const providerToTools = new Map();
4049
4260
  for (const filePath of _walkSourceFiles(packageDir)) {
4050
4261
  let code;
4051
4262
  try {
@@ -4054,15 +4265,81 @@ function requireScanWidgetPackagePermissions () {
4054
4265
  continue;
4055
4266
  }
4056
4267
  const { providers, tools } = scanFileForMcpUsage(code);
4057
- for (const p of providers) allProviders.add(p);
4058
- for (const t of tools) allTools.add(t);
4268
+ // No provider in this file → tools have no statically-known
4269
+ // server to attach to. Skip rather than cross-pollinate.
4270
+ if (providers.length === 0) continue;
4271
+ // No tools in this file → still register the provider with an
4272
+ // empty set so the manifest knows the widget declares this
4273
+ // server. (Edge case: helper file that mounts useMcpProvider but
4274
+ // calls callTool via an indirection we can't statically read.)
4275
+ for (const p of providers) {
4276
+ if (!providerToTools.has(p)) providerToTools.set(p, new Set());
4277
+ const bucket = providerToTools.get(p);
4278
+ for (const t of tools) bucket.add(t);
4279
+ }
4059
4280
  }
4060
4281
 
4061
- if (allProviders.size === 0 || allTools.size === 0) return {};
4282
+ // Drop providers that ended up with no tools across the whole
4283
+ // package — declaring a server with an empty tool list is
4284
+ // misleading downstream (the Permissions panel renders an empty
4285
+ // section, the gate treats it as "all tools require JIT"). The
4286
+ // runtime gate is the safety net for tools we couldn't statically
4287
+ // pair.
4288
+ const out = {};
4289
+ for (const [provider, toolSet] of providerToTools.entries()) {
4290
+ if (toolSet.size === 0) continue;
4291
+ out[provider] = { tools: Array.from(toolSet).sort() };
4292
+ }
4293
+ return out;
4294
+ }
4062
4295
 
4296
+ /**
4297
+ * Per-component scanner. Walks each `widgets/<Name>.js` paired with
4298
+ * `widgets/<Name>.dash.js` and produces a map
4299
+ * { [componentName]: { [server]: { tools: [...] } } }
4300
+ *
4301
+ * Each component's entry contains ONLY the providers + tools its own
4302
+ * file uses. Sibling widgets in the same package don't bleed into
4303
+ * each other — the bug the package-level `mcp` block carries by
4304
+ * design (one shared block for the whole package) is closed here at
4305
+ * the widget granularity.
4306
+ *
4307
+ * Used by `widgetPermissions.getWidgetMcpPermissions(scopedWidgetId)`
4308
+ * to answer "what does THIS specific widget declare" instead of
4309
+ * "what does the WHOLE package declare", which is what fixes:
4310
+ * - Permissions panel showing every sibling's declarations under
4311
+ * every widget
4312
+ * - JIT consent's sibling-fanout writing grants to widgets that
4313
+ * don't actually use the granted tool
4314
+ *
4315
+ * Returns `{}` when no component file declares any MCP usage.
4316
+ */
4317
+ function scanWidgetPackagePermissionsByComponent(packageDir) {
4318
+ if (typeof packageDir !== "string" || !fs.existsSync(packageDir)) return {};
4063
4319
  const out = {};
4064
- for (const provider of allProviders) {
4065
- out[provider] = { tools: Array.from(allTools).sort() };
4320
+ for (const { componentName, componentFilePath } of _findComponentFiles(
4321
+ packageDir,
4322
+ )) {
4323
+ let code;
4324
+ try {
4325
+ code = fs.readFileSync(componentFilePath, "utf8");
4326
+ } catch (_) {
4327
+ continue;
4328
+ }
4329
+ const { providers, tools } = scanFileForMcpUsage(code);
4330
+ if (providers.length === 0) continue;
4331
+ // Pair this file's providers with this file's tools — same
4332
+ // per-file logic as `scanWidgetPackagePermissions`, scoped to
4333
+ // a single widget's component file.
4334
+ const servers = {};
4335
+ for (const provider of providers) {
4336
+ const sorted = Array.from(new Set(tools)).sort();
4337
+ if (sorted.length === 0) continue;
4338
+ servers[provider] = { tools: sorted };
4339
+ }
4340
+ if (Object.keys(servers).length > 0) {
4341
+ out[componentName] = { servers };
4342
+ }
4066
4343
  }
4067
4344
  return out;
4068
4345
  }
@@ -4099,8 +4376,21 @@ function requireScanWidgetPackagePermissions () {
4099
4376
 
4100
4377
  /**
4101
4378
  * Apply the scanner's findings to `package.json` in `packageDir` —
4102
- * read, merge with any existing `dash.permissions.mcp` block, write
4103
- * back. Returns the resulting block (or null if no package.json).
4379
+ * read, merge with any existing manifest, write back. Returns the
4380
+ * resulting package-level `mcp` block (or null if no package.json AND
4381
+ * no usage to write).
4382
+ *
4383
+ * Writes BOTH blocks:
4384
+ * - `dash.permissions.mcp` — package-level union (back-compat with
4385
+ * the gate's pre-per-component lookup path)
4386
+ * - `dash.permissions.mcpByComponent` — per-widget breakdown so the
4387
+ * Permissions panel and JIT-consent sibling fanout can answer
4388
+ * "which specific widgets declare this tool" without cross-
4389
+ * contaminating siblings
4390
+ *
4391
+ * The per-component block is fully scanner-derived (no hand-merge) —
4392
+ * editing it by hand is unsupported. Package-level `mcp` continues
4393
+ * to honor hand-authored entries.
4104
4394
  */
4105
4395
  function applyScanToPackageJson(packageDir) {
4106
4396
  const pkgPath = path.join(packageDir, "package.json");
@@ -4115,18 +4405,42 @@ function requireScanWidgetPackagePermissions () {
4115
4405
  const scanned = scanWidgetPackagePermissions(packageDir);
4116
4406
  const human = existing.dash?.permissions?.mcp || null;
4117
4407
  const merged = mergePermissions(human, scanned);
4118
- if (Object.keys(merged).length === 0) {
4408
+ const byComponent = scanWidgetPackagePermissionsByComponent(packageDir);
4409
+ // Existing stale `mcpByComponent` from a prior scan must be dropped
4410
+ // when this run finds no qualifying components — otherwise leftover
4411
+ // entries for deleted widgets keep applying permissions the user
4412
+ // didn't actually re-approve. Treat "has existing block but new
4413
+ // scan empty" as a write trigger (with the side effect of removing
4414
+ // the block via the delete below).
4415
+ const hadByComponent =
4416
+ !!existing.dash?.permissions?.mcpByComponent &&
4417
+ typeof existing.dash.permissions.mcpByComponent === "object" &&
4418
+ Object.keys(existing.dash.permissions.mcpByComponent).length > 0;
4419
+ if (
4420
+ Object.keys(merged).length === 0 &&
4421
+ Object.keys(byComponent).length === 0 &&
4422
+ !hadByComponent
4423
+ ) {
4119
4424
  // Nothing to write. Don't churn the file.
4120
4425
  return null;
4121
4426
  }
4427
+ const nextPermissions = {
4428
+ ...((existing.dash || {}).permissions || {}),
4429
+ };
4430
+ if (Object.keys(merged).length > 0) nextPermissions.mcp = merged;
4431
+ if (Object.keys(byComponent).length > 0) {
4432
+ nextPermissions.mcpByComponent = byComponent;
4433
+ } else {
4434
+ // Drop a stale `mcpByComponent` block when re-scanning finds no
4435
+ // qualifying component files. Prevents leftover entries from a
4436
+ // prior install lingering after the user deletes a widget.
4437
+ delete nextPermissions.mcpByComponent;
4438
+ }
4122
4439
  const next = {
4123
4440
  ...existing,
4124
4441
  dash: {
4125
4442
  ...(existing.dash || {}),
4126
- permissions: {
4127
- ...((existing.dash || {}).permissions || {}),
4128
- mcp: merged,
4129
- },
4443
+ permissions: nextPermissions,
4130
4444
  },
4131
4445
  };
4132
4446
  fs.writeFileSync(pkgPath, JSON.stringify(next, null, 2), "utf8");
@@ -4163,6 +4477,7 @@ function requireScanWidgetPackagePermissions () {
4163
4477
  scanWidgetPackagePermissions_1 = {
4164
4478
  scanFileForMcpUsage,
4165
4479
  scanWidgetPackagePermissions,
4480
+ scanWidgetPackagePermissionsByComponent,
4166
4481
  mergePermissions,
4167
4482
  applyScanToPackageJson,
4168
4483
  backfillPackagePermissions,
@@ -26546,6 +26861,7 @@ const { safePath: safePath$1 } = safePath_1;
26546
26861
  const { requestApproval } = jitConsent$1;
26547
26862
  const { lookup: lookupMountToken } = mountTokenRegistry;
26548
26863
  const { resolveSiblings } = resolveSiblings_1;
26864
+ const { getWidgetMcpPermissions: getWidgetMcpPermissions$1 } = widgetPermissions;
26549
26865
 
26550
26866
  // Lazy default for the registry snapshot — `widgetRegistry.js` pulls
26551
26867
  // in a lot, so we don't want to require it at module-load time. The
@@ -26696,6 +27012,66 @@ function gateToolCall$1({ widgetId, token, serverName, toolName, args }) {
26696
27012
  return { allow: true };
26697
27013
  }
26698
27014
 
27015
+ /**
27016
+ * Filter a sibling-widget list down to widgets whose static manifest
27017
+ * declares the given `serverName + toolName`. The originating widget
27018
+ * (`originatingWidgetId`) is always retained — the call IS proof of
27019
+ * use even if the manifest hasn't been re-scanned to declare the tool
27020
+ * (e.g., variable-indirect `callTool(name, …)` calls that the scanner
27021
+ * intentionally skips).
27022
+ *
27023
+ * Back-compat: if no sibling has any per-widget declaration at all
27024
+ * (package never re-scanned with the per-component scanner), the
27025
+ * filter returns the unmodified list — the user's pre-per-component
27026
+ * "Apply to all in package" behavior is preserved for those packages.
27027
+ *
27028
+ * Used by the JIT consent path to:
27029
+ * - Set the modal's "Apply to all N widgets" count to the actually-
27030
+ * applicable subset
27031
+ * - Limit the grant fanout on approval to the same subset
27032
+ *
27033
+ * Pure helper modulo `getWidgetMcpPermissions` (which is cached + fs
27034
+ * memo-ish per process).
27035
+ */
27036
+ function _filterSiblingsByDeclaration(
27037
+ siblingWidgetIds,
27038
+ originatingWidgetId,
27039
+ serverName,
27040
+ toolName,
27041
+ ) {
27042
+ if (!Array.isArray(siblingWidgetIds) || siblingWidgetIds.length === 0) {
27043
+ return [originatingWidgetId];
27044
+ }
27045
+ // Look up each sibling's per-widget manifest. We need to know
27046
+ // whether ANY sibling has the per-component breakdown — if not,
27047
+ // we're in back-compat territory and should not filter.
27048
+ const lookups = siblingWidgetIds.map((sibId) => ({
27049
+ sibId,
27050
+ perms: getWidgetMcpPermissions$1(sibId),
27051
+ }));
27052
+ const anyManifested = lookups.some(
27053
+ (l) =>
27054
+ l.perms && l.perms.servers && Object.keys(l.perms.servers).length > 0,
27055
+ );
27056
+ if (!anyManifested) return siblingWidgetIds;
27057
+
27058
+ const out = [];
27059
+ for (const { sibId, perms } of lookups) {
27060
+ if (sibId === originatingWidgetId) {
27061
+ out.push(sibId);
27062
+ continue;
27063
+ }
27064
+ const tools = perms?.servers?.[serverName]?.tools;
27065
+ if (Array.isArray(tools) && tools.includes(toolName)) {
27066
+ out.push(sibId);
27067
+ }
27068
+ }
27069
+ // Defensive: if filtering somehow eliminated everything (shouldn't
27070
+ // happen since the originating widget is always retained), fall
27071
+ // back to the single originating widget.
27072
+ return out.length > 0 ? out : [originatingWidgetId];
27073
+ }
27074
+
26699
27075
  /**
26700
27076
  * Merge `addition` (a grant blob) into the widget's existing grant. Used
26701
27077
  * by the JIT path to extend an existing grant with a new tool/path
@@ -26787,6 +27163,25 @@ async function gateToolCallWithJit$1(req, opts = {}) {
26787
27163
  opts.getRegistrySnapshot || _defaultRegistrySnapshot;
26788
27164
  const siblingInfo = resolveSiblings(verifiedWidgetId, getRegistrySnapshot());
26789
27165
 
27166
+ // Filter siblings to those whose per-component manifest actually
27167
+ // declares the requested `serverName + toolName`. Without this
27168
+ // filter, "Apply to all in package" writes the grant to every
27169
+ // sibling regardless of whether that sibling uses the tool — a
27170
+ // Counter widget ends up with Google Drive grants it doesn't
27171
+ // need. The originating widget is always included (its runtime
27172
+ // call IS proof) even if its manifest hasn't been re-scanned to
27173
+ // declare the tool.
27174
+ //
27175
+ // Falls back to the unfiltered sibling list when no per-component
27176
+ // data exists on ANY sibling (back-compat for older packages that
27177
+ // haven't been re-scanned with the new scanner).
27178
+ const applicableSiblingIds = _filterSiblingsByDeclaration(
27179
+ siblingInfo.siblingWidgetIds,
27180
+ verifiedWidgetId,
27181
+ req.serverName,
27182
+ req.toolName,
27183
+ );
27184
+
26790
27185
  let decision;
26791
27186
  try {
26792
27187
  decision = await requestApproval(
@@ -26800,7 +27195,7 @@ async function gateToolCallWithJit$1(req, opts = {}) {
26800
27195
  args: req.args || {},
26801
27196
  },
26802
27197
  packageId: siblingInfo.packageId,
26803
- siblingWidgetIds: siblingInfo.siblingWidgetIds,
27198
+ siblingWidgetIds: applicableSiblingIds,
26804
27199
  },
26805
27200
  { timeoutMs: opts.timeoutMs },
26806
27201
  );
@@ -26851,11 +27246,17 @@ async function gateToolCallWithJit$1(req, opts = {}) {
26851
27246
  // its prior grants on other servers/tools. When the option is off
26852
27247
  // (or no siblings were resolved), behave as before: single-widget
26853
27248
  // write to the requesting widget only.
27249
+ //
27250
+ // Use the FILTERED sibling list (`applicableSiblingIds`) here — the
27251
+ // grant only fans out to widgets whose manifest actually declares
27252
+ // the server+tool. The modal showed the same filtered count, so
27253
+ // the user's "Apply to all N widgets" decision was already made
27254
+ // against the correct set.
26854
27255
  const targetWidgetIds =
26855
27256
  decision.applyToSiblings === true &&
26856
- Array.isArray(siblingInfo.siblingWidgetIds) &&
26857
- siblingInfo.siblingWidgetIds.length > 1
26858
- ? siblingInfo.siblingWidgetIds
27257
+ Array.isArray(applicableSiblingIds) &&
27258
+ applicableSiblingIds.length > 1
27259
+ ? applicableSiblingIds
26859
27260
  : [verifiedWidgetId];
26860
27261
 
26861
27262
  try {