Skip to content
Closed
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo

### Added

- Bound Chrome native-messaging byte framing with native-endian 32-bit lengths, exact one-frame matching, direction-specific 1 MiB host-to-Chrome and 64 MiB Chrome-to-host limits, and UTF-8 fail-closed validation without granting process, JSON, Agent, or secret authority.
- 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.
- Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules.
Expand Down
2 changes: 2 additions & 0 deletions crates/originweave-core/src/crate_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use std::fmt;

#[path = "lib.rs"]
mod base;
mod native_messaging_frame;
pub use base::*;
pub use native_messaging_frame::*;

impl fmt::Display for NativeMessagingHostNameError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
Expand Down
132 changes: 132 additions & 0 deletions crates/originweave-core/src/native_messaging_frame.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! Bounded Chrome native-messaging frame encoding and decoding.
//!
//! Chrome native messaging prefixes each UTF-8 JSON message with a 32-bit
//! native-endian byte length. This module validates only that framing boundary;
//! JSON parsing, host registration, process launch, authority, and secret handling
//! remain separate reviewed layers.

use std::fmt;

const NATIVE_MESSAGING_HOST_TO_CHROME_MAX_BYTES: usize = 1_048_576;
const NATIVE_MESSAGING_CHROME_TO_HOST_MAX_BYTES: usize = 67_108_864;
const NATIVE_MESSAGING_LENGTH_BYTES: usize = 4;

/// Direction of one Chrome native-messaging payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NativeMessagingFrameDirection {
/// A message written by Chrome and read by the native host.
ChromeToHost,
/// A message written by the native host and read by Chrome.
HostToChrome,
}

impl NativeMessagingFrameDirection {
const fn maximum_payload_bytes(self) -> usize {
match self {
Self::ChromeToHost => NATIVE_MESSAGING_CHROME_TO_HOST_MAX_BYTES,
Self::HostToChrome => NATIVE_MESSAGING_HOST_TO_CHROME_MAX_BYTES,
}
}
}

/// A fail-closed native-messaging frame validation failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NativeMessagingFrameError {
/// Fewer than four bytes were available for the native-endian length header.
TruncatedHeader,
/// The declared or outbound payload exceeds Chrome's limit for this direction.
PayloadTooLarge {
/// Number of payload bytes declared or supplied.
declared_bytes: usize,
/// Maximum payload bytes Chrome permits for this direction.
maximum_bytes: usize,
},
/// The frame body length does not exactly match the declared byte length.
LengthMismatch {
/// Number of payload bytes declared by the frame header.
declared_bytes: usize,
/// Number of payload bytes actually present after the header.
actual_bytes: usize,
},
/// The frame body is not valid UTF-8 and therefore cannot be a JSON text message.
InvalidUtf8,
}

impl fmt::Display for NativeMessagingFrameError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TruncatedHeader => {
formatter.write_str("native-messaging frame is missing its four-byte header")
}
Self::PayloadTooLarge { .. } => {
formatter.write_str("native-messaging payload exceeds the direction limit")
}
Self::LengthMismatch { .. } => {
formatter.write_str("native-messaging frame length does not match its header")
}
Self::InvalidUtf8 => formatter.write_str("native-messaging payload is not valid UTF-8"),
}
}
}

impl std::error::Error for NativeMessagingFrameError {}

/// Encode one already-serialized UTF-8 JSON message as a bounded Chrome native frame.
///
/// This function does not validate JSON syntax. Callers must serialize a reviewed
/// message schema before this framing step and must keep authority decisions outside
/// the payload codec.
pub fn encode_native_messaging_frame(
payload: &str,
direction: NativeMessagingFrameDirection,
) -> Result<Vec<u8>, NativeMessagingFrameError> {
let payload_bytes = payload.as_bytes();
let maximum_bytes = direction.maximum_payload_bytes();
if payload_bytes.len() > maximum_bytes {
return Err(NativeMessagingFrameError::PayloadTooLarge {
declared_bytes: payload_bytes.len(),
maximum_bytes,
});
}

// Both reviewed Chrome direction limits are far below u32::MAX, so the
// preceding bound proves this conversion cannot truncate.
let declared_length = payload_bytes.len() as u32;
let mut frame = Vec::with_capacity(NATIVE_MESSAGING_LENGTH_BYTES + payload_bytes.len());
frame.extend_from_slice(&declared_length.to_ne_bytes());
frame.extend_from_slice(payload_bytes);
Ok(frame)
}

