From e0e4cef2546c0564ba86b6301a3375656ed988ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 10:01:13 +0900 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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.""" From 5d68bcd0558566cbbda338c23a4aa67652a6c695 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 11:10:54 +0900 Subject: [PATCH 11/15] fix(stack): restore current process parent before semantic locator replay Replace the stale-child tree attached by the predecessor merge with the exact live #73 tree, preserving only the three child files whose old prerequisite content is unchanged on #73. Runner and contract changes are deliberately withheld for semantic replay against the live parent. --- CHANGELOG.md | 27 +- Cargo.lock | 31 ++ Cargo.toml | 1 + README.md | 8 +- crates/originweave-bap/Cargo.toml | 12 + crates/originweave-bap/src/lib.rs | 329 +++++++++++++++ .../originweave-bap/tests/task_lifecycle.rs | 253 +++++++++++ .../tests/task_lifecycle_recovery.rs | 142 +++++++ crates/originweave-core/Cargo.toml | 1 + crates/originweave-core/src/lib.rs | 3 + crates/originweave-core/src/mcp.rs | 230 ++++++++++ .../src/release_acceptance.rs | 368 ++++++++++++++++ crates/originweave-core/src/root.rs | 2 + .../tests/mcp_tools_list_cache.rs | 221 ++++++++++ .../tests/origin_port_syntax.rs | 18 + .../tests/release_acceptance.rs | 397 ++++++++++++++++++ .../release_acceptance_canonical_text.rs | 116 +++++ ...elease_acceptance_meaningful_limitation.rs | 46 ++ .../release_acceptance_resource_bounds.rs | 98 +++++ .../tests/release_acceptance_unicode17.rs | 121 ++++++ crates/originweave-destination/src/lib.rs | 3 +- crates/originweave-destination/src/proxy.rs | 3 + .../originweave-destination/src/resolution.rs | 236 +++++++++++ .../tests/proxy_port_syntax.rs | 29 ++ .../tests/resolution_freshness.rs | 235 +++++++++++ .../resolution_post_expiry_revalidation.rs | 78 ++++ .../src/extraction_schema.rs | 297 +++++++++++++ crates/originweave-evidence/src/lib.rs | 10 + .../src/sensitive_access.rs | 5 +- .../src/sensitive_handle_lifecycle.rs | 144 +++++++ .../tests/extraction_normalization.rs | 77 ++++ .../tests/extraction_schema.rs | 326 ++++++++++++++ .../tests/extraction_schema_error_contract.rs | 48 +++ .../tests/extraction_source_channel_set.rs | 40 ++ .../tests/sensitive_handle_access_binding.rs | 114 +++++ .../sensitive_handle_lifecycle_evidence.rs | 142 +++++++ .../tests/extension_mutation_isolation.rs | 343 +++++++++++++++ .../tests/extension_policy_isolation.rs | 215 ++++++++++ .../tests/extension_secret_isolation.rs | 96 +++++ crates/originweave-resource/src/lib.rs | 15 + .../tests/error_contract.rs | 21 + crates/originweave-tls/src/lib.rs | 2 + crates/originweave-tls/src/revocation.rs | 174 ++++++++ crates/originweave-tls/src/trust.rs | 1 + .../originweave-tls/tests/policy_contract.rs | 2 +- .../tests/revocation_freshness.rs | 119 ++++++ docs/README.md | 8 + docs/adr/0016-bap-task-lifecycle-authority.md | 123 ++++++ docs/adr/0106-provenance-evidence-model.md | 20 +- .../0107-browser-protocol-adapter-strategy.md | 12 +- docs/adr/README.md | 10 + docs/doctoring.md | 16 +- docs/doctoring/browser-agent-protocols.md | 14 +- docs/doctoring/mv3-compatibility.md | 2 + docs/product-technical-gap-baseline.md | 91 ++-- .../action-postcondition-evidence.md | 10 +- docs/traceability/mcp-authority-route.md | 31 +- scripts/ci/run_mv3_compatibility.py | 122 +----- .../test_agent_task_pinned_chrome_contract.py | 164 ++++---- tests/test_doctoring_reference_contract.py | 28 ++ ...cumentation_active_pr_evidence_contract.py | 16 +- ...test_gap_snapshot_inventory_consistency.py | 58 +++ tests/test_product_completion_gap_contract.py | 20 +- tests/test_product_documentation_contract.py | 6 +- tests/test_repository_contract.py | 4 +- 65 files changed, 5684 insertions(+), 270 deletions(-) create mode 100644 crates/originweave-bap/Cargo.toml create mode 100644 crates/originweave-bap/src/lib.rs create mode 100644 crates/originweave-bap/tests/task_lifecycle.rs create mode 100644 crates/originweave-bap/tests/task_lifecycle_recovery.rs create mode 100644 crates/originweave-core/src/release_acceptance.rs create mode 100644 crates/originweave-core/tests/mcp_tools_list_cache.rs create mode 100644 crates/originweave-core/tests/origin_port_syntax.rs create mode 100644 crates/originweave-core/tests/release_acceptance.rs create mode 100644 crates/originweave-core/tests/release_acceptance_canonical_text.rs create mode 100644 crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs create mode 100644 crates/originweave-core/tests/release_acceptance_resource_bounds.rs create mode 100644 crates/originweave-core/tests/release_acceptance_unicode17.rs create mode 100644 crates/originweave-destination/tests/proxy_port_syntax.rs create mode 100644 crates/originweave-destination/tests/resolution_freshness.rs create mode 100644 crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs create mode 100644 crates/originweave-evidence/src/extraction_schema.rs create mode 100644 crates/originweave-evidence/src/sensitive_handle_lifecycle.rs create mode 100644 crates/originweave-evidence/tests/extraction_normalization.rs create mode 100644 crates/originweave-evidence/tests/extraction_schema.rs create mode 100644 crates/originweave-evidence/tests/extraction_schema_error_contract.rs create mode 100644 crates/originweave-evidence/tests/extraction_source_channel_set.rs create mode 100644 crates/originweave-evidence/tests/sensitive_handle_access_binding.rs create mode 100644 crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs create mode 100644 crates/originweave-policy/tests/extension_mutation_isolation.rs create mode 100644 crates/originweave-policy/tests/extension_policy_isolation.rs create mode 100644 crates/originweave-policy/tests/extension_secret_isolation.rs create mode 100644 crates/originweave-resource/tests/error_contract.rs create mode 100644 crates/originweave-tls/src/revocation.rs create mode 100644 crates/originweave-tls/tests/revocation_freshness.rs create mode 100644 docs/adr/0016-bap-task-lifecycle-authority.md create mode 100644 tests/test_doctoring_reference_contract.py create mode 100644 tests/test_gap_snapshot_inventory_consistency.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bad713949..682b9ef46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,20 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] -### Added +- Refreshed the product-gap queue to 126 open pull requests (54 ready, 72 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review; these are queue-consolidation results, not protected-main shipment. - Classified bounded WebDriver HTTP cleanup failures, including truncated responses, as typed cleanup evidence while preserving the primary browser error. +- Record truncated WebDriver trial responses as bounded failed-trial evidence instead of aborting the complete MV3 compatibility run. - 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. +- Refreshed the product and technical gap baseline onto the 2026-08-26 live inventory: 126 open pull requests (54 ready, 72 draft), protected-main promotion of #168/#194/#196/#216/#151, a verified maintenance-loop record (supersession closure of #153, conflict reconciliations on #37/#149/#152/#173/#175, issue #212 option-(b) authorization on #43, Strix vuln-0001 homoglyph remediation on #124), provider-rerun outcome evidence, an organization review-pipeline congestion record, and refreshed merge-order queue guidance. Documentation evidence contracts were aligned to the same snapshot so the baseline, its dated markers, and the pinned exact-head rows cannot silently diverge. + +- Added `originweave_core::release_acceptance`, a deterministic fail-closed benchmark release-decision contract that requires one authoritative result for every mandatory suite, bounds explicit buyer-visible limitations, rejects duplicate limitation claim identities, and rejects non-canonical surrounding whitespace rather than normalizing it into an alternate claim spelling. - 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. @@ -16,16 +26,20 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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`. +- Protected main now contains deterministic MCP `2026-07-28` stateless `tools/call` routing with bounded method/tool names, a single reviewed tool-to-action registry shared by routing and discovery metadata, and fail-closed policy binding that grants no ambient authority. The complete MCP adapter, transport serialization, discovery response handling, OAuth, browser I/O, and persistence remain planned. +- Active PR #170 adds conservative MCP `2026-07-28` `tools/list` discovery metadata derived from that protected-main catalog, with `resultType = complete`, zero freshness, private cache scope, no continuation cursor, per-request protocol/client-capability admission, and bounded protocol-version and method metadata validated before cross-field comparison. This remains active-PR evidence only and grants no browser, network, secret, approval, or Agent authority. - 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. +- Bounded resolution-freshness authority with trusted monotonic approval time, capped non-zero validity, half-open use windows, non-expanding revalidation, and credential-free authorization timestamps. - 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. +- Deterministic TLS revocation-material freshness authority with a strict signed `thisUpdate`→`nextUpdate` half-open window and typed invalid-window, not-yet-valid, and stale failures, without claiming OCSP/CRL acquisition, cryptographic validation, or certificate revocation status. - 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 sensitive-handle lifecycle evidence binds issuance, exclusive expiry, bounded uses, observed resolution count, and revocation to the exact credential-free `OpaqueHandleOnly` sensitive-access receipt, preserving tenant, actor, task, field set, purpose, destination, classification, policy version, and decision time without storing opaque handle tokens or protected values. - 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. +- Standard `Display` and `std::error::Error` contracts for destination, redirect, digest, direct-network, TLS, and resource-budget 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. @@ -34,11 +48,11 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. -- 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. +- Resumable BAP lifecycle restoration with monotonic sequence recovery and fail-closed sequence exhaustion. - 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. - Purpose-bound data-governance and privacy baseline that rejects both blanket masking and ambient raw-value propagation, defines field-scoped just-in-time disclosure, opaque-handle/trusted-broker boundaries, model/provider/region policy, retention/deletion/residency/break-glass controls, truthful CSAP/SOC 2 readiness language, and machine-checkable documentation contracts without inventing an OriginWeave-owned production database. - Proposed product-wide target-architecture ADRs for the Rust control plane, isolated execution modes, typed actions, semantic observation/stale-node authority, prompt-injection and secret separation, resource-governor priority, provenance evidence, browser/protocol adapters, crawler policy, and hourly automation operational closure; these remain Proposed rather than shipped claims until protected review and merge. @@ -67,7 +81,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. +- Explicit proxy server identifiers require ASCII decimal port tokens before numeric range parsing, preventing Rust-specific leading-plus spellings from widening proxy authority. - 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. @@ -87,6 +101,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - TLS accepts only an already verified direct stream, never a hostname or new socket, and requires the TLS origin to match the transport-authority origin exactly. - DNS TLS identity requires an applicable subjectAltName and never falls back to Common Name; literal IPv4 and IPv6 origins require exact IP subjectAltName entries. - TLS uses an explicit immutable trust-root bundle and fixed verification time, and permits only TLS 1.2 and TLS 1.3. +- TLS trust-bundle policy identifiers must contain at least one ASCII alphanumeric character; punctuation-only labels are rejected while `.`, `_`, `:`, and `-` remain permitted. - TLS resumption, 0-RTT, secret extraction, key logging, client certificates, certificate compression, and dangerous custom verifier hooks are disabled in the first slice. - The operating-system peer is rechecked before, during, and after the deadline-bound TLS handshake. - ALPN selection is restricted to the caller's bounded allow-list, while absence is either explicitly recorded or rejected by policy. diff --git a/Cargo.lock b/Cargo.lock index e2ada3c4e..848cb7320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,9 +263,16 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "originweave-bap" +version = "0.1.0" + [[package]] name = "originweave-core" version = "0.1.0" +dependencies = [ + "unicode-normalization", +] [[package]] name = "originweave-destination" @@ -554,6 +561,21 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "typenum" version = "1.20.1" @@ -566,6 +588,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index fc723f3a4..0d5ab469c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/originweave-core", + "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-resource", "crates/originweave-evidence", diff --git a/README.md b/README.md index a956ff60b..0942976cf 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 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. +> Project status: pre-alpha. The current protected repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, authenticated TLS service-identity, and bounded MCP `2026-07-28` stateless `tools/call` routing/policy foundations. Chromium, WebDriver BiDi, CDP, complete MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. Active PR #170 implements only conservative `tools/list` discovery metadata on top of the protected-main MCP catalog; it remains non-shipped active-PR evidence and does not make the complete MCP adapter available. ## Why OriginWeave @@ -40,7 +40,7 @@ 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. +Protected main additionally contains an `originweave-core` MCP routing registry and `originweave-policy` binding for the MCP `2026-07-28` `tools/call` boundary. That shipped foundation validates and maps an explicit tool name to an existing typed action while preserving normal OriginWeave policy. Active PR #170 adds non-shipped conservative `tools/list` discovery metadata derived from the same reviewed catalog. Neither boundary implements transport parsing, 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. @@ -99,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, 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). +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 protected-main `tools/call` foundation and active `tools/list` refinement, 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 @@ -111,4 +111,4 @@ Read [AGENTS.md](AGENTS.md), [CONTRIBUTING.md](CONTRIBUTING.md), and [SECURITY.m ## License -Apache License 2.0. See [LICENSE](LICENSE). +Apache License 2.0. See [LICENSE](LICENSE). \ No newline at end of file diff --git a/crates/originweave-bap/Cargo.toml b/crates/originweave-bap/Cargo.toml new file mode 100644 index 000000000..39e8e38f7 --- /dev/null +++ b/crates/originweave-bap/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "originweave-bap" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[lints] +workspace = true diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs new file mode 100644 index 000000000..404a88c10 --- /dev/null +++ b/crates/originweave-bap/src/lib.rs @@ -0,0 +1,329 @@ +//! Stable internal Browser Agent Protocol lifecycle contracts. +//! +//! This crate intentionally owns no transport, browser, network, model, secret, +//! approval, or persistence authority. External protocol adapters may project +//! these states, but protocol metadata cannot mint or change OriginWeave task +//! authority. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +/// Durable logical state of one governed BAP task. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskState { + /// The task record exists but has not entered admission control. + Created, + /// Admission control accepted the task but execution has not started. + Admitted, + /// The task is actively executing governed work. + Running, + /// Execution is suspended until an approval decision is available. + WaitingForApproval, + /// Execution is suspended until required external input is available. + WaitingForExternalInput, + /// Execution is suspended at a compatible recoverable checkpoint. + Checkpointed, + /// Execution is suspended until an explicit reconciliation decision is recorded. + /// + /// The lifecycle state does not itself persist or authenticate reconciliation + /// evidence. A durable owner must preserve the complete evidence that caused + /// the task to enter this state before resolution is considered. + ReconciliationRequired, + /// The declared post-condition completed successfully. + Succeeded, + /// The task reached a terminal execution failure. + Failed, + /// Cancellation completed and the task cannot resume. + Cancelled, + /// The task exceeded its allowed lifetime and cannot resume. + Expired, + /// The task was terminally removed from automatic execution after governed handling. + /// + /// Durable dead-letter evidence remains the responsibility of the persistence + /// boundary; this in-memory marker must not be treated as the evidence itself. + DeadLettered, +} + +impl BapTaskState { + /// Return whether this state is final and must never transition again. + #[must_use] + pub const fn is_terminal(self) -> bool { + matches!( + self, + Self::Succeeded | Self::Failed | Self::Cancelled | Self::Expired | Self::DeadLettered + ) + } +} + +/// One requested task-lifecycle event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskEvent { + /// Admit a newly created task. + Admit, + /// Start an admitted task. + Start, + /// Suspend a running task until approval is available. + WaitForApproval, + /// Suspend a running task until external input is available. + WaitForExternalInput, + /// Suspend a running task at a recoverable checkpoint. + Checkpoint, + /// Resume a normal suspended task into governed execution. + Resume, + /// Suspend a running task because its external outcome requires reconciliation. + RequireReconciliation, + /// Explicitly resolve a reconciliation hold and return the task to governed execution. + ResolveReconciliation, + /// Terminally remove a running or reconciliation-held task from automatic execution. + DeadLetter, + /// Record successful completion after the declared post-condition is verified. + Succeed, + /// Record terminal task failure. + Fail, + /// Record terminal cancellation. + Cancel, + /// Record terminal expiry. + Expire, +} + +/// A fail-closed lifecycle transition failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskTransitionError { + /// The requested event is not valid from the current non-terminal state. + InvalidTransition { + /// Current state that rejected the event. + from: BapTaskState, + /// Event that was rejected. + event: BapTaskEvent, + }, + /// The lifecycle sequence reached its maximum representable value. + SequenceExhausted, + /// A terminal task cannot be reopened or mutated by lifecycle events. + TerminalState { + /// Final state that rejected all further events. + state: BapTaskState, + }, +} + +impl std::fmt::Display for BapTaskTransitionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidTransition { from, event } => { + write!( + formatter, + "BAP task event {event:?} is invalid from state {from:?}" + ) + } + Self::SequenceExhausted => { + write!(formatter, "BAP task transition sequence is exhausted") + } + Self::TerminalState { state } => { + write!(formatter, "BAP task state {state:?} is terminal") + } + } + } +} + +impl std::error::Error for BapTaskTransitionError {} + +/// A fail-closed lifecycle recovery failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskRestoreError { + /// The supplied state and transition sequence cannot arise from this state machine. + InvalidSnapshot { + /// Logical state supplied by the durable recovery boundary. + state: BapTaskState, + /// Last accepted transition sequence supplied by the durable recovery boundary. + transition_sequence: u64, + }, +} + +impl std::fmt::Display for BapTaskRestoreError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidSnapshot { + state, + transition_sequence, + } => write!( + formatter, + "BAP task snapshot state {state:?} with transition sequence {transition_sequence} is unreachable" + ), + } + } +} + +impl std::error::Error for BapTaskRestoreError {} + +/// Immutable receipt for one accepted in-memory lifecycle transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BapTaskTransition { + previous_state: BapTaskState, + current_state: BapTaskState, + sequence: u64, +} + +impl BapTaskTransition { + /// Return the state before the accepted transition. + #[must_use] + pub const fn previous_state(self) -> BapTaskState { + self.previous_state + } + + /// Return the state after the accepted transition. + #[must_use] + pub const fn current_state(self) -> BapTaskState { + self.current_state + } + + /// Return the monotonic transition sequence for this lifecycle instance. + #[must_use] + pub const fn sequence(self) -> u64 { + self.sequence + } +} + +/// Deterministic fail-closed BAP task-lifecycle kernel. +/// +/// This value is intentionally an in-memory state-transition primitive. A +/// durable repository must persist accepted transitions and impose its own +/// bounded sequence/retention contract before commercial task recovery can be +/// claimed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BapTaskLifecycle { + state: BapTaskState, + transition_sequence: u64, +} + +impl Default for BapTaskLifecycle { + fn default() -> Self { + Self::new() + } +} + +impl BapTaskLifecycle { + /// Create one lifecycle in the `created` state with no accepted transitions. + #[must_use] + pub const fn new() -> Self { + Self { + state: BapTaskState::Created, + transition_sequence: 0, + } + } + + /// Restore a lifecycle state and its last accepted transition sequence. + /// + /// Recovery accepts only state/sequence pairs that are reachable through + /// this exact state machine. This prevents corrupt or stale durable metadata + /// from manufacturing an impossible execution state. + pub const fn restore( + state: BapTaskState, + transition_sequence: u64, + ) -> Result { + if !reachable_snapshot(state, transition_sequence) { + return Err(BapTaskRestoreError::InvalidSnapshot { + state, + transition_sequence, + }); + } + Ok(Self { + state, + transition_sequence, + }) + } + + /// Return the current logical task state. + #[must_use] + pub const fn state(self) -> BapTaskState { + self.state + } + + /// Return the number of accepted lifecycle transitions. + #[must_use] + pub const fn transition_sequence(self) -> u64 { + self.transition_sequence + } + + /// Apply one reviewed lifecycle event without granting execution authority. + /// + /// Rejected events leave both state and sequence unchanged. Terminal states + /// reject every later event before evaluating any normal transition rule. + /// Reconciliation cannot use the generic `Resume` event: it requires the + /// explicit `ResolveReconciliation` event so ambiguous external outcomes + /// cannot silently re-enter execution. + pub fn apply( + &mut self, + event: BapTaskEvent, + ) -> Result { + if self.state.is_terminal() { + return Err(BapTaskTransitionError::TerminalState { state: self.state }); + } + + let next_state = match (self.state, event) { + (BapTaskState::Created, BapTaskEvent::Admit) => BapTaskState::Admitted, + (BapTaskState::Admitted, BapTaskEvent::Start) => BapTaskState::Running, + (BapTaskState::Running, BapTaskEvent::WaitForApproval) => { + BapTaskState::WaitingForApproval + } + (BapTaskState::Running, BapTaskEvent::WaitForExternalInput) => { + BapTaskState::WaitingForExternalInput + } + (BapTaskState::Running, BapTaskEvent::Checkpoint) => BapTaskState::Checkpointed, + ( + BapTaskState::WaitingForApproval + | BapTaskState::WaitingForExternalInput + | BapTaskState::Checkpointed, + BapTaskEvent::Resume, + ) => BapTaskState::Running, + (BapTaskState::Running, BapTaskEvent::RequireReconciliation) => { + BapTaskState::ReconciliationRequired + } + (BapTaskState::ReconciliationRequired, BapTaskEvent::ResolveReconciliation) => { + BapTaskState::Running + } + ( + BapTaskState::Running | BapTaskState::ReconciliationRequired, + BapTaskEvent::DeadLetter, + ) => BapTaskState::DeadLettered, + (BapTaskState::Running, BapTaskEvent::Succeed) => BapTaskState::Succeeded, + (_, BapTaskEvent::Fail) => BapTaskState::Failed, + (_, BapTaskEvent::Cancel) => BapTaskState::Cancelled, + (_, BapTaskEvent::Expire) => BapTaskState::Expired, + (from, event) => { + return Err(BapTaskTransitionError::InvalidTransition { from, event }); + } + }; + + let Some(sequence) = self.transition_sequence.checked_add(1) else { + return Err(BapTaskTransitionError::SequenceExhausted); + }; + let previous_state = self.state; + self.state = next_state; + self.transition_sequence = sequence; + Ok(BapTaskTransition { + previous_state, + current_state: next_state, + sequence, + }) + } +} + +const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bool { + match state { + BapTaskState::Created => transition_sequence == 0, + BapTaskState::Admitted => transition_sequence == 1, + BapTaskState::Running => transition_sequence >= 2 && transition_sequence.is_multiple_of(2), + BapTaskState::WaitingForApproval + | BapTaskState::WaitingForExternalInput + | BapTaskState::Checkpointed + | BapTaskState::ReconciliationRequired => { + transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) + } + BapTaskState::Succeeded => { + transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) + } + BapTaskState::Failed | BapTaskState::Cancelled | BapTaskState::Expired => { + transition_sequence >= 1 + } + BapTaskState::DeadLettered => transition_sequence >= 3, + } +} diff --git a/crates/originweave-bap/tests/task_lifecycle.rs b/crates/originweave-bap/tests/task_lifecycle.rs new file mode 100644 index 000000000..01013682a --- /dev/null +++ b/crates/originweave-bap/tests/task_lifecycle.rs @@ -0,0 +1,253 @@ +#![allow(clippy::expect_used)] + +use originweave_bap::{BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError}; + +#[test] +fn default_starts_a_new_created_lifecycle() { + assert_eq!(BapTaskLifecycle::default(), BapTaskLifecycle::new()); +} + +#[test] +fn bap_task_lifecycle_follows_the_reviewed_resumable_path() { + let mut task = BapTaskLifecycle::new(); + assert_eq!(task.state(), BapTaskState::Created); + assert!(!task.state().is_terminal()); + assert_eq!(task.transition_sequence(), 0); + + let admitted = task.apply(BapTaskEvent::Admit).expect("admit"); + assert_eq!(admitted.previous_state(), BapTaskState::Created); + assert_eq!(admitted.current_state(), BapTaskState::Admitted); + assert_eq!(admitted.sequence(), 1); + + task.apply(BapTaskEvent::Start).expect("start"); + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait for approval"); + assert_eq!(task.state(), BapTaskState::WaitingForApproval); + + task.apply(BapTaskEvent::Resume).expect("resume approval"); + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + assert_eq!(task.state(), BapTaskState::Checkpointed); + + task.apply(BapTaskEvent::Resume).expect("resume checkpoint"); + let succeeded = task.apply(BapTaskEvent::Succeed).expect("succeed"); + assert_eq!(succeeded.current_state(), BapTaskState::Succeeded); + assert!(task.state().is_terminal()); + assert_eq!(task.transition_sequence(), 7); +} + +#[test] +fn waiting_for_external_input_can_resume_but_cannot_succeed_directly() { + let mut task = running_task(); + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait for input"); + + let error = task + .apply(BapTaskEvent::Succeed) + .expect_err("waiting task must not skip resume and post-condition work"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::WaitingForExternalInput, + event: BapTaskEvent::Succeed, + } + ); + assert_eq!(task.state(), BapTaskState::WaitingForExternalInput); + assert_eq!(task.transition_sequence(), 3); + + task.apply(BapTaskEvent::Resume).expect("resume input"); + assert_eq!(task.state(), BapTaskState::Running); +} + +#[test] +fn invalid_transition_is_fail_closed_and_does_not_advance_history() { + let mut task = BapTaskLifecycle::new(); + + let error = task + .apply(BapTaskEvent::Start) + .expect_err("created task must be admitted first"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::Start, + } + ); + assert_eq!(task.state(), BapTaskState::Created); + assert_eq!(task.transition_sequence(), 0); +} + +#[test] +fn terminal_task_never_reopens_or_advances_history() { + for terminal_event in [ + BapTaskEvent::Succeed, + BapTaskEvent::Fail, + BapTaskEvent::Cancel, + BapTaskEvent::Expire, + ] { + let mut task = if terminal_event == BapTaskEvent::Succeed { + running_task() + } else { + BapTaskLifecycle::new() + }; + task.apply(terminal_event).expect("enter terminal state"); + let terminal_state = task.state(); + let terminal_sequence = task.transition_sequence(); + + for later_event in [ + BapTaskEvent::Admit, + BapTaskEvent::Start, + BapTaskEvent::Resume, + BapTaskEvent::Cancel, + ] { + assert_eq!( + task.apply(later_event), + Err(BapTaskTransitionError::TerminalState { + state: terminal_state, + }) + ); + assert_eq!(task.state(), terminal_state); + assert_eq!(task.transition_sequence(), terminal_sequence); + } + } +} + +#[test] +fn cancellation_and_expiry_cover_pre_dispatch_and_suspended_states() { + for state in [ + BapTaskState::Created, + BapTaskState::Admitted, + BapTaskState::Running, + BapTaskState::WaitingForApproval, + BapTaskState::WaitingForExternalInput, + BapTaskState::Checkpointed, + BapTaskState::ReconciliationRequired, + ] { + for terminal_event in [BapTaskEvent::Cancel, BapTaskEvent::Expire] { + let mut task = task_in_state(state); + assert_eq!(task.state(), state); + task.apply(terminal_event).expect("terminal interruption"); + assert!(task.state().is_terminal()); + } + } +} + +#[test] +fn reconciliation_requires_explicit_resolution_and_dead_letter_is_terminal() { + let mut task = running_task(); + let required = task + .apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation"); + assert_eq!(required.previous_state(), BapTaskState::Running); + assert_eq!( + required.current_state(), + BapTaskState::ReconciliationRequired + ); + assert!(!task.state().is_terminal()); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::ReconciliationRequired, + event: BapTaskEvent::Resume, + }) + ); + assert_eq!( + task.apply(BapTaskEvent::Succeed), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::ReconciliationRequired, + event: BapTaskEvent::Succeed, + }) + ); + assert_eq!(task.transition_sequence(), 3); + + task.apply(BapTaskEvent::ResolveReconciliation) + .expect("resolve reconciliation"); + assert_eq!(task.state(), BapTaskState::Running); + + task.apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation again"); + let dead_lettered = task + .apply(BapTaskEvent::DeadLetter) + .expect("dead-letter unresolved task"); + assert_eq!(dead_lettered.current_state(), BapTaskState::DeadLettered); + assert!(task.state().is_terminal()); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::TerminalState { + state: BapTaskState::DeadLettered, + }) + ); +} + +#[test] +fn running_task_may_dead_letter_but_pre_dispatch_task_may_not() { + let mut running = running_task(); + let transition = running + .apply(BapTaskEvent::DeadLetter) + .expect("dead-letter running task"); + assert_eq!(transition.previous_state(), BapTaskState::Running); + assert_eq!(transition.current_state(), BapTaskState::DeadLettered); + assert_eq!(transition.sequence(), 3); + assert!(running.state().is_terminal()); + + let mut created = BapTaskLifecycle::new(); + assert_eq!( + created.apply(BapTaskEvent::DeadLetter), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::DeadLetter, + }) + ); + assert_eq!(created.state(), BapTaskState::Created); + assert_eq!(created.transition_sequence(), 0); +} + +fn running_task() -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + task.apply(BapTaskEvent::Admit).expect("admit"); + task.apply(BapTaskEvent::Start).expect("start"); + task +} + +fn task_in_state(target: BapTaskState) -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + if target == BapTaskState::Created { + return task; + } + + task.apply(BapTaskEvent::Admit).expect("admit"); + if target == BapTaskState::Admitted { + return task; + } + + task.apply(BapTaskEvent::Start).expect("start"); + match target { + BapTaskState::Running => {} + BapTaskState::WaitingForApproval => { + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait approval"); + } + BapTaskState::WaitingForExternalInput => { + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait external"); + } + BapTaskState::Checkpointed => { + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + } + BapTaskState::ReconciliationRequired => { + task.apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation"); + } + BapTaskState::Created + | BapTaskState::Admitted + | BapTaskState::Succeeded + | BapTaskState::Failed + | BapTaskState::Cancelled + | BapTaskState::Expired + | BapTaskState::DeadLettered => { + unreachable!("task_in_state only constructs non-terminal lifecycle states") + } + } + task +} diff --git a/crates/originweave-bap/tests/task_lifecycle_recovery.rs b/crates/originweave-bap/tests/task_lifecycle_recovery.rs new file mode 100644 index 000000000..67deae949 --- /dev/null +++ b/crates/originweave-bap/tests/task_lifecycle_recovery.rs @@ -0,0 +1,142 @@ +#![allow(clippy::expect_used)] + +use std::error::Error as _; + +use originweave_bap::{ + BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, BapTaskState, BapTaskTransitionError, +}; + +#[test] +fn restored_lifecycle_preserves_state_and_monotonic_sequence() { + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, 41) + .expect("valid checkpoint snapshot"); + + assert_eq!(task.state(), BapTaskState::Checkpointed); + assert_eq!(task.transition_sequence(), 41); + + let resumed = task + .apply(BapTaskEvent::Resume) + .expect("resume restored task"); + assert_eq!(resumed.previous_state(), BapTaskState::Checkpointed); + assert_eq!(resumed.current_state(), BapTaskState::Running); + assert_eq!(resumed.sequence(), 42); +} + +#[test] +fn impossible_restored_snapshots_fail_closed() { + for (state, sequence) in [ + (BapTaskState::Created, 1), + (BapTaskState::Admitted, 0), + (BapTaskState::Admitted, 2), + (BapTaskState::Running, 1), + (BapTaskState::Running, 3), + (BapTaskState::WaitingForApproval, 2), + (BapTaskState::WaitingForApproval, 4), + (BapTaskState::WaitingForExternalInput, 2), + (BapTaskState::WaitingForExternalInput, 4), + (BapTaskState::Checkpointed, 2), + (BapTaskState::Checkpointed, 4), + (BapTaskState::ReconciliationRequired, 2), + (BapTaskState::ReconciliationRequired, 4), + (BapTaskState::Succeeded, 2), + (BapTaskState::Succeeded, 4), + (BapTaskState::Failed, 0), + (BapTaskState::Cancelled, 0), + (BapTaskState::Expired, 0), + (BapTaskState::DeadLettered, 2), + ] { + assert_eq!( + BapTaskLifecycle::restore(state, sequence), + Err(BapTaskRestoreError::InvalidSnapshot { + state, + transition_sequence: sequence, + }), + "state={state:?}, sequence={sequence}", + ); + } +} + +#[test] +fn valid_restored_snapshot_classes_remain_accepted() { + for (state, sequence) in [ + (BapTaskState::Created, 0), + (BapTaskState::Admitted, 1), + (BapTaskState::Running, 2), + (BapTaskState::Running, 4), + (BapTaskState::WaitingForApproval, 3), + (BapTaskState::WaitingForExternalInput, 5), + (BapTaskState::Checkpointed, 7), + (BapTaskState::ReconciliationRequired, 3), + (BapTaskState::Succeeded, 3), + (BapTaskState::Failed, 1), + (BapTaskState::Cancelled, 2), + (BapTaskState::Expired, 4), + (BapTaskState::DeadLettered, 3), + (BapTaskState::DeadLettered, 4), + ] { + let task = BapTaskLifecycle::restore(state, sequence).expect("reachable snapshot"); + assert_eq!(task.state(), state); + assert_eq!(task.transition_sequence(), sequence); + } +} + +#[test] +fn exhausted_sequence_fails_closed_without_mutating_state() { + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, u64::MAX) + .expect("valid exhausted checkpoint snapshot"); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::SequenceExhausted), + ); + assert_eq!(task.state(), BapTaskState::Checkpointed); + assert_eq!(task.transition_sequence(), u64::MAX); +} + +#[test] +fn restored_terminal_lifecycle_remains_terminal() { + let mut task = + BapTaskLifecycle::restore(BapTaskState::Succeeded, 9).expect("valid terminal snapshot"); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::TerminalState { + state: BapTaskState::Succeeded, + }), + ); + assert_eq!(task.transition_sequence(), 9); +} + +#[test] +fn lifecycle_failures_use_the_standard_rust_error_contract() { + let mut created = BapTaskLifecycle::new(); + let invalid_transition = created + .apply(BapTaskEvent::Start) + .expect_err("created task must reject start"); + assert_eq!( + invalid_transition.to_string(), + "BAP task event Start is invalid from state Created" + ); + assert!(invalid_transition.source().is_none()); + + let exhausted = BapTaskTransitionError::SequenceExhausted; + assert_eq!( + exhausted.to_string(), + "BAP task transition sequence is exhausted" + ); + assert!(exhausted.source().is_none()); + + let terminal = BapTaskTransitionError::TerminalState { + state: BapTaskState::Cancelled, + }; + assert_eq!(terminal.to_string(), "BAP task state Cancelled is terminal"); + assert!(terminal.source().is_none()); + + let restore = BapTaskLifecycle::restore(BapTaskState::Created, 1) + .expect_err("unreachable snapshot must fail"); + assert_eq!( + restore.to_string(), + "BAP task snapshot state Created with transition sequence 1 is unreachable" + ); + assert!(restore.source().is_none()); +} diff --git a/crates/originweave-core/Cargo.toml b/crates/originweave-core/Cargo.toml index 517e41217..dcda2a6c4 100644 --- a/crates/originweave-core/Cargo.toml +++ b/crates/originweave-core/Cargo.toml @@ -14,6 +14,7 @@ publish = false path = "src/root.rs" [dependencies] +unicode-normalization = "=0.1.25" [lints] workspace = true diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index b6ed55ff2..e33a7e7e5 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -165,6 +165,9 @@ fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), } fn parse_port(port_text: &str) -> Result { + if port_text.is_empty() || !port_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(OriginError::InvalidPort); + } let port = port_text .parse::() .map_err(|_error| OriginError::InvalidPort)?; diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index b026d5e61..c7200e327 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -17,6 +17,9 @@ 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"; +/// The MCP discovery method accepted by the typed tools-list boundary. +pub const MCP_TOOLS_LIST_METHOD: &str = "tools/list"; + /// Maximum accepted MCP method-name length in bytes. pub const MAX_MCP_METHOD_NAME_BYTES: usize = 64; @@ -119,6 +122,233 @@ pub const fn supported_mcp_tools() -> &'static [McpToolCatalogEntry] { MCP_TOOL_CATALOG } +/// Protocol disposition carried by a typed MCP result. +/// +/// OriginWeave currently constructs only terminal results at this boundary. A transport adapter +/// must serialize [`Self::Complete`] as MCP's `"complete"` result type and must not omit or +/// reinterpret the required protocol field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpResultType { + /// The request completed and this value contains the final result. + Complete, +} + +/// Cache-sharing scope for an MCP cacheable list result. +/// +/// OriginWeave currently exposes only the conservative private scope. A transport adapter must +/// serialize this as MCP's `"private"` cache scope and must not widen it without a separately +/// reviewed policy that proves the returned catalog is safe to share across callers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpCacheScope { + /// The result may be cached only for the current caller's private context. + Private, +} + +/// One typed MCP `tools/list` page derived from the reviewed tool catalog. +/// +/// This value is discovery metadata only. It does not grant any tool capability or action +/// authority. The initial contract is deliberately one complete private page with zero freshness +/// so adapters cannot omit MCP's required result disposition or accidentally share or reuse +/// discovery metadata beyond the current request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct McpToolsListPage { + result_type: McpResultType, + tools: &'static [McpToolCatalogEntry], + ttl_ms: u64, + cache_scope: McpCacheScope, + next_cursor: Option<&'static str>, +} + +impl McpToolsListPage { + /// Return the mandatory MCP result disposition for this list page. + #[must_use] + pub const fn result_type(&self) -> McpResultType { + self.result_type + } + + /// Return the deterministic reviewed tool entries in this page. + #[must_use] + pub const fn tools(&self) -> &'static [McpToolCatalogEntry] { + self.tools + } + + /// Return the MCP freshness lifetime in milliseconds. + /// + /// The current conservative contract is zero, so clients must treat the result as + /// immediately stale rather than reusing it for a later request. + #[must_use] + pub const fn ttl_ms(&self) -> u64 { + self.ttl_ms + } + + /// Return the MCP cache-sharing scope for this page. + #[must_use] + pub const fn cache_scope(&self) -> McpCacheScope { + self.cache_scope + } + + /// Return the opaque continuation cursor when another page exists. + /// + /// The current fixed catalog is emitted as one complete page, so this is always `None`. + #[must_use] + pub const fn next_cursor(&self) -> Option<&'static str> { + self.next_cursor + } +} + +/// Build the conservative typed MCP `tools/list` result for the reviewed catalog. +/// +/// This function does not perform transport serialization, authorization, or pagination. It +/// binds the catalog to the mandatory complete result disposition plus explicit zero-TTL/private +/// cache hints so adapters cannot invent broader protocol or cache semantics independently from +/// this reviewed boundary. +#[must_use] +pub const fn mcp_tools_list_page() -> McpToolsListPage { + McpToolsListPage { + result_type: McpResultType::Complete, + tools: MCP_TOOL_CATALOG, + ttl_ms: 0, + cache_scope: McpCacheScope::Private, + next_cursor: None, + } +} + +/// A deterministic failure while validating one MCP `tools/list` request envelope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpToolsListBoundaryError { + /// The transport request omitted the required MCP protocol-version header. + MissingProtocolVersionHeader, + /// The structured request metadata omitted the required MCP protocol version. + MissingProtocolVersionMetadata, + /// The transport protocol version disagrees with the structured request metadata. + ProtocolVersionHeaderBodyMismatch, + /// The request names an MCP protocol generation this boundary does not support. + UnsupportedProtocolVersion, + /// The structured request metadata omitted the required client-capabilities object. + MissingClientCapabilities, + /// The request method violates the bounded ASCII MCP routing syntax. + InvalidMethod, + /// MCP routing method metadata disagrees with the method in the request body. + MethodHeaderBodyMismatch, + /// The request method is not the supported `tools/list` operation. + UnsupportedMethod, + /// The request supplied a cursor that this fixed single-page catalog never issued. + UnsupportedCursor, +} + +impl fmt::Display for McpToolsListBoundaryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingProtocolVersionHeader => { + formatter.write_str("MCP protocol version header is required") + } + Self::MissingProtocolVersionMetadata => { + formatter.write_str("MCP request metadata protocol version is required") + } + Self::ProtocolVersionHeaderBodyMismatch => { + formatter.write_str("MCP protocol version header does not match request metadata") + } + Self::UnsupportedProtocolVersion => { + formatter.write_str("unsupported MCP protocol version") + } + Self::MissingClientCapabilities => { + formatter.write_str("MCP request metadata client capabilities are required") + } + Self::InvalidMethod => { + formatter.write_str("MCP method violates the bounded ASCII routing syntax") + } + Self::MethodHeaderBodyMismatch => { + formatter.write_str("MCP method header does not match the request body") + } + Self::UnsupportedMethod => { + formatter.write_str("only MCP tools/list requests can enter the discovery boundary") + } + Self::UnsupportedCursor => { + formatter.write_str("MCP tools/list cursor was not issued by this fixed catalog") + } + } + } +} + +impl std::error::Error for McpToolsListBoundaryError {} + +/// An MCP `tools/list` request whose protocol, required metadata, and routing envelope were +/// validated. +/// +/// This boundary is deliberately narrower than a general transport or pagination implementation. +/// A trusted structured parser must prove whether the required per-request client-capabilities +/// object was present; this type never accepts its contents as authority. The current reviewed +/// catalog returns one complete page and emits no continuation cursor, so no non-null cursor can +/// be a value previously issued by OriginWeave. A transport adapter must not silently ignore or +/// reinterpret a supplied cursor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ValidatedMcpToolsListRequest { + method: &'static str, +} + +impl ValidatedMcpToolsListRequest { + /// Validate the stateless request envelope for the current fixed `tools/list` catalog. + /// + /// Both the required transport protocol-version header and structured request `_meta` + /// protocol version must be present, individually bounded to the exact supported-version + /// length before cross-field comparison, equal, and exactly [`MCP_PROTOCOL_VERSION`]. A + /// trusted structured parser must also attest that the required `_meta` client-capabilities + /// object was present; its contents grant no OriginWeave authority. Each untrusted method + /// value is shape-validated before comparison. The routing/body method must then agree exactly. + /// Any supplied cursor fails closed because [`mcp_tools_list_page`] emits no continuation + /// cursor; accepting one would silently invent pagination state that OriginWeave never issued. + pub fn new( + protocol_version_header: Option<&str>, + protocol_version_metadata: Option<&str>, + client_capabilities_present: bool, + routing_method: &str, + body_method: &str, + cursor: Option<&str>, + ) -> Result { + let protocol_version_header = protocol_version_header + .ok_or(McpToolsListBoundaryError::MissingProtocolVersionHeader)?; + let protocol_version_metadata = protocol_version_metadata + .ok_or(McpToolsListBoundaryError::MissingProtocolVersionMetadata)?; + + if protocol_version_header.len() > MCP_PROTOCOL_VERSION.len() + || protocol_version_metadata.len() > MCP_PROTOCOL_VERSION.len() + { + return Err(McpToolsListBoundaryError::UnsupportedProtocolVersion); + } + if protocol_version_header != protocol_version_metadata { + return Err(McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch); + } + if protocol_version_metadata != MCP_PROTOCOL_VERSION { + return Err(McpToolsListBoundaryError::UnsupportedProtocolVersion); + } + if !client_capabilities_present { + return Err(McpToolsListBoundaryError::MissingClientCapabilities); + } + if !valid_method(routing_method) || !valid_method(body_method) { + return Err(McpToolsListBoundaryError::InvalidMethod); + } + if routing_method != body_method { + return Err(McpToolsListBoundaryError::MethodHeaderBodyMismatch); + } + if routing_method != MCP_TOOLS_LIST_METHOD { + return Err(McpToolsListBoundaryError::UnsupportedMethod); + } + if cursor.is_some() { + return Err(McpToolsListBoundaryError::UnsupportedCursor); + } + + Ok(Self { + method: MCP_TOOLS_LIST_METHOD, + }) + } + + /// Return the canonical MCP method validated by this request. + #[must_use] + pub const fn method(&self) -> &'static str { + self.method + } +} + /// A deterministic failure while validating untrusted MCP routing metadata. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpToolBoundaryError { diff --git a/crates/originweave-core/src/release_acceptance.rs b/crates/originweave-core/src/release_acceptance.rs new file mode 100644 index 000000000..a3655de52 --- /dev/null +++ b/crates/originweave-core/src/release_acceptance.rs @@ -0,0 +1,368 @@ +//! Deterministic fail-closed release acceptance for commercial benchmark evidence. +//! +//! This module aggregates only explicit mandatory-suite outcomes and bounded, +//! buyer-visible limitations. It does not execute benchmarks, infer missing +//! evidence, authenticate artifacts, or grant release authority. + +use std::fmt; + +use unicode_normalization::is_nfc; + +/// Maximum UTF-8 byte length retained for either buyer-visible limitation field. +pub const MAX_RELEASE_LIMITATION_TEXT_BYTES: usize = 1024; + +/// Maximum number of buyer-visible limitations retained in one release report. +pub const MAX_DECLARED_RELEASE_LIMITATIONS: usize = 64; + +/// One mandatory benchmark suite in the release acceptance contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BenchmarkSuite { + /// Controlled local fixtures with deterministic post-condition oracles. + ControlledDeterministic, + /// Stable web compatibility tasks for the declared support profile. + WebCompatibility, + /// Hostile security cases that measure unauthorized authority or disclosure. + SecurityAdversarial, + /// Crash, timeout, retry, reconciliation, cleanup, and restore behavior. + ReliabilityRecovery, + /// Enterprise isolation, identity, policy, audit, and operator controls. + EnterpriseOperability, +} + +impl BenchmarkSuite { + /// Every mandatory benchmark suite in canonical release-report order. + pub const ALL: [Self; 5] = [ + Self::ControlledDeterministic, + Self::WebCompatibility, + Self::SecurityAdversarial, + Self::ReliabilityRecovery, + Self::EnterpriseOperability, + ]; + + /// Return the stable snake-case suite identifier used by benchmark evidence. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ControlledDeterministic => "controlled_deterministic_suite", + Self::WebCompatibility => "web_compatibility_suite", + Self::SecurityAdversarial => "security_adversarial_suite", + Self::ReliabilityRecovery => "reliability_recovery_suite", + Self::EnterpriseOperability => "enterprise_operability_suite", + } + } + + const fn index(self) -> usize { + match self { + Self::ControlledDeterministic => 0, + Self::WebCompatibility => 1, + Self::SecurityAdversarial => 2, + Self::ReliabilityRecovery => 3, + Self::EnterpriseOperability => 4, + } + } +} + +/// Evaluated outcome for one mandatory benchmark suite. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BenchmarkSuiteOutcome { + /// Every threshold required for the declared profile passed. + Passed, + /// At least one mandatory threshold is known to have failed. + Failed, + /// Evidence is insufficient to establish either pass or threshold failure. + Inconclusive, +} + +/// One explicit narrowed release claim and its buyer-visible consequence. +/// +/// An accepted-with-limitations decision cannot be produced from an opaque +/// boolean. Every limitation must name the unsupported claim and state the +/// consequence that a buyer must account for in the declared support profile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeclaredLimitation { + unsupported_claim: String, + buyer_consequence: String, +} + +impl DeclaredLimitation { + /// Construct one explicit buyer-visible release limitation. + /// + /// Empty/whitespace-only or punctuation-only values, surrounding whitespace, + /// non-NFC Unicode, fields exceeding the fixed UTF-8 byte budget, and ambiguous + /// presentation characters fail closed because they cannot safely represent one + /// canonical, resource-bounded buyer-visible release limitation. Accepted text + /// is retained byte-for-byte; this constructor never normalizes caller input + /// implicitly. + pub fn new( + unsupported_claim: impl Into, + buyer_consequence: impl Into, + ) -> Result { + Self::from_owned_text(unsupported_claim.into(), buyer_consequence.into()) + } + + fn from_owned_text( + unsupported_claim: String, + buyer_consequence: String, + ) -> Result { + if unsupported_claim.trim().is_empty() { + return Err(ReleaseDecisionError::EmptyLimitationClaim); + } + if unsupported_claim.trim() != unsupported_claim { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } + if unsupported_claim.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { + return Err(ReleaseDecisionError::LimitationClaimTooLong); + } + if !is_nfc(&unsupported_claim) { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } + if unsupported_claim + .chars() + .any(disallowed_release_limitation_character) + || !unsupported_claim.chars().any(char::is_alphanumeric) + { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } + if buyer_consequence.trim().is_empty() { + return Err(ReleaseDecisionError::EmptyLimitationConsequence); + } + if buyer_consequence.trim() != buyer_consequence { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } + if buyer_consequence.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { + return Err(ReleaseDecisionError::LimitationConsequenceTooLong); + } + if !is_nfc(&buyer_consequence) { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } + if buyer_consequence + .chars() + .any(disallowed_release_limitation_character) + || !buyer_consequence.chars().any(char::is_alphanumeric) + { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } + Ok(Self { + unsupported_claim, + buyer_consequence, + }) + } + + /// Return the exact unsupported or narrowed release claim. + #[must_use] + pub fn unsupported_claim(&self) -> &str { + &self.unsupported_claim + } + + /// Return the exact consequence exposed to buyers and operators. + #[must_use] + pub fn buyer_consequence(&self) -> &str { + &self.buyer_consequence + } +} + +fn disallowed_release_limitation_character(character: char) -> bool { + let code_point = character as u32; + character.is_control() + || matches!( + code_point, + 0x00ad + | 0x034f + | 0x061c + | 0x115f..=0x1160 + | 0x17b4..=0x17b5 + | 0x180b..=0x180f + | 0x200b..=0x200f + | 0x2028..=0x202e + | 0x2060..=0x206f + | 0x3164 + | 0xfe00..=0xfe0f + | 0xfeff + | 0xffa0 + | 0xfff0..=0xfff8 + | 0x1bca0..=0x1bca3 + | 0x1d173..=0x1d17a + | 0xe0000..=0xe0fff + ) +} + +/// Deterministic release decision produced from mandatory suite evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseDecision { + /// Every mandatory suite passed for the full declared support profile. + Accepted, + /// Every mandatory suite passed after buyer-visible limitations were declared. + AcceptedWithDeclaredLimitations, + /// At least one mandatory suite is known to have failed its threshold. + Rejected, + /// No known threshold failure exists, but mandatory evidence is incomplete. + Inconclusive, +} + +/// Fail-closed input error while constructing a release decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseDecisionError { + /// A declared limitation did not identify the unsupported release claim. + EmptyLimitationClaim, + /// A declared limitation claim exceeded the fixed UTF-8 byte budget. + LimitationClaimTooLong, + /// A declared limitation claim was not canonical NFC text or was presentation-unsafe. + InvalidLimitationClaim, + /// A declared limitation did not state the buyer-visible consequence. + EmptyLimitationConsequence, + /// A declared limitation consequence exceeded the fixed UTF-8 byte budget. + LimitationConsequenceTooLong, + /// A limitation consequence was not canonical NFC text or was presentation-unsafe. + InvalidLimitationConsequence, + /// One release report supplied more buyer-visible limitations than the fixed resource budget. + TooManyDeclaredLimitations, + /// More than one limitation used the same unsupported claim identity. + DuplicateLimitationClaim, + /// The same suite appeared more than once instead of one authoritative result. + DuplicateSuite(BenchmarkSuite), +} + +impl fmt::Display for ReleaseDecisionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyLimitationClaim => { + formatter.write_str("declared release limitation must name an unsupported claim") + } + Self::LimitationClaimTooLong => { + formatter.write_str("declared release limitation claim exceeds the byte budget") + } + Self::InvalidLimitationClaim => formatter.write_str( + "declared release limitation claim is not canonical or contains an unsafe presentation character", + ), + Self::EmptyLimitationConsequence => formatter + .write_str("declared release limitation must state a buyer-visible consequence"), + Self::LimitationConsequenceTooLong => formatter + .write_str("declared release limitation consequence exceeds the byte budget"), + Self::InvalidLimitationConsequence => formatter.write_str( + "declared release limitation consequence is not canonical or contains an unsafe presentation character", + ), + Self::TooManyDeclaredLimitations => formatter + .write_str("benchmark release decision contains too many declared limitations"), + Self::DuplicateLimitationClaim => formatter + .write_str("benchmark release decision contains duplicate limitation claim"), + Self::DuplicateSuite(suite) => write!( + formatter, + "benchmark release evidence contains duplicate suite: {}", + suite.as_str() + ), + } + } +} + +impl std::error::Error for ReleaseDecisionError {} + +/// Release decision together with exact mandatory-suite evidence gaps and failures. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseDecisionReport { + decision: ReleaseDecision, + failed_suites: Vec, + inconclusive_suites: Vec, + missing_suites: Vec, + declared_limitations: Vec, +} + +impl ReleaseDecisionReport { + /// Return the deterministic release decision. + #[must_use] + pub const fn decision(&self) -> ReleaseDecision { + self.decision + } + + /// Return suites with a known mandatory-threshold failure. + #[must_use] + pub fn failed_suites(&self) -> &[BenchmarkSuite] { + &self.failed_suites + } + + /// Return suites whose supplied evidence was explicitly inconclusive. + #[must_use] + pub fn inconclusive_suites(&self) -> &[BenchmarkSuite] { + &self.inconclusive_suites + } + + /// Return mandatory suites for which no outcome was supplied. + #[must_use] + pub fn missing_suites(&self) -> &[BenchmarkSuite] { + &self.missing_suites + } + + /// Return the exact buyer-visible limitations retained with this decision. + #[must_use] + pub fn declared_limitations(&self) -> &[DeclaredLimitation] { + &self.declared_limitations + } +} + +/// Produce one deterministic release decision from mandatory suite outcomes. +/// +/// Duplicate suite evidence, duplicate buyer-visible limitation claim identities, +/// and excessive declared-limitation cardinality fail closed rather than selecting +/// or retaining ambiguous or attacker-controlled release metadata. A known +/// mandatory-threshold failure is always rejected, even when other suites are +/// missing or inconclusive; all such evidence gaps remain in the returned report. +/// Without a known failure, missing or inconclusive evidence is never promoted to +/// acceptance. Accepted-with-limitations requires at least one validated +/// [`DeclaredLimitation`], so the decision cannot be detached from the exact +/// narrowed claim and buyer-visible consequence. +pub fn decide_release( + results: I, + declared_limitations: &[DeclaredLimitation], +) -> Result +where + I: IntoIterator, +{ + if declared_limitations.len() > MAX_DECLARED_RELEASE_LIMITATIONS { + return Err(ReleaseDecisionError::TooManyDeclaredLimitations); + } + + let mut limitation_claims = std::collections::BTreeSet::new(); + for limitation in declared_limitations { + if !limitation_claims.insert(limitation.unsupported_claim()) { + return Err(ReleaseDecisionError::DuplicateLimitationClaim); + } + } + + let mut outcomes = [None; BenchmarkSuite::ALL.len()]; + for (suite, outcome) in results { + let slot = &mut outcomes[suite.index()]; + if slot.is_some() { + return Err(ReleaseDecisionError::DuplicateSuite(suite)); + } + *slot = Some(outcome); + } + + let mut failed_suites = Vec::new(); + let mut inconclusive_suites = Vec::new(); + let mut missing_suites = Vec::new(); + for suite in BenchmarkSuite::ALL { + match outcomes[suite.index()] { + Some(BenchmarkSuiteOutcome::Passed) => {} + Some(BenchmarkSuiteOutcome::Failed) => failed_suites.push(suite), + Some(BenchmarkSuiteOutcome::Inconclusive) => inconclusive_suites.push(suite), + None => missing_suites.push(suite), + } + } + + let decision = if !failed_suites.is_empty() { + ReleaseDecision::Rejected + } else if !inconclusive_suites.is_empty() || !missing_suites.is_empty() { + ReleaseDecision::Inconclusive + } else if declared_limitations.is_empty() { + ReleaseDecision::Accepted + } else { + ReleaseDecision::AcceptedWithDeclaredLimitations + }; + + Ok(ReleaseDecisionReport { + decision, + failed_suites, + inconclusive_suites, + missing_suites, + declared_limitations: declared_limitations.to_vec(), + }) +} diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs index 7acced460..c47a136d4 100644 --- a/crates/originweave-core/src/root.rs +++ b/crates/originweave-core/src/root.rs @@ -13,3 +13,5 @@ pub use contracts::*; /// Stateless MCP routing validation that maps only explicit tools to typed actions. pub mod mcp; +/// Deterministic fail-closed release benchmark acceptance aggregation. +pub mod release_acceptance; diff --git a/crates/originweave-core/tests/mcp_tools_list_cache.rs b/crates/originweave-core/tests/mcp_tools_list_cache.rs new file mode 100644 index 000000000..9d3681673 --- /dev/null +++ b/crates/originweave-core/tests/mcp_tools_list_cache.rs @@ -0,0 +1,221 @@ +use std::error::Error; + +use originweave_core::mcp::{ + MAX_MCP_METHOD_NAME_BYTES, MCP_PROTOCOL_VERSION, MCP_TOOLS_LIST_METHOD, McpCacheScope, + McpResultType, McpToolsListBoundaryError, ValidatedMcpToolsListRequest, mcp_tools_list_page, + supported_mcp_tools, +}; + +#[test] +fn mcp_tools_list_page_is_complete_private_and_immediately_stale() { + let page = mcp_tools_list_page(); + + assert_eq!(page.result_type(), McpResultType::Complete); + assert_eq!(page.tools(), supported_mcp_tools()); + assert_eq!(page.ttl_ms(), 0); + assert_eq!(page.cache_scope(), McpCacheScope::Private); + assert_eq!(page.next_cursor(), None); +} + +fn valid_tools_list_request( + cursor: Option<&str>, +) -> Result { + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + cursor, + ) +} + +#[test] +fn mcp_tools_list_request_requires_complete_request_metadata() { + assert_eq!( + valid_tools_list_request(None).map(|validated| validated.method()), + Ok(MCP_TOOLS_LIST_METHOD) + ); + + assert_eq!( + ValidatedMcpToolsListRequest::new( + None, + Some(MCP_PROTOCOL_VERSION), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingProtocolVersionHeader) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + None, + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingProtocolVersionMetadata) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some("2025-11-25"), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some("2025-11-25"), + Some("2025-11-25"), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::UnsupportedProtocolVersion) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + false, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingClientCapabilities) + ); +} + +#[test] +fn mcp_tools_list_bounds_protocol_metadata_before_cross_field_comparison() { + let oversized_protocol_version = format!("{MCP_PROTOCOL_VERSION}0"); + + for (header, metadata) in [ + (oversized_protocol_version.as_str(), MCP_PROTOCOL_VERSION), + (MCP_PROTOCOL_VERSION, oversized_protocol_version.as_str()), + ] { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(header), + Some(metadata), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::UnsupportedProtocolVersion) + ); + } +} + +#[test] +fn mcp_tools_list_validates_each_method_before_cross_field_comparison() { + let oversized_method = "a".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + + for (routing_method, body_method) in [ + ("tools list", MCP_TOOLS_LIST_METHOD), + (MCP_TOOLS_LIST_METHOD, "tools list"), + (oversized_method.as_str(), MCP_TOOLS_LIST_METHOD), + (MCP_TOOLS_LIST_METHOD, oversized_method.as_str()), + ] { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + routing_method, + body_method, + None, + ), + Err(McpToolsListBoundaryError::InvalidMethod) + ); + } +} + +#[test] +fn mcp_tools_list_request_requires_exact_routing_and_no_unissued_cursor() { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + MCP_TOOLS_LIST_METHOD, + "tools/call", + None, + ), + Err(McpToolsListBoundaryError::MethodHeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + "resources/list", + "resources/list", + None, + ), + Err(McpToolsListBoundaryError::UnsupportedMethod) + ); + + for cursor in ["cursor-1", ""] { + assert_eq!( + valid_tools_list_request(Some(cursor)), + Err(McpToolsListBoundaryError::UnsupportedCursor) + ); + } +} + +#[test] +fn mcp_tools_list_request_errors_are_source_free_and_non_echoing() { + let cases = [ + ( + McpToolsListBoundaryError::MissingProtocolVersionHeader, + "MCP protocol version header is required", + ), + ( + McpToolsListBoundaryError::MissingProtocolVersionMetadata, + "MCP request metadata protocol version is required", + ), + ( + McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch, + "MCP protocol version header does not match request metadata", + ), + ( + McpToolsListBoundaryError::UnsupportedProtocolVersion, + "unsupported MCP protocol version", + ), + ( + McpToolsListBoundaryError::MissingClientCapabilities, + "MCP request metadata client capabilities are required", + ), + ( + McpToolsListBoundaryError::InvalidMethod, + "MCP method violates the bounded ASCII routing syntax", + ), + ( + McpToolsListBoundaryError::MethodHeaderBodyMismatch, + "MCP method header does not match the request body", + ), + ( + McpToolsListBoundaryError::UnsupportedMethod, + "only MCP tools/list requests can enter the discovery boundary", + ), + ( + McpToolsListBoundaryError::UnsupportedCursor, + "MCP tools/list cursor was not issued by this fixed catalog", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/origin_port_syntax.rs b/crates/originweave-core/tests/origin_port_syntax.rs new file mode 100644 index 000000000..ce58e523e --- /dev/null +++ b/crates/originweave-core/tests/origin_port_syntax.rs @@ -0,0 +1,18 @@ +use originweave_core::{Origin, OriginError}; + +#[test] +fn origin_rejects_non_digit_port_prefixes() { + for input in [ + "https://example.com:+443", + "https://example.com:+8443", + "http://localhost:+80", + "http://127.0.0.1:+8080", + "https://[2001:db8::1]:+443", + ] { + assert_eq!( + Origin::parse(input), + Err(OriginError::InvalidPort), + "input={input}" + ); + } +} diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs new file mode 100644 index 000000000..3e37fab18 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -0,0 +1,397 @@ +use originweave_core::release_acceptance::{ + BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, MAX_DECLARED_RELEASE_LIMITATIONS, + ReleaseDecision, ReleaseDecisionError, decide_release, +}; + +fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { + BenchmarkSuite::ALL + .into_iter() + .map(|suite| (suite, BenchmarkSuiteOutcome::Passed)) + .collect() +} + +fn declared_limitation() -> Result { + DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is not included in the declared release support profile.", + ) +} + +#[test] +fn generic_constructor_input_shapes_cover_success_paths_in_this_test_crate() { + assert!( + DeclaredLimitation::new(String::from("linux_arm64"), "Linux ARM64 is unsupported.").is_ok() + ); + assert!( + DeclaredLimitation::new("linux_arm64", String::from("Linux ARM64 is unsupported.")).is_ok() + ); +} + +#[test] +fn complete_passing_evidence_is_accepted_without_declared_limitations() +-> Result<(), ReleaseDecisionError> { + let report = decide_release(passing_results(), &[])?; + + assert_eq!(report.decision(), ReleaseDecision::Accepted); + assert!(report.failed_suites().is_empty()); + assert!(report.inconclusive_suites().is_empty()); + assert!(report.missing_suites().is_empty()); + assert!(report.declared_limitations().is_empty()); + Ok(()) +} + +#[test] +fn complete_passing_evidence_preserves_declared_limitation_details() +-> Result<(), ReleaseDecisionError> { + let limitation = declared_limitation()?; + let report = decide_release(passing_results(), std::slice::from_ref(&limitation))?; + + assert_eq!( + report.decision(), + ReleaseDecision::AcceptedWithDeclaredLimitations + ); + assert_eq!(report.declared_limitations(), &[limitation]); + Ok(()) +} + +#[test] +fn limitation_requires_an_unsupported_claim() { + assert_eq!( + DeclaredLimitation::new( + " ", + "A buyer-visible consequence must not stand without the narrowed claim.", + ), + Err(ReleaseDecisionError::EmptyLimitationClaim) + ); +} + +#[test] +fn limitation_requires_a_buyer_visible_consequence() { + assert_eq!( + DeclaredLimitation::new("linux_arm64", "\t\n"), + Err(ReleaseDecisionError::EmptyLimitationConsequence) + ); +} + +#[test] +fn limitation_rejects_control_characters_in_release_metadata() { + assert_eq!( + DeclaredLimitation::new( + "linux_arm64\nforged_release_claim", + "Linux ARM64 is unsupported." + ), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is unsupported.\rforged_release_consequence" + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + +#[test] +fn limitation_rejects_ambiguous_unicode_formatting_characters() { + for character in [ + '\u{00ad}', '\u{061c}', '\u{180e}', '\u{200b}', '\u{200f}', '\u{2028}', '\u{202e}', + '\u{2060}', '\u{2066}', '\u{206f}', '\u{feff}', + ] { + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{character}forged_release_claim"), + "Linux ARM64 is unsupported." + ), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{character}forged_release_consequence") + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); + } +} + +#[test] +fn limitation_preserves_unambiguous_international_buyer_text() -> Result<(), ReleaseDecisionError> { + let limitation = DeclaredLimitation::new( + "한국어_운영환경", + "이 운영환경은 현재 지원 범위에 포함되지 않습니다.", + )?; + + assert_eq!(limitation.unsupported_claim(), "한국어_운영환경"); + assert_eq!( + limitation.buyer_consequence(), + "이 운영환경은 현재 지원 범위에 포함되지 않습니다." + ); + Ok(()) +} + +#[test] +fn limitation_errors_have_deterministic_standard_error_contracts() { + let cases = [ + ( + ReleaseDecisionError::EmptyLimitationClaim, + "declared release limitation must name an unsupported claim", + ), + ( + ReleaseDecisionError::InvalidLimitationClaim, + "declared release limitation claim is not canonical or contains an unsafe presentation character", + ), + ( + ReleaseDecisionError::EmptyLimitationConsequence, + "declared release limitation must state a buyer-visible consequence", + ), + ( + ReleaseDecisionError::InvalidLimitationConsequence, + "declared release limitation consequence is not canonical or contains an unsafe presentation character", + ), + ( + ReleaseDecisionError::DuplicateLimitationClaim, + "benchmark release decision contains duplicate limitation claim", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + let standard_error: &dyn std::error::Error = &error; + assert!(standard_error.source().is_none()); + } +} + +#[test] +fn limitation_exposes_the_exact_narrowed_claim_and_consequence() -> Result<(), ReleaseDecisionError> +{ + let limitation = declared_limitation()?; + + assert_eq!(limitation.unsupported_claim(), "linux_arm64"); + assert_eq!( + limitation.buyer_consequence(), + "Linux ARM64 is not included in the declared release support profile." + ); + Ok(()) +} + +#[test] +fn every_mandatory_suite_is_required_for_acceptance() -> Result<(), ReleaseDecisionError> { + for omitted_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .filter(|(suite, _)| *suite != omitted_suite) + .collect::>(); + + let report = decide_release(evidence, &[])?; + + assert_eq!(report.decision(), ReleaseDecision::Inconclusive); + assert_eq!(report.missing_suites(), &[omitted_suite]); + assert!(report.failed_suites().is_empty()); + } + Ok(()) +} + +#[test] +fn explicit_inconclusive_suite_evidence_cannot_be_promoted_to_acceptance() +-> Result<(), ReleaseDecisionError> { + for inconclusive_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .map(|(suite, outcome)| { + if suite == inconclusive_suite { + (suite, BenchmarkSuiteOutcome::Inconclusive) + } else { + (suite, outcome) + } + }) + .collect::>(); + let limitation = declared_limitation()?; + + let report = decide_release(evidence, std::slice::from_ref(&limitation))?; + + assert_eq!(report.decision(), ReleaseDecision::Inconclusive); + assert_eq!(report.inconclusive_suites(), &[inconclusive_suite]); + assert_eq!(report.declared_limitations(), &[limitation]); + } + Ok(()) +} + +#[test] +fn any_known_threshold_failure_rejects_release_and_identifies_the_suite() +-> Result<(), ReleaseDecisionError> { + for failed_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .map(|(suite, outcome)| { + if suite == failed_suite { + (suite, BenchmarkSuiteOutcome::Failed) + } else { + (suite, outcome) + } + }) + .collect::>(); + let limitation = declared_limitation()?; + + let report = decide_release(evidence, std::slice::from_ref(&limitation))?; + + assert_eq!(report.decision(), ReleaseDecision::Rejected); + assert_eq!(report.failed_suites(), &[failed_suite]); + assert_eq!(report.declared_limitations(), &[limitation]); + } + Ok(()) +} + +#[test] +fn known_failure_remains_rejected_when_other_evidence_is_incomplete() +-> Result<(), ReleaseDecisionError> { + let report = decide_release( + vec![ + ( + BenchmarkSuite::ControlledDeterministic, + BenchmarkSuiteOutcome::Failed, + ), + ( + BenchmarkSuite::WebCompatibility, + BenchmarkSuiteOutcome::Inconclusive, + ), + ], + &[], + )?; + + assert_eq!(report.decision(), ReleaseDecision::Rejected); + assert_eq!( + report.failed_suites(), + &[BenchmarkSuite::ControlledDeterministic] + ); + assert_eq!( + report.inconclusive_suites(), + &[BenchmarkSuite::WebCompatibility] + ); + assert_eq!( + report.missing_suites(), + &[ + BenchmarkSuite::SecurityAdversarial, + BenchmarkSuite::ReliabilityRecovery, + BenchmarkSuite::EnterpriseOperability, + ] + ); + Ok(()) +} + +#[test] +fn duplicate_suite_evidence_fails_closed_instead_of_overwriting_results() { + for duplicate_suite in BenchmarkSuite::ALL { + let expected_error = ReleaseDecisionError::DuplicateSuite(duplicate_suite); + assert_eq!( + decide_release( + vec![ + (duplicate_suite, BenchmarkSuiteOutcome::Passed), + (duplicate_suite, BenchmarkSuiteOutcome::Failed), + ], + &[], + ), + Err(expected_error) + ); + + assert_eq!( + expected_error.to_string(), + format!( + "benchmark release evidence contains duplicate suite: {}", + duplicate_suite.as_str() + ) + ); + let standard_error: &dyn std::error::Error = &expected_error; + assert!(standard_error.source().is_none()); + } +} + +#[test] +fn duplicate_suite_evidence_in_vector_input_also_fails_closed() { + let duplicate_suite = BenchmarkSuite::ControlledDeterministic; + let mut evidence = passing_results(); + evidence.push((duplicate_suite, BenchmarkSuiteOutcome::Failed)); + + assert_eq!( + decide_release(evidence, &[]), + Err(ReleaseDecisionError::DuplicateSuite(duplicate_suite)) + ); +} + +#[test] +fn decision_is_independent_of_evidence_input_order() { + let mut reversed = passing_results(); + reversed.reverse(); + + assert_eq!( + decide_release(reversed, &[]), + decide_release(passing_results(), &[]) + ); +} + +#[test] +fn conflicting_consequences_for_one_limitation_claim_fail_closed() +-> Result<(), ReleaseDecisionError> { + let first = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is excluded from the support profile.", + )?; + let conflicting = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is supported only for evaluation deployments.", + )?; + + assert_eq!( + decide_release(passing_results(), &[first, conflicting]), + Err(ReleaseDecisionError::DuplicateLimitationClaim) + ); + Ok(()) +} + +#[test] +fn duplicate_limitation_claim_fails_closed_even_when_consequence_matches() +-> Result<(), ReleaseDecisionError> { + let limitation = declared_limitation()?; + + assert_eq!( + decide_release(passing_results(), &[limitation.clone(), limitation],), + Err(ReleaseDecisionError::DuplicateLimitationClaim) + ); + Ok(()) +} + +#[test] +fn release_report_bounds_declared_limitation_count_before_cloning() +-> Result<(), ReleaseDecisionError> { + let maximum = (0..MAX_DECLARED_RELEASE_LIMITATIONS) + .map(|index| { + DeclaredLimitation::new( + format!("unsupported_profile_{index}"), + "This profile is excluded from the declared support profile.", + ) + }) + .collect::, _>>()?; + let report = decide_release(passing_results(), &maximum)?; + + assert_eq!( + report.decision(), + ReleaseDecision::AcceptedWithDeclaredLimitations + ); + assert_eq!( + report.declared_limitations().len(), + MAX_DECLARED_RELEASE_LIMITATIONS + ); + + let too_many = (0..=MAX_DECLARED_RELEASE_LIMITATIONS) + .map(|index| { + DeclaredLimitation::new( + format!("unsupported_profile_{index}"), + "This profile is excluded from the declared support profile.", + ) + }) + .collect::, _>>()?; + assert_eq!( + decide_release(passing_results(), &too_many), + Err(ReleaseDecisionError::TooManyDeclaredLimitations) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/release_acceptance_canonical_text.rs b/crates/originweave-core/tests/release_acceptance_canonical_text.rs new file mode 100644 index 000000000..2d7840af3 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_canonical_text.rs @@ -0,0 +1,116 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +#[test] +fn limitation_accepts_canonical_boundary_text() { + let limitation = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ); + + assert_eq!( + limitation + .as_ref() + .map(|value| (value.unsupported_claim(), value.buyer_consequence())), + Ok(( + "linux_arm64", + "Linux ARM64 is excluded from the support profile." + )) + ); +} + +#[test] +fn limitation_rejects_empty_fields_for_the_canonical_string_input_shape() { + assert_eq!( + DeclaredLimitation::new("", "Linux ARM64 is excluded from the support profile."), + Err(ReleaseDecisionError::EmptyLimitationClaim), + ); + assert_eq!( + DeclaredLimitation::new("linux_arm64", ""), + Err(ReleaseDecisionError::EmptyLimitationConsequence), + ); +} + +#[test] +fn limitation_rejects_surrounding_whitespace_that_changes_claim_identity() { + for unsupported_claim in [" linux_arm64", "linux_arm64 ", "\tlinux_arm64"] { + assert_eq!( + DeclaredLimitation::new( + unsupported_claim, + "Linux ARM64 is excluded from the support profile.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "surrounding whitespace must not create a second spelling for one claim identity: {unsupported_claim:?}", + ); + } +} + +#[test] +fn limitation_rejects_surrounding_whitespace_in_buyer_consequence() { + for buyer_consequence in [ + " Linux ARM64 is excluded from the support profile.", + "Linux ARM64 is excluded from the support profile. ", + "Linux ARM64 is excluded from the support profile.\t", + ] { + assert_eq!( + DeclaredLimitation::new("linux_arm64", buyer_consequence), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "buyer-visible consequence must have one canonical boundary spelling: {buyer_consequence:?}", + ); + } +} + +#[test] +fn limitation_rejects_non_nfc_claim_identity() { + let nfc_claim = "caf\u{e9}"; + let canonically_equivalent_nfd_claim = "cafe\u{301}"; + + assert!( + DeclaredLimitation::new( + nfc_claim, + "This normalized claim remains a supported buyer-visible spelling.", + ) + .is_ok(), + "NFC international text must remain admissible", + ); + assert_eq!( + DeclaredLimitation::new( + canonically_equivalent_nfd_claim, + "This decomposed spelling must not create a second claim identity.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "canonically equivalent NFD text must not bypass limitation identity", + ); +} + +#[test] +fn limitation_rejects_non_nfc_buyer_consequence() { + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + "Cafe\u{301} support is excluded from this profile.", + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "buyer-visible consequences must use one canonical Unicode spelling", + ); +} + +#[test] +fn invalid_canonical_text_errors_describe_all_rejected_causes() { + let claim_result = DeclaredLimitation::new( + " linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ); + assert_eq!( + claim_result.as_ref().map_err(ToString::to_string), + Err("declared release limitation claim is not canonical or contains an unsafe presentation character".to_owned()) + ); + + let consequence_result = DeclaredLimitation::new( + "linux_arm64", + "Cafe\u{301} support is excluded from this profile.", + ); + assert_eq!( + consequence_result.as_ref().map_err(ToString::to_string), + Err("declared release limitation consequence is not canonical or contains an unsafe presentation character".to_owned()) + ); +} diff --git a/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs b/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs new file mode 100644 index 000000000..0dfb20ba3 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs @@ -0,0 +1,46 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +#[test] +fn punctuation_only_limitation_claim_does_not_name_an_unsupported_claim() { + assert_eq!( + DeclaredLimitation::new("---", "Linux ARM64 is excluded from the support profile."), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); +} + +#[test] +fn punctuation_only_limitation_consequence_does_not_state_a_buyer_consequence() { + assert_eq!( + DeclaredLimitation::new("linux_arm64", "..."), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + +#[test] +fn meaningful_text_may_begin_with_allowed_punctuation() { + assert!( + DeclaredLimitation::new( + "-linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ) + .is_ok() + ); + assert!( + DeclaredLimitation::new( + "linux_arm64", + "... Linux ARM64 remains outside the support profile.", + ) + .is_ok() + ); +} + +#[test] +fn international_alphanumeric_limitation_text_remains_admissible() { + assert!( + DeclaredLimitation::new( + "한국어_운영환경", + "이 운영환경은 현재 지원 범위에 포함되지 않습니다.", + ) + .is_ok() + ); +} diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs new file mode 100644 index 000000000..fd45e0e6d --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs @@ -0,0 +1,98 @@ +use originweave_core::release_acceptance::{ + DeclaredLimitation, MAX_RELEASE_LIMITATION_TEXT_BYTES, ReleaseDecisionError, +}; + +#[test] +fn limitation_metadata_enforces_exact_utf8_byte_budget() -> Result<(), ReleaseDecisionError> { + let maximum_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); + let maximum_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); + let limitation = DeclaredLimitation::new(maximum_claim.as_str(), maximum_consequence.as_str())?; + + assert_eq!(limitation.unsupported_claim(), maximum_claim.as_str()); + assert_eq!(limitation.buyer_consequence(), maximum_consequence.as_str()); + + let oversized_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1); + assert_eq!( + DeclaredLimitation::new(oversized_claim.as_str(), "bounded buyer consequence"), + Err(ReleaseDecisionError::LimitationClaimTooLong) + ); + + let oversized_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1); + assert_eq!( + DeclaredLimitation::new("bounded_claim", oversized_consequence.as_str()), + Err(ReleaseDecisionError::LimitationConsequenceTooLong) + ); + Ok(()) +} + +#[test] +fn borrowed_limitation_text_covers_every_validation_exit() { + assert_eq!( + DeclaredLimitation::new("", "bounded buyer consequence"), + Err(ReleaseDecisionError::EmptyLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new(" bounded_claim", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("cafe\u{301}", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", ""), + Err(ReleaseDecisionError::EmptyLimitationConsequence) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "bounded buyer consequence "), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "cafe\u{301} buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); + assert_eq!( + DeclaredLimitation::new("forged\nclaim", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "forged\nconsequence"), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + +#[test] +fn limitation_byte_budget_applies_to_international_text() { + let korean_character = "가"; + let repeated = + korean_character.repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES / korean_character.len() + 1); + assert!(repeated.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES); + assert_eq!( + DeclaredLimitation::new(repeated.as_str(), "지원 범위를 설명하는 구매자 안내"), + Err(ReleaseDecisionError::LimitationClaimTooLong) + ); +} + +#[test] +fn release_resource_limit_errors_have_deterministic_standard_error_contracts() { + let cases = [ + ( + ReleaseDecisionError::LimitationClaimTooLong, + "declared release limitation claim exceeds the byte budget", + ), + ( + ReleaseDecisionError::LimitationConsequenceTooLong, + "declared release limitation consequence exceeds the byte budget", + ), + ( + ReleaseDecisionError::TooManyDeclaredLimitations, + "benchmark release decision contains too many declared limitations", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + let standard_error: &dyn std::error::Error = &error; + assert!(standard_error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/release_acceptance_unicode17.rs b/crates/originweave-core/tests/release_acceptance_unicode17.rs new file mode 100644 index 000000000..eccd90e89 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_unicode17.rs @@ -0,0 +1,121 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +const UNICODE_17_DEFAULT_IGNORABLE_CODE_POINT_COUNT: usize = 4_174; + +#[test] +fn generic_constructor_input_shapes_cover_fail_closed_empty_boundaries() { + assert_eq!( + DeclaredLimitation::new(String::new(), "Linux ARM64 is unsupported."), + Err(ReleaseDecisionError::EmptyLimitationClaim), + ); + assert!( + DeclaredLimitation::new(String::from("linux_arm64"), "Linux ARM64 is unsupported.").is_ok() + ); + assert_eq!( + DeclaredLimitation::new("linux_arm64", String::new()), + Err(ReleaseDecisionError::EmptyLimitationConsequence), + ); + assert!( + DeclaredLimitation::new("linux_arm64", String::from("Linux ARM64 is unsupported.")).is_ok() + ); +} + +#[test] +fn limitation_rejects_unicode_17_default_ignorable_code_points() -> Result<(), &'static str> { + // Unicode 17.0.0 DerivedCoreProperties.txt (2025-07-30), + // Default_Ignorable_Code_Point. The reviewed ranges contain exactly 4,174 code points. + let ranges = [ + (0x00ad_u32, 0x00ad_u32), + (0x034f, 0x034f), + (0x061c, 0x061c), + (0x115f, 0x1160), + (0x17b4, 0x17b5), + (0x180b, 0x180f), + (0x200b, 0x200f), + (0x202a, 0x202e), + (0x2060, 0x206f), + (0x3164, 0x3164), + (0xfe00, 0xfe0f), + (0xfeff, 0xfeff), + (0xffa0, 0xffa0), + (0xfff0, 0xfff8), + (0x1bca0, 0x1bca3), + (0x1d173, 0x1d17a), + (0xe0000, 0xe0fff), + ]; + let mut tested_code_points = 0_usize; + + for (start, end) in ranges { + for code_point in start..=end { + let character = char::from_u32(code_point) + .ok_or("reviewed Unicode 17 default-ignorable range must contain scalar values")?; + tested_code_points += 1; + + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{character}forged_release_claim"), + "Linux ARM64 is unsupported.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "U+{code_point:04X} must be rejected in the unsupported claim", + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{character}forged_release_consequence"), + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "U+{code_point:04X} must be rejected in the buyer consequence", + ); + } + } + + assert_eq!( + tested_code_points, UNICODE_17_DEFAULT_IGNORABLE_CODE_POINT_COUNT, + "reviewed Unicode 17 Default_Ignorable_Code_Point ranges must match the authoritative cardinality", + ); + Ok(()) +} + +#[test] +fn limitation_rejects_line_and_paragraph_separators_beyond_default_ignorable_set() { + for (name, separator) in [("U+2028", '\u{2028}'), ("U+2029", '\u{2029}')] { + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{separator}forged_release_claim"), + "Linux ARM64 is unsupported.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "{name} must be rejected in the unsupported claim to prevent line-forging ambiguity", + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{separator}forged_release_consequence"), + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "{name} must be rejected in the buyer consequence to prevent line-forging ambiguity", + ); + } +} + +#[test] +fn limitation_does_not_blanket_reject_unicode_17_whitespace() -> Result<(), ReleaseDecisionError> { + let medium_mathematical_space = '\u{205f}'; + let ideographic_space = '\u{3000}'; + + let limitation = DeclaredLimitation::new( + format!("east{ideographic_space}asia"), + format!("Support is limited{medium_mathematical_space}to the declared profile."), + )?; + + assert_eq!( + limitation.unsupported_claim(), + format!("east{ideographic_space}asia") + ); + assert_eq!( + limitation.buyer_consequence(), + format!("Support is limited{medium_mathematical_space}to the declared profile.") + ); + Ok(()) +} diff --git a/crates/originweave-destination/src/lib.rs b/crates/originweave-destination/src/lib.rs index 5fdf2d363..774ba9ee9 100644 --- a/crates/originweave-destination/src/lib.rs +++ b/crates/originweave-destination/src/lib.rs @@ -24,6 +24,7 @@ pub use redirect::{ RedirectTargetDigestError, }; pub use resolution::{ - ConnectionEvidence, DestinationError, DestinationPolicy, MAX_RESOLUTION_ADDRESS_COUNT, + ConnectionEvidence, DestinationError, DestinationPolicy, FreshConnectionEvidence, + FreshResolutionSnapshot, MAX_RESOLUTION_ADDRESS_COUNT, MAX_RESOLUTION_VALIDITY, ResolutionSnapshot, }; diff --git a/crates/originweave-destination/src/proxy.rs b/crates/originweave-destination/src/proxy.rs index ef64289fa..4695dc3aa 100644 --- a/crates/originweave-destination/src/proxy.rs +++ b/crates/originweave-destination/src/proxy.rs @@ -446,6 +446,9 @@ fn explicit_port(authority: &str) -> Result, ProxyServerError> { port }; + if !port_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(ProxyServerError::InvalidIdentifier); + } let port = port_text .parse::() .map_err(|_error| ProxyServerError::InvalidIdentifier)?; diff --git a/crates/originweave-destination/src/resolution.rs b/crates/originweave-destination/src/resolution.rs index f55d1722b..45620e6cd 100644 --- a/crates/originweave-destination/src/resolution.rs +++ b/crates/originweave-destination/src/resolution.rs @@ -1,6 +1,7 @@ use std::collections::BTreeSet; use std::fmt; use std::net::IpAddr; +use std::time::Duration; use originweave_core::Origin; @@ -9,6 +10,13 @@ use crate::{AddressClass, ClassifiedAddress, classify_address}; /// The largest resolver answer accepted by one resolution snapshot. pub const MAX_RESOLUTION_ADDRESS_COUNT: usize = 256; +/// The largest freshness interval accepted for one resolution approval. +/// +/// This is an OriginWeave product safety budget, not a DNS protocol validity +/// rule. Callers may choose any smaller non-zero interval appropriate to their +/// resolver and network adapter. +pub const MAX_RESOLUTION_VALIDITY: Duration = Duration::from_secs(30); + /// A fail-closed allow-list of destination address classes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DestinationPolicy { @@ -103,6 +111,34 @@ pub enum DestinationError { /// The newly introduced canonical address. address: IpAddr, }, + /// A freshness interval was zero or exceeded [`MAX_RESOLUTION_VALIDITY`]. + InvalidResolutionValidity { + /// The rejected freshness interval. + validity: Duration, + /// The largest accepted freshness interval. + maximum_validity: Duration, + }, + /// Adding the freshness interval to the approval time overflowed. + ResolutionValidityOverflow { + /// The trusted monotonic time at which the answer was approved. + approved_at: Duration, + /// The requested freshness interval. + validity: Duration, + }, + /// A caller supplied a monotonic time earlier than the recorded approval. + ResolutionUseBeforeApproval { + /// The recorded approval time. + approved_at: Duration, + /// The caller-supplied current time. + current_time: Duration, + }, + /// A bounded resolution approval reached its exclusive validity deadline. + ResolutionApprovalExpired { + /// The exclusive upper bound of the approval interval. + valid_until: Duration, + /// The caller-supplied current time. + current_time: Duration, + }, } impl fmt::Display for DestinationError { @@ -142,6 +178,34 @@ impl fmt::Display for DestinationError { formatter, "refreshed DNS answer introduced unapproved address {address}", ), + Self::InvalidResolutionValidity { + validity, + maximum_validity, + } => write!( + formatter, + "resolution validity {validity:?} is outside 1ns..={maximum_validity:?}", + ), + Self::ResolutionValidityOverflow { + approved_at, + validity, + } => write!( + formatter, + "resolution validity {validity:?} overflows approval time {approved_at:?}", + ), + Self::ResolutionUseBeforeApproval { + approved_at, + current_time, + } => write!( + formatter, + "resolution use time {current_time:?} precedes approval time {approved_at:?}", + ), + Self::ResolutionApprovalExpired { + valid_until, + current_time, + } => write!( + formatter, + "resolution approval expired at {valid_until:?}; current time is {current_time:?}", + ), } } } @@ -254,6 +318,143 @@ impl ResolutionSnapshot { } } +/// A resolution snapshot bound to one explicit trusted monotonic validity window. +/// +/// The time values are opaque durations from one caller-owned monotonic clock +/// domain. This type never reads a wall clock itself. Constructing a new fresh +/// snapshot always reruns the same destination validation used by +/// [`ResolutionSnapshot`], so callers cannot renew authority without presenting +/// another policy-valid answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreshResolutionSnapshot { + snapshot: ResolutionSnapshot, + approved_at: Duration, + validity: Duration, + valid_until: Duration, +} + +impl FreshResolutionSnapshot { + /// Validate addresses and bind the resulting snapshot to a bounded lifetime. + pub fn approve( + origin: Origin, + addresses: impl IntoIterator, + policy: &DestinationPolicy, + approved_at: Duration, + validity: Duration, + ) -> Result { + let snapshot = ResolutionSnapshot::approve(origin, addresses, policy)?; + Self::from_snapshot(snapshot, approved_at, validity) + } + + fn from_snapshot( + snapshot: ResolutionSnapshot, + approved_at: Duration, + validity: Duration, + ) -> Result { + if validity.is_zero() || validity > MAX_RESOLUTION_VALIDITY { + return Err(DestinationError::InvalidResolutionValidity { + validity, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }); + } + let Some(valid_until) = approved_at.checked_add(validity) else { + return Err(DestinationError::ResolutionValidityOverflow { + approved_at, + validity, + }); + }; + Ok(Self { + snapshot, + approved_at, + validity, + valid_until, + }) + } + + /// Return the logical origin whose DNS answer was approved. + #[must_use] + pub const fn origin(&self) -> &Origin { + self.snapshot.origin() + } + + /// Return the canonical addresses pinned for this fresh snapshot. + #[must_use] + pub const fn addresses(&self) -> &BTreeSet { + self.snapshot.addresses() + } + + /// Return the trusted monotonic approval time. + #[must_use] + pub const fn approved_at(&self) -> Duration { + self.approved_at + } + + /// Return the configured non-zero validity budget. + #[must_use] + pub const fn validity(&self) -> Duration { + self.validity + } + + /// Return the exclusive upper bound of the approval interval. + #[must_use] + pub const fn valid_until(&self) -> Duration { + self.valid_until + } + + /// Authorize one pinned address only while the freshness window is valid. + pub fn authorize_connection( + &self, + address: IpAddr, + current_time: Duration, + ) -> Result { + self.validate_current_time(current_time)?; + let connection = self.snapshot.authorize_connection(address)?; + Ok(FreshConnectionEvidence { + connection, + resolution_approved_at: self.approved_at, + resolution_valid_until: self.valid_until, + authorized_at: current_time, + }) + } + + /// Revalidate a fresh answer and renew the same bounded validity budget. + /// + /// `revalidated_at` must come from the same monotonic clock domain and may + /// not precede this snapshot's approval time. Expansion of the pinned set + /// remains fail-closed under [`ResolutionSnapshot::revalidate`]. + pub fn revalidate( + &self, + addresses: impl IntoIterator, + policy: &DestinationPolicy, + revalidated_at: Duration, + ) -> Result { + if revalidated_at < self.approved_at { + return Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: self.approved_at, + current_time: revalidated_at, + }); + } + let snapshot = self.snapshot.revalidate(addresses, policy)?; + Self::from_snapshot(snapshot, revalidated_at, self.validity) + } + + fn validate_current_time(&self, current_time: Duration) -> Result<(), DestinationError> { + if current_time < self.approved_at { + return Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: self.approved_at, + current_time, + }); + } + if current_time >= self.valid_until { + return Err(DestinationError::ResolutionApprovalExpired { + valid_until: self.valid_until, + current_time, + }); + } + Ok(()) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OriginHostConstraint { Domain, @@ -344,3 +545,38 @@ impl ConnectionEvidence { self.address_class } } + +/// Credential-free evidence that a pinned connection address was used while fresh. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreshConnectionEvidence { + connection: ConnectionEvidence, + resolution_approved_at: Duration, + resolution_valid_until: Duration, + authorized_at: Duration, +} + +impl FreshConnectionEvidence { + /// Return the underlying canonical destination/connection evidence. + #[must_use] + pub const fn connection_evidence(&self) -> &ConnectionEvidence { + &self.connection + } + + /// Return the trusted monotonic time at which the answer was approved. + #[must_use] + pub const fn resolution_approved_at(&self) -> Duration { + self.resolution_approved_at + } + + /// Return the exclusive upper bound of the resolution approval interval. + #[must_use] + pub const fn resolution_valid_until(&self) -> Duration { + self.resolution_valid_until + } + + /// Return the trusted monotonic time used for this authorization decision. + #[must_use] + pub const fn authorized_at(&self) -> Duration { + self.authorized_at + } +} diff --git a/crates/originweave-destination/tests/proxy_port_syntax.rs b/crates/originweave-destination/tests/proxy_port_syntax.rs new file mode 100644 index 000000000..9038c14ed --- /dev/null +++ b/crates/originweave-destination/tests/proxy_port_syntax.rs @@ -0,0 +1,29 @@ +use originweave_destination::{ProxyServer, ProxyServerError}; + +#[test] +fn proxy_server_rejects_non_digit_port_prefixes() { + for input in [ + "proxy.example:+8080", + "http://proxy.example:+8080", + "https://proxy.example:+8443", + "socks5://proxy.example:+1080", + "https://[2001:db8::1]:+8443", + ] { + assert_eq!( + ProxyServer::parse(input), + Err(ProxyServerError::InvalidIdentifier), + "input={input}", + ); + } +} + +#[test] +fn proxy_server_rejects_decimal_ports_outside_u16_range() { + for input in ["proxy.example:65536", "https://[2001:db8::1]:65536"] { + assert_eq!( + ProxyServer::parse(input), + Err(ProxyServerError::InvalidIdentifier), + "input={input}", + ); + } +} diff --git a/crates/originweave-destination/tests/resolution_freshness.rs b/crates/originweave-destination/tests/resolution_freshness.rs new file mode 100644 index 000000000..2df264563 --- /dev/null +++ b/crates/originweave-destination/tests/resolution_freshness.rs @@ -0,0 +1,235 @@ +#![allow(clippy::expect_used)] + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use originweave_core::Origin; +use originweave_destination::{ + AddressClass, DestinationError, DestinationPolicy, FreshResolutionSnapshot, + MAX_RESOLUTION_VALIDITY, +}; + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("test origin must parse") +} + +fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) +} + +#[test] +fn fresh_resolution_authority_is_half_open_and_bound_to_pinned_addresses() { + let approved_at = Duration::from_secs(100); + let validity = Duration::from_secs(5); + let target = origin("https://example.com"); + let approved = ipv4(8, 8, 8, 8); + let snapshot = FreshResolutionSnapshot::approve( + target.clone(), + [approved], + &DestinationPolicy::public_web(), + approved_at, + validity, + ) + .expect("bounded fresh resolution"); + + assert_eq!(snapshot.origin(), &target); + assert_eq!(snapshot.approved_at(), approved_at); + assert_eq!(snapshot.validity(), validity); + assert_eq!(snapshot.valid_until(), Duration::from_secs(105)); + + let evidence = snapshot + .authorize_connection(approved, approved_at) + .expect("authority begins at approval time"); + let connection = evidence.connection_evidence(); + assert_eq!(connection.origin(), &target); + assert_eq!(connection.requested_address(), approved); + assert_eq!(connection.canonical_address(), approved); + assert_eq!(connection.address_class(), AddressClass::Public); + assert_eq!(evidence.resolution_approved_at(), approved_at); + assert_eq!(evidence.resolution_valid_until(), Duration::from_secs(105)); + assert_eq!(evidence.authorized_at(), approved_at); + + snapshot + .authorize_connection(approved, Duration::from_secs(104)) + .expect("authority remains valid before the exclusive deadline"); + + assert_eq!( + snapshot.authorize_connection(approved, Duration::from_secs(99)), + Err(DestinationError::ResolutionUseBeforeApproval { + approved_at, + current_time: Duration::from_secs(99), + }) + ); + assert_eq!( + snapshot.authorize_connection(approved, Duration::from_secs(105)), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: Duration::from_secs(105), + current_time: Duration::from_secs(105), + }) + ); + assert_eq!( + snapshot.authorize_connection(ipv4(9, 9, 9, 9), approved_at), + Err(DestinationError::UnapprovedConnectionAddress { + address: ipv4(9, 9, 9, 9), + }) + ); +} + +#[test] +fn fresh_resolution_rejects_invalid_or_overflowing_validity() { + let target = origin("https://example.com"); + let address = ipv4(8, 8, 8, 8); + let policy = DestinationPolicy::public_web(); + + for validity in [ + Duration::ZERO, + MAX_RESOLUTION_VALIDITY + Duration::from_nanos(1), + ] { + assert_eq!( + FreshResolutionSnapshot::approve( + target.clone(), + [address], + &policy, + Duration::from_secs(1), + validity, + ), + Err(DestinationError::InvalidResolutionValidity { + validity, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }) + ); + } + + assert_eq!( + FreshResolutionSnapshot::approve( + target, + [address], + &policy, + Duration::MAX, + Duration::from_nanos(1), + ), + Err(DestinationError::ResolutionValidityOverflow { + approved_at: Duration::MAX, + validity: Duration::from_nanos(1), + }) + ); +} + +#[test] +fn fresh_resolution_rejects_denied_addresses_before_granting_time_authority() { + let target = origin("https://example.com"); + let denied = ipv4(127, 0, 0, 1); + let public = ipv4(8, 8, 8, 8); + let policy = DestinationPolicy::public_web(); + let expected = Err(DestinationError::AddressClassDenied { + address: denied, + address_class: AddressClass::Loopback, + }); + + assert_eq!( + FreshResolutionSnapshot::approve( + target.clone(), + [denied], + &policy, + Duration::from_secs(1), + Duration::from_secs(1), + ), + expected.clone() + ); + assert_eq!( + FreshResolutionSnapshot::approve( + target, + [denied, public], + &policy, + Duration::from_secs(1), + Duration::from_secs(1), + ), + expected + ); +} + +#[test] +fn fresh_revalidation_preserves_the_budget_and_resets_approval_time() { + let first = ipv4(8, 8, 8, 8); + let second = ipv4(1, 1, 1, 1); + let unexpected = ipv4(9, 9, 9, 9); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin("https://example.com"), + [first, second], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial fresh resolution"); + + let refreshed = snapshot + .revalidate([second], &policy, Duration::from_secs(13)) + .expect("a fresh non-expanding answer renews the bounded window"); + assert_eq!( + refreshed.addresses(), + &std::collections::BTreeSet::from([second]) + ); + assert_eq!(refreshed.approved_at(), Duration::from_secs(13)); + assert_eq!(refreshed.validity(), Duration::from_secs(4)); + assert_eq!(refreshed.valid_until(), Duration::from_secs(17)); + refreshed + .authorize_connection(second, Duration::from_secs(16)) + .expect("refreshed authority is usable before its new deadline"); + + assert_eq!( + snapshot.revalidate([second], &policy, Duration::from_secs(9)), + Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: Duration::from_secs(10), + current_time: Duration::from_secs(9), + }) + ); + assert_eq!( + snapshot.revalidate([unexpected], &policy, Duration::from_secs(11)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, + }) + ); + assert_eq!( + snapshot.revalidate([first, unexpected], &policy, Duration::from_secs(11)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, + }) + ); +} + +#[test] +fn freshness_errors_have_deterministic_bounded_messages() { + let invalid = DestinationError::InvalidResolutionValidity { + validity: Duration::ZERO, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }; + assert_eq!( + invalid.to_string(), + "resolution validity 0ns is outside 1ns..=30s" + ); + + let overflow = DestinationError::ResolutionValidityOverflow { + approved_at: Duration::MAX, + validity: Duration::from_nanos(1), + }; + assert!(overflow.to_string().contains("overflows approval time")); + + let before = DestinationError::ResolutionUseBeforeApproval { + approved_at: Duration::from_secs(10), + current_time: Duration::from_secs(9), + }; + assert_eq!( + before.to_string(), + "resolution use time 9s precedes approval time 10s" + ); + + let expired = DestinationError::ResolutionApprovalExpired { + valid_until: Duration::from_secs(15), + current_time: Duration::from_secs(15), + }; + assert_eq!( + expired.to_string(), + "resolution approval expired at 15s; current time is 15s" + ); +} diff --git a/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs b/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs new file mode 100644 index 000000000..3c8443554 --- /dev/null +++ b/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs @@ -0,0 +1,78 @@ +#![allow(clippy::expect_used)] + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use originweave_core::Origin; +use originweave_destination::{DestinationError, DestinationPolicy, FreshResolutionSnapshot}; + +fn origin() -> Origin { + Origin::parse("https://example.com").expect("test origin must parse") +} + +fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) +} + +#[test] +fn post_expiry_revalidation_establishes_new_authority_without_reviving_the_old_snapshot() { + let first = ipv4(8, 8, 8, 8); + let second = ipv4(1, 1, 1, 1); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin(), + [first, second], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial bounded freshness authority"); + + let expiry = Duration::from_secs(14); + assert_eq!( + snapshot.authorize_connection(first, expiry), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: expiry, + current_time: expiry, + }) + ); + + let refreshed = snapshot + .revalidate([second], &policy, expiry) + .expect("fresh non-expanding validation may establish a new bounded snapshot"); + assert_eq!(refreshed.approved_at(), expiry); + assert_eq!(refreshed.valid_until(), Duration::from_secs(18)); + refreshed + .authorize_connection(second, expiry) + .expect("the newly validated snapshot has independent current authority"); + + assert_eq!( + snapshot.authorize_connection(second, expiry), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: expiry, + current_time: expiry, + }) + ); +} + +#[test] +fn post_expiry_revalidation_still_rejects_address_set_expansion() { + let approved = ipv4(8, 8, 8, 8); + let unexpected = ipv4(9, 9, 9, 9); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin(), + [approved], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial bounded freshness authority"); + + assert_eq!( + snapshot.revalidate([approved, unexpected], &policy, Duration::from_secs(14)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, + }) + ); +} diff --git a/crates/originweave-evidence/src/extraction_schema.rs b/crates/originweave-evidence/src/extraction_schema.rs new file mode 100644 index 000000000..14a86a24c --- /dev/null +++ b/crates/originweave-evidence/src/extraction_schema.rs @@ -0,0 +1,297 @@ +//! Versioned schema contracts for typed evidence extraction. +//! +//! These value objects describe what may be extracted and which reviewed +//! evidence channels may support each field. They do not read browser data, +//! disclose protected values, persist artifacts, execute models, or grant any +//! browser, network, secret, approval, or storage authority. + +use std::{collections::BTreeSet, fmt}; + +/// Maximum encoded byte length for an extraction schema or field identifier. +pub const MAX_EXTRACTION_IDENTIFIER_BYTES: usize = 128; +/// Maximum number of fields admitted by one extraction schema. +pub const MAX_EXTRACTION_FIELD_COUNT: usize = 256; + +/// The typed value contract for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionValueType { + /// Bounded textual data. + Text, + /// A whole-number value. + Integer, + /// A decimal numeric value. + Decimal, + /// A boolean value. + Boolean, + /// A timestamp value whose concrete normalization is defined by the schema version. + Timestamp, +} + +/// The number of values admitted for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionCardinality { + /// Exactly one value is admitted. + One, + /// Zero or one value is admitted. + ZeroOrOne, + /// A bounded collection may be admitted by a later extraction runtime. + Many, +} + +/// A reviewed evidence channel that may support an extracted value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionSourceChannel { + /// A semantic browser node with an independently validated identity. + SemanticNode, + /// Embedded structured metadata such as JSON-LD, RDFa, or Microdata. + StructuredData, + /// A bounded table-cell observation. + TableCell, + /// A bounded network response whose origin and response identity are independently verified. + NetworkResponse, + /// A separately approved model interpretation backed by explicit evidence identifiers. + ModelInterpretation, +} + +/// A deterministic normalization rule declared for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionNormalizationRule { + /// Preserve the typed source value without text normalization. + Verbatim, + /// Trim surrounding whitespace from a textual value. + TrimTextWhitespace, + /// Normalize a timestamp into an RFC 3339 UTC representation. + Rfc3339Utc, +} + +/// A validation failure while constructing an extraction schema contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtractionSchemaError { + /// A schema or field identifier was empty or outside the accepted identifier grammar. + InvalidIdentifier, + /// An identifier or field collection exceeded its bounded limit. + LimitExceeded, + /// A field's required flag contradicted its declared cardinality. + InvalidCardinalityRequirement, + /// A field did not declare any reviewed source channel. + MissingSourceChannel, + /// A field declared the same source channel more than once. + DuplicateSourceChannel, + /// The declared normalization rule was incompatible with the field value type. + InvalidNormalizationRule, + /// A schema did not contain any field definitions. + MissingField, + /// A schema declared the same field identifier more than once. + DuplicateField, +} + +impl fmt::Display for ExtractionSchemaError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidIdentifier => "invalid extraction schema or field identifier", + Self::LimitExceeded => "extraction schema limit exceeded", + Self::InvalidCardinalityRequirement => { + "extraction field required flag is incompatible with the declared cardinality" + } + Self::MissingSourceChannel => "extraction field requires at least one source channel", + Self::DuplicateSourceChannel => "extraction field contains a duplicate source channel", + Self::InvalidNormalizationRule => { + "extraction normalization rule is incompatible with the field value type" + } + Self::MissingField => "extraction schema requires at least one field", + Self::DuplicateField => "extraction schema contains a duplicate field identifier", + }) + } +} + +impl std::error::Error for ExtractionSchemaError {} + +/// One typed field declared by a versioned extraction schema. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractionField { + identifier: String, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + normalization_rule: ExtractionNormalizationRule, + source_channels: Vec, +} + +impl ExtractionField { + /// Validate and construct one extraction field contract with verbatim normalization. + pub fn new( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + source_channels: &[ExtractionSourceChannel], + ) -> Result { + Self::new_with_normalization( + identifier, + value_type, + cardinality, + required, + ExtractionNormalizationRule::Verbatim, + source_channels, + ) + } + + /// Validate and construct one extraction field with an explicit normalization rule. + pub fn new_with_normalization( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + normalization_rule: ExtractionNormalizationRule, + source_channels: &[ExtractionSourceChannel], + ) -> Result { + validate_identifier(identifier)?; + + let cardinality_requirement_is_compatible = match cardinality { + ExtractionCardinality::One => required, + ExtractionCardinality::ZeroOrOne => !required, + ExtractionCardinality::Many => true, + }; + if !cardinality_requirement_is_compatible { + return Err(ExtractionSchemaError::InvalidCardinalityRequirement); + } + + if source_channels.is_empty() { + return Err(ExtractionSchemaError::MissingSourceChannel); + } + + let normalization_is_compatible = match normalization_rule { + ExtractionNormalizationRule::Verbatim => true, + ExtractionNormalizationRule::TrimTextWhitespace => { + value_type == ExtractionValueType::Text + } + ExtractionNormalizationRule::Rfc3339Utc => value_type == ExtractionValueType::Timestamp, + }; + if !normalization_is_compatible { + return Err(ExtractionSchemaError::InvalidNormalizationRule); + } + + let mut seen_channels = BTreeSet::new(); + for source_channel in source_channels { + if !seen_channels.insert(*source_channel) { + return Err(ExtractionSchemaError::DuplicateSourceChannel); + } + } + + Ok(Self { + identifier: identifier.to_owned(), + value_type, + cardinality, + required, + normalization_rule, + source_channels: seen_channels.into_iter().collect(), + }) + } + + /// Return the stable field identifier. + #[must_use] + pub fn identifier(&self) -> &str { + &self.identifier + } + + /// Return the declared value type. + #[must_use] + pub const fn value_type(&self) -> ExtractionValueType { + self.value_type + } + + /// Return the declared cardinality. + #[must_use] + pub const fn cardinality(&self) -> ExtractionCardinality { + self.cardinality + } + + /// Return whether the field must be present in a conforming extraction result. + #[must_use] + pub const fn required(&self) -> bool { + self.required + } + + /// Return the deterministic normalization rule declared for this field. + #[must_use] + pub const fn normalization_rule(&self) -> ExtractionNormalizationRule { + self.normalization_rule + } + + /// Return the reviewed source channels that may support this field. + #[must_use] + pub fn source_channels(&self) -> &[ExtractionSourceChannel] { + &self.source_channels + } +} + +/// A bounded versioned collection of typed extraction-field contracts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractionSchema { + version: String, + fields: Vec, +} + +impl ExtractionSchema { + /// Validate and construct one versioned extraction schema. + pub fn new(version: &str, fields: Vec) -> Result { + validate_identifier(version)?; + if fields.is_empty() { + return Err(ExtractionSchemaError::MissingField); + } + if fields.len() > MAX_EXTRACTION_FIELD_COUNT { + return Err(ExtractionSchemaError::LimitExceeded); + } + + let mut field_identifiers = BTreeSet::new(); + for field in &fields { + if !field_identifiers.insert(field.identifier()) { + return Err(ExtractionSchemaError::DuplicateField); + } + } + + Ok(Self { + version: version.to_owned(), + fields, + }) + } + + /// Return the immutable schema version identifier. + #[must_use] + pub fn version(&self) -> &str { + &self.version + } + + /// Return the schema's ordered field definitions. + #[must_use] + pub fn fields(&self) -> &[ExtractionField] { + &self.fields + } + + /// Find one field by its stable identifier. + #[must_use] + pub fn field(&self, identifier: &str) -> Option<&ExtractionField> { + self.fields + .iter() + .find(|field| field.identifier() == identifier) + } +} + +fn validate_identifier(identifier: &str) -> Result<(), ExtractionSchemaError> { + if identifier.len() > MAX_EXTRACTION_IDENTIFIER_BYTES { + return Err(ExtractionSchemaError::LimitExceeded); + } + + let mut bytes = identifier.bytes(); + let Some(first_byte) = bytes.next() else { + return Err(ExtractionSchemaError::InvalidIdentifier); + }; + if !first_byte.is_ascii_lowercase() { + return Err(ExtractionSchemaError::InvalidIdentifier); + } + if bytes.any(|byte| !matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')) { + return Err(ExtractionSchemaError::InvalidIdentifier); + } + + Ok(()) +} diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index b38578044..17c97bec8 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -7,13 +7,23 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod extraction_schema; mod sensitive_access; +mod sensitive_handle_lifecycle; +pub use extraction_schema::{ + ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchema, + ExtractionSchemaError, ExtractionSourceChannel, ExtractionValueType, + MAX_EXTRACTION_FIELD_COUNT, MAX_EXTRACTION_IDENTIFIER_BYTES, +}; pub use sensitive_access::{ MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome, SensitiveEvidenceError, }; +pub use sensitive_handle_lifecycle::{ + SensitiveHandleLifecycleEvidence, SensitiveHandleLifecycleEvidenceInput, +}; use std::collections::BTreeMap; diff --git a/crates/originweave-evidence/src/sensitive_access.rs b/crates/originweave-evidence/src/sensitive_access.rs index 9123119f7..24cb43047 100644 --- a/crates/originweave-evidence/src/sensitive_access.rs +++ b/crates/originweave-evidence/src/sensitive_access.rs @@ -297,7 +297,10 @@ fn validate_fields(field_ids: &[String]) -> Result<(), SensitiveEvidenceError> { Ok(()) } -fn valid_identifier(value: &str) -> bool { +/// Return whether `value` is a non-empty identifier of at most +/// `MAX_SENSITIVE_IDENTIFIER_BYTES` ASCII bytes, contains at least one +/// alphanumeric byte, and otherwise uses only `.`, `_`, `:`, or `-` punctuation. +pub(crate) fn valid_identifier(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_SENSITIVE_IDENTIFIER_BYTES && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) diff --git a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs new file mode 100644 index 000000000..f61c8527f --- /dev/null +++ b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs @@ -0,0 +1,144 @@ +//! Credential-free lifecycle evidence for opaque sensitive-value handles. +//! +//! A trusted broker can use this value object to record when a handle was +//! issued, when it expires, how many uses it permits, how many resolutions were +//! observed, and when it was revoked. The lifecycle retains the complete +//! credential-free sensitive-access receipt that authorized opaque-handle use, +//! while intentionally excluding the opaque handle token and protected value. + +use crate::sensitive_access::{ + SensitiveAccessEvidence, SensitiveAccessOutcome, SensitiveEvidenceError, +}; + +/// Unvalidated metadata describing one opaque sensitive-value handle lifecycle. +/// +/// The embedded access receipt binds the lifecycle to the tenant, actor, task, +/// field set, purpose, destination, classification, policy version, and exact +/// opaque-handle authorization without carrying protected values. When the access +/// receipt carries a retention deadline, the handle must expire no later than +/// that deadline so derived opaque authority cannot outlive its governing receipt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SensitiveHandleLifecycleEvidenceInput { + /// Credential-free access receipt that authorized this opaque handle. + pub access_evidence: SensitiveAccessEvidence, + /// Trusted Unix epoch second when the handle was issued. + pub issued_epoch_seconds: u64, + /// Trusted Unix epoch second after which the handle is no longer valid. + /// + /// When the retained access receipt defines a retention deadline, this value + /// may equal but must not exceed that deadline. + pub expires_epoch_seconds: u64, + /// Maximum number of broker resolutions authorized for the handle. + pub maximum_uses: u32, + /// Number of broker resolutions already observed for the handle. + pub resolution_count: u32, + /// Trusted Unix epoch second when the handle was revoked, when applicable. + /// + /// A revocation recorded exactly at expiry is retained as a terminal audit + /// event even though it cannot extend or restore handle validity. + pub revoked_epoch_seconds: Option, +} + +/// Immutable credential-free evidence about one opaque handle lifecycle. +/// +/// The value retains the exact credential-free sensitive-access receipt that +/// authorized opaque-handle use, but deliberately excludes both the opaque +/// handle token and the secret or protected value that the broker can resolve. +/// Any receipt retention deadline also bounds the derived handle lifetime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SensitiveHandleLifecycleEvidence { + access_evidence: SensitiveAccessEvidence, + issued_epoch_seconds: u64, + expires_epoch_seconds: u64, + maximum_uses: u32, + resolution_count: u32, + revoked_epoch_seconds: Option, +} + +impl TryFrom for SensitiveHandleLifecycleEvidence { + type Error = SensitiveEvidenceError; + + fn try_from(input: SensitiveHandleLifecycleEvidenceInput) -> Result { + if input.access_evidence.outcome() != SensitiveAccessOutcome::OpaqueHandleOnly + || input.issued_epoch_seconds == 0 + || input.issued_epoch_seconds < input.access_evidence.decision_epoch_seconds() + || input.expires_epoch_seconds <= input.issued_epoch_seconds + || input + .access_evidence + .retention_deadline_epoch_seconds() + .is_some_and(|deadline| input.expires_epoch_seconds > deadline) + || input.maximum_uses == 0 + || input.resolution_count > input.maximum_uses + || input.revoked_epoch_seconds.is_some_and(|revoked| { + revoked < input.issued_epoch_seconds || revoked > input.expires_epoch_seconds + }) + { + return Err(SensitiveEvidenceError::InvalidLifecycle); + } + + Ok(Self { + access_evidence: input.access_evidence, + issued_epoch_seconds: input.issued_epoch_seconds, + expires_epoch_seconds: input.expires_epoch_seconds, + maximum_uses: input.maximum_uses, + resolution_count: input.resolution_count, + revoked_epoch_seconds: input.revoked_epoch_seconds, + }) + } +} + +impl SensitiveHandleLifecycleEvidence { + /// Return the credential-free access receipt that authorized this opaque handle. + #[must_use] + pub const fn access_evidence(&self) -> &SensitiveAccessEvidence { + &self.access_evidence + } + + /// Return the originating sensitive-data access request identifier. + #[must_use] + pub fn request_id(&self) -> &str { + self.access_evidence.request_id() + } + + /// Return the policy decision identifier associated with the handle. + #[must_use] + pub fn decision_id(&self) -> &str { + self.access_evidence.decision_id() + } + + /// Return the trusted handle issuance time as a Unix epoch second. + #[must_use] + pub const fn issued_epoch_seconds(&self) -> u64 { + self.issued_epoch_seconds + } + + /// Return the trusted handle expiry time as a Unix epoch second. + #[must_use] + pub const fn expires_epoch_seconds(&self) -> u64 { + self.expires_epoch_seconds + } + + /// Return the maximum number of broker resolutions authorized for the handle. + #[must_use] + pub const fn maximum_uses(&self) -> u32 { + self.maximum_uses + } + + /// Return the number of broker resolutions already observed for the handle. + #[must_use] + pub const fn resolution_count(&self) -> u32 { + self.resolution_count + } + + /// Return the trusted revocation time when the handle has been revoked. + #[must_use] + pub const fn revoked_epoch_seconds(&self) -> Option { + self.revoked_epoch_seconds + } + + /// Return whether trusted evidence records that this handle was revoked. + #[must_use] + pub const fn is_revoked(&self) -> bool { + self.revoked_epoch_seconds.is_some() + } +} diff --git a/crates/originweave-evidence/tests/extraction_normalization.rs b/crates/originweave-evidence/tests/extraction_normalization.rs new file mode 100644 index 000000000..63afd39e6 --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_normalization.rs @@ -0,0 +1,77 @@ +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchemaError, + ExtractionSourceChannel, ExtractionValueType, +}; + +#[test] +fn extraction_fields_require_an_explicit_typed_normalization_rule() +-> Result<(), ExtractionSchemaError> { + let text = ExtractionField::new_with_normalization( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::TrimTextWhitespace, + &[ExtractionSourceChannel::SemanticNode], + )?; + assert_eq!( + text.normalization_rule(), + ExtractionNormalizationRule::TrimTextWhitespace + ); + + let timestamp = ExtractionField::new_with_normalization( + "captured_at", + ExtractionValueType::Timestamp, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::Rfc3339Utc, + &[ExtractionSourceChannel::NetworkResponse], + )?; + assert_eq!( + timestamp.normalization_rule(), + ExtractionNormalizationRule::Rfc3339Utc + ); + Ok(()) +} + +#[test] +fn extraction_fields_fail_closed_on_type_incompatible_normalization() { + assert_eq!( + ExtractionField::new_with_normalization( + "captured_at", + ExtractionValueType::Timestamp, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::TrimTextWhitespace, + &[ExtractionSourceChannel::NetworkResponse], + ), + Err(ExtractionSchemaError::InvalidNormalizationRule) + ); + assert_eq!( + ExtractionField::new_with_normalization( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::Rfc3339Utc, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidNormalizationRule) + ); +} + +#[test] +fn existing_fields_default_to_verbatim_normalization() -> Result<(), ExtractionSchemaError> { + let field = ExtractionField::new( + "unit_price", + ExtractionValueType::Decimal, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::StructuredData], + )?; + assert_eq!( + field.normalization_rule(), + ExtractionNormalizationRule::Verbatim + ); + Ok(()) +} diff --git a/crates/originweave-evidence/tests/extraction_schema.rs b/crates/originweave-evidence/tests/extraction_schema.rs new file mode 100644 index 000000000..fc875ef0f --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_schema.rs @@ -0,0 +1,326 @@ +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionSchema, ExtractionSchemaError, + ExtractionSourceChannel, ExtractionValueType, MAX_EXTRACTION_FIELD_COUNT, + MAX_EXTRACTION_IDENTIFIER_BYTES, +}; + +fn field( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + source_channels: &[ExtractionSourceChannel], +) -> Result { + ExtractionField::new( + identifier, + value_type, + cardinality, + required, + source_channels, + ) +} + +#[test] +fn schema_binds_versioned_typed_fields_to_explicit_source_channels() +-> Result<(), ExtractionSchemaError> { + let schema = ExtractionSchema::new( + "product-card-v1", + vec![ + field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::StructuredData, + ], + )?, + field( + "unit_price", + ExtractionValueType::Decimal, + ExtractionCardinality::ZeroOrOne, + false, + &[ + ExtractionSourceChannel::TableCell, + ExtractionSourceChannel::NetworkResponse, + ], + )?, + ], + )?; + + assert_eq!(schema.version(), "product-card-v1"); + assert_eq!(schema.fields().len(), 2); + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::identifier), + Some("product_name") + ); + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::value_type), + Some(ExtractionValueType::Text) + ); + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::cardinality), + Some(ExtractionCardinality::One) + ); + assert_eq!( + schema.field("product_name").map(ExtractionField::required), + Some(true) + ); + let expected_product_sources = [ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::StructuredData, + ]; + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::source_channels), + Some(expected_product_sources.as_slice()) + ); + assert_eq!( + schema.field("unit_price").map(ExtractionField::value_type), + Some(ExtractionValueType::Decimal) + ); + assert_eq!( + schema.field("unit_price").map(ExtractionField::cardinality), + Some(ExtractionCardinality::ZeroOrOne) + ); + assert_eq!( + schema.field("unit_price").map(ExtractionField::required), + Some(false) + ); + assert!(schema.field("missing_field").is_none()); + Ok(()) +} + +#[test] +fn field_accepts_all_reviewed_value_and_source_channel_variants() +-> Result<(), ExtractionSchemaError> { + let cases = [ + ( + ExtractionValueType::Text, + ExtractionSourceChannel::SemanticNode, + ), + ( + ExtractionValueType::Integer, + ExtractionSourceChannel::StructuredData, + ), + ( + ExtractionValueType::Decimal, + ExtractionSourceChannel::TableCell, + ), + ( + ExtractionValueType::Boolean, + ExtractionSourceChannel::NetworkResponse, + ), + ( + ExtractionValueType::Timestamp, + ExtractionSourceChannel::ModelInterpretation, + ), + ]; + + for (index, (value_type, source_channel)) in cases.into_iter().enumerate() { + let field = field( + &format!("field_{index}"), + value_type, + ExtractionCardinality::Many, + false, + &[source_channel], + )?; + assert_eq!(field.value_type(), value_type); + assert_eq!(field.cardinality(), ExtractionCardinality::Many); + assert_eq!(field.source_channels(), &[source_channel]); + } + + let required_many = field( + "required_many", + ExtractionValueType::Text, + ExtractionCardinality::Many, + true, + &[ExtractionSourceChannel::SemanticNode], + )?; + assert!(required_many.required()); + Ok(()) +} + +#[test] +fn field_rejects_contradictory_required_cardinality_contracts() { + assert_eq!( + ExtractionField::new( + "optional_exactly_one", + ExtractionValueType::Text, + ExtractionCardinality::One, + false, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidCardinalityRequirement) + ); + assert_eq!( + ExtractionField::new( + "required_zero_or_one", + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidCardinalityRequirement) + ); +} + +#[test] +fn field_rejects_empty_malformed_or_overlong_identifiers() { + assert_eq!( + ExtractionField::new( + "", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "Product Name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "product name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "1product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + &"a".repeat(MAX_EXTRACTION_IDENTIFIER_BYTES + 1), + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::LimitExceeded) + ); +} + +#[test] +fn field_requires_a_nonempty_duplicate_free_source_channel_set() { + assert_eq!( + ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[], + ), + Err(ExtractionSchemaError::MissingSourceChannel) + ); + assert_eq!( + ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::SemanticNode, + ], + ), + Err(ExtractionSchemaError::DuplicateSourceChannel) + ); +} + +#[test] +fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overflow() +-> Result<(), ExtractionSchemaError> { + assert_eq!( + ExtractionSchema::new( + "Product Schema", + vec![field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )?] + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionSchema::new( + &"a".repeat(MAX_EXTRACTION_IDENTIFIER_BYTES + 1), + vec![field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )?], + ), + Err(ExtractionSchemaError::LimitExceeded) + ); + assert_eq!( + ExtractionSchema::new("product-card-v1", vec![]), + Err(ExtractionSchemaError::MissingField) + ); + + let duplicate = field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )?; + let duplicate_again = field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::StructuredData], + )?; + assert_eq!( + ExtractionSchema::new("product-card-v1", vec![duplicate, duplicate_again]), + Err(ExtractionSchemaError::DuplicateField) + ); + + let too_many_fields = (0..=MAX_EXTRACTION_FIELD_COUNT) + .map(|index| { + field( + &format!("field_{index}"), + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::SemanticNode], + ) + }) + .collect::, _>>()?; + assert_eq!( + ExtractionSchema::new("product-card-v1", too_many_fields), + Err(ExtractionSchemaError::LimitExceeded) + ); + Ok(()) +} diff --git a/crates/originweave-evidence/tests/extraction_schema_error_contract.rs b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs new file mode 100644 index 000000000..b4897d90f --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs @@ -0,0 +1,48 @@ +use std::error::Error as _; + +use originweave_evidence::ExtractionSchemaError; + +fn assert_standard_error_contract() {} + +#[test] +fn extraction_schema_errors_implement_standard_error_contract() { + assert_standard_error_contract::(); + + for (error, message) in [ + ( + ExtractionSchemaError::InvalidIdentifier, + "invalid extraction schema or field identifier", + ), + ( + ExtractionSchemaError::LimitExceeded, + "extraction schema limit exceeded", + ), + ( + ExtractionSchemaError::InvalidCardinalityRequirement, + "extraction field required flag is incompatible with the declared cardinality", + ), + ( + ExtractionSchemaError::MissingSourceChannel, + "extraction field requires at least one source channel", + ), + ( + ExtractionSchemaError::DuplicateSourceChannel, + "extraction field contains a duplicate source channel", + ), + ( + ExtractionSchemaError::InvalidNormalizationRule, + "extraction normalization rule is incompatible with the field value type", + ), + ( + ExtractionSchemaError::MissingField, + "extraction schema requires at least one field", + ), + ( + ExtractionSchemaError::DuplicateField, + "extraction schema contains a duplicate field identifier", + ), + ] { + assert_eq!(error.to_string(), message); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-evidence/tests/extraction_source_channel_set.rs b/crates/originweave-evidence/tests/extraction_source_channel_set.rs new file mode 100644 index 000000000..1f5070e8a --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_source_channel_set.rs @@ -0,0 +1,40 @@ +#![allow(clippy::expect_used)] + +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionSourceChannel, ExtractionValueType, +}; + +#[test] +fn equivalent_source_channel_sets_have_canonical_identity() { + let semantic_then_network = ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::NetworkResponse, + ], + ) + .expect("reviewed source set must be valid"); + let network_then_semantic = ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::NetworkResponse, + ExtractionSourceChannel::SemanticNode, + ], + ) + .expect("equivalent reviewed source set must be valid"); + + assert_eq!(semantic_then_network, network_then_semantic); + assert_eq!( + network_then_semantic.source_channels(), + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::NetworkResponse, + ] + ); +} diff --git a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs new file mode 100644 index 000000000..6dbf8d713 --- /dev/null +++ b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs @@ -0,0 +1,114 @@ +use originweave_core::Origin; +use originweave_evidence::{ + SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, + SensitiveAccessOutcome, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, + SensitiveHandleLifecycleEvidenceInput, +}; + +type TestResult = Result<(), String>; + +fn access_evidence( + outcome: SensitiveAccessOutcome, + decision_epoch_seconds: u64, +) -> Result { + let destination = + Origin::parse("https://checkout.example.com").map_err(|error| format!("{error:?}"))?; + SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { + request_id: "request-42".to_owned(), + decision_id: "decision-42".to_owned(), + tenant_id: "tenant-7".to_owned(), + actor_id: "workload-browser-adapter".to_owned(), + task_id: "task-99".to_owned(), + field_ids: vec!["shipping_name".to_owned(), "shipping_address".to_owned()], + purpose_id: "fulfill-shipment".to_owned(), + destination, + classification: SensitiveAccessClass::PersonalData, + outcome, + policy_version: "sensitive-policy-v3".to_owned(), + approval_reference: None, + decision_epoch_seconds, + disclosure_epoch_seconds: None, + retention_deadline_epoch_seconds: Some(decision_epoch_seconds + 3_600), + }) + .map_err(|error| format!("{error:?}")) +} + +fn lifecycle_input( + access_evidence: SensitiveAccessEvidence, + issued_epoch_seconds: u64, +) -> SensitiveHandleLifecycleEvidenceInput { + SensitiveHandleLifecycleEvidenceInput { + access_evidence, + issued_epoch_seconds, + expires_epoch_seconds: issued_epoch_seconds + 300, + maximum_uses: 2, + resolution_count: 0, + revoked_epoch_seconds: None, + } +} + +#[test] +fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> TestResult { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; + let evidence = + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access.clone(), 1_720_000_001)) + .map_err(|error| format!("{error:?}"))?; + + assert_eq!(evidence.access_evidence(), &access); + assert_eq!(evidence.request_id(), access.request_id()); + assert_eq!(evidence.decision_id(), access.decision_id()); + assert_eq!(evidence.access_evidence().tenant_id(), "tenant-7"); + assert_eq!(evidence.access_evidence().task_id(), "task-99"); + assert_eq!( + evidence.access_evidence().field_ids(), + ["shipping_name", "shipping_address"] + ); + assert_eq!( + evidence.access_evidence().destination().as_str(), + "https://checkout.example.com" + ); + Ok(()) +} + +#[test] +fn lifecycle_rejects_non_opaque_handle_access_decision() -> TestResult { + let denied = access_evidence(SensitiveAccessOutcome::DenyAccess, 1_720_000_000)?; + + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(denied, 1_720_000_001)), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} + +#[test] +fn lifecycle_rejects_issuance_before_policy_decision() -> TestResult { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_100)?; + + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access, 1_720_000_099)), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} + +#[test] +fn lifecycle_expiry_respects_access_retention_deadline() -> TestResult { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; + let retention_deadline = access + .retention_deadline_epoch_seconds() + .ok_or_else(|| "fixture must carry a retention deadline".to_owned())?; + + let mut exact_deadline = lifecycle_input(access.clone(), 1_720_000_001); + exact_deadline.expires_epoch_seconds = retention_deadline; + SensitiveHandleLifecycleEvidence::try_from(exact_deadline) + .map_err(|error| format!("{error:?}"))?; + + let mut after_deadline = lifecycle_input(access, 1_720_000_001); + after_deadline.expires_epoch_seconds = retention_deadline + 1; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(after_deadline), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs new file mode 100644 index 000000000..95034cecc --- /dev/null +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -0,0 +1,142 @@ +use originweave_core::Origin; +use originweave_evidence::{ + SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, + SensitiveAccessOutcome, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, + SensitiveHandleLifecycleEvidenceInput, +}; + +type TestResult = Result<(), String>; + +fn valid_access_evidence() -> Result { + let destination = + Origin::parse("https://shipping.example").map_err(|error| format!("{error:?}"))?; + SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { + request_id: "request-42".to_owned(), + decision_id: "decision-42".to_owned(), + tenant_id: "tenant-7".to_owned(), + actor_id: "workload-fulfillment".to_owned(), + task_id: "task-42".to_owned(), + field_ids: vec!["shipping.address".to_owned()], + purpose_id: "fulfill-shipment".to_owned(), + destination, + classification: SensitiveAccessClass::PersonalData, + outcome: SensitiveAccessOutcome::OpaqueHandleOnly, + policy_version: "sensitive-policy-v3".to_owned(), + approval_reference: None, + decision_epoch_seconds: 1_720_000_000, + disclosure_epoch_seconds: None, + retention_deadline_epoch_seconds: Some(1_720_003_600), + }) + .map_err(|error| format!("{error:?}")) +} + +fn valid_input() -> Result { + Ok(SensitiveHandleLifecycleEvidenceInput { + access_evidence: valid_access_evidence()?, + issued_epoch_seconds: 1_720_000_001, + expires_epoch_seconds: 1_720_000_301, + maximum_uses: 2, + resolution_count: 1, + revoked_epoch_seconds: None, + }) +} + +#[test] +fn records_bounded_handle_lifecycle_without_handle_or_secret_material() -> TestResult { + let evidence = SensitiveHandleLifecycleEvidence::try_from(valid_input()?) + .map_err(|error| format!("{error:?}"))?; + + assert_eq!(evidence.request_id(), "request-42"); + assert_eq!(evidence.decision_id(), "decision-42"); + assert_eq!(evidence.issued_epoch_seconds(), 1_720_000_001); + assert_eq!(evidence.expires_epoch_seconds(), 1_720_000_301); + assert_eq!(evidence.maximum_uses(), 2); + assert_eq!(evidence.resolution_count(), 1); + assert_eq!(evidence.revoked_epoch_seconds(), None); + assert!(!evidence.is_revoked()); + + let debug = format!("{evidence:?}"); + assert!(!debug.contains("opaque-handle-token-should-never-be-evidence")); + assert!(!debug.contains("raw-secret-should-never-be-evidence")); + Ok(()) +} + +#[test] +fn records_revocation_time_without_storing_revocation_payloads() -> TestResult { + let mut input = valid_input()?; + input.revoked_epoch_seconds = Some(1_720_000_120); + input.resolution_count = 2; + + let evidence = + SensitiveHandleLifecycleEvidence::try_from(input).map_err(|error| format!("{error:?}"))?; + + assert_eq!(evidence.revoked_epoch_seconds(), Some(1_720_000_120)); + assert!(evidence.is_revoked()); + assert_eq!(evidence.resolution_count(), evidence.maximum_uses()); + Ok(()) +} + +#[test] +fn records_revocation_at_exact_expiry_boundary() -> TestResult { + let mut input = valid_input()?; + input.revoked_epoch_seconds = Some(input.expires_epoch_seconds); + + let evidence = + SensitiveHandleLifecycleEvidence::try_from(input).map_err(|error| format!("{error:?}"))?; + + assert_eq!( + evidence.revoked_epoch_seconds(), + Some(evidence.expires_epoch_seconds()) + ); + assert!(evidence.is_revoked()); + Ok(()) +} + +#[test] +fn rejects_zero_or_non_increasing_handle_lifetime() -> TestResult { + for (issued, expires) in [ + (0, 1_720_000_301), + (1_720_000_301, 1_720_000_301), + (1_720_000_302, 1_720_000_301), + ] { + let mut input = valid_input()?; + input.issued_epoch_seconds = issued; + input.expires_epoch_seconds = expires; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(input), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + } + Ok(()) +} + +#[test] +fn rejects_zero_use_limit_or_resolution_count_above_limit() -> TestResult { + let mut zero_limit = valid_input()?; + zero_limit.maximum_uses = 0; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(zero_limit), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + + let mut overused = valid_input()?; + overused.resolution_count = overused.maximum_uses + 1; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(overused), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} + +#[test] +fn rejects_revocation_before_issue_or_after_expiry() -> TestResult { + for revoked in [1_720_000_000, 1_720_000_302] { + let mut input = valid_input()?; + input.revoked_epoch_seconds = Some(revoked); + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(input), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + } + Ok(()) +} diff --git a/crates/originweave-policy/tests/extension_mutation_isolation.rs b/crates/originweave-policy/tests/extension_mutation_isolation.rs new file mode 100644 index 000000000..48d7936e1 --- /dev/null +++ b/crates/originweave-policy/tests/extension_mutation_isolation.rs @@ -0,0 +1,343 @@ +#![allow(clippy::expect_used)] + +//! Keep extension proposal-grant evaluation separate from ordinary action policy. +//! +//! OriginWeave does not yet implement an adapter that converts an extension proposal into an +//! [`ActionRequest`]. These regressions therefore prove two independent fail-closed boundaries: +//! the exact extension/session/context/origin/unexpired grant permits only `ProposeTypedAction`, +//! while an ordinary user-sourced action request remains subject to the core policy decision +//! shown in each test. + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, + evaluate_extension_access, +}; +use originweave_policy::{Decision, DenialReason, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EXTENSION_ORIGIN: &str = "https://extension.example"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(17).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(23).expect("nonzero browsing context") +} + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_proposal_grant_is_independently_allowed(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_cross_origin_mutation_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let source = origin("https://source.example"); + let target = origin("https://target.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([source.clone(), target.clone()]), + BTreeSet::from([target.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + source, + target, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::CrossOriginMutation) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_write_origin_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::OriginNotWritable) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_crawler_mutation_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::CrawlerMutation) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_mode_purpose_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::ModePurposeMismatch) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_disallowed_robots_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Disallowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsDisallowed) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_unknown_robots_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Unknown, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsUnknown) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_missing_robots_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::NotApplicable, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsNotApplicable) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_non_delegable_r5_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://consent.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::LegalConsent]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::LegalConsent, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::ForbiddenRisk) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_human_mode_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://human.example"); + let context = PolicyContext::new( + SessionMode::Human, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::NotApplicable, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::HumanModeNotAgentControlled) + ); +} diff --git a/crates/originweave-policy/tests/extension_policy_isolation.rs b/crates/originweave-policy/tests/extension_policy_isolation.rs new file mode 100644 index 000000000..f32d8733c --- /dev/null +++ b/crates/originweave-policy/tests/extension_policy_isolation.rs @@ -0,0 +1,215 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, + evaluate_extension_access, +}; +use originweave_policy::{Decision, DenialReason, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EXTENSION_ORIGIN: &str = "https://extension.example"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(7).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(11).expect("nonzero browsing context") +} + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_extension_can_only_propose(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +#[test] +fn explicit_extension_grant_does_not_widen_agent_origin_authority() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let allowed = origin("https://app.example"); + let forbidden = origin("https://outside.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([allowed.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + allowed, + forbidden, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::OriginNotReadable) + ); +} + +#[test] +fn explicit_extension_grant_does_not_supply_agent_action_capability() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::MissingCapability(Capability::Navigate)) + ); +} + +#[test] +fn untrusted_extension_content_cannot_become_a_policy_instruction() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::WebContent, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::UntrustedInstructionSource) + ); +} + +#[test] +fn explicit_extension_grant_cannot_turn_raw_secret_delivery_into_a_fill_capability() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::FillSecret]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::FillSecret, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::RawValue, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::SecretBrokerRequired) + ); +} + +#[test] +fn explicit_extension_grant_cannot_attach_secret_material_to_non_secret_action() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::RawValue, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::UnexpectedSecretMaterial) + ); +} diff --git a/crates/originweave-policy/tests/extension_secret_isolation.rs b/crates/originweave-policy/tests/extension_secret_isolation.rs new file mode 100644 index 000000000..f808bec04 --- /dev/null +++ b/crates/originweave-policy/tests/extension_secret_isolation.rs @@ -0,0 +1,96 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, + SessionMode, evaluate_extension_access, +}; +use originweave_policy::{Decision, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(7).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(11).expect("nonzero browsing context") +} + +fn origin() -> Origin { + Origin::parse("https://login.example").expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + origin(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_extension_can_propose(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + browser_session(), + browsing_context(), + origin(), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +fn secret_context(site: &Origin) -> PolicyContext { + PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::FillSecret]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ) +} + +#[test] +fn extension_action_grant_cannot_skip_secret_broker_approval() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin(); + let proposed = ActionRequest::new( + ActionKind::FillSecret, + site.clone(), + site.clone(), + InstructionSource::User, + SecretDelivery::BrokerHandle, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &secret_context(&site)), + Decision::RequireApproval(RiskClass::R3) + ); +} diff --git a/crates/originweave-resource/src/lib.rs b/crates/originweave-resource/src/lib.rs index 8c77aa3d0..35a30789a 100644 --- a/crates/originweave-resource/src/lib.rs +++ b/crates/originweave-resource/src/lib.rs @@ -9,6 +9,8 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +use std::fmt; + /// A validation error in a resource budget. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BudgetError { @@ -18,6 +20,19 @@ pub enum BudgetError { SoftExceedsHard, } +impl fmt::Display for BudgetError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ZeroLimit => formatter.write_str("resource budget limits must be nonzero"), + Self::SoftExceedsHard => { + formatter.write_str("resource budget soft limits must not exceed hard limits") + } + } + } +} + +impl std::error::Error for BudgetError {} + /// Validated resource limits for one agent task. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ResourceBudget { diff --git a/crates/originweave-resource/tests/error_contract.rs b/crates/originweave-resource/tests/error_contract.rs new file mode 100644 index 000000000..cc8b88dfb --- /dev/null +++ b/crates/originweave-resource/tests/error_contract.rs @@ -0,0 +1,21 @@ +use originweave_resource::BudgetError; +use std::error::Error as _; + +#[test] +fn budget_errors_expose_stable_standard_error_contract() { + let cases = [ + ( + BudgetError::ZeroLimit, + "resource budget limits must be nonzero", + ), + ( + BudgetError::SoftExceedsHard, + "resource budget soft limits must not exceed hard limits", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-tls/src/lib.rs b/crates/originweave-tls/src/lib.rs index f9ec5e877..9024946f4 100644 --- a/crates/originweave-tls/src/lib.rs +++ b/crates/originweave-tls/src/lib.rs @@ -14,6 +14,7 @@ mod evidence; mod handshake; mod identity; mod policy; +mod revocation; mod trust; mod validity; @@ -29,6 +30,7 @@ pub use policy::{ MAX_MINIMUM_LEAF_VALIDITY, MAX_SERVER_CERTIFICATE_BYTES, MAX_SERVER_CERTIFICATE_COUNT, MAX_TLS_HANDSHAKE_TIMEOUT, TlsClientPolicy, }; +pub use revocation::{RevocationMaterialFreshness, RevocationMaterialFreshnessError}; pub use trust::{ MAX_TRUST_ROOT_BYTES, MAX_TRUST_ROOT_COUNT, TrustBundleIdentifier, TrustRootBundle, }; diff --git a/crates/originweave-tls/src/revocation.rs b/crates/originweave-tls/src/revocation.rs new file mode 100644 index 000000000..e500125a2 --- /dev/null +++ b/crates/originweave-tls/src/revocation.rs @@ -0,0 +1,174 @@ +use std::fmt; + +/// A deterministic freshness window for independently verified revocation material. +/// +/// This value does not fetch, parse, authenticate, or interpret OCSP responses or +/// certificate revocation lists. A trusted adapter must first obtain and +/// cryptographically validate the revocation material, then pass the signed +/// `thisUpdate` and `nextUpdate` timestamps into this authority together with a +/// caller-selected local maximum freshness window. Passing this check proves only +/// that the supplied material is within both its signed interval and the caller's +/// bounded freshness policy; it does not prove that any certificate is unrevoked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RevocationMaterialFreshness { + this_update_unix_seconds: u64, + next_update_unix_seconds: u64, + maximum_window_seconds: u64, +} + +impl RevocationMaterialFreshness { + /// Create a non-empty, locally bounded freshness window from trusted signed timestamps. + /// + /// The signed window is half-open: `thisUpdate <= trusted_time < nextUpdate`. + /// Equal or reversed timestamps fail closed because they provide no usable + /// interval. `maximum_window_seconds` is a separate local policy ceiling and + /// must be nonzero; signed material whose declared interval exceeds that + /// ceiling is rejected even if its timestamps are otherwise well-formed. + pub const fn new( + this_update_unix_seconds: u64, + next_update_unix_seconds: u64, + maximum_window_seconds: u64, + ) -> Result { + if next_update_unix_seconds <= this_update_unix_seconds { + return Err(RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds, + next_update_unix_seconds, + }); + } + if maximum_window_seconds == 0 { + return Err(RevocationMaterialFreshnessError::ZeroMaximumWindow); + } + + let window_seconds = next_update_unix_seconds - this_update_unix_seconds; + if window_seconds > maximum_window_seconds { + return Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds, + maximum_window_seconds, + }); + } + + Ok(Self { + this_update_unix_seconds, + next_update_unix_seconds, + maximum_window_seconds, + }) + } + + /// Return the signed time at which the revocation material becomes current. + #[must_use] + pub const fn this_update_unix_seconds(self) -> u64 { + self.this_update_unix_seconds + } + + /// Return the signed time at which this freshness window stops being usable. + #[must_use] + pub const fn next_update_unix_seconds(self) -> u64 { + self.next_update_unix_seconds + } + + /// Return the caller-selected maximum accepted signed-window duration. + #[must_use] + pub const fn maximum_window_seconds(self) -> u64 { + self.maximum_window_seconds + } + + /// Evaluate one trusted time against the half-open freshness window. + /// + /// A time before `thisUpdate` is not yet usable. A time equal to or later + /// than `nextUpdate` is stale. Both cases fail closed without making any + /// statement about the certificate's revocation state. + pub const fn evaluate( + self, + trusted_time_unix_seconds: u64, + ) -> Result<(), RevocationMaterialFreshnessError> { + if trusted_time_unix_seconds < self.this_update_unix_seconds { + Err(RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds, + this_update_unix_seconds: self.this_update_unix_seconds, + }) + } else if trusted_time_unix_seconds >= self.next_update_unix_seconds { + Err(RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds, + next_update_unix_seconds: self.next_update_unix_seconds, + }) + } else { + Ok(()) + } + } +} + +/// A deterministic reason that verified revocation material is not fresh enough to use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RevocationMaterialFreshnessError { + /// The supplied signed timestamps do not define a non-empty freshness window. + InvalidWindow { + /// Signed `thisUpdate` timestamp in Unix seconds. + this_update_unix_seconds: u64, + /// Signed `nextUpdate` timestamp in Unix seconds. + next_update_unix_seconds: u64, + }, + /// The caller supplied no positive local maximum freshness duration. + ZeroMaximumWindow, + /// The material's signed interval exceeds the caller's local freshness ceiling. + WindowExceedsMaximum { + /// Duration of the signed `thisUpdate` to `nextUpdate` interval in seconds. + window_seconds: u64, + /// Caller-selected maximum accepted interval in seconds. + maximum_window_seconds: u64, + }, + /// Trusted time falls before the material's signed `thisUpdate` timestamp. + NotYetValid { + /// Trusted evaluation time in Unix seconds. + trusted_time_unix_seconds: u64, + /// Signed `thisUpdate` timestamp in Unix seconds. + this_update_unix_seconds: u64, + }, + /// Trusted time is equal to or later than the material's signed `nextUpdate` timestamp. + Expired { + /// Trusted evaluation time in Unix seconds. + trusted_time_unix_seconds: u64, + /// Signed `nextUpdate` timestamp in Unix seconds. + next_update_unix_seconds: u64, + }, +} + +impl fmt::Display for RevocationMaterialFreshnessError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidWindow { + this_update_unix_seconds, + next_update_unix_seconds, + } => write!( + formatter, + "revocation material window is invalid: thisUpdate {this_update_unix_seconds} must be before nextUpdate {next_update_unix_seconds}", + ), + Self::ZeroMaximumWindow => write!( + formatter, + "revocation material maximum freshness window must be greater than zero", + ), + Self::WindowExceedsMaximum { + window_seconds, + maximum_window_seconds, + } => write!( + formatter, + "revocation material window is {window_seconds} seconds, exceeding the local maximum of {maximum_window_seconds} seconds", + ), + Self::NotYetValid { + trusted_time_unix_seconds, + this_update_unix_seconds, + } => write!( + formatter, + "revocation material is not usable at trusted time {trusted_time_unix_seconds}; thisUpdate is {this_update_unix_seconds}", + ), + Self::Expired { + trusted_time_unix_seconds, + next_update_unix_seconds, + } => write!( + formatter, + "revocation material is stale at trusted time {trusted_time_unix_seconds}; nextUpdate is {next_update_unix_seconds}", + ), + } + } +} + +impl std::error::Error for RevocationMaterialFreshnessError {} diff --git a/crates/originweave-tls/src/trust.rs b/crates/originweave-tls/src/trust.rs index f3e3374b6..32aa66e17 100644 --- a/crates/originweave-tls/src/trust.rs +++ b/crates/originweave-tls/src/trust.rs @@ -19,6 +19,7 @@ impl TrustBundleIdentifier { pub fn parse(input: &str) -> Result { if input.is_empty() || input.len() > 128 + || !input.bytes().any(|byte| byte.is_ascii_alphanumeric()) || !input.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-') }) diff --git a/crates/originweave-tls/tests/policy_contract.rs b/crates/originweave-tls/tests/policy_contract.rs index 5a8b71ef4..4fad353b3 100644 --- a/crates/originweave-tls/tests/policy_contract.rs +++ b/crates/originweave-tls/tests/policy_contract.rs @@ -25,7 +25,7 @@ fn trust_bundle_identifier_is_bounded_and_ascii() { TrustBundleIdentifier::parse("enterprise_roots:v1").expect("valid trust bundle identifier"); assert_eq!(identifier.as_str(), "enterprise_roots:v1"); - for invalid in ["", "contains space", "한글", "slash/value"] { + for invalid in ["", "contains space", "한글", "slash/value", "---"] { assert!(matches!( TrustBundleIdentifier::parse(invalid), Err(TlsError::InvalidTrustBundleIdentifier) diff --git a/crates/originweave-tls/tests/revocation_freshness.rs b/crates/originweave-tls/tests/revocation_freshness.rs new file mode 100644 index 000000000..c7af7bd7c --- /dev/null +++ b/crates/originweave-tls/tests/revocation_freshness.rs @@ -0,0 +1,119 @@ +use std::error::Error as _; + +use originweave_tls::{RevocationMaterialFreshness, RevocationMaterialFreshnessError}; + +const MAXIMUM_WINDOW_SECONDS: u64 = 300; + +#[test] +fn revocation_material_freshness_uses_a_half_open_verified_window() { + let freshness = RevocationMaterialFreshness::new(1_000, 1_100, MAXIMUM_WINDOW_SECONDS); + assert!(freshness.is_ok()); + + if let Ok(freshness) = freshness { + assert_eq!(freshness.this_update_unix_seconds(), 1_000); + assert_eq!(freshness.next_update_unix_seconds(), 1_100); + assert_eq!(freshness.maximum_window_seconds(), MAXIMUM_WINDOW_SECONDS); + assert_eq!(freshness.evaluate(1_000), Ok(())); + assert_eq!(freshness.evaluate(1_099), Ok(())); + assert_eq!( + freshness.evaluate(999), + Err(RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds: 999, + this_update_unix_seconds: 1_000, + }) + ); + assert_eq!( + freshness.evaluate(1_100), + Err(RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds: 1_100, + next_update_unix_seconds: 1_100, + }) + ); + } +} + +#[test] +fn revocation_material_freshness_rejects_empty_or_reversed_windows() { + for (this_update, next_update) in [(1_000, 1_000), (1_001, 1_000)] { + assert_eq!( + RevocationMaterialFreshness::new(this_update, next_update, MAXIMUM_WINDOW_SECONDS), + Err(RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds: this_update, + next_update_unix_seconds: next_update, + }) + ); + } +} + +#[test] +fn revocation_material_freshness_requires_a_bounded_local_policy_window() { + assert_eq!( + RevocationMaterialFreshness::new(1_000, 1_100, 0), + Err(RevocationMaterialFreshnessError::ZeroMaximumWindow) + ); + + let exact_maximum = RevocationMaterialFreshness::new(1_000, 1_300, MAXIMUM_WINDOW_SECONDS); + assert!(exact_maximum.is_ok()); + + assert_eq!( + RevocationMaterialFreshness::new(1_000, 1_301, MAXIMUM_WINDOW_SECONDS), + Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: 301, + maximum_window_seconds: MAXIMUM_WINDOW_SECONDS, + }) + ); + + assert_eq!( + RevocationMaterialFreshness::new(1, u64::MAX, 1), + Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: u64::MAX - 1, + maximum_window_seconds: 1, + }) + ); +} + +#[test] +fn revocation_freshness_errors_are_stable_and_source_free() { + let invalid = RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds: 1_000, + next_update_unix_seconds: 1_000, + }; + let zero_maximum = RevocationMaterialFreshnessError::ZeroMaximumWindow; + let too_long = RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: 301, + maximum_window_seconds: MAXIMUM_WINDOW_SECONDS, + }; + let future = RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds: 999, + this_update_unix_seconds: 1_000, + }; + let stale = RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds: 1_100, + next_update_unix_seconds: 1_100, + }; + + assert_eq!( + invalid.to_string(), + "revocation material window is invalid: thisUpdate 1000 must be before nextUpdate 1000" + ); + assert_eq!( + zero_maximum.to_string(), + "revocation material maximum freshness window must be greater than zero" + ); + assert_eq!( + too_long.to_string(), + "revocation material window is 301 seconds, exceeding the local maximum of 300 seconds" + ); + assert_eq!( + future.to_string(), + "revocation material is not usable at trusted time 999; thisUpdate is 1000" + ); + assert_eq!( + stale.to_string(), + "revocation material is stale at trusted time 1100; nextUpdate is 1100" + ); + + for error in [invalid, zero_maximum, too_long, future, stale] { + assert!(error.source().is_none()); + } +} diff --git a/docs/README.md b/docs/README.md index 775dd0de6..1ea57ad29 100644 --- a/docs/README.md +++ b/docs/README.md @@ -87,4 +87,12 @@ Proposed ADRs are reviewable architecture memory, not shipped behavior and not a The second group exists only on this documentation branch until the branch integrates. After integration, the heading remains useful historical provenance; it does not promote either ADR from Proposed to Accepted and it does not claim that the described runtime capability is implemented. +### Proposed decisions introduced by active feature work + +- [ADR 0016: BAP task lifecycle and state authority](adr/0016-bap-task-lifecycle-authority.md) + +ADR 0016 is owned by this active BAP lifecycle feature branch and remains Proposed. Its presence here makes the branch documentation graph complete without presenting the decision or implementation as protected-main truth before integration. + +After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016 from Proposed or assert implementation maturity. + See the [ADR index](adr/README.md) for status rules, required decision structure, supersession rules, and active feature ADRs. The index and each ADR's own status metadata must agree; a PR body, chat transcript, automation prompt, or stale issue reference cannot change ADR status. diff --git a/docs/adr/0016-bap-task-lifecycle-authority.md b/docs/adr/0016-bap-task-lifecycle-authority.md new file mode 100644 index 000000000..54fae8607 --- /dev/null +++ b/docs/adr/0016-bap-task-lifecycle-authority.md @@ -0,0 +1,123 @@ +# ADR 0016: BAP task lifecycle and state authority + +- **Status:** Proposed +- **Date:** 2026-08-22 +- **Supersedes:** None +- **Superseded by:** None + +## Context + +OriginWeave needs a deterministic lifecycle primitive for governed browser-agent work before durable BAP transport, persistence, idempotency, or crash recovery can be added safely. A task state is security-relevant because downstream components may use it to decide whether work may start, resume, complete, reconcile, or terminate. If adapters, persistence layers, browser drivers, or recovery code can mint state independently, OriginWeave would inherit ambient execution authority from whichever boundary supplied the most convenient state value. + +The `originweave-bap` crate therefore introduces a typed in-memory state machine with monotonic transition receipts and fail-closed recovery validation. The crate deliberately owns no browser, network, model, secret, approval, persistence, tenant-authentication, or protocol authority. External protocols may project lifecycle intent into this kernel, but protocol metadata cannot bypass its transition rules or upgrade a task's authority. + +## Decision drivers + +- Keep task-state authority explicit and deterministic rather than distributed across protocol adapters. +- Prevent stale, unreachable, or terminal lifecycle snapshots from reopening governed work. +- Preserve a monotonic transition sequence suitable for later durable replay evidence without claiming persistence today. +- Separate lifecycle state from browser, network, secret, model, approval, and tenant authority. +- Make waiting, checkpoint, reconciliation, completion, cancellation, expiry, and dead-letter behavior typed and testable. +- Keep recovery validation fail closed when a supplied state/sequence pair cannot arise from the reviewed state machine. + +## Assumptions and authority boundaries + +- The lifecycle is an in-memory logical primitive; it is not a durable task repository. +- Creating or restoring a lifecycle does not authenticate a caller, tenant, browser session, document, origin, destination, secret, model, approval, or external side effect. +- A transition receipt proves only what this in-memory lifecycle instance accepted. It is not durable audit evidence until a separate authenticated persistence boundary stores it. +- Waiting for approval is a lifecycle condition, not proof that approval exists. A later approval authority must independently authenticate and authorize any decision before resumption. +- `Succeeded` is entered only after a caller asserts that its separately governed post-condition has been verified; the lifecycle does not itself verify that post-condition. +- Reconciliation and dead-letter states preserve control-flow intent only. Durable reconciliation evidence remains the responsibility of a later persistence/recovery boundary. + +## Options considered + +### Let each BAP or MCP adapter own its own state machine + +Rejected. Adapter-local state machines would duplicate policy, make recovery semantics drift by protocol, and allow external protocol metadata to become implicit OriginWeave execution authority. + +### Store task state as an unrestricted string or integer + +Rejected. Untyped state admits unknown values, weakens exhaustive transition review, and makes invalid or stale recovery snapshots difficult to reject deterministically. + +### Allow restored state to resume whenever the state name looks resumable + +Rejected. State-only recovery loses monotonic history. A state/sequence pair that cannot be reached through the reviewed transitions must fail closed rather than becoming execution authority. + +### Centralize logical lifecycle transitions in a typed Rust kernel + +Selected. + +## Decision + +If Accepted, OriginWeave applies these lifecycle rules: + +1. **One typed kernel owns logical BAP task state.** `originweave-bap` is the canonical state-transition authority for the task lifecycle represented by this contract. Protocol adapters may request transitions but do not mint lifecycle state directly. +2. **Transitions are explicit and fail closed.** The kernel accepts only reviewed event/state combinations. Invalid events preserve the existing state and sequence and return a typed error. +3. **Terminal states never reopen.** `Succeeded`, `Failed`, `Cancelled`, `Expired`, and `DeadLettered` reject later lifecycle events. +4. **Waiting and checkpoint states require explicit resumption.** Approval wait, external-input wait, and checkpoint states do not silently become running work. +5. **Reconciliation is distinct from normal suspension.** A task in `ReconciliationRequired` cannot use the ordinary resume path; it requires explicit reconciliation resolution or governed dead-letter handling. +6. **Transition sequence is monotonic and bounded.** Every accepted transition advances the sequence exactly once. Sequence exhaustion fails closed instead of wrapping. +7. **Recovery validates reachability.** A supplied state/sequence snapshot must be reachable under the same reviewed state machine. Unreachable snapshots are rejected with a typed restore error. +8. **Lifecycle state grants no ambient authority.** A `Running`, resumable, or otherwise valid lifecycle state does not authorize browser I/O, network destinations, secret resolution, model access, approvals, external protocol operations, or tenant access. Those authorities must be revalidated by their owning boundaries. +9. **Durability is a separate owner.** This contract does not claim atomic persistence, idempotency, locking, authenticated replay evidence, side-effect reconciliation, or crash-safe recovery. Later durable components must bind those concerns to lifecycle receipts without weakening this state authority. +10. **External protocol state is projected, not inherited.** BAP, MCP, WebDriver BiDi, CDP, or other adapters may translate reviewed external events into typed lifecycle requests only after their own authentication and policy checks. External state labels cannot overwrite the kernel directly. + +## Consequences + +OriginWeave gains one reviewable state authority that later transport, idempotency, persistence, and recovery slices can compose without duplicating transition semantics. Invalid transitions and unreachable recovery snapshots have deterministic typed failures, while terminal and reconciliation states have explicit closure behavior. + +The trade-off is that adapters and durable stores must perform explicit mapping and validation instead of assigning state directly. The current slice also cannot claim commercial crash recovery until durable authenticated evidence and side-effect reconciliation are implemented separately. + +## Failure and degraded behavior + +- An invalid event returns a typed transition error and leaves state/history unchanged. +- A terminal lifecycle rejects all later events rather than reopening work. +- Sequence exhaustion returns a typed failure rather than wrapping or silently reusing an identifier. +- An unreachable restored state/sequence pair is rejected rather than normalized into a nearby valid state. +- Missing browser, tenant, policy, destination, secret, approval, persistence, or recovery authority is not converted into lifecycle success. +- If a future adapter cannot map external protocol state without ambiguity, it must fail closed or require reconciliation rather than inventing a lifecycle transition. + +## Security / privacy / governance impact + +This decision narrows authority. It prevents external protocol metadata, stale snapshots, or arbitrary state assignment from becoming execution authority and keeps lifecycle state separate from sensitive-data, secret, browser, network, model, approval, and tenant boundaries. The lifecycle stores no secret values or personal-data payloads by itself. Any future persistent representation must independently satisfy OriginWeave data-governance, retention, tenant-isolation, integrity, and evidence requirements. + +## Tests and acceptance evidence + +The owning branch must keep executable evidence for: + +- the reviewed created/admitted/running/waiting/checkpointed/reconciliation/terminal transition paths; +- fail-closed invalid transitions with no sequence advancement; +- terminal irreversibility; +- cancellation and expiry across allowed pre-dispatch and suspended states; +- explicit reconciliation resolution and governed dead-letter behavior; +- monotonic transition receipts and sequence-exhaustion failure; +- recovery acceptance for reachable snapshots and rejection for unreachable snapshots; and +- deterministic public Rust error contracts. + +Repository contracts must also require this ADR so the `originweave-bap` control-plane boundary cannot remain undocumented while the crate is present. Exact protected-main acceptance still depends on current-head CI, exact owned-production coverage, rustdoc, security evidence, review, live governance, and integration state; ADR presence does not substitute for those gates. + +## Migration and rollback + +No database migration is introduced. Existing callers on this branch construct the typed lifecycle directly. A future durable task repository should persist state and transition evidence in an authenticated form that can be validated by this kernel rather than introducing a second transition authority. + +Rollback before acceptance is removal of the active BAP lifecycle branch and its Proposed ADR. After acceptance, rollback or replacement must preserve fail-closed terminal/recovery semantics or explicitly supersede this ADR with a reviewed migration for any persisted lifecycle representation. + +## Open follow-ups + +- Bind durable idempotency receipts to exact accepted transitions without making retry metadata task authority. +- Define authenticated persistence, atomicity, and concurrency semantics for lifecycle plus command evidence. +- Define crash-recovery classification and reconciliation for ambiguous external side effects. +- Map authenticated BAP/MCP transport messages into typed lifecycle requests without ambient protocol authority. +- Propagate cancellation and expiry into real browser/process supervision only after the corresponding runtime authority exists. + +## Supersession / reversal conditions + +Supersede this ADR if OriginWeave replaces the BAP lifecycle model, introduces a materially different durable event-sourced task authority, or moves canonical task-state ownership to another reviewed component. A successor must preserve explicit state authority, terminal fail-closure, monotonic recovery evidence, and the rule that lifecycle state cannot mint unrelated browser/network/secret/model/approval/tenant authority. + +## References + +ContextualWisdomLab. (2026). *OriginWeave architecture* [Repository specification]. *OriginWeave*. [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) + +ContextualWisdomLab. (2026). *OriginWeave architecture decision records* [Repository specification]. *OriginWeave*. [`README.md`](README.md) + +ContextualWisdomLab. (2026). *Agent development contract* [Repository specification]. *OriginWeave*. [`../../AGENTS.md`](../../AGENTS.md) diff --git a/docs/adr/0106-provenance-evidence-model.md b/docs/adr/0106-provenance-evidence-model.md index 0e2741f37..09cb0d7ca 100644 --- a/docs/adr/0106-provenance-evidence-model.md +++ b/docs/adr/0106-provenance-evidence-model.md @@ -33,29 +33,47 @@ OriginWeave maintains provenance-native evidence with stable identifiers for ses WARC and PROV are interoperability/export contracts, not substitutes for OriginWeave's internal authorization or evidence schema. A WARC record can contain untrusted or sensitive payload bytes and therefore inherits capture, retention, encryption, and export policy. A PROV entity/activity/agent relation records derivation or responsibility; it cannot manufacture authentication, authorization, durable completion, or tenant ownership not established by the producing system. +### Versioned extraction-schema binding + +A versioned `ExtractionSchema` is the binding contract for typed extraction before any capture persistence or export format is allowed to claim semantic authority. Each schema version contains an ordered, non-empty set of unique `ExtractionField` definitions. Schema-version and field identifiers are bounded to 128 encoded bytes, begin with a lowercase ASCII letter, and thereafter admit only lowercase ASCII letters, digits, `_`, or `-`. One schema admits at most 256 fields. + +Every extraction field binds its stable identifier to a value type, cardinality, required/optional status, deterministic normalization rule, and a non-empty duplicate-free set of reviewed source-channel classes. Cardinality and required status form one internally consistent presence contract: `One` is necessarily required, `ZeroOrOne` is necessarily optional, and `Many` may be marked required or optional because this value-object layer does not yet define a minimum collection item count. Contradictory `One`/optional or `ZeroOrOne`/required declarations fail closed during field construction. `Verbatim` is the compatibility default used by the existing constructor. `TrimTextWhitespace` is admitted only for text fields and `Rfc3339Utc` only for timestamp fields; type-incompatible normalization fails closed. A `ModelInterpretation` source channel is classification metadata only and does not grant model execution, approval, disclosure, browser, network, secret, or storage authority. + +At this value-object boundary, the version identifier is immutable schema identity; there is deliberately no registry that silently treats two different field contracts as compatible merely because their version strings compare or sort in a particular way. Callers changing a field identifier, value type, cardinality, required status, normalization rule, or admitted source-channel set must use a distinct reviewed schema version and perform any migration/compatibility decision at an explicit higher layer. The current schema object does not itself read browser data, materialize extracted values, persist artifacts, execute models, or change governance policy. Those capabilities require separately authorized runtime boundaries and are not implied by schema construction. + ## Consequences Capture becomes a designed product surface rather than incidental logging. Storage and retention need budgets. Consumers can distinguish a model claim from source evidence and an action request from verified completion. Export adapters can target WARC, provenance graphs, audit streams, or buyer-specific schemas. +A schema consumer can also determine the exact field/type/cardinality/normalization/source contract it reviewed rather than relying on free-form extraction instructions. Schema evolution is explicit instead of being inferred from mutable field definitions; runtime compatibility, migrations, durable storage, and extracted-value validation remain separate implementation work until those boundaries are delivered. + ## Failure and degraded behavior If mandatory evidence cannot be recorded durably enough for a governed state-changing action, the action fails before execution or reports an explicit unverifiable failure; it is never marked proved. Read-only operations may degrade to reduced evidence only when the API contract declares that mode. Corrupt or incomplete evidence is quarantined rather than silently accepted. +Invalid or oversized extraction identifiers, contradictory cardinality/required declarations, empty or duplicate field sets, missing or duplicate source channels, and type-incompatible normalization rules fail during schema construction. A caller must not reinterpret such a failure as an empty/default-success schema or silently substitute another source channel. + ## Security / privacy / governance impact Evidence is tenant-scoped, selectively disclosed, encrypted as appropriate, retention-bounded, and auditable. Credential-bearing headers, cookies, secret values, and sensitive form data are excluded or transformed according to explicit schema policy. Integrity metadata and immutable artifact identities support tamper detection without claiming external certification. `docs/DATA_GOVERNANCE.md` defines the disclosure/retention boundary for protected content and derived artifacts. +The extraction-schema contract does not modify governance authority. It describes admissible typed fields and reviewed evidence-channel classes only. In particular, declaring `NetworkResponse` or `ModelInterpretation` does not authorize network access, model execution, protected-data disclosure, approvals, retention, or export; those remain governed by their existing owning boundaries. + ## Tests and acceptance evidence Require provenance-link tests, credential-leak tests, integrity/corruption tests, crash-recovery tests, WARC/export conformance where implemented, PROV relation/schema tests where implemented, retention/deletion tests, tenant-isolation tests, and end-to-end checks that state-changing actions link request, policy, approval, execution, and post-condition as separate records. Export tests must prove that disabled or unauthorized source bodies never appear merely because metadata provenance is exportable. +The extraction-schema boundary additionally requires tests for the identifier grammar and limits, field-count bound, duplicate identifiers, source-channel presence and uniqueness, every reviewed value/cardinality/source-channel variant, consistent cardinality/required combinations and contradictory-combination rejection, deterministic normalization selection, incompatible normalization rejection, and the backward-compatible `Verbatim` constructor default. + ## Migration and rollback Introduce stable evidence identifiers and schema versions before changing export formats. Migrations preserve old evidence semantics or explicitly mark unavailable fields. Rollback may revert an exporter but cannot collapse mandatory action and policy evidence into opaque logs. +Extraction contract changes that alter field identity or semantics require a new reviewed schema version rather than mutating the meaning of an existing version. Rolling back a consumer may stop accepting a newer version, but it must not reinterpret that newer contract as an older one or silently discard required fields. + ## Open follow-ups -Finalize canonical evidence schemas, content-retention defaults, signing/attestation strategy, cross-system export identifiers, and buyer-controlled disclosure policies. +Finalize canonical evidence schemas, content-retention defaults, signing/attestation strategy, cross-system export identifiers, and buyer-controlled disclosure policies. Add the runtime that validates concrete extracted values against an `ExtractionSchema`, plus explicit migration/compatibility policy when durable schema registration is introduced. ## Supersession / reversal conditions diff --git a/docs/adr/0107-browser-protocol-adapter-strategy.md b/docs/adr/0107-browser-protocol-adapter-strategy.md index e3c0bf657..fb1bf2e17 100644 --- a/docs/adr/0107-browser-protocol-adapter-strategy.md +++ b/docs/adr/0107-browser-protocol-adapter-strategy.md @@ -36,11 +36,13 @@ MCP version negotiation is independent of the OriginWeave Protocol version. As o ### 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. +The complete MCP adapter remains **Planned**. Protected main now contains the narrower bounded Rust `tools/call` routing/action-policy foundation merged through PR #168. That protected-main foundation 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. +Active PR #170 is a separate non-shipped refinement on top of that protected-main catalog. It adds one conservative typed `tools/list` request/result contract: both protocol-version fields are required and bounded before comparison, client-capability metadata must be present without becoming authority, both routing/body methods are syntax-bounded before correlation, only exact `tools/list` is admitted, and every caller-supplied cursor is rejected because the current fixed catalog issues none. The result is one complete page with zero freshness, private cache scope, and no continuation cursor. -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. +Neither protected main nor PR #170 implements Streamable HTTP transport parsing, JSON-RPC/HTTP serialization, OAuth, browser I/O, WebMCP/BiDi/CDP translation, secret delivery, persistence, general pagination/subscription state, or a complete OriginWeave Protocol adapter. Those remain separate adapter/runtime work. Protected `main` may therefore describe only the bounded merged `tools/call` foundation as implemented; the full MCP adapter remains planned, and the `tools/list` refinement remains active-PR evidence until separately integrated. + +The version boundary is explicit: the protected-main routing foundation and active discovery refinement accept only MCP `2026-07-28`; neither infers compatibility with later protocol generations. OriginWeave Protocol versioning remains independent and cannot be changed by MCP metadata. ## Consequences @@ -58,7 +60,7 @@ Protocol validation occurs before messages influence policy. Tool/page-provided 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. +For the protected-main `tools/call` foundation, acceptance includes 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 integrated review evidence from PR #168. For active PR #170, exact-current acceptance additionally requires bounded protocol metadata before cross-field comparison, required client-capabilities presence, bounded `tools/list` method correlation, rejection of unissued cursors, deterministic result/cache semantics, 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 @@ -66,7 +68,7 @@ Adapters are independently versioned and can be canaried. Clients migrate throug ## Open follow-ups -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. +Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, complete MCP Streamable HTTP/request-metadata validation, MCP transport serialization, authenticated deployment, and MCP/WebMCP schema isolation. ## Supersession / reversal conditions diff --git a/docs/adr/README.md b/docs/adr/README.md index 416231b1c..5f9e2a878 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,6 +57,16 @@ Proposed ADR files are reviewable target architecture without becoming Accepted ADR 0013 and ADR 0014 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; both decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. +### Proposed decisions introduced by active feature work + +| ADR | Decision | Status | Governs | +|---|---|---|---| +| [0016](0016-bap-task-lifecycle-authority.md) | BAP task lifecycle and state authority | Proposed | BAP task states, transitions, recovery validation, transition sequencing, and authority separation | + +ADR 0016 belongs to the active BAP lifecycle feature branch. Indexing it makes the branch documentation graph complete while preserving its Proposed lifecycle and active-PR, non-protected-main maturity. + +After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016 from Proposed or assert implementation maturity. + Other active feature PRs may contain additional Proposed ADRs. Those files are not part of this canonical documentation line until integrated or deliberately reconciled here. Historical PR checks, stale branch state, or chat decisions never transfer ADR acceptance across a changed head. ## Index completeness rule diff --git a/docs/doctoring.md b/docs/doctoring.md index 693840f63..ec51daaf3 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -24,6 +24,12 @@ RFC 6454 defines a web origin as the scheme, host, and port tuple that browsers RFC 9700 is the current Best Current Practice for OAuth 2.0 security. It requires access tokens to be restricted in lifetime and treats long-lived bearer credentials as a standing authorization risk. An OriginWeave `extension_grant` that matches extension identity, session, browsing context, and canonical origin but has no exclusive expiry remains usable after the Agent Task window ends. OriginWeave therefore requires the grant to carry an exclusive `expires_at_epoch_seconds` deadline and the request to carry trusted `now_epoch_seconds`. Evaluation fails closed when `now >= expires_at`, matching the existing sensitive-handle exclusive-expiry rule. Page, extension, and model clocks are not trusted time. This slice does not bind task identity, install an extension, or mint Agent capabilities from Manifest V3 permissions. +### Release-limitation presentation safety + +Unicode 17.0 defines `Default_Ignorable_Code_Point` in the Unicode Character Database and records the exact derived set in the versioned `DerivedCoreProperties.txt` data file. Those characters can be invisible or alter presentation without supplying an ordinary visible glyph. OriginWeave therefore treats the Unicode 17.0 derived property as a pinned presentation-safety input for buyer-visible release-limitation metadata, in addition to rejecting control characters and non-canonical leading or trailing whitespace. The admitted text is not silently normalized: accepted content retains its exact bytes, while ambiguous presentation characters and surrounding whitespace fail closed so one release claim cannot acquire multiple stored spellings. This is a bounded metadata-identity policy, not a claim of complete Unicode spoofing resistance or semantic text equivalence. + +Unicode Standard Annex #15, revision 57 for Unicode 17.0.0, defines canonical equivalence and NFC and states that normalized equivalent strings have a unique binary representation. A release limitation is an identity-bearing buyer artifact, so OriginWeave rejects canonically equivalent non-NFC spellings instead of silently rewriting them. The production boundary uses only `unicode_normalization::is_nfc`; accepted strings remain byte-for-byte caller input. Rust's standard library does not provide Unicode normalization, so `unicode-normalization` is pinned exactly to 0.1.25. The reviewed crate implements UAX #15 normalization, declares Rust 1.36+ compatibility (below OriginWeave's Rust 1.97.1 baseline), is dual MIT/Apache-2.0 licensed, and adds only `tinyvec`/`tinyvec_macros` transitively in this workspace lockfile. The dependency is narrow, deterministic, non-networked, and maintained through the existing locked-dependency/security-scan process; any future Unicode-version or crate-version movement requires renewed normalization and supply-chain review. + ### Resolved destination and redirect safety Canonical origin identity is not a network-destination authorization. The IANA IPv4 and IPv6 Special-Purpose Address Space registries enumerate blocks whose source, destination, forwardability, globally reachable, and protocol-reserved properties differ. Both registries were last updated on 9 October 2025 and explicitly warn that registry presence does not guarantee routability in a particular local or global context. RFC 6890 established the common special-purpose registry fields, and RFC 8190 replaced the ambiguous `global` field with `globally reachable`. @@ -84,6 +90,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. +The versioned `ExtractionSchema` is an admission and interpretation contract for typed extracted fields: each field is bounded, declares a value type, cardinality, normalization rule, and a canonical duplicate-free set of reviewed source-channel classes. That declaration does not create browser, network, model, secret, storage, retention, disclosure, or governance authority. PROV/WARC interoperability is therefore layered after the schema contract rather than inferred from it. + 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 @@ -174,6 +182,12 @@ The Rust Project Developers. (2026). *Ipv6Addr in std::net* (Rust 1.97.1) [Softw The Rust Project Developers. (2026). *TcpStream in std::net* (Rust 1.97.1) [Software documentation]. https://doc.rust-lang.org/stable/std/net/struct.TcpStream.html +The Unicode Consortium. (2025). *DerivedCoreProperties-17.0.0.txt* [Data file]. https://www.unicode.org/Public/17.0.0/ucd/DerivedCoreProperties.txt + +The Unicode Consortium. (2025, July 30). *Unicode Standard Annex #15: Unicode normalization forms* (Revision 57, Unicode 17.0.0). https://www.unicode.org/reports/tr15/ + +Unicode-RS Project Developers. (2025). *unicode-normalization 0.1.25* [Computer software]. https://docs.rs/unicode-normalization/0.1.25/unicode_normalization/ + Web Hypertext Application Technology Working Group. (2026). *URL standard*. https://url.spec.whatwg.org/ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ @@ -182,4 +196,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 \ No newline at end of file +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 diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 5173a32e6..052ecf2aa 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -1,6 +1,6 @@ # Browser and Agent Protocol Standards Evidence -- **Reviewed:** 2026-08-10 +- **Reviewed:** 2026-08-18 - **Purpose:** primary-source evidence for OriginWeave browser compatibility and adapter boundaries - **Canonical research index:** [`../doctoring.md`](../doctoring.md) @@ -38,6 +38,10 @@ Primary sources: Chrome for Developers, *WebMCP*; *WebMCP tool security*; *Agent The Model Context Protocol project released specification version `2026-07-28` on 28 July 2026. That release moved the protocol core toward stateless request/response operation and removed the earlier protocol-session assumptions described by previous releases. OriginWeave therefore keeps durable browser state in explicit OriginWeave application handles and exposes MCP only as a high-level adapter to the Rust runtime. MCP clients or servers do not connect models directly to Chromium/CDP authority. +The final `2026-07-28` schema requires every client request to carry `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities` in request `_meta`; client capabilities are request-scoped and servers must not infer them from prior requests. `io.modelcontextprotocol/clientInfo` is optional/SHOULD rather than authorization evidence. For Streamable HTTP, `MCP-Protocol-Version` must agree with the body protocol version, `Mcp-Method` is required for every request, and `Mcp-Name` is required only for named operations such as `tools/call`, `resources/read`, and `prompts/get`, not `tools/list`. OriginWeave's typed `tools/list` admission boundary therefore independently requires the transport protocol-version header and body `_meta` protocol version, rejects disagreement or an unsupported generation, requires per-request client-capabilities presence without treating its contents as OriginWeave authority, validates routing/body `tools/list` method agreement, and does not invent a name header. It rejects any supplied cursor because the current fixed catalog emits no `nextCursor`; this is a conservative local invariant against accepting pagination state OriginWeave never issued, not a claim that MCP forbids `tools/list` cursors generally. + +The same specification requires every Result to carry `resultType`, using `complete` for a terminal result, and adds explicit cache hints for cacheable result families including `tools/list`: `ttlMs` expresses freshness lifetime and `cacheScope` expresses whether reuse is private or shareable. OriginWeave's first typed `tools/list` result therefore binds `resultType = complete`, chooses the conservative boundary `ttlMs = 0` and private scope, derives the page directly from the reviewed tool catalog, and emits no continuation cursor for the current fixed single-page catalog. These metadata choices do not grant tool authority and do not claim JSON-RPC serialization, transport caching, OAuth, or a general pagination implementation. + Primary sources: Model Context Protocol, *2026-07-28 Specification* and the maintainers' official release announcement. ## Provenance standards @@ -51,8 +55,10 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re 3. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. 4. Keep WebMCP experimental/optional and propagate untrusted-content semantics. 5. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority. -6. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. -7. Treat WARC/PROV as provenance representations, not policy or truth escalation. +6. Require modern MCP per-request protocol version and client capabilities from request `_meta`; on Streamable HTTP require the matching protocol-version header and exact method routing, while treating optional client identity metadata as non-authoritative. +7. Bind mandatory MCP result disposition and cacheable-list metadata to reviewed typed results; use a complete terminal result with zero freshness and private scope unless a separate reviewed policy proves broader semantics safe. Reject a `tools/list` cursor while the current fixed page has never issued one. +8. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. +9. Treat WARC/PROV as provenance representations, not policy or truth escalation. ## References — APA 7th @@ -76,4 +82,4 @@ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.o World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ -International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html +International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html \ No newline at end of file diff --git a/docs/doctoring/mv3-compatibility.md b/docs/doctoring/mv3-compatibility.md index 571c49329..c43c5340a 100644 --- a/docs/doctoring/mv3-compatibility.md +++ b/docs/doctoring/mv3-compatibility.md @@ -8,6 +8,8 @@ OriginWeave uses Chromium as its compatibility kernel, so browser-extension comp The checked-in fixture is intentionally local-only. Its host permission is limited to loopback HTTP used by the deterministic test server. It contains no remote code, user credential, model call, external content, native-messaging host, or production PII. Chrome permissions remain distinct from the explicit OriginWeave extension-to-Agent grant implemented in `originweave-core`. Compatibility mutation tests create only controlled synthetic state inside the ephemeral test profile and must clean it up; successful API compatibility never grants the OriginWeave Agent ambient bookmarks/history/downloads authority. +The runner treats expected `http.client.HTTPException` transport failures, including truncated ChromeDriver responses, as failed trials and continues to emit bounded aggregate evidence. It does not classify such a run as successful: the repeatability gate still fails when the required trial count is not met. + ## Supported-capability evidence matrix This matrix separates protected-main executable evidence from active, non-shipped evidence and from genuinely unproven surfaces. A row marked **ACTIVE_PR** is never a release claim; exact head/run provenance belongs in `docs/evidence/2026-08-10-active-pr-maturity.md` and must be refreshed when the branch changes. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 234e6ae5c..8a702c75f 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,56 +2,75 @@ 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 +## Observed snapshot: 2026-08-26 ### 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. +- Protected `main` is at `b05d5acca82b9d916ada2c8e82f59f92a89817e1` for this snapshot. Since the 2026-08-24 observation (`0841d2ab`), protected `main` absorbed #196 (dated gap baseline publication), #216 (RFC 3986 evidence-path syntax enforcement), #194 (branch-coverage nightly and toolchain tracking refresh), #168 (typed MCP stateless tool-routing foundations), and #151 (exact crash-root termination before crash credit). +- Phase 0 remains 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`. +The live repository contained **126 open pull requests: 54 non-draft and 72 draft** when this snapshot re-paginated the complete open inventory. Compared with the prior **2026-08-24 158-PR snapshot**, the current inventory is 32 PRs smaller. Intervening queue consolidation includes #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 being merged into their immediate stacked prerequisites, while PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review. Those transitions are queue consolidation, not protected-main delivery; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`, with 13 open issues and no releases or tags. The volume and stack depth remain 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`. + +#### 2026-08-26 maintenance-loop record + +The interactive maintenance loop performed the following verified state changes on exact heads; none of them is protected-main behavior until merged: + +| Action | Exact evidence | +|---|---| +| Supersession closure | #153 closed with replacement evidence: base-stack tip (`4da223ac`) already implements `_terminate_owned_process_bounded` exit-race tolerance that supersedes the branch delta | +| Conflict reconciliation | Merge commits pushed to #37 (`27f6acd6`, ci.yml aligned to reviewed `nightly-2026-08-18` pin), #149 (`7852a540` + rustfmt fix `54f96008`), #152 (`65b0c705`), #173 (`ecc9574a`), #175 (`765c88f6`, keeps `crate_root.rs` naming) | +| Governance remediation (#212) | #43 reconciled with main in `04e262d5`; the `chrome_sandbox` workflow mutation was first removed, then restored under recorded independent authorization (issue #212 option (b)) because the PR's own contract test fails closed without it; fresh exact-head checks re-ran on the restored head | +| Security finding fix (#124) | Strix vuln-0001 (Unicode homoglyph path confusion, MEDIUM) remediated in `30cc458b`: audited workflow paths now restricted to a canonical ASCII alphabet with homoglyph/fraction-slash/fullwidth regression contract tests; CHANGELOG updated | +| Fail-closed provider re-dispatch | ~21 failed Strix required-check runs re-dispatched on unchanged exact heads; completed reruns returned success on #46, #48, #156, #157, #159, #218, and #219 heads at snapshot time; cancellations only where newer heads superseded the run | +| Current-head review re-dispatch | Central merge-scheduler dispatches sent for #47, #62, #63, #65, #74, #166, #173, #175, and #220 because their stale `CHANGES_REQUESTED` verdicts cited coverage-evidence results that are green on the same heads today | + +#### Organization review-pipeline congestion record + +Between 2026-08-26T02:44Z and 2026-08-26T03:35Z the organization-wide Actions queue exhibited a systemic backlog: scheduler, OpenCode-review-dispatch, Noema, and Strix runs across `.github`, `naruon`, `pg-erd-cloud`, and OriginWeave sat `queued`/`pending` while only single-digit runs were `in_progress`. This delays every current-head AI review and therefore every ruleset-gated merge. It is an infrastructure-capacity signal, not a code defect, and it does not authorize merging without current-head review evidence. 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 | +| Product baseline | (merged: #196 on 2026-08-24) | Baseline publication reached protected `main`; this document is its successor snapshot | +| Presentation identity | #229 at `585a7d5545b13f18d76f79100ff4d47ac423e861` onto `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | Ready/non-draft local privacy kernel; all observed exact-head checks except Strix passed, but the PR remains blocked and review-required, and no Chromium adapter or protected-main shipment is claimed | +| Enterprise approval authority | #220 | Ready/non-draft bounded maker-checker approval lifecycle on the exact `ApprovalScope`; all current-head checks green at snapshot, awaiting current-head review evidence | +| Release artifact identity | #218 and #219 | Ready/non-draft fail-closed benchmark release decision and canonical release manifest binding; Strix provider-failure reruns completed green on both heads | +| Schema-bound extraction and BAP lifecycle | #209 and #208 | Ready/non-draft schema-bound extraction contract and resumable task-lifecycle kernel; #209 Strix rerun green, #208 rerun re-dispatched after a further provider failure | +| WebDriver BiDi transport | #188 through #205 | Active stack whose top #205 merged into its prerequisite branch, not protected `main`; it exercises framed `locateNodes` exchange over a bounded WebSocket opening path, but authenticated browser-process provenance, semantic task execution, and protected-main shipment remain unproven | +| MCP adapter | (#168 merged) and #170 | Typed MCP routing foundations are protected-main behavior since 2026-08-24; conservative `tools/list` cache metadata remains active-PR evidence with a Strix rerun in flight | +| Workflow-registry audit | #124 | Real Strix finding vuln-0001 (Unicode homoglyph path confusion, MEDIUM) remediated on head `30cc458b` with regression contract tests; fresh exact-head checks and review re-running | +| Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#152 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 | +| Manifest V3 and native messaging | #27, #43 governance remediation, and the extension/native-host stack including #154 and #169 | Compatibility and Agent-authority isolation remain incomplete until exact release artifacts and platform matrices are proven; #43's sandbox workflow mutation is now owner-authorized under issue #212 option (b) | | 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 | +| VPN/profile intent | #149 | Bounded WireGuard/IKEv2 profile authority reconciled with main (`54f96008`); 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. +PR #205 head `f427aa69151987d7e3369bd96d5739ea38d0f7ad` merged as `6c5ef5e2079d54c617183ecfa757e406f48f0aea` into stacked prerequisite branch `feat/webdriver-bidi-websocket-frame-transport` at base `c1bc7e78f3a9debf4f517fb6b5f11dd67be4ad92`. Its successful exact-head checks are stacked-branch integration evidence only; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`. #### 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: +The following newest 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` | +| #220 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e0740a6f3a41067a4460249378e0266815018a74` | +| #219 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` | +| #218 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `911ea33d8a5aca7673307bb6fdcad4b450f5c111` | +| #209 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `b35d739017aa5d361b605be48045be50b5a35f6f` | +| #208 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` | +| #124 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `296ad25bb541023dbc869ae07ae1d853820f83a4` | -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. +These rows are delivery evidence only. None has counted independent approval in the current collaborator inventory, and predecessor rows from earlier snapshots are retained below as regression anchors that must never be promoted to current-head evidence. -#### Refreshed exact-head active PR evidence: 2026-08-24 +#### Regression-anchor exact-head evidence: superseded 2026-08-24 rows -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: +The following rows were current on 2026-08-24 and are retained only as regression anchors; every listed head has since been superseded or merged and must never be promoted to current-head evidence: | PR | State | Exact base head | Exact head | |---|---|---|---| @@ -72,7 +91,9 @@ The stack topology shows #209 → #210 → #217 → #222 (WARC/PROV chain), #208 ### 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. +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 and again on 2026-08-26. This is a provider-infrastructure failure record, not a weakening of the fail-closed gate or a substitute for a completed authoritative scan. + +On 2026-08-26 rerun outcomes were verified per run: completed reruns returned `success` on the heads of #46, #48, #156, #157, #159, #218, and #219; several earlier runs for #37, #43, and #149 were cancelled only because conflict-reconciliation pushes created newer heads with fresh scans; remaining reruns were still in flight at snapshot time. One rerun (#124) produced a real MEDIUM finding (vuln-0001) instead of provider noise; that finding was remediated on the branch head rather than suppressed, preserving the fail-closed contract. #### #195/#198 WebDriver BiDi opening path status @@ -80,15 +101,15 @@ Phase 1 is **in progress**, not shipped. #195 and #198 provide bounded WebSocket #### #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. +PR #149 is a ready (non-draft) pull request whose conflict reconciliation and rustfmt correction landed on head `54f96008` on 2026-08-26; it still only describes bounded WireGuard/IKEv2 profile authority and 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. +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; this loop exercised that policy by closing superseded #153 with replacement evidence. ### 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. +The active `CWL Central required workflows` ruleset (re-fetched for this snapshot) requires one approving review, resolved review threads, no last-push approval requirement, `merge`/`squash` merge methods, and seven configured required workflows (`close-empty-pr`, `opencode-review`, `pr-review-merge-scheduler`, `security-scan`, `strix`, `sast-semgrep`, `noema-review`). 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. +This gap does not authorize self-approval, stale-head merges, administrative bypass, or weaker checks. Because the current GitHub ruleset independently requires a counted approval, the solo-maintainer hold does not satisfy the live merge gate: an eligible non-author collaborator must submit a formal `APPROVED` review on the current head. Until that reviewer-provisioning gap is repaired, protected-main merges stop even when exact-head checks, security gates, complete coverage, rustdoc/Clippy, threads, and AI-review evidence are otherwise complete. 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 @@ -100,7 +121,7 @@ This gap does not authorize self-approval, administrative bypass, stale-head mer | #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 | +| #212 | Governance: remove or independently authorize the PR #43 MV3 workflow mutation — **option (b) executed 2026-08-26** with owner-directed authorization recorded on the issue and the mutation restored on the reconciled branch; re-evaluate if the authorization record is contested | | #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 | @@ -127,7 +148,7 @@ The hourly product-development loop is operational infrastructure, not proof tha | 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 | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 126-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | ## Commercial completion definition @@ -146,9 +167,9 @@ OriginWeave is not complete merely because every low-level primitive exists in s ## 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. +1. Drain the merge gate in dependency order: for every ready root PR whose current head is check-green with resolved threads, obtain the current ruleset's counted `APPROVED` review from an eligible non-author collaborator; OpenCode approval or skip evidence does not substitute for that GitHub review. If no eligible approver exists, record the reviewer-provisioning gap and do not merge. Root candidates include #37, #40, #43, #45–#48, #51, #62–#65, #74, #82, #124, #149, #152, #156–#166, #170, #173, #175, #208, #209, #218, and #219 as their re-dispatched checks land. Treat dependent children separately: only after a predecessor reaches protected `main`, retarget and independently revalidate its immediate child; preserve orders such as #218 → #221 → #220 rather than treating #208–#220 as a flat merge range. +2. Keep the organization review pipeline healthy: monitor the central Actions backlog recorded above; if OpenCode reviews stop landing on OriginWeave heads while the queue is idle, repair `ContextualWisdomLab/.github` dispatch/concurrency configuration rather than weakening any gate. +3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #181–#205 WebSocket opening path and framed BiDi command/response stack, then semantic observation, policy, action, post-condition, and recovery boundaries on protected `main`. 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. @@ -322,4 +343,4 @@ 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 +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. diff --git a/docs/traceability/action-postcondition-evidence.md b/docs/traceability/action-postcondition-evidence.md index d8b69873a..fe26f2c2a 100644 --- a/docs/traceability/action-postcondition-evidence.md +++ b/docs/traceability/action-postcondition-evidence.md @@ -59,7 +59,7 @@ This remains controlled test infrastructure rather than browser-execution eviden **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. +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 fixture-shutdown contract also exercises successful MV3 and Agent Task trial paths before proving reverse-order cleanup after one server stop fails. 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. @@ -67,7 +67,7 @@ This is real WebDriver evidence for a controlled local fixture, not a product br **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. +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, and the cleanup regression double supplies both success surfaces so teardown failures cannot mask incomplete evidence. 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. @@ -75,7 +75,7 @@ This is bounded browser-computed evidence for a synthetic test target, not the O **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. +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; its shutdown regression double supplies every required semantic and resource surface before cleanup failures are exercised, so incomplete evidence cannot pass incidentally. This makes 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. @@ -83,7 +83,7 @@ This is resource evidence for the active test harness, not process-set attributi **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. It also contains the local fixture server within its configured root and records only a bounded failure type when a trial fails. +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. @@ -149,4 +149,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, 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. +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. None of these 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 index ddbd5927c..94f181ed4 100644 --- a/docs/traceability/mcp-authority-route.md +++ b/docs/traceability/mcp-authority-route.md @@ -1,35 +1,38 @@ # 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 +- **`tools/call` capability maturity:** `IMPLEMENTED_ON_PROTECTED_MAIN` +- **`tools/list` capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` +- **Protected-main owning work:** merged PR #168 `feat(mcp): bind stateless tool routing to typed actions` +- **Active follow-on:** PR #170 `feat(mcp): expose conservative tools list cache contract` - **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. +Protected main at `b05d5acca82b9d916ada2c8e82f59f92a89817e1` contains the bounded Rust control-plane foundation for MCP `2026-07-28` `tools/call` routing that merged through PR #168. 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. +Active PR #170 builds on that protected-main catalog with a conservative typed `tools/list` request/result boundary. Its current branch requires matching MCP protocol metadata, required client-capability presence, bounded and syntax-validated routing/body methods, exact `tools/list` routing, and no caller-supplied cursor because the fixed catalog issues none. Its result is one complete page with zero freshness, private cache scope, and no continuation cursor. This active-PR slice remains non-shipped until it reaches protected main and does not grant any OriginWeave action authority. + ## 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. +`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 the bounded `tools/call` foundation now on protected main or by active PR #170: both are reusable control-plane contracts below the complete product adapter. `README.md` and `CHANGELOG.md` distinguish protected-main routing from the active discovery refinement, and ADR 0107 records the protocol/version and authority boundary. -The following remain outside PR #168 and must not be inferred from it: +The following remain outside protected main and PR #170 and must not be inferred from either: - 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; +- JSON-RPC/HTTP response serialization of the typed discovery page; - 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 +- persistence, durable audit storage, or WARC/PROV export; +- general pagination/subscription state beyond the fixed no-cursor catalog; 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 protected-main routing foundation and active discovery refinement accept 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: @@ -39,15 +42,17 @@ The canonical bibliography remains `docs/doctoring.md`. ## Executable evidence -Current PR #168 production/test surfaces include: +Protected-main 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. +Active PR #170 additionally exercises its discovery contract in `crates/originweave-core/tests/mcp_tools_list_cache.rs`, including result/cache semantics, required protocol/client metadata, bounded protocol and method validation, routing correlation, cursor rejection, and public error contracts. + +Exact current-head CI/security/review evidence must be regenerated after every branch mutation. Protected-main evidence proves only the merged `tools/call` foundation; predecessor or protected-main results are not current-head proof for active PR #170. ## 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. +The bounded `tools/call` routing foundation is already `IMPLEMENTED_ON_PROTECTED_MAIN`. The `tools/list` discovery refinement may change to `IMPLEMENTED_ON_PROTECTED_MAIN` only after PR #170 reaches protected `main` under live governance and exact-head acceptance. Neither promotion makes the complete MCP adapter implemented; 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 bb0586655..b7d3cc4d1 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -7,17 +7,15 @@ 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, 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. +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. """ from __future__ import annotations -import contextlib -import hashlib import http.client import http.server import json @@ -47,8 +45,6 @@ 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 + "-_.") @@ -243,60 +239,6 @@ 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.""" @@ -891,12 +833,7 @@ 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_by_accessible_role_name( - driver_port, - session_id, - "textbox", - "Task text", - ) + input_element = _find_element(driver_port, session_id, "#task-text") input_role, input_name = _get_element_semantics( driver_port, session_id, @@ -904,11 +841,10 @@ 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_by_accessible_role_name( + submit_element = _find_element( driver_port, session_id, - "button", - "Submit task", + "#agent-task-form button[type=submit]", ) submit_role, submit_name = _get_element_semantics( driver_port, @@ -964,19 +900,7 @@ def _run_agent_task_browser_pass( if not url_unchanged: raise RuntimeError("Agent Task URL changed during submission") - 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") + result_element = _find_element(driver_port, session_id, "#task-result") state = _json_request( driver_port, "GET", @@ -990,7 +914,6 @@ 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") - 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, @@ -1014,9 +937,6 @@ 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), @@ -1091,9 +1011,6 @@ 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"], @@ -1118,11 +1035,6 @@ 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) @@ -1206,7 +1118,13 @@ def main() -> int: trial_number, ) ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + http.client.HTTPException, + json.JSONDecodeError, + ) as exc: trial_results.append( { "trial_number": trial_number, @@ -1249,7 +1167,13 @@ def main() -> int: trial_number, ) ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + http.client.HTTPException, + json.JSONDecodeError, + ) as exc: agent_task_trials.append( { "trial_number": trial_number, diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index 411bb19ee..e76ba3b76 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -2,6 +2,7 @@ from __future__ import annotations +import io import http.client import inspect import os @@ -10,6 +11,7 @@ import tempfile import unittest import unittest.mock +from contextlib import redirect_stdout from unittest.mock import patch ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -117,6 +119,51 @@ def malformed_cleanup_response(*_args: object, **_kwargs: object) -> None: self.assertEqual(raised.exception.cleanup_error_type, "IncompleteRead") self.assertNotIn("partial", str(raised.exception)) + def test_agent_task_response_failure_is_recorded_as_a_failed_trial(self) -> None: + """A truncated WebDriver response must become bounded failed-trial evidence.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_trial_failure_contract") + main_globals = namespace["main"].__globals__ + + class FakeServer: + server_port = 9515 + + servers_started = 0 + + def start_fixture_server(_directory: pathlib.Path) -> tuple[FakeServer, object]: + nonlocal servers_started + servers_started += 1 + return FakeServer(), object() + + def successful_restart_trial(*_args: object, **_kwargs: object) -> dict[str, object]: + return {"trial_number": 1, "passed": True, "surfaces": {"worker": True}} + + def truncated_agent_task_response( + *_args: object, **_kwargs: object + ) -> dict[str, object]: + raise http.client.IncompleteRead(b"partial", 32) + + main_globals.update( + { + "_start_fixture_server": start_fixture_server, + "_stop_fixture_server": lambda *_args: None, + "_run_restart_trial": successful_restart_trial, + "_run_agent_task_trial": truncated_agent_task_response, + "REPEATABILITY_TRIALS": 1, + "AGENT_TASK_REPEATABILITY_TRIALS": 1, + } + ) + with patch.dict( + os.environ, + {"CHROME_BIN": "/bin/sh", "CHROMEDRIVER_BIN": "/bin/sh"}, + ), redirect_stdout(io.StringIO()), self.assertRaisesRegex( + RuntimeError, + r"^Agent Task repeatability gate failed: 0/1 trials passed$", + ): + namespace["main"]() + + self.assertEqual(servers_started, 2) + def test_unexpected_cleanup_programming_failure_is_not_normalized(self) -> None: """Programming failures in cleanup must propagate rather than enter fallback handling.""" @@ -163,9 +210,6 @@ def test_agent_task_surface_completeness_is_non_vacuous(self) -> 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, @@ -231,9 +275,12 @@ def test_fixture_shutdown_attempts_both_servers_when_first_stop_fails(self) -> N """A cleanup failure for one fixture must not skip the other fixture.""" namespace = runpy.run_path(str(RUNNER), run_name="fixture_shutdown_contract") - first_server = object() + class FakeServer: + server_port = 9515 + + first_server = FakeServer() first_thread = object() - second_server = object() + second_server = FakeServer() second_thread = object() starts = 0 stopped: list[tuple[object, object]] = [] @@ -254,6 +301,38 @@ def stop_fixture_server(server: object, thread: object) -> None: namespace["main"].__globals__["_start_fixture_server"] = start_fixture_server namespace["main"].__globals__["_stop_fixture_server"] = stop_fixture_server + namespace["main"].__globals__["_run_restart_trial"] = ( + lambda *_args, **_kwargs: { + "trial_number": 1, + "passed": True, + "surfaces": {"worker": True}, + } + ) + def successful_agent_task_trial(*_args: object, **_kwargs: object) -> dict[str, object]: + return { + "trial_number": 1, + "passed": True, + "post_condition": True, + "input_echo_verified": True, + "url_unchanged": True, + "input_semantics_verified": True, + "submit_semantics_verified": True, + "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, + "profile_cleaned": True, + } + + self.assertTrue( + namespace["_agent_task_surfaces_complete"]([successful_agent_task_trial()]) + ) + namespace["main"].__globals__["_run_agent_task_trial"] = successful_agent_task_trial + namespace["main"].__globals__["REPEATABILITY_TRIALS"] = 1 + namespace["main"].__globals__["AGENT_TASK_REPEATABILITY_TRIALS"] = 1 with patch.dict( os.environ, {"CHROME_BIN": "/bin/sh", "CHROMEDRIVER_BIN": "/bin/sh"}, @@ -300,80 +379,6 @@ 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_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.""" @@ -549,6 +554,7 @@ def test_documentation_separates_active_browser_evidence_from_product_runtime(se self.assertIn("PR #70", traceability) self.assertIn("real WebDriver", traceability) self.assertIn("not a product browser adapter", traceability) + self.assertIn("None of these introduces a new trust domain", traceability) self.assertIn("pinned Chrome", fitness) self.assertIn("not a browser adapter", fitness) self.assertIn("browser-computed role/name", changelog) diff --git a/tests/test_doctoring_reference_contract.py b/tests/test_doctoring_reference_contract.py new file mode 100644 index 000000000..bdeded44f --- /dev/null +++ b/tests/test_doctoring_reference_contract.py @@ -0,0 +1,28 @@ +"""Regression contracts for standards references that bind OriginWeave design claims.""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DOCTORING = ROOT / "docs" / "doctoring.md" + + +class DoctoringReferenceContractTests(unittest.TestCase): + """Keep cited primary-standard authorship aligned with the canonical source.""" + + def test_rfc_5280_reference_uses_canonical_author_initials(self) -> None: + """RFC 5280 must credit Sharon Boeyen as S. Boeyen, matching RFC Editor metadata.""" + text = DOCTORING.read_text(encoding="utf-8") + expected = ( + "Cooper, D., Santesson, S., Farrell, S., Boeyen, S., Housley, R., & Polk, W. " + "(2008). *Internet X.509 public key infrastructure certificate and certificate " + "revocation list (CRL) profile* (RFC 5280). Internet Engineering Task Force. " + "https://doi.org/10.17487/RFC5280" + ) + self.assertIn(expected, text) + + +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 34e8a0238..bc60535a2 100644 --- a/tests/test_documentation_active_pr_evidence_contract.py +++ b/tests/test_documentation_active_pr_evidence_contract.py @@ -37,11 +37,12 @@ def test_latest_live_pr_snapshot_is_recorded_in_the_product_baseline(self) -> No """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` |", + "| #220 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e0740a6f3a41067a4460249378e0266815018a74` |", + "| #219 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` |", + "| #218 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `911ea33d8a5aca7673307bb6fdcad4b450f5c111` |", + "| #209 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `b35d739017aa5d361b605be48045be50b5a35f6f` |", + "| #208 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` |", + "| #124 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `296ad25bb541023dbc869ae07ae1d853820f83a4` |", ): with self.subTest(marker=marker): self.assertIn(marker, self.baseline) @@ -53,8 +54,9 @@ def test_baseline_refresh_changelog_matches_the_live_snapshot(self) -> None: 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) + self.assertIn("126 open pull requests (54 ready, 72 draft)", self.changelog) + self.assertNotIn("128 open pull requests (54 ready, 74 draft)", added) + self.assertNotIn("153 open pull requests (39 ready, 114 draft)", added) 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_gap_snapshot_inventory_consistency.py b/tests/test_gap_snapshot_inventory_consistency.py new file mode 100644 index 000000000..0daca1f85 --- /dev/null +++ b/tests/test_gap_snapshot_inventory_consistency.py @@ -0,0 +1,58 @@ +"""Regression contracts for the current dated product-gap inventory snapshot.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +BASELINE = ROOT / "docs" / "product-technical-gap-baseline.md" +CHANGELOG = ROOT / "CHANGELOG.md" + + +class GapSnapshotInventoryConsistencyTests(unittest.TestCase): + """Prevent one dated snapshot from carrying contradictory live PR totals.""" + + @classmethod + def setUpClass(cls) -> None: + cls.baseline = BASELINE.read_text(encoding="utf-8") + cls.changelog = CHANGELOG.read_text(encoding="utf-8") + + def test_current_baseline_inventory_matches_the_verified_snapshot(self) -> None: + """The current snapshot must use the exact 126/54/72 inventory observation.""" + current = self.baseline.split("### Open pull requests", 1)[1].split( + "#### 2026-08-26 maintenance-loop record", 1 + )[0] + for marker in ( + "126 open pull requests", + "54 non-draft", + "72 draft", + ): + with self.subTest(marker=marker): + self.assertIn(marker, current) + + for stale in ( + "128 open pull requests", + "74 draft", + "153 open pull requests", + "114 draft", + ): + with self.subTest(stale=stale): + self.assertNotIn(stale, current) + + def test_unreleased_changelog_uses_one_current_inventory(self) -> None: + """The Unreleased current snapshot must agree before and inside Added.""" + unreleased = self.changelog.split("## [Unreleased]", 1)[1] + preamble, remainder = unreleased.split("### Added", 1) + added = remainder.split("### Changed", 1)[0] + + expected = "126 open pull requests (54 ready, 72 draft)" + self.assertIn(expected, preamble) + self.assertIn(expected, added) + self.assertNotIn("128 open pull requests (54 ready, 74 draft)", preamble) + self.assertNotIn("153 open pull requests (39 ready, 114 draft)", added) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 839393f30..1c24fe674 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -17,9 +17,10 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: text = BASELINE.read_text(encoding="utf-8") for phrase in ( - "158 open pull requests", - "44 non-draft", - "114 draft", + "126 open pull requests", + "54 non-draft", + "72 draft", + "2026-08-24 158-PR snapshot", "#198", "#199", "#200", @@ -41,13 +42,24 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: "78 draft", "148 open pull requests", "79 draft PRs", - "150 open pull requests", "40 non-draft", "110 draft", + "150 open pull requests", + "prior 150-PR snapshot", + "128 open pull requests", + "74 draft", ): with self.subTest(stale_phrase=stale_phrase): self.assertNotIn(stale_phrase, text) + def test_active_github_approval_rule_is_not_documented_as_bypassable(self) -> None: + """An active counted-approval rule must stop merge without an eligible approver.""" + text = BASELINE.read_text(encoding="utf-8") + + self.assertIn("eligible non-author", text) + self.assertIn("reviewer-provisioning gap", text) + self.assertNotIn("owner-directed administrative merge", 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") diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index 5a1c1133c..f192aaa4d 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -44,7 +44,7 @@ def test_product_technical_gap_baseline_records_live_delivery_state(self) -> Non self.assertTrue(baseline.is_file()) text = baseline.read_text(encoding="utf-8") for phrase in ( - "Observed snapshot: 2026-08-24", + "Observed snapshot: 2026-08-26", "Protected-main truth", "Open pull requests", "Open issues", @@ -62,7 +62,7 @@ def test_product_technical_gap_baseline_records_live_delivery_state(self) -> Non )[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.", + "none of them is protected-main behavior until merged", open_pull_requests, ) bidi_status = self._subsection( @@ -73,7 +73,7 @@ def test_product_technical_gap_baseline_records_live_delivery_state(self) -> Non ) self.assertIn("Phase 1 is **in progress**, not shipped.", bidi_status) self.assertIn( - "It remains draft evidence and cannot be treated as shipped behavior.", + "does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof", vpn_status, ) diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 360e11143..057a0011b 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -20,6 +20,7 @@ def test_workspace_declares_all_independently_reusable_crates(self) -> None: set(data["workspace"]["members"]), { "crates/originweave-core", + "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-destination", "crates/originweave-network", @@ -59,6 +60,7 @@ def test_required_architecture_and_governance_documents_exist(self) -> None: "docs/adr/0005-direct-socket-binding.md", "docs/adr/0006-tls-server-identity.md", "docs/adr/0009-hourly-agent-credential-boundary.md", + "docs/adr/0016-bap-task-lifecycle-authority.md", "docs/superpowers/specs/2026-08-06-resolved-destination-policy-design.md", "docs/superpowers/specs/2026-08-06-direct-socket-binding-design.md", "docs/superpowers/specs/2026-08-06-tls-server-identity-design.md", @@ -185,4 +187,4 @@ def test_database_contract_requires_two_word_snake_case(self) -> None: if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From 6c53996a1ab09dc2edb0e80fd2e7487f88d40d1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 20:48:08 +0900 Subject: [PATCH 12/15] fix(browser): replay semantic result evidence on current parent --- scripts/ci/run_mv3_compatibility.py | 122 ++++++++++++++++++++++------ 1 file changed, 99 insertions(+), 23 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index b7d3cc4d1..bb0586655 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 @@ -45,6 +47,8 @@ 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 + "-_.") @@ -239,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.""" @@ -833,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, @@ -841,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, @@ -900,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", @@ -914,6 +990,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") + 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, @@ -937,6 +1014,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": len(chromium_process_ids), @@ -1011,6 +1091,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"], @@ -1035,6 +1118,11 @@ 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) @@ -1118,13 +1206,7 @@ def main() -> int: trial_number, ) ) - except ( - OSError, - ValueError, - RuntimeError, - http.client.HTTPException, - json.JSONDecodeError, - ) as exc: + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: trial_results.append( { "trial_number": trial_number, @@ -1167,13 +1249,7 @@ def main() -> int: trial_number, ) ) - except ( - OSError, - ValueError, - RuntimeError, - http.client.HTTPException, - json.JSONDecodeError, - ) as exc: + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: agent_task_trials.append( { "trial_number": trial_number, From 6721cc495c9967fe7869b211accbe41df6b5f3ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 22:33:28 +0900 Subject: [PATCH 13/15] fix(browser): preserve bounded WebDriver HTTP trial failures --- scripts/ci/run_mv3_compatibility.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index bb0586655..1ba4edd78 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -321,19 +321,6 @@ def _parse_linux_proc_status_rss_bytes(status_text: str) -> int: return rss_values[0] -def _sample_linux_process_rss_bytes(process_id: int) -> int: - """Read one attributed Linux process RSS through a bounded ``/proc`` status file.""" - - if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: - raise ValueError("invalid Linux process identifier") - status_path = pathlib.Path("/proc") / str(process_id) / "status" - with status_path.open("r", encoding="utf-8", errors="strict") as status_file: - status_text = status_file.read(MAX_PROC_STATUS_CHARACTERS + 1) - if len(status_text) > MAX_PROC_STATUS_CHARACTERS: - raise RuntimeError("Linux proc status exceeded the bounded text limit") - 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.""" @@ -1206,7 +1193,13 @@ def main() -> int: trial_number, ) ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + http.client.HTTPException, + json.JSONDecodeError, + ) as exc: trial_results.append( { "trial_number": trial_number, @@ -1249,7 +1242,13 @@ def main() -> int: trial_number, ) ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + http.client.HTTPException, + json.JSONDecodeError, + ) as exc: agent_task_trials.append( { "trial_number": trial_number, From 2a690c886c198305cd8e19e759f756569ebee8a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 22:37:01 +0900 Subject: [PATCH 14/15] fix(browser): restore bounded process RSS helper --- scripts/ci/run_mv3_compatibility.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 1ba4edd78..2b1c6320d 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -321,6 +321,19 @@ def _parse_linux_proc_status_rss_bytes(status_text: str) -> int: return rss_values[0] +def _sample_linux_process_rss_bytes(process_id: int) -> int: + """Read one attributed Linux process RSS through a bounded ``/proc`` status file.""" + + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + status_path = pathlib.Path("/proc") / str(process_id) / "status" + with status_path.open("r", encoding="utf-8", errors="strict") as status_file: + status_text = status_file.read(MAX_PROC_STATUS_CHARACTERS + 1) + if len(status_text) > MAX_PROC_STATUS_CHARACTERS: + raise RuntimeError("Linux proc status exceeded the bounded text limit") + 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.""" From 722ea29b479c4a0b6c79a4538c8b98e5b10b3215 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 22:38:31 +0900 Subject: [PATCH 15/15] test(browser): carry semantic result evidence into parent contracts --- tests/test_agent_task_pinned_chrome_contract.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_agent_task_pinned_chrome_contract.py b/tests/test_agent_task_pinned_chrome_contract.py index e76ba3b76..8d10fdc97 100644 --- a/tests/test_agent_task_pinned_chrome_contract.py +++ b/tests/test_agent_task_pinned_chrome_contract.py @@ -210,6 +210,9 @@ def test_agent_task_surface_completeness_is_non_vacuous(self) -> 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, @@ -317,6 +320,9 @@ 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,