@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.
@@ -258,3 +258,197 @@ class TestPreToolCallDecision:
258
258
  raise RuntimeError("no session")
259
259
 
260
260
  assert authz.pre_tool_call_decision(boom, authz.TurnAuthzLedger())["action"] == "block"
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # H0 (audit r1 #3, r2 #1): an ungranted turn still in flight for a (chat,
265
+ # user) key must block tools for that key, whatever a later grant records.
266
+ # Before H0 the ledger kept only the LATEST decision, so a guest's relay turn
267
+ # that was still running borrowed the owner's grant the moment the owner's
268
+ # relay turn recorded True for the same key.
269
+ # ---------------------------------------------------------------------------
270
+
271
+
272
+ def _bound(chat_id, user_id, platform="securecoms"):
273
+ values = {
274
+ "HERMES_SESSION_PLATFORM": platform,
275
+ "HERMES_SESSION_CHAT_ID": chat_id,
276
+ "HERMES_SESSION_USER_ID": user_id,
277
+ }
278
+ return lambda name, default="": values.get(name, default)
279
+
280
+
281
+ class TestUngrantedInFlight:
282
+ def test_a_guest_turn_in_flight_cannot_borrow_the_owners_grant(self):
283
+ ledger = authz.TurnAuthzLedger()
284
+ ledger.record("room", "relay", False)
285
+ ledger.begin_ungranted("room", "relay") # the guest's relay turn is running
286
+ ledger.record("room", "relay", True) # the owner's relay turn is dispatched
287
+ assert ledger.tools_allowed("room", "relay") is False
288
+ assert authz.pre_tool_call_decision(_bound("room", "relay"), ledger) == {
289
+ "action": "block",
290
+ "message": authz.TOOL_VETO_MESSAGE,
291
+ }
292
+ ledger.end_ungranted("room", "relay")
293
+ assert ledger.tools_allowed("room", "relay") is True
294
+ assert authz.pre_tool_call_decision(_bound("room", "relay"), ledger) is None
295
+
296
+ def test_two_concurrent_ungranted_turns_need_two_releases(self):
297
+ ledger = authz.TurnAuthzLedger()
298
+ ledger.begin_ungranted("room", "relay")
299
+ ledger.begin_ungranted("room", "relay")
300
+ ledger.record("room", "relay", True)
301
+ ledger.end_ungranted("room", "relay")
302
+ assert ledger.tools_allowed("room", "relay") is False
303
+ ledger.end_ungranted("room", "relay")
304
+ assert ledger.tools_allowed("room", "relay") is True
305
+
306
+ def test_a_release_never_goes_below_zero(self):
307
+ ledger = authz.TurnAuthzLedger()
308
+ ledger.record("room", "relay", True)
309
+ ledger.end_ungranted("room", "relay") # stray release: nothing to release
310
+ ledger.begin_ungranted("room", "relay")
311
+ assert ledger.tools_allowed("room", "relay") is False
312
+
313
+ def test_a_later_grant_never_releases_an_in_flight_count(self):
314
+ ledger = authz.TurnAuthzLedger()
315
+ ledger.begin_ungranted("room", "relay")
316
+ for _ in range(3):
317
+ ledger.record("room", "relay", True)
318
+ assert ledger.tools_allowed("room", "relay") is False
319
+ assert ledger.ungranted_in_flight("room", "relay") == 1
320
+
321
+ def test_eviction_never_drops_an_in_flight_entry(self):
322
+ ledger = authz.TurnAuthzLedger(max_entries=2)
323
+ ledger.begin_ungranted("room", "relay")
324
+ ledger.record("room", "relay", True)
325
+ for i in range(10): # fill well past max_entries
326
+ ledger.record(f"c{i}", "u", True)
327
+ ledger.record("room", "relay", True)
328
+ assert ledger.tools_allowed("room", "relay") is False
329
+ assert ledger.ungranted_in_flight("room", "relay") == 1
330
+
331
+ def test_an_owner_dm_key_is_unaffected_by_a_room_key(self):
332
+ ledger = authz.TurnAuthzLedger()
333
+ ledger.record("dm", "owner", True)
334
+ ledger.begin_ungranted("room", "owner")
335
+ ledger.begin_ungranted("room", "relay")
336
+ assert ledger.tools_allowed("dm", "owner") is True
337
+
338
+ def test_a_shared_room_session_count_blocks_every_sender_in_that_chat(self):
339
+ # H0.2b: with group_sessions_per_user off, one room session is shared
340
+ # by every sender, so a count blocks the whole chat.
341
+ ledger = authz.TurnAuthzLedger()
342
+ ledger.record("room", "owner", True)
343
+ ledger.begin_ungranted("room", "guest", shared_session=True)
344
+ assert ledger.tools_allowed("room", "owner") is False
345
+ ledger.end_ungranted("room", "guest", shared_session=True)
346
+ assert ledger.tools_allowed("room", "owner") is True
347
+
348
+
349
+ class TestNonOwnerThread:
350
+ """H0.2c (re-ruled): every non-owner DM turn runs in the sc-non-owner
351
+ thread session; the thread is part of the ledger key."""
352
+
353
+ def test_the_thread_is_part_of_the_key(self):
354
+ ledger = authz.TurnAuthzLedger()
355
+ ledger.record("dm", "relay", True) # the owner's relayed voice
356
+ assert ledger.tools_allowed("dm", "relay") is True
357
+ assert ledger.tools_allowed("dm", "relay", authz.NON_OWNER_THREAD) is False
358
+ ledger.begin_ungranted("dm", "relay", authz.NON_OWNER_THREAD) # a guest's relayed text
359
+ assert ledger.tools_allowed("dm", "relay") is True # no chat-wide DM block
360
+ assert ledger.ungranted_in_flight("dm", "relay") == 0
361
+ assert ledger.ungranted_in_flight("dm", "relay", authz.NON_OWNER_THREAD) == 1
362
+
363
+ def test_a_record_under_the_non_owner_thread_is_never_a_grant(self):
364
+ ledger = authz.TurnAuthzLedger()
365
+ ledger.record("dm", "relay", True, authz.NON_OWNER_THREAD)
366
+ assert ledger.tools_allowed("dm", "relay", authz.NON_OWNER_THREAD) is False
367
+
368
+ def test_the_constant_has_no_key_separator(self):
369
+ assert authz.NON_OWNER_THREAD == "sc-non-owner" and ":" not in authz.NON_OWNER_THREAD
370
+
371
+ def test_a_guest_session_tool_call_is_blocked_even_with_an_owner_relay_grant(self):
372
+ ledger = authz.TurnAuthzLedger()
373
+ ledger.record("dm", "relay", True)
374
+ env = _bound_thread("dm", "relay", authz.NON_OWNER_THREAD)
375
+ assert authz.pre_tool_call_decision(env, ledger)["action"] == "block"
376
+ assert authz.pre_tool_call_decision(_bound_thread("dm", "relay", ""), ledger) is None
377
+
378
+ def test_an_unset_session_var_blocks(self):
379
+ ledger = authz.TurnAuthzLedger()
380
+ ledger.record("dm", "relay", True)
381
+ for missing in ("HERMES_SESSION_CHAT_ID", "HERMES_SESSION_USER_ID", "HERMES_SESSION_THREAD_ID"):
382
+ values = {"HERMES_SESSION_PLATFORM": "securecoms", "HERMES_SESSION_CHAT_ID": "dm",
383
+ "HERMES_SESSION_USER_ID": "relay", "HERMES_SESSION_THREAD_ID": ""}
384
+ values[missing] = None # unset in this task (direct ContextVar read)
385
+ assert authz.pre_tool_call_decision(lambda n, d="": values[n], ledger)["action"] == "block", missing
386
+
387
+ @staticmethod
388
+ def _frame(**kw):
389
+ block = auth(kw.pop("audience", "other_human_dm"), grants=[])
390
+ block.update(kw)
391
+ return frame(block)
392
+
393
+ def test_owner_turn_classification(self):
394
+ assert authz.is_owner_turn(frame(auth("owner_dm"))) is True
395
+ assert authz.is_owner_turn(frame(auth("owner_dm", grants=[]))) is True # the owner's ungranted turn
396
+ assert authz.is_owner_turn(self._frame(relay_speaker="owner")) is True
397
+ assert authz.is_owner_turn(self._frame()) is False # a guest's relayed voice
398
+ assert authz.is_owner_turn(frame(auth("agent_to_agent"))) is False
399
+ assert authz.is_owner_turn(frame()) is False # no block
400
+ assert authz.is_owner_turn(frame(auth("owner_dm", version=2))) is False
401
+ assert authz.is_owner_turn(frame(auth("owner_dm", membership="unknown"))) is False
402
+ assert authz.is_owner_turn(self._frame(relay_speaker="Owner")) is False
403
+
404
+
405
+ def _bound_thread(chat_id, user_id, thread_id):
406
+ values = {"HERMES_SESSION_PLATFORM": "securecoms", "HERMES_SESSION_CHAT_ID": chat_id,
407
+ "HERMES_SESSION_USER_ID": user_id, "HERMES_SESSION_THREAD_ID": thread_id}
408
+ return lambda name, default="": values.get(name, default)
409
+
410
+
411
+ class TestUnclassifiedThread:
412
+ """Audit r2 (ruled): a DM frame whose block cannot be classified runs in a
413
+ THIRD session, sc-unclassified: never the owner's, never the guests'."""
414
+
415
+ def test_dm_thread_classification(self):
416
+ assert authz.dm_thread(frame(auth("owner_dm"))) is None
417
+ assert authz.dm_thread(frame(auth("other_human_dm", grants=[], ))) == authz.NON_OWNER_THREAD
418
+ stale = auth("owner_dm", membership="unknown") # the owner during a stale roster
419
+ assert authz.dm_thread(frame(stale)) == authz.UNCLASSIFIED_THREAD
420
+ assert authz.dm_thread(frame()) == authz.UNCLASSIFIED_THREAD # no block
421
+ assert authz.dm_thread(frame(auth("owner_dm", version=2))) == authz.UNCLASSIFIED_THREAD
422
+ assert authz.dm_thread(frame("not a block")) == authz.UNCLASSIFIED_THREAD
423
+
424
+ def test_the_constant_is_distinct_and_has_no_key_separator(self):
425
+ assert authz.UNCLASSIFIED_THREAD == "sc-unclassified"
426
+ assert authz.UNCLASSIFIED_THREAD not in (authz.NON_OWNER_THREAD, "")
427
+
428
+ def test_a_record_under_the_unclassified_thread_is_never_a_grant(self):
429
+ ledger = authz.TurnAuthzLedger()
430
+ ledger.record("dm", "owner", True, authz.UNCLASSIFIED_THREAD)
431
+ assert ledger.tools_allowed("dm", "owner", authz.UNCLASSIFIED_THREAD) is False
432
+
433
+
434
+ class TestBlockSaysDm:
435
+ """Audit r2: the daemon's block marks a DM (owner_dm / other_human_dm are
436
+ set only when meta.is_dm, and so is relay_speaker), whatever the
437
+ connector's channel cache says."""
438
+
439
+ def test_dm_audiences_and_relay_mark(self):
440
+ assert authz.block_says_dm(frame(auth("owner_dm"))) is True
441
+ assert authz.block_says_dm(frame(auth("other_human_dm", grants=[]))) is True
442
+ relay = auth("agent_to_agent", grants=[])
443
+ relay["relay_speaker"] = "owner"
444
+ assert authz.block_says_dm(frame(relay)) is True
445
+
446
+ def test_room_audiences_and_no_block_say_nothing(self):
447
+ for aud in ("team_room", "partner_room", "agent_to_agent", "unknown"):
448
+ assert authz.block_says_dm(frame(auth(aud, grants=[]))) is False
449
+ assert authz.block_says_dm(frame()) is False
450
+
451
+ def test_a_stale_block_still_says_dm(self):
452
+ # The DM signal is a split signal only (never a grant), so it is read
453
+ # from the raw block even when the block is not valid for tools.
454
+ assert authz.block_says_dm(frame(auth("owner_dm", membership="unknown"))) is True
@@ -10,9 +10,12 @@
10
10
 
