sandbox-command 0.1.0

An asynchronous `Command` like process launcher that uses `bwrap` or `sandbox-exec` for controlled sandbox process execution.
Documentation
# sandbox-command

`sandbox-command` is an asynchronous Rust `Command` like process launcher that utilizes `bwrap` (linux) or `sandbox-exec` (osx) for launching child processes in a controlled sandbox environment. Useful for running untrusted code, tools, or for use with agents.

## Requirements

- A supported host operating system:
  - Linux with `bwrap` installed and usable by the current user.
  - macOS with `/usr/bin/sandbox-exec` available.

The platform sandbox executable is detected the first time `SandboxCommand` is initialized or spawned. The result is cached for the lifetime of the process.

### Installing Bubblewrap on Linux

Install Bubblewrap using your distribution's package manager. For example:

```bash
# Debian or Ubuntu
sudo apt-get install bubblewrap

# Fedora
sudo dnf install bubblewrap

# Arch
pacman -S bubblewrap
```

Bubblewrap also depends on the host's user-namespace configuration. If the executable is installed but spawning fails, check that unprivileged user namespaces are enabled and that the system permits Bubblewrap to create a sandbox.

## Usage

### Run a command and collect its output

`SandboxCommand` configures the child, and `SandboxChild::wait_with_output` collects both output streams asynchronously. Use `Stdio::piped()` for any stream that should be captured.

```rust
use std::process::Stdio;

use sandbox_command::SandboxCommand;

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    SandboxCommand::initialize()?;

    let mut command = SandboxCommand::new("/usr/bin/printf");
    command
        .arg("hello from the sandbox\n")
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    let output = command.spawn()?.wait_with_output().await?;

    if !output.status.success() {
        return Err(format!("command exited with {}", output.status).into());
    }

    print!("{}", String::from_utf8_lossy(&output.stdout));
    eprintln!("{}", String::from_utf8_lossy(&output.stderr));
    Ok(())
}
```

`initialize` is optional. Calling it during application startup is useful when you want to report an unavailable sandbox before doing other work; `spawn` performs the same platform check if initialization was skipped.

### Set the working directory and filesystem access

The working directory is canonicalized before spawning. It is writable by default. On Linux, `bind_ro` and `bind_rw` add paths at their existing locations inside the sandbox. On macOS, the working directory and paths passed to `bind_rw` are granted write access; the sandbox profile already permits reads required to execute the command.

```rust
use std::{path::Path, process::Stdio};

use sandbox_command::SandboxCommand;

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let workspace = Path::new("/tmp/job");
    let cache = Path::new("/tmp/tool-cache");

    let mut command = SandboxCommand::new("/usr/bin/env");
    command
        .current_dir(workspace)
        .bind_rw(cache)
        .bind_ro("/usr/local/bin")
        .stdout(Stdio::piped());

    let output = command.spawn()?.wait_with_output().await?;
    println!("{}", String::from_utf8_lossy(&output.stdout));
    Ok(())
}
```

Paths supplied to `current_dir`, `bind_ro`, and `bind_rw` should exist and be accessible to the parent process. The library does not create missing directories or copy files into the sandbox.

### Stream output or interact with a child

`spawn` returns a `SandboxChild` instead of immediately waiting. Take the piped handles when you need to process output incrementally or send input to the child.

```rust
use std::process::Stdio;

use sandbox_command::SandboxCommand;
use tokio::io::AsyncReadExt;

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut command = SandboxCommand::new("/usr/bin/printf");
    command.arg("streamed output").stdout(Stdio::piped());

    let mut child = command.spawn()?;
    let mut stdout = child.take_stdout().expect("stdout was piped");
    let mut output = String::new();
    stdout.read_to_string(&mut output).await?;
    let status = child.wait().await?;

    println!("{status}: {output}");
    Ok(())
}
```

Other child-management operations include:

```rust
let process_id = child.id();
let status_if_finished = child.try_wait()?;
child.start_kill()?;       // request termination without awaiting it
child.kill().await?;       // terminate and await the kill operation
```

When using `wait_with_output`, the parent's stdin handle is closed automatically so a child waiting for EOF does not deadlock output collection.

