diff --git a/docs/doctoring/mv3-compatibility.md b/docs/doctoring/mv3-compatibility.md index 07731c19a..0a5c4aca3 100644 --- a/docs/doctoring/mv3-compatibility.md +++ b/docs/doctoring/mv3-compatibility.md @@ -1,7 +1,7 @@ # Manifest V3 compatibility evidence baseline - **Status:** Active implementation evidence for issue #27 -- **Reviewed:** 2026-08-21 +- **Reviewed:** 2026-09-04 - **Pinned browser:** Chrome for Testing `150.0.7871.129`, Chromium revision `r1639810` OriginWeave uses Chromium as its compatibility kernel, so browser-extension compatibility must be demonstrated with executable Chromium evidence rather than inferred from architecture alone. The protected-main lane exercises a controlled unpacked Manifest V3 extension against one exact Chrome for Testing build and proves service-worker, content-script, storage, declarative-network-request, tabs, windows, scripting, commands, side-panel, bookmarks/history read compatibility, restart persistence, repeatability, and one real WebDriver click/post-condition. Active stacked compatibility work adds downloads, bounded bookmark/history mutation, profile isolation, explicit extension update/version-migration evidence, and an exact content-script isolated-world check. OriginWeave does **not claim 100% Chrome extension compatibility**. @@ -48,6 +48,8 @@ For downloads compatibility specifically, the current official Chrome Extensions RFC 9112 requires a well-formed HTTP/1.1 status-line and a message body that matches the announced framing. W3C WebDriver sends commands over that HTTP transport. When ChromeDriver returns a malformed status-line or an incomplete body, the compatibility runner raises only `WebDriver transport protocol failure`; when a WebDriver response supplies a recognized protocol error, it retains only an allow-listed error code. Raw status-line text, partial body bytes, paths, URLs, browser messages, or tokens must not enter exception text or trial evidence. This classification lets `main` record the failure in `trial_results` instead of aborting the compatibility run with an unclassified parser exception. +The 2 July 2026 WebDriver Working Draft defines remote error responses with a standardized error code plus implementation-defined `message` and `stacktrace` strings and optional `data`; its user-prompt example shows page-originated prompt text in `data.text`. OriginWeave therefore treats remote-end diagnostic text and returned capability values as untrusted evidence input. The compatibility runner retains only locally owned or explicit allow-list classifications: HTTP status, a reviewed WebDriver error code, `failure_kind`, sanitized fixture-surface tokens, and the expected pinned browser version. Raw response bodies, `message`, `stacktrace`, `data`, capability values, startup exception text, DOM values, and returned page text are not copied into CI/audit exceptions. This is an evidence-provenance boundary, not browser-policy authority and not an authentication claim about WebDriver, Chromium, or page content. + ## ChromeDriver startup-record robustness boundary ChromeDriver startup stdout is diagnostic input, not authority. The compatibility runner retains at most `MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + 1` bytes from one record and drains the remainder through bounded reads. A prefixed record that is oversized, lacks the required terminal period, carries a non-decimal port, or names a port outside `1..65535` is treated as non-authoritative and ignored while the existing bounded startup wait continues. A later well-formed candidate can therefore recover from malformed-but-expected startup diagnostics without turning the malformed record into success. @@ -95,3 +97,5 @@ Google Chrome Labs. (2026, July 21). *Chrome for Testing availability*. https:// Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP/1.1* (RFC 9112). Internet Engineering Task Force. https://doi.org/10.17487/RFC9112 World Wide Web Consortium. (2018, June 5). *WebDriver* (W3C Recommendation). https://www.w3.org/TR/2018/REC-webdriver1-20180605/ + +World Wide Web Consortium. (2026, July 2). *WebDriver* (Working Draft). https://www.w3.org/TR/2026/WD-webdriver2-20260702/ diff --git a/tests/test_mv3_fixture_evidence_redaction_contract.py b/tests/test_mv3_fixture_evidence_redaction_contract.py new file mode 100644 index 000000000..f79511672 --- /dev/null +++ b/tests/test_mv3_fixture_evidence_redaction_contract.py @@ -0,0 +1,175 @@ +"""Regression contracts for MV3 evidence diagnostic redaction.""" + +from __future__ import annotations + +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" +SENSITIVE_MARKERS = ( + "secret-token", + "/home/runner/private", + "example.invalid", +) + + +class ManifestV3FixtureEvidenceRedactionTests(unittest.TestCase): + """Prevent page-controlled fixture values from entering CI diagnostics.""" + + def assert_redacted(self, rendered: str) -> None: + """Require representative token, path, and URL material to be absent.""" + + for marker in SENSITIVE_MARKERS: + with self.subTest(marker=marker): + self.assertNotIn(marker, rendered) + + def test_fixture_timeout_does_not_echo_page_controlled_values(self) -> None: + """A non-converging fixture must report local classifications, not DOM values.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_fixture_redaction_contract") + wait_for_extension_evidence = namespace["_wait_for_extension_evidence"] + sensitive = "secret-token /home/runner/private https://example.invalid" + hostile = { + "content": sensitive, + "storage": sensitive, + "storagePersistence": sensitive, + "workerReply": sensitive, + "workerState": sensitive, + "workerStartCount": "0", + "dnr": sensitive, + "tabs": sensitive, + "windows": sensitive, + "scripting": sensitive, + "scriptingExecuted": sensitive, + "commands": sensitive, + "sidePanel": sensitive, + "bookmarks": sensitive, + "history": sensitive, + } + + with unittest.mock.patch.dict( + wait_for_extension_evidence.__globals__, + { + "_execute": unittest.mock.Mock(return_value=hostile), + "FIXTURE_TIMEOUT_SECONDS": 1.0, + }, + ), unittest.mock.patch.object( + namespace["time"], + "monotonic", + side_effect=(0.0, 0.0, 2.0), + ), unittest.mock.patch.object(namespace["time"], "sleep", return_value=None): + with self.assertRaises(RuntimeError) as raised: + wait_for_extension_evidence(9515, "session", "initialized") + + self.assert_redacted(str(raised.exception)) + + def test_click_mismatch_does_not_echo_page_controlled_text(self) -> None: + """A click post-condition mismatch must not retain returned DOM text.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_click_redaction_contract") + exercise_real_click = namespace["_exercise_real_click"] + element_key = namespace["W3C_ELEMENT_KEY"] + sensitive = "secret-token /home/runner/private https://example.invalid" + responses = ( + {"value": {element_key: "element.one"}}, + {}, + {"value": {element_key: "element.two"}}, + {"value": sensitive}, + ) + + with unittest.mock.patch.dict( + exercise_real_click.__globals__, + {"_json_request": unittest.mock.Mock(side_effect=responses)}, + ): + with self.assertRaises(RuntimeError) as raised: + exercise_real_click(9515, "session") + + self.assert_redacted(str(raised.exception)) + + def test_driver_startup_timeout_does_not_echo_remote_error_details(self) -> None: + """Startup timeout evidence must classify failure without retaining exception text.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_driver_startup_redaction_contract") + wait_for_driver = namespace["_wait_for_driver"] + sensitive = "secret-token /home/runner/private https://example.invalid" + + with unittest.mock.patch.dict( + wait_for_driver.__globals__, + { + "_json_request": unittest.mock.Mock(side_effect=RuntimeError(sensitive)), + "STARTUP_TIMEOUT_SECONDS": 1.0, + }, + ), unittest.mock.patch.object( + namespace["time"], + "monotonic", + side_effect=(0.0, 0.0, 2.0), + ), unittest.mock.patch.object(namespace["time"], "sleep", return_value=None): + with self.assertRaises(RuntimeError) as raised: + wait_for_driver(9515) + + self.assert_redacted(str(raised.exception)) + + def test_browser_version_mismatch_does_not_echo_remote_capability_value(self) -> None: + """A pin mismatch must not copy the WebDriver capability value into evidence.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_browser_version_redaction_contract") + run_browser_pass = namespace["_run_browser_pass"] + sensitive = "secret-token /home/runner/private https://example.invalid" + + class FakeDriver: + def terminate(self) -> None: + return None + + def wait(self, timeout: float) -> int: + _ = timeout + return 0 + + def kill(self) -> None: + return None + + def fake_json_request( + _driver_port: int, + method: str, + path: str, + _payload: object = None, + **_kwargs: object, + ) -> dict[str, object]: + if method == "POST" and path == "/session": + return { + "value": { + "sessionId": "session.one", + "capabilities": {"browserVersion": sensitive}, + } + } + if method == "DELETE": + return {"value": None} + raise AssertionError(f"unexpected request: {method} {path}") + + with unittest.mock.patch.dict( + run_browser_pass.__globals__, + { + "_start_chromedriver": unittest.mock.Mock( + return_value=(FakeDriver(), 9515) + ), + "_wait_for_driver": unittest.mock.Mock(return_value=None), + "_json_request": fake_json_request, + }, + ), tempfile.TemporaryDirectory() as profile_dir: + with self.assertRaises(RuntimeError) as raised: + run_browser_pass( + pathlib.Path("/controlled/chrome"), + pathlib.Path("/controlled/chromedriver"), + "http://127.0.0.1:9516/page.html", + profile_dir, + "initialized", + ) + + self.assert_redacted(str(raised.exception)) + + +if __name__ == "__main__": + unittest.main()