diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb12e3..315343a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. - Authority-bound, bounded semantic node observations with typed node-local action evidence and explicit observation-channel provenance for the first Chromium vertical slice; observation metadata grants no execution authority. +- Bounded typed semantic node queries over reviewed role, accessible-name, and node-action evidence, without exposing raw DOM/protocol selector languages or granting execution authority. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 9d75d9e..61803eb 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -20,5 +20,6 @@ pub use contracts::*; pub use semantic_observation::{ MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES, MAX_VISIBLE_TEXT_BYTES, NodeActionKind, ObservationChannel, SemanticNodeObservation, - SemanticNodeObservationError, SemanticNodeObservationInput, + SemanticNodeObservationError, SemanticNodeObservationInput, SemanticNodeQuery, + SemanticNodeQueryError, }; diff --git a/crates/originweave-core/src/semantic_observation.rs b/crates/originweave-core/src/semantic_observation.rs index 950cff6..d8c0501 100644 --- a/crates/originweave-core/src/semantic_observation.rs +++ b/crates/originweave-core/src/semantic_observation.rs @@ -209,6 +209,119 @@ impl SemanticNodeObservation { } } +/// A bounded typed selector over already validated semantic node observations. +/// +/// Queries match only reviewed semantic fields and descriptive action evidence. They never expose +/// raw DOM/protocol selectors and never grant browser action authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemanticNodeQuery { + role: Option, + accessible_name: Option, + required_action: Option, +} + +impl SemanticNodeQuery { + /// Validate and construct a query with at least one exact typed selector. + pub fn new( + role: Option, + accessible_name: Option, + required_action: Option, + ) -> Result { + if role.is_none() && accessible_name.is_none() && required_action.is_none() { + return Err(SemanticNodeQueryError::EmptySelector); + } + if role + .as_ref() + .is_some_and(|role| role.len() > MAX_SEMANTIC_ROLE_BYTES) + { + return Err(SemanticNodeQueryError::RoleTooLong); + } + if accessible_name + .as_ref() + .is_some_and(|accessible_name| accessible_name.len() > MAX_ACCESSIBLE_NAME_BYTES) + { + return Err(SemanticNodeQueryError::AccessibleNameTooLong); + } + Ok(Self { + role, + accessible_name, + required_action, + }) + } + + /// Return the optional exact semantic-role selector. + #[must_use] + pub fn role(&self) -> Option<&str> { + self.role.as_deref() + } + + /// Return the optional exact accessible-name selector. + #[must_use] + pub fn accessible_name(&self) -> Option<&str> { + self.accessible_name.as_deref() + } + + /// Return the optional required descriptive node action. + #[must_use] + pub const fn required_action(&self) -> Option { + self.required_action + } + + /// Match the query against one already bounded semantic observation. + #[must_use] + pub fn matches(&self, observation: &SemanticNodeObservation) -> bool { + if self + .role + .as_deref() + .is_some_and(|role| observation.role() != role) + { + return false; + } + if self + .accessible_name + .as_deref() + .is_some_and(|accessible_name| observation.accessible_name() != accessible_name) + { + return false; + } + if self.required_action.is_some_and(|required_action| { + !observation.supported_actions().contains(&required_action) + }) { + return false; + } + true + } +} + +/// A bounded validation failure for one typed semantic node query. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SemanticNodeQueryError { + /// No typed selector was supplied. + EmptySelector, + /// The role selector exceeded [`MAX_SEMANTIC_ROLE_BYTES`]. + RoleTooLong, + /// The accessible-name selector exceeded [`MAX_ACCESSIBLE_NAME_BYTES`]. + AccessibleNameTooLong, +} + +impl fmt::Display for SemanticNodeQueryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptySelector => { + formatter.write_str("semantic node query requires at least one selector") + } + Self::RoleTooLong => { + formatter.write_str("semantic node query role exceeds 64 UTF-8 bytes") + } + Self::AccessibleNameTooLong => { + formatter.write_str("semantic node query accessible name exceeds 512 UTF-8 bytes") + } + } + } +} + +impl std::error::Error for SemanticNodeQueryError {} + fn validate_relationship( handle: &ObservedNodeHandle, related: &ObservedNodeHandle, diff --git a/crates/originweave-core/tests/semantic_node_query.rs b/crates/originweave-core/tests/semantic_node_query.rs new file mode 100644 index 0000000..5cf063d --- /dev/null +++ b/crates/originweave-core/tests/semantic_node_query.rs @@ -0,0 +1,103 @@ +use std::collections::BTreeSet; + +use originweave_core::{ + BrowserSessionId, BrowsingContextId, DocumentEpoch, MAX_ACCESSIBLE_NAME_BYTES, + MAX_SEMANTIC_ROLE_BYTES, NodeActionKind, ObservationChannel, ObservedNodeHandle, Origin, + SemanticNodeObservation, SemanticNodeObservationInput, SemanticNodeQuery, + SemanticNodeQueryError, +}; + +fn observation() -> Result { + let handle = ObservedNodeHandle::new( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?, + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + 17, + ) + .map_err(|error| error.to_string())?; + + SemanticNodeObservation::new(SemanticNodeObservationInput { + handle, + parent: None, + children: Vec::new(), + role: "textbox".to_owned(), + accessible_name: "Email address".to_owned(), + visible_text: Some("name@example.test".to_owned()), + enabled: true, + visible: true, + selected: None, + supported_actions: BTreeSet::from([NodeActionKind::Click, NodeActionKind::TypeText]), + evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]), + }) + .map_err(|error| error.to_string()) +} + +#[test] +fn semantic_node_query_matches_exact_reviewed_fields_and_action() -> Result<(), String> { + let observed = observation()?; + let query = SemanticNodeQuery::new( + Some("textbox".to_owned()), + Some("Email address".to_owned()), + Some(NodeActionKind::TypeText), + ) + .map_err(|error| error.to_string())?; + + assert!(query.matches(&observed)); + assert_eq!(query.role(), Some("textbox")); + assert_eq!(query.accessible_name(), Some("Email address")); + assert_eq!(query.required_action(), Some(NodeActionKind::TypeText)); + Ok(()) +} + +#[test] +fn semantic_node_query_fails_closed_on_each_exact_selector_mismatch() -> Result<(), String> { + let observed = observation()?; + let cases = [ + SemanticNodeQuery::new(Some("button".to_owned()), None, None), + SemanticNodeQuery::new(None, Some("Different label".to_owned()), None), + SemanticNodeQuery::new(None, None, Some(NodeActionKind::SelectOption)), + ]; + + for query in cases { + let query = query.map_err(|error| error.to_string())?; + assert!(!query.matches(&observed)); + } + Ok(()) +} + +#[test] +fn semantic_node_query_requires_at_least_one_selector() { + assert_eq!( + SemanticNodeQuery::new(None, None, None).err(), + Some(SemanticNodeQueryError::EmptySelector) + ); +} + +#[test] +fn semantic_node_query_bounds_attacker_controlled_text() { + assert_eq!( + SemanticNodeQuery::new(Some("r".repeat(MAX_SEMANTIC_ROLE_BYTES + 1)), None, None).err(), + Some(SemanticNodeQueryError::RoleTooLong) + ); + assert_eq!( + SemanticNodeQuery::new(None, Some("n".repeat(MAX_ACCESSIBLE_NAME_BYTES + 1)), None,).err(), + Some(SemanticNodeQueryError::AccessibleNameTooLong) + ); +} + +#[test] +fn semantic_node_query_errors_are_stable_and_credential_free() { + assert_eq!( + SemanticNodeQueryError::EmptySelector.to_string(), + "semantic node query requires at least one selector" + ); + assert_eq!( + SemanticNodeQueryError::RoleTooLong.to_string(), + "semantic node query role exceeds 64 UTF-8 bytes" + ); + assert_eq!( + SemanticNodeQueryError::AccessibleNameTooLong.to_string(), + "semantic node query accessible name exceeds 512 UTF-8 bytes" + ); +}