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:
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
1315
crates/domain/src/permission.rs
Normal file
1315
crates/domain/src/permission.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -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]
|
||||
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user