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