@securecoms/agent-bridge 0.26.4 → 0.26.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/connectors/hermes/adapter.py +202 -3
- package/connectors/hermes/securecoms_authz.py +190 -0
- package/connectors/hermes/tests/test_adapter_wiring.py +481 -3
- package/connectors/hermes/tests/test_securecoms_authz.py +235 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +19 -4
- package/connectors/openclaw/openclaw-entry.js +184 -3
- package/connectors/openclaw/openclaw-entry.status.test.ts +11 -1
- package/connectors/openclaw/openclaw-entry.toolgate.test.ts +466 -0
- package/dist/bin.js +88 -7
- package/dist/bridge-agent.d.ts +38 -4
- package/dist/harness-report.d.ts +14 -0
- package/dist/index.js +88 -7
- package/dist/service/runtime-config-dir.d.ts +22 -0
- package/dist/version.d.ts +1 -1
- package/dist/ws-server.d.ts +0 -77
- package/package.json +1 -1
|
@@ -0,0 +1,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
|
-
|
|
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,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
|
|
774
|
-
// system prompt (get-reply's extraSystemPromptParts)
|
|
775
|
-
|
|
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
|
-
|
|
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");
|