//! Sandbox profiles (issue #79): restrict which commands an agent may run, //! which working directories it may use, and its network policy. //! //! Enforcement is best-effort per platform: the launcher checks the resolved //! command against the allowlist and the process cwd against the allowed //! directories BEFORE spawning; network blocking uses proxy environment //! variables (a warning is emitted where it cannot be guaranteed). Every //! refused attempt is journalized (EventKind::Sandbox) for audit. use crate::app::App; use crate::config::{AgentDef, SandboxProfile}; use anyhow::{anyhow, Result}; use std::collections::BTreeMap; use std::path::Path; /// Active profile of an agent (None = unsandboxed, the documented default). pub fn profile_of(agent: &AgentDef) -> Option<&SandboxProfile> { agent.sandbox.as_ref().filter(|p| p.enabled) } /// Check the resolved command and the process cwd against the profile, and /// apply the network policy (mutating the environment). Returns an error /// (and emits a Sandbox event) when the launch is refused. pub fn enforce( app: &App, agent: &AgentDef, prog: &str, env: &mut BTreeMap, skip: bool, ) -> Result<()> { let Some(profile) = profile_of(agent) else { return Ok(()); }; if skip { app.log.warn(&format!( "sandbox de '{}' ignorée (--no-sandbox)", agent.name )); return Ok(()); } // 1. Command allowlist (basename of the resolved program). let bin = Path::new(prog) .file_name() .map(|b| b.to_string_lossy().to_string()) .unwrap_or_else(|| prog.to_string()); // Windows: "python.exe" and "python" both match the allowlist entry. let bin_stem = Path::new(&bin) .file_stem() .map(|b| b.to_string_lossy().to_string()) .unwrap_or_else(|| bin.clone()); let allowed = |c: &String| c == &bin || c == &bin_stem; if !profile.commands.is_empty() && !profile.commands.iter().any(allowed) { let denied = format!("command:{bin}"); app.emit( &crate::events::Event::now(crate::events::EventKind::Sandbox) .with_agent(agent.name.clone()) .with_args(vec![denied]), ); return Err(anyhow!( "commande '{}' hors profil sandbox de '{}' — autorisées: {}", bin, agent.name, profile.commands.join(", ") )); } // 2. Working-directory perimeter. if !profile.dirs.is_empty() { let cwd = std::env::current_dir().unwrap_or_default(); let allowed = profile .dirs .iter() .any(|d| cwd.starts_with(crate::config::expand_path(d))); if !allowed { let denied = format!("cwd:{}", cwd.display()); app.emit( &crate::events::Event::now(crate::events::EventKind::Sandbox) .with_agent(agent.name.clone()) .with_args(vec![denied]), ); return Err(anyhow!( "répertoire '{}' hors périmètre sandbox de '{}' — autorisés: {}", cwd.display(), agent.name, profile.dirs.join(", ") )); } } // 3. Network policy: best-effort block through proxy env vars. if !profile.network { env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:1".to_string()); env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:1".to_string()); env.insert("ALL_PROXY".to_string(), "http://127.0.0.1:1".to_string()); app.log.warn(&format!( "réseau bloqué pour '{}' (best-effort via proxy) — non garanti sur cette plateforme", agent.name )); } Ok(()) } #[cfg(test)] mod tests { use super::*; use clap::Parser; fn test_app(sandbox_yaml: &str) -> App { let guard = tempfile::tempdir().unwrap(); let dir = guard.path().to_path_buf(); std::mem::forget(guard); let cfg = dir.join("config.yaml"); std::fs::write( &cfg, format!( "version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents:\n - name: demo\n installable: false\n run: demo\n {sandbox_yaml}\n" ), ) .unwrap(); let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); crate::app::App::from_cli(cli).unwrap() } fn agent(app: &App) -> &AgentDef { app.catalog.resolve("demo").unwrap() } #[test] fn no_profile_means_unsandboxed() { let app = test_app(""); assert!(profile_of(agent(&app)).is_none()); let mut env = BTreeMap::new(); // No profile: everything allowed, nothing refused. enforce(&app, agent(&app), "anything.exe", &mut env, false).unwrap(); assert!(env.is_empty()); } #[test] fn disallowed_command_is_refused_and_journalized() { let app = test_app( "sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n", ); let mut env = BTreeMap::new(); let err = enforce(&app, agent(&app), "C:/tools/powershell.exe", &mut env, false) .unwrap_err(); assert!(err.to_string().contains("hors profil"), "{err}"); // The refusal is journalized for audit. let dir = app.events_dir(); let journaled = std::fs::read_dir(&dir) .map(|rd| { rd.flatten().any(|e| { let p = e.path(); p.extension().map(|x| x == "jsonl").unwrap_or(false) && std::fs::read_to_string(&p) .map(|t| t.contains("sandbox")) .unwrap_or(false) }) }) .unwrap_or(false); assert!(journaled, "refusal must be journalized"); } #[test] fn allowed_command_and_cwd_pass() { let cwd = std::env::current_dir().unwrap(); let app = test_app(&format!( "sandbox:\n enabled: true\n commands: [demo.exe]\n dirs: [\"{}\"]\n network: true\n", cwd.display().to_string().replace('\\', "\\\\") )); let mut env = BTreeMap::new(); enforce(&app, agent(&app), "demo.exe", &mut env, false).unwrap(); assert!(env.is_empty()); } #[test] fn cwd_outside_perimeter_is_blocked() { let app = test_app( "sandbox:\n enabled: true\n commands: []\n dirs: [\"C:/definitely/not/here\"]\n network: true\n", ); let mut env = BTreeMap::new(); let err = enforce(&app, agent(&app), "demo.exe", &mut env, false).unwrap_err(); assert!(err.to_string().contains("hors périmètre"), "{err}"); } #[test] fn network_off_injects_blocking_proxy_env() { let app = test_app( "sandbox:\n enabled: true\n commands: []\n dirs: []\n network: false\n", ); let mut env = BTreeMap::new(); enforce(&app, agent(&app), "demo.exe", &mut env, false).unwrap(); assert_eq!(env.get("HTTP_PROXY").map(String::as_str), Some("http://127.0.0.1:1")); assert!(env.contains_key("HTTPS_PROXY")); assert!(env.contains_key("ALL_PROXY")); } #[test] fn no_sandbox_flag_bypasses_the_profile() { let app = test_app( "sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n", ); let mut env = BTreeMap::new(); // --no-sandbox: the disallowed command passes (with a warning). enforce(&app, agent(&app), "powershell.exe", &mut env, true).unwrap(); } }