switchroom 0.14.6 → 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.
- package/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +396 -358
- package/dist/host-control/main.js +148 -148
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/examples/switchroom.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +23 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +583 -284
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/config-approval-handler.ts +36 -0
- package/telegram-plugin/gateway/gateway.ts +296 -180
- package/telegram-plugin/gateway/hostd-dispatch.ts +2 -1
- package/telegram-plugin/permission-diff.ts +382 -0
- package/telegram-plugin/tests/always-allow-correlation.test.ts +147 -0
- package/telegram-plugin/tests/always-allow-grant.test.ts +84 -88
- package/telegram-plugin/tests/permission-diff.test.ts +336 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +25 -13
- package/telegram-plugin/tool-activity-summary.ts +27 -15
|
@@ -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
|
|
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
|
+
})
|