- 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
382 lines
14 KiB
Rust
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"),
|
|
}
|
|
}
|
|
}
|
|
}
|