@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.
- package/README.md +33 -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 +665 -42
- package/connectors/hermes/securecoms_authz.py +112 -11
- package/connectors/hermes/securecoms_transport.py +206 -1
- package/connectors/hermes/tests/test_adapter_wiring.py +1136 -2
- 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 +156 -0
- package/connectors/openclaw/openclaw-entry.contract.test.ts +96 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +363 -4
- package/connectors/openclaw/openclaw-entry.js +239 -4
- package/dist/attachments.d.ts +33 -1
- package/dist/bin.js +4415 -1014
- package/dist/bridge-agent.d.ts +65 -0
- package/dist/index.js +4387 -986
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
@@ -26,7 +26,10 @@ from __future__ import annotations
|
|
|
26
26
|
|
|
27
27
|
import json
|
|
28
28
|
import os
|
|
29
|
-
|
|
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")
|