use std::{
collections::BTreeMap,
ffi::OsString,
process::{Command, Stdio},
time::{Duration, Instant},
};
use crate::{
artifact_config::ARTIFACT_CONFIG_ENV,
detached::{DetachPolicy, DetachedDispatchInfo},
types::CapabilityPolicy,
workdir_fs::{WorkdirRoot, DEFAULT_FILE_MODE},
};
pub(crate) const MAX_SHELL_OUTPUT_BYTES: usize = 16 * 1024;
const SHELL_INTERPRETERS: &[&str] = &["bash", "sh", "zsh", "fish", "dash", "ksh"];
const DEFAULT_ENV_BASELINE: &[&str] = &[
"PATH",
"HOME",
"USER",
"LOGNAME",
"LANG",
"LC_ALL",
"CARGO_HOME",
"RUSTUP_HOME",
"TERM",
];
pub(crate) const ACCOUNT_ENV: [&str; 2] = ["LOGNAME", "USER"];
#[cfg(test)]
pub(crate) static ACCOUNT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HostAccountEnv {
set: Vec<(String, String)>,
unset: Vec<String>,
}
impl HostAccountEnv {
pub(crate) fn from_host() -> Self {
Self::from_lookup(|name| std::env::var(name).ok())
}
pub(crate) fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
let mut set = Vec::new();
let mut unset = Vec::new();
for name in ACCOUNT_ENV {
match lookup(name) {
Some(value) => set.push((name.to_string(), value)),
None => unset.push(name.to_string()),
}
}
Self { set, unset }
}
#[cfg(test)]
pub(crate) fn from_pairs(pairs: &[(&str, &str)]) -> Self {
Self::from_lookup(|name| {
pairs
.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| value.to_string())
})
}
pub(crate) fn unset(&self) -> &[String] {
&self.unset
}
pub(crate) fn set(&self) -> &[(String, String)] {
&self.set
}
pub(crate) fn apply(&self, env: &mut BTreeMap<String, String>) {
for name in ACCOUNT_ENV {
env.remove(name);
}
env.extend(self.set.iter().cloned());
}
}
pub(crate) const NO_HOME_FOLDER_REFUSAL: &str =
"no home folder: this command's enforcement holds no session home folder (home/ in the \
session's records directory) to be its $HOME, so it is not run";
pub(crate) const CREDENTIAL_ENV_PATTERNS: &[&str] = &[
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GOOGLE_API_KEY",
"HUGGING_FACE_HUB_TOKEN",
"AWS_*",
"GITHUB_TOKEN",
"GH_TOKEN",
"DOCKER_*",
"KUBECONFIG",
"NPM_TOKEN",
"PYPI_TOKEN",
"CARGO_REGISTRY_TOKEN",
"*_API_KEY",
];
#[derive(Debug)]
pub(crate) struct ShellResult {
pub(crate) binary: String,
pub(crate) exit_code: i32,
pub(crate) stdout: String,
pub(crate) stderr: String,
pub(crate) duration_ms: u64,
pub(crate) truncated: bool,
pub(crate) full_output_path: Option<String>,
pub(crate) resource_limit_hit: Option<String>,
pub(crate) output_holders: OutputHolders,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OutputHolders {
Closed,
Killed,
Escaped,
}
impl OutputHolders {
pub(crate) fn note(self) -> Option<String> {
const REDIRECT: &str = "A process meant to keep running in the background needs its \
output redirected away from the call, e.g. `server >server.log 2>&1 &`.";
match self {
Self::Closed => None,
Self::Killed => Some(format!(
"The command exited, but processes it started were still holding its output \
after the wait and were killed. {REDIRECT}"
)),
Self::Escaped => Some(format!(
"The command exited, but processes it started outside its process group still \
hold its output; the result has what was read before the call returned, and \
those processes keep running until the session ends. {REDIRECT}"
)),
}
}
}
const PIPE_DRAIN_AFTER_EXIT: Duration = Duration::from_secs(1);
pub(crate) const LEFTOVER_KILL_WAIT: Duration = Duration::from_secs(2);
const PIPE_POLL_INTERVAL: Duration = Duration::from_millis(10);
#[derive(Debug)]
pub(crate) enum ShellExecError {
Failed(String),
SealedRootConstructionFailed { detail: String },
}
impl ShellExecError {
pub(crate) fn session_fatal(&self) -> Option<crate::errors::RuntimeError> {
match self {
Self::Failed(_) => None,
Self::SealedRootConstructionFailed { detail } => {
Some(crate::errors::RuntimeError::SealedRootConstructionFailed {
detail: detail.clone(),
})
}
}
}
}
pub(crate) fn enforced_spawn_failure(
command: Command,
supervisor: &crate::sandbox::SupervisorHandle,
subject: &str,
spawn_error: String,
) -> ShellExecError {
drop(command);
match supervisor.read_diagnostic() {
Some(detail) if detail.contains(crate::sealed::SEALED_ROOT_FAILURE_PREFIX) => {
ShellExecError::SealedRootConstructionFailed { detail }
}
Some(detail) if !detail.is_empty() => ShellExecError::Failed(format!(
"sandbox: {subject} enforcement setup failed before exec: {detail}"
)),
_ => ShellExecError::Failed(spawn_error),
}
}
impl std::fmt::Display for ShellExecError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Failed(message) => f.write_str(message),
Self::SealedRootConstructionFailed { detail } => write!(
f,
"{}",
crate::errors::RuntimeError::SealedRootConstructionFailed {
detail: detail.clone(),
}
),
}
}
}
impl From<String> for ShellExecError {
fn from(message: String) -> Self {
Self::Failed(message)
}
}
pub(crate) fn is_shell_interpreter(binary: &str) -> bool {
SHELL_INTERPRETERS.contains(&binary)
}
pub(crate) use murmur_artifact::split_shell_words;
pub(crate) fn shell_tool_manifest_yaml(binary: &str) -> String {
let description = if is_shell_interpreter(binary) {
format!("Run a shell command via {binary} in the capsule workdir. The `command` field is passed to {binary} via -c.")
} else {
format!("Run {binary} in the capsule workdir. The `command` field holds {binary}'s arguments and not the binary name. It is split into arguments at whitespace; single or double quotes group words, and a backslash escapes the next character. No shell runs it, so nothing in it is expanded.")
};
format!(
"name: {binary}\nversion: 0.0.0\nruntime: tool\ndescription: \"{description}\"\ninput_schema: '{{\"type\":\"object\",\"properties\":{{\"command\":{{\"type\":\"string\"}}}},\"required\":[\"command\"]}}'\n"
)
}
thread_local! {
static SPAWNED_GROUP: std::cell::RefCell<Option<std::sync::Arc<std::sync::atomic::AtomicI32>>> =
const { std::cell::RefCell::new(None) };
}
pub(crate) fn report_spawned_group_to(sink: std::sync::Arc<std::sync::atomic::AtomicI32>) {
SPAWNED_GROUP.with(|slot| *slot.borrow_mut() = Some(sink));
}
#[derive(Debug)]
pub(crate) enum ShellOutcome {
Finished(ShellResult),
Detached(DetachedDispatchInfo),
}
const DETACH_POLL_INTERVAL: Duration = Duration::from_millis(20);
pub(crate) const CANCELED_BEFORE_SPAWN: &str =
"the task was canceled before this command started, so it was not run";
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_shell(
binary: &str,
args: &[&str],
env_overrides: &[(String, String)],
accessible_workdir: &WorkdirRoot,
policy: &CapabilityPolicy,
enforcement: &crate::sandbox::ShellEnforcement,
detach: Option<DetachPolicy>,
) -> Result<ShellOutcome, ShellExecError> {
let wait = detach.map_or(Wait::Blocking, Wait::Detach);
spawn_and_wait(
binary,
args,
env_overrides,
accessible_workdir,
policy,
enforcement,
wait,
)
.map(|(outcome, _)| outcome)
}
pub(crate) struct ForegroundStop {
pub canceled: crate::cancel::CancelSignal,
pub timeout: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ForegroundStopReason {
Canceled,
TimedOut,
}
#[derive(Debug)]
pub(crate) struct ForegroundRun {
pub result: ShellResult,
pub stopped: Option<ForegroundStopReason>,
}
pub(crate) fn run_shell_until_stopped(
binary: &str,
args: &[&str],
env_overrides: &[(String, String)],
accessible_workdir: &WorkdirRoot,
policy: &CapabilityPolicy,
enforcement: &crate::sandbox::ShellEnforcement,
stop: ForegroundStop,
) -> Result<ForegroundRun, ShellExecError> {
match spawn_and_wait(
binary,
args,
env_overrides,
accessible_workdir,
policy,
enforcement,
Wait::Stop(stop),
)? {
(ShellOutcome::Finished(result), stopped) => Ok(ForegroundRun { result, stopped }),
(ShellOutcome::Detached(info), _) => Err(ShellExecError::Failed(format!(
"a foreground command was demoted to the background as {}",
info.work_id
))),
}
}
enum Wait {
Blocking,
Detach(DetachPolicy),
Stop(ForegroundStop),
}
pub(crate) fn check_shell_allow(policy: &CapabilityPolicy, binary: &str) -> Result<(), String> {
if !policy.shell_allow.iter().any(|allowed| allowed == binary) {
return Err(format!(
"binary '{binary}' is not in capabilities.shell.allow"
));
}
Ok(())
}
pub(crate) enum ContainedSpawnError {
WorkdirBudget(String),
Exec(ShellExecError),
}
impl From<ContainedSpawnError> for ShellExecError {
fn from(error: ContainedSpawnError) -> Self {
match error {
ContainedSpawnError::WorkdirBudget(message) => Self::Failed(message),
ContainedSpawnError::Exec(error) => error,
}
}
}
pub(crate) struct ContainedChild {
pub(crate) child: std::process::Child,
pub(crate) pgid: i32,
pub(crate) resolved_binary: String,
pub(crate) started: Instant,
pub(crate) cgroup_occupancy: Option<crate::cgroup::ScopeOccupancy>,
pub(crate) cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn spawn_contained(
binary: &str,
args: &[&str],
env_overrides: &[(String, String)],
accessible_workdir: &WorkdirRoot,
policy: &CapabilityPolicy,
enforcement: &crate::sandbox::ShellEnforcement,
stdin: Stdio,
subject: &str,
) -> Result<ContainedChild, ContainedSpawnError> {
use std::os::unix::process::CommandExt as _;
enforcement
.check_workdir_budget()
.map_err(ContainedSpawnError::WorkdirBudget)?;
let env = subprocess_env(policy, env_overrides, enforcement)
.map_err(|error| ContainedSpawnError::Exec(ShellExecError::Failed(error)))?;
let resolved_binary = crate::sandbox::resolve_invoked_binary_path(binary);
let started = Instant::now();
let mut command = Command::new(binary);
command
.args(args)
.current_dir(accessible_workdir.path())
.env_clear()
.envs(env)
.stdin(stdin)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0);
let supervisor =
crate::sandbox::prepare_enforcement(&mut command, enforcement, accessible_workdir.path())
.map_err(|error| ContainedSpawnError::Exec(ShellExecError::Failed(error)))?;
let cgroup_occupancy = enforcement.cgroup_scope.as_ref().map(|scope| scope.enter());
let cgroup_counters_before = enforcement
.cgroup_scope
.as_ref()
.map(|scope| scope.event_counters());
let child = match command.spawn() {
Ok(child) => child,
Err(error) => {
return Err(ContainedSpawnError::Exec(enforced_spawn_failure(
command,
&supervisor,
subject,
error.to_string(),
)))
}
};
supervisor.hand_off_bind_listener(enforcement);
let pgid = child.id() as i32;
SPAWNED_GROUP.with(|slot| {
if let Some(sink) = slot.borrow().as_ref() {
sink.store(pgid, std::sync::atomic::Ordering::SeqCst);
}
});
Ok(ContainedChild {
child,
pgid,
resolved_binary,
started,
cgroup_occupancy,
cgroup_counters_before,
})
}
fn spawn_and_wait(
binary: &str,
args: &[&str],
env_overrides: &[(String, String)],
accessible_workdir: &WorkdirRoot,
policy: &CapabilityPolicy,
enforcement: &crate::sandbox::ShellEnforcement,
wait: Wait,
) -> Result<(ShellOutcome, Option<ForegroundStopReason>), ShellExecError> {
check_shell_allow(policy, binary).map_err(ShellExecError::Failed)?;
if let Wait::Detach(DetachPolicy {
canceled: Some(canceled),
..
}) = &wait
{
if canceled.is_canceled() {
return Err(ShellExecError::Failed(CANCELED_BEFORE_SPAWN.to_string()));
}
}
let ContainedChild {
mut child,
pgid,
resolved_binary,
started,
cgroup_occupancy,
cgroup_counters_before,
} = spawn_contained(
binary,
args,
env_overrides,
accessible_workdir,
policy,
enforcement,
Stdio::null(),
"shell",
)?;
let readers = OutputReaders::spawn(&mut child);
let records_root = enforcement.records_root();
let wait = match wait {
Wait::Detach(_) if records_root.is_none() => Wait::Blocking,
wait => wait,
};
let mut stopped = None;
let (status, drain_deadline) = match wait {
Wait::Blocking => {
let status = child.wait().map_err(|error| error.to_string())?;
(status, Instant::now() + PIPE_DRAIN_AFTER_EXIT)
}
Wait::Stop(stop) => {
let deadline = started + stop.timeout;
loop {
if let Some(status) = child.try_wait().map_err(|error| error.to_string())? {
break (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
}
let remaining = deadline.saturating_duration_since(Instant::now());
let reason = if stop.canceled.is_canceled() {
Some(ForegroundStopReason::Canceled)
} else if remaining.is_zero() {
Some(ForegroundStopReason::TimedOut)
} else {
None
};
if let Some(reason) = reason {
crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL);
let status = child.wait().map_err(|error| error.to_string())?;
stopped = Some(reason);
break (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
}
std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
}
}
Wait::Detach(policy) => {
let deadline = policy.grace.map(|grace| started + grace);
loop {
match child.try_wait().map_err(|error| error.to_string())? {
Some(status) => {
let drain = Instant::now() + PIPE_DRAIN_AFTER_EXIT;
break (
status,
deadline.map_or(drain, |deadline| drain.min(deadline)),
);
}
None => {
let remaining = deadline
.map(|deadline| deadline.saturating_duration_since(Instant::now()));
let abandoned = policy
.abandoned
.as_ref()
.is_some_and(crate::detached::AbandonSignal::is_raised);
let canceled = policy
.canceled
.as_ref()
.filter(|signal| signal.is_canceled());
if let Some(signal) = canceled {
signal.note_phase(crate::cancel::PHASE_SHELL);
}
if remaining.is_some_and(|remaining| remaining.is_zero())
|| abandoned
|| canceled.is_some()
{
let records_root = records_root
.expect("a detach policy is kept only with a records root")
.clone();
let detached = demote(
child,
readers,
DemotionContext {
policy,
records_root,
invoked_binary: binary.to_string(),
resolved_binary,
args: args.iter().map(|arg| (*arg).to_string()).collect(),
started,
pgid,
cgroup_scope: enforcement.cgroup_scope.clone(),
cgroup_counters_before,
cgroup_occupancy,
},
);
return Ok((ShellOutcome::Detached(detached), None));
}
std::thread::sleep(remaining.map_or(DETACH_POLL_INTERVAL, |remaining| {
remaining.min(DETACH_POLL_INTERVAL)
}));
}
}
}
}
};
let (stdout_bytes, stderr_bytes, output_holders) = finish_output(readers, pgid, drain_deadline);
let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
let resource_limit_hit = classify_resource_limit(
&status,
enforcement.cgroup_scope.as_deref(),
cgroup_counters_before,
cgroup_occupancy.as_ref(),
)
.filter(|_| stopped.is_none());
drop(cgroup_occupancy);
let exit_code = exit_code_of(&status);
let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
let mut stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
let mut truncated = false;
let mut full_output_path = None;
if stdout_bytes.len() > MAX_SHELL_OUTPUT_BYTES || stderr_bytes.len() > MAX_SHELL_OUTPUT_BYTES {
truncated = true;
let stdout_cut = stdout_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
let stderr_cut = stderr_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
let full_stdout = std::mem::replace(
&mut stdout,
String::from_utf8_lossy(&stdout_bytes[..stdout_cut]).to_string(),
);
let full_stderr = std::mem::replace(
&mut stderr,
String::from_utf8_lossy(&stderr_bytes[..stderr_cut]).to_string(),
);
if let Some(records_root) = records_root {
full_output_path = Some(write_shell_output_log(
records_root,
&format!("shell-{}", crate::trace::timestamp_ms()),
binary,
args,
exit_code,
&full_stdout,
&full_stderr,
)?);
}
}
Ok((
ShellOutcome::Finished(ShellResult {
binary: resolved_binary,
exit_code,
stdout,
stderr,
duration_ms,
truncated,
full_output_path,
resource_limit_hit,
output_holders,
}),
stopped,
))
}
pub(crate) fn execute_shell(
binary: &str,
args: &[&str],
env_overrides: &[(String, String)],
accessible_workdir: &WorkdirRoot,
policy: &CapabilityPolicy,
enforcement: &crate::sandbox::ShellEnforcement,
) -> Result<ShellResult, ShellExecError> {
match run_shell(
binary,
args,
env_overrides,
accessible_workdir,
policy,
enforcement,
None,
)? {
ShellOutcome::Finished(result) => Ok(result),
ShellOutcome::Detached(info) => Err(ShellExecError::Failed(format!(
"shell command was demoted to the background as {} on a call site that cannot receive a completion",
info.work_id
))),
}
}
struct OutputReaders {
stdout: Option<PipeReader>,
stderr: Option<PipeReader>,
}
enum PipeSink {
Buffer(Vec<u8>),
Live(std::sync::Arc<std::sync::Mutex<crate::detached::LiveOutput>>),
Discard,
}
struct PipeReader {
thread: std::thread::JoinHandle<()>,
sink: std::sync::Arc<std::sync::Mutex<PipeSink>>,
}
fn lock_sink(sink: &std::sync::Mutex<PipeSink>) -> std::sync::MutexGuard<'_, PipeSink> {
sink.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
impl PipeReader {
fn spawn(mut pipe: impl std::io::Read + Send + 'static) -> Self {
let sink = std::sync::Arc::new(std::sync::Mutex::new(PipeSink::Buffer(Vec::new())));
let thread = {
let sink = std::sync::Arc::clone(&sink);
std::thread::spawn(move || {
let mut chunk = [0u8; 8192];
loop {
match pipe.read(&mut chunk) {
Ok(0) => break,
Ok(read) => match &mut *lock_sink(&sink) {
PipeSink::Buffer(buffer) => buffer.extend_from_slice(&chunk[..read]),
PipeSink::Live(output) => output
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.append(&chunk[..read]),
PipeSink::Discard => {}
},
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
})
};
Self { thread, sink }
}
fn take(self) -> Vec<u8> {
let taken = match std::mem::replace(&mut *lock_sink(&self.sink), PipeSink::Discard) {
PipeSink::Buffer(buffer) => buffer,
PipeSink::Live(_) | PipeSink::Discard => Vec::new(),
};
if self.thread.is_finished() && self.thread.join().is_err() {
return Vec::new();
}
taken
}
}
impl OutputReaders {
fn spawn(child: &mut std::process::Child) -> Self {
Self {
stdout: child.stdout.take().map(PipeReader::spawn),
stderr: child.stderr.take().map(PipeReader::spawn),
}
}
fn go_live(&self, output: &std::sync::Arc<std::sync::Mutex<crate::detached::LiveOutput>>) {
let mut sinks: Vec<_> = [&self.stdout, &self.stderr]
.into_iter()
.flatten()
.map(|reader| lock_sink(&reader.sink))
.collect();
let mut live = output
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for sink in &mut sinks {
if let PipeSink::Buffer(buffer) =
std::mem::replace(&mut **sink, PipeSink::Live(std::sync::Arc::clone(output)))
{
live.append(&buffer);
}
}
}
fn at_eof(&self) -> bool {
[&self.stdout, &self.stderr]
.into_iter()
.flatten()
.all(|reader| reader.thread.is_finished())
}
fn wait_for_eof(&self, deadline: Instant) -> bool {
loop {
if self.at_eof() {
return true;
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return false;
}
std::thread::sleep(remaining.min(PIPE_POLL_INTERVAL));
}
}
fn take(self) -> (Vec<u8>, Vec<u8>) {
let take = |reader: Option<PipeReader>| reader.map(PipeReader::take).unwrap_or_default();
(take(self.stdout), take(self.stderr))
}
}
fn finish_output(
readers: OutputReaders,
pgid: i32,
drain_deadline: Instant,
) -> (Vec<u8>, Vec<u8>, OutputHolders) {
if readers.wait_for_eof(drain_deadline) {
let (stdout, stderr) = readers.take();
return (stdout, stderr, OutputHolders::Closed);
}
let give_up = Instant::now() + LEFTOVER_KILL_WAIT;
let mut group_alive = true;
let mut killed = false;
let closed = loop {
if group_alive {
if crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL) {
killed = true;
} else {
group_alive = false;
}
}
if readers.at_eof() {
break true;
}
let remaining = give_up.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break false;
}
std::thread::sleep(remaining.min(PIPE_POLL_INTERVAL));
};
let (stdout, stderr) = readers.take();
let holders = match (closed, killed) {
(true, true) => OutputHolders::Killed,
(true, false) => OutputHolders::Closed,
(false, _) => OutputHolders::Escaped,
};
(stdout, stderr, holders)
}
struct DemotionContext {
policy: DetachPolicy,
records_root: WorkdirRoot,
invoked_binary: String,
resolved_binary: String,
args: Vec<String>,
started: Instant,
pgid: i32,
cgroup_scope: Option<std::sync::Arc<crate::cgroup::CgroupScope>>,
cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
cgroup_occupancy: Option<crate::cgroup::ScopeOccupancy>,
}
fn demote(
mut child: std::process::Child,
readers: OutputReaders,
context: DemotionContext,
) -> DetachedDispatchInfo {
let work_id = crate::detached::new_work_id();
let info = DetachedDispatchInfo {
work_id: work_id.clone(),
binary: context.resolved_binary.clone(),
command: context.policy.command.clone(),
grace_ms: context
.policy
.grace
.map(|grace| grace.as_millis().try_into().unwrap_or(u64::MAX)),
};
let arg_refs: Vec<&str> = context.args.iter().map(String::as_str).collect();
let log = crate::detached::output_path_for(&context.records_root, &work_id);
let output = std::sync::Arc::new(std::sync::Mutex::new(crate::detached::LiveOutput::open(
&context.records_root,
&log.relative,
&log_command_line(&context.invoked_binary, &arg_refs),
)));
readers.go_live(&output);
context.policy.registry.register(
crate::detached::DetachedWork {
work_id: work_id.clone(),
binary: context.resolved_binary.clone(),
command: context.policy.command.clone(),
started_at_ms: crate::trace::timestamp_ms().saturating_sub(
context
.started
.elapsed()
.as_millis()
.try_into()
.unwrap_or(u64::MAX),
),
},
context.started,
context.pgid,
log.path.clone(),
std::sync::Arc::clone(&output),
);
let provenance = context.policy.completion_provenance();
let DemotionContext {
policy,
records_root,
resolved_binary,
started,
pgid,
cgroup_scope,
cgroup_counters_before,
cgroup_occupancy,
..
} = context;
std::thread::spawn(move || {
let observed = wait_for_exit_unreaped(pgid);
policy.registry.leader_exited(&work_id);
let waited = child.wait();
let exited_at_ms = crate::trace::timestamp_ms();
let (_, _, output_holders) =
finish_output(readers, pgid, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
let (exit_code, resource_limit, mut error) = match waited {
Ok(status) => (
exit_code_of(&status),
classify_resource_limit(
&status,
cgroup_scope.as_deref(),
cgroup_counters_before,
cgroup_occupancy.as_ref(),
),
None,
),
Err(failure) => (
-1,
None,
Some(format!("waiting for the command failed: {failure}")),
),
};
drop(cgroup_occupancy);
if let (Err(failure), None) = (observed, &error) {
error = Some(format!("observing the command's exit failed: {failure}"));
}
let write_error = output
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.finish(exit_code);
if let Some(failure) = write_error {
error = Some(match error {
Some(existing) => format!("{existing}; {failure}"),
None => failure,
});
}
let output_bytes = records_root
.file_len(&log.relative)
.ok()
.flatten()
.unwrap_or(0);
policy
.registry
.complete(crate::detached::DetachedCompletion {
work_id,
binary: resolved_binary,
command: policy.command.clone(),
exit_code,
duration_ms,
exited_at_ms,
output_path: log.path,
output_bytes,
resource_limit,
output_holders,
context_id: policy.context_id.clone(),
provenance,
error,
});
});
info
}
pub(crate) fn wait_for_exit_unreaped(pid: i32) -> std::io::Result<()> {
use rustix::process::{waitid, Pid, WaitId, WaitIdOptions};
let pid = (pid > 0)
.then(|| Pid::from_raw(pid))
.flatten()
.ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
loop {
match waitid(
WaitId::Pid(pid),
WaitIdOptions::EXITED | WaitIdOptions::NOWAIT,
) {
Ok(_) => return Ok(()),
Err(rustix::io::Errno::INTR) => {}
Err(errno) => return Err(errno.into()),
}
}
}
pub(crate) fn exit_code_of(status: &std::process::ExitStatus) -> i32 {
use std::os::unix::process::ExitStatusExt;
status
.code()
.or_else(|| status.signal().map(|signal| 128 + signal))
.unwrap_or(-1)
}
fn classify_resource_limit(
status: &std::process::ExitStatus,
cgroup_scope: Option<&crate::cgroup::CgroupScope>,
counters_before: Option<crate::cgroup::CgroupEventCounters>,
occupancy: Option<&crate::cgroup::ScopeOccupancy>,
) -> Option<String> {
use std::os::unix::process::ExitStatusExt;
let signal_limit = status
.signal()
.and_then(crate::resources::limit_from_signal);
let counter_delta = match (cgroup_scope, counters_before) {
(Some(scope), Some(before)) => scope.event_counters().attribution_since(before),
_ => None,
};
let exclusive = occupancy.is_some_and(crate::cgroup::ScopeOccupancy::exclusive);
attribute_resource_limit(signal_limit, counter_delta, exclusive)
}
pub(crate) fn attribute_resource_limit(
signal_limit: Option<&'static str>,
counter_delta: Option<&'static str>,
exclusive: bool,
) -> Option<String> {
signal_limit
.or(counter_delta.filter(|_| exclusive))
.map(str::to_string)
}
pub(crate) fn build_shell_env(
policy: &CapabilityPolicy,
env_overrides: &[(String, String)],
home_dir: Option<&std::path::Path>,
runtime_dir: Option<&std::path::Path>,
records_dir: Option<&std::path::Path>,
tmpdir: Option<&str>,
) -> Result<BTreeMap<String, String>, String> {
let mut env = BTreeMap::new();
for key in DEFAULT_ENV_BASELINE {
if let Ok(value) = std::env::var(key) {
env.insert((*key).to_string(), value);
}
}
for (key, value) in env_overrides {
env.insert(key.clone(), value.clone());
}
for key in &policy.shell_baseline_env {
if crate::formation::is_member_grant_env(key)
|| key == crate::lifeline::SPAWNER_LIFELINE_ENV
{
continue;
}
if let Ok(value) = std::env::var(key) {
env.insert(key.clone(), value);
}
}
strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
env.remove(ARTIFACT_CONFIG_ENV);
let home = home_dir.ok_or_else(|| NO_HOME_FOLDER_REFUSAL.to_string())?;
let home = home.to_string_lossy().into_owned();
env.insert("HOME".to_string(), home.clone());
env.insert("USERPROFILE".to_string(), home);
env.remove(crate::runtime_dir::RUNTIME_DIR_ENV);
if let Some(runtime_dir) = runtime_dir {
env.insert(
crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
runtime_dir.to_string_lossy().into_owned(),
);
}
env.remove(crate::records_dir::RECORDS_DIR_ENV);
if let Some(records_dir) = records_dir {
env.insert(
crate::records_dir::RECORDS_DIR_ENV.to_string(),
records_dir.to_string_lossy().into_owned(),
);
}
for name in crate::temp_dir::TEMP_DIR_ENV {
env.remove(name);
if let Some(tmpdir) = tmpdir {
env.insert(name.to_string(), tmpdir.to_string());
}
}
Ok(env)
}
pub(crate) const LINEAGE_ENV: &[&str] = &[
crate::delegation::SPAWNER_ENV,
crate::formation::FORMATION_ID_ENV,
crate::lifeline::SPAWNER_LIFELINE_ENV,
crate::host_warnings::HOST_WARNINGS_REPORTED_ENV,
"MURMUR_ROOST_URL",
];
pub(crate) fn subprocess_env(
policy: &CapabilityPolicy,
env_overrides: &[(String, String)],
enforcement: &crate::sandbox::ShellEnforcement,
) -> Result<BTreeMap<OsString, OsString>, String> {
let mut env: BTreeMap<OsString, OsString> =
if enforcement.tier == crate::sandbox::EnforcementTier::Unconfined {
build_unconfined_env(
policy,
std::env::vars_os(),
env_overrides,
enforcement.runtime_dir_path(),
enforcement.records_dir_path(),
)
} else {
build_shell_env(
policy,
env_overrides,
enforcement.home_dir_path(),
enforcement.runtime_dir_path(),
enforcement.records_dir_path(),
enforcement.tmpdir_value().as_deref(),
)?
.into_iter()
.map(|(key, value)| (OsString::from(key), OsString::from(value)))
.collect()
};
append_pinned_program_path(&mut env, policy)?;
Ok(env)
}
pub(crate) fn append_pinned_program_path(
env: &mut BTreeMap<OsString, OsString>,
policy: &CapabilityPolicy,
) -> Result<(), String> {
let pinned = crate::pinned_programs::pinned_bin_dirs(&policy.shell_pinned_programs);
if pinned.is_empty() {
return Ok(());
}
let key = OsString::from("PATH");
let host = env.get(&key).filter(|path| !path.is_empty()).cloned();
let host_dirs: Vec<std::path::PathBuf> = host
.as_ref()
.map(|path| std::env::split_paths(path).collect())
.unwrap_or_default();
let added: Vec<std::path::PathBuf> = pinned
.into_iter()
.filter(|dir| !host_dirs.contains(dir))
.collect();
if added.is_empty() {
return Ok(());
}
let added = std::env::join_paths(added)
.map_err(|err| format!("a pinned program folder cannot join PATH: {err}"))?;
let joined = match host {
Some(mut host) => {
host.push(":");
host.push(added);
host
}
None => added,
};
env.insert(key, joined);
Ok(())
}
pub(crate) fn build_unconfined_env(
policy: &CapabilityPolicy,
base: impl IntoIterator<Item = (OsString, OsString)>,
env_overrides: &[(String, String)],
runtime_dir: Option<&std::path::Path>,
records_dir: Option<&std::path::Path>,
) -> BTreeMap<OsString, OsString> {
let mut env: BTreeMap<OsString, OsString> = base.into_iter().collect();
for (key, value) in env_overrides {
env.insert(OsString::from(key), OsString::from(value));
}
env.retain(|key, _| {
let Some(key) = key.to_str() else {
return true;
};
!(LINEAGE_ENV.contains(&key)
|| crate::formation::is_member_grant_env(key)
|| key == ARTIFACT_CONFIG_ENV
|| key == crate::runtime_dir::RUNTIME_DIR_ENV
|| key == crate::records_dir::RECORDS_DIR_ENV
|| policy
.shell_strip_env
.iter()
.any(|pattern| env_name_matches_pattern(pattern, key)))
});
if let Some(runtime_dir) = runtime_dir {
env.insert(
OsString::from(crate::runtime_dir::RUNTIME_DIR_ENV),
runtime_dir.as_os_str().to_owned(),
);
}
if let Some(records_dir) = records_dir {
env.insert(
OsString::from(crate::records_dir::RECORDS_DIR_ENV),
records_dir.as_os_str().to_owned(),
);
}
env
}
pub(crate) fn strip_credential_shaped_vars(
env: &mut BTreeMap<String, String>,
extra_patterns: &[String],
) {
env.retain(|key, _| !credential_backstop_drops(key, extra_patterns));
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BackstopPatternSource {
Builtin,
StripEnv,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackstopMatch {
pub pattern: String,
pub source: BackstopPatternSource,
}
pub fn credential_backstop_match(name: &str, extra_patterns: &[String]) -> Option<BackstopMatch> {
CREDENTIAL_ENV_PATTERNS
.iter()
.map(|pattern| (*pattern, BackstopPatternSource::Builtin))
.chain(
extra_patterns
.iter()
.map(|pattern| (pattern.as_str(), BackstopPatternSource::StripEnv)),
)
.find(|(pattern, _)| env_name_matches_pattern(pattern, name))
.map(|(pattern, source)| BackstopMatch {
pattern: pattern.to_string(),
source,
})
}
pub fn credential_backstop_drops(name: &str, extra_patterns: &[String]) -> bool {
credential_backstop_match(name, extra_patterns).is_some()
}
pub(crate) fn build_declared_env(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
let mut env = BTreeMap::new();
for key in &policy.env_allow {
if key == ARTIFACT_CONFIG_ENV
|| crate::formation::is_member_grant_env(key)
|| key == crate::lifeline::SPAWNER_LIFELINE_ENV
|| key == crate::runtime_dir::RUNTIME_DIR_ENV
|| key == crate::records_dir::RECORDS_DIR_ENV
{
continue;
}
if let Ok(value) = std::env::var(key) {
env.insert(key.clone(), value);
}
}
strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
env
}
fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
if let Some(rest) = pattern.strip_prefix('*') {
if let Some(middle) = rest.strip_suffix('*') {
return key.contains(middle);
}
return key.ends_with(rest);
}
if let Some(prefix) = pattern.strip_suffix('*') {
return key.starts_with(prefix);
}
pattern == key
}
fn log_command_line(binary: &str, args: &[&str]) -> String {
if args.is_empty() {
binary.to_string()
} else {
format!("{binary} {}", args.join(" "))
}
}
fn write_shell_output_log(
records_root: &WorkdirRoot,
file_stem: &str,
binary: &str,
args: &[&str],
exit_code: i32,
stdout: &str,
stderr: &str,
) -> Result<String, String> {
let log = crate::detached::output_path_for(records_root, file_stem);
let command = log_command_line(binary, args);
let content = format!(
"Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
);
records_root
.write_file(&log.relative, content.as_bytes(), DEFAULT_FILE_MODE)
.map_err(|error| format!("failed to write shell log: {error}"))?;
Ok(log.path)
}
#[cfg(test)]
mod tests {
use std::{fs, path::Path};
use tempfile::tempdir;
use super::test_support::{process_dead_within, read_pid, KillPidFileOnDrop};
use super::*;
use crate::sandbox::ShellEnforcement;
#[test]
fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
resources: crate::resources::HostResourceLimits {
max_open_files: 64,
..crate::resources::HostResourceLimits::default()
},
..CapabilityPolicy::default()
};
let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
enforcement.resource_limits = policy.resources;
let result = execute_shell(
"bash",
&["-c", "ulimit -Hn"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.expect("bash must run");
assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
assert_eq!(result.resource_limit_hit, None);
}
#[cfg(target_os = "linux")]
fn run_bash_with_limits(
limits: crate::resources::HostResourceLimits,
script: &str,
) -> ShellResult {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
resources: limits,
..CapabilityPolicy::default()
};
let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
enforcement.resource_limits = limits;
execute_shell(
"bash",
&["-c", script],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.expect("bash must run")
}
#[cfg(target_os = "linux")]
#[test]
fn a_command_that_ignores_sigxcpu_is_killed_one_second_later() {
let result = run_bash_with_limits(
crate::resources::HostResourceLimits {
cpu_seconds: Some(1),
..crate::resources::HostResourceLimits::default()
},
"( trap '' XCPU; while :; do :; done ); echo \"status=$?\"; times",
);
assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
assert!(
result.stdout.contains("status=137"),
"the subshell must die of SIGKILL; stdout was: {}",
result.stdout
);
assert_eq!(result.resource_limit_hit, None);
let children = result
.stdout
.lines()
.last()
.unwrap_or_else(|| panic!("no children line from `times`: {}", result.stdout));
let cpu: f64 = children
.split_whitespace()
.map(|field| {
let (minutes, seconds) = field.trim_end_matches('s').split_once('m').unwrap();
minutes.parse::<f64>().unwrap() * 60.0 + seconds.parse::<f64>().unwrap()
})
.sum();
assert!(
cpu <= 2.5,
"the subshell ran {cpu} CPU-seconds, past cpu_seconds + 1"
);
}
#[cfg(target_os = "linux")]
#[test]
#[allow(unsafe_code)]
fn the_cpu_limit_warns_one_second_before_its_hard_ceiling() {
let result = run_bash_with_limits(
crate::resources::HostResourceLimits {
cpu_seconds: Some(5),
max_open_files: 64,
..crate::resources::HostResourceLimits::default()
},
r#"echo "$(ulimit -St) $(ulimit -Ht) $(ulimit -Sn) $(ulimit -Hn)"; ulimit -Ht 10 2>/dev/null || echo "raise refused""#,
);
let mut lines = result.stdout.lines();
assert_eq!(
lines.next(),
Some("5 6 64 64"),
"stderr was: {}",
result.stderr
);
if unsafe { libc::geteuid() } != 0 {
assert_eq!(lines.next(), Some("raise refused"));
}
}
#[cfg(target_os = "linux")]
#[test]
#[allow(clippy::unnecessary_cast)]
fn with_no_cpu_seconds_a_command_keeps_the_inherited_cpu_limit() {
let own = crate::resources::read_rlimit(libc::RLIMIT_CPU as u32)
.expect("getrlimit(RLIMIT_CPU) failed");
let render = |slot: libc::rlim_t| {
if slot == libc::RLIM_INFINITY {
"unlimited".to_string()
} else {
slot.to_string()
}
};
let result = run_bash_with_limits(
crate::resources::HostResourceLimits::resolve(None),
r#"echo "$(ulimit -St) $(ulimit -Ht)""#,
);
assert_eq!(
result.stdout.trim(),
format!("{} {}", render(own.rlim_cur), render(own.rlim_max)),
"stderr was: {}",
result.stderr
);
}
#[cfg(target_os = "linux")]
#[test]
fn with_no_cpu_seconds_a_cpu_bound_command_runs_to_its_end() {
let result = run_bash_with_limits(
crate::resources::HostResourceLimits::resolve(None),
"end=$((SECONDS + 3)); while [ \"$SECONDS\" -lt \"$end\" ]; do :; done; echo done",
);
assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
assert_eq!(result.stdout.trim(), "done");
assert_eq!(result.resource_limit_hit, None);
}
#[cfg(target_os = "linux")]
#[test]
fn a_kernel_tier_command_starts_under_a_limit_below_the_held_descriptors() {
const TEST: &str = "a_kernel_tier_command_starts_under_a_limit_below_the_held_descriptors";
if crate::network_namespace::skip_without_egress_namespace(TEST) {
return;
}
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
resources: crate::resources::HostResourceLimits {
max_open_files: 16,
..crate::resources::HostResourceLimits::default()
},
..CapabilityPolicy::default()
};
let enforcement = ShellEnforcement::resolve_declared(
&policy,
murmur_artifact::ContainmentClass::Scoped,
crate::sandbox::HostProbe::probe(),
)
.unwrap()
.with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
crate::runtime_err!(
"[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
enforcement.tier
);
return;
}
let held: Vec<fs::File> = (0..128)
.map(|_| fs::File::open("/dev/null").unwrap())
.collect();
let result = execute_shell(
"bash",
&["-c", "ulimit -Hn"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.expect("bash must start under the limit");
drop(held);
assert_eq!(result.stdout.trim(), "16", "stderr was: {}", result.stderr);
assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
}
#[cfg(target_os = "linux")]
#[test]
fn a_program_outside_the_allowlist_is_refused_with_exit_126() {
const TEST: &str = "a_program_outside_the_allowlist_is_refused_with_exit_126";
if crate::network_namespace::skip_without_egress_namespace(TEST) {
return;
}
let run = |allow: &[&str]| {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: allow.iter().map(|b| b.to_string()).collect(),
..CapabilityPolicy::default()
};
let enforcement = ShellEnforcement::resolve_declared(
&policy,
murmur_artifact::ContainmentClass::Scoped,
crate::sandbox::HostProbe::probe(),
)
.unwrap()
.with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
crate::runtime_err!(
"[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
enforcement.tier
);
return None;
}
let result = execute_shell(
"bash",
&["-c", "cat /dev/null"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.expect("bash must start");
Some(result)
};
let Some(refused) = run(&["bash"]) else {
return;
};
assert_eq!(refused.exit_code, 126, "stderr was: {}", refused.stderr);
assert!(
refused.stderr.contains("Permission denied"),
"stderr was: {}",
refused.stderr
);
let allowed = run(&["bash", "cat"]).expect("the tier did not change between runs");
assert_eq!(allowed.exit_code, 0, "stderr was: {}", allowed.stderr);
crate::runtime_err!("{TEST}: ran on KernelFull; [bash] exited 126, [bash, cat] exited 0");
}
#[cfg(target_os = "linux")]
#[test]
#[allow(clippy::print_stdout)]
fn a_kernel_tier_command_gets_the_cpu_slots() {
const TEST: &str = "a_kernel_tier_command_gets_the_cpu_slots";
if crate::network_namespace::skip_without_egress_namespace(TEST) {
return;
}
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
resources: crate::resources::HostResourceLimits {
cpu_seconds: Some(5),
max_open_files: 64,
..crate::resources::HostResourceLimits::default()
},
..CapabilityPolicy::default()
};
let enforcement = ShellEnforcement::resolve_declared(
&policy,
murmur_artifact::ContainmentClass::Scoped,
crate::sandbox::HostProbe::probe(),
)
.unwrap()
.with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
crate::runtime_err!(
"[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
enforcement.tier
);
return;
}
let result = execute_shell(
"bash",
&[
"-c",
r#"echo "$(ulimit -St) $(ulimit -Ht) $(ulimit -Sn) $(ulimit -Hn)""#,
],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.expect("bash must start under the limits");
println!("{}", result.stdout.trim());
assert_eq!(
result.stdout.trim(),
"5 6 64 64",
"stderr was: {}",
result.stderr
);
assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
}
#[cfg(target_os = "linux")]
#[test]
fn a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max() {
const TEST: &str = "a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max";
if !crate::cgroup::cgroup_delegation_available() {
crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
return;
}
let temp = tempdir().unwrap();
let limits = crate::resources::HostResourceLimits {
cgroup_pids_max: 16,
..crate::resources::HostResourceLimits::default()
};
let scope = crate::cgroup::prepare_scope(true, &limits, "ses_fork_bomb", temp.path())
.expect("this host delegates a cgroup scope")
.scope;
let policy = CapabilityPolicy {
shell_allow: vec!["python3".to_string()],
resources: limits,
..CapabilityPolicy::default()
};
let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
enforcement.resource_limits = limits;
let enforcement = enforcement.with_host_bounding(scope, None);
let script = "import os, resource, signal, time\n\
children = []\n\
for _ in range(4096):\n\
\x20 try:\n\
\x20 pid = os.fork()\n\
\x20 except OSError:\n\
\x20 break\n\
\x20 if pid == 0:\n\
\x20 time.sleep(30)\n\
\x20 os._exit(0)\n\
\x20 children.append(pid)\n\
for pid in children:\n\
\x20 os.kill(pid, signal.SIGKILL)\n\
for pid in children:\n\
\x20 os.waitpid(pid, 0)\n\
soft = resource.getrlimit(resource.RLIMIT_NPROC)[0]\n\
print(len(children))\n\
print('unlimited' if soft == resource.RLIM_INFINITY else soft)\n";
let result = execute_shell(
"python3",
&["-c", script],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.expect("python3 must run");
assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
let mut lines = result.stdout.lines();
let reached: u64 = lines.next().unwrap().parse().unwrap();
let soft = lines.next().unwrap();
assert!(
reached < 16,
"pids.max is 16 and the python process holds one task, so at most 15 children can \
be live; reached {reached}"
);
assert_eq!(
result.resource_limit_hit,
Some("cgroup_pids_max".to_string())
);
let (own_soft, _) = crate::resources::test_support::own_rlimit_nproc();
assert_eq!(
soft,
crate::resources::test_support::rendered_rlimit(own_soft),
"a scoped spawn inherits RLIMIT_NPROC unchanged"
);
}
#[test]
fn home_folder_is_home_and_the_work_folder_gains_nothing() {
let accessible = tempdir().unwrap();
let runtime = tempdir().unwrap();
let enforcement = ShellEnforcement::environment_only()
.with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
let home = enforcement.home_dir_path().unwrap().to_path_buf();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let result = execute_shell(
"bash",
&[
"-c",
"echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && echo \"$HOME\" && pwd",
],
&[],
&crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
&policy,
&enforcement,
)
.expect("bash must run");
assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
let mut lines = result.stdout.lines();
assert_eq!(lines.next(), Some("marker"));
assert_eq!(lines.next(), Some(home.to_str().unwrap()));
assert_eq!(
std::fs::canonicalize(lines.next().unwrap()).unwrap(),
std::fs::canonicalize(accessible.path()).unwrap(),
"the subprocess cwd is the work folder"
);
assert_eq!(fs::read_to_string(home.join("probe")).unwrap(), "marker\n");
assert_eq!(
fs::read_dir(accessible.path()).unwrap().count(),
0,
"the work folder gains nothing"
);
}
fn print_seq(workdir: &Path, enforcement: &ShellEnforcement, last: u32) -> ShellResult {
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let command = format!("seq 1 {last}");
execute_shell(
"bash",
&["-c", &command],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
&policy,
enforcement,
)
.expect("bash must run")
}
#[test]
fn a_truncated_output_log_lands_in_the_records_directory() {
let workdir = tempdir().unwrap();
let runtime = tempdir().unwrap();
let enforcement = ShellEnforcement::environment_only()
.with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
let records_root = enforcement.records_root().unwrap().clone();
let result = print_seq(workdir.path(), &enforcement, 20_000);
assert!(result.truncated);
assert_eq!(result.stdout.len(), MAX_SHELL_OUTPUT_BYTES);
let path = result
.full_output_path
.expect("a truncated command names its log");
let file_name = Path::new(&path).file_name().unwrap().to_str().unwrap();
let ms = file_name
.strip_prefix("shell-")
.and_then(|rest| rest.strip_suffix(".log"))
.unwrap_or_else(|| panic!("{path} is not logs/shell-<ms>.log"));
assert!(ms.chars().all(|c| c.is_ascii_digit()), "{path}");
assert_eq!(
Path::new(&path),
records_root.host_path("logs").join(file_name),
);
assert!(Path::new(&path).is_absolute(), "{path}");
let log = fs::read_to_string(&path).unwrap();
let expected: String = (1..=20_000).map(|n| format!("{n}\n")).collect();
let stdout = log
.split_once("\nStdout:\n")
.and_then(|(_, rest)| rest.split_once("\n\nStderr:\n"))
.map(|(stdout, _)| stdout)
.expect("the log keeps its Stdout: and Stderr: sections");
assert_eq!(stdout, expected, "the log holds the full stdout");
assert!(log.starts_with("Command: bash -c seq 1 20000\nExit code: 0\n"));
assert!(
log.ends_with("\n\nStderr:\n\n"),
"the log keeps the empty Stderr: section"
);
assert!(
!workdir.path().join("logs").exists(),
"nothing is written into the accessible workdir"
);
assert_eq!(fs::read_dir(workdir.path()).unwrap().count(), 0);
}
#[test]
fn with_no_records_directory_a_truncated_command_writes_no_log() {
let runtime = tempdir().unwrap();
let open = |sub: &str| {
let path = runtime.path().join(sub);
fs::create_dir_all(&path).unwrap();
crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
};
let roots_without_records = ShellEnforcement::environment_only().with_runtime_dir(
crate::runtime_dir::RuntimeDirRoots::new(
open(crate::runtime_dir::RUNTIME_SUBDIR),
open(crate::runtime_dir::SEALED_ETC_SUBDIR),
open(crate::runtime_dir::HARNESS_SUBDIR),
)
.with_home(open(crate::records_dir::HOME_SUBDIR)),
None,
);
let enforcement = &roots_without_records;
let workdir = tempdir().unwrap();
let result = print_seq(workdir.path(), enforcement, 20_000);
assert!(result.truncated);
assert_eq!(result.stdout.len(), MAX_SHELL_OUTPUT_BYTES);
assert_eq!(result.full_output_path, None);
assert_eq!(
fs::read_dir(workdir.path()).unwrap().count(),
0,
"nothing is written into the workdir"
);
assert!(!runtime.path().join("logs").exists());
assert!(!runtime
.path()
.join(crate::runtime_dir::RUNTIME_SUBDIR)
.join("logs")
.exists());
}
#[test]
fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let result = execute_shell(
"bash",
&["-c", "exit 3"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&ShellEnforcement::environment_only().with_test_session_dirs(),
)
.expect("bash must run");
assert_eq!(result.exit_code, 3);
assert_eq!(result.resource_limit_hit, None);
}
#[test]
fn execute_shell_blocks_binary_not_in_allowlist() {
let policy = CapabilityPolicy::default();
let error = execute_shell(
"bash",
&["-c", "echo hi"],
&[],
&crate::workdir_fs::WorkdirRoot::open(Path::new(".")).unwrap(),
&policy,
&ShellEnforcement::environment_only().with_test_session_dirs(),
)
.unwrap_err();
assert!(error.to_string().contains("capabilities.shell.allow"));
}
#[test]
fn execute_shell_returns_nonzero_exit_code_without_error() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let result = execute_shell(
"bash",
&["-c", "exit 42"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&ShellEnforcement::environment_only().with_test_session_dirs(),
)
.unwrap();
assert_eq!(result.exit_code, 42);
}
#[test]
fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let result = execute_shell(
"bash",
&["-c", "exit 0"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&ShellEnforcement::environment_only().with_test_session_dirs(),
)
.unwrap();
assert!(
Path::new(&result.binary).is_absolute(),
"a binary found on PATH must be reported as an absolute path, got {:?}",
result.binary
);
assert!(
result.binary.ends_with("bash"),
"the reported path must still name bash, got {:?}",
result.binary
);
}
#[test]
fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
let temp = tempdir().unwrap();
let tool = temp.path().join("local-tool");
fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
let mut perms = fs::metadata(&tool).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
fs::set_permissions(&tool, perms).unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["./local-tool".to_string()],
..CapabilityPolicy::default()
};
let result = execute_shell(
"./local-tool",
&[],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&ShellEnforcement::environment_only().with_test_session_dirs(),
)
.unwrap();
assert_eq!(result.exit_code, 7, "the subprocess must still have run");
assert_eq!(
result.binary, "./local-tool",
"an unresolvable name degrades to the bare invoked name, never an error"
);
}
#[test]
fn build_shell_env_sets_home_to_the_home_folder() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy::default();
let env = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
let expected_home = temp.path().to_string_lossy().into_owned();
assert_eq!(env.get("HOME"), Some(&expected_home));
assert_eq!(env.get("USERPROFILE"), Some(&expected_home));
}
#[test]
fn no_home_folder_build_shell_env_refuses() {
let error =
build_shell_env(&CapabilityPolicy::default(), &[], None, None, None, None).unwrap_err();
assert_eq!(error, NO_HOME_FOLDER_REFUSAL);
assert!(error.contains("home folder"), "{error}");
}
#[test]
fn no_home_folder_execute_shell_refuses_and_creates_nothing() {
let parent = tempdir().unwrap();
let workdir = parent.path().join("work");
fs::create_dir(&workdir).unwrap();
let runtime = tempdir().unwrap();
let roots = crate::sandbox::test_runtime_dir(runtime.path());
let without_home = crate::runtime_dir::RuntimeDirRoots {
home: None,
..roots
};
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
for enforcement in [
ShellEnforcement::environment_only(),
ShellEnforcement::environment_only().with_runtime_dir(without_home.clone(), None),
] {
let error = execute_shell(
"bash",
&["-c", "touch ran"],
&[],
&crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
&policy,
&enforcement,
)
.unwrap_err();
assert!(error.to_string().contains("no home folder"), "{error}");
}
assert_eq!(fs::read_dir(&workdir).unwrap().count(), 0);
assert_eq!(fs::read_dir(parent.path()).unwrap().count(), 1);
}
#[test]
fn execute_shell_reports_the_home_folder_not_real_host_home() {
let temp = tempdir().unwrap();
let runtime = tempdir().unwrap();
let enforcement = ShellEnforcement::environment_only()
.with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let result = execute_shell(
"bash",
&["-c", "echo -n $HOME"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&enforcement,
)
.unwrap();
let expected_home = runtime.path().join("records/home");
assert_eq!(result.stdout, expected_home.to_string_lossy());
if let Ok(real_home) = std::env::var("HOME") {
assert_ne!(result.stdout, real_home);
}
}
#[test]
fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy::default();
let overrides = vec![
("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
("STRIPE_API_KEY".to_string(), "leaked".to_string()),
("GITHUB_TOKEN".to_string(), "leaked".to_string()),
("SAFE_VAR".to_string(), "kept".to_string()),
];
let env =
build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
assert!(!env.contains_key("STRIPE_API_KEY"));
assert!(!env.contains_key("GITHUB_TOKEN"));
assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
}
#[test]
fn declared_env_is_empty_without_declarations() {
std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
let policy = CapabilityPolicy::default();
let env = build_declared_env(&policy);
assert!(env.is_empty());
}
#[test]
fn declared_and_shell_env_never_carry_the_formation_lifeline() {
use crate::lifeline::FORMATION_LIFELINE_ENV;
let _guard = crate::formation::FORMATION_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let temp = tempdir().unwrap();
std::env::set_var(FORMATION_LIFELINE_ENV, "7");
let policy = CapabilityPolicy {
env_allow: vec![FORMATION_LIFELINE_ENV.to_string()],
shell_baseline_env: vec![FORMATION_LIFELINE_ENV.to_string()],
..CapabilityPolicy::default()
};
let declared = build_declared_env(&policy);
let shell = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
std::env::remove_var(FORMATION_LIFELINE_ENV);
assert!(
!declared.contains_key(FORMATION_LIFELINE_ENV),
"{declared:?}"
);
assert!(!shell.contains_key(FORMATION_LIFELINE_ENV), "{shell:?}");
}
#[test]
fn declared_and_shell_env_never_carry_the_spawner_lifeline() {
use crate::lifeline::SPAWNER_LIFELINE_ENV;
let _guard = crate::formation::FORMATION_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let temp = tempdir().unwrap();
std::env::set_var(SPAWNER_LIFELINE_ENV, "7");
let policy = CapabilityPolicy {
env_allow: vec![SPAWNER_LIFELINE_ENV.to_string()],
shell_baseline_env: vec![SPAWNER_LIFELINE_ENV.to_string()],
..CapabilityPolicy::default()
};
let declared = build_declared_env(&policy);
let shell = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
std::env::remove_var(SPAWNER_LIFELINE_ENV);
assert!(!declared.contains_key(SPAWNER_LIFELINE_ENV), "{declared:?}");
assert!(!shell.contains_key(SPAWNER_LIFELINE_ENV), "{shell:?}");
}
#[test]
fn declared_env_passes_through_declared_host_var() {
std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
let policy = CapabilityPolicy {
env_allow: vec![
"MURMUR_TEST_WASI_ALLOWED".to_string(),
"MURMUR_TEST_WASI_NEVER_SET".to_string(),
],
..CapabilityPolicy::default()
};
let env = build_declared_env(&policy);
assert_eq!(
env.get("MURMUR_TEST_WASI_ALLOWED"),
Some(&"host-value".to_string())
);
assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
}
#[test]
fn declared_env_strips_credential_shaped_declarations() {
std::env::set_var("GITHUB_TOKEN", "leaked-token");
std::env::set_var("STRIPE_API_KEY", "leaked-key");
std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
let policy = CapabilityPolicy {
env_allow: vec![
"GITHUB_TOKEN".to_string(),
"STRIPE_API_KEY".to_string(),
"MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
],
shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
..CapabilityPolicy::default()
};
let env = build_declared_env(&policy);
assert!(env.is_empty(), "expected all names stripped, got {env:?}");
}
#[test]
fn declared_env_delivers_a_credential_shaped_name_the_backstop_keeps() {
std::env::set_var("PRIVATE_KEY", "host-private-key");
std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
let policy = CapabilityPolicy {
env_allow: vec![
"PRIVATE_KEY".to_string(),
"MURMUR_TEST_WASI_PLAIN".to_string(),
],
..CapabilityPolicy::default()
};
let env = build_declared_env(&policy);
assert_eq!(
env.get("PRIVATE_KEY"),
Some(&"host-private-key".to_string())
);
assert_eq!(
env.get("MURMUR_TEST_WASI_PLAIN"),
Some(&"host-plain".to_string())
);
}
#[test]
fn build_shell_env_home_override_cannot_survive() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_baseline_env: vec!["HOME".to_string()],
..CapabilityPolicy::default()
};
let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
let env =
build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
assert_eq!(
env.get("HOME"),
Some(&temp.path().to_string_lossy().into_owned())
);
}
#[test]
fn build_shell_env_keeps_safe_baseline_vars() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy::default();
std::env::set_var("CARGO_HOME", "/fake/cargo/home");
let env = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
std::env::remove_var("CARGO_HOME");
assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
}
#[test]
fn build_shell_env_carries_the_hosts_account_names() {
let temp = tempdir().unwrap();
let root = crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap();
let _guard = ACCOUNT_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
std::env::set_var("USER", "murmur-test-user");
std::env::set_var("LOGNAME", "murmur-test-login");
let env = build_shell_env(
&CapabilityPolicy::default(),
&[],
Some(root.path()),
None,
None,
None,
)
.unwrap();
let stripped = build_shell_env(
&CapabilityPolicy {
shell_strip_env: vec!["LOGNAME".to_string()],
..CapabilityPolicy::default()
},
&[],
Some(root.path()),
None,
None,
None,
)
.unwrap();
assert_eq!(
env.get("USER").map(String::as_str),
Some("murmur-test-user")
);
assert_eq!(
env.get("LOGNAME").map(String::as_str),
Some("murmur-test-login")
);
assert_eq!(
env.get("HOME"),
Some(&temp.path().to_string_lossy().into_owned())
);
assert!(!stripped.contains_key("LOGNAME"), "{stripped:?}");
assert_eq!(
stripped.get("USER").map(String::as_str),
Some("murmur-test-user")
);
let guest = build_declared_env(&CapabilityPolicy::default());
assert!(
!guest.contains_key("USER") && !guest.contains_key("LOGNAME"),
"a WASM guest is handed only what env.allow declares: {guest:?}"
);
}
#[test]
fn host_account_with_both_names_set_reports_none_unset() {
let account = HostAccountEnv::from_pairs(&[("USER", "alice"), ("LOGNAME", "alice-login")]);
assert!(account.unset().is_empty());
let mut env = BTreeMap::new();
account.apply(&mut env);
assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
assert_eq!(env.get("LOGNAME").map(String::as_str), Some("alice-login"));
}
#[test]
fn host_account_with_one_name_set_never_copies_it_to_the_other() {
let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
assert_eq!(account.unset(), ["LOGNAME".to_string()]);
let mut env = BTreeMap::new();
account.apply(&mut env);
assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
assert!(!env.contains_key("LOGNAME"), "{env:?}");
}
#[test]
fn host_account_with_neither_name_set_reports_both_sorted() {
let account = HostAccountEnv::from_pairs(&[]);
assert_eq!(account.unset(), ["LOGNAME".to_string(), "USER".to_string()]);
let mut env = BTreeMap::new();
account.apply(&mut env);
assert!(env.is_empty(), "{env:?}");
}
#[test]
fn host_account_apply_replaces_a_stale_value() {
let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
let mut env = BTreeMap::from([
("USER".to_string(), "someone-else".to_string()),
("LOGNAME".to_string(), "someone-else".to_string()),
("PATH".to_string(), "/bin".to_string()),
]);
account.apply(&mut env);
assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
assert!(!env.contains_key("LOGNAME"), "{env:?}");
assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
}
#[test]
fn build_shell_env_developer_declared_strip_env_still_composes() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_strip_env: vec!["MYCOMPANY_*".to_string()],
..CapabilityPolicy::default()
};
let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
let env =
build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
assert!(!env.contains_key("MYCOMPANY_SECRET"));
}
#[test]
fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
let extra = vec!["*_SERVICE_SECRET".to_string()];
let names = [
"ANTHROPIC_API_KEY",
"GITHUB_TOKEN",
"GITHUB_TOKEN_2",
"AWS_ACCESS_KEY_ID",
"MY_AWS_KEY",
"STRIPE_API_KEY",
"KUBECONFIG",
"MY_SERVICE_SECRET",
"DATABASE_PASSWORD",
"SLACK_BOT_TOKEN",
"HOME",
"TZ",
];
let mut env: BTreeMap<String, String> = names
.iter()
.map(|name| ((*name).to_string(), "value".to_string()))
.collect();
strip_credential_shaped_vars(&mut env, &extra);
for name in names {
assert_eq!(
credential_backstop_drops(name, &extra),
!env.contains_key(name),
"prediction and strip disagree about {name}"
);
assert_eq!(
credential_backstop_match(name, &extra).is_some(),
!env.contains_key(name),
"match and strip disagree about {name}"
);
}
}
#[test]
fn credential_backstop_match_attributes_a_builtin_pattern() {
assert_eq!(
credential_backstop_match("AWS_REGION", &[]),
Some(BackstopMatch {
pattern: "AWS_*".to_string(),
source: BackstopPatternSource::Builtin,
})
);
assert_eq!(
credential_backstop_match("GITHUB_TOKEN", &[]),
Some(BackstopMatch {
pattern: "GITHUB_TOKEN".to_string(),
source: BackstopPatternSource::Builtin,
})
);
}
#[test]
fn credential_backstop_match_attributes_a_strip_env_pattern() {
assert_eq!(
credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
Some(BackstopMatch {
pattern: "*_SERVICE_SECRET".to_string(),
source: BackstopPatternSource::StripEnv,
})
);
}
#[test]
fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
assert_eq!(
credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
Some(BackstopMatch {
pattern: "*_API_KEY".to_string(),
source: BackstopPatternSource::Builtin,
})
);
}
#[test]
fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
assert_eq!(credential_backstop_match("TZ", &[]), None);
assert_eq!(
credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
None
);
}
#[test]
fn env_name_matches_pattern_trailing_wildcard() {
assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
}
#[test]
fn env_name_matches_pattern_leading_wildcard() {
assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
}
#[test]
fn env_name_matches_pattern_exact_match() {
assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
}
#[test]
fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
let yaml = shell_tool_manifest_yaml("bash");
serde_norway::from_str::<serde_norway::Value>(&yaml)
.expect("bash manifest must be valid YAML");
}
#[test]
fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
let yaml = shell_tool_manifest_yaml("curl");
serde_norway::from_str::<serde_norway::Value>(&yaml)
.expect("curl manifest must be valid YAML — embedded quotes break YAML parsing");
}
#[test]
fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
let yaml = shell_tool_manifest_yaml("curl");
let value: serde_norway::Value = serde_norway::from_str(&yaml).unwrap();
assert_eq!(
value.get("runtime").and_then(|v| v.as_str()),
Some("tool"),
"non-interpreter manifest must have runtime: tool so inventory picks it up"
);
}
#[test]
fn a_direct_binary_description_states_the_argument_rule_and_nothing_else() {
for binary in ["curl", "git", "jq", "python3"] {
let yaml = shell_tool_manifest_yaml(binary);
let value: serde_norway::Value = serde_norway::from_str(&yaml)
.unwrap_or_else(|e| panic!("{binary} manifest must be valid YAML: {e}"));
let description = value
.get("description")
.and_then(|v| v.as_str())
.expect("the manifest has a description");
assert_eq!(
description,
format!(
"Run {binary} in the capsule workdir. The `command` field holds {binary}'s \
arguments and not the binary name. It is split into arguments at whitespace; \
single or double quotes group words, and a backslash escapes the next \
character. No shell runs it, so nothing in it is expanded."
)
);
assert!(!description.contains("example.com"), "{description}");
assert!(!description.contains(" -s "), "{description}");
assert_eq!(yaml, shell_tool_manifest_yaml(binary));
}
}
#[test]
fn an_abandoned_command_demotes_before_its_grace() {
an_abandoned_command_demotes_under(Some(Duration::from_secs(60)));
}
#[test]
fn an_abandoned_command_demotes_with_no_grace() {
an_abandoned_command_demotes_under(None);
}
fn an_abandoned_command_demotes_under(grace: Option<Duration>) {
use std::sync::Arc;
use crate::{
detached::{AbandonSignal, DetachPolicy, DetachedRegistry},
origin::{TaskOrigin, TaskProvenance},
};
let temp = tempdir().unwrap();
let runtime = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let (registry, _reports) = DetachedRegistry::new();
let abandon = AbandonSignal::new();
let raiser = {
let abandon = abandon.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
abandon.raise();
Instant::now()
})
};
let started = Instant::now();
let outcome = run_shell(
"bash",
&["-c", "sleep 5"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&ShellEnforcement::environment_only()
.with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
Some(DetachPolicy {
grace,
delivered: true,
registry: Arc::clone(®istry),
command: "sleep 5".to_string(),
context_id: "ctx_abandon_test".to_string(),
provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
abandoned: Some(abandon),
canceled: None,
}),
)
.expect("a declared binary runs");
let returned = Instant::now();
let elapsed = returned - started;
let raised = raiser.join().unwrap();
let ShellOutcome::Detached(info) = outcome else {
panic!("an abandoned command must demote, got {outcome:?}");
};
assert!(
elapsed < Duration::from_secs(2),
"demotion waited {elapsed:?}, as if the command had to finish first"
);
assert!(
returned.saturating_duration_since(raised) < Duration::from_secs(1),
"demotion came {:?} after the abandon",
returned.saturating_duration_since(raised)
);
assert_eq!(
info.grace_ms,
grace.map(|grace| u64::try_from(grace.as_millis()).unwrap())
);
assert!(
registry
.outstanding()
.iter()
.any(|work| work.work_id == info.work_id),
"the demoted command is registered like one that outran its grace"
);
}
fn cancel_test_policy(
registry: &std::sync::Arc<crate::detached::DetachedRegistry>,
command: &str,
canceled: crate::cancel::CancelSignal,
grace: Option<Duration>,
) -> crate::detached::DetachPolicy {
use crate::origin::{TaskOrigin, TaskProvenance};
crate::detached::DetachPolicy {
grace,
delivered: true,
registry: std::sync::Arc::clone(registry),
command: command.to_string(),
context_id: "ctx_cancel_test".to_string(),
provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
abandoned: None,
canceled: Some(canceled),
}
}
#[test]
fn a_canceled_task_demotes_its_foreground_command() {
a_canceled_task_demotes_its_foreground_command_under(Some(Duration::from_secs(60)));
}
#[test]
fn a_canceled_task_demotes_its_foreground_command_with_no_grace() {
a_canceled_task_demotes_its_foreground_command_under(None);
}
fn a_canceled_task_demotes_its_foreground_command_under(grace: Option<Duration>) {
use crate::{
cancel::{CancelSignal, PHASE_SHELL},
detached::{DetachedRegistry, KillRunningOnDrop},
};
let temp = tempdir().unwrap();
let runtime = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let (registry, _reports) = DetachedRegistry::new();
let _kill = KillRunningOnDrop(std::sync::Arc::clone(®istry));
let cancel = CancelSignal::new();
let raiser = {
let cancel = cancel.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
cancel.cancel();
Instant::now()
})
};
let started = Instant::now();
let outcome = run_shell(
"bash",
&["-c", "sleep 30"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&ShellEnforcement::environment_only()
.with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
Some(cancel_test_policy(
®istry,
"sleep 30",
cancel.clone(),
grace,
)),
)
.expect("a declared binary runs");
let returned = Instant::now();
let elapsed = returned - started;
let raised = raiser.join().unwrap();
let ShellOutcome::Detached(info) = outcome else {
panic!("a canceled task's command must demote, got {outcome:?}");
};
assert!(
elapsed < Duration::from_secs(2),
"demotion waited {elapsed:?}, as if the command had to finish first"
);
assert!(
returned.saturating_duration_since(raised) < Duration::from_secs(1),
"demotion came {:?} after the cancel",
returned.saturating_duration_since(raised)
);
assert!(
registry
.outstanding()
.iter()
.any(|work| work.work_id == info.work_id),
"the demoted command is registered, so the cancel residue names it"
);
assert_eq!(cancel.phase(), PHASE_SHELL);
}
#[test]
fn an_earlier_claim_keeps_the_phase_over_the_shell() {
use crate::{
cancel::{CancelSignal, PHASE_INFERENCE},
detached::{DetachedRegistry, KillRunningOnDrop},
};
let temp = tempdir().unwrap();
let runtime = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let (registry, _reports) = DetachedRegistry::new();
let _kill = KillRunningOnDrop(std::sync::Arc::clone(®istry));
let cancel = CancelSignal::new();
cancel.note_phase(PHASE_INFERENCE);
let raiser = {
let cancel = cancel.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
cancel.cancel();
})
};
let outcome = run_shell(
"bash",
&["-c", "sleep 30"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&ShellEnforcement::environment_only()
.with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
Some(cancel_test_policy(
®istry,
"sleep 30",
cancel.clone(),
Some(Duration::from_secs(60)),
)),
)
.expect("a declared binary runs");
raiser.join().unwrap();
assert!(matches!(outcome, ShellOutcome::Detached(_)), "{outcome:?}");
assert_eq!(cancel.phase(), PHASE_INFERENCE);
}
#[test]
fn a_canceled_task_starts_no_command() {
a_canceled_task_starts_no_command_under(Some(Duration::from_secs(60)));
}
#[test]
fn a_canceled_task_starts_no_command_with_no_grace() {
a_canceled_task_starts_no_command_under(None);
}
fn a_canceled_task_starts_no_command_under(grace: Option<Duration>) {
use crate::{cancel::CancelSignal, detached::DetachedRegistry};
let temp = tempdir().unwrap();
let runtime = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let (registry, _reports) = DetachedRegistry::new();
let cancel = CancelSignal::new();
cancel.cancel();
let result = run_shell(
"bash",
&["-c", "touch started"],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&policy,
&ShellEnforcement::environment_only()
.with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
Some(cancel_test_policy(
®istry,
"touch started",
cancel.clone(),
grace,
)),
);
match result {
Err(ShellExecError::Failed(message)) => assert_eq!(message, CANCELED_BEFORE_SPAWN),
other => panic!("a canceled task's call must be refused, got {other:?}"),
}
assert!(!temp.path().join("started").exists(), "nothing was spawned");
assert!(registry.outstanding().is_empty(), "nothing was registered");
}
fn run_until_stopped(
workdir: &std::path::Path,
runtime: &std::path::Path,
script: &str,
stop: ForegroundStop,
) -> ForegroundRun {
let mut enforcement = ShellEnforcement::environment_only()
.with_runtime_dir(crate::sandbox::test_runtime_dir(runtime), None);
enforcement.resource_limits.max_processes = 1 << 20;
run_shell_until_stopped(
"bash",
&["-c", script],
&[],
&crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
&CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
},
&enforcement,
stop,
)
.expect("a declared binary runs")
}
const STOPPABLE_SCRIPT: &str = "sleep 300 & echo $! > sleeper.pid; echo started; sleep 300";
#[test]
fn a_foreground_stop_kills_the_group_on_cancel() {
let temp = tempdir().unwrap();
let runtime = tempdir().unwrap();
let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
let cancel = crate::cancel::CancelSignal::new();
let raiser = {
let cancel = cancel.clone();
let pid_file = temp.path().join("sleeper.pid");
std::thread::spawn(move || {
while !pid_file.exists() {
std::thread::sleep(Duration::from_millis(10));
}
std::thread::sleep(Duration::from_millis(100));
cancel.cancel();
Instant::now()
})
};
let run = run_until_stopped(
temp.path(),
runtime.path(),
STOPPABLE_SCRIPT,
ForegroundStop {
canceled: cancel,
timeout: Duration::from_secs(60),
},
);
let raised = raiser.join().unwrap();
assert!(
raised.elapsed() < Duration::from_secs(1),
"the call returned {:?} after the cancel",
raised.elapsed()
);
assert_eq!(run.stopped, Some(ForegroundStopReason::Canceled));
assert!(run.result.stdout.contains("started"), "{:?}", run.result);
assert_eq!(run.result.resource_limit_hit, None);
let sleeper = read_pid(&temp.path().join("sleeper.pid"));
assert!(
process_dead_within(sleeper, Duration::from_secs(1)),
"the background sleeper {sleeper} outlived the stop"
);
}
#[test]
fn a_foreground_stop_kills_the_group_at_the_timeout() {
let temp = tempdir().unwrap();
let runtime = tempdir().unwrap();
let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
let started = Instant::now();
let run = run_until_stopped(
temp.path(),
runtime.path(),
STOPPABLE_SCRIPT,
ForegroundStop {
canceled: crate::cancel::CancelSignal::new(),
timeout: Duration::from_secs(1),
},
);
let elapsed = started.elapsed();
assert!(
elapsed >= Duration::from_secs(1) && elapsed < Duration::from_secs(2),
"the call returned after {elapsed:?} for a 1 s timeout"
);
assert_eq!(run.stopped, Some(ForegroundStopReason::TimedOut));
assert!(run.result.stdout.contains("started"), "{:?}", run.result);
let sleeper = read_pid(&temp.path().join("sleeper.pid"));
assert!(
process_dead_within(sleeper, Duration::from_secs(1)),
"the background sleeper {sleeper} outlived the timeout"
);
}
#[test]
fn a_foreground_stop_leaves_a_finished_command_alone() {
let temp = tempdir().unwrap();
let runtime = tempdir().unwrap();
let run = run_until_stopped(
temp.path(),
runtime.path(),
"echo out; echo err >&2; exit 3",
ForegroundStop {
canceled: crate::cancel::CancelSignal::new(),
timeout: Duration::from_secs(60),
},
);
assert_eq!(run.stopped, None);
assert_eq!(run.result.exit_code, 3);
assert_eq!(run.result.stdout, "out\n");
assert_eq!(run.result.stderr, "err\n");
}
fn returns_within_30_s<T: Send + 'static>(run: impl FnOnce() -> T + Send + 'static) -> T {
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = sender.send(run());
});
receiver
.recv_timeout(Duration::from_secs(30))
.unwrap_or_else(|_| panic!("the shell call did not return within 30 s"))
}
#[test]
fn a_background_holder_does_not_hold_a_graced_call() {
use std::sync::Arc;
use crate::{
detached::{DetachPolicy, DetachedRegistry},
origin::{TaskOrigin, TaskProvenance},
};
let temp = tempdir().unwrap();
let runtime = tempdir().unwrap();
let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
let workdir = temp.path().to_path_buf();
let runtime_base = runtime.path().to_path_buf();
let script = "sleep 300 & echo $! > sleeper.pid; echo started";
let started = Instant::now();
let outcome = returns_within_30_s(move || {
let (registry, _reports) = DetachedRegistry::new();
run_shell(
"bash",
&["-c", script],
&[],
&crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
&CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
},
&ShellEnforcement::environment_only()
.with_runtime_dir(crate::sandbox::test_runtime_dir(&runtime_base), None),
Some(DetachPolicy {
grace: Some(Duration::from_secs(5)),
delivered: true,
registry: Arc::clone(®istry),
command: script.to_string(),
context_id: "ctx_holder_test".to_string(),
provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
abandoned: None,
canceled: None,
}),
)
.expect("a declared binary runs")
});
let elapsed = started.elapsed();
let ShellOutcome::Finished(result) = outcome else {
panic!("a command that exits at once must finish, got {outcome:?}");
};
assert!(
elapsed < Duration::from_secs(5),
"the call took {elapsed:?}, past its 5 s grace"
);
assert!(result.stdout.contains("started"), "{result:?}");
assert_eq!(result.exit_code, 0, "{result:?}");
let sleeper = read_pid(&temp.path().join("sleeper.pid"));
assert!(
process_dead_within(sleeper, Duration::from_secs(2)),
"the background sleeper {sleeper} is still running"
);
}
#[test]
fn a_background_holder_does_not_hold_an_ungraced_call() {
let temp = tempdir().unwrap();
let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
let workdir = temp.path().to_path_buf();
let started = Instant::now();
let result = returns_within_30_s(move || {
execute_shell(
"bash",
&["-c", "sleep 300 & echo $! > sleeper.pid; echo started"],
&[],
&crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
&CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
},
&ShellEnforcement::environment_only().with_test_session_dirs(),
)
.expect("a declared binary runs")
});
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(10),
"the call took {elapsed:?}"
);
assert!(result.stdout.contains("started"), "{result:?}");
assert_eq!(result.exit_code, 0, "{result:?}");
let sleeper = read_pid(&temp.path().join("sleeper.pid"));
assert!(
process_dead_within(sleeper, Duration::from_secs(2)),
"the background sleeper {sleeper} is still running"
);
assert_eq!(result.output_holders, OutputHolders::Killed);
}
#[test]
fn output_written_just_after_exit_is_kept() {
use std::sync::Arc;
use crate::{
detached::{DetachPolicy, DetachedRegistry},
origin::{TaskOrigin, TaskProvenance},
};
let temp = tempdir().unwrap();
let runtime = tempdir().unwrap();
let (registry, _reports) = DetachedRegistry::new();
let script = "(sleep 0.3; echo late) & echo early";
let outcome = run_shell(
"bash",
&["-c", script],
&[],
&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
&CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
},
&ShellEnforcement::environment_only()
.with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
Some(DetachPolicy {
grace: Some(Duration::from_secs(5)),
delivered: true,
registry: Arc::clone(®istry),
command: script.to_string(),
context_id: "ctx_late_output_test".to_string(),
provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
abandoned: None,
canceled: None,
}),
)
.expect("a declared binary runs");
let ShellOutcome::Finished(result) = outcome else {
panic!("a command that exits at once must finish, got {outcome:?}");
};
assert!(result.stdout.contains("early"), "{result:?}");
assert!(result.stdout.contains("late"), "{result:?}");
assert_eq!(result.output_holders, OutputHolders::Closed);
assert!(OutputHolders::Closed.note().is_none());
}
#[cfg(target_os = "linux")]
#[test]
fn a_holder_outside_the_process_group_is_reported_not_waited_for() {
use std::sync::Arc;
use crate::{
detached::{DetachPolicy, DetachedRegistry},
origin::{TaskOrigin, TaskProvenance},
};
let temp = tempdir().unwrap();
let runtime = tempdir().unwrap();
let pid_file = temp.path().join("escaped.pid");
let escaped_guard = KillPidFileOnDrop(pid_file.clone());
let workdir = temp.path().to_path_buf();
let runtime_base = runtime.path().to_path_buf();
let script = "setsid sleep 300 & echo $! > escaped.pid; echo started";
let started = Instant::now();
let outcome = returns_within_30_s(move || {
let (registry, _reports) = DetachedRegistry::new();
run_shell(
"bash",
&["-c", script],
&[],
&crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
&CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
},
&ShellEnforcement::environment_only()
.with_runtime_dir(crate::sandbox::test_runtime_dir(&runtime_base), None),
Some(DetachPolicy {
grace: Some(Duration::from_secs(5)),
delivered: true,
registry: Arc::clone(®istry),
command: script.to_string(),
context_id: "ctx_escaped_test".to_string(),
provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
abandoned: None,
canceled: None,
}),
)
.expect("a declared binary runs")
});
let elapsed = started.elapsed();
let ShellOutcome::Finished(result) = outcome else {
panic!("a command that exits at once must finish, got {outcome:?}");
};
let bound = Duration::from_secs(5) + LEFTOVER_KILL_WAIT + Duration::from_secs(2);
assert!(elapsed < bound, "the call took {elapsed:?}, past {bound:?}");
assert_eq!(result.stdout.trim(), "started", "{result:?}");
assert_eq!(result.output_holders, OutputHolders::Escaped);
let escaped = read_pid(&pid_file);
assert!(
!process_dead_within(escaped, Duration::ZERO),
"a holder outside the process group is not the call's to kill"
);
drop(escaped_guard);
assert!(process_dead_within(escaped, Duration::from_secs(2)));
}
#[test]
fn a_shared_window_attributes_no_cgroup_delta() {
assert_eq!(
attribute_resource_limit(None, Some("cgroup_memory_bytes"), true).as_deref(),
Some("cgroup_memory_bytes"),
"a call alone in the scope owns its delta"
);
assert_eq!(
attribute_resource_limit(None, Some("cgroup_memory_bytes"), false),
None,
"a delta from a shared window could be a sibling's"
);
assert_eq!(
attribute_resource_limit(Some("cpu_seconds"), Some("cgroup_memory_bytes"), false)
.as_deref(),
Some("cpu_seconds"),
"the signal is per-process evidence and needs no exclusive window"
);
assert_eq!(attribute_resource_limit(None, None, true), None);
}
#[test]
fn build_shell_env_sets_the_runtime_dir_last_whatever_else_says() {
let workdir = tempfile::tempdir().unwrap();
let root = WorkdirRoot::open(workdir.path()).unwrap();
std::env::set_var(crate::runtime_dir::RUNTIME_DIR_ENV, "/host/value");
let policy = CapabilityPolicy {
shell_baseline_env: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
env_allow: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
..CapabilityPolicy::default()
};
let overrides = [(
crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
"/override".to_string(),
)];
let env = build_shell_env(
&policy,
&overrides,
Some(root.path()),
Some(std::path::Path::new("/runtime/dir")),
None,
None,
)
.unwrap();
assert_eq!(
env.get(crate::runtime_dir::RUNTIME_DIR_ENV)
.map(String::as_str),
Some("/runtime/dir")
);
let without =
build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
assert!(!without.contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
assert!(!build_declared_env(&policy).contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
}
#[test]
fn build_unconfined_env_inherits_everything_but_the_lineage_names() {
let get = |env: &BTreeMap<OsString, OsString>, name: &str| {
env.get(std::ffi::OsStr::new(name))
.map(|value| value.to_string_lossy().into_owned())
};
let removed: Vec<&str> = LINEAGE_ENV
.iter()
.copied()
.chain([
crate::formation::FORMATION_PEERS_ENV,
crate::formation_credentials::FORMATION_CHANNEL_ENV,
crate::lifeline::FORMATION_LIFELINE_ENV,
ARTIFACT_CONFIG_ENV,
])
.collect();
let host: BTreeMap<OsString, OsString> = removed
.iter()
.map(|name| (*name, "host-value"))
.chain([
("GITHUB_TOKEN", "unconfined-sentinel"),
("MURMUR_TEST_UNCONFINED", "present"),
("MURMUR_TEST_STRIPPED", "gone"),
("HOME", "/home/operator"),
("TMPDIR", "/var/tmp/operator"),
(crate::runtime_dir::RUNTIME_DIR_ENV, "/host/runtime"),
(crate::records_dir::RECORDS_DIR_ENV, "/host/records"),
])
.map(|(name, value)| (OsString::from(name), OsString::from(value)))
.collect();
let policy = CapabilityPolicy {
shell_strip_env: vec!["MURMUR_TEST_STRIP*".to_string()],
..CapabilityPolicy::default()
};
let overrides = [
(
crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
"/override".to_string(),
),
(
crate::delegation::SPAWNER_ENV.to_string(),
"override".to_string(),
),
("GUEST_SET".to_string(), "yes".to_string()),
];
let env = build_unconfined_env(
&policy,
host.clone(),
&overrides,
Some(std::path::Path::new("/session/runtime")),
Some(std::path::Path::new("/session/records")),
);
for name in &removed {
assert_eq!(
get(&env, name),
None,
"{name} reached an unconfined command"
);
}
assert_eq!(
get(&env, "GITHUB_TOKEN").as_deref(),
Some("unconfined-sentinel")
);
assert_eq!(
get(&env, "MURMUR_TEST_UNCONFINED").as_deref(),
Some("present")
);
assert_eq!(get(&env, "MURMUR_TEST_STRIPPED"), None);
assert_eq!(get(&env, "HOME").as_deref(), Some("/home/operator"));
assert_eq!(get(&env, "TMPDIR").as_deref(), Some("/var/tmp/operator"));
assert_eq!(get(&env, "GUEST_SET").as_deref(), Some("yes"));
assert_eq!(
get(&env, crate::runtime_dir::RUNTIME_DIR_ENV).as_deref(),
Some("/session/runtime")
);
assert_eq!(
get(&env, crate::records_dir::RECORDS_DIR_ENV).as_deref(),
Some("/session/records")
);
let without = build_unconfined_env(&policy, host, &overrides, None, None);
assert_eq!(get(&without, crate::runtime_dir::RUNTIME_DIR_ENV), None);
assert_eq!(get(&without, crate::records_dir::RECORDS_DIR_ENV), None);
}
#[test]
fn subprocess_env_appends_the_pinned_bin_folder_last_on_every_tier() {
use crate::pinned_programs::{PinnedSource, ResolvedPinnedProgram};
let bin = std::path::PathBuf::from("/home/u/.murmur/programs/abc/bin");
let program = |source: PinnedSource| ResolvedPinnedProgram {
artifact: "local/pinned-tool".to_string(),
program: "fixture-prog".to_string(),
platform: "linux-x86_64".to_string(),
source,
};
let policy = CapabilityPolicy {
shell_pinned_programs: vec![program(PinnedSource::Pinned {
path: bin.join("../files/fixture-prog"),
bin_dir: bin.clone(),
tree: bin.parent().unwrap().to_path_buf(),
url: "http://127.0.0.1:1/f".to_string(),
sha256: "abc".to_string(),
})],
..CapabilityPolicy::default()
};
let contained =
crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs();
let unconfined = crate::sandbox::ShellEnforcement {
tier: crate::sandbox::EnforcementTier::Unconfined,
..contained.clone()
};
let path_key = std::ffi::OsStr::new("PATH");
for enforcement in [&contained, &unconfined] {
let without = subprocess_env(&CapabilityPolicy::default(), &[], enforcement).unwrap();
let with = subprocess_env(&policy, &[], enforcement).unwrap();
let host = without.get(path_key).cloned().unwrap_or_default();
let mut expected = host.clone();
if !expected.is_empty() {
expected.push(":");
}
expected.push(&bin);
assert_eq!(
with.get(path_key),
Some(&expected),
"{:?}",
enforcement.tier
);
assert!(!host.to_string_lossy().contains(".murmur/programs"));
}
let mut empty = BTreeMap::new();
append_pinned_program_path(&mut empty, &policy).unwrap();
assert_eq!(empty.get(path_key), Some(&bin.clone().into_os_string()));
let host_choice = CapabilityPolicy {
shell_pinned_programs: vec![program(PinnedSource::Host {
path: "/usr/bin/fixture-prog".into(),
})],
..CapabilityPolicy::default()
};
let mut env = BTreeMap::from([(OsString::from("PATH"), OsString::from("/usr/bin"))]);
append_pinned_program_path(&mut env, &host_choice).unwrap();
assert_eq!(env.get(path_key), Some(&OsString::from("/usr/bin")));
}
#[test]
fn subprocess_env_follows_the_enforcement_tier() {
let policy = CapabilityPolicy::default();
let contained =
crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs();
let unconfined = crate::sandbox::ShellEnforcement {
tier: crate::sandbox::EnforcementTier::Unconfined,
..contained.clone()
};
let session_home = contained.home_dir_path().unwrap().as_os_str().to_owned();
let home = std::ffi::OsStr::new("HOME");
assert_eq!(
subprocess_env(&policy, &[], &contained).unwrap().get(home),
Some(&session_home)
);
let unconfined_home = subprocess_env(&policy, &[], &unconfined)
.unwrap()
.get(home)
.cloned();
assert_eq!(unconfined_home, std::env::var_os("HOME"));
assert_ne!(unconfined_home, Some(session_home));
}
#[test]
fn build_shell_env_sets_the_records_dir_last_whatever_else_says() {
let workdir = tempfile::tempdir().unwrap();
let root = WorkdirRoot::open(workdir.path()).unwrap();
std::env::set_var(crate::records_dir::RECORDS_DIR_ENV, "/host/records");
let policy = CapabilityPolicy {
shell_baseline_env: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
env_allow: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
..CapabilityPolicy::default()
};
let overrides = [(
crate::records_dir::RECORDS_DIR_ENV.to_string(),
"/override".to_string(),
)];
let env = build_shell_env(
&policy,
&overrides,
Some(root.path()),
None,
Some(std::path::Path::new("/records/dir")),
None,
)
.unwrap();
assert_eq!(
env.get(crate::records_dir::RECORDS_DIR_ENV)
.map(String::as_str),
Some("/records/dir")
);
let without =
build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
assert!(!without.contains_key(crate::records_dir::RECORDS_DIR_ENV));
assert!(!build_declared_env(&policy).contains_key(crate::records_dir::RECORDS_DIR_ENV));
}
#[test]
fn build_shell_env_drops_the_artifact_config_name_whatever_else_says() {
let workdir = tempfile::tempdir().unwrap();
let root = WorkdirRoot::open(workdir.path()).unwrap();
let names = [ARTIFACT_CONFIG_ENV];
for name in names {
std::env::set_var(name, "/host/value");
}
let policy = CapabilityPolicy {
shell_baseline_env: names.iter().map(|name| name.to_string()).collect(),
env_allow: names.iter().map(|name| name.to_string()).collect(),
..CapabilityPolicy::default()
};
let overrides: Vec<(String, String)> = names
.iter()
.map(|name| (name.to_string(), "/override".to_string()))
.collect();
let env =
build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
let declared = build_declared_env(&policy);
for name in names {
std::env::remove_var(name);
}
for name in names {
assert!(!env.contains_key(name), "{name}: {env:?}");
assert!(!declared.contains_key(name), "{name}: {declared:?}");
}
}
#[test]
fn build_shell_env_owns_tmpdir_tmp_and_temp() {
use crate::temp_dir::{NotMappedReason, TempMapping, TEMP_DIR_ENV};
let workdir = tempfile::tempdir().unwrap();
let root = WorkdirRoot::open(workdir.path()).unwrap();
let base = tempfile::tempdir().unwrap();
let roots = crate::sandbox::test_runtime_dir(base.path());
let folder = roots.temp.as_ref().unwrap().path().to_path_buf();
let host_tmp = std::env::temp_dir().join("murmur-host-tmp");
std::fs::create_dir_all(&host_tmp).unwrap();
let saved: Vec<_> = TEMP_DIR_ENV
.iter()
.map(|name| (*name, std::env::var_os(name)))
.collect();
for name in TEMP_DIR_ENV {
std::env::set_var(name, &host_tmp);
}
let policy = CapabilityPolicy {
shell_baseline_env: TEMP_DIR_ENV.iter().map(|name| name.to_string()).collect(),
..CapabilityPolicy::default()
};
let overrides: Vec<(String, String)> = TEMP_DIR_ENV
.iter()
.map(|name| (name.to_string(), "/override".to_string()))
.collect();
let held = |tier, mapping| {
crate::sandbox::ShellEnforcement {
tier,
..crate::sandbox::ShellEnforcement::environment_only()
}
.with_runtime_dir(roots.clone(), None)
.with_temp_mapping(mapping)
};
let folder_text = folder.to_string_lossy().into_owned();
let cases = [
(
held(
crate::sandbox::EnforcementTier::KernelFull,
TempMapping::MountNamespace,
),
Some("/tmp".to_string()),
),
(
held(
crate::sandbox::EnforcementTier::KernelSealed,
TempMapping::ComposedRoot,
),
Some("/tmp".to_string()),
),
(
held(
crate::sandbox::EnforcementTier::KernelFull,
TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
),
Some(folder_text.clone()),
),
(
held(
crate::sandbox::EnforcementTier::KernelSeccompOnly,
TempMapping::NotMapped(NotMappedReason::NoMountNamespaceNoLandlock),
),
Some(folder_text.clone()),
),
(
held(
crate::sandbox::EnforcementTier::KernelFull,
TempMapping::NotMapped(NotMappedReason::WorkdirIsTmp),
),
Some(folder_text.clone()),
),
(
held(
crate::sandbox::EnforcementTier::EnvironmentOnly,
TempMapping::NotMapped(NotMappedReason::NoKernelContainment),
),
Some(folder_text.clone()),
),
(crate::sandbox::ShellEnforcement::environment_only(), None),
];
for (enforcement, expected) in cases {
assert_eq!(
enforcement.tmpdir_value(),
expected,
"{:?}",
enforcement.tier
);
let env = build_shell_env(
&policy,
&overrides,
Some(root.path()),
None,
None,
enforcement.tmpdir_value().as_deref(),
)
.unwrap();
for name in TEMP_DIR_ENV {
assert_eq!(
env.get(name),
expected.as_ref(),
"{name} on {:?}",
enforcement.tier
);
}
}
for (name, value) in saved {
match value {
Some(value) => std::env::set_var(name, value),
None => std::env::remove_var(name),
}
}
}
#[test]
fn the_env_baseline_names_no_temp_variable() {
for name in crate::temp_dir::TEMP_DIR_ENV {
assert!(!DEFAULT_ENV_BASELINE.contains(&name), "{name}");
}
}
}
#[cfg(test)]
pub(crate) mod test_support {
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub(crate) fn read_pid(pid_file: &Path) -> u32 {
std::fs::read_to_string(pid_file)
.expect("the command wrote its pid file")
.trim()
.parse()
.expect("the pid file holds a pid")
}
pub(crate) fn process_dead_within(pid: u32, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if !crate::running::pid_is_alive(pid) {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(20));
}
}
pub(crate) struct KillPidFileOnDrop(pub(crate) PathBuf);
impl Drop for KillPidFileOnDrop {
fn drop(&mut self) {
let Some(pid) = std::fs::read_to_string(&self.0)
.ok()
.and_then(|text| text.trim().parse::<libc::pid_t>().ok())
.filter(|pid| *pid > 0)
else {
return;
};
if !process_dead_within(pid.unsigned_abs(), Duration::ZERO) {
#[allow(unsafe_code)]
unsafe {
libc::kill(pid, libc::SIGKILL);
}
}
}
}
}