diff --git a/CHANGELOG.md b/CHANGELOG.md index f747adeae..31768c2e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ 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 +- Active PR #40 adds the `BrowserAuthorityRegistry`, `BrowserProtocolAdapterDescriptor`, and Agent-Task-bound extension grant/request API; it requires exact `AgentTaskId` authority for extension access and rejects bare `0x` browser-special numeric host spellings. This remains active-PR evidence, not protected-main shipment. - 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-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs new file mode 100644 index 000000000..3421b27b3 --- /dev/null +++ b/crates/originweave-core/src/browser_protocol.rs @@ -0,0 +1,187 @@ +use std::fmt; + +/// Maximum UTF-8 byte length for browser protocol adapter metadata tokens. +pub const MAX_BROWSER_PROTOCOL_METADATA_BYTES: usize = 128; + +/// Browser automation protocol family used by one versioned adapter. +/// +/// The protocol family is descriptive metadata only. Selecting a kind does not +/// grant any OriginWeave capability or imply that a particular protocol +/// feature is available. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolKind { + /// Standards-track WebDriver BiDi adapter. + WebDriverBiDi, + /// Chromium-specific Chrome DevTools Protocol adapter. + ChromeDevToolsProtocol, +} + +/// One browser operation surface explicitly implemented by an adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolCapability { + /// Navigate a controlled browser context through the adapter. + Navigation, + /// Produce bounded semantic browser observations. + SemanticObservation, + /// Dispatch typed browser input after OriginWeave policy authorization. + TypedInput, + /// Observe bounded network evidence needed by higher-level provenance. + NetworkObservation, +} + +/// Immutable version and capability metadata for one browser protocol adapter. +/// +/// This value is deliberately not browser authority. It contains no browser +/// session, context, origin, node handle, action grant, credential, or network +/// permission. Higher layers may use it to fail closed when a required adapter +/// capability is absent, while all OriginWeave authority remains separately +/// validated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserProtocolAdapterDescriptor { + kind: BrowserProtocolKind, + adapter_version: String, + protocol_revision: String, + browser_revision: String, + capabilities: Vec, +} + +impl BrowserProtocolAdapterDescriptor { + /// Construct one explicit adapter descriptor. + /// + /// Adapter version, upstream protocol revision, and browser revision are + /// separate bounded ASCII metadata tokens. This prevents an OriginWeave + /// adapter release from being mistaken for the WebDriver BiDi/CDP revision + /// or the pinned browser build it was validated against. The declared + /// capability list must be non-empty and duplicate-free and is normalized + /// into one stable order so caller ordering cannot change descriptor identity. + pub fn new( + kind: BrowserProtocolKind, + adapter_version: &str, + protocol_revision: &str, + browser_revision: &str, + capabilities: &[BrowserProtocolCapability], + ) -> Result { + if !metadata_token_is_valid(adapter_version) { + return Err(BrowserProtocolDescriptorError::InvalidAdapterVersion); + } + if !metadata_token_is_valid(protocol_revision) { + return Err(BrowserProtocolDescriptorError::InvalidProtocolRevision); + } + if !metadata_token_is_valid(browser_revision) { + return Err(BrowserProtocolDescriptorError::InvalidBrowserRevision); + } + if capabilities.is_empty() { + return Err(BrowserProtocolDescriptorError::EmptyCapabilities); + } + + let mut canonical_capabilities = Vec::with_capacity(capabilities.len()); + for capability in capabilities { + if canonical_capabilities.contains(capability) { + return Err(BrowserProtocolDescriptorError::DuplicateCapability); + } + canonical_capabilities.push(*capability); + } + canonical_capabilities.sort_unstable_by_key(|capability| capability_rank(*capability)); + + Ok(Self { + kind, + adapter_version: adapter_version.to_owned(), + protocol_revision: protocol_revision.to_owned(), + browser_revision: browser_revision.to_owned(), + capabilities: canonical_capabilities, + }) + } + + /// Return the explicitly declared browser protocol family. + #[must_use] + pub const fn kind(&self) -> BrowserProtocolKind { + self.kind + } + + /// Return the bounded OriginWeave adapter-version metadata token. + #[must_use] + pub fn adapter_version(&self) -> &str { + &self.adapter_version + } + + /// Return the bounded upstream browser-protocol revision metadata token. + #[must_use] + pub fn protocol_revision(&self) -> &str { + &self.protocol_revision + } + + /// Return the bounded pinned browser-revision metadata token. + #[must_use] + pub fn browser_revision(&self) -> &str { + &self.browser_revision + } + + /// Return the number of explicitly declared capabilities. + #[must_use] + pub fn capability_count(&self) -> usize { + self.capabilities.len() + } + + /// Return whether this descriptor explicitly declares one capability. + #[must_use] + pub fn supports(&self, capability: BrowserProtocolCapability) -> bool { + self.capabilities.contains(&capability) + } +} + +const fn capability_rank(capability: BrowserProtocolCapability) -> u8 { + match capability { + BrowserProtocolCapability::Navigation => 0, + BrowserProtocolCapability::SemanticObservation => 1, + BrowserProtocolCapability::TypedInput => 2, + BrowserProtocolCapability::NetworkObservation => 3, + } +} + +fn metadata_token_is_valid(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_BROWSER_PROTOCOL_METADATA_BYTES + && value.is_ascii() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) +} + +/// Failure to construct canonical browser protocol adapter metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolDescriptorError { + /// The adapter-version token was empty, oversized, non-ASCII, or malformed. + InvalidAdapterVersion, + /// The upstream protocol-revision token was empty, oversized, non-ASCII, or malformed. + InvalidProtocolRevision, + /// The browser-revision token was empty, oversized, non-ASCII, or malformed. + InvalidBrowserRevision, + /// The adapter declared no supported browser capability. + EmptyCapabilities, + /// The adapter declared the same capability more than once. + DuplicateCapability, +} + +impl fmt::Display for BrowserProtocolDescriptorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidAdapterVersion => formatter.write_str( + "browser protocol adapter version must be a bounded ASCII metadata token", + ), + Self::InvalidProtocolRevision => formatter + .write_str("browser protocol revision must be a bounded ASCII metadata token"), + Self::InvalidBrowserRevision => { + formatter.write_str("browser revision must be a bounded ASCII metadata token") + } + Self::EmptyCapabilities => { + formatter.write_str("browser protocol adapter must declare at least one capability") + } + Self::DuplicateCapability => { + formatter.write_str("browser protocol adapter capabilities must be unique") + } + } + } +} + +impl std::error::Error for BrowserProtocolDescriptorError {} diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs new file mode 100644 index 000000000..f6cc7c4a0 --- /dev/null +++ b/crates/originweave-core/src/browser_registry.rs @@ -0,0 +1,931 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; + +use crate::contracts::ObservedNodeHandle as NodeTuple; +use crate::{BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, Origin}; + +/// Maximum UTF-8 byte length of an opaque browser-protocol identifier retained by the registry. +pub const MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES: usize = 512; + +/// Default maximum number of authority identifiers allocated per registry namespace. +const DEFAULT_MAX_BROWSER_AUTHORITY_IDENTIFIERS: u64 = 1_000_000; + +/// A node observation that can carry registry-local issuance authority. +/// +/// [`ObservedNodeHandle::new`] creates a structurally valid but unregistered observation. Such a +/// value is useful for parsing and fail-closed validation but cannot become live browser authority +/// merely by reproducing session, context, origin, epoch, and node identifiers. Handles returned +/// by [`BrowserAuthorityRegistry::bind_node`] additionally carry an unforgeable in-process +/// registry-instance token. That token is never serialized or exposed through the public API. +#[derive(Debug, Clone)] +pub struct ObservedNodeHandle { + observed: NodeTuple, + registry_authority: Option>, +} + +impl ObservedNodeHandle { + /// Create one structurally valid, unregistered observed node handle. + /// + /// Directly constructed handles deliberately carry no registry issuance authority and are + /// rejected by [`BrowserAuthorityRegistry::validate_node_handle`]. + pub fn new( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, + ) -> Result { + NodeTuple::new( + browser_session, + browsing_context, + origin, + document_epoch, + node_id, + ) + .map(|observed| Self { + observed, + registry_authority: None, + }) + } + + fn registered( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, + registry_authority: Arc<()>, + ) -> Result { + NodeTuple::new( + browser_session, + browsing_context, + origin, + document_epoch, + node_id, + ) + .map(|observed| Self { + observed, + registry_authority: Some(registry_authority), + }) + } + + /// Return the browser session that produced the node observation. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.observed.browser_session() + } + + /// Return the browsing context that produced the node observation. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.observed.browsing_context() + } + + /// Return the canonical origin that produced the node observation. + #[must_use] + pub const fn origin(&self) -> &Origin { + self.observed.origin() + } + + /// Return the document epoch that produced the node observation. + #[must_use] + pub const fn document_epoch(&self) -> DocumentEpoch { + self.observed.document_epoch() + } + + /// Return the registry-local nonzero node identifier. + #[must_use] + pub const fn node_id(&self) -> u64 { + self.observed.node_id() + } + + /// Reject use when the session, browsing context, origin, or document epoch has changed. + pub fn validate_current( + &self, + current_session: BrowserSessionId, + current_context: BrowsingContextId, + current_origin: &Origin, + current_epoch: DocumentEpoch, + ) -> Result<(), NodeHandleError> { + self.observed.validate_current( + current_session, + current_context, + current_origin, + current_epoch, + ) + } + + fn belongs_to(&self, registry_authority: &Arc<()>) -> bool { + self.registry_authority + .as_ref() + .is_some_and(|authority| Arc::ptr_eq(authority, registry_authority)) + } +} + +impl PartialEq for ObservedNodeHandle { + fn eq(&self, other: &Self) -> bool { + if self.observed != other.observed { + return false; + } + match (&self.registry_authority, &other.registry_authority) { + (Some(left), Some(right)) => Arc::ptr_eq(left, right), + (None, None) => true, + _ => false, + } + } +} + +impl Eq for ObservedNodeHandle {} + +/// A bounded in-memory mapping from untrusted adapter identifiers to OriginWeave authority values. +/// +/// External WebDriver BiDi, CDP, renderer, frame, and DOM identifiers are retained only as +/// private lookup keys. Callers receive OriginWeave-owned numeric identities whose meaning is +/// scoped to this registry instance. Node identities are additionally scoped to one browsing +/// context, document epoch, canonical origin, and registry-instance issuance token. +pub struct BrowserAuthorityRegistry { + session_by_external: BTreeMap, + known_sessions: BTreeSet, + context_by_external: BTreeMap<(BrowserSessionId, String), BrowsingContextId>, + context_session: BTreeMap, + context_epoch: BTreeMap, + context_origin: BTreeMap, + node_by_external: BTreeMap<(BrowsingContextId, DocumentEpoch, String), u64>, + node_binding_by_id: BTreeMap, + registry_authority: Arc<()>, + maximum_identifier: u64, + next_session_id: u64, + next_context_id: u64, + next_node_id: u64, +} + +impl BrowserAuthorityRegistry { + /// Create an empty registry with the reviewed default per-namespace identifier capacity. + #[must_use] + pub fn new() -> Self { + Self::with_identifier_limit(DEFAULT_MAX_BROWSER_AUTHORITY_IDENTIFIERS) + } + + /// Create an empty registry with a caller-selected per-namespace identifier capacity. + /// + /// Session, browsing-context, and node identifiers each have an independent monotonic + /// namespace capped at `maximum_identifier`. A zero limit intentionally rejects every new + /// allocation. Values above `u64::MAX - 1` are clamped so incrementing the next identifier + /// never wraps to zero. + #[must_use] + pub fn with_identifier_limit(maximum_identifier: u64) -> Self { + let maximum_identifier = maximum_identifier.min(u64::MAX - 1); + Self { + session_by_external: BTreeMap::new(), + known_sessions: BTreeSet::new(), + context_by_external: BTreeMap::new(), + context_session: BTreeMap::new(), + context_epoch: BTreeMap::new(), + context_origin: BTreeMap::new(), + node_by_external: BTreeMap::new(), + node_binding_by_id: BTreeMap::new(), + registry_authority: Arc::new(()), + maximum_identifier, + next_session_id: 1, + next_context_id: 1, + next_node_id: 1, + } + } + + /// Register one opaque external browser-session identifier. + /// + /// Re-registering the same identifier in this registry returns the same OriginWeave session. + pub fn register_session( + &mut self, + external_identifier: &str, + ) -> Result { + validate_external_identifier(external_identifier)?; + if let Some(existing) = self.session_by_external.get(external_identifier) { + return Ok(*existing); + } + let identifier = take_identifier(&mut self.next_session_id, self.maximum_identifier)?; + browser_session_id(identifier).inspect(|&session| { + self.session_by_external + .insert(external_identifier.to_owned(), session); + self.known_sessions.insert(session); + }) + } + + /// Register one opaque external browsing-context identifier inside a known browser session. + /// + /// A newly registered context starts at document epoch one. The same external context text in + /// another browser session receives a different OriginWeave context identity. + pub fn register_context( + &mut self, + browser_session: BrowserSessionId, + external_identifier: &str, + ) -> Result { + validate_external_identifier(external_identifier)?; + if !self.known_sessions.contains(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let key = (browser_session, external_identifier.to_owned()); + if let Some(existing) = self.context_by_external.get(&key) { + return Ok(*existing); + } + let identifier = take_identifier(&mut self.next_context_id, self.maximum_identifier)?; + browsing_context_id(identifier).and_then(|context| { + document_epoch(1).map(|initial_epoch| { + self.context_by_external.insert(key, context); + self.context_session.insert(context, browser_session); + self.context_epoch.insert(context, initial_epoch); + context + }) + }) + } + + /// Retire one browsing context and all registry-local authority derived from it. + /// + /// Retirement removes external lookup state, the current document epoch and origin, and every + /// node binding owned by the context. Monotonic context and node identifiers are never reused. + /// This revokes only OriginWeave registry-local authority; it does not prove that an external + /// browser context or process has terminated. + pub fn remove_context( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result<(), BrowserRegistryError> { + if self.context_session.remove(&browsing_context).is_none() { + return Err(BrowserRegistryError::UnknownBrowsingContext); + } + self.context_by_external + .retain(|_key, context| *context != browsing_context); + self.context_epoch.remove(&browsing_context); + self.context_origin.remove(&browsing_context); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| *context != browsing_context); + Ok(()) + } + + /// Retire one browser session and every registered context and node binding beneath it. + /// + /// Retirement removes only registry-local authority and external lookup state. Session, + /// context, and node identifiers remain strictly monotonic so a later registration of the same + /// opaque browser identifier cannot revive stale authority. External process termination is a + /// separate adapter responsibility. + pub fn remove_session( + &mut self, + browser_session: BrowserSessionId, + ) -> Result<(), BrowserRegistryError> { + if !self.known_sessions.remove(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + self.session_by_external + .retain(|_external, session| *session != browser_session); + self.context_by_external + .retain(|(session, _external), _context| *session != browser_session); + self.context_session + .retain(|_context, session| *session != browser_session); + + let live_contexts = &self.context_session; + self.context_epoch + .retain(|context, _epoch| live_contexts.contains_key(context)); + self.context_origin + .retain(|context, _origin| live_contexts.contains_key(context)); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| live_contexts.contains_key(context)); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| live_contexts.contains_key(context)); + Ok(()) + } + + /// Return the currently active document epoch for a known browsing context. + pub fn current_epoch( + &self, + browsing_context: BrowsingContextId, + ) -> Result { + self.context_epoch + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext) + } + + /// Advance a browsing context to the next document epoch and invalidate old node bindings. + /// + /// Call this whenever navigation or document replacement invalidates actionable node identity. + pub fn advance_document( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + let current = self + .context_epoch + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + let next_value = current + .value() + .checked_add(1) + .ok_or(BrowserRegistryError::DocumentEpochExhausted)?; + document_epoch(next_value).inspect(|&next| { + self.context_epoch.insert(browsing_context, next); + self.context_origin.remove(&browsing_context); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| *context != browsing_context); + }) + } + + /// Bind one opaque adapter-local node identifier to the exact current browser authority. + /// + /// Rebinding the same adapter node inside the same document returns a stable OriginWeave node + /// identifier. A document advance discards that mapping, so adapter node-number reuse cannot + /// revive stale authority. An origin change without a document advance fails closed. + pub fn bind_node( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifier: &str, + ) -> Result { + validate_external_identifier(external_identifier)?; + if !self.known_sessions.contains(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let expected_session = self + .context_session + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + if expected_session != browser_session { + return Err(BrowserRegistryError::ContextSessionMismatch { + expected: expected_session, + actual: browser_session, + }); + } + let origin_is_unbound = match self.context_origin.get(&browsing_context) { + Some(expected_origin) if expected_origin != origin => { + return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); + } + Some(_expected_origin) => false, + None => true, + }; + let epoch = self.current_epoch(browsing_context)?; + let key = (browsing_context, epoch, external_identifier.to_owned()); + let existing = self.node_by_external.get(&key).copied(); + let node_id = match existing { + Some(node_id) => node_id, + None => take_identifier(&mut self.next_node_id, self.maximum_identifier)?, + }; + if let Some(binding) = self.node_binding_by_id.get(&node_id) { + if *binding != (browsing_context, epoch) { + return Err(BrowserRegistryError::InternalAuthorityInvariant); + } + } else if existing.is_some() { + return Err(BrowserRegistryError::InternalAuthorityInvariant); + } + + let handle = registered_node_handle( + browser_session, + browsing_context, + origin, + epoch, + node_id, + Arc::clone(&self.registry_authority), + )?; + if origin_is_unbound { + self.context_origin.insert(browsing_context, origin.clone()); + } + if existing.is_none() { + self.node_by_external.insert(key, node_id); + self.node_binding_by_id + .insert(node_id, (browsing_context, epoch)); + } + Ok(handle) + } + + /// Retire one exact live node handle without advancing the document epoch. + /// + /// This revokes only registry-local node authority. It is intended for relevant same-document + /// mutations that invalidate one actionable node while leaving the surrounding browsing + /// context and document epoch current. The node identifier is globally unique inside one + /// registry, so retirement purges every external alias that refers to that identifier; this + /// also fails safe if private lookup state was duplicated or corrupted. Retirement does not + /// claim that Chromium destroyed the underlying DOM/backend node, and the monotonic node + /// identifier is never reused. + pub fn remove_node(&mut self, handle: &ObservedNodeHandle) -> Result<(), BrowserRegistryError> { + self.validate_node_handle(handle)?; + let node_id = handle.node_id(); + self.node_binding_by_id.remove(&node_id); + self.node_by_external + .retain(|_key, bound_node_id| *bound_node_id != node_id); + Ok(()) + } + + /// Verify that an observed node handle is still live authority in this registry. + /// + /// This check must run immediately before a node-local browser action. It re-derives the + /// current session, context, origin, and document epoch from registry-owned state, requires the + /// handle to have been issued by this exact registry instance, and resolves the node through a + /// reverse index rather than scanning every live binding. Caller-constructed, cross-registry, + /// or retired handles therefore cannot manufacture authority from a self-consistent tuple. + pub fn validate_node_handle( + &self, + handle: &ObservedNodeHandle, + ) -> Result<(), BrowserRegistryError> { + if !handle.belongs_to(&self.registry_authority) { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + if !self.known_sessions.contains(&handle.browser_session()) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let context = handle.browsing_context(); + let expected_session = self + .context_session + .get(&context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + if expected_session != handle.browser_session() { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + let epoch = self.current_epoch(context)?; + let origin = self + .context_origin + .get(&context) + .ok_or(BrowserRegistryError::UnknownNodeAuthority)?; + handle + .validate_current(expected_session, context, origin, epoch) + .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; + if self.node_binding_by_id.get(&handle.node_id()) != Some(&(context, epoch)) { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + Ok(()) + } +} + +impl Default for BrowserAuthorityRegistry { + fn default() -> Self { + Self::new() + } +} + +/// A fail-closed error produced while translating external browser identifiers into local authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserRegistryError { + /// An external identifier was empty or exceeded the reviewed byte bound. + InvalidExternalIdentifier, + /// The supplied OriginWeave browser session is not registered in this registry. + UnknownBrowserSession, + /// The supplied OriginWeave browsing context is not registered in this registry. + UnknownBrowsingContext, + /// The browsing context belongs to another browser session. + ContextSessionMismatch { + /// Session that owns the registered context. + expected: BrowserSessionId, + /// Session supplied by the current caller. + actual: BrowserSessionId, + }, + /// The context origin changed without first rotating the document epoch. + OriginChangedWithoutDocumentAdvance, + /// The observed node handle is not a current node binding owned by this registry. + UnknownNodeAuthority, + /// The registry exhausted one of its monotonic internal identifier spaces. + IdentifierSpaceExhausted, + /// A document epoch reached the maximum representable value. + DocumentEpochExhausted, + /// A private registry consistency invariant was violated. + InternalAuthorityInvariant, +} + +impl fmt::Display for BrowserRegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidExternalIdentifier => write!( + formatter, + "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} UTF-8 bytes" + ), + Self::UnknownBrowserSession => { + formatter.write_str("browser session is not registered in this authority registry") + } + Self::UnknownBrowsingContext => { + formatter.write_str("browsing context is not registered in this authority registry") + } + Self::ContextSessionMismatch { expected, actual } => write!( + formatter, + "browsing context belongs to session {}, not session {}", + expected.value(), + actual.value() + ), + Self::OriginChangedWithoutDocumentAdvance => formatter + .write_str("browsing context origin changed without advancing the document epoch"), + Self::UnknownNodeAuthority => formatter + .write_str("observed node handle is not registered as current browser authority"), + Self::IdentifierSpaceExhausted => { + formatter.write_str("browser authority identifier space is exhausted") + } + Self::DocumentEpochExhausted => { + formatter.write_str("browser document epoch space is exhausted") + } + Self::InternalAuthorityInvariant => { + formatter.write_str("browser authority registry violated a nonzero invariant") + } + } + } +} + +impl std::error::Error for BrowserRegistryError {} + +fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { + if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { + return Err(BrowserRegistryError::InvalidExternalIdentifier); + } + Ok(()) +} + +fn take_identifier(next: &mut u64, maximum_identifier: u64) -> Result { + if *next > maximum_identifier { + return Err(BrowserRegistryError::IdentifierSpaceExhausted); + } + let identifier = *next; + *next = identifier + 1; + Ok(identifier) +} + +fn browser_session_id(value: u64) -> Result { + BrowserSessionId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +fn browsing_context_id(value: u64) -> Result { + BrowsingContextId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +fn document_epoch(value: u64) -> Result { + DocumentEpoch::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +fn registered_node_handle( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + document_epoch: DocumentEpoch, + node_id: u64, + registry_authority: Arc<()>, +) -> Result { + ObservedNodeHandle::registered( + browser_session, + browsing_context, + origin.clone(), + document_epoch, + node_id, + registry_authority, + ) + .map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn values(result: Result) -> Vec { + result.into_iter().collect() + } + + #[test] + fn unregistered_handle_equality_covers_all_authority_states() { + let sessions = values(BrowserSessionId::new(1)); + let contexts = values(BrowsingContextId::new(1)); + let epochs = values(DocumentEpoch::new(1)); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(sessions.len(), 1); + assert_eq!(contexts.len(), 1); + assert_eq!(epochs.len(), 1); + assert_eq!(origins.len(), 1); + let session = sessions[0]; + let context = contexts[0]; + let epoch = epochs[0]; + let origin = origins[0].clone(); + + let first = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let same = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let different = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 2, + )); + assert_eq!(first.len(), 1); + assert_eq!(same.len(), 1); + assert_eq!(different.len(), 1); + assert_eq!(first[0], same[0]); + assert_ne!(first[0], different[0]); + + let registered = values(ObservedNodeHandle::registered( + session, + context, + origin, + epoch, + 1, + Arc::new(()), + )); + assert_eq!(registered.len(), 1); + assert_ne!(first[0], registered[0]); + } + + #[test] + fn helper_invariants_and_reverse_index_corruption_fail_closed() { + assert_eq!( + browser_session_id(0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + assert_eq!( + browsing_context_id(0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + assert_eq!( + document_epoch(0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + + let sessions = values(BrowserSessionId::new(1)); + let contexts = values(BrowsingContextId::new(1)); + let epochs = values(DocumentEpoch::new(1)); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(sessions.len(), 1); + assert_eq!(contexts.len(), 1); + assert_eq!(epochs.len(), 1); + assert_eq!(origins.len(), 1); + assert!( + registered_node_handle( + sessions[0], + contexts[0], + &origins[0], + epochs[0], + 0, + Arc::new(()), + ) + .is_err() + ); + + let mut registry = BrowserAuthorityRegistry::new(); + let registered_sessions = values(registry.register_session("corrupt-session")); + assert_eq!(registered_sessions.len(), 1); + let session = registered_sessions[0]; + let registered_contexts = values(registry.register_context(session, "corrupt-context")); + assert_eq!(registered_contexts.len(), 1); + let context = registered_contexts[0]; + let origin = &origins[0]; + let handles = values(registry.bind_node(session, context, origin, "node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + registry.node_binding_by_id.remove(&handle.node_id()); + assert_eq!( + registry.bind_node(session, context, origin, "node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + + registry + .node_binding_by_id + .insert(handle.node_id(), (context, epochs[0])); + registry.node_by_external.clear(); + let other_contexts = values(registry.register_context(session, "other-context")); + assert_eq!(other_contexts.len(), 1); + let other_context = other_contexts[0]; + registry.node_by_external.insert( + (other_context, epochs[0], "other-node".to_owned()), + handle.node_id(), + ); + assert_eq!( + registry.bind_node(session, other_context, origin, "other-node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + + let zero_epochs = values(registry.current_epoch(context)); + assert_eq!(zero_epochs.len(), 1); + let zero_epoch = zero_epochs[0]; + registry + .node_by_external + .insert((context, zero_epoch, "zero-node".to_owned()), 0); + registry.node_binding_by_id.insert(0, (context, zero_epoch)); + assert_eq!( + registry.bind_node(session, context, origin, "zero-node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + } + + #[test] + fn validation_reverse_index_rejects_missing_binding() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let handles = values(registry.bind_node(session, context, &origins[0], "node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + registry.node_binding_by_id.remove(&handle.node_id()); + assert_eq!( + registry.validate_node_handle(handle), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + } + + #[test] + fn issued_handle_rejects_private_context_session_corruption() { + let mut registry = BrowserAuthorityRegistry::new(); + let owners = values(registry.register_session("corrupt-owner-session")); + let attackers = values(registry.register_session("corrupt-attacker-session")); + assert_eq!(owners.len(), 1); + assert_eq!(attackers.len(), 1); + let owner = owners[0]; + let attacker = attackers[0]; + + let contexts = values(registry.register_context(owner, "corrupt-context-session")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let handles = + values(registry.bind_node(owner, context, &origins[0], "corrupt-context-node")); + assert_eq!(handles.len(), 1); + + registry.context_session.insert(context, attacker); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + } + + #[test] + fn issued_handle_rejects_private_context_origin_corruption() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("corrupt-origin-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "corrupt-origin-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + let corrupt_origins = values(Origin::parse("http://127.0.0.1:43128")); + assert_eq!(origins.len(), 1); + assert_eq!(corrupt_origins.len(), 1); + let handles = + values(registry.bind_node(session, context, &origins[0], "corrupt-origin-node")); + assert_eq!(handles.len(), 1); + + registry + .context_origin + .insert(context, corrupt_origins[0].clone()); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + } + + #[test] + fn unit_cfg_error_propagation_covers_private_fail_closed_boundaries() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("boundary-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + assert_eq!( + registry.register_context(session, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let contexts = values(registry.register_context(session, "boundary-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + assert_eq!( + registry.bind_node(session, context, origin, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let epochs = values(registry.current_epoch(context)); + assert_eq!(epochs.len(), 1); + let epoch = epochs[0]; + registry.context_epoch.remove(&context); + assert_eq!( + registry.bind_node(session, context, origin, "missing-epoch-node"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + registry.context_epoch.insert(context, epoch); + let handles = values(registry.bind_node(session, context, origin, "live-node")); + assert_eq!(handles.len(), 1); + + registry.context_origin.remove(&context); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + registry.context_origin.insert(context, origin.clone()); + registry.context_epoch.remove(&context); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + } + + #[test] + fn node_retirement_purges_duplicate_private_aliases_fail_closed() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("retirement-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "retirement-context")); + let other_contexts = values(registry.register_context(session, "other-retirement-context")); + assert_eq!(contexts.len(), 1); + assert_eq!(other_contexts.len(), 1); + let context = contexts[0]; + let other_context = other_contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + let targets = values(registry.bind_node(session, context, origin, "target-node")); + let siblings = values(registry.bind_node(session, context, origin, "sibling-node")); + let others = values(registry.bind_node(session, other_context, origin, "other-node")); + assert_eq!(targets.len(), 1); + assert_eq!(siblings.len(), 1); + assert_eq!(others.len(), 1); + let target = &targets[0]; + let sibling = &siblings[0]; + let other = &others[0]; + + let epochs = values(DocumentEpoch::new(target.document_epoch().value() + 1)); + assert_eq!(epochs.len(), 1); + let future_key = (context, epochs[0], "corrupt-future-alias".to_owned()); + let cross_context_key = ( + other_context, + target.document_epoch(), + "corrupt-cross-context-alias".to_owned(), + ); + registry + .node_by_external + .insert(future_key.clone(), target.node_id()); + registry + .node_by_external + .insert(cross_context_key.clone(), target.node_id()); + + assert_eq!(registry.remove_node(target), Ok(())); + + assert_eq!(registry.validate_node_handle(sibling), Ok(())); + assert_eq!(registry.validate_node_handle(other), Ok(())); + assert_eq!(registry.node_by_external.get(&future_key), None); + assert_eq!(registry.node_by_external.get(&cross_context_key), None); + assert_eq!(registry.node_binding_by_id.get(&target.node_id()), None); + } + + #[test] + fn document_epoch_exhaustion_is_fail_closed() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("epoch-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "epoch-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let maximum_epochs = values(DocumentEpoch::new(u64::MAX)); + assert_eq!(maximum_epochs.len(), 1); + registry.context_epoch.insert(context, maximum_epochs[0]); + + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::DocumentEpochExhausted) + ); + } + + #[test] + fn monotonic_identifier_exhaustion_is_fail_closed() { + let mut next = 1; + assert_eq!(take_identifier(&mut next, 1), Ok(1)); + assert_eq!(next, 2); + assert_eq!( + take_identifier(&mut next, 1), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + } + + #[test] + fn maximum_identifier_limit_is_clamped_without_wrapping() { + let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); + assert_eq!(registry.maximum_identifier, u64::MAX - 1); + } +} diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs new file mode 100644 index 000000000..8834d7062 --- /dev/null +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -0,0 +1,422 @@ +use crate::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, + ObservedNodeHandle, Origin, +}; + +fn values(result: Result) -> Vec { + result.into_iter().collect() +} + +#[test] +fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("unit-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let repeated_sessions = values(registry.register_session("unit-session")); + assert_eq!(repeated_sessions, sessions); + + let contexts = values(registry.register_context(session, "unit-context")); + assert_eq!(contexts.len(), 1); + let repeated_contexts = values(registry.register_context(session, "unit-context")); + assert_eq!(repeated_contexts, contexts); + let context = contexts[0]; + + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + + let first = values(registry.bind_node(session, context, origin, "unit-node")); + let repeated = values(registry.bind_node(session, context, origin, "unit-node")); + assert_eq!(first.len(), 1); + assert_eq!(repeated.len(), 1); + assert_eq!(first[0], repeated[0]); + assert_eq!(registry.validate_node_handle(&first[0]), Ok(())); + + let epochs = values(DocumentEpoch::new(1)); + assert_eq!(epochs.len(), 1); + let forged = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epochs[0], + first[0].node_id() + 1, + )); + assert_eq!(forged.len(), 1); + assert_eq!( + registry.validate_node_handle(&forged[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let mismatched_origins = values(Origin::parse("http://localhost:43127")); + assert_eq!(mismatched_origins.len(), 1); + let mismatched = values(ObservedNodeHandle::new( + session, + context, + mismatched_origins[0].clone(), + epochs[0], + first[0].node_id(), + )); + assert_eq!(mismatched.len(), 1); + assert_eq!( + registry.validate_node_handle(&mismatched[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); +} + +#[test] +fn node_validation_rejects_each_missing_authority_boundary() { + let mut registry = BrowserAuthorityRegistry::new(); + assert_eq!( + registry.register_session(""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + let oversized_identifier = "x".repeat(513); + assert_eq!( + registry.register_session(&oversized_identifier), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + let known_sessions = values(registry.register_session("validation-session")); + let attacker_sessions = values(registry.register_session("validation-attacker")); + assert_eq!(known_sessions.len(), 1); + assert_eq!(attacker_sessions.len(), 1); + let known = known_sessions[0]; + let attacker = attacker_sessions[0]; + let contexts = values(registry.register_context(known, "validation-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = origins[0].clone(); + let epochs = values(registry.current_epoch(context)); + assert_eq!(epochs.len(), 1); + let epoch = epochs[0]; + + let unknown_sessions = values(BrowserSessionId::new(999)); + assert_eq!(unknown_sessions.len(), 1); + let unknown_handle = values(ObservedNodeHandle::new( + unknown_sessions[0], + context, + origin.clone(), + epoch, + 1, + )); + assert_eq!(unknown_handle.len(), 1); + assert_eq!( + registry.validate_node_handle(&unknown_handle[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let mismatched_handle = values(ObservedNodeHandle::new( + attacker, + context, + origin.clone(), + epoch, + 1, + )); + assert_eq!(mismatched_handle.len(), 1); + assert_eq!( + registry.validate_node_handle(&mismatched_handle[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let unbound_handle = values(ObservedNodeHandle::new( + known, + context, + origin.clone(), + epoch, + 1, + )); + assert_eq!(unbound_handle.len(), 1); + assert_eq!( + registry.validate_node_handle(&unbound_handle[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + assert_eq!(registry.remove_context(context), Ok(())); + assert_eq!( + registry.validate_node_handle(&unbound_handle[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); +} + +#[test] +fn issued_handles_report_retired_authority_boundaries() { + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + + let mut context_registry = BrowserAuthorityRegistry::new(); + let context_sessions = values(context_registry.register_session("context-retirement-session")); + assert_eq!(context_sessions.len(), 1); + let context_session = context_sessions[0]; + let contexts = + values(context_registry.register_context(context_session, "context-retirement-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let context_handles = values(context_registry.bind_node( + context_session, + context, + origin, + "context-retirement-node", + )); + assert_eq!(context_handles.len(), 1); + let context_handle = &context_handles[0]; + assert_eq!(context_registry.remove_context(context), Ok(())); + assert_eq!( + context_registry.validate_node_handle(context_handle), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + let mut session_registry = BrowserAuthorityRegistry::new(); + let sessions = values(session_registry.register_session("session-retirement-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let session_contexts = + values(session_registry.register_context(session, "session-retirement-context")); + assert_eq!(session_contexts.len(), 1); + let session_context = session_contexts[0]; + let session_handles = values(session_registry.bind_node( + session, + session_context, + origin, + "session-retirement-node", + )); + assert_eq!(session_handles.len(), 1); + let session_handle = &session_handles[0]; + assert_eq!(session_registry.remove_session(session), Ok(())); + assert_eq!( + session_registry.validate_node_handle(session_handle), + Err(BrowserRegistryError::UnknownBrowserSession) + ); +} + +#[test] +fn session_authority_failures_are_exercised_in_the_unit_crate() { + let mut registry = BrowserAuthorityRegistry::new(); + let unknown_sessions = values(BrowserSessionId::new(999)); + assert_eq!(unknown_sessions.len(), 1); + let unknown = unknown_sessions[0]; + assert_eq!( + registry.register_context(unknown, "unknown-context"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let owner_sessions = values(registry.register_session("owner-session")); + let attacker_sessions = values(registry.register_session("attacker-session")); + assert_eq!(owner_sessions.len(), 1); + assert_eq!(attacker_sessions.len(), 1); + let owner = owner_sessions[0]; + let attacker = attacker_sessions[0]; + + let contexts = values(registry.register_context(owner, "owner-context")); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(contexts.len(), 1); + assert_eq!(origins.len(), 1); + let context = contexts[0]; + + assert_eq!( + registry.bind_node(attacker, context, &origins[0], "unit-node"), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); +} + +#[test] +fn direct_fail_closed_registry_paths_are_exercised_in_the_unit_crate() { + let mut registry = BrowserAuthorityRegistry::new(); + let unknown_sessions = values(BrowserSessionId::new(999)); + assert_eq!(unknown_sessions.len(), 1); + let unknown = unknown_sessions[0]; + + let sessions = values(registry.register_session("direct-path-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "direct-path-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + let changed_origins = values(Origin::parse("http://localhost:43127")); + assert_eq!(origins.len(), 1); + assert_eq!(changed_origins.len(), 1); + + assert_eq!( + registry.bind_node(unknown, context, &origins[0], "unknown-session-node"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + assert_eq!( + values(registry.bind_node(session, context, &origins[0], "live-node")).len(), + 1 + ); + assert_eq!( + registry.bind_node(session, context, &changed_origins[0], "changed-origin-node"), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + + assert_eq!(registry.remove_context(context), Ok(())); + assert_eq!( + registry.remove_context(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); +} + +#[test] +fn unit_cfg_allocation_rotation_and_retirement_edges_are_exercised() { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let sessions = values(registry.register_session("capacity-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + assert_eq!( + registry.register_session("capacity-session-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let contexts = values(registry.register_context(session, "capacity-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + assert_eq!( + registry.register_context(session, "capacity-context-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + let handles = values(registry.bind_node(session, context, origin, "capacity-node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + assert_eq!( + registry.bind_node(session, context, origin, "capacity-node-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let forged = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + handle.document_epoch(), + handle.node_id() + 1, + )); + assert_eq!(forged.len(), 1); + assert_eq!( + registry.remove_node(&forged[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let next_epochs = values(registry.advance_document(context)); + assert_eq!(next_epochs.len(), 1); + assert_eq!(next_epochs[0].value(), 2); + assert_eq!(registry.remove_context(context), Ok(())); + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); +} + +#[test] +fn unit_cfg_adapter_surface_exercises_accessors_equality_default_and_errors() { + let mut registry = BrowserAuthorityRegistry::default(); + let sessions = values(registry.register_session("adapter-surface-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "adapter-surface-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = origins[0].clone(); + let handles = values(registry.bind_node(session, context, &origin, "adapter-surface-node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + + assert_eq!(handle.browser_session(), session); + assert_eq!(handle.browsing_context(), context); + assert_eq!(handle.origin(), &origin); + assert_eq!(handle.document_epoch().value(), 1); + assert_ne!(handle.node_id(), 0); + + let unregistered_same = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + handle.document_epoch(), + handle.node_id(), + )); + assert_eq!(unregistered_same.len(), 1); + let second_unregistered_same = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + handle.document_epoch(), + handle.node_id(), + )); + assert_eq!(second_unregistered_same.len(), 1); + assert_ne!(*handle, unregistered_same[0]); + assert_eq!(unregistered_same[0], second_unregistered_same[0]); + + let unregistered_other = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + handle.document_epoch(), + handle.node_id() + 1, + )); + assert_eq!(unregistered_other.len(), 1); + assert_ne!(unregistered_same[0], unregistered_other[0]); + + assert_eq!( + registry.validate_node_handle(&unregistered_same[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!(registry.remove_node(handle), Ok(())); + assert_eq!( + registry.validate_node_handle(handle), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!(registry.remove_context(context), Ok(())); + assert_eq!( + registry.bind_node(session, context, &origin, "retired-context-node"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + let display_cases = [ + BrowserRegistryError::InvalidExternalIdentifier, + BrowserRegistryError::UnknownBrowserSession, + BrowserRegistryError::UnknownBrowsingContext, + BrowserRegistryError::ContextSessionMismatch { + expected: session, + actual: session, + }, + BrowserRegistryError::OriginChangedWithoutDocumentAdvance, + BrowserRegistryError::UnknownNodeAuthority, + BrowserRegistryError::IdentifierSpaceExhausted, + BrowserRegistryError::DocumentEpochExhausted, + BrowserRegistryError::InternalAuthorityInvariant, + ]; + for error in display_cases { + assert!(!error.to_string().is_empty()); + } +} + +#[test] +fn session_retirement_covers_unit_success_and_unknown_paths() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("retirement-unit-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "retirement-unit-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + + assert_eq!(registry.remove_session(session), Ok(())); + assert_eq!( + registry.current_epoch(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.remove_session(session), + Err(BrowserRegistryError::UnknownBrowserSession) + ); +} diff --git a/crates/originweave-core/src/contract_errors.rs b/crates/originweave-core/src/contract_errors.rs new file mode 100644 index 000000000..c34131bf1 --- /dev/null +++ b/crates/originweave-core/src/contract_errors.rs @@ -0,0 +1,47 @@ +use std::fmt; + +use crate::contracts::{ActionIntentDigestError, ExtensionIdError, OriginError}; + +impl fmt::Display for OriginError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::MissingScheme => "origin must include an explicit scheme", + Self::UnsupportedScheme => "origin scheme must be HTTPS or loopback HTTP", + Self::InsecureRemoteOrigin => "remote HTTP origins are not permitted", + Self::MissingAuthority => "origin authority must not be empty", + Self::UserInfoNotAllowed => "origin authority must not contain user information", + Self::PathNotAllowed => "origin must not contain a path, query, or fragment", + Self::InvalidAuthority => "origin authority is malformed or ambiguous", + Self::AmbiguousNumericHost => { + "origin host uses a browser-ambiguous numeric address spelling" + } + Self::InvalidPort => "origin port must be a numeric value from 1 through 65535", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for OriginError {} + +impl fmt::Display for ActionIntentDigestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFormat => formatter.write_str( + "action intent digest must be sha256: followed by 64 lowercase hexadecimal digits", + ), + } + } +} + +impl std::error::Error for ActionIntentDigestError {} + +impl fmt::Display for ExtensionIdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidExtensionId => formatter + .write_str("extension identifier must be 32 lowercase characters from a through p"), + } + } +} + +impl std::error::Error for ExtensionIdError {} diff --git a/crates/originweave-core/src/contracts.rs b/crates/originweave-core/src/contracts.rs new file mode 100644 index 000000000..865f627a8 --- /dev/null +++ b/crates/originweave-core/src/contracts.rs @@ -0,0 +1,1064 @@ +//! Shared security and governance contracts for OriginWeave. +//! +//! The crate deliberately contains no browser-engine integration. It defines +//! small, deterministic value types that can be reused by the browser shell, +//! headless runtime, MCP adapter, and enterprise policy service. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use std::collections::BTreeSet; +use std::fmt; +use std::net::{Ipv4Addr, Ipv6Addr}; + +/// A normalized web origin accepted by the OriginWeave trust boundary. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Origin { + canonical: String, +} + +impl Origin { + /// Parse one origin and reject paths, credentials, fragments, insecure + /// remote HTTP endpoints, and browser-special numeric host spellings. + pub fn parse(input: &str) -> Result { + if input.trim() != input + || input + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { + return Err(OriginError::InvalidAuthority); + } + + let Some((raw_scheme, authority)) = input.split_once("://") else { + return Err(OriginError::MissingScheme); + }; + let scheme = raw_scheme.to_ascii_lowercase(); + if scheme != "https" && scheme != "http" { + return Err(OriginError::UnsupportedScheme); + } + if authority.is_empty() { + return Err(OriginError::MissingAuthority); + } + if authority.contains('@') { + return Err(OriginError::UserInfoNotAllowed); + } + if authority + .chars() + .any(|character| matches!(character, '/' | '?' | '#')) + { + return Err(OriginError::PathNotAllowed); + } + + let (host, port, is_loopback) = parse_authority(authority)?; + if scheme == "http" && !is_loopback { + return Err(OriginError::InsecureRemoteOrigin); + } + let normalized_port = normalize_default_port(&scheme, port); + let canonical = match normalized_port { + Some(port_number) => format!("{scheme}://{host}:{port_number}"), + None => format!("{scheme}://{host}"), + }; + Ok(Self { canonical }) + } + + /// Return the normalized origin string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } + + /// Return the validated lowercase origin scheme. + #[must_use] + pub fn scheme(&self) -> &str { + if self.canonical.starts_with("https://") { + "https" + } else { + "http" + } + } + + /// Return the validated canonical host without IPv6 brackets. + #[must_use] + pub fn host(&self) -> &str { + let authority = &self.canonical[self.scheme().len() + 3..]; + let bracketed = authority.starts_with('['); + let host_start = usize::from(bracketed); + let host_end = if bracketed { + authority.find(']').unwrap_or(authority.len()) + } else { + authority.find(':').unwrap_or(authority.len()) + }; + &authority[host_start..host_end] + } +} + +impl fmt::Display for Origin { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +fn normalize_default_port(scheme: &str, port: Option) -> Option { + match (scheme, port) { + ("https", Some(443)) | ("http", Some(80)) => None, + (_, other) => other, + } +} + +fn parse_authority(authority: &str) -> Result<(String, Option, bool), OriginError> { + if authority.starts_with('[') { + return parse_bracketed_ipv6(authority); + } + if authority.matches(':').count() > 1 { + return Err(OriginError::InvalidAuthority); + } + + let (host_text, port) = match authority.rsplit_once(':') { + Some((host, port_text)) => (host, Some(parse_port(port_text)?)), + None => (authority, None), + }; + let host = host_text.to_ascii_lowercase(); + if let Ok(address) = host.parse::() { + return Ok((host, port, address.is_loopback())); + } + if looks_like_browser_ipv4_host(&host) { + return Err(OriginError::AmbiguousNumericHost); + } + validate_dns_host(&host)?; + Ok((host.clone(), port, host == "localhost")) +} +fn looks_like_browser_ipv4_host(host: &str) -> bool { + host.rsplit('.') + .next() + .is_some_and(looks_like_browser_ipv4_number) +} + +fn looks_like_browser_ipv4_number(label: &str) -> bool { + if label.is_empty() { + return false; + } + let lowercase = label.to_ascii_lowercase(); + if let Some(hexadecimal) = lowercase.strip_prefix("0x") { + return hexadecimal.bytes().all(|byte| byte.is_ascii_hexdigit()); + } + label.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), OriginError> { + let Some(close_index) = authority.find(']') else { + return Err(OriginError::InvalidAuthority); + }; + let address_text = &authority[1..close_index]; + let address = address_text + .parse::() + .map_err(|_error| OriginError::InvalidAuthority)?; + let remainder = &authority[close_index + 1..]; + let port = if remainder.is_empty() { + None + } else if let Some(port_text) = remainder.strip_prefix(':') { + Some(parse_port(port_text)?) + } else { + return Err(OriginError::InvalidAuthority); + }; + Ok((format!("[{address}]"), port, address.is_loopback())) +} + +fn parse_port(port_text: &str) -> Result { + if port_text.is_empty() || !port_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(OriginError::InvalidPort); + } + let port = port_text + .parse::() + .map_err(|_error| OriginError::InvalidPort)?; + if port == 0 { + return Err(OriginError::InvalidPort); + } + Ok(port) +} + +fn validate_dns_host(host: &str) -> Result<(), OriginError> { + if host.is_empty() { + return Err(OriginError::InvalidAuthority); + } + if host.len() > 253 { + return Err(OriginError::InvalidAuthority); + } + if !host.is_ascii() { + return Err(OriginError::InvalidAuthority); + } + if host.starts_with('.') || host.ends_with('.') { + return Err(OriginError::InvalidAuthority); + } + for label in host.split('.') { + if label.is_empty() { + return Err(OriginError::InvalidAuthority); + } + if label.len() > 63 { + return Err(OriginError::InvalidAuthority); + } + let bytes = label.as_bytes(); + if !bytes[0].is_ascii_alphanumeric() { + return Err(OriginError::InvalidAuthority); + } + if !bytes[bytes.len() - 1].is_ascii_alphanumeric() { + return Err(OriginError::InvalidAuthority); + } + if !bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') + { + return Err(OriginError::InvalidAuthority); + } + } + Ok(()) +} + +/// A reason that an origin string could not enter the trust boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OriginError { + /// The input did not contain a `scheme://` separator. + MissingScheme, + /// The scheme was neither HTTPS nor locally scoped HTTP. + UnsupportedScheme, + /// HTTP was requested for a non-loopback host. + InsecureRemoteOrigin, + /// No authority followed the scheme. + MissingAuthority, + /// User information appeared before the host. + UserInfoNotAllowed, + /// A path, query, or fragment was supplied where only an origin is valid. + PathNotAllowed, + /// The host or authority syntax was ambiguous or malformed. + InvalidAuthority, + /// A browser could reinterpret the host as a non-canonical IPv4 address. + AmbiguousNumericHost, + /// The explicit port was outside `1..=65535` or was not numeric. + InvalidPort, +} + +/// A nonzero identity for one active browser automation session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowserSessionId(u64); + +impl BrowserSessionId { + /// Validate one adapter-supplied browser-session identifier. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(NodeHandleError::InvalidBrowserSessionId); + } + Ok(Self(value)) + } + + /// Return the validated browser-session identifier. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A nonzero identity for one independently navigable browser context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowsingContextId(u64); +impl BrowsingContextId { + /// Validate one adapter-supplied browsing-context identifier. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(NodeHandleError::InvalidBrowsingContextId); + } + Ok(Self(value)) + } + + /// Return the validated browsing-context identifier. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A nonzero identity for one observed browser document lifetime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DocumentEpoch(u64); + +impl DocumentEpoch { + /// Validate one adapter-supplied document epoch. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(NodeHandleError::InvalidDocumentEpoch); + } + Ok(Self(value)) + } + + /// Return the validated document epoch value. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A node identity bound to the exact session, context, origin, and document that produced it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObservedNodeHandle { + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, +} + +impl ObservedNodeHandle { + /// Create one authority-bound observed node handle from a nonzero adapter node identifier. + pub fn new( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, + ) -> Result { + if node_id == 0 { + return Err(NodeHandleError::InvalidNodeId); + } + Ok(Self { + browser_session, + browsing_context, + origin, + document_epoch, + node_id, + }) + } + + /// Return the browser session that produced the node observation. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the browsing context that produced the node observation. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.browsing_context + } + + /// Return the canonical origin that produced the node observation. + #[must_use] + pub const fn origin(&self) -> &Origin { + &self.origin + } + + /// Return the document epoch that produced the node observation. + #[must_use] + pub const fn document_epoch(&self) -> DocumentEpoch { + self.document_epoch + } + + /// Return the adapter-local nonzero node identifier. + #[must_use] + pub const fn node_id(&self) -> u64 { + self.node_id + } + + /// Reject use when the session, browsing context, origin, or document epoch has changed. + pub fn validate_current( + &self, + current_session: BrowserSessionId, + current_context: BrowsingContextId, + current_origin: &Origin, + current_epoch: DocumentEpoch, + ) -> Result<(), NodeHandleError> { + if self.browser_session != current_session { + return Err(NodeHandleError::BrowserSessionMismatch { + observed: self.browser_session, + current: current_session, + }); + } + if self.browsing_context != current_context { + return Err(NodeHandleError::BrowsingContextMismatch { + observed: self.browsing_context, + current: current_context, + }); + } + if &self.origin != current_origin { + return Err(NodeHandleError::OriginMismatch); + } + if self.document_epoch != current_epoch { + return Err(NodeHandleError::StaleDocumentEpoch { + observed: self.document_epoch, + current: current_epoch, + }); + } + Ok(()) + } +} +/// A failure to construct or reuse an authority- and document-bound node handle safely. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NodeHandleError { + /// Browser-session identifiers are one-based and zero was supplied. + InvalidBrowserSessionId, + /// Browsing-context identifiers are one-based and zero was supplied. + InvalidBrowsingContextId, + /// Document epochs are one-based and zero was supplied. + InvalidDocumentEpoch, + /// Adapter-local node identifiers are one-based and zero was supplied. + InvalidNodeId, + /// The node handle belongs to a different browser automation session. + BrowserSessionMismatch { + /// Session that originally produced the node handle. + observed: BrowserSessionId, + /// Session currently active for the requested action. + current: BrowserSessionId, + }, + /// The node handle belongs to a different independently navigable context. + BrowsingContextMismatch { + /// Context that originally produced the node handle. + observed: BrowsingContextId, + /// Context currently active for the requested action. + current: BrowsingContextId, + }, + /// The browser context is now at a different canonical origin. + OriginMismatch, + /// The browser context is now at a different document epoch. + StaleDocumentEpoch { + /// Epoch that originally produced the node handle. + observed: DocumentEpoch, + /// Epoch currently active in the browser context. + current: DocumentEpoch, + }, +} + +impl fmt::Display for NodeHandleError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidBrowserSessionId => { + formatter.write_str("browser session identifier must be nonzero") + } + Self::InvalidBrowsingContextId => { + formatter.write_str("browsing context identifier must be nonzero") + } + Self::InvalidDocumentEpoch => formatter.write_str("document epoch must be nonzero"), + Self::InvalidNodeId => formatter.write_str("observed node identifier must be nonzero"), + Self::BrowserSessionMismatch { observed, current } => write!( + formatter, + "observed node browser session {} does not match current session {}", + observed.value(), + current.value() + ), + Self::BrowsingContextMismatch { observed, current } => write!( + formatter, + "observed node browsing context {} does not match current context {}", + observed.value(), + current.value() + ), + Self::OriginMismatch => { + formatter.write_str("observed node origin does not match the current origin") + } + Self::StaleDocumentEpoch { observed, current } => write!( + formatter, + "observed node document epoch {} is stale; current epoch is {}", + observed.value(), + current.value() + ), + } + } +} + +impl std::error::Error for NodeHandleError {} + +/// An immutable digest of the complete canonical action intent. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ActionIntentDigest { + canonical: String, +} + +impl ActionIntentDigest { + /// Parse a lowercase `sha256:` digest of the complete canonical intent. + pub fn parse(input: &str) -> Result { + let Some(hexadecimal) = input.strip_prefix("sha256:") else { + return Err(ActionIntentDigestError::InvalidFormat); + }; + if hexadecimal.len() != 64 + || !hexadecimal + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ActionIntentDigestError::InvalidFormat); + } + Ok(Self { + canonical: input.to_owned(), + }) + } + + /// Return the canonical lowercase digest. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } +} + +/// A validation error for an action-intent digest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionIntentDigestError { + /// The value was not `sha256:` followed by 64 lowercase hexadecimal digits. + InvalidFormat, +} + +/// The browser execution mode that owns an action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SessionMode { + /// A person controls the browser without agent execution privileges. + Human, + /// An agent assists a person while write actions remain governed. + Assist, + /// An isolated task session is delegated to an agent. + AgentTask, + /// A read-only crawler performs policy-bounded collection. + Crawler, +} + +/// The declared business purpose of one browser execution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExecutionPurpose { + /// Public content is collected under crawler policy. + PublicCrawl, + /// A person delegated a bounded task in their own context. + UserDelegatedTask, + /// An enterprise policy authorized a managed task. + EnterpriseAuthorizedTask, + /// The action is running in a non-production test environment. + TestingEnvironment, +} + +/// The trust class of the instruction that proposed an action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum InstructionSource { + /// A human user supplied the instruction. + User, + /// A managed enterprise policy supplied the instruction. + EnterprisePolicy, + /// Untrusted page or document content supplied the instruction. + WebContent, +} + +/// The result of applying a robots-exclusion policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RobotsDecision { + /// The requested crawl is explicitly allowed. + Allowed, + /// The requested crawl is explicitly disallowed. + Disallowed, + /// The policy could not be fetched or interpreted safely. + Unknown, + /// Robots policy was not evaluated for this execution purpose. + NotApplicable, +} + +/// How secret material is delivered to a browser action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SecretDelivery { + /// The action carries no secret material. + None, + /// A trusted broker resolves an opaque secret handle outside the model. + BrokerHandle, + /// A raw secret value would be exposed directly to the caller. + RawValue, +} + +/// The ordered risk class assigned to an action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RiskClass { + /// Read-only observation with no state change. + R0, + /// Low-risk navigation or local retrieval. + R1, + /// Reversible preparation such as creating a draft. + R2, + /// External submission or sensitive interaction requiring approval. + R3, + /// High-impact purchase, deletion, or permission change. + R4, + /// Legal or similarly non-delegable consent. + R5, +} + +impl RiskClass { + /// Return whether the risk class requires approval before execution. + #[must_use] + pub const fn requires_approval(self) -> bool { + matches!(self, Self::R3 | Self::R4 | Self::R5) + } +} + +/// A capability that may be granted to an isolated agent session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Capability { + /// Observe a page's governed semantic representation. + Observe, + /// Extract structured information from allowed evidence. + Extract, + /// Navigate to an allowed origin. + Navigate, + /// Download a resource from an allowed origin. + Download, + /// Prepare a reversible draft. + Draft, + /// Submit data to an allowed origin. + Submit, + /// Upload a pre-approved artifact. + Upload, + /// Fill a secret through the trusted secret broker. + FillSecret, + /// Complete a purchase after approval. + Purchase, + /// Delete a remote object after approval. + Delete, + /// Change a permission after approval. + ManagePermission, + /// Record legal consent, which agents cannot perform autonomously. + LegalConsent, +} + +/// A typed browser action exposed to policy evaluation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ActionKind { + /// Observe governed page state. + Observe, + /// Extract structured data. + Extract, + /// Navigate the browser. + Navigate, + /// Download a resource. + Download, + /// Create or update a reversible draft. + Draft, + /// Submit data externally. + Submit, + /// Upload an approved file. + Upload, + /// Fill a secret using an opaque broker handle. + FillSecret, + /// Complete a purchase. + Purchase, + /// Delete remote state. + Delete, + /// Change access permissions. + ManagePermission, + /// Accept legally binding terms. + LegalConsent, +} + +impl ActionKind { + /// Return the action's fixed risk classification. + #[must_use] + pub const fn risk_class(self) -> RiskClass { + match self { + Self::Observe | Self::Extract => RiskClass::R0, + Self::Navigate | Self::Download => RiskClass::R1, + Self::Draft => RiskClass::R2, + Self::Submit | Self::Upload | Self::FillSecret => RiskClass::R3, + Self::Purchase | Self::Delete | Self::ManagePermission => RiskClass::R4, + Self::LegalConsent => RiskClass::R5, + } + } + + /// Return the capability required to request this action. + #[must_use] + pub const fn required_capability(self) -> Capability { + match self { + Self::Observe => Capability::Observe, + Self::Extract => Capability::Extract, + Self::Navigate => Capability::Navigate, + Self::Download => Capability::Download, + Self::Draft => Capability::Draft, + Self::Submit => Capability::Submit, + Self::Upload => Capability::Upload, + Self::FillSecret => Capability::FillSecret, + Self::Purchase => Capability::Purchase, + Self::Delete => Capability::Delete, + Self::ManagePermission => Capability::ManagePermission, + Self::LegalConsent => Capability::LegalConsent, + } + } + + /// Return whether execution can mutate browser or remote state. + #[must_use] + pub const fn mutates_state(self) -> bool { + !matches!( + self, + Self::Observe | Self::Extract | Self::Navigate | Self::Download + ) + } + + /// Return whether this action is designed to resolve a brokered secret. + #[must_use] + pub const fn uses_secret(self) -> bool { + matches!(self, Self::FillSecret) + } +} + +/// The exact action, target origin, and complete intent covered by an approval. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalScope { + action: ActionKind, + target_origin: Origin, + intent_digest: ActionIntentDigest, +} + +impl ApprovalScope { + /// Create one exact approval scope. + #[must_use] + pub const fn new( + action: ActionKind, + target_origin: Origin, + intent_digest: ActionIntentDigest, + ) -> Self { + Self { + action, + target_origin, + intent_digest, + } + } + + /// Return the approved action kind. + #[must_use] + pub const fn action(&self) -> ActionKind { + self.action + } + + /// Return the approved target origin. + #[must_use] + pub const fn target_origin(&self) -> &Origin { + &self.target_origin + } + + /// Return the approved complete-intent digest. + #[must_use] + pub const fn intent_digest(&self) -> &ActionIntentDigest { + &self.intent_digest + } +} + +/// Evidence that a high-risk action was approved for an exact scope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApprovalEvidence { + /// No approval was supplied. + None, + /// A person confirmed the exact action, target, and complete intent. + UserConfirmed(ApprovalScope), + /// A managed policy approved the exact action, target, and complete intent. + EnterprisePolicy(ApprovalScope), +} + +impl ApprovalEvidence { + /// Return whether this evidence authorizes the exact required scope. + #[must_use] + pub fn authorizes(&self, required: &ApprovalScope) -> bool { + match self { + Self::None => false, + Self::UserConfirmed(scope) | Self::EnterprisePolicy(scope) => scope == required, + } + } +} + +/// A complete typed request presented to the policy engine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionRequest { + action: ActionKind, + source_origin: Origin, + target_origin: Origin, + instruction_source: InstructionSource, + secret_delivery: SecretDelivery, + intent_digest: ActionIntentDigest, +} + +impl ActionRequest { + /// Create one action request without executing it. + #[must_use] + pub const fn new( + action: ActionKind, + source_origin: Origin, + target_origin: Origin, + instruction_source: InstructionSource, + secret_delivery: SecretDelivery, + intent_digest: ActionIntentDigest, + ) -> Self { + Self { + action, + source_origin, + target_origin, + instruction_source, + secret_delivery, + intent_digest, + } + } + + /// Return the requested action. + #[must_use] + pub const fn action(&self) -> ActionKind { + self.action + } + + /// Return the origin that currently owns the browser context. + #[must_use] + pub const fn source_origin(&self) -> &Origin { + &self.source_origin + } + + /// Return the origin affected by the action. + #[must_use] + pub const fn target_origin(&self) -> &Origin { + &self.target_origin + } + + /// Return the trust class of the proposing instruction. + #[must_use] + pub const fn instruction_source(&self) -> InstructionSource { + self.instruction_source + } + + /// Return how secret material would be delivered. + #[must_use] + pub const fn secret_delivery(&self) -> SecretDelivery { + self.secret_delivery + } + + /// Return the digest of the complete canonical action intent. + #[must_use] + pub const fn intent_digest(&self) -> &ActionIntentDigest { + &self.intent_digest + } +} + +/// Immutable grants and mutable evidence used for one policy decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyContext { + mode: SessionMode, + purpose: ExecutionPurpose, + capabilities: BTreeSet, + read_origins: BTreeSet, + write_origins: BTreeSet, + robots_decision: RobotsDecision, + approval: ApprovalEvidence, +} + +impl PolicyContext { + /// Create one policy context from explicitly granted capabilities and origins. + #[must_use] + pub const fn new( + mode: SessionMode, + purpose: ExecutionPurpose, + capabilities: BTreeSet, + read_origins: BTreeSet, + write_origins: BTreeSet, + robots_decision: RobotsDecision, + approval: ApprovalEvidence, + ) -> Self { + Self { + mode, + purpose, + capabilities, + read_origins, + write_origins, + robots_decision, + approval, + } + } + + /// Return the browser execution mode. + #[must_use] + pub const fn mode(&self) -> SessionMode { + self.mode + } + + /// Return the declared execution purpose. + #[must_use] + pub const fn purpose(&self) -> ExecutionPurpose { + self.purpose + } + + /// Return the granted capabilities. + #[must_use] + pub const fn capabilities(&self) -> &BTreeSet { + &self.capabilities + } + + /// Return the origins that may be read. + #[must_use] + pub const fn read_origins(&self) -> &BTreeSet { + &self.read_origins + } + + /// Return the origins that may be mutated. + #[must_use] + pub const fn write_origins(&self) -> &BTreeSet { + &self.write_origins + } + + /// Return the robots-exclusion decision. + #[must_use] + pub const fn robots_decision(&self) -> RobotsDecision { + self.robots_decision + } + + /// Replace robots evidence after a fresh policy lookup. + pub const fn set_robots_decision(&mut self, decision: RobotsDecision) { + self.robots_decision = decision; + } + + /// Return the supplied approval evidence. + #[must_use] + pub const fn approval(&self) -> &ApprovalEvidence { + &self.approval + } + /// Replace approval evidence after a user or enterprise decision. + pub fn set_approval(&mut self, approval: ApprovalEvidence) { + self.approval = approval; + } +} + +/// A canonical Chromium extension identifier admitted to OriginWeave policy. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExtensionId { + canonical: String, +} + +impl ExtensionId { + /// Parse one canonical 32-character lowercase Chromium extension identifier. + /// + /// Chromium extension identifiers use only the lowercase `a` through `p` + /// alphabet. OriginWeave rejects any non-canonical spelling rather than + /// normalizing caller-controlled identity text. + pub fn parse(input: &str) -> Result { + if input.len() != 32 { + return Err(ExtensionIdError::InvalidExtensionId); + } + if !input.bytes().all(|byte| (b'a'..=b'p').contains(&byte)) { + return Err(ExtensionIdError::InvalidExtensionId); + } + Ok(Self { + canonical: input.to_owned(), + }) + } + + /// Return the canonical extension identifier. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } +} + +/// A validation error for a Chromium extension identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionIdError { + /// The value was not exactly 32 lowercase characters from `a` through `p`. + InvalidExtensionId, +} + +/// An OriginWeave Agent capability that a browser extension may request explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtensionAgentCapability { + /// Observe the governed semantic representation of the exact current context. + ObserveCurrentContext, + /// Propose a typed action for independent OriginWeave policy evaluation. + ProposeTypedAction, +} + +/// An explicit host-originated grant from one extension to bounded Agent capabilities. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAgentGrant { + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capabilities: BTreeSet, +} + +impl ExtensionAgentGrant { + /// Build an exact extension-to-Agent grant for one browser session and context. + #[must_use] + pub fn new( + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capabilities: I, + ) -> Self + where + I: IntoIterator, + { + Self { + extension_id, + browser_session, + browsing_context, + capabilities: capabilities.into_iter().collect(), + } + } +} + +/// One extension request to use a bounded OriginWeave Agent capability. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAccessRequest { + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capability: ExtensionAgentCapability, +} + +impl ExtensionAccessRequest { + /// Build one exact extension capability request without granting authority. + #[must_use] + pub const fn new( + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capability: ExtensionAgentCapability, + ) -> Self { + Self { + extension_id, + browser_session, + browsing_context, + capability, + } + } +} + +/// Result of evaluating an extension request against one explicit Agent grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionAccessDecision { + /// The exact extension, session, context, and capability are explicitly granted. + Allow, + /// No explicit extension-to-Agent grant was supplied. + DenyMissingGrant, + /// The request belongs to a different extension identity. + DenyExtensionMismatch, + /// The request belongs to a different browser automation session. + DenyBrowserSessionMismatch, + /// The request belongs to a different independently navigable browser context. + DenyBrowsingContextMismatch, + /// The extension grant does not contain the requested OriginWeave capability. + DenyCapabilityNotGranted, +} + +/// Evaluate extension Agent access without inheriting ambient Chrome permissions. +/// +/// A Chrome extension permission, installation state, or page capability is never +/// consulted here. A future Chromium adapter must construct a host-originated +/// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session/context +/// request at the boundary where Agent authority would otherwise cross. +#[must_use] +pub fn evaluate_extension_access( + request: &ExtensionAccessRequest, + grant: Option<&ExtensionAgentGrant>, +) -> ExtensionAccessDecision { + let Some(grant) = grant else { + return ExtensionAccessDecision::DenyMissingGrant; + }; + if request.extension_id != grant.extension_id { + return ExtensionAccessDecision::DenyExtensionMismatch; + } + if request.browser_session != grant.browser_session { + return ExtensionAccessDecision::DenyBrowserSessionMismatch; + } + if request.browsing_context != grant.browsing_context { + return ExtensionAccessDecision::DenyBrowsingContextMismatch; + } + if !grant.capabilities.contains(&request.capability) { + return ExtensionAccessDecision::DenyCapabilityNotGranted; + } + ExtensionAccessDecision::Allow +} diff --git a/crates/originweave-core/src/extension_authority.rs b/crates/originweave-core/src/extension_authority.rs new file mode 100644 index 000000000..f06ce5c31 --- /dev/null +++ b/crates/originweave-core/src/extension_authority.rs @@ -0,0 +1,199 @@ +//! Extension-to-Agent authority adapted onto the refactored core contracts. +//! +//! The browser-registry branch split long-lived contracts into a private +//! `contracts` module before protected main added origin and exclusive-expiry +//! binding to extension grants. This module preserves those protected-main +//! semantics without allowing raw Chromium permissions or identifiers to become +//! Agent authority. + +use std::fmt; + +use crate::contracts::{ + BrowserSessionId, BrowsingContextId, ExtensionAccessDecision as BaseExtensionAccessDecision, + ExtensionAccessRequest as BaseExtensionAccessRequest, ExtensionAgentCapability, + ExtensionAgentGrant as BaseExtensionAgentGrant, ExtensionId, Origin, + evaluate_extension_access as evaluate_base_extension_access, +}; + +/// A nonzero host-assigned identity for one isolated Agent Task. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AgentTaskId(u64); + +impl AgentTaskId { + /// Validate one host-assigned Agent Task identifier. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(AgentTaskIdError::InvalidAgentTaskId); + } + Ok(Self(value)) + } + + /// Return the validated Agent Task identifier. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A validation failure for an Agent Task identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentTaskIdError { + /// Agent Task identities are one-based and zero was supplied. + InvalidAgentTaskId, +} + +impl fmt::Display for AgentTaskIdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidAgentTaskId => { + formatter.write_str("Agent Task identifier must be nonzero") + } + } + } +} + +impl std::error::Error for AgentTaskIdError {} + +/// An explicit host-originated extension grant bound to task, session, context, origin, and expiry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAgentGrant { + base: BaseExtensionAgentGrant, + agent_task: AgentTaskId, + origin: Origin, + expires_at_epoch_seconds: u64, +} + +impl ExtensionAgentGrant { + /// Build an exact extension-to-Agent grant for one task, session, context, origin, and expiry. + #[must_use] + pub fn new( + extension_id: ExtensionId, + agent_task: AgentTaskId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + expires_at_epoch_seconds: u64, + capabilities: I, + ) -> Self + where + I: IntoIterator, + { + Self { + base: BaseExtensionAgentGrant::new( + extension_id, + browser_session, + browsing_context, + capabilities, + ), + agent_task, + origin, + expires_at_epoch_seconds, + } + } +} + +/// One task-bound extension request to use a bounded Agent capability at trusted evaluation time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAccessRequest { + base: BaseExtensionAccessRequest, + agent_task: AgentTaskId, + origin: Origin, + now_epoch_seconds: u64, +} + +impl ExtensionAccessRequest { + /// Build one exact task-bound extension capability request without granting authority. + /// + /// `now_epoch_seconds` must come from trusted host evaluation time rather + /// than a page, extension, model, or other caller-controlled clock. + #[must_use] + pub const fn new( + extension_id: ExtensionId, + agent_task: AgentTaskId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + now_epoch_seconds: u64, + capability: ExtensionAgentCapability, + ) -> Self { + Self { + base: BaseExtensionAccessRequest::new( + extension_id, + browser_session, + browsing_context, + capability, + ), + agent_task, + origin, + now_epoch_seconds, + } + } +} + +/// Result of evaluating one extension request against one explicit Agent grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionAccessDecision { + /// Extension, task, session, context, origin, expiry, and capability all match. + Allow, + /// No explicit extension-to-Agent grant was supplied. + DenyMissingGrant, + /// The request belongs to a different extension identity. + DenyExtensionMismatch, + /// The request belongs to a different Agent Task identity. + DenyAgentTaskMismatch, + /// The request belongs to a different browser automation session. + DenyBrowserSessionMismatch, + /// The request belongs to a different independently navigable browser context. + DenyBrowsingContextMismatch, + /// The request belongs to a different canonical origin than the grant. + DenyOriginMismatch, + /// Trusted evaluation time is at or after the grant's exclusive expiry. + DenyExpired, + /// The extension grant does not contain the requested OriginWeave capability. + DenyCapabilityNotGranted, +} + +/// Evaluate extension Agent access without inheriting ambient Chrome permissions. +/// +/// Extension identity, session, context, and missing-grant checks reuse the pre-existing +/// deterministic contract. Exact Agent Task identity, origin, and exclusive-expiry checks are +/// then applied before a capability denial or allowance is returned, preserving fail-closed +/// authority ordering on the refactored branch. +#[must_use] +pub fn evaluate_extension_access( + request: &ExtensionAccessRequest, + grant: Option<&ExtensionAgentGrant>, +) -> ExtensionAccessDecision { + let base_decision = + evaluate_base_extension_access(&request.base, grant.map(|grant| &grant.base)); + match base_decision { + BaseExtensionAccessDecision::DenyMissingGrant => ExtensionAccessDecision::DenyMissingGrant, + BaseExtensionAccessDecision::DenyExtensionMismatch => { + ExtensionAccessDecision::DenyExtensionMismatch + } + BaseExtensionAccessDecision::DenyBrowserSessionMismatch => { + ExtensionAccessDecision::DenyBrowserSessionMismatch + } + BaseExtensionAccessDecision::DenyBrowsingContextMismatch => { + ExtensionAccessDecision::DenyBrowsingContextMismatch + } + BaseExtensionAccessDecision::Allow + | BaseExtensionAccessDecision::DenyCapabilityNotGranted => { + grant.map_or(ExtensionAccessDecision::DenyMissingGrant, |grant| { + if request.agent_task != grant.agent_task { + return ExtensionAccessDecision::DenyAgentTaskMismatch; + } + if request.origin != grant.origin { + return ExtensionAccessDecision::DenyOriginMismatch; + } + if request.now_epoch_seconds >= grant.expires_at_epoch_seconds { + return ExtensionAccessDecision::DenyExpired; + } + if base_decision == BaseExtensionAccessDecision::DenyCapabilityNotGranted { + return ExtensionAccessDecision::DenyCapabilityNotGranted; + } + ExtensionAccessDecision::Allow + }) + } + } +} diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index e33a7e7e5..f5791fb5c 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1,1094 +1,40 @@ //! Shared security and governance contracts for OriginWeave. //! -//! The crate deliberately contains no browser-engine integration. It defines -//! small, deterministic value types that can be reused by the browser shell, -//! headless runtime, MCP adapter, and enterprise policy service. +//! This crate keeps the long-lived value contracts in `contracts`, the +//! browser protocol/identifier boundaries and extension authority in focused +//! modules so browser adapters can evolve without turning raw CDP or WebDriver +//! metadata into OriginWeave authority. #![forbid(unsafe_code)] #![deny(missing_docs)] -use std::collections::BTreeSet; -use std::fmt; -use std::net::{Ipv4Addr, Ipv6Addr}; - -/// A normalized web origin accepted by the OriginWeave trust boundary. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Origin { - canonical: String, -} - -impl Origin { - /// Parse one origin and reject paths, credentials, fragments, insecure - /// remote HTTP endpoints, and browser-special numeric host spellings. - pub fn parse(input: &str) -> Result { - if input.trim() != input - || input - .chars() - .any(|character| character.is_control() || character.is_whitespace()) - { - return Err(OriginError::InvalidAuthority); - } - - let Some((raw_scheme, authority)) = input.split_once("://") else { - return Err(OriginError::MissingScheme); - }; - let scheme = raw_scheme.to_ascii_lowercase(); - if scheme != "https" && scheme != "http" { - return Err(OriginError::UnsupportedScheme); - } - if authority.is_empty() { - return Err(OriginError::MissingAuthority); - } - if authority.contains('@') { - return Err(OriginError::UserInfoNotAllowed); - } - if authority - .chars() - .any(|character| matches!(character, '/' | '?' | '#')) - { - return Err(OriginError::PathNotAllowed); - } - - let (host, port, is_loopback) = parse_authority(authority)?; - if scheme == "http" && !is_loopback { - return Err(OriginError::InsecureRemoteOrigin); - } - let normalized_port = normalize_default_port(&scheme, port); - let canonical = match normalized_port { - Some(port_number) => format!("{scheme}://{host}:{port_number}"), - None => format!("{scheme}://{host}"), - }; - Ok(Self { canonical }) - } - - /// Return the normalized origin string. - #[must_use] - pub fn as_str(&self) -> &str { - &self.canonical - } - - /// Return the validated lowercase origin scheme. - #[must_use] - pub fn scheme(&self) -> &str { - if self.canonical.starts_with("https://") { - "https" - } else { - "http" - } - } - - /// Return the validated canonical host without IPv6 brackets. - #[must_use] - pub fn host(&self) -> &str { - let authority = &self.canonical[self.scheme().len() + 3..]; - let bracketed = authority.starts_with('['); - let host_start = usize::from(bracketed); - let host_end = if bracketed { - authority.find(']').unwrap_or(authority.len()) - } else { - authority.find(':').unwrap_or(authority.len()) - }; - &authority[host_start..host_end] - } -} - -impl fmt::Display for Origin { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -fn normalize_default_port(scheme: &str, port: Option) -> Option { - match (scheme, port) { - ("https", Some(443)) | ("http", Some(80)) => None, - (_, other) => other, - } -} - -fn parse_authority(authority: &str) -> Result<(String, Option, bool), OriginError> { - if authority.starts_with('[') { - return parse_bracketed_ipv6(authority); - } - if authority.matches(':').count() > 1 { - return Err(OriginError::InvalidAuthority); - } - - let (host_text, port) = match authority.rsplit_once(':') { - Some((host, port_text)) => (host, Some(parse_port(port_text)?)), - None => (authority, None), - }; - let host = host_text.to_ascii_lowercase(); - if let Ok(address) = host.parse::() { - return Ok((host, port, address.is_loopback())); - } - if looks_like_browser_ipv4_host(&host) { - return Err(OriginError::AmbiguousNumericHost); - } - validate_dns_host(&host)?; - Ok((host.clone(), port, host == "localhost")) -} - -fn looks_like_browser_ipv4_host(host: &str) -> bool { - host.rsplit('.') - .next() - .is_some_and(looks_like_browser_ipv4_number) -} - -fn looks_like_browser_ipv4_number(label: &str) -> bool { - if label.is_empty() { - return false; - } - let lowercase = label.to_ascii_lowercase(); - if let Some(hexadecimal) = lowercase.strip_prefix("0x") { - return !hexadecimal.is_empty() && hexadecimal.bytes().all(|byte| byte.is_ascii_hexdigit()); - } - label.bytes().all(|byte| byte.is_ascii_digit()) -} - -fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), OriginError> { - let Some(close_index) = authority.find(']') else { - return Err(OriginError::InvalidAuthority); - }; - let address_text = &authority[1..close_index]; - let address = address_text - .parse::() - .map_err(|_error| OriginError::InvalidAuthority)?; - let remainder = &authority[close_index + 1..]; - let port = if remainder.is_empty() { - None - } else if let Some(port_text) = remainder.strip_prefix(':') { - Some(parse_port(port_text)?) - } else { - return Err(OriginError::InvalidAuthority); - }; - Ok((format!("[{address}]"), port, address.is_loopback())) -} - -fn parse_port(port_text: &str) -> Result { - if port_text.is_empty() || !port_text.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(OriginError::InvalidPort); - } - let port = port_text - .parse::() - .map_err(|_error| OriginError::InvalidPort)?; - if port == 0 { - return Err(OriginError::InvalidPort); - } - Ok(port) -} - -fn validate_dns_host(host: &str) -> Result<(), OriginError> { - if host.is_empty() { - return Err(OriginError::InvalidAuthority); - } - if host.len() > 253 { - return Err(OriginError::InvalidAuthority); - } - if !host.is_ascii() { - return Err(OriginError::InvalidAuthority); - } - if host.starts_with('.') || host.ends_with('.') { - return Err(OriginError::InvalidAuthority); - } - for label in host.split('.') { - if label.is_empty() { - return Err(OriginError::InvalidAuthority); - } - if label.len() > 63 { - return Err(OriginError::InvalidAuthority); - } - let bytes = label.as_bytes(); - if !bytes[0].is_ascii_alphanumeric() { - return Err(OriginError::InvalidAuthority); - } - if !bytes[bytes.len() - 1].is_ascii_alphanumeric() { - return Err(OriginError::InvalidAuthority); - } - if !bytes - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') - { - return Err(OriginError::InvalidAuthority); - } - } - Ok(()) -} - -/// A reason that an origin string could not enter the trust boundary. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OriginError { - /// The input did not contain a `scheme://` separator. - MissingScheme, - /// The scheme was neither HTTPS nor locally scoped HTTP. - UnsupportedScheme, - /// HTTP was requested for a non-loopback host. - InsecureRemoteOrigin, - /// No authority followed the scheme. - MissingAuthority, - /// User information appeared before the host. - UserInfoNotAllowed, - /// A path, query, or fragment was supplied where only an origin is valid. - PathNotAllowed, - /// The host or authority syntax was ambiguous or malformed. - InvalidAuthority, - /// A browser could reinterpret the host as a non-canonical IPv4 address. - AmbiguousNumericHost, - /// The explicit port was outside `1..=65535` or was not numeric. - InvalidPort, -} - -/// A nonzero identity for one active browser automation session. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct BrowserSessionId(u64); - -impl BrowserSessionId { - /// Validate one adapter-supplied browser-session identifier. - pub const fn new(value: u64) -> Result { - if value == 0 { - return Err(NodeHandleError::InvalidBrowserSessionId); - } - Ok(Self(value)) - } - - /// Return the validated browser-session identifier. - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } -} - -/// A nonzero identity for one independently navigable browser context. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct BrowsingContextId(u64); - -impl BrowsingContextId { - /// Validate one adapter-supplied browsing-context identifier. - pub const fn new(value: u64) -> Result { - if value == 0 { - return Err(NodeHandleError::InvalidBrowsingContextId); - } - Ok(Self(value)) - } - - /// Return the validated browsing-context identifier. - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } -} - -/// A nonzero identity for one observed browser document lifetime. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct DocumentEpoch(u64); - -impl DocumentEpoch { - /// Validate one adapter-supplied document epoch. - pub const fn new(value: u64) -> Result { - if value == 0 { - return Err(NodeHandleError::InvalidDocumentEpoch); - } - Ok(Self(value)) - } - - /// Return the validated document epoch value. - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } -} - -/// A node identity bound to the exact session, context, origin, and document that produced it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ObservedNodeHandle { - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - document_epoch: DocumentEpoch, - node_id: u64, -} - -impl ObservedNodeHandle { - /// Create one authority-bound observed node handle from a nonzero adapter node identifier. - pub fn new( - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - document_epoch: DocumentEpoch, - node_id: u64, - ) -> Result { - if node_id == 0 { - return Err(NodeHandleError::InvalidNodeId); - } - Ok(Self { - browser_session, - browsing_context, - origin, - document_epoch, - node_id, - }) - } - - /// Return the browser session that produced the node observation. - #[must_use] - pub const fn browser_session(&self) -> BrowserSessionId { - self.browser_session - } - - /// Return the browsing context that produced the node observation. - #[must_use] - pub const fn browsing_context(&self) -> BrowsingContextId { - self.browsing_context - } - - /// Return the canonical origin that produced the node observation. - #[must_use] - pub const fn origin(&self) -> &Origin { - &self.origin - } - - /// Return the document epoch that produced the node observation. - #[must_use] - pub const fn document_epoch(&self) -> DocumentEpoch { - self.document_epoch - } - - /// Return the adapter-local nonzero node identifier. - #[must_use] - pub const fn node_id(&self) -> u64 { - self.node_id - } - - /// Reject use when the session, browsing context, origin, or document epoch has changed. - pub fn validate_current( - &self, - current_session: BrowserSessionId, - current_context: BrowsingContextId, - current_origin: &Origin, - current_epoch: DocumentEpoch, - ) -> Result<(), NodeHandleError> { - if self.browser_session != current_session { - return Err(NodeHandleError::BrowserSessionMismatch { - observed: self.browser_session, - current: current_session, - }); - } - if self.browsing_context != current_context { - return Err(NodeHandleError::BrowsingContextMismatch { - observed: self.browsing_context, - current: current_context, - }); - } - if &self.origin != current_origin { - return Err(NodeHandleError::OriginMismatch); - } - if self.document_epoch != current_epoch { - return Err(NodeHandleError::StaleDocumentEpoch { - observed: self.document_epoch, - current: current_epoch, - }); - } - Ok(()) - } -} - -/// A failure to construct or reuse an authority- and document-bound node handle safely. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NodeHandleError { - /// Browser-session identifiers are one-based and zero was supplied. - InvalidBrowserSessionId, - /// Browsing-context identifiers are one-based and zero was supplied. - InvalidBrowsingContextId, - /// Document epochs are one-based and zero was supplied. - InvalidDocumentEpoch, - /// Adapter-local node identifiers are one-based and zero was supplied. - InvalidNodeId, - /// The node handle belongs to a different browser automation session. - BrowserSessionMismatch { - /// Session that originally produced the node handle. - observed: BrowserSessionId, - /// Session currently active for the requested action. - current: BrowserSessionId, - }, - /// The node handle belongs to a different independently navigable context. - BrowsingContextMismatch { - /// Context that originally produced the node handle. - observed: BrowsingContextId, - /// Context currently active for the requested action. - current: BrowsingContextId, - }, - /// The browser context is now at a different canonical origin. - OriginMismatch, - /// The browser context is now at a different document epoch. - StaleDocumentEpoch { - /// Epoch that originally produced the node handle. - observed: DocumentEpoch, - /// Epoch currently active in the browser context. - current: DocumentEpoch, - }, -} - -impl fmt::Display for NodeHandleError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidBrowserSessionId => { - formatter.write_str("browser session identifier must be nonzero") - } - Self::InvalidBrowsingContextId => { - formatter.write_str("browsing context identifier must be nonzero") - } - Self::InvalidDocumentEpoch => formatter.write_str("document epoch must be nonzero"), - Self::InvalidNodeId => formatter.write_str("observed node identifier must be nonzero"), - Self::BrowserSessionMismatch { observed, current } => write!( - formatter, - "observed node browser session {} does not match current session {}", - observed.value(), - current.value() - ), - Self::BrowsingContextMismatch { observed, current } => write!( - formatter, - "observed node browsing context {} does not match current context {}", - observed.value(), - current.value() - ), - Self::OriginMismatch => { - formatter.write_str("observed node origin does not match the current origin") - } - Self::StaleDocumentEpoch { observed, current } => write!( - formatter, - "observed node document epoch {} is stale; current epoch is {}", - observed.value(), - current.value() - ), - } - } -} - -impl std::error::Error for NodeHandleError {} - -/// An immutable digest of the complete canonical action intent. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct ActionIntentDigest { - canonical: String, -} - -impl ActionIntentDigest { - /// Parse a lowercase `sha256:` digest of the complete canonical intent. - pub fn parse(input: &str) -> Result { - let Some(hexadecimal) = input.strip_prefix("sha256:") else { - return Err(ActionIntentDigestError::InvalidFormat); - }; - if hexadecimal.len() != 64 - || !hexadecimal - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(ActionIntentDigestError::InvalidFormat); - } - Ok(Self { - canonical: input.to_owned(), - }) - } - - /// Return the canonical lowercase digest. - #[must_use] - pub fn as_str(&self) -> &str { - &self.canonical - } -} - -/// A validation error for an action-intent digest. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ActionIntentDigestError { - /// The value was not `sha256:` followed by 64 lowercase hexadecimal digits. - InvalidFormat, -} - -/// The browser execution mode that owns an action. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum SessionMode { - /// A person controls the browser without agent execution privileges. - Human, - /// An agent assists a person while write actions remain governed. - Assist, - /// An isolated task session is delegated to an agent. - AgentTask, - /// A read-only crawler performs policy-bounded collection. - Crawler, -} - -/// The declared business purpose of one browser execution. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ExecutionPurpose { - /// Public content is collected under crawler policy. - PublicCrawl, - /// A person delegated a bounded task in their own context. - UserDelegatedTask, - /// An enterprise policy authorized a managed task. - EnterpriseAuthorizedTask, - /// The action is running in a non-production test environment. - TestingEnvironment, -} - -/// The trust class of the instruction that proposed an action. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum InstructionSource { - /// A human user supplied the instruction. - User, - /// A managed enterprise policy supplied the instruction. - EnterprisePolicy, - /// Untrusted page or document content supplied the instruction. - WebContent, -} - -/// The result of applying a robots-exclusion policy. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RobotsDecision { - /// The requested crawl is explicitly allowed. - Allowed, - /// The requested crawl is explicitly disallowed. - Disallowed, - /// The policy could not be fetched or interpreted safely. - Unknown, - /// Robots policy was not evaluated for this execution purpose. - NotApplicable, -} - -/// How secret material is delivered to a browser action. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum SecretDelivery { - /// The action carries no secret material. - None, - /// A trusted broker resolves an opaque secret handle outside the model. - BrokerHandle, - /// A raw secret value would be exposed directly to the caller. - RawValue, -} - -/// The ordered risk class assigned to an action. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RiskClass { - /// Read-only observation with no state change. - R0, - /// Low-risk navigation or local retrieval. - R1, - /// Reversible preparation such as creating a draft. - R2, - /// External submission or sensitive interaction requiring approval. - R3, - /// High-impact purchase, deletion, or permission change. - R4, - /// Legal or similarly non-delegable consent. - R5, -} - -impl RiskClass { - /// Return whether the risk class requires approval before execution. - #[must_use] - pub const fn requires_approval(self) -> bool { - matches!(self, Self::R3 | Self::R4 | Self::R5) - } -} - -/// A capability that may be granted to an isolated agent session. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Capability { - /// Observe a page's governed semantic representation. - Observe, - /// Extract structured information from allowed evidence. - Extract, - /// Navigate to an allowed origin. - Navigate, - /// Download a resource from an allowed origin. - Download, - /// Prepare a reversible draft. - Draft, - /// Submit data to an allowed origin. - Submit, - /// Upload a pre-approved artifact. - Upload, - /// Fill a secret through the trusted secret broker. - FillSecret, - /// Complete a purchase after approval. - Purchase, - /// Delete a remote object after approval. - Delete, - /// Change a permission after approval. - ManagePermission, - /// Record legal consent, which agents cannot perform autonomously. - LegalConsent, -} - -/// A typed browser action exposed to policy evaluation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ActionKind { - /// Observe governed page state. - Observe, - /// Extract structured data. - Extract, - /// Navigate the browser. - Navigate, - /// Download a resource. - Download, - /// Create or update a reversible draft. - Draft, - /// Submit data externally. - Submit, - /// Upload an approved file. - Upload, - /// Fill a secret using an opaque broker handle. - FillSecret, - /// Complete a purchase. - Purchase, - /// Delete remote state. - Delete, - /// Change access permissions. - ManagePermission, - /// Accept legally binding terms. - LegalConsent, -} - -impl ActionKind { - /// Return the action's fixed risk classification. - #[must_use] - pub const fn risk_class(self) -> RiskClass { - match self { - Self::Observe | Self::Extract => RiskClass::R0, - Self::Navigate | Self::Download => RiskClass::R1, - Self::Draft => RiskClass::R2, - Self::Submit | Self::Upload | Self::FillSecret => RiskClass::R3, - Self::Purchase | Self::Delete | Self::ManagePermission => RiskClass::R4, - Self::LegalConsent => RiskClass::R5, - } - } - - /// Return the capability required to request this action. - #[must_use] - pub const fn required_capability(self) -> Capability { - match self { - Self::Observe => Capability::Observe, - Self::Extract => Capability::Extract, - Self::Navigate => Capability::Navigate, - Self::Download => Capability::Download, - Self::Draft => Capability::Draft, - Self::Submit => Capability::Submit, - Self::Upload => Capability::Upload, - Self::FillSecret => Capability::FillSecret, - Self::Purchase => Capability::Purchase, - Self::Delete => Capability::Delete, - Self::ManagePermission => Capability::ManagePermission, - Self::LegalConsent => Capability::LegalConsent, - } - } - - /// Return whether execution can mutate browser or remote state. - #[must_use] - pub const fn mutates_state(self) -> bool { - !matches!( - self, - Self::Observe | Self::Extract | Self::Navigate | Self::Download - ) - } - - /// Return whether this action is designed to resolve a brokered secret. - #[must_use] - pub const fn uses_secret(self) -> bool { - matches!(self, Self::FillSecret) - } -} - -/// The exact action, target origin, and complete intent covered by an approval. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ApprovalScope { - action: ActionKind, - target_origin: Origin, - intent_digest: ActionIntentDigest, -} - -impl ApprovalScope { - /// Create one exact approval scope. - #[must_use] - pub const fn new( - action: ActionKind, - target_origin: Origin, - intent_digest: ActionIntentDigest, - ) -> Self { - Self { - action, - target_origin, - intent_digest, - } - } - - /// Return the approved action kind. - #[must_use] - pub const fn action(&self) -> ActionKind { - self.action - } - - /// Return the approved target origin. - #[must_use] - pub const fn target_origin(&self) -> &Origin { - &self.target_origin - } - - /// Return the approved complete-intent digest. - #[must_use] - pub const fn intent_digest(&self) -> &ActionIntentDigest { - &self.intent_digest - } -} - -/// Evidence that a high-risk action was approved for an exact scope. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ApprovalEvidence { - /// No approval was supplied. - None, - /// A person confirmed the exact action, target, and complete intent. - UserConfirmed(ApprovalScope), - /// A managed policy approved the exact action, target, and complete intent. - EnterprisePolicy(ApprovalScope), -} - -impl ApprovalEvidence { - /// Return whether this evidence authorizes the exact required scope. - #[must_use] - pub fn authorizes(&self, required: &ApprovalScope) -> bool { - match self { - Self::None => false, - Self::UserConfirmed(scope) | Self::EnterprisePolicy(scope) => scope == required, - } - } -} - -/// A complete typed request presented to the policy engine. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActionRequest { - action: ActionKind, - source_origin: Origin, - target_origin: Origin, - instruction_source: InstructionSource, - secret_delivery: SecretDelivery, - intent_digest: ActionIntentDigest, -} - -impl ActionRequest { - /// Create one action request without executing it. - #[must_use] - pub const fn new( - action: ActionKind, - source_origin: Origin, - target_origin: Origin, - instruction_source: InstructionSource, - secret_delivery: SecretDelivery, - intent_digest: ActionIntentDigest, - ) -> Self { - Self { - action, - source_origin, - target_origin, - instruction_source, - secret_delivery, - intent_digest, - } - } - - /// Return the requested action. - #[must_use] - pub const fn action(&self) -> ActionKind { - self.action - } - - /// Return the origin that currently owns the browser context. - #[must_use] - pub const fn source_origin(&self) -> &Origin { - &self.source_origin - } - - /// Return the origin affected by the action. - #[must_use] - pub const fn target_origin(&self) -> &Origin { - &self.target_origin - } - - /// Return the trust class of the proposing instruction. - #[must_use] - pub const fn instruction_source(&self) -> InstructionSource { - self.instruction_source - } - - /// Return how secret material would be delivered. - #[must_use] - pub const fn secret_delivery(&self) -> SecretDelivery { - self.secret_delivery - } - - /// Return the digest of the complete canonical action intent. - #[must_use] - pub const fn intent_digest(&self) -> &ActionIntentDigest { - &self.intent_digest - } -} - -/// Immutable grants and mutable evidence used for one policy decision. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PolicyContext { - mode: SessionMode, - purpose: ExecutionPurpose, - capabilities: BTreeSet, - read_origins: BTreeSet, - write_origins: BTreeSet, - robots_decision: RobotsDecision, - approval: ApprovalEvidence, -} - -impl PolicyContext { - /// Create one policy context from explicitly granted capabilities and origins. - #[must_use] - pub const fn new( - mode: SessionMode, - purpose: ExecutionPurpose, - capabilities: BTreeSet, - read_origins: BTreeSet, - write_origins: BTreeSet, - robots_decision: RobotsDecision, - approval: ApprovalEvidence, - ) -> Self { - Self { - mode, - purpose, - capabilities, - read_origins, - write_origins, - robots_decision, - approval, - } - } - - /// Return the browser execution mode. - #[must_use] - pub const fn mode(&self) -> SessionMode { - self.mode - } - - /// Return the declared execution purpose. - #[must_use] - pub const fn purpose(&self) -> ExecutionPurpose { - self.purpose - } - - /// Return the granted capabilities. - #[must_use] - pub const fn capabilities(&self) -> &BTreeSet { - &self.capabilities - } - - /// Return the origins that may be read. - #[must_use] - pub const fn read_origins(&self) -> &BTreeSet { - &self.read_origins - } - - /// Return the origins that may be mutated. - #[must_use] - pub const fn write_origins(&self) -> &BTreeSet { - &self.write_origins - } - - /// Return the robots-exclusion decision. - #[must_use] - pub const fn robots_decision(&self) -> RobotsDecision { - self.robots_decision - } - - /// Replace robots evidence after a fresh policy lookup. - pub const fn set_robots_decision(&mut self, decision: RobotsDecision) { - self.robots_decision = decision; - } - - /// Return the supplied approval evidence. - #[must_use] - pub const fn approval(&self) -> &ApprovalEvidence { - &self.approval - } - - /// Replace approval evidence after a user or enterprise decision. - pub fn set_approval(&mut self, approval: ApprovalEvidence) { - self.approval = approval; - } -} - -/// A canonical Chromium extension identifier admitted to OriginWeave policy. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct ExtensionId { - canonical: String, -} - -impl ExtensionId { - /// Parse one canonical 32-character lowercase Chromium extension identifier. - /// - /// Chromium extension identifiers use only the lowercase `a` through `p` - /// alphabet. OriginWeave rejects any non-canonical spelling rather than - /// normalizing caller-controlled identity text. - pub fn parse(input: &str) -> Result { - if input.len() != 32 { - return Err(ExtensionIdError::InvalidExtensionId); - } - if !input.bytes().all(|byte| (b'a'..=b'p').contains(&byte)) { - return Err(ExtensionIdError::InvalidExtensionId); - } - Ok(Self { - canonical: input.to_owned(), - }) - } - - /// Return the canonical extension identifier. - #[must_use] - pub fn as_str(&self) -> &str { - &self.canonical - } -} - -/// A validation error for a Chromium extension identifier. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExtensionIdError { - /// The value was not exactly 32 lowercase characters from `a` through `p`. - InvalidExtensionId, -} - -/// An OriginWeave Agent capability that a browser extension may request explicitly. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ExtensionAgentCapability { - /// Observe the governed semantic representation of the exact current context. - ObserveCurrentContext, - /// Propose a typed action for independent OriginWeave policy evaluation. - ProposeTypedAction, -} - -/// An explicit host-originated grant from one extension to bounded Agent capabilities. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExtensionAgentGrant { - extension_id: ExtensionId, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - expires_at_epoch_seconds: u64, - capabilities: BTreeSet, -} - -impl ExtensionAgentGrant { - /// Build an exact extension-to-Agent grant for one session, context, origin, and exclusive expiry. - #[must_use] - pub fn new( - extension_id: ExtensionId, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - expires_at_epoch_seconds: u64, - capabilities: I, - ) -> Self - where - I: IntoIterator, - { - Self { - extension_id, - browser_session, - browsing_context, - origin, - expires_at_epoch_seconds, - capabilities: capabilities.into_iter().collect(), - } - } -} - -/// One extension request to use a bounded OriginWeave Agent capability. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExtensionAccessRequest { - extension_id: ExtensionId, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - now_epoch_seconds: u64, - capability: ExtensionAgentCapability, -} - -impl ExtensionAccessRequest { - /// Build one exact extension capability request without granting authority. - /// - /// `now_epoch_seconds` must be trusted evaluation time supplied by the host, - /// not a page, extension, or model clock. - #[must_use] - pub const fn new( - extension_id: ExtensionId, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - now_epoch_seconds: u64, - capability: ExtensionAgentCapability, - ) -> Self { - Self { - extension_id, - browser_session, - browsing_context, - origin, - now_epoch_seconds, - capability, - } - } -} - -/// Result of evaluating an extension request against one explicit Agent grant. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExtensionAccessDecision { - /// The exact extension, session, context, origin, unexpired grant, and capability are explicitly granted. - Allow, - /// No explicit extension-to-Agent grant was supplied. - DenyMissingGrant, - /// The request belongs to a different extension identity. - DenyExtensionMismatch, - /// The request belongs to a different browser automation session. - DenyBrowserSessionMismatch, - /// The request belongs to a different independently navigable browser context. - DenyBrowsingContextMismatch, - /// The request belongs to a different canonical origin than the grant. - DenyOriginMismatch, - /// Trusted evaluation time is at or after the grant's exclusive expiry. - DenyExpired, - /// The extension grant does not contain the requested OriginWeave capability. - DenyCapabilityNotGranted, -} - -/// Evaluate extension Agent access without inheriting ambient Chrome permissions. -/// -/// A Chrome extension permission, installation state, or page capability is never -/// consulted here. A future Chromium adapter must construct a host-originated -/// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session, context, -/// canonical origin, and exclusive expiry at the boundary where Agent authority -/// would otherwise cross. -#[must_use] -pub fn evaluate_extension_access( - request: &ExtensionAccessRequest, - grant: Option<&ExtensionAgentGrant>, -) -> ExtensionAccessDecision { - let Some(grant) = grant else { - return ExtensionAccessDecision::DenyMissingGrant; - }; - if request.extension_id != grant.extension_id { - return ExtensionAccessDecision::DenyExtensionMismatch; - } - if request.browser_session != grant.browser_session { - return ExtensionAccessDecision::DenyBrowserSessionMismatch; - } - if request.browsing_context != grant.browsing_context { - return ExtensionAccessDecision::DenyBrowsingContextMismatch; - } - if request.origin != grant.origin { - return ExtensionAccessDecision::DenyOriginMismatch; - } - if request.now_epoch_seconds >= grant.expires_at_epoch_seconds { - return ExtensionAccessDecision::DenyExpired; - } - if !grant.capabilities.contains(&request.capability) { - return ExtensionAccessDecision::DenyCapabilityNotGranted; - } - ExtensionAccessDecision::Allow -} +mod browser_protocol; +mod browser_registry; +#[cfg(test)] +mod browser_registry_coverage; +mod contract_errors; +mod contracts; +mod extension_authority; + +pub use browser_protocol::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, + BrowserProtocolKind, MAX_BROWSER_PROTOCOL_METADATA_BYTES, +}; +pub use browser_registry::{ + BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + ObservedNodeHandle as RegistryObservedNodeHandle, +}; +pub use contracts::{ + ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, ApprovalEvidence, + ApprovalScope, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, + ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, + ExtensionAgentGrant, ExtensionId, ExtensionIdError, InstructionSource, NodeHandleError, + ObservedNodeHandle, Origin, OriginError, PolicyContext, RiskClass, RobotsDecision, + SecretDelivery, SessionMode, evaluate_extension_access, +}; +pub use extension_authority::{ + AgentTaskId, AgentTaskIdError, ExtensionAccessDecision as AuthorityExtensionAccessDecision, + ExtensionAccessRequest as AuthorityExtensionAccessRequest, + ExtensionAgentGrant as AuthorityExtensionAgentGrant, + evaluate_extension_access as evaluate_extension_authority_access, +}; diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs index c47a136d4..933641bba 100644 --- a/crates/originweave-core/src/root.rs +++ b/crates/originweave-core/src/root.rs @@ -7,9 +7,23 @@ #![deny(missing_docs)] #[path = "lib.rs"] -mod contracts; +mod core_contracts; +use core_contracts as contracts; -pub use contracts::*; +pub use core_contracts::{ + ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, AgentTaskId, + AgentTaskIdError, ApprovalEvidence, ApprovalScope, + AuthorityExtensionAccessDecision as ExtensionAccessDecision, + AuthorityExtensionAccessRequest as ExtensionAccessRequest, + AuthorityExtensionAgentGrant as ExtensionAgentGrant, BrowserAuthorityRegistry, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, + BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, BrowsingContextId, Capability, + DocumentEpoch, ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, + InstructionSource, MAX_BROWSER_PROTOCOL_METADATA_BYTES, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + NodeHandleError, Origin, OriginError, PolicyContext, + RegistryObservedNodeHandle as ObservedNodeHandle, RiskClass, RobotsDecision, SecretDelivery, + SessionMode, evaluate_extension_authority_access as evaluate_extension_access, +}; /// Stateless MCP routing validation that maps only explicit tools to typed actions. pub mod mcp; diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs new file mode 100644 index 000000000..7a53e62c2 --- /dev/null +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -0,0 +1,380 @@ +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, ObservedNodeHandle, Origin, +}; + +fn loopback_origin() -> Result> { + Ok(Origin::parse("http://127.0.0.1:43127")?) +} + +#[test] +fn external_protocol_identifiers_are_scoped_and_never_become_authority() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + + let first_session = registry.register_session("webdriver-session-A")?; + let repeated_session = registry.register_session("webdriver-session-A")?; + let second_session = registry.register_session("webdriver-session-B")?; + + assert_eq!(first_session, repeated_session); + assert_ne!(first_session, second_session); + + let first_context = registry.register_context(first_session, "frame-root")?; + let repeated_context = registry.register_context(first_session, "frame-root")?; + let second_context = registry.register_context(second_session, "frame-root")?; + + assert_eq!(first_context, repeated_context); + assert_ne!(first_context, second_context); + assert_eq!( + registry.current_epoch(first_context)?, + DocumentEpoch::new(1)? + ); + Ok(()) +} + +#[test] +fn public_default_and_error_contracts_are_usable_from_an_adapter() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::default(); + assert!(registry.register_session("adapter-session")?.value() > 0); + + let first_session = BrowserSessionId::new(1)?; + let second_session = BrowserSessionId::new(2)?; + let cases = [ + ( + BrowserRegistryError::InvalidExternalIdentifier, + "external browser identifier must contain 1 to 512 UTF-8 bytes".to_owned(), + ), + ( + BrowserRegistryError::UnknownBrowserSession, + "browser session is not registered in this authority registry".to_owned(), + ), + ( + BrowserRegistryError::UnknownBrowsingContext, + "browsing context is not registered in this authority registry".to_owned(), + ), + ( + BrowserRegistryError::ContextSessionMismatch { + expected: first_session, + actual: second_session, + }, + "browsing context belongs to session 1, not session 2".to_owned(), + ), + ( + BrowserRegistryError::OriginChangedWithoutDocumentAdvance, + "browsing context origin changed without advancing the document epoch".to_owned(), + ), + ( + BrowserRegistryError::UnknownNodeAuthority, + "observed node handle is not registered as current browser authority".to_owned(), + ), + ( + BrowserRegistryError::IdentifierSpaceExhausted, + "browser authority identifier space is exhausted".to_owned(), + ), + ( + BrowserRegistryError::DocumentEpochExhausted, + "browser document epoch space is exhausted".to_owned(), + ), + ( + BrowserRegistryError::InternalAuthorityInvariant, + "browser authority registry violated a nonzero invariant".to_owned(), + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + } + Ok(()) +} + +#[test] +fn document_rotation_invalidates_old_external_node_bindings() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = loopback_origin()?; + + let first = registry.bind_node(session, context, &origin, "backend-node-17")?; + let same = registry.bind_node(session, context, &origin, "backend-node-17")?; + assert_eq!(first.node_id(), same.node_id()); + + let next_epoch = registry.advance_document(context)?; + assert_eq!(next_epoch.value(), 2); + assert_eq!( + first.validate_current(session, context, &origin, next_epoch), + Err(NodeHandleError::StaleDocumentEpoch { + observed: first.document_epoch(), + current: next_epoch, + }) + ); + + let rebound = registry.bind_node(session, context, &origin, "backend-node-17")?; + assert_eq!(rebound.document_epoch(), next_epoch); + assert_ne!(first.node_id(), rebound.node_id()); + Ok(()) +} + +#[test] +fn retired_context_and_session_authority_cannot_be_reused() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = loopback_origin()?; + let first_node = registry.bind_node(session, context, &origin, "backend-node-17")?; + + registry.remove_context(context)?; + assert_eq!( + registry.current_epoch(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.bind_node(session, context, &origin, "backend-node-17"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.remove_context(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + let replacement_context = registry.register_context(session, "top-level-context")?; + assert_ne!(replacement_context, context); + let replacement_node = + registry.bind_node(session, replacement_context, &origin, "backend-node-17")?; + assert_ne!(replacement_node.node_id(), first_node.node_id()); + + registry.remove_session(session)?; + assert_eq!( + registry.register_context(session, "after-session-retirement"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + assert_eq!( + registry.current_epoch(replacement_context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.remove_session(session), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let replacement_session = registry.register_session("webdriver-session")?; + assert_ne!(replacement_session, session); + let next_context = registry.register_context(replacement_session, "top-level-context")?; + assert_ne!(next_context, replacement_context); + Ok(()) +} + +#[test] +fn advancing_a_retired_context_is_rejected() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + + registry.remove_context(context)?; + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + Ok(()) +} + +#[test] +fn context_cannot_be_reused_by_another_session() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "shared-looking-context")?; + let origin = loopback_origin()?; + + assert_eq!( + registry.bind_node(attacker, context, &origin, "node"), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); + Ok(()) +} + +#[test] +fn context_origin_cannot_change_without_document_rotation() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first_origin = loopback_origin()?; + let second_origin = Origin::parse("http://localhost:43127")?; + + registry.bind_node(session, context, &first_origin, "backend-node-17")?; + assert_eq!( + registry.bind_node(session, context, &second_origin, "backend-node-18"), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + Ok(()) +} + +#[test] +fn failed_node_allocation_does_not_bind_context_origin() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(2); + let session = registry.register_session("webdriver-session")?; + let exhausted_context = registry.register_context(session, "exhaustion-source")?; + let clean_context = registry.register_context(session, "clean-context")?; + let first_origin = loopback_origin()?; + let second_origin = Origin::parse("http://localhost:43127")?; + + registry.bind_node(session, exhausted_context, &first_origin, "node-one")?; + registry.bind_node(session, exhausted_context, &first_origin, "node-two")?; + + assert_eq!( + registry.bind_node(session, clean_context, &first_origin, "node-three"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + assert_eq!( + registry.bind_node(session, clean_context, &second_origin, "node-three"), + Err(BrowserRegistryError::IdentifierSpaceExhausted), + "a failed allocation must not leave behind origin authority" + ); + Ok(()) +} + +#[test] +fn external_identifiers_are_bounded_without_assuming_protocol_syntax() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + + assert_eq!( + registry.register_session(""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session(&"x".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1)), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let unicode = registry.register_session("세션-opaque-✓")?; + assert!(unicode.value() > 0); + Ok(()) +} + +#[test] +fn authority_identifier_capacity_is_bounded_and_testable() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let session = registry.register_session("session-one")?; + assert_eq!( + registry.register_session("session-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let context = registry.register_context(session, "context-one")?; + assert_eq!( + registry.register_context(session, "context-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let origin = loopback_origin()?; + assert!( + registry + .bind_node(session, context, &origin, "node-one") + .is_ok() + ); + assert_eq!( + registry.bind_node(session, context, &origin, "node-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + Ok(()) +} + +#[test] +fn unknown_internal_authority_is_rejected_before_node_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let unknown = BrowserSessionId::new(999)?; + + assert_eq!( + registry.register_context(unknown, "context"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let known = registry.register_session("known-session")?; + let context = registry.register_context(known, "known-context")?; + let origin = loopback_origin()?; + assert_eq!( + registry.bind_node(unknown, context, &origin, "node"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + Ok(()) +} + +#[test] +fn registry_revalidates_live_node_authority_and_rejects_forged_or_retired_handles() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let other = registry.register_session("other-session")?; + let context = registry.register_context(owner, "top-level-context")?; + let origin = loopback_origin()?; + let live = registry.bind_node(owner, context, &origin, "backend-node-17")?; + + assert_eq!(registry.validate_node_handle(&live), Ok(())); + + let forged_node = ObservedNodeHandle::new( + owner, + context, + origin.clone(), + live.document_epoch(), + live.node_id() + 1, + )?; + assert_eq!( + registry.validate_node_handle(&forged_node), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let wrong_session = ObservedNodeHandle::new( + other, + context, + origin.clone(), + live.document_epoch(), + live.node_id(), + )?; + assert_eq!( + registry.validate_node_handle(&wrong_session), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let unbound_context = registry.register_context(owner, "unbound-context")?; + let synthetic_unbound = ObservedNodeHandle::new( + owner, + unbound_context, + origin.clone(), + DocumentEpoch::new(1)?, + 777, + )?; + assert_eq!( + registry.validate_node_handle(&synthetic_unbound), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let next_epoch = registry.advance_document(context)?; + assert_eq!(next_epoch.value(), 2); + assert_eq!( + registry.validate_node_handle(&live), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let replacement = registry.bind_node(owner, context, &origin, "backend-node-17")?; + assert_eq!(registry.validate_node_handle(&replacement), Ok(())); + + registry.remove_context(context)?; + assert_eq!( + registry.validate_node_handle(&replacement), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + let session_context = registry.register_context(owner, "session-retirement")?; + let session_handle = registry.bind_node(owner, session_context, &origin, "session-node")?; + registry.remove_session(owner)?; + assert_eq!( + registry.validate_node_handle(&session_handle), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_protocol_adapter.rs b/crates/originweave-core/tests/browser_protocol_adapter.rs new file mode 100644 index 000000000..3e369bfc6 --- /dev/null +++ b/crates/originweave-core/tests/browser_protocol_adapter.rs @@ -0,0 +1,240 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolDescriptorError, + BrowserProtocolKind, MAX_BROWSER_PROTOCOL_METADATA_BYTES, +}; + +const BIDI_ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const BIDI_PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const CDP_ADAPTER_VERSION: &str = "originweave-cdp-v1"; +const CDP_PROTOCOL_REVISION: &str = "cdp-browser-r1639810"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +#[test] +fn webdriver_bidi_descriptor_is_explicit_and_capability_bounded() -> Result<(), Box> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[ + BrowserProtocolCapability::Navigation, + BrowserProtocolCapability::SemanticObservation, + BrowserProtocolCapability::TypedInput, + ], + )?; + + assert_eq!(descriptor.kind(), BrowserProtocolKind::WebDriverBiDi); + assert_eq!(descriptor.adapter_version(), BIDI_ADAPTER_VERSION); + assert_eq!(descriptor.protocol_revision(), BIDI_PROTOCOL_REVISION); + assert_eq!(descriptor.browser_revision(), BROWSER_REVISION); + assert_eq!(descriptor.capability_count(), 3); + assert!(descriptor.supports(BrowserProtocolCapability::Navigation)); + assert!(descriptor.supports(BrowserProtocolCapability::SemanticObservation)); + assert!(descriptor.supports(BrowserProtocolCapability::TypedInput)); + assert!(!descriptor.supports(BrowserProtocolCapability::NetworkObservation)); + Ok(()) +} + +#[test] +fn cdp_capability_is_not_inferred_from_protocol_kind() -> Result<(), Box> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::ChromeDevToolsProtocol, + CDP_ADAPTER_VERSION, + CDP_PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::NetworkObservation], + )?; + + assert_eq!(descriptor.protocol_revision(), CDP_PROTOCOL_REVISION); + assert!(descriptor.supports(BrowserProtocolCapability::NetworkObservation)); + assert!(!descriptor.supports(BrowserProtocolCapability::Navigation)); + assert!(!descriptor.supports(BrowserProtocolCapability::SemanticObservation)); + assert!(!descriptor.supports(BrowserProtocolCapability::TypedInput)); + Ok(()) +} + +#[test] +fn malformed_or_ambiguous_metadata_fails_closed() { + let valid_capabilities = [BrowserProtocolCapability::Navigation]; + let invalid_adapter_versions = ["", " ", "bidi version", "bidi/version", "---", "비디"]; + for adapter_version in invalid_adapter_versions { + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + adapter_version, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidAdapterVersion) + ); + } + + let invalid_protocol_revisions = [ + "", + " ", + "webdriver bidi", + "webdriver/bidi", + "---", + "프로토콜", + ]; + for protocol_revision in invalid_protocol_revisions { + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + protocol_revision, + BROWSER_REVISION, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidProtocolRevision) + ); + } + + let invalid_browser_revisions = [ + "", + " ", + "chromium revision", + "chromium/revision", + "---", + "크로미움", + ]; + for browser_revision in invalid_browser_revisions { + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + browser_revision, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidBrowserRevision) + ); + } + + let oversized = "a".repeat(MAX_BROWSER_PROTOCOL_METADATA_BYTES + 1); + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + &oversized, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidAdapterVersion) + ); + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + &oversized, + BROWSER_REVISION, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidProtocolRevision) + ); + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + &oversized, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidBrowserRevision) + ); +} + +#[test] +fn capability_set_must_be_nonempty_and_canonical() { + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[], + ), + Err(BrowserProtocolDescriptorError::EmptyCapabilities) + ); + + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[ + BrowserProtocolCapability::Navigation, + BrowserProtocolCapability::Navigation, + ], + ), + Err(BrowserProtocolDescriptorError::DuplicateCapability) + ); +} + +#[test] +fn capability_order_does_not_change_descriptor_identity() -> Result<(), Box> { + let forward = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[ + BrowserProtocolCapability::Navigation, + BrowserProtocolCapability::SemanticObservation, + BrowserProtocolCapability::TypedInput, + BrowserProtocolCapability::NetworkObservation, + ], + )?; + let reverse = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[ + BrowserProtocolCapability::NetworkObservation, + BrowserProtocolCapability::TypedInput, + BrowserProtocolCapability::SemanticObservation, + BrowserProtocolCapability::Navigation, + ], + )?; + + assert_eq!(forward, reverse); + Ok(()) +} + +#[test] +fn descriptor_errors_are_stable_and_source_free() { + let cases = [ + ( + BrowserProtocolDescriptorError::InvalidAdapterVersion, + "browser protocol adapter version must be a bounded ASCII metadata token", + ), + ( + BrowserProtocolDescriptorError::InvalidProtocolRevision, + "browser protocol revision must be a bounded ASCII metadata token", + ), + ( + BrowserProtocolDescriptorError::InvalidBrowserRevision, + "browser revision must be a bounded ASCII metadata token", + ), + ( + BrowserProtocolDescriptorError::EmptyCapabilities, + "browser protocol adapter must declare at least one capability", + ), + ( + BrowserProtocolDescriptorError::DuplicateCapability, + "browser protocol adapter capabilities must be unique", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/browser_registry_cross_instance.rs b/crates/originweave-core/tests/browser_registry_cross_instance.rs new file mode 100644 index 000000000..c38fcf227 --- /dev/null +++ b/crates/originweave-core/tests/browser_registry_cross_instance.rs @@ -0,0 +1,88 @@ +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, ObservedNodeHandle, Origin, +}; + +#[test] +fn node_handles_cannot_cross_registry_instances_when_numeric_ids_collide() +-> Result<(), Box> { + let origin = Origin::parse("http://127.0.0.1:43127")?; + + let mut first_registry = BrowserAuthorityRegistry::new(); + let first_session = first_registry.register_session("first-session")?; + let first_context = first_registry.register_context(first_session, "first-context")?; + let first_handle = + first_registry.bind_node(first_session, first_context, &origin, "first-node")?; + + let mut second_registry = BrowserAuthorityRegistry::new(); + let second_session = second_registry.register_session("second-session")?; + let second_context = second_registry.register_context(second_session, "second-context")?; + let second_handle = + second_registry.bind_node(second_session, second_context, &origin, "second-node")?; + let forged_matching = ObservedNodeHandle::new( + second_session, + second_context, + origin.clone(), + second_handle.document_epoch(), + second_handle.node_id(), + )?; + + assert_eq!(first_session, second_session); + assert_eq!(first_context, second_context); + assert_eq!(first_handle.node_id(), second_handle.node_id()); + assert_ne!(first_handle, second_handle); + assert_eq!( + second_registry.validate_node_handle(&first_handle), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!( + second_registry.validate_node_handle(&forged_matching), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!(second_registry.validate_node_handle(&second_handle), Ok(())); + Ok(()) +} + +#[test] +fn unissued_handles_do_not_reveal_registered_session_membership() -> Result<(), Box> { + let origin = Origin::parse("http://127.0.0.1:43128")?; + + let mut issuing_registry = BrowserAuthorityRegistry::new(); + let known_numeric_session = issuing_registry.register_session("issuing-session")?; + let unknown_numeric_session = issuing_registry.register_session("issuing-extra-session")?; + + let mut target_registry = BrowserAuthorityRegistry::new(); + let target_session = target_registry.register_session("target-session")?; + let target_context = target_registry.register_context(target_session, "target-context")?; + let target_handle = + target_registry.bind_node(target_session, target_context, &origin, "target-node")?; + + assert_eq!(known_numeric_session, target_session); + assert_ne!(unknown_numeric_session, target_session); + + let forged_known_session = ObservedNodeHandle::new( + known_numeric_session, + target_context, + origin.clone(), + target_handle.document_epoch(), + target_handle.node_id(), + )?; + let forged_unknown_session = ObservedNodeHandle::new( + unknown_numeric_session, + target_context, + origin, + target_handle.document_epoch(), + target_handle.node_id(), + )?; + + assert_eq!( + target_registry.validate_node_handle(&forged_known_session), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!( + target_registry.validate_node_handle(&forged_unknown_session), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/extension_authority.rs b/crates/originweave-core/tests/extension_authority.rs index f34c30e9b..1ddeb5383 100644 --- a/crates/originweave-core/tests/extension_authority.rs +++ b/crates/originweave-core/tests/extension_authority.rs @@ -1,14 +1,19 @@ #![allow(clippy::expect_used)] use originweave_core::{ - BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest, - ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, evaluate_extension_access, + AgentTaskId, BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, + evaluate_extension_access, }; fn extension_id(value: &str) -> ExtensionId { ExtensionId::parse(value).expect("valid extension id") } +fn task(value: u64) -> AgentTaskId { + AgentTaskId::new(value).expect("nonzero Agent Task identity") +} + fn session(value: u64) -> BrowserSessionId { BrowserSessionId::new(value).expect("nonzero browser session") } @@ -23,6 +28,7 @@ fn origin(value: &str) -> Origin { const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; +const AGENT_TASK_ID: u64 = 29; #[test] fn extension_id_accepts_only_canonical_chromium_extension_ids() { @@ -53,6 +59,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let granted_origin = origin("https://app.example"); let grant = ExtensionAgentGrant::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(7), context(11), granted_origin.clone(), @@ -62,6 +69,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let exact = ExtensionAccessRequest::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(7), context(11), granted_origin.clone(), @@ -78,6 +86,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_extension = ExtensionAccessRequest::new( other_extension, + task(AGENT_TASK_ID), session(7), context(11), granted_origin.clone(), @@ -91,6 +100,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_session = ExtensionAccessRequest::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(8), context(11), granted_origin.clone(), @@ -104,6 +114,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_context = ExtensionAccessRequest::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(7), context(12), granted_origin.clone(), @@ -117,6 +128,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_origin = ExtensionAccessRequest::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(7), context(11), origin("https://other.example"), @@ -130,6 +142,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_port = ExtensionAccessRequest::new( allowed_extension, + task(AGENT_TASK_ID), session(7), context(11), origin("https://app.example:8443"), @@ -148,6 +161,7 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { let granted_origin = origin("https://mail.example"); let grant = ExtensionAgentGrant::new( id.clone(), + task(AGENT_TASK_ID), session(3), context(5), granted_origin.clone(), @@ -157,6 +171,7 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { let propose_action = ExtensionAccessRequest::new( id, + task(AGENT_TASK_ID), session(3), context(5), granted_origin, @@ -175,6 +190,7 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { let granted_origin = origin("http://127.0.0.1:8080"); let grant = ExtensionAgentGrant::new( id.clone(), + task(AGENT_TASK_ID), session(13), context(17), granted_origin.clone(), @@ -191,6 +207,7 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { ] { let request = ExtensionAccessRequest::new( id.clone(), + task(AGENT_TASK_ID), session(13), context(17), granted_origin.clone(), @@ -211,6 +228,7 @@ fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { let expires_at_epoch_seconds = 1_700_000_100; let grant = ExtensionAgentGrant::new( id.clone(), + task(AGENT_TASK_ID), session(19), context(23), granted_origin.clone(), @@ -220,6 +238,7 @@ fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { let before_deadline = ExtensionAccessRequest::new( id.clone(), + task(AGENT_TASK_ID), session(19), context(23), granted_origin.clone(), @@ -233,6 +252,7 @@ fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { let at_deadline = ExtensionAccessRequest::new( id.clone(), + task(AGENT_TASK_ID), session(19), context(23), granted_origin.clone(), @@ -246,6 +266,7 @@ fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { let after_deadline = ExtensionAccessRequest::new( id, + task(AGENT_TASK_ID), session(19), context(23), granted_origin, diff --git a/crates/originweave-core/tests/extension_task_authority.rs b/crates/originweave-core/tests/extension_task_authority.rs new file mode 100644 index 000000000..ef21c2f1f --- /dev/null +++ b/crates/originweave-core/tests/extension_task_authority.rs @@ -0,0 +1,83 @@ +#![allow(clippy::expect_used)] + +use originweave_core::{ + AgentTaskId, AgentTaskIdError, BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, + evaluate_extension_access, +}; + +fn extension_id(value: &str) -> ExtensionId { + ExtensionId::parse(value).expect("valid extension id") +} + +fn task(value: u64) -> AgentTaskId { + AgentTaskId::new(value).expect("nonzero Agent Task identity") +} + +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(value: &str) -> Origin { + Origin::parse(value).expect("canonical origin") +} + +#[test] +fn agent_task_identity_rejects_zero_with_standard_error_contract() { + assert_eq!( + AgentTaskId::new(0), + Err(AgentTaskIdError::InvalidAgentTaskId) + ); + assert_eq!(task(29).value(), 29); + + let error = AgentTaskIdError::InvalidAgentTaskId; + assert_eq!(error.to_string(), "Agent Task identifier must be nonzero"); + assert!(std::error::Error::source(&error).is_none()); +} + +#[test] +fn extension_agent_grants_are_non_transferable_between_agent_tasks() { + let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("https://agent.example"); + let grant = ExtensionAgentGrant::new( + id.clone(), + task(29), + session(7), + context(11), + granted_origin.clone(), + 1_700_000_600, + [ExtensionAgentCapability::ProposeTypedAction], + ); + + let exact_task = ExtensionAccessRequest::new( + id.clone(), + task(29), + session(7), + context(11), + granted_origin.clone(), + 1_700_000_000, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&exact_task, Some(&grant)), + ExtensionAccessDecision::Allow + ); + + let other_task = ExtensionAccessRequest::new( + id, + task(30), + session(7), + context(11), + granted_origin, + 1_700_000_000, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&other_task, Some(&grant)), + ExtensionAccessDecision::DenyAgentTaskMismatch + ); +} diff --git a/crates/originweave-core/tests/node_retirement.rs b/crates/originweave-core/tests/node_retirement.rs new file mode 100644 index 000000000..56c4d2132 --- /dev/null +++ b/crates/originweave-core/tests/node_retirement.rs @@ -0,0 +1,39 @@ +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, ObservedNodeHandle, Origin, +}; + +#[test] +fn same_document_node_retirement_revokes_authority_without_reusing_identity() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = Origin::parse("http://127.0.0.1:43127")?; + let live = registry.bind_node(session, context, &origin, "backend-node-17")?; + + let different_observation = ObservedNodeHandle::new( + session, + context, + origin.clone(), + live.document_epoch(), + live.node_id() + 1, + )?; + assert_ne!(live, different_observation); + + registry.remove_node(&live)?; + assert_eq!( + registry.validate_node_handle(&live), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!( + registry.remove_node(&live), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let rebound = registry.bind_node(session, context, &origin, "backend-node-17")?; + assert_ne!(live.node_id(), rebound.node_id()); + assert_eq!(registry.validate_node_handle(&rebound), Ok(())); + Ok(()) +} diff --git a/crates/originweave-core/tests/public_error_contract.rs b/crates/originweave-core/tests/public_error_contract.rs new file mode 100644 index 000000000..a0e8fac2e --- /dev/null +++ b/crates/originweave-core/tests/public_error_contract.rs @@ -0,0 +1,60 @@ +use std::error::Error; + +use originweave_core::{ActionIntentDigestError, ExtensionIdError, OriginError}; + +fn assert_standard_error() {} + +#[test] +fn public_validation_errors_are_standard_errors() { + assert_standard_error::(); + assert_standard_error::(); + assert_standard_error::(); +} + +#[test] +fn public_validation_errors_have_stable_operator_messages() { + assert_eq!( + OriginError::MissingScheme.to_string(), + "origin must include an explicit scheme" + ); + assert_eq!( + OriginError::UnsupportedScheme.to_string(), + "origin scheme must be HTTPS or loopback HTTP" + ); + assert_eq!( + OriginError::InsecureRemoteOrigin.to_string(), + "remote HTTP origins are not permitted" + ); + assert_eq!( + OriginError::MissingAuthority.to_string(), + "origin authority must not be empty" + ); + assert_eq!( + OriginError::UserInfoNotAllowed.to_string(), + "origin authority must not contain user information" + ); + assert_eq!( + OriginError::PathNotAllowed.to_string(), + "origin must not contain a path, query, or fragment" + ); + assert_eq!( + OriginError::InvalidAuthority.to_string(), + "origin authority is malformed or ambiguous" + ); + assert_eq!( + OriginError::AmbiguousNumericHost.to_string(), + "origin host uses a browser-ambiguous numeric address spelling" + ); + assert_eq!( + OriginError::InvalidPort.to_string(), + "origin port must be a numeric value from 1 through 65535" + ); + assert_eq!( + ActionIntentDigestError::InvalidFormat.to_string(), + "action intent digest must be sha256: followed by 64 lowercase hexadecimal digits" + ); + assert_eq!( + ExtensionIdError::InvalidExtensionId.to_string(), + "extension identifier must be 32 lowercase characters from a through p" + ); +} diff --git a/crates/originweave-core/tests/security_review.rs b/crates/originweave-core/tests/security_review.rs index ec9dc0abd..e3676b77c 100644 --- a/crates/originweave-core/tests/security_review.rs +++ b/crates/originweave-core/tests/security_review.rs @@ -17,6 +17,9 @@ fn origin_rejects_browser_special_numeric_hosts() { "https://0177.0.0.1", "https://1.2.3.04", "https://example.127", + "https://0x", + "https://1.2.3.0x", + "https://example.0X", ] { assert_eq!( Origin::parse(input), @@ -31,18 +34,6 @@ fn origin_rejects_browser_special_numeric_hosts() { .as_str(), "https://127.0.0.1" ); - assert_eq!( - Origin::parse("https://0x") - .expect("an empty hexadecimal suffix is a DNS label, not an IPv4 number") - .as_str(), - "https://0x" - ); - assert_eq!( - Origin::parse("https://1.2.3.0x") - .expect("an empty hexadecimal final label remains a DNS authority") - .as_str(), - "https://1.2.3.0x" - ); assert_eq!( Origin::parse("https://0xg") .expect("a non-hexadecimal suffix is a DNS label, not an IPv4 number") diff --git a/crates/originweave-policy/tests/extension_mutation_isolation.rs b/crates/originweave-policy/tests/extension_mutation_isolation.rs index 48d7936e1..b9c55b278 100644 --- a/crates/originweave-policy/tests/extension_mutation_isolation.rs +++ b/crates/originweave-policy/tests/extension_mutation_isolation.rs @@ -4,14 +4,14 @@ //! //! OriginWeave does not yet implement an adapter that converts an extension proposal into an //! [`ActionRequest`]. These regressions therefore prove two independent fail-closed boundaries: -//! the exact extension/session/context/origin/unexpired grant permits only `ProposeTypedAction`, -//! while an ordinary user-sourced action request remains subject to the core policy decision -//! shown in each test. +//! the exact extension/task/session/context/origin/unexpired grant permits only +//! `ProposeTypedAction`, while an ordinary user-sourced action request remains subject to the +//! core policy decision shown in each test. use std::collections::BTreeSet; use originweave_core::{ - ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + ActionIntentDigest, ActionKind, ActionRequest, AgentTaskId, ApprovalEvidence, BrowserSessionId, BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, @@ -29,6 +29,10 @@ fn extension_id() -> ExtensionId { ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") } +fn agent_task() -> AgentTaskId { + AgentTaskId::new(13).expect("nonzero agent task") +} + fn browser_session() -> BrowserSessionId { BrowserSessionId::new(17).expect("nonzero browser session") } @@ -48,6 +52,7 @@ fn intent() -> ActionIntentDigest { fn action_proposal_grant() -> ExtensionAgentGrant { ExtensionAgentGrant::new( extension_id(), + agent_task(), browser_session(), browsing_context(), origin(EXTENSION_ORIGIN), @@ -59,6 +64,7 @@ fn action_proposal_grant() -> ExtensionAgentGrant { fn assert_proposal_grant_is_independently_allowed(grant: &ExtensionAgentGrant) { let request = ExtensionAccessRequest::new( extension_id(), + agent_task(), browser_session(), browsing_context(), origin(EXTENSION_ORIGIN), diff --git a/crates/originweave-policy/tests/extension_policy_isolation.rs b/crates/originweave-policy/tests/extension_policy_isolation.rs index f32d8733c..11986b1f9 100644 --- a/crates/originweave-policy/tests/extension_policy_isolation.rs +++ b/crates/originweave-policy/tests/extension_policy_isolation.rs @@ -3,7 +3,7 @@ use std::collections::BTreeSet; use originweave_core::{ - ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + ActionIntentDigest, ActionKind, ActionRequest, AgentTaskId, ApprovalEvidence, BrowserSessionId, BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, @@ -21,6 +21,10 @@ fn extension_id() -> ExtensionId { ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") } +fn agent_task() -> AgentTaskId { + AgentTaskId::new(5).expect("nonzero agent task") +} + fn browser_session() -> BrowserSessionId { BrowserSessionId::new(7).expect("nonzero browser session") } @@ -40,6 +44,7 @@ fn intent() -> ActionIntentDigest { fn action_proposal_grant() -> ExtensionAgentGrant { ExtensionAgentGrant::new( extension_id(), + agent_task(), browser_session(), browsing_context(), origin(EXTENSION_ORIGIN), @@ -51,6 +56,7 @@ fn action_proposal_grant() -> ExtensionAgentGrant { fn assert_extension_can_only_propose(grant: &ExtensionAgentGrant) { let request = ExtensionAccessRequest::new( extension_id(), + agent_task(), browser_session(), browsing_context(), origin(EXTENSION_ORIGIN), diff --git a/crates/originweave-policy/tests/extension_secret_isolation.rs b/crates/originweave-policy/tests/extension_secret_isolation.rs index f808bec04..86d04df4b 100644 --- a/crates/originweave-policy/tests/extension_secret_isolation.rs +++ b/crates/originweave-policy/tests/extension_secret_isolation.rs @@ -3,7 +3,7 @@ use std::collections::BTreeSet; use originweave_core::{ - ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + ActionIntentDigest, ActionKind, ActionRequest, AgentTaskId, ApprovalEvidence, BrowserSessionId, BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, InstructionSource, Origin, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, @@ -20,6 +20,10 @@ fn extension_id() -> ExtensionId { ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") } +fn agent_task() -> AgentTaskId { + AgentTaskId::new(5).expect("nonzero agent task") +} + fn browser_session() -> BrowserSessionId { BrowserSessionId::new(7).expect("nonzero browser session") } @@ -39,6 +43,7 @@ fn intent() -> ActionIntentDigest { fn action_proposal_grant() -> ExtensionAgentGrant { ExtensionAgentGrant::new( extension_id(), + agent_task(), browser_session(), browsing_context(), origin(), @@ -50,6 +55,7 @@ fn action_proposal_grant() -> ExtensionAgentGrant { fn assert_extension_can_propose(grant: &ExtensionAgentGrant) { let request = ExtensionAccessRequest::new( extension_id(), + agent_task(), browser_session(), browsing_context(), origin(), diff --git a/docs/adr/0013-manifest-v3-extension-authority.md b/docs/adr/0013-manifest-v3-extension-authority.md index 8feacbf27..a05768a9d 100644 --- a/docs/adr/0013-manifest-v3-extension-authority.md +++ b/docs/adr/0013-manifest-v3-extension-authority.md @@ -92,7 +92,7 @@ No persistent database migration is introduced. A release can roll back the Chro ## Open follow-ups -- Complete issue #27's compatibility matrix and production isolation acceptance. Exclusive trusted-time expiry on origin-bound `ExtensionAgentGrant` evaluation is the next protected-main candidate; task identity binding remains open. +- Complete issue #27's compatibility matrix and production isolation acceptance. Exact Agent Task identity binding, canonical-origin binding, and exclusive trusted-time expiry are implemented on active PR #40 and remain active-PR evidence until protected integration. - Define managed-extension identity/update semantics. - Implement the native-messaging allow-list/process boundary before claiming support. - Integrate the complete Agent Task browser vertical slice under issue #28. @@ -105,4 +105,12 @@ Supersede this ADR if Chromium adopts a materially different extension authority ## References -Primary browser/extension/protocol evidence and APA 7 references are maintained in [`../doctoring/browser-agent-protocols.md`](../doctoring/browser-agent-protocols.md) and [`../doctoring.md`](../doctoring.md). Related decisions include ADR 0001, ADR 0002, ADR 0007, ADR 0010, ADR 0101, ADR 0104, and ADR 0107. \ No newline at end of file +The following primary Chrome extension documentation directly supports this ADR's service-worker execution plane, untrusted-message boundary, and separately bounded native-messaging decision: + +Google Chrome. (2023, May 2). *Extension service worker basics*. Chrome for Developers. https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/basics + +Google Chrome. (2023, February 27). *Native messaging*. Chrome for Developers. https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging + +Google Chrome. (2025, December 3). *Message passing*. Chrome for Developers. https://developer.chrome.com/docs/extensions/develop/concepts/messaging + +Additional primary browser/extension/protocol evidence and APA 7 references are maintained in [`../doctoring/browser-agent-protocols.md`](../doctoring/browser-agent-protocols.md) and [`../doctoring.md`](../doctoring.md). Related decisions include ADR 0001, ADR 0002, ADR 0007, ADR 0010, ADR 0101, ADR 0104, and ADR 0107.