552 lines
17 KiB
Rust
552 lines
17 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::ffi::OsString;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde::Deserialize;
|
|
|
|
use crate::cli::Args;
|
|
use crate::{BindSpec, EnvEntry, 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.entrypoint, args.command_and_args, &profile, &globals)?;
|
|
let command = resolve_binary(&command)
|
|
.ok_or_else(|| SandboxError::CommandNotFound(PathBuf::from(&command)))?;
|
|
|
|
let env = dedupe_env_last_wins(parse_env_entries(&merge_vecs(
|
|
args.env,
|
|
&profile.env,
|
|
&globals.env,
|
|
))?);
|
|
let unsetenv = merge_vecs(args.unsetenv, &profile.unsetenv, &globals.unsetenv);
|
|
reject_env_key_conflicts(&env, &unsetenv)?;
|
|
|
|
Ok(SandboxConfig {
|
|
mode: merge_mode(args.blacklist, args.whitelist, &profile, &globals),
|
|
hardened: merge_flag(
|
|
merge_flag_pair(args.hardened, args.no_hardened),
|
|
profile.hardened,
|
|
globals.hardened,
|
|
),
|
|
unshare_net: merge_flag(
|
|
merge_flag_pair(args.unshare_net, args.share_net),
|
|
profile.unshare_net,
|
|
globals.unshare_net,
|
|
),
|
|
seccomp: merge_flag_with_default(
|
|
merge_flag_pair(args.seccomp, args.no_seccomp),
|
|
profile.seccomp,
|
|
globals.seccomp,
|
|
true,
|
|
),
|
|
env_filter: merge_flag_with_default(
|
|
merge_flag_pair(args.env_filter, args.no_env_filter),
|
|
profile.env_filter,
|
|
globals.env_filter,
|
|
true,
|
|
),
|
|
dry_run: merge_flag(
|
|
merge_flag_pair(args.dry_run, args.no_dry_run),
|
|
profile.dry_run,
|
|
globals.dry_run,
|
|
),
|
|
chdir: resolve_chdir(args.chdir, profile.chdir, globals.chdir)?,
|
|
extra_rw: merge_bind_specs(args.extra_rw, &profile.rw, &globals.rw)?,
|
|
extra_ro: merge_bind_specs(args.extra_ro, &profile.ro, &globals.ro)?,
|
|
mask: merge_vecs(args.mask, &profile.mask, &globals.mask),
|
|
env,
|
|
unsetenv,
|
|
bwrap_args: split_bwrap_args(merge_vecs(
|
|
args.bwrap_args,
|
|
&profile.bwrap_args,
|
|
&globals.bwrap_args,
|
|
))?,
|
|
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: Option<bool>, profile: Option<bool>, globals: Option<bool>) -> bool {
|
|
cli.or(profile).or(globals).unwrap_or(false)
|
|
}
|
|
|
|
fn merge_flag_with_default(
|
|
cli: Option<bool>,
|
|
profile: Option<bool>,
|
|
globals: Option<bool>,
|
|
default: bool,
|
|
) -> bool {
|
|
cli.or(profile).or(globals).unwrap_or(default)
|
|
}
|
|
|
|
fn merge_flag_pair(enable: bool, disable: bool) -> Option<bool> {
|
|
if enable {
|
|
Some(true)
|
|
} else if disable {
|
|
Some(false)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
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_bind_specs(
|
|
cli: Vec<String>,
|
|
profile: &[String],
|
|
globals: &[String],
|
|
) -> Result<Vec<BindSpec>, SandboxError> {
|
|
let config_specs = globals
|
|
.iter()
|
|
.chain(profile)
|
|
.map(|raw| resolve_config_bind_spec(raw));
|
|
let cli_specs = cli.iter().map(|raw| resolve_cli_bind_spec(raw));
|
|
config_specs.chain(cli_specs).collect()
|
|
}
|
|
|
|
fn resolve_cli_bind_spec(raw: &str) -> Result<BindSpec, SandboxError> {
|
|
let (src, dst) = split_bind_spec(raw)?;
|
|
let source = prepare_cli_bind_path(Path::new(src))?;
|
|
let target = match dst {
|
|
Some(d) => {
|
|
let p = PathBuf::from(d);
|
|
require_absolute(&p)?;
|
|
p
|
|
}
|
|
None => source.clone(),
|
|
};
|
|
Ok(BindSpec { source, target })
|
|
}
|
|
|
|
fn resolve_config_bind_spec(raw: &str) -> Result<BindSpec, SandboxError> {
|
|
let (src, dst) = split_bind_spec(raw)?;
|
|
let source = expand_and_require_existing(Path::new(src))?;
|
|
let target = match dst {
|
|
Some(d) => expand_and_require_absolute(Path::new(d))?,
|
|
None => source.clone(),
|
|
};
|
|
Ok(BindSpec { source, target })
|
|
}
|
|
|
|
fn split_bind_spec(raw: &str) -> Result<(&str, Option<&str>), SandboxError> {
|
|
match raw.split_once(':') {
|
|
Some((src, dst)) if !src.is_empty() && !dst.is_empty() => Ok((src, Some(dst))),
|
|
Some(_) => Err(SandboxError::InvalidBindSpec(raw.to_string())),
|
|
None if raw.is_empty() => Err(SandboxError::InvalidBindSpec(raw.to_string())),
|
|
None => Ok((raw, None)),
|
|
}
|
|
}
|
|
|
|
fn prepare_cli_bind_path(path: &Path) -> Result<PathBuf, SandboxError> {
|
|
let absolute =
|
|
std::path::absolute(path).map_err(|_| SandboxError::PathMissing(path.to_path_buf()))?;
|
|
if !absolute.exists() {
|
|
return Err(SandboxError::PathMissing(absolute));
|
|
}
|
|
Ok(absolute)
|
|
}
|
|
|
|
fn split_bwrap_args(raw: Vec<String>) -> Result<Vec<String>, SandboxError> {
|
|
let mut out = Vec::new();
|
|
for value in &raw {
|
|
let words =
|
|
shlex::split(value).ok_or_else(|| SandboxError::InvalidBwrapArg(value.clone()))?;
|
|
out.extend(words);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn merge_vecs<T: Clone>(cli: Vec<T>, profile: &[T], globals: &[T]) -> Vec<T> {
|
|
globals.iter().chain(profile).cloned().chain(cli).collect()
|
|
}
|
|
|
|
fn parse_env_entries(raw: &[String]) -> Result<Vec<EnvEntry>, SandboxError> {
|
|
raw.iter()
|
|
.map(|s| match s.split_once('=') {
|
|
None if s.is_empty() => Err(SandboxError::InvalidEnvEntry(s.clone())),
|
|
None => Ok(EnvEntry::Keep(s.clone())),
|
|
Some(("", _)) => Err(SandboxError::InvalidEnvEntry(s.clone())),
|
|
Some((key, value)) => Ok(EnvEntry::Set(key.to_string(), value.to_string())),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn dedupe_env_last_wins(entries: Vec<EnvEntry>) -> Vec<EnvEntry> {
|
|
let mut seen: HashSet<String> = HashSet::new();
|
|
let mut result: Vec<EnvEntry> = Vec::new();
|
|
for entry in entries.into_iter().rev() {
|
|
if seen.insert(entry.key().to_string()) {
|
|
result.push(entry);
|
|
}
|
|
}
|
|
result.reverse();
|
|
result
|
|
}
|
|
|
|
fn reject_env_key_conflicts(env: &[EnvEntry], unsetenv: &[String]) -> Result<(), SandboxError> {
|
|
let env_keys: HashSet<&str> = env.iter().map(EnvEntry::key).collect();
|
|
let unsetenv_keys: HashSet<&str> = unsetenv.iter().map(String::as_str).collect();
|
|
match env_keys.intersection(&unsetenv_keys).next() {
|
|
Some(&key) => Err(SandboxError::ConflictingEnvKey(key.to_string())),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
fn resolve_command(
|
|
cli_entrypoint: Option<String>,
|
|
mut passthrough_args: Vec<OsString>,
|
|
profile: &Options,
|
|
globals: &Options,
|
|
) -> Result<(OsString, Vec<OsString>), SandboxError> {
|
|
let entrypoint = cli_entrypoint
|
|
.map(CommandValue::Simple)
|
|
.or_else(|| profile.entrypoint.clone())
|
|
.or_else(|| globals.entrypoint.clone());
|
|
let command = profile.command.clone().or(globals.command.clone());
|
|
|
|
if let Some(ep) = entrypoint {
|
|
let (cmd, mut args) = ep.into_binary_and_args();
|
|
|
|
if !passthrough_args.is_empty() {
|
|
args.extend(passthrough_args);
|
|
} else if let Some(default_cmd) = command {
|
|
args.extend(default_cmd.into_vec().into_iter().map(OsString::from));
|
|
}
|
|
|
|
return Ok((cmd, args));
|
|
}
|
|
|
|
if !passthrough_args.is_empty() {
|
|
return Ok((passthrough_args.remove(0), passthrough_args));
|
|
}
|
|
if let Some(config_cmd) = command {
|
|
return Ok(config_cmd.into_binary_and_args());
|
|
}
|
|
Err(SandboxError::NoCommand)
|
|
}
|
|
|
|
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>,
|
|
#[serde(rename = "default-profile", default)]
|
|
pub default_profile: Option<String>,
|
|
#[serde(rename = "extra-config", default)]
|
|
pub extra_config: Option<PathBuf>,
|
|
// Collects unrecognized keys; deny_unknown_fields is incompatible with flatten.
|
|
#[serde(flatten)]
|
|
_unknown: HashMap<String, toml::Value>,
|
|
}
|
|
|
|
impl FileConfig {
|
|
pub fn load(path: &Path) -> Result<Self, SandboxError> {
|
|
Self::load_file(path)?.load_extra()
|
|
}
|
|
|
|
fn load_extra(self) -> Result<Self, SandboxError> {
|
|
let Some(ref extra_path) = self.extra_config else {
|
|
return Ok(self);
|
|
};
|
|
let resolved = expand_and_require_absolute(extra_path)?;
|
|
let extra = match Self::load_file(&resolved) {
|
|
Ok(c) => c,
|
|
Err(SandboxError::ConfigRead { source, .. })
|
|
if source.kind() == std::io::ErrorKind::NotFound =>
|
|
{
|
|
return Ok(self);
|
|
}
|
|
Err(e) => return Err(e),
|
|
};
|
|
if extra.extra_config.is_some() {
|
|
return Err(SandboxError::NestedExtraConfig(resolved));
|
|
}
|
|
Ok(self.merge_with(extra))
|
|
}
|
|
|
|
fn load_file(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,
|
|
})?;
|
|
Self::parse(&contents).map_err(|e| match e {
|
|
SandboxError::ConfigParse { source, .. } => SandboxError::ConfigParse {
|
|
path: path.to_path_buf(),
|
|
source,
|
|
},
|
|
other => other,
|
|
})
|
|
}
|
|
|
|
fn merge_with(self, extra: FileConfig) -> FileConfig {
|
|
let mut profile = self.profile;
|
|
for (profile_name, profile_options) in extra.profile {
|
|
let merged = match profile.remove(&profile_name) {
|
|
Some(existing) => existing.merge_with(profile_options),
|
|
None => profile_options,
|
|
};
|
|
profile.insert(profile_name, merged);
|
|
}
|
|
FileConfig {
|
|
options: self.options.merge_with(extra.options),
|
|
profile,
|
|
default_profile: extra.default_profile.or(self.default_profile),
|
|
extra_config: None,
|
|
_unknown: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
fn parse(contents: &str) -> Result<Self, SandboxError> {
|
|
let config: Self = toml::from_str(contents).map_err(|e| SandboxError::ConfigParse {
|
|
path: PathBuf::new(),
|
|
source: e,
|
|
})?;
|
|
if let Some(key) = config._unknown.keys().next() {
|
|
return Err(SandboxError::UnknownConfigKey(key.clone()));
|
|
}
|
|
Ok(config)
|
|
}
|
|
|
|
fn resolve_profile(&self, name: Option<&str>) -> Result<Options, SandboxError> {
|
|
match name.or(self.default_profile.as_deref()) {
|
|
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", deny_unknown_fields)]
|
|
pub struct Options {
|
|
pub blacklist: Option<bool>,
|
|
pub whitelist: Option<bool>,
|
|
pub hardened: Option<bool>,
|
|
pub unshare_net: Option<bool>,
|
|
pub seccomp: Option<bool>,
|
|
pub env_filter: Option<bool>,
|
|
pub entrypoint: Option<CommandValue>,
|
|
pub command: Option<CommandValue>,
|
|
pub dry_run: Option<bool>,
|
|
pub chdir: Option<PathBuf>,
|
|
#[serde(default)]
|
|
pub rw: Vec<String>,
|
|
#[serde(default)]
|
|
pub ro: Vec<String>,
|
|
#[serde(default)]
|
|
pub mask: Vec<PathBuf>,
|
|
#[serde(default)]
|
|
pub env: Vec<String>,
|
|
#[serde(default)]
|
|
pub unsetenv: Vec<String>,
|
|
#[serde(default)]
|
|
pub bwrap_args: Vec<String>,
|
|
}
|
|
|
|
impl Options {
|
|
fn merge_with(self, extra: Options) -> Options {
|
|
let (blacklist, whitelist) = pick_mode_flags(&self, &extra);
|
|
Options {
|
|
blacklist,
|
|
whitelist,
|
|
hardened: extra.hardened.or(self.hardened),
|
|
unshare_net: extra.unshare_net.or(self.unshare_net),
|
|
seccomp: extra.seccomp.or(self.seccomp),
|
|
env_filter: extra.env_filter.or(self.env_filter),
|
|
entrypoint: extra.entrypoint.or(self.entrypoint),
|
|
command: extra.command.or(self.command),
|
|
dry_run: extra.dry_run.or(self.dry_run),
|
|
chdir: extra.chdir.or(self.chdir),
|
|
rw: append(self.rw, extra.rw),
|
|
ro: append(self.ro, extra.ro),
|
|
mask: append(self.mask, extra.mask),
|
|
env: append(self.env, extra.env),
|
|
unsetenv: append(self.unsetenv, extra.unsetenv),
|
|
bwrap_args: append(self.bwrap_args, extra.bwrap_args),
|
|
}
|
|
}
|
|
|
|
fn validate_paths(&mut self) -> Result<(), SandboxError> {
|
|
if let Some(ref chdir) = self.chdir {
|
|
self.chdir = Some(expand_and_canonicalize(chdir)?);
|
|
}
|
|
for p in &mut self.mask {
|
|
*p = expand_and_require_absolute(p)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn append<T>(mut base: Vec<T>, extra: Vec<T>) -> Vec<T> {
|
|
base.extend(extra);
|
|
base
|
|
}
|
|
|
|
fn pick_mode_flags(base: &Options, extra: &Options) -> (Option<bool>, Option<bool>) {
|
|
if extra.blacklist.is_some() || extra.whitelist.is_some() {
|
|
(extra.blacklist, extra.whitelist)
|
|
} else {
|
|
(base.blacklist, base.whitelist)
|
|
}
|
|
}
|
|
|
|
fn expand_and_require_existing(path: &Path) -> Result<PathBuf, SandboxError> {
|
|
let expanded = expand_and_require_absolute(path)?;
|
|
if !expanded.exists() {
|
|
return Err(SandboxError::PathMissing(expanded));
|
|
}
|
|
Ok(expanded)
|
|
}
|
|
|
|
fn expand_and_require_absolute(path: &Path) -> Result<PathBuf, SandboxError> {
|
|
let expanded = expand_tilde(path)?;
|
|
require_absolute(&expanded)?;
|
|
Ok(expanded)
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
}
|
|
|
|
fn into_binary_and_args(self) -> (OsString, Vec<OsString>) {
|
|
let mut parts = self.into_vec();
|
|
let binary = OsString::from(parts.remove(0));
|
|
let args = parts.into_iter().map(OsString::from).collect();
|
|
(binary, args)
|
|
}
|
|
}
|
|
|
|
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)]
|
|
#[path = "../tests/unit/config.rs"]
|
|
mod tests;
|