diff --git a/CHANGELOG.md b/CHANGELOG.md index 04bc41cd3..7d9f45736 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Controlled browser-crash compatibility evidence now credits a crash only after the exact PID/start-time identity is signalled through a revalidated Linux pidfd and that same pidfd becomes readable within the bounded deadline; generic WebDriver transport failures no longer substitute for process-termination proof, while sampled Chromium process-set teardown remains a separate recovery boundary. - Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result. - Separated resolved-address authorization from direct transport evidence; an approved IP now becomes a usable stream only after the operating system reports the exact requested IP and port. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index ea8d1a00f..10df269c8 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -23,6 +23,7 @@ import math import os import pathlib +import select import signal import socket import string @@ -513,6 +514,68 @@ def _signal_linux_process_identity( os.close(pidfd) +def _signal_and_wait_for_linux_process_identity_termination( + process_identity: tuple[int, int], + signal_number: int, + *, + timeout_seconds: float = PROCESS_EXIT_TIMEOUT_SECONDS, +) -> bool: + """Signal one exact Linux identity and await termination on that same pidfd.""" + + if not isinstance(process_identity, tuple) or len(process_identity) != 2: + raise ValueError("invalid Linux process identity") + process_id, start_time_ticks = process_identity + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + if ( + isinstance(start_time_ticks, bool) + or not isinstance(start_time_ticks, int) + or start_time_ticks <= 0 + ): + raise ValueError("invalid Linux process start time") + if ( + isinstance(signal_number, bool) + or not isinstance(signal_number, int) + or signal_number <= 0 + ): + raise ValueError("invalid Linux process signal") + if ( + isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, (int, float)) + or timeout_seconds < 0 + or not math.isfinite(timeout_seconds) + ): + raise ValueError("invalid Linux process termination timeout") + + expected_identity = (process_id, start_time_ticks) + if _read_linux_proc_stat_process_identity(process_id) != expected_identity: + return False + pidfd_open = getattr(os, "pidfd_open", None) + pidfd_send_signal = getattr(signal, "pidfd_send_signal", None) + if not callable(pidfd_open) or not callable(pidfd_send_signal): + raise RuntimeError("Linux pidfd signalling is unavailable") + try: + pidfd = pidfd_open(process_id, 0) + except ProcessLookupError: + return False + try: + if _read_linux_proc_stat_process_identity(process_id) != expected_identity: + return False + try: + pidfd_send_signal(pidfd, signal_number) + except ProcessLookupError: + return False + readable, _writable, _exceptional = select.select( + [pidfd], + [], + [], + float(timeout_seconds), + ) + return bool(readable) + finally: + os.close(pidfd) + + def _wait_for_linux_process_identity_exit( process_id: int, start_time_ticks: int, @@ -2178,25 +2241,14 @@ def _run_agent_task_browser_crash_browser_pass( required_root_identity=browser_process_identity, ) ) - if not _signal_linux_process_identity(browser_process_identity, signal.SIGKILL): - raise RuntimeError("Agent Task browser process identity changed before crash signal") - - deadline = time.monotonic() + PROCESS_EXIT_TIMEOUT_SECONDS - while True: - try: - _json_request( - driver_port, - "GET", - _webdriver_path(session_id, "/url"), - timeout=1.0, - ) - except (OSError, RuntimeError, json.JSONDecodeError): - browser_process_crash_detected = True - break - remaining_seconds = deadline - time.monotonic() - if remaining_seconds <= 0: - raise RuntimeError("Agent Task browser remained usable after SIGKILL") - time.sleep(min(0.05, remaining_seconds)) + if not _signal_and_wait_for_linux_process_identity_termination( + browser_process_identity, + signal.SIGKILL, + ): + raise RuntimeError( + "Agent Task browser process was not observed terminated after crash signal" + ) + browser_process_crash_detected = True finally: _cleanup_crashed_browser_session(driver_port, session_id) _stop_crashed_driver(driver) diff --git a/tests/test_agent_task_browser_crash_exact_exit_detection.py b/tests/test_agent_task_browser_crash_exact_exit_detection.py new file mode 100644 index 000000000..9a0ae7579 --- /dev/null +++ b/tests/test_agent_task_browser_crash_exact_exit_detection.py @@ -0,0 +1,151 @@ +"""Contract for exact browser-root termination evidence before crash credit.""" + +from __future__ import annotations + +import contextlib +import pathlib +import runpy +import signal +import subprocess +import sys +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class AgentTaskBrowserCrashExactExitDetectionContractTests(unittest.TestCase): + """Prevent transport failures or unreaped zombies from faking crash evidence.""" + + def test_crash_detection_uses_exact_pidfd_termination_observation(self) -> None: + """Signal and observe termination through one exact kernel process handle.""" + + runner = RUNNER.read_text(encoding="utf-8") + start = runner.index("def _run_agent_task_browser_crash_browser_pass(") + end = runner.index("\ndef _run_agent_task_browser_crash_trial(", start) + browser_pass = runner[start:end] + + termination_call = browser_pass.index( + "_signal_and_wait_for_linux_process_identity_termination(" + ) + crash_credit = browser_pass.index( + "browser_process_crash_detected = True", termination_call + ) + + self.assertLess(termination_call, crash_credit) + self.assertNotIn( + "_wait_for_linux_process_identity_exit(", + browser_pass[termination_call:crash_credit], + ) + self.assertNotIn( + "except (OSError, RuntimeError, json.JSONDecodeError):", + browser_pass[termination_call:crash_credit], + ) + + def test_pidfd_termination_observes_killed_unreaped_child(self) -> None: + """Kernel termination evidence must not require the parent to reap the child.""" + + namespace = runpy.run_path( + str(RUNNER), + run_name="agent_task_browser_crash_exact_termination_contract", + ) + signal_and_wait = namespace[ + "_signal_and_wait_for_linux_process_identity_termination" + ] + read_identity = namespace["_read_linux_proc_stat_process_identity"] + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + identity = read_identity(child.pid) + self.assertIsNotNone(identity) + assert identity is not None + + self.assertTrue( + signal_and_wait(identity, signal.SIGKILL, timeout_seconds=1.0) + ) + self.assertEqual( + read_identity(child.pid), + identity, + "the killed child should still be observable as unreaped procfs identity", + ) + finally: + with contextlib.suppress(ProcessLookupError): + child.kill() + child.wait(timeout=5) + + def test_pidfd_termination_does_not_credit_signal_delivery_as_exit(self) -> None: + """A successfully delivered non-terminating signal is not termination evidence.""" + + namespace = runpy.run_path( + str(RUNNER), + run_name="agent_task_browser_crash_nonterminating_signal_contract", + ) + signal_and_wait = namespace[ + "_signal_and_wait_for_linux_process_identity_termination" + ] + read_identity = namespace["_read_linux_proc_stat_process_identity"] + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + identity = read_identity(child.pid) + self.assertIsNotNone(identity) + assert identity is not None + + self.assertFalse( + signal_and_wait(identity, signal.SIGCONT, timeout_seconds=0.05) + ) + self.assertIsNone( + child.poll(), + "signal delivery without process exit must not be credited as termination", + ) + finally: + with contextlib.suppress(ProcessLookupError): + child.kill() + child.wait(timeout=5) + + def test_pidfd_termination_refuses_stale_identity_without_signalling(self) -> None: + """A stale PID/start-time identity must never signal the current process owner.""" + + namespace = runpy.run_path( + str(RUNNER), + run_name="agent_task_browser_crash_stale_identity_contract", + ) + signal_and_wait = namespace[ + "_signal_and_wait_for_linux_process_identity_termination" + ] + read_identity = namespace["_read_linux_proc_stat_process_identity"] + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + identity = read_identity(child.pid) + self.assertIsNotNone(identity) + assert identity is not None + stale_identity = (identity[0], identity[1] + 1) + + self.assertFalse( + signal_and_wait(stale_identity, signal.SIGKILL, timeout_seconds=0.1) + ) + self.assertIsNone( + child.poll(), + "stale identity validation must happen before any signal is delivered", + ) + finally: + with contextlib.suppress(ProcessLookupError): + child.kill() + child.wait(timeout=5) + + +if __name__ == "__main__": + unittest.main()