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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user