### Configure environment variables and standard I/O

The builder supports the usual environment operations. `env_clear` removes the inherited environment before applying later `env` or `envs` values. Standard streams use Tokio's `Stdio` configuration.

```rust
use std::process::Stdio;

let mut command = SandboxCommand::new("/usr/bin/my-tool");
command
    .env_clear()
    .env("HOME", "/tmp/sandbox-home")
    .envs([(String::from("MODE"), String::from("check"))])
    .env_remove("SECRET_TOKEN")
    .stdin(Stdio::null())
    .stdout(Stdio::piped())
    .stderr(Stdio::piped())
    .kill_on_drop(true);
```

## API overview

### `SandboxCommand`

| Method | Purpose |
| --- | --- |
| `new(program)` | Create a command for the executable path or name. |
| `arg(value)`, `args(values)` | Add command-line arguments. |
| `env(key, value)`, `envs(values)` | Set environment variables. |
| `env_remove(key)`, `env_clear()` | Remove selected or all inherited environment variables. |
| `current_dir(path)` | Set and canonicalize the child's working directory. |
| `stdin(cfg)`, `stdout(cfg)`, `stderr(cfg)` | Configure standard I/O. |
| `kill_on_drop(kill)` | Pass Tokio's kill-on-drop setting to the child process. |
| `bind_ro(path)` | Add a read-only path binding on Linux. |
| `bind_rw(path)` | Add a writable path binding. |
| `spawn()` | Launch the sandboxed process. |

### `SandboxChild`

`SandboxChild` exposes the child's optional `stdin`, `stdout`, and `stderr` handles and provides `take_stdin`, `take_stdout`, and `take_stderr` helpers. It also provides `id`, `wait`, `try_wait`, `kill`, `start_kill`, and `wait_with_output`.

The child owns any temporary macOS sandbox profile created for the process. The profile is removed after the child exits, fails to spawn, or is dropped.

### Errors

Operations return `SandboxCommandError` for library-level failures:

| Variant | Meaning |
| --- | --- |
| `Initialization` | The host sandbox is unavailable or its configuration could not be prepared. |
| `Spawn` | The wrapped command could not be started. The original `std::io::Error` is preserved as the source. |
| `InvalidPath` | The working directory could not be canonicalized. |

Child operations such as `wait` and `kill` return Tokio's `std::io::Result` because they operate on an already-spawned process.

## Sandbox behavior and limitations

The library is a process-launching wrapper, not a complete container runtime. Keep the host-specific behavior in mind when choosing it for a workload:

- Linux uses Bubblewrap and shares the host network with the child (`--share-net`). It exposes `/usr`, `/proc`, and `/dev` as needed for execution and makes the configured working directory available inside the sandbox.
- macOS generates a temporary `sandbox-exec` profile for each child. The profile allows network access, process execution/forking, and broad file reads while restricting writes to the working directory, temporary directories, and paths passed to `bind_rw`.
- The library does not impose CPU, memory, wall-clock, or network quotas.
- The library does not validate whether a command is trusted, sanitize arguments, or automatically collect output.
- A command can still consume resources or produce large output. Applications should add timeouts, output limits, and resource controls appropriate to their threat model.
- Platform sandbox semantics and system policy remain authoritative. Test the exact host configuration used in production.

## Troubleshooting

### `required sandbox executable ... was not found`

Install the platform dependency and ensure it is discoverable:

- Linux: install `bubblewrap` and make sure `bwrap` is on `PATH`.
- macOS: verify that `/usr/bin/sandbox-exec` exists.

### `sandbox_apply: Operation not permitted` on macOS

The executable may be present while the host policy still prevents sandbox creation. This commonly occurs in restricted CI runners, containers, or other nested sandbox environments. Run the command on a host that permits `sandbox-exec`, or adjust the runner's security policy.

### Bubblewrap starts but the child cannot run

Confirm that the working directory and any paths passed to `bind_ro` or `bind_rw` already exist. Also verify that the host permits unprivileged user namespaces and that the command's executable and runtime files are available through the sandbox's filesystem view.