diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1d9106..266fa260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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 that accept only observation-advertised node-local actions and revalidate exact session, context, origin, and document authority before later use without granting policy or browser-execution authority. +- Same-call semantic-node dispatch boundary that revalidates exact browser session, browsing context, canonical origin, and document epoch before invoking an already policy-authorized adapter callback, while keeping adapter execution outcome and post-condition proof separate. - 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. @@ -76,4 +77,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-policy/src/semantic_node_action.rs b/crates/originweave-policy/src/semantic_node_action.rs index 5789587b..88041146 100644 --- a/crates/originweave-policy/src/semantic_node_action.rs +++ b/crates/originweave-policy/src/semantic_node_action.rs @@ -54,6 +54,33 @@ impl PolicyAuthorizedSemanticNodeAction { current_epoch, ) } + + /// Revalidate exact browser authority and immediately invoke one adapter dispatch callback. + /// + /// The supplied session, context, origin, and document epoch must be trusted adapter state + /// sampled for the action that is about to be dispatched. The callback is never invoked when + /// that state no longer matches the semantic-node binding. A successful callback invocation + /// does not authenticate the adapter, grant destination, secret, or approval authority, or + /// prove the action's post-condition; those remain separate execution boundaries. + pub fn dispatch_if_current( + &self, + current_session: BrowserSessionId, + current_context: BrowsingContextId, + current_origin: &Origin, + current_epoch: DocumentEpoch, + dispatch: F, + ) -> Result + where + F: FnOnce(&SemanticNodeActionBinding) -> R, + { + self.validate_current( + current_session, + current_context, + current_origin, + current_epoch, + )?; + Ok(dispatch(&self.binding)) + } } /// A fail-closed outcome that did not produce a policy-authorized semantic-node action. diff --git a/crates/originweave-policy/tests/semantic_node_dispatch_revalidation.rs b/crates/originweave-policy/tests/semantic_node_dispatch_revalidation.rs new file mode 100644 index 00000000..c9c4e438 --- /dev/null +++ b/crates/originweave-policy/tests/semantic_node_dispatch_revalidation.rs @@ -0,0 +1,142 @@ +use std::cell::Cell; +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, DocumentEpoch, ExecutionPurpose, InstructionSource, NodeActionKind, + ObservationChannel, ObservedNodeHandle, Origin, PolicyContext, RobotsDecision, SecretDelivery, + SemanticNodeActionBinding, SemanticNodeActionTarget, SemanticNodeObservation, + SemanticNodeObservationInput, SessionMode, +}; +use originweave_policy::PolicyAuthorizedSemanticNodeAction; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn origin(value: &str) -> Result { + Origin::parse(value).map_err(|error| format!("{error:?}")) +} + +fn authorized_action() -> Result { + let site = origin("https://app.example")?; + let handle = ObservedNodeHandle::new( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + site.clone(), + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + 17, + ) + .map_err(|error| error.to_string())?; + let observation = SemanticNodeObservation::new(SemanticNodeObservationInput { + handle, + parent: None, + children: Vec::new(), + role: "button".to_owned(), + accessible_name: "Continue".to_owned(), + visible_text: Some("Continue".to_owned()), + enabled: true, + visible: true, + selected: None, + supported_actions: BTreeSet::from([NodeActionKind::Click]), + evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]), + }) + .map_err(|error| error.to_string())?; + let target = SemanticNodeActionTarget::from_observation(&observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + let request = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site.clone(), + InstructionSource::User, + SecretDelivery::None, + ActionIntentDigest::parse(VALID_INTENT).map_err(|error| format!("{error:?}"))?, + ); + let binding = + SemanticNodeActionBinding::new(target, request).map_err(|error| error.to_string())?; + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([ActionKind::Navigate.required_capability()]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + + PolicyAuthorizedSemanticNodeAction::authorize(binding, &context) + .map_err(|error| error.to_string()) +} + +fn dispatch_unit_callback( + authorized: &PolicyAuthorizedSemanticNodeAction, + document_epoch: u64, + called: &Cell, +) -> Result<(), String> { + authorized + .dispatch_if_current( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + &origin("https://app.example")?, + DocumentEpoch::new(document_epoch).map_err(|error| error.to_string())?, + |_binding| called.set(true), + ) + .map_err(|error| error.to_string()) +} + +#[test] +fn dispatch_callback_runs_only_after_exact_browser_revalidation() -> Result<(), String> { + let authorized = authorized_action()?; + let called = Cell::new(false); + + let result = authorized + .dispatch_if_current( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + &origin("https://app.example")?, + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + |binding| { + called.set(true); + (binding.target().action(), binding.request().action()) + }, + ) + .map_err(|error| error.to_string())?; + + assert!(called.get()); + assert_eq!(result, (NodeActionKind::Click, ActionKind::Navigate)); + Ok(()) +} + +#[test] +fn stale_browser_authority_never_reaches_dispatch_callback() -> Result<(), String> { + let authorized = authorized_action()?; + let called = Cell::new(false); + + dispatch_unit_callback(&authorized, 3, &called)?; + assert!(called.replace(false)); + + let error = dispatch_unit_callback(&authorized, 4, &called) + .err() + .ok_or_else(|| "stale browser authority unexpectedly reached dispatch".to_owned())?; + + assert!(!called.get()); + assert!(error.contains("stale")); + Ok(()) +} + +#[test] +fn adapter_failure_remains_separate_after_successful_revalidation() -> Result<(), String> { + let authorized = authorized_action()?; + + let adapter_result = authorized + .dispatch_if_current( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + &origin("https://app.example")?, + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + |_binding| -> Result<(), &'static str> { Err("adapter failed") }, + ) + .map_err(|error| error.to_string())?; + + assert_eq!(adapter_result, Err("adapter failed")); + Ok(()) +}