Skip to content
39 changes: 39 additions & 0 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,45 @@ them. The tool-permission members (`Evaluate`, `Check` and their scoped and audi
forms) still cannot return `Allow` once approvals are configured, because a tool call
must go through the durable lifecycle.

## Runtime sandbox scopes (contract 1.19)

Additive: a 1.18 host keeps working against a 1.19 module, and a 1.19 host refuses a
1.18 module (it requires the two scope members).

`SandboxConfig2` fixes at most 128 immutable scopes at init, and each scope's subject
must equal the audit scope's subject. A host that serves an unbounded set of subjects
(profiles, tenants, derived agents) registers each subject's scope later, the same way
it registers runtime approval scopes:

| Member | Effect |
| --- | --- |
| `RegisterSandboxScope2` | Adds a scope validated exactly like an init scope. An identical repeat on the same stream is `unchanged`; the same identity with other facts or another stream, or an initialized identity, is refused. |
| `RetireSandboxScope2` | Removes a runtime scope and discards every artifact it issued, pending or active, so none can be checked or consumed afterwards. Initialized scopes are never retired. |

Both are private host-only members served to the authenticated init peer. They need a
configured sandbox engine (an empty `SandboxConfig2` is enough) and a configured audit:
every runtime scope carries a host-persisted `audit_stream_id` and records its plans and
consumes on the host `AuditSink2` under its own subject and stream, never in a native
journal. A scope's `approval_scope_id` must name a live approval scope of the same
subject. The receipt (`SandboxScopeReceipt2.scope_handle`, only when
`report_handle` is set, never on a retirement) carries the subject exactly as
`AuditSink2` receives it, so a host keys its ledgers by it.

Bounds and eviction never widen policy. The table holds at most 65,536 live scopes; a
full table refuses a new registration (`capacity`) and never evicts a live scope, so a
subject's ceilings are never silently dropped or replaced. Retirement is the only way a
scope leaves, and the registry slot, artifacts and audit client go with it. A runtime
subject holds at most 8 staged or active artifacts across all of its scope identities, runtime scopes share a pool of 15,360 and
initialized scopes keep their own 1024, so one subject cannot exhaust another's capacity and
busy runtime scopes cannot starve the initialized ones. A plan in flight keeps the audit
stream it started on if its scope is retired meanwhile, and a retired or unknown runtime
identity never falls back to the base stream. A live subject keeps its audit
stream and a retired one keeps it for an hour: registering it again with another stream
in that window is refused, so a plan still in flight for a retired scope cannot reach
another ledger. Several scope identities may share a subject; its stream is released
only when the last of them retires. Changing a live scope's facts is a retirement
followed by a new registration, which also invalidates its outstanding artifacts.

