diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ebfd045d..0ca81cfbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. - 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. +- Authority-bound semantic node action targets accept only observation-advertised node-local actions and revalidate the exact node binding against the live browser authority registry before later use, so retired or stale handles cannot be revived by caller-supplied authority tuples. - 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. @@ -81,4 +82,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index c9fd11a94..703f69ca9 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -2,7 +2,7 @@ //! //! This crate keeps the long-lived value contracts in `contracts`, the //! protocol-identifier registry and extension authority in focused modules, -//! and bounded semantic observations in a separate authority-preserving module. +//! and bounded semantic observations in separate authority-preserving modules. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -13,6 +13,7 @@ mod browser_registry_coverage; mod contract_errors; mod contracts; mod extension_authority; +mod semantic_action_target; mod semantic_observation; pub use browser_registry::{ @@ -30,6 +31,7 @@ pub use extension_authority::{ AgentTaskId, AgentTaskIdError, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentGrant, evaluate_extension_access, }; +pub use semantic_action_target::{SemanticNodeActionTarget, SemanticNodeActionTargetError}; pub use semantic_observation::{ MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES, MAX_VISIBLE_TEXT_BYTES, NodeActionKind, ObservationChannel, SemanticNodeObservation, diff --git a/crates/originweave-core/src/semantic_action_target.rs b/crates/originweave-core/src/semantic_action_target.rs new file mode 100644 index 000000000..6d4a1a418 --- /dev/null +++ b/crates/originweave-core/src/semantic_action_target.rs @@ -0,0 +1,74 @@ +use std::fmt; + +use crate::{ + BrowserAuthorityRegistry, BrowserRegistryError, NodeActionKind, ObservedNodeHandle, + SemanticNodeObservation, +}; + +/// One node-local action bound to the exact browser authority that produced its observation. +/// +/// This value narrows descriptive observation evidence into a stale-checkable action target. It +/// does not grant policy authority, classify business risk, or execute browser input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemanticNodeActionTarget { + handle: ObservedNodeHandle, + action: NodeActionKind, +} + +impl SemanticNodeActionTarget { + /// Construct a target only when the observation advertised the requested node-local action. + pub fn from_observation( + observation: &SemanticNodeObservation, + action: NodeActionKind, + ) -> Result { + if !observation.supported_actions().contains(&action) { + return Err(SemanticNodeActionTargetError::UnsupportedAction); + } + Ok(Self { + handle: observation.handle().clone(), + action, + }) + } + + /// Return the exact OriginWeave-owned node handle retained by this target. + #[must_use] + pub const fn handle(&self) -> &ObservedNodeHandle { + &self.handle + } + + /// Return the descriptive node-local action selected from the observation. + #[must_use] + pub const fn action(&self) -> NodeActionKind { + self.action + } + + /// Revalidate this target against current registry-owned browser authority before later use. + /// + /// The exact node binding must still be live in `registry`; a caller cannot revive a retired + /// or stale target merely by presenting a self-consistent session/context/origin/epoch tuple. + pub fn validate_current( + &self, + registry: &BrowserAuthorityRegistry, + ) -> Result<(), BrowserRegistryError> { + registry.validate_node_handle(&self.handle) + } +} + +/// A bounded validation failure when deriving one semantic node action target. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SemanticNodeActionTargetError { + /// The requested action was not advertised by the semantic observation. + UnsupportedAction, +} + +impl fmt::Display for SemanticNodeActionTargetError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedAction => { + formatter.write_str("semantic node action is not advertised by the observation") + } + } + } +} + +impl std::error::Error for SemanticNodeActionTargetError {} diff --git a/crates/originweave-core/tests/semantic_node_action_target.rs b/crates/originweave-core/tests/semantic_node_action_target.rs new file mode 100644 index 000000000..916b77b1a --- /dev/null +++ b/crates/originweave-core/tests/semantic_node_action_target.rs @@ -0,0 +1,150 @@ +use std::collections::BTreeSet; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, BrowsingContextId, + NodeActionKind, ObservationChannel, Origin, SemanticNodeActionTarget, + SemanticNodeActionTargetError, SemanticNodeObservation, SemanticNodeObservationInput, +}; + +struct ObservationFixture { + registry: BrowserAuthorityRegistry, + session: BrowserSessionId, + context: BrowsingContextId, + observation: SemanticNodeObservation, +} + +fn observation_fixture() -> Result { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry + .register_session("semantic-action-session") + .map_err(|error| error.to_string())?; + let context = registry + .register_context(session, "semantic-action-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-action-node") + .map_err(|error| error.to_string())?; + let observation = SemanticNodeObservation::new( + SemanticNodeObservationInput { + handle, + parent: None, + children: Vec::new(), + role: "button".to_owned(), + accessible_name: "Save draft".to_owned(), + visible_text: Some("Save draft".to_owned()), + enabled: true, + visible: true, + selected: None, + supported_actions: BTreeSet::from([NodeActionKind::Click]), + evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]), + }, + ®istry, + ) + .map_err(|error| error.to_string())?; + + Ok(ObservationFixture { + registry, + session, + context, + observation, + }) +} + +#[test] +fn advertised_node_action_becomes_an_authority_bound_target() -> Result<(), String> { + let fixture = observation_fixture()?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + + assert_eq!(target.handle(), fixture.observation.handle()); + assert_eq!(target.action(), NodeActionKind::Click); + Ok(()) +} + +#[test] +fn unsupported_node_action_fails_closed_without_minting_authority() -> Result<(), String> { + let fixture = observation_fixture()?; + assert_eq!( + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::TypeText) + .err(), + Some(SemanticNodeActionTargetError::UnsupportedAction) + ); + Ok(()) +} + +#[test] +fn node_action_target_revalidates_live_registry_authority() -> Result<(), String> { + let fixture = observation_fixture()?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + + target + .validate_current(&fixture.registry) + .map_err(|error| error.to_string())?; + Ok(()) +} + +#[test] +fn node_action_target_rejects_retired_context_authority() -> Result<(), String> { + let mut fixture = observation_fixture()?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + fixture + .registry + .remove_context(fixture.context) + .map_err(|error| error.to_string())?; + + assert_eq!( + target.validate_current(&fixture.registry).err(), + Some(BrowserRegistryError::UnknownBrowsingContext) + ); + Ok(()) +} + +#[test] +fn node_action_target_rejects_retired_session_authority() -> Result<(), String> { + let mut fixture = observation_fixture()?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + fixture + .registry + .remove_session(fixture.session) + .map_err(|error| error.to_string())?; + + assert_eq!( + target.validate_current(&fixture.registry).err(), + Some(BrowserRegistryError::UnknownBrowserSession) + ); + Ok(()) +} + +#[test] +fn node_action_target_rejects_stale_document_authority() -> Result<(), String> { + let mut fixture = observation_fixture()?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + fixture + .registry + .advance_document(fixture.context) + .map_err(|error| error.to_string())?; + + assert_eq!( + target.validate_current(&fixture.registry).err(), + Some(BrowserRegistryError::UnknownNodeAuthority) + ); + Ok(()) +} + +#[test] +fn node_action_target_error_is_stable_and_credential_free() { + assert_eq!( + SemanticNodeActionTargetError::UnsupportedAction.to_string(), + "semantic node action is not advertised by the observation" + ); +}