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.
@@ -1,194 +1,223 @@
1
1
  /**
2
- * Tests for the always-allow rule resolver — pinned by the Telegram
3
- * `🔁 Always allow` button (popup callback handler in gateway.ts).
2
+ * Tests for the scoped always-allow rule resolver — pinned by the
3
+ * Telegram "🔁 Always…" scope sub-menu (callback handler in gateway.ts).
4
4
  *
5
- * The shape we promise to the gateway:
5
+ * The shape we promise the gateway:
6
6
  * - `null` ⇒ "don't show the Always button" (unknown tool, missing
7
7
  * skill name, characters that could break Claude Code's
8
8
  * permission-rule grammar).
9
- * - `{rule, label}` ⇒ a string we can hand to
10
- * `switchroom agent grant <agent> <rule>` and a human-readable
11
- * label for the chat confirmation.
9
+ * - `{ specific?, broad }` ⇒ one or two ScopeOptions. `specific` is the
10
+ * narrow grant (this file / this command / this MCP action); `broad`
11
+ * is the whole-category grant (any file / every server tool) and is
12
+ * always present when choices resolve at all.
13
+ *
14
+ * `matchesAllowRule` is the inverse — does a stored rule cover a fresh
15
+ * request — used by the bridge's session-scoped allow cache (#1138).
12
16
  */
13
17
 
14
18
  import { describe, it, expect } from 'vitest'
15
- import { resolveAlwaysAllowRule, matchesAllowRule } from '../permission-rule.js'
19
+ import {
20
+ resolveScopedAllowChoices,
21
+ matchesAllowRule,
22
+ isRulePersisted,
23
+ prettyMcpServer,
24
+ } from '../permission-rule.js'
16
25
 
