switchroom 0.14.11 → 0.14.13

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.
@@ -24045,6 +24045,23 @@ var init_ipc_client = () => {};
24045
24045
 
24046
24046
  // permission-rule.ts
24047
24047
  import { basename as basename2 } from "node:path";
24048
+ function resolveSkillName(input) {
24049
+ return readString(input, "skill") ?? readString(input, "skill_name") ?? readString(input, "skillName") ?? readString(input, "name") ?? skillBasenameFromPath(input);
24050
+ }
24051
+ function filePathFrom(input) {
24052
+ if (!input)
24053
+ return null;
24054
+ return readString(input, "file_path") ?? readString(input, "notebook_path");
24055
+ }
24056
+ function bashFirstToken(command) {
24057
+ const m = /^\s*([^\s|&;<>()`$]+)/.exec(command);
24058
+ if (!m)
24059
+ return null;
24060
+ const tok = m[1];
24061
+ if (tok.includes(".."))
24062
+ return null;
24063
+ return /^[A-Za-z0-9._\-\/]+$/.test(tok) ? tok : null;
24064
+ }
24048
24065
  function parseInput(raw) {
24049
24066
  if (!raw || typeof raw !== "string")
24050
24067
  return null;
@@ -24073,20 +24090,58 @@ function skillBasenameFromPath(input) {
24073
24090
  function matchesAllowRule(rule, toolName, inputPreview) {
24074
24091
  if (!rule || !toolName)
24075
24092
  return false;
24076
- const skillMatch = /^Skill\(([^)]+)\)$/.exec(rule);
24077
- if (skillMatch) {
24078
- if (toolName !== "Skill")
24093
+ if (rule.endsWith("__*") && rule.startsWith("mcp__")) {
24094
+ const prefix = rule.slice(0, -1);
24095
+ return toolName.startsWith(prefix);
24096
+ }
24097
+ const scoped = /^([A-Za-z]+)\((.+)\)$/.exec(rule);
24098
+ if (scoped) {
24099
+ const ruleTool = scoped[1];
24100
+ const arg = scoped[2];
24101
+ if (ruleTool !== toolName)
24079
24102
  return false;
24080
- const ruleSkill = skillMatch[1];
24081
24103
  const input = parseInput(inputPreview);
24082
- if (!input)
24083
- return false;
24084
- const reqSkill = readString(input, "skill") ?? readString(input, "skill_name") ?? readString(input, "skillName") ?? readString(input, "name") ?? skillBasenameFromPath(input);
24085
- return reqSkill === ruleSkill;
24104
+ if (ruleTool === "Skill") {
24105
+ if (!input)
24106
+ return false;
24107
+ return resolveSkillName(input) === arg;
24108
+ }
24109
+ if (ruleTool === "Bash") {
24110
+ const cmd = input ? readString(input, "command") : null;
24111
+ if (!cmd)
24112
+ return false;
24113
+ const m = /^([^:]+):\*$/.exec(arg);
24114
+ if (!m)
24115
+ return false;
24116
+ return bashFirstToken(cmd) === m[1];
24117
+ }
24118
+ if (FILE_TOOLS.has(ruleTool)) {
24119
+ return filePathFrom(input) === arg;
24120
+ }
24121
+ return false;
24086
24122
  }
24087
24123
  return rule === toolName;
24088
24124
  }
24089
- var init_permission_rule = () => {};
24125
+ var FILE_TOOLS, BROAD_ONLY_TOOLS;
24126
+ var init_permission_rule = __esm(() => {
24127
+ FILE_TOOLS = new Set([
24128
+ "Edit",
24129
+ "Write",
24130
+ "MultiEdit",
24131
+ "NotebookEdit",
24132
+ "Read"
24133
+ ]);
24134
+ BROAD_ONLY_TOOLS = new Set([
24135
+ "Glob",
24136
+ "Grep",
24137
+ "WebFetch",
24138
+ "WebSearch",
24139
+ "Task",
24140
+ "Agent",
24141
+ "TodoWrite",
24142
+ "ExitPlanMode"
24143
+ ]);
24144
+ });
24090
24145
 
24091
24146
  // bridge/bridge.ts
24092
24147
  var exports_bridge = {};
@@ -361,8 +361,8 @@ import { maybeRenderUpdateAnnouncement } from './update-announce.js'
361
361
  import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
362
362
  import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
363
363
  import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
364
- import { summarizeToolForTitle, formatPermissionCardBody } from '../permission-title.js'
365
- import { resolveAlwaysAllowRule, isRulePersisted } from '../permission-rule.js'
364
+ import { formatPermissionCardBody, describeGrant } from '../permission-title.js'
365
+ import { resolveScopedAllowChoices, isRulePersisted } from '../permission-rule.js'
366
366
  import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
367
367
  import {
368
368
  readClaudeJsonOverage,
@@ -3773,6 +3773,21 @@ const pendingPermissionBuffer = createPendingPermissionBuffer()
3773
3773
  * pre-2747 TTL sweep can reference it; ipcServer/pendingPermissionBuffer
3774
3774
  * are resolved at call-time, after module init.)
3775
3775
  */
3776
+ /**
3777
+ * The default permission-card action row: ❌ Deny · ✅ Allow once ·
3778
+ * 🔁 Always… (the last only when a meaningful always-rule exists).
3779
+ * Tapping "🔁 Always…" swaps this row for the scope sub-menu; "← Back"
3780
+ * rebuilds this row. callback_data stays tiny (verb + 5-char id) so we
3781
+ * never approach Telegram's 64-byte ceiling.
3782
+ */
3783
+ function buildPermissionActionRow(requestId: string, showAlways: boolean): InlineKeyboard {
3784
+ const kb = new InlineKeyboard()
3785
+ .text('❌ Deny', `perm:deny:${requestId}`)
3786
+ .text('✅ Allow once', `perm:allow:${requestId}`)
3787
+ if (showAlways) kb.text('🔁 Always…', `perm:always:${requestId}`)
3788
+ return kb
3789
+ }
3790
+
3776
3791
  function dispatchPermissionVerdict(ev: PermissionEvent): void {
3777
3792
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
3778
3793
  const delivered = ipcServer.sendToAgent(selfAgent, ev)
@@ -4106,37 +4121,25 @@ const ipcServer: IpcServer = createIpcServer({
4106
4121
  const { requestId, toolName, description, inputPreview } = msg
4107
4122
  pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
4108
4123
  const access = loadAccess()
4109
- // #1790 — multi-line collapsed body so the operator can see what
4110
- // is being requested and why without tapping "See more". Mirrors
4111
- // the `vault_request_access` card layout (the gold standard).
4112
- // The detail (expanded `tool_name` / pretty `input_preview`)
4113
- // still surfaces on the See-more tap; this is the
4114
- // collapsed-view fix only. Sent with parse_mode=HTML below.
4124
+ // Natural-language card body a plain sentence ("Gymbro wants to
4125
+ // edit: supplement-log.md" + a why-line), never a raw tool id.
4126
+ // The operator sees what is being requested and why at a glance.
4127
+ // Mirrors the `vault_request_access` card layout. Sent with
4128
+ // parse_mode=HTML below.
4115
4129
  const text = formatPermissionCardBody({
4116
4130
  toolName,
4117
4131
  inputPreview,
4118
4132
  description,
4119
4133
  agentName: _client.agentName,
4120
4134
  })
4121
- // Build the keyboard. The "🔁 Always" button only appears when we
4122
- // can synthesize a meaningful allow-rule for this tool for an
4123
- // unknown tool we'd write a useless rule (or worse, a rule that
4124
- // expanded to too much). resolveAlwaysAllowRule returns null in
4125
- // that case and we fall back to the legacy 3-button layout.
4126
- const alwaysRule = resolveAlwaysAllowRule(toolName, inputPreview)
4127
- const keyboard = new InlineKeyboard()
4128
- .text('See more', `perm:more:${requestId}`)
4129
- .text('✅ Allow', `perm:allow:${requestId}`)
4130
- .text('❌ Deny', `perm:deny:${requestId}`)
4131
- if (alwaysRule != null) {
4132
- // Second row — full-width label like "🔁 Always allow Skill(mail)"
4133
- // so the operator sees exactly what rule they're whitelisting
4134
- // before tapping. Truncate at Telegram's 64-byte callback_data
4135
- // ceiling defensively (long MCP tool names can push past it).
4136
- keyboard
4137
- .row()
4138
- .text(`🔁 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`)
4139
- }
4135
+ // Compact action row: Deny · Allow once · 🔁 Always… — the
4136
+ // scope of an "always" grant stays hidden until the operator taps
4137
+ // "🔁 Always…", which swaps the row for a scope choice (this file /
4138
+ // any file ⚠️). The "🔁 Always…" button only appears when we can
4139
+ // synthesize a meaningful rule for this tool; unknown tools get the
4140
+ // two-button row only.
4141
+ const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
4142
+ const keyboard = buildPermissionActionRow(requestId, showAlways)
4140
4143
  // PR4b emitter sweep — supergroup-mode permission card routing.
4141
4144
  // Per CPO #3 the design is "turn-initiated requests follow the
4142
4145
  // conversation topic; background requests go to admin alias."
@@ -14871,7 +14874,7 @@ async function registerSwitchroomBotCommands(): Promise<void> {
14871
14874
  }
14872
14875
 
14873
14876
  // ─── Inline-button handler (permissions) ──────────────────────────────────
14874
- // Handles `perm:(allow|deny|more):<id>` — permission request buttons
14877
+ // Handles `perm:(allow|deny|always|asn|asb|back):<id>` — permission request buttons
14875
14878
  bot.on('callback_query:data', async ctx => {
14876
14879
  const data = ctx.callbackQuery.data
14877
14880
 
@@ -15246,33 +15249,40 @@ bot.on('callback_query:data', async ctx => {
15246
15249
  }
15247
15250
 
15248
15251
  // Permission request buttons.
15249
- const m = /^perm:(allow|deny|more|always):([a-km-z]{5})$/.exec(data)
15252
+ const m = /^perm:(allow|deny|always|asn|asb|back):([a-km-z]{5})$/.exec(data)
15250
15253
  if (!m) { await ctx.answerCallbackQuery().catch(() => {}); return }
15251
15254
  const access = loadAccess()
15252
15255
  const senderId = String(ctx.from.id)
15253
15256
  if (!access.allowFrom.includes(senderId)) { await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {}); return }
15254
15257
  const [, behavior, request_id] = m
15255
15258
 
15256
- if (behavior === 'more') {
15259
+ // "🔁 Always…" / "← Back" — toggle between the default action row and
15260
+ // the scope sub-menu. Neither dispatches a verdict; the scope is only
15261
+ // committed when the operator taps a scope button (asn / asb).
15262
+ if (behavior === 'always' || behavior === 'back') {
15257
15263
  const details = pendingPermissions.get(request_id)
15258
15264
  if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
15259
- const { tool_name, description, input_preview } = details
15260
- let prettyInput: string
15261
- try { prettyInput = JSON.stringify(JSON.parse(input_preview), null, 2) } catch { prettyInput = input_preview }
15262
- const expanded = `🔐 Permission: ${tool_name}\n\ntool_name: ${tool_name}\ndescription: ${description}\ninput_preview:\n${prettyInput}`
15263
- const expandedRule = resolveAlwaysAllowRule(tool_name, input_preview)
15264
- const expandedKeyboard = new InlineKeyboard()
15265
- .text(' Allow', `perm:allow:${request_id}`)
15266
- .text('❌ Deny', `perm:deny:${request_id}`)
15267
- if (expandedRule != null) {
15268
- expandedKeyboard.row().text(`🔁 Always allow ${expandedRule.label}`, `perm:always:${request_id}`)
15265
+ let keyboard: InlineKeyboard
15266
+ if (behavior === 'back') {
15267
+ keyboard = buildPermissionActionRow(request_id, true)
15268
+ } else {
15269
+ const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
15270
+ if (choices == null) {
15271
+ await ctx.answerCallbackQuery({ text: 'No always-allow rule for this tool.' }).catch(() => {})
15272
+ return
15273
+ }
15274
+ // Scope row: Back · [this thing] · [any thing] ⚠️ — the ⚠️
15275
+ // rides on the broad option only. Scope stays hidden until now.
15276
+ keyboard = new InlineKeyboard().text('← Back', `perm:back:${request_id}`)
15277
+ if (choices.specific) keyboard.text(choices.specific.buttonLabel, `perm:asn:${request_id}`)
15278
+ keyboard.text(`${choices.broad.buttonLabel} ⚠️`, `perm:asb:${request_id}`)
15269
15279
  }
15270
- await ctx.editMessageText(expanded, { reply_markup: expandedKeyboard }).catch(() => {})
15280
+ await ctx.editMessageReplyMarkup({ reply_markup: keyboard }).catch(() => {})
15271
15281
  await ctx.answerCallbackQuery().catch(() => {})
15272
15282
  return
15273
15283
  }
15274
15284
 
15275
- if (behavior === 'always') {
15285
+ if (behavior === 'asn' || behavior === 'asb') {
15276
15286
  // "🔁 Always allow" (#1977) — persist the resolved rule into the
15277
15287
  // agent's tools.allow in the DURABLE host config. The old path
15278
15288
  // shelled `switchroom agent grant` which wrote
@@ -15289,11 +15299,15 @@ bot.on('callback_query:data', async ctx => {
15289
15299
  // apply+reconcile result.
15290
15300
  const details = pendingPermissions.get(request_id)
15291
15301
  if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
15292
- const rule = resolveAlwaysAllowRule(details.tool_name, details.input_preview)
15293
- if (rule == null) {
15302
+ const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
15303
+ if (choices == null) {
15294
15304
  await ctx.answerCallbackQuery({ text: 'Cannot synthesize an always-allow rule for this tool.' }).catch(() => {})
15295
15305
  return
15296
15306
  }
15307
+ // asn → the narrow/specific scope (falls back to broad when the tool
15308
+ // has no sub-scope); asb → the broad, whole-category scope.
15309
+ const chosen = behavior === 'asn' ? (choices.specific ?? choices.broad) : choices.broad
15310
+ const grantPhrase = describeGrant(details.tool_name, details.input_preview, chosen)
15297
15311
  const agentName = process.env.SWITCHROOM_AGENT_NAME
15298
15312
  if (!agentName) {
15299
15313
  await ctx.answerCallbackQuery({ text: 'Always-allow needs SWITCHROOM_AGENT_NAME — gateway is misconfigured.' }).catch(() => {})
@@ -15304,7 +15318,7 @@ bot.on('callback_query:data', async ctx => {
15304
15318
 
15305
15319
  // (2) Dispatch the in-flight permission verdict IMMEDIATELY — before
15306
15320
  // any host round-trip — so the turn never blocks on persistence.
15307
- // We carry the resolved `rule` so the bridge caches it for the rest
15321
+ // We carry the chosen `rule` so the bridge caches it for the rest
15308
15322
  // of the session and auto-allows matching tool calls from sub-agents
15309
15323
  // (Task tool) + the parent without re-popping the prompt (#1138).
15310
15324
  // The rule is safe to cache regardless of whether the *durable*
@@ -15313,7 +15327,7 @@ bot.on('callback_query:data', async ctx => {
15313
15327
  type: 'permission',
15314
15328
  requestId: request_id,
15315
15329
  behavior: 'allow',
15316
- rule: rule.rule,
15330
+ rule: chosen.rule,
15317
15331
  })
15318
15332
 
15319
15333
  // (3) Decide the persistence path. tryHostdDispatch returns
@@ -15331,14 +15345,14 @@ bot.on('callback_query:data', async ctx => {
15331
15345
  try {
15332
15346
  const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
15333
15347
  const raw = readFileSync(cfgPath, 'utf8')
15334
- return synthesizeAllowRuleDiff({ agentName, rule: rule.rule, configText: raw })
15348
+ return synthesizeAllowRuleDiff({ agentName, rule: chosen.rule, configText: raw })
15335
15349
  } catch (err) {
15336
15350
  process.stderr.write(`telegram gateway: always-allow diff synth failed: ${(err as Error).message}\n`)
15337
15351
  return null
15338
15352
  }
15339
15353
  })()
15340
15354
 
15341
- const correlationKey = `${agentName}::${rule.rule}`
15355
+ const correlationKey = `${agentName}::${chosen.rule}`
15342
15356
  try {
15343
15357
  if (unifiedDiff == null) {
15344
15358
  // Could not locate the agent block / read config → fall back to
@@ -15348,14 +15362,14 @@ bot.on('callback_query:data', async ctx => {
15348
15362
  } else {
15349
15363
  // Pre-register the single-tap correlation so hostd's callback
15350
15364
  // (request_config_approval) auto-approves WITHOUT a second card.
15351
- pendingAlwaysAllowCorrelations.set(correlationKey, { agentName, rule: rule.rule, unifiedDiff, createdAt: Date.now() })
15365
+ pendingAlwaysAllowCorrelations.set(correlationKey, { agentName, rule: chosen.rule, unifiedDiff, createdAt: Date.now() })
15352
15366
  const req: HostdRequest = {
15353
15367
  v: 1,
15354
15368
  op: 'config_propose_edit',
15355
15369
  request_id: hostdRequestId('gw-always-allow'),
15356
15370
  args: {
15357
15371
  unified_diff: unifiedDiff,
15358
- reason: `Operator 'always allow' for ${rule.label}`,
15372
+ reason: `Operator 'always allow': ${agentName} can ${grantPhrase}`,
15359
15373
  target_path: '/state/config/switchroom.yaml',
15360
15374
  },
15361
15375
  }
@@ -15368,7 +15382,7 @@ bot.on('callback_query:data', async ctx => {
15368
15382
  } else if (resp.result === 'completed') {
15369
15383
  durable = true
15370
15384
  process.stderr.write(
15371
- `telegram gateway: always-allow durable via hostd rule="${rule.rule}" agent=${agentName} (request_id=${request_id})\n`,
15385
+ `telegram gateway: always-allow durable via hostd rule="${chosen.rule}" agent=${agentName} (request_id=${request_id})\n`,
15372
15386
  )
15373
15387
  } else {
15374
15388
  failReason = resp.error ?? `hostd ${resp.result}`
@@ -15386,20 +15400,20 @@ bot.on('callback_query:data', async ctx => {
15386
15400
  // landed. Honest messaging: "saved (legacy path)" on verify,
15387
15401
  // else the "did NOT save" warning.
15388
15402
  try {
15389
- switchroomExec(['agent', 'grant', agentName, rule.rule, '--no-restart'])
15403
+ switchroomExec(['agent', 'grant', agentName, chosen.rule, '--no-restart'])
15390
15404
  try {
15391
15405
  const cfg = loadSwitchroomConfig()
15392
15406
  const rawAgent = cfg.agents?.[agentName]
15393
15407
  if (rawAgent) {
15394
15408
  const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
15395
15409
  const allowList: string[] = (resolved as { tools?: { allow?: string[] } }).tools?.allow ?? []
15396
- if (isRulePersisted(allowList, rule.rule)) {
15410
+ if (isRulePersisted(allowList, chosen.rule)) {
15397
15411
  durable = true // legacy path verified — durable on this host shape
15398
15412
  process.stderr.write(
15399
- `telegram gateway: always-allow added rule="${rule.rule}" agent=${agentName} via legacy grant (request_id=${request_id})\n`,
15413
+ `telegram gateway: always-allow added rule="${chosen.rule}" agent=${agentName} via legacy grant (request_id=${request_id})\n`,
15400
15414
  )
15401
15415
  } else {
15402
- failReason = `rule "${rule.rule}" not found in resolved tools.allow after write — config location may have drifted`
15416
+ failReason = `rule "${chosen.rule}" not found in resolved tools.allow after write — config location may have drifted`
15403
15417
  process.stderr.write(
15404
15418
  `telegram gateway: always-allow VERIFY FAILED: ${failReason} (request_id=${request_id})\n`,
15405
15419
  )
@@ -15431,8 +15445,8 @@ bot.on('callback_query:data', async ctx => {
15431
15445
  const legacyNote = legacy && durable
15432
15446
  const ackText = ok
15433
15447
  ? (legacyNote
15434
- ? `🔁 Always allow ${rule.label} for ${agentName} (legacy path)`
15435
- : `🔁 Always allow ${rule.label} for ${agentName}`)
15448
+ ? `✅ Saved. ${agentName} can now ${grantPhrase} without asking (legacy path).`
15449
+ : `✅ Saved. ${agentName} can now ${grantPhrase} without asking.`)
15436
15450
  : (editLockHint
15437
15451
  ? `⚠️ Allowed for now — config edits are locked. Enable hostd.config_edit_enabled.`
15438
15452
  : `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`)
@@ -15448,8 +15462,8 @@ bot.on('callback_query:data', async ctx => {
15448
15462
  : ''
15449
15463
  const editLabel = ok
15450
15464
  ? (legacyNote
15451
- ? `🔁 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName)} saved (legacy path); restart agent for full effect`
15452
- : `🔁 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName)} saved; restart agent for full effect`)
15465
+ ? `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking (legacy path); restart agent for full effect`
15466
+ : `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking; restart agent for full effect`)
15453
15467
  : (editLockHint
15454
15468
  ? `⚠️ <b>Allowed for now — "always" did NOT save.</b> Config edits are locked; enable <code>hostd.config_edit_enabled</code>.`
15455
15469
  : `⚠️ <b>Allowed for now — "always" did NOT save.</b> It will ask again after restart. Check gateway log.`)
@@ -15469,13 +15483,11 @@ bot.on('callback_query:data', async ctx => {
15469
15483
  // Forward permission decision to connected bridges
15470
15484
  pendingPermissions.delete(request_id)
15471
15485
  const label = behavior === 'allow' ? '✅ Allowed' : '❌ Denied'
15472
- // HTML-escape the source text — same hazard as the `always` and
15473
- // recent-denial paths above. The original permission card was
15474
- // posted with plain text (`gateway.ts:2828` — `🔐 Permission:
15475
- // <toolName>`), but the expanded form via `behavior === 'more'`
15476
- // (this same handler, line ~11471) appends claude-supplied
15477
- // `description` and `input_preview` strings that frequently contain
15478
- // raw `<`/`>`/`&` (shell commands, JSON nested in JSON, etc). Mixing
15486
+ // HTML-escape the source text — same hazard as the scope-commit and
15487
+ // recent-denial paths above. The permission card body
15488
+ // (formatPermissionCardBody) appends claude-supplied `description`
15489
+ // and `input_preview` strings that frequently contain raw
15490
+ // `<`/`>`/`&` (shell commands, JSON nested in JSON, etc). Mixing
15479
15491
  // a plain-text edit with `parseMode: 'HTML'` is the safe direction:
15480
15492
  // escaped baseText round-trips visually as plain text; the appended
15481
15493
  // status line carries our chosen styling cleanly.
@@ -32,8 +32,10 @@ export interface PermissionEvent {
32
32
  * tapped "Always allow" still hit the popup, because Claude Code reads
33
33
  * `.claude/settings.json` once at boot.
34
34
  *
35
- * Format matches `resolveAlwaysAllowRule`'s output: bare tool name
36
- * (`Edit`), `Skill(<name>)`, or `mcp__<server>__<tool>`.
35
+ * Format matches `resolveScopedAllowChoices`' output: bare tool name
36
+ * (`Edit`), scoped (`Edit(<path>)` / `Bash(<tok>:*)` / `Skill(<name>)`),
37
+ * exact MCP tool (`mcp__<server>__<tool>`), or server wildcard
38
+ * (`mcp__<server>__*`).
37
39
  */
38
40
  rule?: string;
39
41
  }