diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e773752a..3fc5651f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. @@ -103,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/crates/originweave-core/src/native_messaging.rs b/crates/originweave-core/src/native_messaging.rs index 68cbc562a..5c8823842 100644 --- a/crates/originweave-core/src/native_messaging.rs +++ b/crates/originweave-core/src/native_messaging.rs @@ -1,10 +1,14 @@ //! 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)] @@ -157,3 +161,171 @@ pub fn evaluate_native_messaging_access( } 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-core/tests/native_messaging_framing.rs b/crates/originweave-core/tests/native_messaging_framing.rs new file mode 100644 index 000000000..667ea9587 --- /dev/null +++ b/crates/originweave-core/tests/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/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..a805778c4 100644 --- a/tests/test_doctoring_reference_contract.py +++ b/tests/test_doctoring_reference_contract.py @@ -23,6 +23,31 @@ 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) + self.assertIn( + "https://chromium.googlesource.com/chromium/src/+/160af61f9d1316fd1f1dc41e9503cc1f1926d31f/", + compatibility, + ) + self.assertNotIn( + "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/" + "chrome/browser/extensions/api/messaging/native_message_process_host.cc", + compatibility, + ) + if __name__ == "__main__": unittest.main()