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
4 changes: 4 additions & 0 deletions crates/originweave-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@
#![forbid(unsafe_code)]
#![deny(missing_docs)]

mod semantic_node_action;
mod sensitive_data;

pub use semantic_node_action::{
PolicyAuthorizedSemanticNodeAction, SemanticNodePolicyAuthorizationError,
};
pub use sensitive_data::{
DataClassification, DisclosureDecision, DisclosureScope, HandleUseDecision, HandleUseRequest,
SensitiveDataAuthority, SensitiveDataRequest, SensitiveValueHandleScope, evaluate_disclosure,
Expand Down
104 changes: 104 additions & 0 deletions crates/originweave-policy/src/semantic_node_action.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use std::fmt;

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

use crate::{Decision, DenialReason, evaluate};

/// A semantic-node action that the deterministic action policy explicitly allowed.
///
/// Construction evaluates the exact [`SemanticNodeActionBinding`] request through the ordinary
/// OriginWeave action policy. This value does not grant browser authority, approval, destination
/// authority, secret access, or execution success. Callers must still revalidate the bound browser
/// authority immediately before dispatch and satisfy every later execution boundary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyAuthorizedSemanticNodeAction {
binding: SemanticNodeActionBinding,
}

impl PolicyAuthorizedSemanticNodeAction {
/// Evaluate the exact bound business request and retain it only after explicit policy allow.
pub fn authorize(
binding: SemanticNodeActionBinding,
context: &PolicyContext,
) -> Result<Self, SemanticNodePolicyAuthorizationError> {
match evaluate(binding.request(), context) {
Decision::Allow => Ok(Self { binding }),
Decision::Deny(reason) => Err(SemanticNodePolicyAuthorizationError::Denied(reason)),
Decision::RequireApproval(risk) => {
Err(SemanticNodePolicyAuthorizationError::ApprovalRequired(risk))
}
}
}

/// Return the exact semantic-node target and business request that policy allowed together.
#[must_use]
pub const fn binding(&self) -> &SemanticNodeActionBinding {
&self.binding
}

/// Revalidate browser session, context, origin, and document epoch immediately before dispatch.
pub fn validate_current(
&self,
current_session: BrowserSessionId,
current_context: BrowsingContextId,
current_origin: &Origin,
current_epoch: DocumentEpoch,
) -> Result<(), NodeHandleError> {
self.binding.validate_current(
current_session,
current_context,
current_origin,
current_epoch,
)
}
}

/// A fail-closed outcome that did not produce a policy-authorized semantic-node action.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SemanticNodePolicyAuthorizationError {
/// Deterministic policy denied the exact business action request.
Denied(DenialReason),
/// Deterministic policy requires approval for the returned risk class before authorization.
ApprovalRequired(RiskClass),
}

impl fmt::Display for SemanticNodePolicyAuthorizationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Denied(reason) => write!(
formatter,
"semantic node action denied by deterministic policy: {}",
denial_reason_message(reason)
),
Self::ApprovalRequired(risk) => write!(
formatter,
"semantic node action requires {risk:?} approval before policy authorization"
),
}
}
}

impl std::error::Error for SemanticNodePolicyAuthorizationError {}

fn denial_reason_message(reason: &DenialReason) -> &'static str {
match reason {
DenialReason::HumanModeNotAgentControlled => "human mode is not agent controlled",
DenialReason::ModePurposeMismatch => "execution mode and purpose mismatch",
DenialReason::UntrustedInstructionSource => "untrusted instruction source",
DenialReason::MissingCapability(_) => "required capability is missing",
DenialReason::OriginNotReadable => "target origin is not readable",
DenialReason::CrawlerMutation => "crawler mutation is forbidden",
DenialReason::CrossOriginMutation => "cross-origin mutation is forbidden",
DenialReason::OriginNotWritable => "target origin is not writable",
DenialReason::RobotsDisallowed => "robots policy disallows the crawl",
DenialReason::RobotsUnknown => "robots policy is unknown",
DenialReason::RobotsNotApplicable => "robots policy was not evaluated",
DenialReason::SecretBrokerRequired => "secret broker handle is required",
DenialReason::UnexpectedSecretMaterial => "unexpected secret material",
DenialReason::ForbiddenRisk => "risk class is not delegable",
DenialReason::ApprovalScopeMismatch => "approval scope does not match",
}
}
209 changes: 209 additions & 0 deletions crates/originweave-policy/tests/semantic_node_policy_authorization.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
use std::collections::BTreeSet;

use originweave_core::{
ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId,
BrowsingContextId, Capability, DocumentEpoch, ExecutionPurpose, InstructionSource,
NodeActionKind, ObservationChannel, ObservedNodeHandle, Origin, PolicyContext, RiskClass,
RobotsDecision, SecretDelivery, SemanticNodeActionBinding, SemanticNodeActionTarget,
SemanticNodeObservation, SemanticNodeObservationInput, SessionMode,
};
use originweave_policy::{
DenialReason, PolicyAuthorizedSemanticNodeAction, SemanticNodePolicyAuthorizationError,
};

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

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

