From 92af59a71fccae2a9bd6a69944cf8381c373ef97 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 19 Aug 2026 22:57:45 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20v1.0=20=E2=80=94=20profils=20sandbox=20?= =?UTF-8?q?par=20agent=20:=20commandes=20autoris=C3=A9es=20(allowlist,=20s?= =?UTF-8?q?tem=20Windows),=20p=C3=A9rim=C3=A8tre=20de=20r=C3=A9pertoires,?= =?UTF-8?q?=20politique=20r=C3=A9seau=20best-effort,=20refus=20journalis?= =?UTF-8?q?=C3=A9s=20pour=20l'audit,=20--no-sandbox=20pour=20contourner=20?= =?UTF-8?q?(closes=20#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ROADMAP.md | 2 +- config.yaml | 9 ++ man/am-restart.1 | 7 +- man/am-run.1 | 5 +- man/am-start.1 | 7 +- src/agent_config.rs | 1 + src/cli.rs | 10 +- src/commands/mod.rs | 3 +- src/commands/run_cmd.rs | 48 +++++++- src/commands/suggest_cmd.rs | 1 + src/config.rs | 29 ++++- src/events.rs | 3 + src/help.rs | 9 +- src/lib.rs | 1 + src/repl.rs | 2 + src/sandbox.rs | 204 ++++++++++++++++++++++++++++++++++ tests/instrumentation_test.rs | 4 +- 17 files changed, 326 insertions(+), 19 deletions(-) create mode 100644 src/sandbox.rs diff --git a/ROADMAP.md b/ROADMAP.md index 0619f7a..01a571a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -477,7 +477,7 @@ alerte). | [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | ✅ Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M | | [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | ✅ Registre communautaire — am registry (publication + recherche sur Gitea) | L | | [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | ✅ am ask — langage naturel → commande am (fournisseur LLM optionnel) | L | -| [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | Profils sandbox par agent (commandes/répertoires autorisés) | L | +| [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | ✅ Profils sandbox par agent (commandes/répertoires autorisés) | L | | [#80](https://git.dracodev.net/Projets/agent-manager/issues/80) | am serve --token — API HTTP + WebSocket pour piloter à distance | XL | Critère de sortie du jalon : dashboard web complet + API distante. diff --git a/config.yaml b/config.yaml index 138c5fe..621261b 100644 --- a/config.yaml +++ b/config.yaml @@ -64,6 +64,15 @@ settings: # sources: # - https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml # author: bruno + # 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. + # Exemple dans un agent : + # sandbox: + # enabled: true + # commands: [python, git] + # dirs: ["~/workspace"] + # network: false # Registre des providers LLM (#88) : nom -> base_url, modèles, modèle par # défaut. Le provider par défaut est utilisé à l'install/au run quand aucun # n'est donné (issue #90). Les tokens ne vont JAMAIS ici — ils vivent dans diff --git a/man/am-restart.1 b/man/am-restart.1 index f03b2b6..e6708c8 100644 --- a/man/am-restart.1 +++ b/man/am-restart.1 @@ -4,7 +4,7 @@ .SH NAME restart \- Restart an agent: stop, then start with the same options .SH SYNOPSIS -\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR] +\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-no\-sandbox\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR] .SH DESCRIPTION Restart an agent: stop, then start with the same options .SH OPTIONS @@ -31,7 +31,10 @@ Apply an environment profile (env + args, defined in the config) Local model to use for this run (validated against the local runtimes) .TP \fB\-\-provider\fR \fI\fR -Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model +Provider to use for this run (issue #92): resolved from the registry +.TP +\fB\-\-no\-sandbox\fR +Skip the agent\*(Aqs sandbox profile for this run (issue #79) .TP \fB\-\-parallel\fR Start every member of a group simultaneously (issue #57) diff --git a/man/am-run.1 b/man/am-run.1 index eeb5825..8862c56 100644 --- a/man/am-run.1 +++ b/man/am-run.1 @@ -4,7 +4,7 @@ .SH NAME run \- Run the agent command directly with the given arguments (no process management) .SH SYNOPSIS -\fBrun\fR [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR] +\fBrun\fR [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-no\-sandbox\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR] .SH DESCRIPTION Run the agent command directly with the given arguments (no process management) .SH OPTIONS @@ -15,6 +15,9 @@ Local model to use for this run (validated against the local runtimes) \fB\-\-provider\fR \fI\fR Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model .TP +\fB\-\-no\-sandbox\fR +Skip the agent\*(Aqs sandbox profile (issue #79) +.TP \fB\-\-container\fR Run the agent inside a container (issue #58) .TP diff --git a/man/am-start.1 b/man/am-start.1 index c7b8b65..9d63642 100644 --- a/man/am-start.1 +++ b/man/am-start.1 @@ -4,7 +4,7 @@ .SH NAME start \- Start an agent (foreground by default, or detached with \-\-background) .SH SYNOPSIS -\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR] +\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-provider\fR] [\fB\-\-no\-sandbox\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR] .SH DESCRIPTION Start an agent (foreground by default, or detached with \-\-background) .SH OPTIONS @@ -31,7 +31,10 @@ Apply an environment profile (env + args, defined in the config) Local model to use for this run (validated against the local runtimes) .TP \fB\-\-provider\fR \fI\fR -Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model +Provider to use for this run (issue #92): resolved from the registry +.TP +\fB\-\-no\-sandbox\fR +Skip the agent\*(Aqs sandbox profile for this run (issue #79) .TP \fB\-\-parallel\fR Start every member of a group simultaneously (issue #57) diff --git a/src/agent_config.rs b/src/agent_config.rs index 623c686..605a1dd 100644 --- a/src/agent_config.rs +++ b/src/agent_config.rs @@ -417,6 +417,7 @@ mod tests { provider, model: None, config: Some(config), + sandbox: None, tags: vec![], installable: false, note: None, diff --git a/src/cli.rs b/src/cli.rs index 64460df..45ae501 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -512,6 +512,9 @@ pub enum Command { /// Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model #[arg(long, value_name = "PROVIDER")] provider: Option, + /// Skip the agent's sandbox profile (issue #79) + #[arg(long)] + no_sandbox: bool, /// Run the agent inside a container (issue #58) #[arg(long)] container: bool, @@ -583,9 +586,12 @@ pub struct StartArgs { /// Local model to use for this run (validated against the local runtimes) #[arg(long, value_name = "MODEL")] pub model: Option, - /// Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model - #[arg(long, value_name = "PROVIDER")] + /// Provider to use for this run (issue #92): resolved from the registry + #[arg(long)] pub provider: Option, + /// Skip the agent's sandbox profile for this run (issue #79) + #[arg(long)] + pub no_sandbox: bool, /// Start every member of a group simultaneously (issue #57) #[arg(long, action = ArgAction::SetTrue)] pub parallel: bool, diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4611482..ce7d839 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -229,13 +229,14 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { Command::Doctor { fix, watch } => { doctor_cmd::run(app, *fix, *watch) } - Command::Run { agent, model, provider, container, args } => { + Command::Run { agent, model, provider, container, no_sandbox, args } => { run_cmd::run( app, agent, model.as_deref(), provider.as_deref(), *container, + *no_sandbox, args, ) } diff --git a/src/commands/run_cmd.rs b/src/commands/run_cmd.rs index 5139a90..71d34d8 100644 --- a/src/commands/run_cmd.rs +++ b/src/commands/run_cmd.rs @@ -105,7 +105,7 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path> // waiting (the OS spawns them concurrently); the default waits each // health check before the next member. for m in members { - start_one(app, m, &extra_args, &extra_env, opts.notify, true, cwd)?; + start_one(app, m, &extra_args, &extra_env, opts.notify, true, opts.no_sandbox, cwd)?; if !opts.parallel { // Issue #57: block until healthy before the next member. crate::automation::wait_health(&m.name, m.healthcheck.as_ref(), &app.log)?; @@ -114,7 +114,16 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path> return Ok(0); } let agent = require_agent(app, &target)?; - start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, cwd) + start_one( + app, + agent, + &extra_args, + &extra_env, + opts.notify, + opts.background, + opts.no_sandbox, + cwd, + ) } fn start_one( @@ -124,6 +133,7 @@ fn start_one( extra_env: &BTreeMap, notify: bool, background: bool, + no_sandbox: bool, cwd: Option<&std::path::Path>, ) -> Result { let exec = resolve_exec(app, agent, extra_args, extra_env)?; @@ -133,7 +143,10 @@ fn start_one( let current = std::env::current_dir().unwrap_or_default(); crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(¤t)); if background { - let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env, cwd)?; + // Issue #79: sandbox enforcement before spawning. + let mut env = exec.env.clone(); + crate::sandbox::enforce(app, agent, &exec.program, &mut env, no_sandbox)?; + let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &env, cwd)?; if app.dry_run() { return Ok(0); } @@ -323,13 +336,31 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option) - stop_one(app, m, force, timeout)?; } for m in members { - start_one(app, m, &extra_args, &extra_env, opts.notify, true, None)?; + start_one( + app, + m, + &extra_args, + &extra_env, + opts.notify, + true, + opts.no_sandbox, + None, + )?; } return Ok(0); } let agent = require_agent(app, &target)?; stop_one(app, agent, force, timeout)?; - start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, None) + start_one( + app, + agent, + &extra_args, + &extra_env, + opts.notify, + opts.background, + opts.no_sandbox, + None, + ) } /// run: execute the agent command directly, no process management. @@ -339,6 +370,7 @@ pub fn run( model: Option<&str>, provider: Option<&str>, container: bool, + no_sandbox: bool, extra: &[OsString], ) -> Result { let agent = require_agent(app, target)?; @@ -374,7 +406,7 @@ pub fn run( }, } } - let exec = resolve_exec(app, agent, &extra_args, &extra_env)?; + let mut exec = resolve_exec(app, agent, &extra_args, &extra_env)?; if app.dry_run() { app.log.dry(format!( "would run {} {}", @@ -387,6 +419,9 @@ pub fn run( let (prog, prefix) = crate::runner::resolve_program(&exec.program); let mut full_args = prefix; full_args.extend(exec.args.iter().cloned()); + // Issue #79: sandbox enforcement (command allowlist, cwd perimeter, + // network policy) — refused attempts are journalized for audit. + crate::sandbox::enforce(app, agent, &prog, &mut exec.env, no_sandbox)?; let status = Command::new(&prog) .args(&full_args) .envs(&exec.env) @@ -633,6 +668,7 @@ mod tests { files: vec![], provider_default: None, }), + sandbox: None, tags: vec![], installable: false, note: None, diff --git a/src/commands/suggest_cmd.rs b/src/commands/suggest_cmd.rs index 5531ace..4dd470e 100644 --- a/src/commands/suggest_cmd.rs +++ b/src/commands/suggest_cmd.rs @@ -199,6 +199,7 @@ mod tests { provider: None, model: None, config: None, + sandbox: None, tags: tags.iter().map(|s| s.to_string()).collect(), installable: false, note: None, diff --git a/src/config.rs b/src/config.rs index 0362909..463fbc6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -212,6 +212,28 @@ pub struct RegistrySettings { pub author: Option, } +/// Sandbox profile of an agent (issue #79): restrict which commands it may +/// run, which working directories it may use, and whether it may reach the +/// network. Enforcement is best-effort per platform — the launcher checks +/// the resolved command and the process cwd, and a warning is emitted when +/// the enforcement cannot be fully guaranteed. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct SandboxProfile { + /// Sandbox active for this agent (default false). + pub enabled: bool, + /// Allowed command names (basenames). Empty = no command restriction. + #[serde(default)] + pub commands: Vec, + /// Allowed working directories (support ~ and env vars). Empty = no + /// directory restriction. + #[serde(default)] + pub dirs: Vec, + /// Network access (default true). false = best-effort block. + #[serde(default = "default_true")] + pub network: bool, +} + /// One entry of the LLM provider registry (issue #88). #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] @@ -403,9 +425,14 @@ pub struct AgentDef { /// provider's default_model when unset. #[serde(default)] pub model: Option, - /// Post-install provider configuration: env_map + files (issue #91). + /// Post-install provider configuration (issue #91). #[serde(default)] pub config: Option, + /// Sandbox profile (issue #79): allowed commands, working directories + /// and network policy. Disabled by default — the unsandboxed mode + /// stays available and documented. + #[serde(default)] + pub sandbox: Option, #[serde(default)] pub tags: Vec, /// When false, the agent is listed but cannot be installed locally. diff --git a/src/events.rs b/src/events.rs index a28e536..2f22a11 100644 --- a/src/events.rs +++ b/src/events.rs @@ -50,6 +50,8 @@ pub enum EventKind { Lab, /// LLM provider registry change: add, remove, default (issue #88). Provider, + /// Sandbox refusal journalized for audit (issue #79). + Sandbox, } impl EventKind { @@ -76,6 +78,7 @@ impl EventKind { EventKind::Alert => "alert", EventKind::Lab => "lab", EventKind::Provider => "provider", + EventKind::Sandbox => "sandbox", } } } diff --git a/src/help.rs b/src/help.rs index d507d3d..bb0398c 100644 --- a/src/help.rs +++ b/src/help.rs @@ -156,6 +156,12 @@ const START_FLAGS: &[HelpFlag] = &[ value: "", desc: "Run inside the agent's container profile (issue #58)", }, + HelpFlag { + short: "", + long: "--no-sandbox", + value: "", + desc: "Skip the agent's sandbox profile (issue #79)", + }, HelpFlag { short: "", long: "--model", @@ -969,7 +975,8 @@ pub static HELP_SPECS: &[HelpSpec] = &[ search_terms: &["exec", "pass-through", "container", "docker", "podman"], flags: &[ HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Model to use: local runtime first, then the provider registry (issue #71/#92)" }, - HelpFlag { short: "", long: "--provider", value: "PROVIDER", desc: "Provider of the registry to use (base_url + keyring token + model) (issue #92)" }, + HelpFlag { short: "", long: "--provider", value: "PROVIDER", desc: "Provider registry override at launch (issue #92)" }, + HelpFlag { short: "", long: "--no-sandbox", value: "", desc: "Skip the agent's sandbox profile for this run (issue #79)" }, HelpFlag { short: "", long: "--container", value: "", desc: "Run inside the agent's container profile (issue #58)" }, ], subcommands: &[], diff --git a/src/lib.rs b/src/lib.rs index ccc2ed6..ea344f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,7 @@ pub mod process; pub mod registry; pub mod repl; pub mod runner; +pub mod sandbox; pub mod secrets; pub mod sessions; pub mod shell; diff --git a/src/repl.rs b/src/repl.rs index e4a2b26..2a82129 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -1778,6 +1778,7 @@ fn handle_line( profile: opt_value("--profile"), model: opt_value("--model"), provider: opt_value("--provider"), + no_sandbox: flag("--no-sandbox"), ..Default::default() }), "stop" => Command::Stop { @@ -1888,6 +1889,7 @@ fn handle_line( agent, model: opt_value("--model"), provider: opt_value("--provider"), + no_sandbox: flag("--no-sandbox"), container: flag("--container"), args: rest[1..].iter().map(|s| s.as_str().into()).collect(), } diff --git a/src/sandbox.rs b/src/sandbox.rs new file mode 100644 index 0000000..748af36 --- /dev/null +++ b/src/sandbox.rs @@ -0,0 +1,204 @@ +//! 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(); + } +} diff --git a/tests/instrumentation_test.rs b/tests/instrumentation_test.rs index 1f903d4..9e6e0df 100644 --- a/tests/instrumentation_test.rs +++ b/tests/instrumentation_test.rs @@ -45,7 +45,7 @@ agents: #[test] fn run_emits_one_run_event_with_exit_code() { let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT); - let code = run_cmd::run(&app, "echo-agent", None, None, false, &[OsString::from("hello")]).unwrap(); + let code = run_cmd::run(&app, "echo-agent", None, None, false, false, &[OsString::from("hello")]).unwrap(); assert_eq!(code, 0); let evs = events(&app); assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs); @@ -58,7 +58,7 @@ fn run_emits_one_run_event_with_exit_code() { #[test] fn dry_run_writes_no_event() { let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT); - let code = run_cmd::run(&app, "echo-agent", None, None, false, &[]).unwrap(); + let code = run_cmd::run(&app, "echo-agent", None, None, false, false, &[]).unwrap(); assert_eq!(code, 0); assert!(events(&app).is_empty(), "dry-run must not append events"); assert!(events::journal_files(&app.events_dir()).is_empty());