diff --git a/CHANGELOG.md b/CHANGELOG.md index f747adeae..3fc5651f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ All notable changes to OriginWeave are documented in this file. The format follo - Refreshed the product and technical gap baseline with the current open-PR inventory and exact base/head evidence for the newest Chromium, BAP, extraction, WARC, and idempotency slices. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. +- Added bounded native-messaging host-name identity and explicit exact extension-to-host grants so Chrome native-messaging permission or a manifest host name cannot implicitly become OriginWeave Agent authority. +- Bounded Chrome native-messaging framing with native-endian 32-bit byte lengths, direction-specific 1 MiB host-to-browser and 64 MiB browser-to-host payload ceilings, bounded 64 KiB stream reads that avoid allocating a declared maximum before bytes arrive, fail-closed rejection of oversized, truncated, or trailing frame data, and explicit UTF-8 payload validation before framed bytes can be treated as native-messaging text, without granting Agent authority or claiming JSON trust. +- Pinned the native-messaging compatibility evidence to an immutable Chromium revision and connected its framing, resource-ceiling, and non-authority decisions to the standards doctoring record. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. @@ -102,4 +105,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/Cargo.lock b/Cargo.lock index 848cb7320..c5cbc5057 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -274,6 +274,13 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "originweave-extension" +version = "0.1.0" +dependencies = [ + "originweave-core", +] + [[package]] name = "originweave-destination" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0d5ab469c..7c7a78e49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/originweave-core", + "crates/originweave-extension", "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-resource", diff --git a/crates/originweave-extension/Cargo.toml b/crates/originweave-extension/Cargo.toml new file mode 100644 index 000000000..65b68bac5 --- /dev/null +++ b/crates/originweave-extension/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "originweave-extension" +description = "OriginWeave Extension Policy bounded context and Chromium extension adapter contracts." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +publish = false + +[lib] +path = "src/lib.rs" + +[dependencies] +originweave-core = { path = "../originweave-core" } + +[lints] +workspace = true diff --git a/crates/originweave-extension/src/lib.rs b/crates/originweave-extension/src/lib.rs new file mode 100644 index 000000000..000e93c79 --- /dev/null +++ b/crates/originweave-extension/src/lib.rs @@ -0,0 +1,13 @@ +//! Extension Policy bounded context for Chromium-compatible extension integration. +//! +//! This crate owns Chrome-specific extension adapter vocabulary and authority checks. +//! Stable browser/session/action contracts remain in `originweave-core`; this context +//! depends inward on those contracts without exporting Chromium adapter types back into core. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use originweave_core::ExtensionId; + +mod native_messaging; +pub use native_messaging::*; diff --git a/crates/originweave-extension/src/native_messaging.rs b/crates/originweave-extension/src/native_messaging.rs new file mode 100644 index 000000000..5c8823842 --- /dev/null +++ b/crates/originweave-extension/src/native_messaging.rs @@ -0,0 +1,331 @@ +//! Explicit Chrome native-messaging host authority without ambient Agent authority. + +use std::fmt; +use std::io::Read; + +use crate::ExtensionId; + +const MAX_NATIVE_MESSAGING_HOST_NAME_BYTES: usize = 256; +const HOST_TO_BROWSER_NATIVE_MESSAGING_LIMIT: usize = 1_048_576; +const BROWSER_TO_HOST_NATIVE_MESSAGING_LIMIT: usize = 67_108_864; +const NATIVE_MESSAGING_READ_CHUNK_BYTES: usize = 64 * 1024; + +/// A canonical Chrome native-messaging host name admitted to OriginWeave policy. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct NativeMessagingHostName { + canonical: String, +} + +impl NativeMessagingHostName { + /// Parse the host name syntax accepted by Chrome native-messaging manifests. + /// + /// Host names are exact identities rather than display labels: only lowercase + /// ASCII alphanumeric characters, underscores, and dots are accepted. Dots + /// cannot lead, trail, or appear consecutively. + pub fn parse(input: &str) -> Result { + if input.is_empty() + || input.len() > MAX_NATIVE_MESSAGING_HOST_NAME_BYTES + || input.starts_with('.') + || input.ends_with('.') + || input.contains("..") + || !input.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'.' + }) + { + return Err(NativeMessagingHostNameError::InvalidHostName); + } + Ok(Self { + canonical: input.to_owned(), + }) + } + + /// Return the validated native-messaging host name. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } +} + +/// A validation error for a Chrome native-messaging host name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeMessagingHostNameError { + /// The value violated Chrome's native-messaging host-name syntax. + InvalidHostName, +} + +impl fmt::Display for NativeMessagingHostNameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidHostName => formatter.write_str( + "native-messaging host name violates the reviewed Chrome identity syntax", + ), + } + } +} + +impl std::error::Error for NativeMessagingHostNameError {} + +/// One explicit host-managed allow-list entry for a Chromium extension. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeMessagingHostGrant { + extension_id: ExtensionId, + host_name: NativeMessagingHostName, +} + +impl NativeMessagingHostGrant { + /// Build one exact extension-to-native-host allow-list entry. + #[must_use] + pub const fn new(extension_id: ExtensionId, host_name: NativeMessagingHostName) -> Self { + Self { + extension_id, + host_name, + } + } + + /// Return the extension identity granted native-messaging access. + #[must_use] + pub const fn extension_id(&self) -> &ExtensionId { + &self.extension_id + } + + /// Return the exact native-messaging host identity in this grant. + #[must_use] + pub const fn host_name(&self) -> &NativeMessagingHostName { + &self.host_name + } +} + +/// One extension request to connect to an exact native-messaging host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeMessagingAccessRequest { + extension_id: ExtensionId, + host_name: NativeMessagingHostName, +} + +impl NativeMessagingAccessRequest { + /// Build one native-messaging access request without granting process authority. + #[must_use] + pub const fn new(extension_id: ExtensionId, host_name: NativeMessagingHostName) -> Self { + Self { + extension_id, + host_name, + } + } + + /// Return the extension identity requesting native-messaging access. + #[must_use] + pub const fn extension_id(&self) -> &ExtensionId { + &self.extension_id + } + + /// Return the exact native-messaging host identity requested. + #[must_use] + pub const fn host_name(&self) -> &NativeMessagingHostName { + &self.host_name + } +} + +/// Result of evaluating native-messaging access against one explicit host grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeMessagingAccessDecision { + /// The exact extension identity and native host name are explicitly granted. + Allow, + /// No explicit host-managed native-messaging grant was supplied. + DenyMissingGrant, + /// The request belongs to a different extension identity. + DenyExtensionMismatch, + /// The request names a different native-messaging host. + DenyHostMismatch, +} + +/// Evaluate one exact native-messaging request without minting Agent authority. +/// +/// This deterministic primitive models one entry in the native host's explicit +/// extension allow-list. It deliberately does not launch a process, resolve a +/// host path, parse messages, or convert Chrome's `nativeMessaging` permission +/// into an OriginWeave Agent capability. Those remain separate adapter and policy +/// boundaries. +#[must_use] +pub fn evaluate_native_messaging_access( + request: &NativeMessagingAccessRequest, + grant: Option<&NativeMessagingHostGrant>, +) -> NativeMessagingAccessDecision { + let Some(grant) = grant else { + return NativeMessagingAccessDecision::DenyMissingGrant; + }; + if request.extension_id != grant.extension_id { + return NativeMessagingAccessDecision::DenyExtensionMismatch; + } + if request.host_name != grant.host_name { + return NativeMessagingAccessDecision::DenyHostMismatch; + } + NativeMessagingAccessDecision::Allow +} + +/// Direction of one Chrome native-messaging frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeMessagingFrameDirection { + /// A frame written by a native host for delivery to the browser. + HostToBrowser, + /// A frame written by the browser for delivery to a native host. + BrowserToHost, +} + +/// Failure to encode or decode a bounded Chrome native-messaging frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeMessagingFrameError { + /// Fewer than four bytes were available for the native-endian length prefix. + MissingLengthPrefix, + /// The advertised or supplied payload exceeds the limit for its direction. + PayloadTooLarge, + /// The complete frame length differs from the advertised payload length. + LengthMismatch, + /// The framed payload is not valid UTF-8 text. + InvalidUtf8Payload, +} + +impl fmt::Display for NativeMessagingFrameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingLengthPrefix => { + formatter.write_str("native messaging frame is missing its 32-bit length prefix") + } + Self::PayloadTooLarge => { + formatter.write_str("native messaging payload exceeds the direction-specific limit") + } + Self::LengthMismatch => { + formatter.write_str("native messaging frame length does not match its prefix") + } + Self::InvalidUtf8Payload => { + formatter.write_str("native messaging payload is not valid UTF-8") + } + } + } +} + +impl std::error::Error for NativeMessagingFrameError {} + +/// Failure while reading one bounded native-messaging payload from a stream. +#[derive(Debug)] +pub enum NativeMessagingFrameReadError { + /// Framing policy rejected the advertised payload before payload allocation or I/O. + Frame(NativeMessagingFrameError), + /// The underlying stream failed while reading the prefix or the admitted payload. + Io(std::io::Error), +} + +impl fmt::Display for NativeMessagingFrameReadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Frame(error) => error.fmt(formatter), + Self::Io(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for NativeMessagingFrameReadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Frame(error) => Some(error), + Self::Io(error) => Some(error), + } + } +} + +/// Return OriginWeave's reviewed payload ceiling for one native-messaging direction. +#[must_use] +pub const fn native_messaging_payload_limit(direction: NativeMessagingFrameDirection) -> usize { + match direction { + NativeMessagingFrameDirection::HostToBrowser => HOST_TO_BROWSER_NATIVE_MESSAGING_LIMIT, + NativeMessagingFrameDirection::BrowserToHost => BROWSER_TO_HOST_NATIVE_MESSAGING_LIMIT, + } +} + +/// Read one native-messaging payload from a stream after enforcing the direction-specific budget. +/// +/// The four-byte native-endian length prefix is read first. An oversized advertised length is +/// rejected before allocating or reading payload bytes. Stream failures retain their original +/// `std::io::Error` as a causal source. The returned bytes are untrusted framing output only; +/// JSON parsing, provenance, process identity, secrets, and Agent authority remain separate +/// fail-closed boundaries. +pub fn read_native_messaging_payload( + direction: NativeMessagingFrameDirection, + reader: &mut dyn Read, +) -> Result, NativeMessagingFrameReadError> { + let mut prefix = [0_u8; 4]; + reader + .read_exact(&mut prefix) + .map_err(NativeMessagingFrameReadError::Io)?; + + let advertised_length = u32::from_ne_bytes(prefix) as usize; + if advertised_length > native_messaging_payload_limit(direction) { + return Err(NativeMessagingFrameReadError::Frame( + NativeMessagingFrameError::PayloadTooLarge, + )); + } + + let mut payload = Vec::with_capacity(advertised_length.min(NATIVE_MESSAGING_READ_CHUNK_BYTES)); + while payload.len() < advertised_length { + let chunk_start = payload.len(); + let chunk_length = (advertised_length - chunk_start).min(NATIVE_MESSAGING_READ_CHUNK_BYTES); + payload.resize(chunk_start + chunk_length, 0); + reader + .read_exact(&mut payload[chunk_start..]) + .map_err(NativeMessagingFrameReadError::Io)?; + } + Ok(payload) +} + +/// Encode one complete native-messaging frame with a native-endian 32-bit length prefix. +/// +/// The payload is rejected before allocation when it exceeds the direction-specific +/// OriginWeave limit. The returned bytes are framing only and carry no trust or Agent authority. +pub fn encode_native_messaging_frame( + direction: NativeMessagingFrameDirection, + payload: &[u8], +) -> Result, NativeMessagingFrameError> { + if payload.len() > native_messaging_payload_limit(direction) { + return Err(NativeMessagingFrameError::PayloadTooLarge); + } + + let payload_length = payload.len() as u32; + let mut frame = Vec::with_capacity(payload.len() + 4); + frame.extend_from_slice(&payload_length.to_ne_bytes()); + frame.extend_from_slice(payload); + Ok(frame) +} + +/// Decode one complete bounded native-messaging frame without allocating its payload. +/// +/// Oversized advertised lengths are rejected before payload slicing. The frame must +/// contain exactly the advertised payload bytes; truncation and trailing data fail closed. +pub fn decode_native_messaging_frame( + direction: NativeMessagingFrameDirection, + frame: &[u8], +) -> Result<&[u8], NativeMessagingFrameError> { + if frame.len() < 4 { + return Err(NativeMessagingFrameError::MissingLengthPrefix); + } + + let advertised_length = u32::from_ne_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize; + if advertised_length > native_messaging_payload_limit(direction) { + return Err(NativeMessagingFrameError::PayloadTooLarge); + } + if frame.len() != advertised_length + 4 { + return Err(NativeMessagingFrameError::LengthMismatch); + } + + Ok(&frame[4..]) +} + +/// Decode one bounded native-messaging frame and validate its payload as UTF-8 text. +/// +/// This validates only framing and UTF-8 encoding. JSON syntax, message provenance, and +/// any Agent authority remain separate fail-closed boundaries for a later adapter. +pub fn decode_native_messaging_text_frame( + direction: NativeMessagingFrameDirection, + frame: &[u8], +) -> Result<&str, NativeMessagingFrameError> { + let payload = decode_native_messaging_frame(direction, frame)?; + std::str::from_utf8(payload).map_err(|_error| NativeMessagingFrameError::InvalidUtf8Payload) +} diff --git a/crates/originweave-extension/tests/native_messaging_authority.rs b/crates/originweave-extension/tests/native_messaging_authority.rs new file mode 100644 index 000000000..7c190595c --- /dev/null +++ b/crates/originweave-extension/tests/native_messaging_authority.rs @@ -0,0 +1,117 @@ +#![allow(clippy::expect_used)] + +use originweave_core::{ + BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest, + ExtensionAgentCapability, ExtensionId, Origin, evaluate_extension_access, +}; +use originweave_extension::{ + NativeMessagingAccessDecision, NativeMessagingAccessRequest, NativeMessagingHostGrant, + NativeMessagingHostName, evaluate_native_messaging_access, +}; + +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; + +fn extension_id(value: &str) -> ExtensionId { + ExtensionId::parse(value).expect("valid extension id") +} + +fn host_name(value: &str) -> NativeMessagingHostName { + NativeMessagingHostName::parse(value).expect("valid native messaging host name") +} + +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("valid test origin") +} + +#[test] +fn native_messaging_host_name_matches_chromium_manifest_syntax() { + let canonical = "com.contextualwisdom.originweave_host1"; + assert_eq!(host_name(canonical).as_str(), canonical); + + for invalid in [ + "", + ".com.contextualwisdom.originweave", + "com.contextualwisdom.originweave.", + "com..contextualwisdom.originweave", + "Com.contextualwisdom.originweave", + "com.contextual-wisdom.originweave", + "com/contextualwisdom/originweave", + "com.contextualwisdom.originweave\n", + "com.contextualwisdom.originweaveฯ€", + ] { + assert!( + NativeMessagingHostName::parse(invalid).is_err(), + "unexpected host name: {invalid:?}" + ); + } +} + +#[test] +fn native_messaging_requires_an_explicit_exact_extension_and_host_grant() { + let allowed_extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let other_extension = extension_id("bcdefghijklmnopabcdefghijklmnopa"); + let allowed_host = host_name("com.contextualwisdom.originweave"); + let other_host = host_name("com.contextualwisdom.other_host"); + let grant = NativeMessagingHostGrant::new(allowed_extension.clone(), allowed_host.clone()); + + assert_eq!(grant.extension_id(), &allowed_extension); + assert_eq!(grant.host_name(), &allowed_host); + + let exact = NativeMessagingAccessRequest::new(allowed_extension.clone(), allowed_host.clone()); + assert_eq!(exact.extension_id(), &allowed_extension); + assert_eq!(exact.host_name(), &allowed_host); + assert_eq!( + evaluate_native_messaging_access(&exact, Some(&grant)), + NativeMessagingAccessDecision::Allow + ); + assert_eq!( + evaluate_native_messaging_access(&exact, None), + NativeMessagingAccessDecision::DenyMissingGrant + ); + + let wrong_extension = NativeMessagingAccessRequest::new(other_extension, allowed_host); + assert_eq!( + evaluate_native_messaging_access(&wrong_extension, Some(&grant)), + NativeMessagingAccessDecision::DenyExtensionMismatch + ); + + let wrong_host = NativeMessagingAccessRequest::new(allowed_extension, other_host); + assert_eq!( + evaluate_native_messaging_access(&wrong_host, Some(&grant)), + NativeMessagingAccessDecision::DenyHostMismatch + ); +} + +#[test] +fn native_messaging_grant_does_not_mint_agent_capability() { + let extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let host = host_name("com.contextualwisdom.originweave"); + let native_grant = NativeMessagingHostGrant::new(extension.clone(), host.clone()); + let native_request = NativeMessagingAccessRequest::new(extension.clone(), host); + + assert_eq!( + evaluate_native_messaging_access(&native_request, Some(&native_grant)), + NativeMessagingAccessDecision::Allow + ); + + let agent_request = ExtensionAccessRequest::new( + extension, + session(23), + context(29), + origin("https://native-messaging.example"), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&agent_request, None), + ExtensionAccessDecision::DenyMissingGrant + ); +} diff --git a/crates/originweave-extension/tests/native_messaging_framing.rs b/crates/originweave-extension/tests/native_messaging_framing.rs new file mode 100644 index 000000000..da69e7446 --- /dev/null +++ b/crates/originweave-extension/tests/native_messaging_framing.rs @@ -0,0 +1,2 @@ +use originweave_extension as originweave_core; +include!("support/native_messaging_framing.rs"); diff --git a/crates/originweave-extension/tests/native_messaging_host_bounds.rs b/crates/originweave-extension/tests/native_messaging_host_bounds.rs new file mode 100644 index 000000000..933f2590c --- /dev/null +++ b/crates/originweave-extension/tests/native_messaging_host_bounds.rs @@ -0,0 +1,2 @@ +use originweave_extension as originweave_core; +include!("support/native_messaging_host_bounds.rs"); diff --git a/crates/originweave-extension/tests/support/native_messaging_framing.rs b/crates/originweave-extension/tests/support/native_messaging_framing.rs new file mode 100644 index 000000000..667ea9587 --- /dev/null +++ b/crates/originweave-extension/tests/support/native_messaging_framing.rs @@ -0,0 +1,270 @@ +use std::error::Error; +use std::io::{Cursor, ErrorKind, Read}; + +use originweave_core::{ + NativeMessagingFrameDirection, NativeMessagingFrameError, NativeMessagingFrameReadError, + decode_native_messaging_frame, decode_native_messaging_text_frame, + encode_native_messaging_frame, native_messaging_payload_limit, read_native_messaging_payload, +}; + +const HOST_TO_BROWSER_LIMIT: usize = 1_048_576; +const BROWSER_TO_HOST_LIMIT: usize = 67_108_864; + +struct RecordingReader { + data: Vec, + offset: usize, + max_request: usize, +} + +impl RecordingReader { + fn new(data: Vec) -> Self { + Self { + data, + offset: 0, + max_request: 0, + } + } +} + +impl Read for RecordingReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + self.max_request = self.max_request.max(buffer.len()); + let remaining = &self.data[self.offset..]; + if remaining.is_empty() { + return Ok(0); + } + let amount = remaining.len().min(buffer.len()); + buffer[..amount].copy_from_slice(&remaining[..amount]); + self.offset += amount; + Ok(amount) + } +} + +#[test] +fn native_messaging_payload_limits_are_direction_specific() { + assert_eq!( + native_messaging_payload_limit(NativeMessagingFrameDirection::HostToBrowser), + HOST_TO_BROWSER_LIMIT + ); + assert_eq!( + native_messaging_payload_limit(NativeMessagingFrameDirection::BrowserToHost), + BROWSER_TO_HOST_LIMIT + ); +} + +#[test] +fn native_messaging_frame_round_trip_uses_native_u32_byte_length() -> Result<(), Box> { + let payload = b"{}"; + + for direction in [ + NativeMessagingFrameDirection::HostToBrowser, + NativeMessagingFrameDirection::BrowserToHost, + ] { + let frame = encode_native_messaging_frame(direction, payload)?; + assert_eq!(&frame[..4], &2_u32.to_ne_bytes()); + assert_eq!(decode_native_messaging_frame(direction, &frame)?, payload); + } + Ok(()) +} + +#[test] +fn native_messaging_stream_reader_round_trips_one_bounded_payload() -> Result<(), Box> { + let payload = b"{\"message\":\"bounded\"}"; + let frame = + encode_native_messaging_frame(NativeMessagingFrameDirection::HostToBrowser, payload)?; + let mut reader = Cursor::new(frame); + + assert_eq!( + read_native_messaging_payload(NativeMessagingFrameDirection::HostToBrowser, &mut reader,)?, + payload + ); + assert_eq!(reader.position(), payload.len() as u64 + 4); + Ok(()) +} + +#[test] +fn native_messaging_stream_reader_rejects_oversized_prefix_before_reading_payload() { + let oversized = (HOST_TO_BROWSER_LIMIT as u32 + 1).to_ne_bytes(); + let mut reader = Cursor::new(oversized); + + assert!(matches!( + read_native_messaging_payload(NativeMessagingFrameDirection::HostToBrowser, &mut reader,), + Err(NativeMessagingFrameReadError::Frame( + NativeMessagingFrameError::PayloadTooLarge + )) + )); + assert_eq!(reader.position(), 4); +} + +#[test] +fn native_messaging_stream_reader_preserves_truncated_prefix_io_cause() { + let mut reader = Cursor::new([0_u8; 3]); + + assert!(matches!( + read_native_messaging_payload(NativeMessagingFrameDirection::HostToBrowser, &mut reader), + Err(NativeMessagingFrameReadError::Io(ref error)) + if error.kind() == ErrorKind::UnexpectedEof + )); +} + +#[test] +fn native_messaging_stream_reader_preserves_truncated_payload_io_cause() { + let mut frame = Vec::from(4_u32.to_ne_bytes()); + frame.extend_from_slice(b"abc"); + let mut reader = Cursor::new(frame); + + assert!(matches!( + read_native_messaging_payload(NativeMessagingFrameDirection::HostToBrowser, &mut reader), + Err(NativeMessagingFrameReadError::Io(ref error)) + if error.kind() == ErrorKind::UnexpectedEof + )); +} + +#[test] +fn native_messaging_stream_reader_bounds_each_payload_read_buffer() { + let prefix = (BROWSER_TO_HOST_LIMIT as u32).to_ne_bytes(); + let mut reader = RecordingReader::new(prefix.to_vec()); + + assert!(matches!( + read_native_messaging_payload(NativeMessagingFrameDirection::BrowserToHost, &mut reader), + Err(NativeMessagingFrameReadError::Io(ref error)) + if error.kind() == ErrorKind::UnexpectedEof + )); + assert!(reader.max_request <= 64 * 1024); +} + +#[test] +fn native_messaging_stream_read_errors_preserve_typed_sources_and_display() { + let frame_error = + NativeMessagingFrameReadError::Frame(NativeMessagingFrameError::PayloadTooLarge); + assert_eq!( + frame_error.to_string(), + "native messaging payload exceeds the direction-specific limit" + ); + assert!(Error::source(&frame_error).is_some()); + + let io_error = + NativeMessagingFrameReadError::Io(std::io::Error::from(ErrorKind::UnexpectedEof)); + assert_eq!(io_error.to_string(), "unexpected end of file"); + let source = Error::source(&io_error); + assert!(source.is_some()); + assert_eq!( + source.and_then(|source| { + source + .downcast_ref::() + .map(std::io::Error::kind) + }), + Some(ErrorKind::UnexpectedEof) + ); +} + +#[test] +fn native_messaging_text_frame_accepts_utf8_and_rejects_invalid_text() -> Result<(), Box> +{ + let utf8_payload = "{\"message\":\"์•ˆ๋…• ๐Ÿ‘‹\"}".as_bytes(); + let utf8_frame = + encode_native_messaging_frame(NativeMessagingFrameDirection::HostToBrowser, utf8_payload)?; + assert_eq!( + decode_native_messaging_text_frame( + NativeMessagingFrameDirection::HostToBrowser, + &utf8_frame, + )?, + "{\"message\":\"์•ˆ๋…• ๐Ÿ‘‹\"}" + ); + + let invalid_utf8_frame = + encode_native_messaging_frame(NativeMessagingFrameDirection::HostToBrowser, &[0xff])?; + assert_eq!( + decode_native_messaging_text_frame( + NativeMessagingFrameDirection::HostToBrowser, + &invalid_utf8_frame, + ), + Err(NativeMessagingFrameError::InvalidUtf8Payload) + ); + assert_eq!( + decode_native_messaging_text_frame( + NativeMessagingFrameDirection::HostToBrowser, + &[0, 0, 0], + ), + Err(NativeMessagingFrameError::MissingLengthPrefix) + ); + Ok(()) +} + +#[test] +fn native_messaging_encoder_rejects_oversized_host_payload_before_framing() { + let oversized = vec![b'x'; HOST_TO_BROWSER_LIMIT + 1]; + + assert_eq!( + encode_native_messaging_frame(NativeMessagingFrameDirection::HostToBrowser, &oversized,), + Err(NativeMessagingFrameError::PayloadTooLarge) + ); +} + +#[test] +fn native_messaging_decoder_rejects_missing_oversized_and_mismatched_lengths() { + assert_eq!( + decode_native_messaging_frame(NativeMessagingFrameDirection::HostToBrowser, &[0, 0, 0],), + Err(NativeMessagingFrameError::MissingLengthPrefix) + ); + + let host_to_browser_oversized = 1_048_577_u32.to_ne_bytes(); + assert_eq!( + decode_native_messaging_frame( + NativeMessagingFrameDirection::HostToBrowser, + &host_to_browser_oversized, + ), + Err(NativeMessagingFrameError::PayloadTooLarge) + ); + + let browser_to_host_oversized = 67_108_865_u32.to_ne_bytes(); + assert_eq!( + decode_native_messaging_frame( + NativeMessagingFrameDirection::BrowserToHost, + &browser_to_host_oversized, + ), + Err(NativeMessagingFrameError::PayloadTooLarge) + ); + + let mut short_frame = Vec::from(4_u32.to_ne_bytes()); + short_frame.extend_from_slice(b"abc"); + assert_eq!( + decode_native_messaging_frame(NativeMessagingFrameDirection::HostToBrowser, &short_frame,), + Err(NativeMessagingFrameError::LengthMismatch) + ); + + let mut trailing_frame = Vec::from(2_u32.to_ne_bytes()); + trailing_frame.extend_from_slice(b"abc"); + assert_eq!( + decode_native_messaging_frame( + NativeMessagingFrameDirection::HostToBrowser, + &trailing_frame, + ), + Err(NativeMessagingFrameError::LengthMismatch) + ); +} + +#[test] +fn native_messaging_frame_errors_are_stable_and_source_free() { + for (error, message) in [ + ( + NativeMessagingFrameError::MissingLengthPrefix, + "native messaging frame is missing its 32-bit length prefix", + ), + ( + NativeMessagingFrameError::PayloadTooLarge, + "native messaging payload exceeds the direction-specific limit", + ), + ( + NativeMessagingFrameError::LengthMismatch, + "native messaging frame length does not match its prefix", + ), + ( + NativeMessagingFrameError::InvalidUtf8Payload, + "native messaging payload is not valid UTF-8", + ), + ] { + assert_eq!(error.to_string(), message); + assert!(Error::source(&error).is_none()); + } +} diff --git a/crates/originweave-extension/tests/support/native_messaging_host_bounds.rs b/crates/originweave-extension/tests/support/native_messaging_host_bounds.rs new file mode 100644 index 000000000..d900f89a5 --- /dev/null +++ b/crates/originweave-extension/tests/support/native_messaging_host_bounds.rs @@ -0,0 +1,27 @@ +use originweave_core::{NativeMessagingHostName, NativeMessagingHostNameError}; + +#[test] +fn native_messaging_host_name_is_bounded_before_it_becomes_authority() { + let exact_limit = "a".repeat(256); + let parsed = NativeMessagingHostName::parse(&exact_limit); + assert_eq!( + parsed.as_ref().map(|host| host.as_str()), + Ok(exact_limit.as_str()) + ); + + let one_over = "a".repeat(257); + assert_eq!( + NativeMessagingHostName::parse(&one_over), + Err(NativeMessagingHostNameError::InvalidHostName) + ); +} + +#[test] +fn native_messaging_host_name_error_is_a_standard_credential_safe_error() { + let error = NativeMessagingHostNameError::InvalidHostName; + assert_eq!( + error.to_string(), + "native-messaging host name violates the reviewed Chrome identity syntax" + ); + assert!(std::error::Error::source(&error).is_none()); +} diff --git a/docs/doctoring.md b/docs/doctoring.md index ec51daaf3..c0efe6155 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -10,6 +10,12 @@ The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-cont The final Model Context Protocol `2026-07-28` specification defines the currently reviewed MCP generation. Its stateless request model carries protocol metadata per request and standard Streamable HTTP routing metadata for MCP operations; its Tools surface defines bounded, case-sensitive tool names and requires clients to treat tool annotations as untrusted unless supplied by a trusted server. OriginWeave therefore keeps MCP outside the product authority model. Active PR #168 implements only a bounded Rust `tools/call` routing/action-policy foundation for that exact generation; the complete transport, request-metadata, discovery, OAuth, browser, secret, and persistence adapter remains planned and cannot be inferred from the core routing primitive. +### Native messaging framing and authority + +Chrome's native-messaging protocol uses a UTF-8 JSON payload preceded by a 32-bit length in native byte order. Chrome documents a 1 MB native-host-to-browser message limit and a 4 GB browser-to-host protocol envelope; current Chromium source enforces the 1 MiB incoming-host limit before delivery. The nearby 64 MiB value is only a write-size histogram bucket, not a Chrome protocol limit. OriginWeave therefore keeps the browser protocol envelope separate from its own bounded resource policy: the native-messaging framing adapter applies a 64 MiB browser-to-host ceiling, reads admitted stream payloads in bounded 64 KiB chunks rather than allocating the declared maximum up front, rejects incomplete or trailing complete frames, and validates UTF-8 before text handling. It does not parse or trust JSON, launch or authenticate a host, or turn the `nativeMessaging` permission into Agent authority. The executable compatibility and process-ownership boundary remains the security-gated surface documented in `docs/doctoring/mv3-compatibility.md`. + +The reviewed Chromium source is pinned to immutable revision `160af61f9d1316fd1f1dc41e9503cc1f1926d31f` and its file blob `9d205a90d70b0c1c9f0b3b1c5f296528f6b21755`; a mutable branch URL is not reproducible evidence. + ### Browser origin equivalence The WHATWG URL host parser and Chromium canonicalizer classify shortened decimal, integer, hexadecimal, legacy octal-looking, and mixed-component numeric hosts as IPv4 or broken IPv4 candidates rather than ordinary DNS names. Chromium's regression suite includes values such as `192`, `0xC0a80001`, `030052000001`, and mixed hexadecimal components. A non-final empty `0x` component can participate in Chromium's multi-part IPv4 truncation behavior, but a final `0x` label does not produce an IPv4 number because stripping its prefix leaves no digits; it remains a domain label. Chromium also warns that broken IP-like hosts must not be connected because another resolver could accept them. OriginWeave therefore admits only canonical dotted-decimal IPv4 into its policy origin type, rejects browser-special numeric spellings before DNS validation, and preserves final non-numeric DNS labels such as `0x`. @@ -124,6 +130,8 @@ Bonica, R., Cotton, M., Haberman, B., & Vegoda, L. (2017). *Updates to the speci Chromium Authors. (n.d.). *Proxy support in Chrome* [Source documentation]. Chromium. https://chromium.googlesource.com/chromium/src/+/a3e71ebfa307d8760eb68b777e2998a869940092/net/docs/proxy.md +Chromium Authors. (2026). *native_message_process_host.cc* [Source code, blob `9d205a90d70b0c1c9f0b3b1c5f296528f6b21755` at revision `160af61f9d1316fd1f1dc41e9503cc1f1926d31f`]. Chromium. https://chromium.googlesource.com/chromium/src/+/160af61f9d1316fd1f1dc41e9503cc1f1926d31f/chrome/browser/extensions/api/messaging/native_message_process_host.cc + Chromium Authors. (2026). *URL canonicalizer unit tests* [Source code]. Chromium. https://chromium.googlesource.com/chromium/src/+/446d05d21720f0b3505ec21057b3e9f909784262/url/url_canon_unittest.cc Cooper, D., Santesson, S., Farrell, S., Boeyen, S., Housley, R., & Polk, W. (2008). *Internet X.509 public key infrastructure certificate and certificate revocation list (CRL) profile* (RFC 5280). Internet Engineering Task Force. https://doi.org/10.17487/RFC5280 diff --git a/docs/doctoring/mv3-compatibility.md b/docs/doctoring/mv3-compatibility.md index 571c49329..4f658463c 100644 --- a/docs/doctoring/mv3-compatibility.md +++ b/docs/doctoring/mv3-compatibility.md @@ -1,7 +1,7 @@ # Manifest V3 compatibility evidence baseline - **Status:** Active implementation evidence for issue #27 -- **Reviewed:** 2026-08-11 +- **Reviewed:** 2026-08-28 - **Pinned browser:** Chrome for Testing `150.0.7871.129`, Chromium revision `r1639810` OriginWeave uses Chromium as its compatibility kernel, so browser-extension compatibility must be demonstrated with executable Chromium evidence rather than inferred from architecture alone. The protected-main lane exercises a controlled unpacked Manifest V3 extension against one exact Chrome for Testing build and proves service-worker, content-script, storage, declarative-network-request, tabs, windows, scripting, commands, side-panel, bookmarks/history read compatibility, restart persistence, repeatability, and one real WebDriver click/post-condition. Active stacked compatibility work adds downloads, bounded bookmark/history mutation, profile isolation, explicit extension update/version-migration evidence, and an exact content-script isolated-world check. OriginWeave does **not claim 100% Chrome extension compatibility**. @@ -29,7 +29,7 @@ This matrix separates protected-main executable evidence from active, non-shippe | Per-trial Agent Task profile isolation | **ACTIVE_PR #49** | Compatibility trials use isolated ephemeral profiles rather than ambient human state. | Full production Agent Task browser orchestration remains issue #28 work. | | Extension update/version migration | **ACTIVE_PR #60** | Trial-local extension copy transitions `1.0.0` โ†’ `1.0.1` on the same ephemeral profile; versioned storage state is required to migrate and real pinned-Chromium evidence reports the update-migration surface. | No Chrome Web Store updater, enterprise deployment channel, arbitrary downgrade, or protected-main release claim. | | Managed enterprise extension policy | **PLANNED** | No protected-main executable compatibility proof yet. | Do not infer managed-policy support from Chromium ancestry alone. | -| Native messaging | **PLANNED / SECURITY-GATED** | No compatibility claim. | Future support requires an explicit host-managed allow-list and process boundary. | +| Native messaging | **PLANNED / SECURITY-GATED** | Active PR #82 defines exact extension-to-host authority and stacked Draft #154 defines bounded binary framing plus UTF-8 payload validation, but neither is real pinned-Chromium native-host compatibility evidence. | Process launch/registration ownership, JSON syntax/semantic parsing, untrusted-message classification, sandboxing, real stdio integration, and executable browser compatibility remain unproven. | | Google-only services, proprietary codecs, DRM, Web Store licensing | **OUT_OF_SCOPE FOR COMPATIBILITY CLAIM** | Deliberately excluded from the open compatibility claim. | Chromium/API compatibility must not be conflated with Google service or licensing equivalence. | The release-quality capability matrix must remain coupled to executable evidence. Adding a row to documentation never creates support; declaring a new supported capability must first add a realistic regression test and pinned-Chromium proof. Conversely, if a declared protected-main capability regresses, the release gate must fail rather than silently downgrading the matrix. @@ -46,6 +46,12 @@ Restart persistence and extension update migration are separate compatibility cl Content-script injection and content-script JavaScript isolation are separate compatibility claims. Active PR #61 writes `window.originweaveWorldSentinel = "page"` in the fixture page's main world and repeatedly publishes that value through one controlled DOM attribute. The content script assigns the same global name to `"extension"` in its own execution world, waits a bounded interval, and only reports the existing compatibility surface ready when it simultaneously observes the page's published `page` value and its own `extension` value. If both scripts share one JavaScript global namespace, the page publisher changes to `extension` and real-browser compatibility fails. DOM sharing here is deliberate test evidence, not permission for arbitrary page content to become trusted instruction or Agent authority. +## Native-messaging protocol boundary + +Chrome's native-messaging protocol uses a UTF-8 JSON message preceded by a 32-bit payload length in native byte order. Chrome's documented protocol ceiling is 1 MB for a message sent by the native host to the browser and 4 GB for a message sent by the browser to the native host. Current Chromium source independently enforces the 1 MiB incoming-host ceiling before delivering host data. Its extension-to-host write path encodes the payload length through a checked `uint32_t`; the nearby 64 MiB value is the upper bucket used by the `Extensions.NativeMessaging.MessageSize.Extension` histogram, not an enforced Chrome protocol ceiling. The reviewed source content is identified by Chromium blob `9d205a90d70b0c1c9f0b3b1c5f296528f6b21755`. + +Draft PR #154 therefore mirrors Chrome's 1 MiB host-to-browser safety boundary but deliberately applies a stricter **OriginWeave-owned 64 MiB resource ceiling** to browser-to-host frames. That local bound limits allocation and buffering below Chrome's protocol envelope; it must not be described as a Chrome compatibility maximum. The reusable Rust boundary rejects oversized encoder input before allocation, rejects an oversized advertised decoder length before payload slicing, reads admitted stream payloads in 64 KiB chunks instead of committing the full declared ceiling before bytes arrive, requires the complete frame length to equal the advertised byte count so truncation and trailing data fail closed, and rejects invalid UTF-8 before a caller can treat framed bytes as native-messaging text. It still does not validate JSON syntax or semantics, trust the decoded text, launch or authenticate a native-host process, validate operating-system registration, or convert Chrome `nativeMessaging` permission into OriginWeave Agent authority. + ## Supply-chain and repeatability evidence The CI lane downloads the exact Chrome/ChromeDriver version from the official Chrome for Testing public bucket, records SHA-256 receipts for the downloaded archives, verifies the runtime-reported browser version, and emits bounded JSON compatibility evidence. A future release-quality matrix should additionally pin published artifact digests or equivalent immutable supply-chain identity when the upstream distribution exposes that identity in an authoritative machine-readable form. @@ -64,6 +70,10 @@ Chrome for Developers. (n.d.). *chrome.history*. Google. Retrieved August 11, 20 Chrome for Developers. (n.d.). *Manifest file format*. Google. Retrieved August 9, 2026, from https://developer.chrome.com/docs/extensions/reference/manifest +Chrome for Developers. (n.d.). *Native messaging*. Google. Retrieved August 24, 2026, from https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging + +Chromium Authors. (2026). *native_message_process_host.cc* [Source code, blob `9d205a90d70b0c1c9f0b3b1c5f296528f6b21755` at revision `160af61f9d1316fd1f1dc41e9503cc1f1926d31f`]. Chromium. https://chromium.googlesource.com/chromium/src/+/160af61f9d1316fd1f1dc41e9503cc1f1926d31f/chrome/browser/extensions/api/messaging/native_message_process_host.cc + Bynens, M. (2023, June 12). *Chrome for Testing*. Chrome for Developers. https://developer.chrome.com/docs/automation-and-testing/chrome-for-testing Google Chrome Labs. (2026, July 21). *Chrome for Testing availability*. https://googlechromelabs.github.io/chrome-for-testing/ diff --git a/tests/test_doctoring_reference_contract.py b/tests/test_doctoring_reference_contract.py index bdeded44f..a0583f50d 100644 --- a/tests/test_doctoring_reference_contract.py +++ b/tests/test_doctoring_reference_contract.py @@ -23,6 +23,37 @@ def test_rfc_5280_reference_uses_canonical_author_initials(self) -> None: ) self.assertIn(expected, text) + def test_native_messaging_decision_trace_is_pinned(self) -> None: + """Native messaging framing evidence must remain traceable and immutable.""" + text = DOCTORING.read_text(encoding="utf-8") + compatibility = ( + ROOT / "docs" / "doctoring" / "mv3-compatibility.md" + ).read_text(encoding="utf-8") + for expected in ( + "### Native messaging framing and authority", + "Chrome's native-messaging protocol uses a UTF-8 JSON payload", + "64 MiB browser-to-host ceiling", + "Chromium Authors. (2026). *native_message_process_host.cc*", + "160af61f9d1316fd1f1dc41e9503cc1f1926d31f", + ): + with self.subTest(expected=expected): + self.assertIn(expected, text) + expected_source_url = ( + "https://chromium.googlesource.com/chromium/src/+/" + "160af61f9d1316fd1f1dc41e9503cc1f1926d31f/" + "chrome/browser/extensions/api/messaging/native_message_process_host.cc" + ) + expected_blob = "9d205a90d70b0c1c9f0b3b1c5f296528f6b21755" + mutable_source_url = ( + "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/" + "chrome/browser/extensions/api/messaging/native_message_process_host.cc" + ) + for document in (text, compatibility): + with self.subTest(document=document[:24]): + self.assertIn(expected_source_url, document) + self.assertIn(expected_blob, document) + self.assertNotIn(mutable_source_url, document) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_extension_context_architecture.py b/tests/test_extension_context_architecture.py new file mode 100644 index 000000000..3dc363130 --- /dev/null +++ b/tests/test_extension_context_architecture.py @@ -0,0 +1,39 @@ +"""Architectural fitness for the Extension Policy bounded context.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class ExtensionContextArchitectureTest(unittest.TestCase): + """Keep Chromium extension adapter vocabulary out of stable browser authority contracts.""" + + def test_native_messaging_adapter_has_dedicated_context(self) -> None: + """Native-messaging integration belongs to the Extension Policy context, not core.""" + workspace = (ROOT / "Cargo.toml").read_text(encoding="utf-8") + self.assertIn('"crates/originweave-extension"', workspace) + + extension_root = ROOT / "crates" / "originweave-extension" + self.assertTrue((extension_root / "Cargo.toml").is_file()) + self.assertTrue((extension_root / "src" / "lib.rs").is_file()) + self.assertTrue((extension_root / "src" / "native_messaging.rs").is_file()) + + core_root = ROOT / "crates" / "originweave-core" / "src" + self.assertFalse((core_root / "native_messaging.rs").exists()) + core_entry = (core_root / "root.rs").read_text(encoding="utf-8") + self.assertNotIn("mod native_messaging;", core_entry) + + extension_manifest = (extension_root / "Cargo.toml").read_text(encoding="utf-8") + self.assertIn('originweave-core = { path = "../originweave-core" }', extension_manifest) + core_manifest = (ROOT / "crates" / "originweave-core" / "Cargo.toml").read_text( + encoding="utf-8" + ) + self.assertNotIn("originweave-extension", core_manifest) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 057a0011b..0db84ef69 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -20,6 +20,7 @@ def test_workspace_declares_all_independently_reusable_crates(self) -> None: set(data["workspace"]["members"]), { "crates/originweave-core", + "crates/originweave-extension", "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-destination",