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:
2026-06-15 20:39:18 +02:00
parent 46492506e1
commit 27597eb64e
41 changed files with 6513 additions and 269 deletions

View File

@ -18,6 +18,7 @@
//! making the reference profiles addressable across runs without a registry.
use domain::ids::ProfileId;
use domain::permission::ProjectorKey;
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
@ -61,6 +62,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
)
.expect("claude reference profile is valid")
.with_structured_adapter(StructuredAdapter::Claude)
.with_projector(ProjectorKey::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json")
.expect(".mcp.json is a valid relative MCP config target"),
@ -79,6 +81,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
)
.expect("codex reference profile is valid")
.with_structured_adapter(StructuredAdapter::Codex)
.with_projector(ProjectorKey::Codex)
.with_mcp(McpCapability::new(
// Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` :
// IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour
@ -201,4 +204,30 @@ mod mcp_tests {
);
}
}
// -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ----------
#[test]
fn claude_and_codex_seed_their_projector_key() {
assert_eq!(
profile("claude").projector,
Some(ProjectorKey::Claude),
"the Claude seed must pose the Claude projector"
);
assert_eq!(
profile("codex").projector,
Some(ProjectorKey::Codex),
"the Codex seed must pose the Codex projector"
);
}
#[test]
fn gemini_and_aider_have_no_projector() {
for slug in ["gemini", "aider"] {
assert!(
profile(slug).projector.is_none(),
"non-structured profile `{slug}` must NOT carry a projector (native prompting)"
);
}
}
}

View File