17
- describe('resolveAlwaysAllowRule — Skill', () => {
18
- it('returns Skill(name) for a typical skill input', () => {
19
- const result = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'mail' }))
20
- expect(result).toEqual({ rule: 'Skill(mail)', label: 'Skill(mail)' })
26
+ describe('resolveScopedAllowChoices — Skill', () => {
27
+ it('offers this-skill (specific) + any-skill (broad)', () => {
28
+ const r = resolveScopedAllowChoices('Skill', JSON.stringify({ skill: 'mail' }))
29
+ expect(r).toEqual({
30
+ specific: { rule: 'Skill(mail)', buttonLabel: 'This skill', broad: false },
31
+ broad: { rule: 'Skill', buttonLabel: 'Any skill', broad: true },
32
+ })
21
33
  })
22
34
 
23
- it('falls back to skill_name field', () => {
24
- const result = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill_name: 'calendar' }))
25
- expect(result).toEqual({ rule: 'Skill(calendar)', label: 'Skill(calendar)' })
35
+ it('follows the field fallback chain (skill_name / skillName / name / path)', () => {
36
+ for (const input of [
37
+ { skill_name: 'calendar' },
38
+ { skillName: 'calendar' },
39
+ { name: 'calendar' },
40
+ ]) {
41
+ expect(resolveScopedAllowChoices('Skill', JSON.stringify(input))?.specific?.rule).toBe(
42
+ 'Skill(calendar)',
43
+ )
44
+ }
45
+ expect(
46
+ resolveScopedAllowChoices('Skill', JSON.stringify({ path: 'skills/coolify/SKILL.md' }))
47
+ ?.specific?.rule,
48
+ ).toBe('Skill(coolify)')
26
49
  })
27
50
 
28
- it('falls back to skillName field', () => {
29
- const result = resolveAlwaysAllowRule('Skill', JSON.stringify({ skillName: 'garmin' }))
30
- expect(result).toEqual({ rule: 'Skill(garmin)', label: 'Skill(garmin)' })
51
+ it('returns null when no skill identifier is present', () => {
52
+ expect(resolveScopedAllowChoices('Skill', JSON.stringify({ unrelated: 'x' }))).toBeNull()
53
+ expect(resolveScopedAllowChoices('Skill', undefined)).toBeNull()
54
+ expect(resolveScopedAllowChoices('Skill', 'not-json')).toBeNull()
31
55
  })
32
56
 
33
- it('falls back to name field', () => {
34
- const result = resolveAlwaysAllowRule('Skill', JSON.stringify({ name: 'home-assistant' }))
35
- expect(result).toEqual({ rule: 'Skill(home-assistant)', label: 'Skill(home-assistant)' })
57
+ it('refuses skill names with grammar-breaking characters', () => {
58
+ expect(resolveScopedAllowChoices('Skill', JSON.stringify({ skill: 'mail(secret)' }))).toBeNull()
59
+ expect(resolveScopedAllowChoices('Skill', JSON.stringify({ skill: 'mail/calendar' }))).toBeNull()
60
+ expect(resolveScopedAllowChoices('Skill', JSON.stringify({ skill: 'mail calendar' }))).toBeNull()
36
61
  })
37
62
 
38
- it('extracts skill name from path with SKILL.md', () => {
39
- const result = resolveAlwaysAllowRule(
40
- 'Skill',
41
- JSON.stringify({ path: 'skills/coolify/SKILL.md' }),
42
- )
43
- expect(result).toEqual({ rule: 'Skill(coolify)', label: 'Skill(coolify)' })
63
+ it('accepts the safe alphanumeric + ._-+ alphabet', () => {
64
+ for (const s of ['home-assistant', 'home_assistant', 'docs.v2', 'work+personal']) {
65
+ expect(resolveScopedAllowChoices('Skill', JSON.stringify({ skill: s }))).not.toBeNull()
66
+ }
44
67
  })
68
+ })
45
69
 
46
- it('extracts skill name from a directory path', () => {
47
- const result = resolveAlwaysAllowRule(
48
- 'Skill',
49
- JSON.stringify({ skill_path: '/home/x/.switchroom/skills/mail' }),
50
- )
51
- expect(result).toEqual({ rule: 'Skill(mail)', label: 'Skill(mail)' })
70
+ describe('resolveScopedAllowChoices file tools', () => {
71
+ it('offers this-file (specific) + any-file (broad) when a path is present', () => {
72
+ const r = resolveScopedAllowChoices('Edit', JSON.stringify({ file_path: '/work/log.md' }))
73
+ expect(r).toEqual({
74
+ specific: { rule: 'Edit(/work/log.md)', buttonLabel: 'This file', broad: false },
75
+ broad: { rule: 'Edit', buttonLabel: 'Any file', broad: true },
76
+ })
52
77
  })
53
78
 
54
- it('returns null when no skill identifier is present', () => {
55
- expect(resolveAlwaysAllowRule('Skill', JSON.stringify({ unrelated: 'x' }))).toBeNull()
56
- expect(resolveAlwaysAllowRule('Skill', undefined)).toBeNull()
57
- expect(resolveAlwaysAllowRule('Skill', '')).toBeNull()
58
- expect(resolveAlwaysAllowRule('Skill', 'not-json')).toBeNull()
79
+ it('falls back to broad-only when no path is present', () => {
80
+ const r = resolveScopedAllowChoices('Write', JSON.stringify({ unrelated: 'x' }))
81
+ expect(r).toEqual({ broad: { rule: 'Write', buttonLabel: 'Any file', broad: true } })
59
82
  })
60
83
 
61
- it('refuses skill names with characters that could break the rule grammar', () => {
62
- // Parens, slashes, quotes, whitespace would break Claude Code's
63
- // permission-rule parser or expand to unintended matches.
64
- expect(resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'mail(secret)' }))).toBeNull()
65
- expect(resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'mail/calendar' }))).toBeNull()
66
- expect(resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'mail calendar' }))).toBeNull()
67
- expect(resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'mail"calendar' }))).toBeNull()
84
+ it('prefers notebook_path for NotebookEdit', () => {
85
+ const r = resolveScopedAllowChoices(
86
+ 'NotebookEdit',
87
+ JSON.stringify({ notebook_path: '/work/a.ipynb' }),
88
+ )
89
+ expect(r?.specific?.rule).toBe('NotebookEdit(/work/a.ipynb)')
68
90
  })
69
91
 
70
- it('accepts the safe alphanumeric + ._-+ alphabet', () => {
71
- expect(resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'home-assistant' }))).not.toBeNull()
72
- expect(resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'home_assistant' }))).not.toBeNull()
73
- expect(resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'docs.v2' }))).not.toBeNull()
74
- expect(resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'work+personal' }))).not.toBeNull()
75
- })
92
+ it.each(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Read'])(
93
+ 'treats %s as a file tool',
94
+ (tool) => {
95
+ const r = resolveScopedAllowChoices(tool, JSON.stringify({ file_path: '/x' }))
96
+ expect(r?.broad.rule).toBe(tool)
97
+ },
98
+ )
76
99
  })
77
100
 
