switchroom 0.14.12 → 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.
@@ -1,108 +1,187 @@
1
1
  /**
2
- * Resolve a Claude Code permission allow-rule from a permission_request
3
- * payload — the value we'd add to `tools.allow` in switchroom.yaml so
2
+ * Resolve Claude Code permission allow-rules from a permission_request
3
+ * payload — the value(s) we'd add to `tools.allow` in switchroom.yaml so
4
4
  * future invocations of the same operation never pop another approval
5
5
  * dialog.
6
6
  *
7
- * Used by the Telegram permission popup's "🔁 Always allow" button
8
- * (issue follow-up to #186). Per-skill granularity for `Skill` so
9
- * tapping "Always" on a `Skill (mail)` prompt only whitelists `mail`,
10
- * not all skills. Other tools fall back to the bare tool name
11
- * promoting `Bash` from per-call confirm to always-allow is a more
12
- * meaningful blast-radius decision and shouldn't be hidden behind a
13
- * one-tap button on an arbitrary command, but the bare-name behaviour
14
- * is consistent with how the existing `tools.allow: [Bash]` works.
7
+ * Used by the Telegram permission card's "🔁 Always" button. The button
8
+ * no longer commits a single fixed-breadth rule: it opens a scope choice
9
+ * so the operator decides whether "always" means *this exact thing* (e.g.
10
+ * this one file, this one MCP action) or *the whole category* (any file,
11
+ * every tool on the server). The scope of the grant — especially for
12
+ * "always" must be legible before the operator taps it (vision pillar
13
+ * #2: you hold the leash).
15
14
  *
16
- * Returns:
17
- * - `{ rule, label }` when we can compute a rule for the agent's yaml
18
- * - `null` when the tool is something we don't know how to allow at
19
- * a meaningful granularity (caller should disable the Always button
20
- * to avoid writing a useless or dangerously-broad rule)
15
+ * `resolveScopedAllowChoices` returns up to two options:
16
+ * - `specific` the narrow grant (this file / this command / this
17
+ * MCP action). Omitted when the tool has no meaningful sub-scope.
18
+ * - `broad` the category grant (any file / any command / every tool
19
+ * on the server). Always present when choices resolve at all; the
20
+ * gateway flags it with a ⚠️ and places it last.
21
21
  *
22
- * The `label` is what we surface to the operator in the confirmation
23
- * "🔁 Always allow Skill(mail) for clerk". The rule is what lands in
24
- * yaml: matches Claude Code's settings.json permission-rule grammar
25
- * (`Tool` or `Tool(arg)` for granular).
22
+ * Returns `null` when the tool is something we can't allow at a
23
+ * meaningful granularity the caller hides the Always button rather
24
+ * than write a useless or dangerously-broad rule.
25
+ *
26
+ * The rule strings match Claude Code's settings.json permission-rule
27
+ * grammar (`Tool`, `Tool(arg)`, `mcp__server__tool`, `mcp__server__*`).
26
28
  */
27
29
 
28
30
  import { basename } from "node:path";
29
31
 
