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

@ -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.