78
- describe('resolveAlwaysAllowRulebuilt-in tools', () => {
79
- it.each([
80
- 'Bash', 'Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit',
81
- 'Glob', 'Grep', 'WebFetch', 'WebSearch',
82
- 'Task', 'Agent', 'TodoWrite', 'ExitPlanMode',
83
- ])('returns the bare tool name for %s', (tool) => {
84
- expect(resolveAlwaysAllowRule(tool, undefined)).toEqual({ rule: tool, label: tool })
85
- })
86
-
87
- it('returns the bare name even when input_preview is present', () => {
88
- // The button is for "trust this tool category" — fine-grained
89
- // pattern rules (Bash(npm:*) etc.) are the operator's job to
90
- // craft via the CLI.
91
- const result = resolveAlwaysAllowRule(
92
- 'Bash',
93
- JSON.stringify({ command: 'rm -rf /tmp/x' }),
94
- )
95
- expect(result).toEqual({ rule: 'Bash', label: 'Bash' })
101
+ describe('resolveScopedAllowChoicesBash', () => {
102
+ it('offers a command-family prefix (specific) + any-command (broad)', () => {
103
+ const r = resolveScopedAllowChoices('Bash', JSON.stringify({ command: 'npm run build' }))
104
+ expect(r).toEqual({
105
+ specific: { rule: 'Bash(npm:*)', buttonLabel: 'npm commands', broad: false },
106
+ broad: { rule: 'Bash', buttonLabel: 'Any command', broad: true },
107
+ })
108
+ })
109
+
110
+ it('falls back to broad-only when the first token is unsafe', () => {
111
+ const r = resolveScopedAllowChoices('Bash', JSON.stringify({ command: 'foo | bar' }))
112
+ // "foo" is a clean token, so this resolves; use a metacharacter-led command:
113
+ const r2 = resolveScopedAllowChoices('Bash', JSON.stringify({ command: '$(evil)' }))
114
+ expect(r?.specific?.rule).toBe('Bash(foo:*)')
115
+ expect(r2).toEqual({ broad: { rule: 'Bash', buttonLabel: 'Any command', broad: true } })
96
116
  })
97
117
  })
98
118
 
99
- describe('resolveAlwaysAllowRuleMCP tools', () => {
100
- it('preserves the namespaced MCP tool name', () => {
101
- expect(resolveAlwaysAllowRule('mcp__switchroom-telegram__reply', undefined))
102
- .toEqual({ rule: 'mcp__switchroom-telegram__reply', label: 'mcp__switchroom-telegram__reply' })
119
+ describe('resolveScopedAllowChoicesbroad-only built-ins', () => {
120
+ it.each(['Glob', 'Grep', 'WebFetch', 'WebSearch', 'Task', 'Agent', 'TodoWrite', 'ExitPlanMode'])(
121
+ 'offers a single "Always allow" broad grant for %s',
122
+ (tool) => {
123
+ expect(resolveScopedAllowChoices(tool, undefined)).toEqual({
124
+ broad: { rule: tool, buttonLabel: 'Always allow', broad: true },
125
+ })
126
+ },
127
+ )
128
+ })
129
+
130
+ describe('resolveScopedAllowChoices — MCP tools', () => {
131
+ it('offers this-action (specific) + all-server (broad)', () => {
132
+ const r = resolveScopedAllowChoices('mcp__perplexity__search', undefined)
133
+ expect(r).toEqual({
134
+ specific: { rule: 'mcp__perplexity__search', buttonLabel: 'This action', broad: false },
135
+ broad: { rule: 'mcp__perplexity__*', buttonLabel: 'All Perplexity', broad: true },
136
+ })
137
+ })
138
+
139
+ it('prettifies multi-word server slugs in the broad label', () => {
140
+ const r = resolveScopedAllowChoices('mcp__google-workspace__list_files', undefined)
141
+ expect(r?.broad).toEqual({
142
+ rule: 'mcp__google-workspace__*',
143
+ buttonLabel: 'All Google Workspace',
144
+ broad: true,
145
+ })
103
146
  })
104
147
 
105
- it('accepts MCP server-only namespaces', () => {
106
- expect(resolveAlwaysAllowRule('mcp__hindsight', undefined))
107
- .toEqual({ rule: 'mcp__hindsight', label: 'mcp__hindsight' })
148
+ it('offers a broad-only grant for a server-only namespace', () => {
149
+ expect(resolveScopedAllowChoices('mcp__hindsight', undefined)).toEqual({
150
+ broad: { rule: 'mcp__hindsight', buttonLabel: 'Always allow', broad: true },
151
+ })
108
152
  })
109
153
 
110
- it('refuses malformed mcp_ shapes (missing prefix structure)', () => {
111
- expect(resolveAlwaysAllowRule('mcp_foo', undefined)).toBeNull()
112
- expect(resolveAlwaysAllowRule('mcp__', undefined)).toBeNull()
154
+ it('refuses malformed mcp shapes', () => {
155
+ expect(resolveScopedAllowChoices('mcp_foo', undefined)).toBeNull()
156
+ expect(resolveScopedAllowChoices('mcp__', undefined)).toBeNull()
113
157
  })
114
158
  })
115
159
 
116
- describe('resolveAlwaysAllowRule — fallback', () => {
117
- it('returns null for unknown tools', () => {
118
- expect(resolveAlwaysAllowRule('UnknownTool', undefined)).toBeNull()
119
- expect(resolveAlwaysAllowRule('', undefined)).toBeNull()
160
+ describe('resolveScopedAllowChoices — fallback', () => {
161
+ it('returns null for unknown tools and empty input', () => {
162
+ expect(resolveScopedAllowChoices('UnknownTool', undefined)).toBeNull()
163
+ expect(resolveScopedAllowChoices('', undefined)).toBeNull()
164
+ })
165
+ })
166
+
167
+ describe('prettyMcpServer', () => {
168
+ it('title-cases and splits on - and _', () => {
169
+ expect(prettyMcpServer('perplexity')).toBe('Perplexity')
170
+ expect(prettyMcpServer('google-workspace')).toBe('Google Workspace')
171
+ expect(prettyMcpServer('agent_config')).toBe('Agent Config')
120
172
  })
121
173
  })
122
174
 
123
175
  describe('matchesAllowRule — bare tool names', () => {
124
- // The whole point of #1138: a cached `Edit` rule covers every Edit
125
- // call from the parent claude AND from sub-agents dispatched via the
126
- // Task tool, no matter the file path.
127
176
  it('matches any invocation of the same tool', () => {
128
177
  expect(matchesAllowRule('Edit', 'Edit', undefined)).toBe(true)
129
- expect(matchesAllowRule('Edit', 'Edit', JSON.stringify({ file_path: '/tmp/a' }))).toBe(true)
130
178
  expect(matchesAllowRule('Edit', 'Edit', JSON.stringify({ file_path: '/etc/passwd' }))).toBe(true)
131
179
  })
132
180
 
133
181
  it('does not bleed into other tools', () => {
134
182
  expect(matchesAllowRule('Edit', 'Write', undefined)).toBe(false)
135
- expect(matchesAllowRule('Read', 'Edit', undefined)).toBe(false)
136
183
  expect(matchesAllowRule('Bash', 'BashOutput', undefined)).toBe(false)
137
184
  })
138
-
139
- it.each(['Bash', 'Read', 'Write', 'MultiEdit', 'Glob', 'Grep', 'WebFetch', 'TodoWrite'])(
140
- 'roundtrips through resolve → match for %s',
141
- (tool) => {
142
- const resolved = resolveAlwaysAllowRule(tool, undefined)
143
- expect(resolved).not.toBeNull()
144
- expect(matchesAllowRule(resolved!.rule, tool, undefined)).toBe(true)
145
- },
146
- )
147
185
  })
148
186
 
149
- describe('matchesAllowRule — Skill(name)', () => {
150
- it('matches only the specific skill', () => {
151
- expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ skill: 'mail' }))).toBe(true)
152
- expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ skill: 'calendar' }))).toBe(false)
187
+ describe('matchesAllowRule — scoped file/Bash/Skill rules', () => {
188
+ it('matches a file rule only for that exact path', () => {
189
+ expect(matchesAllowRule('Edit(/work/log.md)', 'Edit', JSON.stringify({ file_path: '/work/log.md' }))).toBe(true)
190
+ expect(matchesAllowRule('Edit(/work/log.md)', 'Edit', JSON.stringify({ file_path: '/work/other.md' }))).toBe(false)
153
191
  })
154
192
 
155
- it('uses the same field fallback chain as the resolver', () => {
156
- expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ skill_name: 'mail' }))).toBe(true)
157
- expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ skillName: 'mail' }))).toBe(true)
158
- expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ name: 'mail' }))).toBe(true)
159
- expect(matchesAllowRule(
160
- 'Skill(coolify)',
161
- 'Skill',
162
- JSON.stringify({ path: 'skills/coolify/SKILL.md' }),
163
- )).toBe(true)
193
+ it('matches a Bash prefix rule by first command token', () => {
194
+ expect(matchesAllowRule('Bash(npm:*)', 'Bash', JSON.stringify({ command: 'npm run build' }))).toBe(true)
195
+ expect(matchesAllowRule('Bash(npm:*)', 'Bash', JSON.stringify({ command: 'git status' }))).toBe(false)
164
196
  })
165
197
 
166
- it('does not match a different tool with the same arg', () => {
167
- expect(matchesAllowRule('Skill(mail)', 'Bash', JSON.stringify({ skill: 'mail' }))).toBe(false)
198
+ it('matches a Skill rule only for that skill', () => {
199
+ expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ skill: 'mail' }))).toBe(true)
200
+ expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ skill: 'calendar' }))).toBe(false)
168
201
  })
