Files
IdeA/crates/infrastructure/src/runtime/mod.rs
Blomios b05d04ab7a feat(permissions): LP4-0 — fondations domaine de l'enforcement OS (pur)
Fondations pures (zéro I/O, zéro dépendance landlock, aucun câblage
runtime — SpawnSpec.sandbox posé mais jamais lu ⇒ zéro régression) de la
voie « airtight » des permissions, complément de la voie projection LP3.

- domain/sandbox.rs : SandboxPlan/PathGrant/PathAccess (RO|RW|EXEC),
  SandboxContext, SandboxKind/Status/Error, port SandboxEnforcer, et la
  fonction pure compile_sandbox_plan(EffectivePermissions → plan OS).
- domain/permission.rs : render_permission_summary (bloc Markdown injecté
  plus tard ; mentionne explicitement fichiers OS-enforced vs commandes
  advisory).
- domain/ports.rs : SpawnSpec.sandbox: Option<SandboxPlan> (None ⇒ natif),
  propagé à tous les sites de construction.

Sémantique de compile_sandbox_plan :
- Invariant produit : eff == None ⇒ None (rien posé ⇒ CLI 100 % native).
- Borne Landlock : seules les capabilities fichier produisent des grants
  (Read→RO, Write/Delete→RW) ; ExecuteBash reste advisory (non verrouillable
  par chemin).
- Deny-wins PAR CLASSE D'ACCÈS (RO/RW), fail-closed intra-classe : un Deny
  ne ferme que les grants de sa propre classe (un Deny Write n'ampute pas un
  Allow Read). Choix retenu pour maximiser l'autonomie des agents : on
  respecte exactement la politique pré-renseignée sans sur-restreindre, donc
  moins de blocages qui forceraient l'agent à redemander l'utilisateur.
- Globs réduits à leur préfixe statique ; grant abandonné si une barrière de
  même classe chevauche (égal/ancêtre/descendant) — sous-approximation
  conservatrice (un sandbox additif ne peut pas carver un deny sous-arbre).

Tests : 16 tests purs sur sandbox + 3 sur render_permission_summary,
cargo test --workspace 100 % vert, 0 ignored.

Reste LP4 : LP4-1 adapter LandlockSandbox + pre_exec PTY (fail-open+warning
sauf posture Deny), LP4-2 câblage application, LP4-3 composition root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:37:34 +02:00

238 lines
11 KiB
Rust

