@securecoms/agent-bridge 0.26.5 → 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 CHANGED
@@ -56,6 +56,19 @@ The bundled connectors give tools only to the agent owner's turns. Each harness
56
56
 
57
57
  On an older harness the connector reports its tool veto as inactive and refuses every turn that has no tool grant, so non-owner rooms go quiet instead of running tools unchecked. `/healthz` shows `harness_tool_veto_active` (true, false, or null when no connector is attached), the authenticated `GET /v1/status` shows the harness name and version, and the daemon logs `harness.tool_veto_inactive` once when an attached harness cannot veto. After upgrading the package, re-run `agent-bridge wire --harness openclaw` (or re-copy the Hermes connector files) so the harness loads the new connector, then restart the harness gateway.
58
58
 
59
+ ### Room tool rule (0.26.6 and later)
60
+
61
+ In a room whose every member, including pending members and anyone who left but still holds an MLS leaf, is in the agent owner's org, a same-org human sender's turn may use `current_time` only. Cross-org and unknown rooms get no tools. The owner DM keeps `current_time` and `web_search`. Replies to room turns are re-classified when they are sent.
62
+
63
+ Operator steps:
64
+
65
+ - **Deploy order:** backend (already live), then this bridge, then `@securecoms/agent-runtime` 0.9.9. A new runtime on an old bridge offers no tools on the owner's relayed voice turns. Room grants need both the new bridge and the new runtime.
66
+ - **Re-wire connectors:** re-run `agent-bridge wire --harness ...` on every Hermes and OpenClaw host after upgrading. Connectors are copied at wire time and an upgrade does not refresh them. A bridge downgrade after re-wiring makes the new connectors refuse tools on the owner's voice turns.
67
+ - **Kill switch:** `SECURECOMS_ROOM_DEFAULT_GRANTS` (bridge service environment, read at startup). Unset or `on` keeps the room default; any other value sends no tools on every room turn. It does not affect the owner DM. `SECURECOMS_OWNER_DM_DEFAULT_GRANTS` is unchanged.
68
+ - **Owner-DM epoch freshness:** owner-DM tools now also require a roster read at the agent's current MLS group and epoch. A wedged or group-less DM shows no tools. Diagnose with the `authz.decision` log fields `epoch_fresh`, `room_class`, `per_tool_consumer` and `room_kill_switch`.
69
+ - **Restart residual:** after a bridge restart, a reply to a room turn granted before the restart is not re-classified. Only `current_time` output can be affected.
70
+ - **Post-deploy probe:** count `connect.roster_mirror_unresolved` warnings and rooms reading `unknown`. A high rate means roster mirror drift is hiding room tools.
71
+
59
72
  ---
60
73
 
61
74
  ## Quickstart
@@ -9,11 +9,13 @@ The daemon attaches an ``authorization`` block to every inbound frame
9
9
  from server-authenticated roster data, never from message plaintext):
10
10
 
11
11
  {"version": 1, "audience": ..., "sender_membership": "active"|"unknown",
12
- "tool_grants": [{"tool": ..., "mode": ...}, ...]}
12
+ "tool_grants": [{"tool": ..., "mode": ...}, ...],
13
+ "relay_speaker": "owner"} # optional; only on an owner-spoken relay turn
13
14
 
14
15
  ``tool_grants`` is non-empty ONLY for a DM's single human counterpart whom the
15
16
  server marks as the agent's owner (``is_agent_owner``), or a relayed voice
16
- turn whose speaker is that person. The daemon pins that counterpart per
17
+ turn whose speaker is that person (the daemon marks it ``relay_speaker:
18
+ "owner"``). The daemon pins that counterpart per
17
19
  channel, so the owner distinction rests on the server's flag: a backend that
