@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 CHANGED
@@ -45,6 +45,17 @@ To install Node 24 manually instead:
45
45
 
46
46
  Always re-run the install command after upgrading Node — the background service pins the Node path it was installed with.
47
47
 
48
+ ### Harness minimum versions (0.26.5 and later)
49
+
50
+ The bundled connectors give tools only to the agent owner's turns. Each harness must be able to block a tool call before it runs, so each has a minimum version:
51
+
52
+ | Harness | Minimum version | Hook the connector relies on |
53
+ |---------|-----------------|------------------------------|
54
+ | OpenClaw | 2026.4.27 | `before_tool_call` that can block |
55
+ | Hermes | v2026.9.24 | `pre_tool_call` veto |
56
+
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
+
48
59
  ---
49
60
 
50
61
  ## Quickstart
@@ -71,11 +71,44 @@ from gateway.platforms.base import (
71
71
  )
72
72
  from gateway.config import Platform
73
73
 
74
+ import securecoms_authz
74
75
  import securecoms_transport
75
76
  from securecoms_gate import resolve_bound_identity
76
77
 
77
78
  logger = logging.getLogger(__name__)
78
79
 
80
+ # Tool gate (security fix 2026-10-05). Hermes runs every turn with the
81
+ # owner's configured toolsets unless told otherwise, so a room member, a
82
+ # partner-org member or another agent would reach the same shell/file tools
83
+ # as the owner. The daemon's per-frame `authorization` block says who was
84
+ # granted tools (securecoms_authz.tools_allowed); a turn without a grant runs
85
+ # with tools disabled, enforced at two Hermes seams:
86
+ # 1. toolsets_for_source(): the turn is OFFERED no toolsets (best effort,
87
+ # narrows what the model sees);
88
+ # 2. a pre_tool_call veto registered by register(ctx): any tool call in a
89
+ # SecureComs turn that was not granted tools is BLOCKED (the enforcement).
90
+ # The veto is only trusted when register(ctx) proves the running Hermes can
91
+ # honour it (_TOOL_VETO_ACTIVE). When it cannot, a turn without a grant is
92
+ # refused outright (acked, never handed to Hermes). Fail closed.
93
+ _TURN_AUTHZ = securecoms_authz.TurnAuthzLedger()
94
+ _TOOL_VETO_ACTIVE = False
95
+ # gateway.session_context.get_session_env, bound by register(ctx): reads the
96
+ # CURRENT turn's task-local session vars (platform, chat, user).
97
+ _GET_SESSION_ENV = None
98
+ # gateway.session_context.session_context_engaged when this Hermes has it;
99
+ # None means "assume engaged" (an empty turn identity then blocks).
100
+ _SESSION_ENGAGED = None
101
+ # Reads Hermes's task-local cron ContextVar (session_context._CRON_SESSION);
102
+ # None when this Hermes has none, and then no turn is exempt as cron.
103
+ _IS_CRON = None
104
+ # hermes_cli.__version__, reported to the daemon in the hello frame.
105
+ _HARNESS_VERSION: Optional[str] = None
106
+ # Channels already told that this agent cannot respond (veto inactive).
107
+ _REFUSAL_NOTICE_SENT: set = set()
108
+ _REFUSAL_NOTICE_MAX = 4096
109
+ # Inbound frames seen without an authorization block (daemon too old).
110
+ _FRAMES_WITHOUT_AUTHORIZATION = 0
111
+
79
112
  _PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
80
113
  _BINDING_PATH = os.path.join(_PLUGIN_DIR, "securecoms.binding.json")
81
114
  _CONNECTIONS_DIR = os.path.expanduser("~/.securecoms/connections")
@@ -257,9 +290,31 @@ class SecureComsAdapter(BasePlatformAdapter):
257
290
  self.ws_url = target.ws_url
258
291
  self._token = target.token
259
292
  self._leave_hold()
293
+ await self._send_hello()
260
294
  await self._refresh_channels()
