Files
agent-manager/src/shell_ai.rs
T
bruno e637c066fe fix: am ai avec aichat >= 0.30 — rôles index.yaml + agents.txt, contexte filtré, generation --code (v1.1.5)
- roles.rs: aichat 0.30 a remplace les agents markdown agents/<name>.md
  par functions/agents/<name>/index.yaml + registre functions/agents.txt;
  generate_all ecrit les DEUX formats (compat <= 0.29 et >= 0.30)
- ai_cmd.rs: role_args detecte la definition index.yaml en premier
  ('Unknown agent am-copilot' corrige) ; contexte par defaut (dossier
  courant) filtre par am (.git, target, node_modules, binaires non-UTF-8,
  .env*, > 64 KiB, budget 128 KiB, 40 fichiers max) — aichat ne filtre
  rien et echouait sur .git/index ; exec mode utilise le role am-do
  par defaut (commande brute)
- shell_ai.rs: generation via --code au lieu de --execute --dry-run
  (aichat 0.30: --dry-run echo la requete sans appeler l'API)
- version 1.1.5, man pages et ROADMAP maj
2026-08-21 12:22:18 -04:00

473 lines
16 KiB
Rust

//! 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 <ctx> "<prompt>"` is
//! spawned in the foreground, exactly as the user would run it;
//! - exec mode (`--exec`): aichat is asked to generate the command with
//! `--code` (it prints only the command and never runs it — aichat is
//! invoked without `--execute`), 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<String> {
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<String>, BTreeMap<String, String>)> {
// 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 <nom> --base-url <url>)".to_string()
} else {
known.join(", ")
};
bail!(crate::i18n::tr_fmt_in(
app.lang(),
"provider '{}' inconnu — providers enregistrés: {}",
&[&provider.unwrap_or(""), &hint]
));
}
return Ok((Vec::new(), BTreeMap::new()));
};
let mut args: Vec<String> = Vec::new();
let mut env: BTreeMap<String, String> = 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>,
role_args: &[String],
) -> Result<String> {
let agent = require_agent(app, "aichat")?;
// aichat >= 0.30 renamed --exec to --execute AND changed --dry-run to
// skip the API call entirely (it echoes the request), so the old
// `--execute --dry-run` no longer yields the generated command. Plain
// chat with `--code` extracts and prints only the command (same output
// shape, works on every aichat version). aichat NEVER executes here —
// am enforces the safety policy before running the command itself.
let mut extra_args: Vec<String> = vec!["--code".to_string()];
extra_args.extend(role_args.iter().cloned());
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::i18n::tr_fmt_in(
app.lang(),
"aichat n'a pas généré de commande (exit {}){}",
&[
&out.status.code().unwrap_or(-1).to_string(),
&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_in(
app.lang(),
"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<i32> {
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);
}
}