From 8fd4112e9f9b3a3dea33393bc66873e1b1ad4e8d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 12:21:38 +0900 Subject: [PATCH 01/22] test(browser): require bounded Chromium process-tree RSS --- .../test_agent_task_pinned_chrome_contract.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index 87faf198a..4b68965b5 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -86,6 +86,26 @@ def test_agent_task_records_real_bounded_resource_evidence(self) -> None: with self.subTest(expected=expected): self.assertIn(expected, runner) + def test_agent_task_records_bounded_chromium_process_tree_rss(self) -> None: + """The evidence runner must measure a bounded root-plus-descendant process set.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_tree_contract") + runner = RUNNER.read_text(encoding="utf-8") + for expected in ( + "MAX_BROWSER_PROCESS_TREE_SIZE", + "_parse_linux_children_process_ids", + "_discover_linux_process_tree_ids", + "_sample_linux_process_set_rss_bytes", + ): + with self.subTest(expected=expected): + self.assertIn(expected, namespace) + for expected in ( + '"chromium_process_count"', + '"chromium_process_set_rss_bytes"', + ): + with self.subTest(expected=expected): + self.assertIn(expected, runner) + def test_linux_rss_parser_is_strict_and_overflow_safe(self) -> None: """Runner-side RSS evidence must not accept ambiguous proc status input.""" @@ -104,6 +124,26 @@ def test_linux_rss_parser_is_strict_and_overflow_safe(self) -> None: with self.assertRaises((ValueError, OverflowError)): parser(malformed) + def test_linux_children_parser_is_bounded_unique_and_positive(self) -> None: + """Process-tree discovery must reject ambiguous or unbounded kernel child lists.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_children_contract") + parser = namespace["_parse_linux_children_process_ids"] + limit = namespace["MAX_BROWSER_PROCESS_TREE_SIZE"] + self.assertEqual(parser(""), ()) + self.assertEqual(parser("12 34\n"), (12, 34)) + for malformed in ( + "0\n", + "12 12\n", + "12 child\n", + "-1\n", + "12 34 trailing!\n", + " ".join(str(index) for index in range(1, limit + 2)), + ): + with self.subTest(malformed=malformed[:120]): + with self.assertRaises(ValueError): + parser(malformed) + def test_agent_task_fixture_runs_under_the_existing_pinned_chrome_job(self) -> None: """No floating browser or second workflow may be introduced for this slice.""" From f73f2a66ec2cb6bdbb66f27b7e56ad9dc29022c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:09:15 +0900 Subject: [PATCH 02/22] test(browser): measure bounded Chromium process-tree RSS --- scripts/ci/run_mv3_compatibility.py | 95 +++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 1aa680d50..2086ed33a 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -42,6 +42,7 @@ FIXTURE_TIMEOUT_SECONDS = 20.0 MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576 MAX_PROC_STATUS_CHARACTERS = 65_536 +MAX_BROWSER_PROCESS_TREE_SIZE = 256 MAX_U64 = (1 << 64) - 1 W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.") @@ -241,6 +242,86 @@ def _sample_linux_process_rss_bytes(process_id: int) -> int: return _parse_linux_proc_status_rss_bytes(status_text) +def _parse_linux_children_process_ids(children_text: str) -> tuple[int, ...]: + """Parse one bounded Linux ``children`` list into unique positive process IDs.""" + + fields = children_text.split() + if len(fields) > MAX_BROWSER_PROCESS_TREE_SIZE: + raise ValueError("Linux child process list exceeded the bounded process-tree size") + process_ids: list[int] = [] + seen: set[int] = set() + for field in fields: + if not field.isascii() or not field.isdigit(): + raise ValueError("malformed Linux child process identifier") + process_id = int(field, 10) + if process_id <= 0 or process_id in seen: + raise ValueError("Linux child process identifiers must be unique and positive") + seen.add(process_id) + process_ids.append(process_id) + return tuple(process_ids) + + +def _discover_linux_process_tree_ids(root_process_id: int) -> tuple[int, ...]: + """Discover one bounded root-plus-descendant Linux process tree from ``/proc``.""" + + if ( + isinstance(root_process_id, bool) + or not isinstance(root_process_id, int) + or root_process_id <= 0 + ): + raise ValueError("invalid Linux root process identifier") + + discovered: list[int] = [] + queued: list[int] = [root_process_id] + known: set[int] = {root_process_id} + while queued: + process_id = queued.pop(0) + discovered.append(process_id) + if len(discovered) > MAX_BROWSER_PROCESS_TREE_SIZE: + raise ValueError("Linux process tree exceeded the bounded process-tree size") + + children_path = ( + pathlib.Path("/proc") + / str(process_id) + / "task" + / str(process_id) + / "children" + ) + with children_path.open("r", encoding="utf-8", errors="strict") as children_file: + children_text = children_file.read(MAX_PROC_STATUS_CHARACTERS + 1) + if len(children_text) > MAX_PROC_STATUS_CHARACTERS: + raise RuntimeError("Linux proc children exceeded the bounded text limit") + children = _parse_linux_children_process_ids(children_text) + for child_process_id in children: + if child_process_id in known: + raise ValueError("Linux process tree contained a duplicate process identifier") + if len(known) >= MAX_BROWSER_PROCESS_TREE_SIZE: + raise ValueError("Linux process tree exceeded the bounded process-tree size") + known.add(child_process_id) + queued.append(child_process_id) + + return tuple(discovered) + + +def _sample_linux_process_set_rss_bytes(process_ids: tuple[int, ...]) -> int: + """Sample and sum one exact bounded Linux process set without silent overflow.""" + + if not process_ids or len(process_ids) > MAX_BROWSER_PROCESS_TREE_SIZE: + raise ValueError("invalid Linux process set size") + if len(set(process_ids)) != len(process_ids): + raise ValueError("Linux process set identifiers must be unique") + + total_rss_bytes = 0 + for process_id in process_ids: + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + rss_bytes = _sample_linux_process_rss_bytes(process_id) + if rss_bytes > MAX_U64 - total_rss_bytes: + raise OverflowError("Linux process-set RSS exceeds u64 byte range") + total_rss_bytes += rss_bytes + return total_rss_bytes + + def _wait_for_extension_evidence( driver_port: int, session_id: str, @@ -652,7 +733,13 @@ def _run_agent_task_browser_pass( raise RuntimeError(f"Agent Task state post-condition failed: {state!r}") if text != AGENT_TASK_INPUT_VALUE: raise RuntimeError("Agent Task result did not match the synthetic typed value") + + chromium_process_ids = _discover_linux_process_tree_ids(browser_process_id) browser_process_rss_bytes = _sample_linux_process_rss_bytes(browser_process_id) + chromium_process_set_rss_bytes = _sample_linux_process_set_rss_bytes( + chromium_process_ids + ) + chromium_process_count = len(chromium_process_ids) task_duration_ms = round((time.monotonic() - started) * 1000, 3) if task_duration_ms <= 0: raise RuntimeError("Agent Task measured a non-positive task duration") @@ -664,6 +751,8 @@ def _run_agent_task_browser_pass( "submit_semantics_verified": True, "extensions_disabled": True, "browser_process_rss_bytes": browser_process_rss_bytes, + "chromium_process_count": chromium_process_count, + "chromium_process_set_rss_bytes": chromium_process_set_rss_bytes, "semantic_observation_bytes": semantic_observation_bytes, "action_latency_ms": action_latency_ms, "task_duration_ms": task_duration_ms, @@ -720,6 +809,8 @@ def _run_agent_task_trial( "submit_semantics_verified": result["submit_semantics_verified"], "extensions_disabled": result["extensions_disabled"], "browser_process_rss_bytes": result["browser_process_rss_bytes"], + "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"], "action_latency_ms": result["action_latency_ms"], "task_duration_ms": result["task_duration_ms"], @@ -853,6 +944,10 @@ def main() -> int: and trial.get("profile_cleaned") 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) + and 0 < trial["chromium_process_count"] <= MAX_BROWSER_PROCESS_TREE_SIZE + and isinstance(trial.get("chromium_process_set_rss_bytes"), int) + and trial["chromium_process_set_rss_bytes"] > 0 and isinstance(trial.get("semantic_observation_bytes"), int) and trial["semantic_observation_bytes"] > 0 and isinstance(trial.get("action_latency_ms"), (int, float)) From a64d3b16967ff99b228ae0b44ff74a7494f52fc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:11:07 +0900 Subject: [PATCH 03/22] test(browser): reject unreliable live proc children discovery --- .../test_agent_task_pinned_chrome_contract.py | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index 4b68965b5..aa0ec5990 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -93,12 +93,15 @@ def test_agent_task_records_bounded_chromium_process_tree_rss(self) -> None: runner = RUNNER.read_text(encoding="utf-8") for expected in ( "MAX_BROWSER_PROCESS_TREE_SIZE", - "_parse_linux_children_process_ids", + "MAX_PROC_PROCESS_SCAN_SIZE", + "_parse_linux_proc_status_process_identity", + "_snapshot_linux_process_parent_ids", "_discover_linux_process_tree_ids", "_sample_linux_process_set_rss_bytes", ): with self.subTest(expected=expected): self.assertIn(expected, namespace) + self.assertNotIn("_parse_linux_children_process_ids", namespace) for expected in ( '"chromium_process_count"', '"chromium_process_set_rss_bytes"', @@ -124,23 +127,23 @@ def test_linux_rss_parser_is_strict_and_overflow_safe(self) -> None: with self.assertRaises((ValueError, OverflowError)): parser(malformed) - def test_linux_children_parser_is_bounded_unique_and_positive(self) -> None: - """Process-tree discovery must reject ambiguous or unbounded kernel child lists.""" + def test_linux_status_identity_parser_is_strict_and_positive(self) -> None: + """Parent-map discovery must parse one unambiguous process identity per status.""" - namespace = runpy.run_path(str(RUNNER), run_name="agent_task_children_contract") - parser = namespace["_parse_linux_children_process_ids"] - limit = namespace["MAX_BROWSER_PROCESS_TREE_SIZE"] - self.assertEqual(parser(""), ()) - self.assertEqual(parser("12 34\n"), (12, 34)) + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_parent_map_contract") + parser = namespace["_parse_linux_proc_status_process_identity"] + self.assertEqual(parser("Name:\tchrome\nPid:\t34\nPPid:\t12\n"), (34, 12)) + self.assertEqual(parser("Name:\tinit\nPid:\t1\nPPid:\t0\n"), (1, 0)) for malformed in ( - "0\n", - "12 12\n", - "12 child\n", - "-1\n", - "12 34 trailing!\n", - " ".join(str(index) for index in range(1, limit + 2)), + "Name:\tchrome\nPid:\t34\n", + "Name:\tchrome\nPPid:\t12\n", + "Pid:\t0\nPPid:\t12\n", + "Pid:\t34\nPPid:\t-1\n", + "Pid:\tchild\nPPid:\t12\n", + "Pid:\t34\nPid:\t35\nPPid:\t12\n", + "Pid:\t34\nPPid:\t12\nPPid:\t13\n", ): - with self.subTest(malformed=malformed[:120]): + with self.subTest(malformed=malformed): with self.assertRaises(ValueError): parser(malformed) From 675e5737f6cc507ae85e2fc5d56f43a17d4d22d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:13:22 +0900 Subject: [PATCH 04/22] fix(browser): discover Chromium process set from proc status --- scripts/ci/run_mv3_compatibility.py | 124 ++++++++++++++++++---------- 1 file changed, 81 insertions(+), 43 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 2086ed33a..290bb7bd0 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -43,6 +43,7 @@ MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576 MAX_PROC_STATUS_CHARACTERS = 65_536 MAX_BROWSER_PROCESS_TREE_SIZE = 256 +MAX_PROC_PROCESS_SCAN_SIZE = 32_768 MAX_U64 = (1 << 64) - 1 W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.") @@ -229,6 +230,35 @@ def _parse_linux_proc_status_rss_bytes(status_text: str) -> int: return rss_values[0] +def _parse_linux_proc_status_process_identity(status_text: str) -> tuple[int, int]: + """Parse exactly one positive ``Pid`` and one non-negative ``PPid`` from status.""" + + parsed: dict[str, int] = {} + for line in status_text.splitlines(): + if not (line.startswith("Pid:") or line.startswith("PPid:")): + continue + fields = line.split() + if len(fields) != 2 or fields[0] not in {"Pid:", "PPid:"}: + raise ValueError("malformed Linux process identity field") + label = fields[0] + if label in parsed: + raise ValueError("duplicate Linux process identity field") + raw_process_id = fields[1] + if not raw_process_id.isascii() or not raw_process_id.isdigit(): + raise ValueError("malformed Linux process identity value") + parsed[label] = int(raw_process_id, 10) + + if set(parsed) != {"Pid:", "PPid:"}: + raise ValueError("Linux proc status must contain exactly one Pid and PPid") + process_id = parsed["Pid:"] + parent_process_id = parsed["PPid:"] + if process_id <= 0: + raise ValueError("Linux process identifier must be positive") + if parent_process_id < 0: + raise ValueError("Linux parent process identifier must be non-negative") + return process_id, parent_process_id + + def _sample_linux_process_rss_bytes(process_id: int) -> int: """Read one attributed Linux process RSS through a bounded ``/proc`` status file.""" @@ -242,27 +272,45 @@ def _sample_linux_process_rss_bytes(process_id: int) -> int: return _parse_linux_proc_status_rss_bytes(status_text) -def _parse_linux_children_process_ids(children_text: str) -> tuple[int, ...]: - """Parse one bounded Linux ``children`` list into unique positive process IDs.""" +def _snapshot_linux_process_parent_ids() -> dict[int, int]: + """Read one bounded best-effort Linux PID/PPID snapshot from process status files.""" - fields = children_text.split() - if len(fields) > MAX_BROWSER_PROCESS_TREE_SIZE: - raise ValueError("Linux child process list exceeded the bounded process-tree size") - process_ids: list[int] = [] - seen: set[int] = set() - for field in fields: - if not field.isascii() or not field.isdigit(): - raise ValueError("malformed Linux child process identifier") - process_id = int(field, 10) - if process_id <= 0 or process_id in seen: - raise ValueError("Linux child process identifiers must be unique and positive") - seen.add(process_id) - process_ids.append(process_id) - return tuple(process_ids) + proc_root = pathlib.Path("/proc") + process_entries: list[tuple[int, pathlib.Path]] = [] + for entry in proc_root.iterdir(): + raw_process_id = entry.name + if not raw_process_id.isascii() or not raw_process_id.isdigit(): + continue + process_id = int(raw_process_id, 10) + if process_id <= 0: + continue + process_entries.append((process_id, entry)) + if len(process_entries) > MAX_PROC_PROCESS_SCAN_SIZE: + raise RuntimeError("Linux proc process scan exceeded the bounded entry limit") + + parent_ids: dict[int, int] = {} + for expected_process_id, entry in sorted(process_entries): + status_path = entry / "status" + try: + with status_path.open("r", encoding="utf-8", errors="strict") as status_file: + status_text = status_file.read(MAX_PROC_STATUS_CHARACTERS + 1) + except FileNotFoundError: + continue + if len(status_text) > MAX_PROC_STATUS_CHARACTERS: + raise RuntimeError("Linux proc status exceeded the bounded text limit") + process_id, parent_process_id = _parse_linux_proc_status_process_identity( + status_text + ) + if process_id != expected_process_id: + raise RuntimeError("Linux proc status identity did not match its directory") + if process_id in parent_ids: + raise RuntimeError("Linux proc process snapshot contained a duplicate PID") + parent_ids[process_id] = parent_process_id + return parent_ids def _discover_linux_process_tree_ids(root_process_id: int) -> tuple[int, ...]: - """Discover one bounded root-plus-descendant Linux process tree from ``/proc``.""" + """Discover one bounded root-plus-descendant process set from a PID/PPID snapshot.""" if ( isinstance(root_process_id, bool) @@ -271,35 +319,25 @@ def _discover_linux_process_tree_ids(root_process_id: int) -> tuple[int, ...]: ): raise ValueError("invalid Linux root process identifier") - discovered: list[int] = [] - queued: list[int] = [root_process_id] - known: set[int] = {root_process_id} - while queued: - process_id = queued.pop(0) - discovered.append(process_id) - if len(discovered) > MAX_BROWSER_PROCESS_TREE_SIZE: - raise ValueError("Linux process tree exceeded the bounded process-tree size") - - children_path = ( - pathlib.Path("/proc") - / str(process_id) - / "task" - / str(process_id) - / "children" + parent_ids = _snapshot_linux_process_parent_ids() + if root_process_id not in parent_ids: + raise RuntimeError("Linux process snapshot did not contain the browser root PID") + + discovered = [root_process_id] + known = {root_process_id} + while True: + children = sorted( + process_id + for process_id, parent_process_id in parent_ids.items() + if parent_process_id in known and process_id not in known ) - with children_path.open("r", encoding="utf-8", errors="strict") as children_file: - children_text = children_file.read(MAX_PROC_STATUS_CHARACTERS + 1) - if len(children_text) > MAX_PROC_STATUS_CHARACTERS: - raise RuntimeError("Linux proc children exceeded the bounded text limit") - children = _parse_linux_children_process_ids(children_text) - for child_process_id in children: - if child_process_id in known: - raise ValueError("Linux process tree contained a duplicate process identifier") + if not children: + break + for process_id in children: if len(known) >= MAX_BROWSER_PROCESS_TREE_SIZE: raise ValueError("Linux process tree exceeded the bounded process-tree size") - known.add(child_process_id) - queued.append(child_process_id) - + known.add(process_id) + discovered.append(process_id) return tuple(discovered) From 623ac23790535f3025a82af4f357e9fc2a1b3c36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:16:23 +0900 Subject: [PATCH 05/22] test(browser): require one sampled process evidence snapshot --- .../test_agent_task_pinned_chrome_contract.py | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index aa0ec5990..9d24c1ac2 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -87,7 +87,7 @@ def test_agent_task_records_real_bounded_resource_evidence(self) -> None: self.assertIn(expected, runner) def test_agent_task_records_bounded_chromium_process_tree_rss(self) -> None: - """The evidence runner must measure a bounded root-plus-descendant process set.""" + """The evidence runner must measure one bounded sampled process-set snapshot.""" namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_tree_contract") runner = RUNNER.read_text(encoding="utf-8") @@ -95,20 +95,40 @@ def test_agent_task_records_bounded_chromium_process_tree_rss(self) -> None: "MAX_BROWSER_PROCESS_TREE_SIZE", "MAX_PROC_PROCESS_SCAN_SIZE", "_parse_linux_proc_status_process_identity", - "_snapshot_linux_process_parent_ids", + "_snapshot_linux_process_evidence", "_discover_linux_process_tree_ids", "_sample_linux_process_set_rss_bytes", ): with self.subTest(expected=expected): self.assertIn(expected, namespace) self.assertNotIn("_parse_linux_children_process_ids", namespace) + self.assertNotIn("_snapshot_linux_process_parent_ids", namespace) for expected in ( '"chromium_process_count"', '"chromium_process_set_rss_bytes"', + '"failure_type"', ): with self.subTest(expected=expected): self.assertIn(expected, runner) + def test_process_tree_and_rss_use_one_sampled_process_snapshot(self) -> None: + """Descendant RSS must come from the same bounded status snapshot as lineage.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_snapshot") + discover = namespace["_discover_linux_process_tree_ids"] + sample = namespace["_sample_linux_process_set_rss_bytes"] + evidence = { + 10: (1, 100), + 20: (10, 200), + 30: (20, 300), + 40: (999, 400), + } + process_ids = discover(10, evidence) + self.assertEqual(process_ids, (10, 20, 30)) + self.assertEqual(sample(process_ids, evidence), 600) + with self.assertRaises(ValueError): + sample((10, 50), evidence) + def test_linux_rss_parser_is_strict_and_overflow_safe(self) -> None: """Runner-side RSS evidence must not accept ambiguous proc status input.""" @@ -128,7 +148,7 @@ def test_linux_rss_parser_is_strict_and_overflow_safe(self) -> None: parser(malformed) def test_linux_status_identity_parser_is_strict_and_positive(self) -> None: - """Parent-map discovery must parse one unambiguous process identity per status.""" + """Process snapshot discovery must parse one unambiguous identity per status.""" namespace = runpy.run_path(str(RUNNER), run_name="agent_task_parent_map_contract") parser = namespace["_parse_linux_proc_status_process_identity"] From 1d12a1ce5939aded2ea149ed88413e48e77c8d3b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:20:14 +0900 Subject: [PATCH 06/22] fix(browser): sample Chromium lineage and RSS in one proc sweep --- scripts/ci/run_mv3_compatibility.py | 64 +++++++++++++++++++---------- 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 290bb7bd0..10c66af56 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -272,8 +272,8 @@ def _sample_linux_process_rss_bytes(process_id: int) -> int: return _parse_linux_proc_status_rss_bytes(status_text) -def _snapshot_linux_process_parent_ids() -> dict[int, int]: - """Read one bounded best-effort Linux PID/PPID snapshot from process status files.""" +def _snapshot_linux_process_evidence() -> dict[int, tuple[int, int | None]]: + """Capture one bounded best-effort PID/PPID/RSS sweep from Linux proc status.""" proc_root = pathlib.Path("/proc") process_entries: list[tuple[int, pathlib.Path]] = [] @@ -288,7 +288,7 @@ def _snapshot_linux_process_parent_ids() -> dict[int, int]: if len(process_entries) > MAX_PROC_PROCESS_SCAN_SIZE: raise RuntimeError("Linux proc process scan exceeded the bounded entry limit") - parent_ids: dict[int, int] = {} + process_evidence: dict[int, tuple[int, int | None]] = {} for expected_process_id, entry in sorted(process_entries): status_path = entry / "status" try: @@ -303,14 +303,24 @@ def _snapshot_linux_process_parent_ids() -> dict[int, int]: ) if process_id != expected_process_id: raise RuntimeError("Linux proc status identity did not match its directory") - if process_id in parent_ids: + if process_id in process_evidence: raise RuntimeError("Linux proc process snapshot contained a duplicate PID") - parent_ids[process_id] = parent_process_id - return parent_ids - - -def _discover_linux_process_tree_ids(root_process_id: int) -> tuple[int, ...]: - """Discover one bounded root-plus-descendant process set from a PID/PPID snapshot.""" + try: + rss_bytes: int | None = _parse_linux_proc_status_rss_bytes(status_text) + except ValueError as exc: + if "VmRSS must be positive" in str(exc) or "exactly one VmRSS" in str(exc): + rss_bytes = None + else: + raise + process_evidence[process_id] = (parent_process_id, rss_bytes) + return process_evidence + + +def _discover_linux_process_tree_ids( + root_process_id: int, + process_evidence: dict[int, tuple[int, int | None]], +) -> tuple[int, ...]: + """Discover one bounded root-plus-descendant set from sampled process evidence.""" if ( isinstance(root_process_id, bool) @@ -318,9 +328,7 @@ def _discover_linux_process_tree_ids(root_process_id: int) -> tuple[int, ...]: or root_process_id <= 0 ): raise ValueError("invalid Linux root process identifier") - - parent_ids = _snapshot_linux_process_parent_ids() - if root_process_id not in parent_ids: + if root_process_id not in process_evidence: raise RuntimeError("Linux process snapshot did not contain the browser root PID") discovered = [root_process_id] @@ -328,7 +336,7 @@ def _discover_linux_process_tree_ids(root_process_id: int) -> tuple[int, ...]: while True: children = sorted( process_id - for process_id, parent_process_id in parent_ids.items() + for process_id, (parent_process_id, _rss_bytes) in process_evidence.items() if parent_process_id in known and process_id not in known ) if not children: @@ -341,8 +349,11 @@ def _discover_linux_process_tree_ids(root_process_id: int) -> tuple[int, ...]: return tuple(discovered) -def _sample_linux_process_set_rss_bytes(process_ids: tuple[int, ...]) -> int: - """Sample and sum one exact bounded Linux process set without silent overflow.""" +def _sample_linux_process_set_rss_bytes( + process_ids: tuple[int, ...], + process_evidence: dict[int, tuple[int, int | None]], +) -> int: + """Sum positive sampled RSS for one exact bounded process set without overflow.""" if not process_ids or len(process_ids) > MAX_BROWSER_PROCESS_TREE_SIZE: raise ValueError("invalid Linux process set size") @@ -353,7 +364,11 @@ def _sample_linux_process_set_rss_bytes(process_ids: tuple[int, ...]) -> int: for process_id in process_ids: if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: raise ValueError("invalid Linux process identifier") - rss_bytes = _sample_linux_process_rss_bytes(process_id) + if process_id not in process_evidence: + raise ValueError("Linux process set was not present in the sampled evidence") + rss_bytes = process_evidence[process_id][1] + if isinstance(rss_bytes, bool) or not isinstance(rss_bytes, int) or rss_bytes <= 0: + raise ValueError("Linux process set contained unavailable sampled RSS") if rss_bytes > MAX_U64 - total_rss_bytes: raise OverflowError("Linux process-set RSS exceeds u64 byte range") total_rss_bytes += rss_bytes @@ -772,10 +787,15 @@ def _run_agent_task_browser_pass( if text != AGENT_TASK_INPUT_VALUE: raise RuntimeError("Agent Task result did not match the synthetic typed value") - chromium_process_ids = _discover_linux_process_tree_ids(browser_process_id) + process_evidence = _snapshot_linux_process_evidence() + chromium_process_ids = _discover_linux_process_tree_ids( + browser_process_id, + process_evidence, + ) browser_process_rss_bytes = _sample_linux_process_rss_bytes(browser_process_id) chromium_process_set_rss_bytes = _sample_linux_process_set_rss_bytes( - chromium_process_ids + chromium_process_ids, + process_evidence, ) chromium_process_count = len(chromium_process_ids) task_duration_ms = round((time.monotonic() - started) * 1000, 3) @@ -917,11 +937,12 @@ def main() -> int: trial_number, ) ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError): + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: trial_results.append( { "trial_number": trial_number, "passed": False, + "failure_type": type(exc).__name__, } ) @@ -959,11 +980,12 @@ def main() -> int: trial_number, ) ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError): + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: agent_task_trials.append( { "trial_number": trial_number, "passed": False, + "failure_type": type(exc).__name__, } ) From ec5a34ad7ef453879847182e357380dfb8e1312a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:22:19 +0900 Subject: [PATCH 07/22] docs(changelog): record sampled Chromium process-set evidence --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39ca..15b5a2de8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. +- Pinned Chrome-for-Testing Agent Task evidence now captures a bounded sampled Chromium root-plus-descendant process count and RSS total from one `/proc` status sweep, with bounded failure-type diagnostics while preserving the root-only metric and making no trusted per-task attribution claim. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. - Authoritative product documentation graph spanning PRD, TRD, ADR lifecycle/index, product-wide UML, conceptual ERD, requirement/decision traceability, threat modeling, product-wide test strategy, operability, API/protocol, release/rollback, and current primary-source standards doctoring, with machine-checkable repository contracts that keep conversation-derived future work distinct from protected-main implementation claims. From 85df93827e187865a136facb6a2fe37e265d4df2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 14:02:02 +0900 Subject: [PATCH 08/22] test(browser): reproduce missing descendant RSS failure --- tests/test_agent_task_pinned_chrome_contract.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index 9d24c1ac2..7f8e91b7b 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -129,6 +129,18 @@ def test_process_tree_and_rss_use_one_sampled_process_snapshot(self) -> None: with self.assertRaises(ValueError): sample((10, 50), evidence) + def test_process_set_tolerates_descendant_without_resident_rss(self) -> None: + """A sampled child with no resident RSS must not invalidate the whole tree.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_zero_rss_contract") + sample = namespace["_sample_linux_process_set_rss_bytes"] + evidence = { + 10: (1, 100), + 20: (10, None), + 30: (20, 300), + } + self.assertEqual(sample((10, 20, 30), evidence), 400) + def test_linux_rss_parser_is_strict_and_overflow_safe(self) -> None: """Runner-side RSS evidence must not accept ambiguous proc status input.""" From cbf922fccc83782d3e114ed65afbeb6d84ef5ce6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 14:08:05 +0900 Subject: [PATCH 09/22] fix(browser): tolerate nonresident Chromium descendants --- scripts/ci/run_mv3_compatibility.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 10c66af56..bf224c2ed 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -353,7 +353,7 @@ def _sample_linux_process_set_rss_bytes( process_ids: tuple[int, ...], process_evidence: dict[int, tuple[int, int | None]], ) -> int: - """Sum positive sampled RSS for one exact bounded process set without overflow.""" + """Sum resident RSS for one exact bounded process set without overflow.""" if not process_ids or len(process_ids) > MAX_BROWSER_PROCESS_TREE_SIZE: raise ValueError("invalid Linux process set size") @@ -367,8 +367,10 @@ def _sample_linux_process_set_rss_bytes( if process_id not in process_evidence: raise ValueError("Linux process set was not present in the sampled evidence") rss_bytes = process_evidence[process_id][1] + if rss_bytes is None: + continue if isinstance(rss_bytes, bool) or not isinstance(rss_bytes, int) or rss_bytes <= 0: - raise ValueError("Linux process set contained unavailable sampled RSS") + raise ValueError("Linux process set contained invalid sampled RSS") if rss_bytes > MAX_U64 - total_rss_bytes: raise OverflowError("Linux process-set RSS exceeds u64 byte range") total_rss_bytes += rss_bytes From 015e4a5f79c0abee40c6807b481d3afce613c6c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 14:35:32 +0900 Subject: [PATCH 10/22] test(browser): reject ambiguous sampled VmRSS evidence --- ...t_task_proc_snapshot_integrity_contract.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/test_agent_task_proc_snapshot_integrity_contract.py diff --git a/tests/test_agent_task_proc_snapshot_integrity_contract.py b/tests/test_agent_task_proc_snapshot_integrity_contract.py new file mode 100644 index 000000000..4289296fd --- /dev/null +++ b/tests/test_agent_task_proc_snapshot_integrity_contract.py @@ -0,0 +1,39 @@ +"""Integrity regressions for sampled Linux process evidence in the controlled browser fixture.""" + +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" + + +class AgentTaskProcSnapshotIntegrityContractTests(unittest.TestCase): + """Keep nonresident processes distinct from malformed or ambiguous proc evidence.""" + + def test_optional_rss_parser_accepts_absent_or_zero_but_rejects_ambiguity(self) -> None: + """Only an unambiguous absent/zero VmRSS may mean no resident bytes.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_proc_integrity_contract") + parser = namespace["_parse_linux_proc_status_optional_rss_bytes"] + + self.assertIsNone(parser("Name:\tchrome\nPid:\t34\nPPid:\t12\n")) + self.assertIsNone(parser("Name:\tchrome\nVmRSS:\t0 kB\n")) + self.assertEqual(parser("Name:\tchrome\nVmRSS:\t123 kB\n"), 123 * 1024) + + for malformed in ( + "VmRSS:\t123 kB\nVmRSS:\t124 kB\n", + "VmRSS:\t0 kB\nVmRSS:\t124 kB\n", + "VmRSS:\t123 MB\n", + "VmRSS:\t123 kB extra\n", + "VmRSS:\tnot-a-number kB\n", + ): + with self.subTest(malformed=malformed): + with self.assertRaises(ValueError): + parser(malformed) + + +if __name__ == "__main__": + unittest.main() From ef6f23365f225b825505a58556d6917aeef505a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 14:38:00 +0900 Subject: [PATCH 11/22] test(browser): restore green process-set evidence lane after RED probe --- ...t_task_proc_snapshot_integrity_contract.py | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 tests/test_agent_task_proc_snapshot_integrity_contract.py diff --git a/tests/test_agent_task_proc_snapshot_integrity_contract.py b/tests/test_agent_task_proc_snapshot_integrity_contract.py deleted file mode 100644 index 4289296fd..000000000 --- a/tests/test_agent_task_proc_snapshot_integrity_contract.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Integrity regressions for sampled Linux process evidence in the controlled browser fixture.""" - -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" - - -class AgentTaskProcSnapshotIntegrityContractTests(unittest.TestCase): - """Keep nonresident processes distinct from malformed or ambiguous proc evidence.""" - - def test_optional_rss_parser_accepts_absent_or_zero_but_rejects_ambiguity(self) -> None: - """Only an unambiguous absent/zero VmRSS may mean no resident bytes.""" - - namespace = runpy.run_path(str(RUNNER), run_name="agent_task_proc_integrity_contract") - parser = namespace["_parse_linux_proc_status_optional_rss_bytes"] - - self.assertIsNone(parser("Name:\tchrome\nPid:\t34\nPPid:\t12\n")) - self.assertIsNone(parser("Name:\tchrome\nVmRSS:\t0 kB\n")) - self.assertEqual(parser("Name:\tchrome\nVmRSS:\t123 kB\n"), 123 * 1024) - - for malformed in ( - "VmRSS:\t123 kB\nVmRSS:\t124 kB\n", - "VmRSS:\t0 kB\nVmRSS:\t124 kB\n", - "VmRSS:\t123 MB\n", - "VmRSS:\t123 kB extra\n", - "VmRSS:\tnot-a-number kB\n", - ): - with self.subTest(malformed=malformed): - with self.assertRaises(ValueError): - parser(malformed) - - -if __name__ == "__main__": - unittest.main() From ceb1c72cf0f91ca8723bb5b3029044dae5d185b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:08:04 +0900 Subject: [PATCH 12/22] test(browser): distinguish absent from ambiguous VmRSS --- .../test_agent_task_pinned_chrome_contract.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index 7f8e91b7b..df27f50b3 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -95,6 +95,7 @@ def test_agent_task_records_bounded_chromium_process_tree_rss(self) -> None: "MAX_BROWSER_PROCESS_TREE_SIZE", "MAX_PROC_PROCESS_SCAN_SIZE", "_parse_linux_proc_status_process_identity", + "_parse_linux_proc_status_optional_rss_bytes", "_snapshot_linux_process_evidence", "_discover_linux_process_tree_ids", "_sample_linux_process_set_rss_bytes", @@ -141,6 +142,25 @@ def test_process_set_tolerates_descendant_without_resident_rss(self) -> None: } self.assertEqual(sample((10, 20, 30), evidence), 400) + def test_optional_linux_rss_parser_separates_absence_from_ambiguity(self) -> None: + """Snapshot parsing may tolerate absence, never malformed or duplicate VmRSS.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_optional_rss_contract") + parser = namespace["_parse_linux_proc_status_optional_rss_bytes"] + self.assertIsNone(parser("Name:\tchrome\n")) + self.assertIsNone(parser("Name:\tchrome\nVmRSS:\t0 kB\n")) + self.assertEqual(parser("Name:\tchrome\nVmRSS:\t123 kB\n"), 123 * 1024) + for malformed in ( + "VmRSS:\t123 MB\n", + "VmRSS:\t123 kB extra\n", + "VmRSS:\tnot-a-number kB\n", + "VmRSS:\t123 kB\nVmRSS:\t124 kB\n", + "VmRSS:\t18446744073709551616 kB\n", + ): + with self.subTest(malformed=malformed): + with self.assertRaises((ValueError, OverflowError)): + parser(malformed) + def test_linux_rss_parser_is_strict_and_overflow_safe(self) -> None: """Runner-side RSS evidence must not accept ambiguous proc status input.""" From e5fabfd57387ec7d2db692961eda93c95cf8d886 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:12:17 +0900 Subject: [PATCH 13/22] fix(browser): fail closed on ambiguous VmRSS evidence --- scripts/ci/run_mv3_compatibility.py | 33 ++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index bf224c2ed..8240bf0c5 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -78,7 +78,7 @@ def _path_token(value: str, label: str) -> str: def _webdriver_path(session_id: str, suffix: str) -> str: - """Build one bounded ChromeDriver path from a validated session identifier.""" + """Build a bounded ChromeDriver path from a validated session identifier.""" safe_session = _path_token(session_id, "session identifier") if suffix and not suffix.startswith("/"): @@ -230,6 +230,29 @@ def _parse_linux_proc_status_rss_bytes(status_text: str) -> int: return rss_values[0] +def _parse_linux_proc_status_optional_rss_bytes(status_text: str) -> int | None: + """Parse optional Linux ``VmRSS`` without normalizing malformed evidence.""" + + rss_lines = [line for line in status_text.splitlines() if line.startswith("VmRSS:")] + if not rss_lines: + return None + if len(rss_lines) != 1: + raise ValueError("Linux proc status must contain at most one VmRSS field") + + fields = rss_lines[0].split() + if len(fields) != 3 or fields[0] != "VmRSS:" or fields[2] != "kB": + raise ValueError("malformed Linux VmRSS field") + raw_kibibytes = fields[1] + if not raw_kibibytes.isascii() or not raw_kibibytes.isdigit(): + raise ValueError("malformed Linux VmRSS value") + kibibytes = int(raw_kibibytes, 10) + if kibibytes == 0: + return None + if kibibytes > MAX_U64 // 1024: + raise OverflowError("Linux VmRSS exceeds u64 byte range") + return kibibytes * 1024 + + def _parse_linux_proc_status_process_identity(status_text: str) -> tuple[int, int]: """Parse exactly one positive ``Pid`` and one non-negative ``PPid`` from status.""" @@ -305,13 +328,7 @@ def _snapshot_linux_process_evidence() -> dict[int, tuple[int, int | None]]: raise RuntimeError("Linux proc status identity did not match its directory") if process_id in process_evidence: raise RuntimeError("Linux proc process snapshot contained a duplicate PID") - try: - rss_bytes: int | None = _parse_linux_proc_status_rss_bytes(status_text) - except ValueError as exc: - if "VmRSS must be positive" in str(exc) or "exactly one VmRSS" in str(exc): - rss_bytes = None - else: - raise + rss_bytes = _parse_linux_proc_status_optional_rss_bytes(status_text) process_evidence[process_id] = (parent_process_id, rss_bytes) return process_evidence From 3ab127fef69096904df5db851f33824e7d868c0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 23:50:27 +0900 Subject: [PATCH 14/22] chore(browser): sync hardened fixture contract --- tests/test_agent_task_fixture_contract.py | 66 +++++++++++++++++++---- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/tests/test_agent_task_fixture_contract.py b/tests/test_agent_task_fixture_contract.py index 2a1465b2e..2565a35c7 100644 --- a/tests/test_agent_task_fixture_contract.py +++ b/tests/test_agent_task_fixture_contract.py @@ -10,6 +10,21 @@ FIXTURE = ROOT / "tests" / "fixtures" / "agent_task_basic" / "index.html" +def _is_credential_input(attributes: dict[str, str | None]) -> bool: + """Return whether parsed input attributes describe a credential surface.""" + + input_type = (attributes.get("type") or "").strip().lower() + if input_type == "password": + return True + + autocomplete = (attributes.get("autocomplete") or "").strip().lower() + autocomplete_tokens = autocomplete.split() + return any( + token == "one-time-code" or "password" in token + for token in autocomplete_tokens + ) + + class _FixtureParser(HTMLParser): """Collect the small semantic surface required by the deterministic fixture.""" @@ -18,6 +33,7 @@ def __init__(self) -> None: self.ids: set[str] = set() self.labels_for: set[str] = set() self.input_names: set[str] = set() + self.input_attributes: list[dict[str, str | None]] = [] self.button_types: set[str] = set() self.hidden_injection_markers = 0 @@ -30,12 +46,15 @@ def handle_starttag( self.ids.add(element_id) if tag == "label" and attributes.get("for"): self.labels_for.add(attributes["for"]) - if tag == "input" and attributes.get("name"): - self.input_names.add(attributes["name"]) + if tag == "input": + self.input_attributes.append(attributes) + if attributes.get("name"): + self.input_names.add(attributes["name"]) if tag == "button" and attributes.get("type"): self.button_types.add(attributes["type"]) if ( attributes.get("data-originweave-untrusted") == "prompt-injection" + and "hidden" in attributes and attributes.get("aria-hidden") == "true" ): self.hidden_injection_markers += 1 @@ -70,20 +89,49 @@ def test_fixture_contains_explicit_untrusted_hidden_prompt_injection(self) -> No self.assertIn("UNTRUSTED_PAGE_INSTRUCTION", self.html) self.assertIn("request new browser capabilities", self.html) + def test_hidden_injection_requires_the_actual_hidden_attribute(self) -> None: + """ARIA metadata alone must not satisfy the hidden-injection fixture contract.""" + + parser = _FixtureParser() + parser.feed( + "" + "" + ) + self.assertEqual(parser.hidden_injection_markers, 1) + def test_fixture_is_synthetic_and_has_no_credential_fields(self) -> None: """The controlled workflow must not require or imitate real secret collection.""" + for attributes in self.parser.input_attributes: + with self.subTest(attributes=attributes): + self.assertFalse(_is_credential_input(attributes)) + lowered = self.html.lower() - for forbidden in ( - 'type="password"', - 'autocomplete="current-password"', - 'autocomplete="one-time-code"', - "api_key", - "secret_key", - ): + for forbidden in ("api_key", "secret_key"): with self.subTest(forbidden=forbidden): self.assertNotIn(forbidden, lowered) + def test_credential_detection_is_quote_independent(self) -> None: + """Parsed credential semantics must reject single-quoted and tokenized forms.""" + + for html in ( + "", + "", + "", + "", + "", + ): + with self.subTest(html=html): + parser = _FixtureParser() + parser.feed(html) + self.assertEqual(len(parser.input_attributes), 1) + self.assertTrue(_is_credential_input(parser.input_attributes[0])) + + parser = _FixtureParser() + parser.feed("") + self.assertEqual(len(parser.input_attributes), 1) + self.assertFalse(_is_credential_input(parser.input_attributes[0])) + if __name__ == "__main__": unittest.main() From d502385feb71187955a430f6280da44fde2644c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 09:21:49 +0900 Subject: [PATCH 15/22] test(browser): require inherited URL invariance --- tests/test_agent_task_pinned_chrome_contract.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index df27f50b3..ff183bcea 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -46,6 +46,19 @@ def test_agent_task_pass_uses_real_webdriver_input_and_post_condition(self) -> N with self.subTest(expected=expected): self.assertIn(expected, runner) + def test_agent_task_submission_preserves_the_loaded_url(self) -> None: + """Submission must prove that the controlled action did not navigate away.""" + + runner = RUNNER.read_text(encoding="utf-8") + for expected in ( + "initial_url", + "post_submit_url", + "url_unchanged", + ): + with self.subTest(expected=expected): + self.assertIn(expected, runner) + self.assertIn("Agent Task URL changed during submission", runner) + def test_agent_task_observes_computed_role_and_name_before_action(self) -> None: """Real-browser evidence must bind the controlled targets to semantic role/name.""" From d099b810174f7a8977fea3effab626b08ff202fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 09:23:36 +0900 Subject: [PATCH 16/22] fix(browser): preserve URL invariant in process-set evidence --- scripts/ci/run_mv3_compatibility.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 8240bf0c5..34506fb02 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -9,8 +9,9 @@ restart-persistence behavior. It also executes the controlled Agent Task fixture with extensions disabled in a fresh profile, verifies browser-computed role/name for the controlled action targets, performs real WebDriver input and click -operations, verifies the observable post-condition, and records bounded runtime -resource evidence without treating page content as instruction or authority. +operations, verifies the observable post-condition, proves the controlled action +preserves its loaded URL, and records bounded runtime resource evidence without +treating page content as instruction or authority. """ from __future__ import annotations @@ -78,7 +79,7 @@ def _path_token(value: str, label: str) -> str: def _webdriver_path(session_id: str, suffix: str) -> str: - """Build a bounded ChromeDriver path from a validated session identifier.""" + """Build one bounded ChromeDriver path from a validated session identifier.""" safe_session = _path_token(session_id, "session identifier") if suffix and not suffix.startswith("/"): @@ -732,6 +733,14 @@ def _run_agent_task_browser_pass( _webdriver_path(session_id, "/url"), {"url": fixture_url}, ) + initial_url = _json_request( + driver_port, + "GET", + _webdriver_path(session_id, "/url"), + ).get("value") + if initial_url != fixture_url: + raise RuntimeError("Agent Task did not load the requested fixture URL") + input_element = _find_element(driver_port, session_id, "#task-text") input_role, input_name = _get_element_semantics( driver_port, @@ -790,6 +799,15 @@ def _run_agent_task_browser_pass( if action_latency_ms <= 0: raise RuntimeError("Agent Task measured a non-positive action latency") + post_submit_url = _json_request( + driver_port, + "GET", + _webdriver_path(session_id, "/url"), + ).get("value") + url_unchanged = post_submit_url == initial_url + if not url_unchanged: + raise RuntimeError("Agent Task URL changed during submission") + result_element = _find_element(driver_port, session_id, "#task-result") state = _json_request( driver_port, @@ -824,6 +842,7 @@ def _run_agent_task_browser_pass( "browser_version": browser_version, "post_condition": True, "input_echo_verified": True, + "url_unchanged": url_unchanged, "input_semantics_verified": True, "submit_semantics_verified": True, "extensions_disabled": True, @@ -882,6 +901,7 @@ def _run_agent_task_trial( "browser_version": result["browser_version"], "post_condition": result["post_condition"], "input_echo_verified": result["input_echo_verified"], + "url_unchanged": result["url_unchanged"], "input_semantics_verified": result["input_semantics_verified"], "submit_semantics_verified": result["submit_semantics_verified"], "extensions_disabled": result["extensions_disabled"], @@ -1017,6 +1037,7 @@ def main() -> int: agent_task_surfaces_complete = all( trial.get("post_condition") is True and trial.get("input_echo_verified") is True + and trial.get("url_unchanged") is True and trial.get("input_semantics_verified") is True and trial.get("submit_semantics_verified") is True and trial.get("extensions_disabled") is True From 91bf621764e4a83926dddde8681a6214b0cadd04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:52:09 +0900 Subject: [PATCH 17/22] docs(browser): preserve process-set evidence changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d17419927..290a2df3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. +- Pinned Chrome-for-Testing Agent Task evidence now captures a bounded sampled Chromium root-plus-descendant process count and RSS total from one `/proc` status sweep, with bounded failure-type diagnostics while preserving the root-only metric and making no trusted per-task attribution claim. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. - Authoritative product documentation graph spanning PRD, TRD, ADR lifecycle/index, product-wide UML, conceptual ERD, requirement/decision traceability, threat modeling, product-wide test strategy, operability, API/protocol, release/rollback, and current primary-source standards doctoring, with machine-checkable repository contracts that keep conversation-derived future work distinct from protected-main implementation claims. From b0efd75b63435d30d8991ba5265bc1a063a993c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:46:39 -0700 Subject: [PATCH 18/22] test(browser): require one RSS evidence snapshot --- tests/test_agent_task_pinned_chrome_contract.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index ff183bcea..e8b6a5383 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -126,11 +126,12 @@ def test_agent_task_records_bounded_chromium_process_tree_rss(self) -> None: self.assertIn(expected, runner) def test_process_tree_and_rss_use_one_sampled_process_snapshot(self) -> None: - """Descendant RSS must come from the same bounded status snapshot as lineage.""" + """Root and descendant RSS must come from the same bounded status snapshot.""" namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_snapshot") discover = namespace["_discover_linux_process_tree_ids"] sample = namespace["_sample_linux_process_set_rss_bytes"] + sample_root = namespace["_sample_linux_process_snapshot_rss_bytes"] evidence = { 10: (1, 100), 20: (10, 200), @@ -139,9 +140,22 @@ def test_process_tree_and_rss_use_one_sampled_process_snapshot(self) -> None: } process_ids = discover(10, evidence) self.assertEqual(process_ids, (10, 20, 30)) + self.assertEqual(sample_root(10, evidence), 100) self.assertEqual(sample(process_ids, evidence), 600) with self.assertRaises(ValueError): sample((10, 50), evidence) + with self.assertRaises(RuntimeError): + sample_root(50, evidence) + + runner = RUNNER.read_text(encoding="utf-8") + self.assertIn( + "browser_process_rss_bytes = _sample_linux_process_snapshot_rss_bytes(", + runner, + ) + self.assertNotIn( + "browser_process_rss_bytes = _sample_linux_process_rss_bytes(browser_process_id)", + runner, + ) def test_process_set_tolerates_descendant_without_resident_rss(self) -> None: """A sampled child with no resident RSS must not invalidate the whole tree.""" From 7644e412d0c580174248ff6dc239f9da2d492b99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:09:17 +0900 Subject: [PATCH 19/22] fix(mv3): sample browser rss from one snapshot --- scripts/ci/run_mv3_compatibility.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index cfb081be3..c5798c1c5 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -395,6 +395,17 @@ def _sample_linux_process_set_rss_bytes( return total_rss_bytes +def _sample_linux_process_snapshot_rss_bytes( + process_id: int, + process_evidence: dict[int, tuple[int, int | None]], +) -> int: + """Sample one process RSS from the same bounded snapshot as its process set.""" + + if process_id not in process_evidence: + raise RuntimeError("Linux process snapshot did not contain the browser root PID") + return _sample_linux_process_set_rss_bytes((process_id,), process_evidence) + + def _wait_for_extension_evidence( driver_port: int, session_id: str, @@ -835,7 +846,10 @@ def _run_agent_task_browser_pass( browser_process_id, process_evidence, ) - browser_process_rss_bytes = _sample_linux_process_rss_bytes(browser_process_id) + browser_process_rss_bytes = _sample_linux_process_snapshot_rss_bytes( + browser_process_id, + process_evidence, + ) chromium_process_set_rss_bytes = _sample_linux_process_set_rss_bytes( chromium_process_ids, process_evidence, From 7861d88d21ed0f0adaeb467957e809826f835071 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:25:47 +0900 Subject: [PATCH 20/22] fix(mv3): reject unsafe fixture and proc paths --- CHANGELOG.md | 1 + docs/DOCUMENTATION_FITNESS.md | 4 +- scripts/ci/run_mv3_compatibility.py | 14 +++++ .../test_agent_task_pinned_chrome_contract.py | 51 +++++++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 290a2df3c..ae63f5b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security +- Hardened the controlled pinned-Chrome fixture server against symlink escapes outside its fixture root and made numeric `/proc` process snapshots skip symlinked or non-directory entries before reading status files. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. diff --git a/docs/DOCUMENTATION_FITNESS.md b/docs/DOCUMENTATION_FITNESS.md index 69f603252..e2d622a1c 100644 --- a/docs/DOCUMENTATION_FITNESS.md +++ b/docs/DOCUMENTATION_FITNESS.md @@ -164,9 +164,9 @@ Active #65 supplies a deterministic synthetic local web fixture with a labelled ### 3.20 Bounded browser process-set resource evidence -Active #51→#66 establishes two distinct layers: #51 owns single explicitly supplied Linux PID sampling and the bounded telemetry value boundary; #66 owns bounded duplicate-safe aggregation/sampling over an exact caller-owned PID set. #66's exact current contract rejects empty, zero-PID, duplicate, oversized and overflow states and fails closed if any member cannot be sampled. +Active #51→#66 establishes two distinct layers: #51 owns single explicitly supplied Linux PID sampling and the bounded telemetry value boundary; #66 owns bounded duplicate-safe aggregation/sampling over an exact caller-owned PID set. Active #72 adds bounded Agent Task resource evidence, and #73 extends the controlled pinned-Chrome lane with one sampled numeric `/proc` process set while rejecting symlinked entries and fixture paths that resolve outside the synthetic fixture root. These are exact-head test-harness contracts, not production browser attribution. #66's exact current contract rejects empty, zero-PID, duplicate, oversized and overflow states and fails closed if any member cannot be sampled. -**Resolution:** aggregate resource measurement must not silently undercount a known caller-owned process set, but process membership remains an external attribution responsibility. The implementation does not discover Chromium PIDs, prove process ancestry/task ownership, walk cgroups, sample GPU/VRAM or create a durable telemetry store. +**Resolution:** aggregate resource measurement must not silently undercount a known caller-owned process set, and controlled fixtures must not turn symlinks into authority to read outside their root. Process membership remains an external attribution responsibility. The implementation does not discover Chromium PIDs in the product runtime, prove process ancestry/task ownership, walk cgroups, sample GPU/VRAM or create a durable telemetry store. ## 4. Durable product decisions captured by the canonical graph diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index c5798c1c5..39b7ec2d9 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -56,6 +56,18 @@ class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): def log_message(self, _format: str, *args: object) -> None: """Suppress request logs because the fixture contains no diagnostic value.""" + def translate_path(self, path: str) -> str: + """Resolve requests only when their final path remains inside the fixture root.""" + + translated = pathlib.Path(super().translate_path(path)) + fixture_root = pathlib.Path(self.directory).resolve() + try: + resolved = translated.resolve(strict=False) + resolved.relative_to(fixture_root) + except (OSError, ValueError): + return str(fixture_root / ".originweave-rejected-fixture-path") + return str(resolved) + def _free_loopback_port() -> int: """Reserve and release one loopback TCP port for a short-lived local service.""" @@ -305,6 +317,8 @@ def _snapshot_linux_process_evidence() -> dict[int, tuple[int, int | None]]: raw_process_id = entry.name if not raw_process_id.isascii() or not raw_process_id.isdigit(): continue + if entry.is_symlink() or not entry.is_dir(): + continue process_id = int(raw_process_id, 10) if process_id <= 0: continue diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index 214b7b0fc..68a162a9a 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -2,9 +2,12 @@ from __future__ import annotations +import http.client import pathlib import runpy +import tempfile import unittest +import unittest.mock ROOT = pathlib.Path(__file__).resolve().parents[1] RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" @@ -172,6 +175,54 @@ def test_process_tree_and_rss_use_one_sampled_process_snapshot(self) -> None: runner, ) + def test_process_snapshot_ignores_symlinked_proc_entries(self) -> None: + """The proc snapshot must not follow a symlink presented as a PID entry.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_proc_symlink_contract") + with tempfile.TemporaryDirectory() as directory: + temporary_root = pathlib.Path(directory) + target = temporary_root / "target" + target.mkdir() + (target / "status").write_text( + "Name:\tchrome\nPid:\t123\nPPid:\t1\nVmRSS:\t1 kB\n", + encoding="utf-8", + ) + symlinked_entry = temporary_root / "123" + symlinked_entry.symlink_to(target, target_is_directory=True) + with unittest.mock.patch.object( + pathlib.Path, "iterdir", return_value=iter((symlinked_entry,)) + ): + evidence = namespace["_snapshot_linux_process_evidence"]() + + self.assertEqual(evidence, {}) + + def test_fixture_server_does_not_follow_symlinks_outside_fixture_root(self) -> None: + """The controlled fixture server must not disclose a linked outside file.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_fixture_symlink_contract") + with tempfile.TemporaryDirectory() as directory: + temporary_root = pathlib.Path(directory) + fixture_root = temporary_root / "fixture" + fixture_root.mkdir() + (fixture_root / "index.html").write_text("fixture", encoding="utf-8") + secret_path = temporary_root / "secret.txt" + secret_path.write_text("not-for-the-fixture", encoding="utf-8") + (fixture_root / "linked.txt").symlink_to(secret_path) + server, thread = namespace["_start_fixture_server"](fixture_root) + try: + connection = http.client.HTTPConnection( + "127.0.0.1", server.server_port, timeout=2 + ) + connection.request("GET", "/linked.txt") + response = connection.getresponse() + body = response.read() + connection.close() + finally: + namespace["_stop_fixture_server"](server, thread) + + self.assertIn(response.status, {403, 404}) + self.assertNotIn(b"not-for-the-fixture", body) + def test_process_set_tolerates_descendant_without_resident_rss(self) -> None: """A sampled child with no resident RSS must not invalidate the whole tree.""" From 15931e44754981656bcdf9f8276586f000ff56cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 14:14:50 -0700 Subject: [PATCH 21/22] fix(stack): restore current resource prerequisite tree --- .github/dependabot.yml | 7 + .../workflows/apply-rust-nightly-refresh.yml | 57 +++ .github/workflows/ci.yml | 6 +- .../workflows/hourly-product-development.yml | 4 +- ARCHITECTURE.md | 1 + CHANGELOG.md | 21 +- README.md | 6 +- crates/originweave-core/Cargo.toml | 3 + crates/originweave-core/src/mcp.rs | 248 ++++++++++++ crates/originweave-core/src/root.rs | 15 + .../tests/mcp_authority_route.rs | 362 ++++++++++++++++++ crates/originweave-evidence/src/lib.rs | 29 ++ crates/originweave-evidence/tests/evidence.rs | 4 + crates/originweave-policy/src/lib.rs | 20 + .../tests/mcp_route_binding.rs | 96 +++++ docs/DOCUMENTATION_FITNESS.md | 8 +- docs/README.md | 1 + docs/TEST_STRATEGY.md | 18 + .../0107-browser-protocol-adapter-strategy.md | 18 +- docs/doctoring.md | 14 +- docs/doctoring/rust-toolchain-freshness.md | 44 +++ docs/product-technical-gap-baseline.md | 325 ++++++++++++++++ .../action-postcondition-evidence.md | 39 +- docs/traceability/mcp-authority-route.md | 53 +++ scripts/ci/run_mv3_compatibility.py | 241 ++---------- .../test_agent_task_pinned_chrome_contract.py | 205 +++------- ...cumentation_active_pr_evidence_contract.py | 27 ++ tests/test_product_completion_gap_contract.py | 111 ++++++ tests/test_product_documentation_contract.py | 49 +++ tests/test_rust_toolchain_contract.py | 53 +++ 30 files changed, 1683 insertions(+), 402 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/apply-rust-nightly-refresh.yml create mode 100644 crates/originweave-core/src/mcp.rs create mode 100644 crates/originweave-core/src/root.rs create mode 100644 crates/originweave-core/tests/mcp_authority_route.rs create mode 100644 crates/originweave-policy/tests/mcp_route_binding.rs create mode 100644 docs/doctoring/rust-toolchain-freshness.md create mode 100644 docs/product-technical-gap-baseline.md create mode 100644 docs/traceability/mcp-authority-route.md create mode 100644 tests/test_product_completion_gap_contract.py create mode 100644 tests/test_rust_toolchain_contract.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..d331df5fd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "rust-toolchain" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 1 diff --git a/.github/workflows/apply-rust-nightly-refresh.yml b/.github/workflows/apply-rust-nightly-refresh.yml new file mode 100644 index 000000000..7f3186b39 --- /dev/null +++ b/.github/workflows/apply-rust-nightly-refresh.yml @@ -0,0 +1,57 @@ +name: Materialize Rust nightly refresh once + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + materialize-owned-branch: + if: >- + github.repository == 'ContextualWisdomLab/OriginWeave' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'agent/rust-toolchain-refresh-2026-08-19' && + github.event.pull_request.user.login == 'seonghobae' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - name: Materialize only the reviewed nightly snapshot + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + source_path = Path('.github/workflows/hourly-product-development.yml') + source = source_path.read_text(encoding='utf-8') + old = 'nightly-2026-08-01' + new = 'nightly-2026-08-18' + old_count = source.count(old) + new_count = source.count(new) + if old_count == 2 and new_count == 0: + refreshed_source = source.replace(old, new) + elif old_count == 0 and new_count == 2: + refreshed_source = source + else: + raise SystemExit( + f'expected exactly two selectors in one state, found old={old_count}, new={new_count}' + ) + output = Path('nightly-refresh-artifact/hourly-product-development.yml') + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(refreshed_source, encoding='utf-8') + refreshed = output.read_text(encoding='utf-8') + if old in refreshed or refreshed.count(new) < 2: + raise SystemExit('nightly refresh artifact failed its replacement contract') + PY + - name: Upload exact refreshed workflow + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: hourly-rust-nightly-${{ github.event.pull_request.head.sha }} + path: nightly-refresh-artifact/hourly-product-development.yml + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f804f7496..95c2fa1d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,13 +73,13 @@ jobs: persist-credentials: false - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 with: - toolchain: nightly-2026-08-01 + toolchain: nightly-2026-08-18 components: llvm-tools-preview - name: Install pinned cargo-llvm-cov run: cargo +1.97.1 install cargo-llvm-cov --version 0.8.6 --locked - name: Measure production functions, lines, regions, and branches run: >- - cargo +nightly-2026-08-01 llvm-cov + cargo +nightly-2026-08-18 llvm-cov --locked --workspace --all-features @@ -88,7 +88,7 @@ jobs: --output-path coverage.json - name: Record uncovered production lines run: >- - cargo +nightly-2026-08-01 llvm-cov report + cargo +nightly-2026-08-18 llvm-cov report --branch --text --show-missing-lines diff --git a/.github/workflows/hourly-product-development.yml b/.github/workflows/hourly-product-development.yml index 672754c69..396af4a95 100644 --- a/.github/workflows/hourly-product-development.yml +++ b/.github/workflows/hourly-product-development.yml @@ -154,7 +154,7 @@ jobs: run: | set -euo pipefail rustup toolchain install 1.97.1 --profile minimal --component clippy,rustfmt - rustup toolchain install nightly-2026-08-01 --profile minimal --component llvm-tools-preview + rustup toolchain install nightly-2026-08-18 --profile minimal --component llvm-tools-preview cargo +1.97.1 install cargo-llvm-cov --version 0.8.6 --locked archive="${RUNNER_TEMP}/opencode-linux-x64.tar.gz" curl -fsSL -o "$archive" \ @@ -894,7 +894,7 @@ jobs: cargo +1.97.1 test --locked --workspace --all-targets cargo +1.97.1 clippy --locked --workspace --all-targets -- -D warnings RUSTDOCFLAGS='-D warnings' cargo +1.97.1 doc --locked --workspace --no-deps - cargo +nightly-2026-08-01 llvm-cov \ + cargo +nightly-2026-08-18 llvm-cov \ --locked --workspace --all-features --branch --json --summary-only \ --output-path "${RUNNER_TEMP}/coverage.json" python3 scripts/ci/verify_coverage.py "${RUNNER_TEMP}/coverage.json" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9b23ef9f0..fe287389b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,6 +12,7 @@ This file is the canonical product-wide topology and bounded-context view. It is - [Requirement, decision, standards, and implementation traceability](docs/traceability/README.md) - [Research and standards doctoring](docs/doctoring.md) - [Product roadmap](docs/product-roadmap.md) +- [Live product and technical gap baseline](docs/product-technical-gap-baseline.md) Protected-main code and executable tests define current implementation truth; deployed build/release artifacts, migrations, and configuration are additional operational evidence when they exist. Accepted ADRs define design authority, not proof that planned behavior has shipped. The PRD/TRD/diagrams may also contain `Planned`, `Proposed`, or `Open` product direction; those labels must remain explicit until corresponding implementation and review evidence reaches protected `main`. diff --git a/CHANGELOG.md b/CHANGELOG.md index ae63f5b6b..211faa09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,26 +6,32 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Refreshed the product and technical gap baseline with the 2026-08-24 live inventory: 158 open pull requests (44 ready, 114 draft), refreshed exact base/head evidence for the #208–#222 release, enterprise-approval, BAP, and WARC/PROV chains, the governance issue additions #212 and #215, and a required-check provider-failure record for the fail-closed Strix re-dispatches on #208/#218/#220. +- Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. +- Refreshed the product and technical gap baseline with the current open-PR inventory and exact base/head evidence for the newest Chromium, BAP, extraction, WARC, and idempotency slices. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. +- Active PR #168 adds deterministic MCP `2026-07-28` stateless tool-routing foundations with bounded names, a single reviewed tool-to-action registry shared by routing and discovery metadata, and fail-closed policy binding that grants no ambient authority. This is active-PR evidence only; the complete MCP adapter, transport serialization, discovery response handling, OAuth, browser I/O, and persistence remain planned until separately integrated on protected `main`. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. - Authenticated `originweave-tls` service-identity boundary that consumes an existing verified TCP stream, requires exact TLS-origin and transport-origin equality, derives RFC 9525 DNS or literal-IP reference identity only from the canonical HTTPS origin, validates WebPKI with explicit roots and fixed time, permits only TLS 1.2 and TLS 1.3, and never reconnects or resolves. - Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior and `NotConfigured` revocation evidence. -- Credential-free TLS evidence containing canonical origin, requested and observed peer, DNS/IP reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. +- Credential-free TLS evidence containing canonical origin, TCP peers, reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. - Credential-free connection and redirect evidence containing canonical addresses, destination classes, target digests, hop numbers, and approved-address counts. - Credential-free verified TCP evidence containing the logical origin, requested socket, observed peer, destination class, successful attempt number, and per-attempt timeout. - Standard `Display` and `std::error::Error` contracts for destination, redirect, digest, direct-network, and TLS failures, including preserved destination-policy, rustls, and operating-system sources where applicable. - Real loopback TCP integration proof plus deterministic timeout, refusal, retry, peer-inspection, peer-mismatch, canonicalization, IPv6 metadata, and single-use replay tests. - Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding. - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. +- Real pinned-Chrome WebDriver evidence for the controlled Agent Task fixture: the CI lane uses an isolated profile, disables extensions, types and submits synthetic text, observes the same-document post-condition, and proves profile cleanup; this does not claim a shipped OriginWeave browser adapter. +- Active pinned-Chrome Agent Task evidence verifies browser-computed role/name for controlled input and submit targets before action; this remains test-harness semantic evidence and does not claim a product semantic observer or authority. +- Active pinned-Chrome Agent Task evidence records browser-process RSS, semantic-observation bytes, action latency, and task duration from bounded trusted adapter inputs; this remains test evidence and does not claim process-set attribution or product resource telemetry. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. -- Pinned Chrome-for-Testing Agent Task evidence now captures a bounded sampled Chromium root-plus-descendant process count and RSS total from one `/proc` status sweep, with bounded failure-type diagnostics while preserving the root-only metric and making no trusted per-task attribution claim. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. - Authoritative product documentation graph spanning PRD, TRD, ADR lifecycle/index, product-wide UML, conceptual ERD, requirement/decision traceability, threat modeling, product-wide test strategy, operability, API/protocol, release/rollback, and current primary-source standards doctoring, with machine-checkable repository contracts that keep conversation-derived future work distinct from protected-main implementation claims. @@ -34,6 +40,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Aligned the hourly product-development branch-coverage toolchain and its one-shot materializer with the reviewed `nightly-2026-08-18` pin, and corrected the official Dependabot Rust-toolchain reference. - 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. @@ -42,12 +49,19 @@ All notable changes to OriginWeave are documented in this file. The format follo - Updated the first Chromium slice to distinguish implemented origin, destination, direct TCP, and TLS identity kernels from the remaining trusted DNS adapter, proxy/PAC, HTTP budget, MIME, download, and Chromium integration required before safe navigation can be claimed. - Separated hourly product PR publication authority from the organization review and merge system, and added live default-branch and release-blocker rechecks immediately before publication. - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. +- Hardened the dated baseline evidence collector with fail-fast isolated artifacts, paginated branch and collaborator rules, and post-collection exact-head revalidation. +- Flattened every paginated workflow-run page in the baseline merge verdict so exact-head evidence cannot silently discard later runs. +- Hardened the baseline evidence procedure with exact-head legacy status and workflow-run capture, counted approval binding, required-workflow recording, merge verdict artifacts, and bounded moving-head retries. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. +- Tightened the product-baseline contract so the BiDi opening path and VPN/profile evidence retain their explicit not-shipped status within their own documentation sections. +- Refreshed the product and technical gap baseline against the 2026-08-21 live inventory: 150 open pull requests, 110 drafts, and the new hardened-runner/MV3 evidence gap issue #206. +- Tightened the baseline completion-gap contract so superseded inventory counts (including the 2026-08-21 150/40/110 snapshot) can no longer pass as current evidence. +- Refreshed the baseline's merge-authority statement to the live ruleset: two approving reviews are required, while the collaborator inventory still contains only the solo maintainer. +- Corrected the baseline evidence collector to flatten every paginated input, apply current reviewer and last-push approval semantics, and discard verdicts when either the PR head or base moves. ### Security -- Hardened the controlled pinned-Chrome fixture server against symlink escapes outside its fixture root and made numeric `/proc` process snapshots skip symlinked or non-directory entries before reading status files. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. @@ -73,6 +87,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Revocation is reported as not configured; the product makes no OCSP or CRL validation claim without supplied revocation evidence. - Every generic network header and query value is redacted before evidence leaves the trusted boundary, including conventionally benign field names containing attacker-controlled bytes. - Evidence capture enforces count and byte bounds and rejects credential-bearing source URLs, query strings, fragments, controls, whitespace, malformed percent escapes, encoded separators, dot segments, and backslash paths. +- Network-evidence paths and provenance source URL paths accept only RFC 3986 literal `pchar` syntax plus validated percent-encoded octets and slash separators, preventing raw general delimiters such as `[` and `]` or other invalid URI-presentation bytes from entering either evidence surface. - Hard RAM and VRAM pressure pauses the active agent and rejects new admission; hard VRAM pressure also offloads a resident local model. - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. diff --git a/README.md b/README.md index 17085c05d..a956ff60b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ OriginWeave is a Chromium-compatible, Rust-first control plane for governed AI agents on the web. It is designed to let an agent observe, extract, and act without turning untrusted page content into authority, exposing secrets to a model, connecting to an unapproved network destination, accepting an unauthenticated web service, or losing the evidence required to explain what happened. -> Project status: pre-alpha. The current repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, and authenticated TLS service-identity kernels. Chromium, WebDriver BiDi, CDP, MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. +> Project status: pre-alpha. The current protected repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, and authenticated TLS service-identity kernels. Chromium, WebDriver BiDi, CDP, complete MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. Active PR #168 implements only a bounded MCP `2026-07-28` stateless tool-routing and typed-action/policy foundation; it remains non-shipped active-PR evidence and does not make the complete MCP adapter available. ## Why OriginWeave @@ -40,6 +40,8 @@ The repository is organized as independently consumable Rust crates: - `originweave-resource`: task-level RAM, VRAM, thread, and frame-time budgets with cumulative mitigation plans. - `originweave-evidence`: universally value-redacted network evidence and source-bound provenance records. +Active PR #168 additionally carries a non-shipped `originweave-core` MCP routing registry and `originweave-policy` binding for the MCP `2026-07-28` `tools/call` boundary. That foundation validates and maps an explicit tool name to an existing typed action; it does not implement transport parsing, `tools/list`, OAuth, browser control, secret materialization, persistence, or ambient authority. + See [ARCHITECTURE.md](ARCHITECTURE.md) and the [architecture decision records](docs/adr/) for binding design decisions. ## Safety model @@ -97,7 +99,7 @@ isolated Chromium session → redacted provenance bundle ``` -Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, MCP and Browser Agent Protocol adapters, extension compatibility testing, GPU/RAM telemetry, prompt-injection benchmarks, and an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). +Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, completes the MCP and Browser Agent Protocol adapters beyond the active routing foundation, expands extension compatibility testing, adds GPU/RAM telemetry and prompt-injection benchmarks, and builds an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). ## Hourly product-development loop diff --git a/crates/originweave-core/Cargo.toml b/crates/originweave-core/Cargo.toml index 35c83b19b..517e41217 100644 --- a/crates/originweave-core/Cargo.toml +++ b/crates/originweave-core/Cargo.toml @@ -10,6 +10,9 @@ repository.workspace = true homepage.workspace = true publish = false +[lib] +path = "src/root.rs" + [dependencies] [lints] diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs new file mode 100644 index 000000000..b026d5e61 --- /dev/null +++ b/crates/originweave-core/src/mcp.rs @@ -0,0 +1,248 @@ +//! Fail-closed MCP routing integrity for the external adapter boundary. +//! +//! This module validates only the stateless MCP protocol/method/tool routing +//! envelope and derives an existing [`ActionKind`]. It is deliberately not an +//! authorization decision: callers must independently enforce OriginWeave +//! capability, risk, approval, origin, secret-broker, and evidence policies. +//! No MCP arguments, outputs, credentials, or arbitrary model-visible values +//! are retained by this boundary. + +use std::fmt; + +use crate::{ActionKind, Capability, RiskClass}; + +/// MCP protocol generation accepted by this stateless adapter boundary. +pub const MCP_PROTOCOL_VERSION: &str = "2026-07-28"; + +/// The only MCP method that can enter the typed action-routing boundary. +pub const MCP_TOOLS_CALL_METHOD: &str = "tools/call"; + +/// Maximum accepted MCP method-name length in bytes. +pub const MAX_MCP_METHOD_NAME_BYTES: usize = 64; + +/// Maximum accepted MCP tool-name length in bytes. +pub const MAX_MCP_TOOL_NAME_BYTES: usize = 128; + +/// One deterministic MCP tool descriptor derived from OriginWeave's reviewed action registry. +/// +/// The descriptor is discovery metadata only. It does not grant capabilities, origin access, +/// approval, secret access, or any other authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct McpToolCatalogEntry { + tool_name: &'static str, + action_kind: ActionKind, +} + +impl McpToolCatalogEntry { + /// Return the canonical MCP tool name exposed by this registry entry. + #[must_use] + pub const fn tool_name(&self) -> &'static str { + self.tool_name + } + + /// Return the typed OriginWeave action represented by this registry entry. + #[must_use] + pub const fn action_kind(&self) -> ActionKind { + self.action_kind + } + + /// Return the capability required by the represented action. + #[must_use] + pub const fn required_capability(&self) -> Capability { + self.action_kind.required_capability() + } + + /// Return the risk class assigned to the represented action. + #[must_use] + pub const fn risk_class(&self) -> RiskClass { + self.action_kind.risk_class() + } +} + +/// The complete explicit MCP tool-to-action registry accepted by this boundary. +/// +/// Order is deterministic so adapters can derive stable discovery output from this single +/// reviewed registry rather than maintaining a second mapping that could drift from routing. +const MCP_TOOL_CATALOG: &[McpToolCatalogEntry] = &[ + McpToolCatalogEntry { + tool_name: "originweave.observe", + action_kind: ActionKind::Observe, + }, + McpToolCatalogEntry { + tool_name: "originweave.extract", + action_kind: ActionKind::Extract, + }, + McpToolCatalogEntry { + tool_name: "originweave.navigate", + action_kind: ActionKind::Navigate, + }, + McpToolCatalogEntry { + tool_name: "originweave.download", + action_kind: ActionKind::Download, + }, + McpToolCatalogEntry { + tool_name: "originweave.draft", + action_kind: ActionKind::Draft, + }, + McpToolCatalogEntry { + tool_name: "originweave.submit", + action_kind: ActionKind::Submit, + }, + McpToolCatalogEntry { + tool_name: "originweave.upload", + action_kind: ActionKind::Upload, + }, + McpToolCatalogEntry { + tool_name: "originweave.fill_secret", + action_kind: ActionKind::FillSecret, + }, + McpToolCatalogEntry { + tool_name: "originweave.purchase", + action_kind: ActionKind::Purchase, + }, + McpToolCatalogEntry { + tool_name: "originweave.delete", + action_kind: ActionKind::Delete, + }, + McpToolCatalogEntry { + tool_name: "originweave.manage_permission", + action_kind: ActionKind::ManagePermission, + }, +]; + +/// Return the deterministic reviewed MCP tool catalog. +/// +/// Adapters may use this slice to derive discovery responses. Serialization, pagination, cache +/// policy, transport I/O, and authorization remain outside this stateless registry boundary. +#[must_use] +pub const fn supported_mcp_tools() -> &'static [McpToolCatalogEntry] { + MCP_TOOL_CATALOG +} + +/// A deterministic failure while validating untrusted MCP routing metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpToolBoundaryError { + /// The request names an MCP protocol generation this boundary does not support. + UnsupportedProtocolVersion, + /// MCP routing metadata disagrees with the method or tool name in the body. + HeaderBodyMismatch, + /// The request method violates the bounded ASCII MCP routing syntax. + InvalidMethod, + /// The request method is not the supported `tools/call` operation. + UnsupportedMethod, + /// The tool name violates the bounded ASCII MCP routing syntax. + InvalidToolName, + /// The tool name has no explicit mapping to an OriginWeave typed action. + UnknownTool, +} + +impl fmt::Display for McpToolBoundaryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedProtocolVersion => { + formatter.write_str("unsupported MCP protocol version") + } + Self::HeaderBodyMismatch => { + formatter.write_str("MCP routing headers do not match the request body") + } + Self::InvalidMethod => { + formatter.write_str("MCP method violates the bounded ASCII routing syntax") + } + Self::UnsupportedMethod => formatter + .write_str("only MCP tools/call requests can enter the typed action boundary"), + Self::InvalidToolName => { + formatter.write_str("MCP tool name violates the bounded ASCII routing syntax") + } + Self::UnknownTool => { + formatter.write_str("MCP tool is not mapped to an OriginWeave typed action") + } + } + } +} + +impl std::error::Error for McpToolBoundaryError {} + +/// An MCP tool call whose routing envelope has been validated and mapped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ValidatedMcpToolCall { + tool_name: &'static str, + action_kind: ActionKind, +} + +impl ValidatedMcpToolCall { + /// Validate one stateless MCP tool-call routing envelope. + /// + /// Routing integrity is intentionally narrower than authorization. A + /// successful value proves only that the untrusted protocol version, + /// routing metadata, body method, and body tool name agree with one + /// explicitly supported mapping. Each untrusted method and tool name is + /// shape-validated before cross-field comparison so malformed or oversized + /// metadata cannot bypass the bounded routing syntax through mismatch handling. + pub fn new( + protocol_version: &str, + routing_method: &str, + routing_tool_name: &str, + body_method: &str, + body_tool_name: &str, + ) -> Result { + if protocol_version != MCP_PROTOCOL_VERSION { + return Err(McpToolBoundaryError::UnsupportedProtocolVersion); + } + if !valid_method(routing_method) || !valid_method(body_method) { + return Err(McpToolBoundaryError::InvalidMethod); + } + if !valid_tool_name(routing_tool_name) || !valid_tool_name(body_tool_name) { + return Err(McpToolBoundaryError::InvalidToolName); + } + if routing_method != body_method || routing_tool_name != body_tool_name { + return Err(McpToolBoundaryError::HeaderBodyMismatch); + } + if routing_method != MCP_TOOLS_CALL_METHOD { + return Err(McpToolBoundaryError::UnsupportedMethod); + } + + let (tool_name, action_kind) = map_tool(routing_tool_name)?; + Ok(Self { + tool_name, + action_kind, + }) + } + + /// Return the canonical static tool name selected by the explicit mapping. + #[must_use] + pub const fn tool_name(&self) -> &'static str { + self.tool_name + } + + /// Return the existing OriginWeave typed action selected by this tool. + #[must_use] + pub const fn action_kind(&self) -> ActionKind { + self.action_kind + } +} + +fn valid_method(method: &str) -> bool { + if method.is_empty() || method.len() > MAX_MCP_METHOD_NAME_BYTES { + return false; + } + method + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/')) +} + +fn valid_tool_name(tool_name: &str) -> bool { + if tool_name.is_empty() || tool_name.len() > MAX_MCP_TOOL_NAME_BYTES { + return false; + } + tool_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +fn map_tool(tool_name: &str) -> Result<(&'static str, ActionKind), McpToolBoundaryError> { + MCP_TOOL_CATALOG + .iter() + .find(|entry| entry.tool_name == tool_name) + .map(|entry| (entry.tool_name, entry.action_kind)) + .ok_or(McpToolBoundaryError::UnknownTool) +} diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs new file mode 100644 index 000000000..7acced460 --- /dev/null +++ b/crates/originweave-core/src/root.rs @@ -0,0 +1,15 @@ +//! Shared security and governance contracts for OriginWeave. +//! +//! The historical core contracts remain source-compatible while adapter-specific +//! boundaries can live in focused modules without changing their authority model. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +#[path = "lib.rs"] +mod contracts; + +pub use contracts::*; + +/// Stateless MCP routing validation that maps only explicit tools to typed actions. +pub mod mcp; diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs new file mode 100644 index 000000000..80357ec63 --- /dev/null +++ b/crates/originweave-core/tests/mcp_authority_route.rs @@ -0,0 +1,362 @@ +use std::error::Error; + +use originweave_core::mcp::{ + MAX_MCP_METHOD_NAME_BYTES, MAX_MCP_TOOL_NAME_BYTES, MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, McpToolBoundaryError, ValidatedMcpToolCall, supported_mcp_tools, +}; +use originweave_core::{ActionKind, Capability, RiskClass}; + +fn validate(tool_name: &str) -> Result { + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + tool_name, + MCP_TOOLS_CALL_METHOD, + tool_name, + ) +} + +#[test] +fn supported_mcp_tools_map_to_exact_originweave_actions() -> Result<(), Box> { + let cases = [ + ( + "originweave.observe", + ActionKind::Observe, + Capability::Observe, + RiskClass::R0, + ), + ( + "originweave.extract", + ActionKind::Extract, + Capability::Extract, + RiskClass::R0, + ), + ( + "originweave.navigate", + ActionKind::Navigate, + Capability::Navigate, + RiskClass::R1, + ), + ( + "originweave.download", + ActionKind::Download, + Capability::Download, + RiskClass::R1, + ), + ( + "originweave.draft", + ActionKind::Draft, + Capability::Draft, + RiskClass::R2, + ), + ( + "originweave.submit", + ActionKind::Submit, + Capability::Submit, + RiskClass::R3, + ), + ( + "originweave.upload", + ActionKind::Upload, + Capability::Upload, + RiskClass::R3, + ), + ( + "originweave.fill_secret", + ActionKind::FillSecret, + Capability::FillSecret, + RiskClass::R3, + ), + ( + "originweave.purchase", + ActionKind::Purchase, + Capability::Purchase, + RiskClass::R4, + ), + ( + "originweave.delete", + ActionKind::Delete, + Capability::Delete, + RiskClass::R4, + ), + ( + "originweave.manage_permission", + ActionKind::ManagePermission, + Capability::ManagePermission, + RiskClass::R4, + ), + ]; + + for (tool_name, expected_action, expected_capability, expected_risk) in cases { + let call = validate(tool_name)?; + assert_eq!(call.tool_name(), tool_name); + assert_eq!(call.action_kind(), expected_action); + assert_eq!( + call.action_kind().required_capability(), + expected_capability + ); + assert_eq!(call.action_kind().risk_class(), expected_risk); + } + Ok(()) +} + +#[test] +fn mcp_tool_catalog_is_deterministic_complete_and_action_unambiguous() -> Result<(), Box> +{ + let expected = [ + ("originweave.observe", ActionKind::Observe), + ("originweave.extract", ActionKind::Extract), + ("originweave.navigate", ActionKind::Navigate), + ("originweave.download", ActionKind::Download), + ("originweave.draft", ActionKind::Draft), + ("originweave.submit", ActionKind::Submit), + ("originweave.upload", ActionKind::Upload), + ("originweave.fill_secret", ActionKind::FillSecret), + ("originweave.purchase", ActionKind::Purchase), + ("originweave.delete", ActionKind::Delete), + ( + "originweave.manage_permission", + ActionKind::ManagePermission, + ), + ]; + let catalog = supported_mcp_tools(); + + assert_eq!(catalog.len(), expected.len()); + for (entry, (expected_name, expected_action)) in catalog.iter().zip(expected) { + assert_eq!(entry.tool_name(), expected_name); + assert_eq!(entry.action_kind(), expected_action); + assert_eq!( + entry.required_capability(), + expected_action.required_capability() + ); + assert_eq!(entry.risk_class(), expected_action.risk_class()); + + let call = validate(entry.tool_name())?; + assert_eq!(call.action_kind(), entry.action_kind()); + } + + for (index, entry) in catalog.iter().enumerate() { + for other in &catalog[index + 1..] { + assert_ne!(entry.tool_name(), other.tool_name()); + assert_ne!(entry.action_kind(), other.action_kind()); + } + } + assert!( + catalog + .iter() + .all(|entry| entry.action_kind() != ActionKind::LegalConsent) + ); + Ok(()) +} + +#[test] +fn mcp_route_rejects_protocol_header_body_and_method_drift() { + assert_eq!( + ValidatedMcpToolCall::new( + "2025-11-25", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::UnsupportedProtocolVersion) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + "tools/list", + "originweave.observe", + ), + Err(McpToolBoundaryError::HeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.extract", + ), + Err(McpToolBoundaryError::HeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + "resources/read", + "originweave.observe", + "resources/read", + "originweave.observe", + ), + Err(McpToolBoundaryError::UnsupportedMethod) + ); +} + +#[test] +fn mcp_route_bounds_each_untrusted_method_before_cross_field_comparison() { + let at_limit = "x".repeat(MAX_MCP_METHOD_NAME_BYTES); + let oversized_routing = "r".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + let oversized_body = "b".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + "", + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + "", + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + &oversized_routing, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + &oversized_body, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + "tools call", + "originweave.observe", + "tools call", + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + &at_limit, + "originweave.observe", + &at_limit, + "originweave.observe", + ), + Err(McpToolBoundaryError::UnsupportedMethod) + ); +} + +#[test] +fn mcp_route_rejects_unbounded_malformed_and_unmapped_tool_names() { + let at_limit = "x".repeat(MAX_MCP_TOOL_NAME_BYTES); + let oversized = "x".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + for tool_name in [ + "", + "originweave legal", + "originweave/observe", + "originweave.관찰", + &oversized, + ] { + assert_eq!( + validate(tool_name), + Err(McpToolBoundaryError::InvalidToolName) + ); + } + + assert_eq!(validate(&at_limit), Err(McpToolBoundaryError::UnknownTool)); + assert_eq!( + validate("originweave.legal_consent"), + Err(McpToolBoundaryError::UnknownTool) + ); + assert_eq!( + validate("third_party.arbitrary_javascript"), + Err(McpToolBoundaryError::UnknownTool) + ); +} + +#[test] +fn mcp_route_validates_each_untrusted_tool_name_before_cross_field_comparison() { + let oversized_routing = "r".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + let oversized_body = "b".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + &oversized_routing, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidToolName) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + &oversized_body, + ), + Err(McpToolBoundaryError::InvalidToolName) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave/observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidToolName) + ); +} + +#[test] +fn mcp_boundary_errors_are_deterministic_and_do_not_echo_untrusted_values() { + let cases = [ + ( + McpToolBoundaryError::UnsupportedProtocolVersion, + "unsupported MCP protocol version", + ), + ( + McpToolBoundaryError::HeaderBodyMismatch, + "MCP routing headers do not match the request body", + ), + ( + McpToolBoundaryError::UnsupportedMethod, + "only MCP tools/call requests can enter the typed action boundary", + ), + ( + McpToolBoundaryError::InvalidMethod, + "MCP method violates the bounded ASCII routing syntax", + ), + ( + McpToolBoundaryError::InvalidToolName, + "MCP tool name violates the bounded ASCII routing syntax", + ), + ( + McpToolBoundaryError::UnknownTool, + "MCP tool is not mapped to an OriginWeave typed action", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index ad183e9eb..b38578044 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -224,6 +224,9 @@ fn validate_path(path: &str) -> Result<(), EvidenceError> { index += 3; continue; } + if !is_rfc3986_pchar(byte) { + return Err(EvidenceError::InvalidPath); + } segment.push(byte); index += 1; } @@ -233,6 +236,32 @@ fn validate_path(path: &str) -> Result<(), EvidenceError> { Ok(()) } +const fn is_rfc3986_pchar(byte: u8) -> bool { + matches!( + byte, + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + ) +} + const fn hexadecimal_value(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), diff --git a/crates/originweave-evidence/tests/evidence.rs b/crates/originweave-evidence/tests/evidence.rs index 2912180f1..48d49cbc4 100644 --- a/crates/originweave-evidence/tests/evidence.rs +++ b/crates/originweave-evidence/tests/evidence.rs @@ -80,6 +80,9 @@ fn network_evidence_rejects_non_path_inputs() { "/bad\npath", "/bad path", "/windows\\path", + "/[segment]", + "/raw|pipe", + "/raw-한글", ] { assert_eq!( NetworkEvidence::capture( @@ -128,6 +131,7 @@ fn provenance_rejects_credential_bearing_or_ambiguous_source_urls() { "https://example.com/bad\\path", "https://example.com/\n", "https://example.com/a/%2f/b", + "https://example.com/[segment]", ] { assert_eq!( ProvenanceRecord::new( diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index 243ae8ce7..dbfb3c16d 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -15,6 +15,7 @@ pub use sensitive_data::{ evaluate_handle_use, }; +use originweave_core::mcp::ValidatedMcpToolCall; use originweave_core::{ ActionRequest, ApprovalEvidence, ApprovalScope, Capability, ExecutionPurpose, InstructionSource, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, SessionMode, @@ -40,6 +41,8 @@ pub enum DenialReason { ModePurposeMismatch, /// Page or document content attempted to become a trusted instruction. UntrustedInstructionSource, + /// The validated MCP route resolved to a different action than the policy request. + McpActionMismatch, /// The session lacks the exact capability required by the action. MissingCapability(Capability), /// The target origin is outside the session's read grant. @@ -66,6 +69,23 @@ pub enum DenialReason { ApprovalScopeMismatch, } +/// Evaluate a policy request only when it matches an already validated MCP route. +/// +/// Matching routing metadata grants no authority. Once route and request action agree, the request +/// still passes through the existing action policy unchanged. +#[must_use] +pub fn evaluate_mcp( + call: &ValidatedMcpToolCall, + request: &ActionRequest, + context: &PolicyContext, +) -> Decision { + if call.action_kind() != request.action() { + return Decision::Deny(DenialReason::McpActionMismatch); + } + + evaluate(request, context) +} + /// Evaluate a typed browser action against one explicit policy context. #[must_use] pub fn evaluate(request: &ActionRequest, context: &PolicyContext) -> Decision { diff --git a/crates/originweave-policy/tests/mcp_route_binding.rs b/crates/originweave-policy/tests/mcp_route_binding.rs new file mode 100644 index 000000000..8e9661af6 --- /dev/null +++ b/crates/originweave-policy/tests/mcp_route_binding.rs @@ -0,0 +1,96 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::mcp::{MCP_PROTOCOL_VERSION, MCP_TOOLS_CALL_METHOD, ValidatedMcpToolCall}; +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, Capability, ExecutionPurpose, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, +}; +use originweave_policy::{Decision, DenialReason, evaluate_mcp}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn origin() -> Origin { + Origin::parse("https://mcp.example").expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn validated_call(tool_name: &str) -> ValidatedMcpToolCall { + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + tool_name, + MCP_TOOLS_CALL_METHOD, + tool_name, + ) + .expect("known test MCP tool") +} + +fn request(action: ActionKind) -> ActionRequest { + let site = origin(); + ActionRequest::new( + action, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ) +} + +fn context(capabilities: BTreeSet) -> PolicyContext { + let site = origin(); + PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + capabilities, + BTreeSet::from([site.clone()]), + BTreeSet::from([site]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ) +} + +#[test] +fn matching_mcp_route_enters_the_existing_policy_boundary() { + let call = validated_call("originweave.observe"); + let decision = evaluate_mcp( + &call, + &request(ActionKind::Observe), + &context(BTreeSet::from([Capability::Observe])), + ); + + assert_eq!(decision, Decision::Allow); +} + +#[test] +fn mismatched_mcp_route_cannot_be_reinterpreted_as_another_action() { + let call = validated_call("originweave.observe"); + let decision = evaluate_mcp( + &call, + &request(ActionKind::Navigate), + &context(BTreeSet::from([Capability::Navigate])), + ); + + assert_eq!(decision, Decision::Deny(DenialReason::McpActionMismatch)); +} + +#[test] +fn matching_mcp_route_does_not_bypass_existing_policy_denials() { + let call = validated_call("originweave.navigate"); + let decision = evaluate_mcp( + &call, + &request(ActionKind::Navigate), + &context(BTreeSet::from([Capability::Observe])), + ); + + assert_eq!( + decision, + Decision::Deny(DenialReason::MissingCapability(Capability::Navigate)) + ); +} diff --git a/docs/DOCUMENTATION_FITNESS.md b/docs/DOCUMENTATION_FITNESS.md index e2d622a1c..640ed7111 100644 --- a/docs/DOCUMENTATION_FITNESS.md +++ b/docs/DOCUMENTATION_FITNESS.md @@ -158,15 +158,15 @@ Active #64 makes a successful action-outcome value require existing verified pro ### 3.19 Controlled Agent Task fixture -Active #65 supplies a deterministic synthetic local web fixture with a labelled semantic input, submit control, same-document post-condition and explicitly hidden/untrusted prompt-injection text. The fixture contains no credential collection surface and requires no live third-party site. +Active #65 supplies a deterministic synthetic local web fixture with a labelled semantic input, submit control, same-document post-condition and explicitly hidden/untrusted prompt-injection text. Active #70 executes that fixture through real WebDriver on pinned Chrome with an isolated profile, disabled extensions, synthetic input, same-document post-condition verification and profile cleanup. Active #71 verifies browser-computed role/name for the controlled input and submit button before action. The fixture contains no credential collection surface and requires no live third-party site. -**Resolution:** the fixture makes the future real Chromium vertical slice reproducible without turning a third-party site into a test dependency. It is not a browser adapter, semantic extractor, input dispatcher, policy engine, trusted clock, process-attribution source or proof of real Chromium execution. +**Resolution:** the #65/#70/#71 lane makes controlled browser-level and browser-computed role/name evidence reproducible without turning a third-party site into a test dependency. It is not a browser adapter, product semantic extractor, OriginWeave input-dispatch authority, policy engine, trusted clock, process-attribution source or proof of the shipped product runtime. ### 3.20 Bounded browser process-set resource evidence -Active #51→#66 establishes two distinct layers: #51 owns single explicitly supplied Linux PID sampling and the bounded telemetry value boundary; #66 owns bounded duplicate-safe aggregation/sampling over an exact caller-owned PID set. Active #72 adds bounded Agent Task resource evidence, and #73 extends the controlled pinned-Chrome lane with one sampled numeric `/proc` process set while rejecting symlinked entries and fixture paths that resolve outside the synthetic fixture root. These are exact-head test-harness contracts, not production browser attribution. #66's exact current contract rejects empty, zero-PID, duplicate, oversized and overflow states and fails closed if any member cannot be sampled. +Active #51→#66 establishes two distinct layers: #51 owns single explicitly supplied Linux PID sampling and the bounded telemetry value boundary; #66 owns bounded duplicate-safe aggregation/sampling over an exact caller-owned PID set. #66's exact current contract rejects empty, zero-PID, duplicate, oversized and overflow states and fails closed if any member cannot be sampled. Active PR #72 records browser-process RSS, semantic-observation bytes, action latency, and task duration for the controlled pinned-Chrome fixture from bounded trusted adapter inputs. -**Resolution:** aggregate resource measurement must not silently undercount a known caller-owned process set, and controlled fixtures must not turn symlinks into authority to read outside their root. Process membership remains an external attribution responsibility. The implementation does not discover Chromium PIDs in the product runtime, prove process ancestry/task ownership, walk cgroups, sample GPU/VRAM or create a durable telemetry store. +**Resolution:** aggregate resource measurement must not silently undercount a known caller-owned process set, but process membership remains an external attribution responsibility. PR #72 is bounded resource evidence for test repeatability; it does not discover Chromium PIDs, prove process ancestry/task ownership, walk cgroups, sample GPU/VRAM or create a durable telemetry store, and does not turn the fixture into a product resource adapter. ## 4. Durable product decisions captured by the canonical graph diff --git a/docs/README.md b/docs/README.md index 03b573c54..775dd0de6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ - [OriginWeave API and protocol contract](API_CONTRACT.md) - [Release and rollback contract](RELEASE_AND_ROLLBACK.md) - [Product roadmap](product-roadmap.md) +- [Product and technical gap baseline](product-technical-gap-baseline.md) - [Research and standards](doctoring.md) - [Browser and Agent protocol standards evidence](doctoring/browser-agent-protocols.md) - [Current product-baseline standards addendum](doctoring/product-documentation-baseline.md) diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index ba3c31624..a2e898ff1 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -78,6 +78,24 @@ session creation -> task close/recovery ``` +Active PR #70 exercises the controlled local Agent Task fixture on the pinned +Chrome for Testing build through real WebDriver input, same-document +post-condition observation and ephemeral-profile cleanup. That lane proves +browser-level fixture execution only; it does not replace the OriginWeave +BiDi/CDP authority adapter, semantic node contract, policy dispatch or +protected-main runtime acceptance required by issue #28. + +Active PR #71 additionally verifies browser-computed role/name for the +controlled input and submit target before the real WebDriver action. CSS remains +a fixture-harness locator; this does not establish OriginWeave node authority, +semantic provenance or policy dispatch. + +Active PR #72 additionally records bounded browser-process RSS, +semantic-observation bytes, action latency and task duration for the same +controlled fixture. These are test-harness resource evidence from trusted +adapter inputs; they do not establish Chromium process-set attribution, +GPU/VRAM telemetry or a product resource adapter. + ### 3.5 Buyer acceptance Versioned task packs measure repeatable product outcomes rather than one lucky agent run. The benchmark artifact records browser build, OriginWeave version, model/provider/reasoning configuration, seed where supported, policy profile, hardware profile and source fixtures. diff --git a/docs/adr/0107-browser-protocol-adapter-strategy.md b/docs/adr/0107-browser-protocol-adapter-strategy.md index 8923616be..e3c0bf657 100644 --- a/docs/adr/0107-browser-protocol-adapter-strategy.md +++ b/docs/adr/0107-browser-protocol-adapter-strategy.md @@ -34,6 +34,14 @@ OriginWeave exposes its own versioned protocol for session, observation, query, MCP version negotiation is independent of the OriginWeave Protocol version. As of this review, MCP `2026-07-28` is the current released protocol generation; a future MCP change does not silently alter OriginWeave task, approval, secret, tenant, or browser semantics. MCP tool/resource content remains untrusted input and any server-to-client/user interaction capability is mediated by the same OriginWeave policy/approval boundaries as other adapter traffic. +### Current implementation boundary + +The complete MCP adapter remains **Planned**. Active PR #168 is narrower **IMPLEMENTED_ON_ACTIVE_PR** evidence inside the Rust control plane: it validates the `2026-07-28` stateless `tools/call` routing envelope presented to this boundary, bounds and syntax-checks both untrusted method fields and both untrusted tool-name fields before cross-field correlation, derives one of the existing typed `ActionKind` values from a deterministic reviewed registry, exposes discovery metadata from that same registry, and requires the resulting action to pass the ordinary OriginWeave policy evaluator. The method boundary accepts only nonempty ASCII method names up to 64 bytes using the reviewed routing alphabet, while the tool-name boundary accepts only nonempty ASCII names up to 128 bytes using its narrower reviewed alphabet. The catalog and validated route grant no capability, approval, origin, secret, browser, persistence, or evidence authority by themselves. + +PR #168 does not implement Streamable HTTP transport parsing, complete request `_meta` validation, `tools/list` serialization/caching/pagination, OAuth, browser I/O, WebMCP/BiDi/CDP translation, secret delivery, persistence, or a complete OriginWeave Protocol adapter. Those remain separate adapter/runtime work. Protected `main` therefore must continue to describe MCP as planned until this active-PR evidence is integrated, and even after integration only the merged bounded routing foundation may be called implemented; the full adapter remains planned until its remaining acceptance boundaries ship. + +The version boundary is explicit: the routing foundation accepts only MCP `2026-07-28`; it does not infer compatibility with later protocol generations. OriginWeave Protocol versioning remains independent and cannot be changed by MCP metadata. + ## Consequences OriginWeave carries adapter maintenance and version negotiation but gains a durable customer API. Multiple browser/control transports can coexist. New upstream capabilities do not silently change risk or action semantics. Compatibility matrices become release artifacts. @@ -44,19 +52,21 @@ Adapter negotiation failure disables only affected capabilities. Unsupported or ## Security / privacy / governance impact -Protocol validation occurs before messages influence policy. Tool/page-provided strings remain untrusted. Secret handles never become raw secret protocol payloads; only the separately authorized trusted broker-to-browser delivery path may materialize the value, and that value does not pass through MCP, WebMCP, BiDi observation, or model-visible CDP output. Adapter version/provenance is recorded for audit and incident reconstruction. +Protocol validation occurs before messages influence policy. Tool/page-provided strings remain untrusted. Method and tool routing metadata is shape-bounded before correlation, preventing malformed or oversized untrusted routing strings from being reinterpreted through mismatch handling. Secret handles never become raw secret protocol payloads; only the separately authorized trusted broker-to-browser delivery path may materialize the value, and that value does not pass through MCP, WebMCP, BiDi observation, or model-visible CDP output. Adapter version/provenance is recorded for audit and incident reconstruction. ## Tests and acceptance evidence Require version-negotiation tests, schema/property tests, malformed-message tests, BiDi/CDP semantic parity tests for shared capabilities, WebMCP prompt-injection tests, MCP authority-separation and version-change tests, browser-version compatibility matrices, and end-to-end proof that unsupported capabilities fail without side effects. +For active PR #168 specifically, acceptance additionally requires deterministic method and tool-name bounds/syntax, exact header/body method and tool-name correlation only after both sides are bounded, explicit invalid-method/invalid-tool-name/unknown-tool rejection, one unambiguous tool-to-action registry, independent capability/risk expectations, route/action mismatch denial before ordinary policy evaluation, exact 100% owned-production coverage, and unchanged-head CI/security/review evidence. These checks do not substitute for complete transport or adapter conformance. + ## Migration and rollback Adapters are independently versioned and can be canaried. Clients migrate through OriginWeave Protocol compatibility rules, not upstream protocol rewrites. Rollback pins a previously supported adapter/browser/protocol pair and records that pair in provenance. ## Open follow-ups -Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, and MCP/WebMCP schema isolation. +Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, complete MCP Streamable HTTP/request-metadata validation, MCP discovery/serialization/cache behavior, and MCP/WebMCP schema isolation. ## Supersession / reversal conditions @@ -68,10 +78,12 @@ Chrome DevTools Protocol. (2026). *Chrome DevTools Protocol — latest (tip-of-t Chrome DevTools Protocol. (2026). *WebMCP domain*. Chromium. Retrieved August 9, 2026, from https://chromedevtools.github.io/devtools-protocol/tot/WebMCP/ +Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 + Parra, D. S., & Delimarsky, D. (2026, July 28). *The 2026-07-28 specification*. Model Context Protocol Blog. https://blog.modelcontextprotocol.io/posts/2026-07-28/ World Wide Web Consortium. (2026, June 29). *WebDriver BiDi* [Working Draft]. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260629/ ## Related documents -See `docs/API_CONTRACT.md`, `docs/TRD.md`, `docs/doctoring/product-documentation-baseline.md`, and `docs/DATA_GOVERNANCE.md`. +See `docs/API_CONTRACT.md`, `docs/TRD.md`, `docs/doctoring.md`, `docs/doctoring/product-documentation-baseline.md`, `docs/traceability/README.md`, and `docs/DATA_GOVERNANCE.md`. diff --git a/docs/doctoring.md b/docs/doctoring.md index f0133bb5d..693840f63 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,6 +8,8 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. +The final Model Context Protocol `2026-07-28` specification defines the currently reviewed MCP generation. Its stateless request model carries protocol metadata per request and standard Streamable HTTP routing metadata for MCP operations; its Tools surface defines bounded, case-sensitive tool names and requires clients to treat tool annotations as untrusted unless supplied by a trusted server. OriginWeave therefore keeps MCP outside the product authority model. Active PR #168 implements only a bounded Rust `tools/call` routing/action-policy foundation for that exact generation; the complete transport, request-metadata, discovery, OAuth, browser, secret, and persistence adapter remains planned and cannot be inferred from the core routing primitive. + ### Browser origin equivalence The WHATWG URL host parser and Chromium canonicalizer classify shortened decimal, integer, hexadecimal, legacy octal-looking, and mixed-component numeric hosts as IPv4 or broken IPv4 candidates rather than ordinary DNS names. Chromium's regression suite includes values such as `192`, `0xC0a80001`, `030052000001`, and mixed hexadecimal components. A non-final empty `0x` component can participate in Chromium's multi-part IPv4 truncation behavior, but a final `0x` label does not produce an IPv4 number because stripping its prefix leaves no digits; it remains a domain label. Chromium also warns that broken IP-like hosts must not be connected because another resolver could accept them. OriginWeave therefore admits only canonical dotted-decimal IPv4 into its policy origin type, rejects browser-special numeric spellings before DNS validation, and preserves final non-numeric DNS labels such as `0x`. @@ -82,6 +84,8 @@ RFC 9309 standardizes robots parsing, matching, error handling, and caching. It W3C PROV-O supplies interoperable Entity, Activity, Agent, derivation, attribution, and responsibility concepts. ISO 28500:2017, confirmed in 2023, defines WARC storage for protocol payloads, control information, metadata, transformations, duplicate detection, integrity, and segmentation. OriginWeave uses source hashes and locators in the safety kernel, then adds WARC and PROV adapters as separately testable modules. +RFC 3986 remains Internet Standard STD 66 for generic URI syntax. RFC 8820 is the current URI design-and-ownership Best Current Practice; it obsoletes RFC 7320 and updates RFC 3986 without replacing RFC 3986's path grammar. Section 3.3 of RFC 3986 defines each path segment as `*pchar`, where literal path characters are unreserved characters, sub-delimiters, `:`, or `@`; `/` separates segments and other reserved characters such as `[` and `]` are not literal `pchar`. OriginWeave's shared evidence-path validator therefore applies that literal ASCII `pchar` set plus validated percent-encoded octets and explicit slash separators to both `NetworkEvidence::capture` paths and provenance source-URL paths. Existing stricter evidence-safety rules continue to reject encoded separators, dot-segment ambiguity, controls, whitespace, query strings, fragments, backslashes, and credential-bearing authority. This fail-closed syntax tightening affects both evidence surfaces; it does not authorize the source origin, destination, network access, capture, disclosure, or retention. + ### AI risk and prompt injection NIST AI 600-1 provides generative-AI lifecycle risk guidance. WASP demonstrates that web-navigation agents can follow low-effort indirect prompt injections. OriginWeave therefore separates trusted instructions, untrusted observations, and protected secrets at type and process boundaries rather than rely on prompting alone. @@ -106,6 +110,8 @@ Autio, C., Schwartz, R., Dunietz, J., Jain, S., Stanley, M., Tabassi, E., Hall, Barth, A. (2011). *The web origin concept* (RFC 6454). Internet Engineering Task Force. https://doi.org/10.17487/RFC6454 +Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform resource identifier (URI): Generic syntax* (RFC 3986; STD 66). Internet Engineering Task Force. https://doi.org/10.17487/RFC3986 + Bonica, R., Cotton, M., Haberman, B., & Vegoda, L. (2017). *Updates to the special-purpose IP address registries* (RFC 8190). Internet Engineering Task Force. https://doi.org/10.17487/RFC8190 Chromium Authors. (n.d.). *Proxy support in Chrome* [Source documentation]. Chromium. https://chromium.googlesource.com/chromium/src/+/a3e71ebfa307d8760eb68b777e2998a869940092/net/docs/proxy.md @@ -142,8 +148,14 @@ Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *OAuth 2.0 securi Microsoft. (2025, July 25). *Azure IP address 168.63.129.16 overview*. Microsoft Learn. https://learn.microsoft.com/azure/virtual-network/what-is-ip-address-168-63-129-16 +Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 + Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). *Learning to orchestrate agents in natural language with the Conductor* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04388 +Nottingham, M. (2014). *URI design and ownership* (RFC 7320). Internet Engineering Task Force. https://doi.org/10.17487/RFC7320 + +Nottingham, M. (2020). *URI design and ownership* (RFC 8820; BCP 190). Internet Engineering Task Force. https://doi.org/10.17487/RFC8820 + Rescorla, E. (2026). *The Transport Layer Security (TLS) protocol version 1.3* (RFC 9846). Internet Engineering Task Force. https://doi.org/10.17487/RFC9846 Rustls Project Developers. (2026). *rustls 0.23.42* [Computer software]. https://docs.rs/rustls/0.23.42/rustls/ @@ -170,4 +182,4 @@ World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04695 -Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 +Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 \ No newline at end of file diff --git a/docs/doctoring/rust-toolchain-freshness.md b/docs/doctoring/rust-toolchain-freshness.md new file mode 100644 index 000000000..a00e7fb08 --- /dev/null +++ b/docs/doctoring/rust-toolchain-freshness.md @@ -0,0 +1,44 @@ +# Rust toolchain freshness and reproducibility + +## Decision + +OriginWeave keeps Rust `1.97.1` as the exact stable compiler baseline. As of +2026-08-19 this is the current stable point release, so the generic compiler +suggestion to upgrade does not justify replacing it with a floating `stable` +channel. + +Production line, region, and function coverage remains on the stable compiler. +Branch coverage uses the independently date-pinned `nightly-2026-08-18` +toolchain because upstream `cargo-llvm-cov` still identifies Rust branch +coverage as unstable and nightly-only. Every branch-coverage command must use +the same date pin, and exact-head CI must prove that `llvm-tools-preview`, the +pinned `cargo-llvm-cov` release, the workspace, and the coverage verifier remain +compatible before merge. + +The root `rust-toolchain.toml` is tracked through GitHub Dependabot's +`rust-toolchain` ecosystem. Toolchain changes therefore arrive as reviewable +pull requests rather than silently changing underneath local or CI builds. +Date-pinned branch-coverage nightly updates remain explicit infrastructure +changes and must preserve the repository contract test. + +## Failure interpretation + +The historical OriginWeave coverage failure at PR #192 predecessor head +`ccb7d31dfe7654bab800d463c2391cc1a19c7d74` was not proof that the compiler was +too old. The compiler emitted the generic note while rejecting a non-stable +const conversion in test code. The current PR #192 head moved that conversion +out of a constant and passed the complete native CI workflow. Toolchain +freshness and source compatibility are therefore maintained as separate +controls. + +## References + +GitHub. (2025, August 19). *Dependabot now supports Rust toolchain updates*. +GitHub Changelog. +https://github.blog/changelog/2025-08-19-dependabot-now-supports-rust-toolchain-updates/ + +Rust Project Developers. (2026, July 16). *Announcing Rust 1.97.1*. Rust Blog. +https://blog.rust-lang.org/2026/07/16/Rust-1.97.1/ + +Taiki Endo and contributors. (2026). *cargo-llvm-cov* (Version 0.8.6) +[Computer software]. GitHub. https://github.com/taiki-e/cargo-llvm-cov diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 000000000..234e6ae5c --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,325 @@ +# Product and Technical Gap Baseline + +This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, architecture decisions, or live GitHub state. It keeps buyer-visible gaps, current issues, active pull-request evidence, and commercial completion tracks in one discoverable place. Protected `main` is the implementation boundary: code in an open pull request is not shipped behavior. + +## Observed snapshot: 2026-08-24 + +### Protected-main truth + +- Protected `main` remained at `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` when this snapshot was refreshed. +- Phase 0 is documented as complete as a reusable safety-kernel foundation: typed policy contracts, destination classification, direct TCP peer verification, TLS service identity, evidence bounds, resource mitigation, document-node authority, and protected-main tests. +- Phase 1 is **in progress**, not shipped. The first real Chromium vertical slice still needs the active WebDriver BiDi transport stack to reach protected `main`, then compose isolated Chromium launch, session/context identity, semantic observation, typed action authorization, native browser input, post-condition proof, evidence, cancellation, crash recovery, and profile/process teardown. +- HTTP/1.1 bounds, downloads/MIME, proxy/PAC consumption, full browser-network integration, the sensitive-data broker runtime, durable WARC/PROV capture, persistent task/API surfaces, signed cross-platform distribution, enterprise administration, and release-grade buyer acceptance remain open. +- Active pull requests remain evidence, not shipped behavior. Successful checks on a feature or stacked branch do not prove that protected `main` contains the capability or that a child can merge before its prerequisite. + +### Open pull requests + +The live repository contained **158 open pull requests: 44 non-draft and 114 draft** when this snapshot re-paginated the complete open inventory. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. + +Representative active workstreams at this snapshot were: + +| Workstream | Representative active PR evidence | Delivery boundary | +|---|---|---| +| Product baseline | #196 | Ready/non-draft documentation PR; all exact-head checks passed and review threads resolved, blocked only by the reviewer-provisioning gap below | +| Enterprise approval authority | #220 | Ready/non-draft bounded maker-checker approval lifecycle on the exact `ApprovalScope`; Strix re-scan was re-dispatched after a provider-unavailability failure | +| Release artifact identity | #218 and #219 | Ready/non-draft fail-closed benchmark release decision and canonical release manifest binding; #218's Strix re-scan was re-dispatched after provider unavailability | +| Evidence path conformance | #216 | Ready/non-draft RFC 3986 evidence-path syntax enforcement | +| Schema-bound extraction and BAP lifecycle | #209 and #208 | Ready/non-draft schema-bound extraction contract and resumable task-lifecycle kernel; #208's Strix re-scan was re-dispatched after provider unavailability | +| WebDriver BiDi transport | #188 through #205 | Draft stack exercising framed `locateNodes` exchange over a bounded WebSocket opening path; still no authenticated browser-process provenance, semantic task execution, or protected-main shipment | +| MCP adapter | #168 and #170 | Typed MCP routing and conservative `tools/list` metadata are active-PR foundations; complete authenticated transport, durable task lifecycle, cancellation/resume, and browser execution remain open under #200 | +| Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#153 and descendants | Real pinned-browser fixture, semantic location, resource, crash, and teardown evidence exists on active stacks; evidence does not transfer across heads or prerequisites | +| Durable WARC/PROV evidence | #210, #217 | Bounded WARC resource records and PROV JSON-LD binding are draft active-PR foundations; durable ownership, replay, retention/deletion, and browser side-effect reconciliation remain open | +| Manifest V3 and native messaging | #27 and its active extension/native-host stack, including #154 and #169 | Compatibility and Agent-authority isolation remain incomplete until exact release artifacts and platform matrices are proven | +| Sensitive-data and model route policy | #10 and its active policy stacks | Deterministic policy values exist, but trusted broker execution, retention/deletion, runtime isolation, and auditable product workflows remain open | +| VPN/profile intent | #149 | Bounded WireGuard/IKEv2 profile authority is active-PR evidence; it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof | + +Draft PR #205 is the current top WebDriver BiDi locate-nodes slice; its opening-path prerequisites #195 and #198 remain draft evidence and cannot be treated as shipped behavior. + +#### Current exact-head active PR evidence + +The following newest product slices were re-fetched from GitHub for this snapshot. Their exact base/head pairs are recorded so later checks, reviews, and restacks cannot be confused with predecessor evidence: + +| PR | State | Exact base head | Exact head | +|---|---|---|---| +| #73 | Draft | `da99395b09b419845b4a1222a0725482e9231466` | `7861d88d21ed0f0adaeb467957e809826f835071` | +| #208 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `c3b6e1a475dce333f6115e5113cae9c07974835f` | +| #209 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `69bc738bd45a1b61a4673b122dc3eec8814baa22` | +| #210 | Draft | `69bc738bd45a1b61a4673b122dc3eec8814baa22` | `999979a511c3a890ba93a1a09da8810858877940` | +| #211 | Draft | `c3b6e1a475dce333f6115e5113cae9c07974835f` | `f6e3a3adcfb9cc7a60ef1d79e2aeee27ba54c084` | + +These rows are delivery evidence only. #73's latest Strix remediation is locally verified but its required policy workflows remain queued; #208–#211 are stacked product-gap foundations with no protected-main promotion. None has counted independent approval in the current collaborator inventory. + +#### Refreshed exact-head active PR evidence: 2026-08-24 + +The following newest slices were re-fetched from GitHub for this snapshot. Heads have moved since the 2026-08-21 rows above; those predecessor rows are retained as regression anchors and must never be promoted to current-head evidence: + +| PR | State | Exact base head | Exact head | +|---|---|---|---| +| #222 | Draft | `56fcfa56525e4f2e980e0ee05b6776d621bcddc5` | `1e2ce3d4071a1a75ee891bdcd71c506b3b50d4bc` | +| #221 | Draft | `8145d40f1b028a8f4dc7e7da47ac89bb9e5bb2c7` | `6f339df1e5b3ddb265f4ddd7b262d4de1e0b5e1f` | +| #220 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `ed4cab16cf88c76ce1c145a22d0a274ef2d57263` | +| #219 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `8145d40f1b028a8f4dc7e7da47ac89bb9e5bb2c7` | +| #218 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `49e98fba6974219b3bb0336c822b12667f1e1c03` | +| #217 | Draft | `529d11a3571f6b1834b9baa49ef67eb08f043978` | `56fcfa56525e4f2e980e0ee05b6776d621bcddc5` | +| #216 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `75130851a0f7ce528a7a36382eb026ac7942a0aa` | +| #214 | Draft | `40d642d5470a7753b8211907c190367f742f2f12` | `f79999681866ecf0e5fe17d895170f3f6cae7361` | +| #211 | Draft | `85cc477688246900697f4cfb91c0c8f1f692934a` | `40d642d5470a7753b8211907c190367f742f2f12` | +| #210 | Draft | `c38b9665774d6b3754e572bed527737b5e179833` | `529d11a3571f6b1834b9baa49ef67eb08f043978` | +| #209 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `c38b9665774d6b3754e572bed527737b5e179833` | +| #208 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `85cc477688246900697f4cfb91c0c8f1f692934a` | + +The stack topology shows #209 → #210 → #217 → #222 (WARC/PROV chain), #208 → #211 → #214 (BAP chain), #218 → #221 → #220 (release/enterprise chain) at this snapshot. Every row above remains active-PR evidence; none is protected-main behavior. + +### Required-check provider failure record + +On 2026-08-23 the required Strix security scan failed closed on exact heads of #220 (`ed4cab16…`), #218 (`49e98fba…`), and #208 (`85cc4776…`) because its LLM provider/backend was unavailable (rate limit, token cap, connection, warm-up, or model-behavior failure); no vulnerability report artifact was produced, so the workflow correctly refused to convert an incomplete scan into passing security evidence. Failed jobs were re-dispatched on the unchanged exact heads on 2026-08-24. This is a provider-infrastructure failure record, not a weakening of the fail-closed gate or a substitute for a completed authoritative scan. + +#### #195/#198 WebDriver BiDi opening path status + +Phase 1 is **in progress**, not shipped. #195 and #198 provide bounded WebSocket opening-path evidence on active branches; framed BiDi commands, authenticated browser-process provenance, semantic task execution, and protected-main integration remain open. + +#### #149 VPN/profile intent status + +It remains draft evidence and cannot be treated as shipped behavior. #149 describes bounded WireGuard/IKEv2 profile authority, but it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof. + +The current queue must be processed in dependency order. A green child branch cannot substitute for current checks and review on its prerequisite, synthetic merge, or eventual protected-main commit. PRs that only duplicate, supersede, or preserve stale branch topology should be closed with explicit replacement evidence rather than retained indefinitely. + +### Review and merge authority + +The active `CWL Central required workflows` ruleset requires two approving reviews, approval after the last push, resolved review threads, and configured required workflows. The current collaborator inventory contains only `seonghobae` with administration and push permissions, creating a **reviewer-provisioning gap** for counted non-author approval. + +This gap does not authorize self-approval, administrative bypass, stale-head merge, or weaker checks. Exact current-head checks, security gates, complete coverage, rustdoc/Clippy, thread resolution, and branch protection remain mandatory. Before any merge decision, re-fetch the exact ruleset, collaborators, PR head/base, reviews, unresolved threads, and required checks; do not assume this dated observation remains current. + +### Open issues and operational signals + +| Issue | Current gap or signal | +|---|---| +| #28 | First real Chromium Agent Task vertical slice; highest immediate Phase 1 buyer-visible gap | +| #27 | Complete Manifest V3 compatibility and extension-authority isolation matrix | +| #9 | Bounded HTTP/1.1 semantics over the authenticated TLS stream | +| #10 | Purpose-bound operational PII disclosure and trusted broker/storage lifecycle | +| #123 | Fleet incident: disable orphaned TLS, HTTP, and one-shot workflow identities | +| #187 | Manual-authority review of the coverage-diagnostics workflow delta | +| #212 | Governance: remove or independently authorize the PR #43 MV3 workflow mutation | +| #215 | Governance: restore an enforceable protected-main policy that does not create a routine admin bypass | +| #199 | Schema-bound extraction with durable WARC/PROV replay, retention, deletion, and offline verification | +| #200 | Stable BAP/MCP runtime API with authenticated, idempotent, cancellable, resumable task lifecycle | +| #201 | Signed cross-platform Chromium distribution, installer/updater, patch SLA, rollback, SBOM, and provenance | +| #202 | Enterprise control and experience plane: operator UI, Keyverse-compatible identity, tenancy, approval, audit, SLO, Figma, and Storybook | +| #203 | Release-grade web-agent benchmark and commercial acceptance gate bound to exact signed artifacts | + +Issue #206 (harden-runner custom detection initialization failure) was closed after its remediation landed on protected `main` between snapshots. + +The five newly separated product-completion tracks are **durable WARC/PROV replay**, **stable BAP/MCP runtime API**, **signed cross-platform Chromium distribution**, **enterprise control and experience plane**, and the **commercial acceptance gate**. They are separate issues because each has a distinct authority, data, release, and buyer-acceptance boundary. + +The hourly product-development loop is operational infrastructure, not proof that a browser product, issue, pull request, or release meets buyer acceptance. + +## Buyer-visible and technical gap matrix + +| Priority | Buyer-visible outcome | Protected-main status | Completion issue and acceptance evidence | +|---|---|---|---| +| P0 | A bounded task observes a real Chromium page, performs one typed action, verifies the post-condition, and emits provenance | **Open / Phase 1** | #28; repeated real Chromium E2E with isolated context, exact session/node authority, typed dispatch, post-condition, crash cleanup, and protected-main checks | +| P0 | Navigation consumes approved origin, resolution, route, TCP peer, TLS identity, bounded HTTP, redirect, MIME, and download policy | **Partial foundation** | #9 plus #28; real browser-network adapter proves the governed path is consumed end to end | +| P1 | Existing Chromium extensions remain compatible while Agent authority stays separate | **Partial active-PR evidence** | #27; exact supported-build/platform compatibility matrix, managed allow-list, native-host isolation, repeatability, and release binding | +| P1 | Authorized work can use necessary PII without ambient exposure | **Policy foundation; runtime open** | #10; opaque broker, exact field/purpose/destination/model policy, atomic use/revocation, retention/deletion, and value-free telemetry | +| P1 | Every released structured field is traceable to replayable source evidence | **Foundations only** | #199; durable WARC/PROV replay, integrity, retention, deletion, offline verification, extraction precision/recall, and 100% provenance completeness | +| P1 | External Agents integrate through a stable, authenticated product contract | **Partial active-PR MCP primitives** | #200; BAP 1.0, MCP 2026-07-28 adapter, idempotency, task cancellation/resume, checkpoint/reconciliation, and SDK conformance | +| P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | +| P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | +| P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 158-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | + +## Commercial completion definition + +OriginWeave is not complete merely because every low-level primitive exists in some open branch. A release candidate is commercially complete only when all of the following are true for the declared support profile: + +1. #9, #10, #27, and #28 are integrated on protected `main` as a complete browser/network/action/evidence chain. +2. #199 provides replayable, retention-governed evidence for every released structured result. +3. #200 exposes a stable authenticated runtime API and task lifecycle without raw Chromium authority leakage. +4. #201 produces signed, updateable, rollback-capable release artifacts bound to Chromium, SBOM, and provenance. +5. #202 supplies tenant-safe enterprise administration, approvals, audit, SLOs, incident recovery, accessible Figma/Storybook-backed UX, and control evidence. +6. #203 accepts the exact signed artifacts through a reproducible benchmark; missing or inconclusive evidence cannot be promoted to success. +7. Production function, line, region, and branch coverage and public API documentation remain exactly complete for OriginWeave-owned code. +8. CHANGELOG, version, supported-platform matrix, security policy, runbooks, licensing, release notes, upgrade/rollback guidance, and procurement evidence match the exact release. +9. No required check, browser/platform lane, security case, benchmark case, or independent review is skipped, stale, inherited, or represented by status-only evidence. +10. The open PR queue is reduced to bounded active work rather than being the only place where the product exists. + +## Next executable queue + +1. Re-fetch all 158 open PRs and compute the dependency graph, exact heads/bases, reviews, unresolved threads, current required checks, duplicate/supersession relationships, and branch ancestry. Re-dispatch required checks that failed closed on provider infrastructure instead of code defects. +2. Integrate merge-ready root PRs first; restack and independently revalidate only the immediate children. Close obsolete alternatives instead of carrying parallel truth. +3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #195/#198 WebSocket opening path and the remaining framed BiDi command/response, semantic observation, policy, action, post-condition, and recovery boundaries. +4. Finish #27 and #10 as separate security tracks; neither should be hidden inside the first browser PR. +5. Implement #199, then #200, so durable evidence and stable task authority precede broad enterprise integrations. +6. Implement #201 before making release/support claims; exact CI browser evidence must be bound to the actual signed artifact. +7. Design #202 in Figma, record the Figma File ID in the ADR, implement reusable design tokens and Storybook components, then add identity/tenant/approval/audit/operations integration. +8. Make #203 the final release gate across the exact signed distribution, not a source branch or model narrative. +9. Only after the commercial acceptance gate passes, increment the version, finalize CHANGELOG/release notes, publish signed artifacts, and verify upgrade/rollback from the prior supported release. + +## Evidence commands + +The volatile counts above are reproducible by paginating the complete open-PR inventory, flattening every page, and then inspecting each PR's exact head, checks, reviews, and review threads: + +```bash +set -euo pipefail +EVIDENCE_DIR="$(mktemp -d /tmp/originweave-evidence.XXXXXX)" +printf 'Evidence directory: %s\n' "$EVIDENCE_DIR" >&2 + +gh api --paginate --slurp 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100' \ + > "$EVIDENCE_DIR/open-pr-pages.json" +jq '[.[][]]' "$EVIDENCE_DIR/open-pr-pages.json" \ + > "$EVIDENCE_DIR/open-prs.json" +jq '{ + open_pull_requests: length, + non_draft: (map(select(.draft == false)) | length), + draft: (map(select(.draft == true)) | length) +}' "$EVIDENCE_DIR/open-prs.json" + +gh api 'repos/ContextualWisdomLab/OriginWeave/branches/main' \ + > "$EVIDENCE_DIR/main-branch.json" +gh api --paginate --slurp \ + 'repos/ContextualWisdomLab/OriginWeave/rules/branches/main?per_page=100' \ + > "$EVIDENCE_DIR/main-branch-rule-pages.json" +jq '[.[][]]' "$EVIDENCE_DIR/main-branch-rule-pages.json" \ + > "$EVIDENCE_DIR/main-branch-rules.json" +gh api --paginate --slurp \ + 'repos/ContextualWisdomLab/OriginWeave/collaborators?affiliation=all&per_page=100' \ + > "$EVIDENCE_DIR/collaborator-pages.json" +jq '[.[][]]' "$EVIDENCE_DIR/collaborator-pages.json" \ + > "$EVIDENCE_DIR/collaborators.json" + +jq -r '.[].number' "$EVIDENCE_DIR/open-prs.json" | while read -r PR; do + STABLE_HEAD=false + for ATTEMPT in 1 2 3; do + VERDICT_PATH="$EVIDENCE_DIR/pr-${PR}-merge-verdict.json" + VERDICT_TMP="$EVIDENCE_DIR/pr-${PR}-merge-verdict.json.tmp" + rm -f "$VERDICT_PATH" "$VERDICT_TMP" "$EVIDENCE_DIR/pr-${PR}-rechecked.json" + PR_JSON="$EVIDENCE_DIR/pr-${PR}.json" + gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" > "$PR_JSON" + HEAD_SHA=$(jq -r '.head.sha' "$PR_JSON") + BASE_SHA=$(jq -r '.base.sha' "$PR_JSON") + + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100" \ + > "$EVIDENCE_DIR/pr-${PR}-check-runs.json" + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100" \ + > "$EVIDENCE_DIR/pr-${PR}-statuses.json" + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100" \ + > "$EVIDENCE_DIR/pr-${PR}-reviews.json" + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/actions/runs?head_sha=$HEAD_SHA&per_page=100" \ + > "$EVIDENCE_DIR/pr-${PR}-workflow-runs.json" + gh api graphql --paginate --slurp \ + -F owner=ContextualWisdomLab \ + -F name=OriginWeave \ + -F number="$PR" \ + -f query=' +query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $endCursor) { + nodes { id isResolved isOutdated } + pageInfo { hasNextPage endCursor } + } + } + } +}' > "$EVIDENCE_DIR/pr-${PR}-review-threads.json" + + jq -n \ + --arg head "$HEAD_SHA" \ + --slurpfile pr "$PR_JSON" \ + --slurpfile checks "$EVIDENCE_DIR/pr-${PR}-check-runs.json" \ + --slurpfile statuses "$EVIDENCE_DIR/pr-${PR}-statuses.json" \ + --slurpfile reviews "$EVIDENCE_DIR/pr-${PR}-reviews.json" \ + --slurpfile workflow_runs "$EVIDENCE_DIR/pr-${PR}-workflow-runs.json" \ + --slurpfile rules "$EVIDENCE_DIR/main-branch-rules.json" \ + --slurpfile collaborators "$EVIDENCE_DIR/collaborators.json" \ + --slurpfile threads "$EVIDENCE_DIR/pr-${PR}-review-threads.json" \ + --arg base "$BASE_SHA" \ + '( + [ + $rules[][]? + | select(.type == "pull_request") + | .parameters + ] | first // {} + ) as $pull_request_parameters + | ( + [ + $reviews[][][]? + | {reviewer: .user.login, state, submitted_at, commit_id} + | select(.submitted_at != null) + | select(.reviewer != $pr[0].user.login) + | select(.reviewer as $reviewer | + any($collaborators[][]?; + .login == $reviewer and + (.permissions.push == true or + .permissions.maintain == true or + .permissions.admin == true))) + ] + | group_by(.reviewer) + | map(sort_by(.submitted_at) | last) + | map(select(.state == "APPROVED" and .commit_id == $head)) + ) as $current_approvals + | ($pull_request_parameters.required_approving_review_count // 0) as $required_review_count + | ($pull_request_parameters.require_last_push_approval // false) as $require_last_push_approval + | { + head_sha: $head, + base_sha: $base, + required_status_checks: { + check_runs: [$checks[][].check_runs[]?], + legacy_statuses: [$statuses[][][]?] + }, + workflow_runs: [$workflow_runs[][].workflow_runs[]?], + counted_approvals: ($current_approvals | length), + required_approving_review_count: $required_review_count, + require_last_push_approval: $require_last_push_approval, + last_push_approval_authority: ( + if $require_last_push_approval == true + then "github_rule_evaluation_required" + else "not_required" + end + ), + approval_gate_satisfied: ( + if $pull_request_parameters.require_last_push_approval == true then false + else (($current_approvals | length) >= $required_review_count) + end + ), + required_workflows: [ + $rules[][]? + | select(.type == "workflows") + | .parameters.workflows[] + ], + unresolved_threads: [ + $threads[][].data.repository.pullRequest.reviewThreads.nodes[]? + | select(.isResolved == false and .isOutdated == false) + ] + }' > "$VERDICT_TMP" + + RECHECKED_PR_JSON="$EVIDENCE_DIR/pr-${PR}-rechecked.json" + RECHECKED_HEAD_SHA=$(gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" \ + | tee "$RECHECKED_PR_JSON" \ + | jq -r '.head.sha') + RECHECKED_BASE_SHA=$(jq -r '.base.sha' "$RECHECKED_PR_JSON") + if [[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" && "$RECHECKED_BASE_SHA" == "$BASE_SHA" ]]; then + mv "$VERDICT_TMP" "$VERDICT_PATH" + mv "$RECHECKED_PR_JSON" "$PR_JSON" + STABLE_HEAD=true + break + fi + rm -f "$VERDICT_TMP" "$RECHECKED_PR_JSON" + printf 'Discarding moving head/base evidence for PR #%s (head %s -> %s, base %s -> %s) and retrying.\n' \ + "$PR" "$HEAD_SHA" "$RECHECKED_HEAD_SHA" "$BASE_SHA" "$RECHECKED_BASE_SHA" >&2 + done + if [[ "$STABLE_HEAD" != true ]]; then + rm -f "$EVIDENCE_DIR"/pr-${PR}-*.json + printf 'Unable to collect stable exact-head/base evidence for PR #%s after 3 attempts.\n' "$PR" >&2 + exit 1 + fi +done +``` + +The branch-scoped rules response determines the active rules affecting `main`; each PR's exact `HEAD_SHA` then determines which check runs, legacy statuses, workflow runs, reviews, and unresolved threads are current. The saved merge verdict binds counted approvals to the latest review per eligible collaborator, excludes the PR author, and requires `APPROVED` on the exact head. It deliberately does **not** infer GitHub's actual last-push actor from commit author or committer metadata: when `require_last_push_approval` is active, this portable evidence procedure records `github_rule_evaluation_required` and keeps `approval_gate_satisfied` false until GitHub's authoritative rule evaluation is consulted. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. Evidence is retained only when both `RECHECKED_HEAD_SHA` and `RECHECKED_BASE_SHA` match the collected values; a moving head or base discards the temporary verdict, and three failed attempts leave no unstable merge verdict. + +For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. \ No newline at end of file diff --git a/docs/traceability/action-postcondition-evidence.md b/docs/traceability/action-postcondition-evidence.md index 23a6e764d..866e0404f 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -55,6 +55,30 @@ On that unchanged exact head, CI run `31445201739` succeeds; Rust contracts job This remains controlled test infrastructure rather than browser-execution evidence. The fixture itself does not establish WebDriver BiDi/CDP transport, Chromium semantic extraction, policy dispatch, native input, post-condition provenance, profile teardown or process attribution. +### PR #70 — pinned Chrome execution of the controlled Agent Task fixture + +**Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` + +PR #70 reuses the existing pinned Chrome for Testing workflow and executes the #65 fixture through loopback ChromeDriver with extensions disabled and a fresh temporary profile. Each bounded trial performs real WebDriver clear/type/click operations, observes the `submitted` state and synthetic value through element endpoints, verifies that submission preserves the loaded URL, and proves that the temporary profile is removed after teardown. The runner emits credential-free repeatability evidence and fails the lane when any trial or post-condition is incomplete. + +This is real WebDriver evidence for a controlled local fixture, not a product browser adapter. It does not establish WebDriver BiDi/CDP authority translation, OriginWeave semantic observation or node handles, policy-authorized typed action dispatch, trusted browser-process attribution, or protected-main product runtime completion. + +### PR #71 — browser-computed semantic role/name evidence before action + +**Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` + +PR #71 extends the pinned-Chrome fixture lane by reading WebDriver's browser-computed role and accessible name for the controlled input and submit button before sending input or clicking. The exact expected values are `textbox` / `Task text` and `button` / `Submit task`; the repeatability gate requires both semantic checks in every successful trial. + +This is bounded browser-computed evidence for a synthetic test target, not the OriginWeave semantic observation adapter. CSS locators remain test-harness selectors, and the lane does not create OriginWeave node handles, source-channel provenance, policy authority, or permission to execute page-advertised actions. + +### PR #72 — bounded Agent Task resource evidence + +**Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` + +PR #72 records browser-process RSS, semantic-observation bytes, action latency, and total task duration while the pinned-Chrome fixture runs. The measurements are bounded, positive observations from the trusted ChromeDriver process identifier and the controlled semantic payload; they make the real fixture's resource and timing evidence inspectable without introducing a new telemetry subsystem. + +This is resource evidence for the active test harness, not process-set attribution or a product resource adapter. It does not discover Chromium children, prove task ownership or ancestry, walk cgroups, sample GPU/VRAM, or export durable product telemetry. + ## 4. Non-transitive success semantics The intended first-slice chain is: @@ -77,10 +101,10 @@ Unverified -/> successful action completion Rejected -/> successful action completion caller-supplied timestamp ordering -/> proof of trusted clock provenance VerifiedActionOutcomeEvidence type existence -/> proof of real Chromium execution -controlled fixture success -/> proof of real Chromium execution +controlled fixture success -/> proof of an OriginWeave product browser runtime ``` -PR #64 now rejects a caller-supplied observation timestamp that predates caller-supplied dispatch time, but the type cannot independently prove the clock source, that a real browser actually dispatched the action, that the supplied provenance belongs to the claimed browser target/node, or that the observed state was caused by that action. PR #65 supplies deterministic hostile input and a post-condition target but no browser execution. Those claims remain the responsibility of the real adapter/runtime composition under issue #28. +PR #64 now rejects a caller-supplied observation timestamp that predates caller-supplied dispatch time, but the type cannot independently prove the clock source, that a real browser actually dispatched the action, that the supplied provenance belongs to the claimed browser target/node, or that the observed state was caused by that action. PR #70 proves real Chromium execution against the controlled fixture, PR #71 adds browser-computed role/name evidence, and PR #72 adds bounded resource evidence, but their test-harness CSS locators, direct WebDriver calls, and fixture-scoped measurements are not the OriginWeave adapter/runtime composition required under issue #28. ## 5. Active prerequisite graph for issue #28 @@ -93,15 +117,18 @@ The first real Chromium vertical slice remains distributed across bounded active - PR #49 — ephemeral compatibility-profile lifecycle regression stacked on #43; - PR #51 — bounded browser-task telemetry plus one explicitly supplied Linux PID `VmRSS` sampler; Chromium process discovery/process-set attribution remains outside that slice; - PR #64 — verified and caller-timestamp-ordered post-condition action-outcome evidence; and -- PR #65 — controlled hostile local Agent Task workflow fixture, gate-clean and Ready for review. +- PR #65 — controlled hostile local Agent Task workflow fixture; and +- PR #70 — real WebDriver execution of that fixture on pinned Chrome, without claiming a product browser adapter; and +- PR #71 — browser-computed role/name evidence before controlled action, without claiming a product semantic observer; and +- PR #72 — bounded browser-process RSS, semantic-observation byte, latency, and task-duration resource evidence, without claiming process-set attribution or a product resource adapter. -These active PRs are non-shipped evidence. They do not themselves compose WebDriver BiDi/CDP transport, trusted Chromium process attribution, policy-authorized real input dispatch, causal post-condition observation, or deterministic end-to-end teardown/recovery into one protected-main runtime. +These active PRs are non-shipped evidence. PR #70/#71/#72 prove bounded browser-level, semantic, and resource evidence, but the active set does not itself compose WebDriver BiDi/CDP transport, OriginWeave authority translation, trusted Chromium process attribution, policy-authorized real input dispatch, causal post-condition observation, or deterministic end-to-end teardown/recovery into one protected-main runtime. ## 6. Remaining issue #28 boundary This dossier does **not** close issue #28. Material remaining work includes: -- pinned stock Chromium exercised as one reproducible end-to-end Agent Task runtime path, not only extension compatibility fixtures; +- a production Agent Task runtime path that composes pinned stock Chromium with OriginWeave authority, rather than only the controlled #70 fixture and extension compatibility fixtures; - isolated Agent Task profile/context lifecycle and cleanup in the production vertical path; - versioned WebDriver BiDi adapter plus explicitly bounded CDP observation fallback where needed; - real semantic observation feeding typed query and policy-authorized typed action; @@ -113,4 +140,4 @@ This dossier does **not** close issue #28. Material remaining work includes: ## 7. Documentation fitness consequence -The ADR/PRD/TRD/Architecture/UML/ERD graph remains **DESIGN-SUFFICIENT / PROTECTED-MAIN-PARTIAL**. PR #64 narrows a typed evidence gap already governed by existing provenance/action-success decisions, while PR #65 supplies controlled test infrastructure for the eventual real-browser proof. Neither introduces a new trust domain, deployed component, persistence owner, database schema, or independent architecture decision, so a new ADR or physical ERD entity would overstate the implementation. Detailed real-Chromium dispatch/post-condition sequence diagrams should be reconciled when the executable adapter chain stabilizes rather than manufacturing as-built detail before that runtime exists. +The ADR/PRD/TRD/Architecture/UML/ERD graph remains **DESIGN-SUFFICIENT / PROTECTED-MAIN-PARTIAL**. PR #64 narrows a typed evidence gap, PR #65 supplies the controlled fixture, and PR #70/#71 supply real WebDriver and browser-computed semantic evidence for that fixture. Neither introduces a new trust domain, deployed component, persistence owner, database schema, or independent architecture decision, so a new ADR or physical ERD entity would overstate the implementation. Detailed real-Chromium dispatch/post-condition sequence diagrams should be reconciled when the executable adapter chain stabilizes rather than manufacturing as-built detail before that runtime exists. diff --git a/docs/traceability/mcp-authority-route.md b/docs/traceability/mcp-authority-route.md new file mode 100644 index 000000000..ddbd5927c --- /dev/null +++ b/docs/traceability/mcp-authority-route.md @@ -0,0 +1,53 @@ +# MCP 2026-07-28 authority-route traceability + +- **Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` +- **Owning work:** PR #168 `feat(mcp): bind stateless tool routing to typed actions` +- **Protected-main status:** non-shipped active-PR evidence +- **Complete MCP adapter status:** `PLANNED` +- **Governing decision:** ADR 0107 + +## Scope + +PR #168 implements a bounded Rust control-plane foundation for MCP `2026-07-28` `tools/call` routing. It validates the represented stateless routing envelope, bounds and syntax-validates both attacker-controlled method fields and both attacker-controlled tool-name fields before correlation, maps only an explicit reviewed `originweave.*` catalog to existing typed `ActionKind` values, derives discovery metadata from the same catalog, and rejects route/action mismatch before ordinary deterministic policy evaluation. Methods are nonempty reviewed-ASCII routing tokens of at most 64 bytes; tool names are nonempty reviewed-ASCII identifiers of at most 128 bytes. Invalid method metadata is rejected distinctly from a bounded but unsupported MCP method. + +A successful `ValidatedMcpToolCall` proves routing integrity only. It grants no capability, origin, approval, secret, browser, tenant, persistence, network, or evidence authority. `originweave_policy::evaluate_mcp` still delegates to the ordinary policy evaluator after the route/action match. + +## Product-status reconciliation + +`docs/PRD.md` PRD-INT-004 and `docs/TRD.md` Section 12 intentionally remain **Planned** at the complete-adapter level. That status is not contradicted by this active PR: the PR implements only a reusable routing/action-policy foundation below the product adapter. `README.md` and `CHANGELOG.md` therefore distinguish the active foundation from shipped protected-main capability, and ADR 0107 records the same version and authority boundary. + +The following remain outside PR #168 and must not be inferred from it: + +- Streamable HTTP transport parsing and header materialization; +- complete request `_meta` validation, including per-request client capabilities; +- `tools/list` serialization, pagination, cache semantics, and subscription handling; +- OAuth and authenticated MCP deployment policy; +- browser-control I/O or BiDi/CDP/WebMCP translation; +- secret materialization or broker transport; +- persistence, durable audit storage, or WARC/PROV export; and +- an OriginWeave Protocol version transition. + +## Version boundary + +The active routing foundation accepts only protocol generation `2026-07-28`. MCP versioning is independent of the OriginWeave Protocol. A later MCP revision does not silently change OriginWeave action, risk, capability, approval, secret, origin, tenant, browser, or evidence semantics. + +The reviewed primary source is: + +Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 + +The canonical bibliography remains `docs/doctoring.md`. + +## Executable evidence + +Current PR #168 production/test surfaces include: + +- `crates/originweave-core/src/mcp.rs` — bounded deterministic catalog plus method/tool routing validation in the `ValidatedMcpToolCall` primitive; +- `crates/originweave-core/tests/mcp_authority_route.rs` — mapping, exact method/tool bounds, empty/oversized/malformed inputs, version/method/header-body correlation, and error-contract evidence; +- `crates/originweave-policy/src/lib.rs` — `evaluate_mcp` route/action guard before normal policy evaluation; and +- `crates/originweave-policy/tests/mcp_route_binding.rs` — confused-deputy and policy-preservation evidence. + +Exact current-head CI/security/review evidence must be regenerated after every branch mutation. Predecessor-head success is historical only. + +## Promotion rule + +This dossier may change to `IMPLEMENTED_ON_PROTECTED_MAIN` for the bounded routing foundation only after PR #168 reaches protected `main` under live governance and exact-head acceptance. That promotion still does **not** promote the complete MCP adapter from `PLANNED`; each remaining transport/runtime boundary requires its own integrated evidence. diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 39b7ec2d9..6f5c3f3e6 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -43,8 +43,6 @@ FIXTURE_TIMEOUT_SECONDS = 20.0 MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576 MAX_PROC_STATUS_CHARACTERS = 65_536 -MAX_BROWSER_PROCESS_TREE_SIZE = 256 -MAX_PROC_PROCESS_SCAN_SIZE = 32_768 MAX_U64 = (1 << 64) - 1 W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.") @@ -56,18 +54,6 @@ class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): def log_message(self, _format: str, *args: object) -> None: """Suppress request logs because the fixture contains no diagnostic value.""" - def translate_path(self, path: str) -> str: - """Resolve requests only when their final path remains inside the fixture root.""" - - translated = pathlib.Path(super().translate_path(path)) - fixture_root = pathlib.Path(self.directory).resolve() - try: - resolved = translated.resolve(strict=False) - resolved.relative_to(fixture_root) - except (OSError, ValueError): - return str(fixture_root / ".originweave-rejected-fixture-path") - return str(resolved) - def _free_loopback_port() -> int: """Reserve and release one loopback TCP port for a short-lived local service.""" @@ -243,58 +229,6 @@ def _parse_linux_proc_status_rss_bytes(status_text: str) -> int: return rss_values[0] -def _parse_linux_proc_status_optional_rss_bytes(status_text: str) -> int | None: - """Parse optional Linux ``VmRSS`` without normalizing malformed evidence.""" - - rss_lines = [line for line in status_text.splitlines() if line.startswith("VmRSS:")] - if not rss_lines: - return None - if len(rss_lines) != 1: - raise ValueError("Linux proc status must contain at most one VmRSS field") - - fields = rss_lines[0].split() - if len(fields) != 3 or fields[0] != "VmRSS:" or fields[2] != "kB": - raise ValueError("malformed Linux VmRSS field") - raw_kibibytes = fields[1] - if not raw_kibibytes.isascii() or not raw_kibibytes.isdigit(): - raise ValueError("malformed Linux VmRSS value") - kibibytes = int(raw_kibibytes, 10) - if kibibytes == 0: - return None - if kibibytes > MAX_U64 // 1024: - raise OverflowError("Linux VmRSS exceeds u64 byte range") - return kibibytes * 1024 - - -def _parse_linux_proc_status_process_identity(status_text: str) -> tuple[int, int]: - """Parse exactly one positive ``Pid`` and one non-negative ``PPid`` from status.""" - - parsed: dict[str, int] = {} - for line in status_text.splitlines(): - if not (line.startswith("Pid:") or line.startswith("PPid:")): - continue - fields = line.split() - if len(fields) != 2 or fields[0] not in {"Pid:", "PPid:"}: - raise ValueError("malformed Linux process identity field") - label = fields[0] - if label in parsed: - raise ValueError("duplicate Linux process identity field") - raw_process_id = fields[1] - if not raw_process_id.isascii() or not raw_process_id.isdigit(): - raise ValueError("malformed Linux process identity value") - parsed[label] = int(raw_process_id, 10) - - if set(parsed) != {"Pid:", "PPid:"}: - raise ValueError("Linux proc status must contain exactly one Pid and PPid") - process_id = parsed["Pid:"] - parent_process_id = parsed["PPid:"] - if process_id <= 0: - raise ValueError("Linux process identifier must be positive") - if parent_process_id < 0: - raise ValueError("Linux parent process identifier must be non-negative") - return process_id, parent_process_id - - def _sample_linux_process_rss_bytes(process_id: int) -> int: """Read one attributed Linux process RSS through a bounded ``/proc`` status file.""" @@ -308,118 +242,6 @@ def _sample_linux_process_rss_bytes(process_id: int) -> int: return _parse_linux_proc_status_rss_bytes(status_text) -def _snapshot_linux_process_evidence() -> dict[int, tuple[int, int | None]]: - """Capture one bounded best-effort PID/PPID/RSS sweep from Linux proc status.""" - - proc_root = pathlib.Path("/proc") - process_entries: list[tuple[int, pathlib.Path]] = [] - for entry in proc_root.iterdir(): - raw_process_id = entry.name - if not raw_process_id.isascii() or not raw_process_id.isdigit(): - continue - if entry.is_symlink() or not entry.is_dir(): - continue - process_id = int(raw_process_id, 10) - if process_id <= 0: - continue - process_entries.append((process_id, entry)) - if len(process_entries) > MAX_PROC_PROCESS_SCAN_SIZE: - raise RuntimeError("Linux proc process scan exceeded the bounded entry limit") - - process_evidence: dict[int, tuple[int, int | None]] = {} - for expected_process_id, entry in sorted(process_entries): - status_path = entry / "status" - try: - with status_path.open("r", encoding="utf-8", errors="strict") as status_file: - status_text = status_file.read(MAX_PROC_STATUS_CHARACTERS + 1) - except FileNotFoundError: - continue - if len(status_text) > MAX_PROC_STATUS_CHARACTERS: - raise RuntimeError("Linux proc status exceeded the bounded text limit") - process_id, parent_process_id = _parse_linux_proc_status_process_identity( - status_text - ) - if process_id != expected_process_id: - raise RuntimeError("Linux proc status identity did not match its directory") - if process_id in process_evidence: - raise RuntimeError("Linux proc process snapshot contained a duplicate PID") - rss_bytes = _parse_linux_proc_status_optional_rss_bytes(status_text) - process_evidence[process_id] = (parent_process_id, rss_bytes) - return process_evidence - - -def _discover_linux_process_tree_ids( - root_process_id: int, - process_evidence: dict[int, tuple[int, int | None]], -) -> tuple[int, ...]: - """Discover one bounded root-plus-descendant set from sampled process evidence.""" - - if ( - isinstance(root_process_id, bool) - or not isinstance(root_process_id, int) - or root_process_id <= 0 - ): - raise ValueError("invalid Linux root process identifier") - if root_process_id not in process_evidence: - raise RuntimeError("Linux process snapshot did not contain the browser root PID") - - discovered = [root_process_id] - known = {root_process_id} - while True: - children = sorted( - process_id - for process_id, (parent_process_id, _rss_bytes) in process_evidence.items() - if parent_process_id in known and process_id not in known - ) - if not children: - break - for process_id in children: - if len(known) >= MAX_BROWSER_PROCESS_TREE_SIZE: - raise ValueError("Linux process tree exceeded the bounded process-tree size") - known.add(process_id) - discovered.append(process_id) - return tuple(discovered) - - -def _sample_linux_process_set_rss_bytes( - process_ids: tuple[int, ...], - process_evidence: dict[int, tuple[int, int | None]], -) -> int: - """Sum resident RSS for one exact bounded process set without overflow.""" - - if not process_ids or len(process_ids) > MAX_BROWSER_PROCESS_TREE_SIZE: - raise ValueError("invalid Linux process set size") - if len(set(process_ids)) != len(process_ids): - raise ValueError("Linux process set identifiers must be unique") - - total_rss_bytes = 0 - for process_id in process_ids: - if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: - raise ValueError("invalid Linux process identifier") - if process_id not in process_evidence: - raise ValueError("Linux process set was not present in the sampled evidence") - rss_bytes = process_evidence[process_id][1] - if rss_bytes is None: - continue - if isinstance(rss_bytes, bool) or not isinstance(rss_bytes, int) or rss_bytes <= 0: - raise ValueError("Linux process set contained invalid sampled RSS") - if rss_bytes > MAX_U64 - total_rss_bytes: - raise OverflowError("Linux process-set RSS exceeds u64 byte range") - total_rss_bytes += rss_bytes - return total_rss_bytes - - -def _sample_linux_process_snapshot_rss_bytes( - process_id: int, - process_evidence: dict[int, tuple[int, int | None]], -) -> int: - """Sample one process RSS from the same bounded snapshot as its process set.""" - - if process_id not in process_evidence: - raise RuntimeError("Linux process snapshot did not contain the browser root PID") - return _sample_linux_process_set_rss_bytes((process_id,), process_evidence) - - def _wait_for_extension_evidence( driver_port: int, session_id: str, @@ -691,6 +513,17 @@ def _validate_agent_task_submitted_state(state: object) -> None: raise RuntimeError("Agent Task state post-condition failed") +def _cleanup_agent_task_browser_session(driver_port: int, session_id: str) -> None: + """Delete one Agent Task WebDriver session without suppressing cleanup failures.""" + + _json_request( + driver_port, + "DELETE", + _webdriver_path(session_id, ""), + {}, + ) + + def _run_agent_task_browser_pass( chrome_bin: pathlib.Path, chromedriver_bin: pathlib.Path, @@ -854,21 +687,7 @@ def _run_agent_task_browser_pass( _validate_agent_task_submitted_state(state) if text != AGENT_TASK_INPUT_VALUE: raise RuntimeError("Agent Task result did not match the synthetic typed value") - - process_evidence = _snapshot_linux_process_evidence() - chromium_process_ids = _discover_linux_process_tree_ids( - browser_process_id, - process_evidence, - ) - browser_process_rss_bytes = _sample_linux_process_snapshot_rss_bytes( - browser_process_id, - process_evidence, - ) - chromium_process_set_rss_bytes = _sample_linux_process_set_rss_bytes( - chromium_process_ids, - process_evidence, - ) - chromium_process_count = len(chromium_process_ids) + browser_process_rss_bytes = _sample_linux_process_rss_bytes(browser_process_id) task_duration_ms = round((time.monotonic() - started) * 1000, 3) if task_duration_ms <= 0: raise RuntimeError("Agent Task measured a non-positive task duration") @@ -881,28 +700,22 @@ def _run_agent_task_browser_pass( "submit_semantics_verified": True, "extensions_disabled": True, "browser_process_rss_bytes": browser_process_rss_bytes, - "chromium_process_count": chromium_process_count, - "chromium_process_set_rss_bytes": chromium_process_set_rss_bytes, "semantic_observation_bytes": semantic_observation_bytes, "action_latency_ms": action_latency_ms, "task_duration_ms": task_duration_ms, "duration_ms": round(task_duration_ms), } finally: - if session_id is not None: - with contextlib.suppress(Exception): - _json_request( - driver_port, - "DELETE", - _webdriver_path(session_id, ""), - {}, - ) - driver.terminate() try: - driver.wait(timeout=5) - except subprocess.TimeoutExpired: - driver.kill() - driver.wait(timeout=5) + if session_id is not None: + _cleanup_agent_task_browser_session(driver_port, session_id) + finally: + driver.terminate() + try: + driver.wait(timeout=5) + except subprocess.TimeoutExpired: + driver.kill() + driver.wait(timeout=5) def _run_agent_task_trial( @@ -940,8 +753,6 @@ def _run_agent_task_trial( "submit_semantics_verified": result["submit_semantics_verified"], "extensions_disabled": result["extensions_disabled"], "browser_process_rss_bytes": result["browser_process_rss_bytes"], - "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"], "action_latency_ms": result["action_latency_ms"], "task_duration_ms": result["task_duration_ms"], @@ -1010,12 +821,11 @@ def main() -> int: trial_number, ) ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except (OSError, ValueError, RuntimeError, json.JSONDecodeError): trial_results.append( { "trial_number": trial_number, "passed": False, - "failure_type": type(exc).__name__, } ) @@ -1053,12 +863,11 @@ def main() -> int: trial_number, ) ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except (OSError, ValueError, RuntimeError, json.JSONDecodeError): agent_task_trials.append( { "trial_number": trial_number, "passed": False, - "failure_type": type(exc).__name__, } ) @@ -1078,10 +887,6 @@ def main() -> int: and trial.get("profile_cleaned") 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) - and 0 < trial["chromium_process_count"] <= MAX_BROWSER_PROCESS_TREE_SIZE - and isinstance(trial.get("chromium_process_set_rss_bytes"), int) - and trial["chromium_process_set_rss_bytes"] > 0 and isinstance(trial.get("semantic_observation_bytes"), int) and trial["semantic_observation_bytes"] > 0 and isinstance(trial.get("action_latency_ms"), (int, float)) diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index 68a162a9a..196cbdb62 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -2,17 +2,18 @@ from __future__ import annotations -import http.client +import inspect import pathlib import runpy -import tempfile import unittest -import unittest.mock ROOT = pathlib.Path(__file__).resolve().parents[1] RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" FIXTURE = ROOT / "tests" / "fixtures" / "agent_task_basic" / "index.html" WORKFLOW = ROOT / ".github" / "workflows" / "mv3-compatibility.yml" +CHANGELOG = ROOT / "CHANGELOG.md" +TRACEABILITY = ROOT / "docs" / "traceability" / "action-postcondition-evidence.md" +FITNESS = ROOT / "docs" / "DOCUMENTATION_FITNESS.md" class AgentTaskPinnedChromeContractTests(unittest.TestCase): @@ -64,6 +65,25 @@ def test_agent_task_state_failure_does_not_echo_page_controlled_value(self) -> N validate_state(hostile_state) self.assertNotIn(hostile_state, str(raised.exception)) + def test_agent_task_session_cleanup_never_suppresses_programming_failures(self) -> None: + """Unexpected cleanup defects must fail closed instead of becoming successful evidence.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_cleanup_contract") + self.assertIn("_cleanup_agent_task_browser_session", namespace) + cleanup_session = namespace["_cleanup_agent_task_browser_session"] + browser_pass_source = inspect.getsource(namespace["_run_agent_task_browser_pass"]) + self.assertNotIn("contextlib.suppress(Exception)", browser_pass_source) + + def unexpected_cleanup_failure(*_args: object, **_kwargs: object) -> dict[str, object]: + raise AssertionError("unexpected cleanup programming failure") + + cleanup_session.__globals__["_json_request"] = unexpected_cleanup_failure + with self.assertRaisesRegex( + AssertionError, + r"^unexpected cleanup programming failure$", + ): + cleanup_session(9515, "session-1") + def test_agent_task_submission_preserves_the_loaded_url(self) -> None: """Submission must prove that the controlled action did not navigate away.""" @@ -117,143 +137,6 @@ def test_agent_task_records_real_bounded_resource_evidence(self) -> None: with self.subTest(expected=expected): self.assertIn(expected, runner) - def test_agent_task_records_bounded_chromium_process_tree_rss(self) -> None: - """The evidence runner must measure one bounded sampled process-set snapshot.""" - - namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_tree_contract") - runner = RUNNER.read_text(encoding="utf-8") - for expected in ( - "MAX_BROWSER_PROCESS_TREE_SIZE", - "MAX_PROC_PROCESS_SCAN_SIZE", - "_parse_linux_proc_status_process_identity", - "_parse_linux_proc_status_optional_rss_bytes", - "_snapshot_linux_process_evidence", - "_discover_linux_process_tree_ids", - "_sample_linux_process_set_rss_bytes", - ): - with self.subTest(expected=expected): - self.assertIn(expected, namespace) - self.assertNotIn("_parse_linux_children_process_ids", namespace) - self.assertNotIn("_snapshot_linux_process_parent_ids", namespace) - for expected in ( - '"chromium_process_count"', - '"chromium_process_set_rss_bytes"', - '"failure_type"', - ): - with self.subTest(expected=expected): - self.assertIn(expected, runner) - - def test_process_tree_and_rss_use_one_sampled_process_snapshot(self) -> None: - """Root and descendant RSS must come from the same bounded status snapshot.""" - - namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_snapshot") - discover = namespace["_discover_linux_process_tree_ids"] - sample = namespace["_sample_linux_process_set_rss_bytes"] - sample_root = namespace["_sample_linux_process_snapshot_rss_bytes"] - evidence = { - 10: (1, 100), - 20: (10, 200), - 30: (20, 300), - 40: (999, 400), - } - process_ids = discover(10, evidence) - self.assertEqual(process_ids, (10, 20, 30)) - self.assertEqual(sample_root(10, evidence), 100) - self.assertEqual(sample(process_ids, evidence), 600) - with self.assertRaises(ValueError): - sample((10, 50), evidence) - with self.assertRaises(RuntimeError): - sample_root(50, evidence) - - runner = RUNNER.read_text(encoding="utf-8") - self.assertIn( - "browser_process_rss_bytes = _sample_linux_process_snapshot_rss_bytes(", - runner, - ) - self.assertNotIn( - "browser_process_rss_bytes = _sample_linux_process_rss_bytes(browser_process_id)", - runner, - ) - - def test_process_snapshot_ignores_symlinked_proc_entries(self) -> None: - """The proc snapshot must not follow a symlink presented as a PID entry.""" - - namespace = runpy.run_path(str(RUNNER), run_name="agent_task_proc_symlink_contract") - with tempfile.TemporaryDirectory() as directory: - temporary_root = pathlib.Path(directory) - target = temporary_root / "target" - target.mkdir() - (target / "status").write_text( - "Name:\tchrome\nPid:\t123\nPPid:\t1\nVmRSS:\t1 kB\n", - encoding="utf-8", - ) - symlinked_entry = temporary_root / "123" - symlinked_entry.symlink_to(target, target_is_directory=True) - with unittest.mock.patch.object( - pathlib.Path, "iterdir", return_value=iter((symlinked_entry,)) - ): - evidence = namespace["_snapshot_linux_process_evidence"]() - - self.assertEqual(evidence, {}) - - def test_fixture_server_does_not_follow_symlinks_outside_fixture_root(self) -> None: - """The controlled fixture server must not disclose a linked outside file.""" - - namespace = runpy.run_path(str(RUNNER), run_name="agent_task_fixture_symlink_contract") - with tempfile.TemporaryDirectory() as directory: - temporary_root = pathlib.Path(directory) - fixture_root = temporary_root / "fixture" - fixture_root.mkdir() - (fixture_root / "index.html").write_text("fixture", encoding="utf-8") - secret_path = temporary_root / "secret.txt" - secret_path.write_text("not-for-the-fixture", encoding="utf-8") - (fixture_root / "linked.txt").symlink_to(secret_path) - server, thread = namespace["_start_fixture_server"](fixture_root) - try: - connection = http.client.HTTPConnection( - "127.0.0.1", server.server_port, timeout=2 - ) - connection.request("GET", "/linked.txt") - response = connection.getresponse() - body = response.read() - connection.close() - finally: - namespace["_stop_fixture_server"](server, thread) - - self.assertIn(response.status, {403, 404}) - self.assertNotIn(b"not-for-the-fixture", body) - - def test_process_set_tolerates_descendant_without_resident_rss(self) -> None: - """A sampled child with no resident RSS must not invalidate the whole tree.""" - - namespace = runpy.run_path(str(RUNNER), run_name="agent_task_zero_rss_contract") - sample = namespace["_sample_linux_process_set_rss_bytes"] - evidence = { - 10: (1, 100), - 20: (10, None), - 30: (20, 300), - } - self.assertEqual(sample((10, 20, 30), evidence), 400) - - def test_optional_linux_rss_parser_separates_absence_from_ambiguity(self) -> None: - """Snapshot parsing may tolerate absence, never malformed or duplicate VmRSS.""" - - namespace = runpy.run_path(str(RUNNER), run_name="agent_task_optional_rss_contract") - parser = namespace["_parse_linux_proc_status_optional_rss_bytes"] - self.assertIsNone(parser("Name:\tchrome\n")) - self.assertIsNone(parser("Name:\tchrome\nVmRSS:\t0 kB\n")) - self.assertEqual(parser("Name:\tchrome\nVmRSS:\t123 kB\n"), 123 * 1024) - for malformed in ( - "VmRSS:\t123 MB\n", - "VmRSS:\t123 kB extra\n", - "VmRSS:\tnot-a-number kB\n", - "VmRSS:\t123 kB\nVmRSS:\t124 kB\n", - "VmRSS:\t18446744073709551616 kB\n", - ): - with self.subTest(malformed=malformed): - with self.assertRaises((ValueError, OverflowError)): - parser(malformed) - def test_linux_rss_parser_is_strict_and_overflow_safe(self) -> None: """Runner-side RSS evidence must not accept ambiguous proc status input.""" @@ -272,26 +155,6 @@ def test_linux_rss_parser_is_strict_and_overflow_safe(self) -> None: with self.assertRaises((ValueError, OverflowError)): parser(malformed) - def test_linux_status_identity_parser_is_strict_and_positive(self) -> None: - """Process snapshot discovery must parse one unambiguous identity per status.""" - - namespace = runpy.run_path(str(RUNNER), run_name="agent_task_parent_map_contract") - parser = namespace["_parse_linux_proc_status_process_identity"] - self.assertEqual(parser("Name:\tchrome\nPid:\t34\nPPid:\t12\n"), (34, 12)) - self.assertEqual(parser("Name:\tinit\nPid:\t1\nPPid:\t0\n"), (1, 0)) - for malformed in ( - "Name:\tchrome\nPid:\t34\n", - "Name:\tchrome\nPPid:\t12\n", - "Pid:\t0\nPPid:\t12\n", - "Pid:\t34\nPPid:\t-1\n", - "Pid:\tchild\nPPid:\t12\n", - "Pid:\t34\nPid:\t35\nPPid:\t12\n", - "Pid:\t34\nPPid:\t12\nPPid:\t13\n", - ): - with self.subTest(malformed=malformed): - with self.assertRaises(ValueError): - parser(malformed) - def test_agent_task_fixture_runs_under_the_existing_pinned_chrome_job(self) -> None: """No floating browser or second workflow may be introduced for this slice.""" @@ -305,6 +168,28 @@ def test_agent_task_fixture_runs_under_the_existing_pinned_chrome_job(self) -> N self.assertNotIn("COPILOT_GITHUB_TOKEN", runner) self.assertNotIn("NVIDIA_NIM_API_KEY", runner) + def test_documentation_separates_active_browser_evidence_from_product_runtime(self) -> None: + """Documentation must record the real fixture evidence without shipping the adapter claim.""" + + changelog = CHANGELOG.read_text(encoding="utf-8") + traceability = TRACEABILITY.read_text(encoding="utf-8") + fitness = FITNESS.read_text(encoding="utf-8") + self.assertIn("Real pinned-Chrome WebDriver evidence", changelog) + self.assertIn("does not claim a shipped OriginWeave browser adapter", changelog) + self.assertIn("PR #70", traceability) + self.assertIn("real WebDriver", traceability) + self.assertIn("not a product browser adapter", traceability) + self.assertIn("pinned Chrome", fitness) + self.assertIn("not a browser adapter", fitness) + self.assertIn("browser-computed role/name", changelog) + self.assertIn("PR #71", traceability) + self.assertIn("computed role/name", traceability) + self.assertIn("computed role/name", fitness) + self.assertIn("browser-process RSS", changelog) + self.assertIn("PR #72", traceability) + self.assertIn("resource evidence", traceability) + self.assertIn("resource evidence", fitness) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_documentation_active_pr_evidence_contract.py b/tests/test_documentation_active_pr_evidence_contract.py index d2a067e50..34e8a0238 100644 --- a/tests/test_documentation_active_pr_evidence_contract.py +++ b/tests/test_documentation_active_pr_evidence_contract.py @@ -8,6 +8,8 @@ DOCS = ROOT / "docs" FITNESS = DOCS / "DOCUMENTATION_FITNESS.md" MATURITY = DOCS / "evidence" / "2026-08-10-active-pr-maturity.md" +BASELINE = DOCS / "product-technical-gap-baseline.md" +CHANGELOG = ROOT / "CHANGELOG.md" def active_pr_row(text: str, pr_number: int) -> str: @@ -28,6 +30,31 @@ class ActivePullRequestDocumentationContractTests(unittest.TestCase): def setUpClass(cls) -> None: cls.fitness = FITNESS.read_text(encoding="utf-8") cls.maturity = MATURITY.read_text(encoding="utf-8") + cls.baseline = BASELINE.read_text(encoding="utf-8") + cls.changelog = CHANGELOG.read_text(encoding="utf-8") + + def test_latest_live_pr_snapshot_is_recorded_in_the_product_baseline(self) -> None: + """The baseline must preserve exact heads for the newest active product slices.""" + for marker in ( + "Current exact-head active PR evidence", + "| #73 | Draft | `da99395b09b419845b4a1222a0725482e9231466` | `7861d88d21ed0f0adaeb467957e809826f835071` |", + "| #208 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `c3b6e1a475dce333f6115e5113cae9c07974835f` |", + "| #209 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `69bc738bd45a1b61a4673b122dc3eec8814baa22` |", + "| #210 | Draft | `69bc738bd45a1b61a4673b122dc3eec8814baa22` | `999979a511c3a890ba93a1a09da8810858877940` |", + "| #211 | Draft | `c3b6e1a475dce333f6115e5113cae9c07974835f` | `f6e3a3adcfb9cc7a60ef1d79e2aeee27ba54c084` |", + ): + with self.subTest(marker=marker): + self.assertIn(marker, self.baseline) + + def test_baseline_refresh_changelog_matches_the_live_snapshot(self) -> None: + """The changelog must classify and state the same baseline refresh.""" + refresh = "Refreshed the product and technical gap baseline with the current open-PR inventory" + added = self.changelog.split("### Added", 1)[1].split("### Changed", 1)[0] + changed = self.changelog.split("### Changed", 1)[1].split("### Security", 1)[0] + self.assertIn(refresh, added) + self.assertNotIn(refresh, changed) + self.assertIn("150 open pull requests, 110 drafts", self.changelog) + self.assertNotIn("150 open pull requests, 112 drafts", self.changelog) def test_dependency_stacks_are_explicit_and_non_shipped(self) -> None: """Current browser, network, sensitive and compatibility stacks stay active-only.""" diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py new file mode 100644 index 000000000..839393f30 --- /dev/null +++ b/tests/test_product_completion_gap_contract.py @@ -0,0 +1,111 @@ +"""Regression contract for the dated commercial-completion gap baseline.""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BASELINE = ROOT / "docs/product-technical-gap-baseline.md" + + +class ProductCompletionGapContractTests(unittest.TestCase): + """Keep the exact repository snapshot and completion tracks reviewable.""" + + def test_baseline_records_current_inventory_and_completion_issues(self) -> None: + """The dated baseline must not retain superseded queue counts or omit buyer tracks.""" + text = BASELINE.read_text(encoding="utf-8") + + for phrase in ( + "158 open pull requests", + "44 non-draft", + "114 draft", + "#198", + "#199", + "#200", + "#201", + "#202", + "#203", + "durable WARC/PROV replay", + "stable BAP/MCP runtime API", + "signed cross-platform Chromium distribution", + "enterprise control and experience plane", + "commercial acceptance gate", + ): + with self.subTest(phrase=phrase): + self.assertIn(phrase, text) + + for stale_phrase in ( + "100 open pull requests", + "22 non-draft", + "78 draft", + "148 open pull requests", + "79 draft PRs", + "150 open pull requests", + "40 non-draft", + "110 draft", + ): + with self.subTest(stale_phrase=stale_phrase): + self.assertNotIn(stale_phrase, text) + + def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> None: + """The evidence procedure must paginate the queue and inspect each exact PR head.""" + text = BASELINE.read_text(encoding="utf-8") + evidence = text.split("## Evidence commands", 1)[1].split("\n## ", 1)[0] + shell = evidence.split("```bash", 1)[1].split("```", 1)[0] + + for phrase in ( + "--paginate --slurp 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100'", + "set -euo pipefail", + 'EVIDENCE_DIR="$(mktemp -d /tmp/originweave-evidence.XXXXXX)"', + '"$EVIDENCE_DIR/open-pr-pages.json"', + "jq '[.[][]]' \"$EVIDENCE_DIR/open-pr-pages.json\"", + '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR"', + '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100"', + '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100"', + '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100"', + '"repos/ContextualWisdomLab/OriginWeave/actions/runs?head_sha=$HEAD_SHA&per_page=100"', + "check_runs: [$checks[][].check_runs[]?],", + "legacy_statuses: [$statuses[][][]?]", + "workflow_runs: [$workflow_runs[][].workflow_runs[]?],", + "reviewThreads(first: 100, after: $endCursor)", + "rules/branches/main?per_page=100", + '"$EVIDENCE_DIR/main-branch-rule-pages.json"', + '"$EVIDENCE_DIR/collaborator-pages.json"', + '"$EVIDENCE_DIR/collaborators.json"', + '"$EVIDENCE_DIR/pr-${PR}-merge-verdict.json.tmp"', + '.state == "APPROVED"', + ".submitted_at != null", + ".commit_id == $head", + "group_by(.reviewer)", + "required_approving_review_count", + "require_last_push_approval", + "last_push_approval_authority", + '"github_rule_evaluation_required"', + "if $pull_request_parameters.require_last_push_approval == true then false", + "$pr[0].user.login", + '.type == "workflows"', + ".parameters.workflows", + "required_status_checks", + '"$EVIDENCE_DIR/pr-${PR}-merge-verdict.json"', + "for ATTEMPT in 1 2 3; do", + "RECHECKED_HEAD_SHA=", + "RECHECKED_BASE_SHA=", + 'if [[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" && "$RECHECKED_BASE_SHA" == "$BASE_SHA" ]]; then', + ): + with self.subTest(phrase=phrase): + self.assertIn(phrase, shell) + + self.assertNotIn("while :; do", shell) + self.assertNotIn("/tmp/originweave-open-pr", shell) + self.assertNotIn("check_runs: [$checks[]?.check_runs[]?],", shell) + self.assertNotIn("legacy_statuses: [$statuses[][]?]", shell) + self.assertNotIn("workflow_runs: [$workflow_runs[]?.workflow_runs[]?],", shell) + self.assertNotIn("$reviews[][]?\n | select(.state", shell) + self.assertNotIn("head-commit.json", shell) + self.assertNotIn("$head_commit[0].committer.login", shell) + self.assertNotIn("$head_commit[0].author.login", shell) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index 1313189ea..5a1c1133c 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -11,6 +11,14 @@ class ProductDocumentationContractTests(unittest.TestCase): """Keep product requirements, technical design, diagrams, and traceability discoverable.""" + @staticmethod + def _subsection(text: str, heading: str) -> str: + """Return one fourth-level documentation subsection.""" + start = text.index(heading) + len(heading) + remainder = text[start:] + end = remainder.find("\n#### ") + return remainder if end == -1 else remainder[:end] + def test_authoritative_product_documentation_graph_exists(self) -> None: """Major product decisions must not require reconstructing chat or PR history.""" required_paths = { @@ -25,10 +33,50 @@ def test_authoritative_product_documentation_graph_exists(self) -> None: "docs/OPERABILITY.md", "docs/API_CONTRACT.md", "docs/RELEASE_AND_ROLLBACK.md", + "docs/product-technical-gap-baseline.md", } missing = sorted(path for path in required_paths if not (ROOT / path).is_file()) self.assertEqual(missing, []) + def test_product_technical_gap_baseline_records_live_delivery_state(self) -> None: + """Buyers and maintainers must see implementation gaps and current delivery blockers together.""" + baseline = ROOT / "docs/product-technical-gap-baseline.md" + self.assertTrue(baseline.is_file()) + text = baseline.read_text(encoding="utf-8") + for phrase in ( + "Observed snapshot: 2026-08-24", + "Protected-main truth", + "Open pull requests", + "Open issues", + "#195", + "#149", + "reviewer-provisioning gap", + "Phase 1", + ): + with self.subTest(phrase=phrase): + self.assertIn(phrase, text) + + protected_main = text.split("### Open pull requests", 1)[0] + open_pull_requests = text.split("### Open pull requests", 1)[1].split( + "### Review and merge authority", 1 + )[0] + self.assertIn("Phase 1 is **in progress**, not shipped.", protected_main) + self.assertIn( + "It remains draft evidence and cannot be treated as shipped behavior.", + open_pull_requests, + ) + bidi_status = self._subsection( + open_pull_requests, "#### #195/#198 WebDriver BiDi opening path status" + ) + vpn_status = self._subsection( + open_pull_requests, "#### #149 VPN/profile intent status" + ) + self.assertIn("Phase 1 is **in progress**, not shipped.", bidi_status) + self.assertIn( + "It remains draft evidence and cannot be treated as shipped behavior.", + vpn_status, + ) + def test_root_architecture_links_the_authoritative_product_graph(self) -> None: """Architecture readers must be able to reach requirements, decisions, diagrams, and data.""" architecture = (ROOT / "ARCHITECTURE.md").read_text(encoding="utf-8") @@ -39,6 +87,7 @@ def test_root_architecture_links_the_authoritative_product_graph(self) -> None: "docs/uml/README.md", "docs/erd/README.md", "docs/traceability/README.md", + "docs/product-technical-gap-baseline.md", ): with self.subTest(link=link): self.assertIn(link, architecture) diff --git a/tests/test_rust_toolchain_contract.py b/tests/test_rust_toolchain_contract.py new file mode 100644 index 000000000..058add241 --- /dev/null +++ b/tests/test_rust_toolchain_contract.py @@ -0,0 +1,53 @@ +"""Regression contracts for the reproducible Rust compiler baseline.""" + +from __future__ import annotations + +import tomllib +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +RUST_TOOLCHAIN = REPOSITORY_ROOT / "rust-toolchain.toml" +CI_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "ci.yml" +HOURLY_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "hourly-product-development.yml" +REFRESH_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "apply-rust-nightly-refresh.yml" +DEPENDABOT = REPOSITORY_ROOT / ".github" / "dependabot.yml" + + +class RustToolchainContractTests(unittest.TestCase): + """Keep stable builds reproducible and branch coverage intentionally fresh.""" + + def test_stable_toolchain_is_exact_and_automatically_tracked(self) -> None: + """The stable compiler changes only through a reviewable manifest update.""" + + manifest = tomllib.loads(RUST_TOOLCHAIN.read_text(encoding="utf-8")) + self.assertEqual(manifest["toolchain"]["channel"], "1.97.1") + + dependabot = DEPENDABOT.read_text(encoding="utf-8") + self.assertIn('package-ecosystem: "rust-toolchain"', dependabot) + self.assertIn('directory: "/"', dependabot) + self.assertIn('interval: "weekly"', dependabot) + + def test_branch_coverage_uses_one_current_date_pinned_nightly(self) -> None: + """Every branch-coverage command uses the same reviewed nightly snapshot.""" + + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + self.assertEqual(workflow.count("nightly-2026-08-18"), 3) + self.assertNotIn("nightly-2026-08-01", workflow) + + hourly_workflow = HOURLY_WORKFLOW.read_text(encoding="utf-8") + self.assertEqual(hourly_workflow.count("nightly-2026-08-18"), 2) + self.assertNotIn("nightly-2026-08-01", hourly_workflow) + + def test_nightly_refresh_accepts_only_old_or_already_refreshed_source(self) -> None: + """The one-shot materializer remains valid after the source is refreshed.""" + workflow = REFRESH_WORKFLOW.read_text(encoding="utf-8") + self.assertIn("old_count = source.count(old)", workflow) + self.assertIn("new_count = source.count(new)", workflow) + self.assertIn("if old_count == 2 and new_count == 0:", workflow) + self.assertIn("elif old_count == 0 and new_count == 2:", workflow) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 32c8e6d61ecdab73efbd788e0e013d8c21d62f54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 14:21:35 -0700 Subject: [PATCH 22/22] test(browser): restore bounded process-set regressions --- .../test_agent_task_pinned_chrome_contract.py | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index 196cbdb62..a18cdf3c9 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -2,10 +2,13 @@ from __future__ import annotations +import http.client import inspect import pathlib import runpy +import tempfile import unittest +import unittest.mock ROOT = pathlib.Path(__file__).resolve().parents[1] RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" @@ -137,6 +140,118 @@ def test_agent_task_records_real_bounded_resource_evidence(self) -> None: with self.subTest(expected=expected): self.assertIn(expected, runner) + def test_agent_task_records_bounded_chromium_process_tree_rss(self) -> None: + """The evidence runner must measure one bounded sampled process-set snapshot.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_tree_contract") + runner = RUNNER.read_text(encoding="utf-8") + for expected in ( + "MAX_BROWSER_PROCESS_TREE_SIZE", + "MAX_PROC_PROCESS_SCAN_SIZE", + "_parse_linux_proc_status_process_identity", + "_parse_linux_proc_status_optional_rss_bytes", + "_snapshot_linux_process_evidence", + "_discover_linux_process_tree_ids", + "_sample_linux_process_snapshot_rss_bytes", + "_sample_linux_process_set_rss_bytes", + ): + with self.subTest(expected=expected): + self.assertIn(expected, namespace) + for expected in ( + '"chromium_process_count"', + '"chromium_process_set_rss_bytes"', + '"failure_type"', + ): + with self.subTest(expected=expected): + self.assertIn(expected, runner) + + def test_process_tree_and_rss_use_one_sampled_process_snapshot(self) -> None: + """Root and descendant RSS must come from the same bounded status snapshot.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_one_snapshot_contract") + browser_pass_source = inspect.getsource(namespace["_run_agent_task_browser_pass"]) + self.assertEqual(browser_pass_source.count("_snapshot_linux_process_evidence()"), 1) + self.assertIn("_sample_linux_process_snapshot_rss_bytes", browser_pass_source) + self.assertNotIn("_sample_linux_process_rss_bytes(browser_process_id)", browser_pass_source) + + def test_process_tree_helpers_are_bounded_and_fail_closed(self) -> None: + """Sampled lineage must be deterministic and reject malformed membership/evidence.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_helper_contract") + parse_identity = namespace["_parse_linux_proc_status_process_identity"] + parse_optional_rss = namespace["_parse_linux_proc_status_optional_rss_bytes"] + discover = namespace["_discover_linux_process_tree_ids"] + sample_set = namespace["_sample_linux_process_set_rss_bytes"] + + self.assertEqual(parse_identity("Pid:\t10\nPPid:\t1\n"), (10, 1)) + self.assertIsNone(parse_optional_rss("Name:\tchrome\nPid:\t10\nPPid:\t1\n")) + self.assertEqual(parse_optional_rss("VmRSS:\t7 kB\n"), 7 * 1024) + with self.assertRaises(ValueError): + parse_optional_rss("VmRSS:\t7 kB\nVmRSS:\t8 kB\n") + + evidence = { + 10: (1, 100), + 12: (10, None), + 11: (10, 200), + 13: (11, 300), + } + self.assertEqual(discover(10, evidence), (10, 11, 12, 13)) + self.assertEqual(sample_set((10, 11, 12, 13), evidence), 600) + with self.assertRaises(ValueError): + sample_set((10, 10), evidence) + with self.assertRaises(ValueError): + sample_set((10, 99), evidence) + with self.assertRaises(RuntimeError): + discover(99, evidence) + + def test_process_snapshot_ignores_symlinked_proc_entries(self) -> None: + """The proc snapshot must not follow a symlink presented as a PID entry.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_proc_symlink_contract") + with tempfile.TemporaryDirectory() as directory: + temporary_root = pathlib.Path(directory) + target = temporary_root / "target" + target.mkdir() + (target / "status").write_text( + "Name:\tchrome\nPid:\t123\nPPid:\t1\nVmRSS:\t1 kB\n", + encoding="utf-8", + ) + symlinked_entry = temporary_root / "123" + symlinked_entry.symlink_to(target, target_is_directory=True) + with unittest.mock.patch.object( + pathlib.Path, "iterdir", return_value=iter((symlinked_entry,)) + ): + evidence = namespace["_snapshot_linux_process_evidence"]() + + self.assertEqual(evidence, {}) + + def test_fixture_server_does_not_follow_symlinks_outside_fixture_root(self) -> None: + """The controlled fixture server must not disclose a linked outside file.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_fixture_symlink_contract") + with tempfile.TemporaryDirectory() as directory: + temporary_root = pathlib.Path(directory) + fixture_root = temporary_root / "fixture" + fixture_root.mkdir() + (fixture_root / "index.html").write_text("fixture", encoding="utf-8") + secret_path = temporary_root / "secret.txt" + secret_path.write_text("not-for-the-fixture", encoding="utf-8") + (fixture_root / "linked.txt").symlink_to(secret_path) + server, thread = namespace["_start_fixture_server"](fixture_root) + try: + connection = http.client.HTTPConnection( + "127.0.0.1", server.server_port, timeout=2 + ) + connection.request("GET", "/linked.txt") + response = connection.getresponse() + body = response.read() + connection.close() + finally: + namespace["_stop_fixture_server"](server, thread) + + self.assertIn(response.status, {403, 404}) + self.assertNotIn(b"not-for-the-fixture", body) + def test_linux_rss_parser_is_strict_and_overflow_safe(self) -> None: """Runner-side RSS evidence must not accept ambiguous proc status input."""