@securecoms/agent-bridge 0.26.4 → 0.26.6

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,260 @@
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, a relayed voice turn
7
+ # spoken by that owner (marked `relay_speaker: "owner"` by the daemon), or
8
+ # (from PR R on) a same-org human in an internal room. This connector honours
9
+ # a grant ONLY on an `owner_dm` block or a block marked
10
+ # `relay_speaker: "owner"` (audit C-B1, r2 R2-T1): every other turn runs with
11
+ # NO tools. Fail closed.
12
+
13
+ import securecoms_authz as authz
14
+
15
+ GRANTS = [{"tool": "current_time", "mode": "read"}, {"tool": "web_search", "mode": "read"}]
16
+
17
+
18
+ def auth(audience, *, membership="active", grants=None, version=1):
19
+ return {
20
+ "version": version,
21
+ "audience": audience,
22
+ "sender_membership": membership,
23
+ "tool_grants": list(GRANTS if grants is None else grants),
24
+ }
25
+
26
+
27
+ def frame(authorization=..., **kw):
28
+ f = {"type": "message", "channel_id": "c", "sender_id": "s", "text": "t", "message_id": "m"}
29
+ if authorization is not ...:
30
+ f["authorization"] = authorization
31
+ f.update(kw)
32
+ return f
33
+
34
+
35
+ class TestToolsAllowed:
36
+ def test_owner_dm_with_grants_is_allowed(self):
37
+ assert authz.tools_allowed(frame(auth("owner_dm"))) is True
38
+
39
+ def test_relayed_owner_voice_turn_with_grants_is_allowed(self):
40
+ # The daemon grants a relay turn only when the relayed speaker is the
41
+ # pinned owner, and marks it relay_speaker="owner" (audit r2 R2-T1);
42
+ # its audience is agent_to_agent (the relay is an agent).
43
+ assert authz.tools_allowed(frame({**auth("agent_to_agent"), "relay_speaker": "owner"})) is True
44
+
45
+ def test_agent_to_agent_grants_without_the_owner_relay_mark_are_refused(self):
46
+ # Audit C-B1 / R2-T1: a grant is honoured only on owner_dm or on the
47
+ # bridge's relay_speaker="owner" mark, whatever the daemon sends.
48
+ assert authz.tools_allowed(frame(auth("agent_to_agent"))) is False
49
+
50
+ def test_a_non_owner_relay_mark_with_grants_is_refused(self):
51
+ # Review R2a fix 1: only the exact string "owner" is the mark. A
52
+ # truthiness check would accept every one of these.
53
+ for value in ("Owner", "OWNER", "guest", "", True, 1, ["owner"], {"v": "owner"}, "\u043ewner", "owner\u200b"):
54
+ block = {**auth("agent_to_agent"), "relay_speaker": value}
55
+ assert authz.tools_allowed(frame(block)) is False, repr(value)
56
+
57
+ def test_the_owner_relay_mark_without_grants_is_refused(self):
58
+ # Audit r2 R2-T1: the no-grants agent_to_agent case. The mark alone grants nothing.
59
+ assert authz.tools_allowed(frame({**auth("agent_to_agent", grants=[]), "relay_speaker": "owner"})) is False
60
+
61
+ def test_a_granted_block_outside_the_owner_dm_is_refused(self):
62
+ # Audit C-B1: a room grant means ONE named tool to the daemon, but a
63
+ # granted turn here keeps the owner's whole Hermes toolset.
64
+ for audience in ("team_room", "partner_room", "other_human_dm", "unknown"):
65
+ assert authz.tools_allowed(frame(auth(audience))) is False, audience
66
+
67
+ def test_room_member_without_grants_is_refused(self):
68
+ assert authz.tools_allowed(frame(auth("team_room", grants=[]))) is False
69
+
70
+ def test_other_org_member_without_grants_is_refused(self):
71
+ assert authz.tools_allowed(frame(auth("partner_room", grants=[]))) is False
72
+
73
+ def test_agent_sender_without_grants_is_refused(self):
74
+ assert authz.tools_allowed(frame(auth("agent_to_agent", grants=[]))) is False
75
+
76
+ def test_owner_dm_without_grants_is_refused(self):
77
+ # Kill switch off, an unpinned/anomalous owner, or a lying
78
+ # is_agent_owner flag: the audience alone never grants tools.
79
+ assert authz.tools_allowed(frame(auth("owner_dm", grants=[]))) is False
80
+
81
+ def test_missing_authorization_is_refused(self):
82
+ assert authz.tools_allowed(frame()) is False
83
+
84
+ def test_null_or_non_object_authorization_is_refused(self):
85
+ assert authz.tools_allowed(frame(None)) is False
86
+ assert authz.tools_allowed(frame("owner_dm")) is False
87
+ assert authz.tools_allowed(frame(["owner_dm"])) is False
88
+
89
+ def test_unknown_version_is_refused(self):
90
+ assert authz.tools_allowed(frame(auth("owner_dm", version=2))) is False
91
+ assert authz.tools_allowed(frame(auth("owner_dm", version="1"))) is False
92
+
93
+ def test_unknown_membership_is_refused(self):
94
+ assert authz.tools_allowed(frame(auth("owner_dm", membership="unknown"))) is False
95
+
96
+ def test_grants_that_are_not_a_list_of_tool_objects_are_refused(self):
97
+ assert authz.tools_allowed(frame({**auth("owner_dm"), "tool_grants": "current_time"})) is False
98
+ assert authz.tools_allowed(frame({**auth("owner_dm"), "tool_grants": [None]})) is False
99
+ assert authz.tools_allowed(frame({**auth("owner_dm"), "tool_grants": [{"mode": "read"}]})) is False
100
+ assert authz.tools_allowed(frame({**auth("owner_dm"), "tool_grants": [{"tool": ""}]})) is False
101
+
102
+ def test_non_dict_frame_is_refused(self):
103
+ assert authz.tools_allowed(None) is False
104
+ assert authz.tools_allowed("frame") is False
105
+
106
+
107
+ class TestSenderRole:
108
+ def test_roles_follow_the_daemon_audience(self):
109
+ assert authz.sender_role(frame(auth("owner_dm"))) == "owner"
110
+ assert authz.sender_role(frame(auth("team_room", grants=[]))) == "member"
111
+ assert authz.sender_role(frame(auth("other_human_dm", grants=[]))) == "member"
112
+ assert authz.sender_role(frame(auth("partner_room", grants=[]))) == "other_org"
113
+ assert authz.sender_role(frame(auth("agent_to_agent", grants=[]))) == "agent"
114
+
115
+ def test_missing_or_malformed_authorization_is_unknown(self):
116
+ assert authz.sender_role(frame()) == "unknown"
117
+ assert authz.sender_role(frame(auth("unknown", membership="unknown", grants=[]))) == "unknown"
118
+ assert authz.sender_role(frame(auth("owner_dm", version=2))) == "unknown"
119
+ assert authz.sender_role(frame(auth("owner_dm", membership="unknown"))) == "unknown"
120
+ assert authz.sender_role(frame(auth("some_future_audience"))) == "unknown"
121
+
122
+
123
+ class TestTurnLedger:
124
+ def test_unrecorded_turn_has_no_tools(self):
125
+ ledger = authz.TurnAuthzLedger()
126
+ assert ledger.tools_allowed("c", "u") is False
127
+
128
+ def test_latest_decision_for_the_chat_and_sender_wins(self):
129
+ ledger = authz.TurnAuthzLedger()
130
+ ledger.record("c", "owner", True)
131
+ assert ledger.tools_allowed("c", "owner") is True
132
+ ledger.record("c", "owner", False)
133
+ assert ledger.tools_allowed("c", "owner") is False
134
+
135
+ def test_a_grant_never_leaks_to_another_sender_or_chat(self):
136
+ ledger = authz.TurnAuthzLedger()
137
+ ledger.record("dm", "owner", True)
138
+ assert ledger.tools_allowed("dm", "intruder") is False
139
+ assert ledger.tools_allowed("room", "owner") is False
140
+
141
+ def test_known_chats_survive_decision_eviction(self):
142
+ ledger = authz.TurnAuthzLedger(max_entries=1)
143
+ ledger.record("c1", "u", False)
144
+ ledger.record("c2", "u", False)
145
+ assert ledger.knows_chat("c1") is True
146
+ assert ledger.knows_chat("c3") is False
147
+ assert ledger.knows_chat("") is False
148
+
149
+ def test_empty_keys_are_never_allowed(self):
150
+ ledger = authz.TurnAuthzLedger()
151
+ ledger.record("", "", True)
152
+ assert ledger.tools_allowed("", "") is False
153
+ assert ledger.tools_allowed(None, None) is False
154
+
155
+ def test_ledger_is_bounded_and_evicts_to_no_tools(self):
156
+ ledger = authz.TurnAuthzLedger(max_entries=2)
157
+ ledger.record("c1", "u", True)
158
+ ledger.record("c2", "u", True)
159
+ ledger.record("c3", "u", True)
160
+ assert ledger.tools_allowed("c1", "u") is False
161
+ assert ledger.tools_allowed("c3", "u") is True
162
+
163
+
164
+ class TestPreToolCallDecision:
165
+ def _env(self, **values):
166
+ return lambda name, default="": values.get(name, default)
167
+
168
+ def test_owner_turn_on_securecoms_is_not_blocked(self):
169
+ ledger = authz.TurnAuthzLedger()
170
+ ledger.record("dm", "owner", True)
171
+ env = self._env(HERMES_SESSION_PLATFORM="securecoms", HERMES_SESSION_CHAT_ID="dm", HERMES_SESSION_USER_ID="owner")
172
+ assert authz.pre_tool_call_decision(env, ledger) is None
173
+
174
+ def test_non_owner_turn_on_securecoms_is_blocked(self):
175
+ ledger = authz.TurnAuthzLedger()
176
+ ledger.record("room", "member", False)
177
+ env = self._env(HERMES_SESSION_PLATFORM="securecoms", HERMES_SESSION_CHAT_ID="room", HERMES_SESSION_USER_ID="member")
178
+ decision = authz.pre_tool_call_decision(env, ledger)
179
+ assert decision == {"action": "block", "message": authz.TOOL_VETO_MESSAGE}
180
+
181
+ def test_unrecorded_securecoms_turn_is_blocked(self):
182
+ env = self._env(HERMES_SESSION_PLATFORM="securecoms", HERMES_SESSION_CHAT_ID="dm", HERMES_SESSION_USER_ID="owner")
183
+ assert authz.pre_tool_call_decision(env, authz.TurnAuthzLedger())["action"] == "block"
184
+
185
+ def test_securecoms_turn_without_chat_or_user_is_blocked(self):
186
+ ledger = authz.TurnAuthzLedger()
187
+ ledger.record("dm", "owner", True)
188
+ env = self._env(HERMES_SESSION_PLATFORM="securecoms", HERMES_SESSION_CHAT_ID="dm")
189
+ assert authz.pre_tool_call_decision(env, ledger)["action"] == "block"
190
+
191
+ def test_a_known_securecoms_chat_is_gated_even_if_the_platform_label_differs(self):
192
+ # Defence in depth: should Hermes ever label the platform differently,
193
+ # a chat id this connector recorded is still a SecureComs turn.
194
+ ledger = authz.TurnAuthzLedger()
195
+ ledger.record("room-uuid", "member", False)
196
+ env = self._env(HERMES_SESSION_PLATFORM="SecureComs", HERMES_SESSION_CHAT_ID="room-uuid", HERMES_SESSION_USER_ID="member")
197
+ assert authz.pre_tool_call_decision(env, ledger)["action"] == "block"
198
+
199
+ def test_an_evicted_room_chat_with_a_mislabelled_platform_still_blocks(self):
200
+ # The decision ledger is bounded; the set of SecureComs chat ids is not.
201
+ ledger = authz.TurnAuthzLedger(max_entries=2)
202
+ ledger.record("room-uuid", "member", False)
203
+ ledger.record("c2", "u", True)
204
+ ledger.record("c3", "u", True)
205
+ env = self._env(HERMES_SESSION_PLATFORM="", HERMES_SESSION_CHAT_ID="room-uuid", HERMES_SESSION_USER_ID="member")
206
+ assert authz.pre_tool_call_decision(env, ledger)["action"] == "block"
207
+
208
+ def test_other_platforms_are_left_alone(self):
209
+ env = self._env(HERMES_SESSION_PLATFORM="telegram", HERMES_SESSION_CHAT_ID="x", HERMES_SESSION_USER_ID="y")
210
+ assert authz.pre_tool_call_decision(env, authz.TurnAuthzLedger()) is None
211
+
212
+ def test_an_empty_turn_identity_in_an_engaged_gateway_blocks(self):
213
+ # H1: a tool call that lost its task-local session reads "" for the
214
+ # platform, chat and user. It cannot be attributed, so it is refused.
215
+ decision = authz.pre_tool_call_decision(self._env(), authz.TurnAuthzLedger(), lambda: True)
216
+ assert decision == {"action": "block", "message": authz.TOOL_VETO_MESSAGE}
217
+
218
+ def test_the_session_engaged_reader_defaults_to_engaged(self):
219
+ assert authz.pre_tool_call_decision(self._env(), authz.TurnAuthzLedger())["action"] == "block"
220
+
221
+ def test_an_empty_turn_identity_outside_a_gateway_is_left_alone(self):
222
+ # A Hermes process that never bound a gateway session (the CLI) has no
223
+ # SecureComs turns at all.
224
+ assert authz.pre_tool_call_decision(self._env(), authz.TurnAuthzLedger(), lambda: False) is None
225
+
226
+ def test_a_positively_identified_cron_turn_is_left_alone(self):
227
+ # N2: cron is identified ONLY by the task-local ContextVar reader the
228
+ # adapter passes in (is_cron), never by the environment.
229
+ decide = authz.pre_tool_call_decision
230
+ assert decide(self._env(), authz.TurnAuthzLedger(), lambda: True, lambda: True) is None
231
+ assert decide(self._env(), authz.TurnAuthzLedger(), lambda: True, lambda: False)["action"] == "block"
232
+ assert decide(self._env(), authz.TurnAuthzLedger(), lambda: True, None)["action"] == "block"
233
+
234
+ def test_the_cron_environment_variable_alone_never_exempts(self):
235
+ env = self._env(HERMES_CRON_SESSION="1")
236
+ assert authz.pre_tool_call_decision(env, authz.TurnAuthzLedger(), lambda: True)["action"] == "block"
237
+ assert authz.pre_tool_call_decision(env, authz.TurnAuthzLedger(), lambda: True, lambda: False)["action"] == "block"
238
+
239
+ def test_an_is_cron_reader_that_raises_blocks(self):
240
+ def boom():
241
+ raise RuntimeError("no var")
242
+
243
+ assert authz.pre_tool_call_decision(self._env(), authz.TurnAuthzLedger(), lambda: True, boom)["action"] == "block"
244
+
245
+ def test_empty_platform_with_a_never_seen_chat_blocks_in_an_engaged_gateway(self):
246
+ env = self._env(HERMES_SESSION_CHAT_ID="never-seen", HERMES_SESSION_USER_ID="u")
247
+ assert authz.pre_tool_call_decision(env, authz.TurnAuthzLedger(), lambda: True)["action"] == "block"
248
+ assert authz.pre_tool_call_decision(env, authz.TurnAuthzLedger(), lambda: False) is None
249
+
250
+ def test_an_engaged_reader_that_raises_blocks(self):
251
+ def boom():
252
+ raise RuntimeError("unknown")
253
+
254
+ assert authz.pre_tool_call_decision(self._env(), authz.TurnAuthzLedger(), boom)["action"] == "block"
255
+
256
+ def test_an_env_reader_that_raises_blocks(self):
257
+ def boom(name, default=""):
258
+ raise RuntimeError("no session")
259
+
260
+ 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,143 @@ 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 (BridgeAgent.authorizationFor). A daemon grant names
152
+ // ONE tool, but a granted turn here lifts the veto for every configured tool,
153
+ // so the connector honours a grant ONLY on the owner's own turn: an owner_dm
154
+ // block, or a relayed voice turn the daemon marked relay_speaker "owner"
155
+ // (audit C-B1, r2 R2-T1). OpenClaw has no per-message "no tools"
156
+ // switch the channel can set, so the connector registers a before_tool_call
157
+ // hook and BLOCKS every tool call in a SecureComs session whose turn was not
158
+ // granted tools. If the host gives the plugin no hook API, a turn without a
159
+ // grant is refused outright (acked, never dispatched). Fail closed.
160
+ //
161
+ // What this cannot do: the model is still OFFERED the configured tools (each
162
+ // call is refused when made), and enforcement holds only for tool calls that
163
+ // OpenClaw routes through before_tool_call.
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /** blockReason handed to OpenClaw for a vetoed call (becomes the tool result). */
167
+ export const TOOL_VETO_REASON =
168
+ "Tool use is disabled for this message: SecureComs grants tools only to this agent's owner in their direct message. Answer in text only.";
169
+
170
+ /**
171
+ * Posted once per channel when a turn is refused because this host cannot
172
+ * veto tool calls: the WHOLE turn is refused, so the room is told rather than
173
+ * left with a silent agent.
174
+ */
175
+ export const REFUSED_TURN_NOTICE = "I can't respond in this room until my owner updates me.";
176
+
177
+ /** before_tool_call priority: run ahead of other plugins' handlers. */
178
+ export const TOOL_VETO_PRIORITY = 1000;
179
+
180
+ /** Extra system prompt for a turn without tools, so the model answers in text. */
181
+ export const NO_TOOLS_TURN_NOTE =
182
+ "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.";
183
+
184
+ /**
185
+ * True only when the daemon granted the OWNER'S OWN turn at least one tool: a
186
+ * v1 block, an active sender, audience owner_dm or the daemon's
187
+ * relay_speaker "owner" mark (audit C-B1, r2 R2-T1: any other audience gets no
188
+ * tools even with grants), and a non-empty list of `{ tool: <name> }` grants.
189
+ * The audience alone never grants tools (an unpinned or anomalous owner, or the
190
+ * grants kill switch, arrives as owner_dm with no grants). The daemon grants
191
+ * a DM's single human counterpart whom the server marks `is_agent_owner`; the
192
+ * pin is per channel, so a backend that mis-sets that flag would grant a DM
193
+ * member tools (review H2: binding the owner outside server control is an
194
+ * open follow-up).
195
+ */
196
+ export function toolsAuthorized(authorization) {
197
+ if (authorization === null || typeof authorization !== "object") return false;
198
+ if (authorization.version !== 1 || authorization.sender_membership !== "active") return false;
199
+ // Audit C-B1, r2 R2-T1 (2026-10-05): a daemon grant names ONE tool, but a
200
+ // granted turn here lifts the veto for every configured tool. Only the
201
+ // owner's own turn is honoured: its DM, or the daemon's relay_speaker mark.
202
+ if (authorization.audience !== "owner_dm" && authorization.relay_speaker !== "owner") return false;
203
+ const grants = authorization.tool_grants;
204
+ if (!Array.isArray(grants) || grants.length === 0) return false;
205
+ return grants.every((g) => g !== null && typeof g === "object" && typeof g.tool === "string" && g.tool !== "");
206
+ }
207
+
208
+ const TOOL_AUTHZ_MAX = 5000;
209
+ // Latest turn's decision per session key, and per (session key, sender).
210
+ const _sessionToolAuthz = new Map();
211
+ const _senderToolAuthz = new Map();
212
+ // Ungranted turns per session key whose dispatch has not returned.
213
+ const _ungrantedInFlight = new Map();
214
+ // Set by register(): true only on a host that proves before_tool_call blocks
215
+ // (see register) AND accepted the hook.
216
+ let _toolVetoActive = false;
217
+ // The OpenClaw host version (api.runtime.version), reported in the hello.
218
+ let _harnessVersion = null;
219
+ // Channels already told that this agent cannot respond (veto inactive).
220
+ const _refusalNoticeSent = new Set();
221
+ // Inbound frames seen without an authorization block (daemon too old).
222
+ let _framesWithoutAuthorization = 0;
223
+
224
+ /** The hello frame telling the daemon the harness and whether its veto is active. */
225
+ export function harnessHello() {
226
+ return {
227
+ type: "hello",
228
+ harness: { name: "openclaw", version: _harnessVersion, tool_veto: _toolVetoActive ? "active" : "inactive" },
229
+ };
230
+ }
231
+
232
+ function _boundedSet(map, key, value) {
233
+ map.delete(key);
234
+ map.set(key, value);
235
+ if (map.size > TOOL_AUTHZ_MAX) {
236
+ const oldest = map.keys().next();
237
+ if (!oldest.done) map.delete(oldest.value);
238
+ }
239
+ }
240
+
241
+ function _senderKey(sessionKey, senderId) {
242
+ return `${sessionKey}\u0000${senderId}`;
243
+ }
244
+
245
+ function _recordTurnToolAuthz(sessionKey, senderId, allowed) {
246
+ _boundedSet(_sessionToolAuthz, sessionKey, allowed === true);
247
+ _boundedSet(_senderToolAuthz, _senderKey(sessionKey, senderId), allowed === true);
248
+ }
249
+
250
+ const SECURECOMS_SESSION_MARK = ":securecoms:";
251
+
252
+ function _isSecureComsTool(ctx) {
253
+ if (!ctx || typeof ctx !== "object") return false;
254
+ if (typeof ctx.sessionKey === "string" && ctx.sessionKey.includes(SECURECOMS_SESSION_MARK)) return true;
255
+ if (ctx.channelId === "securecoms") return true;
256
+ return ctx.requester?.channel === "securecoms";
257
+ }
258
+
259
+ /**
260
+ * before_tool_call: no opinion on other channels; for a SecureComs session,
261
+ * allow only when (1) its latest turn was granted tools, (2) no ungranted
262
+ * turn for it is still dispatching, and (3) when OpenClaw names the requester,
263
+ * that sender's latest turn in this session was granted. Anything it cannot
264
+ * establish, including a missing session key, is blocked.
265
+ */
266
+ export function toolCallDecision(_event, ctx) {
267
+ try {
268
+ if (!_isSecureComsTool(ctx)) return undefined;
269
+ const key = ctx.sessionKey;
270
+ if (typeof key === "string" && key !== "" && _sessionToolAuthz.get(key) === true && !(_ungrantedInFlight.get(key) > 0)) {
271
+ const requester = ctx.requester?.senderId;
272
+ if (requester === undefined || requester === null) return undefined;
273
+ const sender = String(requester).replace(/^securecoms:/, "");
274
+ if (_senderToolAuthz.get(_senderKey(key, sender)) === true) return undefined;
275
+ }
276
+ } catch {
277
+ /* fall through: block */
278
+ }
279
+ return { block: true, blockReason: TOOL_VETO_REASON };
280
+ }
281
+
145
282
  /** "direct" only for a DM the daemon classified as one; everything else is a group. */