261
295
  return True
262
296
 
297
+ async def _send_hello(self) -> None:
298
+ """Tell the daemon which harness this is and whether its tool veto is
299
+ active (booleans/versions only), so /healthz shows it. A daemon that
300
+ predates the field ignores it."""
301
+ if self._ws is None:
302
+ return
303
+ try:
304
+ await self._ws.send_json({
305
+ "type": "hello",
306
+ "harness": {
307
+ "name": "hermes",
308
+ "version": _HARNESS_VERSION,
309
+ "tool_veto": "active" if _TOOL_VETO_ACTIVE else "inactive",
310
+ },
311
+ })
312
+ except Exception as e:
313
+ logger.debug(
314
+ "SecureComs: hello send failed (error_class=%s)",
315
+ _error_class(e),
316
+ )
317
+
263
318
  def _enter_hold(self, reason: str) -> None:
264
319
  changed = self._hold_reason != reason
265
320
  self._hold_reason = reason
@@ -424,6 +479,24 @@ class SecureComsAdapter(BasePlatformAdapter):
424
479
  meta = self._channels.get(channel_id, {})
425
480
  chat_type = "dm" if meta.get("is_dm", True) else "group"
426
481
 
482
+ global _FRAMES_WITHOUT_AUTHORIZATION
483
+ if not isinstance(frame.get("authorization"), dict):
484
+ _FRAMES_WITHOUT_AUTHORIZATION += 1
485
+ if _FRAMES_WITHOUT_AUTHORIZATION % 100 == 1:
486
+ logger.warning(
487
+ "SecureComs: inbound frame carries NO authorization block; the "
488
+ "daemon predates the tool gate, so every turn runs without tools. "
489
+ "Upgrade @securecoms/agent-bridge"
490
+ )
491
+ tools_allowed = securecoms_authz.tools_allowed(frame)
492
+ _TURN_AUTHZ.record(channel_id, sender_id, tools_allowed)
493
+ if not tools_allowed and not _TOOL_VETO_ACTIVE:
494
+ # This Hermes cannot be made to refuse tool calls, so a turn the
495
+ # daemon did not grant tools never reaches it. Acked by _pump.
496
+ logger.warning("SecureComs: refused a turn without a tool grant (tool veto unavailable)")
497
+ await self._notify_refused(channel_id, frame)
498
+ return
499
+
427
500
  source = self.build_source(
428
501
  chat_id=channel_id,
429
502
  chat_name=meta.get("name", channel_id),
@@ -431,6 +504,11 @@ class SecureComsAdapter(BasePlatformAdapter):
431
504
  user_id=sender_id,
432
505
  user_name=sender_id,
433
506
  )
507
+ if not tools_allowed and isinstance(text, str):
508
+ # An ungranted sender must never drive gateway commands. EVERY
509
+ # ungranted text is escaped: Hermes strips leading @url:/@file:
510
+ # refs before its "/" check, so "@url:x /reset" is a command too.
511
+ text = securecoms_authz.COMMAND_ESCAPE + text
434
512
  event = MessageEvent(
435
513
  text=text,
436
514
  message_type=MessageType.TEXT,
@@ -438,11 +516,52 @@ class SecureComsAdapter(BasePlatformAdapter):
438
516
  message_id=str(frame.get("message_id") or int(time.time() * 1000)),
439
517
  timestamp=datetime.now(),
440
518
  )
441
- voice_prompt = voice_prompt_of(frame)
442
- if voice_prompt is not None:
443
- event.channel_prompt = voice_prompt
519
+ # Sender role rides as event metadata, never in the user text.
520
+ metadata = getattr(event, "metadata", None)
521
+ if not isinstance(metadata, dict):
522
+ metadata = {}
523
+ metadata["securecoms"] = {
524
+ "sender_role": securecoms_authz.sender_role(frame),
525
+ "tools_allowed": tools_allowed,
526
+ }
527
+ event.metadata = metadata
528
+ if not tools_allowed:
529
+ # Hermes's own switch for "this event may not resolve gateway
530
+ # commands or control prompts" (MessageEvent.allow_gateway_control).
531
+ event.allow_gateway_control = False
532
+ prompts = [p for p in (voice_prompt_of(frame), None if tools_allowed else securecoms_authz.NO_TOOLS_TURN_NOTE) if p]
533
+ if prompts:
534
+ event.channel_prompt = "\n\n".join(prompts)
444
535
  await self.handle_message(event)
