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

@ -211,6 +211,45 @@ pub trait InputMediator: Send + Sync {
false
}
/// Déclare qu'`agent` vient d'être **lancé à froid** : la livraison de son tout
/// premier tour doit être *gatée* sur le prompt-ready (la `DelegationReady` est
/// différée jusqu'à l'apparition du prompt du CLI), pour éviter d'écrire la tâche
/// avant que le CLI ait fini de booter (premier tour perdu sinon).
///
/// À n'appeler **que** lorsqu'un prompt-ready watcher sera effectivement armé (le
/// profil porte un `prompt_ready_pattern` non vide). Sans watcher pour le libérer,
/// gater le premier tour le bloquerait indéfiniment ; dans ce cas l'orchestrateur ne
/// doit **pas** appeler `mark_starting` et l'`enqueue` livre la tâche immédiatement
/// (chemin chaud, fallback sûr, zéro régression).
///
/// Default: no-op (a mediator that does not gate cold starts).
fn mark_starting(&self, _agent: AgentId) {}
/// Signal de **readiness de démarrage** : le pont MCP de `agent` vient de se
/// connecter (son CLI est up et a chargé les outils `idea_*`). Si un premier tour
/// a été différé par [`InputMediator::mark_starting`] (démarrage à froid), c'est le
/// moment de le livrer ⇒ draine le `DelegationReady` retenu. **Contrairement à
/// `prompt_ready`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de
/// fin de tour. Idempotent : un second appel (ou après que `prompt_ready` ait déjà
/// drainé) ne trouve rien et est un no-op. En OR avec le prompt-ready : le premier
/// arrivé draine, l'autre est no-op.
///
/// Default: no-op (médiateur qui ne gate pas les démarrages à froid).
fn release_cold_start(&self, _agent: AgentId) {}
/// Déclare si une **cellule terminal du frontend** est montée pour `agent`
/// (`true` au montage du write-portal, `false` au démontage). C'est le frontend
/// qui possède l'écriture physique du PTY *quand une cellule existe* (cas d'un
/// agent épinglé dans le layout) ; un agent **délégué en arrière-plan n'a aucune
/// cellule**, donc personne ne consomme `DelegationReady` côté UI. Le médiateur
/// utilise cet état pour décider, à la livraison d'un tour, entre **publier
/// l'événement** (cellule présente ⇒ le front écrit) et **écrire lui-même** la
/// tâche dans le PTY (agent headless ⇒ sinon le tour est perdu).
///
/// Default: no-op (médiateur sans notion de cellule front — tout passe par
/// l'événement, comportement historique).
fn set_front_attached(&self, _agent: AgentId, _attached: bool) {}
/// Interrompre = preempt: signals the running turn to stop (Échap/stop). This
/// is **not** an enqueue and correlates **no** ticket.
fn preempt(&self, agent: AgentId);

View File

@ -44,6 +44,7 @@ pub mod mailbox;
pub mod markdown;
pub mod memory;
pub mod orchestrator;
pub mod permission;
pub mod ports;
pub mod profile;
pub mod project;
@ -117,6 +118,13 @@ pub use layout::{
pub use events::{DomainEvent, OrchestrationSource};
pub use permission::{
resolve as resolve_permissions, AgentPermissionOverride, Capability, CommandMatcher,
CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError, PermissionProjection,
PermissionProjector, PermissionRule, PermissionSet, Posture, ProjectedFile, ProjectionContext,
ProjectPermissions, ProjectorKey, PERMISSIONS_VERSION,
};
pub use orchestrator::{
OrchestratorCommand, OrchestratorError, OrchestratorRequest, OrchestratorVisibility,
};
@ -126,7 +134,8 @@ pub use ports::{
EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore,
EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem,
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PreparedContext,
ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore,
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore,
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError,
TemplateStore,
};

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,7 @@ use crate::events::DomainEvent;
use crate::ids::{AgentId, SessionId};
use crate::markdown::MarkdownDoc;
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use crate::permission::ProjectPermissions;
use crate::profile::{AgentProfile, EmbedderProfile};
use crate::project::{Project, ProjectPath};
use crate::remote::RemoteKind;
@ -617,6 +618,21 @@ pub trait FileSystem: Send + Sync {
/// [`FsError`] on failure.
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError>;
/// Removes a single file. A **missing** file is treated as success (idempotent
/// delete), so this is safe to call best-effort.
///
/// Defaults to a **no-op `Ok(())`** so existing adapters and in-memory test
/// doubles keep compiling unchanged; the real adapter overrides it with an
/// actual delete. Callers that rely on the file actually being gone must use an
/// adapter that overrides this (the local FS does).
///
/// # Errors
/// [`FsError`] on a failure other than not-found.
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
let _ = path;
Ok(())
}
/// Creates a directory and all missing parents.
///
/// # Errors
@ -1063,6 +1079,27 @@ pub trait AgentContextStore: Send + Sync {
) -> Result<(), StoreError>;
}
/// Reads/writes a project's `.ideai/permissions.json`.
#[async_trait]
pub trait PermissionStore: Send + Sync {
/// Loads the project's permission document. Missing file returns the default
/// empty document.
///
/// # Errors
/// [`StoreError`] on I/O or deserialisation failure.
async fn load_permissions(&self, project: &Project) -> Result<ProjectPermissions, StoreError>;
/// Saves the project's permission document.
///
/// # Errors
/// [`StoreError`] on I/O or serialisation failure.
async fn save_permissions(
&self,
project: &Project,
permissions: &ProjectPermissions,
) -> Result<(), StoreError>;
}
/// Git operations for a project. Named `GitPort` to avoid clashing with the
/// [`crate::git::GitRepository`] *entity* (state image).
#[async_trait]

