Files
hermes-agent/tests/tools/test_approval_hook_session_id.py
T
Teknium 14791b4d4e simplify(compat): approval — drop 43 facade re-exports + _command_detection_variants late-bind seam, repoint 30 callers + 46 test files
tools/approval.py no longer re-exports sibling names (approval_context/prompt/floors/detection/
human_wait/smart/gateway_wait); it imports only what it uses. Siblings reference sibling-defined
names directly (module-attribute reads on tools.approval_context so patching the defining module
still works); only facade-owned state (_lock, _gateway_queues, _permanent_approved, _denied,
_denial_breaker_addendum, _gateway_notify_cb) is still read back through tools.approval.
approval_detection calls its own _command_detection_variants instead of late-binding through the facade.
2026-09-03 13:49:57 -07:00

94 lines
3.3 KiB
Python

"""Approval hooks must carry the Hermes session id to observer plugins.
Staging defect 2026-08-10: approval marks were emitted under a synthetic
"default" relay session because the approval hook payload carried only
turn_id/tool_call_id — the observability plugin's ``_session_id()`` fell
back to "default", parented the marks to a session scope that never
closes, and close-time exporters never shipped them. The audit board's
approval tables stayed empty while approvals were demonstrably firing.
Contract: when the dispatch layer binds an observability context with a
session id, every approval hook payload carries that session id; when no
context is bound, the payload omits it (legacy behavior preserved).
"""
from __future__ import annotations
from unittest.mock import patch
from tools import approval as approval_mod
from tools import approval_context
def _capture_hook(captured):
def _invoke(hook_name, **kwargs):
captured.append((hook_name, kwargs))
return _invoke
class TestApprovalHookSessionId:
def test_session_id_forwarded_when_bound(self):
captured = []
tokens = approval_context.set_current_observability_context(
turn_id="turn-1",
tool_call_id="call-1",
session_id="20260810_test_session",
)
try:
with patch(
"hermes_cli.lifecycle.invoke_hook",
side_effect=_capture_hook(captured),
):
approval_context._fire_approval_hook(
"pre_approval_request",
command="rm -rf /etc/hosts",
description="dangerous",
surface="gateway",
)
finally:
approval_context.reset_current_observability_context(tokens)
assert captured, "hook must dispatch"
_, kwargs = captured[0]
assert kwargs.get("session_id") == "20260810_test_session"
assert kwargs.get("turn_id") == "turn-1"
assert kwargs.get("tool_call_id") == "call-1"
def test_explicit_session_id_not_clobbered(self):
captured = []
tokens = approval_context.set_current_observability_context(
session_id="context-session",
)
try:
with patch(
"hermes_cli.lifecycle.invoke_hook",
side_effect=_capture_hook(captured),
):
approval_context._fire_approval_hook(
"post_approval_response",
session_id="explicit-session",
choice="approved",
)
finally:
approval_context.reset_current_observability_context(tokens)
_, kwargs = captured[0]
assert kwargs.get("session_id") == "explicit-session"
def test_absent_when_unbound(self):
captured = []
with patch(
"hermes_cli.lifecycle.invoke_hook",
side_effect=_capture_hook(captured),
):
approval_context._fire_approval_hook(
"pre_approval_request",
command="x",
description="y",
)
_, kwargs = captured[0]
assert "session_id" not in kwargs, (
"no synthetic session id when none is bound — the observer's "
"own fallback owns that decision"
)