feat(permissions): LP4-4 — enforcement Landlock sur le chemin structuré
Étend l'enforcement OS au chemin structuré (sessions Claude/Codex mode JSON), jusqu'ici seulement advisory. Approche validée par l'Architecte : transposer la technique du PTY plutôt qu'un pre_exec (rejeté — landlock alloue, deadlock malloc post-fork en process multithreadé). Mécanique (cfg(target_os=linux)) : run_turn_sandboxed/drain_sandboxed exécutent enforce(plan) sur un thread jetable AVANT le spawn std, puis std::process::spawn depuis ce thread ; l'enfant hérite le domaine Landlock via les credentials de la tâche (garanti à travers fork/clone/execve, y compris posix_spawn — pas de pre_exec nécessaire, forbid(unsafe_code) préservé). Fail-closed sur Err d'enforce (aucun child). Timeout sous sandbox : oneshot killer + tokio::time::timeout → kill → EOF → reap (pas de zombie/thread bloqué). Chemin non-sandboxé (plan None / pas d'enforcer / non-Linux) = drain async tokio inchangé. Contrat : SpawnLine.sandbox ; AgentSessionFactory::start(.., sandbox) ; StructuredSessionFactory::with_sandbox_enforcer (jumeau du PTY) ; plan calculé en step 5d de lifecycle relayé à launch_structured ; default_enforcer() injecté au composition root. Tests : 7 invariants e2e (parité, companion négatif, fail-closed, no-op natif, confinement de l'irréversibilité entre tours, timeout, resume préservé) — zéro token (sh/FakeCli). 80 suites vertes, 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -28,12 +28,14 @@
|
||||
|
||||
use std::io;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
|
||||
use domain::ports::AgentSessionError;
|
||||
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||
|
||||
/// Une invocation orientée lignes : binaire + arguments + cwd + env + prompt à
|
||||
/// pousser sur stdin (le cas échéant). **Paramétrable par la commande** : c'est ce
|
||||
@ -51,6 +53,11 @@ pub struct SpawnLine {
|
||||
/// Contenu à écrire sur stdin du process (`None` ⇒ stdin fermé immédiatement).
|
||||
/// Sert au mode `--input-format stream-json` / au passage du prompt.
|
||||
pub stdin: Option<String>,
|
||||
/// Plan de sandbox OS à appliquer sur l'enfant (lot LP4-4). `None` ⇒ aucun
|
||||
/// sandboxing : `run_turn` emprunte le drain async tokio **inchangé** (invariant
|
||||
/// produit : rien posé ⇒ comportement natif). `Some` **et** un enforcer fourni à
|
||||
/// [`run_turn`] ⇒ le plan est appliqué sur l'enfant via [`drain_sandboxed`].
|
||||
pub sandbox: Option<SandboxPlan>,
|
||||
}
|
||||
|
||||
/// Lance `spec`, pousse `spec.stdin` sur l'entrée standard, **draine toutes les
|
||||
@ -61,14 +68,33 @@ pub struct SpawnLine {
|
||||
/// [`AgentSessionError::Timeout`] est retourné (la machinerie ne suppose jamais que
|
||||
/// l'appelant veut attendre indéfiniment). `None` ⇒ pas de borne.
|
||||
///
|
||||
/// Quand `spec.sandbox` porte un plan **et** qu'un `enforcer` est fourni (chemin
|
||||
/// Linux uniquement, lot LP4-4), le tour passe par [`run_turn_sandboxed`] : l'enfant
|
||||
/// est lancé sous le domaine Landlock. Sinon — et **partout** hors Linux — c'est le
|
||||
/// drain async tokio historique, strictement inchangé (zéro régression).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AgentSessionError::Start`] si le process ne démarre pas (binaire introuvable) ;
|
||||
/// - [`AgentSessionError::Start`] si le process ne démarre pas (binaire introuvable,
|
||||
/// ou enforcement de sandbox impossible : fail-closed, **aucun** enfant ne tourne) ;
|
||||
/// - [`AgentSessionError::Io`] sur échec de lecture/écriture des pipes ;
|
||||
/// - [`AgentSessionError::Timeout`] si `timeout` expire.
|
||||
pub async fn run_turn(
|
||||
spec: &SpawnLine,
|
||||
timeout: Option<Duration>,
|
||||
enforcer: Option<&Arc<dyn SandboxEnforcer>>,
|
||||
) -> Result<Vec<String>, AgentSessionError> {
|
||||
// Chemin SANDBOXÉ (Linux + plan posé + enforcer câblé) : transpose la technique
|
||||
// du PTY (`spawn_command_sandboxed`) — enforce sur un thread jetable AVANT le fork,
|
||||
// l'enfant hérite le domaine via fork+exec.
|
||||
#[cfg(target_os = "linux")]
|
||||
if let (Some(plan), Some(enforcer)) = (spec.sandbox.as_ref(), enforcer) {
|
||||
return run_turn_sandboxed(spec, plan.clone(), Arc::clone(enforcer), timeout).await;
|
||||
}
|
||||
// Hors Linux : aucun sandboxing OS ⇒ on ignore l'enforcer (Noop de toute façon) et
|
||||
// on garde le drain async historique. `let _` évite l'avertissement « unused ».
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = enforcer;
|
||||
|
||||
match timeout {
|
||||
Some(dur) => match tokio::time::timeout(dur, drain(spec)).await {
|
||||
Ok(result) => result,
|
||||
@ -78,6 +104,182 @@ pub async fn run_turn(
|
||||
}
|
||||
}
|
||||
|
||||
/// Variante **sandboxée** du tour (lot LP4-4, Linux seulement). Le drain bloquant
|
||||
/// `std::process` est exécuté sur un **thread jetable** : on y restreint le thread
|
||||
/// (`enforcer.enforce(plan)`) AVANT le `spawn`, puis on lance l'enfant. La technique
|
||||
/// reproduit celle du PTY ([`crate::pty`]) : `pre_exec(enforce)` est INTERDIT (Landlock
|
||||
/// alloue ⇒ risque de deadlock `malloc` post-`fork` en process multithreadé), on
|
||||
/// s'appuie donc sur l'**héritage du domaine Landlock**.
|
||||
///
|
||||
/// ## Pourquoi pas de `pre_exec` (divergence assumée du cadrage)
|
||||
///
|
||||
/// Le cadrage proposait un `pre_exec` **vide** pour forcer `std` sur le chemin
|
||||
/// déterministe `fork`+`exec` (jamais `posix_spawn`). Or `pre_exec` est `unsafe`, et
|
||||
/// cette crate est `#![forbid(unsafe_code)]` (invariant non contournable localement).
|
||||
/// On s'en passe sans perte de garantie : `landlock_restrict_self` restreint le
|
||||
/// **thread courant et toute sa descendance**, héritage assuré par le noyau à travers
|
||||
/// `fork`/`clone`/`vfork` **et** préservé par `execve` — donc aussi via `posix_spawn`
|
||||
/// (qui est `clone`+`execve` sous le capot), car l'enforcement vit au niveau des
|
||||
/// *credentials* de la tâche, hors d'atteinte de l'espace utilisateur. Le PTY n'obtenait
|
||||
/// le `fork`+`exec` que comme **effet de bord** du `pre_exec` interne de `portable-pty` ;
|
||||
/// la garantie de sécurité, elle, ne repose que sur cet héritage. Le thread meurt avec
|
||||
/// sa restriction, les autres threads d'IdeA ne sont jamais touchés.
|
||||
///
|
||||
/// `enforce` fail-closed : un `Err` ⇒ [`AgentSessionError::Start`] et **aucun** enfant.
|
||||
///
|
||||
/// ## Timeout sous sandbox
|
||||
///
|
||||
/// Le thread bloquant n'est pas annulable « de l'extérieur ». On le réconcilie avec
|
||||
/// l'async par deux canaux oneshot : le thread renvoie un **killer**
|
||||
/// (`Arc<Mutex<Child>>`) juste après le spawn, puis son résultat à la fin. On pose
|
||||
/// `tokio::time::timeout` sur la réception du résultat ; à expiration on **tue
|
||||
/// l'enfant** via le killer ⇒ EOF côté stdout ⇒ le thread sort de sa boucle de drain,
|
||||
/// `wait()` (reap, pas de zombie) et se termine. On renvoie alors [`AgentSessionError::Timeout`].
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn run_turn_sandboxed(
|
||||
spec: &SpawnLine,
|
||||
plan: SandboxPlan,
|
||||
enforcer: Arc<dyn SandboxEnforcer>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Vec<String>, AgentSessionError> {
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
// Données possédées : rien n'emprunte le thread jetable.
|
||||
let command = spec.command.clone();
|
||||
let args = spec.args.clone();
|
||||
let cwd = spec.cwd.clone();
|
||||
let env = spec.env.clone();
|
||||
let stdin = spec.stdin.clone();
|
||||
|
||||
let (killer_tx, killer_rx) =
|
||||
tokio::sync::oneshot::channel::<Arc<StdMutex<std::process::Child>>>();
|
||||
let (done_tx, done_rx) =
|
||||
tokio::sync::oneshot::channel::<Result<Vec<String>, AgentSessionError>>();
|
||||
|
||||
// Thread JETABLE : sa restriction Landlock meurt avec lui.
|
||||
std::thread::spawn(move || {
|
||||
let result =
|
||||
drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx);
|
||||
// Le récepteur peut avoir abandonné (timeout) : on ignore l'erreur d'envoi.
|
||||
let _ = done_tx.send(result);
|
||||
});
|
||||
|
||||
match timeout {
|
||||
Some(dur) => match tokio::time::timeout(dur, done_rx).await {
|
||||
// Le thread a fini dans les temps (succès ou erreur métier).
|
||||
Ok(Ok(result)) => result,
|
||||
// Sender lâché sans valeur (panique du thread) ⇒ I/O.
|
||||
Ok(Err(_canceled)) => Err(AgentSessionError::Io(
|
||||
"thread sandbox terminé sans résultat".to_owned(),
|
||||
)),
|
||||
// Expiration : tue l'enfant (⇒ EOF ⇒ le thread se termine et reap).
|
||||
Err(_elapsed) => {
|
||||
if let Ok(child) = killer_rx.await {
|
||||
if let Ok(mut c) = child.lock() {
|
||||
let _ = c.kill();
|
||||
}
|
||||
}
|
||||
Err(AgentSessionError::Timeout)
|
||||
}
|
||||
},
|
||||
None => match done_rx.await {
|
||||
Ok(result) => result,
|
||||
Err(_canceled) => Err(AgentSessionError::Io(
|
||||
"thread sandbox terminé sans résultat".to_owned(),
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain **bloquant et sandboxé** exécuté sur le thread jetable (lot LP4-4, Linux).
|
||||
///
|
||||
/// 1. `enforce(plan)` restreint CE thread (fail-closed) — la descendance hérite le
|
||||
/// domaine Landlock (cf. doc de [`run_turn_sandboxed`]) ;
|
||||
/// 2. `std::process::Command::spawn` (aucun `pre_exec` : `unsafe` interdit ici) ;
|
||||
/// 3. pousse le prompt sur stdin puis EOF ;
|
||||
/// 4. sort `stdout` du child **avant** de le partager : le drain lit sans tenir le
|
||||
/// `Mutex`, donc le killer (timeout) peut verrouiller et tuer à tout moment ;
|
||||
/// 5. draine ligne-à-ligne jusqu'à EOF ;
|
||||
/// 6. `wait()` (reap) — pas de zombie.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drain_sandboxed(
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
cwd: String,
|
||||
env: Vec<(String, String)>,
|
||||
stdin_content: Option<String>,
|
||||
enforcer: &Arc<dyn SandboxEnforcer>,
|
||||
plan: &SandboxPlan,
|
||||
killer_tx: tokio::sync::oneshot::Sender<Arc<std::sync::Mutex<std::process::Child>>>,
|
||||
) -> Result<Vec<String>, AgentSessionError> {
|
||||
use std::io::{BufRead, BufReader as StdBufReader, Write as _};
|
||||
use std::process::{Command as StdCommand, Stdio};
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
// 1. Restreint CE thread AVANT tout fork (fail-closed : Err ⇒ aucun child ne tourne).
|
||||
enforcer
|
||||
.enforce(plan)
|
||||
.map_err(|e| AgentSessionError::Start(format!("sandbox enforcement failed: {e}")))?;
|
||||
|
||||
// 2. Commande std (≡ `drain` async, mais synchrone).
|
||||
let mut cmd = StdCommand::new(&command);
|
||||
cmd.args(&args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if cwd != "/" && !cwd.is_empty() {
|
||||
cmd.current_dir(&cwd);
|
||||
}
|
||||
for (key, value) in &env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
// Pas de `pre_exec` : il serait `unsafe` (interdit dans cette crate). L'enfant
|
||||
// hérite de toute façon le domaine Landlock posé sur ce thread (cf. doc de
|
||||
// `run_turn_sandboxed`), que `std` emprunte `posix_spawn` ou `fork`+`exec`.
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| AgentSessionError::Start(format!("{command}: {e}")))?;
|
||||
|
||||
// 3. Pousse le prompt sur stdin puis le ferme (EOF). Erreur d'I/O ⇒ `Io`.
|
||||
if let Some(input) = &stdin_content {
|
||||
let mut si = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| AgentSessionError::Io("stdin pipe indisponible".to_owned()))?;
|
||||
si.write_all(input.as_bytes())
|
||||
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
|
||||
drop(si);
|
||||
} else {
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
|
||||
// 4. Sort stdout AVANT de partager le child (drain sans lock ⇒ killer libre).
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?;
|
||||
let child = Arc::new(StdMutex::new(child));
|
||||
// Donne au côté async de quoi tuer l'enfant en cas de timeout.
|
||||
let _ = killer_tx.send(Arc::clone(&child));
|
||||
|
||||
// 5. Drain ligne-à-ligne jusqu'à EOF. Un kill côté async ferme stdout ⇒ EOF.
|
||||
let mut collected = Vec::new();
|
||||
for line in StdBufReader::new(stdout).lines() {
|
||||
match line {
|
||||
Ok(l) => collected.push(l),
|
||||
Err(e) => return Err(AgentSessionError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Reap (pas de zombie). Lock tenu brièvement.
|
||||
if let Ok(mut c) = child.lock() {
|
||||
let _ = c.wait();
|
||||
}
|
||||
Ok(collected)
|
||||
}
|
||||
|
||||
/// Cœur du drain : spawn → écriture stdin → lecture ligne-à-ligne → wait.
|
||||
async fn drain(spec: &SpawnLine) -> Result<Vec<String>, AgentSessionError> {
|
||||
let mut cmd = Command::new(&spec.command);
|
||||
|
||||
Reference in New Issue
Block a user