@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,235 @@
1
+ # packages/connect-agent-bridge/connectors/hermes/tests/test_securecoms_authz.py
2
+ #
3
+ # Per-turn tool authorization for the Hermes connector. The daemon attaches
4
+ # an `authorization` block to every inbound frame (BridgeAgent.authorizationFor,
5
+ # derived from server-authenticated roster data). Its `tool_grants` list is
6
+ # non-empty ONLY for the agent's pinned owner in their DM (or a relayed voice
7
+ # turn spoken by that owner). Everything else - room members, other-org
8
+ # members, agent senders, an older daemon that sends no block, a malformed
9
+ # block - must run with NO tools. Fail closed.
10
+
11
+ import securecoms_authz as authz
12
+
13
+ GRANTS = [{"tool": "current_time", "mode": "read"}, {"tool": "web_search", "mode": "read"}]
14
+
15
+
16
+ def auth(audience, *, membership="active", grants=None, version=1):
17
+ return {
18
+ "version": version,
19
+ "audience": audience,
20
+ "sender_membership": membership,
21
+ "tool_grants": list(GRANTS if grants is None else grants),
22
+ }
23
+
24
+
25
+ def frame(authorization=..., **kw):
26
+ f = {"type": "message", "channel_id": "c", "sender_id": "s", "text": "t", "message_id": "m"}
27
+ if authorization is not ...:
28
+ f["authorization"] = authorization
29
+ f.update(kw)
30
+ return f
31
+
32
+
33
+ class TestToolsAllowed:
34
+ def test_owner_dm_with_grants_is_allowed(self):
35
+ assert authz.tools_allowed(frame(auth("owner_dm"))) is True
36
+
37
+ def test_relayed_owner_voice_turn_with_grants_is_allowed(self):
38
+ # The daemon grants a relay turn only when the relayed speaker is the
39
+ # pinned owner; its audience is agent_to_agent (the relay is an agent).
40
+ assert authz.tools_allowed(frame(auth("agent_to_agent"))) is True
41
+
42
+ def test_room_member_without_grants_is_refused(self):
43
+ assert authz.tools_allowed(frame(auth("team_room", grants=[]))) is False
44
+
45
+ def test_other_org_member_without_grants_is_refused(self):
46
+ assert authz.tools_allowed(frame(auth("partner_room", grants=[]))) is False
47
+
48
+ def test_agent_sender_without_grants_is_refused(self):
49
+ assert authz.tools_allowed(frame(auth("agent_to_agent", grants=[]))) is False
50
+
51
+ def test_owner_dm_without_grants_is_refused(self):
52
+ # Kill switch off, an unpinned/anomalous owner, or a lying
53
+ # is_agent_owner flag: the audience alone never grants tools.
54
+ assert authz.tools_allowed(frame(auth("owner_dm", grants=[]))) is False
55
+
56
+ def test_missing_authorization_is_refused(self):
57
+ assert authz.tools_allowed(frame()) is False
58
+
59
+ def test_null_or_non_object_authorization_is_refused(self):
60
+ assert authz.tools_allowed(frame(None)) is False
61
+ assert authz.tools_allowed(frame("owner_dm")) is False
62
+ assert authz.tools_allowed(frame(["owner_dm"])) is False
63
+
64
+ def test_unknown_version_is_refused(self):
65
+ assert authz.tools_allowed(frame(auth("owner_dm", version=2))) is False
66
+ assert authz.tools_allowed(frame(auth("owner_dm", version="1"))) is False
67
+
68
+ def test_unknown_membership_is_refused(self):
69
+ assert authz.tools_allowed(frame(auth("owner_dm", membership="unknown"))) is False
70
+
71
+ def test_grants_that_are_not_a_list_of_tool_objects_are_refused(self):
72
+ assert authz.tools_allowed(frame({**auth("owner_dm"), "tool_grants": "current_time"})) is False
73
+ assert authz.tools_allowed(frame({**auth("owner_dm"), "tool_grants": [None]})) is False
74
+ assert authz.tools_allowed(frame({**auth("owner_dm"), "tool_grants": [{"mode": "read"}]})) is False
75
+ assert authz.tools_allowed(frame({**auth("owner_dm"), "tool_grants": [{"tool": ""}]})) is False
76
+
77
+ def test_non_dict_frame_is_refused(self):
78
+ assert authz.tools_allowed(None) is False
79
+ assert authz.tools_allowed("frame") is False
80
+
81
+
82
+ class TestSenderRole:
83
+ def test_roles_follow_the_daemon_audience(self):
84
+ assert authz.sender_role(frame(auth("owner_dm"))) == "owner"
85
+ assert authz.sender_role(frame(auth("team_room", grants=[]))) == "member"
86
+ assert authz.sender_role(frame(auth("other_human_dm", grants=[]))) == "member"
87
+ assert authz.sender_role(frame(auth("partner_room", grants=[]))) == "other_org"
88
+ assert authz.sender_role(frame(auth("agent_to_agent", grants=[]))) == "agent"
89
+
90
+ def test_missing_or_malformed_authorization_is_unknown(self):
91
+ assert authz.sender_role(frame()) == "unknown"
92
+ assert authz.sender_role(frame(auth("unknown", membership="unknown", grants=[]))) == "unknown"
93
+ assert authz.sender_role(frame(auth("owner_dm", version=2))) == "unknown"
94
+ assert authz.sender_role(frame(auth("owner_dm", membership="unknown"))) == "unknown"
95
+ assert authz.sender_role(frame(auth("some_future_audience"))) == "unknown"
96
+
97
+
98
+ class TestTurnLedger:
99
+ def test_unrecorded_turn_has_no_tools(self):
100
+ ledger = authz.TurnAuthzLedger()
101
+ assert ledger.tools_allowed("c", "u") is False
102
+
103
+ def test_latest_decision_for_the_chat_and_sender_wins(self):
104
+ ledger = authz.TurnAuthzLedger()
105
+ ledger.record("c", "owner", True)
106
+ assert ledger.tools_allowed("c", "owner") is True
107
+ ledger.record("c", "owner", False)
108
+ assert ledger.tools_allowed("c", "owner") is False
109
+
110
+ def test_a_grant_never_leaks_to_another_sender_or_chat(self):
111
+ ledger = authz.TurnAuthzLedger()
112
+ ledger.record("dm", "owner", True)
113
+ assert ledger.tools_allowed("dm", "intruder") is False
114
+ assert ledger.tools_allowed("room", "owner") is False
115
+
116
+ def test_known_chats_survive_decision_eviction(self):
117
+ ledger = authz.TurnAuthzLedger(max_entries=1)
118
+ ledger.record("c1", "u", False)
119
+ ledger.record("c2", "u", False)
120
+ assert ledger.knows_chat("c1") is True
121
+ assert ledger.knows_chat("c3") is False
122
+ assert ledger.knows_chat("") is False
123
+
124
+ def test_empty_keys_are_never_allowed(self):
125
+ ledger = authz.TurnAuthzLedger()
126
+ ledger.record("", "", True)
127
+ assert ledger.tools_allowed("", "") is False
128
+ assert ledger.tools_allowed(None, None) is False
129
+
130
+ def test_ledger_is_bounded_and_evicts_to_no_tools(self):
131
+ ledger = authz.TurnAuthzLedger(max_entries=2)
132
+ ledger.record("c1", "u", True)
133
+ ledger.record("c2", "u", True)
134
+ ledger.record("c3", "u", True)
135
+ assert ledger.tools_allowed("c1", "u") is False
136
+ assert ledger.tools_allowed("c3", "u") is True
137
+
138
+
139
+ class TestPreToolCallDecision:
140
+ def _env(self, **values):
141
+ return lambda name, default="": values.get(name, default)
142
+
143
+ def test_owner_turn_on_securecoms_is_not_blocked(self):
144
+ ledger = authz.TurnAuthzLedger()
145
+ ledger.record("dm", "owner", True)
146
+ env = self._env(HERMES_SESSION_PLATFORM="securecoms", HERMES_SESSION_CHAT_ID="dm", HERMES_SESSION_USER_ID="owner")
147
+ assert authz.pre_tool_call_decision(env, ledger) is None
148
+
149
+ def test_non_owner_turn_on_securecoms_is_blocked(self):
150
+ ledger = authz.TurnAuthzLedger()
151
+ ledger.record("room", "member", False)
152
+ env = self._env(HERMES_SESSION_PLATFORM="securecoms", HERMES_SESSION_CHAT_ID="room", HERMES_SESSION_USER_ID="member")
153
+ decision = authz.pre_tool_call_decision(env, ledger)
154
+ assert decision == {"action": "block", "message": authz.TOOL_VETO_MESSAGE}
155
+
156
+ def test_unrecorded_securecoms_turn_is_blocked(self):
157
+ env = self._env(HERMES_SESSION_PLATFORM="securecoms", HERMES_SESSION_CHAT_ID="dm", HERMES_SESSION_USER_ID="owner")
158
+ assert authz.pre_tool_call_decision(env, authz.TurnAuthzLedger())["action"] == "block"
159
+
160
+ def test_securecoms_turn_without_chat_or_user_is_blocked(self):
161
+ ledger = authz.TurnAuthzLedger()
162
+ ledger.record("dm", "owner", True)
163
+ env = self._env(HERMES_SESSION_PLATFORM="securecoms", HERMES_SESSION_CHAT_ID="dm")
164
+ assert authz.pre_tool_call_decision(env, ledger)["action"] == "block"
165
+
166
+ def test_a_known_securecoms_chat_is_gated_even_if_the_platform_label_differs(self):
167
+ # Defence in depth: should Hermes ever label the platform differently,
168
+ # a chat id this connector recorded is still a SecureComs turn.
169
+ ledger = authz.TurnAuthzLedger()
170
+ ledger.record("room-uuid", "member", False)
171
+ env = self._env(HERMES_SESSION_PLATFORM="SecureComs", HERMES_SESSION_CHAT_ID="room-uuid", HERMES_SESSION_USER_ID="member")
172
+ assert authz.pre_tool_call_decision(env, ledger)["action"] == "block"
173
+
174
+ def test_an_evicted_room_chat_with_a_mislabelled_platform_still_blocks(self):
175
+ # The decision ledger is bounded; the set of SecureComs chat ids is not.
176
+ ledger = authz.TurnAuthzLedger(max_entries=2)
177
+ ledger.record("room-uuid", "member", False)
178
+ ledger.record("c2", "u", True)
179
+ ledger.record("c3", "u", True)
180
+ env = self._env(HERMES_SESSION_PLATFORM="", HERMES_SESSION_CHAT_ID="room-uuid", HERMES_SESSION_USER_ID="member")
181
+ assert authz.pre_tool_call_decision(env, ledger)["action"] == "block"
182
+
183
+ def test_other_platforms_are_left_alone(self):
184
+ env = self._env(HERMES_SESSION_PLATFORM="telegram", HERMES_SESSION_CHAT_ID="x", HERMES_SESSION_USER_ID="y")
185
+ assert authz.pre_tool_call_decision(env, authz.TurnAuthzLedger()) is None
186
+
187
+ def test_an_empty_turn_identity_in_an_engaged_gateway_blocks(self):
188
+ # H1: a tool call that lost its task-local session reads "" for the
189
+ # platform, chat and user. It cannot be attributed, so it is refused.
190
+ decision = authz.pre_tool_call_decision(self._env(), authz.TurnAuthzLedger(), lambda: True)
191
+ assert decision == {"action": "block", "message": authz.TOOL_VETO_MESSAGE}
192
+
193
+ def test_the_session_engaged_reader_defaults_to_engaged(self):
194
+ assert authz.pre_tool_call_decision(self._env(), authz.TurnAuthzLedger())["action"] == "block"
195
+
196
+ def test_an_empty_turn_identity_outside_a_gateway_is_left_alone(self):
197
+ # A Hermes process that never bound a gateway session (the CLI) has no
198
+ # SecureComs turns at all.
199
+ assert authz.pre_tool_call_decision(self._env(), authz.TurnAuthzLedger(), lambda: False) is None
200
+
201
+ def test_a_positively_identified_cron_turn_is_left_alone(self):
202
+ # N2: cron is identified ONLY by the task-local ContextVar reader the
203
+ # adapter passes in (is_cron), never by the environment.
204
+ decide = authz.pre_tool_call_decision
205
+ assert decide(self._env(), authz.TurnAuthzLedger(), lambda: True, lambda: True) is None
206
+ assert decide(self._env(), authz.TurnAuthzLedger(), lambda: True, lambda: False)["action"] == "block"
207
+ assert decide(self._env(), authz.TurnAuthzLedger(), lambda: True, None)["action"] == "block"
208
+
209
+ def test_the_cron_environment_variable_alone_never_exempts(self):
210
+ env = self._env(HERMES_CRON_SESSION="1")
211
+ assert authz.pre_tool_call_decision(env, authz.TurnAuthzLedger(), lambda: True)["action"] == "block"
212
+ assert authz.pre_tool_call_decision(env, authz.TurnAuthzLedger(), lambda: True, lambda: False)["action"] == "block"
213
+
214
+ def test_an_is_cron_reader_that_raises_blocks(self):
215
+ def boom():
216
+ raise RuntimeError("no var")
217
+
218
+ assert authz.pre_tool_call_decision(self._env(), authz.TurnAuthzLedger(), lambda: True, boom)["action"] == "block"
219
+
220
+ def test_empty_platform_with_a_never_seen_chat_blocks_in_an_engaged_gateway(self):
221
+ env = self._env(HERMES_SESSION_CHAT_ID="never-seen", HERMES_SESSION_USER_ID="u")
222
+ assert authz.pre_tool_call_decision(env, authz.TurnAuthzLedger(), lambda: True)["action"] == "block"
223
+ assert authz.pre_tool_call_decision(env, authz.TurnAuthzLedger(), lambda: False) is None
224
+
225
+ def test_an_engaged_reader_that_raises_blocks(self):
226
+ def boom():
227
+ raise RuntimeError("unknown")
228
+
229
+ assert authz.pre_tool_call_decision(self._env(), authz.TurnAuthzLedger(), boom)["action"] == "block"
230
+
231
+ def test_an_env_reader_that_raises_blocks(self):
232
+ def boom(name, default=""):
233
+ raise RuntimeError("no session")
234
+
235
+ assert authz.pre_tool_call_decision(boom, authz.TurnAuthzLedger())["action"] == "block"
@@ -204,7 +204,17 @@ describe("openclaw-entry — inbound sender, session, authority, dedup (P0 Task
204
204
  });
