diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d1c9945..4ebfd045d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,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. @@ -80,4 +81,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index f033b34b5..c9fd11a94 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -33,5 +33,6 @@ pub use extension_authority::{ 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 3a7fffbaf..d26250160 100644 --- a/crates/originweave-core/src/semantic_observation.rs +++ b/crates/originweave-core/src/semantic_observation.rs @@ -219,6 +219,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_live_node( registry: &BrowserAuthorityRegistry, handle: &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 000000000..6c25ff3da --- /dev/null +++ b/crates/originweave-core/tests/semantic_node_query.rs @@ -0,0 +1,108 @@ +use std::collections::BTreeSet; + +use originweave_core::{ + BrowserAuthorityRegistry, MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_ROLE_BYTES, NodeActionKind, + ObservationChannel, Origin, SemanticNodeObservation, SemanticNodeObservationInput, + SemanticNodeQuery, SemanticNodeQueryError, +}; + +fn observation() -> Result { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry + .register_session("semantic-query-session") + .map_err(|error| error.to_string())?; + let context = registry + .register_context(session, "semantic-query-context") + .map_err(|error| error.to_string())?; + let origin = Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; + let handle = registry + .bind_node(session, context, &origin, "semantic-query-node") + .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]), + }, + ®istry, + ) + .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" + ); +}