@securecoms/agent-bridge 0.38.0 → 0.39.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 +17 -0
- package/connectors/display-name-fixtures.json +605 -0
- package/connectors/hermes/__pycache__/securecoms_gate.cpython-310.pyc +0 -0
- package/connectors/hermes/__pycache__/securecoms_transport.cpython-310.pyc +0 -0
- package/connectors/hermes/adapter.py +243 -12
- package/connectors/hermes/securecoms_authz.py +112 -11
- package/connectors/hermes/securecoms_transport.py +109 -0
- package/connectors/hermes/tests/test_adapter_wiring.py +254 -7
- package/connectors/hermes/tests/test_busy_session.py +1436 -0
- package/connectors/hermes/tests/test_securecoms_authz.py +194 -0
- package/connectors/hermes/tests/test_securecoms_transport.py +29 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +136 -4
- package/connectors/openclaw/openclaw-entry.js +111 -1
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -140,6 +140,17 @@ _ACCEPT_POLL_S = 0.02
|
|
|
140
140
|
# One warning per process when this Hermes lacks the acceptance signal.
|
|
141
141
|
_TURN_TRACKING_WARNED = False
|
|
142
142
|
|
|
143
|
+
# H0: Hermes session key -> [(ledger token, runner key)] for every ungranted
|
|
144
|
+
# event dispatched into that session and not yet released. Module-level, like
|
|
145
|
+
# _TURN_AUTHZ, so a replaced adapter instance or a task cancelled before its
|
|
146
|
+
# first step cannot strand a count: the next completion that finds the session
|
|
147
|
+
# idle releases it, whichever adapter instance observes it.
|
|
148
|
+
_ATTACHED: Dict[Any, list] = {}
|
|
149
|
+
# One warning per process each: an idle input Hermes does not expose (so no
|
|
150
|
+
# count can be released), and H0.2b's shared room sessions.
|
|
151
|
+
_IDLE_INPUTS_WARNED = False
|
|
152
|
+
_SHARED_ROOMS_WARNED = False
|
|
153
|
+
|
|
143
154
|
# Hermes's Signal adapter mapping (_MEDIA_TYPE_BY_MIME_PREFIX): first matching
|
|
144
155
|
# prefix wins; anything else is a DOCUMENT so Hermes surfaces the file path.
|
|
145
156
|
_MEDIA_TYPE_BY_MIME_PREFIX = (("audio/", "VOICE"), ("image/", "PHOTO"), ("video/", "VIDEO"))
|
|
@@ -306,6 +317,16 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
306
317
|
self._send_status_frame, now=time.monotonic
|
|
307
318
|
)
|
|
308
319
|
|
|
320
|
+
# H0.2b: Hermes shares ONE room session across every sender when
|
|
321
|
+
# group_sessions_per_user is off; every room turn is then ungranted.
|
|
322
|
+
self._room_sessions_shared = self._group_sessions_shared()
|
|
323
|
+
|
|
324
|
+
def _group_sessions_shared(self) -> bool:
|
|
325
|
+
extra = getattr(self.config, "extra", None)
|
|
326
|
+
if not isinstance(extra, dict):
|
|
327
|
+
return True
|
|
328
|
+
return extra.get("group_sessions_per_user", True) is not True
|
|
329
|
+
|
|
309
330
|
@property
|
|
310
331
|
def name(self) -> str:
|
|
311
332
|
return "SecureComs"
|
|
@@ -327,6 +348,14 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
327
348
|
"""
|
|
328
349
|
del is_reconnect # accepted for contract parity; nothing to preserve
|
|
329
350
|
self._stopped = False
|
|
351
|
+
self._room_sessions_shared = self._group_sessions_shared()
|
|
352
|
+
global _SHARED_ROOMS_WARNED
|
|
353
|
+
if self._room_sessions_shared and not _SHARED_ROOMS_WARNED:
|
|
354
|
+
_SHARED_ROOMS_WARNED = True
|
|
355
|
+
logger.warning(
|
|
356
|
+
"SecureComs: group_sessions_per_user is off, so a room shares one Hermes session "
|
|
357
|
+
"across senders; every room turn runs without tools"
|
|
358
|
+
)
|
|
330
359
|
|
|
331
360
|
# BINDING-STABILITY fold: resolve bound_identity here, ONCE, for the
|
|
332
361
|
# entire process life of this adapter instance. Every subsequent
|
|
@@ -523,7 +552,9 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
523
552
|
if r.status == 200:
|
|
524
553
|
for c in await r.json():
|
|
525
554
|
self._channels[c["channel_id"]] = {
|
|
526
|
-
|
|
555
|
+
# Member-chosen and reaches the prompt (chat_name,
|
|
556
|
+
# get_chat_info): sanitized once, here (R3).
|
|
557
|
+
"name": securecoms_transport.display_name(c.get("name")) or c["channel_id"],
|
|
527
558
|
"is_dm": bool(c.get("is_dm")),
|
|
528
559
|
}
|
|
529
560
|
|
|
@@ -708,15 +739,127 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
708
739
|
|
|
709
740
|
async def on_processing_complete(self, event: Any, outcome: Any) -> None:
|
|
710
741
|
"""Hermes lifecycle hook (base.py:3448): record the outcome on the
|
|
711
|
-
event for _await_acceptance, then keep the
|
|
742
|
+
event for _await_acceptance, run the H0 release check, then keep the
|
|
743
|
+
base behaviour."""
|
|
712
744
|
try:
|
|
713
745
|
event._securecoms_outcome = getattr(outcome, "name", None) or "UNKNOWN"
|
|
714
746
|
except Exception:
|
|
715
747
|
pass
|
|
748
|
+
try:
|
|
749
|
+
self._settle_on_completion(event)
|
|
750
|
+
except Exception as e:
|
|
751
|
+
logger.warning(
|
|
752
|
+
"SecureComs: ungranted-turn release check failed; its sender stays without tools "
|
|
753
|
+
"(error_class=%s)",
|
|
754
|
+
_error_class(e),
|
|
755
|
+
)
|
|
716
756
|
base = getattr(super(), "on_processing_complete", None)
|
|
717
757
|
if callable(base):
|
|
718
758
|
await base(event, outcome)
|
|
719
759
|
|
|
760
|
+
# ── H0: ungranted turns in flight ─────────────────────────────────────
|
|
761
|
+
#
|
|
762
|
+
# Hermes v2026.9.24 (read on the Mini): on_processing_complete fires once
|
|
763
|
+
# per started task, for the event that started it only (base.py:4520-4540),
|
|
764
|
+
# before the debounce flush and the pending pop. An event can reach a turn
|
|
765
|
+
# without starting it (redirect, steer, base merge, runner FIFO, debounce),
|
|
766
|
+
# and an event that DID start its own base task can still be parked by the
|
|
767
|
+
# runner behind a pending sentinel (run_inbound.py:690-697) or swapped for
|
|
768
|
+
# an orphaned FIFO head, so its own completion does not prove its text has
|
|
769
|
+
# run. Every ungranted event's count is therefore attached to its Hermes
|
|
770
|
+
# session at dispatch and comes down only when the event never reached
|
|
771
|
+
# Hermes, or at a moment its session is idle: no other running task, no
|
|
772
|
+
# command guard, nothing pending or debounced, an empty runner FIFO and no
|
|
773
|
+
# runner turn. Never on a grant. Anything unreadable counts as busy.
|
|
774
|
+
|
|
775
|
+
def _session_key_of(self, event: Any) -> Optional[str]:
|
|
776
|
+
key_fn = getattr(self, "_event_session_key", None)
|
|
777
|
+
if not callable(key_fn):
|
|
778
|
+
return None
|
|
779
|
+
try:
|
|
780
|
+
key = key_fn(event)
|
|
781
|
+
except Exception:
|
|
782
|
+
return None
|
|
783
|
+
return key if isinstance(key, str) and key else None
|
|
784
|
+
|
|
785
|
+
def _runner_key_of(self, event: Any) -> Optional[str]:
|
|
786
|
+
runner = getattr(self, "gateway_runner", None)
|
|
787
|
+
key_fn = getattr(runner, "_session_key_for_source", None)
|
|
788
|
+
if not callable(key_fn):
|
|
789
|
+
return None
|
|
790
|
+
try:
|
|
791
|
+
key = key_fn(event.source)
|
|
792
|
+
except Exception:
|
|
793
|
+
return None
|
|
794
|
+
return key if isinstance(key, str) and key else None
|
|
795
|
+
|
|
796
|
+
@staticmethod
|
|
797
|
+
def _warn_idle_inputs_missing() -> None:
|
|
798
|
+
global _IDLE_INPUTS_WARNED
|
|
799
|
+
if _IDLE_INPUTS_WARNED:
|
|
800
|
+
return
|
|
801
|
+
_IDLE_INPUTS_WARNED = True
|
|
802
|
+
logger.warning(
|
|
803
|
+
"SecureComs: this Hermes does not expose its session idle state; a sender whose "
|
|
804
|
+
"turn ran without tools stays without tools"
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
def _session_idle(self, session_key: str, completing: Any, runner_keys=()) -> bool:
|
|
808
|
+
"""True only when nothing in this Hermes session can still be
|
|
809
|
+
processing an event's text. ``completing`` is the task whose
|
|
810
|
+
on_processing_complete is running (it is ending, so it is not busy)."""
|
|
811
|
+
try:
|
|
812
|
+
active = getattr(self, "_active_sessions", None)
|
|
813
|
+
tasks = getattr(self, "_session_tasks", None)
|
|
814
|
+
pending = getattr(self, "_pending_messages", None)
|
|
815
|
+
store_fn = getattr(self, "_text_debounce_store", None)
|
|
816
|
+
runner = getattr(self, "gateway_runner", None)
|
|
817
|
+
peek = getattr(runner, "_peek_session_state", None)
|
|
818
|
+
if (
|
|
819
|
+
not isinstance(active, dict) or not isinstance(tasks, dict) or not isinstance(pending, dict)
|
|
820
|
+
or not callable(store_fn) or not callable(peek)
|
|
821
|
+
):
|
|
822
|
+
self._warn_idle_inputs_missing()
|
|
823
|
+
return False
|
|
824
|
+
if session_key in pending:
|
|
825
|
+
return False
|
|
826
|
+
store = store_fn()
|
|
827
|
+
if not isinstance(store, dict) or session_key in store:
|
|
828
|
+
return False
|
|
829
|
+
task = tasks.get(session_key)
|
|
830
|
+
if task is not None and task is not completing and not task.done():
|
|
831
|
+
return False
|
|
832
|
+
if session_key in active and task is None:
|
|
833
|
+
return False # a guard with no owner task: a command is running
|
|
834
|
+
for key in {session_key, *(k for k in runner_keys if k)}:
|
|
835
|
+
state = peek(key)
|
|
836
|
+
if state is None:
|
|
837
|
+
continue
|
|
838
|
+
if state.turn.agent is not None:
|
|
839
|
+
return False
|
|
840
|
+
if state.conversation.queued_events:
|
|
841
|
+
return False
|
|
842
|
+
return True
|
|
843
|
+
except Exception:
|
|
844
|
+
return False
|
|
845
|
+
|
|
846
|
+
def _release_if_idle(self, session_key: Optional[str], completing: Any, runner_key: Optional[str] = None) -> None:
|
|
847
|
+
"""Release every count attached to this session once it is idle."""
|
|
848
|
+
if session_key is None:
|
|
849
|
+
return
|
|
850
|
+
waiting = _ATTACHED.get(session_key)
|
|
851
|
+
if not waiting:
|
|
852
|
+
return
|
|
853
|
+
runner_keys = tuple(rk for _, rk in waiting) + (runner_key,)
|
|
854
|
+
if self._session_idle(session_key, completing, runner_keys):
|
|
855
|
+
for token, _ in _ATTACHED.pop(session_key, []):
|
|
856
|
+
_TURN_AUTHZ.end_ungranted(*token)
|
|
857
|
+
|
|
858
|
+
def _settle_on_completion(self, event: Any) -> None:
|
|
859
|
+
"""H0.2 (d)+(e): at every completion in a session, release what is
|
|
860
|
+
attached to it if the session is idle."""
|
|
861
|
+
self._release_if_idle(self._session_key_of(event), asyncio.current_task(), self._runner_key_of(event))
|
|
862
|
+
|
|
720
863
|
async def _await_acceptance(self, event: Any) -> str:
|
|
721
864
|
""""accepted" once Hermes holds the turn durably (marker persisted) or
|
|
722
865
|
has finished it; "cancelled" when Hermes cancelled it before that
|
|
@@ -834,6 +977,10 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
834
977
|
# (Hermes keys group sessions per user), never a DM: guessing DM would
|
|
835
978
|
# put a room's messages into a direct-chat session.
|
|
836
979
|
chat_type = "dm" if meta.get("is_dm") is True else "group"
|
|
980
|
+
if securecoms_authz.block_says_dm(frame):
|
|
981
|
+
# Audit r2: the daemon's block marks a DM whatever this cache says
|
|
982
|
+
# (a cache miss or a failed refresh must not skip the H0.2c split).
|
|
983
|
+
chat_type = "dm"
|
|
837
984
|
|
|
838
985
|
global _FRAMES_WITHOUT_AUTHORIZATION, _ATTACHMENTS_DROPPED
|
|
839
986
|
if not isinstance(frame.get("authorization"), dict):
|
|
@@ -845,7 +992,20 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
845
992
|
"Upgrade @securecoms/agent-bridge"
|
|
846
993
|
)
|
|
847
994
|
tools_allowed = securecoms_authz.tools_allowed(frame)
|
|
848
|
-
|
|
995
|
+
if tools_allowed and self._room_sessions_shared and chat_type != "dm":
|
|
996
|
+
tools_allowed = False # H0.2b: one room session for every sender
|
|
997
|
+
# H0.2c: Hermes keys a DM session by chat only. Every non-owner DM turn
|
|
998
|
+
# runs in its own session (thread NON_OWNER_THREAD), so its text never
|
|
999
|
+
# enters the transcript the owner's granted turns run in.
|
|
1000
|
+
thread_id = None
|
|
1001
|
+
if chat_type == "dm":
|
|
1002
|
+
thread_id = securecoms_authz.dm_thread(frame)
|
|
1003
|
+
if thread_id is not None:
|
|
1004
|
+
# Defence in depth only: no non-owner or unclassified frame passes
|
|
1005
|
+
# securecoms_authz.tools_allowed, and the ledger forces these
|
|
1006
|
+
# threads off. Removing this line is an equivalent mutant (N4).
|
|
1007
|
+
tools_allowed = False
|
|
1008
|
+
_TURN_AUTHZ.record(channel_id, sender_id, tools_allowed, thread_id)
|
|
849
1009
|
if not tools_allowed and not _TOOL_VETO_ACTIVE:
|
|
850
1010
|
# This Hermes cannot be made to refuse tool calls, so a turn the
|
|
851
1011
|
# daemon did not grant tools never reaches it. Acked at once by
|
|
@@ -856,11 +1016,25 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
856
1016
|
|
|
857
1017
|
source = self.build_source(
|
|
858
1018
|
chat_id=channel_id,
|
|
859
|
-
|
|
1019
|
+
# The room name is member-chosen and reaches the prompt: same
|
|
1020
|
+
# sanitizer as the sender label, falling back to the channel id.
|
|
1021
|
+
chat_name=securecoms_transport.display_name(meta.get("name")) or channel_id,
|
|
860
1022
|
chat_type=chat_type,
|
|
1023
|
+
# user_id (Hermes's session and the tool ledger key) is the
|
|
1024
|
+
# relay-attributed sender_id; user_name is a sanitized label of
|
|
1025
|
+
# that same sender (R3). thread_id: H0.2c above.
|
|
861
1026
|
user_id=sender_id,
|
|
862
|
-
|
|
1027
|
+
thread_id=thread_id,
|
|
1028
|
+
user_name=securecoms_transport.own_label(frame),
|
|
863
1029
|
)
|
|
1030
|
+
speaker = securecoms_transport.speaker_label(frame)
|
|
1031
|
+
if speaker and isinstance(text, str):
|
|
1032
|
+
# A relayed voice room: one Hermes session (the relay's) holds
|
|
1033
|
+
# several speakers, and Hermes pins user_name in the per-session
|
|
1034
|
+
# system prompt, so the real speaker rides each turn's text, the
|
|
1035
|
+
# way Hermes's own shared sessions prefix "[sender name]". The
|
|
1036
|
+
# label's name part cannot contain "[]()" (R3, M2).
|
|
1037
|
+
text = f"[{speaker}] {text}"
|
|
864
1038
|
if not tools_allowed and isinstance(text, str):
|
|
865
1039
|
# An ungranted sender must never drive gateway commands. EVERY
|
|
866
1040
|
# ungranted text is escaped: Hermes strips leading @url:/@file:
|
|
@@ -909,10 +1083,51 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
909
1083
|
prompts = [p for p in (voice_prompt_of(frame), None if tools_allowed else securecoms_authz.NO_TOOLS_TURN_NOTE) if p]
|
|
910
1084
|
if prompts:
|
|
911
1085
|
event.channel_prompt = "\n\n".join(prompts)
|
|
912
|
-
|
|
913
|
-
|
|
1086
|
+
token = None
|
|
1087
|
+
session_key = None
|
|
1088
|
+
if not tools_allowed:
|
|
1089
|
+
# H0.2 (a): this key has no tools until no turn in its session can
|
|
1090
|
+
# still be processing this event's text. Attached to the session
|
|
1091
|
+
# now; released only when the session is idle.
|
|
1092
|
+
token = (channel_id, sender_id, thread_id, self._room_sessions_shared and chat_type != "dm")
|
|
1093
|
+
_TURN_AUTHZ.begin_ungranted(*token)
|
|
1094
|
+
session_key = self._session_key_of(event)
|
|
1095
|
+
if session_key is None:
|
|
1096
|
+
logger.warning(
|
|
1097
|
+
"SecureComs: an ungranted turn's Hermes session is unknown; its sender stays without tools"
|
|
1098
|
+
)
|
|
1099
|
+
else:
|
|
1100
|
+
_ATTACHED.setdefault(session_key, []).append((token, self._runner_key_of(event)))
|
|
1101
|
+
reached = False
|
|
1102
|
+
try:
|
|
1103
|
+
event._securecoms_session_was_busy = self._session_was_busy(event)
|
|
1104
|
+
reached = True
|
|
1105
|
+
await self.handle_message(event)
|
|
1106
|
+
except BaseException:
|
|
1107
|
+
if token is not None and not reached:
|
|
1108
|
+
self._detach(session_key, token) # never reached Hermes
|
|
1109
|
+
_TURN_AUTHZ.end_ungranted(*token)
|
|
1110
|
+
elif token is not None:
|
|
1111
|
+
self._release_if_idle(session_key, None)
|
|
1112
|
+
raise
|
|
1113
|
+
if token is not None:
|
|
1114
|
+
# (b)/(c): released now only if nothing in the session can still
|
|
1115
|
+
# be processing it; else at a later idle completion (d)/(e).
|
|
1116
|
+
self._release_if_idle(session_key, None)
|
|
914
1117
|
return event
|
|
915
1118
|
|
|
1119
|
+
@staticmethod
|
|
1120
|
+
def _detach(session_key: Optional[str], token: tuple) -> None:
|
|
1121
|
+
waiting = _ATTACHED.get(session_key) if session_key is not None else None
|
|
1122
|
+
if not waiting:
|
|
1123
|
+
return
|
|
1124
|
+
for i, (t, _) in enumerate(waiting):
|
|
1125
|
+
if t is token:
|
|
1126
|
+
del waiting[i]
|
|
1127
|
+
break
|
|
1128
|
+
if not waiting:
|
|
1129
|
+
_ATTACHED.pop(session_key, None)
|
|
1130
|
+
|
|
916
1131
|
def _may_look_up(self, channel_id: str) -> bool:
|
|
917
1132
|
"""Negative cache for unknown channels: True at most once per
|
|
918
1133
|
_UNKNOWN_CHANNEL_RETRY_S per channel (bounded map, oldest evicted)."""
|
|
@@ -945,12 +1160,14 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
945
1160
|
def toolsets_for_source(self, source) -> Optional[list]:
|
|
946
1161
|
"""Hermes per-turn toolset override (gateway run_turn
|
|
947
1162
|
_resolve_enabled_toolsets_for_source). None keeps the configured
|
|
948
|
-
toolsets: only for a (chat, sender) whose latest turn was
|
|
949
|
-
tools
|
|
950
|
-
|
|
1163
|
+
toolsets: only for a (chat, sender, thread) whose latest turn was
|
|
1164
|
+
granted tools and that has no ungranted turn still in flight (H0).
|
|
1165
|
+
Anything else, including an unknown source and every turn in the
|
|
1166
|
+
non-owner DM thread, gets the no-tools override."""
|
|
951
1167
|
chat_id = getattr(source, "chat_id", None)
|
|
952
1168
|
user_id = getattr(source, "user_id", None)
|
|
953
|
-
|
|
1169
|
+
thread_id = getattr(source, "thread_id", None)
|
|
1170
|
+
if _TURN_AUTHZ.tools_allowed(chat_id, user_id, thread_id):
|
|
954
1171
|
return None
|
|
955
1172
|
return list(securecoms_authz.NO_TOOLS_TOOLSETS)
|
|
956
1173
|
|
|
@@ -1097,7 +1314,21 @@ def _install_tool_veto(ctx) -> bool:
|
|
|
1097
1314
|
):
|
|
1098
1315
|
return False
|
|
1099
1316
|
from gateway import session_context
|
|
1100
|
-
|
|
1317
|
+
|
|
1318
|
+
# Read the turn's task-local ContextVars directly: Hermes's own
|
|
1319
|
+
# get_session_env falls back to os.environ, which a concurrent turn or
|
|
1320
|
+
# the process can set. Unset (_UNSET) reads as None, never as a value.
|
|
1321
|
+
var_map = getattr(session_context, "_VAR_MAP", None)
|
|
1322
|
+
unset = getattr(session_context, "_UNSET", None)
|
|
1323
|
+
names = ("HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_USER_ID", "HERMES_SESSION_THREAD_ID")
|
|
1324
|
+
if unset is None or not isinstance(var_map, dict) or not all(
|
|
1325
|
+
callable(getattr(var_map.get(n), "get", None)) for n in names
|
|
1326
|
+
):
|
|
1327
|
+
return False
|
|
1328
|
+
|
|
1329
|
+
def get_session_env(name, default=""):
|
|
1330
|
+
value = var_map[name].get()
|
|
1331
|
+
return None if value is unset else value
|
|
1101
1332
|
|
|
1102
1333
|
engaged = getattr(session_context, "session_context_engaged", None)
|
|
1103
1334
|
_SESSION_ENGAGED = engaged if callable(engaged) else None
|
|
@@ -113,6 +113,56 @@ def tools_allowed(frame: Any) -> bool:
|
|
|
113
113
|
return all(_is_grant(g) for g in grants)
|
|
114
114
|
|
|
115
115
|
|
|
116
|
+
# H0.2c (re-ruled after code audit r1): Hermes keys a DM session by chat only,
|
|
117
|
+
# so every non-owner DM turn (a guest's relayed voice, any other sender, a frame
|
|
118
|
+
# with no valid block) runs in its own Hermes session, selected by this
|
|
119
|
+
# thread id. No ":" (Hermes joins session-key parts with ":"). A ledger record
|
|
120
|
+
# under this thread is never a grant.
|
|
121
|
+
NON_OWNER_THREAD = "sc-non-owner"
|
|
122
|
+
# Audit r2 (ruled): a DM frame whose block cannot be classified (missing,
|
|
123
|
+
# malformed, wrong version, or sender_membership not "active", e.g. the owner
|
|
124
|
+
# during a stale roster) runs in a THIRD session, never the owner's and never
|
|
125
|
+
# the guests'. Forced ungranted like NON_OWNER_THREAD.
|
|
126
|
+
UNCLASSIFIED_THREAD = "sc-unclassified"
|
|
127
|
+
_FORCED_OFF_THREADS = (NON_OWNER_THREAD, UNCLASSIFIED_THREAD)
|
|
128
|
+
# authorizationFor (bridge-agent.ts:9093-9094) sets these audiences only when
|
|
129
|
+
# the channel is a DM, and sets relay_speaker only inside its is_dm branch.
|
|
130
|
+
_DM_AUDIENCES = ("owner_dm", "other_human_dm")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def is_owner_turn(frame: Any) -> bool:
|
|
134
|
+
"""True only for a valid block with ``audience == "owner_dm"`` or the
|
|
135
|
+
daemon's ``relay_speaker: "owner"`` mark (granted or not). Anything else,
|
|
136
|
+
including no block or a malformed one, is a non-owner turn."""
|
|
137
|
+
block = _valid_block(frame)
|
|
138
|
+
if block is None:
|
|
139
|
+
return False
|
|
140
|
+
return block.get("audience") == "owner_dm" or block.get("relay_speaker") == "owner"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def dm_thread(frame: Any) -> Optional[str]:
|
|
144
|
+
"""The Hermes thread for a DM turn: None for an owner turn (owner session),
|
|
145
|
+
UNCLASSIFIED_THREAD when the block cannot be classified, else
|
|
146
|
+
NON_OWNER_THREAD."""
|
|
147
|
+
if _valid_block(frame) is None:
|
|
148
|
+
return UNCLASSIFIED_THREAD
|
|
149
|
+
return None if is_owner_turn(frame) else NON_OWNER_THREAD
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def block_says_dm(frame: Any) -> bool:
|
|
153
|
+
"""True when the daemon's block marks this turn as a DM turn (a DM
|
|
154
|
+
audience or any relay_speaker mark). Read from the raw block, valid or
|
|
155
|
+
not: it only ever moves a turn OUT of a shared session, never grants."""
|
|
156
|
+
block = frame.get("authorization") if isinstance(frame, dict) else None
|
|
157
|
+
if not isinstance(block, dict):
|
|
158
|
+
return False
|
|
159
|
+
return block.get("audience") in _DM_AUDIENCES or "relay_speaker" in block
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _thread_key(thread_id: Any) -> Any:
|
|
163
|
+
return "" if thread_id is None else thread_id
|
|
164
|
+
|
|
165
|
+
|
|
116
166
|
def sender_role(frame: Any) -> str:
|
|
117
167
|
"""owner / member / other_org / agent / unknown, from the daemon's
|
|
118
168
|
audience. Descriptive metadata for Hermes, never an authorization input:
|
|
@@ -124,12 +174,24 @@ def sender_role(frame: Any) -> str:
|
|
|
124
174
|
|
|
125
175
|
|
|
126
176
|
class TurnAuthzLedger:
|
|
127
|
-
"""The tool decision of the latest inbound frame per (chat_id, sender
|
|
177
|
+
"""The tool decision of the latest inbound frame per (chat_id, sender,
|
|
178
|
+
thread), held down by any ungranted turn for that key still in flight.
|
|
128
179
|
|
|
129
180
|
Hermes runs a turn later, on its own task, so the decision is recorded at
|
|
130
|
-
dispatch and looked up when a tool is about to run (by the chat and
|
|
131
|
-
Hermes binds to that turn's session). Unrecorded or evicted keys
|
|
132
|
-
tools.
|
|
181
|
+
dispatch and looked up when a tool is about to run (by the chat, user and
|
|
182
|
+
thread Hermes binds to that turn's session). Unrecorded or evicted keys
|
|
183
|
+
have no tools. A record under NON_OWNER_THREAD or UNCLASSIFIED_THREAD is
|
|
184
|
+
always "no tools".
|
|
185
|
+
|
|
186
|
+
H0 (audit r1 #3, r2 #1): the latest decision alone let a guest's turn that
|
|
187
|
+
was still running borrow a grant recorded later for the same key. An
|
|
188
|
+
ungranted event therefore holds an in-flight count (begin_ungranted /
|
|
189
|
+
end_ungranted) until no turn in its Hermes session can still be processing
|
|
190
|
+
its text; while the count is above zero the key has no tools, whatever the
|
|
191
|
+
latest decision says. A ``shared_session`` count (H0.2b: a room whose
|
|
192
|
+
Hermes session is shared by every sender) blocks every key in that chat.
|
|
193
|
+
The counts are never evicted (dropping one would fail open) and a grant
|
|
194
|
+
never lowers them.
|
|
133
195
|
"""
|
|
134
196
|
|
|
135
197
|
def __init__(self, max_entries: int = 4096):
|
|
@@ -139,13 +201,42 @@ class TurnAuthzLedger:
|
|
|
139
201
|
# channel), so a chat whose decision was evicted is still known to be
|
|
140
202
|
# SecureComs and its tool calls are still gated.
|
|
141
203
|
self._chats: set = set()
|
|
204
|
+
# In-flight ungranted events per (chat_id, user_id, thread), and per
|
|
205
|
+
# chat for shared room sessions. Never evicted by max_entries.
|
|
206
|
+
self._inflight: Dict[tuple, int] = {}
|
|
207
|
+
self._inflight_chat: Dict[Any, int] = {}
|
|
208
|
+
|
|
209
|
+
def begin_ungranted(self, chat_id: Any, user_id: Any, thread_id: Any = None, shared_session: bool = False) -> None:
|
|
210
|
+
key = (chat_id, user_id, _thread_key(thread_id))
|
|
211
|
+
if isinstance(chat_id, str) and chat_id:
|
|
212
|
+
self._chats.add(chat_id)
|
|
213
|
+
self._inflight[key] = self._inflight.get(key, 0) + 1
|
|
214
|
+
if shared_session:
|
|
215
|
+
self._inflight_chat[chat_id] = self._inflight_chat.get(chat_id, 0) + 1
|
|
216
|
+
|
|
217
|
+
def end_ungranted(self, chat_id: Any, user_id: Any, thread_id: Any = None, shared_session: bool = False) -> None:
|
|
218
|
+
key = (chat_id, user_id, _thread_key(thread_id))
|
|
219
|
+
n = self._inflight.get(key, 0)
|
|
220
|
+
if n <= 1:
|
|
221
|
+
self._inflight.pop(key, None)
|
|
222
|
+
else:
|
|
223
|
+
self._inflight[key] = n - 1
|
|
224
|
+
if shared_session:
|
|
225
|
+
c = self._inflight_chat.get(chat_id, 0)
|
|
226
|
+
if c <= 1:
|
|
227
|
+
self._inflight_chat.pop(chat_id, None)
|
|
228
|
+
else:
|
|
229
|
+
self._inflight_chat[chat_id] = c - 1
|
|
142
230
|
|
|
143
|
-
def
|
|
144
|
-
|
|
231
|
+
def ungranted_in_flight(self, chat_id: Any, user_id: Any, thread_id: Any = None) -> int:
|
|
232
|
+
return self._inflight.get((chat_id, user_id, _thread_key(thread_id)), 0)
|
|
233
|
+
|
|
234
|
+
def record(self, chat_id: Any, user_id: Any, allowed: bool, thread_id: Any = None) -> None:
|
|
235
|
+
key = (chat_id, user_id, _thread_key(thread_id))
|
|
145
236
|
if isinstance(chat_id, str) and chat_id:
|
|
146
237
|
self._chats.add(chat_id)
|
|
147
238
|
self._entries.pop(key, None)
|
|
148
|
-
self._entries[key] = allowed is True
|
|
239
|
+
self._entries[key] = allowed is True and thread_id not in _FORCED_OFF_THREADS
|
|
149
240
|
while len(self._entries) > self._max:
|
|
150
241
|
self._entries.popitem(last=False)
|
|
151
242
|
|
|
@@ -155,10 +246,15 @@ class TurnAuthzLedger:
|
|
|
155
246
|
return False
|
|
156
247
|
return chat_id in self._chats
|
|
157
248
|
|
|
158
|
-
def tools_allowed(self, chat_id: Any, user_id: Any) -> bool:
|
|
249
|
+
def tools_allowed(self, chat_id: Any, user_id: Any, thread_id: Any = None) -> bool:
|
|
159
250
|
if not isinstance(chat_id, str) or not chat_id or not isinstance(user_id, str) or not user_id:
|
|
160
251
|
return False
|
|
161
|
-
|
|
252
|
+
if thread_id is not None and not isinstance(thread_id, str):
|
|
253
|
+
return False
|
|
254
|
+
key = (chat_id, user_id, _thread_key(thread_id))
|
|
255
|
+
if self._inflight.get(key, 0) > 0 or self._inflight_chat.get(chat_id, 0) > 0:
|
|
256
|
+
return False
|
|
257
|
+
return self._entries.get(key) is True
|
|
162
258
|
|
|
163
259
|
|
|
164
260
|
def pre_tool_call_decision(
|
|
@@ -180,14 +276,19 @@ def pre_tool_call_decision(
|
|
|
180
276
|
Hermes's task-local cron ContextVar; the environment never counts
|
|
181
277
|
(None = no exemption).
|
|
182
278
|
- Any other platform: no opinion.
|
|
183
|
-
A failure to read the session blocks.
|
|
279
|
+
A failure to read the session blocks. ``get_session_env(name, default)``
|
|
280
|
+
returns the turn's task-local value, or None when the variable is unset in
|
|
281
|
+
this task (no environment fallback): an unset or empty chat or user, or an
|
|
282
|
+
unset thread, blocks a SecureComs turn. The thread is "" for an owner turn.
|
|
184
283
|
"""
|
|
185
284
|
try:
|
|
186
285
|
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
|
|
187
286
|
chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "")
|
|
188
287
|
user_id = get_session_env("HERMES_SESSION_USER_ID", "")
|
|
288
|
+
thread_id = get_session_env("HERMES_SESSION_THREAD_ID", "")
|
|
289
|
+
platform = "" if platform is None else platform
|
|
189
290
|
if platform == PLATFORM_ID or ledger.knows_chat(chat_id):
|
|
190
|
-
if ledger.tools_allowed(chat_id, user_id):
|
|
291
|
+
if isinstance(thread_id, str) and ledger.tools_allowed(chat_id, user_id, thread_id or None):
|
|
191
292
|
return None
|
|
192
293
|
elif platform == "":
|
|
193
294
|
engaged = True if session_engaged is None else session_engaged() is not False
|
|
@@ -28,6 +28,7 @@ import json
|
|
|
28
28
|
import os
|
|
29
29
|
import re
|
|
30
30
|
import stat
|
|
31
|
+
import unicodedata
|
|
31
32
|
from typing import Awaitable, Callable, List, Mapping, NamedTuple, Optional, Tuple
|
|
32
33
|
|
|
33
34
|
from securecoms_gate import evaluate_gate, resolve_bound_identity
|
|
@@ -198,6 +199,114 @@ def should_drop_inbound(frame: dict, bound_identity: str) -> bool:
|
|
|
198
199
|
return bool(sender_id) and bool(bound_identity) and sender_id == bound_identity
|
|
199
200
|
|
|
200
201
|
|
|
202
|
+
# Display names (R3). sender_name, a relayed voice turn's speaker name and a
|
|
203
|
+
# room name are member-chosen strings that reach the LLM prompt, so they must
|
|
204
|
+
# not be able to start a new line or fake prompt structure. Python twin of
|
|
205
|
+
# openclaw-entry.js's displayName/senderLabel; both are pinned to the same
|
|
206
|
+
# cases by ../display-name-fixtures.json. Residual drift: character
|
|
207
|
+
# categories come from each runtime's Unicode tables (Python unicodedata vs
|
|
208
|
+
# the JS engine's ICU), so a character assigned in a newer Unicode version
|
|
209
|
+
# can be "unassigned, dropped" in one runtime and kept in the other.
|
|
210
|
+
# A label only: identity, sessions and authorization stay on sender_id and the
|
|
211
|
+
# daemon's authorization block.
|
|
212
|
+
DISPLAY_NAME_MAX = 64
|
|
213
|
+
# These C0/C1 controls become a space (as Python isspace and JS \s do); every
|
|
214
|
+
# other C* character is dropped; every Z* character becomes a space.
|
|
215
|
+
_NAME_SPACE_CONTROLS = frozenset("\t\n\v\f\r\x1c\x1d\x1e\x1f\x85")
|
|
216
|
+
# Letters (Lo/So) that render as nothing: Hangul fillers and the blank braille
|
|
217
|
+
# pattern. Dropped so a blank-looking name falls back to the id.
|
|
218
|
+
_INVISIBLE_LETTERS = frozenset("\u115f\u1160\u3164\uffa0\u2800")
|
|
219
|
+
# Stripped from the NAME part of a label, after NFKC folds compatibility
|
|
220
|
+
# lookalikes (fullwidth, small, superscript forms) to their ASCII originals:
|
|
221
|
+
# every opening/closing/initial/final punctuation character (Ps/Pe/Pi/Pf, so
|
|
222
|
+
# any bracket or quote Unicode classifies as one) plus "<>|" and the backtick.
|
|
223
|
+
# That keeps a name from faking the trusted " (owner)" / " (<id>)" suffix, a
|
|
224
|
+
# "[name]" turn prefix, a tag or a table cell with any bracket Unicode
|
|
225
|
+
# classifies as such. A glyph that merely looks like a bracket but is
|
|
226
|
+
# classified otherwise (e.g. a letter or symbol) is not caught.
|
|
227
|
+
_LABEL_STRUCTURE = frozenset("<>|`")
|
|
228
|
+
_LABEL_STRUCTURE_CATEGORIES = frozenset(("Ps", "Pe", "Pi", "Pf"))
|
|
229
|
+
LABEL_ID_CHARS = 8
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def display_name(raw: object) -> str:
|
|
233
|
+
"""Whitespace (newlines, tabs, Unicode line/paragraph separators) becomes
|
|
234
|
+
one space; other control, format (bidi, zero-width), surrogate,
|
|
235
|
+
private-use and unassigned characters and invisible fillers are dropped;
|
|
236
|
+
runs of spaces collapse; the result is trimmed and capped at
|
|
237
|
+
DISPLAY_NAME_MAX code points. "" for a non-string or nothing left."""
|
|
238
|
+
if not isinstance(raw, str):
|
|
239
|
+
return ""
|
|
240
|
+
out = []
|
|
241
|
+
for ch in raw:
|
|
242
|
+
cat = unicodedata.category(ch)
|
|
243
|
+
if ch in _NAME_SPACE_CONTROLS or cat[0] == "Z":
|
|
244
|
+
out.append(" ")
|
|
245
|
+
elif cat[0] != "C" and ch not in _INVISIBLE_LETTERS:
|
|
246
|
+
out.append(ch)
|
|
247
|
+
return " ".join("".join(out).split())[:DISPLAY_NAME_MAX].rstrip()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _label_name(raw: object) -> str:
|
|
251
|
+
if not isinstance(raw, str):
|
|
252
|
+
return ""
|
|
253
|
+
folded = unicodedata.normalize("NFKC", raw)
|
|
254
|
+
return display_name("".join(
|
|
255
|
+
ch for ch in folded
|
|
256
|
+
if ch not in _LABEL_STRUCTURE and unicodedata.category(ch) not in _LABEL_STRUCTURE_CATEGORIES))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _valid_authorization(frame: Mapping) -> Optional[Mapping]:
|
|
260
|
+
"""The daemon's v1 block for an active sender (securecoms_authz's rule)."""
|
|
261
|
+
block = frame.get("authorization")
|
|
262
|
+
if not isinstance(block, dict):
|
|
263
|
+
return None
|
|
264
|
+
version = block.get("version")
|
|
265
|
+
if version != 1 or isinstance(version, bool) or block.get("sender_membership") != "active":
|
|
266
|
+
return None
|
|
267
|
+
return block
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _label(raw_name: object, raw_id: object, is_owner: bool) -> str:
|
|
271
|
+
"""name + a suffix built only from trusted data: " (owner)" when the
|
|
272
|
+
daemon's block says so, else " (<first LABEL_ID_CHARS of the id>)". No
|
|
273
|
+
usable name: the id itself (plus " (owner)" for the owner)."""
|
|
274
|
+
name = _label_name(raw_name)
|
|
275
|
+
ident = _label_name(raw_id)
|
|
276
|
+
if name:
|
|
277
|
+
return f"{name} ({'owner' if is_owner else (ident[:LABEL_ID_CHARS] or 'unknown')})"
|
|
278
|
+
if ident and is_owner:
|
|
279
|
+
return f"{ident} (owner)"
|
|
280
|
+
return ident
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def own_label(frame: Mapping) -> str:
|
|
284
|
+
"""The frame sender's label (the relay itself on a relayed voice turn).
|
|
285
|
+
Owner only on the daemon's owner_dm block."""
|
|
286
|
+
block = _valid_authorization(frame)
|
|
287
|
+
is_owner = block is not None and block.get("audience") == "owner_dm"
|
|
288
|
+
return _label(frame.get("sender_name"), frame.get("sender_id"), is_owner)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def speaker_label(frame: Mapping) -> str:
|
|
292
|
+
"""A relayed voice turn's speaker label, or "" when the frame has no
|
|
293
|
+
speaker with a usable identityId. Owner only on the daemon's
|
|
294
|
+
relay_speaker="owner" mark (never on what the speaker claims)."""
|
|
295
|
+
speaker = frame.get("speaker")
|
|
296
|
+
if not isinstance(speaker, dict) or not _label_name(speaker.get("identityId")):
|
|
297
|
+
return ""
|
|
298
|
+
block = _valid_authorization(frame)
|
|
299
|
+
is_owner = block is not None and block.get("relay_speaker") == "owner"
|
|
300
|
+
return _label(speaker.get("displayName"), speaker.get("identityId"), is_owner)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def sender_label(frame: Mapping) -> str:
|
|
304
|
+
"""Who the model is told spoke this turn: the relayed speaker when there
|
|
305
|
+
is one, else the sender. Display data only, never an authorization or
|
|
306
|
+
session input."""
|
|
307
|
+
return speaker_label(frame) or own_label(frame)
|
|
308
|
+
|
|
309
|
+
|
|
201
310
|
# Inbound attachments. For an artifact_share the daemon decrypts the file to
|
|
202
311
|
# <attachmentsDir>/<channel_id>/<message_id>-<name> and puts
|
|
203
312
|
# {filename, mime, size, path, sha256} on the frame. Python twin of
|