Skip to content
Merged
6 changes: 3 additions & 3 deletions crates/tinysecurity-bus/src/device_frame_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ fn frame_operations_redact_both_payloads_without_changing_open_wire_shape()
#[test]
fn frame_member_name_is_stable_and_contract_version_is_bumped() {
assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2");
assert_eq!(crate::CONTRACT_VERSION, (1, 31));
assert!(crate::is_compatible((1, 31)));
assert!(crate::is_compatible((1, 31)));
assert_eq!(crate::CONTRACT_VERSION, (1, 32));
assert!(crate::is_compatible((1, 32)));
assert!(!crate::is_compatible((1, 31)));
assert!(!crate::is_compatible((1, 29)));
assert!(!crate::is_compatible((2, 30)));
}
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/src/device_pairing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ fn refresh_and_close_are_distinct_transient_session_operations() {

#[test]
fn pairing_contract_is_versioned_after_the_frame_contract() {
assert_eq!(CONTRACT_VERSION, (1, 31));
assert_eq!(CONTRACT_VERSION, (1, 32));
}

#[test]
Expand Down
4 changes: 3 additions & 1 deletion crates/tinysecurity-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ mod policy;
mod privacy_check;
mod prompt;
mod prompt_score;
mod provider_endpoint;
mod redact;
mod scoped_policy;
mod tool_trust;
Expand All @@ -59,13 +60,14 @@ pub use policy::*;
pub use privacy_check::*;
pub use prompt::*;
pub use prompt_score::*;
pub use provider_endpoint::*;
pub use redact::RedactWithRequest;
pub use redact::RedactorConfig;
pub use scoped_policy::*;
pub use tool_trust::*;
pub mod names;
/// Current wire vocabulary version, independent of the artifact version.
pub const CONTRACT_VERSION: (u32, u32) = (1, 31);
pub const CONTRACT_VERSION: (u32, u32) = (1, 32);
/// Whether a remote module's advertised version supplies this host's vocabulary.
///
/// `version` is the provider version received by the host, not a host requirement
Expand Down
3 changes: 3 additions & 0 deletions crates/tinysecurity-bus/src/names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ pub mod methods {
pub const CLASSIFY_HOST_ACTION2: &str = "ClassifyHostAction2";
/// Descriptor-level privacy decision for a transfer with no pinned destination (contract 1.22).
pub const PRIVACY_CHECK2: &str = "PrivacyCheck2";
/// Classify a configured provider host as same-machine or external (contract 1.32).
pub const CLASSIFY_PROVIDER_ENDPOINT2: &str = "ClassifyProviderEndpoint2";
/// Register an immutable per-subject policy over path and command scopes.
pub const REGISTER_POLICY: &str = "RegisterPolicy";
/// Atomically replace an active policy, with human review when it widens.
Expand Down Expand Up @@ -221,6 +223,7 @@ pub const METHODS: &[&str] = &[
methods::PEEK_BUDGET2,
methods::CLASSIFY_HOST_ACTION2,
methods::PRIVACY_CHECK2,
methods::CLASSIFY_PROVIDER_ENDPOINT2,
methods::REGISTER_COMMAND_POLICY,
methods::CLASSIFY_COMMAND,
methods::CHECK_COMMAND,
Expand Down
56 changes: 56 additions & 0 deletions crates/tinysecurity-bus/src/provider_endpoint.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//! Host endpoint locality classification for `LocalOnly` privacy mode (contract 1.32).
use serde::{Deserialize, Serialize};

/// Maximum size of a parsed URL host supplied for provider locality classification.
pub const MAX_PROVIDER_HOST_BYTES: usize = 255;

/// The parsed host component of a configured provider endpoint.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderEndpointRequest2 {
/// Host only, as returned by the URL parser; excludes scheme, port, path, query and fragment.
pub host: String,
}

impl ProviderEndpointRequest2 {
/// Validate the bounded host input before classifying it.
///
/// # Errors
/// Returns `InvalidArguments` for an empty, oversized, whitespace-containing or
/// control-character-containing host.
pub fn validate(&self) -> crate::Result<()> {
if self.host.is_empty()
|| self.host.len() > MAX_PROVIDER_HOST_BYTES
|| self
.host
.chars()
.any(|c| c.is_control() || c.is_whitespace())
{
return Err(crate::Error::InvalidArguments);
}
Ok(())
}
}

/// Whether a provider host is reserved for the same machine or may be elsewhere.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProviderEndpointLocality2 {
/// A loopback or localhost name that identifies this machine itself.
OnDevice,
/// Any other host, including private or link-local network addresses.
External,
}

/// Result of classifying one configured provider host.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderEndpointResponse2 {
/// Locality used by the host's `LocalOnly` decision.
pub locality: ProviderEndpointLocality2,
}