fn binding(
action: ActionKind,
instruction_source: InstructionSource,
) -> Result<SemanticNodeActionBinding, String> {
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(
action,
site.clone(),
site,
instruction_source,
SecretDelivery::None,
ActionIntentDigest::parse(VALID_INTENT).map_err(|error| format!("{error:?}"))?,
);
SemanticNodeActionBinding::new(target, request).map_err(|error| error.to_string())
}

fn context(action: ActionKind) -> Result<PolicyContext, String> {
let site = origin("https://app.example")?;
Ok(PolicyContext::new(
SessionMode::AgentTask,
ExecutionPurpose::UserDelegatedTask,
BTreeSet::from([action.required_capability()]),
BTreeSet::from([site.clone()]),
BTreeSet::from([site]),
RobotsDecision::Allowed,
ApprovalEvidence::None,
))
}

#[test]
fn semantic_node_action_becomes_policy_authorized_only_after_allow() -> Result<(), String> {
let binding = binding(ActionKind::Navigate, InstructionSource::User)?;
let context = context(ActionKind::Navigate)?;

let authorized = PolicyAuthorizedSemanticNodeAction::authorize(binding.clone(), &context)
.map_err(|error| error.to_string())?;

assert_eq!(authorized.binding(), &binding);
assert_eq!(
authorized.binding().request().action(),
ActionKind::Navigate
);
Ok(())
}

#[test]
fn semantic_node_action_preserves_approval_required_as_non_authorized() -> Result<(), String> {
let binding = binding(ActionKind::Purchase, InstructionSource::User)?;
let context = context(ActionKind::Purchase)?;

assert_eq!(
PolicyAuthorizedSemanticNodeAction::authorize(binding, &context).err(),
Some(SemanticNodePolicyAuthorizationError::ApprovalRequired(
RiskClass::R4
))
);
Ok(())
}

#[test]
fn semantic_node_action_preserves_policy_denial_as_non_authorized() -> Result<(), String> {
let binding = binding(ActionKind::Navigate, InstructionSource::WebContent)?;
let context = context(ActionKind::Navigate)?;

assert_eq!(
PolicyAuthorizedSemanticNodeAction::authorize(binding, &context).err(),
Some(SemanticNodePolicyAuthorizationError::Denied(
DenialReason::UntrustedInstructionSource
))
);
Ok(())
}

#[test]
fn policy_authorized_semantic_node_action_still_revalidates_browser_authority() -> Result<(), String>
{
let binding = binding(ActionKind::Navigate, InstructionSource::User)?;
let context = context(ActionKind::Navigate)?;
let authorized = PolicyAuthorizedSemanticNodeAction::authorize(binding, &context)
.map_err(|error| error.to_string())?;

let error = authorized
.validate_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(4).map_err(|error| error.to_string())?,
)
.err()
.ok_or_else(|| "stale document epoch unexpectedly authorized".to_owned())?;

assert!(error.to_string().contains("stale"));
Ok(())
}

#[test]
fn semantic_node_policy_authorization_errors_are_credential_free() {
let denial_cases = [
(
DenialReason::HumanModeNotAgentControlled,
"human mode is not agent controlled",
),
(
DenialReason::ModePurposeMismatch,
"execution mode and purpose mismatch",
),
(
DenialReason::UntrustedInstructionSource,
"untrusted instruction source",
),
(
DenialReason::MissingCapability(Capability::Navigate),
"required capability is missing",
),
(
DenialReason::OriginNotReadable,
"target origin is not readable",
),
(
DenialReason::CrawlerMutation,
"crawler mutation is forbidden",
),
(
DenialReason::CrossOriginMutation,
"cross-origin mutation is forbidden",
),
(
DenialReason::OriginNotWritable,
"target origin is not writable",
),
(
DenialReason::RobotsDisallowed,
"robots policy disallows the crawl",
),
(DenialReason::RobotsUnknown, "robots policy is unknown"),
(
DenialReason::RobotsNotApplicable,
"robots policy was not evaluated",
),
(
DenialReason::SecretBrokerRequired,
"secret broker handle is required",
),
(
DenialReason::UnexpectedSecretMaterial,
"unexpected secret material",
),
(DenialReason::ForbiddenRisk, "risk class is not delegable"),
(
DenialReason::ApprovalScopeMismatch,
"approval scope does not match",
),
];

for (reason, expected_reason) in denial_cases {
assert_eq!(
SemanticNodePolicyAuthorizationError::Denied(reason).to_string(),
format!("semantic node action denied by deterministic policy: {expected_reason}")
);
}
assert_eq!(
SemanticNodePolicyAuthorizationError::ApprovalRequired(RiskClass::R4).to_string(),
"semantic node action requires R4 approval before policy authorization"
);
}
Loading