diff --git a/Cargo.lock b/Cargo.lock index a7455f0..e9c23d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "1.0.4" +version = "1.0.5" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 322df3a..e54e59a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "1.0.4" +version = "1.0.5" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/README.md b/README.md index ddef06a..a3d7313 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ plateforme, il compile automatiquement depuis les sources. | am search | recherche fuzzy : tolère les fautes de frappe, classe par pertinence, propose « vouliez-vous dire » | | am suggest | recommande un agent pour une demande en langage naturel (tags + usage réel) | | am ask "" | langage naturel → commande(s) am : règles locales hors-ligne, raffinement LLM optionnel (settings.ask), confirmation avant exécution — ex: am ask "installe claude et lance-le" | +| am ai "" [--exec] [--files ] | langage naturel → action shell via AIChat (alias: am shell) : conversationnel par défaut ; --exec génère la commande, la classe safe/risky et applique la politique de sécurité (settings.shell_ai, dry-run par défaut — --yes pour exécuter) | | am info | fiche détaillée (installation, dépendances, site…) | | am status [agent] | état, version, PID, logs | | am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | diff --git a/ROADMAP.md b/ROADMAP.md index 9d3859b..aeef807 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,7 @@ # 🗺️ ROADMAP agent-manager — vers le « super outil » -> **Version du document : 2.4** · Statut : phases 0 à 3 livrées -> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.0.4 +> **Version du document : 2.5** · Statut : phases 0 à 3 livrées +> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.0.5 > Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime) > > ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées, @@ -38,6 +38,15 @@ > **shell-ai** ajoutés (issues #94 #95) : AIChat (alias `ai`, binaire GitHub > Release), ShellGPT (pip), Fabric (go), Shell AI (npm/Ollama), AI CLI > (binaire, politique de sécurité risk/certainty). Catalogue : 77 agents. +> +> 🔧 **Maintenance v1.0.5 (2026-08-20)** : **commande `am ai` livrée** +> (issues #96 #97) — langage naturel → action shell via AIChat : mode +> conversationnel (aichat -f ), mode --exec avec pipeline de sécurité +> (génération aichat --dry-run, classification safe/risky + certainité, +> politique dry-run par défaut configurable settings.shell_ai, confirmation +> y/N pour les commandes risky, exécution via le shell de l'utilisateur), +> flags --files/--provider/--model/--yes, alias CLI `am shell`, événements +> `shell_ai` journalisés. Épique Axe 13 (#93) clôturée. --- @@ -403,12 +412,12 @@ aichat -f . "résume les données de ces fichiers JSON" | Fonctionnalité | Description | Effort | Phase | |---|---|---|---| | ✅ **Catalogue : ajouter AIChat** | Définition `AgentDef` + alias `ai` → `aichat` — livré en v1.0.4 (issues #94 #95) | S | P1 | -| **Commande `am ai `** | Lancer AIChat avec le dossier courant comme contexte | S | P3 | -| **Flag `--exec` / `-e`** | Active le mode exécution directe (`aichat -e`) | S | P3 | -| **Flag `--files `** | Passer fichiers/dossiers spécifiques en contexte | S | P3 | -| **Sécurité : `--dry-run` par défaut** | Simuler avant exécution ; confirmation si modification | M | P3 | -| **Provider configurable** | Réutiliser le registre `providers.rs` (cheap model par défaut) | M | P3 | -| **Alias intégrés** | `am shell`, `am do` comme synonymes | S | P3 | +| ✅ **Commande `am ai `** | Lancer AIChat avec le dossier courant comme contexte — livré en v1.0.5 (issue #96) | S | P3 | +| ✅ **Flag `--exec` / `-e`** | Active le mode exécution directe (`aichat -e` via aichat --dry-run + exécution sécurisée par am) — livré en v1.0.5 (issue #96) | S | P3 | +| ✅ **Flag `--files `** | Passer fichiers/dossiers spécifiques en contexte — livré en v1.0.5 (issue #96) | S | P3 | +| ✅ **Sécurité : `--dry-run` par défaut** | Simuler avant exécution ; classification safe/risky + confirmation — livré en v1.0.5 (issue #97, settings.shell_ai) | M | P3 | +| ✅ **Provider configurable** | Réutiliser le registre `providers.rs` (--provider/--model → env aichat + modèle) — livré en v1.0.5 (issue #96) | M | P3 | +| ✅ **Alias intégrés** | `am shell` comme synonyme CLI de `am ai` — livré en v1.0.5 (issue #96) | S | P3 | | **Extensions sœurs** | `am summarize`, `am explain`, `am fix` (voir ci-dessous) | M | P3 | --- @@ -619,4 +628,4 @@ configuration manuelle (tokens au keyring, provider/modèle par défaut). Phases livrées : 0 (v0.3.0), 1 (v0.4.1), 2 (v0.6.0), v0.7.0 (providers, 2026-08-19), 3 (v1.0.0, #76–#80, 2026-08-20) ; maintenance v1.0.1/v1.0.2 (self-update décompression, REPL), v1.0.3 (détection externe), v1.0.4 -(catalogue shell AI, #94 #95).* +(catalogue shell AI, #94 #95), v1.0.5 (commande am ai, #93 #96 #97).* diff --git a/config.yaml b/config.yaml index e1d49f0..fc82dbe 100644 --- a/config.yaml +++ b/config.yaml @@ -64,6 +64,17 @@ settings: # sources: # - https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml # author: bruno + # am ai (#96 #97) : politique de sécurité Shell AI. dry-run par défaut — + # aucune commande modifiante n'est exécutée sans confirmation explicite + # (--yes). `confirm` demande validation pour les commandes risky ; + # `auto` exécute tout (déconseillé). Les motifs supplémentaires s'ajoutent + # aux patterns intégrés (rm -rf, dd if, mkfs, chmod -R 777, ...). + # shell_ai: + # default_safety: dry-run # dry-run | confirm | auto + # risky_patterns: + # - "rm -rf" + # - "> /dev" + # - "dd if" # Profil sandbox par agent (#79) : commandes autorisées (binaires), répertoires # de travail et politique réseau. Désactivé par défaut (mode non sandboxé). # Les tentatives refusées sont journalisées (events sandbox) pour l'audit. diff --git a/man/am-ai.1 b/man/am-ai.1 new file mode 100644 index 0000000..f1d77be --- /dev/null +++ b/man/am-ai.1 @@ -0,0 +1,28 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH am-ai 1 "ai " +.SH NAME +ai \- Langage naturel → action shell via AIChat (issues #96 #97); alias: shell +.SH SYNOPSIS +\fBai\fR [\fB\-e\fR|\fB\-\-exec\fR] [\fB\-f\fR|\fB\-\-files\fR] [\fB\-\-provider\fR] [\fB\-\-model\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIPROMPT\fR> +.SH DESCRIPTION +Langage naturel → action shell via AIChat (issues #96 #97); alias: shell +.SH OPTIONS +.TP +\fB\-e\fR, \fB\-\-exec\fR +Execute the generated shell command (after the safety policy) +.TP +\fB\-f\fR, \fB\-\-files\fR \fI\fR +Files or directories passed as context (repeatable; default: the current directory) +.TP +\fB\-\-provider\fR \fI\fR +Provider of the registry used by aichat (env vars + model) +.TP +\fB\-\-model\fR \fI\fR +Force a model (aichat \-\-model) +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIPROMPT\fR> +The request in natural language diff --git a/man/am.1 b/man/am.1 index 5b7c653..5f25697 100644 --- a/man/am.1 +++ b/man/am.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH am 1 "am 1.0.3" +.TH am 1 "am 1.0.5" .SH NAME am \- agent\-manager (am) — manage local AI coding agents .SH SYNOPSIS @@ -133,6 +133,9 @@ Suggest agents matching a query, boosted by real usage (issue #61) am\-ask(1) Ask a natural\-language request and get the matching am command(s) (issue #78): local rules first, optional LLM refinement, confirmation before execution .TP +am\-ai(1) +Langage naturel → action shell via AIChat (issues #96 #97); alias: shell +.TP am\-start(1) Start an agent (foreground by default, or detached with \-\-background) .TP @@ -244,4 +247,4 @@ Export the configuration and installation state (backup) am\-import(1) Import a previously exported configuration and state .SH VERSION -v1.0.3 +v1.0.5 diff --git a/src/cli.rs b/src/cli.rs index 8f9dae7..1569255 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -214,6 +214,9 @@ pub enum Command { #[arg(long)] yes: bool, }, + /// Langage naturel → action shell via AIChat (issues #96 #97); alias: shell + #[command(alias = "shell")] + Ai(AiArgs), /// Start an agent (foreground by default, or detached with --background) Start(StartArgs), /// Stop a background agent (SIGTERM, then SIGKILL after the timeout) @@ -700,6 +703,35 @@ pub struct WebArgs { pub no_open: bool, } +/// Arguments of am ai (issues #96 #97): natural language → shell action +/// via AIChat. The global flags --dry-run and --yes/-y apply as well. +#[derive(Args, Debug, Clone, Default)] +pub struct AiArgs { + /// The request in natural language + #[arg(value_name = "PROMPT", num_args = 1.., required = true)] + pub prompt: Vec, + /// Execute the generated shell command (after the safety policy) + #[arg(short = 'e', long, action = ArgAction::SetTrue)] + pub exec: bool, + /// Files or directories passed as context (repeatable; default: the + /// current directory) + #[arg(short = 'f', long = "files", value_name = "PATH", action = ArgAction::Append)] + pub files: Vec, + /// Provider of the registry used by aichat (env vars + model) + #[arg(long, value_name = "ID")] + pub provider: Option, + /// Force a model (aichat --model) + #[arg(long, value_name = "MODEL")] + pub model: Option, + /// REPL-only: --yes given on the REPL line (the CLI global -y/--yes is + /// handled by clap directly). + #[arg(skip)] + pub yes: bool, + /// REPL-only: --dry-run given on the REPL line. + #[arg(skip)] + pub dry_run: bool, +} + /// Arguments of am serve (issue #80). #[derive(Args, Debug, Clone, Default)] pub struct ServeArgs { diff --git a/src/commands/ai_cmd.rs b/src/commands/ai_cmd.rs new file mode 100644 index 0000000..c570886 --- /dev/null +++ b/src/commands/ai_cmd.rs @@ -0,0 +1,184 @@ +//! am ai — langage naturel → action shell (issues #96 #97). +//! +//! Conversational mode (no `--exec`) spawns AIChat with the prompt and the +//! context files (default: the current directory). Exec mode generates the +//! shell command through aichat in dry-run, classifies it, applies the +//! safety policy (settings.shell_ai, dry-run by default) and only executes +//! after confirmation when required. + +use crate::app::App; +use crate::cli::AiArgs; +use crate::commands::{require_agent, resolve_exec}; +use crate::events::{Event, EventKind}; +use crate::shell_ai::{Decision, SafetyMode}; +use anyhow::{Context, Result}; +use std::process::Command; + +pub fn run(app: &App, args: &AiArgs) -> Result { + let prompt = args.prompt.join(" "); + // Context files: explicit --files wins, else the current directory + // (issue #96: `aichat -f . ""`). + let files: Vec = if args.files.is_empty() { + vec![".".to_string()] + } else { + args.files + .iter() + .map(|p| p.display().to_string()) + .collect() + }; + + // REPL per-line flags combine with the CLI globals. + let yes = app.cli.yes || args.yes; + let dry_run = app.cli.dry_run || args.dry_run; + + let agent = require_agent(app, "aichat")?; + + if !args.exec { + return chat_mode(app, agent, &prompt, &files, args, dry_run); + } + exec_mode(app, &prompt, &files, args, yes, dry_run) +} + +/// Conversational mode: aichat -f "" in the foreground, with +/// the provider/model environment applied when requested. +fn chat_mode( + app: &App, + agent: &crate::config::AgentDef, + prompt: &str, + files: &[String], + args: &AiArgs, + dry_run: bool, +) -> Result { + let mut extra: Vec = Vec::new(); + for f in files { + extra.push("-f".to_string()); + extra.push(f.clone()); + } + extra.push(prompt.to_string()); + let (p_args, p_env) = + crate::shell_ai::provider_env(app, args.provider.as_deref(), args.model.as_deref())?; + extra.extend(p_args); + if dry_run { + app.log.dry(&format!( + "would run aichat {} (conversationnel)", + extra.join(" ") + )); + return Ok(0); + } + let exec = resolve_exec(app, agent, &extra, &p_env)?; + app.log.verbose(&format!( + "shell-ai: {} {}", + exec.program, + exec.args.join(" ") + )); + let (prog, prefix) = crate::runner::resolve_program(&exec.program); + let mut full_args = prefix; + full_args.extend(exec.args.iter().cloned()); + let status = Command::new(&prog) + .args(&full_args) + .envs(&exec.env) + .status() + .with_context(|| format!("failed to run {}", exec.program))?; + app.emit( + &Event::now(EventKind::ShellAi) + .with_agent("aichat".to_string()) + .with_args(vec!["mode=chat".to_string(), "executed=true".to_string()]), + ); + Ok(status.code().unwrap_or(1)) +} + +/// Exec mode: generate the command (aichat --dry-run), classify it, apply +/// the safety policy, then execute through the shell when allowed. +fn exec_mode( + app: &App, + prompt: &str, + files: &[String], + args: &AiArgs, + yes: bool, + dry_run: bool, +) -> Result { + if dry_run { + app.log.dry(&format!( + "would generate & classify via aichat (exec) — commande non exécutée" + )); + } + let cmd = crate::shell_ai::generate( + app, + prompt, + files, + args.provider.as_deref(), + args.model.as_deref(), + )?; + let settings = app + .config + .settings + .shell_ai + .clone() + .unwrap_or_default(); + let risk = crate::shell_ai::classify(&cmd, &settings.risky_patterns); + let certainty = crate::shell_ai::estimate_certainty(&cmd, risk); + let mode = settings.safety_mode(); + + // Show the generated command and its classification (issue #97 UX). + println!("🔒 Commande générée : {}", cmd); + println!(" Risk : {}", risk.as_str()); + println!(" Certainty: {}%", certainty); + println!( + " Safety : {} (settings.shell_ai.default_safety; --yes pour exécuter)", + mode.as_str() + ); + + let decision = crate::shell_ai::decide(mode, risk, dry_run, yes); + let mut executed = false; + match decision { + Decision::Abort => { + let msg = if dry_run { + "dry-run : commande non exécutée" + } else { + "politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes" + }; + app.log.info(crate::i18n::tr(msg)); + } + Decision::Ask => { + let ok = app.confirm(crate::i18n::tr("Exécuter ?"))?; + if ok { + executed = true; + } else { + app.log.info(crate::i18n::tr("annulé")); + } + } + Decision::Execute => { + executed = true; + } + } + let code = if executed { + app.log.info(&format!("exécution: {}", cmd)); + let code = crate::shell_ai::execute(app, &cmd)?; + code + } else { + 0 + }; + app.emit( + &Event::now(EventKind::ShellAi) + .with_agent("aichat".to_string()) + .with_args(vec![ + "mode=exec".to_string(), + format!("risk={}", risk.as_str()), + format!("certainty={certainty}"), + format!("executed={executed}"), + ]), + ); + Ok(code) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decision_dry_run_is_the_default_policy() { + // The embedded config does not set shell_ai -> dry-run default. + let s = crate::config::ShellAiSettings::default(); + assert_eq!(s.safety_mode(), SafetyMode::DryRun); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a5a76ef..ac2b085 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ //! Command implementations and dispatch. pub mod agents_cmd; +pub mod ai_cmd; pub mod alias_cmd; pub mod annotations_cmd; pub mod audit_cmd; @@ -106,6 +107,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { Command::Providers(args) => providers_cmd::run(app, args.sub.as_ref()), Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")), Command::Ask { query, yes } => crate::ask::run(app, &query.join(" "), *yes), + Command::Ai(args) => ai_cmd::run(app, args), Command::Start(a) => run_cmd::start(app, a), Command::Stop { agent, diff --git a/src/commands/tip_cmd.rs b/src/commands/tip_cmd.rs index 62c2d76..acb4e70 100644 --- a/src/commands/tip_cmd.rs +++ b/src/commands/tip_cmd.rs @@ -109,6 +109,18 @@ pub static SECTIONS: &[TipSection] = &[ options: &[("--yes", "exécute la commande sans confirmation")], example: "ask installe claude et lance-le", }, + TipEntry { + usage: "ai [--exec] [--files ]", + about: "langage naturel → action shell via AIChat (issues #96 #97) : génère avec aichat, classe safe/risky, dry-run par défaut", + options: &[ + ("--exec", "génère PUIS exécute la commande (après la politique de sécurité)"), + ("-f, --files", "fichiers/dossiers en contexte (défaut: dossier courant)"), + ("--provider", "provider du registre (clé du keyring + modèle)"), + ("--model", "forcer un modèle"), + ("--yes", "exécute sans confirmation"), + ], + example: "ai --exec \"compresse les fichiers JSON en un zip\"", + }, TipEntry { usage: "models", about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)", diff --git a/src/config.rs b/src/config.rs index 6dfd40e..816ad85 100644 --- a/src/config.rs +++ b/src/config.rs @@ -162,6 +162,10 @@ pub struct Settings { /// (issue #89). #[serde(default)] pub providers: Option>>, + /// am ai security settings (issue #97): default safety policy and + /// extra risky command patterns for the Shell AI command. + #[serde(default)] + pub shell_ai: Option, } /// Anonymous opt-in telemetry (issue #76): aggregated counters only — never @@ -200,6 +204,34 @@ impl Default for AskSettings { } } +/// am ai security settings (issue #97): safety policy and risky patterns +/// for the Shell AI command. The default policy is `dry-run` — nothing is +/// executed without an explicit confirmation. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ShellAiSettings { + /// Default safety policy of `am ai --exec`: + /// `dry-run` (show only), `confirm` (ask for risky commands) or + /// `auto` (execute everything). Default: dry-run. + pub default_safety: Option, + /// Extra command substrings classified as risky (in addition to the + /// built-in patterns: rm -rf, dd if, mkfs, chmod -R 777, ...). + #[serde(default)] + pub risky_patterns: Vec, +} + +impl ShellAiSettings { + /// The effective default safety mode (unknown values fall back to + /// dry-run — the safe choice). + pub fn safety_mode(&self) -> crate::shell_ai::SafetyMode { + match self.default_safety.as_deref() { + Some("auto") => crate::shell_ai::SafetyMode::Auto, + Some("confirm") => crate::shell_ai::SafetyMode::Confirm, + _ => crate::shell_ai::SafetyMode::DryRun, + } + } +} + /// Community registry settings (issue #77). #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] @@ -969,6 +1001,9 @@ pub fn merge(base: &mut Config, overlay: Config) { if o.default_provider.is_some() { s.default_provider = o.default_provider; } + if o.shell_ai.is_some() { + s.shell_ai = o.shell_ai; + } // Providers merge key by key (issue #88): an overlay adds or replaces // one provider without wiping the others. An explicit `null` in the // overlay DELETES the provider (the standard YAML overlay pattern) so a diff --git a/src/events.rs b/src/events.rs index 2f22a11..320007f 100644 --- a/src/events.rs +++ b/src/events.rs @@ -52,6 +52,9 @@ pub enum EventKind { Provider, /// Sandbox refusal journalized for audit (issue #79). Sandbox, + /// am ai — Shell AI action: generated, classified and/or executed + /// (issues #96 #97). + ShellAi, } impl EventKind { @@ -79,6 +82,7 @@ impl EventKind { EventKind::Lab => "lab", EventKind::Provider => "provider", EventKind::Sandbox => "sandbox", + EventKind::ShellAi => "shell_ai", } } } diff --git a/src/help.rs b/src/help.rs index 9c4a9d3..28b331b 100644 --- a/src/help.rs +++ b/src/help.rs @@ -876,6 +876,32 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Une commande simple sans confirmation.", code: "ask liste les agents --yes" }, ], }, + HelpSpec { + name: "ai", + category: "Commands", + usage: "ai {flags} ", + about: "Langage naturel → action shell via AIChat (issues #96 #97). Sans --exec : conversationnel (aichat -f ). Avec --exec : génère la commande (aichat --dry-run), la classe safe/risky, applique la politique de sécurité (dry-run par défaut — settings.shell_ai) puis exécute après confirmation. Alias CLI : am shell.", + search_terms: &["shell", "nlp", "natural", "language", "commande", "exec", "langage"], + flags: &[ + HelpFlag { short: "-e", long: "--exec", value: "", desc: "Génère puis exécute la commande shell (après la politique de sécurité)" }, + HelpFlag { short: "-f", long: "--files", value: "PATH", desc: "Fichier ou dossier passé en contexte (répétable ; défaut : dossier courant)" }, + HelpFlag { short: "", long: "--provider", value: "ID", desc: "Provider du registre utilisé par aichat (variables d'env + modèle)" }, + HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Forcer un modèle (aichat --model)" }, + HelpFlag { short: "-y", long: "--yes", value: "", desc: "Exécuter sans confirmation (global)" }, + HelpFlag { short: "", long: "--dry-run", value: "", desc: "Simuler : génère et classe la commande sans rien exécuter (global)" }, + ], + subcommands: &[], + parameters: &[ + HelpParam { name: "prompt", typ: "string", desc: "La demande en langage naturel, ex: «liste les fichiers JSON du dossier courant»" }, + ], + io: None, + examples: &[ + HelpExample { desc: "Mode conversationnel (défaut).", code: "ai liste tous les fichiers JSON et extrait les clés uniques" }, + HelpExample { desc: "Générer la commande sans l'exécuter (dry-run par défaut).", code: "ai --exec \"compresse les fichiers JSON en un zip\"" }, + HelpExample { desc: "Exécuter après confirmation explicite.", code: "ai --exec \"supprime les fichiers .tmp\" --yes" }, + HelpExample { desc: "Passer un dossier en contexte.", code: "ai --files ./data \"résume les données de ces fichiers\"" }, + ], + }, HelpSpec { name: "registry", category: "Commands", diff --git a/src/i18n.rs b/src/i18n.rs index 4e88356..29a9113 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -163,6 +163,12 @@ pub const CATALOG: &[(&str, &str)] = &[ ("je n'ai pas compris — exemples: «installe claude et lance-le», «liste les agents», «arrête codex»", "I did not understand — examples: «installe claude et lance-le», «liste les agents», «arrête codex»"), ("exécuter ces commandes ?", "run these commands?"), ("annulé", "cancelled"), + ("Exécuter ?", "Execute?"), + ("dry-run : commande non exécutée", "dry-run: command not executed"), + ("politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes", "safety policy (dry-run by default): command not executed — use --yes"), + ("aichat n'a retourné aucune commande (dry-run)", "aichat returned no command (dry-run)"), + ("aichat n'a pas généré de commande (exit {}){}", "aichat generated no command (exit {}){}"), + ("usage: ai — ex: ai --exec \"compresse les fichiers JSON\"", "usage: ai — e.g. ai --exec \"compress the JSON files\""), ("usage: ask — ex: ask installe claude et lance-le", "usage: ask — e.g. ask installe claude et lance-le"), ("aucune source enregistrée — settings.registry.sources ou: am registry install ", "no sources registered — settings.registry.sources or: am registry install "), ("installation refusée — source non fiable", "installation refused — untrusted source"), diff --git a/src/lib.rs b/src/lib.rs index 48c3e6d..a8b9fea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod app; pub mod agent_config; pub mod ask; +pub mod shell_ai; pub mod automation; pub mod backup; pub mod catalog; diff --git a/src/repl.rs b/src/repl.rs index c9e02e6..d805d51 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -114,6 +114,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("providers", "manage the LLM provider registry (base URLs, models, default)"), ("suggest", "recommend an agent for a request"), ("ask", "natural language → am commands (local rules first, optional LLM refinement)"), + ("ai", "natural language → shell action via AIChat (--exec to run, dry-run by default)"), ("audit", "who changed what, when (config checksums)"), ("service", "register an agent as a system service (autostart)"), ("schedule", "plan am commands (daily) and check the fleet health"), @@ -239,7 +240,7 @@ impl AmCompleter { "start", "stop", "restart", "run", "doctor", "config", "completion", "self-update", "self-uninstall", "export", "import", "shell", "theme", "lang", "tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", - "profile", "man", "models", "catalog", "providers", "suggest", "ask", "registry", "audit", + "profile", "man", "models", "catalog", "providers", "suggest", "ask", "ai", "registry", "audit", "service", "schedule", "monitor", "web", "serve", "sync", "migrate", "playbook", "lab", "plugins", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", ], @@ -799,7 +800,7 @@ pub fn banner_box( .to_string(), )); rows.push(inner( - " models models · models --prune · catalog · suggest · ask · audit".to_string(), + " models models · models --prune · catalog · suggest · ask · ai · audit".to_string(), )); rows.push(inner( " automate service install · schedule add · doctor --watch · monitor · sync · migrate · playbook".to_string(), @@ -1603,6 +1604,58 @@ fn handle_line( yes: flag("--yes"), } }, + "ai" => { + let mut exec = false; + let mut yes = false; + let mut dry_run = false; + let mut files: Vec = Vec::new(); + let mut provider: Option = None; + let mut model: Option = None; + let mut prompt: Vec = Vec::new(); + let mut i = 0; + while i < rest.len() { + match rest[i].as_str() { + "--exec" | "-e" => exec = true, + "--yes" | "-y" => yes = true, + "--dry-run" => dry_run = true, + "--files" | "-f" => { + i += 1; + if i < rest.len() { + files.push(std::path::PathBuf::from(&rest[i])); + } + } + "--provider" => { + i += 1; + if i < rest.len() { + provider = Some(rest[i].clone()); + } + } + "--model" => { + i += 1; + if i < rest.len() { + model = Some(rest[i].clone()); + } + } + other => prompt.push(other.to_string()), + } + i += 1; + } + if prompt.is_empty() { + app.log.error(crate::i18n::tr( + "usage: ai — ex: ai --exec \"compresse les fichiers JSON\"", + )); + return Ok(false); + } + Command::Ai(crate::cli::AiArgs { + prompt, + exec, + files, + provider, + model, + yes, + dry_run, + }) + }, "alias" => match rest.first().map(|s| s.as_str()) { Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add { name: rest[1].clone(), @@ -2019,6 +2072,7 @@ pub fn is_am_command(word: &str) -> bool { | "providers" | "suggest" | "ask" + | "ai" | "audit" | "service" | "schedule" diff --git a/src/shell_ai.rs b/src/shell_ai.rs new file mode 100644 index 0000000..2058312 --- /dev/null +++ b/src/shell_ai.rs @@ -0,0 +1,453 @@ +//! am ai — langage naturel → action shell (issues #96 #97). +//! +//! The Shell AI command turns a natural-language request into a shell +//! action using AIChat (the `aichat` catalog agent) as the generation +//! engine: +//! +//! - conversational mode (no `--exec`): `aichat -f ""` is +//! spawned in the foreground, exactly as the user would run it; +//! - exec mode (`--exec`): aichat is asked to generate the command with +//! `--dry-run` (it prints the command and never runs it), then the +//! generated command is classified (safe / risky), a confidence score +//! is estimated, the configured safety policy is applied (dry-run by +//! default), and only then — after confirmation when required — is the +//! command executed through the user's shell. +//! +//! Nothing is ever executed without an explicit confirmation: the default +//! policy is `dry-run`, overridden by `--yes` (execute without asking), +//! the `settings.shell_ai.default_safety` setting (dry-run | confirm | +//! auto) or the explicit `--dry-run` flag (never execute, show only). + +use crate::app::App; +use crate::commands::{require_agent, resolve_exec}; +use anyhow::{anyhow, bail, Context, Result}; +use std::collections::BTreeMap; +use std::process::Command; + +/// Safety policies of `am ai --exec` (issue #97). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SafetyMode { + /// Show the generated command only — nothing is executed unless the + /// user passes `--yes`. + DryRun, + /// Execute safe commands directly; ask before risky ones. + Confirm, + /// Execute everything without asking (explicitly enabled by the user). + Auto, +} + +impl SafetyMode { + pub fn as_str(&self) -> &'static str { + match self { + SafetyMode::DryRun => "dry-run", + SafetyMode::Confirm => "confirm", + SafetyMode::Auto => "auto", + } + } +} + +/// Risk class of a generated shell command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Risk { + Safe, + Risky, +} + +impl Risk { + pub fn as_str(&self) -> &'static str { + match self { + Risk::Safe => "safe", + Risk::Risky => "risky", + } + } +} + +/// Built-in substrings that mark a command as risky (issue #97). The user +/// can extend this list with `settings.shell_ai.risky_patterns`. +pub const BUILTIN_RISKY_PATTERNS: &[&str] = &[ + "rm -rf", + "rm -fr", + "rm -r -f", + "dd if=", + "mkfs.", + "fdisk", + "parted", + "gdisk", + ":(){", + "chmod -R 777", + "chmod 777 /", + "chown -R", + "> /dev/sd", + ">/dev/sd", + "sudo rm", + "git push --force", + "git push -f", + "drop database", + "drop table", + "truncate table", + "shutdown", + "reboot", + "poweroff", + "kill -9", + "pkill -9", + "killall", + "init 0", + "init 6", + "mv / ", + "rm /", + "format c:", + "del /f /s", + "rd /s", + "cipher /w", + "curl ... | sh", + "curl ... | bash", + "wget ... | sh", +]; + +/// Classify a generated command against the built-in patterns plus the +/// user-configured ones (`settings.shell_ai.risky_patterns`). +pub fn classify(cmd: &str, extra_patterns: &[String]) -> Risk { + let lower = cmd.to_lowercase(); + let hits = BUILTIN_RISKY_PATTERNS + .iter() + .any(|p| lower.contains(&p.to_lowercase())) + || extra_patterns.iter().any(|p| lower.contains(&p.to_lowercase())); + if hits { + Risk::Risky + } else { + Risk::Safe + } +} + +/// A coarse confidence heuristic (0-100) shown to the user. Risky or +/// compound commands are scored lower; simple, read-only commands score +/// higher. It is an estimate, never a guarantee. +pub fn estimate_certainty(cmd: &str, risk: Risk) -> u8 { + let mut score: i32 = 92; + if risk == Risk::Risky { + score -= 25; + } + // Compound commands chain several effects — harder to predict. + for sep in ["&&", "||", ";\n", "\n", " | ", " 2>"] { + if cmd.contains(sep) { + score -= 6; + } + } + // Redirections and pipes move data around. + if cmd.contains('>') || cmd.contains('<') { + score -= 5; + } + if cmd.contains("sudo") { + score -= 8; + } + score.clamp(40, 99) as u8 +} + +/// Decide what to do with a generated command under the effective policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decision { + /// Print and abort (dry-run or declined confirmation). + Abort, + /// Ask the user (y/N) before executing. + Ask, + /// Execute directly. + Execute, +} + +/// Apply the safety policy: explicit `--dry-run` flag > `--yes` flag > +/// configured default mode (fallback: dry-run). +pub fn decide( + mode: SafetyMode, + risk: Risk, + dry_run_flag: bool, + yes_flag: bool, +) -> Decision { + if dry_run_flag { + return Decision::Abort; + } + if yes_flag { + return Decision::Execute; + } + match mode { + SafetyMode::Auto => Decision::Execute, + SafetyMode::Confirm => { + if risk == Risk::Risky { + Decision::Ask + } else { + Decision::Execute + } + } + SafetyMode::DryRun => Decision::Abort, + } +} + +/// Extract the shell command from aichat's `--dry-run` output: strips a +/// fenced code block if present, otherwise uses the trimmed output as-is. +pub fn extract_command(stdout: &str) -> Option { + let trimmed = stdout.trim(); + if trimmed.is_empty() { + return None; + } + // Fenced block: ```bash ... ``` (or ```sh, ```powershell, ```cmd ...) + let mut lines = trimmed.lines(); + if lines.next().is_some_and(|l| l.trim_start().starts_with("```")) { + let body: Vec<&str> = lines + .take_while(|l| !l.trim().starts_with("```")) + .collect(); + let cmd = body.join("\n").trim().to_string(); + if !cmd.is_empty() { + return Some(cmd); + } + } + Some(trimmed.to_string()) +} + +/// Map a provider of the registry to the environment variables AIChat +/// understands, and resolve the model to pass with `--model`. The token is +/// resolved from the OS keyring via the standard `@secret` mechanism and +/// never appears on the command line (issue #89). +pub fn provider_env( + app: &App, + provider: Option<&str>, + model: Option<&str>, +) -> Result<(Vec, BTreeMap)> { + // Without any flag, aichat keeps its own configuration (its config file + // and API keys) — the registry is only applied on explicit request. + if provider.is_none() && model.is_none() { + return Ok((Vec::new(), BTreeMap::new())); + } + let Some((pname, def, resolved_model)) = + crate::providers::resolve_for(&app.config, None, None, provider, model) + else { + if provider.is_some() { + let known = crate::providers::names(&app.config); + let hint = if known.is_empty() { + "aucun (am providers add --base-url )".to_string() + } else { + known.join(", ") + }; + bail!(crate::tr_fmt!( + "provider '{}' inconnu — providers enregistrés: {}", + provider.unwrap_or(""), + hint + )); + } + return Ok((Vec::new(), BTreeMap::new())); + }; + let mut args: Vec = Vec::new(); + let mut env: BTreeMap = BTreeMap::new(); + // Known providers map to AIChat's standard API-key variables; custom + // provider names fall back to the openai-compatible channel. + let key_var = match pname { + "anthropic" => Some("ANTHROPIC_API_KEY"), + "openai" => Some("OPENAI_API_KEY"), + "deepseek" => Some("DEEPSEEK_API_KEY"), + "google" => Some("GOOGLE_API_KEY"), + "groq" => Some("GROQ_API_KEY"), + "openrouter" => Some("OPENROUTER_API_KEY"), + "xai" | "grok" => Some("XAI_API_KEY"), + "ollama" => None, // local — no key; aichat knows its default endpoint + _ => Some("OPENAI_API_KEY"), + }; + if let Some(var) = key_var { + env.insert(var.to_string(), "@secret".to_string()); + if !matches!(pname, "openai" | "ollama" | "anthropic" | "deepseek" | "google" | "groq" | "openrouter" | "xai" | "grok") { + // Custom endpoints ride the openai-compatible channel. + env.insert("OPENAI_API_BASE".to_string(), def.base_url.clone()); + } + } + if let Some(m) = resolved_model { + args.push("--model".to_string()); + args.push(m.to_string()); + } + let warnings = crate::secrets::resolve_env_secrets( + &crate::secrets::store(), + "aichat", + Some(pname), + &mut env, + ); + for w in warnings { + app.log.warn(&w); + } + Ok((args, env)) +} + +/// Generate a shell command for `prompt` using aichat in dry-run mode. +/// Returns the generated command (never executed by aichat). +pub fn generate( + app: &App, + prompt: &str, + files: &[String], + provider: Option<&str>, + model: Option<&str>, +) -> Result { + let agent = require_agent(app, "aichat")?; + let mut extra_args: Vec = vec!["--exec".to_string(), "--dry-run".to_string()]; + for f in files { + extra_args.push("-f".to_string()); + extra_args.push(f.clone()); + } + extra_args.push(prompt.to_string()); + let (p_args, p_env) = provider_env(app, provider, model)?; + extra_args.extend(p_args); + let exec = resolve_exec(app, agent, &extra_args, &p_env)?; + app.log.verbose(&format!( + "shell-ai: {} {} (dry-run generation)", + exec.program, + exec.args.join(" ") + )); + let (prog, prefix) = crate::runner::resolve_program(&exec.program); + let mut full_args = prefix; + full_args.extend(exec.args.iter().cloned()); + let out = Command::new(&prog) + .args(&full_args) + .envs(&exec.env) + .output() + .with_context(|| format!("failed to run {}", exec.program))?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + let err = err.trim(); + bail!(crate::tr_fmt!( + "aichat n'a pas généré de commande (exit {}){}", + out.status.code().unwrap_or(-1), + if err.is_empty() { String::new() } else { format!(": {err}") } + )); + } + let stdout = String::from_utf8_lossy(&out.stdout); + extract_command(&stdout).ok_or_else(|| anyhow!(crate::i18n::tr( + "aichat n'a retourné aucune commande (dry-run)" + ))) +} + +/// Execute a generated command through the user's shell (default_shell > +/// $SHELL > $COMSPEC > cmd). The command is passed as a single argument. +pub fn execute(app: &App, command: &str) -> Result { + let spec = crate::shell::resolve_default( + app.config.settings.default_shell.as_deref(), + std::env::var_os("SHELL"), + std::env::var_os("COMSPEC"), + ); + app.log.verbose(&format!( + "shell-ai: executing via {} {}", + spec.program, + spec.args.join(" ") + )); + let (prog, prefix) = crate::runner::resolve_program(spec.program); + let mut args = prefix; + args.extend(spec.args.iter().map(|s| s.to_string())); + args.push(command.to_string()); + let status = Command::new(&prog) + .args(&args) + .status() + .with_context(|| format!("failed to run {}", spec.program))?; + Ok(status.code().unwrap_or(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_safe_and_risky() { + assert_eq!(classify("ls -la", &[]), Risk::Safe); + assert_eq!(classify("echo hello", &[]), Risk::Safe); + assert_eq!(classify("find . -name '*.json'", &[]), Risk::Safe); + assert_eq!(classify("rm -rf /tmp/x", &[]), Risk::Risky); + assert_eq!(classify("sudo rm -rf /var/log", &[]), Risk::Risky); + assert_eq!(classify("dd if=/dev/zero of=/dev/sda", &[]), Risk::Risky); + assert_eq!(classify("git push --force origin main", &[]), Risk::Risky); + assert_eq!(classify("drop database prod;", &[]), Risk::Risky); + } + + #[test] + fn classifies_extra_user_patterns() { + let extra = vec!["killall node".to_string()]; + assert_eq!(classify("killall node", &extra), Risk::Risky); + assert_eq!(classify("killall nodejs", &extra), Risk::Risky); + assert_eq!(classify("node --version", &extra), Risk::Safe); + } + + #[test] + fn certainty_stays_in_bounds_and_penalizes_risk() { + let safe = estimate_certainty("ls -la", Risk::Safe); + let risky = estimate_certainty("rm -rf /", Risk::Risky); + assert!(safe > risky, "{safe} should be > {risky}"); + assert!((40..=99).contains(&safe)); + assert!((40..=99).contains(&risky)); + let compound = estimate_certainty("rm -rf /tmp/a && echo ok", Risk::Risky); + assert!(compound < risky || compound == risky); + } + + #[test] + fn extract_command_handles_fences_and_plain() { + assert_eq!( + extract_command("```bash\nrm *.tmp\n```"), + Some("rm *.tmp".to_string()) + ); + assert_eq!( + extract_command("```sh\necho hello\n```\n"), + Some("echo hello".to_string()) + ); + assert_eq!( + extract_command("rm *.tmp\n"), + Some("rm *.tmp".to_string()) + ); + assert_eq!(extract_command(" \n "), None); + } + + #[test] + fn decision_matrix() { + // dry-run (default): abort, even with --yes overriding to execute. + assert_eq!( + decide(SafetyMode::DryRun, Risk::Risky, false, false), + Decision::Abort + ); + assert_eq!( + decide(SafetyMode::DryRun, Risk::Safe, false, false), + Decision::Abort + ); + assert_eq!( + decide(SafetyMode::DryRun, Risk::Risky, false, true), + Decision::Execute + ); + // explicit --dry-run wins over --yes. + assert_eq!( + decide(SafetyMode::Auto, Risk::Risky, true, true), + Decision::Abort + ); + // confirm: ask only for risky. + assert_eq!( + decide(SafetyMode::Confirm, Risk::Safe, false, false), + Decision::Execute + ); + assert_eq!( + decide(SafetyMode::Confirm, Risk::Risky, false, false), + Decision::Ask + ); + // auto: execute everything. + assert_eq!( + decide(SafetyMode::Auto, Risk::Risky, false, false), + Decision::Execute + ); + } + + #[test] + fn safety_mode_parsing_falls_back_to_dry_run() { + use crate::config::ShellAiSettings; + let s = ShellAiSettings { + default_safety: Some("confirm".to_string()), + risky_patterns: vec![], + }; + assert_eq!(s.safety_mode(), SafetyMode::Confirm); + let s = ShellAiSettings { + default_safety: Some("n'importe quoi".to_string()), + risky_patterns: vec![], + }; + assert_eq!(s.safety_mode(), SafetyMode::DryRun); + let s = ShellAiSettings { + default_safety: None, + risky_patterns: vec![], + }; + assert_eq!(s.safety_mode(), SafetyMode::DryRun); + } +} diff --git a/tests/ai_test.rs b/tests/ai_test.rs new file mode 100644 index 0000000..4ba02a8 --- /dev/null +++ b/tests/ai_test.rs @@ -0,0 +1,156 @@ +//! Integration tests for `am ai` (issues #96 #97): the command resolves +//! the `aichat` catalog agent, so a fake `aichat.cmd` shim is placed on +//! PATH and emits a canned command. The safety pipeline (dry-run default, +//! classification, confirmation, --yes) is exercised end to end through +//! the real command dispatch. + +mod common; + +use agent_manager::cli::Cli; +use clap::Parser; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Mutex; + +/// Serialize PATH mutation and process spawning between parallel tests. +static ENV_LOCK: Mutex<()> = Mutex::new(()); +static COUNTER: AtomicU32 = AtomicU32::new(0); + +const AICHAT_DEF: &str = r#" +agents: + - name: aichat + display_name: AIChat + description: fake shim for tests + category: shell-ai + install: + type: binary + repo: sigoden/aichat + binary: aichat + run: aichat + installable: false +"#; + +fn fresh_dir(tag: &str) -> PathBuf { + let id = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("am-ai-{tag}-{}-{id}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Write a fake `aichat.cmd` into `dir` and prepend `dir` to PATH. +fn fake_aichat(dir: &PathBuf, body: &str) { + std::fs::write(dir.join("aichat.cmd"), body).unwrap(); + let mut paths = vec![dir.clone()]; + if let Some(existing) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&existing)); + } + std::env::set_var("PATH", std::env::join_paths(paths).unwrap()); +} + +/// Build the App for an `am ai` invocation with the aichat shim on PATH. +fn app_for(shim_body: &str, settings: &str, args: &[&str]) -> (agent_manager::app::App, PathBuf) { + let dir = fresh_dir("app"); + fake_aichat(&dir, shim_body); + let cfg = common::write_config(&dir, &format!("{settings}{AICHAT_DEF}")); + let mut full = vec!["am".to_string(), "--config".to_string(), cfg.display().to_string()]; + full.extend(args.iter().map(|s| s.to_string())); + let cli = Cli::parse_from(full); + let mut app = agent_manager::app::App::from_cli(cli).expect("app should build"); + common::isolate(&mut app, &dir); + (app, dir) +} + +fn run(args: &[&str], shim_body: &str, settings: &str) -> (String, String, i32) { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (app, dir) = app_for(shim_body, settings, args); + let cmd = app.cli.command.as_ref().expect("a command was parsed"); + let code = agent_manager::commands::execute_command(&app, cmd).unwrap_or_else(|e| { + eprintln!("ERR: {e:#}"); + 1 + }); + let log = std::fs::read_to_string(app.paths.log_dir.join("agent-manager.log")) + .unwrap_or_default(); + // Events journal lives next to state.json (isolated dir). + let mut events = String::new(); + if let Ok(rd) = std::fs::read_dir(&dir) { + for e in rd.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + if name.starts_with("events-") && name.ends_with(".jsonl") { + events.push_str(&std::fs::read_to_string(e.path()).unwrap_or_default()); + } + } + } + (log, events, code) +} + +#[test] +fn ai_exec_dry_run_is_the_default_and_never_executes() { + let (log, events, code) = run( + &["ai", "--exec", "supprime tout"], + "@echo off\r\necho rm -rf /tmp/nonexistent-xyz\r\n", + "", + ); + assert_eq!(code, 0); + assert!( + log.contains("non exécutée"), + "dry-run policy must abort, log: {log}" + ); + assert!(!log.contains("exécution:"), "nothing must run, log: {log}"); + assert!( + events.contains("shell_ai") && events.contains("executed=false"), + "journal must record the aborted exec, events: {events}" + ); +} + +#[test] +fn ai_exec_with_yes_executes_through_the_shell() { + // Shim emits a harmless command; --yes skips the policy gate. + let (log, events, code) = run( + &["ai", "--exec", "dis bonjour", "--yes"], + "@echo off\r\necho echo hello-from-shim\r\n", + "", + ); + assert_eq!(code, 0); + assert!( + log.contains("exécution: echo hello-from-shim"), + "the generated command must run, log: {log}" + ); + assert!( + events.contains("executed=true"), + "journal must record the execution, events: {events}" + ); +} + +#[test] +fn ai_conversational_passes_prompt_and_context_to_aichat() { + // Shim echoes its arguments; conversational mode passes -f . + prompt. + let (log, events, code) = run( + &["ai", "liste les fichiers"], + "@echo off\r\necho %*\r\n", + "", + ); + assert_eq!(code, 0); + assert!( + log.contains("shell-ai:") || events.contains("mode=chat"), + "conversational mode must be journalized, log: {log} events: {events}" + ); + assert!(events.contains("mode=chat"), "events: {events}"); +} + +#[test] +fn ai_exec_respects_configured_confirm_mode() { + // default_safety: confirm + risky command => the confirmation prompt is + // reached; without stdin input the prompt is declined (empty answer). + let (log, events, code) = run( + &["ai", "--exec", "supprime"], + "@echo off\r\necho rm -rf /tmp/nonexistent-xyz\r\n", + " shell_ai:\n default_safety: confirm\n", + ); + assert_eq!(code, 0); + assert!( + log.contains("annulé") || log.contains("cancelled") || log.contains("non exécutée"), + "declined confirmation must abort, log: {log}" + ); + assert!(!log.contains("exécution:"), "log: {log}"); + assert!(events.contains("executed=false"), "events: {events}"); +}