@securecoms/agent-bridge 0.38.0 → 0.40.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.
@@ -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