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
3 changes: 2 additions & 1 deletion MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.

Expand All @@ -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
Expand Down
31 changes: 24 additions & 7 deletions crates/tinysecurity-approval/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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());
Comment thread
senamakel marked this conversation as resolved.
let reusable = if never_reused {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
None
} else {
self.live_reusable(reg, bound, rows, now).await?
Expand Down Expand Up @@ -844,6 +852,9 @@ impl Engine {
bound: &tinysecurity_bus::ApprovalBinding2,
external_effect: bool,
) -> Result<Option<tinysecurity_bus::JudgeEvidence2>> {
if Self::judge_barred(&request.request.call.tool) {
return Ok(None);
}
let Some(engine) = self.automatic()? else {
return Ok(None);
};
Expand Down Expand Up @@ -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);
Expand Down
34 changes: 25 additions & 9 deletions crates/tinysecurity-approval/src/engine_flow_trust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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);
Comment thread
senamakel marked this conversation as resolved.
}
let redactor = self.redactor()?;
let flow = binding::text(&redactor, &request.flow_id, RedactMode::IdentifierHash)?;
let caller = CallerContext {
Expand Down Expand Up @@ -173,7 +189,7 @@ impl Engine {
&self,
request: RevokeFlowToolRequest2,
) -> Result<FlowTrustReceipt2> {
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;
Expand Down
151 changes: 151 additions & 0 deletions crates/tinysecurity-approval/src/engine_gaps_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
4 changes: 2 additions & 2 deletions crates/tinysecurity-sandbox/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
}
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions crates/tinysecurity-sandbox/src/operator_options_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(())
}
Loading