@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.
@@ -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
- "name": c.get("name") or c["channel_id"],
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 base behaviour."""
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
- _TURN_AUTHZ.record(channel_id, sender_id, tools_allowed)
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
- chat_name=meta.get("name", channel_id),
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
- user_name=sender_id,
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
- event._securecoms_session_was_busy = self._session_was_busy(event)
913
- await self.handle_message(event)
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 granted
949
- tools. Anything else, including an unknown source, gets the no-tools
950
- override."""
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
- if _TURN_AUTHZ.tools_allowed(chat_id, user_id):
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
- from gateway.session_context import get_session_env
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 user
131
- Hermes binds to that turn's session). Unrecorded or evicted keys have no
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 record(self, chat_id: Any, user_id: Any, allowed: bool) -> None:
144
- key = (chat_id, user_id)
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
- return self._entries.get((chat_id, user_id)) is True
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