View File

@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::ids::ProfileId;
use crate::permission::ProjectorKey;
/// Strategy for injecting an agent's `.md` context into the launched CLI.
///
@ -574,6 +575,20 @@ pub struct AgentProfile {
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submit_delay_ms: Option<u32>,
/// Clé du **projecteur de permissions** par-CLI (lot LP3) : QUEL adapter
/// traduit les [`crate::permission::EffectivePermissions`] résolues en config
/// de permission concrète de cette CLI au lancement. Donnée **déclarative**
/// (Open/Closed), calquée sur [`StructuredAdapter`].
///
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** projection :
/// la CLI garde son prompting natif (invariant produit de
/// [`crate::permission::resolve`]). `Some(_)` ⇒ IdeA matérialise la config de
/// permission de cette CLI au lancement.
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
/// sérialisation : un profil sans cette clé sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projector: Option<ProjectorKey>,
}
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
@ -713,6 +728,7 @@ impl AgentProfile {
liveness: None,
submit_sequence: None,
submit_delay_ms: None,
projector: None,
})
}
@ -769,6 +785,15 @@ impl AgentProfile {
self
}
/// Builder : fixe la [`ProjectorKey`] de permissions (lot LP3) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans projection de permission ne l'appellent simplement pas.
#[must_use]
pub const fn with_projector(mut self, projector: ProjectorKey) -> Self {
self.projector = Some(projector);
self
}
/// Indique si ce profil peut être **proposé à la sélection/création** d'un
/// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est
/// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il
@ -1257,6 +1282,55 @@ mod mcp_tests {
assert!(!codex_wrong_strategy.materializes_idea_bridge());
}
// -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ----------
#[test]
fn profile_default_has_no_projector() {
// Profils existants (via `new`) : aucune clé de projecteur ⇒ prompting natif.
assert!(profile_without_mcp().projector.is_none());
}
#[test]
fn profile_without_projector_omits_key_in_json() {
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
assert!(
!json.contains("projector"),
"a profile without a projector must NOT serialise the key (zero regression); got: {json}"
);
}
#[test]
fn legacy_json_without_projector_deserialises_to_none() {
// JSON produit avant l'existence du champ `projector` : aucune clé `projector`.
let legacy = r#"{
"id": "00000000-0000-0000-0000-000000000000",
"name": "Dev",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": null,
"cwdTemplate": "{agentRunDir}"
}"#;
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
assert!(profile.projector.is_none());
}
#[test]
fn with_projector_sets_and_round_trips_camel_case() {
let profile = profile_without_mcp().with_projector(ProjectorKey::Claude);
assert_eq!(profile.projector, Some(ProjectorKey::Claude));
let json = serde_json::to_string(&profile).expect("serialise");
assert!(
json.contains("\"projector\":\"claude\""),
"projector key present in stable wire form: {json}"
);
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
assert_eq!(back.projector, Some(ProjectorKey::Claude));
}
#[test]
fn mcp_server_wiring_encodes_expected_toml() {
// A command path with a space and a backslash exercises TOML escaping.