Skip to content
Draft
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ All notable changes to OriginWeave are documented in this file. The format follo
- 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.
- 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.
- Same-call semantic-state dispatch boundary that requires one fresh exact semantic observation to retain the governed node, selected node-local action, and required enabled state before an already policy-authorized adapter callback can run.
- 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.
Expand Down
23 changes: 22 additions & 1 deletion crates/originweave-policy/src/semantic_node_action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::fmt;

use originweave_core::{
BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, Origin, PolicyContext,
RiskClass, SemanticNodeActionBinding,
RiskClass, SemanticNodeActionBinding, SemanticNodeActionTargetError, SemanticNodeObservation,
};

use crate::{Decision, DenialReason, evaluate};
Expand Down Expand Up @@ -81,6 +81,27 @@ impl PolicyAuthorizedSemanticNodeAction {
)?;
Ok(dispatch(&self.binding))
}

/// Revalidate one fresh semantic observation and immediately invoke the dispatch callback.
///
/// The caller must obtain `current_observation` from a trusted browser adapter immediately
/// before the side effect. The callback is not invoked when the observation describes a
/// different OriginWeave-owned node, no longer advertises the selected node-local action, or
/// reports the node disabled for an action that requires enabled state. This method does not
/// obtain or authenticate the observation and does not prove execution success.
pub fn dispatch_if_current_observation<R, F>(
&self,
current_observation: &SemanticNodeObservation,
dispatch: F,
) -> Result<R, SemanticNodeActionTargetError>
where
F: FnOnce(&SemanticNodeActionBinding) -> R,
{
self.binding
.target()
.validate_current_observation(current_observation)?;
Ok(dispatch(&self.binding))
}
}

/// A fail-closed outcome that did not produce a policy-authorized semantic-node action.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
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, SemanticNodeActionTargetError,
SemanticNodeObservation, SemanticNodeObservationInput, SessionMode,
};
use originweave_policy::PolicyAuthorizedSemanticNodeAction;

const VALID_INTENT: &str =
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

fn origin(value: &str) -> Result<Origin, String> {
Origin::parse(value).map_err(|error| format!("{error:?}"))
}

fn observation(
node_id: u64,
enabled: bool,
supported_actions: BTreeSet<NodeActionKind>,
) -> Result<SemanticNodeObservation, String> {
SemanticNodeObservation::new(SemanticNodeObservationInput {
handle: ObservedNodeHandle::new(
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())?,
node_id,
)
.map_err(|error| error.to_string())?,
parent: None,
children: Vec::new(),
role: "button".to_owned(),
accessible_name: "Continue".to_owned(),
visible_text: Some("Continue".to_owned()),
enabled,
visible: true,
selected: None,
supported_actions,
evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]),
})
.map_err(|error| error.to_string())
}

fn authorized_action() -> Result<PolicyAuthorizedSemanticNodeAction, String> {
let site = origin("https://app.example")?;
let initial_observation = observation(17, true, BTreeSet::from([NodeActionKind::Click]))?;
let target =
SemanticNodeActionTarget::from_observation(&initial_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_action(
authorized: &PolicyAuthorizedSemanticNodeAction,
current: &SemanticNodeObservation,
called: &Cell<bool>,
adapter_should_fail: bool,
) -> Result<Result<(NodeActionKind, ActionKind), &'static str>, SemanticNodeActionTargetError> {
authorized.dispatch_if_current_observation(current, |binding| {
called.set(true);
if adapter_should_fail {
Err("adapter failed")
} else {
Ok((binding.target().action(), binding.request().action()))
}
})
}

#[test]
fn exact_current_semantic_observation_reaches_dispatch() -> Result<(), String> {
let authorized = authorized_action()?;
let current = observation(17, true, BTreeSet::from([NodeActionKind::Click]))?;
let called = Cell::new(false);

let adapter_result = dispatch_action(&authorized, &current, &called, false)
.map_err(|error| error.to_string())?;

assert_eq!(
adapter_result,
Ok((NodeActionKind::Click, ActionKind::Navigate))
);
assert!(called.get());
Ok(())
}

#[test]
fn newly_disabled_node_never_reaches_dispatch() -> Result<(), String> {
let authorized = authorized_action()?;
let current = observation(17, false, BTreeSet::from([NodeActionKind::Click]))?;
let called = Cell::new(false);

let error = dispatch_action(&authorized, &current, &called, false)
.err()
.ok_or_else(|| "disabled current observation unexpectedly dispatched".to_owned())?;

assert_eq!(error, SemanticNodeActionTargetError::NodeNotEnabled);
assert!(!called.get());
Ok(())
}

#[test]
fn removed_action_never_reaches_dispatch() -> Result<(), String> {
let authorized = authorized_action()?;
let current = observation(17, true, BTreeSet::from([NodeActionKind::ScrollIntoView]))?;
let called = Cell::new(false);

let error = dispatch_action(&authorized, &current, &called, false)
.err()
.ok_or_else(|| "removed semantic action unexpectedly dispatched".to_owned())?;

assert_eq!(error, SemanticNodeActionTargetError::UnsupportedAction);
assert!(!called.get());
Ok(())
}

#[test]
fn different_same_document_node_never_reaches_dispatch() -> Result<(), String> {
let authorized = authorized_action()?;
let current = observation(18, true, BTreeSet::from([NodeActionKind::Click]))?;
let called = Cell::new(false);

let error = dispatch_action(&authorized, &current, &called, false)
.err()
.ok_or_else(|| "different semantic node unexpectedly dispatched".to_owned())?;

assert_eq!(
error,
SemanticNodeActionTargetError::ObservationAuthorityMismatch
);
assert!(!called.get());
Ok(())
}

#[test]
fn adapter_failure_remains_separate_after_semantic_revalidation() -> Result<(), String> {
let authorized = authorized_action()?;
let current = observation(17, true, BTreeSet::from([NodeActionKind::Click]))?;
let called = Cell::new(false);

let adapter_result =
dispatch_action(&authorized, &current, &called, true).map_err(|error| error.to_string())?;

assert_eq!(adapter_result, Err("adapter failed"));
assert!(called.get());
Ok(())
}
Loading