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:
@ -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\""));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user