205
205
  const mod = await import("./openclaw-entry.js");
206
206
  let plugin: any;
207
- mod.default.register({ runtime, registerChannel: ({ plugin: p }: { plugin: unknown }) => { plugin = p; } });
207
+ // A current OpenClaw host: its plugin api exposes `on` (typed hooks) and
208
+ // registerTrustedToolPolicy (shipped 2026.4.27, #72287; both declared in
209
+ // plugin-api.types.ts), so register() trusts the tool veto and turns
210
+ // without a tool grant are dispatched with tools blocked. Hosts without
211
+ // them (those turns refused) are openclaw-entry.toolgate.test.ts's.
212
+ mod.default.register({
213
+ runtime,
214
+ on: () => {},
215
+ registerTrustedToolPolicy: () => {},
216
+ registerChannel: ({ plugin: p }: { plugin: unknown }) => { plugin = p; },
217
+ });
208
218
  const { FakeWebSocket, instances } = makeFakeWebSocket();
209
219
  vi.stubGlobal("WebSocket", FakeWebSocket);
210
220
  const account = plugin.config.resolveAccount({}, "default");
@@ -259,9 +269,14 @@ describe("openclaw-entry — inbound sender, session, authority, dedup (P0 Task
259
269
  const rt = makeRuntime();
260
270
  const { instances } = await start(rt.runtime);
261
271
  const ws = instances[0];
262
-
263
- deliver(ws, { channel_id: "chan-V", message_id: "m-V", sender_id: OWNER_ID, text: "what's the weather?", voice: true, guidance: "One short spoken sentence.", authorization: auth("owner_dm") });
264
- deliver(ws, { channel_id: "chan-T", message_id: "m-T", sender_id: OWNER_ID, text: "hi", guidance: "ignored without voice", authorization: auth("owner_dm") });
272
+ // The pinned owner with the daemon's default read grants (the state
273
+ // authorizationFor produces unless the grants kill switch is off). A
274
+ // voice turn WITHOUT a grant also carries the no-tools note: see
275
+ // openclaw-entry.toolgate.test.ts.
276
+ const ownerGranted = { ...auth("owner_dm"), tool_grants: [{ tool: "current_time", mode: "read" }] };
277
+
278
+ deliver(ws, { channel_id: "chan-V", message_id: "m-V", sender_id: OWNER_ID, text: "what's the weather?", voice: true, guidance: "One short spoken sentence.", authorization: ownerGranted });
279
+ deliver(ws, { channel_id: "chan-T", message_id: "m-T", sender_id: OWNER_ID, text: "hi", guidance: "ignored without voice", authorization: ownerGranted });
265
280
  await waitFor(() => acksOf(ws).length === 2);
266
281
 
267
282
  const { VOICE_TURN_PREFIX } = await import("./openclaw-entry.js");
@@ -142,6 +142,135 @@ export function isOwnerAuthorization(authorization) {
142
142
  );
143
143
  }
