feat(permissions): LP4-1/LP4-2 — enforcer Landlock OS + consommation PTY du plan
LP4-1 : adaptateurs SandboxEnforcer concrets (LandlockSandbox Linux via le LSM Landlock, NoopSandbox ailleurs) + default_enforcer() cfg-sélectionné, prêts à être injectés au composition root (LP4-3). Aucun changement de comportement runtime tant que rien n'est câblé : SpawnSpec.sandbox reste None. LP4-2 : PortablePtyAdapter consomme SpawnSpec.sandbox via spawn_command_sandboxed (thread jetable restreint par enforce() puis fork → le domaine Landlock est hérité par l'enfant à travers execve ; les autres threads d'IdeA ne sont jamais sandboxés). Builder additif with_sandbox_enforcer ; fail-closed si enforce() échoue. Tests : domain + infrastructure verts (dont les tests Landlock réels de fencing read-only/write-only et la confine-au-thread). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
32
Cargo.lock
generated
32
Cargo.lock
generated
@ -979,6 +979,26 @@ version = "1.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||||
|
dependencies = [
|
||||||
|
"enumflags2_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2_derive"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@ -1923,6 +1943,7 @@ dependencies = [
|
|||||||
"domain",
|
"domain",
|
||||||
"fastembed",
|
"fastembed",
|
||||||
"git2",
|
"git2",
|
||||||
|
"landlock",
|
||||||
"notify",
|
"notify",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@ -2131,6 +2152,17 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "landlock"
|
||||||
|
version = "0.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d"
|
||||||
|
dependencies = [
|
||||||
|
"enumflags2",
|
||||||
|
"libc",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
|||||||
@ -22,6 +22,10 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
portable-pty = "0.9"
|
portable-pty = "0.9"
|
||||||
git2 = { workspace = true }
|
git2 = { workspace = true }
|
||||||
|
|
||||||
|
# OS sandbox (lot LP4-1) — Linux Landlock LSM, best-effort/ABI-compat. Linux-only
|
||||||
|
# target dep so the Windows/macOS builds (and the Noop fallback) compile nothing
|
||||||
|
# extra. `landlock` is a thin, dependency-light wrapper over the kernel ABI.
|
||||||
# Filesystem change notifications used to *wake* the orchestrator poll loop early
|
# Filesystem change notifications used to *wake* the orchestrator poll loop early
|
||||||
# (the poll loop remains the robust cross-platform correctness guarantee).
|
# (the poll loop remains the robust cross-platform correctness guarantee).
|
||||||
notify = "6"
|
notify = "6"
|
||||||
@ -36,6 +40,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
|||||||
# `vector-onnx` feature; the zero-dependency default compiles nothing extra.
|
# `vector-onnx` feature; the zero-dependency default compiles nothing extra.
|
||||||
fastembed = { version = "5", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"], optional = true }
|
fastembed = { version = "5", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"], optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
landlock = "0.4.5"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
||||||
# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency.
|
# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency.
|
||||||
|
|||||||
@ -29,6 +29,7 @@ pub mod process;
|
|||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
|
pub mod sandbox;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
@ -55,6 +56,9 @@ pub use process::LocalProcessSpawner;
|
|||||||
pub use pty::PortablePtyAdapter;
|
pub use pty::PortablePtyAdapter;
|
||||||
pub use remote::{remote_host, LocalHost};
|
pub use remote::{remote_host, LocalHost};
|
||||||
pub use runtime::CliAgentRuntime;
|
pub use runtime::CliAgentRuntime;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub use sandbox::LandlockSandbox;
|
||||||
|
pub use sandbox::{default_enforcer, NoopSandbox};
|
||||||
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
||||||
#[cfg(feature = "vector-onnx")]
|
#[cfg(feature = "vector-onnx")]
|
||||||
pub use store::OnnxEmbedder;
|
pub use store::OnnxEmbedder;
|
||||||
|
|||||||
@ -42,9 +42,10 @@ use std::sync::{Arc, Mutex};
|
|||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem};
|
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem, SlavePty};
|
||||||
|
|
||||||
use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec};
|
use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec};
|
||||||
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
use domain::terminal::PtySize;
|
use domain::terminal::PtySize;
|
||||||
use domain::SessionId;
|
use domain::SessionId;
|
||||||
|
|
||||||
@ -143,16 +144,32 @@ struct LivePty {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct PortablePtyAdapter {
|
pub struct PortablePtyAdapter {
|
||||||
sessions: Mutex<HashMap<SessionId, LivePty>>,
|
sessions: Mutex<HashMap<SessionId, LivePty>>,
|
||||||
|
/// Optional OS-sandbox enforcer (lot LP4-1). `None` ⇒ no sandboxing at all
|
||||||
|
/// (the default, zero behaviour change). When set **and** a [`SpawnSpec`]
|
||||||
|
/// carries a [`SandboxPlan`], the plan is enforced on the child at spawn
|
||||||
|
/// (see [`spawn_command_sandboxed`]).
|
||||||
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PortablePtyAdapter {
|
impl PortablePtyAdapter {
|
||||||
/// Creates an empty adapter.
|
/// Creates an empty adapter (no OS sandbox).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
sessions: Mutex::new(HashMap::new()),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
|
sandbox_enforcer: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Additive builder: wires an OS-sandbox [`SandboxEnforcer`] (lot LP4-1). With
|
||||||
|
/// it set, any [`SpawnSpec`] whose `sandbox` is `Some` has that plan enforced on
|
||||||
|
/// the spawned child. Without it (the default), no spawn is ever sandboxed —
|
||||||
|
/// `new()`'s behaviour is unchanged.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_sandbox_enforcer(mut self, enforcer: Arc<dyn SandboxEnforcer>) -> Self {
|
||||||
|
self.sandbox_enforcer = Some(enforcer);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps the domain [`PtySize`] to the `portable-pty` one.
|
/// Maps the domain [`PtySize`] to the `portable-pty` one.
|
||||||
@ -165,6 +182,54 @@ fn to_pty_size(size: PtySize) -> portable_pty::PtySize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns the child **under an OS sandbox** (lot LP4-1).
|
||||||
|
///
|
||||||
|
/// ## Why a dedicated thread instead of a `pre_exec` hook
|
||||||
|
///
|
||||||
|
/// `portable-pty` 0.9 exposes **no** user-injectable `pre_exec` closure: its
|
||||||
|
/// `CommandBuilder` has no such method, and `SlavePty::spawn_command` installs its
|
||||||
|
/// *own* internal `pre_exec` (for `setsid` / controlling-tty) before exec, with no
|
||||||
|
/// extension point. So we cannot run `enforcer.enforce(plan)` in the child's
|
||||||
|
/// `pre_exec` directly.
|
||||||
|
///
|
||||||
|
/// Instead we lean on a guaranteed kernel property: **a Landlock domain is
|
||||||
|
/// inherited across `fork` and preserved across `execve`**. We therefore restrict a
|
||||||
|
/// **dedicated throwaway thread** with `enforce`, then call `spawn_command` *from
|
||||||
|
/// that thread*. `std::process::Command` (which `spawn_command` drives) forks from
|
||||||
|
/// the calling thread — and because `portable-pty` always sets a `pre_exec`, std is
|
||||||
|
/// forced down the `fork`+`exec` path (never `posix_spawn`) — so the child inherits
|
||||||
|
/// the restricted thread's domain. The throwaway thread then dies, taking its
|
||||||
|
/// (irreversible) restriction with it, leaving IdeA's other threads untouched.
|
||||||
|
///
|
||||||
|
/// On `enforce` returning `Err` (fail-closed, e.g. a `Deny` posture on a kernel
|
||||||
|
/// without Landlock) the spawn is failed and **no child runs**. `Ok(Unsupported /
|
||||||
|
/// Degraded)` proceeds (best-effort).
|
||||||
|
///
|
||||||
|
/// Everything is **moved** into the thread (no borrow), so no `Sync` bound is
|
||||||
|
/// required of the slave; the resulting `Child` is `Send` and returned to the
|
||||||
|
/// caller.
|
||||||
|
fn spawn_command_sandboxed(
|
||||||
|
slave: Box<dyn SlavePty + Send>,
|
||||||
|
cmd: CommandBuilder,
|
||||||
|
enforcer: Arc<dyn SandboxEnforcer>,
|
||||||
|
plan: SandboxPlan,
|
||||||
|
) -> Result<Box<dyn Child + Send + Sync>, PtyError> {
|
||||||
|
let join = std::thread::spawn(move || -> Result<Box<dyn Child + Send + Sync>, PtyError> {
|
||||||
|
// Restrict THIS thread; the forked child inherits the domain.
|
||||||
|
if let Err(e) = enforcer.enforce(&plan) {
|
||||||
|
return Err(PtyError::Spawn(format!("sandbox enforcement failed: {e}")));
|
||||||
|
}
|
||||||
|
let child = slave
|
||||||
|
.spawn_command(cmd)
|
||||||
|
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
||||||
|
// The slave is held by the child; drop our copy so EOF propagates on exit.
|
||||||
|
drop(slave);
|
||||||
|
Ok(child)
|
||||||
|
});
|
||||||
|
join.join()
|
||||||
|
.map_err(|_| PtyError::Spawn("sandbox spawn thread panicked".to_owned()))?
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds the `portable-pty` command from a domain [`SpawnSpec`].
|
/// Builds the `portable-pty` command from a domain [`SpawnSpec`].
|
||||||
fn to_command(spec: &SpawnSpec) -> CommandBuilder {
|
fn to_command(spec: &SpawnSpec) -> CommandBuilder {
|
||||||
let mut cmd = CommandBuilder::new(&spec.command);
|
let mut cmd = CommandBuilder::new(&spec.command);
|
||||||
@ -185,12 +250,22 @@ impl PtyPort for PortablePtyAdapter {
|
|||||||
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
||||||
|
|
||||||
let cmd = to_command(&spec);
|
let cmd = to_command(&spec);
|
||||||
let child = pair
|
// Move the slave out of the pair (the master stays usable) so it can be
|
||||||
.slave
|
// either spawned directly or handed to the sandboxed-spawn thread.
|
||||||
|
let slave = pair.slave;
|
||||||
|
let child = match (spec.sandbox.clone(), self.sandbox_enforcer.clone()) {
|
||||||
|
// Sandboxed spawn: a plan AND an enforcer are present.
|
||||||
|
(Some(plan), Some(enforcer)) => spawn_command_sandboxed(slave, cmd, enforcer, plan)?,
|
||||||
|
// No plan or no enforcer ⇒ plain spawn (the default path, unchanged).
|
||||||
|
_ => {
|
||||||
|
let child = slave
|
||||||
.spawn_command(cmd)
|
.spawn_command(cmd)
|
||||||
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
||||||
// The slave is held by the child; drop our copy so EOF propagates on exit.
|
// The slave is held by the child; drop our copy so EOF propagates.
|
||||||
drop(pair.slave);
|
drop(slave);
|
||||||
|
child
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let writer = pair
|
let writer = pair
|
||||||
.master
|
.master
|
||||||
|
|||||||
412
crates/infrastructure/src/sandbox/landlock.rs
Normal file
412
crates/infrastructure/src/sandbox/landlock.rs
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
//! [`LandlockSandbox`] — the Linux [`SandboxEnforcer`] backed by the kernel
|
||||||
|
//! Landlock LSM (lot LP4-1), via the `landlock` crate, **best-effort / ABI-compat**.
|
||||||
|
//!
|
||||||
|
//! # What it does
|
||||||
|
//!
|
||||||
|
//! [`SandboxEnforcer::enforce`] translates a domain [`SandboxPlan`] into a Landlock
|
||||||
|
//! ruleset and restricts the **current thread** (the contract says `enforce` runs
|
||||||
|
//! post-fork / pre-exec in the child, so the restriction is inherited by the exec'd
|
||||||
|
//! program — Landlock domains survive `execve`).
|
||||||
|
//!
|
||||||
|
//! # Per-access-class handling (mirrors the LP4-0 compile invariant)
|
||||||
|
//!
|
||||||
|
//! Landlock works by *handling* a set of access rights — everything **not** granted
|
||||||
|
//! is then denied **for those handled rights only**. We therefore handle a right
|
||||||
|
//! class **only if the plan actually posed a grant of that class**:
|
||||||
|
//!
|
||||||
|
//! - some `RO` grant present ⇒ handle the **read** class (`from_read`): reads become
|
||||||
|
//! restricted to the granted roots;
|
||||||
|
//! - some `RW` grant present ⇒ handle the **write** class (`from_write`): writes /
|
||||||
|
//! create / delete become restricted to the granted roots.
|
||||||
|
//!
|
||||||
|
//! A write-only policy thus leaves **reads globally unrestricted** (so the agent can
|
||||||
|
//! still load `libc`, read `/etc`, …) and only fences writes — exactly the autonomy
|
||||||
|
//! the per-access-class compile guarantees. The flip side: a policy that restricts
|
||||||
|
//! reads governs *all* reads, so the compiled plan must include the system read
|
||||||
|
//! paths the program needs; that enrichment is the launch-path's concern (LP4-2),
|
||||||
|
//! not this adapter's — here we translate the plan faithfully.
|
||||||
|
//!
|
||||||
|
//! # Fallback policy
|
||||||
|
//!
|
||||||
|
//! On a kernel/ABI without (sufficient) Landlock, `restrict_self` reports
|
||||||
|
//! [`RulesetStatus::NotEnforced`]. We then apply the product fallback: **fail-open**
|
||||||
|
//! ([`SandboxStatus::Unsupported`]) for any posture **except** [`Posture::Deny`],
|
||||||
|
//! where we **fail-closed** with [`SandboxError::KernelTooOld`] (a `Deny` posture
|
||||||
|
//! cannot be silently dropped). Partial enforcement (older ABI missing some rights)
|
||||||
|
//! maps to [`SandboxStatus::Degraded`].
|
||||||
|
|
||||||
|
use domain::sandbox::{
|
||||||
|
PathAccess, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus,
|
||||||
|
};
|
||||||
|
use domain::Posture;
|
||||||
|
|
||||||
|
// Leading `::` selects the extern `landlock` crate (this module is also named
|
||||||
|
// `landlock`, so the disambiguation matters).
|
||||||
|
use ::landlock::{
|
||||||
|
path_beneath_rules, AccessFs, BitFlags, CompatLevel, Compatible, Ruleset, RulesetAttr,
|
||||||
|
RulesetCreatedAttr, RulesetStatus, ABI,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The Landlock ABI we author rules against. `ABI::V1` (Linux ≥ 5.13) is the broad
|
||||||
|
/// floor; `CompatLevel::BestEffort` lets newer kernels run it unchanged and older /
|
||||||
|
/// absent support degrade rather than hard-error.
|
||||||
|
const TARGET_ABI: ABI = ABI::V1;
|
||||||
|
|
||||||
|
/// The Linux Landlock [`SandboxEnforcer`].
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct LandlockSandbox;
|
||||||
|
|
||||||
|
impl LandlockSandbox {
|
||||||
|
/// Builds the Landlock enforcer.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Landlock access rights a single grant opens, intersected later with the
|
||||||
|
/// handled set. An `RW` grant also opens the read rights (writing a tree usually
|
||||||
|
/// implies reading it) — harmless when reads are ungoverned, correct when they are.
|
||||||
|
fn grant_access(access: PathAccess, abi: ABI) -> BitFlags<AccessFs> {
|
||||||
|
let mut acc = BitFlags::<AccessFs>::empty();
|
||||||
|
if access.contains(PathAccess::RO) {
|
||||||
|
acc |= AccessFs::from_read(abi);
|
||||||
|
}
|
||||||
|
if access.contains(PathAccess::RW) {
|
||||||
|
acc |= AccessFs::from_read(abi) | AccessFs::from_write(abi);
|
||||||
|
}
|
||||||
|
if access.contains(PathAccess::EXEC) {
|
||||||
|
// The domain never emits EXEC today; honoured for completeness.
|
||||||
|
acc |= AccessFs::Execute;
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SandboxEnforcer for LandlockSandbox {
|
||||||
|
fn kind(&self) -> SandboxKind {
|
||||||
|
SandboxKind::Landlock
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enforce(&self, plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError> {
|
||||||
|
let abi = TARGET_ABI;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
for grant in &plan.allowed {
|
||||||
|
if grant.access.contains(PathAccess::RO) {
|
||||||
|
handled |= AccessFs::from_read(abi);
|
||||||
|
}
|
||||||
|
if grant.access.contains(PathAccess::RW) {
|
||||||
|
handled |= AccessFs::from_write(abi);
|
||||||
|
}
|
||||||
|
if grant.access.contains(PathAccess::EXEC) {
|
||||||
|
handled |= AccessFs::Execute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if handled.is_empty() {
|
||||||
|
// No file restriction to apply (empty / bash-only plan): nothing for
|
||||||
|
// Landlock to enforce. A `Deny` posture's teeth, if any, then live only
|
||||||
|
// in the advisory LP3 projection — not this adapter's concern.
|
||||||
|
return Ok(SandboxStatus::Enforced);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ruleset = Ruleset::default()
|
||||||
|
.set_compatibility(CompatLevel::BestEffort)
|
||||||
|
.handle_access(handled)
|
||||||
|
.and_then(Ruleset::create)
|
||||||
|
.map_err(|e| SandboxError::Ruleset(e.to_string()))?;
|
||||||
|
|
||||||
|
for grant in &plan.allowed {
|
||||||
|
let access = grant_access(grant.access, abi) & handled;
|
||||||
|
if access.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// `path_beneath_rules` silently skips a path it cannot open (a grant on
|
||||||
|
// a not-yet-existing root just adds no rule — best-effort, never fatal).
|
||||||
|
ruleset = ruleset
|
||||||
|
.add_rules(path_beneath_rules([grant.abs_root.as_str()], access))
|
||||||
|
.map_err(|e: ::landlock::RulesetError| SandboxError::Ruleset(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = ruleset
|
||||||
|
.restrict_self()
|
||||||
|
.map_err(|e| SandboxError::Ruleset(e.to_string()))?;
|
||||||
|
|
||||||
|
status_from_ruleset(status.ruleset, plan.default_posture)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps the kernel's [`RulesetStatus`] (after `restrict_self`) plus the plan's
|
||||||
|
/// fallback [`Posture`] to the domain outcome. **Pure** (no I/O, no kernel): the
|
||||||
|
/// security-critical decision — including the *fail-closed only under `Deny`*
|
||||||
|
/// branch — lives here so it is unit-testable without a Landlock-incapable kernel.
|
||||||
|
///
|
||||||
|
/// - [`RulesetStatus::FullyEnforced`] ⇒ `Ok(`[`SandboxStatus::Enforced`]`)`
|
||||||
|
/// - [`RulesetStatus::PartiallyEnforced`] ⇒ `Ok(`[`SandboxStatus::Degraded`]`)`
|
||||||
|
/// - [`RulesetStatus::NotEnforced`] + [`Posture::Deny`] ⇒
|
||||||
|
/// `Err(`[`SandboxError::KernelTooOld`]`)` (fail-closed)
|
||||||
|
/// - [`RulesetStatus::NotEnforced`] + any other posture ⇒
|
||||||
|
/// `Ok(`[`SandboxStatus::Unsupported`]`)` (fail-open + warning)
|
||||||
|
fn status_from_ruleset(
|
||||||
|
status: RulesetStatus,
|
||||||
|
posture: Posture,
|
||||||
|
) -> Result<SandboxStatus, SandboxError> {
|
||||||
|
match status {
|
||||||
|
RulesetStatus::FullyEnforced => Ok(SandboxStatus::Enforced),
|
||||||
|
RulesetStatus::PartiallyEnforced => Ok(SandboxStatus::Degraded(
|
||||||
|
"landlock ABI compatibility reduced the enforced access rights (best-effort)"
|
||||||
|
.to_owned(),
|
||||||
|
)),
|
||||||
|
RulesetStatus::NotEnforced => {
|
||||||
|
if posture == Posture::Deny {
|
||||||
|
Err(SandboxError::KernelTooOld(
|
||||||
|
"landlock unavailable on this kernel but the policy posture is Deny \
|
||||||
|
(fail-closed)"
|
||||||
|
.to_owned(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(SandboxStatus::Unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::sandbox::{PathGrant, SandboxPlan};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// A unique temp dir for one test (no external tempfile dep).
|
||||||
|
fn fresh_dir(tag: &str) -> PathBuf {
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let p = std::env::temp_dir().join(format!("idea-landlock-{tag}-{nanos}"));
|
||||||
|
std::fs::create_dir_all(&p).unwrap();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real-kernel integration test: a write-only plan that grants RW on one dir
|
||||||
|
/// must let writes there succeed and block writes elsewhere with EACCES, while
|
||||||
|
/// leaving reads unrestricted. Runs the enforcement on a dedicated thread (the
|
||||||
|
/// restriction is irreversible for that thread) and **skips** if the running
|
||||||
|
/// kernel has no Landlock (CI / old kernels), like the SSH/WSL gated tests.
|
||||||
|
#[test]
|
||||||
|
fn landlock_write_only_plan_fences_writes_to_the_grant() {
|
||||||
|
let allowed = fresh_dir("allowed");
|
||||||
|
let denied = fresh_dir("denied");
|
||||||
|
let allowed_for_thread = allowed.clone();
|
||||||
|
let denied_for_thread = denied.clone();
|
||||||
|
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: allowed.to_string_lossy().into_owned(),
|
||||||
|
access: PathAccess::RW,
|
||||||
|
}],
|
||||||
|
default_posture: Posture::Ask,
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = std::thread::spawn(move || {
|
||||||
|
let status = LandlockSandbox::new()
|
||||||
|
.enforce(&plan)
|
||||||
|
.expect("enforce must not error under an Ask posture");
|
||||||
|
// The thread is now sandboxed: try a write inside and outside the grant.
|
||||||
|
let ok_write = std::fs::write(allowed_for_thread.join("inside.txt"), b"hi");
|
||||||
|
let ko_write = std::fs::write(denied_for_thread.join("outside.txt"), b"nope");
|
||||||
|
(status, ok_write, ko_write.map_err(|e| e.kind()))
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (status, ok_write, ko_write) = outcome;
|
||||||
|
|
||||||
|
if status == SandboxStatus::Unsupported {
|
||||||
|
eprintln!("skipping: Landlock not available on this kernel");
|
||||||
|
// Clean up best-effort and bail (no enforcement happened).
|
||||||
|
let _ = std::fs::remove_dir_all(&allowed);
|
||||||
|
let _ = std::fs::remove_dir_all(&denied);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)),
|
||||||
|
"expected an enforced/degraded sandbox, got {status:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ok_write.is_ok(),
|
||||||
|
"write inside the granted root must succeed, got {ok_write:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ko_write,
|
||||||
|
Err(std::io::ErrorKind::PermissionDenied),
|
||||||
|
"write outside the granted root must be blocked (EACCES)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&allowed);
|
||||||
|
let _ = std::fs::remove_dir_all(&denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty plan (no file grant) has nothing to enforce ⇒ `Enforced`, no error.
|
||||||
|
#[test]
|
||||||
|
fn empty_plan_is_a_noop_enforced() {
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![],
|
||||||
|
default_posture: Posture::Ask,
|
||||||
|
};
|
||||||
|
// Run on a throwaway thread to avoid restricting the test runner thread.
|
||||||
|
let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
|
||||||
|
.join()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, SandboxStatus::Enforced);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Bash-only / empty plan under a `Deny` posture.** A bash-only policy compiles
|
||||||
|
/// (LP4-0) to an empty `allowed`; even combined with `Posture::Deny` the adapter
|
||||||
|
/// has no file right to *handle*, so it must early-return `Enforced` WITHOUT
|
||||||
|
/// restricting anything — and crucially WITHOUT `KernelTooOld`: the fail-closed
|
||||||
|
/// branch is only ever reached once a class was actually handled.
|
||||||
|
#[test]
|
||||||
|
fn empty_plan_under_deny_posture_is_enforced_without_restriction() {
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![],
|
||||||
|
default_posture: Posture::Deny,
|
||||||
|
};
|
||||||
|
let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
|
||||||
|
.join()
|
||||||
|
.unwrap()
|
||||||
|
.expect("an empty plan never fails closed (nothing handled)");
|
||||||
|
assert_eq!(status, SandboxStatus::Enforced);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **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**
|
||||||
|
/// leak onto the thread that spawned it (which simulates the IdeA process): after
|
||||||
|
/// the child has sandboxed itself, the parent thread must still read/write OUTSIDE
|
||||||
|
/// the plan's roots. A leak here would be catastrophic (IdeA itself locked down).
|
||||||
|
#[test]
|
||||||
|
fn enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed() {
|
||||||
|
let granted = fresh_dir("safety-granted");
|
||||||
|
let outside = fresh_dir("safety-outside");
|
||||||
|
let granted_t = granted.clone();
|
||||||
|
let outside_t = outside.clone();
|
||||||
|
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: granted.to_string_lossy().into_owned(),
|
||||||
|
access: PathAccess::RW,
|
||||||
|
}],
|
||||||
|
default_posture: Posture::Ask,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enforce on a dedicated thread, exactly as the launch path does, and report
|
||||||
|
// whether the sandbox was actually live there (write outside must be blocked).
|
||||||
|
let (status, child_outside_write) = std::thread::spawn(move || {
|
||||||
|
let status = LandlockSandbox::new()
|
||||||
|
.enforce(&plan)
|
||||||
|
.expect("enforce must not error under an Ask posture");
|
||||||
|
let _ = granted_t; // (writable in the child; not asserted here)
|
||||||
|
let child_outside =
|
||||||
|
std::fs::write(outside_t.join("from-child.txt"), b"x").map_err(|e| e.kind());
|
||||||
|
(status, child_outside)
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if status == SandboxStatus::Unsupported {
|
||||||
|
eprintln!("skipping: Landlock not available on this kernel");
|
||||||
|
let _ = std::fs::remove_dir_all(&granted);
|
||||||
|
let _ = std::fs::remove_dir_all(&outside);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: the sandbox really was active on the throwaway thread.
|
||||||
|
assert_eq!(
|
||||||
|
child_outside_write,
|
||||||
|
Err(std::io::ErrorKind::PermissionDenied),
|
||||||
|
"the throwaway thread must be sandboxed (write outside the grant blocked)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// THE GUARANTEE: the parent thread (≈ the IdeA process) is not a descendant of
|
||||||
|
// the throwaway thread, so the domain must not have leaked onto it.
|
||||||
|
let idea_write = std::fs::write(outside.join("from-idea.txt"), b"idea");
|
||||||
|
assert!(
|
||||||
|
idea_write.is_ok(),
|
||||||
|
"SAFETY VIOLATION: IdeA's own thread was sandboxed — write outside the plan \
|
||||||
|
failed ({idea_write:?}); the Landlock domain leaked off the throwaway thread"
|
||||||
|
);
|
||||||
|
let idea_read = std::fs::read(outside.join("from-idea.txt"));
|
||||||
|
assert!(
|
||||||
|
idea_read.is_ok(),
|
||||||
|
"IdeA must still read outside the plan roots, got {idea_read:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&granted);
|
||||||
|
let _ = std::fs::remove_dir_all(&outside);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **READ (RO) dimension.** A plan with a single RO grant: from the sandboxed
|
||||||
|
/// child, reading a file UNDER the granted root succeeds, while reading a file
|
||||||
|
/// OUTSIDE is denied (EACCES). This also documents the behaviour DevBackend
|
||||||
|
/// flagged: as soon as *any* RO grant is posed the read class is handled, so
|
||||||
|
/// **every** read outside the granted roots is closed — a realistic read-restricted
|
||||||
|
/// plan must therefore include the needed system paths (an LP4-2 concern).
|
||||||
|
#[test]
|
||||||
|
fn read_only_plan_fences_reads_to_the_grant() {
|
||||||
|
let granted = fresh_dir("ro-granted");
|
||||||
|
let outside = fresh_dir("ro-outside");
|
||||||
|
// Files must exist before enforcement (creation is a write).
|
||||||
|
std::fs::write(granted.join("in.txt"), b"inside").unwrap();
|
||||||
|
std::fs::write(outside.join("out.txt"), b"outside").unwrap();
|
||||||
|
let granted_t = granted.clone();
|
||||||
|
let outside_t = outside.clone();
|
||||||
|
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: granted.to_string_lossy().into_owned(),
|
||||||
|
access: PathAccess::RO,
|
||||||
|
}],
|
||||||
|
default_posture: Posture::Ask,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (status, ok_read, ko_read) = std::thread::spawn(move || {
|
||||||
|
let status = LandlockSandbox::new()
|
||||||
|
.enforce(&plan)
|
||||||
|
.expect("enforce must not error under an Ask posture");
|
||||||
|
let ok = std::fs::read(granted_t.join("in.txt"));
|
||||||
|
let ko = std::fs::read(outside_t.join("out.txt")).map_err(|e| e.kind());
|
||||||
|
(status, ok, ko)
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if status == SandboxStatus::Unsupported {
|
||||||
|
eprintln!("skipping: Landlock not available on this kernel");
|
||||||
|
let _ = std::fs::remove_dir_all(&granted);
|
||||||
|
let _ = std::fs::remove_dir_all(&outside);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)),
|
||||||
|
"expected an enforced/degraded sandbox, got {status:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ok_read.is_ok(),
|
||||||
|
"read inside the granted RO root must succeed, got {ok_read:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ko_read,
|
||||||
|
Err(std::io::ErrorKind::PermissionDenied),
|
||||||
|
"once a RO grant is posed, reads outside the granted roots are closed (EACCES)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&granted);
|
||||||
|
let _ = std::fs::remove_dir_all(&outside);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
crates/infrastructure/src/sandbox/mod.rs
Normal file
82
crates/infrastructure/src/sandbox/mod.rs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
//! OS-sandbox adapters (lot LP4-1) — concrete [`domain::sandbox::SandboxEnforcer`]
|
||||||
|
//! implementations and the cfg-selected default constructor.
|
||||||
|
//!
|
||||||
|
//! - [`LandlockSandbox`] (Linux only) enforces a [`domain::sandbox::SandboxPlan`]
|
||||||
|
//! via the kernel Landlock LSM.
|
||||||
|
//! - [`NoopSandbox`] (everywhere) enforces nothing — the non-Linux / fallback path.
|
||||||
|
//!
|
||||||
|
//! [`default_enforcer`] returns the right one for the build target as a
|
||||||
|
//! `Arc<dyn SandboxEnforcer>`, ready for the composition root (LP4-3) to inject.
|
||||||
|
//! Nothing here is wired into the launch path yet (that is LP4-2): until then the
|
||||||
|
//! enforcer stays unused and `SpawnSpec.sandbox` stays `None`, so there is zero
|
||||||
|
//! runtime behaviour change.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::sandbox::SandboxEnforcer;
|
||||||
|
|
||||||
|
mod noop;
|
||||||
|
pub use noop::NoopSandbox;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod landlock;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub use landlock::LandlockSandbox;
|
||||||
|
|
||||||
|
/// The default OS-sandbox enforcer for the current build target: [`LandlockSandbox`]
|
||||||
|
/// on Linux, [`NoopSandbox`] everywhere else.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_enforcer() -> Arc<dyn SandboxEnforcer> {
|
||||||
|
Arc::new(LandlockSandbox::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default OS-sandbox enforcer for the current build target: [`NoopSandbox`]
|
||||||
|
/// on non-Linux platforms (no OS sandbox wired yet).
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_enforcer() -> Arc<dyn SandboxEnforcer> {
|
||||||
|
Arc::new(NoopSandbox::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::sandbox::{SandboxKind, SandboxPlan, SandboxStatus};
|
||||||
|
use domain::Posture;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noop_enforcer_is_unsupported_and_never_errors() {
|
||||||
|
use domain::sandbox::PathGrant;
|
||||||
|
|
||||||
|
let noop = NoopSandbox::new();
|
||||||
|
assert_eq!(noop.kind(), SandboxKind::Unsupported);
|
||||||
|
|
||||||
|
// Across every posture — and even with grants present — the no-op enforces
|
||||||
|
// nothing and never fails closed: there is no OS sandbox here to fail closed
|
||||||
|
// against (the Deny posture's teeth live only in the advisory LP3 projection).
|
||||||
|
for posture in [Posture::Allow, Posture::Ask, Posture::Deny] {
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: "/whatever".to_owned(),
|
||||||
|
access: domain::sandbox::PathAccess::RW,
|
||||||
|
}],
|
||||||
|
default_posture: posture,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
noop.enforce(&plan).unwrap(),
|
||||||
|
SandboxStatus::Unsupported,
|
||||||
|
"no-op must return Unsupported (never Err) under posture {posture:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_enforcer_matches_the_build_target() {
|
||||||
|
let enforcer = default_enforcer();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
assert_eq!(enforcer.kind(), SandboxKind::Landlock);
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
assert_eq!(enforcer.kind(), SandboxKind::Unsupported);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
crates/infrastructure/src/sandbox/noop.rs
Normal file
33
crates/infrastructure/src/sandbox/noop.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
//! [`NoopSandbox`] — the no-op [`SandboxEnforcer`] for platforms without an OS
|
||||||
|
//! sandbox (Windows, macOS) and the universal fallback (lot LP4-1).
|
||||||
|
//!
|
||||||
|
//! It compiles **everywhere** and never restricts anything: `enforce` always
|
||||||
|
//! returns [`SandboxStatus::Unsupported`] and never errors. The caller keeps the
|
||||||
|
//! advisory LP3 projection as its only guard.
|
||||||
|
|
||||||
|
use domain::sandbox::{SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus};
|
||||||
|
|
||||||
|
/// A [`SandboxEnforcer`] that enforces nothing (non-Linux / fallback).
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct NoopSandbox;
|
||||||
|
|
||||||
|
impl NoopSandbox {
|
||||||
|
/// Builds the no-op enforcer.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SandboxEnforcer for NoopSandbox {
|
||||||
|
fn kind(&self) -> SandboxKind {
|
||||||
|
SandboxKind::Unsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enforce(&self, _plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError> {
|
||||||
|
// No OS sandbox here: nothing enforced, never an error (the fail-closed
|
||||||
|
// posture handling is a Landlock-only concern; on an unsupported platform
|
||||||
|
// there is nothing to fail closed *against*).
|
||||||
|
Ok(SandboxStatus::Unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user