18
20
  mis-sets it would grant a DM member tools (review H2; binding the owner
19
21
  identity outside server control is an open follow-up). Every other sender (room
@@ -24,7 +26,11 @@ not positively recognise as a grant is "no tools".
24
26
 
25
27
  Hermes cannot express the daemon's per-tool grants (current_time, web_search)
26
28
  as its own toolsets, so a granted turn keeps the owner's configured Hermes
27
- toolsets unchanged and an ungranted turn gets none.
29
+ toolsets unchanged and an ungranted turn gets none. Because of that, a grant
30
+ is honoured ONLY on an ``owner_dm`` block or a block the daemon marked
31
+ ``relay_speaker: "owner"`` (audit C-B1, r2 R2-T1): the daemon's room grants
32
+ are per-tool and are delivered only to a per-tool consumer, never to this
33
+ connector.
28
34
  """
29
35
 
30
36
  from collections import OrderedDict
@@ -91,10 +97,16 @@ def _is_grant(entry: Any) -> bool:
91
97
 
92
98
 
93
99
  def tools_allowed(frame: Any) -> bool:
94
- """True only when the daemon granted this turn at least one tool."""
100
+ """True only when the turn is the owner's own (its owner_dm block, or the
101
+ daemon's relay_speaker="owner" mark on a relayed voice turn) AND the daemon
102
+ granted it at least one tool. Audit C-B1, r2 R2-T1 (2026-10-05): a daemon
103
+ grant names ONE tool, but a granted turn here keeps the owner's whole Hermes
104
+ toolset, so nothing else is ever honoured, whatever the daemon sends."""
95
105
  block = _valid_block(frame)
96
106
  if block is None:
97
107
  return False
108
+ if block.get("audience") != "owner_dm" and block.get("relay_speaker") != "owner":
109
+ return False
98
110
  grants = block.get("tool_grants")
99
111
  if not isinstance(grants, list) or not grants:
100
112
  return False
@@ -3,10 +3,12 @@
3
3
  # Per-turn tool authorization for the Hermes connector. The daemon attaches
4
4
  # an `authorization` block to every inbound frame (BridgeAgent.authorizationFor,
5
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.
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.
10
12
 
11
13
  import securecoms_authz as authz
12
14
 
@@ -36,8 +38,31 @@ class TestToolsAllowed:
36
38
 
37
39
  def test_relayed_owner_voice_turn_with_grants_is_allowed(self):
38
40
  # 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
+ # 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
41
66
 
42
67
  def test_room_member_without_grants_is_refused(self):
43
68
  assert authz.tools_allowed(frame(auth("team_room", grants=[]))) is False
@@ -148,9 +148,11 @@ export function isOwnerAuthorization(authorization) {
148
148
  // OpenClaw runs every turn with the agent's configured tools, so without this
149
149
  // a room member, a partner-org member or another agent reaches the same exec
150
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"
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"
154
156
  // switch the channel can set, so the connector registers a before_tool_call
155
157
  // hook and BLOCKS every tool call in a SecureComs session whose turn was not
156
158
  // granted tools. If the host gives the plugin no hook API, a turn without a
@@ -180,9 +182,11 @@ export const NO_TOOLS_TURN_NOTE =
180
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.";
181
183
 
182
184
  /**
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
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
186
190
  * grants kill switch, arrives as owner_dm with no grants). The daemon grants
187
191
  * a DM's single human counterpart whom the server marks `is_agent_owner`; the
188
192
  * pin is per channel, so a backend that mis-sets that flag would grant a DM
@@ -192,6 +196,10 @@ export const NO_TOOLS_TURN_NOTE =
192
196
  export function toolsAuthorized(authorization) {
193
197
  if (authorization === null || typeof authorization !== "object") return false;
194
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;
195
203
  const grants = authorization.tool_grants;
196
204
  if (!Array.isArray(grants) || grants.length === 0) return false;
197
205
  return grants.every((g) => g !== null && typeof g === "object" && typeof g.tool === "string" && g.tool !== "");
@@ -1,13 +1,15 @@
1
1
  // packages/connect-agent-bridge/connectors/openclaw/openclaw-entry.toolgate.test.ts
2
2
  //
3
- // Tool gate (security fix 2026-10-05): only a turn the daemon granted tools
4
- // (its authorization block's tool_grants is non-empty, which the daemon does
5
- // ONLY for the agent's pinned owner in their DM or an owner-spoken relayed
6
- // voice turn) may run OpenClaw tools. Every other sender (room member,
7
- // other-org member, another agent, a frame with no or a malformed block) runs
8
- // with every tool call BLOCKED by the connector's before_tool_call hook. If
9
- // the OpenClaw host gives the plugin no hook API, such a turn is refused
10
- // outright (acked, never dispatched). Fail closed.
3
+ // Tool gate (security fix 2026-10-05): the daemon's authorization block's
4
+ // tool_grants is non-empty ONLY for the agent's pinned owner in their DM, a
5
+ // relayed voice turn spoken by that owner (marked `relay_speaker: "owner"` by
6
+ // the daemon), or (from PR R on) a same-org human in an internal room. This
7
+ // connector honours a grant ONLY on an `owner_dm` block or a block marked
8
+ // `relay_speaker: "owner"` (audit C-B1, r2 R2-T1). Every other turn (room
9
+ // member, other-org member, another agent, a frame with no or a malformed
10
+ // block) runs with every tool call BLOCKED by the connector's
11
+ // before_tool_call hook. If the OpenClaw host gives the plugin no hook API,
12
+ // such a turn is refused outright (acked, never dispatched). Fail closed.
11
13
  //
12
14
  // Drives the REAL register() + plugin.gateway.startAccount path with a fake
13
15
  // OpenClaw runtime and api, like openclaw-entry.inbound.test.ts.
@@ -234,7 +236,16 @@ describe("openclaw-entry — tool gate (non-owner senders get no tools)", () =>
234
236
  it("is true only for a v1 block from an active sender with at least one tool grant", async () => {
235
237
  const m = await import("./openclaw-entry.js");
236
238
  expect(m.toolsAuthorized(OWNER_AUTH)).toBe(true);
237
- expect(m.toolsAuthorized({ ...noGrant("agent_to_agent"), tool_grants: GRANTS })).toBe(true); // owner-spoken relay turn
239
+ expect(m.toolsAuthorized({ ...noGrant("agent_to_agent"), tool_grants: GRANTS, relay_speaker: "owner" })).toBe(true); // owner-spoken relay turn (R2-T1)
240
+ expect(m.toolsAuthorized({ ...noGrant("agent_to_agent"), tool_grants: GRANTS })).toBe(false); // no owner relay mark
241
+ expect(m.toolsAuthorized({ ...noGrant("agent_to_agent"), relay_speaker: "owner" })).toBe(false); // the mark alone grants nothing
242
+ // Review R2a fix 1: only the exact string "owner" is the mark; a truthiness check would accept these.
243
+ for (const v of ["Owner", "OWNER", "guest", "", true, 1, ["owner"], { v: "owner" }, "\u043ewner", "owner\u200b"]) {
244
+ expect(m.toolsAuthorized({ ...noGrant("agent_to_agent"), tool_grants: GRANTS, relay_speaker: v }), JSON.stringify(v)).toBe(false);
245
+ }
246
+ for (const aud of ["team_room", "partner_room", "other_human_dm", "unknown"]) {
247
+ expect(m.toolsAuthorized({ ...noGrant(aud), tool_grants: GRANTS }), aud).toBe(false); // audit C-B1
248
+ }
238
249
  expect(m.toolsAuthorized(noGrant("owner_dm"))).toBe(false); // kill switch / unpinned owner / lying flag
239
250
  expect(m.toolsAuthorized(noGrant("team_room"))).toBe(false);
240
251
  expect(m.toolsAuthorized(noGrant("partner_room"))).toBe(false);