/// Decode one complete bounded Chrome native-messaging frame as UTF-8 text.
///
/// Declared size is checked before the body is interpreted, and the complete input
/// must contain exactly one frame. The returned text borrows the caller-owned frame.
pub fn decode_native_messaging_frame(
frame: &[u8],
direction: NativeMessagingFrameDirection,
) -> Result<&str, NativeMessagingFrameError> {
if frame.len() < NATIVE_MESSAGING_LENGTH_BYTES {
return Err(NativeMessagingFrameError::TruncatedHeader);
}

let header = [frame[0], frame[1], frame[2], frame[3]];
let declared_bytes = u32::from_ne_bytes(header) as usize;
let maximum_bytes = direction.maximum_payload_bytes();
if declared_bytes > maximum_bytes {
return Err(NativeMessagingFrameError::PayloadTooLarge {
declared_bytes,
maximum_bytes,
});
}

let payload = &frame[NATIVE_MESSAGING_LENGTH_BYTES..];
if payload.len() != declared_bytes {
return Err(NativeMessagingFrameError::LengthMismatch {
declared_bytes,
actual_bytes: payload.len(),
});
}

std::str::from_utf8(payload).map_err(|_| NativeMessagingFrameError::InvalidUtf8)
}
150 changes: 150 additions & 0 deletions crates/originweave-core/tests/native_messaging_frame_codec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#![allow(clippy::expect_used)]

use originweave_core::{
NativeMessagingFrameDirection, NativeMessagingFrameError, decode_native_messaging_frame,
encode_native_messaging_frame,
};

fn frame_with_declared_length(declared_length: u32, payload: &[u8]) -> Vec<u8> {
let mut frame = Vec::with_capacity(4 + payload.len());
frame.extend_from_slice(&declared_length.to_ne_bytes());
frame.extend_from_slice(payload);
frame
}

#[test]
fn native_messaging_frames_use_native_endian_byte_length_and_round_trip_utf8() {
let payload = r#"{"message":"안녕"}"#;
let encoded =
encode_native_messaging_frame(payload, NativeMessagingFrameDirection::ChromeToHost)
.expect("bounded payload should encode");

let declared = u32::from_ne_bytes(encoded[..4].try_into().expect("four-byte header"));
assert_eq!(declared as usize, payload.len());
assert_eq!(&encoded[4..], payload.as_bytes());
assert_eq!(
decode_native_messaging_frame(&encoded, NativeMessagingFrameDirection::ChromeToHost,)
.expect("encoded payload should decode"),
payload
);
}

#[test]
fn native_messaging_decode_rejects_truncated_header_and_length_mismatch() {
assert_eq!(
decode_native_messaging_frame(&[0, 0, 0], NativeMessagingFrameDirection::HostToChrome),
Err(NativeMessagingFrameError::TruncatedHeader)
);

let truncated = frame_with_declared_length(5, b"{} ");
assert_eq!(
decode_native_messaging_frame(&truncated, NativeMessagingFrameDirection::HostToChrome,),
Err(NativeMessagingFrameError::LengthMismatch {
declared_bytes: 5,
actual_bytes: 3,
})
);

let trailing = frame_with_declared_length(2, b"{}x");
assert_eq!(
decode_native_messaging_frame(&trailing, NativeMessagingFrameDirection::HostToChrome),
Err(NativeMessagingFrameError::LengthMismatch {
declared_bytes: 2,
actual_bytes: 3,
})
);
}

#[test]
fn native_messaging_decode_enforces_direction_specific_chrome_limits_before_body_use() {
let host_to_chrome_oversize = frame_with_declared_length(1_048_577, b"");
assert_eq!(
decode_native_messaging_frame(
&host_to_chrome_oversize,
NativeMessagingFrameDirection::HostToChrome,
),
Err(NativeMessagingFrameError::PayloadTooLarge {
declared_bytes: 1_048_577,
maximum_bytes: 1_048_576,
})
);

let chrome_to_host_oversize = frame_with_declared_length(67_108_865, b"");
assert_eq!(
decode_native_messaging_frame(
&chrome_to_host_oversize,
NativeMessagingFrameDirection::ChromeToHost,
),
Err(NativeMessagingFrameError::PayloadTooLarge {
declared_bytes: 67_108_865,
maximum_bytes: 67_108_864,
})
);
}

#[test]
fn native_messaging_encode_enforces_host_to_chrome_limit() {
let maximum_payload = "x".repeat(1_048_576);
let encoded = encode_native_messaging_frame(
&maximum_payload,
NativeMessagingFrameDirection::HostToChrome,
)
.expect("Chrome accepts a native host message at the documented byte limit");
assert_eq!(encoded.len(), 4 + maximum_payload.len());

let oversized_payload = "x".repeat(1_048_577);
assert_eq!(
encode_native_messaging_frame(
&oversized_payload,
NativeMessagingFrameDirection::HostToChrome,
),
Err(NativeMessagingFrameError::PayloadTooLarge {
declared_bytes: 1_048_577,
maximum_bytes: 1_048_576,
})
);
}