146
283
  export function chatTypeFor(authorization) {
147
284
  const audience = authorization && typeof authorization === "object" ? authorization.audience : undefined;
@@ -427,6 +564,9 @@ export class LoopbackChannel {
427
564
  // enough (see comment in _probeAndGate) — and not while turns are
428
565
  // failing: a close we forced for redelivery must still back off.
429
566
  if (this._failStreak === 0) this._attempt = 0;
567
+ // Report the harness and its tool-veto state (versions/booleans only);
568
+ // a daemon that predates the field ignores it.
569
+ try { ws.send(JSON.stringify(harnessHello())); } catch { /* best effort */ }
430
570
  });
431
571
 
432
572
  ws.addEventListener("message", (ev) => {
@@ -715,6 +855,27 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
715
855
  const senderName = typeof metadata.senderName === "string" && metadata.senderName !== "" ? metadata.senderName : senderId;
716
856
  const chatType = chatTypeFor(metadata.authorization);
717
857
  const commandAuthorized = isOwnerAuthorization(metadata.authorization);
858
+ if (metadata.authorization === null || typeof metadata.authorization !== "object") {
859
+ _framesWithoutAuthorization++;
860
+ if (_framesWithoutAuthorization % 100 === 1) {
861
+ channel.log(
862
+ "[SecureComs] WARNING: inbound frame carries NO authorization block; the daemon predates the tool gate, so every turn runs without tools. Upgrade @securecoms/agent-bridge.",
863
+ );
864
+ }
865
+ }
866
+ const toolsAllowed = toolsAuthorized(metadata.authorization);
867
+ if (!toolsAllowed && !_toolVetoActive) {
868
+ // This OpenClaw host gave the plugin no way to refuse tool calls, so a
869
+ // turn the daemon did not grant tools never reaches the agent. Acked.
870
+ channel._logSafe("[securecoms] turn refused reason={r}", { r: "no_tool_veto" });
871
+ // One notice per channel; never to another agent (two refusing agents
872
+ // would answer each other).
873
+ if (metadata.authorization?.audience !== "agent_to_agent" && !_refusalNoticeSent.has(conv) && _refusalNoticeSent.size < TOOL_AUTHZ_MAX) {
874
+ _refusalNoticeSent.add(conv);
875
+ try { await channel.send(conv, REFUSED_TURN_NOTICE, metadata.messageId); } catch { /* best effort */ }
876
+ }
877
+ return;
878
+ }
718
879
 
719
880
  // Route by the CHANNEL, so each SecureComs room/DM is its own conversation.
720
881
  const route = core.channel.routing.resolveAgentRoute({
@@ -728,6 +889,7 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
728
889
  // main session, which would merge two different humans' DMs (and the
729
890
  // owner's) into one history. One session per SecureComs channel, always.
730
891
  const sessionKey = `agent:${route.agentId}:securecoms:${chatType}:${conv}`;
892
+ _recordTurnToolAuthz(sessionKey, senderId, toolsAllowed);
731
893
 
732
894
  const storePath = core.channel.session.resolveStorePath(cfg?.session?.store, {
733
895
  agentId: route.agentId,
@@ -748,6 +910,11 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
748
910
  body: text,
749
911
  });
750
912
 
913
+ const extraPrompt = [
914
+ metadata.voiceGuidance ? `${VOICE_TURN_PREFIX}${metadata.voiceGuidance}` : undefined,
915
+ toolsAllowed ? undefined : NO_TOOLS_TURN_NOTE,
916
+ ].filter(Boolean).join("\n\n");
917
+
751
918
  const ctxPayload = core.channel.reply.finalizeInboundContext({
752
919
  Body: body,
753
920
  RawBody: text,
@@ -770,9 +937,10 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
770
937
  // authorization block (isOwnerAuthorization). MLS membership proves a
771
938
  // sender is in the room, not that they own this agent.
772
939
  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}` } : {}),
940
+ // Voice turn and no-tools turn: OpenClaw folds GroupSystemPrompt into
941
+ // this turn's extra system prompt (get-reply's extraSystemPromptParts)
942
+ // for any chat type.
943
+ ...(extraPrompt ? { GroupSystemPrompt: extraPrompt } : {}),
776
944
  });
777
945
 
778
946
  await core.channel.session.recordInboundSession({
@@ -793,6 +961,7 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
793
961
  // "idle" waits for the last one to settle (success or failure) so the strip
794
962
  // does not clear before the reply is out. The turn itself does not wait for
795
963
  // that — ack/redelivery behavior is unchanged.
964
+ if (!toolsAllowed) _ungrantedInFlight.set(sessionKey, (_ungrantedInFlight.get(sessionKey) ?? 0) + 1);
796
965
  channel.reportStatus(conv, "working");
797
966
  const pendingDeliveries = new Set();
798
967
  let deliveredBlocks = 0;
@@ -825,6 +994,11 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
825
994
  } finally {
826
995
  // One line per turn, content-free: tells whether dispatch returns before
827
996
  // its reply blocks are delivered (delivered_blocks=0 with pending > 0).
997
+ if (!toolsAllowed) {
998
+ const n = (_ungrantedInFlight.get(sessionKey) ?? 1) - 1;
999
+ if (n > 0) _ungrantedInFlight.set(sessionKey, n);
1000
+ else _ungrantedInFlight.delete(sessionKey);
1001
+ }
828
1002
  channel._logSafe("[securecoms] turn dispatch_ms={ms} delivered_blocks={k} pending_deliveries={p}", {
829
1003
  ms: Date.now() - dispatchStarted,
830
1004
  k: deliveredBlocks,
@@ -965,6 +1139,21 @@ export default {
965
1139
  description: "End-to-end encrypted messaging via the local SecureComs Agent Bridge daemon.",
966
1140
  register(api) {
967
1141
  _runtime = api.runtime;
1142
+ _harnessVersion = typeof api.runtime?.version === "string" ? api.runtime.version.slice(0, 40) : null;
1143
+ _toolVetoActive = false;
1144
+ // Trust the veto only on a host that proves before_tool_call blocks:
1145
+ // registerTrustedToolPolicy shipped in OpenClaw 2026.4.27 (#72287), after
1146
+ // block results became terminal and hook errors became blocks (2026-04-04,
1147
+ // #59822). An older host may accept api.on and ignore the registration,
1148
+ // so below that floor turns without a grant are refused instead.
1149
+ if (typeof api.on === "function" && typeof api.registerTrustedToolPolicy === "function") {
1150
+ try {
1151
+ api.on("before_tool_call", toolCallDecision, { priority: TOOL_VETO_PRIORITY });
1152
+ _toolVetoActive = true;
1153
+ } catch {
1154
+ _toolVetoActive = false;
1155
+ }
1156
+ }
968
1157
  api.registerChannel({ plugin: secureComsPlugin });
969
1158
  },
970
1159
  };
@@ -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");