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:
2026-06-16 08:47:33 +02:00
parent 7e01ac60cb
commit 6236cd727b
13 changed files with 821 additions and 39 deletions

View File

@ -25,6 +25,11 @@ pub mod conformance;
pub mod factory;
pub mod process;
/// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4),
/// Linux uniquement (pair du module `pty::sandbox_e2e_tests` du lot LP4-3).
#[cfg(all(test, target_os = "linux"))]
mod sandbox_e2e;
pub use claude::ClaudeSdkSession;
pub use codex::CodexExecSession;
pub use conformance::FakeCli;
@ -84,7 +89,7 @@ mod tests {
#[tokio::test]
async fn run_turn_drains_every_line_in_order() {
let fake = FakeCli::printing(&["ligne-1", "ligne-2", "ligne-3"]);
let lines = run_turn(&fake.spawn_line(), None)
let lines = run_turn(&fake.spawn_line(), None, None)
.await
.expect("run_turn réussit");
assert_eq!(lines, vec!["ligne-1", "ligne-2", "ligne-3"]);
@ -98,8 +103,9 @@ mod tests {
cwd: "/".to_owned(),
env: Vec::new(),
stdin: None,
sandbox: None,
};
let err = run_turn(&spec, None).await.expect_err("doit échouer");
let err = run_turn(&spec, None, None).await.expect_err("doit échouer");
assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}");
}
@ -284,6 +290,8 @@ mod tests {
fake.command(),
"/",
None,
None,
None,
));
assert_agent_session_contract(session, "claude-conv-1", "réponse Claude").await;
}
@ -296,6 +304,8 @@ mod tests {
fake.command(),
"/",
None,
None,
None,
));
assert_agent_session_contract(session, "codex-conv-1", "réponse Codex").await;
}
@ -305,7 +315,7 @@ mod tests {
#[tokio::test]
async fn stream_is_closed_after_final() {
let fake = FakeCli::printing(&claude_script());
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let stream = session.send("x").await.expect("send ok");
let events: Vec<_> = stream.collect();
let after_final = events
@ -323,7 +333,7 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
"{ ceci n'est pas du json",
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
match session.send("x").await {
Err(AgentSessionError::Decode(_)) => {}
Err(other) => panic!("attendu Decode, vu: {other:?}"),
@ -404,7 +414,7 @@ mod tests {
// Claude : la session démarre et respecte le contrat via le fake CLI.
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
let session = factory
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None)
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
.await
.expect("start Claude ok");
let content = drain_final(session.as_ref()).await;
@ -414,7 +424,7 @@ mod tests {
let fake_cx = FakeCli::printing(&codex_script());
let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command());
let session_cx = factory
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None)
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
.await
.expect("start Codex ok");
let content_cx = drain_final(session_cx.as_ref()).await;
@ -434,6 +444,7 @@ mod tests {
&SessionPlan::Resume {
conversation_id: "repris-42".to_owned(),
},
None,
)
.await
.expect("start resume ok");
@ -525,8 +536,9 @@ mod tests {
cwd: "/".to_owned(),
env: Vec::new(),
stdin: None,
sandbox: None,
};
let err = run_turn(&spec, Some(Duration::from_millis(50)))
let err = run_turn(&spec, Some(Duration::from_millis(50)), None)
.await
.expect_err("doit expirer");
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
@ -757,7 +769,7 @@ mod tests {
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#,
r#"{"type":"turn.completed","usage":{}}"#,
]);
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None);
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let events: Vec<_> = s.send("x").await.expect("send").collect();
let finals = events
.iter()
@ -785,7 +797,7 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
]);
let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let events: Vec<_> = s.send("x").await.expect("send ok").collect();
let finals = events
.iter()
@ -801,7 +813,7 @@ mod tests {
#[tokio::test]
async fn run_turn_empty_output_is_ok() {
let fake = FakeCli::printing(&[]);
let lines = run_turn(&fake.spawn_line(), None).await.expect("ok");
let lines = run_turn(&fake.spawn_line(), None, None).await.expect("ok");
assert!(lines.is_empty());
}
@ -811,7 +823,7 @@ mod tests {
let fake = FakeCli::printing(&["pong"]);
let mut spec = fake.spawn_line();
spec.stdin = Some("ping".to_owned());
let lines = run_turn(&spec, None).await.expect("ok");
let lines = run_turn(&spec, None, None).await.expect("ok");
assert_eq!(lines, vec!["pong"]);
}
@ -872,8 +884,9 @@ mod tests {
cwd: "/".to_owned(),
env: Vec::new(),
stdin: None,
sandbox: None,
};
let err = run_turn(&spec, Some(Duration::from_millis(50)))
let err = run_turn(&spec, Some(Duration::from_millis(50)), None)
.await
.expect_err("doit expirer");
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
@ -894,6 +907,8 @@ mod tests {
cmd.clone(),
"/",
Some("resume-id".to_owned()),
None,
None,
);
// conversation_id amorcé avant tout tour.
assert_eq!(session.conversation_id().as_deref(), Some("resume-id"));
@ -938,6 +953,8 @@ mod tests {
cmd.clone(),
"/",
Some("cx-id".to_owned()),
None,
None,
);
let _ = session.send("vas-y").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
@ -960,7 +977,7 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#,
r#"{"type":"result","subtype":"success","result":"r","session_id":"captured-1"}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None);
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
assert_eq!(session.conversation_id(), None);
let _ = session.send("t1").await.expect("t1");
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
@ -999,6 +1016,7 @@ mod tests {
&SessionPlan::Resume {
conversation_id: "cx-resume".to_owned(),
},
None,
)
.await
.expect("start resume codex");
@ -1021,6 +1039,7 @@ mod tests {
&SessionPlan::Assign {
conversation_id: "ignored-by-engine".to_owned(),
},
None,
)
.await
.expect("start assign");
@ -1045,7 +1064,7 @@ mod tests {
)
.expect("profil valide");
match factory
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None)
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
.await
{
Err(AgentSessionError::Start(_)) => {}
@ -1068,6 +1087,8 @@ mod tests {
fake.command(),
"/",
None,
None,
None,
));
assert_agent_session_contract(session, "cx-0", "direct").await;
}
@ -1096,7 +1117,7 @@ mod tests {
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"T"},{"type":"text","text":"b"}]},"session_id":"flow-1","parent_tool_use_id":null}"#,
r#"{"type":"result","subtype":"success","is_error":false,"result":"final-ok","session_id":"flow-1","num_turns":1}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
assert_eq!(
events,
@ -1133,7 +1154,7 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"new-1"}"#,
r#"{"type":"result","subtype":"success","result":"r","session_id":"new-1"}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None);
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let _ = session.send("bonjour").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
@ -1165,7 +1186,7 @@ mod tests {
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None);
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
@ -1207,7 +1228,7 @@ mod tests {
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None);
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
@ -1241,6 +1262,8 @@ mod tests {
cmd.clone(),
"/",
Some("cx-id".to_owned()),
None,
None,
);
let _ = session.send("vas-y").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");