Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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 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.
- 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