diff --git a/MODULE.md b/MODULE.md index 328d1063..6e1064c4 100644 --- a/MODULE.md +++ b/MODULE.md @@ -547,7 +547,7 @@ decides what each answers, without parking the call: | `Unlabelled` | Denied, unless the scope holds a whole-tool grant for the tool: then an audited approval attributed `auto:tool-grant`. No prompt, no reviewed-reuse row, no judge, and neither `auto_approve_all` nor a flow grant approves it. | A human-only tool (`sandbox_host_network`, `device_crypto`, `policy_change` and the native -grants) still needs its own fresh human decision under every origin. The automatic approvals are +grants) still needs its own fresh human decision under every origin, and no earlier decision of its content is reused. A sandbox scope whose origin is `Unlabelled` is treated like `SaaS` for the operator privileges (mandatory isolation, no operator host network, no unjailed host run). The automatic approvals are persisted and audited through the same callbacks as any other row and are consumed once like any other row; replacing the scope or a grant revokes an unconsumed one. @@ -563,6 +563,7 @@ automatic one attributed `auto:flow-grant`. - The row's binding digests live in their own domain, so the row can never equal a real call's binding: it is never consumed, never resumed and never satisfies `PreauthorizeFlow2`. +- A flow identity may be up to 1024 bytes (a caller's flow identity), a tool name follows the 256-byte grant-name rule, and revocation validates names exactly as a grant does. A scope that already holds 960 rows refuses a new grant (`unavailable`) so its whole row set stays loadable. - Granting a live pair is `Unchanged`; revoking a missing pair is `Absent`, so both are idempotent. Grants and revocations of a scope are serialized, so one pair has one live row. - The row is bound to the scope's subject, policy reference and generation, like every durable diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index abc2eb33..cdb6bbb5 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -579,6 +579,10 @@ impl Engine { } Ok(row) } + /// The judge never qualifies a human-only tool, however the tool is named. + fn judge_barred(tool: &str) -> bool { + binding::is_native(tool) || HUMAN_ONLY_TOOLS2.contains(&tool) + } /// The host-configured or durable grant that approves the call, when none of the /// human-only or reviewed-reuse paths already decided it. fn automatic_grant( @@ -718,7 +722,11 @@ impl Engine { request.request.call.tool.as_str(), "sandbox_host_network" | "device_crypto" ); - let reusable = if human_only { + // No human-only tool is ever approved by an earlier human decision of its content. + let never_reused = human_only + || binding::is_native(&request.request.call.tool) + || HUMAN_ONLY_TOOLS2.contains(&request.request.call.tool.as_str()); + let reusable = if never_reused { None } else { self.live_reusable(reg, bound, rows, now).await? @@ -844,6 +852,9 @@ impl Engine { bound: &tinysecurity_bus::ApprovalBinding2, external_effect: bool, ) -> Result> { + if Self::judge_barred(&request.request.call.tool) { + return Ok(None); + } let Some(engine) = self.automatic()? else { return Ok(None); }; @@ -1599,12 +1610,18 @@ impl Engine { _ => return Ok(()), }; let entry = self.entry(&request.scope_id)?; - let mut granted = entry - .grant( - &request.request.call.tool, - request.request.caller.flow.as_deref(), - ) - .is_some(); + // An unlabelled call is approved by a whole-tool grant alone; a flow grant + // added since never keeps its approval alive. + let mut granted = if row.binding.origin == Origin::Unlabelled { + entry.tool_grant(&request.request.call.tool).is_some() + } else { + entry + .grant( + &request.request.call.tool, + request.request.caller.flow.as_deref(), + ) + .is_some() + }; if !granted && row.attribution.as_deref() == Some("auto:flow-grant") { // A durable flow-trust row can have been revoked since the approval. let reg = self.registered(&entry); diff --git a/crates/tinysecurity-approval/src/engine_flow_trust.rs b/crates/tinysecurity-approval/src/engine_flow_trust.rs index e7149c23..410476d0 100644 --- a/crates/tinysecurity-approval/src/engine_flow_trust.rs +++ b/crates/tinysecurity-approval/src/engine_flow_trust.rs @@ -13,12 +13,16 @@ use super::{ RedactMode, Result, binding, }; use tinysecurity_bus::{ - FLOW_TRUST_LIFETIME_MS2, FlowToolGrant2, FlowTrustEntry2, FlowTrustList2, FlowTrustOutcome2, - FlowTrustReceipt2, GrantFlowToolRequest2, ListFlowTrustRequest2, RevokeFlowToolRequest2, + FLOW_TRUST_LIFETIME_MS2, FlowTrustEntry2, FlowTrustList2, FlowTrustOutcome2, FlowTrustReceipt2, + GrantFlowToolRequest2, ListFlowTrustRequest2, RevokeFlowToolRequest2, }; const ATTRIBUTION: &str = "flow-trust-grant"; +/// Rows a scope may hold before a new grant is refused, below the 1024 the module's +/// load contract accepts so ordinary approvals keep a margin. +const MAX_SCOPE_ROWS: usize = 960; + /// Digest of the exact `(flow, tool)` pair, domain-separated from every call digest. fn trust_key(flow_id: &str, tool: &str) -> String { let mut bytes = Vec::with_capacity(flow_id.len() + tool.len() + 8); @@ -73,14 +77,16 @@ impl Engine { }) } + /// Whether a flow and a tool name could ever name a grant: the flow may be as + /// long as a caller's flow identity (1024 bytes), the tool follows the shared + /// grant-name rules. fn trust_pair_valid(flow_id: &str, tool: &str) -> bool { - flow_id.len() <= 1024 + !flow_id.trim().is_empty() + && flow_id.len() <= 1024 + && !flow_id.chars().any(char::is_control) && ApprovalGrants2 { - tools: vec![], - flows: vec![FlowToolGrant2 { - flow_id: flow_id.into(), - tool: tool.into(), - }], + tools: vec![tool.into()], + flows: Vec::new(), } .is_valid() } @@ -111,6 +117,16 @@ impl Engine { request_id: Some(existing.request_id.clone()), }); } + // A scope's whole row set must stay loadable (`ApprovalLoadRequest2` caps it), + // so a long-lived grant never takes the last slot of a full scope. + // A pair that already has a record (revoked or expired) renews without + // pushing the scope far past its margin, so it is never locked out. + let known = rows + .iter() + .any(|r| r.attribution.as_deref() == Some(ATTRIBUTION) && r.binding.call_sha256 == key); + if rows.len() >= MAX_SCOPE_ROWS && !known { + return Err(Error::Unavailable); + } let redactor = self.redactor()?; let flow = binding::text(&redactor, &request.flow_id, RedactMode::IdentifierHash)?; let caller = CallerContext { @@ -173,7 +189,7 @@ impl Engine { &self, request: RevokeFlowToolRequest2, ) -> Result { - if request.flow_id.is_empty() || request.flow_id.len() > 1024 || request.tool.is_empty() { + if !Self::trust_pair_valid(&request.flow_id, &request.tool) { return Err(Error::Invalid); } let _serial = self.flow_trust_lock.lock().await; diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index e72d0d2f..2effd877 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -475,3 +475,154 @@ async fn a_flow_trust_row_cannot_be_preauthorized_decided_or_consumed_as_a_human assert!(owner.consume(consume).await.is_err()); Ok(()) } + +#[tokio::test] +async fn an_unlabelled_approval_dies_when_only_a_flow_grant_remains() -> Result<()> { + let (_, owner) = owner()?; + let mut req = register(7); + req.grants = grants(&["shell"], &[]); + owner.register_scope(req.clone()).await?; + let call = shell(7, Some("flow-1"), Origin::Unlabelled, "call-1"); + let row = owner.begin(call.clone()).await?; + assert_eq!(row.attribution.as_deref(), Some("auto:tool-grant")); + // The whole-tool grant is replaced by a flow grant for the same tool and flow. + req.grants = grants(&[], &[("flow-1", "shell")]); + owner.replace_scope(req)?; + let consumption = ApprovalActionRequest2 { + scope_id: id(7), + request_id: row.request_id, + request: call.request, + audit: at(2), + }; + assert!(owner.consume(consumption).await.is_err()); + Ok(()) +} + +#[tokio::test] +async fn a_reviewed_human_only_tool_is_never_reused_by_a_trusted_origin() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + fixture.choice.store(1, Ordering::SeqCst); // the human answers "this tool" + let ask = |call: &str, tail: u64| { + let mut req = begin_for(7, "policy_change", None, Origin::LocalOperator); + req.request.caller.call_id = call.into(); + req.request.call.checks = vec![Check::Unknown { + description: "policy_change".into(), + }]; + req.audit = at(tail); + req + }; + let first = owner.begin(ask("call-1", 1)).await?; + assert_eq!(first.state, ApprovalLifecycle2::Pending); + let decision = DecideRequest2 { + scope_id: id(7), + request_id: first.request_id, + decision_ticket: "private-human-ticket".into(), + audit: at(2), + }; + owner.decide(decision).await?; + let again = owner.begin(ask("call-2", 3)).await?; + assert_eq!( + again.state, + ApprovalLifecycle2::Pending, + "asked a person again" + ); + Ok(()) +} + +#[tokio::test] +async fn revocation_refuses_names_a_grant_could_never_carry() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + for (flow, tool) in [ + ("flow-1", "device_crypto"), + ("flow-1", "native_secret_grant"), + ("flow-1", " "), + ("flow\n1", "shell"), + ("", "shell"), + ] { + assert_eq!( + owner.revoke_flow_tool(revoke(7, flow, tool, 1)).await.err(), + Some(Error::Invalid), + "{flow:?} {tool:?}" + ); + } + Ok(()) +} + +#[tokio::test] +async fn a_flow_identity_may_be_as_long_as_a_callers_but_a_tool_name_keeps_its_bound() -> Result<()> +{ + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + let flow = "f".repeat(1024); + let receipt = owner.grant_flow_tool(grant(7, &flow, "shell", 1)).await?; + assert_eq!(receipt.outcome, FlowTrustOutcome2::Granted); + assert!( + owner + .grant_flow_tool(grant(7, &"f".repeat(1025), "shell", 2)) + .await + .is_err() + ); + assert!( + owner + .grant_flow_tool(grant(7, "flow-1", &"t".repeat(257), 2)) + .await + .is_err() + ); + Ok(()) +} + +#[tokio::test] +async fn a_full_scope_takes_no_new_flow_trust_but_still_answers_for_a_live_one() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + for n in 0..960u64 { + owner + .grant_flow_tool(grant(7, &format!("flow-{n}"), "shell", n + 1)) + .await?; + } + assert_eq!( + owner + .grant_flow_tool(grant(7, "one-more", "shell", 961)) + .await + .err(), + Some(Error::Unavailable) + ); + let again = owner + .grant_flow_tool(grant(7, "flow-0", "shell", 962)) + .await?; + assert_eq!(again.outcome, FlowTrustOutcome2::Unchanged); + // The scope still loads. + assert_eq!( + owner.list_flow_trust(listing(7, None)).await?.entries.len(), + 960 + ); + Ok(()) +} + +#[tokio::test] +async fn a_revoked_pair_can_be_granted_again_even_on_a_full_scope() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + for n in 0..960u64 { + owner + .grant_flow_tool(grant(7, &format!("flow-{n}"), "shell", n + 1)) + .await?; + } + let revoked = owner + .revoke_flow_tool(revoke(7, "flow-0", "shell", 961)) + .await?; + assert_eq!(revoked.outcome, FlowTrustOutcome2::Revoked); + let again = owner + .grant_flow_tool(grant(7, "flow-0", "shell", 962)) + .await?; + assert_eq!(again.outcome, FlowTrustOutcome2::Granted); + assert!( + owner + .grant_flow_tool(grant(7, "brand-new", "shell", 963)) + .await + .is_err() + ); + Ok(()) +} diff --git a/crates/tinysecurity-sandbox/src/engine.rs b/crates/tinysecurity-sandbox/src/engine.rs index 2a5d4e4b..eb5d4e05 100644 --- a/crates/tinysecurity-sandbox/src/engine.rs +++ b/crates/tinysecurity-sandbox/src/engine.rs @@ -401,7 +401,7 @@ impl Engine { facts.operator_host_network && facts.network_ceiling == NetworkPolicy::Host && facts.deployment != SandboxDeployment2::Saas - && facts.origin != Origin::Saas + && !matches!(facts.origin, Origin::Saas | Origin::Unlabelled) }) }) } @@ -436,7 +436,7 @@ impl Engine { return Err(SandboxDenial2::InvalidRequest); } let mandatory = facts.deployment == SandboxDeployment2::Saas - || facts.origin == Origin::Saas + || matches!(facts.origin, Origin::Saas | Origin::Unlabelled) || source.trust != SourceTrust::TrustedHost; if request.options.host_run { return Self::host_run( diff --git a/crates/tinysecurity-sandbox/src/operator_options_tests.rs b/crates/tinysecurity-sandbox/src/operator_options_tests.rs index ff111c0e..4757bd35 100644 --- a/crates/tinysecurity-sandbox/src/operator_options_tests.rs +++ b/crates/tinysecurity-sandbox/src/operator_options_tests.rs @@ -83,6 +83,8 @@ fn operator_host_network_is_ignored_under_saas() -> Res { for (deployment, origin) in [ (SandboxDeployment2::Saas, Origin::Operator), (SandboxDeployment2::Local, Origin::Saas), + // A caller the host could not label gets no operator privilege. + (SandboxDeployment2::Local, Origin::Unlabelled), ] { let mut scope = scope.clone(); scope.deployment = deployment; @@ -193,3 +195,17 @@ fn process_level_jail_needs_a_real_jail_and_a_kernel_jail_still_wins() -> Res { assert_eq!(artifact.executor_id, "executor"); Ok(()) } + +#[test] +fn an_unlabelled_origin_is_sandboxed_mandatorily_and_gets_no_unjailed_host_run() -> Res { + let (_dir, mut scope, mut request) = operator()?; + scope.origin = Origin::Unlabelled; + scope.environment = SandboxSetting2::Disabled; + request.options.host_run = true; + let response = engine(scope)?.plan(&request, 1, 100)?; + assert!( + response.artifact.is_none() || response.denial.is_some(), + "an unlabelled caller must not receive an unjailed host run" + ); + Ok(()) +}