feat(sandbox): Landlock no-op sous Posture::Allow (allow-by-default)

Un allowlist Landlock ne peut que retirer des accès (deny-by-default). Sous
une fallback Posture::Allow (allow-by-default), enforcer les grants
verrouillerait tout hors du project root — binaire CLI, libs et ~/.<cli> —
provoquant le 'failed to open terminal'. On n'enforce donc rien sous Allow ;
seuls les Deny explicites font foi (advisory via la projection LP3).
Couvre permissions.json persisté avec fallback allow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 09:38:57 +02:00
parent ab9162f686
commit 40bce5c8bf
2 changed files with 105 additions and 0 deletions

View File

@ -90,6 +90,20 @@ impl SandboxEnforcer for LandlockSandbox {
fn enforce(&self, plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError> {
let abi = TARGET_ABI;
// Landlock is an **additive allowlist** — it can only *remove* access, i.e.
// it is deny-by-default. A `Posture::Allow` fallback means the opposite,
// **allow-by-default**: paths matched by no grant must stay reachable. An
// allowlist cannot express that (it would lock everything down to the granted
// roots, closing the CLI binary, its libs and `~/.<cli>` home — the very
// "failed to open terminal" symptom). So under an Allow fallback we enforce
// nothing: the policy's only teeth are explicit Denies, which are deny-islands
// under an allow-all that an additive sandbox cannot carve out — they remain
// advisory via the LP3 projection. This is a property of the allowlist
// mechanism, hence it lives in the adapter, not the pure plan.
if plan.default_posture == Posture::Allow {
return Ok(SandboxStatus::Enforced);
}
// Handle a right class only if the plan posed a grant of that class, so an
// unposed class stays globally unrestricted (per-access-class autonomy).
let mut handled = BitFlags::<AccessFs>::empty();
@ -283,6 +297,59 @@ mod tests {
assert_eq!(status, SandboxStatus::Enforced);
}
/// **Allow fallback ⇒ no OS restriction even with grants posed.** Setting the
/// policy to allow-by-default (and, in the UI, every option to Allow) emits RO|RW
/// grants scoped to the project root. An allowlist sandbox would then close every
/// read/write OUTSIDE that root — locking out the CLI binary, its libs and home,
/// the exact "failed to open terminal" bug. Under `Posture::Allow` the adapter must
/// instead enforce nothing: from the (would-be) sandboxed thread, a read AND a
/// write OUTSIDE the only grant must both still succeed.
#[test]
fn allow_fallback_does_not_restrict_even_with_grants() {
let granted = fresh_dir("allow-granted");
let outside = fresh_dir("allow-outside");
std::fs::write(outside.join("pre.txt"), b"pre").unwrap();
let outside_t = outside.clone();
let plan = SandboxPlan {
allowed: vec![PathGrant {
abs_root: granted.to_string_lossy().into_owned(),
access: PathAccess::RO.union(PathAccess::RW),
}],
default_posture: Posture::Allow,
};
let (status, read_outside, write_outside) = std::thread::spawn(move || {
let status = LandlockSandbox::new()
.enforce(&plan)
.expect("an Allow fallback never fails closed");
let read = std::fs::read(outside_t.join("pre.txt"));
let write =
std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind());
(status, read, write)
})
.join()
.unwrap();
assert_eq!(
status,
SandboxStatus::Enforced,
"Allow fallback is a no-op enforcement, not an error or a lock-down"
);
assert!(
read_outside.is_ok(),
"Allow fallback must keep reads outside the grant open, got {read_outside:?}"
);
assert_eq!(
write_outside,
Ok(()),
"Allow fallback must keep writes outside the grant open"
);
let _ = std::fs::remove_dir_all(&granted);
let _ = std::fs::remove_dir_all(&outside);
}
/// **CRITICAL SAFETY PROPERTY — IdeA is never sandboxed.** The adapter restricts
/// the *current thread*; the launch path runs it on a throwaway thread. This test
/// proves the Landlock domain is confined to that throwaway thread and does **not**