Files
hermes-agent/tests/gateway/test_goal_gate_access.py
T
Teknium 1cb3ab6173 fix(gateway): /goal gate add now requires an explicitly configured admin (salvage #91677)
A goal quality gate is a shell command persisted by `/goal gate add` and
later executed with `subprocess.run(shell=True)` at every goal turn
boundary (run_gate), with no approval prompt. On the messaging gateway,
slash access is backward-compatible: with no `allow_admin_from` list
configured (the default), every *allowed* chat user is treated as
unrestricted. So an allowed but non-admin remote sender could add an
arbitrary shell command and get authenticated RCE as the Hermes process
account.

Gate ONLY the shell-creating operation (`gate add`) behind the existing
fail-closed explicit-admin check (`_resume_caller_is_admin`, the same one
that guards cross-origin `/resume`). `gate list` / `remove` / `clear`
stay open so a non-admin can still inspect and recover. CLI/TUI/Desktop
`gate add` is local (the user already has host access) and is unchanged.

Salvaged from #91677 by @unsupportedpastels — narrowed to the single
shell-creating op and reusing the existing admin helper rather than
renaming it. Contributor's regression tests preserved.

Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
2026-09-02 10:55:57 -07:00

132 lines
3.9 KiB
Python

"""Gateway authorization tests for shell-backed goal quality gates."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.session import SessionSource
def _source(user_id: str = "user-1", *, chat_type: str = "dm") -> SessionSource:
return SessionSource(
platform=Platform.DISCORD,
user_id=user_id,
chat_id=f"{chat_type}-1",
chat_type=chat_type,
)
def _runner(*, admins=(), group_admins=()):
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={
Platform.DISCORD: PlatformConfig(
enabled=True,
extra={
"allow_admin_from": list(admins),
"group_allow_admin_from": list(group_admins),
},
)
}
)
manager = MagicMock()
manager.add_gate.return_value = SimpleNamespace(
command="touch /tmp/host-in-the-shell",
max_retries=3,
timeout_seconds=300,
)
runner._get_goal_manager_for_event = AsyncMock(
return_value=(manager, SimpleNamespace(session_id="session-1"))
)
return runner, manager
def _event(user_id: str = "user-1", *, chat_type: str = "dm"):
event = MagicMock()
event.source = _source(user_id, chat_type=chat_type)
event.get_command_args.return_value = (
"gate add touch /tmp/host-in-the-shell"
)
return event
@pytest.mark.asyncio
@pytest.mark.parametrize(
("admins", "user_id"),
[
((), "user-1"),
(("admin-1",), "user-1"),
],
)
async def test_gateway_gate_add_requires_explicit_admin(admins, user_id):
"""Allowed chat users must not turn /goal into an unrestricted host shell."""
from gateway.run import GatewayRunner
runner, manager = _runner(admins=admins)
result = await GatewayRunner._handle_goal_command(runner, _event(user_id))
assert "explicitly configured gateway admin" in result
manager.add_gate.assert_not_called()
@pytest.mark.asyncio
async def test_gateway_explicit_admin_can_add_goal_gate():
"""The fix preserves the documented quality-gate capability for operators."""
from gateway.run import GatewayRunner
runner, manager = _runner(admins=("admin-1",))
result = await GatewayRunner._handle_goal_command(runner, _event("admin-1"))
assert "Gate added" in result
manager.add_gate.assert_called_once_with(
"touch /tmp/host-in-the-shell"
)
@pytest.mark.asyncio
async def test_gateway_group_gate_add_uses_group_admin_scope():
"""DM admin status must not silently grant host-shell access in groups."""
from gateway.run import GatewayRunner
runner, manager = _runner(
admins=("dm-admin",),
group_admins=("group-admin",),
)
denied = await GatewayRunner._handle_goal_command(
runner,
_event("dm-admin", chat_type="group"),
)
allowed = await GatewayRunner._handle_goal_command(
runner,
_event("group-admin", chat_type="group"),
)
assert "explicitly configured gateway admin" in denied
assert "Gate added" in allowed
manager.add_gate.assert_called_once_with(
"touch /tmp/host-in-the-shell"
)
@pytest.mark.asyncio
async def test_gateway_non_admin_can_still_list_goal_gates():
"""Non-admins retain read-only visibility into the active goal's gates."""
from gateway.run import GatewayRunner
runner, manager = _runner(admins=("admin-1",))
manager.render_gates.return_value = "- 1. $ scripts/run_tests.sh"
event = _event("user-1")
event.get_command_args.return_value = "gate list"
result = await GatewayRunner._handle_goal_command(runner, event)
assert result == "- 1. $ scripts/run_tests.sh"
manager.render_gates.assert_called_once_with()