diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39c..7bb12e37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding. - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - 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. - 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 bdd1b2aa..9d75d9e6 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1,8 +1,8 @@ //! Shared security and governance contracts for OriginWeave. //! -//! This crate keeps the long-lived value contracts in `contracts` and the -//! protocol-identifier registry in a focused module so browser adapters can -//! evolve without turning raw CDP or WebDriver identifiers into authority. +//! This crate keeps the long-lived value contracts in `contracts`, the +//! protocol-identifier registry in a focused module, and bounded semantic +//! observations in a separate authority-preserving module. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -11,8 +11,14 @@ mod browser_registry; #[cfg(test)] mod browser_registry_coverage; mod contracts; +mod semantic_observation; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; 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, +}; diff --git a/crates/originweave-core/src/semantic_observation.rs b/crates/originweave-core/src/semantic_observation.rs new file mode 100644 index 00000000..950cff6d --- /dev/null +++ b/crates/originweave-core/src/semantic_observation.rs @@ -0,0 +1,280 @@ +use std::collections::BTreeSet; +use std::fmt; + +use crate::ObservedNodeHandle; + +/// Maximum UTF-8 byte length retained for one semantic node role. +pub const MAX_SEMANTIC_ROLE_BYTES: usize = 64; +/// Maximum UTF-8 byte length retained for one semantic node accessible name. +pub const MAX_ACCESSIBLE_NAME_BYTES: usize = 512; +/// Maximum UTF-8 byte length retained for one semantic node visible-text excerpt. +pub const MAX_VISIBLE_TEXT_BYTES: usize = 4_096; +/// Maximum number of child relationships retained for one semantic node observation. +pub const MAX_SEMANTIC_CHILDREN: usize = 128; + +/// A node-local typed action advertised by an observation adapter. +/// +/// This is descriptive evidence only and never grants execution authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NodeActionKind { + /// Activate the node using browser-native click semantics. + Click, + /// Insert bounded non-secret text using browser-native input semantics. + TypeText, + /// Select one option using browser-native selection semantics. + SelectOption, + /// Set a checkable control to an explicit checked state. + SetChecked, + /// Scroll the node into the viewport without activating it. + ScrollIntoView, +} + +/// A structured evidence channel that contributed to a semantic observation. +/// +/// Channel provenance never converts page-provided content into trusted instruction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ObservationChannel { + /// Experimental structured browser tool metadata, such as WebMCP when available. + WebMcp, + /// Structured data interpreted by a versioned adapter. + StructuredData, + /// Browser accessibility-tree evidence. + Accessibility, + /// Browser DOM evidence used through a bounded adapter. + Dom, + /// Browser layout evidence used through a bounded adapter. + Layout, + /// Bounded visual evidence used when structured channels are insufficient. + Visual, +} + +/// Caller-owned fields used to construct one bounded semantic node observation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemanticNodeObservationInput { + /// Exact OriginWeave authority handle for the observed node. + pub handle: ObservedNodeHandle, + /// Optional exact-authority parent relationship. + pub parent: Option, + /// Bounded exact-authority child relationships in adapter-observed order. + pub children: Vec, + /// Bounded semantic or accessibility role. + pub role: String, + /// Bounded accessible name; an empty name is valid. + pub accessible_name: String, + /// Optional bounded visible-text excerpt. + pub visible_text: Option, + /// Whether the adapter observed the node as enabled. + pub enabled: bool, + /// Whether the adapter observed the node as visible. + pub visible: bool, + /// Optional selected state when that concept applies. + pub selected: Option, + /// Finite typed actions the adapter reports as meaningful for this node. + pub supported_actions: BTreeSet, + /// Finite evidence channels that contributed to this observation. + pub evidence_channels: BTreeSet, +} + +/// A bounded semantic view of one authority-bound browser node. +/// +/// The value carries no raw HTML, protocol-local identifier, or independent authorization. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemanticNodeObservation { + handle: ObservedNodeHandle, + parent: Option, + children: Vec, + role: String, + accessible_name: String, + visible_text: Option, + enabled: bool, + visible: bool, + selected: Option, + supported_actions: BTreeSet, + evidence_channels: BTreeSet, +} + +impl SemanticNodeObservation { + /// Validate reviewed text, relationship, authority, and provenance bounds. + pub fn new(input: SemanticNodeObservationInput) -> Result { + if input.role.is_empty() { + return Err(SemanticNodeObservationError::EmptyRole); + } + if input.role.len() > MAX_SEMANTIC_ROLE_BYTES { + return Err(SemanticNodeObservationError::RoleTooLong); + } + if input.accessible_name.len() > MAX_ACCESSIBLE_NAME_BYTES { + return Err(SemanticNodeObservationError::AccessibleNameTooLong); + } + if input + .visible_text + .as_ref() + .is_some_and(|text| text.len() > MAX_VISIBLE_TEXT_BYTES) + { + return Err(SemanticNodeObservationError::VisibleTextTooLong); + } + if input.evidence_channels.is_empty() { + return Err(SemanticNodeObservationError::MissingEvidenceChannel); + } + if input.children.len() > MAX_SEMANTIC_CHILDREN { + return Err(SemanticNodeObservationError::TooManyChildren); + } + if let Some(parent) = input.parent.as_ref() { + validate_relationship(&input.handle, parent)?; + } + for (index, child) in input.children.iter().enumerate() { + validate_relationship(&input.handle, child)?; + if input.children[..index].contains(child) { + return Err(SemanticNodeObservationError::DuplicateChild); + } + } + Ok(Self { + handle: input.handle, + parent: input.parent, + children: input.children, + role: input.role, + accessible_name: input.accessible_name, + visible_text: input.visible_text, + enabled: input.enabled, + visible: input.visible, + selected: input.selected, + supported_actions: input.supported_actions, + evidence_channels: input.evidence_channels, + }) + } + + /// Return the exact authority-bound node handle. + #[must_use] + pub const fn handle(&self) -> &ObservedNodeHandle { + &self.handle + } + + /// Return the optional exact-authority parent relationship. + #[must_use] + pub const fn parent(&self) -> Option<&ObservedNodeHandle> { + self.parent.as_ref() + } + + /// Return the bounded exact-authority child relationships in observed order. + #[must_use] + pub fn children(&self) -> &[ObservedNodeHandle] { + &self.children + } + + /// Return the bounded semantic role. + #[must_use] + pub fn role(&self) -> &str { + &self.role + } + + /// Return the bounded accessible name. + #[must_use] + pub fn accessible_name(&self) -> &str { + &self.accessible_name + } + + /// Return the optional bounded visible-text excerpt. + #[must_use] + pub fn visible_text(&self) -> Option<&str> { + self.visible_text.as_deref() + } + + /// Return whether the node was observed as enabled. + #[must_use] + pub const fn is_enabled(&self) -> bool { + self.enabled + } + + /// Return whether the node was observed as visible. + #[must_use] + pub const fn is_visible(&self) -> bool { + self.visible + } + + /// Return the optional selected state. + #[must_use] + pub const fn is_selected(&self) -> Option { + self.selected + } + + /// Return the adapter-advertised node action set. + #[must_use] + pub const fn supported_actions(&self) -> &BTreeSet { + &self.supported_actions + } + + /// Return the non-empty evidence-channel provenance set. + #[must_use] + pub const fn evidence_channels(&self) -> &BTreeSet { + &self.evidence_channels + } +} + +fn validate_relationship( + handle: &ObservedNodeHandle, + related: &ObservedNodeHandle, +) -> Result<(), SemanticNodeObservationError> { + if handle == related { + return Err(SemanticNodeObservationError::SelfRelationship); + } + if handle.browser_session() != related.browser_session() + || handle.browsing_context() != related.browsing_context() + || handle.origin() != related.origin() + || handle.document_epoch() != related.document_epoch() + { + return Err(SemanticNodeObservationError::RelationshipAuthorityMismatch); + } + Ok(()) +} + +/// A bounded validation failure for one semantic node observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SemanticNodeObservationError { + /// The semantic role was empty. + EmptyRole, + /// The role exceeded [`MAX_SEMANTIC_ROLE_BYTES`]. + RoleTooLong, + /// The accessible name exceeded [`MAX_ACCESSIBLE_NAME_BYTES`]. + AccessibleNameTooLong, + /// The visible-text excerpt exceeded [`MAX_VISIBLE_TEXT_BYTES`]. + VisibleTextTooLong, + /// No evidence channel was supplied for the observation. + MissingEvidenceChannel, + /// The child relationship list exceeded [`MAX_SEMANTIC_CHILDREN`]. + TooManyChildren, + /// A relationship crossed the observation's session, context, origin, or document authority. + RelationshipAuthorityMismatch, + /// The observation attempted to relate the node to itself. + SelfRelationship, + /// The child relationship list contained the same exact handle more than once. + DuplicateChild, +} + +impl fmt::Display for SemanticNodeObservationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyRole => formatter.write_str("semantic node role must not be empty"), + Self::RoleTooLong => formatter.write_str("semantic node role exceeds 64 UTF-8 bytes"), + Self::AccessibleNameTooLong => { + formatter.write_str("semantic node accessible name exceeds 512 UTF-8 bytes") + } + Self::VisibleTextTooLong => { + formatter.write_str("semantic node visible text exceeds 4096 UTF-8 bytes") + } + Self::MissingEvidenceChannel => formatter + .write_str("semantic node observation requires at least one evidence channel"), + Self::TooManyChildren => { + formatter.write_str("semantic node observation exceeds 128 child relationships") + } + Self::RelationshipAuthorityMismatch => formatter.write_str( + "semantic node relationship crosses its session, context, origin, or document authority", + ), + Self::SelfRelationship => { + formatter.write_str("semantic node observation cannot relate the node to itself") + } + Self::DuplicateChild => formatter + .write_str("semantic node observation contains a duplicate child relationship"), + } + } +} + +impl std::error::Error for SemanticNodeObservationError {} diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs new file mode 100644 index 00000000..0e75d698 --- /dev/null +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -0,0 +1,300 @@ +use std::collections::BTreeSet; + +use originweave_core::{ + BrowserSessionId, BrowsingContextId, DocumentEpoch, MAX_ACCESSIBLE_NAME_BYTES, + MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES, MAX_VISIBLE_TEXT_BYTES, NodeActionKind, + ObservationChannel, ObservedNodeHandle, Origin, SemanticNodeObservation, + SemanticNodeObservationError, SemanticNodeObservationInput, +}; + +fn observed_node_with_authority( + browser_session_id: u64, + browsing_context_id: u64, + origin_value: &str, + document_epoch_value: u64, + node_id: u64, +) -> Result { + let browser_session = + BrowserSessionId::new(browser_session_id).map_err(|error| error.to_string())?; + let browsing_context = + BrowsingContextId::new(browsing_context_id).map_err(|error| error.to_string())?; + let origin = Origin::parse(origin_value).map_err(|error| format!("{error:?}"))?; + let document_epoch = + DocumentEpoch::new(document_epoch_value).map_err(|error| error.to_string())?; + ObservedNodeHandle::new( + browser_session, + browsing_context, + origin, + document_epoch, + node_id, + ) + .map_err(|error| error.to_string()) +} + +fn observed_node_with_id(node_id: u64) -> Result { + observed_node_with_authority(7, 11, "https://example.com", 3, node_id) +} + +fn observed_node() -> Result { + observed_node_with_id(17) +} + +fn semantic_input( + role: String, + accessible_name: String, + visible_text: Option, +) -> Result { + Ok(SemanticNodeObservationInput { + handle: observed_node()?, + parent: None, + children: Vec::new(), + role, + accessible_name, + visible_text, + enabled: true, + visible: true, + selected: None, + supported_actions: BTreeSet::from([NodeActionKind::Click, NodeActionKind::TypeText]), + evidence_channels: BTreeSet::from([ + ObservationChannel::Accessibility, + ObservationChannel::Dom, + ]), + }) +} + +#[test] +fn semantic_node_preserves_authority_and_bounded_surface() -> Result<(), String> { + let input = semantic_input( + "textbox".to_owned(), + "Email address".to_owned(), + Some("name@example.test".to_owned()), + )?; + let handle = input.handle.clone(); + let observation = SemanticNodeObservation::new(input).map_err(|error| error.to_string())?; + + assert_eq!(observation.handle(), &handle); + assert_eq!(observation.parent(), None); + assert!(observation.children().is_empty()); + assert_eq!(observation.role(), "textbox"); + assert_eq!(observation.accessible_name(), "Email address"); + assert_eq!(observation.visible_text(), Some("name@example.test")); + assert!(observation.is_enabled()); + assert!(observation.is_visible()); + assert_eq!(observation.is_selected(), None); + assert_eq!( + observation.supported_actions(), + &BTreeSet::from([NodeActionKind::Click, NodeActionKind::TypeText]) + ); + assert_eq!( + observation.evidence_channels(), + &BTreeSet::from([ObservationChannel::Accessibility, ObservationChannel::Dom,]) + ); + Ok(()) +} + +#[test] +fn semantic_node_preserves_bounded_authority_scoped_relationships() -> Result<(), String> { + let parent = observed_node_with_id(16)?; + let first_child = observed_node_with_id(18)?; + let second_child = observed_node_with_id(19)?; + let mut input = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + input.parent = Some(parent.clone()); + input.children = vec![first_child.clone(), second_child.clone()]; + + let observation = SemanticNodeObservation::new(input).map_err(|error| error.to_string())?; + assert_eq!(observation.parent(), Some(&parent)); + assert_eq!(observation.children(), &[first_child, second_child]); + Ok(()) +} + +#[test] +fn semantic_node_bounds_child_relationship_count() -> Result<(), String> { + let mut boundary = semantic_input("list".to_owned(), "Items".to_owned(), None)?; + boundary.children = (0..MAX_SEMANTIC_CHILDREN) + .map(|offset| observed_node_with_id(100 + offset as u64)) + .collect::, _>>()?; + let observation = SemanticNodeObservation::new(boundary).map_err(|error| error.to_string())?; + assert_eq!(observation.children().len(), MAX_SEMANTIC_CHILDREN); + + let mut overflow = semantic_input("list".to_owned(), "Items".to_owned(), None)?; + overflow.children = (0..=MAX_SEMANTIC_CHILDREN) + .map(|offset| observed_node_with_id(1_000 + offset as u64)) + .collect::, _>>()?; + assert_eq!( + SemanticNodeObservation::new(overflow).err(), + Some(SemanticNodeObservationError::TooManyChildren) + ); + Ok(()) +} + +#[test] +fn semantic_node_rejects_each_relationship_authority_axis() -> Result<(), String> { + let mismatched_parents = [ + observed_node_with_authority(8, 11, "https://example.com", 3, 16)?, + observed_node_with_authority(7, 12, "https://example.com", 3, 16)?, + observed_node_with_authority(7, 11, "https://other.example", 3, 16)?, + observed_node_with_authority(7, 11, "https://example.com", 4, 16)?, + ]; + + for parent in mismatched_parents { + let mut input = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + input.parent = Some(parent); + assert_eq!( + SemanticNodeObservation::new(input).err(), + Some(SemanticNodeObservationError::RelationshipAuthorityMismatch) + ); + } + + let mut child_input = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + child_input.children = vec![observed_node_with_authority( + 7, + 11, + "https://other.example", + 3, + 18, + )?]; + assert_eq!( + SemanticNodeObservation::new(child_input).err(), + Some(SemanticNodeObservationError::RelationshipAuthorityMismatch) + ); + Ok(()) +} + +#[test] +fn semantic_node_rejects_self_and_duplicate_child_relationships() -> Result<(), String> { + let mut self_parent = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + self_parent.parent = Some(self_parent.handle.clone()); + assert_eq!( + SemanticNodeObservation::new(self_parent).err(), + Some(SemanticNodeObservationError::SelfRelationship) + ); + + let mut self_child = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + self_child.children = vec![self_child.handle.clone()]; + assert_eq!( + SemanticNodeObservation::new(self_child).err(), + Some(SemanticNodeObservationError::SelfRelationship) + ); + + let child = observed_node_with_id(18)?; + let mut duplicate = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + duplicate.children = vec![child.clone(), child]; + assert_eq!( + SemanticNodeObservation::new(duplicate).err(), + Some(SemanticNodeObservationError::DuplicateChild) + ); + Ok(()) +} + +#[test] +fn reviewed_text_bounds_are_inclusive_and_visible_text_is_optional() -> Result<(), String> { + let boundary = SemanticNodeObservation::new(semantic_input( + "r".repeat(MAX_SEMANTIC_ROLE_BYTES), + "n".repeat(MAX_ACCESSIBLE_NAME_BYTES), + Some("v".repeat(MAX_VISIBLE_TEXT_BYTES)), + )?) + .map_err(|error| error.to_string())?; + assert_eq!(boundary.role().len(), MAX_SEMANTIC_ROLE_BYTES); + assert_eq!(boundary.accessible_name().len(), MAX_ACCESSIBLE_NAME_BYTES); + assert_eq!( + boundary.visible_text().map(str::len), + Some(MAX_VISIBLE_TEXT_BYTES) + ); + + let without_text = + SemanticNodeObservation::new(semantic_input("button".to_owned(), String::new(), None)?) + .map_err(|error| error.to_string())?; + assert_eq!(without_text.visible_text(), None); + Ok(()) +} + +#[test] +fn semantic_node_requires_observation_provenance() -> Result<(), String> { + let mut input = semantic_input("button".to_owned(), "Submit".to_owned(), None)?; + input.evidence_channels.clear(); + + let error = SemanticNodeObservation::new(input).err(); + assert_eq!( + error, + Some(SemanticNodeObservationError::MissingEvidenceChannel) + ); + Ok(()) +} + +#[test] +fn semantic_node_rejects_unbounded_or_missing_role_text() -> Result<(), String> { + let empty_role = + SemanticNodeObservation::new(semantic_input(String::new(), "name".to_owned(), None)?).err(); + assert_eq!(empty_role, Some(SemanticNodeObservationError::EmptyRole)); + + let long_role = SemanticNodeObservation::new(semantic_input( + "r".repeat(MAX_SEMANTIC_ROLE_BYTES + 1), + "name".to_owned(), + None, + )?) + .err(); + assert_eq!(long_role, Some(SemanticNodeObservationError::RoleTooLong)); + + let long_name = SemanticNodeObservation::new(semantic_input( + "button".to_owned(), + "n".repeat(MAX_ACCESSIBLE_NAME_BYTES + 1), + None, + )?) + .err(); + assert_eq!( + long_name, + Some(SemanticNodeObservationError::AccessibleNameTooLong) + ); + + let long_visible_text = SemanticNodeObservation::new(semantic_input( + "button".to_owned(), + "name".to_owned(), + Some("v".repeat(MAX_VISIBLE_TEXT_BYTES + 1)), + )?) + .err(); + assert_eq!( + long_visible_text, + Some(SemanticNodeObservationError::VisibleTextTooLong) + ); + Ok(()) +} + +#[test] +fn semantic_node_errors_are_stable_and_credential_free() { + assert_eq!( + SemanticNodeObservationError::EmptyRole.to_string(), + "semantic node role must not be empty" + ); + assert_eq!( + SemanticNodeObservationError::RoleTooLong.to_string(), + "semantic node role exceeds 64 UTF-8 bytes" + ); + assert_eq!( + SemanticNodeObservationError::AccessibleNameTooLong.to_string(), + "semantic node accessible name exceeds 512 UTF-8 bytes" + ); + assert_eq!( + SemanticNodeObservationError::VisibleTextTooLong.to_string(), + "semantic node visible text exceeds 4096 UTF-8 bytes" + ); + assert_eq!( + SemanticNodeObservationError::MissingEvidenceChannel.to_string(), + "semantic node observation requires at least one evidence channel" + ); + assert_eq!( + SemanticNodeObservationError::TooManyChildren.to_string(), + "semantic node observation exceeds 128 child relationships" + ); + assert_eq!( + SemanticNodeObservationError::RelationshipAuthorityMismatch.to_string(), + "semantic node relationship crosses its session, context, origin, or document authority" + ); + assert_eq!( + SemanticNodeObservationError::SelfRelationship.to_string(), + "semantic node observation cannot relate the node to itself" + ); + assert_eq!( + SemanticNodeObservationError::DuplicateChild.to_string(), + "semantic node observation contains a duplicate child relationship" + ); +}