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,194 +1,223 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for the always-allow rule resolver — pinned by the
|
|
3
|
-
*
|
|
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
|
|
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
|
-
* - `{
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
19
|
+
import {
|
|
20
|
+
resolveScopedAllowChoices,
|
|
21
|
+
matchesAllowRule,
|
|
22
|
+
isRulePersisted,
|
|
23
|
+
prettyMcpServer,
|
|
24
|
+
} from '../permission-rule.js'
|
|
16
25
|
|
|
17
|
-
describe('
|
|
18
|
-
it('
|
|
19
|
-
const
|
|
20
|
-
expect(
|
|
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('
|
|
24
|
-
|
|
25
|
-
|
|
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('
|
|
29
|
-
|
|
30
|
-
expect(
|
|
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('
|
|
34
|
-
|
|
35
|
-
expect(
|
|
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('
|
|
39
|
-
const
|
|
40
|
-
'Skill',
|
|
41
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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('
|
|
55
|
-
|
|
56
|
-
expect(
|
|
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('
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
expect(
|
|
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('
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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('
|
|
79
|
-
it
|
|
80
|
-
'Bash',
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
it('
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
JSON.stringify({ command: 'rm -rf /tmp/x' }),
|
|
94
|
-
)
|
|
95
|
-
expect(result).toEqual({ rule: 'Bash', label: 'Bash' })
|
|
101
|
+
describe('resolveScopedAllowChoices — Bash', () => {
|
|
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('
|
|
100
|
-
it('
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
describe('resolveScopedAllowChoices — broad-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('
|
|
106
|
-
expect(
|
|
107
|
-
|
|
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
|
|
111
|
-
expect(
|
|
112
|
-
expect(
|
|
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('
|
|
117
|
-
it('returns null for unknown tools', () => {
|
|
118
|
-
expect(
|
|
119
|
-
expect(
|
|
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
|
|
150
|
-
it('matches only
|
|
151
|
-
expect(matchesAllowRule('
|
|
152
|
-
expect(matchesAllowRule('
|
|
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('
|
|
156
|
-
expect(matchesAllowRule('
|
|
157
|
-
expect(matchesAllowRule('
|
|
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('
|
|
167
|
-
expect(matchesAllowRule('Skill(mail)', '
|
|
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('
|
|
171
|
-
expect(matchesAllowRule('Skill(mail)', '
|
|
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
|
|
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('
|
|
187
|
-
expect(matchesAllowRule(
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
})
|