@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.
- package/README.md +11 -0
- package/connectors/hermes/adapter.py +202 -3
- package/connectors/hermes/securecoms_authz.py +190 -0
- package/connectors/hermes/tests/test_adapter_wiring.py +481 -3
- package/connectors/hermes/tests/test_securecoms_authz.py +235 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +19 -4
- package/connectors/openclaw/openclaw-entry.js +184 -3
- package/connectors/openclaw/openclaw-entry.status.test.ts +11 -1
- package/connectors/openclaw/openclaw-entry.toolgate.test.ts +466 -0
- package/dist/bin.js +113 -10
- package/dist/bridge-agent.d.ts +42 -4
- package/dist/harness-report.d.ts +14 -0
- package/dist/index.js +113 -10
- package/dist/service/runtime-config-dir.d.ts +22 -0
- package/dist/version.d.ts +1 -1
- package/dist/voice/fast-prompt.d.ts +9 -0
- package/dist/ws-server.d.ts +0 -77
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
# ---------------------------------------------------------------------------
|