agent-locker 0.1.0-alpha.1

A sandbox for running coding agents with restricted filesystem access.
use std::ffi::OsString;
use std::path::PathBuf;

use clap::Parser;
use clap::builder::styling::{AnsiColor, Effects, Styles};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    Basic,
    Opencode,
    Claude,
    Codex,
}

/// Color theme for help, usage, and error output.
const STYLES: Styles = Styles::styled()
    .header(AnsiColor::Green.on_default().effects(Effects::BOLD))
    .usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
    .literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
    .placeholder(AnsiColor::BrightBlue.on_default())
    .error(AnsiColor::Red.on_default().effects(Effects::BOLD))
    .valid(AnsiColor::Green.on_default().effects(Effects::BOLD))
    .invalid(AnsiColor::Yellow.on_default());

const LONG_ABOUT: &str = "\
Run a command inside a filesystem sandbox so a coding agent can only write to \
a small set of directories.

The sandbox grants write access to the context directory (the current \
directory unless -C is given) plus its real git directory, and makes the rest \
of the filesystem read-only via Landlock. The agent presets (opencode, claude, \
codex) additionally allow that agent's own config and cache directories.";

const AFTER_HELP: &str = "\
Examples:
  agent-locker -- cargo test            Run a command in the current directory
  agent-locker -C ./project claude      Run claude scoped to ./project

Notes:
  - The current backend uses Landlock to allow writes only in the context
    directory and a few extra paths; everything else is read-only.";

/// Argument layout as parsed by clap. Converted into [`Cli`] for the rest of
/// the program, which derives the run [`Mode`] from the leading positional.
#[derive(Parser, Debug)]
#[command(
    name = "agent-locker",
    version,
    about = "A sandbox for running coding agents with restricted filesystem access.",
    long_about = LONG_ABOUT,
    after_help = AFTER_HELP,
    styles = STYLES,
)]
struct RawCli {
    /// Use DIR as the main writable project directory
    #[arg(short = 'C', long = "context-dir", value_name = "DIR")]
    context_dir: Option<PathBuf>,

    /// Agent preset (opencode, claude, codex) or command to run, followed by
    /// its arguments
    #[arg(
        value_name = "COMMAND",
        trailing_var_arg = true,
        allow_hyphen_values = true,
        required = true
    )]
    command_and_args: Vec<OsString>,
}

#[derive(Debug, Clone)]
pub struct Cli {
    pub context_dir: Option<PathBuf>,
    pub mode: Mode,
    pub command_and_args: Vec<OsString>,
}

impl Cli {
    /// Parse arguments from the process environment.
    ///
    /// On `--help`/`--version` or a parse error, clap prints to the
    /// appropriate stream and exits the process.
    pub fn parse() -> Self {
        Self::from_raw(RawCli::parse())
    }

    fn from_raw(raw: RawCli) -> Self {
        let mode = match raw.command_and_args.first().and_then(|s| s.to_str()) {
            Some("opencode") => Mode::Opencode,
            Some("claude") => Mode::Claude,
            Some("codex") => Mode::Codex,
            _ => Mode::Basic,
        };

        // For agent presets the leading keyword selects the mode and is not
        // part of the command; in basic mode the whole list is the command.
        let command_and_args = match mode {
            Mode::Basic => raw.command_and_args,
            Mode::Opencode | Mode::Claude | Mode::Codex => raw.command_and_args[1..].to_vec(),
        };

        Cli {
            context_dir: raw.context_dir,
            mode,
            command_and_args,
        }
    }
}