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
29 changes: 29 additions & 0 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,32 @@ The X25519/HKDF device tunnel remains the `EvaluateDevice2`/`ExecuteDevice2`
surface above. The OS keyring test runs on every platform and is required to
use the real credential store when `TINYSECURITY_REQUIRE_OS_KEYRING=1`; it
always removes its entry.

## Credentials-only redaction and open public egress (contract 1.15)

Both additions are additive. A 1.14 host keeps working against a 1.15 module,
and a 1.15 host refuses a 1.14 module.

`RedactMode::CredentialsOnly` (`credentials_only`) is for free-form tool output,
where `Text` over-redacts. It removes the exact initialization registry, provider
token shapes (`sk-`, `gh*_`, `github_pat_`, `AKIA`/`ASIA`, `xox*`, Stripe, Google,
npm, GitLab), JWTs, PEM private keys, `Cookie`/`Set-Cookie` headers, and labelled
values (`token`, `api_key`, `password`, `secret`, `Authorization: Bearer ...`) that
look like a credential. It keeps e-mail, phone, card, address, IP, URL and home-path
content, and keeps labelled values that read as source code (`token = get_token()`,
`accessToken: string`, `password = None`, numbers, `<placeholders>`). It is not a
privacy surface: use `Text` for logs and audit. Rotating a credential is a policy
`Reinit` with the new `RedactorConfig`; the registry is immutable per generation.

`EgressPolicy.open_public` registers every `http`/`https` destination, so
`destinations` and `local_capabilities` must be empty and `LocalOnly` is refused.
The address floor is unchanged: loopback, private, link-local, metadata, CGNAT and
reserved addresses are refused for literals and for every resolved candidate, and
the privacy mode and data-category checks still run first.

`EgressPolicy.preflight_transports` (`browser`, `proxy`; no duplicates) lets those
transports receive a verdict. The module cannot verify that a browser or proxy
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.
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.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.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
14 changes: 14 additions & 0 deletions crates/tinysecurity-bus/src/egress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,20 @@ pub struct EgressPolicy {
pub local_capabilities: Vec<LocalEgressCapability>,
/// Bounded validity in milliseconds, between 1 and 30000.
pub validity_ms: u32,
/// Open public-internet mode (contract 1.15): any `http`/`https` destination
/// is registered, so `destinations` must be empty. The unconditional address
/// floor still applies to the literal and to every resolved candidate, so
/// loopback, private, link-local, metadata, CGNAT and reserved addresses stay
/// refused. Privacy mode and data-category checks are unchanged.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub open_public: bool,
/// Transports other than [`EgressTransport::PinnedDirect`] this policy accepts
/// for a preflight verdict (contract 1.15): only `Browser` and `Proxy`. The
/// module cannot verify those executors pin the returned sockets, so the
/// authorization is a best-effort verdict and DNS rebinding after it is the
/// executor's risk. Local capabilities never use them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub preflight_transports: Vec<EgressTransport>,
}
/// Scoped URL request, version 1.4. Registration and invocation require a confidential host bus.
/// Debug deliberately suppresses all payload, destination and identity values.
Expand Down
2 changes: 1 addition & 1 deletion crates/tinysecurity-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ 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, 14);
pub const CONTRACT_VERSION: (u32, u32) = (1, 15);
/// 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
5 changes: 5 additions & 0 deletions crates/tinysecurity-bus/src/redact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ pub enum RedactMode {
IdentifierHash,
/// Historical first-four-character mask; existing masks are fixed points.
LegacyMask,
/// Credentials only, for free-form tool output: the exact registry, provider
/// token shapes, JWTs, private keys, cookie headers and labelled values that
/// look like a credential. Contact data, URLs and paths are retained, and a
/// labelled value that reads as source code is not rewritten.
CredentialsOnly,
}
/// `ScanVerdict` vocabulary.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
Expand Down
23 changes: 23 additions & 0 deletions crates/tinysecurity-bus/tests/egress_wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,26 @@ fn scoped_contracts_reject_legacy_or_invocation_authority_fields() {
);
assert!(serde_json::from_str::<EgressPolicy>(r#"{"owner":{"user":"u","workspace":"w","agent":"a"},"ceiling":"full","privacy":"standard","destinations":[],"data_categories":[],"local_capabilities":[],"validity_ms":1000,"allow_private":true}"#).is_err());
}
#[test]
fn open_public_and_preflight_fields_round_trip_and_stay_off_the_legacy_wire()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let json = r#"{"owner":{"user":"u","workspace":"w","agent":"a"},"ceiling":"full","privacy":"standard","destinations":[],"data_categories":["user_content"],"local_capabilities":[],"validity_ms":1000,"open_public":true,"preflight_transports":["browser","proxy"]}"#;
let policy: EgressPolicy = serde_json::from_str(json)?;
assert!(policy.open_public);
assert_eq!(
policy.preflight_transports,
[EgressTransport::Browser, EgressTransport::Proxy]
);
assert_eq!(
serde_json::to_value(&policy)?,
serde_json::from_str::<Value>(json)?
);
let closed = EgressPolicy {
open_public: false,
preflight_transports: vec![],
..policy
};
let wire = serde_json::to_value(&closed)?;
assert!(wire.get("open_public").is_none() && wire.get("preflight_transports").is_none());
Ok(())
}
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, 14));
// A host built against 1.14 accepts this module and newer minors, not a 1.13 module.
assert!(is_compatible((1, 14)));
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!(is_compatible((1, 16)));
assert!(!is_compatible((1, 14)));
assert!(!is_compatible((1, 13)));
// A 1.12 module cannot supply the audited scoped members.
assert!(!is_compatible((1, 12)));
Expand Down
3 changes: 2 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, 14));
assert_eq!(CONTRACT_VERSION, (1, 15));
for (mode, wire) in [
(RedactMode::Text, "text"),
(RedactMode::StructuredArguments, "structured_arguments"),
Expand All @@ -358,6 +358,7 @@ fn redactor_init_is_additive_and_secrets_never_enter_public_info()
"structured_log_arguments",
),
(RedactMode::BrowserResult, "browser_result"),
(RedactMode::CredentialsOnly, "credentials_only"),
] {
assert_eq!(serde_json::to_value(mode)?, wire);
}
Expand Down
2 changes: 2 additions & 0 deletions crates/tinysecurity-egress/src/guard_corpus_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ fn policy(validity_ms: u32) -> EgressPolicy {
data_categories: vec![DataCategory::UserContent],
local_capabilities: vec![],
validity_ms,
open_public: false,
preflight_transports: vec![],
}
}
fn request(id: &EgressPolicyId, url: &str) -> ScopedEgressRequest {
Expand Down
Loading
Loading