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
79 changes: 75 additions & 4 deletions crates/originweave-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
136 changes: 136 additions & 0 deletions crates/originweave-policy/tests/extension_message_action_proposal.rs
Original file line number Diff line number Diff line change
@@ -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
))
);
}
Loading