## 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
Expand Down
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.18 returns the sanitized subject handle in `ApprovalScopeReceipt2` and keeps the path, command and egress members available when approvals are configured (see [MODULE.md](MODULE.md#subject-handles-and-unbound-members-contract-118)). Contract 1.17 adds runtime approval scopes (`RegisterApprovalScope2`, `ReplaceApprovalScope2`, `RetireApprovalScope2`), tool and flow grants, and `Check::HostTool` (see [MODULE.md](MODULE.md#runtime-approval-scopes-and-host-tools-contract-117)). 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.19 adds runtime sandbox scopes (`RegisterSandboxScope2`, `RetireSandboxScope2`), so a host that serves unbounded subjects can plan sandboxes per subject (see [MODULE.md](MODULE.md#runtime-sandbox-scopes-contract-119)). Contract 1.18 returns the sanitized subject handle in `ApprovalScopeReceipt2` and keeps the path, command and egress members available when approvals are configured (see [MODULE.md](MODULE.md#subject-handles-and-unbound-members-contract-118)). Contract 1.17 adds runtime approval scopes (`RegisterApprovalScope2`, `ReplaceApprovalScope2`, `RetireApprovalScope2`), tool and flow grants, and `Check::HostTool` (see [MODULE.md](MODULE.md#runtime-approval-scopes-and-host-tools-contract-117)). 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
4 changes: 3 additions & 1 deletion crates/tinysecurity-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ mod device;
pub use device::*;
mod sandbox;
pub use sandbox::*;
mod sandbox_scopes;
pub use sandbox_scopes::*;
mod judge;
pub use judge::*;
mod approval;
Expand Down Expand Up @@ -45,7 +47,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, 18);
pub const CONTRACT_VERSION: (u32, u32) = (1, 19);
/// 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
6 changes: 6 additions & 0 deletions crates/tinysecurity-bus/src/names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ pub mod methods {
pub const PLAN: &str = "Plan";
/// Immutable-scoped declarative sandbox planning.
pub const PLAN2: &str = "Plan2";
/// Register a runtime sandbox scope for one subject (contract 1.19).
pub const REGISTER_SANDBOX_SCOPE2: &str = "RegisterSandboxScope2";
/// Retire a runtime sandbox scope so later calls under it deny (contract 1.19).
pub const RETIRE_SANDBOX_SCOPE2: &str = "RetireSandboxScope2";
/// Exact human approval creation for immutable-ceiling sandbox host networking.
pub const EVALUATE_SANDBOX_APPROVAL2: &str = "EvaluateSandboxApproval2";
/// Exact audited once-only execution admission for an issued declarative artifact.
Expand Down Expand Up @@ -178,6 +182,8 @@ pub const METHODS: &[&str] = &[
methods::REGISTER_APPROVAL_SCOPE2,
methods::REPLACE_APPROVAL_SCOPE2,
methods::RETIRE_APPROVAL_SCOPE2,
methods::REGISTER_SANDBOX_SCOPE2,
methods::RETIRE_SANDBOX_SCOPE2,
methods::IMPORT_APPROVAL_HISTORY2,
methods::PARSE_REPLY,
methods::POLICY_INFO,
Expand Down
83 changes: 83 additions & 0 deletions crates/tinysecurity-bus/src/sandbox_scopes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//! Runtime sandbox scopes (contract 1.19).
//!
//! `SandboxConfig2` fixes at most 128 immutable scopes at init, and each scope's
//! subject must equal the audit scope's subject. A host that serves an unbounded set
//! of subjects (profiles, tenants, derived agents) registers each subject's scope
//! after init with `RegisterSandboxScope2` and releases it with
//! `RetireSandboxScope2`, exactly like runtime approval scopes. Both are private
//! host-only members served to the authenticated init peer: the facts are a trusted
//! adapter claim, never model input.
use crate::SandboxScope2;
use serde::{Deserialize, Serialize};

/// Most live sandbox scopes one module keeps (initialized scopes included).
pub const MAX_SANDBOX_SCOPES2: usize = 65_536;

/// Register one runtime sandbox scope.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RegisterSandboxScopeRequest2 {
/// Registration to add; validated exactly like an init scope.
pub scope: SandboxScope2,
/// Host-persisted random 32-hex audit stream of this subject. Runtime scopes
/// always forward audit to the host `AuditSink2` under this stream; they never
/// open a native journal.
pub audit_stream_id: String,
/// Ask for the sanitized subject handle in the receipt.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub report_handle: bool,
}

/// Retire one runtime sandbox scope. Initialized scopes can never be retired.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RetireSandboxScopeRequest2 {
/// Registration to remove; later calls under this identity deny.
pub scope_id: String,
}

/// What a sandbox scope change did.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SandboxScopeOutcome2 {
/// A new scope is live.
Created,
/// An identical registration already existed; nothing changed.
Unchanged,
/// The scope is gone and every artifact it issued was discarded.
Retired,
}

/// Receipt of a sandbox scope change.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SandboxScopeReceipt2 {
/// Affected registration.
pub scope_id: String,
/// The sanitized subject handle the module sends to `AuditSink2` for this
/// registration. Present only when the registration asked for it
/// ([`RegisterSandboxScopeRequest2::report_handle`]) and never on a retirement.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope_handle: Option<crate::SubjectScope>,
/// What happened.
pub outcome: SandboxScopeOutcome2,
/// Live scopes after the change.
pub live_scopes: u32,
}

macro_rules! redacted_debug {
($($ty:ty),+ $(,)?) => {$(impl std::fmt::Debug for $ty {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(concat!(stringify!($ty), "([REDACTED])"))
}
})+};
}
redacted_debug!(
RegisterSandboxScopeRequest2,
RetireSandboxScopeRequest2,
SandboxScopeReceipt2
);

#[cfg(test)]
#[path = "sandbox_scopes_tests.rs"]
mod tests;
133 changes: 133 additions & 0 deletions crates/tinysecurity-bus/src/sandbox_scopes_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
use super::*;
use crate::{
AccessTier, NetworkPolicy, Origin, PathPolicy, ResourceLimits2, SandboxDeployment2,
SandboxScope2, SandboxSetting2, SubjectScope,
};

type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;

fn scope() -> SandboxScope2 {
SandboxScope2 {
approval_scope_id: None,
scope_id: "scope".into(),
subject: SubjectScope {
user: "u".into(),
workspace: "w".into(),
agent: "a".into(),
},
origin: Origin::Saas,
tier: AccessTier::Full,
deployment: SandboxDeployment2::Saas,
environment: SandboxSetting2::Disabled,
agent_mode: SandboxSetting2::Disabled,
configured: SandboxSetting2::Disabled,
paths: PathPolicy {
enabled: true,
workspace_only: false,
workspace_dir: "/state".into(),
action_dir: "/action".into(),
home_dir: None,
forbidden_paths: vec![],
trusted_roots: vec![],
reserved_paths: vec![],
reserved_prefixes: vec![],
readonly_paths: vec![],
reserved_names: vec![],
},
grant_ceiling: vec![],
network_ceiling: NetworkPolicy::None,
limits: ResourceLimits2::default(),
sources: vec![],
executors: vec![],
proxy: None,
max_ttl_ms: 1000,
}
}

