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.
- package/dist/cli/switchroom.js +13 -11
- package/dist/host-control/main.js +80 -6
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +61 -8
- package/telegram-plugin/dist/gateway/gateway.js +283 -161
- package/telegram-plugin/dist/server.js +64 -9
- package/telegram-plugin/gateway/gateway.ts +78 -66
- package/telegram-plugin/gateway/ipc-protocol.ts +4 -2
- package/telegram-plugin/permission-rule.ts +200 -122
- package/telegram-plugin/permission-title.ts +209 -197
- package/telegram-plugin/tests/always-allow-grant.test.ts +86 -54
- package/telegram-plugin/tests/always-allow-persist.test.ts +35 -34
- package/telegram-plugin/tests/permission-rule.test.ts +185 -127
- package/telegram-plugin/tests/permission-title.test.ts +109 -195
|
@@ -1,108 +1,187 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolve
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
17
|
-
* - `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
/**
|
|
34
|
-
readonly
|
|
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
|
|
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
|
|
74
|
+
export function resolveScopedAllowChoices(
|
|
43
75
|
toolName: string,
|
|
44
76
|
inputPreview: string | undefined,
|
|
45
|
-
):
|
|
77
|
+
): ScopedAllowChoices | null {
|
|
46
78
|
if (!toolName) return null;
|
|
47
79
|
const input = parseInput(inputPreview);
|
|
48
80
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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:
|
|
70
|
-
|
|
98
|
+
specific: { rule: `${toolName}(${path})`, buttonLabel: "This file", broad: false },
|
|
99
|
+
broad,
|
|
71
100
|
};
|
|
72
101
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
*
|
|
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
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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 (
|
|
232
|
+
* Matching rules (mirror what `resolveScopedAllowChoices` produces):
|
|
165
233
|
*
|
|
166
|
-
* - Bare tool name (`Edit`, `Bash`,
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* - `
|
|
171
|
-
*
|
|
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
|
|
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
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
}
|