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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD
93 changes: 91 additions & 2 deletions crates/originweave-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
197 changes: 197 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,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
))
);
}
Loading