144
144
 
145
+ // ---------------------------------------------------------------------------
146
+ // Tool gate (security fix 2026-10-05).
147
+ //
148
+ // OpenClaw runs every turn with the agent's configured tools, so without this
149
+ // a room member, a partner-org member or another agent reaches the same exec
150
+ // and file tools as the owner. The daemon's authorization block says which
151
+ // turn was granted tools: tool_grants is non-empty ONLY for the agent's pinned
152
+ // owner in their DM, or a relayed voice turn the owner spoke
153
+ // (BridgeAgent.authorizationFor). OpenClaw has no per-message "no tools"
154
+ // switch the channel can set, so the connector registers a before_tool_call
155
+ // hook and BLOCKS every tool call in a SecureComs session whose turn was not
156
+ // granted tools. If the host gives the plugin no hook API, a turn without a
157
+ // grant is refused outright (acked, never dispatched). Fail closed.
158
+ //
159
+ // What this cannot do: the model is still OFFERED the configured tools (each
160
+ // call is refused when made), and enforcement holds only for tool calls that
161
+ // OpenClaw routes through before_tool_call.
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /** blockReason handed to OpenClaw for a vetoed call (becomes the tool result). */
165
+ export const TOOL_VETO_REASON =
166
+ "Tool use is disabled for this message: SecureComs grants tools only to this agent's owner in their direct message. Answer in text only.";
167
+
168
+ /**
169
+ * Posted once per channel when a turn is refused because this host cannot
170
+ * veto tool calls: the WHOLE turn is refused, so the room is told rather than
171
+ * left with a silent agent.
172
+ */
173
+ export const REFUSED_TURN_NOTICE = "I can't respond in this room until my owner updates me.";
174
+
175
+ /** before_tool_call priority: run ahead of other plugins' handlers. */
176
+ export const TOOL_VETO_PRIORITY = 1000;
177
+
178
+ /** Extra system prompt for a turn without tools, so the model answers in text. */
179
+ export const NO_TOOLS_TURN_NOTE =
180
+ "Tools are disabled for this message because its sender is not this agent's owner in a direct message. Answer in text only and do not try to call any tool.";
181
+
182
+ /**
183
+ * True only when the daemon granted this turn at least one tool: a v1 block,
184
+ * an active sender, and a non-empty list of `{ tool: <name> }` grants. The
185
+ * audience alone never grants tools (an unpinned or anomalous owner, or the
186
+ * grants kill switch, arrives as owner_dm with no grants). The daemon grants
187
+ * a DM's single human counterpart whom the server marks `is_agent_owner`; the
188
+ * pin is per channel, so a backend that mis-sets that flag would grant a DM
189
+ * member tools (review H2: binding the owner outside server control is an
190
+ * open follow-up).
191
+ */
192
+ export function toolsAuthorized(authorization) {
193
+ if (authorization === null || typeof authorization !== "object") return false;
194
+ if (authorization.version !== 1 || authorization.sender_membership !== "active") return false;
195
+ const grants = authorization.tool_grants;
196
+ if (!Array.isArray(grants) || grants.length === 0) return false;
197
+ return grants.every((g) => g !== null && typeof g === "object" && typeof g.tool === "string" && g.tool !== "");
198
+ }
199
+
200
+ const TOOL_AUTHZ_MAX = 5000;
201
+ // Latest turn's decision per session key, and per (session key, sender).
202
+ const _sessionToolAuthz = new Map();
203
+ const _senderToolAuthz = new Map();
204
+ // Ungranted turns per session key whose dispatch has not returned.
205
+ const _ungrantedInFlight = new Map();
206
+ // Set by register(): true only on a host that proves before_tool_call blocks
207
+ // (see register) AND accepted the hook.
208
+ let _toolVetoActive = false;
209
+ // The OpenClaw host version (api.runtime.version), reported in the hello.
210
+ let _harnessVersion = null;
211
+ // Channels already told that this agent cannot respond (veto inactive).
212
+ const _refusalNoticeSent = new Set();
213
+ // Inbound frames seen without an authorization block (daemon too old).
214
+ let _framesWithoutAuthorization = 0;
215
+
216
+ /** The hello frame telling the daemon the harness and whether its veto is active. */
217
+ export function harnessHello() {
218
+ return {
219
+ type: "hello",
220
+ harness: { name: "openclaw", version: _harnessVersion, tool_veto: _toolVetoActive ? "active" : "inactive" },
221
+ };
222
+ }
223
+
224
+ function _boundedSet(map, key, value) {
225
+ map.delete(key);
226
+ map.set(key, value);
227
+ if (map.size > TOOL_AUTHZ_MAX) {
228
+ const oldest = map.keys().next();
229
+ if (!oldest.done) map.delete(oldest.value);
230
+ }
231
+ }
232
+
233
+ function _senderKey(sessionKey, senderId) {
234
+ return `${sessionKey}\u0000${senderId}`;
235
+ }
236
+
237
+ function _recordTurnToolAuthz(sessionKey, senderId, allowed) {
238
+ _boundedSet(_sessionToolAuthz, sessionKey, allowed === true);
239
+ _boundedSet(_senderToolAuthz, _senderKey(sessionKey, senderId), allowed === true);
240
+ }
241
+
242
+ const SECURECOMS_SESSION_MARK = ":securecoms:";
243
+
244
+ function _isSecureComsTool(ctx) {
245
+ if (!ctx || typeof ctx !== "object") return false;
246
+ if (typeof ctx.sessionKey === "string" && ctx.sessionKey.includes(SECURECOMS_SESSION_MARK)) return true;
247
+ if (ctx.channelId === "securecoms") return true;
248
+ return ctx.requester?.channel === "securecoms";
249
+ }
250
+
251
+ /**
252
+ * before_tool_call: no opinion on other channels; for a SecureComs session,
253
+ * allow only when (1) its latest turn was granted tools, (2) no ungranted
254
+ * turn for it is still dispatching, and (3) when OpenClaw names the requester,
255
+ * that sender's latest turn in this session was granted. Anything it cannot
256
+ * establish, including a missing session key, is blocked.
257
+ */
258
+ export function toolCallDecision(_event, ctx) {
259
+ try {
260
+ if (!_isSecureComsTool(ctx)) return undefined;
261
+ const key = ctx.sessionKey;
262
+ if (typeof key === "string" && key !== "" && _sessionToolAuthz.get(key) === true && !(_ungrantedInFlight.get(key) > 0)) {
263
+ const requester = ctx.requester?.senderId;
264
+ if (requester === undefined || requester === null) return undefined;
265
+ const sender = String(requester).replace(/^securecoms:/, "");
266
+ if (_senderToolAuthz.get(_senderKey(key, sender)) === true) return undefined;
267
+ }
268
+ } catch {
269
+ /* fall through: block */
270
+ }
271
+ return { block: true, blockReason: TOOL_VETO_REASON };
272
+ }
273
+
145
274
  /** "direct" only for a DM the daemon classified as one; everything else is a group. */
