teleo-infrastructure/tests/test_leo_oos_readonly_guard.py
2026-07-16 08:53:53 +02:00

367 lines
12 KiB
Python

"""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_is_provider_compatible_while_handler_enforces_action_contracts() -> None:
parameters = guard.STRUCTURED_READ_TOOL_SCHEMA["parameters"]
assert parameters["required"] == ["action"]
assert parameters["additionalProperties"] is False
assert not ({"oneOf", "anyOf", "allOf"} & set(parameters))
assert set(parameters["properties"]["action"]["enum"]) == set(guard.READ_ONLY_BRIDGE_COMMANDS)
assert "query is required" in parameters["properties"]["action"]["description"]
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,
)