@ -11,17 +11,21 @@
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
//! a concrete adapter or Tauri.
use std::collections::HashMap;
use std::sync::Arc;
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore,
ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError,
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext,
ProfileStore, ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec,
StoreError,
};
use domain::profile::{McpConfigStrategy, StructuredAdapter};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
ConversationParty, DomainEvent, Handoff, HandoffStore, ManifestEntry, MarkdownDoc,
MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, ProjectPath, ProviderSessionStore,
ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry,
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId,
ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore,
PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
};
@ -367,6 +371,13 @@ pub struct ChangeAgentProfile {
/// lieu de tuer un PTY. Injecté au câblage via [`Self::with_structured`] ; `None`
/// ⇒ seul le registre PTY est consulté (mode legacy / tests existants).
structured: Option<Arc<StructuredSessions>>,
/// Registre des **projecteurs de permissions** (lot LP3-4), pour nettoyer au swap
/// les fichiers `Replace` orphelins possédés par l'ANCIEN profil que le nouveau ne
/// réécrira pas. Injecté via [`Self::with_permission_projectors`] (le câblage passe
/// le **même** `Arc` que celui donné au `LaunchAgent` interne). `None` ⇒ aucun
/// nettoyage (comportement legacy ; la re-projection du nouveau profil, elle, reste
/// assurée par le `LaunchAgent` composé).
projectors: Option<Arc<PermissionProjectorRegistry>>,
}
impl ChangeAgentProfile {
@ -393,6 +404,7 @@ impl ChangeAgentProfile {
launch,
events,
structured: None,
projectors: None,
}
}
@ -406,6 +418,20 @@ impl ChangeAgentProfile {
self
}
/// Branche le **registre de projecteurs de permissions** (lot LP3-4) pour le
/// nettoyage des fichiers orphelins au swap cross-profile. Le câblage passe le
/// **même** `Arc<PermissionProjectorRegistry>` que celui injecté au `LaunchAgent`
/// interne (source unique de vérité). Builder additif : signature de [`Self::new`]
/// inchangée ; `None` ⇒ pas de nettoyage (legacy).
#[must_use]
pub fn with_permission_projectors(
mut self,
projectors: Arc<PermissionProjectorRegistry>,
) -> Self {
self.projectors = Some(projectors);
self
}
/// Executes the hot-swap, following the 7-step algorithm of §15.1.
///
/// # Errors
@ -425,6 +451,10 @@ impl ChangeAgentProfile {
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
// Capture the PREVIOUS profile id BEFORE mutation (lot LP3-4): it identifies
// the projector whose now-orphan `Replace` files must be cleaned up at the swap.
let previous_profile_id = entry.profile_id;
// 2. Same profile ⇒ no-op: return the agent unchanged, no kill/relaunch,
// no event.
if entry.profile_id == input.profile_id {
@ -438,15 +468,16 @@ impl ChangeAgentProfile {
}
// 3. Validate that the target profile is a known one (ProfileStore.list).
let known = self
.profiles
.list()
.await?
.into_iter()
.any(|p| p.id == input.profile_id);
if !known {
// Capture both the NEW profile (validation) and the PREVIOUS profile (for
// the LP3-4 cleanup; absent if it was deleted ⇒ cleanup is skipped).
let profiles = self.profiles.list().await?;
let Some(new_profile) = profiles.iter().find(|p| p.id == input.profile_id).cloned() else {
return Err(AppError::NotFound(format!("profile {}", input.profile_id)));
}
};
let previous_profile = profiles
.iter()
.find(|p| p.id == previous_profile_id)
.cloned();
// 4. Mutate the entry (new profile), re-validate (to_agent + manifest)
// and persist.
@ -478,6 +509,20 @@ impl ChangeAgentProfile {
.invalidate_engine_link(&input.project, &input.agent_id)
.await?;
// 5b. (lot LP3-4) Clean up the PREVIOUS profile's now-orphan permission files
// in the agent's (stable) run dir, BEFORE the relaunch re-projects the new
// profile — so we never delete a file the new profile is about to write.
// Only `Replace`-owned paths of the old projector that the new projector
// does NOT also own are removed (best-effort). `MergeToml` files are never
// touched. No-op without a projector registry.
self.cleanup_swapped_out_files(
&input.project.root,
&input.agent_id,
previous_profile.as_ref(),
&new_profile,
)
.await;
// 6. A live session? Kill its PTY then relaunch in the same cell with the
// new profile, carrying the **preserved** pair id (so the handoff is
// re-injected and resume routes via providers.json[new provider]).
@ -555,6 +600,66 @@ impl ChangeAgentProfile {
Ok(pair_id)
}
/// Removes the **previous** profile's now-orphan permission files at the swap
/// (lot LP3-4), best-effort. Because the run dir is **stable per agent id**
/// (`agent_run_dir`), the old CLI's config (e.g. `.claude/settings.local.json`)
/// survives a swap in the very same directory; left in place it is stale.
///
/// Cleanup rule (Architect-validated): delete exactly
/// `owned_replace_paths(old) owned_replace_paths(new)` — only the
/// **`Replace`-owned** files of the old projector that the new projector will not
/// re-own (and thus re-write). `MergeToml` (co-owned, e.g. `.codex/config.toml`)
/// files are **never** deleted. Examples: Claude→Codex removes
/// `.claude/settings.local.json`; Claude→Claude removes nothing (empty diff);
/// Codex→Claude removes nothing on the Replace side (Codex owns no Replace file).
///
/// No-op when: no registry is wired, the previous profile is unknown (deleted),
/// or it maps to no projector. Every delete is best-effort (`remove_file` is
/// idempotent), so a missing file never fails the swap. This does **not** touch
/// the stable pair id or the handoff (P8d) — it only removes engine config files.
async fn cleanup_swapped_out_files(
&self,
project_root: &ProjectPath,
agent_id: &AgentId,
previous_profile: Option<&AgentProfile>,
new_profile: &AgentProfile,
) {
let Some(registry) = &self.projectors else {
return;
};
let Some(previous_profile) = previous_profile else {
return;
};
let Some(old_key) = select_projector_key(previous_profile) else {
return;
};
let Some(old_projector) = registry.get(old_key) else {
return;
};
let old_owned = old_projector.owned_replace_paths();
if old_owned.is_empty() {
return;
}
// Paths the NEW projector will re-own (and re-write) ⇒ never delete these.
let kept: Vec<String> = select_projector_key(new_profile)
.and_then(|key| registry.get(key))
.map(|p| p.owned_replace_paths())
.unwrap_or_default();
let run_dir = match agent_run_dir(project_root, agent_id) {
Ok(dir) => dir,
Err(_) => return,
};
for rel in old_owned {
if kept.iter().any(|k| k == &rel) {
continue;
}
let path = RemotePath::new(join(&run_dir, &rel));
// Best-effort: a missing file / delete failure must never fail the swap.
let _ = self.fs.remove_file(&path).await;
}
}
/// Kills the agent's live PTY (if any) and relaunches it in the same cell with
/// the new profile (step 6), composing [`LaunchAgent::execute`]. Returns the
/// relaunched session, or `None` when the agent had no live session.
@ -832,6 +937,81 @@ pub struct LaunchAgentOutput {
pub structured: Option<StructuredSessionDescriptor>,
}
/// Registry mapping each [`ProjectorKey`] to its concrete
/// [`PermissionProjector`] (lot LP3-3). Injected into [`LaunchAgent`] via the
/// optional builder [`LaunchAgent::with_permission_projectors`]; the concrete
/// projectors (Claude / Codex) live in `infrastructure` and are inserted at the
/// composition root (lot LP3-5). Absent registry ⇒ no projection (legacy
/// behaviour preserved).
#[derive(Default, Clone)]
pub struct PermissionProjectorRegistry {
by_key: HashMap<ProjectorKey, Arc<dyn PermissionProjector>>,
}
impl PermissionProjectorRegistry {
/// An empty registry.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Registers a projector under its own [`PermissionProjector::key`] and returns
/// `self` (builder style), so a registry can be assembled fluently.
#[must_use]
pub fn with(mut self, projector: Arc<dyn PermissionProjector>) -> Self {
self.insert(projector);
self
}
/// Registers (or replaces) a projector under its own key.
pub fn insert(&mut self, projector: Arc<dyn PermissionProjector>) {
self.by_key.insert(projector.key(), projector);
}
/// Returns the projector registered for `key`, if any.
#[must_use]
pub fn get(&self, key: ProjectorKey) -> Option<&Arc<dyn PermissionProjector>> {
self.by_key.get(&key)
}
}
/// Selects the [`ProjectorKey`] for a launched agent's profile (lot LP3-3).
///
/// Primary source: the profile's explicit `projector` field. **Migration
/// fallback** for legacy profiles (e.g. an old `profiles.json` written before the
/// field existed): the historical heuristic — a `CLAUDE.md` convention file ⇒
/// Claude; a `StructuredAdapter::Codex` or a `TomlConfigHome` MCP strategy ⇒
/// Codex. Anything else ⇒ `None` (no projection).
fn select_projector_key(profile: &AgentProfile) -> Option<ProjectorKey> {
if let Some(key) = profile.projector {
return Some(key);
}
// Legacy fallback: Claude is recognised by its `CLAUDE.md` convention file.
let is_claude = matches!(
&profile.context_injection,
ContextInjection::ConventionFile { target }
if target
.rsplit(['/', '\\'])
.next()
.unwrap_or(target)
.eq_ignore_ascii_case("CLAUDE.md")
);
if is_claude {
return Some(ProjectorKey::Claude);
}
// Legacy fallback: Codex is recognised by its structured adapter or its
// CODEX_HOME-isolated TOML MCP strategy.
if matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
return Some(ProjectorKey::Codex);
}
if let Some(mcp) = &profile.mcp {
if matches!(mcp.config, McpConfigStrategy::TomlConfigHome { .. }) {
return Some(ProjectorKey::Codex);
}
}
None
}
/// Launches an agent: resolve profile + context, prepare the invocation, apply
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
///
@ -859,6 +1039,10 @@ pub struct LaunchAgent {
/// reads the project memory. `None` keeps the launcher independent of it (legacy
/// wiring / tests). A failure here never affects the launch.
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
/// Optional permissions store (LP1). When absent, the launcher keeps its
/// historical hardcoded CLI permission seeds. When present and a project or
/// agent policy is configured, the resolved policy is projected to the CLI.
permissions: Option<Arc<dyn PermissionStore>>,
/// Fabrique des sessions **structurées** (IA, §17). Injectée au câblage
/// (composition root) via [`Self::with_structured`]. `None` ⇒ le routage §17.4
/// est désactivé et **tout** profil suit le chemin PTY historique (mode legacy /
@ -882,6 +1066,10 @@ pub struct LaunchAgent {
/// régression pour les call sites/tests legacy). Une écriture en échec ⇒ lancement
/// normal, jamais d'échec.
provider_sessions: Option<Arc<dyn ProviderSessionProvider>>,
/// Registre des **projecteurs de permissions** par CLI (lot LP3-3). Injecté au
/// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection
/// (comportement historique préservé pour les call sites/tests legacy).
projectors: Option<Arc<PermissionProjectorRegistry>>,
}
impl LaunchAgent {
@ -913,13 +1101,36 @@ impl LaunchAgent {
ids,
recall,
embedder_suggestion,
permissions: None,
session_factory: None,
structured: None,
handoffs: None,
provider_sessions: None,
projectors: None,
}
}
/// Injects the per-CLI permission **projector registry** (lot LP3-3) used to
/// translate the resolved [`EffectivePermissions`] into the launched CLI's
/// concrete permission config. Without this call (legacy call sites / tests),
/// no projection happens — signature of [`Self::new`] unchanged.
#[must_use]
pub fn with_permission_projectors(
mut self,
projectors: Arc<PermissionProjectorRegistry>,
) -> Self {
self.projectors = Some(projectors);
self
}
/// Injects the project permission store used to resolve effective agent
/// permissions at launch time.
#[must_use]
pub fn with_permission_store(mut self, permissions: Arc<dyn PermissionStore>) -> Self {
self.permissions = Some(permissions);
self
}
/// Branche le provider de **store de sessions provider (lot P8b)** sur ce launcher :
/// après un lancement structuré exposant un id de session moteur, range
/// `(pair_conversation_id, provider_key) → engine_session_id` dans `providers.json`
@ -1205,16 +1416,14 @@ impl LaunchAgent {
.create_dir_all(&RemotePath::new(run_dir.as_str().to_owned()))
.await?;
// 3b. Seed the CLI's permission config in the run dir so the agent runs
// with the project's full autonomy and never blocks on per-command
// permission prompts. The agent's cwd is the run dir, so the CLI
// writes/reads its permission file there; without a seed, the CLI
// accumulates narrow per-command approvals and keeps prompting.
// Pragmatic per-CLI seed pending the universal `.ideai/permissions.json`
// + OS-sandbox model. Non-clobbering and best-effort.
self.seed_cli_permissions(&profile, &run_dir, &input.project.root)
let effective_permissions = self
.resolve_effective_permissions(&input.project, agent.id)
.await?;
// 3b. (Permission projection moved to step 5c, after the convention file and
// the MCP config, so both the structured and PTY paths inherit it — see
// `apply_permission_projection`.)
// 4. Prepare the invocation (pure): command + args + injection plan + cwd.
// The run dir is passed as the cwd base; the profile's `{agentRunDir}`
// placeholder resolves against it.
@ -1277,8 +1486,29 @@ impl LaunchAgent {
// IdeA matérialise SA config MCP au format de CETTE CLI, dans le **même**
// run dir isolé que le convention file et le seed de permissions. `None`
// ⇒ aucun write/flag/env (chemin actuel inchangé, zéro régression).
self.apply_mcp_config(&profile, &run_dir, input.mcp_runtime.as_ref(), &mut spec)
.await;
self.apply_mcp_config(
&profile,
&run_dir,
&input.project.root,
input.mcp_runtime.as_ref(),
&mut spec,
)
.await;
// 5c. ── PROJECTION DES PERMISSIONS (lot LP3-3) ──
// Strictement APRÈS le convention file (5) ET la conf MCP (5a), donc
// AVANT le split structuré/PTY (5b) et le spawn : les DEUX chemins
// héritent de la projection (fichiers du plan écrits dans le run dir,
// `args`/`env` foldés dans `spec`). `None` registre / profil non
// projetable / `eff == None` ⇒ no-op (prompting natif conservé).
self.apply_permission_projection(
&profile,
&run_dir,
&input.project.root,
effective_permissions.as_ref(),
&mut spec,
)
.await?;
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le
@ -1575,53 +1805,114 @@ impl LaunchAgent {
}
}
/// Seeds the agent's run dir with the CLI permission config matching its
/// context-injection convention, so the agent inherits the project's autonomy
/// instead of prompting per command.
/// Projects the resolved [`EffectivePermissions`] into the launched CLI's
/// concrete permission config (lot LP3-3), replacing the historical per-CLI
/// seeds. Selects the profile's [`PermissionProjector`] (cf.
/// [`select_projector_key`]), asks it for a pure [`domain::permission::PermissionProjection`]
/// (a plan), then **applies** that plan: materialises its files in the run dir
/// and folds its `args`/`env` into `spec`.
///
/// Conditioned on the CLI convention (only Claude Code — convention file
/// `CLAUDE.md` — has a known seed today); a no-op for any other CLI.
/// Best-effort and **non-clobbering**: an existing file (possibly user-edited)
/// is left untouched.
/// File ownership drives the write regime:
/// - [`ProjectedFile::Replace`] ⇒ **clobber** (always overwrite). Unlike the old
/// non-clobbering seed, this lets a re-projection (e.g. after a profile swap)
/// refresh an IdeA-owned file.
/// - [`ProjectedFile::MergeToml`] ⇒ merge only the **managed** tables/keys into
/// any existing file (via the shared TOML helpers), preserving everything else.
///
/// No-op when: no registry is wired, the profile has no matching projector, or
/// the resolved permissions are `None` (the projector returns an empty
/// projection — native prompting preserved).
///
/// # Errors
/// [`AppError::FileSystem`] if the directory/file cannot be written.
async fn seed_cli_permissions(
/// [`AppError::FileSystem`] if a plan file cannot be written.
async fn apply_permission_projection(
&self,
profile: &AgentProfile,
run_dir: &ProjectPath,
project_root: &ProjectPath,
permissions: Option<&EffectivePermissions>,
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
let is_claude = matches!(
&profile.context_injection,
ContextInjection::ConventionFile { target }
if target
.rsplit(['/', '\\'])
.next()
.unwrap_or(target)
.eq_ignore_ascii_case("CLAUDE.md")
);
if !is_claude {
let Some(registry) = &self.projectors else {
return Ok(());
};
let Some(key) = select_projector_key(profile) else {
return Ok(());
};
let Some(projector) = registry.get(key) else {
return Ok(());
};
let ctx = ProjectionContext {
project_root: project_root.as_str(),
run_dir: run_dir.as_str(),
};
let projection = projector.project(permissions, &ctx);
for file in &projection.files {
match file {
ProjectedFile::Replace { rel_path, contents } => {
self.ensure_run_dir_parent(run_dir, rel_path).await?;
let path = RemotePath::new(join(run_dir, rel_path));
// Clobber: IdeA owns this file; overwriting refreshes it on re-projection.
self.fs.write(&path, contents.as_bytes()).await?;
}
ProjectedFile::MergeToml {
rel_path,
managed_tables,
managed_keys,
contents,
} => {
self.ensure_run_dir_parent(run_dir, rel_path).await?;
let path = RemotePath::new(join(run_dir, rel_path));
let existing = match self.fs.read(&path).await {
Ok(bytes) => String::from_utf8(bytes).unwrap_or_default(),
Err(_) => String::new(),
};
let merged =
merge_managed_toml(&existing, managed_tables, managed_keys, contents);
self.fs.write(&path, merged.as_bytes()).await?;
}
}
}
let settings_path =
RemotePath::new(format!("{}/.claude/settings.local.json", run_dir.as_str()));
if self.fs.exists(&settings_path).await? {
return Ok(());
}
self.fs
.create_dir_all(&RemotePath::new(format!("{}/.claude", run_dir.as_str())))
.await?;
self.fs
.write(
&settings_path,
claude_settings_seed(project_root.as_str()).as_bytes(),
)
.await?;
// Fold the plan's launch args/env into the spec, before the structured/PTY
// split so both inherit them.
spec.args.extend(projection.args.iter().cloned());
spec.env.extend(projection.env.iter().cloned());
Ok(())
}
/// Ensures the parent directory of `<run_dir>/<rel_path>` exists (e.g. the
/// `.claude/` or `.codex/` subdir), so a projected file write never fails on a
/// missing directory. A `rel_path` with no separator needs no extra dir.
async fn ensure_run_dir_parent(
&self,
run_dir: &ProjectPath,
rel_path: &str,
) -> Result<(), AppError> {
if let Some((parent, _)) = rel_path.rsplit_once(['/', '\\']) {
if !parent.is_empty() {
self.fs
.create_dir_all(&RemotePath::new(format!("{}/{parent}", run_dir.as_str())))
.await?;
}
}
Ok(())
}
async fn resolve_effective_permissions(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<Option<EffectivePermissions>, AppError> {
let Some(store) = &self.permissions else {
return Ok(None);
};
let doc = store.load_permissions(project).await?;
Ok(doc.resolve_for(agent_id))
}
/// Applies the context-injection plan that must happen *before* spawn:
/// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
/// or attaching the on-disk context path to an environment variable. `Args` is
@ -1709,6 +2000,7 @@ impl LaunchAgent {
&self,
profile: &AgentProfile,
run_dir: &ProjectPath,
project_root: &ProjectPath,
runtime: Option<&McpRuntime>,
spec: &mut SpawnSpec,
) {
@ -1761,16 +2053,37 @@ impl LaunchAgent {
// écraser une déclaration réelle par la minimale.
match runtime {
Some(_) => {
let _ = self.fs.write(&path, declaration.as_bytes()).await;
let existing = match self.fs.read(&path).await {
Ok(bytes) => String::from_utf8(bytes).ok(),
Err(_) => None,
};
let rendered = codex_config_toml(
existing.as_deref(),
&declaration,
run_dir.as_str(),
project_root.as_str(),
);
let _ = self.fs.write(&path, rendered.as_bytes()).await;
}
None => match self.fs.exists(&path).await {
Ok(true) => {}
Ok(false) => {
let _ = self.fs.write(&path, declaration.as_bytes()).await;
let rendered = codex_config_toml(
None,
&declaration,
run_dir.as_str(),
project_root.as_str(),
);
let _ = self.fs.write(&path, rendered.as_bytes()).await;
}
Err(_) => {}
},
}
// Permission projection (sandbox_mode/approval_policy + --sandbox/
// --ask-for-approval) is NO LONGER done here — it is decoupled into
// `apply_permission_projection` (lot LP3-3), so a Codex profile gets its
// sandbox even without an MCP capability. `apply_mcp_config` is now
// MCP-only (mcp_servers.idea table + projects trust).
// `home_env` pointe sur le DOSSIER PARENT de `target` (ex.
// `{runDir}/.codex`), pas sur le fichier — Codex y cherche `config.toml`.
let home_dir = parent_dir(run_dir, target);
@ -1986,57 +2299,9 @@ fn structured_snapshot(
snapshot
}
/// Builds the Claude Code permission seed (`.claude/settings.local.json`) written
/// into an agent's run dir: full project autonomy (`bypassPermissions` + broad
/// Read/Edit/Write/Bash) with the project root granted as an additional working
/// directory (the cwd is the run dir, the agent works on the root above it), while
/// keeping destructive/out-of-project commands denied. `project_root` is embedded
/// verbatim; it is JSON-escaped to stay valid for unusual paths.
///
/// Pure (no I/O), so it is unit-testable in isolation.
#[must_use]
fn claude_settings_seed(project_root: &str) -> String {
let root = json_escape(project_root);
format!(
r#"{{
"permissions": {{
"defaultMode": "bypassPermissions",
"additionalDirectories": [
"{root}"
],
"allow": [
"Read",
"Edit",
"Write",
"Bash"
],
"deny": [
"Bash(sudo *)",
"Bash(rm -rf /)",
"Bash(rm -rf /*)",
"Bash(rm -rf ~)",
"Bash(rm -rf ~/)",
"Bash(rm -rf ~/*)",
"Bash(rm -rf $HOME*)",
"Bash(mkfs*)",
"Bash(dd if=*)",
"Bash(shutdown*)",
"Bash(reboot*)"
]
}},
"skipDangerousModePermissionPrompt": true,
"enabledMcpjsonServers": ["idea"],
"sandbox": {{
"enabled": false
}}
}}
"#
)
}
/// Minimal JSON string escaper for embedding a filesystem path in the settings
/// seed (handles the characters that actually occur in paths: backslash, quote,
/// and control chars).
/// Minimal JSON string escaper used by [`toml_string`] (and historically by the
/// Claude seed, now extracted to the infra projector). Handles the characters that
/// actually occur in paths: backslash, quote, control chars.
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
@ -2053,6 +2318,180 @@ fn json_escape(s: &str) -> String {
out
}
fn toml_string(s: &str) -> String {
format!("\"{}\"", json_escape(s))
}
/// Renders Codex's `config.toml` **MCP part only** (lot LP3-3 decoupling): merges
/// the `[mcp_servers.idea]` table and ensures the run-dir + project-root trust
/// entries. The permission part (`sandbox_mode` / `approval_policy` + the
/// `--sandbox` / `--ask-for-approval` args) now lives in the Codex permission
/// projector (`apply_permission_projection`), so this function is MCP-only and a
/// Codex profile receives its sandbox even without an MCP capability.
fn codex_config_toml(
existing: Option<&str>,
mcp_declaration: &str,
run_dir: &str,
project_root: &str,
) -> String {
let mut text = existing.unwrap_or_default().to_owned();
text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end());
text = ensure_codex_trust(&text, run_dir);
text = ensure_codex_trust(&text, project_root);
if !text.ends_with('\n') {
text.push('\n');
}
text
}
/// Merges a projector's **managed** TOML fragment into an existing `config.toml`
/// (lot LP3-3, [`ProjectedFile::MergeToml`]). Only the `managed_tables` and
/// `managed_keys` are touched; every other line of `existing` is preserved.
///
/// - each managed table is replaced wholesale by its block from `fragment`
/// ([`replace_toml_table`]);
/// - each managed top-level key takes the value found for it in `fragment`
/// ([`set_top_level_toml_line`]).
///
/// A managed table/key absent from `fragment` leaves `existing` untouched for it.
fn merge_managed_toml(
existing: &str,
managed_tables: &[String],
managed_keys: &[String],
fragment: &str,
) -> String {
let mut text = existing.to_owned();
for table in managed_tables {
if let Some(block) = extract_toml_table(fragment, table) {
text = replace_toml_table(&text, table, block.trim_end());
}
}
for key in managed_keys {
if let Some(line) = extract_top_level_toml_line(fragment, key) {
text = set_top_level_toml_line(&text, key, line);
}
}
if !text.ends_with('\n') {
text.push('\n');
}
text
}
/// Returns the full `key = …` line for `key` found at top level in `fragment`
/// (before any `[table]` header), or `None` if absent.
fn extract_top_level_toml_line<'a>(fragment: &'a str, key: &str) -> Option<&'a str> {
let needle = format!("{key} =");
for line in fragment.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
break;
}
if trimmed.starts_with(&needle) {
return Some(line);
}
}
None
}
/// Returns the `[table]` block (header + body up to the next header / EOF) for
/// `table` in `fragment`, or `None` if the table is absent.
fn extract_toml_table(fragment: &str, table: &str) -> Option<String> {
let header = format!("[{table}]");
let mut block: Vec<&str> = Vec::new();
let mut capturing = false;
for line in fragment.lines() {
let trimmed = line.trim();
if trimmed == header {
capturing = true;
block.push(line);
continue;
}
if capturing {
if trimmed.starts_with('[') {
break;
}
block.push(line);
}
}
if capturing {
Some(block.join("\n"))
} else {
None
}
}
fn ensure_codex_trust(input: &str, path: &str) -> String {
let header = format!("[projects.{}]", toml_string(path));
if input.lines().any(|line| line.trim() == header) {
return input.to_owned();
}
append_block(input, &format!("{header}\ntrust_level = \"trusted\""))
}
fn replace_toml_table(input: &str, table: &str, block: &str) -> String {
let header = format!("[{table}]");
let mut out = Vec::new();
let mut skipping = false;
for line in input.lines() {
let trimmed = line.trim();
if trimmed == header {
skipping = true;
continue;
}
if skipping && trimmed.starts_with('[') {
skipping = false;
}
if !skipping {
out.push(line.to_owned());
}
}
append_block(&out.join("\n"), block)
}
/// Upserts a top-level `key` with a pre-formatted `replacement` line (already
/// `key = <toml value>`): replaces the existing top-level occurrence if present,
/// otherwise prepends it. The line-level core shared by [`merge_managed_toml`].
fn set_top_level_toml_line(input: &str, key: &str, replacement: &str) -> String {
let mut out = Vec::new();
let mut replaced = false;
let mut in_top_level = true;
for line in input.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
in_top_level = false;
}
if in_top_level && !replaced && trimmed.starts_with(&format!("{key} =")) {
out.push(replacement.to_owned());
replaced = true;
} else {
out.push(line.to_owned());
}
}
let text = out.join("\n");
if replaced {
text
} else {
prepend_line(&text, replacement)
}
}
fn prepend_line(input: &str, line: &str) -> String {
if input.trim().is_empty() {
format!("{line}\n")
} else {
format!("{line}\n{}", input.trim_start_matches('\n'))
}
}
fn append_block(input: &str, block: &str) -> String {
let trimmed = input.trim_end();
if trimmed.is_empty() {
format!("{}\n", block.trim_end())
} else {
format!("{trimmed}\n\n{}\n", block.trim_end())
}
}
/// Composes the convention file IdeA writes into an agent's run directory: an
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
/// so it must be told where to work), the IdeA orchestration contract, the
@ -2658,39 +3097,83 @@ mod tests {
);
}
#[test]
fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() {
let json = claude_settings_seed("/home/me/proj");
// NB (lot LP3-3): the former `claude_settings_seed_*` unit tests were removed —
// that translation now lives in `infrastructure::permission::ClaudePermissionProjector`
// and is covered by its own tests (LP3-2). Likewise the Codex sandbox/approval
// derivation moved to `CodexPermissionProjector`; `codex_config_toml` here is now
// **MCP-only**, so the Codex tests below assert only the MCP/trust merge.
// Full autonomy.
assert!(json.contains("\"defaultMode\": \"bypassPermissions\""));
assert!(json.contains("\"Bash\""));
// Project root granted as an additional working directory.
assert!(json.contains("\"/home/me/proj\""));
// Destructive guardrails preserved.
assert!(json.contains("Bash(sudo *)"));
assert!(json.contains("Bash(rm -rf /)"));
assert!(json.contains("Bash(mkfs*)"));
// Valid JSON.
let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed is valid JSON");
assert_eq!(parsed["permissions"]["defaultMode"], "bypassPermissions");
// IdeA MCP server pre-approved so idea_* tools load without a prompt.
assert_eq!(parsed["enabledMcpjsonServers"][0], "idea");
assert_eq!(
parsed["permissions"]["additionalDirectories"][0],
"/home/me/proj"
#[test]
fn codex_config_trusts_run_and_project_and_replaces_only_idea_mcp() {
let existing = r#"[projects."/home/me/proj"]
trust_level = "trusted"
approval_policy = "nested"
[mcp_servers.idea]
command = "old"
[mcp_servers.other]
command = "other"
"#;
// No `permissions` argument anymore: the permission projection is decoupled
// (lot LP3-3) into `CodexPermissionProjector`. This function only merges MCP.
let rendered = codex_config_toml(
Some(existing),
"[mcp_servers.idea]\ncommand = \"new\"",
"/home/me/proj/.ideai/run/a",
"/home/me/proj",
);
// MCP table + trust entries are the only things this function touches.
assert!(rendered.contains("[projects.\"/home/me/proj\"]"));
assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]"));
assert!(rendered.contains("approval_policy = \"nested\""));
assert!(rendered.contains("[mcp_servers.idea]\ncommand = \"new\""));
assert!(rendered.contains("[mcp_servers.other]\ncommand = \"other\""));
assert!(!rendered.contains("command = \"old\""));
assert_eq!(rendered.matches("[mcp_servers.idea]").count(), 1);
assert_eq!(rendered.matches("[projects.\"/home/me/proj\"]").count(), 1);
// Permission keys are NOT added by this MCP-only function.
assert!(!rendered.contains("sandbox_mode ="));
}
#[test]
fn claude_settings_seed_escapes_paths_for_valid_json() {
// A path with a backslash and a quote must not break the JSON.
let json = claude_settings_seed(r#"/weird\path"x"#);
let parsed: serde_json::Value =
serde_json::from_str(&json).expect("seed with odd path is valid JSON");
assert_eq!(
parsed["permissions"]["additionalDirectories"][0],
r#"/weird\path"x"#
fn codex_config_is_mcp_only_and_adds_no_permission_keys() {
let rendered = codex_config_toml(
None,
"[mcp_servers.idea]\ncommand = \"idea-mcp\"",
"/home/me/proj/.ideai/run/a",
"/home/me/proj",
);
assert!(!rendered.contains("approval_policy ="));
assert!(!rendered.contains("sandbox_mode ="));
assert!(rendered.contains("[projects.\"/home/me/proj\"]"));
assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]"));
}
#[test]
fn merge_managed_toml_upserts_only_managed_keys() {
// Existing config (e.g. written by `apply_mcp_config`): MCP + trust + a user key.
let existing = r#"user_key = "keep-me"
[mcp_servers.idea]
command = "idea-mcp"
"#;
// Codex projector fragment: the two managed permission keys.
let fragment = "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n";
let merged = merge_managed_toml(
existing,
&[],
&["sandbox_mode".to_owned(), "approval_policy".to_owned()],
fragment,
);
// Managed keys are spliced in…
assert!(merged.contains("sandbox_mode = \"workspace-write\""));
assert!(merged.contains("approval_policy = \"never\""));
// …without disturbing the rest of the file.
assert!(merged.contains("user_key = \"keep-me\""));
assert!(merged.contains("[mcp_servers.idea]\ncommand = \"idea-mcp\""));
}
}

View File

@ -24,9 +24,9 @@ pub use lifecycle::{
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, McpRuntime, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
ReadAgentContextOutput, StructuredSessionDescriptor, UpdateAgentContext,
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
};
pub use resume::{
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,

View File

@ -20,6 +20,7 @@ pub mod health;
pub mod layout;
pub mod memory;
pub mod orchestrator;
pub mod permission;
pub mod project;
pub mod remote;
pub mod skill;
@ -29,14 +30,15 @@ pub mod window;
pub use agent::{
drain_with_readiness, reference_profile_id, reference_profiles, selectable_reference_profiles,
send_blocking, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles,
ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput,
CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput,
DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
HandoffProvider, InspectConversation, InspectConversationInput, InspectConversationOutput,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, ProfileAvailability,
send_blocking, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
FirstRunStateOutput, HandoffProvider, InspectConversation, InspectConversationInput,
InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents,
ListAgentsInput, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry,
ProfileAvailability,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
@ -77,6 +79,12 @@ pub use memory::{
pub use orchestrator::{
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
};
pub use permission::{
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
ResolveAgentPermissions, ResolveAgentPermissionsInput, ResolveAgentPermissionsOutput,
UpdateAgentPermissions, UpdateAgentPermissionsInput, UpdateProjectPermissions,
UpdateProjectPermissionsInput,
};
pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,

View File

@ -0,0 +1,150 @@
//! Permission use cases.
//!
//! This module stays at the application boundary: it loads the project
//! permission document through [`PermissionStore`], applies simple mutations, and
//! delegates all merge semantics to the pure domain model.
use std::sync::Arc;
use domain::ports::PermissionStore;
use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions};
use crate::error::AppError;
/// Reads the full project permission document.
pub struct GetProjectPermissions {
store: Arc<dyn PermissionStore>,
}
impl GetProjectPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
Self { store }
}
/// Executes the read.
pub async fn execute(
&self,
input: GetProjectPermissionsInput,
) -> Result<GetProjectPermissionsOutput, AppError> {
let permissions = self.store.load_permissions(&input.project).await?;
Ok(GetProjectPermissionsOutput { permissions })
}
}
/// Input for [`GetProjectPermissions`].
pub struct GetProjectPermissionsInput {
/// Target project.
pub project: Project,
}
/// Output for [`GetProjectPermissions`].
pub struct GetProjectPermissionsOutput {
/// Persisted permission document.
pub permissions: ProjectPermissions,
}
/// Replaces the project default policy.
pub struct UpdateProjectPermissions {
store: Arc<dyn PermissionStore>,
}
impl UpdateProjectPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
Self { store }
}
/// Executes the mutation.
pub async fn execute(
&self,
input: UpdateProjectPermissionsInput,
) -> Result<GetProjectPermissionsOutput, AppError> {
let mut doc = self.store.load_permissions(&input.project).await?;
doc.set_project_defaults(input.permissions);
self.store.save_permissions(&input.project, &doc).await?;
Ok(GetProjectPermissionsOutput { permissions: doc })
}
}
/// Input for [`UpdateProjectPermissions`].
pub struct UpdateProjectPermissionsInput {
/// Target project.
pub project: Project,
/// New project default policy. `None` removes project defaults.
pub permissions: Option<PermissionSet>,
}
/// Replaces one agent override.
pub struct UpdateAgentPermissions {
store: Arc<dyn PermissionStore>,
}
impl UpdateAgentPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
Self { store }
}
/// Executes the mutation.
pub async fn execute(
&self,
input: UpdateAgentPermissionsInput,
) -> Result<GetProjectPermissionsOutput, AppError> {
let mut doc = self.store.load_permissions(&input.project).await?;
doc.set_agent_permissions(input.agent_id, input.permissions);
self.store.save_permissions(&input.project, &doc).await?;
Ok(GetProjectPermissionsOutput { permissions: doc })
}
}
/// Input for [`UpdateAgentPermissions`].
pub struct UpdateAgentPermissionsInput {
/// Target project.
pub project: Project,
/// Target agent.
pub agent_id: AgentId,
/// New agent policy. `None` removes the override.
pub permissions: Option<PermissionSet>,
}
/// Resolves effective permissions for one agent.
pub struct ResolveAgentPermissions {
store: Arc<dyn PermissionStore>,
}
impl ResolveAgentPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
Self { store }
}
/// Executes the resolution.
pub async fn execute(
&self,
input: ResolveAgentPermissionsInput,
) -> Result<ResolveAgentPermissionsOutput, AppError> {
let doc = self.store.load_permissions(&input.project).await?;
Ok(ResolveAgentPermissionsOutput {
effective: doc.resolve_for(input.agent_id),
})
}
}
/// Input for [`ResolveAgentPermissions`].
pub struct ResolveAgentPermissionsInput {
/// Target project.
pub project: Project,
/// Target agent.
pub agent_id: AgentId,
}
/// Output for [`ResolveAgentPermissions`].
pub struct ResolveAgentPermissionsOutput {
/// Resolved policy, or `None` when neither project nor agent policy exists.
pub effective: Option<EffectivePermissions>,
}