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
This commit is contained in:
2026-08-19 22:57:45 -04:00
parent 900f70c249
commit 92af59a71f
17 changed files with 326 additions and 19 deletions
+1 -1
View File
@@ -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 | | [#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 | | [#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 | | [#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 | | [#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. Critère de sortie du jalon : dashboard web complet + API distante.
+9
View File
@@ -64,6 +64,15 @@ settings:
# sources: # sources:
# - https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml # - https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml
# author: bruno # 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 # 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 # 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 # n'est donné (issue #90). Les tokens ne vont JAMAIS ici — ils vivent dans
+5 -2
View File
@@ -4,7 +4,7 @@
.SH NAME .SH NAME
restart \- Restart an agent: stop, then start with the same options restart \- Restart an agent: stop, then start with the same options
.SH SYNOPSIS .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 .SH DESCRIPTION
Restart an agent: stop, then start with the same options Restart an agent: stop, then start with the same options
.SH 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) Local model to use for this run (validated against the local runtimes)
.TP .TP
\fB\-\-provider\fR \fI<PROVIDER>\fR \fB\-\-provider\fR \fI<PROVIDER>\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 .TP
\fB\-\-parallel\fR \fB\-\-parallel\fR
Start every member of a group simultaneously (issue #57) Start every member of a group simultaneously (issue #57)
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME .SH NAME
run \- Run the agent command directly with the given arguments (no process management) run \- Run the agent command directly with the given arguments (no process management)
.SH SYNOPSIS .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 .SH DESCRIPTION
Run the agent command directly with the given arguments (no process management) Run the agent command directly with the given arguments (no process management)
.SH OPTIONS .SH OPTIONS
@@ -15,6 +15,9 @@ Local model to use for this run (validated against the local runtimes)
\fB\-\-provider\fR \fI<PROVIDER>\fR \fB\-\-provider\fR \fI<PROVIDER>\fR
Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model
.TP .TP
\fB\-\-no\-sandbox\fR
Skip the agent\*(Aqs sandbox profile (issue #79)
.TP
\fB\-\-container\fR \fB\-\-container\fR
Run the agent inside a container (issue #58) Run the agent inside a container (issue #58)
.TP .TP
+5 -2
View File
@@ -4,7 +4,7 @@
.SH NAME .SH NAME
start \- Start an agent (foreground by default, or detached with \-\-background) start \- Start an agent (foreground by default, or detached with \-\-background)
.SH SYNOPSIS .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 .SH DESCRIPTION
Start an agent (foreground by default, or detached with \-\-background) Start an agent (foreground by default, or detached with \-\-background)
.SH 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) Local model to use for this run (validated against the local runtimes)
.TP .TP
\fB\-\-provider\fR \fI<PROVIDER>\fR \fB\-\-provider\fR \fI<PROVIDER>\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 .TP
\fB\-\-parallel\fR \fB\-\-parallel\fR
Start every member of a group simultaneously (issue #57) Start every member of a group simultaneously (issue #57)
+1
View File
@@ -417,6 +417,7 @@ mod tests {
provider, provider,
model: None, model: None,
config: Some(config), config: Some(config),
sandbox: None,
tags: vec![], tags: vec![],
installable: false, installable: false,
note: None, note: None,
+8 -2
View File
@@ -512,6 +512,9 @@ pub enum Command {
/// Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model /// Provider to use for this run (issue #92): registry lookup, base_url + keyring token + model
#[arg(long, value_name = "PROVIDER")] #[arg(long, value_name = "PROVIDER")]
provider: Option<String>, provider: Option<String>,
/// Skip the agent's sandbox profile (issue #79)
#[arg(long)]
no_sandbox: bool,
/// Run the agent inside a container (issue #58) /// Run the agent inside a container (issue #58)
#[arg(long)] #[arg(long)]
container: bool, container: bool,
@@ -583,9 +586,12 @@ pub struct StartArgs {
/// Local model to use for this run (validated against the local runtimes) /// Local model to use for this run (validated against the local runtimes)
#[arg(long, value_name = "MODEL")] #[arg(long, value_name = "MODEL")]
pub model: Option<String>, pub model: Option<String>,
/// 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
#[arg(long, value_name = "PROVIDER")] #[arg(long)]
pub provider: Option<String>, pub provider: Option<String>,
/// 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) /// Start every member of a group simultaneously (issue #57)
#[arg(long, action = ArgAction::SetTrue)] #[arg(long, action = ArgAction::SetTrue)]
pub parallel: bool, pub parallel: bool,
+2 -1
View File
@@ -229,13 +229,14 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Doctor { fix, watch } => { Command::Doctor { fix, watch } => {
doctor_cmd::run(app, *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( run_cmd::run(
app, app,
agent, agent,
model.as_deref(), model.as_deref(),
provider.as_deref(), provider.as_deref(),
*container, *container,
*no_sandbox,
args, args,
) )
} }
+42 -6
View File
@@ -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 // waiting (the OS spawns them concurrently); the default waits each
// health check before the next member. // health check before the next member.
for m in members { 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 { if !opts.parallel {
// Issue #57: block until healthy before the next member. // Issue #57: block until healthy before the next member.
crate::automation::wait_health(&m.name, m.healthcheck.as_ref(), &app.log)?; 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); return Ok(0);
} }
let agent = require_agent(app, &target)?; 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( fn start_one(
@@ -124,6 +133,7 @@ fn start_one(
extra_env: &BTreeMap<String, String>, extra_env: &BTreeMap<String, String>,
notify: bool, notify: bool,
background: bool, background: bool,
no_sandbox: bool,
cwd: Option<&std::path::Path>, cwd: Option<&std::path::Path>,
) -> Result<i32> { ) -> Result<i32> {
let exec = resolve_exec(app, agent, extra_args, extra_env)?; 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(); let current = std::env::current_dir().unwrap_or_default();
crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(&current)); crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(&current));
if background { 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() { if app.dry_run() {
return Ok(0); return Ok(0);
} }
@@ -323,13 +336,31 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
stop_one(app, m, force, timeout)?; stop_one(app, m, force, timeout)?;
} }
for m in members { 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); return Ok(0);
} }
let agent = require_agent(app, &target)?; let agent = require_agent(app, &target)?;
stop_one(app, agent, force, timeout)?; 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. /// run: execute the agent command directly, no process management.
@@ -339,6 +370,7 @@ pub fn run(
model: Option<&str>, model: Option<&str>,
provider: Option<&str>, provider: Option<&str>,
container: bool, container: bool,
no_sandbox: bool,
extra: &[OsString], extra: &[OsString],
) -> Result<i32> { ) -> Result<i32> {
let agent = require_agent(app, target)?; 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() { if app.dry_run() {
app.log.dry(format!( app.log.dry(format!(
"would run {} {}", "would run {} {}",
@@ -387,6 +419,9 @@ pub fn run(
let (prog, prefix) = crate::runner::resolve_program(&exec.program); let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix; let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned()); 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) let status = Command::new(&prog)
.args(&full_args) .args(&full_args)
.envs(&exec.env) .envs(&exec.env)
@@ -633,6 +668,7 @@ mod tests {
files: vec![], files: vec![],
provider_default: None, provider_default: None,
}), }),
sandbox: None,
tags: vec![], tags: vec![],
installable: false, installable: false,
note: None, note: None,
+1
View File
@@ -199,6 +199,7 @@ mod tests {
provider: None, provider: None,
model: None, model: None,
config: None, config: None,
sandbox: None,
tags: tags.iter().map(|s| s.to_string()).collect(), tags: tags.iter().map(|s| s.to_string()).collect(),
installable: false, installable: false,
note: None, note: None,
+28 -1
View File
@@ -212,6 +212,28 @@ pub struct RegistrySettings {
pub author: Option<String>, pub author: Option<String>,
} }
/// 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<String>,
/// Allowed working directories (support ~ and env vars). Empty = no
/// directory restriction.
#[serde(default)]
pub dirs: Vec<String>,
/// Network access (default true). false = best-effort block.
#[serde(default = "default_true")]
pub network: bool,
}
/// One entry of the LLM provider registry (issue #88). /// One entry of the LLM provider registry (issue #88).
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -403,9 +425,14 @@ pub struct AgentDef {
/// provider's default_model when unset. /// provider's default_model when unset.
#[serde(default)] #[serde(default)]
pub model: Option<String>, pub model: Option<String>,
/// Post-install provider configuration: env_map + files (issue #91). /// Post-install provider configuration (issue #91).
#[serde(default)] #[serde(default)]
pub config: Option<AgentConfig>, pub config: Option<AgentConfig>,
/// 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<SandboxProfile>,
#[serde(default)] #[serde(default)]
pub tags: Vec<String>, pub tags: Vec<String>,
/// When false, the agent is listed but cannot be installed locally. /// When false, the agent is listed but cannot be installed locally.
+3
View File
@@ -50,6 +50,8 @@ pub enum EventKind {
Lab, Lab,
/// LLM provider registry change: add, remove, default (issue #88). /// LLM provider registry change: add, remove, default (issue #88).
Provider, Provider,
/// Sandbox refusal journalized for audit (issue #79).
Sandbox,
} }
impl EventKind { impl EventKind {
@@ -76,6 +78,7 @@ impl EventKind {
EventKind::Alert => "alert", EventKind::Alert => "alert",
EventKind::Lab => "lab", EventKind::Lab => "lab",
EventKind::Provider => "provider", EventKind::Provider => "provider",
EventKind::Sandbox => "sandbox",
} }
} }
} }
+8 -1
View File
@@ -156,6 +156,12 @@ const START_FLAGS: &[HelpFlag] = &[
value: "", value: "",
desc: "Run inside the agent's container profile (issue #58)", 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 { HelpFlag {
short: "", short: "",
long: "--model", long: "--model",
@@ -969,7 +975,8 @@ pub static HELP_SPECS: &[HelpSpec] = &[
search_terms: &["exec", "pass-through", "container", "docker", "podman"], search_terms: &["exec", "pass-through", "container", "docker", "podman"],
flags: &[ flags: &[
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Model to use: local runtime first, then the provider registry (issue #71/#92)" }, 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)" }, HelpFlag { short: "", long: "--container", value: "", desc: "Run inside the agent's container profile (issue #58)" },
], ],
subcommands: &[], subcommands: &[],
+1
View File
@@ -46,6 +46,7 @@ pub mod process;
pub mod registry; pub mod registry;
pub mod repl; pub mod repl;
pub mod runner; pub mod runner;
pub mod sandbox;
pub mod secrets; pub mod secrets;
pub mod sessions; pub mod sessions;
pub mod shell; pub mod shell;
+2
View File
@@ -1778,6 +1778,7 @@ fn handle_line(
profile: opt_value("--profile"), profile: opt_value("--profile"),
model: opt_value("--model"), model: opt_value("--model"),
provider: opt_value("--provider"), provider: opt_value("--provider"),
no_sandbox: flag("--no-sandbox"),
..Default::default() ..Default::default()
}), }),
"stop" => Command::Stop { "stop" => Command::Stop {
@@ -1888,6 +1889,7 @@ fn handle_line(
agent, agent,
model: opt_value("--model"), model: opt_value("--model"),
provider: opt_value("--provider"), provider: opt_value("--provider"),
no_sandbox: flag("--no-sandbox"),
container: flag("--container"), container: flag("--container"),
args: rest[1..].iter().map(|s| s.as_str().into()).collect(), args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
} }
+204
View File
@@ -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<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();
}
}
+2 -2
View File
@@ -45,7 +45,7 @@ agents:
#[test] #[test]
fn run_emits_one_run_event_with_exit_code() { fn run_emits_one_run_event_with_exit_code() {
let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT); 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); assert_eq!(code, 0);
let evs = events(&app); let evs = events(&app);
assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs); 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] #[test]
fn dry_run_writes_no_event() { fn dry_run_writes_no_event() {
let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT); 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_eq!(code, 0);
assert!(events(&app).is_empty(), "dry-run must not append events"); assert!(events(&app).is_empty(), "dry-run must not append events");
assert!(events::journal_files(&app.events_dir()).is_empty()); assert!(events::journal_files(&app.events_dir()).is_empty());