diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca81cfbe..a2a04314b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,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 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. +- Policy-authorized semantic node actions revalidate the exact registry-owned node binding in the same call that hands the binding to one adapter dispatch callback, preventing a validate-then-dispatch race from reviving retired or stale 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-policy/src/semantic_node_action.rs b/crates/originweave-policy/src/semantic_node_action.rs index c7797d60b..c46068947 100644 --- a/crates/originweave-policy/src/semantic_node_action.rs +++ b/crates/originweave-policy/src/semantic_node_action.rs @@ -50,6 +50,25 @@ impl PolicyAuthorizedSemanticNodeAction { ) -> Result<(), BrowserRegistryError> { self.binding.validate_current(registry) } + + /// Revalidate registry-owned browser authority and invoke one dispatch callback in the same call. + /// + /// The registry must be the trusted adapter's current authority registry for the action that is + /// about to be dispatched. The callback is never invoked if the retained node binding is stale, + /// retired, forged, or belongs to another registry. 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, + registry: &BrowserAuthorityRegistry, + dispatch: F, + ) -> Result + where + F: FnOnce(&SemanticNodeActionBinding) -> R, + { + self.validate_current(registry) + .map(|()| 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 000000000..6b408e75c --- /dev/null +++ b/crates/originweave-policy/tests/semantic_node_dispatch_revalidation.rs @@ -0,0 +1,150 @@ +use std::cell::Cell; +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserAuthorityRegistry, + BrowserRegistryError, BrowsingContextId, ExecutionPurpose, InstructionSource, NodeActionKind, + ObservationChannel, Origin, PolicyContext, RobotsDecision, SecretDelivery, + SemanticNodeActionBinding, SemanticNodeActionTarget, SemanticNodeObservation, + SemanticNodeObservationInput, SessionMode, +}; +use originweave_policy::PolicyAuthorizedSemanticNodeAction; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +struct AuthorizedFixture { + registry: BrowserAuthorityRegistry, + context: BrowsingContextId, + authorized: PolicyAuthorizedSemanticNodeAction, +} + +fn origin(value: &str) -> Result { + Origin::parse(value).map_err(|error| format!("{error:?}")) +} + +fn authorized_action() -> Result { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry + .register_session("semantic-dispatch-session") + .map_err(|error| error.to_string())?; + let context = registry + .register_context(session, "semantic-dispatch-context") + .map_err(|error| error.to_string())?; + let site = origin("https://app.example")?; + let handle = registry + .bind_node(session, context, &site, "semantic-dispatch-node") + .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]), + }, + ®istry, + ) + .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 policy_context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([ActionKind::Navigate.required_capability()]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let authorized = PolicyAuthorizedSemanticNodeAction::authorize(binding, &policy_context) + .map_err(|error| error.to_string())?; + + Ok(AuthorizedFixture { + registry, + context, + authorized, + }) +} + +fn dispatch_unit_callback( + authorized: &PolicyAuthorizedSemanticNodeAction, + registry: &BrowserAuthorityRegistry, + called: &Cell, +) -> Result<(), BrowserRegistryError> { + authorized.dispatch_if_current(registry, |_binding| called.set(true)) +} + +#[test] +fn dispatch_callback_runs_only_after_registry_owned_browser_revalidation() -> Result<(), String> { + let fixture = authorized_action()?; + let called = Cell::new(false); + + let result = fixture + .authorized + .dispatch_if_current(&fixture.registry, |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_registry_authority_never_reaches_dispatch_callback() -> Result<(), String> { + let mut fixture = authorized_action()?; + let called = Cell::new(false); + + dispatch_unit_callback(&fixture.authorized, &fixture.registry, &called) + .map_err(|error| error.to_string())?; + assert!(called.replace(false)); + + fixture + .registry + .advance_document(fixture.context) + .map_err(|error| error.to_string())?; + + assert_eq!( + fixture + .authorized + .dispatch_if_current(&fixture.registry, |_binding| called.set(true)) + .err(), + Some(BrowserRegistryError::UnknownNodeAuthority) + ); + assert!(!called.get()); + Ok(()) +} + +#[test] +fn adapter_failure_remains_separate_after_successful_revalidation() -> Result<(), String> { + let fixture = authorized_action()?; + + let adapter_result = fixture + .authorized + .dispatch_if_current(&fixture.registry, |_binding| -> Result<(), &'static str> { + Err("adapter failed") + }) + .map_err(|error| error.to_string())?; + + assert_eq!(adapter_result, Err("adapter failed")); + Ok(()) +} diff --git a/docs/doctoring.md b/docs/doctoring.md index f0133bb5d..f84e6aa9a 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -89,6 +89,7 @@ NIST AI 600-1 provides generative-AI lifecycle risk guidance. WASP demonstrates ### Web-agent observation and evaluation Mind2Web reports that raw real-world HTML is often too large for direct LLM use and that filtering improves effectiveness and efficiency. OriginWeave prioritizes typed tools, structured data, redacted network responses, accessibility/DOM/layout semantics, and only then visual fallback. WebArena motivates repeatable task-success and failure-recovery benchmarks instead of anecdotal demonstrations. +Semantic node actions revalidate the registry-owned node binding at the adapter handoff, so observation evidence cannot revive a retired or stale node handle. ### Learned test-time orchestration