From 3dfab9eeeee2e7dbc280bc69b2c8f04451df9dbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 07:37:57 +0900 Subject: [PATCH 1/6] test(policy): require explicit semantic-node policy authorization --- .../semantic_node_policy_authorization.rs | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 crates/originweave-policy/tests/semantic_node_policy_authorization.rs 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 000000000..c9087b060 --- /dev/null +++ b/crates/originweave-policy/tests/semantic_node_policy_authorization.rs @@ -0,0 +1,148 @@ +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() { + assert_eq!( + SemanticNodePolicyAuthorizationError::Denied(DenialReason::UntrustedInstructionSource) + .to_string(), + "semantic node action denied by deterministic policy: untrusted instruction source" + ); + assert_eq!( + SemanticNodePolicyAuthorizationError::ApprovalRequired(RiskClass::R4).to_string(), + "semantic node action requires R4 approval before policy authorization" + ); +} From 6c26e5d84e274f74d252ce70695ff2c2535ef57f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 07:41:15 +0900 Subject: [PATCH 2/6] style(policy): apply canonical rustfmt diagnostics --- .../tests/semantic_node_policy_authorization.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/originweave-policy/tests/semantic_node_policy_authorization.rs b/crates/originweave-policy/tests/semantic_node_policy_authorization.rs index c9087b060..771c06f6e 100644 --- a/crates/originweave-policy/tests/semantic_node_policy_authorization.rs +++ b/crates/originweave-policy/tests/semantic_node_policy_authorization.rs @@ -80,7 +80,10 @@ fn semantic_node_action_becomes_policy_authorized_only_after_allow() -> Result<( .map_err(|error| error.to_string())?; assert_eq!(authorized.binding(), &binding); - assert_eq!(authorized.binding().request().action(), ActionKind::Navigate); + assert_eq!( + authorized.binding().request().action(), + ActionKind::Navigate + ); Ok(()) } From daf94aebc657658a93fb99a84e61b22db9ac67a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 07:46:52 +0900 Subject: [PATCH 3/6] feat(policy): require explicit semantic-node policy authorization --- crates/originweave-policy/src/lib.rs | 4 + .../src/semantic_node_action.rs | 104 ++++++++++++++++++ .../semantic_node_policy_authorization.rs | 71 +++++++++++- 3 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 crates/originweave-policy/src/semantic_node_action.rs diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index 243ae8ce7..79364bfc2 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 000000000..5789587b6 --- /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 index 771c06f6e..ad2ec2c1e 100644 --- a/crates/originweave-policy/tests/semantic_node_policy_authorization.rs +++ b/crates/originweave-policy/tests/semantic_node_policy_authorization.rs @@ -139,11 +139,72 @@ fn policy_authorized_semantic_node_action_still_revalidates_browser_authority() #[test] fn semantic_node_policy_authorization_errors_are_credential_free() { - assert_eq!( - SemanticNodePolicyAuthorizationError::Denied(DenialReason::UntrustedInstructionSource) - .to_string(), - "semantic node action denied by deterministic policy: untrusted instruction source" - ); + 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" From e26a2d07ae731ff35271299036fa1f43c8550039 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 07:50:38 +0900 Subject: [PATCH 4/6] style(policy): apply canonical authorization-test formatting --- .../tests/semantic_node_policy_authorization.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/originweave-policy/tests/semantic_node_policy_authorization.rs b/crates/originweave-policy/tests/semantic_node_policy_authorization.rs index ad2ec2c1e..c7eea57b8 100644 --- a/crates/originweave-policy/tests/semantic_node_policy_authorization.rs +++ b/crates/originweave-policy/tests/semantic_node_policy_authorization.rs @@ -189,10 +189,7 @@ fn semantic_node_policy_authorization_errors_are_credential_free() { DenialReason::UnexpectedSecretMaterial, "unexpected secret material", ), - ( - DenialReason::ForbiddenRisk, - "risk class is not delegable", - ), + (DenialReason::ForbiddenRisk, "risk class is not delegable"), ( DenialReason::ApprovalScopeMismatch, "approval scope does not match", From e69f67b7a336630dacb502c571672e2924977255 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 10:44:18 -0700 Subject: [PATCH 5/6] test(policy): align inherited action-binding formatting --- .../tests/semantic_node_action_binding.rs | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_action_binding.rs b/crates/originweave-core/tests/semantic_node_action_binding.rs index 71a34b661..4122a40bf 100644 --- a/crates/originweave-core/tests/semantic_node_action_binding.rs +++ b/crates/originweave-core/tests/semantic_node_action_binding.rs @@ -74,8 +74,9 @@ fn action_request(source: Origin, target: Origin) -> Result Result<(), String> { let fixture = observation_fixture()?; - let target = SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) - .map_err(|error| error.to_string())?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; let request = action_request( origin("https://app.example")?, origin("https://next.example")?, @@ -92,8 +93,9 @@ fn node_action_binding_preserves_node_target_and_business_request() -> Result<() #[test] fn node_action_binding_rejects_request_from_another_document_origin() -> Result<(), String> { let fixture = observation_fixture()?; - let target = SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) - .map_err(|error| error.to_string())?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; let request = action_request( origin("https://other.example")?, origin("https://next.example")?, @@ -110,8 +112,9 @@ fn node_action_binding_rejects_request_from_another_document_origin() -> Result< fn node_action_binding_does_not_conflate_source_node_with_navigation_target() -> Result<(), String> { let fixture = observation_fixture()?; - let target = SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) - .map_err(|error| error.to_string())?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; let destination = origin("https://destination.example")?; let request = action_request(origin("https://app.example")?, destination.clone())?; @@ -123,10 +126,12 @@ fn node_action_binding_does_not_conflate_source_node_with_navigation_target() -> } #[test] -fn node_action_binding_revalidates_registry_owned_authority_before_dispatch() -> Result<(), String> { +fn node_action_binding_revalidates_registry_owned_authority_before_dispatch() -> Result<(), String> +{ let fixture = observation_fixture()?; - let target = SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) - .map_err(|error| error.to_string())?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; let request = action_request( origin("https://app.example")?, origin("https://next.example")?, @@ -143,8 +148,9 @@ fn node_action_binding_revalidates_registry_owned_authority_before_dispatch() -> #[test] fn node_action_binding_rejects_stale_document_before_dispatch() -> Result<(), String> { let mut fixture = observation_fixture()?; - let target = SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) - .map_err(|error| error.to_string())?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; let request = action_request( origin("https://app.example")?, origin("https://next.example")?, @@ -166,8 +172,9 @@ fn node_action_binding_rejects_stale_document_before_dispatch() -> Result<(), St #[test] fn node_action_binding_rejects_retired_session_before_dispatch() -> Result<(), String> { let mut fixture = observation_fixture()?; - let target = SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) - .map_err(|error| error.to_string())?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; let request = action_request( origin("https://app.example")?, origin("https://next.example")?, From 3c4e0b52486f97e4c5c9d2e8883772a1c6cbaa44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:42:57 -0700 Subject: [PATCH 6/6] fix(policy): revalidate semantic actions through live registry --- .../src/semantic_node_action.rs | 24 ++-- .../semantic_node_policy_authorization.rs | 116 ++++++++++-------- 2 files changed, 78 insertions(+), 62 deletions(-) diff --git a/crates/originweave-policy/src/semantic_node_action.rs b/crates/originweave-policy/src/semantic_node_action.rs index 5789587b6..c7797d60b 100644 --- a/crates/originweave-policy/src/semantic_node_action.rs +++ b/crates/originweave-policy/src/semantic_node_action.rs @@ -1,8 +1,8 @@ use std::fmt; use originweave_core::{ - BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, Origin, PolicyContext, - RiskClass, SemanticNodeActionBinding, + BrowserAuthorityRegistry, BrowserRegistryError, PolicyContext, RiskClass, + SemanticNodeActionBinding, }; use crate::{Decision, DenialReason, evaluate}; @@ -39,20 +39,16 @@ impl PolicyAuthorizedSemanticNodeAction { &self.binding } - /// Revalidate browser session, context, origin, and document epoch immediately before dispatch. + /// Revalidate registry-owned browser authority immediately before dispatch. + /// + /// The exact node binding retained by the policy-authorized action must still be live in the + /// supplied registry. Caller-presented session/context/origin/epoch tuples cannot revive a + /// retired, stale, forged, or cross-registry node target. 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, - ) + registry: &BrowserAuthorityRegistry, + ) -> Result<(), BrowserRegistryError> { + self.binding.validate_current(registry) } } diff --git a/crates/originweave-policy/tests/semantic_node_policy_authorization.rs b/crates/originweave-policy/tests/semantic_node_policy_authorization.rs index c7eea57b8..2e30e772d 100644 --- a/crates/originweave-policy/tests/semantic_node_policy_authorization.rs +++ b/crates/originweave-policy/tests/semantic_node_policy_authorization.rs @@ -1,11 +1,11 @@ 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, + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserAuthorityRegistry, + BrowserRegistryError, BrowsingContextId, Capability, ExecutionPurpose, InstructionSource, + NodeActionKind, ObservationChannel, Origin, PolicyContext, RiskClass, RobotsDecision, + SecretDelivery, SemanticNodeActionBinding, SemanticNodeActionTarget, SemanticNodeObservation, + SemanticNodeObservationInput, SessionMode, }; use originweave_policy::{ DenialReason, PolicyAuthorizedSemanticNodeAction, SemanticNodePolicyAuthorizationError, @@ -14,6 +14,12 @@ use originweave_policy::{ const VALID_INTENT: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +struct BindingFixture { + registry: BrowserAuthorityRegistry, + context: BrowsingContextId, + binding: SemanticNodeActionBinding, +} + fn origin(value: &str) -> Result { Origin::parse(value).map_err(|error| format!("{error:?}")) } @@ -21,30 +27,35 @@ fn origin(value: &str) -> Result { fn binding( action: ActionKind, instruction_source: InstructionSource, -) -> Result { +) -> Result { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry + .register_session("semantic-policy-session") + .map_err(|error| error.to_string())?; + let context = registry + .register_context(session, "semantic-policy-context") + .map_err(|error| error.to_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, + let handle = registry + .bind_node(session, context, &site, "semantic-policy-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 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( @@ -55,7 +66,14 @@ fn binding( SecretDelivery::None, ActionIntentDigest::parse(VALID_INTENT).map_err(|error| format!("{error:?}"))?, ); - SemanticNodeActionBinding::new(target, request).map_err(|error| error.to_string()) + let binding = + SemanticNodeActionBinding::new(target, request).map_err(|error| error.to_string())?; + + Ok(BindingFixture { + registry, + context, + binding, + }) } fn context(action: ActionKind) -> Result { @@ -73,27 +91,31 @@ fn context(action: ActionKind) -> Result { #[test] fn semantic_node_action_becomes_policy_authorized_only_after_allow() -> Result<(), String> { - let binding = binding(ActionKind::Navigate, InstructionSource::User)?; + let fixture = binding(ActionKind::Navigate, InstructionSource::User)?; let context = context(ActionKind::Navigate)?; - let authorized = PolicyAuthorizedSemanticNodeAction::authorize(binding.clone(), &context) - .map_err(|error| error.to_string())?; + let authorized = + PolicyAuthorizedSemanticNodeAction::authorize(fixture.binding.clone(), &context) + .map_err(|error| error.to_string())?; - assert_eq!(authorized.binding(), &binding); + assert_eq!(authorized.binding(), &fixture.binding); assert_eq!( authorized.binding().request().action(), ActionKind::Navigate ); + authorized + .validate_current(&fixture.registry) + .map_err(|error| error.to_string())?; Ok(()) } #[test] fn semantic_node_action_preserves_approval_required_as_non_authorized() -> Result<(), String> { - let binding = binding(ActionKind::Purchase, InstructionSource::User)?; + let fixture = binding(ActionKind::Purchase, InstructionSource::User)?; let context = context(ActionKind::Purchase)?; assert_eq!( - PolicyAuthorizedSemanticNodeAction::authorize(binding, &context).err(), + PolicyAuthorizedSemanticNodeAction::authorize(fixture.binding, &context).err(), Some(SemanticNodePolicyAuthorizationError::ApprovalRequired( RiskClass::R4 )) @@ -103,11 +125,11 @@ fn semantic_node_action_preserves_approval_required_as_non_authorized() -> Resul #[test] fn semantic_node_action_preserves_policy_denial_as_non_authorized() -> Result<(), String> { - let binding = binding(ActionKind::Navigate, InstructionSource::WebContent)?; + let fixture = binding(ActionKind::Navigate, InstructionSource::WebContent)?; let context = context(ActionKind::Navigate)?; assert_eq!( - PolicyAuthorizedSemanticNodeAction::authorize(binding, &context).err(), + PolicyAuthorizedSemanticNodeAction::authorize(fixture.binding, &context).err(), Some(SemanticNodePolicyAuthorizationError::Denied( DenialReason::UntrustedInstructionSource )) @@ -118,22 +140,20 @@ fn semantic_node_action_preserves_policy_denial_as_non_authorized() -> Result<() #[test] fn policy_authorized_semantic_node_action_still_revalidates_browser_authority() -> Result<(), String> { - let binding = binding(ActionKind::Navigate, InstructionSource::User)?; + let mut fixture = binding(ActionKind::Navigate, InstructionSource::User)?; let context = context(ActionKind::Navigate)?; - let authorized = PolicyAuthorizedSemanticNodeAction::authorize(binding, &context) + let authorized = PolicyAuthorizedSemanticNodeAction::authorize(fixture.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")); + fixture + .registry + .advance_document(fixture.context) + .map_err(|error| error.to_string())?; + + assert_eq!( + authorized.validate_current(&fixture.registry).err(), + Some(BrowserRegistryError::UnknownNodeAuthority) + ); Ok(()) }