From 4a7f46f7969d5d419b6d1d45600b87e65d625914 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:32:39 +0900 Subject: [PATCH 01/31] test(core): require typed browser operation capability binding --- ...owser_typed_operation_protocol_dispatch.rs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs diff --git a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs new file mode 100644 index 000000000..0202f8ce4 --- /dev/null +++ b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs @@ -0,0 +1,140 @@ +use std::{cell::Cell, error::Error, io}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolOperation, BrowserProtocolRuntimeMetadata, DocumentEpoch, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn descriptor( + capabilities: &[BrowserProtocolCapability], +) -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capabilities, + )?) +} + +fn runtime_metadata() -> BrowserProtocolRuntimeMetadata<'static> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn origin(value: &str) -> Result> { + Origin::parse(value).map_err(|_| { + Box::new(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid controlled origin fixture", + )) as Box + }) +} + +fn typed_input_target<'a>( + registry: &mut BrowserAuthorityRegistry, + expected_origin: &'a Origin, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, expected_origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + expected_origin, + ), + epoch, + )) +} + +#[test] +fn typed_operations_map_to_exact_transport_capabilities() { + assert_eq!( + BrowserProtocolOperation::Navigate.required_capability(), + BrowserProtocolCapability::Navigation + ); + assert_eq!( + BrowserProtocolOperation::ObserveSemantics.required_capability(), + BrowserProtocolCapability::SemanticObservation + ); + assert_eq!( + BrowserProtocolOperation::DispatchTypedInput.required_capability(), + BrowserProtocolCapability::TypedInput + ); + assert_eq!( + BrowserProtocolOperation::ObserveNetwork.required_capability(), + BrowserProtocolCapability::NetworkObservation + ); +} + +#[test] +fn typed_operation_dispatch_derives_the_required_capability() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::TypedInput])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = origin("https://app.example")?; + let target = typed_input_target(&mut registry, &expected_origin)?; + + let result = descriptor.dispatch_operation_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolOperation::DispatchTypedInput, + |validated: ValidatedBrowserProtocolUse, operation, epoch: DocumentEpoch| { + ( + operation, + validated.capability(), + epoch.value(), + ) + }, + )?; + + assert_eq!( + result, + ( + BrowserProtocolOperation::DispatchTypedInput, + BrowserProtocolCapability::TypedInput, + 1, + ) + ); + Ok(()) +} + +#[test] +fn unsupported_typed_operation_fails_before_dispatch_callback() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = origin("https://app.example")?; + let target = typed_input_target(&mut registry, &expected_origin)?; + let dispatch_called = Cell::new(false); + + let result = descriptor.dispatch_operation_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolOperation::DispatchTypedInput, + |_validated, _operation, _epoch| dispatch_called.set(true), + ); + + assert!(matches!( + result, + Err(BrowserContextProtocolDispatchError::ProtocolValidation(_)) + )); + assert!(!dispatch_called.get()); + Ok(()) +} From c6d370e6d57b09bf8008f428583f5246cb44b57a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:52:39 +0900 Subject: [PATCH 02/31] style(core): format typed browser operation contract --- .../tests/browser_typed_operation_protocol_dispatch.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs index 0202f8ce4..6b555eba7 100644 --- a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs @@ -95,11 +95,7 @@ fn typed_operation_dispatch_derives_the_required_capability() -> Result<(), Box< runtime_metadata(), BrowserProtocolOperation::DispatchTypedInput, |validated: ValidatedBrowserProtocolUse, operation, epoch: DocumentEpoch| { - ( - operation, - validated.capability(), - epoch.value(), - ) + (operation, validated.capability(), epoch.value()) }, )?; From 7a6f0e281a8f5877238475bab735d1a82c99962a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:58:16 +0900 Subject: [PATCH 03/31] feat(core): derive adapter capability from typed browser operation --- .../src/browser_protocol_operation.rs | 73 +++++++++++++++++++ crates/originweave-core/src/lib.rs | 2 + 2 files changed, 75 insertions(+) create mode 100644 crates/originweave-core/src/browser_protocol_operation.rs diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs new file mode 100644 index 000000000..5f7f62529 --- /dev/null +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -0,0 +1,73 @@ +use crate::{ + BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, + BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolRuntimeMetadata, DocumentEpoch, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, +}; + +/// One typed high-level browser operation whose transport prerequisite is derived internally. +/// +/// This value carries operation semantics only. It grants no browser session, context, origin, +/// policy, approval, secret, network, or adapter authority and does not perform browser I/O. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolOperation { + /// Navigate one controlled browser context. + Navigate, + /// Produce one bounded semantic browser observation. + ObserveSemantics, + /// Dispatch policy-authorized typed browser input. + DispatchTypedInput, + /// Produce bounded browser-network evidence. + ObserveNetwork, +} + +impl BrowserProtocolOperation { + /// Return the exact adapter capability required for this typed operation. + #[must_use] + pub const fn required_capability(self) -> BrowserProtocolCapability { + match self { + Self::Navigate => BrowserProtocolCapability::Navigation, + Self::ObserveSemantics => BrowserProtocolCapability::SemanticObservation, + Self::DispatchTypedInput => BrowserProtocolCapability::TypedInput, + Self::ObserveNetwork => BrowserProtocolCapability::NetworkObservation, + } + } +} + +impl BrowserProtocolAdapterDescriptor { + /// Revalidate exact browser authority and derive adapter capability from one typed operation. + /// + /// The existing context/origin/document-epoch boundary runs first, followed by exact runtime + /// protocol metadata and the capability derived from `operation`. The callback can run only + /// after all prerequisites pass and receives the same typed operation together with the + /// non-cloneable protocol-use proof and freshly revalidated document epoch. + /// + /// This method does not authenticate Chromium or the adapter, grant policy approval, validate + /// semantic-node state, authorize destination/network activity, perform browser I/O, or prove + /// an action post-condition. The operation value itself grants no authority. + pub fn dispatch_operation_if_context_origin_epoch_current( + &self, + authority_registry: &BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + operation: BrowserProtocolOperation, + dispatch: F, + ) -> Result + where + F: FnOnce( + ValidatedBrowserProtocolUse, + BrowserProtocolOperation, + DocumentEpoch, + ) -> R, + { + self.dispatch_if_context_origin_epoch_current( + authority_registry, + target, + required_originweave_protocol_version, + runtime_metadata, + operation.required_capability(), + |validated, epoch| dispatch(validated, operation, epoch), + ) + } +} diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index f59ac5f8e..3992b8717 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -10,6 +10,7 @@ mod browser_protocol; mod browser_protocol_dispatch; +mod browser_protocol_operation; mod browser_registry; #[cfg(test)] mod browser_registry_coverage; @@ -27,6 +28,7 @@ pub use browser_protocol_dispatch::{ BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolRuntimeMetadata, }; +pub use browser_protocol_operation::BrowserProtocolOperation; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; From 4c4868fb890cf53bb4bb93c5e7cf584793442d6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:00:29 +0900 Subject: [PATCH 04/31] style(core): format typed operation dispatch boundary --- crates/originweave-core/src/browser_protocol_operation.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index 5f7f62529..6eaf32dd2 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -55,11 +55,7 @@ impl BrowserProtocolAdapterDescriptor { dispatch: F, ) -> Result where - F: FnOnce( - ValidatedBrowserProtocolUse, - BrowserProtocolOperation, - DocumentEpoch, - ) -> R, + F: FnOnce(ValidatedBrowserProtocolUse, BrowserProtocolOperation, DocumentEpoch) -> R, { self.dispatch_if_context_origin_epoch_current( authority_registry, From 9acbac713b4b3221c2a577e8b84df5ecff7d0a7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:04:14 +0900 Subject: [PATCH 05/31] docs(changelog): record typed browser operation binding --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28d0ef113..da31bfd48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Runtime browser-adapter version binding at the atomic protocol-use boundary: the caller-supplied bounded adapter-version token must exactly match the reviewed descriptor version before runtime revision or capability checks can succeed, preventing adapter-build drift from silently reusing otherwise matching protocol/browser metadata without authenticating or attesting the adapter process. - Same-call browser-protocol dispatch gating that validates current protocol family, adapter version, pinned protocol/browser revisions, OriginWeave generation, and required capability before invoking one callback, transferring the non-cloneable validation proof by ownership without turning metadata validation into browser or Agent authority. - Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. +- Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. - Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority. - 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. From 993529296d9e95e360571ed6cd7c11dea9b8a70f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:22:31 +0900 Subject: [PATCH 06/31] test: define buyer-visible browser operation vocabulary --- ...owser_typed_operation_protocol_dispatch.rs | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs index 6b555eba7..6223b067d 100644 --- a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs +++ b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs @@ -62,23 +62,37 @@ fn typed_input_target<'a>( } #[test] -fn typed_operations_map_to_exact_transport_capabilities() { - assert_eq!( - BrowserProtocolOperation::Navigate.required_capability(), - BrowserProtocolCapability::Navigation - ); - assert_eq!( - BrowserProtocolOperation::ObserveSemantics.required_capability(), - BrowserProtocolCapability::SemanticObservation - ); - assert_eq!( - BrowserProtocolOperation::DispatchTypedInput.required_capability(), - BrowserProtocolCapability::TypedInput - ); - assert_eq!( - BrowserProtocolOperation::ObserveNetwork.required_capability(), - BrowserProtocolCapability::NetworkObservation - ); +fn buyer_visible_operations_map_to_exact_transport_capabilities() { + let expected = [ + ( + BrowserProtocolOperation::Navigate, + BrowserProtocolCapability::Navigation, + ), + ( + BrowserProtocolOperation::QueryNodes, + BrowserProtocolCapability::SemanticObservation, + ), + ( + BrowserProtocolOperation::ClickNode, + BrowserProtocolCapability::TypedInput, + ), + ( + BrowserProtocolOperation::TypeText, + BrowserProtocolCapability::TypedInput, + ), + ( + BrowserProtocolOperation::WaitForState, + BrowserProtocolCapability::SemanticObservation, + ), + ( + BrowserProtocolOperation::ObserveNetwork, + BrowserProtocolCapability::NetworkObservation, + ), + ]; + + for (operation, capability) in expected { + assert_eq!(operation.required_capability(), capability); + } } #[test] @@ -93,7 +107,7 @@ fn typed_operation_dispatch_derives_the_required_capability() -> Result<(), Box< target, ORIGINWEAVE_PROTOCOL_VERSION, runtime_metadata(), - BrowserProtocolOperation::DispatchTypedInput, + BrowserProtocolOperation::TypeText, |validated: ValidatedBrowserProtocolUse, operation, epoch: DocumentEpoch| { (operation, validated.capability(), epoch.value()) }, @@ -102,7 +116,7 @@ fn typed_operation_dispatch_derives_the_required_capability() -> Result<(), Box< assert_eq!( result, ( - BrowserProtocolOperation::DispatchTypedInput, + BrowserProtocolOperation::TypeText, BrowserProtocolCapability::TypedInput, 1, ) @@ -123,7 +137,7 @@ fn unsupported_typed_operation_fails_before_dispatch_callback() -> Result<(), Bo target, ORIGINWEAVE_PROTOCOL_VERSION, runtime_metadata(), - BrowserProtocolOperation::DispatchTypedInput, + BrowserProtocolOperation::ClickNode, |_validated, _operation, _epoch| dispatch_called.set(true), ); From 06635ab949ebf0be9501ca738272d7fca1dbd0ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 17:04:38 +0900 Subject: [PATCH 07/31] feat(core): bind buyer-visible browser operation vocabulary --- .../src/browser_protocol_operation.rs | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index 6eaf32dd2..77edd06f1 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -5,30 +5,40 @@ use crate::{ OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; -/// One typed high-level browser operation whose transport prerequisite is derived internally. +/// One typed buyer-visible browser operation whose transport prerequisite is derived internally. /// /// This value carries operation semantics only. It grants no browser session, context, origin, -/// policy, approval, secret, network, or adapter authority and does not perform browser I/O. +/// semantic-node, policy, approval, secret, network, or adapter authority and does not perform +/// browser I/O. The vocabulary intentionally mirrors the bounded first Chromium vertical slice so +/// callers cannot hide materially different actions behind a coarse transport capability. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserProtocolOperation { /// Navigate one controlled browser context. Navigate, - /// Produce one bounded semantic browser observation. - ObserveSemantics, - /// Dispatch policy-authorized typed browser input. - DispatchTypedInput, + /// Query bounded semantic nodes from the current document. + QueryNodes, + /// Dispatch one policy-authorized click to a separately validated semantic node. + ClickNode, + /// Dispatch policy-authorized text input to a separately validated semantic node. + TypeText, + /// Observe bounded semantic state until a separately specified condition is satisfied. + WaitForState, /// Produce bounded browser-network evidence. ObserveNetwork, } impl BrowserProtocolOperation { /// Return the exact adapter capability required for this typed operation. + /// + /// This mapping is a transport prerequisite only. A matching capability does not authorize the + /// operation itself: node freshness, policy, approval, destination, and post-condition checks + /// remain independent authority boundaries. #[must_use] pub const fn required_capability(self) -> BrowserProtocolCapability { match self { Self::Navigate => BrowserProtocolCapability::Navigation, - Self::ObserveSemantics => BrowserProtocolCapability::SemanticObservation, - Self::DispatchTypedInput => BrowserProtocolCapability::TypedInput, + Self::QueryNodes | Self::WaitForState => BrowserProtocolCapability::SemanticObservation, + Self::ClickNode | Self::TypeText => BrowserProtocolCapability::TypedInput, Self::ObserveNetwork => BrowserProtocolCapability::NetworkObservation, } } From d1d5c736a1e50154f59d1ba718108d8610be8165 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:10:29 +0900 Subject: [PATCH 08/31] test(core): require bounded BiDi accessibility query --- .../webdriver_bidi_accessibility_query.rs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs new file mode 100644 index 000000000..c3e632116 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -0,0 +1,111 @@ +use std::error::Error; + +use originweave_core::{ + MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, + MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, +}; + +#[test] +fn accessibility_query_exposes_exact_bidi_method_and_locator_contract() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new( + Some("textbox"), + Some("Task text"), + 32, + )?; + + assert_eq!(query.method(), WEBDRIVER_BIDI_LOCATE_NODES_METHOD); + assert_eq!(query.method(), "browsingContext.locateNodes"); + assert_eq!(query.locator_type(), "accessibility"); + assert_eq!(query.role(), Some("textbox")); + assert_eq!(query.name(), Some("Task text")); + assert_eq!(query.max_node_count(), 32); + Ok(()) +} + +#[test] +fn role_only_and_name_only_queries_are_valid() -> Result<(), Box> { + let role_only = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + assert_eq!(role_only.role(), Some("button")); + assert_eq!(role_only.name(), None); + + let name_only = WebDriverBiDiAccessibilityQuery::new(None, Some("Submit task"), 1)?; + assert_eq!(name_only.role(), None); + assert_eq!(name_only.name(), Some("Submit task")); + Ok(()) +} + +#[test] +fn missing_or_empty_accessibility_locator_fields_fail_closed() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, None, 1), + Err(WebDriverBiDiAccessibilityQueryError::MissingLocatorValue) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some(""), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::EmptyRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(""), 1), + Err(WebDriverBiDiAccessibilityQueryError::EmptyName) + ); +} + +#[test] +fn accessibility_locator_text_is_bounded_by_utf8_bytes() -> Result<(), Box> { + let maximum_role = "r".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES); + let maximum_name = "n".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES); + let query = WebDriverBiDiAccessibilityQuery::new( + Some(&maximum_role), + Some(&maximum_name), + MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, + )?; + assert_eq!(query.role(), Some(maximum_role.as_str())); + assert_eq!(query.name(), Some(maximum_name.as_str())); + + let overlong_role = "r".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES + 1); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some(&overlong_role), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::RoleTooLong) + ); + + let overlong_name = "n".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES + 1); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(&overlong_name), 1), + Err(WebDriverBiDiAccessibilityQueryError::NameTooLong) + ); + Ok(()) +} + +#[test] +fn accessibility_query_node_count_is_finite_and_nonzero() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 0), + Err(WebDriverBiDiAccessibilityQueryError::InvalidNodeCount) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new( + Some("button"), + None, + MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT + 1, + ), + Err(WebDriverBiDiAccessibilityQueryError::InvalidNodeCount) + ); +} + +#[test] +fn accessibility_query_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiAccessibilityQueryError::MissingLocatorValue, + WebDriverBiDiAccessibilityQueryError::EmptyRole, + WebDriverBiDiAccessibilityQueryError::RoleTooLong, + WebDriverBiDiAccessibilityQueryError::EmptyName, + WebDriverBiDiAccessibilityQueryError::NameTooLong, + WebDriverBiDiAccessibilityQueryError::InvalidNodeCount, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} From 341ccdcb0980240fcdb0e8630fd5b4475abd4960 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:12:49 +0900 Subject: [PATCH 09/31] style(core): apply canonical BiDi query test formatting --- .../tests/webdriver_bidi_accessibility_query.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs index c3e632116..34412314b 100644 --- a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -7,12 +7,9 @@ use originweave_core::{ }; #[test] -fn accessibility_query_exposes_exact_bidi_method_and_locator_contract() -> Result<(), Box> { - let query = WebDriverBiDiAccessibilityQuery::new( - Some("textbox"), - Some("Task text"), - 32, - )?; +fn accessibility_query_exposes_exact_bidi_method_and_locator_contract() -> Result<(), Box> +{ + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 32)?; assert_eq!(query.method(), WEBDRIVER_BIDI_LOCATE_NODES_METHOD); assert_eq!(query.method(), "browsingContext.locateNodes"); From ad3b5a217f98c966f9c502936b308968fd7e76c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:14:57 +0900 Subject: [PATCH 10/31] feat(core): bound WebDriver BiDi accessibility query --- .../src/browser_protocol_operation.rs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index 77edd06f1..f7a614988 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -1,3 +1,6 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + use crate::{ BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, @@ -5,6 +8,133 @@ use crate::{ OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; +/// Exact WebDriver BiDi method used by the bounded accessibility-query contract. +pub const WEBDRIVER_BIDI_LOCATE_NODES_METHOD: &str = "browsingContext.locateNodes"; +/// Maximum UTF-8 bytes accepted for one accessibility-role query value. +pub const MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES: usize = 64; +/// Maximum UTF-8 bytes accepted for one accessibility-name query value. +pub const MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES: usize = 512; +/// Maximum number of nodes one bounded accessibility query may request. +pub const MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT: u16 = 128; + +/// Fail-closed validation errors for one bounded WebDriver BiDi accessibility query. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiAccessibilityQueryError { + /// Neither an accessibility role nor an accessible name was supplied. + MissingLocatorValue, + /// An explicitly supplied accessibility role was empty. + EmptyRole, + /// The accessibility role exceeded the local UTF-8 byte budget. + RoleTooLong, + /// An explicitly supplied accessible name was empty. + EmptyName, + /// The accessible name exceeded the local UTF-8 byte budget. + NameTooLong, + /// The requested node count was zero or exceeded the local result budget. + InvalidNodeCount, +} + +impl Display for WebDriverBiDiAccessibilityQueryError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::MissingLocatorValue => "accessibility query requires a role or accessible name", + Self::EmptyRole => "accessibility query role must not be empty", + Self::RoleTooLong => "accessibility query role exceeds the local byte budget", + Self::EmptyName => "accessibility query name must not be empty", + Self::NameTooLong => "accessibility query name exceeds the local byte budget", + Self::InvalidNodeCount => "accessibility query node count is outside the local budget", + }; + formatter.write_str(message) + } +} + +impl Error for WebDriverBiDiAccessibilityQueryError {} + +/// Bounded transport parameters for WebDriver BiDi accessibility-node lookup. +/// +/// This value captures only the reviewed `browsingContext.locateNodes` accessibility-locator +/// parameters needed by the first Chromium observation slice. It accepts an exact role, an exact +/// accessible name, or both, together with a finite result count. Text budgets are OriginWeave +/// resource limits rather than claims about upstream protocol maxima. +/// +/// Construction grants no browser session, context, origin, semantic-node, policy, capability, or +/// network authority and performs no browser I/O. A trusted adapter must still bind the query to an +/// exact current browsing context through the separately reviewed authority and protocol boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebDriverBiDiAccessibilityQuery { + role: Option, + name: Option, + max_node_count: u16, +} + +impl WebDriverBiDiAccessibilityQuery { + /// Validate one bounded accessibility lookup request. + /// + /// Explicit empty values fail closed rather than being treated as absent. Role and name limits + /// are measured in UTF-8 bytes so later serialization cannot exceed the reviewed local budget + /// through multi-byte text. At least one selector value and one result slot are required. + pub fn new( + role: Option<&str>, + name: Option<&str>, + max_node_count: u16, + ) -> Result { + if role.is_some_and(str::is_empty) { + return Err(WebDriverBiDiAccessibilityQueryError::EmptyRole); + } + if role.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES) { + return Err(WebDriverBiDiAccessibilityQueryError::RoleTooLong); + } + if name.is_some_and(str::is_empty) { + return Err(WebDriverBiDiAccessibilityQueryError::EmptyName); + } + if name.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES) { + return Err(WebDriverBiDiAccessibilityQueryError::NameTooLong); + } + if role.is_none() && name.is_none() { + return Err(WebDriverBiDiAccessibilityQueryError::MissingLocatorValue); + } + if max_node_count == 0 || max_node_count > MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT { + return Err(WebDriverBiDiAccessibilityQueryError::InvalidNodeCount); + } + + Ok(Self { + role: role.map(str::to_owned), + name: name.map(str::to_owned), + max_node_count, + }) + } + + /// Return the exact upstream method associated with this query contract. + #[must_use] + pub const fn method(&self) -> &'static str { + WEBDRIVER_BIDI_LOCATE_NODES_METHOD + } + + /// Return the exact WebDriver BiDi locator type represented by this value. + #[must_use] + pub const fn locator_type(&self) -> &'static str { + "accessibility" + } + + /// Return the exact optional accessibility role requested by the caller. + #[must_use] + pub fn role(&self) -> Option<&str> { + self.role.as_deref() + } + + /// Return the exact optional accessible name requested by the caller. + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// Return the finite maximum number of nodes requested from the adapter. + #[must_use] + pub const fn max_node_count(&self) -> u16 { + self.max_node_count + } +} + /// One typed buyer-visible browser operation whose transport prerequisite is derived internally. /// /// This value carries operation semantics only. It grants no browser session, context, origin, From 750348a499f87c4a057acf0f3cd69ad6537a0148 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:15:22 +0900 Subject: [PATCH 11/31] feat(core): export bounded BiDi accessibility query --- crates/originweave-core/src/lib.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 3992b8717..4b809e315 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -28,7 +28,12 @@ pub use browser_protocol_dispatch::{ BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolRuntimeMetadata, }; -pub use browser_protocol_operation::BrowserProtocolOperation; +pub use browser_protocol_operation::{ + BrowserProtocolOperation, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, + MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, +}; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; From 7fc113d26f864ac14e6793409d79328bbe954a2c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:16:46 +0900 Subject: [PATCH 12/31] style(core): apply canonical BiDi query exports --- crates/originweave-core/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 4b809e315..4268f6d27 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -29,10 +29,10 @@ pub use browser_protocol_dispatch::{ BrowserProtocolRuntimeMetadata, }; pub use browser_protocol_operation::{ - BrowserProtocolOperation, WebDriverBiDiAccessibilityQuery, - WebDriverBiDiAccessibilityQueryError, MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, + BrowserProtocolOperation, MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, - WEBDRIVER_BIDI_LOCATE_NODES_METHOD, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From fd66444e8d0b9275d109ffa581ebdcfed0d4e301 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:22:26 +0900 Subject: [PATCH 13/31] test(core): require minimal BiDi node serialization --- .../tests/webdriver_bidi_accessibility_query.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs index 34412314b..26f1d23e0 100644 --- a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -3,7 +3,9 @@ use std::error::Error; use originweave_core::{ MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, - WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, + WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, }; #[test] @@ -20,6 +22,19 @@ fn accessibility_query_exposes_exact_bidi_method_and_locator_contract() -> Resul Ok(()) } +#[test] +fn accessibility_query_fixes_minimal_serialization_options() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), None, 8)?; + + assert_eq!(WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, 0); + assert_eq!(WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, 0); + assert_eq!(WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, "none"); + assert_eq!(query.serialization_max_dom_depth(), 0); + assert_eq!(query.serialization_max_object_depth(), 0); + assert_eq!(query.serialization_include_shadow_tree(), "none"); + Ok(()) +} + #[test] fn role_only_and_name_only_queries_are_valid() -> Result<(), Box> { let role_only = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; From 75356e0fc10ed2486a463381c48b6e37fc50ff92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:23:49 +0900 Subject: [PATCH 14/31] feat(core): minimize BiDi node serialization surface --- .../src/browser_protocol_operation.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index f7a614988..e7e1662d4 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -16,6 +16,12 @@ pub const MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES: usize = 64; pub const MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES: usize = 512; /// Maximum number of nodes one bounded accessibility query may request. pub const MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT: u16 = 128; +/// Fixed DOM serialization depth for the first bounded BiDi node-query slice. +pub const WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH: u16 = 0; +/// Fixed object serialization depth for the first bounded BiDi node-query slice. +pub const WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH: u16 = 0; +/// Fixed shadow-tree serialization mode for the first bounded BiDi node-query slice. +pub const WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE: &str = "none"; /// Fail-closed validation errors for one bounded WebDriver BiDi accessibility query. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -57,6 +63,11 @@ impl Error for WebDriverBiDiAccessibilityQueryError {} /// accessible name, or both, together with a finite result count. Text budgets are OriginWeave /// resource limits rather than claims about upstream protocol maxima. /// +/// The first slice also fixes WebDriver BiDi serialization to zero DOM depth, zero object depth, +/// and no shadow-tree expansion. Those settings intentionally minimize the remote-value surface a +/// future transport adapter may request; they do not themselves parse, validate, or authorize any +/// returned node. +/// /// Construction grants no browser session, context, origin, semantic-node, policy, capability, or /// network authority and performs no browser I/O. A trusted adapter must still bind the query to an /// exact current browsing context through the separately reviewed authority and protocol boundary. @@ -116,6 +127,24 @@ impl WebDriverBiDiAccessibilityQuery { "accessibility" } + /// Return the fixed maximum DOM serialization depth for returned remote nodes. + #[must_use] + pub const fn serialization_max_dom_depth(&self) -> u16 { + WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH + } + + /// Return the fixed maximum object serialization depth for returned remote nodes. + #[must_use] + pub const fn serialization_max_object_depth(&self) -> u16 { + WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH + } + + /// Return the fixed shadow-tree serialization mode for returned remote nodes. + #[must_use] + pub const fn serialization_include_shadow_tree(&self) -> &'static str { + WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE + } + /// Return the exact optional accessibility role requested by the caller. #[must_use] pub fn role(&self) -> Option<&str> { From 8a18184935e3ae9219b5de7e049a87776b8ef0f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:24:05 +0900 Subject: [PATCH 15/31] feat(core): export minimal BiDi serialization limits --- crates/originweave-core/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 4268f6d27..e43cb26bf 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -31,8 +31,9 @@ pub use browser_protocol_dispatch::{ pub use browser_protocol_operation::{ BrowserProtocolOperation, MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, - WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WebDriverBiDiAccessibilityQuery, - WebDriverBiDiAccessibilityQueryError, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, + WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, From b649787d6ea151ab622c98329b408765ff6a808f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:12:46 +0900 Subject: [PATCH 16/31] test(bidi): reject over-budget locateNodes results --- .../tests/webdriver_bidi_accessibility_query.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs index 26f1d23e0..720c3596f 100644 --- a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -105,6 +105,19 @@ fn accessibility_query_node_count_is_finite_and_nonzero() { ); } +#[test] +fn accessibility_query_revalidates_returned_node_count() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 2)?; + + assert_eq!(query.validate_result_count(0), Ok(())); + assert_eq!(query.validate_result_count(2), Ok(())); + assert_eq!( + query.validate_result_count(3), + Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded) + ); + Ok(()) +} + #[test] fn accessibility_query_error_contract_is_source_free() { let errors = [ @@ -114,6 +127,7 @@ fn accessibility_query_error_contract_is_source_free() { WebDriverBiDiAccessibilityQueryError::EmptyName, WebDriverBiDiAccessibilityQueryError::NameTooLong, WebDriverBiDiAccessibilityQueryError::InvalidNodeCount, + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, ]; for error in errors { From 7faa2c6bb3760b7cd67cdae45e5f29ff749aea7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:14:08 +0900 Subject: [PATCH 17/31] fix(bidi): revalidate locateNodes result budget --- .../src/browser_protocol_operation.rs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index e7e1662d4..d921d2d6b 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -38,6 +38,8 @@ pub enum WebDriverBiDiAccessibilityQueryError { NameTooLong, /// The requested node count was zero or exceeded the local result budget. InvalidNodeCount, + /// The untrusted adapter returned more nodes than the reviewed request budget allowed. + ResultNodeCountExceeded, } impl Display for WebDriverBiDiAccessibilityQueryError { @@ -49,6 +51,9 @@ impl Display for WebDriverBiDiAccessibilityQueryError { Self::EmptyName => "accessibility query name must not be empty", Self::NameTooLong => "accessibility query name exceeds the local byte budget", Self::InvalidNodeCount => "accessibility query node count is outside the local budget", + Self::ResultNodeCountExceeded => { + "accessibility query result exceeds the requested node budget" + } }; formatter.write_str(message) } @@ -65,8 +70,8 @@ impl Error for WebDriverBiDiAccessibilityQueryError {} /// /// The first slice also fixes WebDriver BiDi serialization to zero DOM depth, zero object depth, /// and no shadow-tree expansion. Those settings intentionally minimize the remote-value surface a -/// future transport adapter may request; they do not themselves parse, validate, or authorize any -/// returned node. +/// future transport adapter may request. The adapter must additionally revalidate the returned +/// node count against this exact query before it retains or normalizes any returned node data. /// /// Construction grants no browser session, context, origin, semantic-node, policy, capability, or /// network authority and performs no browser I/O. A trusted adapter must still bind the query to an @@ -162,6 +167,21 @@ impl WebDriverBiDiAccessibilityQuery { pub const fn max_node_count(&self) -> u16 { self.max_node_count } + + /// Revalidate an untrusted `locateNodes` result count against this exact request budget. + /// + /// A conforming browser is expected to honor `maxNodeCount`, but an adapter boundary must not + /// treat that expectation as resource authority. Zero through the requested maximum are valid; + /// any larger returned array fails closed before later node normalization or retention. + pub fn validate_result_count( + &self, + returned_node_count: usize, + ) -> Result<(), WebDriverBiDiAccessibilityQueryError> { + if returned_node_count > usize::from(self.max_node_count) { + return Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded); + } + Ok(()) + } } /// One typed buyer-visible browser operation whose transport prerequisite is derived internally. From d99181bb0b7c893c2eba6eaf7c13723dc196ac53 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:14:47 +0900 Subject: [PATCH 18/31] docs(changelog): record BiDi result budget revalidation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da31bfd48..861c04eef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Same-call browser-protocol dispatch gating that validates current protocol family, adapter version, pinned protocol/browser revisions, OriginWeave generation, and required capability before invoking one callback, transferring the non-cloneable validation proof by ownership without turning metadata validation into browser or Agent authority. - Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. - Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. +- Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. - Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority. - 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. From b45c40b3919d923cea1bdfadd7453ea17617dc11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:08:01 +0900 Subject: [PATCH 19/31] test(bidi): require bounded remote node references --- .../webdriver_bidi_remote_node_reference.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs new file mode 100644 index 000000000..cb0fd2d47 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs @@ -0,0 +1,79 @@ +use std::error::Error; + +use originweave_core::{ + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiRemoteNodeReferenceError, +}; + +#[test] +fn remote_node_reference_requires_exact_node_type_and_shared_id() -> Result<(), Box> { + let reference = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + + assert_eq!(reference.remote_type(), WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE); + assert_eq!(reference.remote_type(), "node"); + assert_eq!(reference.shared_id(), "shared-node-42"); + Ok(()) +} + +#[test] +fn remote_node_reference_rejects_non_node_remote_values() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("object", Some("shared-node-42")), + Err(WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType) + ); +} + +#[test] +fn remote_node_reference_requires_a_usable_shared_id() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", None), + Err(WebDriverBiDiRemoteNodeReferenceError::MissingSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); +} + +#[test] +fn remote_node_reference_reuses_the_registry_identifier_budget() -> Result<(), Box> { + let maximum = "n".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); + let reference = WebDriverBiDiRemoteNodeReference::new("node", Some(&maximum))?; + assert_eq!(reference.shared_id(), maximum); + + let overlong = "n".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(&overlong)), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + Ok(()) +} + +#[test] +fn remote_node_reference_bounds_multibyte_shared_ids_by_utf8_bytes() -> Result<(), Box> { + let exact = "한".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES / "한".len()); + let reference = WebDriverBiDiRemoteNodeReference::new("node", Some(&exact))?; + assert_eq!(reference.shared_id(), exact); + + let overlong = format!("{exact}한"); + assert!(overlong.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(&overlong)), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + Ok(()) +} + +#[test] +fn remote_node_reference_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType, + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} From 64d763004dc096f3bf8a3aea4cff47e07fbe60f8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:12:54 +0000 Subject: [PATCH 20/31] feat(core): admit bounded BiDi remote node references Close the locateNodes result-item gap by requiring the exact node remote type and a usable sharedId within the registry identifier budget before an untrusted adapter value can be retained as a later handle. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + .../src/browser_protocol_operation.rs | 89 ++++++++++++++++++- crates/originweave-core/src/lib.rs | 8 +- .../webdriver_bidi_remote_node_reference.rs | 5 +- docs/doctoring.md | 2 + docs/doctoring/browser-agent-protocols.md | 2 +- 6 files changed, 101 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 861c04eef..4ca0efeed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. - Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. - Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. +- Fail-closed WebDriver BiDi `script.NodeRemoteValue` admission that requires the exact remote type `node` and a non-empty `sharedId` within the same UTF-8 identifier budget as browser session and context identifiers, so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. - Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority. - 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. diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index d921d2d6b..b2f61e8f6 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -5,7 +5,7 @@ use crate::{ BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolRuntimeMetadata, DocumentEpoch, - OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, }; /// Exact WebDriver BiDi method used by the bounded accessibility-query contract. @@ -184,6 +184,93 @@ impl WebDriverBiDiAccessibilityQuery { } } +/// Exact WebDriver BiDi remote-value type admitted as a later node handle. +pub const WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE: &str = "node"; + +/// Fail-closed validation errors for one untrusted WebDriver BiDi node remote value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiRemoteNodeReferenceError { + /// The remote value type was not the exact `node` type. + UnexpectedRemoteType, + /// The remote value omitted `sharedId`. + MissingSharedId, + /// The shared identifier was empty or exceeded the local UTF-8 byte budget. + InvalidSharedId, +} + +impl Display for WebDriverBiDiRemoteNodeReferenceError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::UnexpectedRemoteType => { + "remote node reference type must be the exact node remote value" + } + Self::MissingSharedId => "remote node reference requires a shared id", + Self::InvalidSharedId => { + "remote node reference shared id is empty or exceeds the local byte budget" + } + }; + formatter.write_str(message) + } +} + +impl Error for WebDriverBiDiRemoteNodeReferenceError {} + +/// Bounded admission of one untrusted WebDriver BiDi `script.NodeRemoteValue`. +/// +/// The 1 June 2026 WebDriver BiDi Working Draft returns `script.NodeRemoteValue` items from +/// `browsingContext.locateNodes`. Those values have a required `type` of `node` and an optional +/// `sharedId`. OriginWeave admits a result item only when the type is exactly `node` and a +/// non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context +/// identifiers. +/// +/// Requiring `sharedId` is a local fail-closed policy: the Working Draft permits omitting it, but a +/// later typed-input adapter cannot refer to the same node across realms without that shared +/// identity. Construction grants no session, context, origin, document-epoch, semantic-node, +/// policy, or network authority and performs no browser I/O. The admitted value remains an +/// untrusted transport handle until a separately reviewed authority boundary binds it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebDriverBiDiRemoteNodeReference { + shared_id: String, +} + +impl WebDriverBiDiRemoteNodeReference { + /// Admit one untrusted locateNodes remote value as a later node handle. + /// + /// The remote type is checked first so a non-node value cannot be retained even when it carries + /// a well-formed shared identifier. A missing shared identifier is distinct from an empty or + /// over-budget identifier so callers can distinguish protocol omission from local + /// resource-budget rejection. + pub fn new( + remote_type: &str, + shared_id: Option<&str>, + ) -> Result { + if remote_type != WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE { + return Err(WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType); + } + let Some(shared_id) = shared_id else { + return Err(WebDriverBiDiRemoteNodeReferenceError::MissingSharedId); + }; + if shared_id.is_empty() || shared_id.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { + return Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId); + } + Ok(Self { + shared_id: shared_id.to_owned(), + }) + } + + /// Return the exact admitted WebDriver BiDi remote-value type. + #[must_use] + pub const fn remote_type(&self) -> &'static str { + WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE + } + + /// Return the exact shared node identifier admitted from the remote value. + #[must_use] + pub fn shared_id(&self) -> &str { + &self.shared_id + } +} + /// One typed buyer-visible browser operation whose transport prerequisite is derived internally. /// /// This value carries operation semantics only. It grants no browser session, context, origin, diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index e43cb26bf..49a8ca0ff 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -31,9 +31,11 @@ pub use browser_protocol_dispatch::{ pub use browser_protocol_operation::{ BrowserProtocolOperation, MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, - WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, - WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, - WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, + WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, + WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReferenceError, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, diff --git a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs index cb0fd2d47..2aa8a377a 100644 --- a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs +++ b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs @@ -9,7 +9,10 @@ use originweave_core::{ fn remote_node_reference_requires_exact_node_type_and_shared_id() -> Result<(), Box> { let reference = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; - assert_eq!(reference.remote_type(), WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE); + assert_eq!( + reference.remote_type(), + WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE + ); assert_eq!(reference.remote_type(), "node"); assert_eq!(reference.shared_id(), "shared-node-42"); Ok(()) diff --git a/docs/doctoring.md b/docs/doctoring.md index 75c107ef0..28faa6d81 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,6 +8,8 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. +The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers. Requiring `sharedId` is a local fail-closed policy, not a claim that the Working Draft makes the field mandatory. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. + ### Browser origin equivalence The WHATWG URL host parser and Chromium canonicalizer classify shortened decimal, integer, hexadecimal, legacy octal-looking, and mixed-component numeric hosts as IPv4 or broken IPv4 candidates rather than ordinary DNS names. Chromium's regression suite includes values such as `192`, `0xC0a80001`, `030052000001`, and mixed hexadecimal components. A non-final empty `0x` component can participate in Chromium's multi-part IPv4 truncation behavior, but a final `0x` label does not produce an IPv4 number because stripping its prefix leaves no digits; it remains a domain label. Chromium also warns that broken IP-like hosts must not be connected because another resolver could accept them. OriginWeave therefore admits only canonical dotted-decimal IPv4 into its policy origin type, rejects browser-special numeric spellings before DNS validation, and preserves final non-numeric DNS labels such as `0x`. diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 5173a32e6..d31b6ba2f 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -46,7 +46,7 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re ## Product consequences -1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. +1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. Admit a BiDi `script.NodeRemoteValue` only as an untrusted transport handle when its type is exactly `node` and a usable `sharedId` is present; do not treat a realm-local `handle` or a missing shared identifier as OriginWeave node authority. 2. Pin exact Chromium/CDP compatibility evidence at release time. 3. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. 4. Keep WebMCP experimental/optional and propagate untrusted-content semantics. From 601628bf26e20e0926e4474ff230503aeb4864f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:27:30 +0000 Subject: [PATCH 21/31] test(core): reject control-bearing BiDi locator text Require fail-closed rejection of whitespace and control injection in accessibility roles, accessible names, BiDi sharedIds, and registry external identifiers before production support exists. Co-authored-by: Seongho Bae --- .../tests/browser_authority_registry.rs | 23 +++++++++- .../webdriver_bidi_accessibility_query.rs | 42 +++++++++++++++++++ .../webdriver_bidi_remote_node_reference.rs | 16 +++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 98f74ee26..d2c41a5fa 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -46,7 +46,7 @@ fn public_default_and_error_contracts_are_usable_from_an_adapter() -> Result<(), let cases = [ ( BrowserRegistryError::InvalidExternalIdentifier, - "external browser identifier must contain 1 to 512 UTF-8 bytes".to_owned(), + "external browser identifier must contain 1 to 512 UTF-8 bytes without control or whitespace characters".to_owned(), ), ( BrowserRegistryError::UnknownBrowserSession, @@ -214,6 +214,27 @@ fn external_identifiers_are_bounded_without_assuming_protocol_syntax() -> Result let unicode = registry.register_session("세션-opaque-✓")?; assert!(unicode.value() > 0); + + assert_eq!( + registry.register_session(" "), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\n"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\u{0000}"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = loopback_origin(); + assert_eq!( + registry.bind_node(session, context, &origin, "backend-node-17\n"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); Ok(()) } diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs index 720c3596f..347c88c47 100644 --- a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -63,6 +63,46 @@ fn missing_or_empty_accessibility_locator_fields_fail_closed() { ); } +#[test] +fn accessibility_role_rejects_whitespace_and_control_injection() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("text box"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button\n"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button\u{0000}"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); +} + +#[test] +fn accessibility_name_rejects_control_injection_and_whitespace_only_values() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\ntask"), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\u{0000}task"), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(" "), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); +} + +#[test] +fn accessibility_name_keeps_ordinary_spaces_and_multibyte_text() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("작업 텍스트"), 1)?; + assert_eq!(query.role(), Some("textbox")); + assert_eq!(query.name(), Some("작업 텍스트")); + Ok(()) +} + #[test] fn accessibility_locator_text_is_bounded_by_utf8_bytes() -> Result<(), Box> { let maximum_role = "r".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES); @@ -125,6 +165,8 @@ fn accessibility_query_error_contract_is_source_free() { WebDriverBiDiAccessibilityQueryError::EmptyRole, WebDriverBiDiAccessibilityQueryError::RoleTooLong, WebDriverBiDiAccessibilityQueryError::EmptyName, + WebDriverBiDiAccessibilityQueryError::InvalidRole, + WebDriverBiDiAccessibilityQueryError::InvalidName, WebDriverBiDiAccessibilityQueryError::NameTooLong, WebDriverBiDiAccessibilityQueryError::InvalidNodeCount, WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, diff --git a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs index 2aa8a377a..70d76073d 100644 --- a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs +++ b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs @@ -38,6 +38,22 @@ fn remote_node_reference_requires_a_usable_shared_id() { ); } +#[test] +fn remote_node_reference_rejects_whitespace_and_control_injection() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(" ")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\n")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\u{0000}")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); +} + #[test] fn remote_node_reference_reuses_the_registry_identifier_budget() -> Result<(), Box> { let maximum = "n".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); From 496f55698fa196c0878562719157549f25d3a03c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:28:28 +0000 Subject: [PATCH 22/31] feat(core): reject control-bearing BiDi locator text Fail closed on whitespace or control characters in exact accessibility roles, accessible-name locators, BiDi sharedIds, and registry external identifiers so untrusted protocol text cannot become a later handle. Co-authored-by: Seongho Bae --- CHANGELOG.md | 4 +- .../src/browser_protocol_operation.rs | 46 ++++++++++++++++--- .../originweave-core/src/browser_registry.rs | 15 ++++-- docs/doctoring.md | 8 +++- docs/doctoring/browser-agent-protocols.md | 6 ++- 5 files changed, 63 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca0efeed..4f70191fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,8 @@ All notable changes to OriginWeave are documented in this file. The format follo - Same-call browser-protocol dispatch gating that validates current protocol family, adapter version, pinned protocol/browser revisions, OriginWeave generation, and required capability before invoking one callback, transferring the non-cloneable validation proof by ownership without turning metadata validation into browser or Agent authority. - Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. - Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. -- Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. -- Fail-closed WebDriver BiDi `script.NodeRemoteValue` admission that requires the exact remote type `node` and a non-empty `sharedId` within the same UTF-8 identifier budget as browser session and context identifiers, so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. +- Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, exact-token role and control-free accessible-name admission, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. +- Fail-closed WebDriver BiDi `script.NodeRemoteValue` admission that requires the exact remote type `node` and a non-empty `sharedId` within the same UTF-8 identifier budget as browser session and context identifiers, rejecting control and whitespace so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. - Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority. - 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. diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index b2f61e8f6..33ed6f0f4 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -34,6 +34,10 @@ pub enum WebDriverBiDiAccessibilityQueryError { RoleTooLong, /// An explicitly supplied accessible name was empty. EmptyName, + /// The accessibility role contained whitespace or a control character. + InvalidRole, + /// The accessible name contained a control character or only whitespace. + InvalidName, /// The accessible name exceeded the local UTF-8 byte budget. NameTooLong, /// The requested node count was zero or exceeded the local result budget. @@ -49,6 +53,12 @@ impl Display for WebDriverBiDiAccessibilityQueryError { Self::EmptyRole => "accessibility query role must not be empty", Self::RoleTooLong => "accessibility query role exceeds the local byte budget", Self::EmptyName => "accessibility query name must not be empty", + Self::InvalidRole => { + "accessibility query role must not contain whitespace or control characters" + } + Self::InvalidName => { + "accessibility query name must not contain control characters or only whitespace" + } Self::NameTooLong => "accessibility query name exceeds the local byte budget", Self::InvalidNodeCount => "accessibility query node count is outside the local budget", Self::ResultNodeCountExceeded => { @@ -65,8 +75,10 @@ impl Error for WebDriverBiDiAccessibilityQueryError {} /// /// This value captures only the reviewed `browsingContext.locateNodes` accessibility-locator /// parameters needed by the first Chromium observation slice. It accepts an exact role, an exact -/// accessible name, or both, together with a finite result count. Text budgets are OriginWeave -/// resource limits rather than claims about upstream protocol maxima. +/// accessible name, or both, together with a finite result count. Roles are exact tokens and +/// therefore reject whitespace and controls. Accessible names may contain ordinary spaces but +/// reject controls and whitespace-only values. Text budgets are OriginWeave resource limits rather +/// than claims about upstream protocol maxima. /// /// The first slice also fixes WebDriver BiDi serialization to zero DOM depth, zero object depth, /// and no shadow-tree expansion. Those settings intentionally minimize the remote-value surface a @@ -88,7 +100,10 @@ impl WebDriverBiDiAccessibilityQuery { /// /// Explicit empty values fail closed rather than being treated as absent. Role and name limits /// are measured in UTF-8 bytes so later serialization cannot exceed the reviewed local budget - /// through multi-byte text. At least one selector value and one result slot are required. + /// through multi-byte text. Roles are exact WAI-ARIA tokens, so whitespace and control + /// characters fail closed instead of becoming fallback-role lists. Accessible names may contain + /// ordinary spaces but not controls or whitespace-only values. At least one selector value and + /// one result slot are required. pub fn new( role: Option<&str>, name: Option<&str>, @@ -97,12 +112,24 @@ impl WebDriverBiDiAccessibilityQuery { if role.is_some_and(str::is_empty) { return Err(WebDriverBiDiAccessibilityQueryError::EmptyRole); } + if role.is_some_and(|value| { + value + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + }) { + return Err(WebDriverBiDiAccessibilityQueryError::InvalidRole); + } if role.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES) { return Err(WebDriverBiDiAccessibilityQueryError::RoleTooLong); } if name.is_some_and(str::is_empty) { return Err(WebDriverBiDiAccessibilityQueryError::EmptyName); } + if name.is_some_and(|value| { + value.chars().any(char::is_control) || value.chars().all(char::is_whitespace) + }) { + return Err(WebDriverBiDiAccessibilityQueryError::InvalidName); + } if name.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES) { return Err(WebDriverBiDiAccessibilityQueryError::NameTooLong); } @@ -194,7 +221,7 @@ pub enum WebDriverBiDiRemoteNodeReferenceError { UnexpectedRemoteType, /// The remote value omitted `sharedId`. MissingSharedId, - /// The shared identifier was empty or exceeded the local UTF-8 byte budget. + /// The shared identifier was empty, contained control or whitespace, or exceeded the local budget. InvalidSharedId, } @@ -206,7 +233,7 @@ impl Display for WebDriverBiDiRemoteNodeReferenceError { } Self::MissingSharedId => "remote node reference requires a shared id", Self::InvalidSharedId => { - "remote node reference shared id is empty or exceeds the local byte budget" + "remote node reference shared id is empty, contains control or whitespace, or exceeds the local byte budget" } }; formatter.write_str(message) @@ -221,7 +248,7 @@ impl Error for WebDriverBiDiRemoteNodeReferenceError {} /// `browsingContext.locateNodes`. Those values have a required `type` of `node` and an optional /// `sharedId`. OriginWeave admits a result item only when the type is exactly `node` and a /// non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context -/// identifiers. +/// identifiers and contains no control or whitespace characters. /// /// Requiring `sharedId` is a local fail-closed policy: the Working Draft permits omitting it, but a /// later typed-input adapter cannot refer to the same node across realms without that shared @@ -250,7 +277,12 @@ impl WebDriverBiDiRemoteNodeReference { let Some(shared_id) = shared_id else { return Err(WebDriverBiDiRemoteNodeReferenceError::MissingSharedId); }; - if shared_id.is_empty() || shared_id.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { + if shared_id.is_empty() + || shared_id.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + || shared_id + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { return Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId); } Ok(Self { diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 477117e4e..87f801b23 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -338,7 +338,7 @@ impl Default for BrowserAuthorityRegistry { /// A fail-closed error produced while translating external browser identifiers into local authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { - /// An external identifier was empty or exceeded the reviewed byte bound. + /// An external identifier was empty, contained control or whitespace, or exceeded the reviewed byte bound. InvalidExternalIdentifier, /// The supplied OriginWeave browser session is not registered in this registry. UnknownBrowserSession, @@ -366,9 +366,9 @@ pub enum BrowserRegistryError { impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidExternalIdentifier => { - formatter.write_str("external browser identifier must contain 1 to 512 UTF-8 bytes") - } + Self::InvalidExternalIdentifier => formatter.write_str( + "external browser identifier must contain 1 to 512 UTF-8 bytes without control or whitespace characters", + ), Self::UnknownBrowserSession => { formatter.write_str("browser session is not registered in this authority registry") } @@ -402,7 +402,12 @@ impl fmt::Display for BrowserRegistryError { impl std::error::Error for BrowserRegistryError {} fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { - if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { + if identifier.is_empty() + || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + || identifier + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { return Err(BrowserRegistryError::InvalidExternalIdentifier); } Ok(()) diff --git a/docs/doctoring.md b/docs/doctoring.md index 28faa6d81..2e129337f 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,7 +8,9 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. -The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers. Requiring `sharedId` is a local fail-closed policy, not a claim that the Working Draft makes the field mandatory. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. +The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control or whitespace characters. Requiring `sharedId` and rejecting control or whitespace is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. + +WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace or a control character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control characters that would become protocol-text injection, and rejects whitespace-only names as non-selectors. ### Browser origin equivalence @@ -156,8 +158,12 @@ Web Hypertext Application Technology Working Group. (2026). *URL standard*. http World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ +World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/2023/REC-wai-aria-1.2-20230606/ + World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ +World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ + 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 diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index d31b6ba2f..8da0ed3ea 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -46,7 +46,7 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re ## Product consequences -1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. Admit a BiDi `script.NodeRemoteValue` only as an untrusted transport handle when its type is exactly `node` and a usable `sharedId` is present; do not treat a realm-local `handle` or a missing shared identifier as OriginWeave node authority. +1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. Admit a BiDi `script.NodeRemoteValue` only as an untrusted transport handle when its type is exactly `node` and a usable control-free `sharedId` is present; do not treat a realm-local `handle`, a missing shared identifier, or control/whitespace-bearing protocol text as OriginWeave node authority. Treat an accessibility-query role as one exact WAI-ARIA token, not a whitespace-separated fallback list. 2. Pin exact Chromium/CDP compatibility evidence at release time. 3. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. 4. Keep WebMCP experimental/optional and propagate untrusted-content semantics. @@ -74,6 +74,10 @@ Model Context Protocol. (2026). *Model Context Protocol specification (2026-07-2 World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ +World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/2023/REC-wai-aria-1.2-20230606/ + World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ +World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ + International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html From 9f389402c9e3b5548fe5d7c149d1f3679b3d0c63 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:30:16 +0000 Subject: [PATCH 23/31] test(bidi): bind locateNodes results to current authority Require a fail-closed composition that revalidates the exact current session/context/origin/document epoch, rejects over-budget or non-node items, and translates admitted sharedIds into ObservedNodeHandle values before production support exists. Co-authored-by: Seongho Bae --- .../webdriver_bidi_locate_nodes_admission.rs | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs new file mode 100644 index 000000000..5bdb6282d --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs @@ -0,0 +1,164 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserRegistryError, DocumentEpoch, Origin, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, +}; + +fn controlled_origin() -> Origin { + Origin::parse("https://app.example").expect("valid controlled fixture origin") +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + expected_origin: &'a Origin, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, expected_origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + expected_origin, + ), + epoch, + )) +} + +#[test] +fn locate_nodes_result_binds_admitted_shared_ids_to_current_authority() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 2)?; + + let handles = query.bind_current_nodes( + &mut registry, + target, + &[ + ("node", Some("shared-task-text")), + ("node", Some("shared-task-text-shadow")), + ], + )?; + + assert_eq!(handles.len(), 2); + assert_eq!( + handles[0].browser_session(), + target.context_origin().context().browser_session() + ); + assert_eq!( + handles[0].browsing_context(), + target.context_origin().context().browsing_context() + ); + assert_eq!(handles[0].origin(), &expected_origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + assert_ne!(handles[0].node_id(), handles[1].node_id()); + handles[0].validate_current( + target.context_origin().context().browser_session(), + target.context_origin().context().browsing_context(), + &expected_origin, + target.expected_epoch(), + )?; + Ok(()) +} + +#[test] +fn over_budget_locate_nodes_result_fails_before_node_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + query.bind_current_nodes( + &mut registry, + target, + &[ + ("node", Some("shared-submit")), + ("node", Some("shared-extra")), + ], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded + )) + ); + Ok(()) +} + +#[test] +fn stale_document_epoch_fails_before_locate_nodes_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let stale_target = current_target(&mut registry, &expected_origin)?; + let context = stale_target.context_origin().context().browsing_context(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin( + stale_target.context_origin().context().browser_session(), + context, + &expected_origin, + )?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_ne!(current_epoch, stale_target.expected_epoch()); + assert_eq!( + query.bind_current_nodes( + &mut registry, + stale_target, + &[("node", Some("shared-submit"))], + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: stale_target.expected_epoch(), + current: current_epoch, + } + ) + ); + Ok(()) +} + +#[test] +fn untrusted_non_node_item_fails_before_registry_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + query.bind_current_nodes(&mut registry, target, &[("object", Some("shared-submit"))],) + Err(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType + )) + ); + Ok(()) +} + +#[test] +fn locate_nodes_admission_error_contract_is_source_aware() { + let expected = DocumentEpoch::new(1).expect("nonzero fixture epoch"); + let current = DocumentEpoch::new(2).expect("nonzero fixture epoch"); + let errors = [ + WebDriverBiDiLocateNodesAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, + ), + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + ), + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { expected, current }, + WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession, + ), + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + } + assert!(errors[0].source().is_some()); + assert!(errors[1].source().is_some()); + assert!(errors[2].source().is_none()); + assert!(errors[3].source().is_some()); +} From ce0af01836628f46fde34490c3f71ecd42570138 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:30:24 +0000 Subject: [PATCH 24/31] test(bidi): fix locateNodes admission test syntax Correct a missing comma so the intended missing-production compile failure is not hidden by a test-harness syntax error. Co-authored-by: Seongho Bae --- .../tests/webdriver_bidi_locate_nodes_admission.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs index 5bdb6282d..af591dc21 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs @@ -129,7 +129,7 @@ fn untrusted_non_node_item_fails_before_registry_binding() -> Result<(), Box Date: Sun, 16 Aug 2026 15:31:25 +0000 Subject: [PATCH 25/31] feat(core): bind locateNodes results to current authority Revalidate the exact current session, context, origin, and document epoch, admit every untrusted result item first, then translate shared node identities through the registry into ObservedNodeHandle values. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + .../src/browser_protocol_operation.rs | 126 +++++++++++++++++- crates/originweave-core/src/lib.rs | 4 +- .../webdriver_bidi_locate_nodes_admission.rs | 72 +++++++++- docs/doctoring.md | 2 +- 5 files changed, 195 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f70191fd..639384522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. - Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, exact-token role and control-free accessible-name admission, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. - Fail-closed WebDriver BiDi `script.NodeRemoteValue` admission that requires the exact remote type `node` and a non-empty `sharedId` within the same UTF-8 identifier budget as browser session and context identifiers, rejecting control and whitespace so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. +- Same-call `locateNodes` result admission that revalidates the exact current session, browsing context, canonical origin, and document epoch, rejects an over-budget or non-node result, and translates each admitted `sharedId` through the authority registry into an `ObservedNodeHandle` without performing browser I/O. - Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority. - 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. diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index 33ed6f0f4..3d47cafc8 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -4,8 +4,9 @@ use std::fmt::{Display, Formatter}; use crate::{ BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, - BrowserProtocolCapability, BrowserProtocolRuntimeMetadata, DocumentEpoch, - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + BrowserProtocolCapability, BrowserProtocolRuntimeMetadata, BrowserRegistryError, DocumentEpoch, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, ObservedNodeHandle, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, }; /// Exact WebDriver BiDi method used by the bounded accessibility-query contract. @@ -209,6 +210,127 @@ impl WebDriverBiDiAccessibilityQuery { } Ok(()) } + + /// Admit one untrusted `locateNodes` result against the exact current document authority. + /// + /// The registry first proves that `target` still names the current session, browsing context, + /// canonical origin, and document epoch. Only then is the returned item count checked against + /// this query's budget. Each item must be an exact `node` remote value with a usable shared + /// identifier; those identifiers are translated through the registry into + /// [`ObservedNodeHandle`] values bound to that same current authority. + /// + /// This method performs no browser I/O, does not authenticate Chromium, and does not grant + /// policy, destination, or typed-input authority. A later action must still revalidate the + /// returned handles immediately before use. + pub fn bind_current_nodes( + &self, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + items: &[(&str, Option<&str>)], + ) -> Result, WebDriverBiDiLocateNodesAdmissionError> { + let context_origin = target.context_origin(); + let context = context_origin.context(); + let current_epoch = authority_registry + .require_context_origin( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + if current_epoch != target.expected_epoch() { + return Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + }, + ); + } + self.validate_result_count(items.len()) + .map_err(WebDriverBiDiLocateNodesAdmissionError::Query)?; + + let mut references = Vec::new(); + for (remote_type, shared_id) in items { + references.push( + WebDriverBiDiRemoteNodeReference::new(remote_type, *shared_id) + .map_err(WebDriverBiDiLocateNodesAdmissionError::RemoteNode)?, + ); + } + + let mut handles = Vec::new(); + for reference in references { + handles.push( + authority_registry + .bind_node( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + reference.shared_id(), + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?, + ); + } + Ok(handles) + } +} + +/// Fail-closed errors for admitting an untrusted `locateNodes` result into current node authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesAdmissionError { + /// The untrusted result violated this query's reviewed locator or result-count contract. + Query(WebDriverBiDiAccessibilityQueryError), + /// One result item was not an admissible node remote value. + RemoteNode(WebDriverBiDiRemoteNodeReferenceError), + /// The observed document epoch no longer matches the registry's current document. + DocumentEpochMismatch { + /// The document epoch that produced the observation being bound. + expected: DocumentEpoch, + /// The document epoch currently active in the registry. + current: DocumentEpoch, + }, + /// The supplied browser session, context, or origin is not current in the registry. + BrowserAuthority(BrowserRegistryError), +} + +impl Display for WebDriverBiDiLocateNodesAdmissionError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Query(error) => { + write!( + formatter, + "accessibility query rejected locateNodes admission: {error}" + ) + } + Self::RemoteNode(error) => { + write!( + formatter, + "remote node reference rejected locateNodes admission: {error}" + ) + } + Self::DocumentEpochMismatch { expected, current } => write!( + formatter, + "browser document epoch {} no longer matches observed epoch {}", + current.value(), + expected.value() + ), + Self::BrowserAuthority(error) => { + write!( + formatter, + "browser authority denied locateNodes admission: {error}" + ) + } + } + } +} + +impl Error for WebDriverBiDiLocateNodesAdmissionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Query(error) => Some(error), + Self::RemoteNode(error) => Some(error), + Self::DocumentEpochMismatch { .. } => None, + Self::BrowserAuthority(error) => Some(error), + } + } } /// Exact WebDriver BiDi remote-value type admitted as a later node handle. diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 49a8ca0ff..59785fc8e 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -34,8 +34,8 @@ pub use browser_protocol_operation::{ WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, WebDriverBiDiAccessibilityQuery, - WebDriverBiDiAccessibilityQueryError, WebDriverBiDiRemoteNodeReference, - WebDriverBiDiRemoteNodeReferenceError, + WebDriverBiDiAccessibilityQueryError, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiRemoteNodeReference, WebDriverBiDiRemoteNodeReferenceError, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs index af591dc21..f5ddd4789 100644 --- a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs @@ -4,9 +4,10 @@ use std::error::Error; use originweave_core::{ BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, - BrowserContextOriginEpochDispatchTarget, BrowserRegistryError, DocumentEpoch, Origin, - WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, - WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, + BrowserContextOriginEpochDispatchTarget, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, Origin, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiRemoteNodeReferenceError, }; fn controlled_origin() -> Origin { @@ -30,8 +31,8 @@ fn current_target<'a>( } #[test] -fn locate_nodes_result_binds_admitted_shared_ids_to_current_authority() --> Result<(), Box> { +fn locate_nodes_result_binds_admitted_shared_ids_to_current_authority() -> Result<(), Box> +{ let mut registry = BrowserAuthorityRegistry::new(); let expected_origin = controlled_origin(); let target = current_target(&mut registry, &expected_origin)?; @@ -121,6 +122,67 @@ fn stale_document_epoch_fails_before_locate_nodes_binding() -> Result<(), Box Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 2)?; + + assert_eq!( + query.bind_current_nodes( + &mut registry, + target, + &[ + ("node", Some("shared-submit")), + ("node", Some("shared-extra")), + ], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::IdentifierSpaceExhausted + )) + ); + Ok(()) +} + +#[test] +fn empty_locate_nodes_result_is_valid_when_the_document_is_current() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + let handles = query.bind_current_nodes(&mut registry, target, &[])?; + assert!(handles.is_empty()); + Ok(()) +} + +#[test] +fn unknown_browser_session_fails_before_locate_nodes_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new( + BrowserSessionId::new(99).expect("nonzero fixture session"), + BrowsingContextId::new(7).expect("nonzero fixture context"), + ), + &expected_origin, + ), + DocumentEpoch::new(1).expect("nonzero fixture epoch"), + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + query.bind_current_nodes(&mut registry, target, &[("node", Some("shared-submit"))]), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession + )) + ); + Ok(()) +} + #[test] fn untrusted_non_node_item_fails_before_registry_binding() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); diff --git a/docs/doctoring.md b/docs/doctoring.md index 2e129337f..643f443a8 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,7 +8,7 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. -The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control or whitespace characters. Requiring `sharedId` and rejecting control or whitespace is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. +The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control or whitespace characters. Requiring `sharedId` and rejecting control or whitespace is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. A later same-call admission boundary may translate that handle through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace or a control character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control characters that would become protocol-text injection, and rejects whitespace-only names as non-selectors. From 2533394e618cc55f4a40c417e660354201f503f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:36:54 +0000 Subject: [PATCH 26/31] test(bidi): require QueryNodes capability before node admission Add the failing contract that an untrusted locateNodes result cannot become ObservedNodeHandle values unless the adapter proves SemanticObservation on the exact current session, context, origin, and document epoch. Co-authored-by: Seongho Bae --- .../webdriver_bidi_query_nodes_admission.rs | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs diff --git a/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs new file mode 100644 index 000000000..d6906b0eb --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs @@ -0,0 +1,310 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolCapabilityRequirementError, BrowserProtocolKind, BrowserProtocolOperation, + BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, Origin, OriginWeaveProtocolVersion, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiQueryNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn descriptor( + capabilities: &[BrowserProtocolCapability], +) -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capabilities, + )?) +} + +fn runtime_metadata() -> BrowserProtocolRuntimeMetadata<'static> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn controlled_origin() -> Origin { + Origin::parse("https://app.example").expect("valid controlled fixture origin") +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + expected_origin: &'a Origin, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, expected_origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + expected_origin, + ), + epoch, + )) +} + +fn admit_query_nodes<'a>( + descriptor: &BrowserProtocolAdapterDescriptor, + registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'a>, + query: &WebDriverBiDiAccessibilityQuery, + items: &[(&str, Option<&str>)], +) -> Result, WebDriverBiDiQueryNodesAdmissionError> { + descriptor.admit_query_nodes( + registry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + query, + items, + ) +} + +#[test] +fn query_nodes_admission_requires_semantic_observation_and_binds_current_handles( +) -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 2)?; + + let handles = admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[ + ("node", Some("shared-task-text")), + ("node", Some("shared-task-text-shadow")), + ], + )?; + + assert_eq!(handles.len(), 2); + assert_eq!( + handles[0].browser_session(), + target.context_origin().context().browser_session() + ); + assert_eq!( + handles[0].browsing_context(), + target.context_origin().context().browsing_context() + ); + assert_eq!(handles[0].origin(), &expected_origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + assert_ne!(handles[0].node_id(), handles[1].node_id()); + handles[0].validate_current( + target.context_origin().context().browser_session(), + target.context_origin().context().browsing_context(), + &expected_origin, + target.expected_epoch(), + )?; + Ok(()) +} + +#[test] +fn navigation_only_adapter_cannot_admit_query_nodes() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::Navigation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::Capability( + BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::SemanticObservation, + ), + ), + ), + )) + ); + Ok(()) +} + +#[test] +fn typed_input_only_adapter_cannot_admit_query_nodes() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::TypedInput])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::Capability( + BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::SemanticObservation, + ), + ), + ), + )) + ); + Ok(()) +} + +#[test] +fn query_nodes_admission_rejects_control_bearing_and_omitted_shared_ids() -> Result<(), Box> +{ + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit\n"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes( + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId + ) + )) + ); + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", None)], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes( + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId + ) + )) + ); + Ok(()) +} + +#[test] +fn query_nodes_admission_fails_closed_on_stale_document_epoch() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let stale_target = current_target(&mut registry, &expected_origin)?; + let context = stale_target.context_origin().context().browsing_context(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin( + stale_target.context_origin().context().browser_session(), + context, + &expected_origin, + )?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_ne!(current_epoch, stale_target.expected_epoch()); + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + stale_target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::DocumentEpochMismatch { + expected: stale_target.expected_epoch(), + current: current_epoch, + } + )) + ); + Ok(()) +} + +#[test] +fn query_nodes_admission_fails_closed_on_unknown_session() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new( + BrowserSessionId::new(99).expect("nonzero fixture session"), + BrowsingContextId::new(7).expect("nonzero fixture context"), + ), + &expected_origin, + ), + DocumentEpoch::new(1).expect("nonzero fixture epoch"), + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert!(matches!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::BrowserAuthority(_) + )) + )); + Ok(()) +} + +#[test] +fn query_nodes_maps_to_semantic_observation_and_error_contract_is_source_aware() { + assert_eq!( + BrowserProtocolOperation::QueryNodes.required_capability(), + BrowserProtocolCapability::SemanticObservation + ); + + let expected = DocumentEpoch::new(1).expect("nonzero fixture epoch"); + let current = DocumentEpoch::new(2).expect("nonzero fixture epoch"); + let errors = [ + WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::DocumentEpochMismatch { expected, current }, + ), + WebDriverBiDiQueryNodesAdmissionError::LocateNodes( + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + ), + ), + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_some()); + } +} From 4186437b12aa051f6c868fb60838a0d235542e27 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:38:22 +0000 Subject: [PATCH 27/31] feat(core): admit locateNodes only after QueryNodes proof Consume a non-cloneable SemanticObservation protocol-use proof before bind_current_nodes can translate untrusted sharedId values into current ObservedNodeHandle values. Navigation-only and TypedInput-only adapters fail closed. Co-authored-by: Seongho Bae --- .../src/browser_protocol_operation.rs | 72 +++++++++++++++++++ crates/originweave-core/src/lib.rs | 3 +- .../webdriver_bidi_query_nodes_admission.rs | 12 ++-- 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index 3d47cafc8..ca5faf14d 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -273,6 +273,43 @@ impl WebDriverBiDiAccessibilityQuery { } } +/// Fail-closed errors for QueryNodes admission that requires SemanticObservation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiQueryNodesAdmissionError { + /// Protocol metadata or the QueryNodes capability failed before node admission. + ProtocolDispatch(BrowserContextProtocolDispatchError), + /// The untrusted `locateNodes` result failed current-authority admission. + LocateNodes(WebDriverBiDiLocateNodesAdmissionError), +} + +impl Display for WebDriverBiDiQueryNodesAdmissionError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::ProtocolDispatch(error) => { + write!( + formatter, + "QueryNodes protocol dispatch rejected locateNodes admission: {error}" + ) + } + Self::LocateNodes(error) => { + write!( + formatter, + "QueryNodes current-authority admission rejected locateNodes result: {error}" + ) + } + } + } +} + +impl Error for WebDriverBiDiQueryNodesAdmissionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::ProtocolDispatch(error) => Some(error), + Self::LocateNodes(error) => Some(error), + } + } +} + /// Fail-closed errors for admitting an untrusted `locateNodes` result into current node authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WebDriverBiDiLocateNodesAdmissionError { @@ -496,4 +533,39 @@ impl BrowserProtocolAdapterDescriptor { |validated, epoch| dispatch(validated, operation, epoch), ) } + + /// Admit one untrusted `locateNodes` result only after QueryNodes protocol proof. + /// + /// The same-call boundary first consumes a non-cloneable protocol-use proof for + /// [`BrowserProtocolOperation::QueryNodes`], which derives + /// [`BrowserProtocolCapability::SemanticObservation`]. Only then may + /// [`WebDriverBiDiAccessibilityQuery::bind_current_nodes`] translate admitted `sharedId` + /// values into [`ObservedNodeHandle`] values on the exact current session, browsing + /// context, canonical origin, and document epoch. + /// + /// This method performs no browser I/O, does not authenticate Chromium, and does not grant + /// policy, destination, or typed-input authority. A later action must still revalidate the + /// returned handles immediately before use. + pub fn admit_query_nodes( + &self, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + query: &WebDriverBiDiAccessibilityQuery, + items: &[(&str, Option<&str>)], + ) -> Result, WebDriverBiDiQueryNodesAdmissionError> { + self.dispatch_operation_if_context_origin_epoch_current( + authority_registry, + target, + required_originweave_protocol_version, + runtime_metadata, + BrowserProtocolOperation::QueryNodes, + |_validated, _operation, _epoch| (), + ) + .map_err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch)?; + query + .bind_current_nodes(authority_registry, target, items) + .map_err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes) + } } diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 59785fc8e..81c5dd60d 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -35,7 +35,8 @@ pub use browser_protocol_operation::{ WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, WebDriverBiDiLocateNodesAdmissionError, - WebDriverBiDiRemoteNodeReference, WebDriverBiDiRemoteNodeReferenceError, + WebDriverBiDiQueryNodesAdmissionError, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReferenceError, }; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, diff --git a/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs index d6906b0eb..204df6baf 100644 --- a/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs +++ b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs @@ -8,7 +8,7 @@ use originweave_core::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolKind, BrowserProtocolOperation, BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserSessionId, - BrowsingContextId, DocumentEpoch, Origin, OriginWeaveProtocolVersion, + BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin, OriginWeaveProtocolVersion, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiQueryNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, }; @@ -67,7 +67,7 @@ fn admit_query_nodes<'a>( target: BrowserContextOriginEpochDispatchTarget<'a>, query: &WebDriverBiDiAccessibilityQuery, items: &[(&str, Option<&str>)], -) -> Result, WebDriverBiDiQueryNodesAdmissionError> { +) -> Result, WebDriverBiDiQueryNodesAdmissionError> { descriptor.admit_query_nodes( registry, target, @@ -79,8 +79,8 @@ fn admit_query_nodes<'a>( } #[test] -fn query_nodes_admission_requires_semantic_observation_and_binds_current_handles( -) -> Result<(), Box> { +fn query_nodes_admission_requires_semantic_observation_and_binds_current_handles() +-> Result<(), Box> { let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; let mut registry = BrowserAuthorityRegistry::new(); let expected_origin = controlled_origin(); @@ -178,8 +178,8 @@ fn typed_input_only_adapter_cannot_admit_query_nodes() -> Result<(), Box Result<(), Box> -{ +fn query_nodes_admission_rejects_control_bearing_and_omitted_shared_ids() +-> Result<(), Box> { let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; let mut registry = BrowserAuthorityRegistry::new(); let expected_origin = controlled_origin(); From 87e1e715a0310785b0ff024835a61cf99f2ed262 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:38:22 +0000 Subject: [PATCH 28/31] docs: record QueryNodes SemanticObservation admission boundary Keep CHANGELOG, doctoring, API, architecture, ADR 0010, and the roadmap aligned with the same-call proof that observation handles require QueryNodes capability and current document authority. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + docs/API_CONTRACT.md | 2 ++ docs/adr/0010-session-context-bound-node-authority.md | 1 + docs/doctoring.md | 2 +- docs/product-roadmap.md | 1 + 6 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9b23ef9f0..4213966b9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -166,7 +166,7 @@ Observation should prefer the most structured trustworthy source available: 4. accessibility tree combined with DOM and layout; 5. screenshot or vision fallback for canvas and inaccessible custom interfaces. -Raw HTML is not the default model input. Full snapshots are followed by incremental semantic diffs, versioned by document epoch. Node references become invalid after navigation or epoch change. +Raw HTML is not the default model input. Full snapshots are followed by incremental semantic diffs, versioned by document epoch. Node references become invalid after navigation or epoch change. An untrusted WebDriver BiDi `locateNodes` result becomes an `ObservedNodeHandle` only after the adapter proves `QueryNodes` / `SemanticObservation` and the exact current session, browsing context, canonical origin, and document epoch still match. That control-plane composition does not perform browser I/O or authorize typed input. ## 8. Action lifecycle diff --git a/CHANGELOG.md b/CHANGELOG.md index 639384522..4b59ba6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, exact-token role and control-free accessible-name admission, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. - Fail-closed WebDriver BiDi `script.NodeRemoteValue` admission that requires the exact remote type `node` and a non-empty `sharedId` within the same UTF-8 identifier budget as browser session and context identifiers, rejecting control and whitespace so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. - Same-call `locateNodes` result admission that revalidates the exact current session, browsing context, canonical origin, and document epoch, rejects an over-budget or non-node result, and translates each admitted `sharedId` through the authority registry into an `ObservedNodeHandle` without performing browser I/O. +- Same-call QueryNodes admission that consumes a non-cloneable SemanticObservation protocol-use proof before `bind_current_nodes` can translate an untrusted `locateNodes` result into current `ObservedNodeHandle` values, so Navigation-only or TypedInput-only adapters cannot mint observation handles. - Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority. - 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. diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index f750922fd..93b53b36f 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -227,6 +227,8 @@ Queries the OriginWeave semantic observation contract; callers do not need to sy Query predicates may include role, accessible name, state, structured field, source channel and scoped layout attributes. Results contain opaque semantic-node handles bound to the current session/context/origin/document epoch. +The first Rust control-plane slice admits an untrusted WebDriver BiDi `locateNodes` result only after the adapter proves `QueryNodes` / `SemanticObservation` on the exact current session, browsing context, canonical origin, and document epoch. That composition still performs no browser I/O and does not authorize typed input. + ## 14. Action operations ### `browser.act` diff --git a/docs/adr/0010-session-context-bound-node-authority.md b/docs/adr/0010-session-context-bound-node-authority.md index 9c085bdd3..1b8933ab9 100644 --- a/docs/adr/0010-session-context-bound-node-authority.md +++ b/docs/adr/0010-session-context-bound-node-authority.md @@ -30,6 +30,7 @@ The numeric identifiers are internal opaque registry identities. They are not ra - The Rust core stays independent of Chromium, WebDriver, selectors, script execution, network access, storage, credentials, and model providers. - Future WebDriver BiDi and CDP adapters must own external-to-internal identity translation, registry lifecycle, epoch rotation, and immediate pre-action validation. - A valid handle proves only observation authority. It does not grant a browser capability, origin permission, resolved-destination authority, transport authority, approval, or successful post-condition. +- QueryNodes admission consumes a non-cloneable SemanticObservation protocol-use proof before translating an admitted `locateNodes` `sharedId` into an `ObservedNodeHandle`. Navigation-only or TypedInput-only adapters cannot mint observation handles. ## References diff --git a/docs/doctoring.md b/docs/doctoring.md index 643f443a8..cb33f6d94 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,7 +8,7 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. -The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control or whitespace characters. Requiring `sharedId` and rejecting control or whitespace is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. A later same-call admission boundary may translate that handle through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. +The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control or whitespace characters. Requiring `sharedId` and rejecting control or whitespace is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. The same-call QueryNodes admission boundary first consumes a non-cloneable SemanticObservation protocol-use proof, then translates each admitted `sharedId` through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. That composition still performs no browser I/O and does not authorize typed input. WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace or a control character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control characters that would become protocol-text injection, and rejects whitespace-only names as non-selectors. diff --git a/docs/product-roadmap.md b/docs/product-roadmap.md index c61dfee63..0905e0054 100644 --- a/docs/product-roadmap.md +++ b/docs/product-roadmap.md @@ -70,6 +70,7 @@ Delivered document-node authority foundation: - a nonzero `BrowsingContextId` for one independently navigable browser context inside that session; - a nonzero `DocumentEpoch` identity for one observed document lifetime inside that context; - an `ObservedNodeHandle` bound to the exact browser session, browsing context, canonical origin, document epoch, and nonzero adapter-local node identifier; +- same-call QueryNodes admission that consumes a SemanticObservation protocol-use proof before translating admitted `locateNodes` `sharedId` values into those handles; - deterministic rejection of cross-session, cross-context, cross-origin, or stale-document node reuse before a future browser adapter performs an action; - reusable core contracts without Chromium, WebDriver, selector, script-execution, network, storage, or secret dependencies. From 685d517f1280e492db42048aa89e025dd05fdd94 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:39:43 +0000 Subject: [PATCH 29/31] test(core): reject Unicode format locator and identifier text Add the failing contract that zero-width and bidi-override format characters cannot be admitted in accessibility roles, accessible names, BiDi sharedId values, or registry external identifiers. Co-authored-by: Seongho Bae --- .../tests/browser_authority_registry.rs | 8 ++++++++ .../webdriver_bidi_accessibility_query.rs | 20 +++++++++++++++++++ .../webdriver_bidi_remote_node_reference.rs | 12 +++++++++++ 3 files changed, 40 insertions(+) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index d2c41a5fa..627b90ac2 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -227,6 +227,14 @@ fn external_identifiers_are_bounded_without_assuming_protocol_syntax() -> Result registry.register_session("webdriver-session\u{0000}"), Err(BrowserRegistryError::InvalidExternalIdentifier) ); + assert_eq!( + registry.register_session("webdriver-session\u{200B}"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\u{202E}"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); let session = registry.register_session("webdriver-session")?; let context = registry.register_context(session, "top-level-context")?; diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs index 347c88c47..e3a1a9f2d 100644 --- a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -79,6 +79,26 @@ fn accessibility_role_rejects_whitespace_and_control_injection() { ); } +#[test] +fn accessibility_locator_text_rejects_unicode_format_and_bidi_overrides() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button\u{200B}"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button\u{202E}"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\u{200B}task"), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\u{202E}task"), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); +} + #[test] fn accessibility_name_rejects_control_injection_and_whitespace_only_values() { assert_eq!( diff --git a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs index 70d76073d..5ce4ab05c 100644 --- a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs +++ b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs @@ -38,6 +38,18 @@ fn remote_node_reference_requires_a_usable_shared_id() { ); } +#[test] +fn remote_node_reference_rejects_unicode_format_and_bidi_overrides() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\u{200B}")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\u{202E}")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); +} + #[test] fn remote_node_reference_rejects_whitespace_and_control_injection() { assert_eq!( From 816fa1f14bb2da948c6c67b7e9667a81571c30a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:41:21 +0000 Subject: [PATCH 30/31] feat(core): reject Unicode format protocol text Fail closed on reviewed Default_Ignorable and bidirectional format characters in accessibility locators, BiDi sharedId values, and registry external identifiers. Ordinary spaces in accessible names remain valid. Co-authored-by: Seongho Bae --- .../src/browser_protocol_operation.rs | 40 +++++++++---------- .../originweave-core/src/browser_registry.rs | 37 ++++++++++++++--- crates/originweave-core/src/lib.rs | 2 + .../tests/browser_authority_registry.rs | 2 +- .../webdriver_bidi_accessibility_query.rs | 36 ++++++++--------- .../webdriver_bidi_remote_node_reference.rs | 20 +++++----- 6 files changed, 79 insertions(+), 58 deletions(-) diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs index ca5faf14d..c768617bd 100644 --- a/crates/originweave-core/src/browser_protocol_operation.rs +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -35,9 +35,9 @@ pub enum WebDriverBiDiAccessibilityQueryError { RoleTooLong, /// An explicitly supplied accessible name was empty. EmptyName, - /// The accessibility role contained whitespace or a control character. + /// The accessibility role contained whitespace, a control, or a Unicode format character. InvalidRole, - /// The accessible name contained a control character or only whitespace. + /// The accessible name contained a control, Unicode format character, or only whitespace. InvalidName, /// The accessible name exceeded the local UTF-8 byte budget. NameTooLong, @@ -55,10 +55,10 @@ impl Display for WebDriverBiDiAccessibilityQueryError { Self::RoleTooLong => "accessibility query role exceeds the local byte budget", Self::EmptyName => "accessibility query name must not be empty", Self::InvalidRole => { - "accessibility query role must not contain whitespace or control characters" + "accessibility query role must not contain whitespace, control, or Unicode format characters" } Self::InvalidName => { - "accessibility query name must not contain control characters or only whitespace" + "accessibility query name must not contain control or Unicode format characters or only whitespace" } Self::NameTooLong => "accessibility query name exceeds the local byte budget", Self::InvalidNodeCount => "accessibility query node count is outside the local budget", @@ -77,8 +77,9 @@ impl Error for WebDriverBiDiAccessibilityQueryError {} /// This value captures only the reviewed `browsingContext.locateNodes` accessibility-locator /// parameters needed by the first Chromium observation slice. It accepts an exact role, an exact /// accessible name, or both, together with a finite result count. Roles are exact tokens and -/// therefore reject whitespace and controls. Accessible names may contain ordinary spaces but -/// reject controls and whitespace-only values. Text budgets are OriginWeave resource limits rather +/// therefore reject whitespace, controls, and Unicode format characters. Accessible names may +/// contain ordinary spaces but reject controls, format characters, and whitespace-only values. +/// Text budgets are OriginWeave resource limits rather /// than claims about upstream protocol maxima. /// /// The first slice also fixes WebDriver BiDi serialization to zero DOM depth, zero object depth, @@ -101,10 +102,10 @@ impl WebDriverBiDiAccessibilityQuery { /// /// Explicit empty values fail closed rather than being treated as absent. Role and name limits /// are measured in UTF-8 bytes so later serialization cannot exceed the reviewed local budget - /// through multi-byte text. Roles are exact WAI-ARIA tokens, so whitespace and control - /// characters fail closed instead of becoming fallback-role lists. Accessible names may contain - /// ordinary spaces but not controls or whitespace-only values. At least one selector value and - /// one result slot are required. + /// through multi-byte text. Roles are exact WAI-ARIA tokens, so whitespace, control, and + /// Unicode format characters fail closed instead of becoming fallback-role lists. Accessible + /// names may contain ordinary spaces but not controls, Unicode format characters, or + /// whitespace-only values. At least one selector value and one result slot are required. pub fn new( role: Option<&str>, name: Option<&str>, @@ -113,11 +114,7 @@ impl WebDriverBiDiAccessibilityQuery { if role.is_some_and(str::is_empty) { return Err(WebDriverBiDiAccessibilityQueryError::EmptyRole); } - if role.is_some_and(|value| { - value - .chars() - .any(|character| character.is_control() || character.is_whitespace()) - }) { + if role.is_some_and(|value| crate::contains_disallowed_protocol_text(value, false)) { return Err(WebDriverBiDiAccessibilityQueryError::InvalidRole); } if role.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES) { @@ -127,7 +124,8 @@ impl WebDriverBiDiAccessibilityQuery { return Err(WebDriverBiDiAccessibilityQueryError::EmptyName); } if name.is_some_and(|value| { - value.chars().any(char::is_control) || value.chars().all(char::is_whitespace) + crate::contains_disallowed_protocol_text(value, true) + || value.chars().all(char::is_whitespace) }) { return Err(WebDriverBiDiAccessibilityQueryError::InvalidName); } @@ -380,7 +378,7 @@ pub enum WebDriverBiDiRemoteNodeReferenceError { UnexpectedRemoteType, /// The remote value omitted `sharedId`. MissingSharedId, - /// The shared identifier was empty, contained control or whitespace, or exceeded the local budget. + /// The shared identifier was empty, contained control, whitespace, or Unicode format text, or exceeded the local budget. InvalidSharedId, } @@ -392,7 +390,7 @@ impl Display for WebDriverBiDiRemoteNodeReferenceError { } Self::MissingSharedId => "remote node reference requires a shared id", Self::InvalidSharedId => { - "remote node reference shared id is empty, contains control or whitespace, or exceeds the local byte budget" + "remote node reference shared id is empty, contains control, whitespace, or Unicode format characters, or exceeds the local byte budget" } }; formatter.write_str(message) @@ -407,7 +405,7 @@ impl Error for WebDriverBiDiRemoteNodeReferenceError {} /// `browsingContext.locateNodes`. Those values have a required `type` of `node` and an optional /// `sharedId`. OriginWeave admits a result item only when the type is exactly `node` and a /// non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context -/// identifiers and contains no control or whitespace characters. +/// identifiers and contains no control, whitespace, or Unicode format characters. /// /// Requiring `sharedId` is a local fail-closed policy: the Working Draft permits omitting it, but a /// later typed-input adapter cannot refer to the same node across realms without that shared @@ -438,9 +436,7 @@ impl WebDriverBiDiRemoteNodeReference { }; if shared_id.is_empty() || shared_id.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES - || shared_id - .chars() - .any(|character| character.is_control() || character.is_whitespace()) + || crate::contains_disallowed_protocol_text(shared_id, false) { return Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId); } diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 87f801b23..10de5ab69 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -6,6 +6,35 @@ use crate::{BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHand /// Maximum UTF-8 byte length of an opaque browser-protocol identifier retained by the registry. pub const MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES: usize = 512; +/// Invisible and bidirectional Unicode format characters rejected in protocol text. +/// +/// These code points are Default_Ignorable or bidirectional format controls. They can hide or +/// reorder locator and identifier text without being `char::is_control` or `char::is_whitespace`. +/// The reviewed set is a local fail-closed policy for OriginWeave protocol admission, not a claim +/// that every Unicode format character is forbidden by WebDriver BiDi or WAI-ARIA. +pub const UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS: &[char] = &[ + '\u{00AD}', '\u{061C}', '\u{180E}', '\u{200B}', '\u{200C}', '\u{200D}', '\u{200E}', '\u{200F}', + '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2060}', '\u{2061}', '\u{2062}', + '\u{2063}', '\u{2064}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', '\u{206A}', '\u{206B}', + '\u{206C}', '\u{206D}', '\u{206E}', '\u{206F}', '\u{FEFF}', +]; + +/// Return whether protocol text contains a control, whitespace, or reviewed format character. +/// +/// When `allow_ordinary_space` is true, U+0020 may appear so accessible names can keep ordinary +/// spaces. Every other whitespace character, every control, and every reviewed format character +/// still fail closed. +pub(crate) fn contains_disallowed_protocol_text(value: &str, allow_ordinary_space: bool) -> bool { + value.chars().any(|character| { + if allow_ordinary_space && character == ' ' { + return false; + } + character.is_control() + || character.is_whitespace() + || UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS.contains(&character) + }) +} + /// Default maximum number of authority identifiers allocated per registry namespace. const DEFAULT_MAX_BROWSER_AUTHORITY_IDENTIFIERS: u64 = 1_000_000; @@ -338,7 +367,7 @@ impl Default for BrowserAuthorityRegistry { /// A fail-closed error produced while translating external browser identifiers into local authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { - /// An external identifier was empty, contained control or whitespace, or exceeded the reviewed byte bound. + /// An external identifier was empty, contained control, whitespace, or Unicode format text, or exceeded the reviewed byte bound. InvalidExternalIdentifier, /// The supplied OriginWeave browser session is not registered in this registry. UnknownBrowserSession, @@ -367,7 +396,7 @@ impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidExternalIdentifier => formatter.write_str( - "external browser identifier must contain 1 to 512 UTF-8 bytes without control or whitespace characters", + "external browser identifier must contain 1 to 512 UTF-8 bytes without control, whitespace, or Unicode format characters", ), Self::UnknownBrowserSession => { formatter.write_str("browser session is not registered in this authority registry") @@ -404,9 +433,7 @@ impl std::error::Error for BrowserRegistryError {} fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES - || identifier - .chars() - .any(|character| character.is_control() || character.is_whitespace()) + || contains_disallowed_protocol_text(identifier, false) { return Err(BrowserRegistryError::InvalidExternalIdentifier); } diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 81c5dd60d..095e6512c 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -38,7 +38,9 @@ pub use browser_protocol_operation::{ WebDriverBiDiQueryNodesAdmissionError, WebDriverBiDiRemoteNodeReference, WebDriverBiDiRemoteNodeReferenceError, }; +pub(crate) use browser_registry::contains_disallowed_protocol_text; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, }; pub use contracts::*; diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 627b90ac2..6cef3e353 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -46,7 +46,7 @@ fn public_default_and_error_contracts_are_usable_from_an_adapter() -> Result<(), let cases = [ ( BrowserRegistryError::InvalidExternalIdentifier, - "external browser identifier must contain 1 to 512 UTF-8 bytes without control or whitespace characters".to_owned(), + "external browser identifier must contain 1 to 512 UTF-8 bytes without control, whitespace, or Unicode format characters".to_owned(), ), ( BrowserRegistryError::UnknownBrowserSession, diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs index e3a1a9f2d..360e8d4f1 100644 --- a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -2,10 +2,10 @@ use std::error::Error; use originweave_core::{ MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, - MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, - WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, - WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, WebDriverBiDiAccessibilityQuery, - WebDriverBiDiAccessibilityQueryError, + MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, + WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, }; #[test] @@ -81,22 +81,18 @@ fn accessibility_role_rejects_whitespace_and_control_injection() { #[test] fn accessibility_locator_text_rejects_unicode_format_and_bidi_overrides() { - assert_eq!( - WebDriverBiDiAccessibilityQuery::new(Some("button\u{200B}"), None, 1), - Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) - ); - assert_eq!( - WebDriverBiDiAccessibilityQuery::new(Some("button\u{202E}"), None, 1), - Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) - ); - assert_eq!( - WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\u{200B}task"), 1), - Err(WebDriverBiDiAccessibilityQueryError::InvalidName) - ); - assert_eq!( - WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\u{202E}task"), 1), - Err(WebDriverBiDiAccessibilityQueryError::InvalidName) - ); + for character in UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS { + let role = format!("button{character}"); + let name = format!("Submit{character}task"); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some(&role), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(&name), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + } } #[test] diff --git a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs index 5ce4ab05c..d5f25bfbc 100644 --- a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs +++ b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs @@ -1,8 +1,9 @@ use std::error::Error; use originweave_core::{ - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, - WebDriverBiDiRemoteNodeReference, WebDriverBiDiRemoteNodeReferenceError, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, + WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReferenceError, }; #[test] @@ -40,14 +41,13 @@ fn remote_node_reference_requires_a_usable_shared_id() { #[test] fn remote_node_reference_rejects_unicode_format_and_bidi_overrides() { - assert_eq!( - WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\u{200B}")), - Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) - ); - assert_eq!( - WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\u{202E}")), - Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) - ); + for character in UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS { + let shared_id = format!("shared-node-42{character}"); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(&shared_id)), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + } } #[test] From ef4a3b7bc5e4ee509aaf857b880945eff4bba5d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:41:21 +0000 Subject: [PATCH 31/31] docs: cite UTS 39 and UAX 9 for format-character rejection Record the local fail-closed format-character policy and cite the current Unicode security-mechanisms standard, the bidirectional algorithm, and the superseded UTR 36 report. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + docs/doctoring.md | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b59ba6f8..c0235be4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Fail-closed WebDriver BiDi `script.NodeRemoteValue` admission that requires the exact remote type `node` and a non-empty `sharedId` within the same UTF-8 identifier budget as browser session and context identifiers, rejecting control and whitespace so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. - Same-call `locateNodes` result admission that revalidates the exact current session, browsing context, canonical origin, and document epoch, rejects an over-budget or non-node result, and translates each admitted `sharedId` through the authority registry into an `ObservedNodeHandle` without performing browser I/O. - Same-call QueryNodes admission that consumes a non-cloneable SemanticObservation protocol-use proof before `bind_current_nodes` can translate an untrusted `locateNodes` result into current `ObservedNodeHandle` values, so Navigation-only or TypedInput-only adapters cannot mint observation handles. +- Fail-closed rejection of reviewed Unicode format and bidirectional-override characters in accessibility roles, accessible names, BiDi `sharedId` values, and registry external identifiers, while ordinary spaces in accessible names remain valid. - Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority. - 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. diff --git a/docs/doctoring.md b/docs/doctoring.md index cb33f6d94..fcc61d618 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,9 +8,11 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. -The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control or whitespace characters. Requiring `sharedId` and rejecting control or whitespace is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. The same-call QueryNodes admission boundary first consumes a non-cloneable SemanticObservation protocol-use proof, then translates each admitted `sharedId` through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. That composition still performs no browser I/O and does not authorize typed input. +The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control, whitespace, or reviewed Unicode format characters. Requiring `sharedId` and rejecting control, whitespace, and format characters is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. The same-call QueryNodes admission boundary first consumes a non-cloneable SemanticObservation protocol-use proof, then translates each admitted `sharedId` through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. That composition still performs no browser I/O and does not authorize typed input. -WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace or a control character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control characters that would become protocol-text injection, and rejects whitespace-only names as non-selectors. +WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace, a control character, or a Unicode format character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control and reviewed format characters that would become protocol-text injection or bidirectional spoofing, and rejects whitespace-only names as non-selectors. + +UTS #39 Revision 32 is the current Unicode security-mechanisms standard and marks Default_Ignorable and bidirectional format characters as restricted in identifier profiles. UAX #9 defines the bidirectional format controls that can reorder displayed protocol text. UTR #36 Revision 15 remains a stabilized historical security-considerations report; its identifier recommendations are superseded by UTS #39 rather than cited as current normative profile rules. OriginWeave therefore rejects the reviewed format-character set in roles, shared identifiers, and registry external identifiers, and rejects those same characters inside accessible names while still allowing ordinary U+0020 spaces. ### Browser origin equivalence @@ -154,6 +156,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 +Unicode Consortium. (2014, September 19). *Unicode security considerations* (Unicode Technical Report #36, Revision 15). https://www.unicode.org/reports/tr36/tr36-15.html + +Unicode Consortium. (2025a, September 4). *Unicode bidirectional algorithm* (Unicode Standard Annex #9, Version 17.0.0). https://www.unicode.org/reports/tr9/ + +Unicode Consortium. (2025b, September 4). *Unicode security mechanisms* (Unicode Technical Standard #39, Revision 32). https://www.unicode.org/reports/tr39/tr39-32.html + 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/