diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc5651f0..ebec3fab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ 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. ### Added + +- Time-bounded managed extension admission for isolated Agent Task profiles with an explicit half-open validity window and caller-supplied trusted evaluation time; invalid, not-yet-valid, and expired policy states fail closed before exact extension allow-list membership is considered. +- Fail-closed managed extension admission for isolated Agent Task profiles: an empty policy admits no extension, only exact canonical `ExtensionId` allow-list membership is accepted, duplicate entries cannot widen authority, and successful profile admission remains separate from `ExtensionAgentGrant` capability. +- Explicit reduced-assurance classification for attached human tabs when trusted adapter evidence says an existing extension can influence page state; the narrow rule does not detect extensions, prove extension absence, grant Agent authority, or turn an unclassified context into high-assurance evidence. - 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. diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index dbfb3c16d..0491b55ea 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -15,12 +15,107 @@ pub use sensitive_data::{ evaluate_handle_use, }; +use std::collections::BTreeSet; + use originweave_core::mcp::ValidatedMcpToolCall; use originweave_core::{ - ActionRequest, ApprovalEvidence, ApprovalScope, Capability, ExecutionPurpose, + ActionRequest, ApprovalEvidence, ApprovalScope, Capability, ExecutionPurpose, ExtensionId, InstructionSource, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, SessionMode, }; +/// Exact extension identities that may be present in one managed Agent Task profile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentTaskExtensionPolicy { + managed_extensions: BTreeSet, + valid_from: u64, + valid_until: u64, + maximum_window: u64, +} + +impl AgentTaskExtensionPolicy { + /// Build one fail-closed Agent Task extension admission policy. + /// + /// Duplicate identifiers collapse to one exact managed identity. An empty + /// iterator therefore represents the default policy that admits no extension. + /// `valid_from` is inclusive and `valid_until` is exclusive. `maximum_window` + /// is the reviewed local ceiling for that interval. All three values are opaque + /// timestamps or durations in the same caller-defined trusted time domain and + /// units supplied to [`evaluate_agent_task_extension`]. This constructor does + /// not authenticate policy provenance or attest a clock; an invalid, empty, or + /// overlong validity window is retained so evaluation can fail closed + /// deterministically. + #[must_use] + pub fn new( + managed_extensions: I, + valid_from: u64, + valid_until: u64, + maximum_window: u64, + ) -> Self + where + I: IntoIterator, + { + Self { + managed_extensions: managed_extensions.into_iter().collect(), + valid_from, + valid_until, + maximum_window, + } + } +} + +/// Result of evaluating one extension identity for an isolated Agent Task profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentTaskExtensionDecision { + /// The exact canonical extension identity appears in the managed allow-list. + AllowManagedExtension, + /// The extension identity is absent from the managed allow-list. + DenyNotManaged, + /// The configured validity window is empty/reversed or its local ceiling is zero. + DenyInvalidPolicyWindow, + /// The configured policy validity interval exceeds the reviewed local maximum. + DenyPolicyWindowExceedsMaximum, + /// The trusted evaluation time precedes the policy validity window. + DenyPolicyNotYetValid, + /// The trusted evaluation time is at or beyond the policy expiry boundary. + DenyPolicyExpired, +} + +/// Evaluate extension admission without minting OriginWeave Agent capability. +/// +/// This pure boundary answers only whether the exact canonical extension may be +/// present in the caller's managed Agent Task profile at `trusted_time`. +/// `trusted_time`, [`AgentTaskExtensionPolicy::new`] `valid_from`, `valid_until`, +/// and `maximum_window` must use one caller-attested time domain and compatible +/// units; this function does not read or attest a clock. The validity window is +/// half-open (`valid_from <= trusted_time < valid_until`) and must not exceed the +/// reviewed local maximum. Chromium permissions, installation state, native +/// messaging, and [`originweave_core::ExtensionAgentGrant`] remain separate +/// authorities. +#[must_use] +pub fn evaluate_agent_task_extension( + extension_id: &ExtensionId, + policy: &AgentTaskExtensionPolicy, + trusted_time: u64, +) -> AgentTaskExtensionDecision { + if policy.valid_from >= policy.valid_until || policy.maximum_window == 0 { + return AgentTaskExtensionDecision::DenyInvalidPolicyWindow; + } + if policy.valid_until - policy.valid_from > policy.maximum_window { + return AgentTaskExtensionDecision::DenyPolicyWindowExceedsMaximum; + } + if trusted_time < policy.valid_from { + return AgentTaskExtensionDecision::DenyPolicyNotYetValid; + } + if trusted_time >= policy.valid_until { + return AgentTaskExtensionDecision::DenyPolicyExpired; + } + if policy.managed_extensions.contains(extension_id) { + AgentTaskExtensionDecision::AllowManagedExtension + } else { + AgentTaskExtensionDecision::DenyNotManaged + } +} + /// The result of evaluating one typed action request. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Decision { diff --git a/crates/originweave-policy/tests/agent_task_extension_policy.rs b/crates/originweave-policy/tests/agent_task_extension_policy.rs new file mode 100644 index 000000000..fae16ade5 --- /dev/null +++ b/crates/originweave-policy/tests/agent_task_extension_policy.rs @@ -0,0 +1,147 @@ +#![allow(clippy::expect_used)] + +use originweave_core::{ + BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest, + ExtensionAgentCapability, ExtensionId, Origin, evaluate_extension_access, +}; +use originweave_policy::{ + AgentTaskExtensionDecision, AgentTaskExtensionPolicy, evaluate_agent_task_extension, +}; + +fn extension_id(value: &str) -> ExtensionId { + ExtensionId::parse(value).expect("valid extension identifier") +} + +fn session(value: u64) -> BrowserSessionId { + BrowserSessionId::new(value).expect("nonzero browser session") +} + +fn context(value: u64) -> BrowsingContextId { + BrowsingContextId::new(value).expect("nonzero browsing context") +} + +fn origin() -> Origin { + Origin::parse("https://app.example").expect("valid controlled origin") +} + +#[test] +fn empty_agent_task_extension_policy_denies_every_extension() { + let policy = AgentTaskExtensionPolicy::new([], 10, 20, 10); + let extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); + + assert_eq!( + evaluate_agent_task_extension(&extension, &policy, 15), + AgentTaskExtensionDecision::DenyNotManaged + ); +} + +#[test] +fn managed_agent_task_extension_policy_allows_only_exact_identifiers() { + let allowed = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let other = extension_id("bcdefghijklmnopabcdefghijklmnopa"); + let policy = AgentTaskExtensionPolicy::new([allowed.clone(), allowed.clone()], 10, 20, 10); + + assert_eq!( + evaluate_agent_task_extension(&allowed, &policy, 10), + AgentTaskExtensionDecision::AllowManagedExtension + ); + assert_eq!( + evaluate_agent_task_extension(&other, &policy, 19), + AgentTaskExtensionDecision::DenyNotManaged + ); +} + +#[test] +fn managed_agent_task_extension_policy_fails_closed_outside_its_validity_window() { + let extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let policy = AgentTaskExtensionPolicy::new([extension.clone()], 10, 20, 10); + + assert_eq!( + evaluate_agent_task_extension(&extension, &policy, 9), + AgentTaskExtensionDecision::DenyPolicyNotYetValid + ); + assert_eq!( + evaluate_agent_task_extension(&extension, &policy, 20), + AgentTaskExtensionDecision::DenyPolicyExpired + ); + assert_eq!( + evaluate_agent_task_extension(&extension, &policy, u64::MAX), + AgentTaskExtensionDecision::DenyPolicyExpired + ); +} + +#[test] +fn invalid_managed_extension_policy_window_fails_closed_before_membership() { + let extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let reversed = AgentTaskExtensionPolicy::new([extension.clone()], 20, 10, 10); + let empty = AgentTaskExtensionPolicy::new([extension.clone()], 20, 20, 10); + let zero_maximum = AgentTaskExtensionPolicy::new([extension.clone()], 20, 21, 0); + + assert_eq!( + evaluate_agent_task_extension(&extension, &reversed, 15), + AgentTaskExtensionDecision::DenyInvalidPolicyWindow + ); + assert_eq!( + evaluate_agent_task_extension(&extension, &empty, 20), + AgentTaskExtensionDecision::DenyInvalidPolicyWindow + ); + assert_eq!( + evaluate_agent_task_extension(&extension, &zero_maximum, 20), + AgentTaskExtensionDecision::DenyInvalidPolicyWindow + ); +} + +#[test] +fn managed_extension_policy_window_cannot_exceed_local_maximum() { + let extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let exact = AgentTaskExtensionPolicy::new([extension.clone()], 10, 20, 10); + let overlong = AgentTaskExtensionPolicy::new([extension.clone()], 10, 21, 10); + + assert_eq!( + evaluate_agent_task_extension(&extension, &exact, 19), + AgentTaskExtensionDecision::AllowManagedExtension + ); + assert_eq!( + evaluate_agent_task_extension(&extension, &overlong, 19), + AgentTaskExtensionDecision::DenyPolicyWindowExceedsMaximum + ); +} + +#[test] +fn maximum_timestamp_window_remains_half_open_without_overflow() { + let extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let policy = AgentTaskExtensionPolicy::new([extension.clone()], u64::MAX - 1, u64::MAX, 1); + + assert_eq!( + evaluate_agent_task_extension(&extension, &policy, u64::MAX - 1), + AgentTaskExtensionDecision::AllowManagedExtension + ); + assert_eq!( + evaluate_agent_task_extension(&extension, &policy, u64::MAX), + AgentTaskExtensionDecision::DenyPolicyExpired + ); +} + +#[test] +fn managed_agent_task_extension_admission_does_not_mint_agent_capability() { + let extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let policy = AgentTaskExtensionPolicy::new([extension.clone()], 10, 20, 10); + + assert_eq!( + evaluate_agent_task_extension(&extension, &policy, 15), + AgentTaskExtensionDecision::AllowManagedExtension + ); + + let request = ExtensionAccessRequest::new( + extension, + session(31), + context(37), + origin(), + 100, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, None), + ExtensionAccessDecision::DenyMissingGrant + ); +}