@securecoms/agent-bridge 0.36.0 → 0.38.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.
@@ -26,7 +26,9 @@ 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
+ from typing import Awaitable, Callable, List, Mapping, NamedTuple, Optional, Tuple
30
32
 
31
33
  from securecoms_gate import evaluate_gate, resolve_bound_identity
32
34
 
@@ -196,6 +198,100 @@ def should_drop_inbound(frame: dict, bound_identity: str) -> bool:
196
198
  return bool(sender_id) and bool(bound_identity) and sender_id == bound_identity
197
199
 
198
200
 
201
+ # Inbound attachments. For an artifact_share the daemon decrypts the file to
202
+ # <attachmentsDir>/<channel_id>/<message_id>-<name> and puts
203
+ # {filename, mime, size, path, sha256} on the frame. Python twin of
204
+ # openclaw-entry.js's scanInboundAttachments. The frame's fields are claims;
205
+ # only a file that passes ALL of these reaches Hermes:
206
+ # - path: a string, absolute and already normalized (normpath collapses any
207
+ # "." or ".." segment and a trailing "/", so any traversal or an empty
208
+ # basename fails the equality), no C0/C1 control or bidi character,
209
+ # parent dir == the frame's channel id inside a dir named "attachments",
210
+ # every kept file in one dir;
211
+ # - filename (when present): no control or bidi character;
212
+ # - the file itself (opened O_NOFOLLOW|O_NONBLOCK): a regular file of at most
213
+ # MAX_INBOUND_ATTACHMENT_BYTES whose first bytes are a PNG, JPEG, GIF or
214
+ # WEBP signature, and the claimed mime equals that sniffed type.
215
+ # Images only. Anything else is dropped; at most MAX_INBOUND_ATTACHMENTS kept.
216
+ MAX_INBOUND_ATTACHMENTS = 8
217
+ MAX_INBOUND_ATTACHMENT_BYTES = 5 * 1024 * 1024
218
+ _FORBIDDEN_CHARS = re.compile("[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]")
219
+
220
+
221
+ def sniff_image_mime(b: bytes) -> Optional[str]:
222
+ """The image type from a file's leading bytes, or None."""
223
+ if b.startswith(b"\x89PNG\r\n\x1a\n"):
224
+ return "image/png"
225
+ if b.startswith(b"\xff\xd8\xff"):
226
+ return "image/jpeg"
227
+ if b.startswith(b"GIF87a") or b.startswith(b"GIF89a"):
228
+ return "image/gif"
229
+ if len(b) >= 12 and b[:4] == b"RIFF" and b[8:12] == b"WEBP":
230
+ return "image/webp"
231
+ return None
232
+
233
+
234
+ def _sniff_file(path: str) -> Optional[str]:
235
+ try:
236
+ # O_NONBLOCK: a FIFO planted at the path must not hang the gateway in
237
+ # open(); fstat below then drops it as not a regular file.
238
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
239
+ except OSError:
240
+ return None
241
+ try:
242
+ st = os.fstat(fd)
243
+ if not stat.S_ISREG(st.st_mode) or st.st_size > MAX_INBOUND_ATTACHMENT_BYTES:
244
+ return None
245
+ return sniff_image_mime(os.pread(fd, 12, 0))
246
+ except OSError:
247
+ return None
248
+ finally:
249
+ os.close(fd)
250
+
251
+
252
+ def scan_inbound_attachments(frame: dict) -> Tuple[List[Tuple[str, str]], int]:
253
+ """(kept (path, sniffed mime) pairs, dropped count) for a daemon frame."""
254
+ raw = frame.get("attachments") if isinstance(frame, dict) else None
255
+ channel_id = frame.get("channel_id") if isinstance(frame, dict) else None
256
+ if not isinstance(raw, list):
257
+ return [], 0
258
+ if not isinstance(channel_id, str) or not channel_id:
259
+ return [], len(raw)
260
+ kept: List[Tuple[str, str]] = []
261
+ first_dir: Optional[str] = None
262
+ for a in raw:
263
+ if len(kept) >= MAX_INBOUND_ATTACHMENTS:
264
+ break
265
+ if not isinstance(a, dict):
266
+ continue
267
+ path, mime, filename = a.get("path"), a.get("mime"), a.get("filename")
268
+ if not isinstance(path, str) or not isinstance(mime, str):
269
+ continue
270
+ if filename is not None and (not isinstance(filename, str) or _FORBIDDEN_CHARS.search(filename)):
271
+ continue
272
+ if _FORBIDDEN_CHARS.search(path) or not os.path.isabs(path) or os.path.normpath(path) != path:
273
+ continue
274
+ parent = os.path.dirname(path)
275
+ if not os.path.basename(path) or os.path.basename(parent) != channel_id:
276
+ continue
277
+ if os.path.basename(os.path.dirname(parent)) != "attachments":
278
+ continue
279
+ if first_dir is not None and parent != first_dir:
280
+ continue
281
+ sniffed = _sniff_file(path)
282
+ if sniffed is None or sniffed != mime.lower():
283
+ continue
284
+ if first_dir is None:
285
+ first_dir = parent
286
+ kept.append((path, sniffed))
287
+ return kept, len(raw) - len(kept)
288
+
289
+
290
+ def inbound_attachments(frame: dict) -> List[Tuple[str, str]]:
291
+ """Validated (path, sniffed mime) pairs from a daemon `message` frame."""
292
+ return scan_inbound_attachments(frame)[0]
293
+
294
+
199
295
  class ChannelStatusManager:
200
296
  """Per-channel working/idle status emitter, decoupled from any one reply
201
297
  turn (unlike openclaw-entry.js's inline reportStatus("working")/("idle")