146
275
  export function chatTypeFor(authorization) {
147
276
  const audience = authorization && typeof authorization === "object" ? authorization.audience : undefined;
@@ -427,6 +556,9 @@ export class LoopbackChannel {
427
556
  // enough (see comment in _probeAndGate) — and not while turns are
428
557
  // failing: a close we forced for redelivery must still back off.
429
558
  if (this._failStreak === 0) this._attempt = 0;
559
+ // Report the harness and its tool-veto state (versions/booleans only);
560
+ // a daemon that predates the field ignores it.
561
+ try { ws.send(JSON.stringify(harnessHello())); } catch { /* best effort */ }
430
562
  });
431
563
 
432
564
  ws.addEventListener("message", (ev) => {
@@ -715,6 +847,27 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
715
847
  const senderName = typeof metadata.senderName === "string" && metadata.senderName !== "" ? metadata.senderName : senderId;
716
848
  const chatType = chatTypeFor(metadata.authorization);
717
849
  const commandAuthorized = isOwnerAuthorization(metadata.authorization);
850
+ if (metadata.authorization === null || typeof metadata.authorization !== "object") {
851
+ _framesWithoutAuthorization++;
852
+ if (_framesWithoutAuthorization % 100 === 1) {
853
+ channel.log(
854
+ "[SecureComs] WARNING: inbound frame carries NO authorization block; the daemon predates the tool gate, so every turn runs without tools. Upgrade @securecoms/agent-bridge.",
855
+ );
856
+ }
857
+ }
858
+ const toolsAllowed = toolsAuthorized(metadata.authorization);
859
+ if (!toolsAllowed && !_toolVetoActive) {
860
+ // This OpenClaw host gave the plugin no way to refuse tool calls, so a
861
+ // turn the daemon did not grant tools never reaches the agent. Acked.
862
+ channel._logSafe("[securecoms] turn refused reason={r}", { r: "no_tool_veto" });
863
+ // One notice per channel; never to another agent (two refusing agents
864
+ // would answer each other).
865
+ if (metadata.authorization?.audience !== "agent_to_agent" && !_refusalNoticeSent.has(conv) && _refusalNoticeSent.size < TOOL_AUTHZ_MAX) {
866
+ _refusalNoticeSent.add(conv);
867
+ try { await channel.send(conv, REFUSED_TURN_NOTICE, metadata.messageId); } catch { /* best effort */ }
868
+ }
869
+ return;
870
+ }
718
871
 
719
872
  // Route by the CHANNEL, so each SecureComs room/DM is its own conversation.
720
873
  const route = core.channel.routing.resolveAgentRoute({
@@ -728,6 +881,7 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
728
881
  // main session, which would merge two different humans' DMs (and the
729
882
  // owner's) into one history. One session per SecureComs channel, always.
730
883
  const sessionKey = `agent:${route.agentId}:securecoms:${chatType}:${conv}`;
884
+ _recordTurnToolAuthz(sessionKey, senderId, toolsAllowed);
731
885
 
732
886
  const storePath = core.channel.session.resolveStorePath(cfg?.session?.store, {
733
887
  agentId: route.agentId,
@@ -748,6 +902,11 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
748
902
  body: text,
749
903
  });
750
904
 
905
+ const extraPrompt = [
906
+ metadata.voiceGuidance ? `${VOICE_TURN_PREFIX}${metadata.voiceGuidance}` : undefined,
907
+ toolsAllowed ? undefined : NO_TOOLS_TURN_NOTE,
908
+ ].filter(Boolean).join("\n\n");
909
+
751
910
  const ctxPayload = core.channel.reply.finalizeInboundContext({
752
911
  Body: body,
753
912
  RawBody: text,
@@ -770,9 +929,10 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
770
929
  // authorization block (isOwnerAuthorization). MLS membership proves a
771
930
  // sender is in the room, not that they own this agent.
772
931
  CommandAuthorized: commandAuthorized,
773
- // Voice turn: OpenClaw folds GroupSystemPrompt into this turn's extra
774
- // system prompt (get-reply's extraSystemPromptParts) for any chat type.
775
- ...(metadata.voiceGuidance ? { GroupSystemPrompt: `${VOICE_TURN_PREFIX}${metadata.voiceGuidance}` } : {}),
932
+ // Voice turn and no-tools turn: OpenClaw folds GroupSystemPrompt into
933
+ // this turn's extra system prompt (get-reply's extraSystemPromptParts)
934
+ // for any chat type.
935
+ ...(extraPrompt ? { GroupSystemPrompt: extraPrompt } : {}),
776
936
  });
777
937
 
778
938
  await core.channel.session.recordInboundSession({
@@ -793,6 +953,7 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
793
953
  // "idle" waits for the last one to settle (success or failure) so the strip
794
954
  // does not clear before the reply is out. The turn itself does not wait for
795
955
  // that — ack/redelivery behavior is unchanged.
956
+ if (!toolsAllowed) _ungrantedInFlight.set(sessionKey, (_ungrantedInFlight.get(sessionKey) ?? 0) + 1);
796
957
  channel.reportStatus(conv, "working");
797
958
  const pendingDeliveries = new Set();
798
959
  let deliveredBlocks = 0;
@@ -825,6 +986,11 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
825
986
  } finally {
826
987
  // One line per turn, content-free: tells whether dispatch returns before
827
988
  // its reply blocks are delivered (delivered_blocks=0 with pending > 0).
989
+ if (!toolsAllowed) {
990
+ const n = (_ungrantedInFlight.get(sessionKey) ?? 1) - 1;
991
+ if (n > 0) _ungrantedInFlight.set(sessionKey, n);
992
+ else _ungrantedInFlight.delete(sessionKey);
993
+ }
828
994
  channel._logSafe("[securecoms] turn dispatch_ms={ms} delivered_blocks={k} pending_deliveries={p}", {
829
995
  ms: Date.now() - dispatchStarted,
830
996
  k: deliveredBlocks,
@@ -965,6 +1131,21 @@ export default {
965
1131
  description: "End-to-end encrypted messaging via the local SecureComs Agent Bridge daemon.",
966
1132
  register(api) {
967
1133
  _runtime = api.runtime;
1134
+ _harnessVersion = typeof api.runtime?.version === "string" ? api.runtime.version.slice(0, 40) : null;
1135
+ _toolVetoActive = false;
1136
+ // Trust the veto only on a host that proves before_tool_call blocks:
1137
+ // registerTrustedToolPolicy shipped in OpenClaw 2026.4.27 (#72287), after
1138
+ // block results became terminal and hook errors became blocks (2026-04-04,
1139
+ // #59822). An older host may accept api.on and ignore the registration,
1140
+ // so below that floor turns without a grant are refused instead.
1141
+ if (typeof api.on === "function" && typeof api.registerTrustedToolPolicy === "function") {
1142
+ try {
1143
+ api.on("before_tool_call", toolCallDecision, { priority: TOOL_VETO_PRIORITY });
1144
+ _toolVetoActive = true;
1145
+ } catch {
1146
+ _toolVetoActive = false;
1147
+ }
1148
+ }
968
1149
  api.registerChannel({ plugin: secureComsPlugin });
969
1150
  },
970
1151
  };
@@ -194,7 +194,17 @@ describe("openclaw-entry — per-channel status reporter (#910)", () => {
194
194
  async function startTurnHarness(runtime: unknown) {
195
195
  const mod = await loadModule(home);
196
196
  let plugin: any;
197
- mod.default.register({ runtime, registerChannel: ({ plugin: p }: { plugin: unknown }) => { plugin = p; } });
197
+ // A current OpenClaw host: its plugin api exposes `on` (typed hooks) and
198
+ // registerTrustedToolPolicy (shipped 2026.4.27, #72287; both declared in
199
+ // plugin-api.types.ts), so register() trusts the tool veto and turns
200
+ // without a tool grant are dispatched with tools blocked. Hosts without
201
+ // them (those turns refused) are openclaw-entry.toolgate.test.ts's.
202
+ mod.default.register({
203
+ runtime,
204
+ on: () => {},
205
+ registerTrustedToolPolicy: () => {},
206
+ registerChannel: ({ plugin: p }: { plugin: unknown }) => { plugin = p; },
207
+ });
198
208
  const { FakeWebSocket, instances } = makeFakeWebSocket();
199
209
  vi.stubGlobal("WebSocket", FakeWebSocket);
200
210
  const account = plugin.config.resolveAccount({}, "default");