diff --git a/CHANGELOG.md b/CHANGELOG.md index 213ceb189..682b9ef46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Close the first pinned-Chrome fixture server when startup of the second server fails, and attempt both shutdowns when one cleanup fails, preventing partial compatibility runs from leaking loopback server threads. - The fixture-shutdown contract now exercises successful MV3 and Agent Task trial paths before asserting reverse-order server cleanup. - The resource-evidence shutdown fixture now supplies complete semantic and measured resource surfaces before exercising reverse-order cleanup. +- The process-set resource fixture now supplies Chromium count and aggregate RSS surfaces before exercising reverse-order cleanup. - The fixture-shutdown success double now includes both browser-computed semantic verification surfaces, so cleanup failures cannot mask incomplete Agent Task evidence. ### Added - Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, current base/head pairs, the 126-PR queue count, explicit root-versus-child merge ordering, and the active GitHub counted-approval gate. @@ -45,6 +46,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. +- Active pinned-Chrome Agent Task evidence derives a bounded Chromium process count and process-set RSS from one Linux proc snapshot, rejects proc/fixture symlink escapes, and retains failure-type-only diagnostics; this remains test-harness evidence and does not claim trusted production process attribution. - 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. - Versioned schema-bound extraction contracts with bounded identifiers and field counts, typed value/cardinality metadata, explicit duplicate-free reviewed source channels, fail-closed schema validation, and deterministic `Display`/`std::error::Error` contracts for public schema failures. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. diff --git a/docs/DOCUMENTATION_FITNESS.md b/docs/DOCUMENTATION_FITNESS.md index f36d6c945..9955da431 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 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. +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. Active PR #73 derives a bounded Chromium process count and process-set RSS from one Linux proc snapshot, with process identity and symlink-escape checks. -**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. +**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, while PR #73 adds bounded test-harness process-set evidence from one proc snapshot. Neither discovers production authority, proves task ownership, walks cgroups, samples GPU/VRAM or creates a durable telemetry store, and neither turns the fixture into a product resource adapter. ## 4. Durable product decisions captured by the canonical graph diff --git a/docs/traceability/action-postcondition-evidence.md b/docs/traceability/action-postcondition-evidence.md index 0350a4c9a..fe26f2c2a 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -79,6 +79,14 @@ PR #72 records browser-process RSS, semantic-observation bytes, action latency, 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. +### PR #73 — bounded Chromium process-set resource evidence + +**Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` + +PR #73 derives a bounded Chromium process count and process-set RSS from one Linux `/proc` snapshot, binding each sampled status file to its directory PID and rejecting proc-entry symlinks. Its cleanup regression double supplies the Chromium count and aggregate RSS surfaces before shutdown failure is exercised, so incomplete process evidence cannot pass incidentally. It also contains the local fixture server within its configured root and records only a bounded failure type when a trial fails. + +This remains test-harness evidence, not trusted production process attribution or a product resource adapter. It does not prove cgroup/task ownership, GPU/VRAM accounting, durable telemetry, or the shipped browser runtime's process authority. + ## 4. Non-transitive success semantics The intended first-slice chain is: @@ -120,9 +128,10 @@ The first real Chromium vertical slice remains distributed across bounded active - 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. +- 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; and +- PR #73 — bounded Chromium process count and process-set RSS evidence from one proc snapshot, without claiming trusted production process attribution or a product resource adapter. -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. +These active PRs are non-shipped evidence. PR #70/#71/#72/#73 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 diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 9ea8bb765..2b1c6320d 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -7,15 +7,17 @@ content-script, storage, declarative-net-request, tabs, windows, scripting, commands, side-panel, bookmarks, history, real browser-click, and 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, proves the controlled action -preserves its loaded URL, and records bounded runtime resource evidence without -treating page content as instruction or authority. +with extensions disabled in a fresh profile, locates the controlled action +targets by exact browser-computed role/name evidence, performs real WebDriver +input and click 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 +import contextlib +import hashlib import http.client import http.server import json @@ -43,6 +45,10 @@ 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_SEMANTIC_LOCATOR_CANDIDATES = 128 +MAX_AGENT_TASK_STRUCTURED_VALUE_BYTES = 4_096 MAX_U64 = (1 << 64) - 1 W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.") @@ -51,6 +57,18 @@ class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): """Serve only the controlled local fixture without noisy access logging.""" + 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 log_message(self, _format: str, *args: object) -> None: """Suppress request logs because the fixture contains no diagnostic value.""" @@ -225,6 +243,60 @@ def _get_element_semantics( return role, label +def _find_element_by_accessible_role_name( + driver_port: int, + session_id: str, + role: str, + accessible_name: str, +) -> str: + """Find exactly one controlled element by browser-computed role and name.""" + + found = _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/elements"), + {"using": "css selector", "value": "*"}, + ) + elements = found.get("value") + if not isinstance(elements, list): + raise RuntimeError("WebDriver did not return a semantic locator candidate list") + if len(elements) > MAX_SEMANTIC_LOCATOR_CANDIDATES: + raise RuntimeError("semantic locator exceeded bounded candidate limit") + + matches: list[str] = [] + for element in elements: + element_id = element.get(W3C_ELEMENT_KEY) if isinstance(element, dict) else None + if not isinstance(element_id, str): + raise RuntimeError("WebDriver returned malformed semantic locator candidate") + safe_element = _path_token(element_id, "element identifier") + candidate_role, candidate_name = _get_element_semantics( + driver_port, + session_id, + safe_element, + ) + if candidate_role == role and candidate_name == accessible_name: + matches.append(safe_element) + if len(matches) > 1: + raise RuntimeError("semantic locator returned multiple exact matches") + + if not matches: + raise RuntimeError("semantic locator returned no exact match") + return matches[0] + + +def _hash_agent_task_structured_value(value: str) -> str: + """Hash one bounded extracted text value without retaining the raw value in evidence.""" + + if not isinstance(value, str): + raise TypeError("Agent Task structured value must be text") + encoded = value.encode("utf-8") + if not encoded: + raise ValueError("Agent Task structured value must not be empty") + if len(encoded) > MAX_AGENT_TASK_STRUCTURED_VALUE_BYTES: + raise ValueError("Agent Task structured value exceeded the bounded text contract") + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + def _parse_linux_proc_status_rss_bytes(status_text: str) -> int: """Parse exactly one positive Linux ``VmRSS`` kB field into bounded bytes.""" @@ -262,6 +334,170 @@ def _sample_linux_process_rss_bytes(process_id: int) -> int: return _parse_linux_proc_status_rss_bytes(status_text) +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") + process_id = int(raw_process_id, 10) + if process_id > MAX_U64: + raise OverflowError("Linux process identifier exceeds u64 range") + parsed[label] = process_id + + 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") + return process_id, parent_process_id + + +def _snapshot_linux_process_evidence() -> dict[int, tuple[int, int | None]]: + """Capture one bounded best-effort PID/PPID/RSS sweep from Linux proc status.""" + + process_entries: list[tuple[int, pathlib.Path]] = [] + for entry in pathlib.Path("/proc").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 _cleanup_browser_session(driver_port: int, session_id: str) -> None: """Delete one WebDriver session through the fixed loopback authority.""" @@ -655,7 +891,12 @@ def _run_agent_task_browser_pass( 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_element = _find_element_by_accessible_role_name( + driver_port, + session_id, + "textbox", + "Task text", + ) input_role, input_name = _get_element_semantics( driver_port, session_id, @@ -663,10 +904,11 @@ def _run_agent_task_browser_pass( ) if input_role != "textbox" or input_name != "Task text": raise RuntimeError("Agent Task input semantic evidence mismatch") - submit_element = _find_element( + submit_element = _find_element_by_accessible_role_name( driver_port, session_id, - "#agent-task-form button[type=submit]", + "button", + "Submit task", ) submit_role, submit_name = _get_element_semantics( driver_port, @@ -722,7 +964,19 @@ def _run_agent_task_browser_pass( if not url_unchanged: raise RuntimeError("Agent Task URL changed during submission") - result_element = _find_element(driver_port, session_id, "#task-result") + result_element = _find_element_by_accessible_role_name( + driver_port, + session_id, + "status", + "Task result", + ) + result_role, result_name = _get_element_semantics( + driver_port, + session_id, + result_element, + ) + if result_role != "status" or result_name != "Task result": + raise RuntimeError("Agent Task result semantic evidence mismatch") state = _json_request( driver_port, "GET", @@ -736,7 +990,20 @@ 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") - browser_process_rss_bytes = _sample_linux_process_rss_bytes(browser_process_id) + structured_value_sha256 = _hash_agent_task_structured_value(text) + 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, + ) task_duration_ms = round((time.monotonic() - started) * 1000, 3) if task_duration_ms <= 0: raise RuntimeError("Agent Task measured a non-positive task duration") @@ -747,8 +1014,13 @@ def _run_agent_task_browser_pass( "url_unchanged": url_unchanged, "input_semantics_verified": True, "submit_semantics_verified": True, + "result_semantics_verified": True, + "structured_value_field": "task_result", + "structured_value_sha256": structured_value_sha256, "extensions_disabled": True, "browser_process_rss_bytes": browser_process_rss_bytes, + "chromium_process_count": len(chromium_process_ids), + "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, @@ -819,8 +1091,13 @@ def _run_agent_task_trial( "url_unchanged": result["url_unchanged"], "input_semantics_verified": result["input_semantics_verified"], "submit_semantics_verified": result["submit_semantics_verified"], + "result_semantics_verified": result["result_semantics_verified"], + "structured_value_field": result["structured_value_field"], + "structured_value_sha256": result["structured_value_sha256"], "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"], @@ -841,10 +1118,19 @@ def _agent_task_surfaces_complete(agent_task_trials: list[dict[str, Any]]) -> bo 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("result_semantics_verified") is True + and trial.get("structured_value_field") == "task_result" + and isinstance(trial.get("structured_value_sha256"), str) + and len(trial["structured_value_sha256"]) == len("sha256:") + 64 + and trial["structured_value_sha256"].startswith("sha256:") and trial.get("extensions_disabled") is True 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)) @@ -926,11 +1212,12 @@ def main() -> int: RuntimeError, http.client.HTTPException, json.JSONDecodeError, - ): + ) as exc: trial_results.append( { "trial_number": trial_number, "passed": False, + "failure_type": type(exc).__name__, } ) @@ -974,11 +1261,12 @@ def main() -> int: RuntimeError, http.client.HTTPException, json.JSONDecodeError, - ): + ) as exc: agent_task_trials.append( { "trial_number": trial_number, "passed": False, + "failure_type": type(exc).__name__, } ) diff --git a/tests/fixtures/agent_task_basic/index.html b/tests/fixtures/agent_task_basic/index.html index 510b239f1..d97f2046e 100644 --- a/tests/fixtures/agent_task_basic/index.html +++ b/tests/fixtures/agent_task_basic/index.html @@ -16,7 +16,12 @@
None: "url_unchanged": True, "input_semantics_verified": True, "submit_semantics_verified": True, + "result_semantics_verified": True, + "structured_value_field": "task_result", + "structured_value_sha256": "sha256:" + "0" * 64, "extensions_disabled": True, "profile_cleaned": True, "browser_process_rss_bytes": 1, + "chromium_process_count": 1, + "chromium_process_set_rss_bytes": 1, "semantic_observation_bytes": 1, "action_latency_ms": 1, "task_duration_ms": 2, @@ -313,8 +320,13 @@ def successful_agent_task_trial(*_args: object, **_kwargs: object) -> dict[str, "url_unchanged": True, "input_semantics_verified": True, "submit_semantics_verified": True, + "result_semantics_verified": True, + "structured_value_field": "task_result", + "structured_value_sha256": "sha256:" + "0" * 64, "extensions_disabled": True, "browser_process_rss_bytes": 1, + "chromium_process_count": 1, + "chromium_process_set_rss_bytes": 1, "semantic_observation_bytes": 1, "action_latency_ms": 1, "task_duration_ms": 2, @@ -394,6 +406,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.""" diff --git a/tests/test_agent_task_structured_value_contract.py b/tests/test_agent_task_structured_value_contract.py new file mode 100644 index 000000000..1ab0c8eb1 --- /dev/null +++ b/tests/test_agent_task_structured_value_contract.py @@ -0,0 +1,66 @@ +"""Contract for credential-safe structured extraction in the controlled Agent Task.""" + +from __future__ import annotations + +import hashlib +import pathlib +import runpy +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" +FIXTURE = ROOT / "tests" / "fixtures" / "agent_task_basic" / "index.html" + + +class AgentTaskStructuredValueContractTests(unittest.TestCase): + """Require bounded semantic extraction without retaining the extracted value.""" + + def test_result_is_discovered_by_exact_browser_semantics(self) -> None: + """The result node must be located by browser-computed role/name, not fixture CSS.""" + + runner = RUNNER.read_text(encoding="utf-8") + fixture = FIXTURE.read_text(encoding="utf-8") + self.assertIn('aria-label="Task result"', fixture) + self.assertIn('"status"', runner) + self.assertIn('"Task result"', runner) + self.assertIn('"result_semantics_verified"', runner) + self.assertNotIn('_find_element(driver_port, session_id, "#task-result")', runner) + + def test_structured_value_hash_is_bounded_and_canonical(self) -> None: + """Only a canonical SHA-256 digest may leave the controlled extraction boundary.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_structured_value_contract") + self.assertIn("MAX_AGENT_TASK_STRUCTURED_VALUE_BYTES", namespace) + self.assertIn("_hash_agent_task_structured_value", namespace) + helper = namespace["_hash_agent_task_structured_value"] + maximum = namespace["MAX_AGENT_TASK_STRUCTURED_VALUE_BYTES"] + + value = "synthetic structured value" + expected = "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest() + digest = helper(value) + self.assertEqual(digest, expected) + self.assertNotIn(value, digest) + self.assertEqual(len(digest), len("sha256:") + 64) + + with self.assertRaises(ValueError): + helper("") + with self.assertRaises(ValueError): + helper("x" * (maximum + 1)) + with self.assertRaises(TypeError): + helper(42) + + def test_agent_task_evidence_reports_field_and_digest_not_raw_result(self) -> None: + """Trial evidence must expose a field identifier and digest, not extracted text.""" + + runner = RUNNER.read_text(encoding="utf-8") + for expected in ( + '"structured_value_field"', + '"structured_value_sha256"', + '"task_result"', + ): + with self.subTest(expected=expected): + self.assertIn(expected, runner) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index 10872ddac..8b7e0de98 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -2,9 +2,11 @@ from __future__ import annotations +import http.client import json import pathlib import runpy +import tempfile import unittest ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -120,6 +122,32 @@ def test_runner_transport_cannot_follow_dynamic_url_schemes(self) -> None: self.assertNotIn("urllib.request", runner) self.assertNotIn("urllib.error", runner) + def test_fixture_handler_cannot_follow_symlinks_outside_its_root(self) -> None: + """The loopback fixture server must not expose files outside its configured root.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_contract") + start_server = namespace["_start_fixture_server"] + stop_server = namespace["_stop_fixture_server"] + with tempfile.TemporaryDirectory() as temporary_directory: + temporary_root = pathlib.Path(temporary_directory) + fixture_root = temporary_root / "fixture" + outside_root = temporary_root / "outside" + fixture_root.mkdir() + outside_root.mkdir() + (outside_root / "secret.txt").write_text("outside", encoding="utf-8") + (fixture_root / "escape").symlink_to(outside_root, target_is_directory=True) + + server, thread = start_server(fixture_root) + connection = http.client.HTTPConnection(*server.server_address, timeout=1) + try: + connection.request("GET", "/escape/secret.txt") + response = connection.getresponse() + self.assertEqual(response.status, 404) + self.assertNotIn(b"outside", response.read()) + finally: + connection.close() + stop_server(server, thread) + def test_runner_accepts_real_chromedriver_element_ids_without_path_injection(self) -> None: """ChromeDriver dotted element IDs must work while path syntax stays fail-closed."""