@securecoms/agent-bridge 0.26.3 → 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 +113 -10
- package/dist/bridge-agent.d.ts +42 -4
- package/dist/harness-report.d.ts +14 -0
- package/dist/index.js +113 -10
- package/dist/service/runtime-config-dir.d.ts +22 -0
- package/dist/version.d.ts +1 -1
- package/dist/voice/fast-prompt.d.ts +9 -0
- package/dist/ws-server.d.ts +0 -77
- package/package.json +1 -1
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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}
|