@securecoms/agent-bridge 0.26.4 → 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
+ });
package/dist/bin.js CHANGED
@@ -28790,7 +28790,7 @@ function assertManagedName(name) {
28790
28790
  }
28791
28791
  function isLocalOnlyName(name) {
28792
28792
  const n = name.toLowerCase().replace(/[. ]+$/, "");
28793
- return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json";
28793
+ return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json" || n === "secrets";
28794
28794
  }
28795
28795
  function atomicWrite(path, body, deps) {
28796
28796
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
@@ -28849,6 +28849,26 @@ function lineHash(line) {
28849
28849
  return createHash2("sha256").update(line.trim(), "utf8").digest("hex");
28850
28850
  }
28851
28851
  var LOCAL_REPORT_FILE = "local-config.json";
28852
+ var SECRET_PATH_COUNT_MAX = 1e4;
28853
+ var isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 && v <= SECRET_PATH_COUNT_MAX;
28854
+ function parseSecretPathsReport(raw) {
28855
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
28856
+ const r = raw;
28857
+ const f = r.outside_by_field;
28858
+ if (r.mode !== "report" && r.mode !== "enforce" || !isCount(r.inside) || !isCount(r.allowlisted) || !isCount(r.outside) || !f || typeof f !== "object" || Array.isArray(f) || !isCount(f.headers_file) || !isCount(f.postgres_url_file) || !isCount(f.search_key_file) || f.sqlite !== void 0 && !isCount(f.sqlite)) return void 0;
28859
+ return {
28860
+ mode: r.mode,
28861
+ inside: r.inside,
28862
+ allowlisted: r.allowlisted,
28863
+ outside: r.outside,
28864
+ outside_by_field: {
28865
+ headers_file: f.headers_file,
28866
+ postgres_url_file: f.postgres_url_file,
28867
+ search_key_file: f.search_key_file,
28868
+ ...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {}
28869
+ }
28870
+ };
28871
+ }
28852
28872
  var LEGACY_MEMORY_SOURCE_MAX_BYTES = 4 * 1024 * 1024;
28853
28873
  var LEGACY_MEMORY_REPORT_MAX_BYTES = 4 * 1024 * 1024;
28854
28874
  var LEGACY_MEMORY_REPORT_MAX_SOURCES = 100;
@@ -28917,7 +28937,8 @@ function readLocalReport(home, identityId) {
28917
28937
  persona,
28918
28938
  has_search_key: hasSearchKey,
28919
28939
  has_llm_key: hasLlmKey,
28920
- legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source)
28940
+ legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source),
28941
+ secret_paths: parseSecretPathsReport(doc.secret_paths)
28921
28942
  };
28922
28943
  }
