Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 116 additions & 1 deletion crates/originweave-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ pub enum NodeHandleError {
StaleDocumentEpoch {
/// Epoch that originally produced the node handle.
observed: DocumentEpoch,
/// Epoch currently active in the browser context.
/// Epoch currently active for the browser context.
current: DocumentEpoch,
},
}
Expand Down Expand Up @@ -1063,3 +1063,118 @@ pub fn evaluate_extension_access(
}
ExtensionAccessDecision::Allow
}

/// 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<Self, NativeMessagingHostNameError> {
if input.is_empty()
|| 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,
}

/// 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,
}
}
}

/// 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,
}
}
}

/// 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
}
102 changes: 102 additions & 0 deletions crates/originweave-core/tests/native_messaging_authority.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#![allow(clippy::expect_used)]

use originweave_core::{
BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest,
ExtensionAgentCapability, ExtensionId, NativeMessagingAccessDecision,
NativeMessagingAccessRequest, NativeMessagingHostGrant, NativeMessagingHostName,
evaluate_extension_access, evaluate_native_messaging_access,
};

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")
}

#[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());

let exact = NativeMessagingAccessRequest::new(allowed_extension.clone(), allowed_host.clone());
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),
ExtensionAgentCapability::ProposeTypedAction,
);
assert_eq!(
evaluate_extension_access(&agent_request, None),
ExtensionAccessDecision::DenyMissingGrant
);
}
Loading