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:
2026-06-16 08:00:47 +02:00
parent b05d04ab7a
commit 17ca65ed0f
7 changed files with 653 additions and 8 deletions

View File

@ -42,9 +42,10 @@ use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
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::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::terminal::PtySize;
use domain::SessionId;
@ -143,16 +144,32 @@ struct LivePty {
#[derive(Default)]
pub struct PortablePtyAdapter {
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 {
/// Creates an empty adapter.
/// Creates an empty adapter (no OS sandbox).
#[must_use]
pub fn new() -> Self {
Self {
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.
@ -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`].
fn to_command(spec: &SpawnSpec) -> CommandBuilder {
let mut cmd = CommandBuilder::new(&spec.command);
@ -185,12 +250,22 @@ impl PtyPort for PortablePtyAdapter {
.map_err(|e| PtyError::Spawn(e.to_string()))?;
let cmd = to_command(&spec);
let child = pair
.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(pair.slave);
// Move the slave out of the pair (the master stays usable) so it can be
// 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)
.map_err(|e| PtyError::Spawn(e.to_string()))?;
// The slave is held by the child; drop our copy so EOF propagates.
drop(slave);
child
}
};
let writer = pair
.master