fix(sandbox): accès Git minimal pour Codex — writable roots restreints au .git résolu, sans blanket grant project (#195, QA verte)
Codex ne pouvait pas accéder aux métadonnées Git sous sandbox workspace-write. À l'inverse, la factory relayait jusqu'ici le project_root entier en --add-dir, soit un grant large non défendable. Change pour n'accorder en écriture (Codex --add-dir ET SandboxPlan OS) que le .git réellement résolu : - resolve_git_dir gère .git/ directory ET fichier worktree 'gitdir: <path>' (absolu ou relatif au parent du marker), puis canonicalize. - enrich_sandbox_plan_with_codex_git_roots ajoute/déduplique les roots en RW sur le SandboxPlan existant (préserve l'autorité EffectivePermissions). - factory: défaut writable_roots = Vec::new() en l'absence de policy explicite. Invariants conservés : - project_root n'est jamais injecté comme writable root. - aucun élargissement au parent commun (canonicalize + BTreeSet). - permissions MCP/globales inchangées (seul le plan runtime est enrichi). Tests (crates/application) : - standard .git/ dir -> root unique. - projet non-Git -> aucun root. - worktree (.git file gitdir:) -> résolution + canonicalization. - symlink -> dédup, pas de grant parent. - factory: défaut sans policy n'émet plus --add-dir project_root. Réserve QA: launch_conventionfile_injects_assigned_skills_in_order (agent_lifecycle) arbitré hors-scope/non bloquant pour #195.
This commit is contained in:
@ -11,7 +11,8 @@
|
|||||||
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
|
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
|
||||||
//! a concrete adapter or Tauri.
|
//! a concrete adapter or Tauri.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -24,7 +25,7 @@ use domain::profile::{
|
|||||||
resolve_effort, EffortSelection, McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter,
|
resolve_effort, EffortSelection, McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter,
|
||||||
CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML,
|
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::{
|
use domain::{
|
||||||
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
||||||
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
|
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
|
||||||
@ -1339,7 +1340,7 @@ fn build_structured_launch_policy(
|
|||||||
profile: &AgentProfile,
|
profile: &AgentProfile,
|
||||||
permissions: Option<&EffectivePermissions>,
|
permissions: Option<&EffectivePermissions>,
|
||||||
network: Option<NetworkPolicy>,
|
network: Option<NetworkPolicy>,
|
||||||
project_root: &ProjectPath,
|
codex_git_writable_roots: &[String],
|
||||||
) -> Option<StructuredProviderLaunchPolicy> {
|
) -> Option<StructuredProviderLaunchPolicy> {
|
||||||
if !matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
|
if !matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
|
||||||
return None;
|
return None;
|
||||||
@ -1349,8 +1350,8 @@ fn build_structured_launch_policy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let sandbox_mode = codex_structured_sandbox_mode(permissions).to_owned();
|
let sandbox_mode = codex_structured_sandbox_mode(permissions).to_owned();
|
||||||
let writable_roots = if sandbox_mode == "workspace-write" && !project_root.as_str().is_empty() {
|
let writable_roots = if sandbox_mode == "workspace-write" {
|
||||||
vec![project_root.as_str().to_owned()]
|
codex_git_writable_roots.to_vec()
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
@ -1362,6 +1363,77 @@ fn build_structured_launch_policy(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn codex_git_writable_roots(project_root: &ProjectPath) -> Vec<String> {
|
||||||
|
git_writable_roots_for_project(Path::new(project_root.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_writable_roots_for_project(project_root: &Path) -> Vec<String> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<SandboxPlan>,
|
||||||
|
codex_git_writable_roots: &[String],
|
||||||
|
) -> Option<SandboxPlan> {
|
||||||
|
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> {
|
fn projection_model(profile: &AgentProfile) -> Option<&str> {
|
||||||
profile.model.as_deref()
|
profile.model.as_deref()
|
||||||
}
|
}
|
||||||
@ -2157,6 +2229,12 @@ impl LaunchAgent {
|
|||||||
// remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan,
|
// remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan,
|
||||||
// comportement natif conservé (invariant produit). L'enforcer concret
|
// comportement natif conservé (invariant produit). L'enforcer concret
|
||||||
// (Landlock/Noop) est injecté côté PTY au composition root.
|
// (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(
|
spec.sandbox = compile_sandbox_plan(
|
||||||
effective_permissions.as_ref(),
|
effective_permissions.as_ref(),
|
||||||
&SandboxContext {
|
&SandboxContext {
|
||||||
@ -2164,11 +2242,13 @@ impl LaunchAgent {
|
|||||||
run_dir: run_dir.as_str(),
|
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(
|
let structured_policy = build_structured_launch_policy(
|
||||||
&profile,
|
&profile,
|
||||||
effective_permissions.as_ref(),
|
effective_permissions.as_ref(),
|
||||||
network_permission,
|
network_permission,
|
||||||
&input.project.root,
|
&codex_git_writable_roots,
|
||||||
);
|
);
|
||||||
let resolved_effort = resolve_effort(
|
let resolved_effort = resolve_effort(
|
||||||
profile.model_reasoning_effort.as_deref(),
|
profile.model_reasoning_effort.as_deref(),
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
//! `LaunchAgent`: `prepare_invocation` → injection (fs write) → `pty.spawn`.
|
//! `LaunchAgent`: `prepare_invocation` → injection (fs write) → `pty.spawn`.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -615,6 +616,7 @@ struct FakeStructuredFactory {
|
|||||||
starts: Arc<Mutex<Vec<ProfileId>>>,
|
starts: Arc<Mutex<Vec<ProfileId>>>,
|
||||||
efforts: Arc<Mutex<Vec<Option<String>>>>,
|
efforts: Arc<Mutex<Vec<Option<String>>>>,
|
||||||
envs: Arc<Mutex<Vec<Vec<(String, String)>>>>,
|
envs: Arc<Mutex<Vec<Vec<(String, String)>>>>,
|
||||||
|
sandboxes: Arc<Mutex<Vec<Option<domain::sandbox::SandboxPlan>>>>,
|
||||||
policies: Arc<Mutex<Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>>>,
|
policies: Arc<Mutex<Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>>>,
|
||||||
shutdowns: Arc<Mutex<Vec<SessionId>>>,
|
shutdowns: Arc<Mutex<Vec<SessionId>>>,
|
||||||
next_session: SessionId,
|
next_session: SessionId,
|
||||||
@ -627,6 +629,7 @@ impl FakeStructuredFactory {
|
|||||||
starts: Arc::new(Mutex::new(Vec::new())),
|
starts: Arc::new(Mutex::new(Vec::new())),
|
||||||
efforts: Arc::new(Mutex::new(Vec::new())),
|
efforts: Arc::new(Mutex::new(Vec::new())),
|
||||||
envs: 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())),
|
policies: Arc::new(Mutex::new(Vec::new())),
|
||||||
shutdowns: Arc::new(Mutex::new(Vec::new())),
|
shutdowns: Arc::new(Mutex::new(Vec::new())),
|
||||||
next_session,
|
next_session,
|
||||||
@ -645,6 +648,10 @@ impl FakeStructuredFactory {
|
|||||||
self.envs.lock().unwrap().clone()
|
self.envs.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sandboxes(&self) -> Vec<Option<domain::sandbox::SandboxPlan>> {
|
||||||
|
self.sandboxes.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
fn policies(&self) -> Vec<Option<domain::ports::StructuredProviderLaunchPolicy>> {
|
fn policies(&self) -> Vec<Option<domain::ports::StructuredProviderLaunchPolicy>> {
|
||||||
self.policies.lock().unwrap().clone()
|
self.policies.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
@ -677,6 +684,7 @@ impl AgentSessionFactory for FakeStructuredFactory {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.push(profile.model_reasoning_effort.clone());
|
.push(profile.model_reasoning_effort.clone());
|
||||||
self.envs.lock().unwrap().push(_env.to_vec());
|
self.envs.lock().unwrap().push(_env.to_vec());
|
||||||
|
self.sandboxes.lock().unwrap().push(_sandbox.cloned());
|
||||||
self.policies
|
self.policies
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@ -738,11 +746,21 @@ fn sid(n: u128) -> SessionId {
|
|||||||
SessionId::from_uuid(Uuid::from_u128(n))
|
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 {
|
fn project() -> Project {
|
||||||
|
project_at("/home/me/proj")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_at(root: &str) -> Project {
|
||||||
Project::new(
|
Project::new(
|
||||||
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
||||||
"demo",
|
"demo",
|
||||||
ProjectPath::new("/home/me/proj").unwrap(),
|
ProjectPath::new(root).unwrap(),
|
||||||
RemoteRef::local(),
|
RemoteRef::local(),
|
||||||
1_700_000_000_000,
|
1_700_000_000_000,
|
||||||
)
|
)
|
||||||
@ -1183,8 +1201,16 @@ fn launch_fixture_with_profile_agent_and_recall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
|
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 {
|
LaunchAgentInput {
|
||||||
project: project(),
|
project,
|
||||||
agent_id,
|
agent_id,
|
||||||
rows: 24,
|
rows: 24,
|
||||||
cols: 80,
|
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<Option<domain::ports::StructuredProviderLaunchPolicy>>,
|
||||||
|
Vec<Option<domain::sandbox::SandboxPlan>>,
|
||||||
|
) {
|
||||||
|
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<Option<domain::ports::StructuredProviderLaunchPolicy>>,
|
||||||
|
sandboxes: Vec<Option<domain::sandbox::SandboxPlan>>,
|
||||||
|
expected_roots: Vec<String>,
|
||||||
|
) {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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) ⇒
|
/// (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.
|
/// no projection at all, even with a full registry and a posed policy.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@ -451,18 +451,18 @@ impl CodexExecSession {
|
|||||||
///
|
///
|
||||||
/// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) :
|
/// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) :
|
||||||
/// - Conversation neuve : `codex exec --json --skip-git-repo-check
|
/// - Conversation neuve : `codex exec --json --skip-git-repo-check
|
||||||
/// --sandbox workspace-write --add-dir <project-root> <prompt>`.
|
/// --sandbox workspace-write [--add-dir <explicit-root>...] <prompt>`.
|
||||||
/// - Reprise (id connu) : `codex exec --json
|
/// - Reprise (id connu) : `codex exec --json
|
||||||
/// --skip-git-repo-check --sandbox workspace-write --add-dir <project-root> resume
|
/// --skip-git-repo-check --sandbox workspace-write [--add-dir <explicit-root>...]
|
||||||
/// <thread_id> <prompt>`.
|
/// resume <thread_id> <prompt>`.
|
||||||
///
|
///
|
||||||
/// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à
|
/// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à
|
||||||
/// écrire dans son workspace. `codex exec` est déjà non-interactif (aucun prompt
|
/// é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
|
/// d'approbation possible), donc on ne passe **pas** `--ask-for-approval` : ce flag
|
||||||
/// appartient à la commande interactive `codex`, pas à la sous-commande `exec` qui
|
/// appartient à la commande interactive `codex`, pas à la sous-commande `exec` qui
|
||||||
/// sort sur `error: unexpected argument '--ask-for-approval' found`. Comme l'agent
|
/// 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
|
/// tourne depuis son run dir isolé, `--add-dir` expose uniquement les roots
|
||||||
/// à la sandbox Codex pour que les écritures Git touchent le vrai workspace.
|
/// writable explicitement fournis par la launch policy.
|
||||||
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
|
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
|
||||||
let mut args = vec!["exec".to_owned()];
|
let mut args = vec!["exec".to_owned()];
|
||||||
let conversation_id = self.conversation_id.lock().expect("mutex sain").clone();
|
let conversation_id = self.conversation_id.lock().expect("mutex sain").clone();
|
||||||
|
|||||||
@ -189,11 +189,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
|||||||
writable_roots.clone(),
|
writable_roots.clone(),
|
||||||
Some(*network_access),
|
Some(*network_access),
|
||||||
),
|
),
|
||||||
None => (
|
None => ("workspace-write".to_owned(), Vec::new(), None),
|
||||||
"workspace-write".to_owned(),
|
|
||||||
vec![ctx.project_root.clone()],
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
Arc::new(CodexExecSession::new_with_policy_and_overrides(
|
Arc::new(CodexExecSession::new_with_policy_and_overrides(
|
||||||
id,
|
id,
|
||||||
|
|||||||
@ -647,7 +647,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 factory = StructuredSessionFactory::new();
|
||||||
let (cmd, argv) = make_recording_fake(&[
|
let (cmd, argv) = make_recording_fake(&[
|
||||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
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 recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
let args: Vec<&str> = recorded.lines().collect();
|
let args: Vec<&str> = recorded.lines().collect();
|
||||||
assert!(
|
assert!(
|
||||||
args.windows(2).any(|w| w == ["--add-dir", "/project/root"]),
|
!args.windows(2).any(|w| w == ["--add-dir", "/project/root"]),
|
||||||
"factory must relay PreparedContext.project_root to Codex --add-dir, got: {args:?}"
|
"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!(
|
assert!(
|
||||||
!args.contains(&"--ask-for-approval"),
|
!args.contains(&"--ask-for-approval"),
|
||||||
|
|||||||
Reference in New Issue
Block a user