diff --git a/CHANGELOG.md b/CHANGELOG.md index f747adeae..56527dfe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to OriginWeave are documented in this file. The format follo - Refreshed the product-gap queue to 126 open pull requests (54 ready, 72 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review; these are queue-consolidation results, not protected-main shipment. +- Raw extension-message typed-action proposals are constructed internally as `InstructionSource::WebContent` before ordinary action-policy evaluation, while retaining exact source-origin and trusted-time extension-grant checks, so extension-produced message content cannot mint human or enterprise instruction trust or reuse a grant after origin movement or expiry. + ### Added - Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, current base/head pairs, the 126-PR queue count, explicit root-versus-child merge ordering, and the active GitHub counted-approval gate. - Refreshed the product and technical gap baseline onto the 2026-08-26 live inventory: 126 open pull requests (54 ready, 72 draft), protected-main promotion of #168/#194/#196/#216/#151, a verified maintenance-loop record (supersession closure of #153, conflict reconciliations on #37/#149/#152/#173/#175, issue #212 option-(b) authorization on #43, Strix vuln-0001 homoglyph remediation on #124), provider-rerun outcome evidence, an organization review-pipeline congestion record, and refreshed merge-order queue guidance. Documentation evidence contracts were aligned to the same snapshot so the baseline, its dated markers, and the pinned exact-head rows cannot silently diverge. @@ -102,4 +104,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index dbfb3c16d..998f0bcd9 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -17,8 +17,11 @@ pub use sensitive_data::{ use originweave_core::mcp::ValidatedMcpToolCall; use originweave_core::{ - ActionRequest, ApprovalEvidence, ApprovalScope, Capability, ExecutionPurpose, - InstructionSource, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, SessionMode, + 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. @@ -32,6 +35,50 @@ pub enum Decision { RequireApproval(RiskClass), } +/// Result of composing exact extension proposal authority with ordinary action policy. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExtensionProposalDecision { + /// The extension/session/context/origin/time lacks exact typed-action proposal authority. + ExtensionAccessDenied(ExtensionAccessDecision), + /// Proposal authority was present; this is the unchanged ordinary action-policy result. + 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 { @@ -86,6 +133,48 @@ pub fn evaluate_mcp( evaluate(request, context) } +/// Evaluate a raw extension-message proposal as untrusted web content. +/// +/// Exact extension/session/context/origin/time proposal authority is checked before the proposal is +/// 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. +/// `now_epoch_seconds` must come from the trusted caller rather than extension or page 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, + now_epoch_seconds: u64, + 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(), + ); + let access_request = ExtensionAccessRequest::new( + extension_id.clone(), + browser_session, + browsing_context, + request.source_origin().clone(), + now_epoch_seconds, + ExtensionAgentCapability::ProposeTypedAction, + ); + let access = evaluate_extension_access(&access_request, grant); + if access != ExtensionAccessDecision::Allow { + return ExtensionProposalDecision::ExtensionAccessDenied(access); + } + ExtensionProposalDecision::ActionPolicy(evaluate(&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 000000000..e929c68df --- /dev/null +++ b/crates/originweave-policy/tests/extension_message_action_proposal.rs @@ -0,0 +1,197 @@ +#![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"; +const APP_ORIGIN: &str = "https://app.example"; +const OTHER_ORIGIN: &str = "https://other.example"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + +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(), + origin(APP_ORIGIN), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn observe_proposal() -> ExtensionMessageActionProposal { + let site = origin(APP_ORIGIN); + 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(APP_ORIGIN)]), + 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(), + UNEXPIRED_NOW_EPOCH_SECONDS, + 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(), + UNEXPIRED_NOW_EPOCH_SECONDS, + Some(&proposal_grant()), + &observe_proposal(), + &observe_context(), + ), + ExtensionProposalDecision::ActionPolicy(Decision::Deny( + DenialReason::UntrustedInstructionSource + )) + ); +} + +#[test] +fn raw_extension_message_cannot_reuse_grant_for_another_origin() { + let grant = ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + origin(OTHER_ORIGIN), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ); + + assert_eq!( + evaluate_extension_message_action_proposal( + &extension_id(), + browser_session(), + browsing_context(), + UNEXPIRED_NOW_EPOCH_SECONDS, + Some(&grant), + &observe_proposal(), + &observe_context(), + ), + ExtensionProposalDecision::ExtensionAccessDenied( + ExtensionAccessDecision::DenyOriginMismatch + ) + ); +} + +#[test] +fn raw_extension_message_cannot_reuse_expired_grant() { + let grant = ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + origin(APP_ORIGIN), + UNEXPIRED_NOW_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ); + + assert_eq!( + evaluate_extension_message_action_proposal( + &extension_id(), + browser_session(), + browsing_context(), + UNEXPIRED_NOW_EPOCH_SECONDS, + Some(&grant), + &observe_proposal(), + &observe_context(), + ), + ExtensionProposalDecision::ExtensionAccessDenied(ExtensionAccessDecision::DenyExpired) + ); +} + +#[test] +fn raw_extension_message_cannot_hide_broker_material_inside_non_secret_action() { + let site = origin(APP_ORIGIN); + 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(), + UNEXPIRED_NOW_EPOCH_SECONDS, + Some(&proposal_grant()), + &proposal, + &observe_context(), + ), + ExtensionProposalDecision::ActionPolicy(Decision::Deny( + DenialReason::UntrustedInstructionSource + )) + ); +}