- 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
205 lines
7.7 KiB
Rust
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();
|
|
}
|
|
}
|