fix(permissions): validate network access flow

This commit is contained in:
2026-07-26 10:52:46 +02:00
parent 3047dc9195
commit 13fb538880
70 changed files with 4784 additions and 158 deletions

View File

@ -154,8 +154,12 @@ pub struct CodexExecSession {
command: String,
/// Répertoire de travail (run dir isolé §14.1).
cwd: String,
/// Codex CLI sandbox mode passed to `codex exec --sandbox`.
sandbox_mode: String,
/// Project/workspace roots that must be writable in Codex's CLI sandbox.
writable_roots: Vec<String>,
/// Structured policy projection of Codex workspace-write sandbox network access.
network_access: Option<bool>,
/// Variables d'environnement préparées au lancement (ex. `CODEX_HOME` isolé).
env: Vec<(String, String)>,
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
@ -183,12 +187,44 @@ impl CodexExecSession {
env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Self {
Self::new_with_policy(
id,
command,
cwd,
seed_conversation_id,
"workspace-write",
writable_roots,
None,
env,
sandbox,
sandbox_enforcer,
)
}
/// Construit l'adapter avec la politique compatible `codex exec` résolue par
/// `LaunchAgent`.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new_with_policy(
id: SessionId,
command: impl Into<String>,
cwd: impl Into<String>,
seed_conversation_id: Option<String>,
sandbox_mode: impl Into<String>,
writable_roots: Vec<String>,
network_access: Option<bool>,
env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Self {
Self {
id,
command: command.into(),
cwd: cwd.into(),
sandbox_mode: sandbox_mode.into(),
writable_roots,
network_access,
env,
conversation_id: Mutex::new(seed_conversation_id),
sandbox,
@ -217,28 +253,54 @@ impl CodexExecSession {
let conversation_id = self.conversation_id.lock().expect("mutex sain").clone();
args.push("--json".to_owned());
args.push("--skip-git-repo-check".to_owned());
args.push("--sandbox".to_owned());
args.push("workspace-write".to_owned());
for root in self.writable_roots.iter().filter(|root| !root.is_empty()) {
args.push("--add-dir".to_owned());
args.push(root.clone());
if !self.sandbox_mode.trim().is_empty() {
args.push("--sandbox".to_owned());
args.push(self.sandbox_mode.clone());
}
if self.sandbox_mode == "workspace-write" {
for root in self.writable_roots.iter().filter(|root| !root.is_empty()) {
args.push("--add-dir".to_owned());
args.push(root.clone());
}
}
if let Some(network_access) = self.network_access {
args.push("-c".to_owned());
args.push(format!(
"sandbox_workspace_write.network_access={network_access}"
));
}
if let Some(id) = conversation_id {
args.push("resume".to_owned());
args.push(id);
}
args.push(prompt.to_owned());
let mut env = self.env.clone();
if let Some(network_access) = self.network_access {
upsert_env(
&mut env,
"CODEX_SANDBOX_NETWORK_DISABLED",
if network_access { "0" } else { "1" },
);
}
SpawnLine {
command: self.command.clone(),
args,
cwd: self.cwd.clone(),
env: self.env.clone(),
env,
stdin: None,
sandbox: self.sandbox.clone(),
}
}
}
fn upsert_env(env: &mut Vec<(String, String)>, key: &str, value: &str) {
if let Some((_, existing)) = env.iter_mut().find(|(k, _)| k == key) {
*existing = value.to_owned();
} else {
env.push((key.to_owned(), value.to_owned()));
}
}
#[async_trait]
impl AgentSession for CodexExecSession {
fn id(&self) -> SessionId {

View File

@ -14,7 +14,7 @@ use serde_json::Value;
use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan,
ToolInvocationError, ToolInvoker, ToolSpec,
StructuredProviderLaunchPolicy, ToolInvocationError, ToolInvoker, ToolSpec,
};
use domain::profile::{AgentProfile, StructuredAdapter};
use domain::project::ProjectPath;
@ -139,6 +139,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
requester: Option<&str>,
env: &[(String, String)],
sandbox: Option<&SandboxPlan>,
structured_policy: Option<&StructuredProviderLaunchPolicy>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
let adapter = profile.structured_adapter.ok_or_else(|| {
AgentSessionError::Start(format!(
@ -171,16 +172,36 @@ impl AgentSessionFactory for StructuredSessionFactory {
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(
id, command, cwd, seed, plan, enforcer,
)),
StructuredAdapter::Codex => Arc::new(CodexExecSession::new(
id,
command,
cwd,
seed,
vec![ctx.project_root.clone()],
env.to_vec(),
plan,
enforcer,
)),
StructuredAdapter::Codex => {
let policy = match structured_policy {
Some(StructuredProviderLaunchPolicy::Codex {
sandbox_mode,
writable_roots,
network_access,
}) => (
sandbox_mode.clone(),
writable_roots.clone(),
Some(*network_access),
),
None => (
"workspace-write".to_owned(),
vec![ctx.project_root.clone()],
None,
),
};
Arc::new(CodexExecSession::new_with_policy(
id,
command,
cwd,
seed,
policy.0,
policy.1,
policy.2,
env.to_vec(),
plan,
enforcer,
))
}
StructuredAdapter::OpenCode => Arc::new(OpenCodeSession::new(
id,
profile.command.clone(),

View File

@ -513,6 +513,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
.expect("start Claude ok");
@ -531,6 +532,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
.expect("start Codex ok");
@ -563,6 +565,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
.expect("start OpenAI-compatible ok");
@ -592,7 +595,16 @@ mod tests {
};
let session = factory
.start(&codex, &ctx, &cwd(), &SessionPlan::None, None, &[], None)
.start(
&codex,
&ctx,
&cwd(),
&SessionPlan::None,
None,
&[],
None,
None,
)
.await
.expect("start Codex ok");
let content = drain_final(session.as_ref()).await;
@ -629,6 +641,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
.expect("start resume ok");
@ -1444,6 +1457,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
.expect("start resume codex");
@ -1469,6 +1483,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
.expect("start assign");
@ -1501,6 +1516,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
{
@ -1667,8 +1683,7 @@ mod tests {
// =====================================================================
// DURCISSEMENT QA (lot D3, §17.9 D3 — fix codex 0.137) — autonomie
// d'écriture Codex : la commande générée porte EXACTEMENT
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write,
// --add-dir, <project-root>, <prompt>]
// [exec, --json, --skip-git-repo-check, --sandbox, <mode>, --add-dir, <project-root>, <prompt>]
// (`resume <id>` après les options `exec` pour une reprise). Le flag `--ask-for-approval never`
// a été RETIRÉ : `codex exec` 0.137 ne le connaît pas (`error: unexpected
// argument`) et est déjà non-interactif. Ce test verrouille l'argv exact pour
@ -1759,6 +1774,186 @@ mod tests {
let _ = std::fs::remove_file(&argv);
}
#[tokio::test]
async fn codex_read_only_policy_omits_add_dir_and_approval_flag() {
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new_with_policy(
SessionId::new_random(),
cmd.clone(),
"/",
None,
"read-only",
vec!["/project/root".to_owned()],
Some(false),
Vec::new(),
None,
None,
);
let _ = session.send("analyse").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
assert_eq!(
args,
vec![
"exec",
"--json",
"--skip-git-repo-check",
"--sandbox",
"read-only",
"-c",
"sandbox_workspace_write.network_access=false",
"analyse",
],
"read-only must not carry --add-dir or --ask-for-approval and must disable Codex network, got: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
#[tokio::test]
async fn codex_workspace_write_policy_carries_network_config_override() {
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new_with_policy(
SessionId::new_random(),
cmd.clone(),
"/",
None,
"workspace-write",
vec!["/project/root".to_owned()],
Some(true),
Vec::new(),
None,
None,
);
let _ = session.send("analyse").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
assert_eq!(
args,
vec![
"exec",
"--json",
"--skip-git-repo-check",
"--sandbox",
"workspace-write",
"--add-dir",
"/project/root",
"-c",
"sandbox_workspace_write.network_access=true",
"analyse",
],
"workspace-write Allow must pass the official Codex network override, got: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
#[tokio::test]
async fn codex_structured_session_forwards_network_env() {
use std::io::Write as _;
let dir = std::env::current_dir()
.expect("cwd")
.join("target")
.join("test-fakes")
.join("session");
std::fs::create_dir_all(&dir).expect("fake dir");
let bin = dir.join(format!("idea-env-cli-{}", std::process::id()));
let env_file = dir.join(format!("idea-env-rec-{}", std::process::id()));
let script = format!(
"#!/bin/sh\nprintf '%s\\n' \"$CODEX_SANDBOX_NETWORK_DISABLED\" > '{}'\nprintf '%s\\n' '{}'\n",
env_file.display(),
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#
);
{
let mut f = std::fs::File::create(&bin).expect("create fake");
f.write_all(script.as_bytes()).expect("write fake");
f.sync_all().expect("sync fake");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut p = std::fs::metadata(&bin).unwrap().permissions();
p.set_mode(0o755);
std::fs::set_permissions(&bin, p).unwrap();
}
super::conformance::wait_until_executable(&bin);
let session = CodexExecSession::new(
SessionId::new_random(),
bin.to_string_lossy().into_owned(),
"/",
None,
Vec::new(),
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "0".to_owned())],
None,
None,
);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&env_file).expect("env file");
assert_eq!(recorded.trim(), "0");
let _ = std::fs::remove_file(&bin);
let _ = std::fs::remove_file(&env_file);
}
#[tokio::test]
async fn codex_structured_policy_overrides_stale_network_env() {
use std::io::Write as _;
let dir = std::env::current_dir()
.expect("cwd")
.join("target")
.join("test-fakes")
.join("session");
std::fs::create_dir_all(&dir).expect("fake dir");
let bin = dir.join(format!("idea-env-override-cli-{}", std::process::id()));
let env_file = dir.join(format!("idea-env-override-rec-{}", std::process::id()));
let script = format!(
"#!/bin/sh\nprintf '%s\\n' \"$CODEX_SANDBOX_NETWORK_DISABLED\" > '{}'\nprintf '%s\\n' '{}'\n",
env_file.display(),
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#
);
{
let mut f = std::fs::File::create(&bin).expect("create fake");
f.write_all(script.as_bytes()).expect("write fake");
f.sync_all().expect("sync fake");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut p = std::fs::metadata(&bin).unwrap().permissions();
p.set_mode(0o755);
std::fs::set_permissions(&bin, p).unwrap();
}
super::conformance::wait_until_executable(&bin);
let session = CodexExecSession::new_with_policy(
SessionId::new_random(),
bin.to_string_lossy().into_owned(),
"/",
None,
"workspace-write",
Vec::new(),
Some(true),
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())],
None,
None,
);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&env_file).expect("env file");
assert_eq!(recorded.trim(), "0");
let _ = std::fs::remove_file(&bin);
let _ = std::fs::remove_file(&env_file);
}
// =====================================================================
// LS2 — adapter Claude niveau 1 (§21) : `parse_reset_ms` (parseur ISO-8601
// À LA MAIN + heuristique secondes/ms + days_from_civil) et le mapping

View File

@ -478,6 +478,7 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
None,
&[],
Some(&plan),
None,
)
.await
.expect("start sandboxé ok");