Files
agent-manager/src/sandbox.rs
T
bruno 92af59a71f feat: v1.0 — profils sandbox par agent : commandes autorisées (allowlist, stem Windows), périmètre de répertoires, politique réseau best-effort, refus journalisés pour l'audit, --no-sandbox pour contourner (closes #79)
- src/sandbox.rs : profile_of + enforce (basename/stem allowlist, cwd starts_with périmètre, proxy env si network:false), EventKind::Sandbox
- enforcement au run et au start (start_one, --parallel, restart) ; AgentDef.sandbox + doc config.yaml
- 6 tests : défaut non sandboxé, refus + journalisation, stem .exe, périmètre cwd, env réseau, bypass --no-sandbox
2026-08-19 22:57:45 -04:00

205 lines
7.7 KiB
Rust

//! 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<String, String>,
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();
}
}