switchroom 0.14.7 → 0.14.8

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.
@@ -94,13 +94,14 @@ export function hostdWillBeUsed(agentName: string): boolean {
94
94
  export async function tryHostdDispatch(
95
95
  agentName: string,
96
96
  req: HostdRequest,
97
+ timeoutMs = 5000,
97
98
  ): Promise<HostdResponse | "not-configured"> {
98
99
  if (!isHostdEnabled()) return "not-configured";
99
100
  const sockPath = hostdSocketPath(agentName);
100
101
  if (!existsSync(sockPath)) return "not-configured";
101
102
  try {
102
103
  return await hostdRequest(
103
- { socketPath: sockPath, timeoutMs: 5000 },
104
+ { socketPath: sockPath, timeoutMs },
104
105
  req,
105
106
  );
106
107
  } catch (err) {
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Pure (no-I/O) helpers for the durable "🔁 Always allow" flow (#1977).
3
+ *
4
+ * The Telegram permission card's "Always allow" button used to shell
5
+ * `switchroom agent grant <agent> <rule>` which writes
6
+ * `/state/config/switchroom.yaml` — but that path is bind-mounted
7
+ * READ-ONLY into agent containers, so the write silently no-op'd.
8
+ * Durable host-config writes only land via the host-side `hostd`
9
+ * daemon's `config_propose_edit` flow, which takes a unified diff and
10
+ * runs it through `git apply --recount` against the live config.
11
+ *
12
+ * {@link synthesizeAllowRuleDiff} builds that diff by operating on the
13
+ * *literal* config text (never a parsed/merged object) so the diff's
14
+ * context lines byte-match the on-disk file — a requirement for
15
+ * `git apply` to succeed.
16
+ *
17
+ * {@link extractAddedAllowRule} is the inverse parse used by the
18
+ * single-tap auto-approve correlation: it returns the single
19
+ * `tools.allow` rule a diff adds, so the gateway can confirm that an
20
+ * inbound `request_config_approval` corresponds to a rule IT just
21
+ * queued (and so auto-approve without a second card), while any
22
+ * uncorrelated / forged edit still posts a real operator card.
23
+ *
24
+ * No file I/O, no yaml parsing of structure — deliberately. Callers
25
+ * read the raw config bytes and feed them in.
26
+ */
27
+
28
+ const TARGET_HEADER_A = "--- a/switchroom.yaml";
29
+ const TARGET_HEADER_B = "+++ b/switchroom.yaml";
30
+
31
+ export interface SynthesizeAllowRuleDiffArgs {
32
+ /** Agent whose `tools.allow` gains the rule. */
33
+ agentName: string;
34
+ /** The rule token (e.g. `Bash`, `Skill(mail)`, `mcp__x__y`). */
35
+ rule: string;
36
+ /** RAW config file bytes (LF). NOT a parsed/merged object. */
37
+ configText: string;
38
+ }
39
+
40
+ /** Internal: a 0-based line index range describing a located block. */
41
+ interface AgentBlock {
42
+ /** Indentation (spaces) of the `<agentName>:` key line. */
43
+ agentIndent: number;
44
+ /** 0-based index of the `<agentName>:` line. */
45
+ agentLineIdx: number;
46
+ /** 0-based index just past the agent block (exclusive). */
47
+ agentBlockEnd: number;
48
+ }
49
+
50
+ /** Count leading spaces of a line. */
51
+ function indentOf(line: string): number {
52
+ let n = 0;
53
+ while (n < line.length && line[n] === " ") n++;
54
+ return n;
55
+ }
56
+
57
+ /** True if the line is blank or a comment (ignored for block scans). */
58
+ function isBlankOrComment(line: string): boolean {
59
+ const t = line.trim();
60
+ return t.length === 0 || t.startsWith("#");
61
+ }
62
+
63
+ /**
64
+ * Locate the `agents:` → `<agentName>:` block in raw config text.
65
+ * Returns the agent key line index, its indentation, and the
66
+ * exclusive end of its block (first subsequent non-blank line at an
67
+ * indent ≤ the agent key's indent). Returns null if not found.
68
+ */
69
+ function locateAgentBlock(
70
+ lines: string[],
71
+ agentName: string,
72
+ ): AgentBlock | null {
73
+ // Find a top-level `agents:` key (indent 0 by convention, but be
74
+ // lenient: any line whose trimmed form is exactly `agents:`).
75
+ let agentsIdx = -1;
76
+ let agentsIndent = 0;
77
+ for (let i = 0; i < lines.length; i++) {
78
+ const line = lines[i]!;
79
+ if (isBlankOrComment(line)) continue;
80
+ if (line.trim() === "agents:") {
81
+ agentsIdx = i;
82
+ agentsIndent = indentOf(line);
83
+ break;
84
+ }
85
+ }
86
+ if (agentsIdx === -1) return null;
87
+
88
+ // The agent keys are the first indent level deeper than `agents:`.
89
+ // Scan within the agents block for `<agentName>:`.
90
+ let childIndent = -1;
91
+ for (let i = agentsIdx + 1; i < lines.length; i++) {
92
+ const line = lines[i]!;
93
+ if (isBlankOrComment(line)) continue;
94
+ const ind = indentOf(line);
95
+ if (ind <= agentsIndent) break; // left the agents block
96
+ if (childIndent === -1) childIndent = ind;
97
+ if (ind !== childIndent) continue; // nested deeper — not an agent key
98
+ const key = line.slice(ind).split(":")[0]!.trim();
99
+ if (key === agentName) {
100
+ // Found it. Now find the block end.
101
+ let end = lines.length;
102
+ for (let j = i + 1; j < lines.length; j++) {
103
+ const l2 = lines[j]!;
104
+ if (isBlankOrComment(l2)) continue;
105
+ if (indentOf(l2) <= ind) {
106
+ end = j;
107
+ break;
108
+ }
109
+ }
110
+ return { agentIndent: ind, agentLineIdx: i, agentBlockEnd: end };
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /** Locate the `tools:` line within an agent block (or null). */
117
+ function locateToolsLine(
118
+ lines: string[],
119
+ block: AgentBlock,
120
+ ): { idx: number; indent: number } | null {
121
+ for (let i = block.agentLineIdx + 1; i < block.agentBlockEnd; i++) {
122
+ const line = lines[i]!;
123
+ if (isBlankOrComment(line)) continue;
124
+ const ind = indentOf(line);
125
+ if (ind <= block.agentIndent) break;
126
+ const key = line.slice(ind).split(":")[0]!.trim();
127
+ if (key === "tools" && ind === block.agentIndent + 2) {
128
+ return { idx: i, indent: ind };
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Locate the `allow:` line within a `tools:` mapping. Returns its
136
+ * index, indent, and whether it is a flow list (`allow: [..]`) or a
137
+ * block sequence (`allow:` then `- x` lines).
138
+ */
139
+ function locateAllowLine(
140
+ lines: string[],
141
+ toolsIdx: number,
142
+ toolsIndent: number,
143
+ blockEnd: number,
144
+ ): { idx: number; indent: number; inline: string } | null {
145
+ for (let i = toolsIdx + 1; i < blockEnd; i++) {
146
+ const line = lines[i]!;
147
+ if (isBlankOrComment(line)) continue;
148
+ const ind = indentOf(line);
149
+ if (ind <= toolsIndent) break; // left the tools mapping
150
+ const key = line.slice(ind).split(":")[0]!.trim();
151
+ if (key === "allow" && ind === toolsIndent + 2) {
152
+ const after = line.slice(line.indexOf(":") + 1);
153
+ return { idx: i, indent: ind, inline: after };
154
+ }
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Build a unified-diff hunk from a contiguous slice of the original
161
+ * file. `lines` is the whole file split on `\n`. The hunk replaces the
162
+ * lines in `[changeStart, changeEnd)` with `replacement` (an array of
163
+ * raw lines, no leading `+`). `contextN` context lines are copied
164
+ * verbatim before and after. `@@` numbers are best-effort (hostd runs
165
+ * `git apply --recount`), but context lines byte-match the source.
166
+ */
167
+ function buildHunk(
168
+ lines: string[],
169
+ changeStart: number,
170
+ changeEnd: number,
171
+ removed: string[],
172
+ added: string[],
173
+ contextN = 3,
174
+ ): string {
175
+ const preStart = Math.max(0, changeStart - contextN);
176
+ let postEnd = Math.min(lines.length, changeEnd + contextN);
177
+ // A `configText` that ends in `\n` splits to a trailing "" element
178
+ // that is NOT a real file line — including it as a context line
179
+ // (` `) makes `git apply` reject the hunk ("patch does not apply").
180
+ // Drop it from the trailing context window.
181
+ if (postEnd === lines.length && lines.length > 0 && lines[lines.length - 1] === "") {
182
+ postEnd -= 1;
183
+ }
184
+ const pre = lines.slice(preStart, changeStart);
185
+ const post = lines.slice(changeEnd, Math.max(changeEnd, postEnd));
186
+
187
+ const oldCount = pre.length + removed.length + post.length;
188
+ const newCount = pre.length + added.length + post.length;
189
+ const oldStartLine = preStart + 1;
190
+ const newStartLine = preStart + 1;
191
+
192
+ const out: string[] = [];
193
+ out.push(`@@ -${oldStartLine},${oldCount} +${newStartLine},${newCount} @@`);
194
+ for (const l of pre) out.push(` ${l}`);
195
+ for (const l of removed) out.push(`-${l}`);
196
+ for (const l of added) out.push(`+${l}`);
197
+ for (const l of post) out.push(` ${l}`);
198
+ return out.join("\n");
199
+ }
200
+
201
+ function wrapDiff(hunk: string): string {
202
+ return `${TARGET_HEADER_A}\n${TARGET_HEADER_B}\n${hunk}\n`;
203
+ }
204
+
205
+ /**
206
+ * Synthesize a git-apply-compatible unified diff that adds `rule` to
207
+ * the target agent's `tools.allow` in raw `configText`. Returns null if
208
+ * the agent block can't be located.
209
+ *
210
+ * Three structural cases:
211
+ * (a) flow list on one line: `allow: [ all ]` / `allow: [X, Y]`
212
+ * → one-line replace appending `, <rule>` before the closing `]`.
213
+ * (b) block sequence: `allow:\n - Bash`
214
+ * → insert a new ` - <rule>` line after the last `- ` entry.
215
+ * (c) `tools.allow` absent: insert
216
+ * ` tools:\n allow:\n - <rule>` under the agent.
217
+ */
218
+ export function synthesizeAllowRuleDiff(
219
+ args: SynthesizeAllowRuleDiffArgs,
220
+ ): string | null {
221
+ const { agentName, rule, configText } = args;
222
+ if (!agentName || !rule || !configText) return null;
223
+ const lines = configText.split("\n");
224
+ const block = locateAgentBlock(lines, agentName);
225
+ if (!block) return null;
226
+
227
+ const tools = locateToolsLine(lines, block);
228
+
229
+ // Case (c): no tools: mapping at all → insert tools/allow/rule under
230
+ // the agent block, indented two deeper than the agent key.
231
+ if (!tools) {
232
+ const baseIndent = block.agentIndent + 2;
233
+ const insertAt = block.agentLineIdx + 1;
234
+ const added = [
235
+ `${" ".repeat(baseIndent)}tools:`,
236
+ `${" ".repeat(baseIndent + 2)}allow:`,
237
+ `${" ".repeat(baseIndent + 4)}- ${rule}`,
238
+ ];
239
+ const hunk = buildHunk(lines, insertAt, insertAt, [], added);
240
+ return wrapDiff(hunk);
241
+ }
242
+
243
+ const allow = locateAllowLine(
244
+ lines,
245
+ tools.idx,
246
+ tools.indent,
247
+ block.agentBlockEnd,
248
+ );
249
+
250
+ // tools: present but no allow: key → insert an allow block under it.
251
+ if (!allow) {
252
+ const baseIndent = tools.indent + 2;
253
+ const insertAt = tools.idx + 1;
254
+ const added = [
255
+ `${" ".repeat(baseIndent)}allow:`,
256
+ `${" ".repeat(baseIndent + 2)}- ${rule}`,
257
+ ];
258
+ const hunk = buildHunk(lines, insertAt, insertAt, [], added);
259
+ return wrapDiff(hunk);
260
+ }
261
+
262
+ const inlineTrimmed = allow.inline.trim();
263
+
264
+ // Case (a): flow list on one line.
265
+ if (inlineTrimmed.startsWith("[")) {
266
+ const original = lines[allow.idx]!;
267
+ const close = original.lastIndexOf("]");
268
+ if (close === -1) return null;
269
+ // Empty list `[]` / `[ ]` → first element, no leading comma.
270
+ const inner = original.slice(original.indexOf("[") + 1, close).trim();
271
+ const replacement =
272
+ inner.length === 0
273
+ ? `${original.slice(0, close)}${rule}${original.slice(close)}`
274
+ : `${original.slice(0, close).replace(/\s*$/, "")}, ${rule}${original.slice(close)}`;
275
+ const hunk = buildHunk(
276
+ lines,
277
+ allow.idx,
278
+ allow.idx + 1,
279
+ [original],
280
+ [replacement],
281
+ );
282
+ return wrapDiff(hunk);
283
+ }
284
+
285
+ // Case (b): block sequence. Find the last `- ` entry at indent
286
+ // allow.indent + 2 and insert a new entry after it.
287
+ const itemIndent = allow.indent + 2;
288
+ let lastItemIdx = -1;
289
+ for (let i = allow.idx + 1; i < block.agentBlockEnd; i++) {
290
+ const line = lines[i]!;
291
+ if (isBlankOrComment(line)) continue;
292
+ const ind = indentOf(line);
293
+ if (ind <= allow.indent) break; // left the allow sequence
294
+ const t = line.slice(ind);
295
+ if (ind === itemIndent && t.startsWith("- ")) {
296
+ lastItemIdx = i;
297
+ }
298
+ }
299
+ if (lastItemIdx === -1) {
300
+ // `allow:` with no entries (e.g. empty seq) — insert the first one.
301
+ const insertAt = allow.idx + 1;
302
+ const added = [`${" ".repeat(itemIndent)}- ${rule}`];
303
+ const hunk = buildHunk(lines, insertAt, insertAt, [], added);
304
+ return wrapDiff(hunk);
305
+ }
306
+ const insertAt = lastItemIdx + 1;
307
+ const added = [`${" ".repeat(itemIndent)}- ${rule}`];
308
+ const hunk = buildHunk(lines, insertAt, insertAt, [], added);
309
+ return wrapDiff(hunk);
310
+ }
311
+
312
+ /**
313
+ * Inverse of {@link synthesizeAllowRuleDiff} for the correlation check:
314
+ * return the single `tools.allow` rule token added by the diff, or null
315
+ * if the diff doesn't add exactly one such rule.
316
+ *
317
+ * Recognises both shapes the synthesizer emits:
318
+ * - a block-sequence add: `+ - <rule>`
319
+ * - a flow-list element appended on a replaced line: the `+` line
320
+ * differs from the `-` line by a single trailing `, <rule>` (or a
321
+ * first element inside an empty `[]`).
322
+ *
323
+ * Deliberately strict: returns null on multi-add / structural diffs so
324
+ * a forged edit touching arbitrary fields can never be mistaken for a
325
+ * queued always-allow rule.
326
+ */
327
+ export function extractAddedAllowRule(unifiedDiff: string): string | null {
328
+ if (!unifiedDiff) return null;
329
+ const lines = unifiedDiff.split("\n");
330
+ const plus: string[] = [];
331
+ const minus: string[] = [];
332
+ for (const l of lines) {
333
+ if (l.startsWith("+++") || l.startsWith("---")) continue;
334
+ if (l.startsWith("@@")) continue;
335
+ if (l.startsWith("+")) plus.push(l.slice(1));
336
+ else if (l.startsWith("-")) minus.push(l.slice(1));
337
+ }
338
+
339
+ // Block-sequence add: exactly one `+` line of the form ` - <rule>`
340
+ // and no `-` (removal) lines, OR (case-c/no-allow insert) several
341
+ // `+` lines where exactly one is a `- <rule>` item.
342
+ if (minus.length === 0) {
343
+ const items = plus
344
+ .map((p) => {
345
+ const ind = indentOf(p);
346
+ const t = p.slice(ind);
347
+ return t.startsWith("- ") ? t.slice(2).trim() : null;
348
+ })
349
+ .filter((x): x is string => x !== null);
350
+ if (items.length === 1) return items[0]!;
351
+ return null;
352
+ }
353
+
354
+ // Flow-list append: one `-` line replaced by one `+` line that adds a
355
+ // trailing element. Diff the two for the new `]`-interior token(s).
356
+ if (minus.length === 1 && plus.length === 1) {
357
+ const before = extractFlowItems(minus[0]!);
358
+ const after = extractFlowItems(plus[0]!);
359
+ if (before === null || after === null) return null;
360
+ if (after.length !== before.length + 1) return null;
361
+ // The new element is the one in `after` not in `before` (by
362
+ // position — synthesizer appends, so it's the last).
363
+ const added = after[after.length - 1]!;
364
+ // Confirm prefix matches (defense against reordering forgery).
365
+ for (let i = 0; i < before.length; i++) {
366
+ if (before[i] !== after[i]) return null;
367
+ }
368
+ return added;
369
+ }
370
+
371
+ return null;
372
+ }
373
+
374
+ /** Parse the bracketed flow-list items from a `allow: [ ... ]` line. */
375
+ function extractFlowItems(line: string): string[] | null {
376
+ const open = line.indexOf("[");
377
+ const close = line.lastIndexOf("]");
378
+ if (open === -1 || close === -1 || close < open) return null;
379
+ const inner = line.slice(open + 1, close).trim();
380
+ if (inner.length === 0) return [];
381
+ return inner.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
382
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Single-tap correlation auto-resolve for the durable "🔁 Always allow"
3
+ * flow (#1977, security-critical).
4
+ *
5
+ * When the gateway dispatches a `config_propose_edit` to hostd in
6
+ * response to an operator tap, hostd calls back asking for operator
7
+ * approval. The `tryAutoResolve` hook lets the gateway auto-approve
8
+ * THAT (already-operator-tapped) edit WITHOUT posting a second card,
9
+ * while any uncorrelated (e.g. agent-forged) config edit still posts a
10
+ * real operator approval card.
11
+ *
12
+ * These tests pin the handler contract:
13
+ * - tryAutoResolve → 'approve': sends config_approval_resolved
14
+ * (verdict: approve), does NOT post a card, creates NO pending
15
+ * entry, arms NO timer.
16
+ * - tryAutoResolve → null (forge / normal path): posts the card AND
17
+ * creates a pending entry (the real human-in-the-loop control).
18
+ */
19
+
20
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
21
+ import {
22
+ handleRequestConfigApproval,
23
+ _resetPendingConfigApprovalsForTest,
24
+ _peekPendingConfigApprovalForTest,
25
+ type ConfigApprovalHandlerDeps,
26
+ } from '../gateway/config-approval-handler.js'
27
+ import type { RequestConfigApprovalMessage } from '../gateway/ipc-protocol.js'
28
+
29
+ function makeMsg(overrides: Partial<RequestConfigApprovalMessage> = {}): RequestConfigApprovalMessage {
30
+ return {
31
+ type: 'request_config_approval',
32
+ requestId: 'req-abc123',
33
+ agentName: 'clerk',
34
+ reason: "Operator 'always allow' for Bash",
35
+ unifiedDiff: [
36
+ '--- a/switchroom.yaml',
37
+ '+++ b/switchroom.yaml',
38
+ '@@ -1,3 +1,4 @@',
39
+ ' agents:',
40
+ ' clerk:',
41
+ ' tools:',
42
+ '+ - Bash',
43
+ ].join('\n'),
44
+ timeoutMs: 600_000,
45
+ ...overrides,
46
+ }
47
+ }
48
+
49
+ interface SentMsg {
50
+ type: string
51
+ requestId?: string
52
+ verdict?: string
53
+ [k: string]: unknown
54
+ }
55
+
56
+ describe('always-allow correlation — auto-resolve path', () => {
57
+ beforeEach(() => {
58
+ _resetPendingConfigApprovalsForTest()
59
+ vi.useFakeTimers()
60
+ })
61
+ afterEach(() => {
62
+ vi.clearAllTimers()
63
+ vi.useRealTimers()
64
+ _resetPendingConfigApprovalsForTest()
65
+ })
66
+
67
+ it("tryAutoResolve → 'approve': sends approve verdict, NO card, NO pending entry, NO timer", async () => {
68
+ const sent: SentMsg[] = []
69
+ const postCard = vi.fn(async () => ({ messageId: 42 }))
70
+ const client = { send: (m: SentMsg) => sent.push(m) }
71
+ const msg = makeMsg()
72
+ const deps: ConfigApprovalHandlerDeps = {
73
+ agentName: 'clerk',
74
+ loadTargetChat: () => ({ chatId: 1001 }),
75
+ postCard,
76
+ buildKeyboard: () => ({ inline_keyboard: [] }),
77
+ editCard: async () => {},
78
+ log: () => {},
79
+ tryAutoResolve: () => 'approve',
80
+ }
81
+
82
+ await handleRequestConfigApproval(client, msg, deps)
83
+
84
+ // Verdict sent — approve.
85
+ expect(sent).toHaveLength(1)
86
+ expect(sent[0]).toMatchObject({
87
+ type: 'config_approval_resolved',
88
+ requestId: 'req-abc123',
89
+ verdict: 'approve',
90
+ })
91
+ // No card posted.
92
+ expect(postCard).not.toHaveBeenCalled()
93
+ // No pending entry created.
94
+ expect(_peekPendingConfigApprovalForTest('req-abc123')).toBeUndefined()
95
+ // No timer armed — advancing past the timeout fires nothing extra.
96
+ vi.advanceTimersByTime(700_000)
97
+ expect(sent).toHaveLength(1)
98
+ })
99
+
100
+ it('tryAutoResolve → null (forge / normal path): posts card AND creates a pending entry', async () => {
101
+ const sent: SentMsg[] = []
102
+ const postCard = vi.fn(async () => ({ messageId: 77 }))
103
+ const client = { send: (m: SentMsg) => sent.push(m) }
104
+ const msg = makeMsg({ requestId: 'req-forge99' })
105
+ const deps: ConfigApprovalHandlerDeps = {
106
+ agentName: 'clerk',
107
+ loadTargetChat: () => ({ chatId: 1001 }),
108
+ postCard,
109
+ buildKeyboard: () => ({ inline_keyboard: [] }),
110
+ editCard: async () => {},
111
+ log: () => {},
112
+ tryAutoResolve: () => null,
113
+ }
114
+
115
+ await handleRequestConfigApproval(client, msg, deps)
116
+
117
+ // Card WAS posted.
118
+ expect(postCard).toHaveBeenCalledTimes(1)
119
+ // A pending entry exists, awaiting the operator tap.
120
+ const entry = _peekPendingConfigApprovalForTest('req-forge99')
121
+ expect(entry).toBeDefined()
122
+ expect(entry?.messageId).toBe(77)
123
+ expect(entry?.resolved).toBe(false)
124
+ // No verdict sent yet — the operator hasn't tapped.
125
+ expect(sent).toHaveLength(0)
126
+ })
127
+
128
+ it('no tryAutoResolve dep at all: behaves as the normal card path', async () => {
129
+ const sent: SentMsg[] = []
130
+ const postCard = vi.fn(async () => ({ messageId: 88 }))
131
+ const client = { send: (m: SentMsg) => sent.push(m) }
132
+ const msg = makeMsg({ requestId: 'req-nohook' })
133
+ const deps: ConfigApprovalHandlerDeps = {
134
+ agentName: 'clerk',
135
+ loadTargetChat: () => ({ chatId: 1001 }),
136
+ postCard,
137
+ buildKeyboard: () => ({ inline_keyboard: [] }),
138
+ editCard: async () => {},
139
+ log: () => {},
140
+ }
141
+
142
+ await handleRequestConfigApproval(client, msg, deps)
143
+
144
+ expect(postCard).toHaveBeenCalledTimes(1)
145
+ expect(_peekPendingConfigApprovalForTest('req-nohook')).toBeDefined()
146
+ })
147
+ })