diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index 007f1548..01f2bec2 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -16,10 +16,11 @@ pub use sensitive_data::{ }; use originweave_core::{ - ActionRequest, ApprovalEvidence, ApprovalScope, BrowserSessionId, BrowsingContextId, - Capability, ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, - ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, InstructionSource, PolicyContext, - RiskClass, RobotsDecision, SecretDelivery, SessionMode, evaluate_extension_access, + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, + BrowserSessionId, BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, + SessionMode, evaluate_extension_access, }; /// The result of evaluating one typed action request. @@ -42,6 +43,41 @@ pub enum ExtensionProposalDecision { ActionPolicy(Decision), } +/// A typed action proposal derived from raw extension-produced message content. +/// +/// This value intentionally has no instruction-source field. Raw extension messages are untrusted +/// observations regardless of the extension's Chrome permissions or OriginWeave proposal grant. +/// A separate trusted adapter must authenticate independent human or enterprise-policy provenance +/// before using any path that can construct a trusted [`ActionRequest`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionMessageActionProposal { + action: ActionKind, + source_origin: Origin, + target_origin: Origin, + secret_delivery: SecretDelivery, + intent_digest: ActionIntentDigest, +} + +impl ExtensionMessageActionProposal { + /// Construct one raw extension-message action proposal without granting instruction trust. + #[must_use] + pub const fn new( + action: ActionKind, + source_origin: Origin, + target_origin: Origin, + secret_delivery: SecretDelivery, + intent_digest: ActionIntentDigest, + ) -> Self { + Self { + action, + source_origin, + target_origin, + secret_delivery, + intent_digest, + } + } +} + /// A stable reason that policy denied an action. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DenialReason { @@ -107,6 +143,41 @@ pub fn evaluate_extension_action_proposal( ExtensionProposalDecision::ActionPolicy(evaluate(request, context)) } +/// Evaluate a raw extension-message proposal as untrusted web content. +/// +/// Exact extension/session/context proposal authority is still checked first by +/// [`evaluate_extension_action_proposal`]. The proposal is then converted internally into an +/// [`ActionRequest`] whose instruction source is always [`InstructionSource::WebContent`]. The +/// extension therefore cannot select human or enterprise instruction trust from message content. +/// This boundary does not authenticate an independently trusted human/policy source, parse Chrome +/// messages, execute input, resolve secrets, or verify action success. +#[must_use] +pub fn evaluate_extension_message_action_proposal( + extension_id: &ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + grant: Option<&ExtensionAgentGrant>, + proposal: &ExtensionMessageActionProposal, + context: &PolicyContext, +) -> ExtensionProposalDecision { + let request = ActionRequest::new( + proposal.action, + proposal.source_origin.clone(), + proposal.target_origin.clone(), + InstructionSource::WebContent, + proposal.secret_delivery, + proposal.intent_digest.clone(), + ); + evaluate_extension_action_proposal( + extension_id, + browser_session, + browsing_context, + grant, + &request, + context, + ) +} + /// Evaluate a typed browser action against one explicit policy context. #[must_use] pub fn evaluate(request: &ActionRequest, context: &PolicyContext) -> Decision { diff --git a/crates/originweave-policy/tests/extension_message_action_proposal.rs b/crates/originweave-policy/tests/extension_message_action_proposal.rs new file mode 100644 index 00000000..afbeeee5 --- /dev/null +++ b/crates/originweave-policy/tests/extension_message_action_proposal.rs @@ -0,0 +1,136 @@ +#![allow(clippy::expect_used)] + +//! Raw extension messages remain untrusted observations when they propose typed actions. +//! +//! A Chrome/OriginWeave extension grant may authorize the right to propose a typed action, but +//! extension-produced message content cannot select `User` or `EnterprisePolicy` instruction trust. +//! A future trusted adapter that independently authenticates human or managed-policy provenance +//! needs a separate boundary; this raw-message path always enters ordinary policy as web content. + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ApprovalEvidence, BrowserSessionId, BrowsingContextId, + Capability, ExecutionPurpose, ExtensionAccessDecision, ExtensionAgentCapability, + ExtensionAgentGrant, ExtensionId, Origin, PolicyContext, RobotsDecision, SecretDelivery, + SessionMode, +}; +use originweave_policy::{ + Decision, DenialReason, ExtensionMessageActionProposal, ExtensionProposalDecision, + evaluate_extension_message_action_proposal, +}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(17).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(23).expect("nonzero browsing context") +} + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn observe_proposal() -> ExtensionMessageActionProposal { + let site = origin("https://app.example"); + ExtensionMessageActionProposal::new( + ActionKind::Observe, + site.clone(), + site, + SecretDelivery::None, + intent(), + ) +} + +fn observe_context() -> PolicyContext { + PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([origin("https://app.example")]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ) +} + +#[test] +fn missing_extension_grant_stops_before_raw_message_policy() { + assert_eq!( + evaluate_extension_message_action_proposal( + &extension_id(), + browser_session(), + browsing_context(), + None, + &observe_proposal(), + &observe_context(), + ), + ExtensionProposalDecision::ExtensionAccessDenied(ExtensionAccessDecision::DenyMissingGrant) + ); +} + +#[test] +fn exact_proposal_grant_cannot_promote_raw_extension_message_to_trusted_instruction() { + assert_eq!( + evaluate_extension_message_action_proposal( + &extension_id(), + browser_session(), + browsing_context(), + Some(&proposal_grant()), + &observe_proposal(), + &observe_context(), + ), + ExtensionProposalDecision::ActionPolicy(Decision::Deny( + DenialReason::UntrustedInstructionSource + )) + ); +} + +#[test] +fn raw_extension_message_cannot_hide_broker_material_inside_non_secret_action() { + let site = origin("https://app.example"); + let proposal = ExtensionMessageActionProposal::new( + ActionKind::Observe, + site.clone(), + site, + SecretDelivery::BrokerHandle, + intent(), + ); + + // Raw extension message trust fails before later secret/action checks. This ordering prevents + // the extension transport from probing which additional action-policy state would have matched. + assert_eq!( + evaluate_extension_message_action_proposal( + &extension_id(), + browser_session(), + browsing_context(), + Some(&proposal_grant()), + &proposal, + &observe_context(), + ), + ExtensionProposalDecision::ActionPolicy(Decision::Deny( + DenialReason::UntrustedInstructionSource + )) + ); +}