É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>
480 lines
20 KiB
Rust
480 lines
20 KiB
Rust
//! **Tests bout-en-bout de l'enforcement Landlock sur le chemin STRUCTURÉ** (lot
|
|
//! LP4-4). Pair du module `sandbox_e2e_tests` ajouté dans [`crate::pty`] au lot
|
|
//! LP4-3 (chemin PTY brut) ; ici la cible est la machinerie des sessions structurées
|
|
//! (Claude/Codex en mode JSON) : `run_turn` → `run_turn_sandboxed` → `drain_sandboxed`
|
|
//! (thread jetable restreint par `enforce()` AVANT le spawn std, puis héritage du
|
|
//! domaine Landlock à travers `fork`/`exec`).
|
|
//!
|
|
//! **ZÉRO token** : aucun vrai `claude`/`codex` n'est lancé. On substitue soit `sh`
|
|
//! (qui émet une ligne JSONL puis tente des écritures FS), soit le [`FakeCli`]
|
|
//! scriptable existant. Tout est derrière `#[cfg(all(test, target_os = "linux"))]`
|
|
//! et — pour les assertions qui exigent un fencing réel — derrière le garde
|
|
//! [`landlock_is_enforced`] (skip propre sur un kernel sans Landlock, comme les
|
|
//! tests de l'adapter et du PTY).
|
|
//!
|
|
//! ## Couverture des 7 invariants (Architecte)
|
|
//!
|
|
//! 1. **Parité** — `pty_structured_run_turn_enforces_plan_end_to_end`
|
|
//! 2. **Companion négatif** — `structured_run_turn_without_plan_does_not_sandbox`
|
|
//! 3. **Fail-closed** — `structured_run_turn_fail_closed_no_child_on_enforce_err`
|
|
//! 4. **No-op par défaut** — `structured_run_turn_none_plan_is_native_path`
|
|
//! 5. **Confinement/irréversibilité** — `structured_two_turns_disjoint_grants_are_confined`
|
|
//! 6. **Timeout sous sandbox** — `structured_run_turn_timeout_under_sandbox`
|
|
//! 7. **Resume préservé** — `structured_sandboxed_turn_preserves_conversation_id`
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use domain::sandbox::{
|
|
PathAccess, PathGrant, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus,
|
|
};
|
|
use domain::Posture;
|
|
|
|
use super::process::{run_turn, SpawnLine};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers (calqués sur sandbox/landlock.rs et pty::sandbox_e2e_tests)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Un répertoire temporaire unique pour un test (zéro dépendance tempfile).
|
|
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-struct-sbx-{tag}-{nanos}"));
|
|
std::fs::create_dir_all(&p).unwrap();
|
|
p
|
|
}
|
|
|
|
/// Sonde si le kernel courant **applique réellement** Landlock, exactement comme le
|
|
/// chemin de lancement l'observerait : `enforce` d'un petit plan RW sur un **thread
|
|
/// jetable** (la restriction est irréversible ⇒ jamais sur le thread de test) et
|
|
/// renvoie `false` sur [`SandboxStatus::Unsupported`]. Permet le skip propre.
|
|
fn landlock_is_enforced() -> bool {
|
|
let probe = fresh_dir("probe");
|
|
let plan = SandboxPlan {
|
|
allowed: vec![PathGrant {
|
|
abs_root: probe.to_string_lossy().into_owned(),
|
|
access: PathAccess::RW,
|
|
}],
|
|
default_posture: Posture::Ask,
|
|
};
|
|
let status = std::thread::spawn(move || crate::sandbox::LandlockSandbox::new().enforce(&plan))
|
|
.join()
|
|
.unwrap()
|
|
.expect("enforce ne doit pas échouer sous une posture Ask");
|
|
let _ = std::fs::remove_dir_all(&probe);
|
|
!matches!(status, SandboxStatus::Unsupported)
|
|
}
|
|
|
|
/// Plan RW sur un seul répertoire, posture `Ask` (jamais fail-closed).
|
|
fn rw_plan(root: &Path) -> SandboxPlan {
|
|
SandboxPlan {
|
|
allowed: vec![PathGrant {
|
|
abs_root: root.to_string_lossy().into_owned(),
|
|
access: PathAccess::RW,
|
|
}],
|
|
default_posture: Posture::Ask,
|
|
}
|
|
}
|
|
|
|
/// Construit un [`SpawnLine`] sur `sh -c` qui **émet une ligne JSONL** sur stdout
|
|
/// (preuve que le drain/parsing du chemin structuré survit au sandbox) puis tente
|
|
/// d'écrire HORS grant **puis** DANS le grant. L'écriture hors-grant est tentée en
|
|
/// premier : si le marqueur in-grant apparaît, la tentative hors-grant a déjà eu lieu.
|
|
/// `json_line` ne doit contenir que des guillemets doubles (pas de quote simple).
|
|
fn writing_spawn_line(
|
|
json_line: &str,
|
|
denied_marker: &Path,
|
|
allowed_marker: &Path,
|
|
plan: Option<SandboxPlan>,
|
|
) -> SpawnLine {
|
|
let script = format!(
|
|
"printf '%s\\n' '{json}'; echo outside > '{denied}'; echo inside > '{allowed}'",
|
|
json = json_line,
|
|
denied = denied_marker.display(),
|
|
allowed = allowed_marker.display(),
|
|
);
|
|
SpawnLine {
|
|
command: "sh".to_owned(),
|
|
args: vec!["-c".to_owned(), script],
|
|
cwd: "/".to_owned(),
|
|
env: Vec::new(),
|
|
stdin: None,
|
|
sandbox: plan,
|
|
}
|
|
}
|
|
|
|
/// Une ligne JSONL `result` réaliste (format Claude vérifié) — sans quote simple.
|
|
const RESULT_LINE: &str =
|
|
r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s-1"}"#;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// INVARIANT 1 — PARITÉ (test pivot)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// **Pivot.** Le chemin structuré sandboxé (`run_turn` + plan + enforcer Landlock)
|
|
/// laisse l'écriture DANS le grant réussir, bloque (noyau) l'écriture HORS grant, et
|
|
/// **draine quand même** la ligne JSONL émise sur stdout (le parsing n'est pas cassé
|
|
/// par la restriction FS). Strictement équivalent au pivot PTY du lot LP4-3, mais via
|
|
/// la machinerie `process::run_turn` (chemin Claude/Codex JSON).
|
|
#[tokio::test]
|
|
async fn pty_structured_run_turn_enforces_plan_end_to_end() {
|
|
if !landlock_is_enforced() {
|
|
eprintln!("skip pty_structured_run_turn_enforces_plan_end_to_end: Landlock indisponible");
|
|
return;
|
|
}
|
|
let allowed = fresh_dir("p1-allowed");
|
|
let denied = fresh_dir("p1-denied");
|
|
let allowed_marker = allowed.join("in.txt");
|
|
let denied_marker = denied.join("out.txt");
|
|
|
|
let spec = writing_spawn_line(
|
|
RESULT_LINE,
|
|
&denied_marker,
|
|
&allowed_marker,
|
|
Some(rw_plan(&allowed)),
|
|
);
|
|
let enforcer = crate::sandbox::default_enforcer();
|
|
|
|
let lines = run_turn(&spec, None, Some(&enforcer))
|
|
.await
|
|
.expect("run_turn sandboxé réussit sous posture Ask");
|
|
|
|
// Le drain a bien remonté la ligne JSONL (parsing intact sous sandbox).
|
|
assert_eq!(
|
|
lines,
|
|
vec![RESULT_LINE.to_owned()],
|
|
"la ligne JSONL doit être drainée malgré la restriction FS"
|
|
);
|
|
// L'écriture in-grant a réussi…
|
|
assert!(
|
|
allowed_marker.exists(),
|
|
"écriture in-grant doit réussir: {allowed_marker:?} absent — le grant a été mal fencé"
|
|
);
|
|
// …et l'écriture hors-grant a été bloquée par le noyau.
|
|
assert!(
|
|
!denied_marker.exists(),
|
|
"BRÈCHE SANDBOX: écriture hors-grant réussie ({denied_marker:?}) — plan NON enforce sur le chemin structuré"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&allowed);
|
|
let _ = std::fs::remove_dir_all(&denied);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// INVARIANT 2 — COMPANION NÉGATIF
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Même machinerie + enforcer câblé, mais `sandbox == None` ⇒ l'écriture hors-grant
|
|
/// RÉUSSIT. Prouve que le blocage de l'invariant 1 vient du **plan enforce**, pas
|
|
/// d'une restriction ambiante du chemin structuré. (Contrôle anti faux-positif.)
|
|
#[tokio::test]
|
|
async fn structured_run_turn_without_plan_does_not_sandbox() {
|
|
let allowed = fresh_dir("p2-allowed");
|
|
let denied = fresh_dir("p2-denied");
|
|
let allowed_marker = allowed.join("in.txt");
|
|
let denied_marker = denied.join("out.txt");
|
|
|
|
// plan = None ⇒ chemin natif, l'enforcer est ignoré même s'il est fourni.
|
|
let spec = writing_spawn_line(RESULT_LINE, &denied_marker, &allowed_marker, None);
|
|
let enforcer = crate::sandbox::default_enforcer();
|
|
|
|
let lines = run_turn(&spec, None, Some(&enforcer))
|
|
.await
|
|
.expect("run_turn natif réussit");
|
|
|
|
assert_eq!(lines, vec![RESULT_LINE.to_owned()]);
|
|
assert!(allowed_marker.exists(), "écriture in-grant réussit (sans plan)");
|
|
assert!(
|
|
denied_marker.exists(),
|
|
"sans plan, l'écriture hors-grant DOIT réussir: {denied_marker:?} absent — restriction ambiante anormale"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&allowed);
|
|
let _ = std::fs::remove_dir_all(&denied);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// INVARIANT 3 — FAIL-CLOSED
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Enforcer double qui **échoue toujours** — simule fidèlement le cas réel
|
|
/// « posture Deny sur kernel sans Landlock » (où `enforce` renvoie
|
|
/// [`SandboxError::KernelTooOld`]), de façon **déterministe** et indépendante du
|
|
/// kernel de CI (impossible de forcer `NotEnforced` sur une machine Landlock-capable).
|
|
#[derive(Debug)]
|
|
struct AlwaysFailEnforcer;
|
|
|
|
impl SandboxEnforcer for AlwaysFailEnforcer {
|
|
fn kind(&self) -> SandboxKind {
|
|
SandboxKind::Landlock
|
|
}
|
|
fn enforce(&self, _plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError> {
|
|
Err(SandboxError::KernelTooOld(
|
|
"simulé: Landlock indisponible + posture Deny ⇒ fail-closed".to_owned(),
|
|
))
|
|
}
|
|
}
|
|
|
|
/// **Fail-closed.** Quand `enforce` renvoie `Err`, `run_turn` (chemin sandboxé) doit
|
|
/// remonter [`AgentSessionError::Start`] et **aucun enfant ne tourne** : le marqueur
|
|
/// que le child aurait écrit reste absent. C'est le câblage critique de sécurité du
|
|
/// chemin structuré (équivalent du fail-closed PTY).
|
|
#[tokio::test]
|
|
async fn structured_run_turn_fail_closed_no_child_on_enforce_err() {
|
|
use domain::ports::AgentSessionError;
|
|
|
|
let dir = fresh_dir("p3");
|
|
let marker = dir.join("should-not-exist.txt");
|
|
|
|
// Plan posture Deny (cohérent avec le scénario simulé) + enforcer qui échoue.
|
|
let plan = SandboxPlan {
|
|
allowed: vec![PathGrant {
|
|
abs_root: dir.to_string_lossy().into_owned(),
|
|
access: PathAccess::RW,
|
|
}],
|
|
default_posture: Posture::Deny,
|
|
};
|
|
// Le child écrirait CE marqueur s'il était lancé : il ne doit jamais l'être.
|
|
let script = format!("echo ran > '{}'", marker.display());
|
|
let spec = SpawnLine {
|
|
command: "sh".to_owned(),
|
|
args: vec!["-c".to_owned(), script],
|
|
cwd: "/".to_owned(),
|
|
env: Vec::new(),
|
|
stdin: None,
|
|
sandbox: Some(plan),
|
|
};
|
|
let enforcer: Arc<dyn SandboxEnforcer> = Arc::new(AlwaysFailEnforcer);
|
|
|
|
let err = run_turn(&spec, None, Some(&enforcer))
|
|
.await
|
|
.expect_err("enforce Err ⇒ run_turn doit échouer (fail-closed)");
|
|
assert!(
|
|
matches!(err, AgentSessionError::Start(_)),
|
|
"fail-closed doit remonter Start, vu: {err:?}"
|
|
);
|
|
assert!(
|
|
!marker.exists(),
|
|
"FAIL-CLOSED VIOLÉ: un enfant a tourné ({marker:?} existe) malgré l'échec d'enforce"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// INVARIANT 4 — NO-OP PAR DÉFAUT
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// **No-op par défaut.** `plan == None` (effective_permissions None) ⇒ le chemin async
|
|
/// tokio historique est emprunté, comportement natif inchangé : le drain remonte les
|
|
/// lignes et **aucune** restriction n'est posée (une écriture arbitraire réussit),
|
|
/// y compris quand un enforcer est pourtant câblé sur la fabrique. La non-régression
|
|
/// du gros de la suite structurée (conformance/D0/D3) confirme l'absence d'effet de bord.
|
|
#[tokio::test]
|
|
async fn structured_run_turn_none_plan_is_native_path() {
|
|
let dir = fresh_dir("p4");
|
|
let marker = dir.join("native.txt");
|
|
let script = format!("printf '%s\\n' '{RESULT_LINE}'; echo ok > '{}'", marker.display());
|
|
let spec = SpawnLine {
|
|
command: "sh".to_owned(),
|
|
args: vec!["-c".to_owned(), script],
|
|
cwd: "/".to_owned(),
|
|
env: Vec::new(),
|
|
stdin: None,
|
|
sandbox: None, // ⇐ rien posé ⇒ chemin natif
|
|
};
|
|
let enforcer = crate::sandbox::default_enforcer();
|
|
|
|
// Enforcer fourni MAIS plan None ⇒ la branche sandboxée n'est pas prise.
|
|
let lines = run_turn(&spec, None, Some(&enforcer))
|
|
.await
|
|
.expect("chemin natif réussit");
|
|
assert_eq!(lines, vec![RESULT_LINE.to_owned()]);
|
|
assert!(
|
|
marker.exists(),
|
|
"sans plan, aucune restriction: l'écriture native doit réussir"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// INVARIANT 5 — CONFINEMENT / IRRÉVERSIBILITÉ
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// **Confinement.** Deux tours successifs (instances `run_turn` distinctes) avec des
|
|
/// grants **disjoints** : chacun ne voit que son propre périmètre. Le thread jetable
|
|
/// du 1er tour meurt avec sa restriction ⇒ le 2e tour n'en hérite pas (et inversement).
|
|
/// Prouve que l'enforcement ne « bave » pas d'un tour à l'autre ni sur IdeA.
|
|
#[tokio::test]
|
|
async fn structured_two_turns_disjoint_grants_are_confined() {
|
|
if !landlock_is_enforced() {
|
|
eprintln!("skip structured_two_turns_disjoint_grants_are_confined: Landlock indisponible");
|
|
return;
|
|
}
|
|
let dir_a = fresh_dir("p5-a");
|
|
let dir_b = fresh_dir("p5-b");
|
|
let enforcer = crate::sandbox::default_enforcer();
|
|
|
|
// --- Tour A : grant = dir_a. Écrit dans A (ok) puis B (bloqué). ---
|
|
let a_in = dir_a.join("in.txt");
|
|
let a_into_b = dir_b.join("from-a.txt");
|
|
let spec_a = writing_spawn_line(RESULT_LINE, &a_into_b, &a_in, Some(rw_plan(&dir_a)));
|
|
run_turn(&spec_a, None, Some(&enforcer))
|
|
.await
|
|
.expect("tour A ok");
|
|
assert!(a_in.exists(), "tour A: écriture dans son grant (A) doit réussir");
|
|
assert!(
|
|
!a_into_b.exists(),
|
|
"tour A: écriture dans B (hors grant A) doit être bloquée"
|
|
);
|
|
|
|
// --- Tour B : grant = dir_b. Écrit dans B (ok) puis A (bloqué). ---
|
|
// Si la restriction du tour A avait bavé, l'écriture dans B échouerait ici.
|
|
let b_in = dir_b.join("in.txt");
|
|
let b_into_a = dir_a.join("from-b.txt");
|
|
let spec_b = writing_spawn_line(RESULT_LINE, &b_into_a, &b_in, Some(rw_plan(&dir_b)));
|
|
run_turn(&spec_b, None, Some(&enforcer))
|
|
.await
|
|
.expect("tour B ok");
|
|
assert!(
|
|
b_in.exists(),
|
|
"tour B: écriture dans son grant (B) doit réussir — la restriction du tour A n'a pas bavé"
|
|
);
|
|
assert!(
|
|
!b_into_a.exists(),
|
|
"tour B: écriture dans A (hors grant B) doit être bloquée"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&dir_a);
|
|
let _ = std::fs::remove_dir_all(&dir_b);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// INVARIANT 6 — TIMEOUT SOUS SANDBOX
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// **Timeout sous sandbox.** Un enfant qui ne ferme jamais stdout (`sleep`) lancé via
|
|
/// le chemin sandboxé : `run_turn(timeout)` doit **tuer** l'enfant et renvoyer
|
|
/// [`AgentSessionError::Timeout`], rapidement (≪ durée du sleep) — preuve que le killer
|
|
/// oneshot + `tokio::time::timeout` fonctionnent et qu'aucun thread ne reste bloqué.
|
|
#[tokio::test]
|
|
async fn structured_run_turn_timeout_under_sandbox() {
|
|
use domain::ports::AgentSessionError;
|
|
|
|
let dir = fresh_dir("p6");
|
|
// `sleep 30` garde stdout ouvert ⇒ le drain bloquerait sans le killer du timeout.
|
|
let spec = SpawnLine {
|
|
command: "sleep".to_owned(),
|
|
args: vec!["30".to_owned()],
|
|
cwd: "/".to_owned(),
|
|
env: Vec::new(),
|
|
stdin: None,
|
|
sandbox: Some(rw_plan(&dir)), // ⇒ branche sandboxée (posture Ask ⇒ enforce Ok)
|
|
};
|
|
let enforcer = crate::sandbox::default_enforcer();
|
|
|
|
let started = Instant::now();
|
|
let err = run_turn(&spec, Some(Duration::from_millis(250)), Some(&enforcer))
|
|
.await
|
|
.expect_err("doit expirer");
|
|
let elapsed = started.elapsed();
|
|
|
|
assert!(
|
|
matches!(err, AgentSessionError::Timeout),
|
|
"le tour sandboxé doit expirer en Timeout, vu: {err:?}"
|
|
);
|
|
assert!(
|
|
elapsed < Duration::from_secs(10),
|
|
"le timeout doit tuer l'enfant promptement (vu {elapsed:?}) — pas d'attente des 30s"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// INVARIANT 7 — RESUME PRÉSERVÉ (via la fabrique réelle + adapter Claude)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// **Resume préservé.** Après un tour réellement sandboxé, l'`session_id`/conversation_id
|
|
/// est toujours capté correctement : la restriction FS ne casse pas le parsing du flux.
|
|
/// Passe par la **fabrique réelle** `StructuredSessionFactory::with_sandbox_enforcer`,
|
|
/// un profil Claude branché sur un [`FakeCli`] (init + result), et un plan transmis à
|
|
/// `start(.., Some(&plan))`. Le fake n'écrit aucun fichier (il imprime sur un pipe, hors
|
|
/// périmètre Landlock-FS), donc un plan write-only suffit : on prouve que la capture
|
|
/// d'id survit au domaine actif.
|
|
#[tokio::test]
|
|
async fn structured_sandboxed_turn_preserves_conversation_id() {
|
|
use domain::ids::ProfileId;
|
|
use domain::ports::{AgentSessionFactory, PreparedContext, ReplyEvent, SessionPlan};
|
|
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
|
use domain::project::ProjectPath;
|
|
use domain::MarkdownDoc;
|
|
|
|
use super::conformance::FakeCli;
|
|
use super::factory::StructuredSessionFactory;
|
|
|
|
if !landlock_is_enforced() {
|
|
eprintln!("skip structured_sandboxed_turn_preserves_conversation_id: Landlock indisponible");
|
|
return;
|
|
}
|
|
|
|
let run_dir = fresh_dir("p7");
|
|
let fake = FakeCli::printing(&[
|
|
r#"{"type":"system","subtype":"init","session_id":"conv-sbx-1","cwd":"/tmp","tools":[]}"#,
|
|
r#"{"type":"result","subtype":"success","is_error":false,"result":"réponse","session_id":"conv-sbx-1","num_turns":1}"#,
|
|
]);
|
|
|
|
let profile = AgentProfile::new(
|
|
ProfileId::new_random(),
|
|
"Claude sandboxé",
|
|
&fake.command(),
|
|
Vec::new(),
|
|
ContextInjection::convention_file("CLAUDE.md").expect("convention file valide"),
|
|
None,
|
|
"{agentRunDir}",
|
|
None,
|
|
)
|
|
.expect("profil valide")
|
|
.with_structured_adapter(StructuredAdapter::Claude);
|
|
|
|
let factory =
|
|
StructuredSessionFactory::new().with_sandbox_enforcer(crate::sandbox::default_enforcer());
|
|
|
|
let ctx = PreparedContext {
|
|
content: MarkdownDoc::new("# ctx"),
|
|
relative_path: "CLAUDE.md".to_owned(),
|
|
};
|
|
let cwd = ProjectPath::new(run_dir.to_string_lossy().into_owned()).expect("cwd absolu");
|
|
let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés
|
|
|
|
let session = factory
|
|
.start(&profile, &ctx, &cwd, &SessionPlan::None, Some(&plan))
|
|
.await
|
|
.expect("start sandboxé ok");
|
|
|
|
// Avant tout tour : aucun id moteur.
|
|
assert_eq!(session.conversation_id(), None);
|
|
|
|
// Un tour sandboxé : le flux doit porter un Final ET capter l'id.
|
|
let events: Vec<ReplyEvent> = session.send("salut").await.expect("send ok").collect();
|
|
let finals = events
|
|
.iter()
|
|
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
|
.count();
|
|
assert_eq!(finals, 1, "un Final attendu malgré le sandbox, vu: {events:?}");
|
|
|
|
// LE POINT : l'id de conversation a bien été capté sous enforcement actif.
|
|
assert_eq!(
|
|
session.conversation_id().as_deref(),
|
|
Some("conv-sbx-1"),
|
|
"la restriction FS ne doit pas casser la capture du conversation_id (resume)"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&run_dir);
|
|
}
|