#[test]
fn native_messaging_errors_are_deterministic_and_payload_free() {
let errors = [
(
NativeMessagingFrameError::TruncatedHeader,
"native-messaging frame is missing its four-byte header",
),
(
NativeMessagingFrameError::PayloadTooLarge {
declared_bytes: 2,
maximum_bytes: 1,
},
"native-messaging payload exceeds the direction limit",
),
(
NativeMessagingFrameError::LengthMismatch {
declared_bytes: 2,
actual_bytes: 1,
},
"native-messaging frame length does not match its header",
),
(
NativeMessagingFrameError::InvalidUtf8,
"native-messaging payload is not valid UTF-8",
),
];

for (error, expected) in errors {
assert_eq!(error.to_string(), expected);
}
}

#[test]
fn native_messaging_decode_rejects_non_utf8_payload_without_reflecting_bytes() {
let frame = frame_with_declared_length(2, &[0xff, 0xfe]);
let error = decode_native_messaging_frame(&frame, NativeMessagingFrameDirection::HostToChrome)
.expect_err("non-UTF-8 native message must fail closed");
assert_eq!(error, NativeMessagingFrameError::InvalidUtf8);
assert_eq!(
error.to_string(),
"native-messaging payload is not valid UTF-8"
);
}
23 changes: 17 additions & 6 deletions docs/doctoring/browser-agent-protocols.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Browser and Agent Protocol Standards Evidence

- **Reviewed:** 2026-08-10
- **Reviewed:** 2026-08-21
- **Purpose:** primary-source evidence for OriginWeave browser compatibility and adapter boundaries
- **Canonical research index:** [`../doctoring.md`](../doctoring.md)

This addendum complements the main doctoring record. The main record already carries the WebDriver BiDi, WARC/ISO 28500 and W3C PROV-O evidence. This addendum records the current primary sources for Manifest V3, Chrome DevTools Protocol, WebMCP and Model Context Protocol so product documentation does not rely on uncited protocol names.
This addendum complements the main doctoring record. The main record already carries the WebDriver BiDi, WARC/ISO 28500 and W3C PROV-O evidence. This addendum records the current primary sources for Manifest V3, Chrome native messaging, Chrome DevTools Protocol, WebMCP and Model Context Protocol so product documentation does not rely on uncited protocol names.

## WebDriver BiDi

Expand All @@ -20,6 +20,14 @@ A Chrome extension permission remains separate from an OriginWeave Agent capabil

Primary source: Chrome for Developers, *Manifest file format* and *Manifest Version*.

## Chrome native messaging

Chrome's native-messaging contract launches each registered native host in a separate process and exchanges UTF-8 JSON messages over standard input and standard output. Each message is prefixed by a 32-bit unsigned length in native byte order. Chrome documents a 1 MiB maximum for a message sent from the native host to Chrome and a 64 MiB maximum for a message sent from Chrome to the native host. Chrome also passes the calling extension origin to the native host process, but that process argument is not itself OriginWeave authorization.

OriginWeave therefore keeps native-messaging framing as a narrow byte-level boundary. Manifest and operating-system registration, process identity and supervision, caller-origin binding, JSON schema validation, extension-to-host authority, Agent capability, and secret disclosure remain separately reviewed fail-closed layers.

Primary source: Chrome for Developers, *Native messaging*.

## Chrome DevTools Protocol

The official CDP documentation states that tip-of-tree changes frequently and provides no backward-compatibility guarantee for capabilities it introduces. OriginWeave therefore pins the Chromium/protocol evidence used by a release and keeps CDP behind an adapter. CDP is useful for Chromium-specific Network, Accessibility, DOMSnapshot, tracing and diagnostic surfaces; it is not the durable OriginWeave authority model.
Expand Down Expand Up @@ -49,10 +57,11 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re
1. Version adapter contracts independently from OriginWeave session/context/action/evidence types.
2. Pin exact Chromium/CDP compatibility evidence at release time.
3. Keep WebDriver BiDi's Working Draft status visible in compatibility claims.
4. Keep WebMCP experimental/optional and propagate untrusted-content semantics.
5. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority.
6. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes.
7. Treat WARC/PROV as provenance representations, not policy or truth escalation.
4. Treat native-messaging framing as a bounded transport codec, never as host registration, process identity, Agent capability, or secret authority.
5. Keep WebMCP experimental/optional and propagate untrusted-content semantics.
6. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority.
7. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes.
8. Treat WARC/PROV as provenance representations, not policy or truth escalation.

## References — APA 7th

Expand All @@ -62,6 +71,8 @@ Google Chrome Developers. (n.d.). *Manifest file format*. Chrome for Developers.

Google Chrome Developers. (n.d.). *Manifest Version*. Chrome for Developers. Retrieved August 10, 2026, from https://developer.chrome.com/docs/extensions/reference/manifest/manifest-version

Google Chrome Developers. (n.d.). *Native messaging*. Chrome for Developers. Retrieved August 21, 2026, from https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging

Google Chrome Developers. (2026). *WebMCP*. Chrome for Developers. https://developer.chrome.com/docs/ai/webmcp

Pagnucco, J., & Klepper, A. (2026, June 9). *Agent security considerations for WebMCP*. Chrome for Developers. https://developer.chrome.com/docs/agents/security
Expand Down
Loading