@securecoms/agent-bridge 0.26.3 → 0.26.5

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.
@@ -0,0 +1,466 @@
1
+ // packages/connect-agent-bridge/connectors/openclaw/openclaw-entry.toolgate.test.ts
2
+ //
3
+ // Tool gate (security fix 2026-10-05): only a turn the daemon granted tools
4
+ // (its authorization block's tool_grants is non-empty, which the daemon does
5
+ // ONLY for the agent's pinned owner in their DM or an owner-spoken relayed
6
+ // voice turn) may run OpenClaw tools. Every other sender (room member,
7
+ // other-org member, another agent, a frame with no or a malformed block) runs
8
+ // with every tool call BLOCKED by the connector's before_tool_call hook. If
9
+ // the OpenClaw host gives the plugin no hook API, such a turn is refused
10
+ // outright (acked, never dispatched). Fail closed.
11
+ //
12
+ // Drives the REAL register() + plugin.gateway.startAccount path with a fake
13
+ // OpenClaw runtime and api, like openclaw-entry.inbound.test.ts.
14
+
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
+ import { mkdtempSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { buildConnectionFile, writeConnectionFile } from "../../src/service/connection-file.js";
20
+
21
+ const BOUND_ID = "agent-identity";
22
+ const OWNER_ID = "owner-human";
23
+ const PORT = 7099;
24
+ const TOKEN_VALUE = "toolgate-test-token";
25
+ const ORIGINAL_CONN_FILE_ENV = process.env.SECURECOMS_CONNECTION_FILE;
26
+
27
+ const GRANTS = [{ tool: "current_time", mode: "read" }, { tool: "web_search", mode: "read" }];
28
+ const OWNER_AUTH = { version: 1, audience: "owner_dm", sender_membership: "active", tool_grants: GRANTS };
29
+ const noGrant = (audience: string) => ({ version: 1, audience, sender_membership: "active", tool_grants: [] });
30
+
31
+ const MISSING = Symbol("missing");
32
+ const NO_TOOL_SENDERS: Array<[string, unknown]> = [
33
+ ["room member", noGrant("team_room")],
34
+ ["other-org member", noGrant("partner_room")],
35
+ ["agent sender", noGrant("agent_to_agent")],
36
+ ["missing authorization", MISSING],
37
+ ];
38
+
39
+ function waitFor(predicate: () => boolean, timeout = 3000): Promise<void> {
40
+ const start = Date.now();
41
+ return new Promise((resolve, reject) => {
42
+ const check = () => {
43
+ if (predicate()) return resolve();
44
+ if (Date.now() - start > timeout) return reject(new Error("waitFor: condition never became true"));
45
+ setTimeout(check, 5);
46
+ };
47
+ check();
48
+ });
49
+ }
50
+
51
+ type FakeWs = { sent: Array<Record<string, unknown>>; _emit: (type: string, ev?: unknown) => void };
52
+
53
+ function makeFakeWebSocket() {
54
+ const instances: FakeWs[] = [];
55
+ class FakeWebSocket {
56
+ url: string;
57
+ sent: Array<Record<string, unknown>> = [];
58
+ closed = false;
59
+ listeners: Record<string, Array<(ev?: unknown) => void>> = {};
60
+ constructor(url: string) {
61
+ this.url = url;
62
+ instances.push(this);
63
+ }
64
+ addEventListener(type: string, cb: (ev?: unknown) => void) {
65
+ (this.listeners[type] ??= []).push(cb);
66
+ }
67
+ send(data: string) {
68
+ this.sent.push(JSON.parse(data));
69
+ }
70
+ close() {
71
+ if (this.closed) return;
72
+ this.closed = true;
73
+ this._emit("close");
74
+ }
75
+ _emit(type: string, ev?: unknown) {
76
+ for (const cb of this.listeners[type] ?? []) cb(ev ?? {});
77
+ }
78
+ }
79
+ return { FakeWebSocket, instances };
80
+ }
81
+
82
+ type HookFn = (event: Record<string, unknown>, ctx: Record<string, unknown>) => unknown;
83
+
84
+ /**
85
+ * A fake runtime whose dispatch can run "tool calls" through the hook the
86
+ * connector registered, with the context OpenClaw would build for this turn
87
+ * (the turn's SessionKey, and the requester when the host proves it).
88
+ */
89
+ function makeRuntime(getHook: () => HookFn | undefined) {
90
+ const contexts: Array<Record<string, any>> = [];
91
+ const toolResults: unknown[] = [];
92
+ let toolCallDuringDispatch: ((ctx: Record<string, any>) => Record<string, unknown>) | undefined;
93
+ const runtime = {
94
+ version: "2026.9.8",
95
+ channel: {
96
+ routing: { resolveAgentRoute: () => ({ agentId: "main", sessionKey: "agent:main:main" }) },
97
+ session: {
98
+ resolveStorePath: () => "/tmp/openclaw-store-unused",
99
+ readSessionUpdatedAt: () => undefined,
100
+ recordInboundSession: async () => {},
101
+ },
102
+ reply: {
103
+ resolveEnvelopeFormatOptions: () => ({}),
104
+ formatAgentEnvelope: (args: any) => `ENV:${args.body}`,
105
+ finalizeInboundContext: (ctx: any) => { contexts.push(ctx); return ctx; },
106
+ dispatchReplyWithBufferedBlockDispatcher: async ({ ctx }: any) => {
107
+ if (toolCallDuringDispatch) {
108
+ const hook = getHook();
109
+ toolResults.push(hook ? await hook({ toolName: "exec", params: {} }, toolCallDuringDispatch(ctx)) : "no-hook");
110
+ }
111
+ },
112
+ },
113
+ },
114
+ error: () => {},
115
+ };
116
+ return {
117
+ runtime,
118
+ contexts,
119
+ toolResults,
120
+ callToolDuringDispatch(fn: (ctx: Record<string, any>) => Record<string, unknown>) { toolCallDuringDispatch = fn; },
121
+ };
122
+ }
123
+
124
+ describe("openclaw-entry — tool gate (non-owner senders get no tools)", () => {
125
+ let home: string;
126
+ let controller: AbortController;
127
+ let mod: any;
128
+ let hook: HookFn | undefined;
129
+ let hookNames: string[];
130
+ let hookOpts: unknown[];
131
+ let sends: Array<Record<string, unknown>>;
132
+ let logs: string[];
133
+
134
+ beforeEach(() => {
135
+ home = mkdtempSync(join(tmpdir(), "sc-toolgate-"));
136
+ const tokenFile = join(home, "bridge-token");
137
+ writeFileSync(tokenFile, TOKEN_VALUE, { mode: 0o600 });
138
+ writeConnectionFile(home, buildConnectionFile({
139
+ identityId: BOUND_ID,
140
+ port: PORT,
141
+ tokenFile,
142
+ slug: "toolgate-test",
143
+ label: "Toolgate Test",
144
+ instanceId: "toolgate-test-instance",
145
+ now: new Date().toISOString(),
146
+ }));
147
+ process.env.SECURECOMS_CONNECTION_FILE = join(home, ".securecoms", "connections", `${BOUND_ID}.json`);
148
+ vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => {
149
+ const u = String(url);
150
+ if (u.endsWith("/healthz")) return { ok: true, json: async () => ({ identity_id: BOUND_ID, joined_channels: 1 }) };
151
+ if (u.includes("/v1/reply-receipt")) return { ok: true, status: 200, json: async () => ({ found: false }) };
152
+ if (u.endsWith("/v1/send")) {
153
+ sends.push(JSON.parse(String(init?.body)));
154
+ return { ok: true, json: async () => ({ message_id: "reply-1" }) };
155
+ }
156
+ if (u.endsWith("/v1/status")) return { ok: true, json: async () => ({ ok: true }) };
157
+ throw new Error(`unexpected fetch: ${u}`);
158
+ }));
159
+ controller = new AbortController();
160
+ hook = undefined;
161
+ hookNames = [];
162
+ hookOpts = [];
163
+ sends = [];
164
+ logs = [];
165
+ });
166
+
167
+ afterEach(async () => {
168
+ controller.abort();
169
+ vi.unstubAllGlobals();
170
+ vi.unmock("node:os");
171
+ if (ORIGINAL_CONN_FILE_ENV === undefined) delete process.env.SECURECOMS_CONNECTION_FILE;
172
+ else process.env.SECURECOMS_CONNECTION_FILE = ORIGINAL_CONN_FILE_ENV;
173
+ vi.resetModules();
174
+ });
175
+
176
+ async function start(
177
+ runtime: unknown,
178
+ opts: { withHookApi?: boolean; onThrows?: boolean; withTrustedPolicyApi?: boolean } = {},
179
+ ) {
180
+ const withHookApi = opts.withHookApi ?? true;
181
+ const withTrustedPolicyApi = opts.withTrustedPolicyApi ?? true;
182
+ vi.resetModules();
183
+ vi.doMock("node:os", async (importOriginal) => {
184
+ const actual = await importOriginal<typeof import("node:os")>();
185
+ return { ...actual, homedir: () => home };
186
+ });
187
+ mod = await import("./openclaw-entry.js");
188
+ let plugin: any;
189
+ const api: Record<string, unknown> = {
190
+ runtime,
191
+ registerChannel: ({ plugin: p }: { plugin: unknown }) => { plugin = p; },
192
+ };
193
+ if (withHookApi) {
194
+ api.on = (name: string, handler: HookFn, hookOptions?: unknown) => {
195
+ if (opts.onThrows) throw new Error("hooks unsupported");
196
+ hookNames.push(name);
197
+ hookOpts.push(hookOptions);
198
+ if (name === "before_tool_call") hook = handler;
199
+ };
200
+ }
201
+ // OpenClaw >= 2026.4.27 (#72287) exposes registerTrustedToolPolicy; its
202
+ // presence is the connector's proof that before_tool_call blocks.
203
+ if (withTrustedPolicyApi) api.registerTrustedToolPolicy = () => {};
204
+ mod.default.register(api);
205
+ const { FakeWebSocket, instances } = makeFakeWebSocket();
206
+ vi.stubGlobal("WebSocket", FakeWebSocket);
207
+ const account = plugin.config.resolveAccount({}, "default");
208
+ plugin.gateway.startAccount({ account, cfg: {}, log: (m: string) => logs.push(String(m)), abortSignal: controller.signal });
209
+ await waitFor(() => instances.length >= 1);
210
+ instances[0]._emit("open");
211
+ return instances[0];
212
+ }
213
+
214
+ function deliver(ws: FakeWs, frame: Record<string, unknown>) {
215
+ ws._emit("message", { data: JSON.stringify({ type: "message", ts: 1_700_000_000_000, ...frame }) });
216
+ }
217
+ function frame(messageId: string, channel: string, sender: string, authorization: unknown, extra: Record<string, unknown> = {}) {
218
+ return {
219
+ channel_id: channel, message_id: messageId, sender_id: sender, text: "run something",
220
+ ...(authorization === MISSING ? {} : { authorization }),
221
+ ...extra,
222
+ };
223
+ }
224
+ const acksOf = (ws: FakeWs) => ws.sent.filter((f) => f.type === "ack").map((f) => f.message_id);
225
+ const turnCtx = (ctx: Record<string, any>) => ({
226
+ sessionKey: ctx.SessionKey,
227
+ channelId: "securecoms",
228
+ requester: { channel: "securecoms", senderId: ctx.SenderId },
229
+ });
230
+
231
+ // ------------------------------------------------------------ pure ----
232
+
233
+ describe("toolsAuthorized", () => {
234
+ it("is true only for a v1 block from an active sender with at least one tool grant", async () => {
235
+ const m = await import("./openclaw-entry.js");
236
+ expect(m.toolsAuthorized(OWNER_AUTH)).toBe(true);
237
+ expect(m.toolsAuthorized({ ...noGrant("agent_to_agent"), tool_grants: GRANTS })).toBe(true); // owner-spoken relay turn
238
+ expect(m.toolsAuthorized(noGrant("owner_dm"))).toBe(false); // kill switch / unpinned owner / lying flag
239
+ expect(m.toolsAuthorized(noGrant("team_room"))).toBe(false);
240
+ expect(m.toolsAuthorized(noGrant("partner_room"))).toBe(false);
241
+ expect(m.toolsAuthorized(noGrant("agent_to_agent"))).toBe(false);
242
+ expect(m.toolsAuthorized(undefined)).toBe(false);
243
+ expect(m.toolsAuthorized(null)).toBe(false);
244
+ expect(m.toolsAuthorized("owner_dm")).toBe(false);
245
+ expect(m.toolsAuthorized({ ...OWNER_AUTH, version: 2 })).toBe(false);
246
+ expect(m.toolsAuthorized({ ...OWNER_AUTH, sender_membership: "unknown" })).toBe(false);
247
+ expect(m.toolsAuthorized({ ...OWNER_AUTH, tool_grants: "current_time" })).toBe(false);
248
+ expect(m.toolsAuthorized({ ...OWNER_AUTH, tool_grants: [null] })).toBe(false);
249
+ expect(m.toolsAuthorized({ ...OWNER_AUTH, tool_grants: [{ mode: "read" }] })).toBe(false);
250
+ expect(m.toolsAuthorized({ ...OWNER_AUTH, tool_grants: [{ tool: "" }] })).toBe(false);
251
+ });
252
+ });
253
+
254
+ // ------------------------------------------------------------- owner ----
255
+
256
+ it("owner DM: tools run (the hook does not block), CommandAuthorized stays true", async () => {
257
+ const rt = makeRuntime(() => hook);
258
+ rt.callToolDuringDispatch(turnCtx);
259
+ const ws = await start(rt.runtime);
260
+ expect(hookNames).toEqual(["before_tool_call"]);
261
+ deliver(ws, frame("m1", "dm", OWNER_ID, OWNER_AUTH));
262
+ await waitFor(() => acksOf(ws).includes("m1"));
263
+ expect(rt.contexts).toHaveLength(1);
264
+ expect(rt.contexts[0].CommandAuthorized).toBe(true);
265
+ expect(rt.contexts[0].GroupSystemPrompt).toBeUndefined();
266
+ expect(rt.toolResults).toEqual([undefined]);
267
+ });
268
+
269
+ // ---------------------------------------------------------- no tools ----
270
+
271
+ it.each(NO_TOOL_SENDERS)("%s: every tool call in the turn is blocked", async (_name, authorization) => {
272
+ const rt = makeRuntime(() => hook);
273
+ rt.callToolDuringDispatch(turnCtx);
274
+ const ws = await start(rt.runtime);
275
+ deliver(ws, frame("m1", "room", "sender", authorization));
276
+ await waitFor(() => acksOf(ws).includes("m1"));
277
+ expect(rt.contexts).toHaveLength(1);
278
+ expect(rt.contexts[0].CommandAuthorized).toBe(false);
279
+ expect(rt.contexts[0].GroupSystemPrompt).toBe(mod.NO_TOOLS_TURN_NOTE);
280
+ expect(rt.toolResults).toEqual([{ block: true, blockReason: mod.TOOL_VETO_REASON }]);
281
+ });
282
+
283
+ it.each(NO_TOOL_SENDERS)("%s: without a hook API the turn is refused and acked", async (_name, authorization) => {
284
+ const rt = makeRuntime(() => hook);
285
+ const ws = await start(rt.runtime, { withHookApi: false });
286
+ deliver(ws, frame("m1", "room", "sender", authorization));
287
+ await waitFor(() => acksOf(ws).includes("m1"));
288
+ expect(rt.contexts).toEqual([]);
289
+ });
290
+
291
+ it("a hook API that throws on registration also refuses non-owner turns", async () => {
292
+ const rt = makeRuntime(() => hook);
293
+ const ws = await start(rt.runtime, { onThrows: true });
294
+ deliver(ws, frame("m1", "room", "sender", noGrant("team_room")));
295
+ await waitFor(() => acksOf(ws).includes("m1"));
296
+ expect(rt.contexts).toEqual([]);
297
+ });
298
+
299
+ it("without a hook API the owner DM turn still runs", async () => {
300
+ const rt = makeRuntime(() => hook);
301
+ const ws = await start(rt.runtime, { withHookApi: false });
302
+ deliver(ws, frame("m1", "dm", OWNER_ID, OWNER_AUTH));
303
+ await waitFor(() => acksOf(ws).includes("m1"));
304
+ expect(rt.contexts).toHaveLength(1);
305
+ });
306
+
307
+ it("a voice turn without tools carries the voice guidance and the note", async () => {
308
+ const rt = makeRuntime(() => hook);
309
+ const ws = await start(rt.runtime);
310
+ deliver(ws, frame("v1", "room", "sender", noGrant("team_room"), { voice: true, guidance: "Be brief." }));
311
+ await waitFor(() => acksOf(ws).includes("v1"));
312
+ expect(rt.contexts[0].GroupSystemPrompt).toBe(`${mod.VOICE_TURN_PREFIX}Be brief.\n\n${mod.NO_TOOLS_TURN_NOTE}`);
313
+ });
314
+
315
+ it("a host that accepts api.on but has no registerTrustedToolPolicy is not trusted: non-owner turns are refused", async () => {
316
+ // H3: before 2026.4.27 a host can take the registration and still not
317
+ // block; the connector cannot tell, so it refuses instead.
318
+ const rt = makeRuntime(() => hook);
319
+ const ws = await start(rt.runtime, { withTrustedPolicyApi: false });
320
+ deliver(ws, frame("m1", "room", "sender", noGrant("team_room")));
321
+ await waitFor(() => acksOf(ws).includes("m1"));
322
+ expect(rt.contexts).toEqual([]);
323
+ });
324
+
325
+ it("registers the veto at a high priority", async () => {
326
+ const rt = makeRuntime(() => hook);
327
+ await start(rt.runtime);
328
+ expect(hookOpts).toEqual([{ priority: mod.TOOL_VETO_PRIORITY }]);
329
+ expect(mod.TOOL_VETO_PRIORITY).toBeGreaterThanOrEqual(1000);
330
+ });
331
+
332
+ // ---------------------------------------------------------- rollout ----
333
+
334
+ it("on connect, a hello tells the daemon the harness, its version and that the veto is active", async () => {
335
+ const rt = makeRuntime(() => hook);
336
+ const ws = await start(rt.runtime);
337
+ expect(ws.sent.filter((f) => f.type === "hello")).toEqual([
338
+ { type: "hello", harness: { name: "openclaw", version: "2026.9.8", tool_veto: "active" } },
339
+ ]);
340
+ });
341
+
342
+ it("the hello reports an inactive veto on a host without the hook API", async () => {
343
+ const rt = makeRuntime(() => hook);
344
+ const ws = await start(rt.runtime, { withHookApi: false });
345
+ expect(ws.sent.filter((f) => f.type === "hello")).toEqual([
346
+ { type: "hello", harness: { name: "openclaw", version: "2026.9.8", tool_veto: "inactive" } },
347
+ ]);
348
+ });
349
+
350
+ it("a refused turn gets one notice per channel, never to an agent", async () => {
351
+ const rt = makeRuntime(() => hook);
352
+ const ws = await start(rt.runtime, { withHookApi: false });
353
+ deliver(ws, frame("m1", "room", "sender", noGrant("team_room")));
354
+ deliver(ws, frame("m2", "room", "sender", noGrant("team_room")));
355
+ deliver(ws, frame("m3", "room-2", "other", noGrant("partner_room")));
356
+ deliver(ws, frame("m4", "room-3", "agent-b", noGrant("agent_to_agent")));
357
+ await waitFor(() => ["m1", "m2", "m3", "m4"].every((id) => acksOf(ws).includes(id)));
358
+ expect(sends.map((b) => [b.channel_id, b.text])).toEqual([
359
+ ["room", mod.REFUSED_TURN_NOTICE],
360
+ ["room-2", mod.REFUSED_TURN_NOTICE],
361
+ ]);
362
+ expect(mod.REFUSED_TURN_NOTICE).toBe("I can't respond in this room until my owner updates me.");
363
+ });
364
+
365
+ it("with an active veto no notice is sent: the turn runs without tools", async () => {
366
+ const rt = makeRuntime(() => hook);
367
+ const ws = await start(rt.runtime);
368
+ deliver(ws, frame("m1", "room", "sender", noGrant("team_room")));
369
+ await waitFor(() => acksOf(ws).includes("m1"));
370
+ expect(sends).toEqual([]);
371
+ expect(rt.contexts).toHaveLength(1);
372
+ });
373
+
374
+ it("frames without an authorization block are warned about loudly", async () => {
375
+ const rt = makeRuntime(() => hook);
376
+ const ws = await start(rt.runtime);
377
+ deliver(ws, frame("m1", "c", "s", MISSING));
378
+ deliver(ws, frame("m2", "c", "s", OWNER_AUTH));
379
+ await waitFor(() => acksOf(ws).includes("m1") && acksOf(ws).includes("m2"));
380
+ expect(logs.filter((l) => l.includes("NO authorization block"))).toHaveLength(1);
381
+ });
382
+
383
+ // ------------------------------------------------------------- hook ----
384
+
385
+ describe("the before_tool_call decision", () => {
386
+ it("blocks a SecureComs session it never saw, and one with no session key", async () => {
387
+ const rt = makeRuntime(() => hook);
388
+ await start(rt.runtime);
389
+ expect(await hook!({ toolName: "exec" }, { sessionKey: "agent:main:securecoms:direct:never" }))
390
+ .toEqual({ block: true, blockReason: mod.TOOL_VETO_REASON });
391
+ expect(await hook!({ toolName: "exec" }, { channelId: "securecoms" }))
392
+ .toEqual({ block: true, blockReason: mod.TOOL_VETO_REASON });
393
+ expect(await hook!({ toolName: "exec" }, { requester: { channel: "securecoms" } }))
394
+ .toEqual({ block: true, blockReason: mod.TOOL_VETO_REASON });
395
+ });
396
+
397
+ it("leaves other channels' sessions alone", async () => {
398
+ const rt = makeRuntime(() => hook);
399
+ await start(rt.runtime);
400
+ expect(await hook!({ toolName: "exec" }, { sessionKey: "agent:main:telegram:direct:42", channelId: "telegram" }))
401
+ .toBeUndefined();
402
+ expect(await hook!({ toolName: "exec" }, {})).toBeUndefined();
403
+ });
404
+
405
+ it("a requester who is not the granted sender is blocked inside the owner's session", async () => {
406
+ const rt = makeRuntime(() => hook);
407
+ const ws = await start(rt.runtime);
408
+ deliver(ws, frame("m1", "dm", OWNER_ID, OWNER_AUTH));
409
+ await waitFor(() => acksOf(ws).includes("m1"));
410
+ const key = rt.contexts[0].SessionKey;
411
+ expect(await hook!({ toolName: "exec" }, { sessionKey: key, requester: { senderId: OWNER_ID } })).toBeUndefined();
412
+ expect(await hook!({ toolName: "exec" }, { sessionKey: key, requester: { senderId: `securecoms:${OWNER_ID}` } })).toBeUndefined();
413
+ expect(await hook!({ toolName: "exec" }, { sessionKey: key, requester: { senderId: "intruder" } }))
414
+ .toEqual({ block: true, blockReason: mod.TOOL_VETO_REASON });
415
+ // A requester the hook cannot read blocks rather than falling open.
416
+ const hostile = { toString() { throw new Error("unreadable"); } };
417
+ expect(await hook!({ toolName: "exec" }, { sessionKey: key, requester: { senderId: hostile } }))
418
+ .toEqual({ block: true, blockReason: mod.TOOL_VETO_REASON });
419
+ });
420
+
421
+ it("an ungranted turn in the owner's session blocks tools there while it is in flight, and after it the latest turn decides", async () => {
422
+ const rt = makeRuntime(() => hook);
423
+ const ws = await start(rt.runtime);
424
+ deliver(ws, frame("m1", "dm", OWNER_ID, OWNER_AUTH));
425
+ await waitFor(() => acksOf(ws).includes("m1"));
426
+ const key = rt.contexts[0].SessionKey;
427
+ // Same DM channel, same chat type ("direct"), but no grant: e.g. the
428
+ // owner_dm audience with the default grants switched off.
429
+ rt.callToolDuringDispatch(() => ({ sessionKey: key }));
430
+ deliver(ws, frame("m2", "dm", OWNER_ID, noGrant("owner_dm")));
431
+ await waitFor(() => acksOf(ws).includes("m2"));
432
+ expect(rt.contexts[1].SessionKey).toBe(key);
433
+ expect(rt.toolResults).toEqual([{ block: true, blockReason: mod.TOOL_VETO_REASON }]);
434
+ expect(await hook!({ toolName: "exec" }, { sessionKey: key })).toEqual({ block: true, blockReason: mod.TOOL_VETO_REASON });
435
+ deliver(ws, frame("m3", "dm", OWNER_ID, OWNER_AUTH));
436
+ await waitFor(() => acksOf(ws).includes("m3"));
437
+ expect(await hook!({ toolName: "exec" }, { sessionKey: key })).toBeUndefined();
438
+ });
439
+
440
+ it("blocks the owner's session while ANOTHER ungranted turn for it is still dispatching", async () => {
441
+ const rt = makeRuntime(() => hook);
442
+ const ws = await start(rt.runtime);
443
+ deliver(ws, frame("m1", "dm", OWNER_ID, OWNER_AUTH));
444
+ await waitFor(() => acksOf(ws).includes("m1"));
445
+ const key = rt.contexts[0].SessionKey;
446
+ // Hold an ungranted turn in dispatch, then let the owner's next turn
447
+ // land: the owner turn is now the latest, but the ungranted one is
448
+ // still running in the same session.
449
+ let release!: () => void;
450
+ const held = new Promise<void>((r) => { release = r; });
451
+ const orig = rt.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher;
452
+ rt.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher = async (args: any) => {
453
+ if (args.ctx.MessageSid === "slow") await held;
454
+ return orig(args);
455
+ };
456
+ deliver(ws, frame("slow", "dm", OWNER_ID, noGrant("owner_dm")));
457
+ await waitFor(() => rt.contexts.length === 2);
458
+ deliver(ws, frame("m3", "dm", OWNER_ID, OWNER_AUTH));
459
+ await waitFor(() => acksOf(ws).includes("m3"));
460
+ expect(await hook!({ toolName: "exec" }, { sessionKey: key })).toEqual({ block: true, blockReason: mod.TOOL_VETO_REASON });
461
+ release();
462
+ await waitFor(() => acksOf(ws).includes("slow"));
463
+ expect(await hook!({ toolName: "exec" }, { sessionKey: key })).toBeUndefined();
464
+ });
465
+ });
466
+ });