174 lines
6.1 KiB
Python
174 lines
6.1 KiB
Python
|
|
"""Contracts for bounded, screened command execution in the handoff harness."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import subprocess
|
||
|
|
|
||
|
|
from testing.tests.test_hermes_handoff_support import (
|
||
|
|
FakeClock,
|
||
|
|
FakeSpawn,
|
||
|
|
load_handoff_module,
|
||
|
|
)
|
||
|
|
|
||
|
|
runner_module = load_handoff_module("hermes_handoff_exec")
|
||
|
|
policy = load_handoff_module("hermes_handoff_policy")
|
||
|
|
|
||
|
|
|
||
|
|
def completed(stdout: str = "", stderr: str = "", returncode: int = 0):
|
||
|
|
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)
|
||
|
|
|
||
|
|
|
||
|
|
def build(results: list, **kwargs) -> tuple:
|
||
|
|
clock = FakeClock()
|
||
|
|
spawn = FakeSpawn(results)
|
||
|
|
runner = runner_module.Runner(clock=clock, spawn=spawn, environ={}, **kwargs)
|
||
|
|
return (runner, spawn, clock)
|
||
|
|
|
||
|
|
|
||
|
|
OPERATOR = runner_module.operator_vantage()
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_successful_command_is_captured_and_screened() -> None:
|
||
|
|
runner, spawn, _ = build([completed(stdout="password=a-long-enough-secret\n")])
|
||
|
|
|
||
|
|
outcome = runner.run(("kubectl", "get", "pods"), OPERATOR)
|
||
|
|
|
||
|
|
assert outcome.ok and outcome.ran
|
||
|
|
assert "[redacted]" in outcome.stdout
|
||
|
|
assert outcome.returncode == 0
|
||
|
|
assert spawn.calls[0]["argv"] == ["kubectl", "get", "pods"]
|
||
|
|
assert runner.commands_run == 1
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_failing_command_is_not_ok_but_still_ran() -> None:
|
||
|
|
runner, _, _ = build([completed(stderr="Error from server (Forbidden)", returncode=1)])
|
||
|
|
|
||
|
|
outcome = runner.run(("kubectl", "get", "pods"), OPERATOR)
|
||
|
|
|
||
|
|
assert outcome.ran and not outcome.ok
|
||
|
|
assert "Forbidden" in outcome.combined
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_policy_violation_never_reaches_a_subprocess() -> None:
|
||
|
|
runner, spawn, _ = build([])
|
||
|
|
|
||
|
|
outcome = runner.run(("kubectl", "delete", "pods", "--all"), OPERATOR)
|
||
|
|
|
||
|
|
assert outcome.error and outcome.error.startswith("policy:")
|
||
|
|
assert not outcome.ran
|
||
|
|
assert spawn.calls == []
|
||
|
|
assert runner.commands_run == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_timeout_is_recorded_rather_than_raised() -> None:
|
||
|
|
runner, _, _ = build([subprocess.TimeoutExpired(cmd="kubectl", timeout=1.0)])
|
||
|
|
|
||
|
|
outcome = runner.run(("kubectl", "get", "pods"), OPERATOR)
|
||
|
|
|
||
|
|
assert outcome.error == runner_module.TIMEOUT_ERROR
|
||
|
|
assert not outcome.ran
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_missing_binary_is_recorded_rather_than_raised() -> None:
|
||
|
|
runner, _, _ = build([FileNotFoundError(2, "No such file or directory")])
|
||
|
|
|
||
|
|
outcome = runner.run(("kubectl", "get", "pods"), OPERATOR)
|
||
|
|
|
||
|
|
assert outcome.error and outcome.error.startswith("spawn:")
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_run_deadline_stops_further_probes_without_spawning() -> None:
|
||
|
|
runner, spawn, clock = build([completed(stdout="first")], deadline_seconds=10.0)
|
||
|
|
|
||
|
|
assert runner.run(("kubectl", "get", "pods"), OPERATOR).ok
|
||
|
|
clock.advance(11.0)
|
||
|
|
second = runner.run(("kubectl", "get", "nodes"), OPERATOR)
|
||
|
|
|
||
|
|
assert second.error == runner_module.DEADLINE_ERROR
|
||
|
|
assert len(spawn.calls) == 1
|
||
|
|
assert runner.remaining_seconds < 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_command_timeout_is_clamped_by_the_remaining_deadline() -> None:
|
||
|
|
runner, spawn, clock = build([completed()], command_timeout=30.0, deadline_seconds=10.0)
|
||
|
|
clock.advance(6.0)
|
||
|
|
|
||
|
|
runner.run(("kubectl", "get", "pods"), OPERATOR)
|
||
|
|
|
||
|
|
assert spawn.calls[0]["timeout"] == 4.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_output_is_bounded_per_command_and_can_be_raised_per_call() -> None:
|
||
|
|
runner, _, _ = build([completed(stdout="y" * 200), completed(stdout="y" * 200)], max_bytes=50)
|
||
|
|
|
||
|
|
small = runner.run(("kubectl", "get", "pods"), OPERATOR)
|
||
|
|
large = runner.run(("kubectl", "get", "pods"), OPERATOR, max_bytes=4096)
|
||
|
|
|
||
|
|
assert small.truncated
|
||
|
|
assert not large.truncated
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_child_environment_is_rebuilt_from_an_allowlist() -> None:
|
||
|
|
source = {"PATH": "/bin", "KUBECONFIG": "/tmp/kc", "ANTHROPIC_API_KEY": "leak", "HOME": "/root"}
|
||
|
|
environment = runner_module.build_environment(OPERATOR, source)
|
||
|
|
|
||
|
|
assert environment == {"PATH": "/bin", "KUBECONFIG": "/tmp/kc", "HOME": "/root", "LC_ALL": "C"}
|
||
|
|
assert "ANTHROPIC_API_KEY" not in environment
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_vantage_can_override_the_environment_it_needs() -> None:
|
||
|
|
vantage = runner_module.operator_vantage(kubeconfig="/tmp/operator.yaml")
|
||
|
|
environment = runner_module.build_environment(vantage, {"KUBECONFIG": "/tmp/other"})
|
||
|
|
|
||
|
|
assert environment["KUBECONFIG"] == "/tmp/operator.yaml"
|
||
|
|
assert "kubeconfig-pinned" in vantage.description
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_operator_context_is_inserted_only_for_context_aware_tools() -> None:
|
||
|
|
vantage = runner_module.operator_vantage(context="atlas-operator")
|
||
|
|
|
||
|
|
assert vantage.wrap(("kubectl", "get", "pods"))[:3] == ("kubectl", "--context", "atlas-operator")
|
||
|
|
assert vantage.wrap(("git", "status")) == ("git", "status")
|
||
|
|
assert vantage.wrap(()) == ()
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_pod_vantage_execs_into_the_container_and_drops_the_context() -> None:
|
||
|
|
operator = runner_module.operator_vantage(context="atlas-operator")
|
||
|
|
vantage = runner_module.pod_vantage("hermes", "hermes-agent-1", "hermes", operator)
|
||
|
|
|
||
|
|
wrapped = vantage.wrap(("kubectl", "get", "pods"))
|
||
|
|
|
||
|
|
assert wrapped[:3] == ("kubectl", "--context", "atlas-operator")
|
||
|
|
assert wrapped[3] == "exec"
|
||
|
|
assert wrapped[-4:] == ("--", "kubectl", "get", "pods")
|
||
|
|
assert vantage.description == "in-pod hermes/hermes-agent-1[hermes]"
|
||
|
|
policy.check_argv(wrapped)
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_pod_vantage_without_an_operator_still_addresses_the_pod() -> None:
|
||
|
|
vantage = runner_module.pod_vantage("hermes", "pod-a", "hermes")
|
||
|
|
|
||
|
|
assert vantage.wrap(("kubectl", "version"))[0] == "kubectl"
|
||
|
|
assert vantage.env == {}
|
||
|
|
|
||
|
|
|
||
|
|
def test_outcome_serialisation_screens_the_command_line() -> None:
|
||
|
|
outcome = runner_module.Outcome(
|
||
|
|
argv=("git", "clone", "https://user:a-long-enough-secret@scm.example.dev/x"),
|
||
|
|
vantage="operator",
|
||
|
|
returncode=0,
|
||
|
|
stdout="ok",
|
||
|
|
)
|
||
|
|
|
||
|
|
payload = outcome.as_dict()
|
||
|
|
|
||
|
|
assert "a-long-enough-secret" not in payload["command"]
|
||
|
|
assert payload["vantage"] == "operator"
|
||
|
|
assert payload["stdout"] == "ok"
|
||
|
|
|
||
|
|
|
||
|
|
def test_combined_output_joins_only_the_streams_that_carry_text() -> None:
|
||
|
|
assert runner_module.Outcome(argv=(), vantage="x", stdout="a", stderr="b").combined == "a\nb"
|
||
|
|
assert runner_module.Outcome(argv=(), vantage="x", stdout="a").combined == "a"
|
||
|
|
assert runner_module.Outcome(argv=(), vantage="x").combined == ""
|