"""Fail-closed tests for the live OOS Hermes tool boundary.""" from __future__ import annotations import hashlib import json import signal import subprocess import sys import types from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) import leo_oos_readonly_guard as guard # noqa: E402 def make_kb_tool(tmp_path: Path) -> tuple[Path, Path, str]: profile = tmp_path / "profile" kb_tool = profile / "bin" / "kb_tool.py" kb_tool.parent.mkdir(parents=True) kb_tool.write_text("#!/usr/bin/env python3\n", encoding="utf-8") kb_tool.chmod(0o700) return profile, kb_tool.resolve(), hashlib.sha256(kb_tool.read_bytes()).hexdigest() def test_structured_context_compiles_to_one_exact_read_only_argv() -> None: argv = guard.structured_read_argv( { "action": "context", "query": "Northstar status snapshot", "limit": 1, "context_limit": 1, "format": "markdown", } ) assert argv == [ "context", "Northstar status snapshot", "--limit", "1", "--context-limit", "1", "--format", "markdown", ] def test_structured_read_omits_unsupplied_flags_so_bridge_defaults_remain_canonical() -> None: assert guard.structured_read_argv({"action": "evidence", "claim_id": "11111111-1111-4111-8111-111111111111"}) == [ "evidence", "11111111-1111-4111-8111-111111111111", ] @pytest.mark.parametrize( "tool_args", [ {"action": "prepare-source", "query": "x"}, {"action": "context", "query": "x", "rationale": "write it"}, {"action": "context"}, {"action": "context", "query": "x", "limit": 0}, {"action": "context", "query": "x", "context_limit": True}, {"action": "context", "query": "x", "format": []}, {"action": "show", "claim_id": "not-a-uuid"}, {"action": "list-proposals", "status": []}, {"action": "status", "query": "irrelevant"}, ], ) def test_structured_read_rejects_writes_bad_types_irrelevant_fields_and_unbounded_values( tool_args: dict[str, object], ) -> None: with pytest.raises(guard.ReadOnlyGuardError): guard.structured_read_argv(tool_args) def test_schema_encodes_action_specific_required_fields() -> None: branches = guard.STRUCTURED_READ_TOOL_SCHEMA["parameters"]["oneOf"] by_action = {branch["properties"]["action"]["enum"][0]: branch for branch in branches} assert by_action["context"]["required"] == ["action", "query"] assert set(by_action["context"]["properties"]) == {"action", "query", "limit", "context_limit", "format"} assert by_action["context"]["additionalProperties"] is False assert by_action["show"]["required"] == ["action", "claim_id"] assert by_action["status"]["required"] == ["action"] class FakeProcess: def __init__(self, argv, **kwargs): self.argv = argv self.kwargs = kwargs self.pid = 8123 self.returncode = 0 def communicate(self, timeout=None): return "{}\n", "" def test_structured_read_child_uses_frozen_absolute_tool_and_no_provider_credentials( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: profile, kb_tool, source_sha256 = make_kb_tool(tmp_path) captured: dict[str, object] = {} def fake_popen(argv, **kwargs): proc = FakeProcess(argv, **kwargs) captured["proc"] = proc return proc monkeypatch.setattr(guard.subprocess, "Popen", fake_popen) output = json.loads( guard.restricted_structured_read_handler( kb_tool, profile, { "action": "context", "query": "Northstar status snapshot", "limit": 1, "context_limit": 1, "format": "markdown", }, allow_kb_reads=True, expected_kb_tool_sha256=source_sha256, ) ) assert output == {"output": "{}\n", "exit_code": 0, "error": None, "error_category": None} proc = captured["proc"] assert proc.argv == [ str(Path(sys.executable).resolve()), str(kb_tool), "--local", "context", "Northstar status snapshot", "--limit", "1", "--context-limit", "1", "--format", "markdown", ] assert proc.kwargs["start_new_session"] is True assert proc.kwargs["env"] == { "HOME": str(profile), "HERMES_HOME": str(profile), "PATH": f"{profile / 'bin'}:{guard.os.environ.get('PATH', '')}", "TELEO_KB_MODE": "local", } assert not any("KEY" in key or "TOKEN" in key or "SECRET" in key for key in proc.kwargs["env"]) def test_structured_read_rejects_hash_drift_before_launch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: profile, kb_tool, source_sha256 = make_kb_tool(tmp_path) kb_tool.write_text("changed\n", encoding="utf-8") monkeypatch.setattr( guard.subprocess, "Popen", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("must not launch")), ) output = json.loads( guard.restricted_structured_read_handler( kb_tool, profile, {"action": "status"}, allow_kb_reads=True, expected_kb_tool_sha256=source_sha256, ) ) assert output["exit_code"] == 126 assert output["error_category"] == "invalid_read_arguments" def test_structured_read_timeout_terminates_the_process_group(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: profile, kb_tool, source_sha256 = make_kb_tool(tmp_path) calls = 0 signals: list[tuple[int, signal.Signals]] = [] class TimeoutProcess(FakeProcess): def communicate(self, timeout=None): nonlocal calls calls += 1 if calls == 1: raise subprocess.TimeoutExpired(self.argv, timeout) return "", "" monkeypatch.setattr(guard.subprocess, "Popen", TimeoutProcess) monkeypatch.setattr(guard.os, "killpg", lambda pid, sent: signals.append((pid, sent))) output = json.loads( guard.restricted_structured_read_handler( kb_tool, profile, {"action": "status"}, allow_kb_reads=True, expected_kb_tool_sha256=source_sha256, ) ) assert output["exit_code"] == 124 assert output["error_category"] == "bridge_timeout" assert signals == [(8123, signal.SIGTERM)] def test_structured_read_timeout_escalates_to_process_group_kill( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: profile, kb_tool, source_sha256 = make_kb_tool(tmp_path) signals: list[tuple[int, signal.Signals]] = [] class StubbornProcess(FakeProcess): def communicate(self, timeout=None): raise subprocess.TimeoutExpired(self.argv, timeout) monkeypatch.setattr(guard.subprocess, "Popen", StubbornProcess) monkeypatch.setattr(guard.os, "killpg", lambda pid, sent: signals.append((pid, sent))) output = json.loads( guard.restricted_structured_read_handler( kb_tool, profile, {"action": "status"}, allow_kb_reads=True, expected_kb_tool_sha256=source_sha256, ) ) assert output["exit_code"] == 124 assert output["error_category"] == "bridge_timeout" assert signals == [(8123, signal.SIGTERM), (8123, signal.SIGKILL)] def test_structured_read_launch_error_is_categorized(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: profile, kb_tool, source_sha256 = make_kb_tool(tmp_path) monkeypatch.setattr(guard.subprocess, "Popen", lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("nope"))) output = json.loads( guard.restricted_structured_read_handler( kb_tool, profile, {"action": "status"}, allow_kb_reads=True, expected_kb_tool_sha256=source_sha256, ) ) assert output["exit_code"] == 126 assert output["error_category"] == "bridge_launch" assert "nope" not in output["error"] def test_structured_read_ablation_preserves_schema_but_never_executes( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: profile, kb_tool, source_sha256 = make_kb_tool(tmp_path) monkeypatch.setattr( guard.subprocess, "Popen", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("ablation must not execute")), ) output = json.loads( guard.restricted_structured_read_handler( kb_tool, profile, {"action": "status", "format": "json"}, allow_kb_reads=False, expected_kb_tool_sha256=source_sha256, ) ) assert output["exit_code"] == 126 assert output["error_category"] == "kb_reads_disabled" class FakeEntry: def __init__(self, handler=None, schema=None): self.handler = handler self.schema = schema or {} class FakeRegistry: def __init__(self): self._tools = {"skills_list": FakeEntry(), "skill_view": FakeEntry(), "terminal": FakeEntry()} def register(self, *, name, schema, handler, **_kwargs): self._tools[name] = FakeEntry(handler=handler, schema=schema) def get_definitions(self, names, quiet=False): del quiet return [ {"type": "function", "function": {**self._tools[name].schema, "name": name}} for name in sorted(names) if name in self._tools ] def dispatch(self, name, args): return self._tools[name].handler(args) def test_registry_install_exposes_exact_schema_and_denies_ablation( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: profile, _kb_tool, source_sha256 = make_kb_tool(tmp_path) registry = FakeRegistry() toolsets = types.ModuleType("toolsets") toolsets.TOOLSETS = {} tools = types.ModuleType("tools") tools.__path__ = [] skills_tool = types.ModuleType("tools.skills_tool") registry_module = types.ModuleType("tools.registry") registry_module.registry = registry hermes_cli = types.ModuleType("hermes_cli") tools_config = types.SimpleNamespace(_get_platform_tools=lambda: set()) hermes_cli.tools_config = tools_config for name, module in { "tools": tools, "tools.skills_tool": skills_tool, "tools.registry": registry_module, "toolsets": toolsets, "hermes_cli": hermes_cli, }.items(): monkeypatch.setitem(sys.modules, name, module) surface = guard.install_read_only_tool_surface( profile, allow_kb_reads=False, expected_kb_tool_sha256=source_sha256, ) denied = json.loads(registry.dispatch("teleo-kb", {"action": "status"})) assert surface["actual_registry_tools"] == ["skill_view", "skills_list", "teleo-kb"] assert surface["shell_tool_enabled"] is False assert len(surface["structured_read_tool_definition_sha256"]) == 64 assert denied["error_category"] == "kb_reads_disabled" registry._tools["process"] = FakeEntry() post_runner = guard.reassert_read_only_tool_surface( profile, expected_kb_tool_sha256=source_sha256, expected_definition_sha256=surface["structured_read_tool_definition_sha256"], ) assert post_runner["initial_registry_tools"] == ["process", "skill_view", "skills_list", "teleo-kb"] assert post_runner["removed_known_runner_tools"] == ["process"] assert post_runner["actual_registry_tools"] == ["skill_view", "skills_list", "teleo-kb"] assert post_runner["final_surface_matches_preexecution"] is True def test_post_runner_registry_rejects_unknown_additions(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: profile, _kb_tool, source_sha256 = make_kb_tool(tmp_path) registry = FakeRegistry() registry._tools["teleo-kb"] = FakeEntry(schema=guard.STRUCTURED_READ_TOOL_SCHEMA) registry._tools["unknown-network-tool"] = FakeEntry() registry_module = types.ModuleType("tools.registry") registry_module.registry = registry monkeypatch.setitem(sys.modules, "tools.registry", registry_module) with pytest.raises(guard.ReadOnlyGuardError, match="unsupported additions"): guard.reassert_read_only_tool_surface( profile, expected_kb_tool_sha256=source_sha256, expected_definition_sha256="0" * 64, )