Files
IdeA/crates/infrastructure/src/permission/codex.rs
Blomios 0821739924 feat: finalise ticket99 - implémentation agent model configuration v2
- agent/lifecycle.rs: lifecycle management per profile
- agent/provider_catalogue.rs: provider registration with model support
- agent/usecases.rs: usecases for profile-based agent invocation
- agent/mod.rs: expose agent capabilities via AgentManager
- backend/dto.rs: AgentModelConfig, AgentProviderConfig DTOs
- domain/profile.rs: extend Profile avec agent capabilities
- domain/permission.rs: permission checks pour agent access
- infrastructure/assistant/mod.rs: agent integration
- infrastructure/permission/{claude,codex}.rs: permission handlers
- web-server/lib.rs: agent endpoints
- commands.rs: agent commands
- frontend/adapters/{http,profile,mock,domain}.ts: adapters
- frontend/first-run/FirstRunWizard.{test.tsx,tsx}: first-run flow
2026-07-26 13:38:18 +02:00

382 lines
14 KiB
Rust

//! Codex CLI permission projector (lot LP3-2).
//!
//! Produces the **permission-relevant** part of Codex's `config.toml`
//! (`sandbox_mode` / `approval_policy` / `sandbox_workspace_write.network_access`) plus the matching launch args
//! (`--sandbox` / `--ask-for-approval`) and, for workspace-write postures, the
//! project root as an additional writable directory (`--add-dir`). The
//! posture→mode derivation is extracted verbatim from the former
//! `codex_sandbox_mode` / `codex_approval_policy` / `apply_codex_cli_permission_args`
//! in `lifecycle.rs`.
//!
//! Unlike Claude's seed, Codex's `config.toml` is **co-owned** (it also carries the
//! `mcp_servers.idea` table and the `projects.*` trust entries, which are MCP/trust
//! concerns, not permissions). The projector therefore emits a
//! [`ProjectedFile::MergeToml`] limited to the permission keys/table it manages —
//! everything else in the file is preserved by the fold, and the file is **never**
//! deleted on swap (hence an empty `owned_replace_paths`).
use domain::permission::{
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
ProjectionContext, ProjectorKey,
};
use domain::NetworkPolicy;
use super::toml_string;
/// Run-dir-relative path of Codex's `config.toml`. Codex reads its config from
/// `$CODEX_HOME/config.toml`; IdeA isolates `CODEX_HOME` to `{runDir}/.codex`, so
/// the file lives at `.codex/config.toml` relative to the run dir.
const CONFIG_REL_PATH: &str = ".codex/config.toml";
/// The top-level keys this projector manages in `config.toml`. Everything else
/// (MCP table, trust entries, user keys) is preserved by the merge.
const PERMISSION_MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];
/// Codex workspace-write sandbox table owned by IdeA for network projection.
const SANDBOX_WORKSPACE_WRITE_TABLE: &str = "sandbox_workspace_write";
/// Projects [`EffectivePermissions`] into Codex's sandbox/approval config + args.
///
/// Pure: `project` only computes the plan; the launch path merges the TOML fragment
/// and appends the args.
#[derive(Debug, Default, Clone, Copy)]
pub struct CodexPermissionProjector;
impl PermissionProjector for CodexPermissionProjector {
fn key(&self) -> ProjectorKey {
ProjectorKey::Codex
}
fn project(
&self,
eff: Option<&EffectivePermissions>,
network: Option<NetworkPolicy>,
ctx: &ProjectionContext,
) -> PermissionProjection {
// No filesystem/bash policy ⇒ no sandbox/approval projection. Network is
// orthogonal and still gets an explicit env override to avoid stale inheritance.
let Some(permissions) = eff else {
return PermissionProjection {
files: vec![codex_model_and_network_file(ctx, network)],
env: codex_network_env(network),
..PermissionProjection::empty()
};
};
let sandbox = codex_sandbox_mode(permissions);
let approval = codex_approval_policy(permissions);
let network_access = codex_network_access(network);
// Permission-only TOML fragment (escaped exactly like the former
// `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a
// permission concern and stay with the MCP wiring (LP3-3).
let mut contents = format!(
"sandbox_mode = {}\napproval_policy = {}\n\n[{}]\nnetwork_access = {}\n",
toml_string(sandbox),
toml_string(approval),
SANDBOX_WORKSPACE_WRITE_TABLE,
network_access,
);
append_codex_model_config(&mut contents, ctx);
let mut args = vec![
"--sandbox".to_owned(),
sandbox.to_owned(),
"--ask-for-approval".to_owned(),
approval.to_owned(),
];
if sandbox == "workspace-write" && !ctx.project_root.is_empty() {
args.push("--add-dir".to_owned());
args.push(ctx.project_root.to_owned());
}
PermissionProjection {
files: vec![ProjectedFile::MergeToml {
rel_path: CONFIG_REL_PATH.to_owned(),
managed_tables: codex_managed_tables(ctx),
managed_keys: codex_managed_keys(ctx, true),
contents,
}],
args,
env: codex_network_env(network),
}
}
fn owned_replace_paths(&self) -> Vec<String> {
// config.toml is co-owned (MergeToml), never an owned Replace file.
Vec::new()
}
}
fn codex_model_and_network_file(
ctx: &ProjectionContext,
network: Option<NetworkPolicy>,
) -> ProjectedFile {
let mut contents = format!(
"[{}]\nnetwork_access = {}\n",
SANDBOX_WORKSPACE_WRITE_TABLE,
codex_network_access(network),
);
append_codex_model_config(&mut contents, ctx);
ProjectedFile::MergeToml {
rel_path: CONFIG_REL_PATH.to_owned(),
managed_tables: codex_managed_tables(ctx),
managed_keys: codex_managed_keys(ctx, false),
contents,
}
}
fn codex_managed_keys(ctx: &ProjectionContext, include_permissions: bool) -> Vec<String> {
let mut keys = Vec::new();
if include_permissions {
keys.extend(PERMISSION_MANAGED_KEYS.iter().map(|k| (*k).to_owned()));
}
if ctx.model.is_some() {
keys.push("model".to_owned());
}
keys
}
fn codex_managed_tables(_ctx: &ProjectionContext) -> Vec<String> {
vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
}
fn append_codex_model_config(contents: &mut String, ctx: &ProjectionContext) {
if let Some(model) = ctx.model {
contents.push_str(&format!("model = {}\n", toml_string(model)));
}
}
fn codex_network_env(network: Option<NetworkPolicy>) -> Vec<(String, String)> {
// Codex inherits the parent environment by default. Always set the variable for
// Codex launches so a stale `CODEX_SANDBOX_NETWORK_DISABLED=1` in IdeA's own
// environment cannot leak into a newly allowed child.
let disabled = if codex_network_access(network) {
"0"
} else {
"1"
};
vec![(
"CODEX_SANDBOX_NETWORK_DISABLED".to_owned(),
disabled.to_owned(),
)]
}
fn codex_network_access(network: Option<NetworkPolicy>) -> bool {
matches!(network, Some(NetworkPolicy::Allow))
}
fn codex_sandbox_mode(permissions: &EffectivePermissions) -> &'static str {
match permissions.fallback() {
Posture::Deny => "read-only",
Posture::Ask | Posture::Allow => "workspace-write",
}
}
fn codex_approval_policy(permissions: &EffectivePermissions) -> &'static str {
match permissions.fallback() {
Posture::Allow => "never",
Posture::Ask | Posture::Deny => "on-request",
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::permission::{resolve, PermissionSet};
fn ctx<'a>() -> ProjectionContext<'a> {
ProjectionContext {
project_root: "/proj",
run_dir: "/run/agent",
model: None,
}
}
/// Builds an [`EffectivePermissions`] with the given fallback posture via the
/// domain API (only the fallback drives Codex's sandbox/approval derivation).
fn eff(fallback: Posture) -> EffectivePermissions {
resolve(Some(&PermissionSet::new(vec![], fallback)), None).unwrap()
}
// ---- product invariant + ownership ----------------------------------
#[test]
fn project_none_is_empty() {
let proj = CodexPermissionProjector.project(None, None, &ctx());
assert_eq!(proj.files.len(), 1);
assert!(proj.args.is_empty());
match &proj.files[0] {
ProjectedFile::MergeToml {
rel_path,
managed_tables,
managed_keys,
contents,
} => {
assert_eq!(rel_path, CONFIG_REL_PATH);
assert_eq!(
managed_tables,
&vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
);
assert!(managed_keys.is_empty());
assert!(
contents.contains("[sandbox_workspace_write]\nnetwork_access = false"),
"network is denied by default: {contents:?}"
);
}
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
}
assert_eq!(
proj.env,
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())]
);
}
#[test]
fn owned_replace_paths_is_empty() {
// config.toml is co-owned (MergeToml) ⇒ nothing to clean up on swap.
assert!(CodexPermissionProjector.owned_replace_paths().is_empty());
}
#[test]
fn model_projection_without_permissions_writes_only_model() {
let ctx = ProjectionContext {
project_root: "/proj",
run_dir: "/run/agent",
model: Some("gpt-5"),
};
let proj = CodexPermissionProjector.project(None, None, &ctx);
assert!(proj.args.is_empty());
match &proj.files[0] {
ProjectedFile::MergeToml {
managed_tables,
managed_keys,
contents,
..
} => {
assert!(managed_keys.contains(&"model".to_owned()));
assert!(!managed_keys.contains(&"model_provider".to_owned()));
assert_eq!(
managed_tables,
&vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
);
assert!(contents.contains("model = \"gpt-5\""), "{contents}");
assert!(!contents.contains("model_provider"), "{contents}");
assert!(!contents.contains("model_providers"), "{contents}");
assert!(!contents.contains("base_url"), "{contents}");
assert!(!contents.contains("env_key"), "{contents}");
}
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
}
}
// ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
// coherence + add-dir + MergeToml shape ---------------------
#[test]
fn posture_maps_sandbox_and_approval_in_file_and_args() {
for (posture, sandbox, approval) in [
(Posture::Deny, "read-only", "on-request"),
(Posture::Ask, "workspace-write", "on-request"),
(Posture::Allow, "workspace-write", "never"),
] {
let proj = CodexPermissionProjector.project(Some(&eff(posture)), None, &ctx());
assert_eq!(
proj.env,
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())],
"Codex projection denies network by default"
);
// -- The single MergeToml file, with the two managed permission keys.
assert_eq!(proj.files.len(), 1, "exactly one file projected");
match &proj.files[0] {
ProjectedFile::MergeToml {
rel_path,
managed_tables,
managed_keys,
contents,
} => {
assert_eq!(rel_path, CONFIG_REL_PATH);
assert_eq!(
managed_tables,
&vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
);
assert_eq!(
managed_keys,
&vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()]
);
assert!(
contents.contains(&format!("sandbox_mode = \"{sandbox}\"")),
"posture {posture:?}: contents={contents:?}"
);
assert!(
contents.contains(&format!("approval_policy = \"{approval}\"")),
"posture {posture:?}: contents={contents:?}"
);
assert!(
contents.contains("[sandbox_workspace_write]\nnetwork_access = false"),
"network is denied by default: {contents:?}"
);
}
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
}
// -- (7) Args reflect the SAME values as the TOML, in CLI order.
assert_eq!(
&proj.args[..4],
vec![
"--sandbox".to_owned(),
sandbox.to_owned(),
"--ask-for-approval".to_owned(),
approval.to_owned(),
],
"posture {posture:?}: args must mirror the TOML values"
);
if sandbox == "workspace-write" {
assert!(
proj.args
.windows(2)
.any(|w| w == ["--add-dir".to_owned(), "/proj".to_owned()]),
"workspace-write posture must add the project root as writable: {:?}",
proj.args
);
} else {
assert!(
!proj.args.contains(&"--add-dir".to_owned()),
"read-only posture must not add writable dirs: {:?}",
proj.args
);
}
}
}
#[test]
fn network_policy_maps_to_stale_proof_env() {
for (network, disabled, network_access) in [
(Some(NetworkPolicy::Allow), "0", true),
(Some(NetworkPolicy::Deny), "1", false),
(Some(NetworkPolicy::Ask), "1", false),
(None, "1", false),
] {
let proj =
CodexPermissionProjector.project(Some(&eff(Posture::Allow)), network, &ctx());
assert_eq!(
proj.env,
vec![(
"CODEX_SANDBOX_NETWORK_DISABLED".to_owned(),
disabled.to_owned()
)],
"network={network:?}"
);
match &proj.files[0] {
ProjectedFile::MergeToml { contents, .. } => assert!(
contents.contains(&format!(
"[sandbox_workspace_write]\nnetwork_access = {network_access}"
)),
"network={network:?}: {contents:?}"
),
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
}
}
}
}