//! 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, 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 { // config.toml is co-owned (MergeToml), never an owned Replace file. Vec::new() } } fn codex_model_and_network_file( ctx: &ProjectionContext, network: Option, ) -> 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 { 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 { 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) -> 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) -> 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"), } } } }