kumoba 0.3.0

Fast VM sandboxes for AI agents, as a Rust library
Documentation
use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration};

#[derive(Debug, Clone)]
pub struct RuntimeConfig {
    pub worker: PathBuf,
    pub library: PathBuf,
    pub firmware: PathBuf,
    pub state_dir: PathBuf,
    pub max_running: usize,
    pub boot_timeout: Duration,
    pub stop_timeout: Duration,
    pub secret_store: Option<crate::SecretStore>,
    pub access_sink: Option<AccessSink>,
    pub egress: Arc<crate::EgressConfig>,
}

#[derive(Clone)]
pub struct AccessSink(Arc<dyn Fn(SandboxId, crate::AccessRecord) + Send + Sync>);

impl AccessSink {
    pub fn new(sink: impl Fn(SandboxId, crate::AccessRecord) + Send + Sync + 'static) -> Self {
        Self(Arc::new(sink))
    }
    pub(crate) fn for_sandbox(&self, id: SandboxId) -> crate::egress::AccessSink {
        let sink = self.0.clone();
        Arc::new(move |record| sink(id, record))
    }
}

impl std::fmt::Debug for AccessSink {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("AccessSink")
    }
}

impl RuntimeConfig {
    pub fn new(
        worker: impl Into<PathBuf>,
        library: impl Into<PathBuf>,
        firmware: impl Into<PathBuf>,
        state_dir: impl Into<PathBuf>,
    ) -> Self {
        Self {
            worker: worker.into(),
            library: library.into(),
            firmware: firmware.into(),
            state_dir: state_dir.into(),
            max_running: 2,
            boot_timeout: Duration::from_secs(30),
            stop_timeout: Duration::from_secs(10),
            secret_store: None,
            access_sink: None,
            egress: Arc::default(),
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub enum Network {
    #[default]
    Disabled,
    HttpConnect(crate::egress::HttpConnectPolicy),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxConfig {
    pub image: PathBuf,
    pub vcpus: u8,
    pub memory_mib: u32,
    pub network: Network,
    #[serde(default)]
    pub secrets: BTreeMap<String, crate::SecretId>,
    #[serde(default)]
    pub mounts: Vec<crate::HostMount>,
}
impl SandboxConfig {
    pub fn new(image: impl Into<PathBuf>) -> Self {
        Self {
            image: image.into(),
            vcpus: 1,
            memory_mib: 512,
            network: Network::Disabled,
            secrets: BTreeMap::new(),
            mounts: Vec::new(),
        }
    }
    pub(crate) fn validate(&self) -> Result<()> {
        crate::mounts::validate_host_mounts(&self.mounts)?;
        if !self.secrets.is_empty() && matches!(self.network, Network::Disabled) {
            return Err(Error::InvalidInput(
                "network secrets require an HTTPS proxy policy".into(),
            ));
        }
        if !(1..=8).contains(&self.vcpus) || !(256..=16384).contains(&self.memory_mib) {
            return Err(Error::InvalidInput(
                "vcpus must be 1..=8 and memory_mib 256..=16384".into(),
            ));
        }
        Ok(())
    }
}

#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)]
pub struct SandboxId(pub(crate) uuid::Uuid);
impl std::fmt::Display for SandboxId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.0.fmt(f)
    }
}
impl std::str::FromStr for SandboxId {
    type Err = Error;
    fn from_str(s: &str) -> Result<Self> {
        uuid::Uuid::parse_str(s)
            .map(Self)
            .map_err(|e| Error::InvalidInput(e.to_string()))
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuestPath(String);
impl GuestPath {
    pub fn new(path: impl Into<String>) -> Result<Self> {
        let path = path.into();
        if !path.starts_with('/')
            || path.len() > 4096
            || path.contains('\0')
            || path.split('/').any(|p| p == "..")
        {
            return Err(Error::InvalidInput(
                "guest path must be absolute without NUL or '..' components".into(),
            ));
        }
        Ok(Self(path))
    }
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Debug, Clone)]
pub struct Command {
    pub(crate) program: String,
    pub(crate) args: Vec<String>,
    pub(crate) cwd: String,
    pub(crate) env: BTreeMap<String, String>,
    pub(crate) stdin: Vec<u8>,
    pub(crate) timeout: Duration,
    pub(crate) output_limit: usize,
    pub(crate) user: Option<String>,
}
impl Command {
    pub fn new(program: impl Into<String>) -> Self {
        Self {
            program: program.into(),
            args: Vec::new(),
            cwd: "/workspace".into(),
            env: BTreeMap::from([(
                "PATH".into(),
                "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin".into(),
            )]),
            stdin: Vec::new(),
            timeout: Duration::from_secs(30),
            output_limit: 64 * 1024,
            user: None,
        }
    }
    pub fn arg(mut self, arg: impl Into<String>) -> Self {
        self.args.push(arg.into());
        self
    }
    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
        self.args.extend(args.into_iter().map(Into::into));
        self
    }
    pub fn cwd(mut self, path: GuestPath) -> Self {
        self.cwd = path.0;
        self
    }
    pub fn env(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.env.insert(name.into(), value.into());
        self
    }
    pub fn stdin(mut self, data: impl Into<Vec<u8>>) -> Self {
        self.stdin = data.into();
        self
    }
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }
    pub fn output_limit(mut self, bytes: usize) -> Self {
        self.output_limit = bytes;
        self
    }
    pub fn user(mut self, name: impl Into<String>) -> Self {
        self.user = Some(name.into());
        self
    }
    pub fn timeout_duration(&self) -> Duration {
        self.timeout
    }
    pub(crate) fn into_request(self) -> kumoba_protocol::ExecRequest {
        use base64::Engine;
        kumoba_protocol::ExecRequest {
            program: self.program,
            args: self.args,
            cwd: self.cwd,
            env: self.env,
            stdin: base64::engine::general_purpose::STANDARD.encode(self.stdin),
            timeout_ms: u64::try_from(self.timeout.as_millis()).unwrap_or(u64::MAX),
            output_limit: self.output_limit,
            user: self.user,
        }
    }
    pub(crate) fn validate(&self) -> Result<()> {
        GuestPath::new(&self.program)?;
        if self.timeout.as_millis() == 0
            || self.timeout > Duration::from_secs(3600)
            || self.output_limit == 0
            || self.output_limit > kumoba_protocol::MAX_OUTPUT_LIMIT
            || self.stdin.len() > kumoba_protocol::MAX_DATA
        {
            return Err(Error::InvalidInput(
                "invalid timeout, input or output limit".into(),
            ));
        }
        if self.args.len() > 1024
            || self.args.iter().any(|x| x.contains('\0'))
            || self
                .env
                .iter()
                .any(|(k, v)| k.is_empty() || k.contains(['=', '\0']) || v.contains('\0'))
            || self.args.iter().map(String::len).sum::<usize>()
                + self
                    .env
                    .iter()
                    .map(|(k, v)| k.len() + v.len())
                    .sum::<usize>()
                > 128 * 1024
        {
            return Err(Error::InvalidInput(
                "invalid or oversized arguments/environment".into(),
            ));
        }
        Ok(())
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
    Stopped,
    Running,
    Deleted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopOutcome {
    AlreadyStopped,
    Graceful,
    Forced,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Termination {
    Exited(i32),
    Signaled(i32),
    TimedOut,
}
#[derive(Debug)]
pub struct ExecOutput {
    pub termination: Termination,
    pub stdout: Vec<u8>,
    pub stderr: Vec<u8>,
    pub truncated: bool,
}
impl ExecOutput {
    pub fn success(&self) -> bool {
        self.termination == Termination::Exited(0)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn config_boundaries() {
        for p in ["../host", "relative", "/a/../b", "/a\0b"] {
            assert!(GuestPath::new(p).is_err());
        }
        assert!(
            Command::new("/bin/echo")
                .arg("a; $(touch bad)")
                .validate()
                .is_ok()
        );
        assert!(Command::new("echo").validate().is_err());
        assert!(
            Command::new("/bin/echo")
                .env("A=B", "x")
                .validate()
                .is_err()
        );
        assert!(
            Command::new("/bin/echo")
                .timeout(Duration::ZERO)
                .validate()
                .is_err()
        );
        let mut config = SandboxConfig::new("base.raw");
        config.vcpus = 0;
        assert!(config.validate().is_err());
    }
}

#[derive(Debug)]
pub struct ExecSummary {
    pub termination: Termination,
    pub stdout_bytes: u64,
    pub stderr_bytes: u64,
    pub truncated: bool,
}
impl ExecSummary {
    pub fn success(&self) -> bool {
        self.termination == Termination::Exited(0)
    }
}