445
536
 
537
+ async def _notify_refused(self, channel_id: str, frame: Dict[str, Any]) -> None:
538
+ """One short notice per channel that this agent cannot respond there.
539
+ Never to another agent (two refusing agents would answer each other)."""
540
+ if securecoms_authz.sender_role(frame) == "agent" or not channel_id:
541
+ return
542
+ if channel_id in _REFUSAL_NOTICE_SENT or len(_REFUSAL_NOTICE_SENT) >= _REFUSAL_NOTICE_MAX:
543
+ return
544
+ _REFUSAL_NOTICE_SENT.add(channel_id)
545
+ try:
546
+ await self.send(channel_id, securecoms_authz.REFUSED_TURN_NOTICE)
547
+ except Exception as e:
548
+ logger.debug(
549
+ "SecureComs: refusal notice failed (error_class=%s)",
550
+ _error_class(e),
551
+ )
552
+
553
+ def toolsets_for_source(self, source) -> Optional[list]:
554
+ """Hermes per-turn toolset override (gateway run_turn
555
+ _resolve_enabled_toolsets_for_source). None keeps the configured
556
+ toolsets: only for a (chat, sender) whose latest turn was granted
557
+ tools. Anything else, including an unknown source, gets the no-tools
558
+ override."""
559
+ chat_id = getattr(source, "chat_id", None)
560
+ user_id = getattr(source, "user_id", None)
561
+ if _TURN_AUTHZ.tools_allowed(chat_id, user_id):
562
+ return None
563
+ return list(securecoms_authz.NO_TOOLS_TOOLSETS)
564
+
446
565
  # ── Outbound ──────────────────────────────────────────────────────────
447
566
 
448
567
  _BLOCK_SEQ_MAX = 1000
@@ -542,8 +661,88 @@ def check_requirements() -> bool:
542
661
  return os.path.isfile(os.getenv("SECURECOMS_CONNECTION_FILE") or _BINDING_PATH)
543
662
 
544
663
 
