Skip to content
Merged
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
21 changes: 21 additions & 0 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required spec and implementation plan

This introduces contract 1.16 and its security-sensitive per-call credential semantics directly in implementation/module documentation, but a repository-wide search finds no RedactWith behavior in docs/specs/ or docs/plans/. Add the accepted behavior and constraints to a spec, then link an implementation-ordered plan, so this contract change follows the repository's required design sequence and has a durable source for its confidentiality, isolation, and limit invariants.

AGENTS.md reference: AGENTS.md:L164-L166

Useful? React with 👍 / 👎.


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.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion crates/tinysecurity-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions crates/tinysecurity-bus/src/redact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::Secret>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Correct Secret's initialization-only transport documentation

At this new field, crate::Secret becomes an RPC argument, but its public rustdoc in policy.rs still says it is initialization-only and that no bus invocation contains it; crates/tinysecurity-redact/README.md likewise says exact credentials live only in RedactorConfig and omits RedactWith from the host invocation surface. This leaves security-sensitive transport guidance directly false, so update the type and module documentation with the confidential per-call semantics in this commit.

AGENTS.md reference: AGENTS.md:L160-L163

Useful? React with 👍 / 👎.

}
/// `RedactResponse` payload.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
Expand Down
7 changes: 4 additions & 3 deletions crates/tinysecurity-bus/tests/scoped_wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 30 additions & 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, 15));
assert_eq!(CONTRACT_VERSION, (1, 16));
for (mode, wire) in [
(RedactMode::Text, "text"),
(RedactMode::StructuredArguments, "structured_arguments"),
Expand Down Expand Up @@ -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::<RedactWithRequest>(wire)?, request);
assert!(
serde_json::from_value::<RedactWithRequest>(
serde_json::json!({"text":"x","mode":"text","secrets":[],"extra":1})
)
.is_err()
);
assert!(
serde_json::from_value::<RedactWithRequest>(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(())
}
31 changes: 31 additions & 0 deletions crates/tinysecurity-module/examples/audit_native_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn std::error::Error>> {
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,
Expand Down Expand Up @@ -515,6 +545,7 @@ async fn verify_permission_phases(
.call_confidential(names::methods::CHECK_AUDITED2, (before.clone(),))
.await?;
assert!(permit.audit.permitted && permit.audit.committed);
verify_redact_with(proxy).await?;
if matches!(permit.decision.decisions[0].verdict, Verdict::Allow) {
executions += 1;
}
Expand Down
22 changes: 21 additions & 1 deletion crates/tinysecurity-module/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ static PATH_SCOPES: OnceLock<Arc<Mutex<PathPolicyRegistry>>> = OnceLock::new();
static COMMAND_SCOPES: OnceLock<Arc<Mutex<CommandPolicyRegistry>>> = OnceLock::new();
static SCOPED_POLICIES: OnceLock<Arc<Mutex<ScopedPolicyRegistry>>> = OnceLock::new();
static EGRESS_SCOPES: OnceLock<Arc<Mutex<EgressPolicyRegistry>>> = OnceLock::new();
static EXTENDED_REDACTORS: OnceLock<Arc<tinysecurity::ExtendedRedactors>> = OnceLock::new();
static AUDIT_CLIENT: OnceLock<Option<Arc<crate::callbacks::AuditClient>>> = OnceLock::new();
static HOST_BINDING: OnceLock<crate::callbacks::PeerBinding> = OnceLock::new();
static APPROVALS: OnceLock<
Expand Down Expand Up @@ -41,6 +42,7 @@ struct PolicyService {
paths: Arc<Mutex<PathPolicyRegistry>>,
commands: Arc<Mutex<CommandPolicyRegistry>>,
egress: Arc<Mutex<EgressPolicyRegistry>>,
extended: Arc<tinysecurity::ExtendedRedactors>,
scoped: Arc<Mutex<ScopedPolicyRegistry>>,
signals: Option<Connection>,
}
Expand Down Expand Up @@ -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<tinysecurity_bus::RedactResponse> {
let registry = self.read()?.redactor();
let extended = Arc::clone(&self.extended);
tokio::task::spawn_blocking(move || {
extended
.redact(&registry, &request)
.map_err(|error| failure(&error))
})
.await
.map_err(|_| failure(&tinysecurity_bus::Error::Faulted))?
}
async fn scan_prompt(
&self,
request: tinysecurity_bus::ScanPromptRequest,
Expand Down Expand Up @@ -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()))),
),
Expand Down Expand Up @@ -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"],
Expand Down
Loading
Loading