@securecoms/agent-bridge 0.26.3 → 0.26.5

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.
@@ -55,6 +55,12 @@ def _install_gateway_stub():
55
55
  message_id: Optional[str] = None
56
56
  timestamp: Any = field(default_factory=datetime.now)
57
57
  channel_prompt: Optional[str] = None
58
+ # Real Hermes (gateway/platforms/event.py): free-form per-event
59
+ # metadata that plugins read with .get().
60
+ metadata: dict = field(default_factory=dict)
61
+ # Real Hermes: may this event resolve gateway commands / control
62
+ # prompts? False keeps untrusted payload text conversational.
63
+ allow_gateway_control: bool = True
58
64
 
59
65
  @dataclass
60
66
  class SendResult:
@@ -294,6 +300,12 @@ class TestEchoGuardWiring:
294
300
 
295
301
  @pytest.mark.asyncio
296
302
  async def test_other_sender_is_dispatched(self, monkeypatch):
303
+ # This frame carries no authorization block, so it is a no-tools turn.
304
+ # It is dispatched only because the tool veto is active, which is the
305
+ # state register(ctx) leaves a current Hermes in (the adapter is only
306
+ # ever built by the factory register(ctx) installs, after the veto
307
+ # hook registration). The veto-inactive case is TestToolGate's.
308
+ monkeypatch.setattr(adapter, "_TOOL_VETO_ACTIVE", True)
297
309
  a, _ = make_adapter(monkeypatch)
298
310
  a._bound_identity = "A"
299
311
  handled = []
@@ -321,7 +333,9 @@ class TestEchoGuardWiring:
321
333
  await a._pump()
322
334
 
323
335
  assert len(handled) == 1
324
- assert handled[0].text == "hi"
336
+ # No authorization block: an ungranted turn, so its text carries the
337
+ # command escape (N1).
338
+ assert handled[0].text == adapter.securecoms_authz.COMMAND_ESCAPE + "hi"
325
339
  assert ws.sent == [{"type": "ack", "message_id": "m2"}]
326
340
 
327
341
 
@@ -336,11 +350,14 @@ class TestVoiceGuidance:
336
350
  handled.append(event)
337
351
 
338
352
  a.set_message_handler(handler)
353
+ # The owner's turns (granted tools), so no no-tools note joins the
354
+ # prompt; TestToolGate covers a voice turn without tools.
339
355
  frames = [
340
356
  {"type": "message", "sender_id": "B", "channel_id": "c", "text": "what's the weather?",
341
- "message_id": "v1", "voice": True, "guidance": "One short spoken sentence."},
357
+ "message_id": "v1", "voice": True, "guidance": "One short spoken sentence.",
358
+ "authorization": OWNER_AUTH},
342
359
  {"type": "message", "sender_id": "B", "channel_id": "c", "text": "hi",
343
- "message_id": "t1", "guidance": "ignored without voice"},
360
+ "message_id": "t1", "guidance": "ignored without voice", "authorization": OWNER_AUTH},
344
361
  ]
345
362
  ws = FakeWS([_FakeWSMsg(aiohttp.WSMsgType.TEXT, json.dumps(f)) for f in frames]
346
363
  + [_FakeWSMsg(aiohttp.WSMsgType.CLOSED)])
@@ -353,6 +370,467 @@ class TestVoiceGuidance:
353
370
  assert handled[1].channel_prompt is None
354
371
 
355
372
 