169
202
 
170
- it('returns false on malformed Skill input', () => {
171
- expect(matchesAllowRule('Skill(mail)', 'Skill', undefined)).toBe(false)
172
- expect(matchesAllowRule('Skill(mail)', 'Skill', 'not-json')).toBe(false)
173
- expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ unrelated: 'x' }))).toBe(false)
203
+ it('does not match a different tool with the same arg', () => {
204
+ expect(matchesAllowRule('Skill(mail)', 'Bash', JSON.stringify({ skill: 'mail' }))).toBe(false)
174
205
  })
175
206
  })
176
207
 
177
- describe('matchesAllowRule — MCP tools', () => {
208
+ describe('matchesAllowRule — MCP', () => {
178
209
  it('matches the exact namespaced tool', () => {
179
- expect(matchesAllowRule(
180
- 'mcp__switchroom-telegram__reply',
181
- 'mcp__switchroom-telegram__reply',
182
- undefined,
183
- )).toBe(true)
210
+ expect(matchesAllowRule('mcp__perplexity__search', 'mcp__perplexity__search', undefined)).toBe(true)
184
211
  })
185
212
 
186
- it('does not match a different MCP tool on the same server', () => {
187
- expect(matchesAllowRule(
188
- 'mcp__switchroom-telegram__reply',
189
- 'mcp__switchroom-telegram__stream_reply',
190
- undefined,
191
- )).toBe(false)
213
+ it('a server wildcard covers every tool on that server', () => {
214
+ expect(matchesAllowRule('mcp__perplexity__*', 'mcp__perplexity__search', undefined)).toBe(true)
215
+ expect(matchesAllowRule('mcp__perplexity__*', 'mcp__perplexity__ask', undefined)).toBe(true)
216
+ expect(matchesAllowRule('mcp__perplexity__*', 'mcp__other__search', undefined)).toBe(false)
217
+ })
218
+
219
+ it('an exact tool rule does not cover siblings on the same server', () => {
220
+ expect(matchesAllowRule('mcp__perplexity__search', 'mcp__perplexity__ask', undefined)).toBe(false)
192
221
  })
193
222
  })
194
223
 
@@ -198,3 +227,32 @@ describe('matchesAllowRule — defensive', () => {
198
227
  expect(matchesAllowRule('Edit', '', undefined)).toBe(false)
199
228
  })
200
229
  })
230
+
231
+ describe('resolve → match roundtrip', () => {
232
+ it.each([
233
+ ['Skill', JSON.stringify({ skill: 'mail' })],
234
+ ['Edit', JSON.stringify({ file_path: '/work/log.md' })],
235
+ ['Bash', JSON.stringify({ command: 'npm run build' })],
236
+ ['mcp__perplexity__search', undefined],
237
+ ] as const)('specific rule from %s matches the originating request', (tool, input) => {
238
+ const choices = resolveScopedAllowChoices(tool, input)
239
+ const rule = (choices?.specific ?? choices?.broad)!.rule
240
+ expect(matchesAllowRule(rule, tool, input)).toBe(true)
241
+ })
242
+
243
+ it.each([
244
+ ['Edit', JSON.stringify({ file_path: '/work/log.md' })],
245
+ ['Bash', JSON.stringify({ command: 'npm run build' })],
246
+ ['mcp__perplexity__search', undefined],
247
+ ] as const)('broad rule from %s also matches the originating request', (tool, input) => {
248
+ const broad = resolveScopedAllowChoices(tool, input)!.broad
249
+ expect(matchesAllowRule(broad.rule, tool, input)).toBe(true)
250
+ })
251
+ })
252
+
253
+ describe('isRulePersisted', () => {
254
+ it('is a membership check against the resolved allow list', () => {
255
+ expect(isRulePersisted(['Edit', 'mcp__perplexity__*'], 'mcp__perplexity__*')).toBe(true)
256
+ expect(isRulePersisted(['Edit'], 'Write')).toBe(false)
257
+ })
258
+ })