ai-jail 1.17.0

Sandbox for AI coding agents (bubblewrap on Linux, sandbox-exec on macOS)
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};

static NEXT_TEST_ID: AtomicUsize = AtomicUsize::new(0);

struct TestTree {
    root: PathBuf,
    project: PathBuf,
    home: PathBuf,
}

impl TestTree {
    fn new(name: &str) -> Self {
        let id = NEXT_TEST_ID.fetch_add(1, Ordering::Relaxed);
        let root = PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
            .join(format!("config-errors-{name}-{}-{id}", std::process::id()));
        let project = root.join("project");
        let home = root.join("home");
        std::fs::create_dir_all(&project).unwrap();
        std::fs::create_dir(&home).unwrap();
        Self {
            root,
            project,
            home,
        }
    }

    fn project_config(&self) -> PathBuf {
        self.project.join(".ai-jail")
    }

    fn global_config(&self) -> PathBuf {
        self.home.join(".ai-jail")
    }

    fn run(&self, args: &[&str]) -> Output {
        Command::new(env!("CARGO_BIN_EXE_ai-jail"))
            .args(args)
            .current_dir(&self.project)
            .env("HOME", &self.home)
            .env_remove("AI_JAIL_QUIET")
            .output()
            .expect("failed to run ai-jail")
    }
}

impl Drop for TestTree {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.root);
    }
}

fn assert_parse_failure(output: &Output, path: &Path) {
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert_eq!(
        output.status.code(),
        Some(1),
        "expected exit 1, stdout={stdout:?}, stderr={stderr:?}"
    );
    assert!(stdout.is_empty(), "unexpected stdout: {stdout:?}");
    assert!(
        stderr.contains(&format!("Failed to parse {}", path.display())),
        "missing config path in stderr: {stderr:?}"
    );
    assert!(
        stderr.contains("TOML parse error"),
        "missing parser detail in stderr: {stderr:?}"
    );
}

#[test]
fn malformed_project_config_blocks_dry_run() {
    let tree = TestTree::new("project-dry-run");
    let path = tree.project_config();
    std::fs::write(&path, "command = [\"bash\"\n").unwrap();

    let output = tree.run(&["--dry-run", "--no-save-config", "/bin/true"]);

    assert_parse_failure(&output, Path::new(".ai-jail"));
}

#[test]
fn malformed_project_config_blocks_init_without_rewriting() {
    let tree = TestTree::new("project-init");
    let path = tree.project_config();
    let original = "command = [\"bash\"\n";
    std::fs::write(&path, original).unwrap();

    let output = tree.run(&["--init", "/bin/true"]);

    assert_parse_failure(&output, Path::new(".ai-jail"));
    assert_eq!(std::fs::read_to_string(path).unwrap(), original);
}

#[test]
fn malformed_global_config_is_fatal_even_with_clean() {
    let tree = TestTree::new("global-clean");
    let path = tree.global_config();
    let original = "no_gpu = tru\n";
    std::fs::write(&path, original).unwrap();

    let output = tree.run(&["--clean", "status"]);

    assert_parse_failure(&output, &path);
    assert_eq!(std::fs::read_to_string(path).unwrap(), original);
}

#[test]
fn exec_mode_does_not_suppress_config_errors() {
    let tree = TestTree::new("exec");
    std::fs::write(tree.project_config(), "command = [\"bash\"\n").unwrap();

    let output = tree.run(&["--exec", "status"]);

    assert_parse_failure(&output, Path::new(".ai-jail"));
}

#[test]
fn clean_init_replaces_a_malformed_project_config() {
    let tree = TestTree::new("clean-init");
    let path = tree.project_config();
    std::fs::write(&path, "command = [\"bash\"\n").unwrap();

    let output = tree.run(&["--clean", "--init", "/bin/true"]);

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(output.status.success(), "clean init failed: {stderr:?}");
    let saved = std::fs::read_to_string(path).unwrap();
    assert!(
        saved.contains("command = ["),
        "unexpected config: {saved:?}"
    );
    assert!(saved.contains("\"/bin/true\""));
}