use std::ffi::OsString;
use std::path::PathBuf;
use clap::builder::styling::{AnsiColor, Effects, Styles};
use clap::{CommandFactory, Parser};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Opencode,
Claude,
Codex,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitAccess {
Allow,
Deny,
Ask,
}
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 supported coding agent inside a filesystem sandbox so it 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) and makes the rest of the filesystem read-only \
via Landlock. Each agent preset (opencode, claude, codex) additionally allows \
that agent's own config and cache directories.
The context_roots config key lists directories that become the context \
whenever the agent is launched anywhere beneath them, widening write access \
from just the current directory to the whole root. An explicit -C overrides it.
When the context directory is a git worktree, its parent git directory lives \
outside the worktree and is not writable by default. Pass --allow-parent-git \
to grant write access to it, or --no-allow-parent-git to deny it. With neither \
flag, the allow_parent_git config default applies; if that is unset too, \
agent-locker prompts when it detects a worktree.
Only the built-in agents are supported; arbitrary commands cannot be run.";
const AFTER_HELP: &str = "\
Examples:
agent-locker claude Run claude in the current directory
agent-locker -C ./project claude Run claude scoped to ./project
agent-locker codex --some-flag Pass extra arguments through to codex
Notes:
- Supported agents: claude, codex, opencode.
- The current backend uses Landlock to allow writes only in the context
directory and a few extra paths; everything else is read-only.";
#[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 {
#[arg(short = 'C', long = "context-dir", value_name = "DIR")]
context_dir: Option<PathBuf>,
#[arg(long = "allow-parent-git", overrides_with = "no_allow_parent_git")]
allow_parent_git: bool,
#[arg(long = "no-allow-parent-git", overrides_with = "allow_parent_git")]
no_allow_parent_git: bool,
#[arg(
value_name = "AGENT",
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 git_access: GitAccess,
pub command_and_args: Vec<OsString>,
}
impl Cli {
pub fn parse() -> Self {
let raw = RawCli::parse();
match Self::from_raw(raw) {
Ok(cli) => cli,
Err(message) => RawCli::command()
.error(clap::error::ErrorKind::InvalidValue, message)
.exit(),
}
}
fn from_raw(raw: RawCli) -> Result<Self, String> {
let agent = raw.command_and_args[0].to_str();
let mode = match agent {
Some("opencode") => Mode::Opencode,
Some("claude") => Mode::Claude,
Some("codex") => Mode::Codex,
other => {
let name = other.unwrap_or("<invalid>");
return Err(format!(
"unsupported agent '{name}' (supported agents: claude, codex, opencode)"
));
}
};
let git_access = if raw.allow_parent_git {
GitAccess::Allow
} else if raw.no_allow_parent_git {
GitAccess::Deny
} else {
GitAccess::Ask
};
Ok(Cli {
context_dir: raw.context_dir,
mode,
git_access,
command_and_args: raw.command_and_args[1..].to_vec(),
})
}
}