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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo

### Added

- Exact extension typed-action proposal composition that requires the matching extension/session/context `ProposeTypedAction` grant before ordinary unchanged action-policy evaluation; extension transport cannot manufacture instruction trust, Agent capability or origin authority, secret approval, browser execution, or verified success.
- Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules.
- Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors.
- Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes.
Expand Down Expand Up @@ -73,4 +74,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
45 changes: 43 additions & 2 deletions crates/originweave-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ pub use sensitive_data::{
};

use originweave_core::{
ActionRequest, ApprovalEvidence, ApprovalScope, Capability, ExecutionPurpose,
InstructionSource, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, SessionMode,
ActionRequest, ApprovalEvidence, ApprovalScope, BrowserSessionId, BrowsingContextId,
Capability, ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest,
ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, InstructionSource, PolicyContext,
RiskClass, RobotsDecision, SecretDelivery, SessionMode, evaluate_extension_access,
};

/// The result of evaluating one typed action request.
Expand All @@ -31,6 +33,15 @@ 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 lacks exact typed-action proposal authority.
ExtensionAccessDenied(ExtensionAccessDecision),
/// Proposal authority was present; this is the unchanged ordinary action-policy result.
ActionPolicy(Decision),
}

/// A stable reason that policy denied an action.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DenialReason {
Expand Down Expand Up @@ -66,6 +77,36 @@ pub enum DenialReason {
ApprovalScopeMismatch,
}

/// Evaluate one extension-originated typed-action proposal without promoting transport authority.
///
/// This function checks the exact extension, browser-session, browsing-context and
/// [`ExtensionAgentCapability::ProposeTypedAction`] grant before ordinary action policy. When
/// extension access is allowed, the caller-supplied [`ActionRequest`] is evaluated unchanged, so
/// its instruction source, capability, origin, secret-delivery and approval requirements cannot be
/// minted or rewritten by extension transport. This function does not parse extension messages,
/// execute browser input, resolve secrets, or claim an action post-condition.
#[must_use]
pub fn evaluate_extension_action_proposal(
extension_id: &ExtensionId,
browser_session: BrowserSessionId,
browsing_context: BrowsingContextId,
grant: Option<&ExtensionAgentGrant>,
request: &ActionRequest,
context: &PolicyContext,
) -> ExtensionProposalDecision {
let access_request = ExtensionAccessRequest::new(
extension_id.clone(),
browser_session,
browsing_context,
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
180 changes: 180 additions & 0 deletions crates/originweave-policy/tests/extension_action_proposal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#![allow(clippy::expect_used)]

//! Compose exact extension proposal authority with ordinary typed-action policy.
//!
//! An extension grant can authorize only the right to propose one typed action for independent
//! OriginWeave policy evaluation. It must not manufacture instruction trust, action capability,
//! origin authority, secret authority, approval, or action success.

use std::collections::BTreeSet;

use originweave_core::{
ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId,
BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision,
ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, InstructionSource, Origin,
PolicyContext, RiskClass, RobotsDecision, SecretDelivery, SessionMode,
};
use originweave_policy::{
Decision, DenialReason, ExtensionProposalDecision, evaluate_extension_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_request(source: InstructionSource) -> ActionRequest {
let site = origin("https://app.example");
ActionRequest::new(
ActionKind::Observe,
site.clone(),
site,
source,
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_action_policy() {
assert_eq!(
evaluate_extension_action_proposal(
&extension_id(),
browser_session(),
browsing_context(),
None,
&observe_request(InstructionSource::User),
&observe_context(),
),
ExtensionProposalDecision::ExtensionAccessDenied(ExtensionAccessDecision::DenyMissingGrant)
);
}

#[test]
fn non_proposal_extension_capability_cannot_submit_an_action() {
let grant = ExtensionAgentGrant::new(
extension_id(),
browser_session(),
browsing_context(),
[ExtensionAgentCapability::ObserveCurrentContext],
);

assert_eq!(
evaluate_extension_action_proposal(
&extension_id(),
browser_session(),
browsing_context(),
Some(&grant),
&observe_request(InstructionSource::User),
&observe_context(),
),
ExtensionProposalDecision::ExtensionAccessDenied(
ExtensionAccessDecision::DenyCapabilityNotGranted
)
);
}

#[test]
fn exact_proposal_grant_reaches_ordinary_action_policy() {
assert_eq!(
evaluate_extension_action_proposal(
&extension_id(),
browser_session(),
browsing_context(),
Some(&proposal_grant()),
&observe_request(InstructionSource::User),
&observe_context(),
),
ExtensionProposalDecision::ActionPolicy(Decision::Allow)
);
}

#[test]
fn extension_transport_does_not_promote_web_content_to_instruction_authority() {
assert_eq!(
evaluate_extension_action_proposal(
&extension_id(),
browser_session(),
browsing_context(),
Some(&proposal_grant()),
&observe_request(InstructionSource::WebContent),
&observe_context(),
),
ExtensionProposalDecision::ActionPolicy(Decision::Deny(
DenialReason::UntrustedInstructionSource
))
);
}

#[test]
fn extension_proposal_grant_cannot_manufacture_secret_fill_approval() {
let site = origin("https://app.example");
let context = PolicyContext::new(
SessionMode::AgentTask,
ExecutionPurpose::UserDelegatedTask,
BTreeSet::from([Capability::FillSecret]),
BTreeSet::from([site.clone()]),
BTreeSet::from([site.clone()]),
RobotsDecision::Allowed,
ApprovalEvidence::None,
);
let request = ActionRequest::new(
ActionKind::FillSecret,
site.clone(),
site,
InstructionSource::User,
SecretDelivery::BrokerHandle,
intent(),
);

assert_eq!(
evaluate_extension_action_proposal(
&extension_id(),
browser_session(),
browsing_context(),
Some(&proposal_grant()),
&request,
&context,
),
ExtensionProposalDecision::ActionPolicy(Decision::RequireApproval(RiskClass::R3))
);
}
Loading