diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index 243ae8ce..79364bfc 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -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, diff --git a/crates/originweave-policy/src/semantic_node_action.rs b/crates/originweave-policy/src/semantic_node_action.rs new file mode 100644 index 00000000..5789587b --- /dev/null +++ b/crates/originweave-policy/src/semantic_node_action.rs @@ -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 { + 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", + } +} diff --git a/crates/originweave-policy/tests/semantic_node_policy_authorization.rs b/crates/originweave-policy/tests/semantic_node_policy_authorization.rs new file mode 100644 index 00000000..c7eea57b --- /dev/null +++ b/crates/originweave-policy/tests/semantic_node_policy_authorization.rs @@ -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::parse(value).map_err(|error| format!("{error:?}")) +} + +fn binding( + action: ActionKind, + instruction_source: InstructionSource, +) -> 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( + 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 { + 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" + ); +}