diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 75b85400..48ea6363 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -11,7 +11,8 @@ //! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about //! a concrete adapter or Tauri. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use domain::ports::{ @@ -24,7 +25,7 @@ use domain::profile::{ resolve_effort, EffortSelection, McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML, }; -use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan}; +use domain::sandbox::{compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxPlan}; use domain::{ bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions, @@ -1339,7 +1340,7 @@ fn build_structured_launch_policy( profile: &AgentProfile, permissions: Option<&EffectivePermissions>, network: Option, - project_root: &ProjectPath, + codex_git_writable_roots: &[String], ) -> Option { if !matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) { return None; @@ -1349,8 +1350,8 @@ fn build_structured_launch_policy( } let sandbox_mode = codex_structured_sandbox_mode(permissions).to_owned(); - let writable_roots = if sandbox_mode == "workspace-write" && !project_root.as_str().is_empty() { - vec![project_root.as_str().to_owned()] + let writable_roots = if sandbox_mode == "workspace-write" { + codex_git_writable_roots.to_vec() } else { Vec::new() }; @@ -1362,6 +1363,77 @@ fn build_structured_launch_policy( }) } +fn codex_git_writable_roots(project_root: &ProjectPath) -> Vec { + git_writable_roots_for_project(Path::new(project_root.as_str())) +} + +fn git_writable_roots_for_project(project_root: &Path) -> Vec { + let git_marker = project_root.join(".git"); + let Some(git_dir) = resolve_git_dir(&git_marker) else { + return Vec::new(); + }; + let Ok(canonical) = git_dir.canonicalize() else { + return Vec::new(); + }; + if !canonical.is_dir() { + return Vec::new(); + } + + let mut roots = BTreeSet::new(); + roots.insert(canonical.to_string_lossy().into_owned()); + roots.into_iter().collect() +} + +fn resolve_git_dir(git_marker: &Path) -> Option { + if git_marker.is_dir() { + return Some(git_marker.to_path_buf()); + } + if !git_marker.is_file() { + return None; + } + + let contents = std::fs::read_to_string(git_marker).ok()?; + let gitdir = contents + .lines() + .find_map(|line| line.trim().strip_prefix("gitdir:").map(str::trim))?; + if gitdir.is_empty() { + return None; + } + + let path = PathBuf::from(gitdir); + if path.is_absolute() { + Some(path) + } else { + git_marker.parent().map(|parent| parent.join(path)) + } +} + +fn enrich_sandbox_plan_with_codex_git_roots( + plan: Option, + codex_git_writable_roots: &[String], +) -> Option { + let mut plan = plan?; + if codex_git_writable_roots.is_empty() { + return Some(plan); + } + + for root in codex_git_writable_roots { + match plan + .allowed + .iter_mut() + .find(|grant| grant.abs_root == *root) + { + Some(grant) => grant.access.insert(PathAccess::RW), + None => plan.allowed.push(PathGrant { + abs_root: root.clone(), + access: PathAccess::RW, + }), + } + } + plan.allowed.sort_by(|a, b| a.abs_root.cmp(&b.abs_root)); + Some(plan) +} + fn projection_model(profile: &AgentProfile) -> Option<&str> { profile.model.as_deref() } @@ -2157,6 +2229,12 @@ impl LaunchAgent { // remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan, // comportement natif conservé (invariant produit). L'enforcer concret // (Landlock/Noop) est injecté côté PTY au composition root. + let codex_git_writable_roots = + if matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) { + codex_git_writable_roots(&input.project.root) + } else { + Vec::new() + }; spec.sandbox = compile_sandbox_plan( effective_permissions.as_ref(), &SandboxContext { @@ -2164,11 +2242,13 @@ impl LaunchAgent { run_dir: run_dir.as_str(), }, ); + spec.sandbox = + enrich_sandbox_plan_with_codex_git_roots(spec.sandbox, &codex_git_writable_roots); let structured_policy = build_structured_launch_policy( &profile, effective_permissions.as_ref(), network_permission, - &input.project.root, + &codex_git_writable_roots, ); let resolved_effort = resolve_effort( profile.model_reasoning_effort.as_deref(), diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index fee85f09..c56199c3 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -16,6 +16,7 @@ //! `LaunchAgent`: `prepare_invocation` → injection (fs write) → `pty.spawn`. use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use async_trait::async_trait; @@ -615,6 +616,7 @@ struct FakeStructuredFactory { starts: Arc>>, efforts: Arc>>>, envs: Arc>>>, + sandboxes: Arc>>>, policies: Arc>>>, shutdowns: Arc>>, next_session: SessionId, @@ -627,6 +629,7 @@ impl FakeStructuredFactory { starts: Arc::new(Mutex::new(Vec::new())), efforts: Arc::new(Mutex::new(Vec::new())), envs: Arc::new(Mutex::new(Vec::new())), + sandboxes: Arc::new(Mutex::new(Vec::new())), policies: Arc::new(Mutex::new(Vec::new())), shutdowns: Arc::new(Mutex::new(Vec::new())), next_session, @@ -645,6 +648,10 @@ impl FakeStructuredFactory { self.envs.lock().unwrap().clone() } + fn sandboxes(&self) -> Vec> { + self.sandboxes.lock().unwrap().clone() + } + fn policies(&self) -> Vec> { self.policies.lock().unwrap().clone() } @@ -677,6 +684,7 @@ impl AgentSessionFactory for FakeStructuredFactory { .unwrap() .push(profile.model_reasoning_effort.clone()); self.envs.lock().unwrap().push(_env.to_vec()); + self.sandboxes.lock().unwrap().push(_sandbox.cloned()); self.policies .lock() .unwrap() @@ -738,11 +746,21 @@ fn sid(n: u128) -> SessionId { SessionId::from_uuid(Uuid::from_u128(n)) } +fn fresh_temp_dir(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("idea-agent-lifecycle-{label}-{}", Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + fn project() -> Project { + project_at("/home/me/proj") +} + +fn project_at(root: &str) -> Project { Project::new( ProjectId::from_uuid(Uuid::from_u128(1000)), "demo", - ProjectPath::new("/home/me/proj").unwrap(), + ProjectPath::new(root).unwrap(), RemoteRef::local(), 1_700_000_000_000, ) @@ -1183,8 +1201,16 @@ fn launch_fixture_with_profile_agent_and_recall( } fn launch_input(agent_id: AgentId) -> LaunchAgentInput { + launch_input_with_project(agent_id, project()) +} + +fn launch_input_with_project_root(agent_id: AgentId, root: &Path) -> LaunchAgentInput { + launch_input_with_project(agent_id, project_at(&root.to_string_lossy())) +} + +fn launch_input_with_project(agent_id: AgentId, project: Project) -> LaunchAgentInput { LaunchAgentInput { - project: project(), + project, agent_id, rows: 24, cols: 80, @@ -4048,6 +4074,174 @@ async fn codex_structured_policy_carries_exec_safe_sandbox_roots_and_network() { ); } +async fn launch_codex_structured_for_project( + project_root: &Path, +) -> ( + FakeStructuredFactory, + Vec>, + Vec>, +) { + let profile = codex_profile() + .with_projector(ProjectorKey::Codex) + .with_structured_adapter(StructuredAdapter::Codex); + let (mut launch, agent, _fs, pty, _s) = launch_with_projection( + profile, + Some(ContextInjectionPlan::File { + target: "AGENTS.md".to_owned(), + }), + Some(full_registry()), + Some(perm_doc(Posture::Ask)), + ); + let factory = FakeStructuredFactory::new(trace(), sid(890)); + let structured = Arc::new(StructuredSessions::new()); + launch = launch + .with_structured_routing_mode(StructuredRoutingMode::RequireStructured) + .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); + + launch + .execute(launch_input_with_project_root(agent.id, project_root)) + .await + .expect("structured launch"); + + assert!( + pty.spawns().is_empty(), + "structured route must not spawn PTY" + ); + (factory.clone(), factory.policies(), factory.sandboxes()) +} + +fn assert_codex_git_roots( + project_root: &Path, + policies: Vec>, + sandboxes: Vec>, + expected_roots: Vec, +) { + assert_eq!(policies.len(), 1, "exactly one structured launch policy"); + let Some(domain::ports::StructuredProviderLaunchPolicy::Codex { + sandbox_mode, + writable_roots, + network_access: _, + }) = policies.into_iter().next().unwrap() + else { + panic!("expected a Codex structured launch policy"); + }; + assert_eq!(sandbox_mode, "workspace-write"); + assert_eq!( + writable_roots, expected_roots, + "Codex writable_roots must be the resolved Git roots only" + ); + assert!( + !writable_roots.contains(&project_root.to_string_lossy().into_owned()), + "project root must not be granted as a Codex writable root" + ); + + assert_eq!(sandboxes.len(), 1, "exactly one structured sandbox plan"); + let sandbox = sandboxes.into_iter().next().unwrap().expect("sandbox plan"); + let sandbox_roots = sandbox + .allowed + .iter() + .map(|grant| grant.abs_root.clone()) + .collect::>(); + assert_eq!( + sandbox_roots, writable_roots, + "OS sandbox Git enrichment must match Codex writable_roots" + ); + assert!( + sandbox + .allowed + .iter() + .all(|grant| grant.access == domain::sandbox::PathAccess::RW), + "Git roots must be added as write grants only" + ); +} + +#[tokio::test] +async fn codex_git_enrichment_adds_standard_git_dir_without_project_root_blanket() { + let root = fresh_temp_dir("standard-git"); + std::fs::create_dir(root.join(".git")).unwrap(); + let expected_git = root.join(".git").canonicalize().unwrap(); + + let (_factory, policies, sandboxes) = launch_codex_structured_for_project(&root).await; + + assert_codex_git_roots( + &root, + policies, + sandboxes, + vec![expected_git.to_string_lossy().into_owned()], + ); + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test] +async fn codex_git_enrichment_adds_no_roots_for_non_git_project() { + let root = fresh_temp_dir("non-git"); + + let (_factory, policies, sandboxes) = launch_codex_structured_for_project(&root).await; + + assert_codex_git_roots(&root, policies, sandboxes, Vec::new()); + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test] +async fn codex_git_enrichment_resolves_worktree_gitdir_file() { + let base = fresh_temp_dir("worktree"); + let root = base.join("worktree"); + let git_dir = base.join("common").join("worktrees").join("wt"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&git_dir).unwrap(); + std::fs::write(root.join(".git"), "gitdir: ../common/worktrees/wt\n").unwrap(); + let expected_git = git_dir.canonicalize().unwrap(); + + let (_factory, policies, sandboxes) = launch_codex_structured_for_project(&root).await; + + assert_codex_git_roots( + &root, + policies, + sandboxes, + vec![expected_git.to_string_lossy().into_owned()], + ); + let _ = std::fs::remove_dir_all(&base); +} + +#[tokio::test] +async fn codex_git_enrichment_canonicalizes_and_avoids_parent_grants() { + let base = fresh_temp_dir("canonical"); + let real_root = base.join("real"); + let linked_root = base.join("linked"); + std::fs::create_dir_all(real_root.join(".git")).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&real_root, &linked_root).unwrap(); + #[cfg(not(unix))] + std::fs::create_dir_all(&linked_root).unwrap(); + + let launch_root = if cfg!(unix) { &linked_root } else { &real_root }; + let expected_git = real_root.join(".git").canonicalize().unwrap(); + + let (_factory, policies, sandboxes) = launch_codex_structured_for_project(launch_root).await; + + assert_codex_git_roots( + launch_root, + policies.clone(), + sandboxes, + vec![expected_git.to_string_lossy().into_owned()], + ); + let Some(domain::ports::StructuredProviderLaunchPolicy::Codex { writable_roots, .. }) = + policies.into_iter().next().unwrap() + else { + panic!("expected Codex policy"); + }; + assert_eq!(writable_roots.len(), 1, "Git roots must be deduplicated"); + assert!( + !writable_roots.contains(&base.to_string_lossy().into_owned()), + "common parent must not be granted" + ); + assert!( + !writable_roots.contains(&real_root.to_string_lossy().into_owned()), + "repo parent must not be granted" + ); + let _ = std::fs::remove_dir_all(&base); +} + /// (1d) A non-projectable profile (no projector, no CLAUDE.md, no Codex signal) ⇒ /// no projection at all, even with a full registry and a posed policy. #[tokio::test] diff --git a/crates/infrastructure/src/session/codex.rs b/crates/infrastructure/src/session/codex.rs index 38d1e0d4..85cdbe3a 100644 --- a/crates/infrastructure/src/session/codex.rs +++ b/crates/infrastructure/src/session/codex.rs @@ -451,18 +451,18 @@ impl CodexExecSession { /// /// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) : /// - Conversation neuve : `codex exec --json --skip-git-repo-check - /// --sandbox workspace-write --add-dir `. + /// --sandbox workspace-write [--add-dir ...] `. /// - Reprise (id connu) : `codex exec --json - /// --skip-git-repo-check --sandbox workspace-write --add-dir resume - /// `. + /// --skip-git-repo-check --sandbox workspace-write [--add-dir ...] + /// resume `. /// /// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à /// écrire dans son workspace. `codex exec` est déjà non-interactif (aucun prompt /// d'approbation possible), donc on ne passe **pas** `--ask-for-approval` : ce flag /// appartient à la commande interactive `codex`, pas à la sous-commande `exec` qui /// sort sur `error: unexpected argument '--ask-for-approval' found`. Comme l'agent - /// tourne depuis son run dir isolé, `--add-dir` expose explicitement le project root - /// à la sandbox Codex pour que les écritures Git touchent le vrai workspace. + /// tourne depuis son run dir isolé, `--add-dir` expose uniquement les roots + /// writable explicitement fournis par la launch policy. fn build_spawn_line(&self, prompt: &str) -> SpawnLine { let mut args = vec!["exec".to_owned()]; let conversation_id = self.conversation_id.lock().expect("mutex sain").clone(); diff --git a/crates/infrastructure/src/session/factory.rs b/crates/infrastructure/src/session/factory.rs index 69e257e3..fec3e547 100644 --- a/crates/infrastructure/src/session/factory.rs +++ b/crates/infrastructure/src/session/factory.rs @@ -189,11 +189,7 @@ impl AgentSessionFactory for StructuredSessionFactory { writable_roots.clone(), Some(*network_access), ), - None => ( - "workspace-write".to_owned(), - vec![ctx.project_root.clone()], - None, - ), + None => ("workspace-write".to_owned(), Vec::new(), None), }; Arc::new(CodexExecSession::new_with_policy_and_overrides( id, diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index fd75138f..045b36ee 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -647,7 +647,7 @@ mod tests { } #[tokio::test] - async fn factory_passes_project_root_to_codex_add_dir() { + async fn factory_does_not_pass_project_root_to_codex_add_dir_without_policy() { let factory = StructuredSessionFactory::new(); let (cmd, argv) = make_recording_fake(&[ r#"{"type":"thread.started","thread_id":"cx-new"}"#, @@ -679,8 +679,12 @@ mod tests { let recorded = std::fs::read_to_string(&argv).expect("argv"); let args: Vec<&str> = recorded.lines().collect(); assert!( - args.windows(2).any(|w| w == ["--add-dir", "/project/root"]), - "factory must relay PreparedContext.project_root to Codex --add-dir, got: {args:?}" + !args.windows(2).any(|w| w == ["--add-dir", "/project/root"]), + "factory must not invent --add-dir project root without an explicit policy, got: {args:?}" + ); + assert!( + !args.contains(&"--add-dir"), + "default Codex adapter launch must not carry any implicit --add-dir, got: {args:?}" ); assert!( !args.contains(&"--ask-for-approval"),