From abac519f85075b8d11c56f3c3ee5f9632d820722 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 15 Oct 2026 20:54:24 +0300 Subject: [PATCH 1/4] feat(redact): RedactWith for per-call exact credentials (contract 1.16) A trusted multi-tenant host redacts each tenant's own provider key from that tenant's output without a process-wide Reinit. The literals extend the initialization registry for one call, are bounded like it, and compiled registries are cached by a digest of the literals. Part of tinyhumansai/openhuman#7328 Co-authored-by: Medulla --- MODULE.md | 21 ++ README.md | 2 +- crates/tinysecurity-bus/src/lib.rs | 3 +- crates/tinysecurity-bus/src/names.rs | 3 + crates/tinysecurity-bus/src/redact.rs | 18 ++ crates/tinysecurity-bus/tests/scoped_wire.rs | 7 +- crates/tinysecurity-bus/tests/wire.rs | 31 ++- crates/tinysecurity-module/src/adapter.rs | 22 +- .../tinysecurity-module/src/adapter_tests.rs | 33 +++ crates/tinysecurity-redact/src/extended.rs | 100 ++++++++ .../tinysecurity-redact/src/extended_tests.rs | 222 ++++++++++++++++++ crates/tinysecurity-redact/src/lib.rs | 5 + crates/tinysecurity/src/lib.rs | 2 +- 13 files changed, 461 insertions(+), 8 deletions(-) create mode 100644 crates/tinysecurity-redact/src/extended.rs create mode 100644 crates/tinysecurity-redact/src/extended_tests.rs diff --git a/MODULE.md b/MODULE.md index fa4df38..3dacfbf 100644 --- a/MODULE.md +++ b/MODULE.md @@ -363,3 +363,24 @@ pins the returned sockets, so the grant is a preflight: DNS rebinding after the verdict is the executor's risk. `PinnedDirect` stays the only pinned transport, and a local capability never accepts a preflight transport. Both fields are omitted from the wire when unset, so existing policies serialize unchanged. + +## Per-call exact credentials: `RedactWith` (contract 1.16) + +Additive: a 1.15 host keeps working against a 1.16 module, and a 1.16 host +refuses a 1.15 module (it requires `RedactWith`). + +`RedactWith` takes a `RedactRequest` plus `secrets`, exact credentials that apply +to that call only. They supplement the initialization registry; they never +replace it, and the next call does not see them. A host that serves many tenants +(each with its own provider key) therefore redacts a tenant's own credential from +that tenant's output without a process-wide `Reinit`, which would serialize the +tenants and let one tenant's key set be visible to another's call. + +Limits match the initialization registry: at most 128 literals of 4 to 4096 bytes, +16384 bytes in all, none equal to or containing a redaction marker. A violation is +a refused request (`InvalidArguments`), never echoed. The module keeps a bounded +cache (32) of compiled registries keyed by a digest of the literals, so repeated +calls with the same key do not recompile the rule set; an evicted registry is +rebuilt on demand. The member is confidential: the literals are not logged or +observable on the bus. + diff --git a/README.md b/README.md index f65bcb7..fa79a9b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Blocked original content denies; suspicious content denies with approval-unavailable in unbound policy methods. Scan verdicts never grant permission, and redaction never clears an existing scan finding. -Contract 1.15 adds `RedactMode::CredentialsOnly` and the `EgressPolicy` fields `open_public` and `preflight_transports` (see [MODULE.md](MODULE.md#credentials-only-redaction-and-open-public-egress-contract-115)). Contract 1.14 adds confidential `PasswordCrypt2`, `KeyringAccess2` and `PairingControl2` (see [MODULE.md](MODULE.md)). Contract 1.13 added `EvaluateScopedAudited` and `CheckScopedAudited` for registered policies under required audit (see [`docs/specs/scoped-policies.md`](docs/specs/scoped-policies.md)). +Contract 1.16 adds `RedactWith`, redaction with exact credentials supplied for one call (see [MODULE.md](MODULE.md#per-call-exact-credentials-redactwith-contract-116)). Contract 1.15 adds `RedactMode::CredentialsOnly` and the `EgressPolicy` fields `open_public` and `preflight_transports` (see [MODULE.md](MODULE.md#credentials-only-redaction-and-open-public-egress-contract-115)). Contract 1.14 adds confidential `PasswordCrypt2`, `KeyringAccess2` and `PairingControl2` (see [MODULE.md](MODULE.md)). Contract 1.13 added `EvaluateScopedAudited` and `CheckScopedAudited` for registered policies under required audit (see [`docs/specs/scoped-policies.md`](docs/specs/scoped-policies.md)). Contract 1.6 supplies `Audit2`, `EvaluateAudited2` and `CheckAudited2` with authenticated exact audit callbacks and a bounded [native journal](crates/tinysecurity-audit/README.md). diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 8e9602f..56a2154 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -38,11 +38,12 @@ pub use future::*; pub use path_policy::*; pub use policy::*; pub use prompt::*; +pub use redact::RedactWithRequest; pub use redact::RedactorConfig; pub use scoped_policy::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 15); +pub const CONTRACT_VERSION: (u32, u32) = (1, 16); /// 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 e716715..bb78fb0 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -93,6 +93,8 @@ pub mod methods { pub const PARSE_REPLY: &str = "ParseReply"; /// Redact member. pub const REDACT: &str = "Redact"; + /// Redact with exact credentials supplied for this call only (contract 1.16). + pub const REDACT_WITH: &str = "RedactWith"; /// `ScanPrompt` member. pub const SCAN_PROMPT: &str = "ScanPrompt"; /// `ScanToolDefinition` member. @@ -155,6 +157,7 @@ pub const METHODS: &[&str] = &[ methods::URL_GUARD, methods::EGRESS_CHECK, methods::REDACT, + methods::REDACT_WITH, methods::SCAN_PROMPT, methods::SCAN_TOOL_DEFINITION, methods::AUDIT2, diff --git a/crates/tinysecurity-bus/src/redact.rs b/crates/tinysecurity-bus/src/redact.rs index e1eacf8..c8f2010 100644 --- a/crates/tinysecurity-bus/src/redact.rs +++ b/crates/tinysecurity-bus/src/redact.rs @@ -50,6 +50,24 @@ pub struct RedactRequest { /// Redaction surface. pub mode: RedactMode, } +/// `RedactWithRequest` payload (contract 1.16): a [`RedactRequest`] plus exact +/// credentials that apply to this call only. +/// +/// The extra literals supplement, never replace, the initialization registry: +/// one trusted host serving many tenants redacts each tenant's own credential +/// without a process-wide reinitialization. They are bounded like the init +/// registry (at most 128 literals of 4..=4096 bytes, 16384 bytes in all) and +/// never echoed; `Debug` shows only [`crate::Secret`]'s redacted form. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RedactWithRequest { + /// Sensitive input. + pub text: String, + /// Redaction surface. + pub mode: RedactMode, + /// Exact credentials for this call. + pub secrets: Vec, +} /// `RedactResponse` payload. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index ce24022..96f99b9 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -82,10 +82,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, 15)); - // A host built against 1.15 accepts this module and newer minors, not a 1.14 module. - assert!(is_compatible((1, 15))); + assert_eq!(CONTRACT_VERSION, (1, 16)); + // A host built against 1.16 accepts this module and newer minors, not a 1.15 module. assert!(is_compatible((1, 16))); + assert!(is_compatible((1, 17))); + assert!(!is_compatible((1, 15))); assert!(!is_compatible((1, 14))); assert!(!is_compatible((1, 13))); // A 1.12 module cannot supply the audited scoped members. diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 9fde23f..3e754e8 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, 15)); + assert_eq!(CONTRACT_VERSION, (1, 16)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), @@ -373,3 +373,32 @@ fn confidential_master_facade_is_additive_and_has_no_generic_secret_members() { assert!(!tinysecurity_bus::names::METHODS.contains(&absent)); } } + +#[test] +fn redact_with_request_carries_literals_without_echoing_them() +-> std::result::Result<(), serde_json::Error> { + let request = RedactWithRequest { + text: "see value".into(), + mode: RedactMode::CredentialsOnly, + secrets: vec![Secret("literal-private".into())], + }; + assert!(!format!("{request:?}").contains("literal-private")); + let wire = serde_json::to_value(&request)?; + assert_eq!(wire["mode"], "credentials_only"); + assert_eq!(wire["secrets"][0], "literal-private"); + assert_eq!(serde_json::from_value::(wire)?, request); + assert!( + serde_json::from_value::( + serde_json::json!({"text":"x","mode":"text","secrets":[],"extra":1}) + ) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({"text":"x","mode":"text"})) + .is_err(), + "secrets is required so a Redact payload is not mistaken for RedactWith" + ); + assert_eq!(names::methods::REDACT_WITH, "RedactWith"); + assert!(names::METHODS.contains(&names::methods::REDACT_WITH)); + Ok(()) +} diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 3bc1adf..8a1b40b 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -12,6 +12,7 @@ static PATH_SCOPES: OnceLock>> = OnceLock::new(); static COMMAND_SCOPES: OnceLock>> = OnceLock::new(); static SCOPED_POLICIES: OnceLock>> = OnceLock::new(); static EGRESS_SCOPES: OnceLock>> = OnceLock::new(); +static EXTENDED_REDACTORS: OnceLock> = OnceLock::new(); static AUDIT_CLIENT: OnceLock>> = OnceLock::new(); static HOST_BINDING: OnceLock = OnceLock::new(); static APPROVALS: OnceLock< @@ -41,6 +42,7 @@ struct PolicyService { paths: Arc>, commands: Arc>, egress: Arc>, + extended: Arc, scoped: Arc>, signals: Option, } @@ -578,6 +580,21 @@ impl PolicyService { .await .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))? } + #[tinybus(confidential)] + async fn redact_with( + &self, + request: tinysecurity_bus::RedactWithRequest, + ) -> BusResult { + let registry = self.read()?.redactor(); + let extended = Arc::clone(&self.extended); + tokio::task::spawn_blocking(move || { + extended + .redact(®istry, &request) + .map_err(|error| failure(&error)) + }) + .await + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))? + } async fn scan_prompt( &self, request: tinysecurity_bus::ScanPromptRequest, @@ -1117,6 +1134,9 @@ fn service_for( egress: Arc::clone( EGRESS_SCOPES.get_or_init(|| Arc::new(Mutex::new(EgressPolicyRegistry::default()))), ), + extended: Arc::clone( + EXTENDED_REDACTORS.get_or_init(|| Arc::new(tinysecurity::ExtendedRedactors::new())), + ), commands: Arc::clone( COMMAND_SCOPES.get_or_init(|| Arc::new(Mutex::new(CommandPolicyRegistry::default()))), ), @@ -1579,7 +1599,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", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "Audit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "ScanPrompt", "ScanToolDefinition"], + methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "Audit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanToolDefinition"], signals = ["PolicyChanged"], requires = [], optional = ["ai.tinyhumans.tinysecurity.KeyringConsent2", "ai.tinyhumans.tinysecurity.SecretCipherConsumer2", "ai.tinyhumans.tinysecurity.SecretBackend2", "ai.tinyhumans.tinysecurity.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], diff --git a/crates/tinysecurity-module/src/adapter_tests.rs b/crates/tinysecurity-module/src/adapter_tests.rs index 35e651d..177aecf 100644 --- a/crates/tinysecurity-module/src/adapter_tests.rs +++ b/crates/tinysecurity-module/src/adapter_tests.rs @@ -434,6 +434,7 @@ fn only_implemented_members_are_advertised() -> std::result::Result<(), Box( + names::methods::REDACT_WITH, + (tinysecurity_bus::RedactWithRequest { + text: "text".into(), + mode: RedactMode::Text, + secrets: vec![tinysecurity_bus::Secret("abc".into())], + },), + ) + .await + .err() + .ok_or("a too-short literal was accepted")?; + assert!(!refused.to_string().contains("abc")); let tool: ScanResponse = proxy .call( names::methods::SCAN_TOOL_DEFINITION, @@ -1060,6 +1089,7 @@ async fn verify_redaction_dispatch( assert_eq!(tool.verdict, ScanVerdict::Blocked); for method in [ names::methods::REDACT, + names::methods::REDACT_WITH, names::methods::SCAN_PROMPT, names::methods::SCAN_TOOL_DEFINITION, ] { @@ -1292,6 +1322,7 @@ async fn native_egress_dispatch_binds_scope_and_denies_legacy_or_generic_local_c paths: Arc::new(Mutex::new(PathPolicyRegistry::default())), commands: Arc::new(Mutex::new(CommandPolicyRegistry::default())), egress: Arc::new(Mutex::new(EgressPolicyRegistry::default())), + extended: Arc::new(tinysecurity::ExtendedRedactors::new()), scoped: Arc::new(Mutex::new(ScopedPolicyRegistry::default())), signals: None, }, @@ -1392,6 +1423,7 @@ async fn native_open_public_policy_and_credentials_only_redaction_dispatch() paths: Arc::new(Mutex::new(PathPolicyRegistry::default())), commands: Arc::new(Mutex::new(CommandPolicyRegistry::default())), egress: Arc::new(Mutex::new(EgressPolicyRegistry::default())), + extended: Arc::new(tinysecurity::ExtendedRedactors::new()), scoped: Arc::new(Mutex::new(ScopedPolicyRegistry::default())), signals: None, }, @@ -1536,6 +1568,7 @@ async fn consumption_work_runs_off_the_sole_sdk_worker_without_registry_locks() paths: Arc::new(Mutex::new(PathPolicyRegistry::default())), commands: Arc::new(Mutex::new(CommandPolicyRegistry::default())), egress: Arc::new(Mutex::new(EgressPolicyRegistry::default())), + extended: Arc::new(tinysecurity::ExtendedRedactors::new()), scoped: Arc::new(Mutex::new(ScopedPolicyRegistry::default())), signals: None, }; diff --git a/crates/tinysecurity-redact/src/extended.rs b/crates/tinysecurity-redact/src/extended.rs new file mode 100644 index 0000000..dc2dc98 --- /dev/null +++ b/crates/tinysecurity-redact/src/extended.rs @@ -0,0 +1,100 @@ +//! Per-call exact credentials: [`ExtendedRedactors`] answers `RedactWith`. +//! +//! A call's literals extend the initialization registry for that call only. +//! Compiling the full rule set is not free, so the extended registries are kept +//! in a small bounded cache keyed by a digest of the literals (never the +//! literals themselves). A registry is immutable once built; an evicted one is +//! rebuilt by the next call that needs it, so eviction costs time, not safety. +use crate::Redactor; +use sha2::{Digest, Sha256}; +use std::{collections::VecDeque, sync::Mutex}; +use tinysecurity_bus::{ + Error, RedactRequest, RedactResponse, RedactWithRequest, RedactorConfig, Result, Secret, +}; + +/// Most extended registries kept at once. +pub(crate) const EXTENDED_CAPACITY: usize = 32; + +/// Bounded cache of redactors extended with per-call literals. +#[derive(Debug, Default)] +pub struct ExtendedRedactors { + entries: Mutex>, +} + +impl ExtendedRedactors { + /// An empty cache. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Redact `request` with `base` extended by the request's own literals. + /// + /// With no literals this is exactly `base.redact`. + /// + /// # Errors + /// `InvalidArguments` when the literals break the registry bounds (the + /// caller supplied them, so this is a bad request, not a bad configuration); + /// otherwise whatever [`Redactor::redact`] rejects. `Faulted` if the cache + /// lock was poisoned. + pub fn redact(&self, base: &Redactor, request: &RedactWithRequest) -> Result { + let plain = RedactRequest { + text: request.text.clone(), + mode: request.mode.clone(), + }; + if request.secrets.is_empty() { + return base.redact(&plain); + } + self.extended(base, &request.secrets)?.redact(&plain) + } + + fn extended(&self, base: &Redactor, extra: &[Secret]) -> Result { + let key = fingerprint(&base.0.base, extra); + let mut entries = self.entries.lock().map_err(|_| Error::Faulted)?; + if let Some((_, redactor)) = entries.iter().find(|(known, _)| *known == key) { + return Ok(redactor.clone()); + } + let mut secrets = base.0.base.secrets.clone(); + secrets.extend(extra.iter().cloned()); + let built = Redactor::new(&RedactorConfig { secrets }).map_err(|error| match error { + Error::InvalidConfig => Error::InvalidArguments, + other => other, + })?; + if entries.len() >= EXTENDED_CAPACITY { + entries.pop_front(); + } + entries.push_back((key, built.clone())); + Ok(built) + } + + /// How many extended registries are cached. + #[must_use] + pub fn len(&self) -> usize { + self.entries.lock().map_or(0, |entries| entries.len()) + } + + /// Whether no extended registry is cached. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// Digest of the initialization literals and the call's, length-prefixed so two +/// different splits never collide. +fn fingerprint(base: &RedactorConfig, extra: &[Secret]) -> [u8; 32] { + let mut hasher = Sha256::new(); + for (tag, secrets) in [(b'b', base.secrets.as_slice()), (b'x', extra)] { + hasher.update([tag]); + hasher.update((secrets.len() as u64).to_be_bytes()); + for secret in secrets { + hasher.update((secret.0.len() as u64).to_be_bytes()); + hasher.update(secret.0.as_bytes()); + } + } + hasher.finalize().into() +} + +#[cfg(test)] +#[path = "extended_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-redact/src/extended_tests.rs b/crates/tinysecurity-redact/src/extended_tests.rs new file mode 100644 index 0000000..1ae06a8 --- /dev/null +++ b/crates/tinysecurity-redact/src/extended_tests.rs @@ -0,0 +1,222 @@ +use super::*; +use tinysecurity_bus::RedactMode; + +fn base(secrets: &[&str]) -> Result { + Redactor::new(&RedactorConfig { + secrets: secrets.iter().map(|s| Secret((*s).into())).collect(), + }) +} + +fn request(text: &str, mode: RedactMode, secrets: &[&str]) -> RedactWithRequest { + RedactWithRequest { + text: text.into(), + mode, + secrets: secrets.iter().map(|s| Secret((*s).into())).collect(), + } +} + +#[test] +fn a_call_literal_is_removed_in_every_text_mode() -> Result<()> { + let cache = ExtendedRedactors::new(); + let registry = base(&[])?; + for mode in [RedactMode::Text, RedactMode::CredentialsOnly] { + let out = cache.redact( + ®istry, + &request("key=tenant-key-12345 ok", mode, &["tenant-key-12345"]), + )?; + assert!(!out.text.contains("tenant-key-12345"), "{}", out.text); + assert!(out.changed); + } + Ok(()) +} + +#[test] +fn a_call_literal_is_removed_from_structured_arguments_too() -> Result<()> { + let cache = ExtendedRedactors::new(); + let out = cache.redact( + &base(&[])?, + &request( + r#"{"note":"see tenant-key-12345 here"}"#, + RedactMode::StructuredLogArguments, + &["tenant-key-12345"], + ), + )?; + assert!(!out.text.contains("tenant-key-12345"), "{}", out.text); + Ok(()) +} + +#[test] +fn without_literals_the_answer_is_the_plain_registry_answer() -> Result<()> { + let cache = ExtendedRedactors::new(); + let registry = base(&[])?; + let out = cache.redact( + ®istry, + &request("hello world", RedactMode::CredentialsOnly, &[]), + )?; + assert_eq!(out.text, "hello world"); + assert!(!out.changed); + assert!( + cache.is_empty(), + "no extended registry is built for no literals" + ); + Ok(()) +} + +#[test] +fn call_literals_supplement_the_initialization_registry() -> Result<()> { + let cache = ExtendedRedactors::new(); + let registry = base(&["init-secret-9999"])?; + let out = cache.redact( + ®istry, + &request( + "init-secret-9999 and call-secret-8888", + RedactMode::CredentialsOnly, + &["call-secret-8888"], + ), + )?; + assert!(!out.text.contains("init-secret-9999")); + assert!(!out.text.contains("call-secret-8888")); + Ok(()) +} + +#[test] +fn one_calls_literal_does_not_leak_into_the_next_call() -> Result<()> { + let cache = ExtendedRedactors::new(); + let registry = base(&[])?; + let first = cache.redact( + ®istry, + &request( + "a tenant-a-key-1111", + RedactMode::CredentialsOnly, + &["tenant-a-key-1111"], + ), + )?; + assert!(!first.text.contains("tenant-a-key-1111")); + let second = cache.redact( + ®istry, + &request( + "b tenant-a-key-1111", + RedactMode::CredentialsOnly, + &["tenant-b-key-2222"], + ), + )?; + assert!( + second.text.contains("tenant-a-key-1111"), + "tenant A's literal must not apply to B's call" + ); + Ok(()) +} + +#[test] +fn the_same_literals_reuse_one_cached_registry() -> Result<()> { + let cache = ExtendedRedactors::new(); + let registry = base(&[])?; + for _ in 0..3 { + cache.redact( + ®istry, + &request("x", RedactMode::Text, &["reused-key-1234"]), + )?; + } + assert_eq!(cache.len(), 1); + Ok(()) +} + +#[test] +fn a_different_initialization_registry_never_shares_a_cached_entry() -> Result<()> { + let cache = ExtendedRedactors::new(); + cache.redact( + &base(&[])?, + &request("x", RedactMode::Text, &["shared-key-1234"]), + )?; + cache.redact( + &base(&["init-secret-9999"])?, + &request("x", RedactMode::Text, &["shared-key-1234"]), + )?; + assert_eq!(cache.len(), 2); + Ok(()) +} + +#[test] +fn the_cache_is_bounded_and_an_evicted_registry_is_rebuilt() -> Result<()> { + let cache = ExtendedRedactors::new(); + let registry = base(&[])?; + for i in 0..(EXTENDED_CAPACITY + 8) { + let key = format!("bounded-key-{i:04}"); + let out = cache.redact( + ®istry, + &request(&format!("v {key}"), RedactMode::Text, &[key.as_str()]), + )?; + assert!(!out.text.contains(&key)); + } + assert_eq!(cache.len(), EXTENDED_CAPACITY); + let first = "bounded-key-0000"; + let out = cache.redact( + ®istry, + &request(&format!("v {first}"), RedactMode::Text, &[first]), + )?; + assert!( + !out.text.contains(first), + "an evicted literal is rebuilt, not skipped" + ); + Ok(()) +} + +#[test] +fn out_of_bounds_literals_are_a_bad_request_and_nothing_is_cached() -> Result<()> { + let cache = ExtendedRedactors::new(); + let registry = base(&[])?; + for secrets in [ + vec!["abc".to_string()], + vec!["x".repeat(4097)], + (0..129) + .map(|i| format!("many-keys-{i:04}")) + .collect::>(), + vec!["[REDACTED]".to_string()], + ] { + let refs: Vec<&str> = secrets.iter().map(String::as_str).collect(); + let error = cache + .redact(®istry, &request("text", RedactMode::Text, &refs)) + .err() + .ok_or(Error::Faulted)?; + assert!(matches!(error, Error::InvalidArguments), "{error:?}"); + } + assert!(cache.is_empty()); + Ok(()) +} + +#[test] +fn oversized_text_is_still_refused_with_literals() -> Result<()> { + let cache = ExtendedRedactors::new(); + let registry = base(&[])?; + let big = "x".repeat(crate::MAX_INPUT + 1); + assert!( + cache + .redact( + ®istry, + &request(&big, RedactMode::Text, &["some-key-1234"]) + ) + .is_err() + ); + Ok(()) +} + +#[test] +fn a_poisoned_cache_faults_instead_of_answering() -> Result<()> { + let cache = std::sync::Arc::new(ExtendedRedactors::new()); + let held = std::sync::Arc::clone(&cache); + let _ = std::thread::spawn(move || { + let _guard = held.entries.lock(); + std::panic::resume_unwind(Box::new("poison the cache")); + }) + .join(); + let error = cache + .redact( + &base(&[])?, + &request("x", RedactMode::Text, &["poisoned-key-1"]), + ) + .err() + .ok_or(Error::Faulted)?; + assert!(matches!(error, Error::Faulted), "{error:?}"); + assert_eq!(cache.len(), 0); + Ok(()) +} diff --git a/crates/tinysecurity-redact/src/lib.rs b/crates/tinysecurity-redact/src/lib.rs index 04bfd91..b5640c0 100644 --- a/crates/tinysecurity-redact/src/lib.rs +++ b/crates/tinysecurity-redact/src/lib.rs @@ -13,6 +13,7 @@ //! assert!(!result.text.contains("private-token")); //! # Ok::<(), tinysecurity_bus::Error>(()) //! ``` +mod extended; mod patterns; mod prompt; mod structured; @@ -27,12 +28,15 @@ pub(crate) const MAX_INPUT: usize = 65_536; pub(crate) const MAX_OUTPUT: usize = 131_072; pub(crate) const MAX_DEPTH: usize = 32; pub(crate) const MARKER: &str = "[REDACTED]"; +pub use extended::ExtendedRedactors; /// Immutable shared matching registry; Debug intentionally exposes no patterns or secrets. #[derive(Clone)] pub struct Redactor(Arc); struct Registry { patterns: patterns::Patterns, scanner: prompt::Scanner, + /// The initialization literals, kept so a per-call extension can union them. + base: RedactorConfig, } impl fmt::Debug for Redactor { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -49,6 +53,7 @@ impl Redactor { Ok(Self(Arc::new(Registry { patterns: patterns::Patterns::new(config)?, scanner: prompt::Scanner::new()?, + base: config.clone(), }))) } /// Sanitize a bounded surface. Failed sanitation returns no original payload. diff --git a/crates/tinysecurity/src/lib.rs b/crates/tinysecurity/src/lib.rs index 508b691..d773a31 100644 --- a/crates/tinysecurity/src/lib.rs +++ b/crates/tinysecurity/src/lib.rs @@ -1,7 +1,7 @@ //! Internal implementation umbrella. Hosts must depend on tinysecurity-bus only. pub use tinysecurity_policy::*; -pub use tinysecurity_redact::Redactor; +pub use tinysecurity_redact::{ExtendedRedactors, Redactor}; pub use tinysecurity_egress::*; From 6702cd7ff3747a0c5aee55185f4f4180f3747997 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 15 Oct 2026 20:57:04 +0300 Subject: [PATCH 2/4] test(redact): pin RedactWith confidentiality and the attested native answer Co-authored-by: Medulla --- .../examples/audit_native_contract.rs | 25 +++++++++++++++++++ .../tinysecurity-module/src/adapter_tests.rs | 22 +++++----------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/crates/tinysecurity-module/examples/audit_native_contract.rs b/crates/tinysecurity-module/examples/audit_native_contract.rs index 86894e1..15cb1bf 100644 --- a/crates/tinysecurity-module/examples/audit_native_contract.rs +++ b/crates/tinysecurity-module/examples/audit_native_contract.rs @@ -515,6 +515,31 @@ async fn verify_permission_phases( .call_confidential(names::methods::CHECK_AUDITED2, (before.clone(),)) .await?; assert!(permit.audit.permitted && permit.audit.committed); + // RedactWith carries a tenant's exact credential for this call only and is + // confidential: the attested native module answers it, the literal is gone, + // and the next call does not remember it. + let with: tinysecurity_bus::RedactResponse = proxy + .call_confidential( + names::methods::REDACT_WITH, + (tinysecurity_bus::RedactWithRequest { + text: "call tenant-key-12345 ok".into(), + mode: tinysecurity_bus::RedactMode::CredentialsOnly, + secrets: vec![tinysecurity_bus::Secret("tenant-key-12345".into())], + },), + ) + .await?; + assert_eq!(with.text, "call [REDACTED] ok"); + let forgotten: tinysecurity_bus::RedactResponse = proxy + .call_confidential( + names::methods::REDACT_WITH, + (tinysecurity_bus::RedactWithRequest { + text: "call tenant-key-12345 ok".into(), + mode: tinysecurity_bus::RedactMode::CredentialsOnly, + secrets: vec![], + },), + ) + .await?; + assert_eq!(forgotten.text, "call tenant-key-12345 ok"); if matches!(permit.decision.decisions[0].verdict, Verdict::Allow) { executions += 1; } diff --git a/crates/tinysecurity-module/src/adapter_tests.rs b/crates/tinysecurity-module/src/adapter_tests.rs index 177aecf..fb6b60f 100644 --- a/crates/tinysecurity-module/src/adapter_tests.rs +++ b/crates/tinysecurity-module/src/adapter_tests.rs @@ -1052,31 +1052,22 @@ async fn verify_redaction_dispatch( .err() .ok_or("malformed mode accepted")?; assert!(!error.to_string().contains("private-value")); - let with: RedactResponse = proxy - .call( - names::methods::REDACT_WITH, - (tinysecurity_bus::RedactWithRequest { - text: "call tenant-key-12345 ok".into(), - mode: RedactMode::CredentialsOnly, - secrets: vec![tinysecurity_bus::Secret("tenant-key-12345".into())], - },), - ) - .await?; - assert_eq!(with.text, "call [REDACTED] ok"); - assert!(with.changed); + // RedactWith carries exact credentials, so it is confidential: an + // unattested in-process proxy is refused and nothing is echoed. The attested + // answer is pinned by the native contract example. let refused = proxy .call::( names::methods::REDACT_WITH, (tinysecurity_bus::RedactWithRequest { text: "text".into(), mode: RedactMode::Text, - secrets: vec![tinysecurity_bus::Secret("abc".into())], + secrets: vec![tinysecurity_bus::Secret("tenant-key-12345".into())], },), ) .await .err() - .ok_or("a too-short literal was accepted")?; - assert!(!refused.to_string().contains("abc")); + .ok_or("RedactWith answered a non-confidential call")?; + assert!(!refused.to_string().contains("tenant-key-12345")); let tool: ScanResponse = proxy .call( names::methods::SCAN_TOOL_DEFINITION, @@ -1089,7 +1080,6 @@ async fn verify_redaction_dispatch( assert_eq!(tool.verdict, ScanVerdict::Blocked); for method in [ names::methods::REDACT, - names::methods::REDACT_WITH, names::methods::SCAN_PROMPT, names::methods::SCAN_TOOL_DEFINITION, ] { From eba4927b8b241947726e2c4f8792cf9a8e951a30 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 15 Oct 2026 20:57:31 +0300 Subject: [PATCH 3/4] style(redact): move the RedactWith native check into its own function Co-authored-by: Medulla --- .../examples/audit_native_contract.rs | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/crates/tinysecurity-module/examples/audit_native_contract.rs b/crates/tinysecurity-module/examples/audit_native_contract.rs index 15cb1bf..b19857c 100644 --- a/crates/tinysecurity-module/examples/audit_native_contract.rs +++ b/crates/tinysecurity-module/examples/audit_native_contract.rs @@ -447,6 +447,36 @@ async fn verify_unbound_authorizations( Ok(()) } +/// `RedactWith` carries a tenant's exact credential for this call only and is +/// confidential: the attested native module answers it, the literal is gone, +/// and the next call does not remember it. +async fn verify_redact_with( + proxy: &tinybus::Proxy, +) -> std::result::Result<(), Box> { + let with: tinysecurity_bus::RedactResponse = proxy + .call_confidential( + names::methods::REDACT_WITH, + (tinysecurity_bus::RedactWithRequest { + text: "call tenant-key-12345 ok".into(), + mode: tinysecurity_bus::RedactMode::CredentialsOnly, + secrets: vec![tinysecurity_bus::Secret("tenant-key-12345".into())], + },), + ) + .await?; + assert_eq!(with.text, "call [REDACTED] ok"); + let forgotten: tinysecurity_bus::RedactResponse = proxy + .call_confidential( + names::methods::REDACT_WITH, + (tinysecurity_bus::RedactWithRequest { + text: "call tenant-key-12345 ok".into(), + mode: tinysecurity_bus::RedactMode::CredentialsOnly, + secrets: vec![], + },), + ) + .await?; + assert_eq!(forgotten.text, "call tenant-key-12345 ok"); + Ok(()) +} async fn verify_permission_phases( proxy: &tinybus::Proxy, mode: &AtomicU8, @@ -515,31 +545,7 @@ async fn verify_permission_phases( .call_confidential(names::methods::CHECK_AUDITED2, (before.clone(),)) .await?; assert!(permit.audit.permitted && permit.audit.committed); - // RedactWith carries a tenant's exact credential for this call only and is - // confidential: the attested native module answers it, the literal is gone, - // and the next call does not remember it. - let with: tinysecurity_bus::RedactResponse = proxy - .call_confidential( - names::methods::REDACT_WITH, - (tinysecurity_bus::RedactWithRequest { - text: "call tenant-key-12345 ok".into(), - mode: tinysecurity_bus::RedactMode::CredentialsOnly, - secrets: vec![tinysecurity_bus::Secret("tenant-key-12345".into())], - },), - ) - .await?; - assert_eq!(with.text, "call [REDACTED] ok"); - let forgotten: tinysecurity_bus::RedactResponse = proxy - .call_confidential( - names::methods::REDACT_WITH, - (tinysecurity_bus::RedactWithRequest { - text: "call tenant-key-12345 ok".into(), - mode: tinysecurity_bus::RedactMode::CredentialsOnly, - secrets: vec![], - },), - ) - .await?; - assert_eq!(forgotten.text, "call tenant-key-12345 ok"); + verify_redact_with(proxy).await?; if matches!(permit.decision.decisions[0].verdict, Verdict::Allow) { executions += 1; } From 0e2a7222dd52fbbe61526717896b0eb0e6c2ceb0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 15 Oct 2026 21:26:20 +0300 Subject: [PATCH 4/4] test(module): cover the RedactWith member body directly The member is confidential, so in-process dispatch cannot call it; the changed-line coverage gate needs the body exercised in a unit test. Co-authored-by: Medulla --- .../tinysecurity-module/src/adapter_tests.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/tinysecurity-module/src/adapter_tests.rs b/crates/tinysecurity-module/src/adapter_tests.rs index fb6b60f..d2ee9fc 100644 --- a/crates/tinysecurity-module/src/adapter_tests.rs +++ b/crates/tinysecurity-module/src/adapter_tests.rs @@ -1814,3 +1814,56 @@ async fn assert_invalid_sdk_reinitializations( ); Ok(()) } + +/// `RedactWith` is confidential on the bus, so the in-process dispatch tests +/// cannot call it; the member body is exercised directly here and over the +/// attested bus in the native contract example. +#[tokio::test] +async fn redact_with_removes_call_literals_and_refuses_bad_ones_without_echo() +-> std::result::Result<(), Box> { + let service = PolicyService { + device: None, + crypto: None, + secret: None, + secret_cipher: None, + sandbox_clock: Arc::new(tinysecurity::approval::SystemClock::default()), + sandbox: None, + approvals: None, + audit: None, + policy: Arc::new(RwLock::new(Policy::new(ModuleConfig::default())?)), + paths: Arc::new(Mutex::new(PathPolicyRegistry::default())), + commands: Arc::new(Mutex::new(CommandPolicyRegistry::default())), + egress: Arc::new(Mutex::new(EgressPolicyRegistry::default())), + extended: Arc::new(tinysecurity::ExtendedRedactors::new()), + scoped: Arc::new(Mutex::new(ScopedPolicyRegistry::default())), + signals: None, + }; + let redacted = service + .redact_with(tinysecurity_bus::RedactWithRequest { + text: "call tenant-key-12345 ok".into(), + mode: RedactMode::CredentialsOnly, + secrets: vec![tinysecurity_bus::Secret("tenant-key-12345".into())], + }) + .await?; + assert_eq!(redacted.text, "call [REDACTED] ok"); + assert!(redacted.changed); + let plain = service + .redact_with(tinysecurity_bus::RedactWithRequest { + text: "call tenant-key-12345 ok".into(), + mode: RedactMode::CredentialsOnly, + secrets: vec![], + }) + .await?; + assert_eq!(plain.text, "call tenant-key-12345 ok"); + let refused = service + .redact_with(tinysecurity_bus::RedactWithRequest { + text: "text".into(), + mode: RedactMode::Text, + secrets: vec![tinysecurity_bus::Secret("abc".into())], + }) + .await + .err() + .ok_or("a too-short literal was accepted")?; + assert!(!refused.to_string().contains("abc")); + Ok(()) +}