//! [`CliAgentRuntime`] — the single, generic [`AgentRuntime`] adapter driven by
//! an [`AgentProfile`] (ARCHITECTURE §5, §4; CONTEXT §9).
//!
//! There is **one** adapter for *every* AI CLI: the diversity (Claude, Codex,
//! Gemini, Aider, custom) lives entirely in the declarative [`AgentProfile`]
//! data, never in code. Adding an AI = adding a profile (the **Open/Closed**
//! principle made literal).
//!
//! # Responsibilities
//!
//! - [`detect`](AgentRuntime::detect): run the profile's detection command via
//! the injected [`ProcessSpawner`] and report whether the CLI is installed
//! (exit code 0). When `profile.detect` is `None`, fall back to
//! `<command> --version` (see [`detection_spec`]).
//! - [`prepare_invocation`](AgentRuntime::prepare_invocation): a **pure**
//! function (no I/O) that builds the [`SpawnSpec`] — command, args, resolved
//! `cwd` (template substitution), and the context-injection plan derived from
//! the profile's [`ContextInjection`]. This is the testable core of L5.
use std::sync::Arc;
use async_trait::async_trait;
use domain::ports::{
AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SessionPlan,
SpawnSpec,
};
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
use domain::project::ProjectPath;
/// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only
/// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure.
#[derive(Clone)]
pub struct CliAgentRuntime {
spawner: Arc<dyn ProcessSpawner>,
}
impl CliAgentRuntime {
/// Builds the runtime from an injected [`ProcessSpawner`] (the composition
/// root passes a [`crate::LocalProcessSpawner`], or a remote one later).
#[must_use]
pub fn new(spawner: Arc<dyn ProcessSpawner>) -> Self {
Self { spawner }
}
/// Builds the [`SpawnSpec`] used to *detect* a profile's CLI.
///
/// - If `profile.detect` is set, it is parsed as a whitespace-delimited
/// command line (`"claude --version"` → command `claude`, args
/// `["--version"]`). The first token is the executable.
/// - Otherwise we fall back to `<command> --version`, the near-universal
/// "is it installed?" probe.
///
/// Detection runs in the current working directory and injects nothing.
///
/// This is pure (no I/O) and therefore unit-testable without a spawner.
///
/// # Errors
/// [`RuntimeError::Detection`] if the detection command is blank.
pub fn detection_spec(profile: &AgentProfile) -> Result<SpawnSpec, RuntimeError> {
let line = profile
.detect
.clone()
.unwrap_or_else(|| format!("{} --version", profile.command));
let mut tokens = line.split_whitespace();
let command = tokens
.next()
.ok_or_else(|| RuntimeError::Detection("empty detection command".to_owned()))?
.to_owned();
let args = tokens.map(str::to_owned).collect();
// Detection runs in a neutral cwd; "." is a safe relative placeholder the
// spawner resolves against the process cwd.
let cwd = ProjectPath::new("/").map_err(|e| RuntimeError::Detection(e.to_string()))?;
Ok(SpawnSpec {
command,
args,
cwd,
env: Vec::new(),
context_plan: None,
sandbox: None,
})
}
/// Resolves the profile's `cwd_template` against the supplied `base` cwd.
///
/// The base is the agent's **run directory** (`.ideai/run/<agent-id>/`),
/// already computed (and created) by `LaunchAgent` and passed as the `cwd`
/// argument of [`prepare_invocation`](AgentRuntime::prepare_invocation). The
/// recognised placeholder is `{agentRunDir}` (ARCHITECTURE §14.1); the legacy
/// `{projectRoot}` is still substituted with the same base for backwards
/// compatibility (the caller always passes the run dir now). An empty template
/// defaults to the base itself.
fn resolve_cwd(
profile: &AgentProfile,
base: &ProjectPath,
) -> Result<ProjectPath, RuntimeError> {
let template = profile.cwd_template.trim();
if template.is_empty() {
return Ok(base.clone());
}
let resolved = template
.replace("{agentRunDir}", base.as_str())
.replace("{projectRoot}", base.as_str());
ProjectPath::new(resolved).map_err(|e| RuntimeError::Invocation(e.to_string()))
}
/// Builds the [`ContextInjectionPlan`] for a given [`ContextInjection`] and
/// the prepared context. **Pure** — the testable heart of L5.
///
/// | `ContextInjection` | `ContextInjectionPlan` |
/// |---------------------------|----------------------------------------------------------|
/// | `ConventionFile { target }` | `File { target }` — caller writes the `.md` there |
/// | `Flag { flag }` | `Args { args }` — `{path}` substituted with the ctx path |
/// | `Stdin` | `Stdin` — content piped on stdin |
/// | `Env { var }` | `Env { var }` — content/path delivered via env var |
///
/// For `Flag`, the flag template may contain `{path}` (replaced by the
/// context's relative path). A flag *without* `{path}` is treated as a
/// standalone switch followed by the path as a separate argument
/// (e.g. `-f` → `["-f", "<path>"]`); a flag *with* `{path}` is split on
/// whitespace after substitution (e.g. `--context-file {path}` →
/// `["--context-file", "<path>"]`).
fn injection_plan(injection: &ContextInjection, ctx: &PreparedContext) -> ContextInjectionPlan {
match injection {
ContextInjection::ConventionFile { target } => ContextInjectionPlan::File {
target: target.clone(),
},
ContextInjection::Flag { flag } => {
let path = &ctx.relative_path;
let args = if flag.contains("{path}") {
flag.replace("{path}", path)
.split_whitespace()
.map(str::to_owned)
.collect()
} else {
vec![flag.clone(), path.clone()]
};
ContextInjectionPlan::Args { args }
}
ContextInjection::Stdin => ContextInjectionPlan::Stdin,
ContextInjection::Env { var } => ContextInjectionPlan::Env { var: var.clone() },
}
}
/// Composes the session resume/assign arguments for a launch. **Pure** — the
/// testable heart of T3, mirroring [`injection_plan`](Self::injection_plan).
///
/// The profile's optional [`SessionStrategy`] crossed with the per-launch
/// [`SessionPlan`] yields the args to append (exhaustive truth table):
///
/// | `profile.session` | `SessionPlan` | Args added |
/// |----------------------------------------|----------------|------------|
/// | `None` | any | `[]` |
/// | `Some{assign_flag: Some(f), ..}` | `Assign{id}` | `[f, id]` |
/// | `Some{resume_flag: r, ..}` | `Resume{id}` | `[r, id]` |
/// | `Some{assign_flag: None, resume_flag: r}` | `Resume{id}` | `[r]` (degraded) |
/// | `Some{..}` | `None` | `[]` |
/// | `Some{assign_flag: None, ..}` | `Assign{id}` | `[]` (no flag) |
fn session_args(session: Option<&SessionStrategy>, plan: &SessionPlan) -> Vec<String> {
let Some(strategy) = session else {
return Vec::new();
};
match plan {
SessionPlan::None => Vec::new(),
SessionPlan::Assign { conversation_id } => match &strategy.assign_flag {
Some(flag) => vec![flag.clone(), conversation_id.clone()],
None => Vec::new(),
},
SessionPlan::Resume { conversation_id } => {
let mut args = vec![strategy.resume_flag.clone()];
if strategy.assign_flag.is_some() {
args.push(conversation_id.clone());
}
args
}
}
}
}
#[async_trait]
impl AgentRuntime for CliAgentRuntime {
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
let spec = Self::detection_spec(profile)?;
// The port is synchronous but the spawner is async; block on it. The
// adapter is invoked off the Tauri async runtime (detection is a
// short-lived probe), so a transient runtime here is acceptable and keeps
// the `AgentRuntime` port plain-`fn` as the domain declares it.
let spawner = Arc::clone(&self.spawner);
let output = futures_block_on(async move { spawner.run(spec).await })
.map_err(|e| RuntimeError::Detection(e.to_string()))?;
Ok(output.status.code == Some(0))
}
fn prepare_invocation(
&self,
profile: &AgentProfile,
ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
) -> Result<SpawnSpec, RuntimeError> {
let resolved_cwd = Self::resolve_cwd(profile, cwd)?;
let plan = Self::injection_plan(&profile.context_injection, ctx);
// For the `Flag` strategy the context path travels *on the command line*,
// so fold those args into the spec's args (after the profile's static
// args). The other strategies leave args untouched; the plan tells the
// launcher (L6) what else to do (write file / pipe stdin / set env).
let mut args = profile.args.clone();
if let ContextInjectionPlan::Args { args: extra } = &plan {
args.extend(extra.iter().cloned());
}
// Session resume/assign args come *after* the static + context-injection
// args, so re-opening a conversation never disturbs context delivery.
args.extend(Self::session_args(profile.session.as_ref(), session));
Ok(SpawnSpec {
command: profile.command.clone(),
args,
cwd: resolved_cwd,
env: Vec::new(),
context_plan: Some(plan),
sandbox: None,
})
}
}
/// Minimal block-on helper that drives a future to completion on a fresh
/// current-thread tokio runtime. Used only by [`CliAgentRuntime::detect`], whose
/// port signature is synchronous while the [`ProcessSpawner`] it calls is async.
fn futures_block_on<F: std::future::Future>(fut: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build a current-thread runtime for detection")
.block_on(fut)
}