216 lines
9.1 KiB
Python
216 lines
9.1 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Regression tests for the fail-closed PID-ownership guard.
|
|
|
|
Refs #90471 / #89614. The three patched Windows ``taskkill`` boundaries:
|
|
|
|
- ``hermes_cli/_subprocess_compat.pid_is_hermes`` / ``kill_process_tree``
|
|
- ``hermes_cli/dashboard_procs._kill_stale_dashboard_processes`` (win32)
|
|
- ``hermes_cli/update_cmd._stop_process_trees``
|
|
|
|
Acceptance from #90471:
|
|
1. missing / unreadable / non-matching identity fails closed -> no taskkill
|
|
2. a recycled or foreign PID control process remains untouched
|
|
3. probe failure or timeout is never converted into permission to kill
|
|
"""
|
|
import subprocess
|
|
import sys
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import _subprocess_compat
|
|
from hermes_cli import dashboard_procs
|
|
from hermes_cli import update_cmd
|
|
|
|
|
|
def _probe_stdout(value: str) -> mock.Mock:
|
|
return mock.Mock(stdout=value)
|
|
|
|
|
|
class TestPidIsHermes:
|
|
"""The shared identity probe must fail closed on every ambiguity."""
|
|
|
|
def test_non_windows_is_unconditional_pass(self):
|
|
# Non-Windows callers have no taskkill path at all.
|
|
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", False):
|
|
assert _subprocess_compat.pid_is_hermes(1234) is True
|
|
|
|
def test_non_windows_still_rejects_recycled_identity(self):
|
|
# An explicit fingerprint mismatch is a recycled PID on any platform.
|
|
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", False), mock.patch.object(
|
|
_subprocess_compat, "_process_start_time", return_value=456
|
|
):
|
|
assert _subprocess_compat.pid_is_hermes(
|
|
1234, expected_start_time=123
|
|
) is False
|
|
|
|
def test_hermes_match_requires_token_boundary(self):
|
|
# "hermes" buried inside an unrelated path segment must not match.
|
|
assert _subprocess_compat._text_names_hermes(
|
|
r"c:\users\shermesa\app.exe"
|
|
) is False
|
|
assert _subprocess_compat._text_names_hermes(
|
|
r"C:\Users\x\.hermes-runtime\python.exe -m hermes_cli.main"
|
|
) is True
|
|
assert _subprocess_compat._text_names_hermes(
|
|
"/opt/hermes-agent/venv/bin/python"
|
|
) is True
|
|
|
|
def test_invalid_pid_inputs_do_not_crash(self):
|
|
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True):
|
|
assert _subprocess_compat.pid_is_hermes(-1) is False
|
|
assert _subprocess_compat.pid_is_hermes(0) is False
|
|
assert _subprocess_compat.pid_is_hermes("not-a-pid") is False
|
|
assert _subprocess_compat.pid_is_hermes(True) is False
|
|
|
|
def test_probe_matches_hermes_like_process(self):
|
|
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True), mock.patch.object(
|
|
_subprocess_compat, "_process_start_time", return_value=123
|
|
), mock.patch.object(
|
|
_subprocess_compat, "_process_command_is_hermes", return_value=True
|
|
):
|
|
assert _subprocess_compat.pid_is_hermes(1234) is True
|
|
|
|
def test_probe_rejects_recycled_process_identity(self):
|
|
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True), mock.patch.object(
|
|
_subprocess_compat, "_process_start_time", return_value=456
|
|
), mock.patch.object(
|
|
_subprocess_compat, "_process_command_is_hermes", return_value=True
|
|
):
|
|
assert _subprocess_compat.pid_is_hermes(
|
|
1234, expected_start_time=123
|
|
) is False
|
|
|
|
def test_probe_rejects_foreign_process(self):
|
|
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True), mock.patch.object(
|
|
_subprocess_compat, "_process_start_time", return_value=123
|
|
), mock.patch.object(
|
|
_subprocess_compat, "_process_command_is_hermes", return_value=False
|
|
):
|
|
assert _subprocess_compat.pid_is_hermes(1234) is False
|
|
|
|
def test_probe_blank_stdout_fails_closed(self):
|
|
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True), mock.patch.object(
|
|
_subprocess_compat, "_process_start_time", return_value=None
|
|
):
|
|
assert _subprocess_compat.pid_is_hermes(1234) is False
|
|
|
|
def test_probe_timeout_fails_closed(self):
|
|
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True), mock.patch.object(
|
|
_subprocess_compat, "_process_start_time", return_value=None
|
|
):
|
|
assert _subprocess_compat.pid_is_hermes(1234) is False
|
|
|
|
def test_probe_oserror_fails_closed(self):
|
|
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True), mock.patch.object(
|
|
_subprocess_compat, "_process_start_time", side_effect=OSError("broken pipe")
|
|
):
|
|
assert _subprocess_compat.pid_is_hermes(1234) is False
|
|
|
|
@pytest.mark.skipif(sys.platform != "win32", reason="real probe is windows-only")
|
|
def test_missing_pid_real_probe_fails_closed(self):
|
|
# A PID that cannot exist must never be judged Hermes-owned.
|
|
assert _subprocess_compat.pid_is_hermes(2**24) is False
|
|
|
|
|
|
class TestKillProcessTree:
|
|
"""kill_process_tree operates on our own retained Popen handle.
|
|
|
|
A retained handle pins the PID (the child cannot be reaped while the
|
|
handle is open), so PID recycling is impossible there and the identity
|
|
guard deliberately does NOT apply — it could only false-refuse a
|
|
legitimate cleanup. These tests pin that contract for the legacy
|
|
Windows fallback path.
|
|
"""
|
|
|
|
def _proc(self, pid=4321):
|
|
return mock.Mock(pid=pid)
|
|
|
|
def test_retained_handle_is_taskkilled_without_probe(self):
|
|
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True), mock.patch.object(
|
|
_subprocess_compat, "pid_is_hermes"
|
|
) as guard, mock.patch.object(_subprocess_compat.subprocess, "run") as run:
|
|
_subprocess_compat._legacy_kill_process_tree(self._proc())
|
|
guard.assert_not_called()
|
|
run.assert_called_once()
|
|
argv = run.call_args.args[0]
|
|
assert argv[0] == "taskkill"
|
|
assert "/PID" in argv
|
|
assert str(4321) in argv
|
|
|
|
|
|
class TestStopProcessTrees:
|
|
"""update_cmd._stop_process_trees guard behaviour."""
|
|
|
|
def test_foreign_pids_only_probed(self):
|
|
with mock.patch(
|
|
"gateway.status.get_process_start_time", return_value=123
|
|
), mock.patch(
|
|
"hermes_cli._subprocess_compat.pid_is_hermes", return_value=False
|
|
), mock.patch.object(update_cmd.subprocess, "run") as run:
|
|
update_cmd._stop_process_trees([1111, 2222])
|
|
run.assert_not_called()
|
|
|
|
def test_hermes_pid_probed_then_taskkilled(self):
|
|
with mock.patch(
|
|
"gateway.status.get_process_start_time", return_value=123
|
|
), mock.patch(
|
|
"hermes_cli._subprocess_compat.pid_is_hermes", return_value=True
|
|
), mock.patch.object(
|
|
update_cmd.subprocess, "run", return_value=mock.Mock(returncode=0)
|
|
) as run:
|
|
update_cmd._stop_process_trees([1111])
|
|
assert len(run.call_args_list) == 1
|
|
assert run.call_args.args[0][0] == "taskkill"
|
|
|
|
def test_probe_timeout_skips_taskkill(self):
|
|
with mock.patch(
|
|
"gateway.status.get_process_start_time", return_value=123
|
|
), mock.patch(
|
|
"hermes_cli._subprocess_compat.pid_is_hermes", return_value=False
|
|
), mock.patch.object(update_cmd.subprocess, "run") as run:
|
|
update_cmd._stop_process_trees([1111, 2222]) # must not raise
|
|
run.assert_not_called()
|
|
|
|
|
|
class TestKillStaleDashboardProcesses:
|
|
"""dashboard_procs win32 kill branch guard behaviour."""
|
|
|
|
def _fake_m(self, pids=(12345,)):
|
|
m = mock.Mock()
|
|
m._find_stale_dashboard_pids.return_value = list(pids)
|
|
return m
|
|
|
|
def test_foreign_pid_reported_not_killed(self):
|
|
with mock.patch.object(dashboard_procs, "_m", return_value=self._fake_m()), mock.patch.object(
|
|
dashboard_procs.sys, "platform", "win32"
|
|
), mock.patch(
|
|
"gateway.status.get_process_start_time", return_value=123
|
|
), mock.patch(
|
|
"hermes_cli._subprocess_compat.pid_is_hermes", return_value=False
|
|
), mock.patch.object(dashboard_procs.subprocess, "run") as run:
|
|
result = dashboard_procs._kill_stale_dashboard_processes()
|
|
assert result["killed"] == []
|
|
assert result["failed"] == [
|
|
(12345, "not hermes-owned or process identity changed")
|
|
]
|
|
run.assert_not_called()
|
|
|
|
def test_hermes_pid_killed(self):
|
|
with mock.patch.object(dashboard_procs, "_m", return_value=self._fake_m()), mock.patch.object(
|
|
dashboard_procs.sys, "platform", "win32"
|
|
), mock.patch(
|
|
"gateway.status.get_process_start_time", return_value=123
|
|
), mock.patch(
|
|
"hermes_cli._subprocess_compat.pid_is_hermes", return_value=True
|
|
), mock.patch.object(
|
|
dashboard_procs.subprocess, "run", return_value=mock.Mock(
|
|
returncode=0, stderr="", stdout=""
|
|
)
|
|
) as run:
|
|
result = dashboard_procs._kill_stale_dashboard_processes()
|
|
taskkill_calls = [c for c in run.call_args_list if c.args[0][0] == "taskkill"]
|
|
assert len(taskkill_calls) == 1
|
|
assert result["killed"] == [12345]
|
|
assert result["failed"] == []
|