diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index d78ee5b..576169e 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -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))); } diff --git a/crates/tinysecurity-bus/src/device_pairing_tests.rs b/crates/tinysecurity-bus/src/device_pairing_tests.rs index b741397..ffd9050 100644 --- a/crates/tinysecurity-bus/src/device_pairing_tests.rs +++ b/crates/tinysecurity-bus/src/device_pairing_tests.rs @@ -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] diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 83c8d02..8d8121b 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -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; @@ -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 diff --git a/crates/tinysecurity-bus/src/names.rs b/crates/tinysecurity-bus/src/names.rs index d12e6bd..b1c815e 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -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. @@ -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, diff --git a/crates/tinysecurity-bus/src/provider_endpoint.rs b/crates/tinysecurity-bus/src/provider_endpoint.rs new file mode 100644 index 0000000..b831580 --- /dev/null +++ b/crates/tinysecurity-bus/src/provider_endpoint.rs @@ -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; diff --git a/crates/tinysecurity-bus/src/provider_endpoint_tests.rs b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs new file mode 100644 index 0000000..cf73cf1 --- /dev/null +++ b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs @@ -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> { + 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::(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::(encoded)?, + response + ); + Ok(()) +} + +#[test] +fn provider_endpoint_locality_has_stable_wire_names_and_method() +-> std::result::Result<(), Box> { + 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(()) +} diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index e651450..019a9d9 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -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}"); } @@ -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))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 70bb482..765452f 100644 --- a/crates/tinysecurity-bus/tests/wire.rs +++ b/crates/tinysecurity-bus/tests/wire.rs @@ -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"), diff --git a/crates/tinysecurity-egress/src/lib.rs b/crates/tinysecurity-egress/src/lib.rs index 33c7357..7cc8b4a 100644 --- a/crates/tinysecurity-egress/src/lib.rs +++ b/crates/tinysecurity-egress/src/lib.rs @@ -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; diff --git a/crates/tinysecurity-egress/src/local_provider.rs b/crates/tinysecurity-egress/src/local_provider.rs new file mode 100644 index 0000000..cfebde9 --- /dev/null +++ b/crates/tinysecurity-egress/src/local_provider.rs @@ -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; diff --git a/crates/tinysecurity-egress/src/local_provider_tests.rs b/crates/tinysecurity-egress/src/local_provider_tests.rs new file mode 100644 index 0000000..d83ad0f --- /dev/null +++ b/crates/tinysecurity-egress/src/local_provider_tests.rs @@ -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}"); + } +} diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index b989130..cd8d42d 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -621,6 +621,17 @@ impl PolicyService { .await } + async fn classify_provider_endpoint2( + &self, + request: tinysecurity_bus::ProviderEndpointRequest2, + ) -> BusResult { + 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, @@ -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"], diff --git a/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs b/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs index 522a517..00d7885 100644 --- a/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs +++ b/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs @@ -545,6 +545,67 @@ async fn privacy_check_decides_and_reports_the_live_generation() -> TestResult { Ok(()) } +#[tokio::test] +async fn provider_endpoint_classification_is_locality_only_not_lan_reachability() -> TestResult { + let service = service()?; + for (host, expected) in [ + ( + "localhost", + tinysecurity_bus::ProviderEndpointLocality2::OnDevice, + ), + ( + "[::ffff:127.0.0.1]", + tinysecurity_bus::ProviderEndpointLocality2::OnDevice, + ), + ( + "192.168.1.12", + tinysecurity_bus::ProviderEndpointLocality2::External, + ), + ( + "[fd00::12]", + tinysecurity_bus::ProviderEndpointLocality2::External, + ), + ] { + let response: tinysecurity_bus::ProviderEndpointResponse2 = call( + &service, + names::methods::CLASSIFY_PROVIDER_ENDPOINT2, + tinysecurity_bus::ProviderEndpointRequest2 { host: host.into() }, + ) + .await?; + assert_eq!(response.locality, expected, "{host}"); + let mut privacy_request = + privacy(PrivacyMode::LocalOnly, PrivacyDataClass2::UserContent, None); + privacy_request.external = + expected == tinysecurity_bus::ProviderEndpointLocality2::External; + let privacy_response: PrivacyCheckResponse2 = + call(&service, names::methods::PRIVACY_CHECK2, privacy_request).await?; + let expected_verdict = if expected == tinysecurity_bus::ProviderEndpointLocality2::OnDevice + { + Verdict::Allow + } else { + Verdict::Deny { + reason: DenialReason::Privacy, + } + }; + assert_eq!( + privacy_response.decision.verdict, expected_verdict, + "{host}" + ); + } + assert!( + call::<_, tinysecurity_bus::ProviderEndpointResponse2>( + &service, + names::methods::CLASSIFY_PROVIDER_ENDPOINT2, + tinysecurity_bus::ProviderEndpointRequest2 { + host: "bad host".into(), + }, + ) + .await + .is_err() + ); + Ok(()) +} + #[tokio::test] async fn host_action_class_does_not_depend_on_audit_configuration() -> TestResult { // Without a required audit configured the members answer; the informational diff --git a/docs/README.md b/docs/README.md index 0c55fea..878ecac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,8 @@ docs/ - **[`module-crypto.md`](module-crypto.md)** — confidential device, master, cipher, password, keyring and pairing members of the TinySecurity module contract. - **[`module-subject-policies.md`](module-subject-policies.md)** — registered subject policy members and their generation-bound authorization rules. - **[`specs/device-pairing-contract-1.25.md`](specs/device-pairing-contract-1.25.md)** — host-attested paired-device lifecycle contract and trust boundaries. +- **[`specs/local-provider-endpoint-1.32.md`](specs/local-provider-endpoint-1.32.md)** — same-machine-only provider locality for `LocalOnly` privacy checks. +- **[`plans/local-provider-endpoint-1.32.md`](plans/local-provider-endpoint-1.32.md)** — ordered implementation and verification steps for provider locality classification. - **[`plans/device-pairing-contract-1.25.md`](plans/device-pairing-contract-1.25.md)** — ordered implementation and verification steps for pairing lifecycle support. - **[`specs/browser-dial-contract-1.28.md`](specs/browser-dial-contract-1.28.md)** — destination-only, one-use socket authorization for opaque browser traffic. - **[`specs/runtime-secret-scopes-contract-1.29.md`](specs/runtime-secret-scopes-contract-1.29.md)** — host-only runtime secret registration bound to live approval scopes. diff --git a/docs/egress.md b/docs/egress.md index 8e8416d..9005ad8 100644 --- a/docs/egress.md +++ b/docs/egress.md @@ -12,7 +12,15 @@ A later authenticated TinyBus sender contract will enforce that channel boundary additionally supports an explicit host-local service capability. That capability is an exact loopback IP origin, path and independently constructed host metadata category; it never grants a generic agent URL access to private addresses. -`LocalOnly` blocks nonlocal requests before DNS. Standard requires a declared +`LocalOnly` blocks nonlocal requests before DNS. Hosts classify configured +provider endpoint hosts through `ClassifyProviderEndpoint2`: only loopback, +unspecified and `localhost` names are on-device. RFC 1918/LAN, link-local and +IPv6 unique-local hosts are external because they can name another device; no +DNS lookup is used to classify names. If classification fails, treat the +endpoint as external. See the +[contract 1.32 specification](specs/local-provider-endpoint-1.32.md). +`Sensitive` denies unassessed external user content; it allows only assessed +`none`/`low` user content with no identifying categories. Standard requires a declared endpoint and data category. Sensitive additionally refuses credentials, identifiers, unknown classification and original payloads, URLs or headers the shared redactor changes. The descriptor is the registered policy's diff --git a/docs/plans/local-provider-endpoint-1.32.md b/docs/plans/local-provider-endpoint-1.32.md new file mode 100644 index 0000000..eb126d9 --- /dev/null +++ b/docs/plans/local-provider-endpoint-1.32.md @@ -0,0 +1,15 @@ +# Local provider endpoint implementation plan + +1. Add the transport-free 1.32 request and response types and the advertised + `ClassifyProviderEndpoint2` method name. Pin bounds, wire names and contract + compatibility in bus tests. +2. Implement host-literal classification in `tinysecurity-egress`: parse with + the existing URL dependency, avoid DNS, classify same-machine-reserved + hosts only, and test loopback, mapped loopback, unspecified, LAN, ULA, + link-local, names and malformed input. +3. Dispatch the method from the native module adapter and exercise it through + the TinyBus service tests, including invalid bounded input. +4. Document how hosts feed the resulting locality into `PrivacyCheck2`, the + fail-closed behavior, and the existing `Sensitive` assessment rule. +5. Run the focused bus, egress and module tests, then workspace fmt, Clippy, + build and all-feature tests before opening the fork PR. diff --git a/docs/specs/local-provider-endpoint-1.32.md b/docs/specs/local-provider-endpoint-1.32.md new file mode 100644 index 0000000..7dca808 --- /dev/null +++ b/docs/specs/local-provider-endpoint-1.32.md @@ -0,0 +1,25 @@ +# Local provider endpoint classification (contract 1.32) + +`ClassifyProviderEndpoint2` gives a host the module-owned locality decision used +by `LocalOnly` privacy checks. The request contains only the host component +returned by the host URL parser. TinySecurity performs no DNS lookup and does +not receive the provider URL's path, query, fragment, credentials or payload. + +Only loopback and unspecified IP literals, IPv4-mapped loopback/unspecified +addresses, and `localhost` names (including subdomains) classify as `on_device`. +RFC 1918 addresses, IPv4 and IPv6 link-local addresses, IPv6 unique-local +addresses, and all other names classify as `external`. A private or link-local +address can name another device on the network; `LocalOnly` means the provider +is on this machine, not merely on the same LAN. The classifier does not resolve +names, so DNS aliases cannot turn an external hostname into a local endpoint. +Malformed or unrecognized host literals classify as external. + +Hosts use the result as the `external` fact supplied to `PrivacyCheck2`. If the +classification call fails or the endpoint cannot be parsed, the host must treat +the transfer as external. The method is informational and does not authorize a +network connection or replace transport address validation. + +The existing `Sensitive` privacy decision remains stricter than LocalOnly for +external user content: unassessed user content is denied; only a `none` or +`low` assessment with no identifying categories is allowed. Metadata remains +allowed, while control-plane requests are exempt in every mode.