diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db26973..d295e158 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, 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; 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 83da4917..465f964a 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.""" @@ -818,6 +915,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, @@ -878,6 +978,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, @@ -1003,7 +1107,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, @@ -1042,6 +1146,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, @@ -1104,6 +1220,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"], @@ -1530,6 +1647,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 00000000..f92097d9 --- /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()