diff --git a/.ideai/permissions.json b/.ideai/permissions.json new file mode 100644 index 0000000..d631d6a --- /dev/null +++ b/.ideai/permissions.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "projectDefaults": { + "rules": [ + { + "capability": "read", + "effect": "allow", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "write", + "effect": "allow", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "delete", + "effect": "allow", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "executeBash", + "effect": "allow", + "paths": [], + "commands": [] + } + ], + "fallback": "allow" + } +} diff --git a/crates/infrastructure/src/sandbox/landlock.rs b/crates/infrastructure/src/sandbox/landlock.rs index 05c4b39..ebad8aa 100644 --- a/crates/infrastructure/src/sandbox/landlock.rs +++ b/crates/infrastructure/src/sandbox/landlock.rs @@ -90,6 +90,20 @@ impl SandboxEnforcer for LandlockSandbox { fn enforce(&self, plan: &SandboxPlan) -> Result { 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 `~/.` 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::::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**