373
+ # ---------------------------------------------------------------------------
374
+ # Tool gate: only the daemon-granted owner turn runs with Hermes tools.
375
+ # ---------------------------------------------------------------------------
376
+
377
+ _GRANTS = [{"tool": "current_time", "mode": "read"}, {"tool": "web_search", "mode": "read"}]
378
+ OWNER_AUTH = {"version": 1, "audience": "owner_dm", "sender_membership": "active", "tool_grants": _GRANTS}
379
+
380
+
381
+ def _no_grant(audience):
382
+ return {"version": 1, "audience": audience, "sender_membership": "active", "tool_grants": []}
383
+
384
+
385
+ _MISSING = object()
386
+
387
+ # (case id, authorization block or _MISSING, expected sender_role)
388
+ NO_TOOL_SENDERS = [
389
+ ("room_member", _no_grant("team_room"), "member"),
390
+ ("other_org_member", _no_grant("partner_room"), "other_org"),
391
+ ("agent_sender", _no_grant("agent_to_agent"), "agent"),
392
+ ("missing_authorization", _MISSING, "unknown"),
393
+ ]
394
+
395
+
396
+ def _frame(message_id, sender, channel, text, authorization=_MISSING, **kw):
397
+ f = {"type": "message", "sender_id": sender, "channel_id": channel, "text": text, "message_id": message_id}
398
+ if authorization is not _MISSING:
399
+ f["authorization"] = authorization
400
+ f.update(kw)
401
+ return f
402
+
403
+
404
+ async def _pump_frames(a, frames):
405
+ ws = FakeWS([_FakeWSMsg(aiohttp.WSMsgType.TEXT, json.dumps(f)) for f in frames]
406
+ + [_FakeWSMsg(aiohttp.WSMsgType.CLOSED)])
407
+ a._ws = ws
408
+ await a._pump()
409
+ return ws
410
+
411
+
412
+ def _session_env(platform, chat_id, user_id):
413
+ values = {"HERMES_SESSION_PLATFORM": platform, "HERMES_SESSION_CHAT_ID": chat_id, "HERMES_SESSION_USER_ID": user_id}
414
+ return lambda name, default="": values.get(name, default)
415
+
416
+
417
+ class _FakeCtx:
418
+ def __init__(self, with_hook=True, hook_raises=False):
419
+ self.hooks = []
420
+ self.platforms = []
421
+ if with_hook:
422
+ def register_hook(name, callback):
423
+ if hook_raises:
424
+ raise ValueError("hooks unsupported")
425
+ self.hooks.append((name, callback))
426
+
427
+ self.register_hook = register_hook
428
+
429
+ def register_platform(self, **kw):
430
+ self.platforms.append(kw)
431
+
432
+
433
+ def _install_hermes_modules(monkeypatch, *, veto=True, session_env=True, env_values=None,
434
+ engaged=True, version="0.12.0"):
435
+ """Stub hermes_cli(.plugins) (veto support, version) and gateway.session_context.
436
+ engaged: True/False -> session_context_engaged() returns it; None -> no accessor."""
437
+ plugins_mod = types.ModuleType("hermes_cli.plugins")
438
+ if veto:
439
+ plugins_mod._get_pre_tool_call_directive_details = lambda *a, **k: None
440
+ cli_mod = types.ModuleType("hermes_cli")
441
+ if version is not None:
442
+ cli_mod.__version__ = version
443
+ monkeypatch.setitem(sys.modules, "hermes_cli", cli_mod)
444
+ monkeypatch.setitem(sys.modules, "hermes_cli.plugins", plugins_mod)
445
+ if session_env:
446
+ ctx_mod = types.ModuleType("gateway.session_context")
447
+ values = dict(env_values or {})
448
+ ctx_mod.get_session_env = lambda name, default="": values.get(name, default)
449
+ if engaged is not None:
450
+ ctx_mod.session_context_engaged = lambda: engaged
451
+ monkeypatch.setitem(sys.modules, "gateway.session_context", ctx_mod)
452
+ return values
453
+ monkeypatch.delitem(sys.modules, "gateway.session_context", raising=False)
454
+ return None
455
+
456
+
457
+ @pytest.fixture
458
+ def fresh_gate(monkeypatch):
459
+ """Each test starts with an empty ledger and the veto state register()
460
+ would leave; tests that need it set _TOOL_VETO_ACTIVE explicitly."""
461
+ monkeypatch.setattr(adapter, "_TURN_AUTHZ", adapter.securecoms_authz.TurnAuthzLedger())
462
+ monkeypatch.setattr(adapter, "_TOOL_VETO_ACTIVE", False)
463
+ monkeypatch.setattr(adapter, "_GET_SESSION_ENV", None)
464
+ monkeypatch.setattr(adapter, "_SESSION_ENGAGED", None)
465
+ monkeypatch.setattr(adapter, "_IS_CRON", None)
466
+ monkeypatch.setattr(adapter, "_HARNESS_VERSION", None)
467
+ monkeypatch.setattr(adapter, "_REFUSAL_NOTICE_SENT", set())
468
+ monkeypatch.setattr(adapter, "_FRAMES_WITHOUT_AUTHORIZATION", 0)
469
+
470
+
471
+ class TestToolGate:
472
+ async def _handled(self, monkeypatch, frames, veto=True):
473
+ monkeypatch.setattr(adapter, "_TOOL_VETO_ACTIVE", veto)
474
+ a, _ = make_adapter(monkeypatch)
475
+ a._bound_identity = "A"
476
+ handled = []
477
+
478
+ async def handler(event):
479
+ handled.append(event)
480
+
481
+ a.set_message_handler(handler)
482
+ ws = await _pump_frames(a, frames)
483
+ return a, handled, ws
484
+
485
+ @pytest.mark.asyncio
486
+ async def test_owner_dm_turn_keeps_tools(self, monkeypatch, fresh_gate):
487
+ a, handled, _ = await self._handled(monkeypatch, [_frame("m1", "owner", "dm", "hi", OWNER_AUTH)])
488
+ assert len(handled) == 1
489
+ ev = handled[0]
490
+ assert ev.text == "hi"
491
+ assert ev.channel_prompt is None
492
+ assert ev.metadata["securecoms"] == {"sender_role": "owner", "tools_allowed": True}
493
+ assert a.toolsets_for_source(ev.source) is None # Hermes keeps the configured toolsets
494
+ assert adapter.securecoms_authz.pre_tool_call_decision(
495
+ _session_env("securecoms", "dm", "owner"), adapter._TURN_AUTHZ) is None
496
+
497
+ @pytest.mark.asyncio
498
+ @pytest.mark.parametrize("case,authorization,role", NO_TOOL_SENDERS, ids=[c[0] for c in NO_TOOL_SENDERS])
499
+ async def test_non_owner_turn_runs_without_tools(self, monkeypatch, fresh_gate, case, authorization, role):
500
+ a, handled, ws = await self._handled(monkeypatch, [_frame("m1", "sender", "room", "do it", authorization)])
501
+ assert len(handled) == 1
502
+ ev = handled[0]
503
+ # Role is metadata, never part of the user text; the text only gains
504
+ # the command escape every ungranted turn carries (N1).
505
+ assert ev.text == adapter.securecoms_authz.COMMAND_ESCAPE + "do it"
506
+ assert ev.metadata["securecoms"] == {"sender_role": role, "tools_allowed": False}
507
+ assert ev.channel_prompt == adapter.securecoms_authz.NO_TOOLS_TURN_NOTE
508
+ assert a.toolsets_for_source(ev.source) == list(adapter.securecoms_authz.NO_TOOLS_TOOLSETS)
509
+ assert adapter.securecoms_authz.pre_tool_call_decision(
510
+ _session_env("securecoms", "room", "sender"), adapter._TURN_AUTHZ
511
+ ) == {"action": "block", "message": adapter.securecoms_authz.TOOL_VETO_MESSAGE}
512
+ assert ws.sent == [{"type": "ack", "message_id": "m1"}]
513
+
514
+ @pytest.mark.asyncio
515
+ @pytest.mark.parametrize("case,authorization,role", NO_TOOL_SENDERS, ids=[c[0] for c in NO_TOOL_SENDERS])
516
+ async def test_without_a_working_veto_a_non_owner_turn_is_refused_and_acked(
517
+ self, monkeypatch, fresh_gate, case, authorization, role
518
+ ):
519
+ _, handled, ws = await self._handled(
520
+ monkeypatch, [_frame("m1", "sender", "room", "do it", authorization)], veto=False)
521
+ assert handled == []
522
+ # Acked: a refused turn must not be redelivered forever.
523
+ assert ws.sent == [{"type": "ack", "message_id": "m1"}]
524
+
525
+ @pytest.mark.asyncio
526
+ async def test_without_a_working_veto_the_owner_turn_still_runs(self, monkeypatch, fresh_gate):
527
+ _, handled, _ = await self._handled(
528
+ monkeypatch, [_frame("m1", "owner", "dm", "hi", OWNER_AUTH)], veto=False)
529
+ assert [e.text for e in handled] == ["hi"]
530
+
531
+ @pytest.mark.asyncio
532
+ async def test_a_room_message_does_not_inherit_the_owner_dm_grant(self, monkeypatch, fresh_gate):
533
+ a, handled, _ = await self._handled(monkeypatch, [
534
+ _frame("m1", "owner", "dm", "hi", OWNER_AUTH),
535
+ _frame("m2", "owner", "room", "in the room", _no_grant("team_room")),
536
+ ])
537
+ assert [e.metadata["securecoms"]["tools_allowed"] for e in handled] == [True, False]
538
+ assert a.toolsets_for_source(handled[0].source) is None
539
+ assert a.toolsets_for_source(handled[1].source) == list(adapter.securecoms_authz.NO_TOOLS_TOOLSETS)
540
+
541
+ @pytest.mark.asyncio
542
+ async def test_voice_turn_without_tools_carries_guidance_and_the_note(self, monkeypatch, fresh_gate):
543
+ _, handled, _ = await self._handled(monkeypatch, [_frame(
544
+ "v1", "sender", "room", "weather?", _no_grant("team_room"), voice=True, guidance="Be brief.")])
545
+ assert handled[0].channel_prompt == (
546
+ adapter.VOICE_TURN_PREFIX + "Be brief.\n\n" + adapter.securecoms_authz.NO_TOOLS_TURN_NOTE)
547
+
548
+ def test_toolsets_for_an_unknown_source_is_no_tools(self, monkeypatch, fresh_gate):
549
+ a, _ = make_adapter(monkeypatch)
550
+ src = types.SimpleNamespace(chat_id="dm", user_id="owner")
551
+ assert a.toolsets_for_source(src) == list(adapter.securecoms_authz.NO_TOOLS_TOOLSETS)
552
+ assert a.toolsets_for_source(None) == list(adapter.securecoms_authz.NO_TOOLS_TOOLSETS)
553
+
554
+
555
+ class TestToolVetoRegistration:
556
+ def test_register_installs_the_veto_and_activates_it(self, monkeypatch, fresh_gate):
557
+ env = _install_hermes_modules(monkeypatch)
558
+ ctx = _FakeCtx()
559
+ adapter.register(ctx)
560
+ assert [name for name, _ in ctx.hooks] == ["pre_tool_call"]
561
+ assert adapter._TOOL_VETO_ACTIVE is True
562
+ assert len(ctx.platforms) == 1
563
+ # The registered callback reads the CURRENT turn's session and blocks
564
+ # a SecureComs turn that was not granted tools.
565
+ callback = ctx.hooks[0][1]
566
+ env.update(HERMES_SESSION_PLATFORM="securecoms", HERMES_SESSION_CHAT_ID="room", HERMES_SESSION_USER_ID="m")
567
+ adapter._TURN_AUTHZ.record("room", "m", False)
568
+ assert callback(tool_name="terminal", args={}, task_id="t")["action"] == "block"
569
+ adapter._TURN_AUTHZ.record("room", "m", True)
570
+ assert callback(tool_name="terminal", args={}, task_id="t") is None
571
+
572
+ def test_no_register_hook_leaves_the_veto_inactive(self, monkeypatch, fresh_gate):
573
+ _install_hermes_modules(monkeypatch)
574
+ adapter.register(_FakeCtx(with_hook=False))
575
+ assert adapter._TOOL_VETO_ACTIVE is False
576
+
577
+ def test_register_hook_that_raises_leaves_the_veto_inactive(self, monkeypatch, fresh_gate):
578
+ _install_hermes_modules(monkeypatch)
579
+ adapter.register(_FakeCtx(hook_raises=True))
580
+ assert adapter._TOOL_VETO_ACTIVE is False
581
+
582
+ def test_hermes_without_block_directives_leaves_the_veto_inactive(self, monkeypatch, fresh_gate):
583
+ _install_hermes_modules(monkeypatch, veto=False)
584
+ adapter.register(_FakeCtx())
585
+ assert adapter._TOOL_VETO_ACTIVE is False
586
+
587
+ def test_hermes_without_task_local_session_context_leaves_the_veto_inactive(self, monkeypatch, fresh_gate):
588
+ # Without gateway.session_context the session vars are process-global
589
+ # os.environ, which a concurrent turn can overwrite: not attributable.
590
+ _install_hermes_modules(monkeypatch, session_env=False)
591
+ adapter.register(_FakeCtx())
592
+ assert adapter._TOOL_VETO_ACTIVE is False
593
+
594
+ def test_callback_that_cannot_read_the_session_blocks(self, monkeypatch, fresh_gate):
595
+ _install_hermes_modules(monkeypatch)
596
+ ctx = _FakeCtx()
597
+ adapter.register(ctx)
598
+
599
+ def boom(name, default=""):
600
+ raise RuntimeError("no session")
601
+
602
+ monkeypatch.setattr(adapter, "_GET_SESSION_ENV", boom)
603
+ assert ctx.hooks[0][1](tool_name="terminal")["action"] == "block"
604
+
605
+ def test_callback_without_a_session_reader_blocks(self, monkeypatch, fresh_gate):
606
+ adapter._TURN_AUTHZ.record("dm", "owner", True)
607
+ assert adapter._GET_SESSION_ENV is None
608
+ assert adapter._securecoms_pre_tool_call(tool_name="terminal")["action"] == "block"
609
+
610
+
611
+ class TestEmptyTurnIdentity:
612
+ def test_register_binds_the_engaged_reader_and_an_empty_identity_blocks(self, monkeypatch, fresh_gate):
613
+ _install_hermes_modules(monkeypatch, engaged=True)
614
+ ctx = _FakeCtx()
615
+ adapter.register(ctx)
616
+ assert ctx.hooks[0][1](tool_name="terminal")["action"] == "block"
617
+
618
+ def test_a_process_with_no_bound_sessions_is_left_alone(self, monkeypatch, fresh_gate):
619
+ _install_hermes_modules(monkeypatch, engaged=False)
620
+ ctx = _FakeCtx()
621
+ adapter.register(ctx)
622
+ assert ctx.hooks[0][1](tool_name="terminal") is None
623
+
624
+ def test_without_an_engaged_accessor_the_gateway_is_assumed_engaged(self, monkeypatch, fresh_gate):
625
+ _install_hermes_modules(monkeypatch, engaged=None)
626
+ ctx = _FakeCtx()
627
+ adapter.register(ctx)
628
+ assert adapter._TOOL_VETO_ACTIVE is True
629
+ assert ctx.hooks[0][1](tool_name="terminal")["action"] == "block"
630
+
631
+
632
+ class TestRegistrationPrerequisites:
633
+ def test_hermes_without_allow_gateway_control_leaves_the_veto_inactive(self, monkeypatch, fresh_gate):
634
+ # N1: without MessageEvent.allow_gateway_control an ungranted turn
635
+ # could still drive gateway commands, so such a Hermes is not trusted.
636
+ @dataclass
637
+ class OldMessageEvent:
638
+ text: str
639
+ message_type: Any = None
640
+ source: Any = None
641
+ message_id: Optional[str] = None
642
+ timestamp: Any = field(default_factory=datetime.now)
643
+
644
+ monkeypatch.setattr(adapter, "MessageEvent", OldMessageEvent)
645
+ _install_hermes_modules(monkeypatch)
646
+ adapter.register(_FakeCtx())
647
+ assert adapter._TOOL_VETO_ACTIVE is False
648
+
649
+ def test_cron_is_read_from_the_context_var_never_the_environment(self, monkeypatch, fresh_gate):
650
+ # N2: HERMES_CRON_SESSION in os.environ with the ContextVar empty is
651
+ # NOT a cron turn: an empty identity still blocks.
652
+ import contextvars
653
+
654
+ _install_hermes_modules(monkeypatch)
655
+ cron_var = contextvars.ContextVar("HERMES_CRON_SESSION", default=object())
656
+ sys.modules["gateway.session_context"]._CRON_SESSION = cron_var
657
+ monkeypatch.setenv("HERMES_CRON_SESSION", "1")
658
+ ctx = _FakeCtx()
659
+ adapter.register(ctx)
660
+ hook = ctx.hooks[0][1]
661
+ assert hook(tool_name="terminal")["action"] == "block"
662
+ token = cron_var.set("1")
663
+ try:
664
+ assert hook(tool_name="terminal") is None
665
+ finally:
666
+ cron_var.reset(token)
667
+ assert hook(tool_name="terminal")["action"] == "block"
668
+
669
+ def test_without_the_cron_context_var_nothing_is_exempt(self, monkeypatch, fresh_gate):
670
+ _install_hermes_modules(monkeypatch)
671
+ monkeypatch.setenv("HERMES_CRON_SESSION", "1")
672
+ ctx = _FakeCtx()
673
+ adapter.register(ctx)
674
+ assert ctx.hooks[0][1](tool_name="terminal")["action"] == "block"
675
+
676
+
677
+ class _SendRecorder:
678
+ def __init__(self):
679
+ self.sent = []
680
+
681
+ async def __call__(self, chat_id, content, reply_to=None, metadata=None):
682
+ self.sent.append((chat_id, content, reply_to))
683
+ return types.SimpleNamespace(success=True)
684
+
685
+
686
+ class TestRolloutSignals:
687
+ @pytest.mark.asyncio
688
+ async def test_hello_reports_harness_and_active_veto(self, monkeypatch, fresh_gate):
689
+ monkeypatch.setattr(adapter, "_TOOL_VETO_ACTIVE", True)
690
+ monkeypatch.setattr(adapter, "_HARNESS_VERSION", "0.12.0")
691
+ a, _ = make_adapter(monkeypatch)
692
+ a._ws = FakeWS()
693
+ await a._send_hello()
694
+ assert a._ws.sent == [{"type": "hello", "harness": {"name": "hermes", "version": "0.12.0", "tool_veto": "active"}}]
695
+
696
+ @pytest.mark.asyncio
697
+ async def test_hello_reports_inactive_veto(self, monkeypatch, fresh_gate):
698
+ a, _ = make_adapter(monkeypatch)
699
+ a._ws = FakeWS()
700
+ await a._send_hello()
701
+ assert a._ws.sent == [{"type": "hello", "harness": {"name": "hermes", "version": None, "tool_veto": "inactive"}}]
702
+
703
+ @pytest.mark.asyncio
704
+ async def test_a_successful_connect_sends_the_hello(self, monkeypatch, fresh_gate):
705
+ files = {BINDING_PATH: binding("A"), CONNECTIONS_DIR + "/A.json": conn_file(identity_id="A"), "/tok/A": "token-A"}
706
+ a, _ = make_adapter(monkeypatch, files=files)
707
+ a._bound_identity = "A"
708
+ ws = FakeWS()
709
+ session = FakeSession(healthz_by_url={"http://127.0.0.1:9001/healthz": {"identity_id": "A"}}, ws=ws)
710
+ monkeypatch.setattr(adapter, "_new_client_session", lambda: session)
711
+ assert await a._open_ws() is True
712
+ assert ws.sent[0]["type"] == "hello"
713
+ assert ws.sent[0]["harness"]["tool_veto"] == "inactive"
714
+
715
+ def test_register_records_the_hermes_version(self, monkeypatch, fresh_gate):
716
+ _install_hermes_modules(monkeypatch, version="0.12.0")
717
+ adapter.register(_FakeCtx())
718
+ assert adapter._HARNESS_VERSION == "0.12.0"
719
+
720
+ @pytest.mark.asyncio
721
+ async def test_a_refused_turn_gets_one_room_notice_per_channel(self, monkeypatch, fresh_gate):
722
+ a, _ = make_adapter(monkeypatch)
723
+ a._bound_identity = "A"
724
+ rec = _SendRecorder()
725
+ monkeypatch.setattr(a, "send", rec)
726
+ a.set_message_handler(lambda e: None)
727
+ await _pump_frames(a, [
728
+ _frame("m1", "member", "room", "hi", _no_grant("team_room")),
729
+ _frame("m2", "member", "room", "hi again", _no_grant("team_room")),
730
+ _frame("m3", "other", "room-2", "hello", _no_grant("partner_room")),
731
+ ])
732
+ assert rec.sent == [
733
+ ("room", adapter.securecoms_authz.REFUSED_TURN_NOTICE, None),
734
+ ("room-2", adapter.securecoms_authz.REFUSED_TURN_NOTICE, None),
735
+ ]
736
+
737
+ @pytest.mark.asyncio
738
+ async def test_a_refused_agent_turn_gets_no_notice(self, monkeypatch, fresh_gate):
739
+ # Two agents whose vetoes are both inactive would otherwise answer
740
+ # each other's notices.
741
+ a, _ = make_adapter(monkeypatch)
742
+ a._bound_identity = "A"
743
+ rec = _SendRecorder()
744
+ monkeypatch.setattr(a, "send", rec)
745
+ a.set_message_handler(lambda e: None)
746
+ await _pump_frames(a, [_frame("m1", "agent-b", "room", "hi", _no_grant("agent_to_agent"))])
747
+ assert rec.sent == []
748
+
749
+ @pytest.mark.asyncio
750
+ async def test_with_an_active_veto_no_notice_is_sent(self, monkeypatch, fresh_gate):
751
+ monkeypatch.setattr(adapter, "_TOOL_VETO_ACTIVE", True)
752
+ a, _ = make_adapter(monkeypatch)
753
+ a._bound_identity = "A"
754
+ rec = _SendRecorder()
755
+ monkeypatch.setattr(a, "send", rec)
756
+ handled = []
757
+
758
+ async def handler(e):
759
+ handled.append(e)
760
+
761
+ a.set_message_handler(handler)
762
+ await _pump_frames(a, [_frame("m1", "member", "room", "hi", _no_grant("team_room"))])
763
+ assert rec.sent == []
764
+ assert len(handled) == 1
765
+
766
+ @pytest.mark.asyncio
767
+ async def test_frames_without_an_authorization_block_warn(self, monkeypatch, fresh_gate, caplog):
768
+ monkeypatch.setattr(adapter, "_TOOL_VETO_ACTIVE", True)
769
+ a, _ = make_adapter(monkeypatch)
770
+ a._bound_identity = "A"
771
+
772
+ async def handler(e):
773
+ pass
774
+
775
+ a.set_message_handler(handler)
776
+ with caplog.at_level("WARNING"):
777
+ await _pump_frames(a, [_frame("m1", "s", "c", "x"), _frame("m2", "s", "c", "y", OWNER_AUTH)])
778
+ hits = [r for r in caplog.records if "NO authorization block" in r.getMessage()]
779
+ assert len(hits) == 1
780
+
781
+
782
+ class TestGatewayCommandsFromUngrantedSenders:
783
+ @pytest.mark.asyncio
784
+ async def test_an_ungranted_slash_message_cannot_drive_gateway_commands(self, monkeypatch, fresh_gate):
785
+ monkeypatch.setattr(adapter, "_TOOL_VETO_ACTIVE", True)
786
+ a, _ = make_adapter(monkeypatch)
787
+ a._bound_identity = "A"
788
+ handled = []
789
+
790
+ async def handler(e):
791
+ handled.append(e)
792
+
793
+ a.set_message_handler(handler)
794
+ await _pump_frames(a, [_frame("m1", "member", "room", " /model gpt-x", _no_grant("team_room"))])
795
+ ev = handled[0]
796
+ assert ev.allow_gateway_control is False
797
+ assert not ev.text.lstrip().startswith("/")
798
+ assert ev.text == "\u2060 /model gpt-x"
799
+
800
+ @pytest.mark.asyncio
801
+ async def test_the_owner_keeps_gateway_commands(self, monkeypatch, fresh_gate):
802
+ a, _ = make_adapter(monkeypatch)
803
+ a._bound_identity = "A"
804
+ handled = []
805
+
806
+ async def handler(e):
807
+ handled.append(e)
808
+
809
+ a.set_message_handler(handler)
810
+ await _pump_frames(a, [_frame("m1", "owner", "dm", "/reset", OWNER_AUTH)])
811
+ assert handled[0].text == "/reset"
812
+ assert handled[0].allow_gateway_control is True
813
+
814
+ @pytest.mark.asyncio
815
+ @pytest.mark.parametrize("text", ["a/b", "@url:x /reset", "@file:/tmp/a /model gpt-x", "hello"])
816
+ async def test_every_ungranted_text_is_escaped(self, monkeypatch, fresh_gate, text):
817
+ # N1: Hermes strips leading @url:/@file: refs before its "/" check, so
818
+ # escaping only slash-leading text is not enough: every ungranted
819
+ # text starts with the escape, which no ref pattern or lstrip removes.
820
+ monkeypatch.setattr(adapter, "_TOOL_VETO_ACTIVE", True)
821
+ a, _ = make_adapter(monkeypatch)
822
+ a._bound_identity = "A"
823
+ handled = []
824
+
825
+ async def handler(e):
826
+ handled.append(e)
827
+
828
+ a.set_message_handler(handler)
829
+ await _pump_frames(a, [_frame("m1", "member", "room", text, _no_grant("team_room"))])
830
+ assert handled[0].text == "\u2060" + text
831
+ assert handled[0].allow_gateway_control is False
832
+
833
+
356
834
  # ---------------------------------------------------------------------------
357
835
  # (2) send() success fires the status manager's on_reply_posted
358
836
  # ---------------------------------------------------------------------------