rx4 0.7.2

The agent harness engine — loop, tools, providers, sessions, permissions, computer-use
Documentation
use super::userspace::SandboxError;
use super::{OsSandbox, OsSandboxConfig, OsSandboxRunner};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SandboxLayer {
    Userspace,
    NestedFs,
    GitReadOnly,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EscalateRetry {
    pub from: SandboxLayer,
    pub to: SandboxLayer,
    pub retry_reason: String,
}

pub fn next_layer(current: SandboxLayer) -> Option<SandboxLayer> {
    match current {
        SandboxLayer::Userspace => Some(SandboxLayer::NestedFs),
        SandboxLayer::NestedFs => Some(SandboxLayer::GitReadOnly),
        SandboxLayer::GitReadOnly => None,
    }
}

pub fn escalate_on_deny(
    current: SandboxLayer,
    deny: &SandboxError,
) -> Result<EscalateRetry, SandboxError> {
    match next_layer(current) {
        Some(to) => Ok(EscalateRetry {
            from: current,
            to,
            retry_reason: format!("sandbox deny at {current:?}: {deny}; escalate to {to:?}"),
        }),
        None => Err(deny.clone()),
    }
}

pub fn layered_os_config(base: OsSandboxConfig, layer: SandboxLayer) -> OsSandboxConfig {
    let mut config = base;
    match layer {
        SandboxLayer::Userspace => {
            config.mode = OsSandbox::UserspaceOnly;
        }
        SandboxLayer::NestedFs | SandboxLayer::GitReadOnly => {
            if config.mode == OsSandbox::UserspaceOnly {
                config.mode = match std::env::consts::OS {
                    "macos" => OsSandbox::MacosSeatbelt,
                    "linux" => OsSandbox::LinuxBubblewrap,
                    _ => OsSandbox::UserspaceOnly,
                };
            }
            if layer == SandboxLayer::GitReadOnly {
                let git = config.workspace.join(".git");
                if !config.extra_ro_paths.iter().any(|p| p == &git) {
                    config.extra_ro_paths.push(git);
                }
            }
        }
    }
    config
}

pub fn git_readonly_sbpl(workspace: &std::path::Path) -> String {
    let git = workspace.join(".git");
    format!("(deny file-write* (subpath \"{}\"))\n", git.display())
}

pub fn apply_layer_to_runner(
    runner: &OsSandboxRunner,
    layer: SandboxLayer,
) -> Result<OsSandboxRunner, SandboxError> {
    let config = layered_os_config(runner.config().clone(), layer);
    OsSandboxRunner::new(config)
}

pub fn bubblewrap_git_ro_args(workspace: PathBuf) -> Vec<String> {
    let git = workspace.join(".git");
    let p = git.display().to_string();
    vec!["--ro-bind".to_string(), p.clone(), p]
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    #[test]
    fn deny_then_escalate() {
        let deny = SandboxError::PathDenied("/etc/passwd".into());
        let retry = escalate_on_deny(SandboxLayer::Userspace, &deny).unwrap();
        assert_eq!(retry.from, SandboxLayer::Userspace);
        assert_eq!(retry.to, SandboxLayer::NestedFs);
        assert!(retry.retry_reason.contains("escalate"));
        let retry = escalate_on_deny(SandboxLayer::NestedFs, &deny).unwrap();
        assert_eq!(retry.to, SandboxLayer::GitReadOnly);
    }

    #[test]
    fn never_silent_pass() {
        let deny = SandboxError::WriteDenied("/tmp/x".into());
        let err = escalate_on_deny(SandboxLayer::GitReadOnly, &deny).unwrap_err();
        assert_eq!(err, deny);
        assert!(next_layer(SandboxLayer::GitReadOnly).is_none());
    }

    #[test]
    fn layered_config_marks_git_readonly() {
        let base = OsSandboxConfig::new(
            OsSandbox::UserspaceOnly,
            PathBuf::from("/workspace/project"),
        );
        let nested = layered_os_config(base.clone(), SandboxLayer::NestedFs);
        assert!(!nested.extra_ro_paths.iter().any(|p| p.ends_with(".git")));
        let git_ro = layered_os_config(base, SandboxLayer::GitReadOnly);
        assert!(git_ro.extra_ro_paths.iter().any(|p| p.ends_with(".git")));
        let sbpl = git_readonly_sbpl(std::path::Path::new("/workspace/project"));
        assert!(sbpl.contains("(deny file-write*"));
        assert!(sbpl.contains(".git"));
        let args = bubblewrap_git_ro_args(PathBuf::from("/workspace/project"));
        assert_eq!(args[0], "--ro-bind");
        assert!(args[1].ends_with(".git"));
    }
}