664
+ def _securecoms_pre_tool_call(**_kwargs):
665
+ """Hermes pre_tool_call hook: block every tool call in a SecureComs turn
666
+ that the daemon did not grant tools, and any call whose turn identity is
667
+ empty in an engaged gateway. No opinion on other platforms."""
668
+ get_env = _GET_SESSION_ENV
669
+ if get_env is None:
670
+ return {"action": "block", "message": securecoms_authz.TOOL_VETO_MESSAGE}
671
+ return securecoms_authz.pre_tool_call_decision(get_env, _TURN_AUTHZ, _SESSION_ENGAGED, _IS_CRON)
672
+
673
+
674
+ def _install_tool_veto(ctx) -> bool:
675
+ """Register the pre_tool_call veto and report whether this Hermes can
676
+ honour it. All three must hold, or the veto is not trusted:
677
+ - pre_tool_call results can BLOCK (hermes_cli.plugins exposes the block
678
+ directive API; older Hermes treated the hook as an observer only);
679
+ - the turn's session is task-local (gateway.session_context), so the
680
+ hook attributes a tool call to the right turn rather than to whatever
681
+ turn last wrote os.environ;
682
+ - MessageEvent has allow_gateway_control (else room members could
683
+ still drive gateway commands);
684
+ - ctx.register_hook accepted the callback.
685
+ Cron exemption is read only from session_context._CRON_SESSION.
686
+ """
687
+ global _GET_SESSION_ENV, _SESSION_ENGAGED, _HARNESS_VERSION, _IS_CRON
688
+ try:
689
+ import hermes_cli
690
+
691
+ version = getattr(hermes_cli, "__version__", None)
692
+ _HARNESS_VERSION = version if isinstance(version, str) else None
693
+ except Exception:
694
+ _HARNESS_VERSION = None
695
+ try:
696
+ from hermes_cli import plugins as hermes_plugins
697
+
698
+ if not any(
699
+ hasattr(hermes_plugins, name)
700
+ for name in (
701
+ "_get_pre_tool_call_directive_details",
702
+ "get_pre_tool_call_directive",
703
+ "get_pre_tool_call_block_message",
704
+ )
705
+ ):
706
+ return False
707
+ from gateway import session_context
708
+ from gateway.session_context import get_session_env
709
+
710
+ engaged = getattr(session_context, "session_context_engaged", None)
711
+ _SESSION_ENGAGED = engaged if callable(engaged) else None
712
+ cron_var = getattr(session_context, "_CRON_SESSION", None)
713
+ if cron_var is not None and callable(getattr(cron_var, "get", None)):
714
+ _IS_CRON = lambda: cron_var.get() == "1" # noqa: E731
715
+ else:
716
+ _IS_CRON = None
717
+
718
+ # Without MessageEvent.allow_gateway_control an ungranted turn could
719
+ # still drive gateway commands, so this Hermes is not trusted either.
720
+ if "allow_gateway_control" not in getattr(MessageEvent, "__dataclass_fields__", {}) and not hasattr(
721
+ MessageEvent, "allow_gateway_control"
722
+ ):
723
+ return False
724
+
725
+ register_hook = getattr(ctx, "register_hook", None)
726
+ if not callable(register_hook):
727
+ return False
728
+ _GET_SESSION_ENV = get_session_env
729
+ register_hook("pre_tool_call", _securecoms_pre_tool_call)
730
+ return True
731
+ except Exception as e:
732
+ logger.warning(
733
+ "SecureComs: tool veto unavailable (error_class=%s)", _error_class(e)
734
+ )
735
+ return False
736
+
737
+
545
738
  def register(ctx):
546
739
  """Plugin entry point: called by the Hermes plugin system."""