#[cfg(test)]
#[path = "provider_endpoint_tests.rs"]
mod tests;
73 changes: 73 additions & 0 deletions crates/tinysecurity-bus/src/provider_endpoint_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use super::*;

#[test]
fn provider_endpoint_request_is_bounded_and_host_only() {
assert!(
ProviderEndpointRequest2 {
host: "[::1]".into()
}
.validate()
.is_ok()
);
for host in ["", "has white space", "host\nname"] {
assert!(
ProviderEndpointRequest2 { host: host.into() }
.validate()
.is_err()
);
}
assert!(
ProviderEndpointRequest2 {
host: "a".repeat(MAX_PROVIDER_HOST_BYTES + 1)
}
.validate()
.is_err()
);
}

#[test]
fn provider_endpoint_request_and_response_have_stable_wire_shapes()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let request = ProviderEndpointRequest2 {
host: "localhost".into(),
};
let encoded = serde_json::to_value(&request)?;
assert_eq!(encoded, serde_json::json!({"host": "localhost"}));
assert_eq!(
serde_json::from_value::<ProviderEndpointRequest2>(encoded)?,
request
);

let response = ProviderEndpointResponse2 {
locality: ProviderEndpointLocality2::OnDevice,
};
let encoded = serde_json::to_value(response)?;
assert_eq!(encoded, serde_json::json!({"locality": "on_device"}));
assert_eq!(
serde_json::from_value::<ProviderEndpointResponse2>(encoded)?,
response
);
Ok(())
}

