Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable

- uses: taiki-e/install-action@nextest

- run: cargo nextest run

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable

- run: rustup component add clippy

- run: cargo clippy -- -D warnings

insanity-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable

- run: rustup component add clippy rustfmt

- run: make check
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
target/
.bunker/
.*
!.github/

# Documentation
.venv-docs/
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ codegen-units = 1
strip = true
panic = "abort"

[lib]
name = "bunkerbox"
path = "src/lib.rs"

[[bin]]
name = "bunkerbox"
path = "src/main.rs"
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

# Bunkerbox

[![CI](https://github.com/anomalyco/bunkerbox/actions/workflows/ci.yml/badge.svg)](https://github.com/anomalyco/bunkerbox/actions/workflows/ci.yml)
[![Rust](https://img.shields.io/badge/rust-1.97.1-orange)](https://www.rust-lang.org)
[![License](https://img.shields.io/github/license/anomalyco/bunkerbox)](https://github.com/anomalyco/bunkerbox/blob/main/LICENSE)

**Run powerful development agents without handing them your whole machine.**

Bunkerbox is an isolation layer for coding agents, CLIs, and other developer tools that need to work inside a project directory. Instead of running those tools directly on the host, Bunkerbox runs them inside a Kata-backed container with a controlled workspace, controlled home directory, and explicit runtime configuration.
Expand Down
5 changes: 2 additions & 3 deletions src/bin/bunkerbox-image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,10 +314,9 @@ fn find_vscomm_binary() -> Result<PathBuf, String> {
}
}

Err(format!(
"musl-static bunkerbox-vscomm not found in target/x86_64-unknown-linux-musl/{{release,debug}}/\n\
Err("musl-static bunkerbox-vscomm not found in target/x86_64-unknown-linux-musl/{release,debug}/\n\
build it first: make musl-vscomm"
))
.to_string())
}

fn podman_build(config: &ImageConfig, build_dir: &Path) -> Result<(), String> {
Expand Down
6 changes: 1 addition & 5 deletions src/cfg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,7 @@ impl RuntimeConfig {

pub fn effective_session_cleanup(&self) -> Vec<String> {
let user = self.session_cleanup.as_deref().unwrap_or(&[]);
DEFAULT_SESSION_CLEANUP
.iter()
.map(|s| s.to_string())
.chain(user.iter().cloned())
.collect()
DEFAULT_SESSION_CLEANUP.iter().map(|s| s.to_string()).chain(user.iter().cloned()).collect()
}

#[allow(dead_code)]
Expand Down
6 changes: 4 additions & 2 deletions src/cfgsetup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ pub fn run(runtime: Option<&RuntimeConfig>) -> Result<(), String> {

let quota = pick_quota()?;
let env_mode = pick_env_mode()?;
let detected = if env_mode == EnvMode::Paranoid { buildsys::scan(&repo_root, buildsys::PassthroughMode::Paranoid) } else { detected_relaxed.clone() };
let detected =
if env_mode == EnvMode::Paranoid { buildsys::scan(&repo_root, buildsys::PassthroughMode::Paranoid) } else { detected_relaxed.clone() };
let passthrough = build_passthrough(detected, env_mode)?;
let profiles = pick_profiles(&detected_relaxed)?;
let overrides = pick_overrides(runtime)?;

let cfg = ProjectConfig { project: ProjectSection { env: env_mode, quota: Some(quota), exclude: Vec::new(), passthrough }, image: overrides, profiles };
let cfg =
ProjectConfig { project: ProjectSection { env: env_mode, quota: Some(quota), exclude: Vec::new(), passthrough }, image: overrides, profiles };

let path = repo_root.join(ProjectConfig::PATH);
std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| format!("failed to create {}: {e}", path.parent().unwrap().display()))?;
Expand Down
112 changes: 63 additions & 49 deletions src/daemon.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::cfg::EnvMode;
use crate::proxy::FilterProxy;
use crate::sandbox::{resolve_profile, MergedProfile, NetworkMode};
use crate::vscomm::{ExecRequest, Frame, FrameType, VSOCK_PORT};
use std::path::{Path, PathBuf};
Expand All @@ -12,49 +13,47 @@ struct VsockSession {
env_mode: EnvMode,
workspace: PathBuf,
merged_profile: Option<Arc<MergedProfile>>,
has_proxy: bool,
}

pub struct VsockDaemon {
join_handle: tokio::task::JoinHandle<()>,
shutdown: tokio::sync::oneshot::Sender<()>,
proxy_handle: Option<tokio::task::JoinHandle<()>>,
}

impl VsockDaemon {
pub fn start(
passthrough: Vec<String>,
env_mode: EnvMode,
workspace: PathBuf,
profiles: Vec<String>,
share_dir: PathBuf,
passthrough: Vec<String>, env_mode: EnvMode, workspace: PathBuf, profiles: Vec<String>, share_dir: PathBuf, allow: Vec<String>,
) -> Result<Self, String> {
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();

let merged_profile = if profiles.is_empty() {
None
} else {
let loaded: Vec<_> = profiles
.iter()
.map(|p| resolve_profile(p, &share_dir))
.collect::<Result<Vec<_>, _>>()?;
let loaded: Vec<_> = profiles.iter().map(|p| resolve_profile(p, &share_dir)).collect::<Result<Vec<_>, _>>()?;
let merged = MergedProfile::from_profiles(&loaded);

let check = std::process::Command::new("bwrap")
.arg("--version")
.output()
.map_err(|e| format!("bwrap not found: {e}"))?;
let check = std::process::Command::new("bwrap").arg("--version").output().map_err(|e| format!("bwrap not found: {e}"))?;
if !check.status.success() {
return Err("bwrap is not functional. Install bubblewrap to use sandbox profiles.".into());
}

Some(Arc::new(merged))
};

let session = Arc::new(VsockSession {
passthrough: Arc::new(passthrough),
env_mode,
workspace,
merged_profile,
});
let has_proxy = !allow.is_empty();
let proxy_handle = if has_proxy {
let rt = tokio::runtime::Handle::current();
Some(rt.block_on(async {
let proxy = FilterProxy::new(allow);
proxy.bind().await
})?)
} else {
None
};

let session = Arc::new(VsockSession { passthrough: Arc::new(passthrough), env_mode, workspace, merged_profile, has_proxy });

let join_handle = tokio::spawn(async move {
let result = daemon_loop(session, shutdown_rx).await;
Expand All @@ -63,25 +62,26 @@ impl VsockDaemon {
}
});

Ok(Self { join_handle, shutdown: shutdown_tx })
Ok(Self { join_handle, shutdown: shutdown_tx, proxy_handle })
}

pub async fn shutdown(self) {
let _ = self.shutdown.send(());
let _ = self.join_handle.await;
if let Some(handle) = self.proxy_handle {
handle.abort();
}
}
}

async fn daemon_loop(
session: Arc<VsockSession>,
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) -> Result<(), String> {
async fn daemon_loop(session: Arc<VsockSession>, mut shutdown_rx: tokio::sync::oneshot::Receiver<()>) -> Result<(), String> {
use tokio_vsock::VsockListener;

let listener = match VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, VSOCK_PORT)) {
Ok(l) => l,
Err(e) => {
eprintln!("bunkerbox: vsock unavailable (passthrough disabled): {e}");
Err(_e) => {
// TODO: route to log socket
// eprintln!("bunkerbox: vsock unavailable (passthrough disabled): {e}");
let _ = shutdown_rx.await;
return Ok(());
}
Expand All @@ -94,8 +94,9 @@ async fn daemon_loop(
Ok((stream, _peer)) => {
let session = session.clone();
tokio::spawn(async move {
if let Err(err) = handle_connection(stream, &session).await {
eprintln!("bunkerbox: vsock session error: {err}");
if let Err(_err) = handle_connection(stream, &session).await {
// TODO: route to log socket
// eprintln!("bunkerbox: vsock session error: {err}");
}
});
}
Expand All @@ -113,10 +114,7 @@ async fn daemon_loop(
Ok(())
}

async fn handle_connection(
stream: tokio_vsock::VsockStream,
session: &VsockSession,
) -> Result<(), String> {
async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSession) -> Result<(), String> {
let (mut reader, mut writer) = tokio::io::split(stream);

let req = read_exec_request(&mut reader).await?;
Expand All @@ -130,9 +128,7 @@ async fn handle_connection(

let sandbox_cwd = req.cwd.clone();
let host_cwd = if req.cwd.starts_with("/workspace") {
session
.workspace
.join(req.cwd.strip_prefix("/workspace").unwrap_or(&req.cwd).trim_start_matches('/'))
session.workspace.join(req.cwd.strip_prefix("/workspace").unwrap_or(&req.cwd).trim_start_matches('/'))
} else {
PathBuf::from(&req.cwd)
};
Expand Down Expand Up @@ -219,7 +215,7 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san
cmd.arg("--ro-bind").arg(&resolved).arg("/bin/sh");
}

if matches!(merged.network, NetworkMode::None) {
if !session.has_proxy && matches!(merged.network, NetworkMode::None) {
cmd.arg("--unshare-net");
}

Expand All @@ -242,13 +238,30 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san
cmd.arg("--setenv").arg("PATH").arg("/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin");
cmd.arg("--setenv").arg("HOME").arg("/home");

if session.has_proxy {
let proxy_url = "http://127.0.0.1:20000";
cmd.arg("--setenv").arg("HTTP_PROXY").arg(proxy_url);
cmd.arg("--setenv").arg("HTTPS_PROXY").arg(proxy_url);
cmd.arg("--setenv").arg("http_proxy").arg(proxy_url);
cmd.arg("--setenv").arg("https_proxy").arg(proxy_url);
}

for (key, val) in &merged.env {
cmd.arg("--setenv").arg(key).arg(val);
}

if session.env_mode == EnvMode::Relaxed {
for (key, val) in &req.env {
if key == "PATH" || key == "HOME" || key == "VSOCK_CID" || key.starts_with("BUNKERBOX_") || key.starts_with("XDG_") {
if key == "PATH"
|| key == "HOME"
|| key == "VSOCK_CID"
|| key == "HTTP_PROXY"
|| key == "HTTPS_PROXY"
|| key == "http_proxy"
|| key == "https_proxy"
|| key.starts_with("BUNKERBOX_")
|| key.starts_with("XDG_")
{
continue;
}
cmd.arg("--setenv").arg(key).arg(val);
Expand All @@ -269,7 +282,16 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san

if session.env_mode == EnvMode::Relaxed {
for (key, val) in &req.env {
if key == "PATH" || key == "HOME" || key == "VSOCK_CID" || key.starts_with("BUNKERBOX_") || key.starts_with("XDG_") {
if key == "PATH"
|| key == "HOME"
|| key == "VSOCK_CID"
|| key == "HTTP_PROXY"
|| key == "HTTPS_PROXY"
|| key == "http_proxy"
|| key == "https_proxy"
|| key.starts_with("BUNKERBOX_")
|| key.starts_with("XDG_")
{
continue;
}
cmd.env(key, val);
Expand Down Expand Up @@ -309,11 +331,7 @@ fn is_allowed(passthrough: &[String], command: &str, args: &[String]) -> bool {
return true;
}
} else {
let full = if args.is_empty() {
command.to_string()
} else {
format!("{} {}", command, args.join(" "))
};
let full = if args.is_empty() { command.to_string() } else { format!("{} {}", command, args.join(" ")) };
if entry == full {
return true;
}
Expand All @@ -327,8 +345,7 @@ async fn read_exec_request<R: AsyncReadExt + Unpin>(reader: &mut R) -> Result<Ex
reader.read_exact(&mut header).await.map_err(|e| format!("read header: {e}"))?;

let frame_type_raw = u16::from_le_bytes([header[0], header[1]]);
let ft = FrameType::from_u16(frame_type_raw)
.ok_or_else(|| format!("unknown frame type: {frame_type_raw}"))?;
let ft = FrameType::from_u16(frame_type_raw).ok_or_else(|| format!("unknown frame type: {frame_type_raw}"))?;

if !matches!(ft, FrameType::ExecReq) {
return Err(format!("expected ExecReq, got {:?}", ft as u16));
Expand All @@ -343,10 +360,7 @@ async fn read_exec_request<R: AsyncReadExt + Unpin>(reader: &mut R) -> Result<Ex
ExecRequest::deserialize(&payload)
}

async fn pump_to_channel<R: AsyncReadExt + Unpin>(
mut reader: R,
tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
) {
async fn pump_to_channel<R: AsyncReadExt + Unpin>(mut reader: R, tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>) {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf).await {
Expand Down
6 changes: 1 addition & 5 deletions src/kata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -704,11 +704,7 @@ fn setup_session(home_path: &Path, session_mb: u32, uid: &str, gid: &str) -> Res
let home_size = dir_size(home_path);
let max_size = (session_mb as u64) * 1024 * 1024;
if home_size > max_size {
eprintln!(
"bunkerbox: warning: home directory is {} MB but session is only {} MB - copy may fail",
home_size / (1024 * 1024),
session_mb
);
eprintln!("bunkerbox: warning: home directory is {} MB but session is only {} MB - copy may fail", home_size / (1024 * 1024), session_mb);
}

let cp_status = run_command_quiet("cp", &["-a", &format!("{}/.", home_path.display()), &format!("{}/", session_dir.display())]);
Expand Down
11 changes: 11 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
pub mod cfg;
pub mod cfgsetup;
pub mod clidef;
pub mod cmdrun;
pub mod daemon;
pub mod kata;
pub mod overlay;
pub mod proxy;
pub mod sandbox;
pub mod vscomm;
pub mod workspace;
Loading
Loading