@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.
- package/README.md +11 -0
- package/connectors/hermes/adapter.py +202 -3
- package/connectors/hermes/securecoms_authz.py +190 -0
- package/connectors/hermes/tests/test_adapter_wiring.py +481 -3
- package/connectors/hermes/tests/test_securecoms_authz.py +235 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +19 -4
- package/connectors/openclaw/openclaw-entry.js +184 -3
- package/connectors/openclaw/openclaw-entry.status.test.ts +11 -1
- package/connectors/openclaw/openclaw-entry.toolgate.test.ts +466 -0
- package/dist/bin.js +88 -7
- package/dist/bridge-agent.d.ts +38 -4
- package/dist/harness-report.d.ts +14 -0
- package/dist/index.js +88 -7
- package/dist/service/runtime-config-dir.d.ts +22 -0
- package/dist/version.d.ts +1 -1
- package/dist/ws-server.d.ts +0 -77
- package/package.json +1 -1
|
@@ -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
|
|
32347
|
-
* (`channelOwner`, never "anomalous")
|
|
32348
|
-
*
|
|
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
|
-
|
|
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
|
}
|