30
- export interface AlwaysAllowRule {
31
- /** The exact string to add to `tools.allow` in switchroom.yaml. */
32
+ /** One scope option offered behind the "🔁 Always…" button. */
33
+ export interface ScopeOption {
34
+ /** Exact string to add to `tools.allow` in switchroom.yaml. */
32
35
  readonly rule: string;
33
- /** Human-readable label for the confirmation message. */
34
- readonly label: string;
36
+ /** Short button label, e.g. "This file" / "Any file" / "All Perplexity". */
37
+ readonly buttonLabel: string;
38
+ /** True for the broadest option — gateway rides a ⚠️ on it and shows it last. */
39
+ readonly broad: boolean;
40
+ }
41
+
42
+ export interface ScopedAllowChoices {
43
+ /** Narrow grant (this file / this command / this action). May be absent. */
44
+ readonly specific?: ScopeOption;
45
+ /** Broadest grant (any file / any command / every server tool). */
46
+ readonly broad: ScopeOption;
35
47
  }
36
48
 
49
+ const FILE_TOOLS = new Set([
50
+ "Edit",
51
+ "Write",
52
+ "MultiEdit",
53
+ "NotebookEdit",
54
+ "Read",
55
+ ]);
56
+
57
+ // Tools with no meaningful sub-scope — only a broad, whole-tool grant.
58
+ const BROAD_ONLY_TOOLS = new Set([
59
+ "Glob",
60
+ "Grep",
61
+ "WebFetch",
62
+ "WebSearch",
63
+ "Task",
64
+ "Agent",
65
+ "TodoWrite",
66
+ "ExitPlanMode",
67
+ ]);
68
+
37
69
  /**
38
- * @param toolName Claude Code's tool_name from the permission_request
70
+ * @param toolName Claude Code's tool_name from the permission_request
39
71
  * @param inputPreview JSON string with the tool's input. May be undefined
40
72
  * or non-JSON; the function is conservative.
41
73
  */
42
- export function resolveAlwaysAllowRule(
74
+ export function resolveScopedAllowChoices(
43
75
  toolName: string,
44
76
  inputPreview: string | undefined,
45
- ): AlwaysAllowRule | null {
77
+ ): ScopedAllowChoices | null {
46
78
  if (!toolName) return null;
47
79
  const input = parseInput(inputPreview);
48
80
 
49
- switch (toolName) {
50
- case "Skill": {
51
- // Per-skill granularity: `Skill(mail)`. Mirror permission-title's
52
- // defensive field-fallback so the rule resolves whenever the
53
- // popup managed to render the skill name in brackets.
54
- if (!input) return null;
55
- const skill =
56
- readString(input, "skill") ??
57
- readString(input, "skill_name") ??
58
- readString(input, "skillName") ??
59
- readString(input, "name") ??
60
- skillBasenameFromPath(input);
61
- if (!skill) return null;
62
- // Claude Code's permission-rule grammar quotes the arg with
63
- // parentheses, no inner quoting needed for typical skill names
64
- // (alphanumeric + dash/underscore/dot). Refuse rules with
65
- // characters that could break the parser or expand to unintended
66
- // matches.
67
- if (!/^[A-Za-z0-9._\-+]+$/.test(skill)) return null;
81
+ // ── Skill: per-skill is the narrow grant; all skills is the broad one.
82
+ if (toolName === "Skill") {
83
+ const skill = input ? resolveSkillName(input) : null;
84
+ if (!skill) return null; // can't name it don't offer an always rule
85
+ if (!/^[A-Za-z0-9._\-+]+$/.test(skill)) return null;
86
+ return {
87
+ specific: { rule: `Skill(${skill})`, buttonLabel: "This skill", broad: false },
88
+ broad: { rule: "Skill", buttonLabel: "Any skill", broad: true },
89
+ };
90
+ }
91
+
92
+ // ── File tools: this exact path vs any file.
93
+ if (FILE_TOOLS.has(toolName)) {
94
+ const path = filePathFrom(input);
95
+ const broad: ScopeOption = { rule: toolName, buttonLabel: "Any file", broad: true };
96
+ if (path) {
68
97
  return {
69
- rule: `Skill(${skill})`,
70
- label: `Skill(${skill})`,
98
+ specific: { rule: `${toolName}(${path})`, buttonLabel: "This file", broad: false },
99
+ broad,
71
100
  };
72
101
  }
73
- case "Bash":
74
- case "Read":
75
- case "Write":
76
- case "Edit":
77
- case "MultiEdit":
78
- case "NotebookEdit":
79
- case "Glob":
80
- case "Grep":
81
- case "WebFetch":
82
- case "WebSearch":
83
- case "Task":
84
- case "Agent":
85
- case "TodoWrite":
86
- case "ExitPlanMode": {
87
- // Bare tool name — same shape as `tools.allow: [Bash]`. We
88
- // don't pattern-match the args here: that's the operator's job
89
- // when they want fine control. The Telegram button is for the
90
- // common "I trust this skill / this tool category" case, not
91
- // for synthesizing precise Bash glob rules.
92
- return { rule: toolName, label: toolName };
93
- }
94
- default: {
95
- // MCP tools (mcp__server__tool) come through with their full
96
- // namespaced name. Pre-approve the exact tool — same pattern
97
- // the scaffold already uses for SWITCHROOM_TELEGRAM_MCP_TOOLS.
98
- if (/^mcp__[A-Za-z0-9_\-]+(__[A-Za-z0-9_\-]+)?$/.test(toolName)) {
99
- return { rule: toolName, label: toolName };
100
- }
101
- // Unknown tool — refuse rather than write a rule we can't
102
- // explain. Leaves the operator to add it via the CLI.
103
- return null;
102
+ return { broad };
103
+ }
104
+
105
+ // ── Bash: this command family (`npm:*`) vs any command.
106
+ if (toolName === "Bash") {
107
+ const broad: ScopeOption = { rule: "Bash", buttonLabel: "Any command", broad: true };
108
+ const cmd = input ? readString(input, "command") : null;
109
+ const tok = cmd ? bashFirstToken(cmd) : null;
110
+ if (tok) {
111
+ return {
112
+ specific: { rule: `Bash(${tok}:*)`, buttonLabel: `${tok} commands`, broad: false },
113
+ broad,
114
+ };
104
115
  }
116
+ return { broad };
117
+ }
118
+
119
+ if (BROAD_ONLY_TOOLS.has(toolName)) {
120
+ return { broad: { rule: toolName, buttonLabel: "Always allow", broad: true } };
121
+ }
122
+
123
+ // ── MCP tools: `mcp__<server>__<tool>`. This exact action vs every
124
+ // tool on the server (`mcp__<server>__*`).
125
+ const mcp = /^mcp__([A-Za-z0-9_-]+)__([A-Za-z0-9_-]+)$/.exec(toolName);
126
+ if (mcp) {
127
+ const server = mcp[1]!;
128
+ return {
129
+ specific: { rule: toolName, buttonLabel: "This action", broad: false },
130
+ broad: { rule: `mcp__${server}__*`, buttonLabel: `All ${prettyMcpServer(server)}`, broad: true },
131
+ };
132
+ }
133
+ // Server-only namespace (`mcp__hindsight`) — broad grant only.
134
+ if (/^mcp__[A-Za-z0-9_-]+$/.test(toolName)) {
135
+ return { broad: { rule: toolName, buttonLabel: "Always allow", broad: true } };
105
136
  }
137
+
138
+ // Unknown tool — refuse rather than write a rule we can't explain.
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Prettify an MCP server slug for display: `perplexity` → `Perplexity`,
144
+ * `google-workspace` → `Google Workspace`. Exported so the card title
145
+ * layer (permission-title.ts) renders the same server name as the
146
+ * scope button.
147
+ */
148
+ export function prettyMcpServer(server: string): string {
149
+ return server
150
+ .split(/[-_]/)
151
+ .filter((w) => w.length > 0)
152
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
153
+ .join(" ");
154
+ }
155
+
156
+ function resolveSkillName(input: Record<string, unknown>): string | null {
157
+ return (
158
+ readString(input, "skill") ??
159
+ readString(input, "skill_name") ??
160
+ readString(input, "skillName") ??
161
+ readString(input, "name") ??
162
+ skillBasenameFromPath(input)
163
+ );
164
+ }
165
+
166
+ function filePathFrom(input: Record<string, unknown> | null): string | null {
167
+ if (!input) return null;
168
+ return readString(input, "file_path") ?? readString(input, "notebook_path");
169
+ }
170
+
171
+ /**
172
+ * First token of a Bash command, used to synthesize a `Bash(<tok>:*)`
173
+ * prefix rule. Returns null for anything that isn't a clean command
174
+ * word (paths, shell metacharacters, traversal) — better to fall back
175
+ * to the explicit "any command" grant than emit a rule that doesn't
176
+ * match what the operator saw.
177
+ */
178
+ function bashFirstToken(command: string): string | null {
179
+ const m = /^\s*([^\s|&;<>()`$]+)/.exec(command);
180
+ if (!m) return null;
181
+ const tok = m[1]!;
182
+ if (tok.includes("..")) return null;
183
+ // Allow a leading path (e.g. /usr/bin/ls) but keep it a single safe word.
184
+ return /^[A-Za-z0-9._\-\/]+$/.test(tok) ? tok : null;
106
185
  }
107
186
 
108
187
  function parseInput(raw: string | undefined): Record<string, unknown> | null {
@@ -134,18 +213,8 @@ function skillBasenameFromPath(input: Record<string, unknown>): string | null {
134
213
 
135
214
  /**
136
215
  * Verify that a grant actually landed in the resolved `tools.allow` list.
137
- *
138
- * Called by the `perm:always:*` handler after `switchroom agent grant`
139
- * returns to guard against silently-failed or misdirected yaml writes.
140
- * Extracted as a pure helper so it can be unit-tested without a full
141
- * Grammy + switchroomExec harness.
142
- *
143
- * @param resolvedAllow The `tools.allow` array from `resolveAgentConfig`
144
- * for the target agent (pass `[]` when absent/undefined).
145
- * @param ruleRule The rule string produced by `resolveAlwaysAllowRule`
146
- * (e.g. `"Skill(garmin)"`, `"Bash"`, `"mcp__x__y"`).
147
- * @returns `true` when the rule is present (grant confirmed), `false` when
148
- * absent (grant failed / config location drifted).
216
+ * Called by the always-allow handler after the persistence round-trip to
217
+ * guard against silently-failed or misdirected yaml writes.
149
218
  */
150
219
  export function isRulePersisted(
151
220
  resolvedAllow: readonly string[],
@@ -155,27 +224,22 @@ export function isRulePersisted(
155
224
  }
156
225
 
157
226
  /**
158
- * Inverse of `resolveAlwaysAllowRule` — does a stored allow-rule cover a
159
- * fresh `permission_request`? Used by the bridge's session-scoped
160
- * always-allow cache (issue #1138) to short-circuit prompts when the
161
- * operator has already tapped "🔁 Always allow" for an equivalent tool
162
- * call earlier in the same session.
227
+ * Inverse of the rule resolver — does a stored allow-rule cover a fresh
228
+ * `permission_request`? Used by the bridge's session-scoped always-allow
229
+ * cache (#1138) to short-circuit prompts when the operator has already
230
+ * tapped an equivalent grant earlier in the same session.
163
231
  *
164
- * Matching rules (mirrors what `resolveAlwaysAllowRule` produces):
232
+ * Matching rules (mirror what `resolveScopedAllowChoices` produces):
165
233
  *
166
- * - Bare tool name (`Edit`, `Bash`, `Write`, …) ⇒ matches any
167
- * invocation of that tool. This is consistent with how
168
- * `tools.allow: [Edit]` works in `.claude/settings.json` there's
169
- * no arg matching at this layer.
170
- * - `Skill(<name>)`matches only `Skill` invocations whose resolved
171
- * skill name (via the same field-fallback chain as the resolver)
172
- * equals `<name>`.
173
- * - `mcp__<server>__<tool>` ⇒ matches the exact namespaced MCP tool
174
- * name.
234
+ * - Bare tool name (`Edit`, `Bash`, …) ⇒ any invocation of that tool.
235
+ * - `Edit(<path>)` / `Read(<path>)` / … ⇒ that tool, that exact file.
236
+ * - `Bash(<tok>:*)` Bash whose first command token equals `<tok>`.
237
+ * - `Skill(<name>)` Skill whose resolved name equals `<name>`.
238
+ * - `mcp__<server>__<tool>`that exact namespaced MCP tool.
239
+ * - `mcp__<server>__*` any tool on that MCP server.
175
240
  *
176
241
  * Returns `false` for any malformed rule rather than throwing — the
177
- * caller (bridge) is on the hot permission path and should fall through
178
- * to the gateway prompt on bad input.
242
+ * caller (bridge) is on the hot permission path.
179
243
  */
180
244
  export function matchesAllowRule(
181
245
  rule: string,
@@ -184,23 +248,37 @@ export function matchesAllowRule(
184
248
  ): boolean {
185
249
  if (!rule || !toolName) return false;
186
250
 
187
- // Skill(name) extract the parenthesized argument and compare against
188
- // the resolved skill identifier from the request.
189
- const skillMatch = /^Skill\(([^)]+)\)$/.exec(rule);
190
- if (skillMatch) {
191
- if (toolName !== "Skill") return false;
192
- const ruleSkill = skillMatch[1];
251
+ // MCP wildcard: `mcp__server__*` covers every tool on that server.
252
+ if (rule.endsWith("__*") && rule.startsWith("mcp__")) {
253
+ const prefix = rule.slice(0, -1); // drop the trailing "*" → "mcp__server__"
254
+ return toolName.startsWith(prefix);
255
+ }
256
+
257
+ // `Tool(arg)` — scoped grants for Skill / file tools / Bash.
258
+ const scoped = /^([A-Za-z]+)\((.+)\)$/.exec(rule);
259
+ if (scoped) {
260
+ const ruleTool = scoped[1]!;
261
+ const arg = scoped[2]!;
262
+ if (ruleTool !== toolName) return false;
193
263
  const input = parseInput(inputPreview);
194
- if (!input) return false;
195
- const reqSkill =
196
- readString(input, "skill") ??
197
- readString(input, "skill_name") ??
198
- readString(input, "skillName") ??
199
- readString(input, "name") ??
200
- skillBasenameFromPath(input);
201
- return reqSkill === ruleSkill;
264
+
265
+ if (ruleTool === "Skill") {
266
+ if (!input) return false;
267
+ return resolveSkillName(input) === arg;
268
+ }
269
+ if (ruleTool === "Bash") {
270
+ const cmd = input ? readString(input, "command") : null;
271
+ if (!cmd) return false;
272
+ const m = /^([^:]+):\*$/.exec(arg); // "<tok>:*"
273
+ if (!m) return false;
274
+ return bashFirstToken(cmd) === m[1];
275
+ }
276
+ if (FILE_TOOLS.has(ruleTool)) {
277
+ return filePathFrom(input) === arg;
278
+ }
279
+ return false;
202
280
  }
203
281
 
204
- // Bare tool name or namespaced MCP tool — exact string compare.
282
+ // Bare tool name or exact namespaced MCP tool — exact string compare.
205
283
  return rule === toolName;
206
284
  }