@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.
- package/README.md +24 -0
- package/connectors/hermes/adapter.py +202 -3
- package/connectors/hermes/securecoms_authz.py +202 -0
- package/connectors/hermes/tests/test_adapter_wiring.py +481 -3
- package/connectors/hermes/tests/test_securecoms_authz.py +260 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +19 -4
- package/connectors/openclaw/openclaw-entry.js +192 -3
- package/connectors/openclaw/openclaw-entry.status.test.ts +11 -1
- package/connectors/openclaw/openclaw-entry.toolgate.test.ts +477 -0
- package/dist/bin.js +401 -75
- package/dist/bridge-agent.d.ts +157 -4
- package/dist/consumer-grants.d.ts +22 -0
- package/dist/harness-report.d.ts +14 -0
- package/dist/http-server.d.ts +10 -0
- package/dist/index.js +401 -75
- package/dist/room-tools.d.ts +23 -0
- 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,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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
|
774
|
-
// system prompt (get-reply's extraSystemPromptParts)
|
|
775
|
-
|
|
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
|
-
|
|
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");
|