diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f25d650..7a66260a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Controlled pinned-Chromium Agent Task success now binds the ChromeDriver browser PID to its Linux `/proc//stat` start-time identity and fails closed unless that exact root process terminates after session/driver shutdown; PID reuse counts only as termination of the original identity, and this does not yet prove termination of every Chromium descendant or process ownership outside the controlled runner. - Failed ordinary and forced-close Agent Task browser trials now retain credential-free temporary-profile cleanup evidence after bounded browser errors, and separate aggregate compatibility gates require cleanup proof from every trial rather than filtering unsuccessful trials out; this does not attest adversarial filesystem erasure, process termination, or arbitrary browser recovery. - Failed Manifest V3 restart trials now retain credential-free temporary-profile cleanup evidence after bounded browser errors, including reviewed ChromeDriver process-teardown `TimeoutExpired` failures; successful trials record the same cleanup fact, and an aggregate compatibility gate requires teardown proof from every MV3 trial before repeatability acceptance without retaining exception messages or command paths; this does not attest adversarial filesystem erasure, browser-process termination, or cleanup outside the controlled temporary profile. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 4315e31d3..e5cc239af 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -21,6 +21,7 @@ import http.client import http.server import json +import math import os import pathlib import socket @@ -42,8 +43,10 @@ REQUEST_TIMEOUT_SECONDS = 5.0 STARTUP_TIMEOUT_SECONDS = 20.0 FIXTURE_TIMEOUT_SECONDS = 20.0 +PROCESS_EXIT_TIMEOUT_SECONDS = 5.0 MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576 MAX_PROC_STATUS_CHARACTERS = 65_536 +MAX_PROC_STAT_CHARACTERS = 65_536 MAX_BROWSER_PROCESS_TREE_SIZE = 256 MAX_PROC_PROCESS_SCAN_SIZE = 32_768 MAX_SEMANTIC_LOCATOR_CANDIDATES = 128 @@ -405,6 +408,100 @@ def _parse_linux_proc_status_process_identity(status_text: str) -> tuple[int, in return process_id, parent_process_id +def _parse_linux_proc_stat_process_identity(stat_text: str) -> tuple[int, int]: + """Parse one Linux proc-stat PID/start-time identity without trusting ``comm`` text.""" + + if not isinstance(stat_text, str) or not stat_text: + raise ValueError("Linux proc stat must be non-empty text") + command_open = stat_text.find(" (") + command_close = stat_text.rfind(") ") + if command_open <= 0 or command_close <= command_open + 2: + raise ValueError("malformed Linux proc stat process identity") + + raw_process_id = stat_text[:command_open] + if not raw_process_id.isascii() or not raw_process_id.isdigit(): + raise ValueError("malformed Linux proc stat process identifier") + process_id = int(raw_process_id, 10) + if process_id <= 0: + raise ValueError("Linux proc stat process identifier must be positive") + + command_text = stat_text[command_open + 2 : command_close] + if not command_text: + raise ValueError("Linux proc stat command must not be empty") + suffix_fields = stat_text[command_close + 2 :].split() + if len(suffix_fields) < 20 or len(suffix_fields[0]) != 1: + raise ValueError("Linux proc stat does not contain field 22 start time") + for raw_field in suffix_fields[1:]: + unsigned_field = raw_field[1:] if raw_field[:1] in {"+", "-"} else raw_field + if not unsigned_field or not unsigned_field.isascii() or not unsigned_field.isdigit(): + raise ValueError("malformed Linux proc stat numeric field") + + raw_start_time_ticks = suffix_fields[19] + if not raw_start_time_ticks.isascii() or not raw_start_time_ticks.isdigit(): + raise ValueError("malformed Linux proc stat start time") + start_time_ticks = int(raw_start_time_ticks, 10) + if start_time_ticks <= 0: + raise ValueError("Linux proc stat start time must be positive") + if start_time_ticks > MAX_U64: + raise OverflowError("Linux proc stat start time exceeds u64 range") + return process_id, start_time_ticks + + +def _read_linux_proc_stat_process_identity(process_id: int) -> tuple[int, int] | None: + """Read one bounded Linux PID/start-time identity, returning absence after exit.""" + + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + stat_path = pathlib.Path("/proc") / str(process_id) / "stat" + try: + with stat_path.open("r", encoding="utf-8", errors="strict") as stat_file: + stat_text = stat_file.read(MAX_PROC_STAT_CHARACTERS + 1) + except FileNotFoundError: + return None + if len(stat_text) > MAX_PROC_STAT_CHARACTERS: + raise RuntimeError("Linux proc stat exceeded the bounded text limit") + identity = _parse_linux_proc_stat_process_identity(stat_text) + if identity[0] != process_id: + raise RuntimeError("Linux proc stat identity did not match its directory") + return identity + + +def _wait_for_linux_process_identity_exit( + process_id: int, + start_time_ticks: int, + *, + timeout_seconds: float = PROCESS_EXIT_TIMEOUT_SECONDS, +) -> bool: + """Wait boundedly until the exact PID/start-time identity exits or is reused.""" + + 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(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-exit timeout") + + deadline = time.monotonic() + float(timeout_seconds) + expected_identity = (process_id, start_time_ticks) + while True: + current_identity = _read_linux_proc_stat_process_identity(process_id) + if current_identity is None or current_identity != expected_identity: + return True + remaining_seconds = deadline - time.monotonic() + if remaining_seconds <= 0: + return False + time.sleep(min(0.05, remaining_seconds)) + + def _sample_linux_process_rss_bytes(process_id: int) -> int: """Read one attributed Linux process RSS through a bounded ``/proc`` status file.""" @@ -824,6 +921,9 @@ def _run_agent_task_browser_pass( started = time.monotonic() driver_port = _free_loopback_port() session_id: str | None = None + browser_process_id: int | None = None + browser_process_start_time_ticks: int | None = None + result: dict[str, Any] | None = None driver = subprocess.Popen( [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], stdout=subprocess.DEVNULL, @@ -884,6 +984,10 @@ def _run_agent_task_browser_pass( or browser_process_id <= 0 ): raise RuntimeError("ChromeDriver did not return a valid browser process id") + browser_process_identity = _read_linux_proc_stat_process_identity(browser_process_id) + if browser_process_identity is None: + raise RuntimeError("Agent Task browser process identity disappeared after launch") + browser_process_start_time_ticks = browser_process_identity[1] _json_request( driver_port, @@ -1009,7 +1113,7 @@ def _run_agent_task_browser_pass( task_duration_ms = round((time.monotonic() - started) * 1000, 3) if task_duration_ms <= 0: raise RuntimeError("Agent Task measured a non-positive task duration") - return { + result = { "browser_version": browser_version, "post_condition": True, "input_echo_verified": True, @@ -1048,6 +1152,18 @@ def _run_agent_task_browser_pass( driver.kill() driver.wait(timeout=5) + if result is None: + raise RuntimeError("Agent Task browser pass returned no result after shutdown") + if browser_process_id is None or browser_process_start_time_ticks is None: + raise RuntimeError("Agent Task browser process identity was not captured") + if not _wait_for_linux_process_identity_exit( + browser_process_id, + browser_process_start_time_ticks, + ): + raise RuntimeError("Agent Task browser process did not terminate") + result["browser_process_terminated"] = True + return result + def _run_agent_task_trial( chrome_bin: pathlib.Path, @@ -1116,6 +1232,7 @@ def _run_agent_task_trial( "saved_credential_services_disabled" ], "browser_process_rss_bytes": result["browser_process_rss_bytes"], + "browser_process_terminated": result["browser_process_terminated"], "chromium_process_count": result["chromium_process_count"], "chromium_process_set_rss_bytes": result["chromium_process_set_rss_bytes"], "semantic_observation_bytes": result["semantic_observation_bytes"], @@ -1548,6 +1665,7 @@ def main() -> int: and trial["structured_value_sha256"].startswith("sha256:") and trial.get("extensions_disabled") is True and trial.get("profile_cleaned") is True + and trial.get("browser_process_terminated") is True and isinstance(trial.get("browser_process_rss_bytes"), int) and trial["browser_process_rss_bytes"] > 0 and isinstance(trial.get("chromium_process_count"), int) diff --git a/tests/test_agent_task_process_termination_contract.py b/tests/test_agent_task_process_termination_contract.py new file mode 100644 index 000000000..f92097d9e --- /dev/null +++ b/tests/test_agent_task_process_termination_contract.py @@ -0,0 +1,117 @@ +"""Contract for proving the controlled Agent Task browser process terminates.""" + +from __future__ import annotations + +import pathlib +import runpy +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +def _proc_stat(process_id: int, command: str, start_time_ticks: int) -> str: + """Build the bounded `/proc//stat` prefix through field 22.""" + + fields_three_through_twenty_one = ["S", *[str(value) for value in range(4, 22)]] + return ( + f"{process_id} ({command}) " + + " ".join(fields_three_through_twenty_one) + + f" {start_time_ticks}\n" + ) + + +class AgentTaskProcessTerminationContractTests(unittest.TestCase): + """Keep process-cleanup evidence PID-reuse-safe and fail closed.""" + + def test_runner_exposes_bounded_process_identity_and_exit_helpers(self) -> None: + """The runner needs a Linux process identity boundary, not PID-only polling.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_termination") + for expected in ( + "MAX_PROC_STAT_CHARACTERS", + "PROCESS_EXIT_TIMEOUT_SECONDS", + "_parse_linux_proc_stat_process_identity", + "_read_linux_proc_stat_process_identity", + "_wait_for_linux_process_identity_exit", + ): + with self.subTest(expected=expected): + self.assertIn(expected, namespace) + + def test_proc_stat_identity_parser_handles_command_text_and_rejects_ambiguity(self) -> None: + """PID reuse proof must bind a positive PID to the exact Linux start-time field.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_proc_stat_parser") + parser = namespace["_parse_linux_proc_stat_process_identity"] + + self.assertEqual(parser(_proc_stat(321, "chrome worker", 987654)), (321, 987654)) + self.assertEqual(parser(_proc_stat(322, "chrome ) helper", 987655)), (322, 987655)) + + for malformed in ( + "", + "321 chrome S 1 2 3\n", + _proc_stat(0, "chrome", 10), + _proc_stat(321, "chrome", 0), + _proc_stat(321, "chrome", -1), + "321 (chrome) S 1 2 3\n", + "not-a-pid (chrome) S " + " ".join(["1"] * 20) + "\n", + "321 (chrome) S " + " ".join(["1"] * 19) + " not-a-time\n", + ): + with self.subTest(malformed=malformed): + with self.assertRaises(ValueError): + parser(malformed) + + def test_process_exit_waiter_distinguishes_exit_pid_reuse_and_live_identity(self) -> None: + """A reused PID is not the original browser process and a live identity must fail closed.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_exit_waiter") + waiter = namespace["_wait_for_linux_process_identity_exit"] + original_reader = waiter.__globals__["_read_linux_proc_stat_process_identity"] + try: + waiter.__globals__["_read_linux_proc_stat_process_identity"] = ( + lambda _process_id: None + ) + self.assertTrue(waiter(321, 987654, timeout_seconds=0.0)) + + waiter.__globals__["_read_linux_proc_stat_process_identity"] = ( + lambda process_id: (process_id, 987655) + ) + self.assertTrue(waiter(321, 987654, timeout_seconds=0.0)) + + waiter.__globals__["_read_linux_proc_stat_process_identity"] = ( + lambda process_id: (process_id, 987654) + ) + self.assertFalse(waiter(321, 987654, timeout_seconds=0.0)) + finally: + waiter.__globals__["_read_linux_proc_stat_process_identity"] = original_reader + + for process_id, start_time_ticks, timeout_seconds in ( + (0, 987654, 0.0), + (321, 0, 0.0), + (321, 987654, -0.1), + ): + with self.subTest( + process_id=process_id, + start_time_ticks=start_time_ticks, + timeout_seconds=timeout_seconds, + ): + with self.assertRaises(ValueError): + waiter(process_id, start_time_ticks, timeout_seconds=timeout_seconds) + + def test_successful_agent_task_requires_post_shutdown_process_termination_evidence(self) -> None: + """A successful task must not be accepted while its original browser root is still live.""" + + runner = RUNNER.read_text(encoding="utf-8") + for expected in ( + "browser_process_start_time_ticks", + '"browser_process_terminated"', + 'result["browser_process_terminated"]', + 'trial.get("browser_process_terminated") is True', + "Agent Task browser process did not terminate", + ): + with self.subTest(expected=expected): + self.assertIn(expected, runner) + + +if __name__ == "__main__": + unittest.main()