11
11
  import json
12
12
  import os
13
+ import subprocess
14
+ import sys
13
15
 
14
16
  import pytest
15
17
 
18
+ import securecoms_transport
16
19
  from securecoms_transport import (
17
20
  ChannelStatusManager,
18
21
  ResolvedTarget,
@@ -606,3 +609,156 @@ class TestChannelStatusManager:
606
609
  clock.advance(100.0)
607
610
  await mgr.on_typing("ch1")
608
611
  assert all(f["state"] != "idle" for f in send.frames)
612
+
613
+
614
+ PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\r"
615
+ JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01"
616
+ GIF = b"GIF89a\x01\x00\x01\x00\x00\x00"
617
+ WEBP = b"RIFF\x10\x00\x00\x00WEBPVP8 "
618
+
619
+
620
+ class TestInboundAttachments:
621
+ @pytest.fixture(autouse=True)
622
+ def _dir(self, tmp_path):
623
+ self.root = tmp_path
624
+ self.dir = tmp_path / "attachments" / "chan"
625
+ self.dir.mkdir(parents=True)
626
+
627
+ def _att(self, name, data=JPEG, mime="image/jpeg", directory=None):
628
+ d = directory or self.dir
629
+ d.mkdir(parents=True, exist_ok=True)
630
+ p = d / f"m-{name}"
631
+ p.write_bytes(data)
632
+ return {"filename": name, "mime": mime, "size": len(data), "path": str(p), "sha256": "x"}
633
+
634
+ def _frame(self, attachments):
635
+ return {"type": "message", "channel_id": "chan", "attachments": attachments}
636
+
637
+ def test_each_allowed_image_type_passes_with_the_sniffed_lowercase_mime(self):
638
+ for name, data, mime in [("a.png", PNG, "image/png"), ("b.jpg", JPEG, "image/jpeg"),
639
+ ("c.gif", GIF, "image/gif"), ("d.webp", WEBP, "image/webp")]:
640
+ a = self._att(name, data, mime.upper())
641
+ assert securecoms_transport.inbound_attachments(self._frame([a])) == [(a["path"], mime)]
642
+
643
+ def test_non_images_mismatches_and_oversize_are_dropped(self):
644
+ big = PNG + b"\x00" * securecoms_transport.MAX_INBOUND_ATTACHMENT_BYTES
645
+ bad = [
646
+ self._att("v.mp3", b"ID3\x04\x00\x00\x00\x00\x00\x00\x00\x00", "audio/mpeg"),
647
+ self._att("d.pdf", b"%PDF-1.7\n....", "application/pdf"),
648
+ self._att("x.svg", b"<svg xmlns='http://www.w3.org/2000/svg'><script/></svg>", "image/svg+xml"),
649
+ self._att("png-as.jpg", PNG, "image/jpeg"),
650
+ self._att("t.png", b"just some text", "image/png"),
651
+ self._att("big.png", big, "image/png"),
652
+ ]
653
+ for b in bad:
654
+ assert securecoms_transport.inbound_attachments(self._frame([b])) == [], b["filename"]
655
+ assert securecoms_transport.scan_inbound_attachments(self._frame(bad)) == ([], len(bad))
656
+
657
+ def test_bad_paths_and_names_are_dropped(self):
658
+ good = self._att("ok.jpg")
659
+ outside = self._att("id.jpg", directory=self.root / "secrets" / "chan")
660
+ other = self._att("o.jpg", directory=self.root / "attachments" / "other")
661
+ bad = [
662
+ {**good, "path": "relative/attachments/chan/m-ok.jpg"},
663
+ {**good, "path": f"{self.dir}/../chan/m-ok.jpg"},
664
+ {**good, "path": f"{self.dir}/./m-ok.jpg"},
665
+ {**good, "path": f"{self.dir}/"}, # empty basename (normpath strips the "/")
666
+ {**good, "path": outside["path"]},
667
+ {**good, "path": other["path"]},
668
+ {**good, "path": f"{self.dir}/x\0.jpg"},
669
+ {**good, "path": self._att("rtl‮gpj.jpg")["path"]},
670
+ {**good, "path": self._att("nl\n.jpg")["path"]},
671
+ {**good, "filename": "inv‮fdp.jpg"},
672
+ {**good, "filename": 5},
673
+ {**good, "path": 42},
674
+ {**good, "mime": 7},
675
+ None,
676
+ "string",
677
+ ]
678
+ for b in bad:
679
+ assert securecoms_transport.inbound_attachments(self._frame([b])) == [], b
680
+ assert len(securecoms_transport.inbound_attachments(self._frame(bad + [good]))) == 1
681
+
682
+ def test_the_basename_guard_alone_rejects_an_empty_basename(self, monkeypatch):
683
+ # normpath already strips a trailing "/", so neutralize it to prove the
684
+ # basename guard is a real second check, not dead code.
685
+ good = self._att("ok.jpg")
686
+ monkeypatch.setattr(securecoms_transport.os.path, "normpath", lambda p: p)
687
+ assert securecoms_transport.inbound_attachments(self._frame([{**good, "path": f"{self.dir}/"}])) == []
688
+
689
+ def test_guards_only_normpath_or_isabs_catch(self, monkeypatch):
690
+ # Review r2 mutation survivors: a real image reachable through an
691
+ # unnormalized path whose literal parent is .../attachments/chan.
692
+ self._att("f.png", PNG, "image/png", directory=self.root / "y" / "attachments" / "chan")
693
+ dotted = f"{self.root}/attachments/chan/../../y/attachments/chan/m-f.png"
694
+ assert securecoms_transport.inbound_attachments(self._frame([{"path": dotted, "mime": "image/png"}])) == []
695
+ # A relative path that resolves to a real image from the cwd.
696
+ self._att("rel.png", PNG, "image/png")
697
+ monkeypatch.chdir(self.root)
698
+ rel = "attachments/chan/m-rel.png"
699
+ assert os.path.isfile(rel)
700
+ assert securecoms_transport.inbound_attachments(self._frame([{"path": rel, "mime": "image/png"}])) == []
701
+
702
+ def test_a_fifo_is_dropped_without_hanging(self):
703
+ # O_NONBLOCK: without it, open() on a FIFO with no writer blocks forever,
704
+ # so run the scan in a child with a hard timeout.
705
+ fifo = self.dir / "m-hang.png"
706
+ os.mkfifo(fifo)
707
+ frame = {"channel_id": "chan", "attachments": [{"path": str(fifo), "mime": "image/png"}]}
708
+ script = "import json, sys; import securecoms_transport as t; print(json.dumps(t.scan_inbound_attachments(json.loads(sys.stdin.read()))))"
709
+ hermes_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
710
+ r = subprocess.run([sys.executable, "-c", script], input=json.dumps(frame), capture_output=True, text=True,
711
+ timeout=15, cwd=hermes_dir, env={**os.environ, "PYTHONPATH": hermes_dir})
712
+ assert r.returncode == 0, r.stderr
713
+ assert json.loads(r.stdout) == [[], 1]
714
+
715
+ def test_a_symlink_is_not_followed(self):
716
+ target = self._att("real.png", PNG, "image/png", directory=self.root / "elsewhere")
717
+ link = self.dir / "m-link.png"
718
+ os.symlink(target["path"], link)
719
+ assert securecoms_transport.inbound_attachments(self._frame([{"path": str(link), "mime": "image/png"}])) == []
720
+
721
+ def test_all_attachments_share_one_dir(self):
722
+ good = self._att("a.jpg")
723
+ other = self._att("b.jpg", directory=self.root / "x" / "attachments" / "chan")
724
+ assert [p for p, _ in securecoms_transport.inbound_attachments(self._frame([good, other]))] == [good["path"]]
725
+
726
+ def test_count_is_capped(self):
727
+ many = [self._att(f"p{i}.jpg") for i in range(20)]
728
+ assert len(securecoms_transport.inbound_attachments(self._frame(many))) == securecoms_transport.MAX_INBOUND_ATTACHMENTS == 8
729
+ assert securecoms_transport.MAX_INBOUND_ATTACHMENT_BYTES == 5 * 1024 * 1024
730
+
731
+ def test_no_list_or_no_channel_is_empty(self):
732
+ good = self._att("a.jpg")
733
+ assert securecoms_transport.inbound_attachments({"channel_id": "chan", "attachments": good}) == []
734
+ assert securecoms_transport.inbound_attachments({"attachments": [good]}) == []
735
+ assert securecoms_transport.inbound_attachments({"channel_id": "chan"}) == []
736
+
737
+
738
+ # R3: display names are member-chosen strings that reach the LLM prompt. The
739
+ # cases live in ONE fixture shared with the OpenClaw twin's vitest suite.
740
+ _FIXTURES = json.load(open(os.path.join(
741
+ os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "display-name-fixtures.json"),
742
+ encoding="utf-8"))
743
+
744
+
745
+ class TestDisplayName:
746
+ @pytest.mark.parametrize("raw,expected", _FIXTURES["display_name"], ids=lambda v: ascii(v)[:40])
747
+ def test_shared_fixture(self, raw, expected):
748
+ assert securecoms_transport.display_name(raw) == expected
749
+
750
+ def test_cap(self):
751
+ assert securecoms_transport.DISPLAY_NAME_MAX == 64
752
+
753
+
754
+ class TestSenderLabel:
755
+ @pytest.mark.parametrize("case,frame,expected", _FIXTURES["sender_label"], ids=[c[0] for c in _FIXTURES["sender_label"]])
756
+ def test_shared_fixture(self, case, frame, expected):
757
+ assert securecoms_transport.sender_label(frame) == expected
758
+
759
+ def test_own_label_ignores_the_speaker(self):
760
+ frame = {"sender_id": "relay-1234567890", "sender_name": "Room relay",
761
+ "speaker": {"identityId": "human-1", "displayName": "Dana"}}
762
+ assert securecoms_transport.own_label(frame) == "Room relay (relay-12)"
763
+ assert securecoms_transport.speaker_label(frame) == "Dana (human-1)"
764
+ assert securecoms_transport.speaker_label({"sender_id": "x"}) == ""
@@ -0,0 +1,96 @@
1
+ // packages/connect-agent-bridge/connectors/openclaw/openclaw-entry.contract.test.ts
2
+ //
3
+ // Bridge -> connector contract (review r1 LOW): a file the daemon's REAL
4
+ // materializeAttachment writes (real crypto, real fs writers) must pass BOTH
5
+ // connector validators unchanged — the OpenClaw scanInboundAttachments and the
6
+ // Hermes securecoms_transport.scan_inbound_attachments — including when the
7
+ // daemon's attachmentsDir carries a trailing "/". A non-image the daemon saves
8
+ // must be dropped by both.
9
+
10
+ import { describe, expect, it } from "vitest";
11
+ import { spawnSync } from "node:child_process";
12
+ import { mkdtempSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { computeFileDigest, decryptFile, encryptFile, base64ToBytes } from "@agentvault/crypto";
16
+ import {
17
+ materializeAttachment,
18
+ mkdirPrivateNoFollow,
19
+ writeFileExclusiveNoFollow,
20
+ type ArtifactShareBody,
21
+ } from "../../src/attachments.js";
22
+ import { inboundAttachmentsOf } from "./openclaw-entry.js";
23
+
24
+ const CH = "3f2b6c1a-8d4e-4f7a-9b2c-1d3e5f7a9b0c";
25
+ const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 1, 2, 3]);
26
+ const HERMES_DIR = join(__dirname, "..", "hermes");
27
+
28
+ async function materialize(plain: Uint8Array, name: string, attachmentsDir: string, messageId: string) {
29
+ const { encryptedData, fileKey, fileNonce, digest } = await encryptFile(plain);
30
+ const b64 = (u: Uint8Array) => Buffer.from(u).toString("base64");
31
+ const body: ArtifactShareBody = {
32
+ v: 1, type: "artifact_share", name, mime: "image/png", size: plain.length,
33
+ blobId: "0f3c2a1e-5b6d-4c7e-8f90-a1b2c3d4e5f6", fileKey: b64(fileKey), fileNonce: b64(fileNonce), digest,
34
+ };
35
+ return materializeAttachment(body, { attachmentsDir, channelId: CH, messageId }, {
36
+ ready: async () => {},
37
+ downloadBlob: async () => encryptedData,
38
+ decryptFile, computeFileDigest, base64ToBytes,
39
+ mkdirp: mkdirPrivateNoFollow,
40
+ writeFile: writeFileExclusiveNoFollow,
41
+ });
42
+ }
43
+
44
+ // Visible skip (review r2): the Hermes half needs python3 on the runner.
45
+ const HAS_PYTHON3 = spawnSync("python3", ["--version"]).status === 0;
46
+
47
+ function hermesScan(frame: unknown): { kept: Array<[string, string]>; dropped: number } {
48
+ const script = [
49
+ "import json, sys",
50
+ "import securecoms_transport as t",
51
+ "kept, dropped = t.scan_inbound_attachments(json.loads(sys.stdin.read()))",
52
+ "print(json.dumps({'kept': kept, 'dropped': dropped}))",
53
+ ].join("\n");
54
+ const r = spawnSync("python3", ["-c", script], {
55
+ input: JSON.stringify(frame),
56
+ cwd: HERMES_DIR,
57
+ env: { ...process.env, PYTHONPATH: HERMES_DIR },
58
+ encoding: "utf8",
59
+ });
60
+ expect(r.status, r.stderr).toBe(0);
61
+ return JSON.parse(r.stdout);
62
+ }
63
+
64
+ describe("bridge materializeAttachment -> connector validators", () => {
65
+ for (const trailing of ["", "/"]) {
66
+ it(`a PNG the bridge writes passes both connectors (attachmentsDir trailing "${trailing}")`, async () => {
67
+ const dir = join(mkdtempSync(join(tmpdir(), "sc-contract-")), "attachments") + trailing;
68
+ const meta = await materialize(PNG, "IMG_3375.png", dir, "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f");
69
+ expect(meta.mime).toBe("image/png");
70
+ const frame = { channel_id: CH, attachments: [meta] };
71
+
72
+ expect(inboundAttachmentsOf(frame)).toEqual([{ path: meta.path, mime: "image/png", filename: "IMG_3375.png" }]);
73
+
74
+ });
75
+
76
+ it.skipIf(!HAS_PYTHON3)(`... and the Hermes validator (attachmentsDir trailing "${trailing}")`, async () => {
77
+ const dir = join(mkdtempSync(join(tmpdir(), "sc-contract-")), "attachments") + trailing;
78
+ const meta = await materialize(PNG, "IMG_3375.png", dir, "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f");
79
+ expect(hermesScan({ channel_id: CH, attachments: [meta] })).toEqual({ kept: [[meta.path, "image/png"]], dropped: 0 });
80
+ });
81
+ }
82
+
83
+ it("a non-image the bridge saves is dropped by both connectors", async () => {
84
+ const dir = join(mkdtempSync(join(tmpdir(), "sc-contract-")), "attachments");
85
+ const meta = await materialize(new TextEncoder().encode("%PDF-1.7\n...."), "r.pdf", dir, "8d2e3f4a-5b6c-4d7e-9f0a-1b2c3d4e5f6a");
86
+ expect(meta.mime).toBe("application/octet-stream");
87
+ const frame = { channel_id: CH, attachments: [meta] };
88
+ expect(inboundAttachmentsOf(frame)).toEqual([]);
89
+ });
90
+
91
+ it.skipIf(!HAS_PYTHON3)("... and the Hermes validator drops it too", async () => {
92
+ const dir = join(mkdtempSync(join(tmpdir(), "sc-contract-")), "attachments");
93
+ const meta = await materialize(new TextEncoder().encode("%PDF-1.7\n...."), "r.pdf", dir, "8d2e3f4a-5b6c-4d7e-9f0a-1b2c3d4e5f6a");
94
+ expect(hermesScan({ channel_id: CH, attachments: [meta] })).toEqual({ kept: [], dropped: 1 });
95
+ });
96
+ });