#[test]
fn registration_omits_handle_request_unless_asked_and_round_trips() -> TestResult {
let quiet = RegisterSandboxScopeRequest2 {
scope: scope(),
audit_stream_id: "a".repeat(32),
report_handle: false,
};
let value = serde_json::to_value(&quiet)?;
assert!(value.get("report_handle").is_none());
assert_eq!(
serde_json::from_value::<RegisterSandboxScopeRequest2>(value)?,
quiet
);
let asking = RegisterSandboxScopeRequest2 {
report_handle: true,
..quiet
};
let value = serde_json::to_value(&asking)?;
assert_eq!(value["report_handle"], true);
assert_eq!(
serde_json::from_value::<RegisterSandboxScopeRequest2>(value)?,
asking
);
Ok(())
}

#[test]
fn requests_and_receipts_reject_unknown_fields() {
assert!(
serde_json::from_str::<RetireSandboxScopeRequest2>(r#"{"scope_id":"s","extra":1}"#)
.is_err()
);
assert!(
serde_json::from_str::<SandboxScopeReceipt2>(
r#"{"scope_id":"s","outcome":"created","live_scopes":1,"extra":1}"#
)
.is_err()
);
}

#[test]
fn receipt_carries_the_handle_only_when_present() -> TestResult {
let bare: SandboxScopeReceipt2 =
serde_json::from_str(r#"{"scope_id":"s","outcome":"retired","live_scopes":0}"#)?;
assert_eq!(bare.scope_handle, None);
assert!(serde_json::to_value(&bare)?.get("scope_handle").is_none());
let handled = SandboxScopeReceipt2 {
scope_handle: Some(SubjectScope {
user: "h".into(),
workspace: "h".into(),
agent: "h".into(),
}),
outcome: SandboxScopeOutcome2::Created,
..bare
};
let round: SandboxScopeReceipt2 = serde_json::from_value(serde_json::to_value(&handled)?)?;
assert_eq!(round, handled);
assert_eq!(
serde_json::to_value(SandboxScopeOutcome2::Unchanged)?,
"unchanged"
);
Ok(())
}

#[test]
fn debug_output_never_prints_registered_facts() {
let request = RegisterSandboxScopeRequest2 {
scope: scope(),
audit_stream_id: "b".repeat(32),
report_handle: false,
};
let text = format!(
"{request:?} {:?} {:?}",
RetireSandboxScopeRequest2 {
scope_id: "secret-scope".into()
},
SandboxScopeReceipt2 {
scope_id: "secret-scope".into(),
scope_handle: None,
outcome: SandboxScopeOutcome2::Created,
live_scopes: 1,
}
);
assert!(!text.contains("secret-scope") && !text.contains(&"b".repeat(32)));
assert!(text.contains("[REDACTED]"));
assert_eq!(MAX_SANDBOX_SCOPES2, 65_536);
}
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, 18));
// A host built against 1.18 accepts this module and newer minors, not a 1.17 module.
assert!(is_compatible((1, 18)));
assert_eq!(CONTRACT_VERSION, (1, 19));
// A host built against 1.19 accepts this module and newer minors, not a 1.18 module.
assert!(is_compatible((1, 19)));
assert!(is_compatible((1, 20)));
assert!(!is_compatible((1, 18)));
assert!(!is_compatible((1, 17)));
assert!(!is_compatible((1, 16)));
assert!(!is_compatible((1, 15)));
Expand Down
9 changes: 8 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, 18));
assert_eq!(CONTRACT_VERSION, (1, 19));
for (mode, wire) in [
(RedactMode::Text, "text"),
(RedactMode::StructuredArguments, "structured_arguments"),
Expand Down Expand Up @@ -400,5 +400,12 @@ fn redact_with_request_carries_literals_without_echoing_them()
);
assert_eq!(names::methods::REDACT_WITH, "RedactWith");
assert!(names::METHODS.contains(&names::methods::REDACT_WITH));
assert_eq!(
names::methods::REGISTER_SANDBOX_SCOPE2,
"RegisterSandboxScope2"
);
assert_eq!(names::methods::RETIRE_SANDBOX_SCOPE2, "RetireSandboxScope2");
assert!(names::METHODS.contains(&names::methods::REGISTER_SANDBOX_SCOPE2));
assert!(names::METHODS.contains(&names::methods::RETIRE_SANDBOX_SCOPE2));
Ok(())
}
Loading
Loading