@securecoms/agent-bridge 0.7.0 → 0.9.0

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
@@ -208,6 +208,8 @@ Three ways to receive, in order of preference for a long-running agent: the WebS
208
208
 
209
209
  `GET /v1/ws` upgrades to a single persistent bidirectional socket. The agent receives live messages and sends/reports status over the same connection — no polling, no separate send channel. This is the interface to use for an always-on agent.
210
210
 
211
+ Two reference connectors speak this protocol today: openclaw (JavaScript) and hermes (Python), both implementing the same ADR-0009 connection-file + two-anchor-gate contract.
212
+
211
213
  Authenticate with the bearer token either as a query param (`ws://127.0.0.1:7077/v1/ws?token=<BRIDGE_TOKEN>`) or an `Authorization: Bearer` header on the upgrade request. The same loopback/Origin/Host rules as the HTTP API apply; an unauthorized upgrade is accepted and then closed with code `1008`.
212
214
 
213
215
  All frames are JSON text.
@@ -0,0 +1,60 @@
1
+ """SecureComs platform plugin for Hermes Agent.
2
+
3
+ Bridges the local SecureComs Agent Bridge daemon (loopback) to the Hermes
4
+ gateway, speaking the ADR-0009 connector contract (connection-file
5
+ resolution, the two-anchor gate, the echo guard). See adapter.py for the
6
+ BasePlatformAdapter implementation and the register(ctx) entry point;
7
+ securecoms_transport.py and securecoms_gate.py carry all contract logic and
8
+ are gateway-import-free.
9
+
10
+ Packaging notes (task-3 review, DEFECT 1 + DEFECT 2 fold):
11
+
12
+ Hermes's real plugin loader (hermes_cli/plugins.py's ``_load_directory_module``)
13
+ imports this file via ``importlib.util.spec_from_file_location(module_name,
14
+ init_file, submodule_search_locations=[plugin_dir])`` and sets
15
+ ``module.__path__ = [plugin_dir]`` on the resulting module BEFORE executing
16
+ it - that gives a relative import here (``from .adapter import ...``) enough
17
+ to find sibling files via the package's ``__path__``, WITHOUT ever adding
18
+ ``plugin_dir`` to ``sys.path``. But adapter.py/securecoms_transport.py import
19
+ each other as bare top-level modules (``import securecoms_transport``,
20
+ ``from securecoms_gate import ...``) - the shape shared with their standalone
21
+ unit tests (tests/test_securecoms_gate.py, tests/test_securecoms_transport.py
22
+ import ``securecoms_gate``/``securecoms_transport`` directly, no package
23
+ prefix) - and a bare absolute import resolves via ``sys.path``, not a
24
+ package's ``__path__``. So we bootstrap ``sys.path`` here, once, before
25
+ anything below needs it (DEFECT 1's fix).
26
+
27
+ The `register` entry point below defers importing adapter.py (and therefore
28
+ adapter.py's `gateway.*` imports, which don't exist outside a real Hermes
29
+ install) until Hermes actually CALLS `register(ctx)` to activate the plugin -
30
+ not merely when this file is imported. That matters because merely
31
+ IMPORTING this `__init__.py` also happens as a side effect of pytest
32
+ collecting any test file under `tests/` (pytest's Package collector runs
33
+ `__init__.py` for any directory containing one, independent of which test
34
+ file was asked for - see tests/test_securecoms_gate.py's docstring). A
35
+ module-level `from .adapter import register` would make every solo `pytest
36
+ tests/test_x.py` invocation require `gateway.*` to be importable in the CI
37
+ backend venv, which spec §6 forbids for securecoms_gate.py/
38
+ securecoms_transport.py's tests (DEFECT 2's fix).
39
+ """
40
+
41
+ import os
42
+ import sys
43
+
44
+ _PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
45
+ if _PLUGIN_DIR not in sys.path:
46
+ sys.path.insert(0, _PLUGIN_DIR)
47
+
48
+
49
+ def register(ctx):
50
+ """Plugin entry point: called by the Hermes plugin system.
51
+
52
+ Deferred import - see module docstring. Only reached when Hermes
53
+ actually activates this plugin, never merely by importing this file.
54
+ """
55
+ from .adapter import register as _register
56
+
57
+ return _register(ctx)
58
+
59
+
60
+ __all__ = ["register"]
@@ -0,0 +1,470 @@
1
+ """
2
+ SecureComs Platform Adapter for Hermes Agent.
3
+
4
+ Bridges the local SecureComs Agent Bridge daemon to the Hermes gateway over
5
+ loopback. The daemon (a separate process started with
6
+ ``connect-agent-bridge --credential <agent>.json``) holds the agent credential
7
+ and performs ALL MLS key agreement and AEAD encryption. This adapter speaks
8
+ plaintext to the daemon on 127.0.0.1, guarded by the daemon's bearer token,
9
+ and never touches cryptographic keys.
10
+
11
+ Flow:
12
+ inbound: daemon --WS {type:message}--> this adapter --> handle_message()
13
+ --> Hermes gateway --> LLM --> send() --> daemon --MLS--> owner
14
+ outbound: the agent's reply is delivered to send(), which POSTs it to the
15
+ daemon's /v1/send; the daemon encrypts and relays it.
16
+
17
+ This is the universal daemon-client pattern wearing a Hermes BasePlatformAdapter
18
+ shim. The same daemon serves any framework; only this thin shim is
19
+ Hermes-specific - ALL contract logic (connection-file/binding resolution, the
20
+ two-anchor gate, the echo guard, per-channel status) lives in
21
+ securecoms_transport.py and securecoms_gate.py, which are gateway-import-free
22
+ and unit tested (see tests/test_securecoms_transport.py,
23
+ tests/test_securecoms_gate.py). This module is covered by
24
+ glue-wiring tests (tests/test_adapter_wiring.py, a stubbed gateway.*) plus
25
+ live validation - it cannot run gateway.* imports in backend CI (spec §6).
26
+
27
+ Wiring is a positive act (ADR-0009 C1): a ``securecoms.binding.json`` file
28
+ beside this plugin (or the ``SECURECOMS_CONNECTION_FILE`` env override) names
29
+ the identity_id this connector is bound to. That binding is resolved EXACTLY
30
+ ONCE, at connect() - never re-derived on a reconnect - so a binding-file
31
+ rewrite while the connector is running cannot silently re-bind it to a
32
+ different agent (task-3 review, "BINDING-STABILITY fold"; see
33
+ securecoms_transport.probe_and_gate_bound). Once bound, every (re)connect
34
+ attempt re-reads ``~/.securecoms/connections/<identity_id>.json`` and the token file
35
+ it points to from scratch, probes ``GET /healthz``, and runs the two-anchor
36
+ gate before ever opening a websocket - so a daemon port move or a rotated
37
+ token is picked up on the very next attempt (securecoms_gate.py's
38
+ ``evaluate_gate``).
39
+
40
+ Config:
41
+ securecoms.binding.json (beside this plugin) {"identity_id": "..."}
42
+ SECURECOMS_CONNECTION_FILE override: an alternate
43
+ binding-shaped file to
44
+ read identity_id from
45
+ SECURECOMS_ALLOW_ALL_USERS true to accept any
46
+ admitted channel member
47
+ SECURECOMS_ALLOWED_USERS comma-separated sender
48
+ identity_ids
49
+
50
+ Deliberately NOT read here (dead config as of this promotion - see spec §5):
51
+ ``SECURECOMS_BRIDGE_URL``, ``SECURECOMS_BRIDGE_TOKEN``,
52
+ ``SECURECOMS_BRIDGE_TOKEN_FILE``. The daemon's address and bearer token are
53
+ never accepted by value or cached across attempts - they come from the
54
+ connection file and token file, re-read on every attempt, exactly like every
55
+ other contract detail.
56
+ """
57
+
58
+ import asyncio
59
+ import json
60
+ import logging
61
+ import os
62
+ import time
63
+ from datetime import datetime
64
+ from typing import Any, Dict, Optional
65
+
66
+ from gateway.platforms.base import (
67
+ BasePlatformAdapter,
68
+ SendResult,
69
+ MessageEvent,
70
+ MessageType,
71
+ )
72
+ from gateway.config import Platform
73
+
74
+ import securecoms_transport
75
+ from securecoms_gate import resolve_bound_identity
76
+
77
+ logger = logging.getLogger(__name__)
78
+
79
+ _PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
80
+ _BINDING_PATH = os.path.join(_PLUGIN_DIR, "securecoms.binding.json")
81
+ _CONNECTIONS_DIR = os.path.expanduser("~/.securecoms/connections")
82
+
83
+
84
+ def _read_file_text(path: str) -> str:
85
+ with open(path, "r") as f:
86
+ return f.read()
87
+
88
+
89
+ def _new_client_session():
90
+ """Seam for tests: the real implementation is a fresh aiohttp session
91
+ per connect attempt (see _open_ws's docstring for why per-attempt, not
92
+ per-adapter-life)."""
93
+ import aiohttp
94
+
95
+ return aiohttp.ClientSession()
96
+
97
+
98
+ class SecureComsAdapter(BasePlatformAdapter):
99
+ """Hermes platform adapter fronting the local SecureComs daemon."""
100
+
101
+ def __init__(self, config, **kwargs):
102
+ super().__init__(config=config, platform=Platform("securecoms"))
103
+
104
+ # channel_id -> {"name": str, "is_dm": bool}
105
+ self._channels: Dict[str, Dict[str, Any]] = {}
106
+
107
+ # Resolved EXACTLY ONCE, by connect() - see the BINDING-STABILITY
108
+ # fold in the module docstring. None until connect() runs.
109
+ self._bound_identity: Optional[str] = None
110
+
111
+ self.bridge_url: Optional[str] = None
112
+ self.ws_url: Optional[str] = None
113
+ self._token: Optional[str] = None
114
+
115
+ self._session = None # aiohttp.ClientSession - the CURRENT connected link
116
+ self._ws = None
117
+ self._recv_task: Optional[asyncio.Task] = None
118
+ self._tick_task: Optional[asyncio.Task] = None
119
+ self._stopped = False # set by disconnect(); stops the reconnect supervisor
120
+ self._hold_reason: Optional[str] = None # None == not holding
121
+
122
+ self._status = securecoms_transport.ChannelStatusManager(
123
+ self._send_status_frame, now=time.monotonic
124
+ )
125
+
126
+ @property
127
+ def name(self) -> str:
128
+ return "SecureComs"
129
+
130
+ def _auth(self) -> Dict[str, str]:
131
+ return {"Authorization": f"Bearer {self._token}"}
132
+
133
+ # ── Connection lifecycle ──────────────────────────────────────────────
134
+
135
+ async def connect(self) -> bool:
136
+ self._stopped = False
137
+
138
+ # BINDING-STABILITY fold: resolve bound_identity here, ONCE, for the
139
+ # entire process life of this adapter instance. Every subsequent
140
+ # attempt (below, and every supervisor reconnect) passes this SAME
141
+ # value into securecoms_transport.probe_and_gate_bound, which takes no
142
+ # env/binding_path argument at all and so structurally cannot
143
+ # re-derive it.
144
+ self._bound_identity = resolve_bound_identity(
145
+ os.environ, _read_file_text, _BINDING_PATH
146
+ )
147
+ if not self._bound_identity:
148
+ # Boot-time check_requirements() already gates on the binding
149
+ # file's presence, so reaching connect() without a resolvable
150
+ # identity is a genuine config error (e.g. the file lost its
151
+ # identity_id field mid-race), not a transient daemon-not-up
152
+ # condition - non-retryable, matching the prior config_missing
153
+ # precedent.
154
+ self._set_fatal_error(
155
+ "unwired",
156
+ "no securecoms.binding.json (run `agent-bridge wire`)",
157
+ retryable=False,
158
+ )
159
+ return False
160
+
161
+ ok = await self._open_ws()
162
+ if not ok:
163
+ # _open_ws already reported the hold via _set_fatal_error
164
+ # (retryable=True); the gateway's reconnect watcher owns retry
165
+ # cadence from here (spec H1).
166
+ return False
167
+
168
+ # Supervisor owns the receive pump AND reconnection: any daemon-side
169
+ # drop (daemon restart, network blip) is recovered automatically
170
+ # with backoff, so presence and message flow self-heal without a
171
+ # gateway restart. A lightweight periodic tick keeps per-channel
172
+ # status honest even when a turn's send_typing refreshes stop
173
+ # arriving (handler error, silently-abandoned turn).
174
+ self._recv_task = asyncio.create_task(self._supervise())
175
+ self._tick_task = asyncio.create_task(self._tick_loop())
176
+ self._mark_connected()
177
+ logger.info("SecureComs: connected to daemon at %s", self.bridge_url)
178
+ return True
179
+
180
+ async def _open_ws(self) -> bool:
181
+ """One connect attempt: fresh session -> probe_and_gate_bound (the
182
+ FIXED self._bound_identity - never re-derived) -> ws_connect.
183
+
184
+ Every failed attempt closes its OWN per-attempt session (the #37011
185
+ fd-leak class this design engineers against) before returning; only
186
+ a successful attempt installs its session as self._session. Reports
187
+ exactly one log line per hold-state CHANGE (spec H1), via
188
+ _enter_hold/_leave_hold, not per retry.
189
+ """
190
+ session = _new_client_session()
191
+ target, reason = await securecoms_transport.probe_and_gate_bound(
192
+ session, self._bound_identity, _read_file_text, _CONNECTIONS_DIR
193
+ )
194
+ if target is None:
195
+ await session.close()
196
+ self._enter_hold(reason)
197
+ return False
198
+
199
+ try:
200
+ ws = await session.ws_connect(
201
+ target.ws_url,
202
+ headers={"Authorization": f"Bearer {target.token}"},
203
+ heartbeat=30.0,
204
+ )
205
+ except Exception as e:
206
+ await session.close()
207
+ self._enter_hold(f"daemon unreachable at {target.bridge_url}: {e}")
208
+ return False
209
+
210
+ self._session = session
211
+ self._ws = ws
212
+ self.bridge_url = target.bridge_url
213
+ self.ws_url = target.ws_url
214
+ self._token = target.token
215
+ self._leave_hold()
216
+ await self._refresh_channels()
217
+ return True
218
+
219
+ def _enter_hold(self, reason: str) -> None:
220
+ changed = self._hold_reason != reason
221
+ self._hold_reason = reason
222
+ if changed:
223
+ logger.warning("SecureComs: waiting for SecureComs daemon: %s", reason)
224
+ self._set_fatal_error(
225
+ "waiting_for_daemon",
226
+ f"waiting for SecureComs daemon: {reason}",
227
+ retryable=True,
228
+ )
229
+
230
+ def _leave_hold(self) -> None:
231
+ self._hold_reason = None
232
+
233
+ async def _supervise(self) -> None:
234
+ """Receive pump + auto-reconnect loop. Runs until disconnect() sets
235
+ _stopped. On a WS drop it marks the channel disconnected (so
236
+ presence reflects the gap), tears down the dead link, then retries
237
+ _open_ws with capped exponential backoff. Mirrors the OpenClaw
238
+ connector."""
239
+ backoff = 1.0
240
+ while not self._stopped:
241
+ try:
242
+ await self._pump() # returns when the WS closes
243
+ except asyncio.CancelledError:
244
+ raise
245
+ except Exception as e:
246
+ logger.warning("SecureComs: receive error: %s", e)
247
+ if self._stopped:
248
+ break
249
+ self._mark_disconnected()
250
+ await self._teardown_link()
251
+ while not self._stopped:
252
+ logger.info(
253
+ "SecureComs: daemon link dropped; reconnecting in %.0fs", backoff
254
+ )
255
+ await asyncio.sleep(backoff)
256
+ if self._stopped:
257
+ break
258
+ if await self._open_ws():
259
+ self._mark_connected()
260
+ backoff = 1.0
261
+ logger.info("SecureComs: reconnected to daemon")
262
+ break
263
+ backoff = min(backoff * 2, 30.0)
264
+
265
+ async def _teardown_link(self) -> None:
266
+ try:
267
+ if self._ws and not self._ws.closed:
268
+ await self._ws.close()
269
+ except Exception:
270
+ pass
271
+ try:
272
+ if self._session and not self._session.closed:
273
+ await self._session.close()
274
+ except Exception:
275
+ pass
276
+ self._ws = None
277
+ self._session = None
278
+
279
+ async def disconnect(self) -> None:
280
+ self._stopped = True # stop the reconnect supervisor before tearing down
281
+ self._mark_disconnected()
282
+ for task_attr in ("_recv_task", "_tick_task"):
283
+ task = getattr(self, task_attr)
284
+ if task and not task.done():
285
+ task.cancel()
286
+ try:
287
+ await task
288
+ except asyncio.CancelledError:
289
+ pass
290
+ await self._teardown_link()
291
+
292
+ async def _refresh_channels(self) -> None:
293
+ try:
294
+ async with self._session.get(
295
+ f"{self.bridge_url}/v1/channels", headers=self._auth()
296
+ ) as r:
297
+ if r.status == 200:
298
+ for c in await r.json():
299
+ self._channels[c["channel_id"]] = {
300
+ "name": c.get("name") or c["channel_id"],
301
+ "is_dm": bool(c.get("is_dm")),
302
+ }
303
+ except Exception as e:
304
+ logger.debug("SecureComs: channel refresh failed: %s", e)
305
+
306
+ # ── Status emit ───────────────────────────────────────────────────────
307
+
308
+ async def _send_status_frame(self, frame: dict) -> None:
309
+ if self._ws is None:
310
+ return
311
+ try:
312
+ await self._ws.send_json(frame)
313
+ except Exception as e:
314
+ logger.debug("SecureComs: status frame send failed: %s", e)
315
+
316
+ async def _tick_loop(self) -> None:
317
+ """Drives ChannelStatusManager.tick() so a channel goes idle after
318
+ 20s of silence even when no send()/send_typing() fires again for it
319
+ (turn ended without a reply, handler raised, etc.)."""
320
+ while not self._stopped:
321
+ try:
322
+ await asyncio.sleep(5.0)
323
+ await self._status.tick()
324
+ except asyncio.CancelledError:
325
+ raise
326
+ except Exception as e:
327
+ logger.debug("SecureComs: status tick error: %s", e)
328
+
329
+ # ── Inbound ───────────────────────────────────────────────────────────
330
+
331
+ async def _pump(self) -> None:
332
+ """Receive frames until the WS closes, then RETURN so the supervisor
333
+ reconnects. Does NOT signal a fatal error - a dropped daemon link is
334
+ recoverable and the supervisor heals it."""
335
+ import aiohttp
336
+
337
+ async for msg in self._ws:
338
+ if msg.type == aiohttp.WSMsgType.TEXT:
339
+ try:
340
+ frame = json.loads(msg.data)
341
+ except (ValueError, TypeError):
342
+ continue
343
+ if frame.get("type") == "message":
344
+ await self._dispatch(frame)
345
+ # at-least-once: ack every accepted frame, dropped or not
346
+ # (echo-guard drops still need the daemon to stop
347
+ # redelivering them).
348
+ try:
349
+ await self._ws.send_json(
350
+ {"type": "ack", "message_id": frame.get("message_id")}
351
+ )
352
+ except Exception as e:
353
+ logger.warning("SecureComs: ack failed: %s", e)
354
+ elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
355
+ return # WS closed → supervisor reconnects
356
+
357
+ async def _dispatch(self, frame: Dict[str, Any]) -> None:
358
+ if not self._message_handler:
359
+ return
360
+ if securecoms_transport.should_drop_inbound(frame, self._bound_identity):
361
+ return # echo guard (contract §4.4): never re-feed our own sends
362
+ channel_id = frame.get("channel_id", "")
363
+ sender_id = frame.get("sender_id", "")
364
+ text = frame.get("text", "")
365
+ meta = self._channels.get(channel_id, {})
366
+ chat_type = "dm" if meta.get("is_dm", True) else "group"
367
+
368
+ source = self.build_source(
369
+ chat_id=channel_id,
370
+ chat_name=meta.get("name", channel_id),
371
+ chat_type=chat_type,
372
+ user_id=sender_id,
373
+ user_name=sender_id,
374
+ )
375
+ event = MessageEvent(
376
+ text=text,
377
+ message_type=MessageType.TEXT,
378
+ source=source,
379
+ message_id=str(frame.get("message_id") or int(time.time() * 1000)),
380
+ timestamp=datetime.now(),
381
+ )
382
+ await self.handle_message(event)
383
+
384
+ # ── Outbound ──────────────────────────────────────────────────────────
385
+
386
+ async def send(
387
+ self,
388
+ chat_id: str,
389
+ content: str,
390
+ reply_to: Optional[str] = None,
391
+ metadata: Optional[Dict[str, Any]] = None,
392
+ ) -> SendResult:
393
+ if not self._session or self._session.closed:
394
+ return SendResult(success=False, error="not connected")
395
+ try:
396
+ async with self._session.post(
397
+ f"{self.bridge_url}/v1/send",
398
+ headers=self._auth(),
399
+ json={"channel_id": chat_id, "text": content},
400
+ ) as r:
401
+ if r.status == 200:
402
+ j = await r.json()
403
+ await self._status.on_reply_posted(chat_id)
404
+ return SendResult(success=True, message_id=str(j.get("message_id")))
405
+ if r.status == 503:
406
+ # daemon not yet admitted to the channel — retryable upstream
407
+ return SendResult(success=False, error="channel_pending")
408
+ body = await r.text()
409
+ return SendResult(success=False, error=f"send failed: {r.status} {body[:200]}")
410
+ except Exception as e:
411
+ return SendResult(success=False, error=str(e))
412
+
413
+ async def send_typing(self, chat_id: str, metadata=None) -> None:
414
+ await self._status.on_typing(chat_id)
415
+
416
+ async def send_image(self, chat_id: str, image_url: str, caption: str = "", **kwargs) -> SendResult:
417
+ # The daemon's text send is the only modality wired today; degrade to
418
+ # sending the URL + caption as text rather than failing the turn.
419
+ text = f"{caption}\n{image_url}".strip() if caption else image_url
420
+ return await self.send(chat_id, text)
421
+
422
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
423
+ meta = self._channels.get(chat_id)
424
+ if not meta:
425
+ await self._refresh_channels()
426
+ meta = self._channels.get(chat_id, {})
427
+ return {
428
+ "name": meta.get("name", chat_id),
429
+ "type": "dm" if meta.get("is_dm", True) else "group",
430
+ "chat_id": chat_id,
431
+ }
432
+
433
+
434
+ # ---------------------------------------------------------------------------
435
+ # Plugin registration
436
+ # ---------------------------------------------------------------------------
437
+
438
+ def check_requirements() -> bool:
439
+ """Wiring is a positive act (ADR-0009 C1): the connector is enabled at
440
+ boot only when its binding file is present - unwired hosts stay quietly
441
+ disabled, exactly like today's env-gated quiet. This does NOT probe the
442
+ daemon; connect() alone decides connected vs. holding.
443
+
444
+ Also honors the SECURECOMS_CONNECTION_FILE override (task-3 review,
445
+ reviewer one-liner (a)): a host wired purely via that env var - no
446
+ securecoms.binding.json on disk - must still show as enabled at boot,
447
+ matching gate.resolve_bound_identity's own env-first precedence."""
448
+ return os.path.isfile(os.getenv("SECURECOMS_CONNECTION_FILE") or _BINDING_PATH)
449
+
450
+
451
+ def register(ctx):
452
+ """Plugin entry point: called by the Hermes plugin system."""
453
+ ctx.register_platform(
454
+ name="securecoms",
455
+ label="SecureComs",
456
+ adapter_factory=lambda cfg: SecureComsAdapter(cfg),
457
+ check_fn=check_requirements,
458
+ is_connected=check_requirements,
459
+ install_hint="Run `agent-bridge wire --harness hermes` to write securecoms.binding.json",
460
+ allowed_users_env="SECURECOMS_ALLOWED_USERS",
461
+ allow_all_env="SECURECOMS_ALLOW_ALL_USERS",
462
+ emoji="🔒",
463
+ pii_safe=True,
464
+ allow_update_command=False,
465
+ platform_hint=(
466
+ "You are chatting via SecureComs, an end-to-end encrypted channel. "
467
+ "Messages are relayed through a local encryption daemon; the server "
468
+ "never sees plaintext. Respond normally and conversationally."
469
+ ),
470
+ )
@@ -0,0 +1,25 @@
1
+ name: securecoms-platform
2
+ label: SecureComs
3
+ kind: platform
4
+ version: 0.2.0
5
+ description: >
6
+ SecureComs gateway adapter for Hermes Agent. Bridges the local SecureComs
7
+ Agent Bridge daemon (end-to-end MLS encrypted messaging) to the Hermes
8
+ gateway over loopback. The daemon holds the credential and does all
9
+ cryptography; this adapter speaks plaintext to the daemon on 127.0.0.1 and
10
+ never touches keys. Inbound SecureComs messages are dispatched to the agent;
11
+ the agent's replies are sent back through the daemon. Wiring is a
12
+ securecoms.binding.json file written by `agent-bridge wire` - there is no
13
+ token env var to configure; the daemon address and bearer token are read
14
+ fresh from ~/.securecoms/connections/<identity_id>.json on every connect
15
+ attempt.
16
+ author: AgentVault
17
+ optional_env:
18
+ - name: SECURECOMS_ALLOW_ALL_USERS
19
+ description: "Allow any member of an admitted SecureComs channel to talk to the agent (the channel is already E2E-restricted to admitted members)"
20
+ prompt: "Allow all users? (true/false)"
21
+ password: false
22
+ - name: SECURECOMS_ALLOWED_USERS
23
+ description: "Comma-separated SecureComs sender identity_ids allowed to talk to the agent"
24
+ prompt: "Allowed sender identity_ids"
25
+ password: false
@@ -0,0 +1,3 @@
1
+ [pytest]
2
+ asyncio_mode = auto
3
+ addopts = --import-mode=importlib
@@ -0,0 +1,70 @@
1
+ # packages/connect-agent-bridge/connectors/hermes/securecoms_gate.py
2
+ #
3
+ # Python port of ../openclaw/gate.js. Pure functions, no I/O, stdlib only.
4
+ # Proves the ADR-0009 connector contract isn't language-shaped.
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import math
10
+ from typing import Callable, Mapping, Optional, Tuple
11
+
12
+
13
+ def resolve_bound_identity(
14
+ env: Mapping[str, str],
15
+ read_file_text: Callable[[str], str],
16
+ binding_path: str,
17
+ ) -> Optional[str]:
18
+ """bound_identity: explicit binding ONLY.
19
+
20
+ There is no directory-census / auto-discovery path here (audit C1) -
21
+ bound_identity must be a positive act (an env var or a binding file),
22
+ never inferred by scanning a connections directory for a sole entry.
23
+ Any read_file_text exception (missing file, bad JSON, ...) is treated
24
+ as "absent" and falls through.
25
+ """
26
+ conn_file = env.get("SECURECOMS_CONNECTION_FILE") if env else None
27
+ if conn_file:
28
+ try:
29
+ identity_id = json.loads(read_file_text(conn_file)).get("identity_id")
30
+ if identity_id:
31
+ return identity_id
32
+ except Exception:
33
+ pass # fall through
34
+ try:
35
+ identity_id = json.loads(read_file_text(binding_path)).get("identity_id")
36
+ if identity_id:
37
+ return identity_id
38
+ except Exception:
39
+ pass # no binding
40
+ return None
41
+
42
+
43
+ def evaluate_gate(
44
+ bound_identity: Optional[str],
45
+ file_identity: Optional[str],
46
+ healthz_identity: Optional[str],
47
+ ) -> Tuple[bool, str]:
48
+ """The two-anchor gate: refuse unless the connector's own binding, the
49
+ on-disk connection file, and the daemon's live healthz identity all
50
+ agree. Order matters (see the check-order mutation in the task brief) -
51
+ check 1 must run before check 2.
52
+ """
53
+ if not bound_identity:
54
+ return False, "unwired: no binding (refusing to guess)"
55
+ if file_identity != healthz_identity:
56
+ return False, "check 1 failed: file identity != daemon identity (stale port reuse)"
57
+ if bound_identity != healthz_identity:
58
+ return False, "check 2 failed: bound identity != daemon identity (wrong file / compartment)"
59
+ return True, ""
60
+
61
+
62
+ def backoff_delay_ms(attempt: int, rand: Callable[[], float]) -> int:
63
+ """Full-jitter exponential backoff, capped at 30s. `rand` is injected
64
+ (rather than reaching for the stdlib `random` module) so tests can pin
65
+ the jitter and assert the ceiling deterministically (F6).
66
+ """
67
+ base = 250
68
+ cap = 30000
69
+ exp = min(cap, base * (2 ** max(0, attempt)))
70
+ return math.floor(rand() * exp)