#[test]
fn provider_endpoint_locality_has_stable_wire_names_and_method()
-> std::result::Result<(), Box<dyn std::error::Error>> {
assert_eq!(
serde_json::to_value(ProviderEndpointLocality2::OnDevice)?,
serde_json::json!("on_device")
);
assert_eq!(
serde_json::to_value(ProviderEndpointLocality2::External)?,
serde_json::json!("external")
);
assert_eq!(
crate::names::methods::CLASSIFY_PROVIDER_ENDPOINT2,
"ClassifyProviderEndpoint2"
);
assert!(crate::names::METHODS.contains(&crate::names::methods::CLASSIFY_PROVIDER_ENDPOINT2));
assert_eq!(crate::CONTRACT_VERSION, (1, 32));
assert!(crate::is_compatible((1, 32)));
assert!(!crate::is_compatible((1, 31)));
Ok(())
}
8 changes: 5 additions & 3 deletions crates/tinysecurity-bus/tests/scoped_wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ fn new_members_are_advertised_and_named_by_contract_constants() {
names::methods::LIST_RECENT_DECISIONS2,
names::methods::PURGE_APPROVAL_SESSION2,
names::methods::RECORD_AUDIT_ERROR2,
names::methods::CLASSIFY_PROVIDER_ENDPOINT2,
] {
assert!(names::METHODS.contains(&name), "{name}");
}
Expand All @@ -91,10 +92,11 @@ fn new_members_are_advertised_and_named_by_contract_constants() {

#[test]
fn contract_bump_is_additive_for_older_hosts_and_modules() {
assert_eq!(CONTRACT_VERSION, (1, 31));
// A host built against 1.31 accepts this module and newer minors, not a 1.30 module.
assert!(is_compatible((1, 31)));
assert_eq!(CONTRACT_VERSION, (1, 32));
// A host built against 1.32 accepts this module and newer minors, not a 1.31 module.
assert!(is_compatible((1, 32)));
assert!(is_compatible((1, 33)));
assert!(!is_compatible((1, 31)));
assert!(!is_compatible((1, 30)));
assert!(!is_compatible((1, 29)));
assert!(!is_compatible((1, 28)));
Expand Down
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/tests/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ fn redactor_init_is_additive_and_secrets_never_enter_public_info()
serde_json::to_value(&config)?["redactor"]["secrets"][0],
"literal-private"
);
assert_eq!(CONTRACT_VERSION, (1, 31));
assert_eq!(CONTRACT_VERSION, (1, 32));
for (mode, wire) in [
(RedactMode::Text, "text"),
(RedactMode::StructuredArguments, "structured_arguments"),
Expand Down
2 changes: 2 additions & 0 deletions crates/tinysecurity-egress/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
mod privacy;
mod privacy_check;
pub use privacy_check::privacy_verdict;
mod local_provider;
pub use local_provider::classify_provider_endpoint;
mod registry;
mod resolver;
mod url_guard;
Expand Down
46 changes: 46 additions & 0 deletions crates/tinysecurity-egress/src/local_provider.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Same-machine local provider endpoint classification for `LocalOnly` privacy mode.
use std::net::{Ipv4Addr, Ipv6Addr};
use tinysecurity_bus::ProviderEndpointLocality2;
use url::Host;

/// Classify a host literal for a local provider endpoint without performing DNS.
///
/// Only loopback and unspecified IP literals plus `localhost` names are considered
/// on-device. RFC 1918, link-local, IPv6 ULA and other LAN addresses remain external:
/// `LocalOnly` means that user data does not leave this machine, not merely this LAN.
/// Invalid or unrecognized hosts fail closed as external.
#[must_use]
pub fn classify_provider_endpoint(host: &str) -> ProviderEndpointLocality2 {
let Ok(host) = Host::parse(host) else {
return ProviderEndpointLocality2::External;
};
let on_device = match host {
Host::Ipv4(address) => local_v4(address),
Host::Ipv6(address) => local_v6(address),
Host::Domain(name) => {
let name = name.strip_suffix('.').unwrap_or(&name);
name.eq_ignore_ascii_case("localhost")
|| name.to_ascii_lowercase().ends_with(".localhost")
}
};
if on_device {
ProviderEndpointLocality2::OnDevice
} else {
ProviderEndpointLocality2::External
}
}

fn local_v4(address: Ipv4Addr) -> bool {
address.is_loopback() || address.is_unspecified()
}

fn local_v6(address: Ipv6Addr) -> bool {
address.to_ipv4_mapped().map_or_else(
|| address.is_loopback() || address.is_unspecified(),
local_v4,
)
}

#[cfg(test)]
#[path = "local_provider_tests.rs"]
mod tests;
39 changes: 39 additions & 0 deletions crates/tinysecurity-egress/src/local_provider_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use super::*;

use tinysecurity_bus::ProviderEndpointLocality2::{External, OnDevice};

#[test]
fn classifies_only_same_machine_reserved_provider_hosts_as_on_device() {
for host in [
"localhost",
"localhost.",
"worker.localhost",
"worker.localhost.",
"127.0.0.1",
"127.25.3.4",
"0.0.0.0",
"[::1]",
"[::]",
"[::ffff:127.0.0.1]",
] {
assert_eq!(classify_provider_endpoint(host), OnDevice, "{host}");
}
}

#[test]
fn treats_lan_and_non_loopback_reserved_addresses_as_external() {
for host in [
"192.168.1.5",
"10.0.0.8",
"172.16.0.2",
"169.254.1.2",
"[::ffff:192.168.1.5]",
"[fe80::1]",
"[fd00::1]",
"example.local",
"localhost.example",
"not a host",
] {
assert_eq!(classify_provider_endpoint(host), External, "{host}");
}
}
13 changes: 12 additions & 1 deletion crates/tinysecurity-module/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,17 @@ impl PolicyService {
.await
}

async fn classify_provider_endpoint2(
&self,
request: tinysecurity_bus::ProviderEndpointRequest2,
) -> BusResult<tinysecurity_bus::ProviderEndpointResponse2> {
request.validate().map_err(|error| failure(&error))?;
std::future::ready(Ok(tinysecurity_bus::ProviderEndpointResponse2 {
locality: tinysecurity::classify_provider_endpoint(&request.host),
}))
.await
}

async fn register_command_policy(
&self,
policy: tinysecurity_bus::CommandPolicy,
Expand Down Expand Up @@ -2205,7 +2216,7 @@ tinybus_module::module_export! {
config = ModuleConfig,
worker_threads = 1,
provides = ["ai.tinyhumans.tinysecurity.Policy"],
methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "DevicePairing2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "ListRecentDecisions2", "PurgeApprovalSession2", "RecordAuditError2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "GrantTool2", "RevokeTool2", "RevokeToolById2", "ListToolTrust2", "ImportTrustSource2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSecretScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "AuthorizeBrowserDial", "ConsumeBrowserDial", "ValidateBrowserDial", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"],
methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "DevicePairing2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "ListRecentDecisions2", "PurgeApprovalSession2", "RecordAuditError2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "GrantTool2", "RevokeTool2", "RevokeToolById2", "ListToolTrust2", "ImportTrustSource2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSecretScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "ClassifyProviderEndpoint2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "AuthorizeBrowserDial", "ConsumeBrowserDial", "ValidateBrowserDial", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"],
signals = ["PolicyChanged"],
requires = [],
optional = ["ai.tinyhumans.tinysecurity.KeyringConsent2", "ai.tinyhumans.tinysecurity.SecretCipherConsumer2", "ai.tinyhumans.tinysecurity.SecretBackend2", "ai.tinyhumans.tinysecurity.DevicePairingHost2", "ai.tinyhumans.tinysecurity.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"],
Expand Down
Loading
Loading