feat(permissions): voie projection CLI (LP0→LP3) + checkpoint Codex/input
Jalon vert regroupant deux chantiers entrelacés dans le working tree, indissociables au niveau fichier mais tous deux verts (cargo test --workspace + tests frontend permissions au vert). Permissions — voie « projection CLI » (advisory), complète : - LP0 domaine pur : modèle PermissionSet/EffectivePermissions, resolve deny-wins + postures Allow<Ask<Deny (crates/domain/src/permission.rs). - LP1 store : FsPermissionStore (.ideai/permissions.json). - LP2 use cases : Get/Update project, Update agent override, Resolve. - LP3 projecteurs Claude/Codex (settings.local.json / config.toml), câblage launch-path + PermissionProjectorRegistry, nettoyage des fichiers Replace orphelins au swap de profil (LP3-4), composition root + commandes Tauri, UI PermissionsPanel (projet + override agent). - ports.rs : PermissionStore + FileSystem::remove_file (cleanup au swap). Reste ouvert (hors scope, marqué dans le code) : LP4 enforcement OS airtight (Landlock fichiers) + résumé de permissions injecté. Inclut aussi le chantier Codex/input/sessions structurées en cours (McpConfigStrategy, StructuredAdapter, gestion d'input) partageant les mêmes fichiers (lifecycle.rs, commands.rs, dto.rs, state.rs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
190
crates/infrastructure/src/permission/codex.rs
Normal file
190
crates/infrastructure/src/permission/codex.rs
Normal file
@ -0,0 +1,190 @@
|
||||
//! Codex CLI permission projector (lot LP3-2).
|
||||
//!
|
||||
//! Produces the **permission-relevant** part of Codex's `config.toml`
|
||||
//! (`sandbox_mode` / `approval_policy`) plus the matching launch args
|
||||
//! (`--sandbox` / `--ask-for-approval`). 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 two permission keys 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 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 two top-level keys this projector manages in `config.toml`. Everything else
|
||||
/// (MCP table, trust entries, user keys) is preserved by the merge.
|
||||
const MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];
|
||||
|
||||
/// 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>,
|
||||
_ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected. Codex keeps its
|
||||
// native sandbox/approval defaults (no args, no managed keys written).
|
||||
let Some(permissions) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let sandbox = codex_sandbox_mode(permissions);
|
||||
let approval = codex_approval_policy(permissions);
|
||||
|
||||
// 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 contents = format!(
|
||||
"sandbox_mode = {}\napproval_policy = {}\n",
|
||||
toml_string(sandbox),
|
||||
toml_string(approval),
|
||||
);
|
||||
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: CONFIG_REL_PATH.to_owned(),
|
||||
managed_tables: Vec::new(),
|
||||
managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(),
|
||||
contents,
|
||||
}],
|
||||
args: vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
],
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_replace_paths(&self) -> Vec<String> {
|
||||
// config.toml is co-owned (MergeToml), never an owned Replace file.
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, &ctx());
|
||||
assert!(proj.files.is_empty());
|
||||
assert!(proj.args.is_empty());
|
||||
assert!(proj.env.is_empty());
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
// ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
|
||||
// coherence + 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)), &ctx());
|
||||
assert!(proj.env.is_empty(), "Codex projection carries no env");
|
||||
|
||||
// -- 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!(managed_tables.is_empty(), "no managed tables");
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
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,
|
||||
vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
],
|
||||
"posture {posture:?}: args must mirror the TOML values"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user