merge(application): writable roots Codex corrects sur projets non-IdeA — #309 (QA verte)

intègre fix/309-codex-writable-roots commit 1f38c61 — politique de lancement Codex unifiée: project root par défaut sans permissions explicites, roots extra posés uniquement en workspace-write, réécriture --sandbox/--add-dir du SpawnSpec PTY par apply_codex_pty_launch_policy (composition identique first-run/relaunch/custom CLI/headless) ; QA verte reconfirmée par Git: fmt --check, diff --check, codex_permissions 3/3, lifecycle 48/48 ; fichiers .ideai/*, attachments/ et scripts/ hors périmètre préservés en worktree ; aucun push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 19:04:11 +02:00
3 changed files with 235 additions and 15 deletions

View File

@ -37,9 +37,7 @@ use domain::{
use domain::live_state::WorkStatus; use domain::live_state::WorkStatus;
use crate::codex_permissions::{ use crate::codex_permissions::{codex_permission_writable_roots, compile_codex_launch_policy};
codex_permission_writable_roots, codex_sandbox_mode, compile_codex_launch_policy,
};
use crate::error::AppError; use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc}; use crate::layout::{persist_doc, resolve_doc};
use crate::managed_temp::{ use crate::managed_temp::{
@ -1649,19 +1647,56 @@ fn append_codex_pty_model_overrides(profile: &AgentProfile, spec: &mut SpawnSpec
} }
} }
fn append_codex_pty_managed_temp_roots( fn apply_codex_pty_launch_policy(
profile: &AgentProfile, profile: &AgentProfile,
permissions: Option<&EffectivePermissions>, permissions: Option<&EffectivePermissions>,
network: Option<NetworkPolicy>,
project_root: &str,
run_dir: &str,
managed_temp_roots: &[String], managed_temp_roots: &[String],
spec: &mut SpawnSpec, spec: &mut SpawnSpec,
) { ) {
if managed_temp_roots.is_empty() || !is_codex_profile(profile) { if !is_codex_profile(profile) {
return; return;
} }
if codex_sandbox_mode(permissions, managed_temp_roots) != "workspace-write" {
return; let StructuredProviderLaunchPolicy::Codex {
sandbox_mode,
writable_roots,
..
} = compile_codex_launch_policy(
project_root,
run_dir,
permissions,
network,
managed_temp_roots,
);
let mut index = 0;
let mut sandbox_seen = false;
while index < spec.args.len() {
if spec.args[index] == "--sandbox" && index + 1 < spec.args.len() {
spec.args[index + 1] = sandbox_mode.clone();
sandbox_seen = true;
index += 2;
continue;
} }
for root in managed_temp_roots { if spec.args[index] == "--add-dir"
&& spec
.args
.get(index + 1)
.is_some_and(|root| root == project_root)
{
spec.args.drain(index..=index + 1);
continue;
}
index += 1;
}
if !sandbox_seen {
spec.args.push("--sandbox".to_owned());
spec.args.push(sandbox_mode);
}
for root in writable_roots {
if spec if spec
.args .args
.windows(2) .windows(2)
@ -1670,7 +1705,7 @@ fn append_codex_pty_managed_temp_roots(
continue; continue;
} }
spec.args.push("--add-dir".to_owned()); spec.args.push("--add-dir".to_owned());
spec.args.push(root.clone()); spec.args.push(root);
} }
} }
@ -2626,9 +2661,12 @@ impl LaunchAgent {
spec.sandbox = spec.sandbox =
enrich_sandbox_plan_with_managed_temp_roots(spec.sandbox, &managed_temp_writable_roots); enrich_sandbox_plan_with_managed_temp_roots(spec.sandbox, &managed_temp_writable_roots);
spec.sandbox = enrich_sandbox_plan_with_plugin_mcp_roots(spec.sandbox, &plugin_mcp_servers); spec.sandbox = enrich_sandbox_plan_with_plugin_mcp_roots(spec.sandbox, &plugin_mcp_servers);
append_codex_pty_managed_temp_roots( apply_codex_pty_launch_policy(
&profile, &profile,
effective_permissions.as_ref(), effective_permissions.as_ref(),
network_permission,
input.project.root.as_str(),
run_dir.as_str(),
&managed_temp_writable_roots, &managed_temp_writable_roots,
&mut spec, &mut spec,
); );
@ -4630,6 +4668,95 @@ mod tests {
use super::*; use super::*;
use domain::Skill; use domain::Skill;
fn codex_profile() -> AgentProfile {
crate::agent::reference_profiles()
.into_iter()
.find(|profile| profile.projector == Some(ProjectorKey::Codex))
.expect("reference Codex profile")
}
fn spawn_spec(args: Vec<&str>) -> SpawnSpec {
SpawnSpec {
command: "codex".to_owned(),
args: args.into_iter().map(str::to_owned).collect(),
cwd: ProjectPath::new("/project/.ideai/run/main").unwrap(),
env: Vec::new(),
context_plan: None,
sandbox: None,
}
}
#[test]
fn codex_pty_first_launch_pairs_workspace_mode_with_project_and_temp_roots() {
let mut spec = spawn_spec(Vec::new());
apply_codex_pty_launch_policy(
&codex_profile(),
None,
None,
"/project",
"/project/.ideai/run/main",
&["/managed/tmp".to_owned(), "/project".to_owned()],
&mut spec,
);
assert!(spec
.args
.windows(2)
.any(|pair| pair == ["--sandbox", "workspace-write"]));
assert_eq!(
spec.args
.windows(2)
.filter(|pair| pair == &["--add-dir", "/project"])
.count(),
1
);
assert_eq!(
spec.args
.windows(2)
.filter(|pair| pair == &["--add-dir", "/managed/tmp"])
.count(),
1
);
}
#[test]
fn codex_pty_deny_replaces_advisory_workspace_root_without_temp_escape() {
use domain::permission::{
resolve, Capability, Effect, PathScope, PermissionRule, PermissionSet, Posture,
};
let deny = PermissionRule::file(
Capability::Delete,
Effect::Deny,
PathScope::new(["**".to_owned()]).unwrap(),
)
.unwrap();
let permissions =
resolve(Some(&PermissionSet::new(vec![deny], Posture::Ask)), None).unwrap();
let mut spec = spawn_spec(vec![
"--sandbox",
"workspace-write",
"--add-dir",
"/project",
]);
apply_codex_pty_launch_policy(
&codex_profile(),
Some(&permissions),
None,
"/project",
"/project/.ideai/run/main",
&["/managed/tmp".to_owned()],
&mut spec,
);
assert!(spec
.args
.windows(2)
.any(|pair| pair == ["--sandbox", "read-only"]));
assert!(!spec.args.iter().any(|arg| arg == "--add-dir"));
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn compose_convention_file( fn compose_convention_file(
project_root: &str, project_root: &str,

View File

@ -11,9 +11,10 @@ pub(crate) fn compile_codex_launch_policy(
extra_writable_roots: &[String], extra_writable_roots: &[String],
) -> StructuredProviderLaunchPolicy { ) -> StructuredProviderLaunchPolicy {
let mut writable_roots = codex_permission_writable_roots(project_root, run_dir, permissions); let mut writable_roots = codex_permission_writable_roots(project_root, run_dir, permissions);
append_unique_paths(&mut writable_roots, extra_writable_roots);
let sandbox_mode = codex_sandbox_mode(permissions, &writable_roots).to_owned(); let sandbox_mode = codex_sandbox_mode(permissions, &writable_roots).to_owned();
if sandbox_mode != "workspace-write" { if sandbox_mode == "workspace-write" {
append_unique_paths(&mut writable_roots, extra_writable_roots);
} else {
writable_roots.clear(); writable_roots.clear();
} }
@ -30,7 +31,11 @@ pub(crate) fn codex_permission_writable_roots(
permissions: Option<&EffectivePermissions>, permissions: Option<&EffectivePermissions>,
) -> Vec<String> { ) -> Vec<String> {
let Some(permissions) = permissions else { let Some(permissions) = permissions else {
return Vec::new(); return if project_root.is_empty() {
Vec::new()
} else {
vec![project_root.to_owned()]
};
}; };
let mut roots = Vec::new(); let mut roots = Vec::new();
@ -99,3 +104,75 @@ fn append_unique_path(target: &mut Vec<String>, root: &str) {
} }
target.push(root.to_owned()); target.push(root.to_owned());
} }
#[cfg(test)]
mod tests {
use super::*;
use domain::permission::{resolve, PathScope, PermissionRule, PermissionSet};
fn effective(rules: Vec<PermissionRule>, fallback: Posture) -> EffectivePermissions {
resolve(Some(&PermissionSet::new(rules, fallback)), None).unwrap()
}
fn codex(policy: StructuredProviderLaunchPolicy) -> (String, Vec<String>) {
let StructuredProviderLaunchPolicy::Codex {
sandbox_mode,
writable_roots,
..
} = policy;
(sandbox_mode, writable_roots)
}
#[test]
fn unconfigured_policy_writes_project_and_deduplicated_managed_temp() {
let (mode, roots) = codex(compile_codex_launch_policy(
"/project",
"/project/.ideai/run/main",
None,
None,
&["/managed/tmp".to_owned(), "/project".to_owned()],
));
assert_eq!(mode, "workspace-write");
assert_eq!(roots, vec!["/project", "/managed/tmp"]);
}
#[test]
fn explicit_write_deny_never_gains_project_or_managed_temp_root() {
let deny = PermissionRule::file(
Capability::Write,
Effect::Deny,
PathScope::new(["**".to_owned()]).unwrap(),
)
.unwrap();
let permissions = effective(vec![deny], Posture::Ask);
let (mode, roots) = codex(compile_codex_launch_policy(
"/project",
"/run",
Some(&permissions),
None,
&["/managed/tmp".to_owned()],
));
assert_eq!(mode, "read-only");
assert!(roots.is_empty());
}
#[test]
fn partial_allow_exposes_only_its_computed_rw_grant() {
let allow = PermissionRule::file(
Capability::Write,
Effect::Allow,
PathScope::new(["generated/**".to_owned()]).unwrap(),
)
.unwrap();
let permissions = effective(vec![allow], Posture::Deny);
let (mode, roots) = codex(compile_codex_launch_policy(
"/project",
"/run",
Some(&permissions),
None,
&[],
));
assert_eq!(mode, "workspace-write");
assert_eq!(roots, vec!["/project/generated"]);
}
}

View File

@ -4342,9 +4342,16 @@ async fn codex_network_env_is_projected_without_filesystem_permission_policy() {
.contains("[sandbox_workspace_write]\nnetwork_access = true")), .contains("[sandbox_workspace_write]\nnetwork_access = true")),
"network policy must still update Codex config without filesystem/bash policy" "network policy must still update Codex config without filesystem/bash policy"
); );
let args = &pty.spawns()[0].args;
assert!( assert!(
!pty.spawns()[0].args.contains(&"--sandbox".to_owned()), args.windows(2)
"no filesystem/bash policy means no Codex sandbox args are folded" .any(|pair| pair == ["--sandbox", "workspace-write"]),
"Codex runs from its isolated run dir, so an unconfigured project still needs an explicit workspace-write mode: {args:?}"
);
assert!(
args.windows(2)
.any(|pair| pair == ["--add-dir", "/home/me/proj"]),
"the real project root must be writable on the first PTY spawn: {args:?}"
); );
assert_eq!( assert_eq!(
pty.spawns()[0] pty.spawns()[0]
@ -4396,6 +4403,15 @@ async fn codex_projection_forwards_network_env_to_structured_session_factory() {
"structured launch output is populated" "structured launch output is populated"
); );
assert_eq!(factory.envs().len(), 1, "exactly one structured launch"); assert_eq!(factory.envs().len(), 1, "exactly one structured launch");
assert_eq!(
factory.policies(),
vec![Some(domain::ports::StructuredProviderLaunchPolicy::Codex {
sandbox_mode: "workspace-write".to_owned(),
writable_roots: vec!["/home/me/proj".to_owned()],
network_access: true,
})],
"unconfigured structured Codex must receive the project root because cwd is the run dir"
);
assert_eq!( assert_eq!(
factory.envs()[0] factory.envs()[0]
.iter() .iter()