Implement config file parsing and precedence with CLI
This commit is contained in:
57
Cargo.lock
generated
57
Cargo.lock
generated
@@ -8,7 +8,9 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"glob",
|
"glob",
|
||||||
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -325,6 +327,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -360,6 +363,15 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -390,6 +402,45 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "1.1.0+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "1.1.0+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.1.0+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
|
||||||
|
dependencies = [
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.0+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -475,6 +526,12 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
glob = "0.3"
|
glob = "0.3"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
60
src/cli.rs
Normal file
60
src/cli.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug, Default)]
|
||||||
|
#[command(
|
||||||
|
name = "agent-sandbox",
|
||||||
|
version,
|
||||||
|
about = "Sandbox agentic coding assistants with bubblewrap"
|
||||||
|
)]
|
||||||
|
pub struct Args {
|
||||||
|
/// Blacklist mode: bind / read-only, overlay sensitive paths (default)
|
||||||
|
#[arg(long, conflicts_with = "whitelist")]
|
||||||
|
pub blacklist: bool,
|
||||||
|
|
||||||
|
/// Whitelist mode: only explicitly listed minimal paths visible
|
||||||
|
#[arg(long)]
|
||||||
|
pub whitelist: bool,
|
||||||
|
|
||||||
|
/// Harden: unshare IPC, PID, UTS; private /tmp, /dev, /run
|
||||||
|
#[arg(long)]
|
||||||
|
pub hardened: bool,
|
||||||
|
|
||||||
|
/// Unshare the network namespace
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_net: bool,
|
||||||
|
|
||||||
|
/// Bind an extra path read-write (repeatable)
|
||||||
|
#[arg(long = "rw", value_name = "PATH", action = clap::ArgAction::Append)]
|
||||||
|
pub extra_rw: Vec<PathBuf>,
|
||||||
|
|
||||||
|
/// Bind an extra path read-only (repeatable)
|
||||||
|
#[arg(long = "ro", value_name = "PATH", action = clap::ArgAction::Append)]
|
||||||
|
pub extra_ro: Vec<PathBuf>,
|
||||||
|
|
||||||
|
/// Print the bwrap command without executing
|
||||||
|
#[arg(long)]
|
||||||
|
pub dry_run: bool,
|
||||||
|
|
||||||
|
/// Working directory inside the sandbox (default: current directory)
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
pub chdir: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Use a named profile from the config file
|
||||||
|
#[arg(long, conflicts_with = "no_config")]
|
||||||
|
pub profile: Option<String>,
|
||||||
|
|
||||||
|
/// Path to config file (default: $XDG_CONFIG_HOME/agent-sandbox/config.toml)
|
||||||
|
#[arg(long = "config", value_name = "PATH")]
|
||||||
|
pub config_path: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Skip loading the config file entirely
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_config: bool,
|
||||||
|
|
||||||
|
/// Command and arguments to run inside the sandbox
|
||||||
|
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||||
|
pub command_and_args: Vec<OsString>,
|
||||||
|
}
|
||||||
544
src/config.rs
Normal file
544
src/config.rs
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::cli::Args;
|
||||||
|
use crate::{SandboxConfig, SandboxError, SandboxMode};
|
||||||
|
|
||||||
|
pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfig, SandboxError> {
|
||||||
|
let (mut globals, mut profile) = match file_config {
|
||||||
|
Some(c) => {
|
||||||
|
let profile = c.resolve_profile(args.profile.as_deref())?;
|
||||||
|
(c.options, profile)
|
||||||
|
}
|
||||||
|
None => (Options::default(), Options::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
validate_mode(&globals)?;
|
||||||
|
validate_mode(&profile)?;
|
||||||
|
globals.validate_paths()?;
|
||||||
|
profile.validate_paths()?;
|
||||||
|
|
||||||
|
let (command, command_args) = resolve_command(
|
||||||
|
args.command_and_args,
|
||||||
|
profile.command.clone(),
|
||||||
|
globals.command.clone(),
|
||||||
|
);
|
||||||
|
let command = resolve_binary(&command)
|
||||||
|
.ok_or_else(|| SandboxError::CommandNotFound(PathBuf::from(&command)))?;
|
||||||
|
|
||||||
|
Ok(SandboxConfig {
|
||||||
|
mode: merge_mode(args.blacklist, args.whitelist, &profile, &globals),
|
||||||
|
hardened: merge_flag(args.hardened, profile.hardened, globals.hardened),
|
||||||
|
no_net: merge_flag(args.no_net, profile.no_net, globals.no_net),
|
||||||
|
dry_run: merge_flag(args.dry_run, profile.dry_run, globals.dry_run),
|
||||||
|
chdir: resolve_chdir(args.chdir, profile.chdir, globals.chdir)?,
|
||||||
|
extra_rw: merge_paths(args.extra_rw, &profile.rw, &globals.rw)?,
|
||||||
|
extra_ro: merge_paths(args.extra_ro, &profile.ro, &globals.ro)?,
|
||||||
|
command,
|
||||||
|
command_args,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_mode(
|
||||||
|
cli_blacklist: bool,
|
||||||
|
cli_whitelist: bool,
|
||||||
|
profile: &Options,
|
||||||
|
globals: &Options,
|
||||||
|
) -> SandboxMode {
|
||||||
|
if cli_whitelist {
|
||||||
|
return SandboxMode::Whitelist;
|
||||||
|
}
|
||||||
|
if cli_blacklist {
|
||||||
|
return SandboxMode::Blacklist;
|
||||||
|
}
|
||||||
|
resolve_mode(profile)
|
||||||
|
.or_else(|| resolve_mode(globals))
|
||||||
|
.unwrap_or(SandboxMode::Blacklist)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_mode(opts: &Options) -> Option<SandboxMode> {
|
||||||
|
match (opts.blacklist, opts.whitelist) {
|
||||||
|
(_, Some(true)) => Some(SandboxMode::Whitelist),
|
||||||
|
(Some(true), _) => Some(SandboxMode::Blacklist),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_flag(cli: bool, profile: Option<bool>, globals: Option<bool>) -> bool {
|
||||||
|
if cli {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
profile.or(globals).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_chdir(
|
||||||
|
cli: Option<PathBuf>,
|
||||||
|
profile: Option<PathBuf>,
|
||||||
|
globals: Option<PathBuf>,
|
||||||
|
) -> Result<PathBuf, SandboxError> {
|
||||||
|
match cli.or(profile).or(globals) {
|
||||||
|
Some(p) => std::fs::canonicalize(&p).map_err(|_| SandboxError::ChdirMissing(p)),
|
||||||
|
None => std::env::current_dir().map_err(SandboxError::CurrentDirUnavailable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_paths(
|
||||||
|
cli: Vec<PathBuf>,
|
||||||
|
profile: &[PathBuf],
|
||||||
|
globals: &[PathBuf],
|
||||||
|
) -> Result<Vec<PathBuf>, SandboxError> {
|
||||||
|
let config_paths = globals.iter().chain(profile).cloned();
|
||||||
|
let cli_paths = cli
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| std::fs::canonicalize(&p).map_err(|_| SandboxError::PathMissing(p)))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(config_paths.chain(cli_paths).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_command(
|
||||||
|
mut positional: Vec<OsString>,
|
||||||
|
profile_cmd: Option<CommandValue>,
|
||||||
|
globals_cmd: Option<CommandValue>,
|
||||||
|
) -> (OsString, Vec<OsString>) {
|
||||||
|
if !positional.is_empty() {
|
||||||
|
let cmd = positional.remove(0);
|
||||||
|
return (cmd, positional);
|
||||||
|
}
|
||||||
|
if let Some(config_cmd) = profile_cmd.or(globals_cmd) {
|
||||||
|
let parts = config_cmd.into_vec();
|
||||||
|
let cmd = OsString::from(&parts[0]);
|
||||||
|
let args = parts[1..].iter().map(OsString::from).collect();
|
||||||
|
return (cmd, args);
|
||||||
|
}
|
||||||
|
if let Ok(cmd) = std::env::var("SANDBOX_CMD") {
|
||||||
|
return (OsString::from(cmd), vec![]);
|
||||||
|
}
|
||||||
|
(
|
||||||
|
OsString::from("claude"),
|
||||||
|
vec![OsString::from("--dangerously-skip-permissions")],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_binary(name: &OsString) -> Option<PathBuf> {
|
||||||
|
let path = PathBuf::from(name);
|
||||||
|
if path.is_absolute() || path.components().count() > 1 {
|
||||||
|
return path.is_file().then_some(path);
|
||||||
|
}
|
||||||
|
std::env::var_os("PATH").and_then(|path_var| {
|
||||||
|
std::env::split_paths(&path_var)
|
||||||
|
.map(|dir| dir.join(name))
|
||||||
|
.find(|p| p.is_file())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub struct FileConfig {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub options: Options,
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile: HashMap<String, Options>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileConfig {
|
||||||
|
pub fn load(path: &Path) -> Result<Self, SandboxError> {
|
||||||
|
let contents = std::fs::read_to_string(path).map_err(|e| SandboxError::ConfigRead {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
|
toml::from_str(&contents).map_err(|e| SandboxError::ConfigParse {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
source: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_profile(&self, name: Option<&str>) -> Result<Options, SandboxError> {
|
||||||
|
match name {
|
||||||
|
Some(n) => self
|
||||||
|
.profile
|
||||||
|
.get(n)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| SandboxError::ProfileNotFound(n.to_string())),
|
||||||
|
None => Ok(Options::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default, Clone)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub struct Options {
|
||||||
|
pub blacklist: Option<bool>,
|
||||||
|
pub whitelist: Option<bool>,
|
||||||
|
pub hardened: Option<bool>,
|
||||||
|
pub no_net: Option<bool>,
|
||||||
|
pub command: Option<CommandValue>,
|
||||||
|
pub dry_run: Option<bool>,
|
||||||
|
pub chdir: Option<PathBuf>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rw: Vec<PathBuf>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ro: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Options {
|
||||||
|
fn validate_paths(&mut self) -> Result<(), SandboxError> {
|
||||||
|
for p in &mut self.rw {
|
||||||
|
*p = expand_and_canonicalize(p)?;
|
||||||
|
}
|
||||||
|
for p in &mut self.ro {
|
||||||
|
*p = expand_and_canonicalize(p)?;
|
||||||
|
}
|
||||||
|
if let Some(ref chdir) = self.chdir {
|
||||||
|
self.chdir = Some(expand_and_canonicalize(chdir)?);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum CommandValue {
|
||||||
|
Simple(String),
|
||||||
|
WithArgs(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandValue {
|
||||||
|
pub fn into_vec(self) -> Vec<String> {
|
||||||
|
match self {
|
||||||
|
Self::Simple(s) => vec![s],
|
||||||
|
Self::WithArgs(v) => v,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_config_path(explicit: Option<&Path>) -> Option<PathBuf> {
|
||||||
|
if let Some(p) = explicit {
|
||||||
|
return Some(p.to_path_buf());
|
||||||
|
}
|
||||||
|
let config_dir = std::env::var("XDG_CONFIG_HOME")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| {
|
||||||
|
std::env::var("HOME")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|h| PathBuf::from(h).join(".config"))
|
||||||
|
})?;
|
||||||
|
let path = config_dir.join("agent-sandbox/config.toml");
|
||||||
|
path.exists().then_some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_mode(opts: &Options) -> Result<(), SandboxError> {
|
||||||
|
if opts.blacklist == Some(true) && opts.whitelist == Some(true) {
|
||||||
|
return Err(SandboxError::ConflictingMode);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_tilde(path: &Path) -> Result<PathBuf, SandboxError> {
|
||||||
|
let s = path.to_string_lossy();
|
||||||
|
if !s.starts_with('~') {
|
||||||
|
return Ok(path.to_path_buf());
|
||||||
|
}
|
||||||
|
if s.len() > 1 && !s.starts_with("~/") {
|
||||||
|
return Err(SandboxError::ConfigPathNotAbsolute(path.to_path_buf()));
|
||||||
|
}
|
||||||
|
let home = std::env::var("HOME")
|
||||||
|
.ok()
|
||||||
|
.filter(|h| !h.is_empty())
|
||||||
|
.ok_or(SandboxError::HomeNotSet)?;
|
||||||
|
Ok(PathBuf::from(home).join(s.strip_prefix("~/").unwrap_or("")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_absolute(path: &Path) -> Result<(), SandboxError> {
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err(SandboxError::ConfigPathNotAbsolute(path.to_path_buf()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_and_canonicalize(path: &Path) -> Result<PathBuf, SandboxError> {
|
||||||
|
let expanded = expand_tilde(path)?;
|
||||||
|
require_absolute(&expanded)?;
|
||||||
|
std::fs::canonicalize(&expanded).map_err(|e| SandboxError::ConfigRead {
|
||||||
|
path: expanded,
|
||||||
|
source: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const FULL_CONFIG_TOML: &str = r#"
|
||||||
|
hardened = true
|
||||||
|
no-net = true
|
||||||
|
rw = ["/tmp/a", "/tmp/b"]
|
||||||
|
command = "zsh"
|
||||||
|
|
||||||
|
[profile.claude]
|
||||||
|
rw = ["/home/user/.config/claude"]
|
||||||
|
ro = ["/etc/claude", "/etc/shared"]
|
||||||
|
command = ["bash", "-c", "echo hi"]
|
||||||
|
|
||||||
|
[profile.codex]
|
||||||
|
whitelist = true
|
||||||
|
dry-run = true
|
||||||
|
chdir = "/home/user/project"
|
||||||
|
rw = ["/home/user/.codex"]
|
||||||
|
"#;
|
||||||
|
|
||||||
|
static CONFIG: LazyLock<FileConfig> =
|
||||||
|
LazyLock::new(|| toml::from_str(FULL_CONFIG_TOML).unwrap());
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn globals_scalars() {
|
||||||
|
assert_eq!(CONFIG.options.hardened, Some(true));
|
||||||
|
assert_eq!(CONFIG.options.no_net, Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn globals_multi_element_vecs() {
|
||||||
|
assert_paths(&CONFIG.options.rw, &["/tmp/a", "/tmp/b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn globals_simple_command() {
|
||||||
|
assert_command(&CONFIG.options.command, &["zsh"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unset_vecs_default_empty() {
|
||||||
|
assert!(CONFIG.options.ro.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unset_scalars_are_none() {
|
||||||
|
assert_eq!(CONFIG.profile["claude"].hardened, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_multi_element_vecs() {
|
||||||
|
assert_paths(
|
||||||
|
&CONFIG.profile["claude"].ro,
|
||||||
|
&["/etc/claude", "/etc/shared"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_command_with_args() {
|
||||||
|
assert_command(
|
||||||
|
&CONFIG.profile["claude"].command,
|
||||||
|
&["bash", "-c", "echo hi"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_kebab_case_scalars() {
|
||||||
|
let codex = &CONFIG.profile["codex"];
|
||||||
|
assert_eq!(codex.whitelist, Some(true));
|
||||||
|
assert_eq!(codex.dry_run, Some(true));
|
||||||
|
assert_eq!(codex.chdir, Some(PathBuf::from("/home/user/project")));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn args_with_command() -> Args {
|
||||||
|
Args {
|
||||||
|
command_and_args: vec!["/usr/bin/true".into()],
|
||||||
|
..Args::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_hardened_from_globals() {
|
||||||
|
let file_config = FileConfig {
|
||||||
|
options: Options {
|
||||||
|
hardened: Some(true),
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
..FileConfig::default()
|
||||||
|
};
|
||||||
|
let config = build(args_with_command(), Some(file_config)).unwrap();
|
||||||
|
assert!(config.hardened);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_profile_overrides_globals() {
|
||||||
|
let file_config = FileConfig {
|
||||||
|
options: Options {
|
||||||
|
hardened: Some(true),
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
profile: HashMap::from([(
|
||||||
|
"relaxed".into(),
|
||||||
|
Options {
|
||||||
|
hardened: Some(false),
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
};
|
||||||
|
let args = Args {
|
||||||
|
profile: Some("relaxed".into()),
|
||||||
|
..args_with_command()
|
||||||
|
};
|
||||||
|
let config = build(args, Some(file_config)).unwrap();
|
||||||
|
assert!(!config.hardened);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_cli_flag_overrides_profile() {
|
||||||
|
let file_config = FileConfig {
|
||||||
|
profile: HashMap::from([(
|
||||||
|
"nonet".into(),
|
||||||
|
Options {
|
||||||
|
no_net: Some(false),
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
..FileConfig::default()
|
||||||
|
};
|
||||||
|
let args = Args {
|
||||||
|
profile: Some("nonet".into()),
|
||||||
|
no_net: true,
|
||||||
|
..args_with_command()
|
||||||
|
};
|
||||||
|
let config = build(args, Some(file_config)).unwrap();
|
||||||
|
assert!(config.no_net);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_cli_mode_overrides_profile() {
|
||||||
|
let file_config = FileConfig {
|
||||||
|
profile: HashMap::from([(
|
||||||
|
"wl".into(),
|
||||||
|
Options {
|
||||||
|
whitelist: Some(true),
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
..FileConfig::default()
|
||||||
|
};
|
||||||
|
let args = Args {
|
||||||
|
profile: Some("wl".into()),
|
||||||
|
blacklist: true,
|
||||||
|
..args_with_command()
|
||||||
|
};
|
||||||
|
let config = build(args, Some(file_config)).unwrap();
|
||||||
|
assert!(matches!(config.mode, SandboxMode::Blacklist));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_rw_paths_accumulate() {
|
||||||
|
let file_config = FileConfig {
|
||||||
|
options: Options {
|
||||||
|
rw: vec![PathBuf::from("/tmp")],
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
profile: HashMap::from([(
|
||||||
|
"extra".into(),
|
||||||
|
Options {
|
||||||
|
rw: vec![PathBuf::from("/usr")],
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
};
|
||||||
|
let args = Args {
|
||||||
|
profile: Some("extra".into()),
|
||||||
|
extra_rw: vec![PathBuf::from("/var")],
|
||||||
|
..args_with_command()
|
||||||
|
};
|
||||||
|
let config = build(args, Some(file_config)).unwrap();
|
||||||
|
assert_eq!(config.extra_rw.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_from_profile() {
|
||||||
|
let file_config = FileConfig {
|
||||||
|
profile: HashMap::from([(
|
||||||
|
"test".into(),
|
||||||
|
Options {
|
||||||
|
command: Some(CommandValue::WithArgs(vec![
|
||||||
|
"/usr/bin/true".into(),
|
||||||
|
"--flag".into(),
|
||||||
|
])),
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
..FileConfig::default()
|
||||||
|
};
|
||||||
|
let args = Args {
|
||||||
|
profile: Some("test".into()),
|
||||||
|
..Args::default()
|
||||||
|
};
|
||||||
|
let config = build(args, Some(file_config)).unwrap();
|
||||||
|
assert_eq!(config.command, PathBuf::from("/usr/bin/true"));
|
||||||
|
assert_eq!(config.command_args, vec![OsString::from("--flag")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_cli_command_overrides_config() {
|
||||||
|
let file_config = FileConfig {
|
||||||
|
options: Options {
|
||||||
|
command: Some(CommandValue::Simple("/usr/bin/false".into())),
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
..FileConfig::default()
|
||||||
|
};
|
||||||
|
let args = Args {
|
||||||
|
command_and_args: vec!["/usr/bin/true".into()],
|
||||||
|
..Args::default()
|
||||||
|
};
|
||||||
|
let config = build(args, Some(file_config)).unwrap();
|
||||||
|
assert_eq!(config.command, PathBuf::from("/usr/bin/true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_no_file_config() {
|
||||||
|
let config = build(args_with_command(), None).unwrap();
|
||||||
|
assert!(matches!(config.mode, SandboxMode::Blacklist));
|
||||||
|
assert!(!config.hardened);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_missing_profile_errors() {
|
||||||
|
let args = Args {
|
||||||
|
profile: Some("nope".into()),
|
||||||
|
..args_with_command()
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
build(args, Some(FileConfig::default())),
|
||||||
|
Err(SandboxError::ProfileNotFound(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_conflicting_mode_errors() {
|
||||||
|
let file_config = FileConfig {
|
||||||
|
options: Options {
|
||||||
|
blacklist: Some(true),
|
||||||
|
whitelist: Some(true),
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
..FileConfig::default()
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
build(args_with_command(), Some(file_config)),
|
||||||
|
Err(SandboxError::ConflictingMode)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_paths(actual: &[PathBuf], expected: &[&str]) {
|
||||||
|
let expected: Vec<PathBuf> = expected.iter().map(PathBuf::from).collect();
|
||||||
|
assert_eq!(actual, &expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_command(cmd: &Option<CommandValue>, expected: &[&str]) {
|
||||||
|
let actual = cmd.clone().unwrap().into_vec();
|
||||||
|
let expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
|
||||||
|
assert_eq!(actual, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,22 @@ pub enum SandboxError {
|
|||||||
BwrapNotFound,
|
BwrapNotFound,
|
||||||
CommandNotFound(PathBuf),
|
CommandNotFound(PathBuf),
|
||||||
CommandNotExecutable(PathBuf),
|
CommandNotExecutable(PathBuf),
|
||||||
RwPathMissing(PathBuf),
|
PathMissing(PathBuf),
|
||||||
RoPathMissing(PathBuf),
|
|
||||||
ChdirMissing(PathBuf),
|
ChdirMissing(PathBuf),
|
||||||
CurrentDirUnavailable(std::io::Error),
|
CurrentDirUnavailable(std::io::Error),
|
||||||
GlobPattern(glob::PatternError),
|
GlobPattern(glob::PatternError),
|
||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
|
ConfigRead {
|
||||||
|
path: PathBuf,
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
ConfigParse {
|
||||||
|
path: PathBuf,
|
||||||
|
source: toml::de::Error,
|
||||||
|
},
|
||||||
|
ProfileNotFound(String),
|
||||||
|
ConflictingMode,
|
||||||
|
ConfigPathNotAbsolute(PathBuf),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for SandboxError {
|
impl std::fmt::Display for SandboxError {
|
||||||
@@ -34,12 +44,25 @@ impl std::fmt::Display for SandboxError {
|
|||||||
Self::CommandNotExecutable(p) => {
|
Self::CommandNotExecutable(p) => {
|
||||||
write!(f, "command is not executable: {}", p.display())
|
write!(f, "command is not executable: {}", p.display())
|
||||||
}
|
}
|
||||||
Self::RwPathMissing(p) => write!(f, "--rw path does not exist: {}", p.display()),
|
Self::PathMissing(p) => write!(f, "path does not exist: {}", p.display()),
|
||||||
Self::RoPathMissing(p) => write!(f, "--ro path does not exist: {}", p.display()),
|
|
||||||
Self::ChdirMissing(p) => write!(f, "--chdir path does not exist: {}", p.display()),
|
Self::ChdirMissing(p) => write!(f, "--chdir path does not exist: {}", p.display()),
|
||||||
Self::CurrentDirUnavailable(e) => write!(f, "cannot determine current directory: {e}"),
|
Self::CurrentDirUnavailable(e) => write!(f, "cannot determine current directory: {e}"),
|
||||||
Self::GlobPattern(e) => write!(f, "invalid glob pattern: {e}"),
|
Self::GlobPattern(e) => write!(f, "invalid glob pattern: {e}"),
|
||||||
Self::Io(e) => write!(f, "I/O error: {e}"),
|
Self::Io(e) => write!(f, "I/O error: {e}"),
|
||||||
|
Self::ConfigRead { path, source } => {
|
||||||
|
write!(f, "cannot read config file '{}': {source}", path.display())
|
||||||
|
}
|
||||||
|
Self::ConfigParse { path, source } => {
|
||||||
|
write!(f, "cannot parse config file '{}': {source}", path.display())
|
||||||
|
}
|
||||||
|
Self::ProfileNotFound(name) => write!(f, "profile not found in config: {name}"),
|
||||||
|
Self::ConflictingMode => write!(
|
||||||
|
f,
|
||||||
|
"config section sets both blacklist and whitelist to true"
|
||||||
|
),
|
||||||
|
Self::ConfigPathNotAbsolute(p) => {
|
||||||
|
write!(f, "config path is not absolute: {}", p.display())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +73,8 @@ impl std::error::Error for SandboxError {
|
|||||||
Self::CurrentDirUnavailable(e) => Some(e),
|
Self::CurrentDirUnavailable(e) => Some(e),
|
||||||
Self::GlobPattern(e) => Some(e),
|
Self::GlobPattern(e) => Some(e),
|
||||||
Self::Io(e) => Some(e),
|
Self::Io(e) => Some(e),
|
||||||
|
Self::ConfigRead { source, .. } => Some(source),
|
||||||
|
Self::ConfigParse { source, .. } => Some(source),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
mod agents;
|
mod agents;
|
||||||
mod blacklist;
|
mod blacklist;
|
||||||
|
pub mod cli;
|
||||||
|
pub mod config;
|
||||||
mod errors;
|
mod errors;
|
||||||
mod preflight;
|
mod preflight;
|
||||||
mod sandbox;
|
mod sandbox;
|
||||||
|
|||||||
142
src/main.rs
142
src/main.rs
@@ -1,146 +1,28 @@
|
|||||||
use std::ffi::{OsStr, OsString};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
use agent_sandbox::{SandboxConfig, SandboxMode};
|
use agent_sandbox::cli::Args;
|
||||||
|
use agent_sandbox::config::{self, FileConfig};
|
||||||
#[derive(Parser, Debug)]
|
|
||||||
#[command(
|
|
||||||
name = "agent-sandbox",
|
|
||||||
version,
|
|
||||||
about = "Sandbox agentic coding assistants with bubblewrap"
|
|
||||||
)]
|
|
||||||
struct Args {
|
|
||||||
/// Blacklist mode: bind / read-only, overlay sensitive paths (default)
|
|
||||||
#[arg(long, conflicts_with = "whitelist")]
|
|
||||||
blacklist: bool,
|
|
||||||
|
|
||||||
/// Whitelist mode: only explicitly listed minimal paths visible
|
|
||||||
#[arg(long)]
|
|
||||||
whitelist: bool,
|
|
||||||
|
|
||||||
/// Harden: unshare IPC, PID, UTS; private /tmp, /dev, /run
|
|
||||||
#[arg(long)]
|
|
||||||
hardened: bool,
|
|
||||||
|
|
||||||
/// Unshare the network namespace
|
|
||||||
#[arg(long)]
|
|
||||||
no_net: bool,
|
|
||||||
|
|
||||||
/// Bind an extra path read-write (repeatable)
|
|
||||||
#[arg(long = "rw", value_name = "PATH", action = clap::ArgAction::Append)]
|
|
||||||
extra_rw: Vec<PathBuf>,
|
|
||||||
|
|
||||||
/// Bind an extra path read-only (repeatable)
|
|
||||||
#[arg(long = "ro", value_name = "PATH", action = clap::ArgAction::Append)]
|
|
||||||
extra_ro: Vec<PathBuf>,
|
|
||||||
|
|
||||||
/// Print the bwrap command without executing
|
|
||||||
#[arg(long)]
|
|
||||||
dry_run: bool,
|
|
||||||
|
|
||||||
/// Working directory inside the sandbox (default: current directory)
|
|
||||||
#[arg(long, value_name = "PATH")]
|
|
||||||
chdir: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Command and arguments to run inside the sandbox
|
|
||||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
|
||||||
command_and_args: Vec<OsString>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
let (command, command_args) = resolve_command(args.command_and_args);
|
let file_config = if args.no_config {
|
||||||
let command = assert_binary_exists(&command);
|
None
|
||||||
let chdir = assert_chdir(args.chdir);
|
|
||||||
|
|
||||||
let mode = if args.whitelist {
|
|
||||||
SandboxMode::Whitelist
|
|
||||||
} else {
|
} else {
|
||||||
SandboxMode::Blacklist
|
config::find_config_path(args.config_path.as_deref())
|
||||||
|
.map(|p| FileConfig::load(&p).unwrap_or_else(|e| exit_err(&e)))
|
||||||
};
|
};
|
||||||
|
|
||||||
let config = SandboxConfig {
|
let config = config::build(args, file_config).unwrap_or_else(|e| exit_err(&e));
|
||||||
mode,
|
|
||||||
hardened: args.hardened,
|
|
||||||
no_net: args.no_net,
|
|
||||||
extra_rw: args
|
|
||||||
.extra_rw
|
|
||||||
.iter()
|
|
||||||
.map(|p| canonicalize_or_exit(p))
|
|
||||||
.collect(),
|
|
||||||
extra_ro: args
|
|
||||||
.extra_ro
|
|
||||||
.iter()
|
|
||||||
.map(|p| canonicalize_or_exit(p))
|
|
||||||
.collect(),
|
|
||||||
command,
|
|
||||||
command_args,
|
|
||||||
chdir,
|
|
||||||
dry_run: args.dry_run,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = agent_sandbox::run(config) {
|
if let Err(e) = agent_sandbox::run(config) {
|
||||||
|
exit_err(&e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_err(e: &dyn std::fmt::Display) -> ! {
|
||||||
eprintln!("error: {e}");
|
eprintln!("error: {e}");
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_command(mut positional: Vec<OsString>) -> (OsString, Vec<OsString>) {
|
|
||||||
if !positional.is_empty() {
|
|
||||||
let cmd = positional.remove(0);
|
|
||||||
return (cmd, positional);
|
|
||||||
}
|
|
||||||
if let Ok(cmd) = std::env::var("SANDBOX_CMD") {
|
|
||||||
return (OsString::from(cmd), vec![]);
|
|
||||||
}
|
|
||||||
(
|
|
||||||
OsString::from("claude"),
|
|
||||||
vec![OsString::from("--dangerously-skip-permissions")],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_binary_exists(name: &OsStr) -> PathBuf {
|
|
||||||
resolve_binary(name).unwrap_or_else(|| {
|
|
||||||
eprintln!("error: command not found: {}", name.to_string_lossy());
|
|
||||||
process::exit(1);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_chdir(explicit: Option<PathBuf>) -> PathBuf {
|
|
||||||
if let Some(p) = explicit {
|
|
||||||
return canonicalize_or_exit(&p);
|
|
||||||
}
|
|
||||||
match std::env::current_dir() {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
"error: {}",
|
|
||||||
agent_sandbox::SandboxError::CurrentDirUnavailable(e)
|
|
||||||
);
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn canonicalize_or_exit(p: &Path) -> PathBuf {
|
|
||||||
std::fs::canonicalize(p).unwrap_or_else(|e| {
|
|
||||||
eprintln!("error: cannot resolve path '{}': {e}", p.display());
|
|
||||||
process::exit(1);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_binary(name: &OsStr) -> Option<PathBuf> {
|
|
||||||
let path = PathBuf::from(name);
|
|
||||||
if path.is_absolute() || path.components().count() > 1 {
|
|
||||||
return path.is_file().then_some(path);
|
|
||||||
}
|
|
||||||
std::env::var_os("PATH").and_then(|path_var| {
|
|
||||||
std::env::split_paths(&path_var)
|
|
||||||
.map(|dir| dir.join(name))
|
|
||||||
.find(|p| p.is_file())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ fn ro_bind_under_tmpfs(cmd: &mut Command, base: &str, paths: &[&str]) {
|
|||||||
|
|
||||||
fn add_rw_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> {
|
fn add_rw_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(SandboxError::RwPathMissing(path.to_path_buf()));
|
return Err(SandboxError::PathMissing(path.to_path_buf()));
|
||||||
}
|
}
|
||||||
cmd.arg("--bind").arg(path).arg(path);
|
cmd.arg("--bind").arg(path).arg(path);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -175,7 +175,7 @@ fn add_rw_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> {
|
|||||||
|
|
||||||
fn add_ro_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> {
|
fn add_ro_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(SandboxError::RoPathMissing(path.to_path_buf()));
|
return Err(SandboxError::PathMissing(path.to_path_buf()));
|
||||||
}
|
}
|
||||||
cmd.arg("--ro-bind").arg(path).arg(path);
|
cmd.arg("--ro-bind").arg(path).arg(path);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ fn sandbox(extra_args: &[&str]) -> Command {
|
|||||||
cmd
|
cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_config(dir: &TempDir, content: &str) -> String {
|
||||||
|
let path = dir.path().join("config.toml");
|
||||||
|
fs::write(&path, content).expect("failed to write config");
|
||||||
|
path.to_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn read_sid_from_stat(stat: &str) -> u32 {
|
fn read_sid_from_stat(stat: &str) -> u32 {
|
||||||
stat.trim()
|
stat.trim()
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
@@ -538,3 +544,36 @@ fn rw_missing_path_errors() {
|
|||||||
"expected path in error message, got: {stderr}"
|
"expected path in error message, got: {stderr}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_missing_file_errors() {
|
||||||
|
let output = sandbox(&["--config", "/nonexistent/config.toml"])
|
||||||
|
.args(["--", "true"])
|
||||||
|
.output()
|
||||||
|
.expect("failed to execute");
|
||||||
|
|
||||||
|
assert!(!output.status.success());
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("/nonexistent/config.toml"),
|
||||||
|
"expected config path in error, got: {stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_invalid_toml_errors() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let cfg = write_config(&dir, "not valid {{{{ toml");
|
||||||
|
|
||||||
|
let output = sandbox(&["--config", &cfg])
|
||||||
|
.args(["--", "true"])
|
||||||
|
.output()
|
||||||
|
.expect("failed to execute");
|
||||||
|
|
||||||
|
assert!(!output.status.success());
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("cannot parse"),
|
||||||
|
"expected parse error, got: {stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user