740
+ global _TOOL_VETO_ACTIVE
741
+ _TOOL_VETO_ACTIVE = _install_tool_veto(ctx)
742
+ if not _TOOL_VETO_ACTIVE:
743
+ logger.warning(
744
+ "SecureComs: tool veto inactive; turns without a tool grant will be refused"
745
+ )
547
746
  ctx.register_platform(
548
747
  name="securecoms",
549
748
  label="SecureComs",
@@ -0,0 +1,190 @@
1
+ """
2
+ Per-turn tool authorization for the SecureComs Hermes connector.
3
+
4
+ Gateway-import-free (like securecoms_gate.py / securecoms_transport.py) so it
5
+ is unit tested in the backend CI venv.
6
+
7
+ The daemon attaches an ``authorization`` block to every inbound frame
8
+ (``BridgeAgent.authorizationFor`` in packages/connect-agent-bridge, derived
9
+ from server-authenticated roster data, never from message plaintext):
10
+
11
+ {"version": 1, "audience": ..., "sender_membership": "active"|"unknown",
12
+ "tool_grants": [{"tool": ..., "mode": ...}, ...]}
13
+
14
+ ``tool_grants`` is non-empty ONLY for a DM's single human counterpart whom the
15
+ 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
+ channel, so the owner distinction rests on the server's flag: a backend that
18
+ mis-sets it would grant a DM member tools (review H2; binding the owner
19
+ identity outside server control is an open follow-up). Every other sender (room
20
+ members, other-org members, other agents, an older daemon that sends no
21
+ block, a malformed block) gets an empty list. This module turns that into one
22
+ decision per turn: tools on, or tools off. It fails closed: anything it does
23
+ not positively recognise as a grant is "no tools".
24
+
25
+ Hermes cannot express the daemon's per-tool grants (current_time, web_search)
26
+ as its own toolsets, so a granted turn keeps the owner's configured Hermes
27
+ toolsets unchanged and an ungranted turn gets none.
28
+ """
29
+
30
+ from collections import OrderedDict
31
+ from typing import Any, Callable, Dict, Optional
32
+
33
+ PLATFORM_ID = "securecoms"
34
+
35
+ # Becomes the tool result when Hermes vetoes a call (pre_tool_call "block").
36
+ TOOL_VETO_MESSAGE = (
37
+ "Tool use is disabled for this message: SecureComs grants tools only to "
38
+ "this agent's owner in their direct message. Answer in text only."
39
+ )
40
+
41
+ # Ephemeral per-turn system note (MessageEvent.channel_prompt) for a turn that
42
+ # runs without tools, so the model answers in text rather than trying tools.
43
+ NO_TOOLS_TURN_NOTE = (
44
+ "Tools are disabled for this message because its sender is not this "
45
+ "agent's owner in a direct message. Answer in text only and do not try "
46
+ "to call any tool."
47
+ )
48
+
49
+ # Posted ONCE per channel (per process) when a turn without a tool grant is
50
+ # refused because this Hermes cannot veto tool calls: the whole turn is
51
+ # refused, so the room is told rather than left with a silent agent.
52
+ REFUSED_TURN_NOTICE = "I can't respond in this room until my owner updates me."
53
+
54
+ # Prefixed to an ungranted message that starts with "/" so the Hermes
55
+ # gateway never reads it as a command (WORD JOINER: not whitespace, so
56
+ # Hermes's lstrip() keeps it; invisible when rendered).
57
+ COMMAND_ESCAPE = "\u2060"
58
+
59
+ # Hermes ``toolsets_for_source`` override for a turn without tools. Hermes
60
+ # ignores an EMPTY override (it falls back to the configured toolsets), so the
61
+ # override is a non-empty list. ``no_mcp`` is Hermes's own sentinel that turns
62
+ # off every MCP server; it names no built-in toolset, so it resolves to no
63
+ # built-in tools either. This only narrows what the model is offered; the
64
+ # pre_tool_call veto is what refuses a call.
65
+ NO_TOOLS_TOOLSETS = ("no_mcp",)
66
+
67
+ _ROLE_BY_AUDIENCE = {
68
+ "owner_dm": "owner",
69
+ "team_room": "member",
70
+ "other_human_dm": "member",
71
+ "partner_room": "other_org",
72
+ "agent_to_agent": "agent",
73
+ }
74
+
75
+
76
+ def _valid_block(frame: Any) -> Optional[Dict[str, Any]]:
77
+ if not isinstance(frame, dict):
78
+ return None
79
+ block = frame.get("authorization")
80
+ if not isinstance(block, dict):
81
+ return None
82
+ if block.get("version") != 1 or isinstance(block.get("version"), bool):
83
+ return None
84
+ if block.get("sender_membership") != "active":
85
+ return None
86
+ return block
87
+
88
+
89
+ def _is_grant(entry: Any) -> bool:
90
+ return isinstance(entry, dict) and isinstance(entry.get("tool"), str) and entry["tool"] != ""
91
+
92
+
93
+ def tools_allowed(frame: Any) -> bool:
94
+ """True only when the daemon granted this turn at least one tool."""
95
+ block = _valid_block(frame)
96
+ if block is None:
97
+ return False
98
+ grants = block.get("tool_grants")
99
+ if not isinstance(grants, list) or not grants:
100
+ return False
101
+ return all(_is_grant(g) for g in grants)
102
+
103
+
104
+ def sender_role(frame: Any) -> str:
105
+ """owner / member / other_org / agent / unknown, from the daemon's
106
+ audience. Descriptive metadata for Hermes, never an authorization input:
107
+ tools follow ``tools_allowed`` alone."""
108
+ block = _valid_block(frame)
109
+ if block is None:
110
+ return "unknown"
111
+ return _ROLE_BY_AUDIENCE.get(block.get("audience"), "unknown")
112
+
113
+
114
+ class TurnAuthzLedger:
115
+ """The tool decision of the latest inbound frame per (chat_id, sender).
116
+
117
+ Hermes runs a turn later, on its own task, so the decision is recorded at
118
+ dispatch and looked up when a tool is about to run (by the chat and user
119
+ Hermes binds to that turn's session). Unrecorded or evicted keys have no
120
+ tools.
121
+ """
122
+
123
+ def __init__(self, max_entries: int = 4096):
124
+ self._max = max_entries
125
+ self._entries: "OrderedDict[tuple, bool]" = OrderedDict()
126
+ # Every SecureComs chat id ever recorded. Never evicted (one entry per
127
+ # channel), so a chat whose decision was evicted is still known to be
128
+ # SecureComs and its tool calls are still gated.
129
+ self._chats: set = set()
130
+
131
+ def record(self, chat_id: Any, user_id: Any, allowed: bool) -> None:
132
+ key = (chat_id, user_id)
133
+ if isinstance(chat_id, str) and chat_id:
134
+ self._chats.add(chat_id)
135
+ self._entries.pop(key, None)
136
+ self._entries[key] = allowed is True
137
+ while len(self._entries) > self._max:
138
+ self._entries.popitem(last=False)
139
+
140
+ def knows_chat(self, chat_id: Any) -> bool:
141
+ """True when some recorded turn came from this chat id."""
142
+ if not isinstance(chat_id, str) or not chat_id:
143
+ return False
144
+ return chat_id in self._chats
145
+
146
+ def tools_allowed(self, chat_id: Any, user_id: Any) -> bool:
147
+ if not isinstance(chat_id, str) or not chat_id or not isinstance(user_id, str) or not user_id:
148
+ return False
149
+ return self._entries.get((chat_id, user_id)) is True
150
+
151
+
152
+ def pre_tool_call_decision(
153
+ get_session_env: Callable[..., str],
154
+ ledger: TurnAuthzLedger,
155
+ session_engaged: Optional[Callable[[], bool]] = None,
156
+ is_cron: Optional[Callable[[], bool]] = None,
157
+ ) -> Optional[Dict[str, str]]:
158
+ """The Hermes ``pre_tool_call`` directive for the current turn.
159
+
160
+ - A SecureComs turn (platform label, or a chat id this connector
161
+ recorded): no opinion only when that chat and sender were granted
162
+ tools, else block.
163
+ - A turn with an EMPTY platform, in a process whose gateway has bound
164
+ sessions (``session_engaged``, default engaged): block, whatever the
165
+ chat and user say. A tool call that lost its task-local session cannot
166
+ be attributed, so it must not be assumed to be someone else's. The one
167
+ exception is a cron turn identified by ``is_cron``, which must read
168
+ Hermes's task-local cron ContextVar; the environment never counts
169
+ (None = no exemption).
170
+ - Any other platform: no opinion.
171
+ A failure to read the session blocks.
172
+ """
173
+ try:
174
+ platform = get_session_env("HERMES_SESSION_PLATFORM", "")
175
+ chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "")
176
+ user_id = get_session_env("HERMES_SESSION_USER_ID", "")
177
+ if platform == PLATFORM_ID or ledger.knows_chat(chat_id):
178
+ if ledger.tools_allowed(chat_id, user_id):
179
+ return None
180
+ elif platform == "":
181
+ engaged = True if session_engaged is None else session_engaged() is not False
182
+ if not engaged:
183
+ return None
184
+ if is_cron is not None and is_cron() is True:
185
+ return None
186
+ else:
187
+ return None
188
+ except Exception:
189
+ pass
190
+ return {"action": "block", "message": TOOL_VETO_MESSAGE}