From e0e4cef2546c0564ba86b6301a3375656ed988ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:01:13 +0900 Subject: [PATCH 01/10] test(browser): require semantic role-name locator --- .../test_agent_task_pinned_chrome_contract.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index ff183bcea..42c7cb5c0 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -78,6 +78,25 @@ def test_agent_task_observes_computed_role_and_name_before_action(self) -> None: with self.subTest(expected=expected): self.assertIn(expected, runner) + def test_agent_task_locates_controlled_targets_by_exact_role_and_name(self) -> None: + """The controlled task must discover targets semantically rather than by fixture CSS.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_semantic_locator") + runner = RUNNER.read_text(encoding="utf-8") + self.assertIn("_find_element_by_accessible_role_name", namespace) + for expected in ( + "MAX_SEMANTIC_LOCATOR_CANDIDATES", + '"/elements"', + '"css selector"', + '"*"', + '"semantic locator returned no exact match"', + '"semantic locator returned multiple exact matches"', + ): + with self.subTest(expected=expected): + self.assertIn(expected, runner) + self.assertNotIn('_find_element(driver_port, session_id, "#task-text")', runner) + self.assertNotIn('_find_element(driver_port, session_id, "#submit-task")', runner) + def test_agent_task_records_real_bounded_resource_evidence(self) -> None: """The real task must report measured browser/runtime resource evidence.""" From 0a10234d32603175344dd337f41bbddb5515beca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:20:48 +0900 Subject: [PATCH 02/10] test(browser): locate Agent Task controls by role and name --- scripts/ci/run_mv3_compatibility.py | 64 +++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 34506fb02..a6619cd5f 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -7,11 +7,11 @@ 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 @@ -45,6 +45,7 @@ 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_U64 = (1 << 64) - 1 W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.") @@ -207,6 +208,47 @@ 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 _parse_linux_proc_status_rss_bytes(status_text: str) -> int: """Parse exactly one positive Linux ``VmRSS`` kB field into bounded bytes.""" @@ -741,7 +783,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, @@ -749,10 +796,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, From 13f49b7fc4f11d0fd851f51d816dc0cc94003b91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:26:05 +0900 Subject: [PATCH 03/10] test(browser): exercise semantic locator ambiguity --- .../test_agent_task_pinned_chrome_contract.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index 42c7cb5c0..f7e535008 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -97,6 +97,61 @@ def test_agent_task_locates_controlled_targets_by_exact_role_and_name(self) -> N self.assertNotIn('_find_element(driver_port, session_id, "#task-text")', runner) self.assertNotIn('_find_element(driver_port, session_id, "#submit-task")', runner) + def test_semantic_role_name_locator_fails_closed_on_ambiguous_candidates(self) -> None: + """Exact semantic discovery must reject zero, duplicate, malformed, and oversized sets.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_locator_behavior") + locate = namespace["_find_element_by_accessible_role_name"] + element_key = namespace["W3C_ELEMENT_KEY"] + candidate_limit = namespace["MAX_SEMANTIC_LOCATOR_CANDIDATES"] + + def install_candidates( + candidate_ids: list[str], + semantics: dict[str, tuple[str, str]], + ) -> None: + locate.__globals__["_json_request"] = lambda *_args, **_kwargs: { + "value": [{element_key: candidate_id} for candidate_id in candidate_ids] + } + locate.__globals__["_get_element_semantics"] = ( + lambda _port, _session, candidate_id: semantics[candidate_id] + ) + + install_candidates( + ["candidate-a", "candidate-b"], + { + "candidate-a": ("button", "Other"), + "candidate-b": ("button", "Submit task"), + }, + ) + self.assertEqual(locate(4444, "session-a", "button", "Submit task"), "candidate-b") + + install_candidates(["candidate-a"], {"candidate-a": ("button", "Other")}) + with self.assertRaisesRegex(RuntimeError, "no exact match"): + locate(4444, "session-a", "button", "Submit task") + + install_candidates( + ["candidate-a", "candidate-b"], + { + "candidate-a": ("button", "Submit task"), + "candidate-b": ("button", "Submit task"), + }, + ) + with self.assertRaisesRegex(RuntimeError, "multiple exact matches"): + locate(4444, "session-a", "button", "Submit task") + + install_candidates( + [f"candidate-{index}" for index in range(candidate_limit + 1)], + {}, + ) + with self.assertRaisesRegex(RuntimeError, "bounded candidate limit"): + locate(4444, "session-a", "button", "Submit task") + + locate.__globals__["_json_request"] = lambda *_args, **_kwargs: { + "value": [{"not-an-element-id": "candidate-a"}] + } + with self.assertRaisesRegex(RuntimeError, "malformed semantic locator candidate"): + locate(4444, "session-a", "button", "Submit task") + def test_agent_task_records_real_bounded_resource_evidence(self) -> None: """The real task must report measured browser/runtime resource evidence.""" From 3eaf34bd1d146dd69351799c5812c07c7b3eb7f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:05:54 +0900 Subject: [PATCH 04/10] test(browser): require structured result evidence --- ...st_agent_task_structured_value_contract.py | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/test_agent_task_structured_value_contract.py 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() From d2e4086127922a96f12ca4fca7b4eef602f4020c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:10:08 +0900 Subject: [PATCH 05/10] feat(browser): name controlled result semantically --- tests/fixtures/agent_task_basic/index.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 @@

Controlled Agent Task

- idle + idle

Date: Wed, 12 Aug 2026 12:15:56 +0900 Subject: [PATCH 06/10] feat(browser): record bounded structured result evidence --- scripts/ci/run_mv3_compatibility.py | 41 ++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index a6619cd5f..e5be0e38b 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -17,6 +17,7 @@ from __future__ import annotations import contextlib +import hashlib import http.client import http.server import json @@ -46,6 +47,7 @@ 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 + "-_.") @@ -249,6 +251,19 @@ def _find_element_by_accessible_role_name( 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.""" @@ -856,7 +871,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", @@ -871,6 +898,7 @@ 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") + structured_value_sha256 = _hash_agent_task_structured_value(text) process_evidence = _snapshot_linux_process_evidence() chromium_process_ids = _discover_linux_process_tree_ids( @@ -893,6 +921,9 @@ 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": chromium_process_count, @@ -952,6 +983,9 @@ 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"], @@ -1088,6 +1122,11 @@ def main() -> int: 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) From bc1d22d6c4848a173c55fdd18054574299488067 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 12:20:32 +0900 Subject: [PATCH 07/10] docs: record structured browser result evidence --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15b5a2de8..9632571dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. +- Pinned Chrome-for-Testing Agent Task evidence now locates the controlled result by exact browser-computed `status`/`Task result` semantics and records only a bounded canonical SHA-256 digest plus stable field identity for the extracted synthetic value, without emitting the raw value. - 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. @@ -74,4 +75,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file From f36c90d5bb46f7301734fef250832b3dab71975f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:10:24 +0900 Subject: [PATCH 08/10] fix(mv3): reuse one rss evidence 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 1f8e1242e..d6c626b1e 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -437,6 +437,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, @@ -883,7 +894,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 c3f14ab7c7d97110053b514e6d540fc4e916b1a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:11:13 +0900 Subject: [PATCH 09/10] fix(mv3): reuse one rss evidence 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 252907924..f89e9f18e 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -452,6 +452,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, @@ -911,7 +922,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 42908adfd5caa9d677bcb58511d2a7f338eed278 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 07:11:45 +0900 Subject: [PATCH 10/10] fix(mv3): contain fixture server paths --- CHANGELOG.md | 3 ++- scripts/ci/run_mv3_compatibility.py | 9 ++++++++ tests/test_mv3_compatibility_contract.py | 28 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 445e5bc91..290beb53a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security +- The loopback Manifest V3 fixture server rejects resolved request targets outside its configured fixture root, including escapes through fixture-tree symlinks. - 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. @@ -77,4 +78,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index f89e9f18e..697588e68 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -56,6 +56,15 @@ class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): """Serve only the controlled local fixture without noisy access logging.""" + def translate_path(self, path: str) -> str: + """Keep resolved request targets inside the configured fixture root.""" + + fixture_root = pathlib.Path(self.directory).resolve() + candidate = pathlib.Path(super().translate_path(path)).resolve() + if not candidate.is_relative_to(fixture_root): + return str(fixture_root / ".originweave-denied") + return str(candidate) + def log_message(self, _format: str, *args: object) -> None: """Suppress request logs because the fixture contains no diagnostic value.""" 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."""