cause: la politique de lancement Codex divergeait selon le chemin de composition. compile_codex_launch_policy ajoutait les roots extra (temp géré) AVANT de calculer le mode de sandbox, et le builder PTY (append_codex_pty_managed_temp_roots) n'appliquait QUE ces roots temp, ignorant project root et politique compilée ; sans permissions explicites, codex_permission_writable_roots renvoyait une liste vide, donc le premier lancement TUI partait sans --add-dir project root et rejetait les roots additionnels (« Error adding directories: Ignoring --add-dir … because the effective permissions do not allow additional writable roots ») ; un --add-dir <project root> préexistant dans spec.args n'était pas réconcilié, d'où des comportements différents entre first-run, stop/relaunch, CLI custom et délégation headless — invisible sur le projet IdeA lui-même du fait de ses permissions déjà configurées. fix: codex_permission_writable_roots retourne par défaut le project root quand aucune permission explicite n'est configurée (jamais vide si root non vide) ; compile_codex_launch_policy n'ajoute les roots extra qu'en mode workspace-write et reste vide en read-only (deny explicite ne gagne jamais project root ni temp géré) ; nouveau apply_codex_pty_launch_policy unique source de vérité PTY Codex — réécrit la valeur --sandbox existante ou l'ajoute, draine le --add-dir <project root> préexistant puis pose les roots compilés dédupliqués, composition identique pour first-run, relaunch, custom CLI et headless ; unconfigured_policy_writes_project_and_deduplicated_managed_temp, explicit_write_deny_never_gains_project_or_managed_temp_root, partial_allow_exposes_only_its_computed_rw_grant + tests lifecycle de réécriture d'args ajoutés. QA verte: cargo targeted tests OK, npm targeted/typecheck OK — reconfirmée par Git: git diff --check OK, cargo fmt --all -- --check OK, cargo test -p application --lib codex_permissions 3/3, cargo test -p application --lib lifecycle 48/48. fichiers .ideai/* (agents/test.md, chat-drafts.json, system-permissions.json, tickets/counter.json, tickets/index.json, tickets/308/, tickets/309/), attachments/ et scripts/ hors périmètre et préservés en worktree ; aucun push. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
179 lines
5.3 KiB
Rust
179 lines
5.3 KiB
Rust
use domain::permission::{Capability, Effect, EffectivePermissions, Posture};
|
|
use domain::ports::StructuredProviderLaunchPolicy;
|
|
use domain::sandbox::{compile_sandbox_plan, PathAccess, SandboxContext};
|
|
use domain::NetworkPolicy;
|
|
|
|
pub(crate) fn compile_codex_launch_policy(
|
|
project_root: &str,
|
|
run_dir: &str,
|
|
permissions: Option<&EffectivePermissions>,
|
|
network: Option<NetworkPolicy>,
|
|
extra_writable_roots: &[String],
|
|
) -> StructuredProviderLaunchPolicy {
|
|
let mut writable_roots = codex_permission_writable_roots(project_root, run_dir, permissions);
|
|
let sandbox_mode = codex_sandbox_mode(permissions, &writable_roots).to_owned();
|
|
if sandbox_mode == "workspace-write" {
|
|
append_unique_paths(&mut writable_roots, extra_writable_roots);
|
|
} else {
|
|
writable_roots.clear();
|
|
}
|
|
|
|
StructuredProviderLaunchPolicy::Codex {
|
|
sandbox_mode,
|
|
writable_roots,
|
|
network_access: codex_network_access(network),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn codex_permission_writable_roots(
|
|
project_root: &str,
|
|
run_dir: &str,
|
|
permissions: Option<&EffectivePermissions>,
|
|
) -> Vec<String> {
|
|
let Some(permissions) = permissions else {
|
|
return if project_root.is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
vec![project_root.to_owned()]
|
|
};
|
|
};
|
|
|
|
let mut roots = Vec::new();
|
|
if !has_rw_deny(permissions)
|
|
&& permissions.fallback() != Posture::Deny
|
|
&& permissions.decide_file(Capability::Write, "") != Posture::Deny
|
|
{
|
|
roots.push(project_root.to_owned());
|
|
}
|
|
|
|
if let Some(plan) = compile_sandbox_plan(
|
|
Some(permissions),
|
|
&SandboxContext {
|
|
project_root,
|
|
run_dir,
|
|
},
|
|
) {
|
|
for grant in plan
|
|
.allowed
|
|
.iter()
|
|
.filter(|grant| grant.access.contains(PathAccess::RW))
|
|
{
|
|
append_unique_path(&mut roots, &grant.abs_root);
|
|
}
|
|
}
|
|
roots.sort();
|
|
roots
|
|
}
|
|
|
|
pub(crate) fn codex_sandbox_mode(
|
|
permissions: Option<&EffectivePermissions>,
|
|
writable_roots: &[String],
|
|
) -> &'static str {
|
|
if !writable_roots.is_empty() {
|
|
return "workspace-write";
|
|
}
|
|
if permissions.is_some_and(has_rw_deny) {
|
|
return "read-only";
|
|
}
|
|
match permissions.map(EffectivePermissions::fallback) {
|
|
Some(Posture::Ask | Posture::Allow) | None => "workspace-write",
|
|
Some(Posture::Deny) => "read-only",
|
|
}
|
|
}
|
|
|
|
fn has_rw_deny(permissions: &EffectivePermissions) -> bool {
|
|
permissions.rules().iter().any(|rule| {
|
|
rule.effect() == Effect::Deny
|
|
&& matches!(rule.capability(), Capability::Write | Capability::Delete)
|
|
})
|
|
}
|
|
|
|
fn codex_network_access(network: Option<NetworkPolicy>) -> bool {
|
|
matches!(network, Some(NetworkPolicy::Allow))
|
|
}
|
|
|
|
fn append_unique_paths(target: &mut Vec<String>, roots: &[String]) {
|
|
for root in roots {
|
|
append_unique_path(target, root);
|
|
}
|
|
}
|
|
|
|
fn append_unique_path(target: &mut Vec<String>, root: &str) {
|
|
if root.is_empty() || target.iter().any(|existing| existing == root) {
|
|
return;
|
|
}
|
|
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"]);
|
|
}
|
|
}
|