@securecoms/agent-bridge 0.37.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.
@@ -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
@@ -26,7 +26,10 @@ from __future__ import annotations
26
26
 
27
27
  import json
28
28
  import os
29
- from typing import Awaitable, Callable, Mapping, NamedTuple, Optional, Tuple
29
+ import re
30
+ import stat
31
+ import unicodedata
32
+ from typing import Awaitable, Callable, List, Mapping, NamedTuple, Optional, Tuple
30
33
 
31
34
  from securecoms_gate import evaluate_gate, resolve_bound_identity
32
35
 
@@ -196,6 +199,208 @@ def should_drop_inbound(frame: dict, bound_identity: str) -> bool:
196
199
  return bool(sender_id) and bool(bound_identity) and sender_id == bound_identity
197
200
 
198
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
+
310
+ # Inbound attachments. For an artifact_share the daemon decrypts the file to
311
+ # <attachmentsDir>/<channel_id>/<message_id>-<name> and puts
312
+ # {filename, mime, size, path, sha256} on the frame. Python twin of
313
+ # openclaw-entry.js's scanInboundAttachments. The frame's fields are claims;
314
+ # only a file that passes ALL of these reaches Hermes:
315
+ # - path: a string, absolute and already normalized (normpath collapses any
316
+ # "." or ".." segment and a trailing "/", so any traversal or an empty
317
+ # basename fails the equality), no C0/C1 control or bidi character,
318
+ # parent dir == the frame's channel id inside a dir named "attachments",
319
+ # every kept file in one dir;
320
+ # - filename (when present): no control or bidi character;
321
+ # - the file itself (opened O_NOFOLLOW|O_NONBLOCK): a regular file of at most
322
+ # MAX_INBOUND_ATTACHMENT_BYTES whose first bytes are a PNG, JPEG, GIF or
323
+ # WEBP signature, and the claimed mime equals that sniffed type.
324
+ # Images only. Anything else is dropped; at most MAX_INBOUND_ATTACHMENTS kept.
325
+ MAX_INBOUND_ATTACHMENTS = 8
326
+ MAX_INBOUND_ATTACHMENT_BYTES = 5 * 1024 * 1024
327
+ _FORBIDDEN_CHARS = re.compile("[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]")
328
+
329
+
330
+ def sniff_image_mime(b: bytes) -> Optional[str]:
331
+ """The image type from a file's leading bytes, or None."""
332
+ if b.startswith(b"\x89PNG\r\n\x1a\n"):
333
+ return "image/png"
334
+ if b.startswith(b"\xff\xd8\xff"):
335
+ return "image/jpeg"
336
+ if b.startswith(b"GIF87a") or b.startswith(b"GIF89a"):
337
+ return "image/gif"
338
+ if len(b) >= 12 and b[:4] == b"RIFF" and b[8:12] == b"WEBP":
339
+ return "image/webp"
340
+ return None
341
+
342
+
343
+ def _sniff_file(path: str) -> Optional[str]:
344
+ try:
345
+ # O_NONBLOCK: a FIFO planted at the path must not hang the gateway in
346
+ # open(); fstat below then drops it as not a regular file.
347
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
348
+ except OSError:
349
+ return None
350
+ try:
351
+ st = os.fstat(fd)
352
+ if not stat.S_ISREG(st.st_mode) or st.st_size > MAX_INBOUND_ATTACHMENT_BYTES:
353
+ return None
354
+ return sniff_image_mime(os.pread(fd, 12, 0))
355
+ except OSError:
356
+ return None
357
+ finally:
358
+ os.close(fd)
359
+
360
+
361
+ def scan_inbound_attachments(frame: dict) -> Tuple[List[Tuple[str, str]], int]:
362
+ """(kept (path, sniffed mime) pairs, dropped count) for a daemon frame."""
363
+ raw = frame.get("attachments") if isinstance(frame, dict) else None
364
+ channel_id = frame.get("channel_id") if isinstance(frame, dict) else None
365
+ if not isinstance(raw, list):
366
+ return [], 0
367
+ if not isinstance(channel_id, str) or not channel_id:
368
+ return [], len(raw)
369
+ kept: List[Tuple[str, str]] = []
370
+ first_dir: Optional[str] = None
371
+ for a in raw:
372
+ if len(kept) >= MAX_INBOUND_ATTACHMENTS:
373
+ break
374
+ if not isinstance(a, dict):
375
+ continue
376
+ path, mime, filename = a.get("path"), a.get("mime"), a.get("filename")
377
+ if not isinstance(path, str) or not isinstance(mime, str):
378
+ continue
379
+ if filename is not None and (not isinstance(filename, str) or _FORBIDDEN_CHARS.search(filename)):
380
+ continue
381
+ if _FORBIDDEN_CHARS.search(path) or not os.path.isabs(path) or os.path.normpath(path) != path:
382
+ continue
383
+ parent = os.path.dirname(path)
384
+ if not os.path.basename(path) or os.path.basename(parent) != channel_id:
385
+ continue
386
+ if os.path.basename(os.path.dirname(parent)) != "attachments":
387
+ continue
388
+ if first_dir is not None and parent != first_dir:
389
+ continue
390
+ sniffed = _sniff_file(path)
391
+ if sniffed is None or sniffed != mime.lower():
392
+ continue
393
+ if first_dir is None:
394
+ first_dir = parent
395
+ kept.append((path, sniffed))
396
+ return kept, len(raw) - len(kept)
397
+
398
+
399
+ def inbound_attachments(frame: dict) -> List[Tuple[str, str]]:
400
+ """Validated (path, sniffed mime) pairs from a daemon `message` frame."""
401
+ return scan_inbound_attachments(frame)[0]
402
+
403
+
199
404
  class ChannelStatusManager:
200
405
  """Per-channel working/idle status emitter, decoupled from any one reply
201
406
  turn (unlike openclaw-entry.js's inline reportStatus("working")/("idle")