28923
28944
  function deleteLegacyMemorySources(home, identityId, ids) {
@@ -30115,7 +30136,11 @@ var BridgeAgent = class _BridgeAgent {
30115
30136
  // Inert legacy files only. `readLocalReport` revalidates the current
30116
30137
  // regular-file bytes and strips every local path before this leaves the
30117
30138
  // machine.
30118
- legacy_memory: local?.legacy_memory ?? []
30139
+ legacy_memory: local?.legacy_memory ?? [],
30140
+ // §B.4: counts of server-named secret paths inside / allowlisted /
30141
+ // outside the secrets directory, and the runtime's mode. Numbers and
30142
+ // one enum only; readLocalReport rebuilds it so no path rides along.
30143
+ secret_paths: local?.secret_paths ?? null
30119
30144
  };
30120
30145
  const posted = await this.fetchBounded("runtime_config_report", "control", `${base}/report`, {
30121
30146
  method: "POST",
@@ -32343,14 +32368,25 @@ var BridgeAgent = class _BridgeAgent {
32343
32368
  *
32344
32369
  * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
32345
32370
  * the kill switch is on, the channel is a DM, and EITHER
32346
- * (a) audience is owner_dm AND the sender is the pinned structural owner
32347
- * (`channelOwner`, never "anomalous") — a lying `is_agent_owner` flag
32348
- * alone gets nothing; OR
32371
+ * (a) audience is owner_dm AND the sender is the channel's pinned DM
32372
+ * counterpart (`channelOwner`, never "anomalous"). A server-marked
32373
+ * `is_agent_owner` on a member who is NOT the DM's single human
32374
+ * counterpart gets nothing; on the counterpart it is trusted (see
32375
+ * LIMIT below); OR
32349
32376
  * (b) the sender is this channel's server-marked relay (`is_relay`) and the
32350
32377
  * relayed `speaker_identity_id` (passed by the caller only for relay
32351
32378
  * turns) is that same pinned owner, whose roster row is the agent's
32352
32379
  * owner. A speaker claim from any non-relay sender gets nothing.
32353
32380
  * Everything else gets the frozen empty list.
32381
+ *
32382
+ * LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
32383
+ * is PER CHANNEL. resolveAndPinOwner pins the single human counterpart of
32384
+ * ANY DM, so in a DM with member Bob, Bob is that channel's pin. What
32385
+ * separates Bob from the real owner is only the server's `is_agent_owner`
32386
+ * flag (connect_channels.py: counterpart user == agent's creator). A
32387
+ * backend that sets that flag wrongly gives Bob the grants, and a harness
32388
+ * connector turns grants into its full toolset. Binding the owner identity
32389
+ * once, outside server control, is an open design follow-up.
32354
32390
  */
32355
32391
  authorizationFor(channelId, senderId, relaySpeakerId) {
32356
32392
  const meta = this.freshMeta(channelId);
@@ -33896,6 +33932,31 @@ var BridgeAgent = class _BridgeAgent {
33896
33932
  noteSessionEvicted() {
33897
33933
  this.evictions++;
33898
33934
  }
33935
+ /** Harness tool gate H4: latest connector self-report (see harness-report.ts). */
33936
+ harness = null;
33937
+ /**
33938
+ * Record a connector's harness report (from its /v1/ws hello). WARN once
33939
+ * each time the attached harness turns to an inactive veto: such a harness
33940
+ * refuses every turn without a tool grant, so its rooms go quiet.
33941
+ */
33942
+ recordHarnessReport(report) {
33943
+ const wasInactive = this.harness?.tool_veto === "inactive";
33944
+ this.harness = report;
33945
+ if (report.tool_veto === "inactive" && !wasInactive) {
33946
+ writeServiceLog("warn", "harness.tool_veto_inactive", {
33947
+ harness: ServiceLogEnum.of(report.name, ["hermes", "openclaw", "other"])
33948
+ });
33949
+ }
33950
+ }
33951
+ /** Forget `report` when the connector that sent it disconnects. A report
33952
+ * from a newer connector (a different object) is kept. */
33953
+ clearHarnessReport(report) {
33954
+ if (this.harness === report) this.harness = null;
33955
+ }
33956
+ /** The latest harness report, or null (GET /v1/status). */
33957
+ harnessReport() {
33958
+ return this.harness;
33959
+ }
33899
33960
  health() {
33900
33961
  let joined = 0;
33901
33962
  for (const s of this.joinState.values()) if (s === "JOINED") joined++;
@@ -33965,6 +34026,7 @@ var BridgeAgent = class _BridgeAgent {
33965
34026
  delivery_pings: this.pingsReceived,
33966
34027
  agent_sessions: this.authSessions,
33967
34028
  evictions: this.evictions,
34029
+ harness_tool_veto_active: this.harness === null ? null : this.harness.tool_veto === "active",
33968
34030
  uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
33969
34031
  version: BRIDGE_VERSION,
33970
34032
  ...this.memory.stats?.() ?? {}
@@ -34389,6 +34451,9 @@ function createHttpServer(agent, opts) {
34389
34451
  const r = await agent.vaultDelete(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
34390
34452
  return json(res, 200, r);
34391
34453
  }
34454
+ if (method === "GET" && path === "/v1/status") {
34455
+ return json(res, 200, { harness: agent.harnessReport() });
34456
+ }
34392
34457
  if (method === "POST" && path === "/v1/status") {
34393
34458
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
34394
34459
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
@@ -34478,6 +34543,17 @@ function createHttpServer(agent, opts) {
34478
34543
  return { server, closeAllSseStreams: () => closeSse() };
34479
34544
  }
34480
34545
 
34546
+ // src/harness-report.ts
34547
+ var VERSION_TOKEN = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,39}$/;
34548
+ function parseHarnessReport(raw) {
34549
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
34550
+ const r = raw;
34551
+ const name = r.name === "hermes" || r.name === "openclaw" ? r.name : "other";
34552
+ const version = typeof r.version === "string" && VERSION_TOKEN.test(r.version) ? r.version : null;
34553
+ const tool_veto = r.tool_veto === "active" || r.tool_veto === "inactive" ? r.tool_veto : "unknown";
34554
+ return Object.freeze({ name, version, tool_veto });
34555
+ }
34556
+
34481
34557
  // src/ws-server.ts
34482
34558
  import { WebSocketServer, WebSocket } from "ws";
34483
34559
  var PING_INTERVAL_MS = 3e4;
@@ -34608,6 +34684,7 @@ function attachAgentWebSocket(server, agent, opts) {
34608
34684
  }
34609
34685
  delivered.clear();
34610
34686
  if (current?.sock === sock) current = null;
34687
+ if (session.harness) agent.clearHarnessReport(session.harness);
34611
34688
  };
34612
34689
  current = { sock, cleanup, sweepStale };
34613
34690
  sock.on("close", cleanup);
@@ -34670,7 +34747,11 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
34670
34747
  return;
34671
34748
  }
34672
34749
  if (m.type === "hello") {
34673
- session.consumer = "runtime";
34750
+ const report = m.harness === void 0 ? null : parseHarnessReport(m.harness);
34751
+ if (report) {
34752
+ session.harness = report;
34753
+ agent.recordHarnessReport(report);
34754
+ } else session.consumer = "runtime";
34674
34755
  sendJson(sock, { type: "hello", features: ["nack"] });
34675
34756
  return;
34676
34757
  }