agent-locker 0.1.0-alpha.1

A sandbox for running coding agents with restricted filesystem access.
use std::env;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};

use clap::builder::styling::{AnsiColor, Reset, Style};

use crate::Result;
use crate::cli::{Cli, Mode};
use crate::config::Config;
use crate::git;

#[derive(Debug, Clone)]
pub struct CommandSpec {
    pub program: std::ffi::OsString,
    pub args: Vec<std::ffi::OsString>,
}

#[derive(Debug, Clone)]
pub struct Policy {
    pub context_dir: PathBuf,
    pub write_dirs: Vec<PathBuf>,
    pub command: CommandSpec,
    pub mode: Mode,
}

impl Policy {
    pub fn from_cli(cli: Cli) -> Result<Self> {
        let cwd = env::current_dir()?;
        let context_dir = match cli.context_dir {
            Some(path) => normalize_path(&cwd, &path),
            None => cwd.clone(),
        };

        let mut write_dirs = vec![context_dir.clone()];
        if let Some(real_git_dir) = git::detect_real_git_dir(&context_dir) {
            write_dirs.push(real_git_dir);
        }

        match cli.mode {
            Mode::Opencode => {
                for path in [
                    "~/.opencode",
                    "~/.local/share/opencode",
                    "~/.cache/opencode",
                ] {
                    write_dirs.push(expand_path(path, &cwd));
                }
            }
            Mode::Claude => {
                write_dirs.push(expand_path("~/.claude", &cwd));
                write_dirs.push(expand_path("~/.claude.json", &cwd));
            }
            Mode::Codex => write_dirs.push(expand_path("~/.codex", &cwd)),
            Mode::Basic => {}
        }

        // Temp space is needed by every command.
        write_dirs.push(PathBuf::from("/tmp"));

        // Coding agents need to write only to the null device and the
        // controlling terminal; nothing else under /dev, and not the XDG
        // runtime directory. This was verified for claude via strace and is
        // applied to every preset and to generic commands to keep the
        // writable device surface minimal. Reads elsewhere (e.g.
        // /dev/urandom) still work because the whole filesystem is readable.
        write_dirs.push(PathBuf::from("/dev/null"));
        write_dirs.push(PathBuf::from("/dev/tty"));

        let config = Config::load()?;
        let command = build_command(
            cli.mode,
            &cli.command_and_args,
            config.preset_args(cli.mode),
        )?;

        dedup_paths(&mut write_dirs);

        Ok(Self {
            context_dir,
            write_dirs,
            command,
            mode: cli.mode,
        })
    }

    /// Human-readable summary of the sandbox printed on every startup.
    pub fn render_banner(&self, color: bool) -> String {
        let (label, label_off) = paint(AnsiColor::Cyan.on_default().bold(), color);
        let home = env::var_os("HOME").map(PathBuf::from);

        let mut out = String::new();
        let _ = writeln!(
            out,
            "{label}Context root:{label_off} {}",
            display_path(&self.context_dir, home.as_deref())
        );
        let _ = writeln!(out, "{label}Writable:{label_off}");
        for group in group_by_parent(&self.write_dirs) {
            let rendered: Vec<String> = group
                .iter()
                .map(|path| display_path(path, home.as_deref()))
                .collect();
            if rendered.len() == 1 {
                let _ = writeln!(out, "  - {}", rendered[0]);
            } else {
                let _ = writeln!(out, "  - [{}]", rendered.join(", "));
            }
        }
        out
    }
}

/// Renders a path for display, replacing a leading `$HOME` with `~`.
fn display_path(path: &Path, home: Option<&Path>) -> String {
    if let Some(home) = home {
        if let Ok(rest) = path.strip_prefix(home) {
            if rest.as_os_str().is_empty() {
                return "~".to_string();
            }
            return format!("~/{}", rest.display());
        }
    }
    path.display().to_string()
}

/// Groups consecutive paths that share the same parent directory, so related
/// entries (e.g. `/dev/null` and `/dev/tty`) render together. The filesystem
/// root is never used as a grouping parent, keeping top-level paths like
/// `/tmp` and `/dev` on their own lines.
fn group_by_parent(paths: &[PathBuf]) -> Vec<Vec<PathBuf>> {
    let mut groups: Vec<Vec<PathBuf>> = Vec::new();
    for path in paths {
        let parent = path.parent();
        let groupable = parent.is_some_and(|p| !p.as_os_str().is_empty() && p != Path::new("/"));
        let merged = match groups.last_mut() {
            Some(last) if groupable && last.first().and_then(|p| p.parent()) == parent => {
                last.push(path.clone());
                true
            }
            _ => false,
        };
        if !merged {
            groups.push(vec![path.clone()]);
        }
    }
    groups
}

/// Returns the SGR start/reset pair for `style`, or empty strings when color
/// is disabled.
fn paint(style: Style, color: bool) -> (String, String) {
    if color {
        (style.render().to_string(), Reset.render().to_string())
    } else {
        (String::new(), String::new())
    }
}

/// Builds the command to run. For agent presets the program is fixed and the
/// arguments are the configured extra arguments (from the config file)
/// followed by anything passed on the command line. Presets run in their
/// default mode unless the config opts in to flags like
/// `--dangerously-skip-permissions`.
fn build_command(
    mode: Mode,
    input: &[std::ffi::OsString],
    extra: &[String],
) -> Result<CommandSpec> {
    let preset = |program: &str| {
        let mut args: Vec<std::ffi::OsString> =
            extra.iter().map(std::ffi::OsString::from).collect();
        args.extend_from_slice(input);
        CommandSpec {
            program: std::ffi::OsString::from(program),
            args,
        }
    };

    Ok(match mode {
        Mode::Basic => {
            let Some(program) = input.first() else {
                return Err("missing command".into());
            };
            CommandSpec {
                program: program.clone(),
                args: input[1..].to_vec(),
            }
        }
        Mode::Opencode => preset("opencode"),
        Mode::Claude => preset("claude"),
        Mode::Codex => preset("codex"),
    })
}

fn dedup_paths(paths: &mut Vec<PathBuf>) {
    let mut seen = std::collections::BTreeSet::new();
    paths.retain(|path| seen.insert(path.clone()));
}

fn normalize_path(cwd: &Path, path: &Path) -> PathBuf {
    let expanded = expand_os_path(path, cwd);
    expanded.canonicalize().unwrap_or(expanded)
}

fn expand_path(path: &str, cwd: &Path) -> PathBuf {
    expand_os_path(Path::new(path), cwd)
}

fn expand_os_path(path: &Path, cwd: &Path) -> PathBuf {
    let raw = path.to_string_lossy();
    if raw == "~" || raw.starts_with("~/") {
        if let Some(home) = env::var_os("HOME") {
            let suffix = raw.strip_prefix('~').unwrap_or("");
            PathBuf::from(home).join(suffix.trim_start_matches('/'))
        } else {
            path.to_path_buf()
        }
    } else if path.is_absolute() {
        path.to_path_buf()
    } else {
        cwd.join(path)
    }
}