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:
+1
-1
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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(¤t));
|
crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(¤t));
|
||||||
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,
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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: &[],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user