use std::path::PathBuf;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const HELP: &str = "\
ai-jail — sandbox for AI coding agents (bwrap on Linux, sandbox-exec on macOS)
USAGE:
ai-jail [OPTIONS] [--] [COMMAND [ARGS...]]
COMMANDS (positional):
gemini, claude, codex, opencode, crush, soulforge, grok, pi, kimi, bash
Known AI tool presets
status Show current .ai-jail config
Any other string Passed through as the command
OPTIONS:
--rw-map <PATH|SOURCE:DEST> Mount read-write, optionally at DEST
(repeatable)
--map <PATH|SOURCE:DEST> Mount read-only, optionally at DEST
(repeatable)
--overlay-map <PATH> Mount PATH copy-on-write: writes go to a side
layer, PATH itself stays untouched so you can
diff/promote later (repeatable; Linux/bwrap
only, read-only on macOS)
--hide-dotdir <NAME> Never mount dotdir NAME (e.g., .my_secrets) (repeatable)
--mask <PATH|GLOB> Replace PATH/glob matches with empty files/tmpfs (repeatable)
--deny-path <PATH|GLOB> Deny access to PATH/glob with permission errors (repeatable)
--mask-except <PATH|GLOB> Do not mask matches; other sandbox rules still apply
(weakens protection; repeatable; no ! negation)
--deny-path-except <PATH|GLOB> Do not deny matches; other sandbox rules still apply
(weakens protection; repeatable; no ! negation)
--private-home / --no-private-home
Disable/enable automatic host home dotdir passthrough
--lockdown / --no-lockdown Enable/disable strict read-only lockdown mode
--landlock / --no-landlock Enable/disable Landlock LSM (Linux 5.13+, default: on)
--seccomp / --no-seccomp Enable/disable seccomp syscall filter (Linux, default: on)
--rlimits / --no-rlimits Enable/disable resource limits (default: on)
--systemd-user / --no-systemd-user
Expose host systemd --user bus (dangerous; default: off)
--no-gpu / --gpu Disable/enable GPU device passthrough (Linux only)
--no-docker / --docker Disable/enable Docker socket passthrough (grants host root; default: off)
--tailscale / --no-tailscale Enable/disable Tailscale socket passthrough (default: off)
--no-display / --display Disable/enable X11/Wayland passthrough (Linux only)
--network / --no-network Enable/disable unrestricted network access (default: off)
--macos-host-ipc / --no-macos-host-ipc
Enable/disable broad macOS host IPC compatibility (default: off)
--x11 / --no-x11 Enable/disable X11 socket passthrough (default: off)
--host-shm / --no-host-shm Enable/disable host shared-memory passthrough (default: off)
--terminal-passthrough / --no-terminal-passthrough
Enable/disable terminal passthrough (default: off)
--agent-state / --no-agent-state
Enable/disable mounting the agent's own state
dirs (~/.claude, ~/.codex, ~/.claude.json, ...;
default: off; project .ai-jail cannot enable)
--env <NAME[=VALUE]> Pass environment variable NAME (value copied from
the host) or NAME=VALUE into the sandbox
(repeatable; not persisted to .ai-jail)
--inherit-env / --no-inherit-env
Inherit the full host environment (default: off —
only a safe allowlist is passed)
--update-check / --no-update-check
Enable/disable the status bar's GitHub update
check (default: off)
--worktree / --no-worktree Enable/disable linked Git worktree metadata passthrough
--no-mise / --mise Disable/enable mise integration
--ssh / --no-ssh Share ~/.ssh read-only + forward SSH_AUTH_SOCK (default: off)
--pictures / --no-pictures Share ~/Pictures read-only (default: off)
--browser[=PROFILE] Use browser isolation profile (hard | soft; default hard)
--no-browser Disable browser auto-detection/profile
--save-config / --no-save-config
Enable/disable automatic .ai-jail writes
--hide-config / --no-hide-config
Mask the project .ai-jail file from the agent (default: on)
-s, --status-bar[=STYLE] Set status line theme (pastel | dark | light; default pastel)
Pastel picks a random pastel palette per session
--no-status-bar Disable persistent status line
--exec Direct execution mode (no PTY proxy, no status bar)
--allow-tcp-port <PORT> Allow outbound TCP to PORT in lockdown (repeatable)
--claude-dir <PATH> Use PATH as Claude config dir (sets CLAUDE_CONFIG_DIR)
--clean Ignore project .ai-jail config, start fresh
--dry-run Print the sandbox command without executing
--init Create/update .ai-jail config and exit
--bootstrap Generate smart permission configs for AI tools
-v, --verbose Show detailed mount info
-h, --help Show help
-V, --version Show version
";
#[derive(Debug, Default)]
pub struct CliArgs {
pub command: Vec<String>,
pub rw_maps: Vec<PathBuf>,
pub ro_maps: Vec<PathBuf>,
pub overlay_maps: Vec<PathBuf>,
pub hide_dotdirs: Vec<String>,
pub mask: Vec<PathBuf>,
pub deny_paths: Vec<PathBuf>,
pub mask_exceptions: Vec<PathBuf>,
pub deny_path_exceptions: Vec<PathBuf>,
pub private_home: Option<bool>,
pub lockdown: Option<bool>,
pub landlock: Option<bool>,
pub seccomp: Option<bool>,
pub rlimits: Option<bool>,
pub systemd_user: Option<bool>,
pub gpu: Option<bool>,
pub docker: Option<bool>,
pub tailscale: Option<bool>,
pub display: Option<bool>,
pub network: Option<bool>,
pub macos_host_ipc: Option<bool>,
pub x11: Option<bool>,
pub host_shm: Option<bool>,
pub terminal_passthrough: Option<bool>,
pub worktree: Option<bool>,
pub mise: Option<bool>,
pub save_config: Option<bool>,
pub hide_config: Option<bool>,
pub ssh: Option<bool>,
pub pictures: Option<bool>,
pub browser_profile: Option<String>,
pub status_bar: Option<bool>,
pub status_bar_style: Option<String>,
pub allow_tcp_ports: Vec<u16>,
pub claude_dir: Option<PathBuf>,
pub agent_state: Option<bool>,
pub inherit_env: Option<bool>,
pub update_check: Option<bool>,
pub env: Vec<String>,
pub exec: bool,
pub clean: bool,
pub dry_run: bool,
pub init: bool,
pub bootstrap: bool,
pub verbose: bool,
pub status: bool,
pub landlock_exec: bool,
pub landlock_rw_paths: Vec<PathBuf>,
pub landlock_ro_paths: Vec<PathBuf>,
}
pub fn parse() -> Result<CliArgs, String> {
parse_from(lexopt::Parser::from_env())
}
pub fn parse_from(mut parser: lexopt::Parser) -> Result<CliArgs, String> {
use lexopt::prelude::*;
let mut args = CliArgs::default();
while let Some(arg) = parser.next().map_err(|e| e.to_string())? {
match arg {
Long("rw-map") => {
let val: PathBuf =
parser.value().map_err(|e| e.to_string())?.into();
args.rw_maps.push(val);
}
Long("map") => {
let val: PathBuf =
parser.value().map_err(|e| e.to_string())?.into();
args.ro_maps.push(val);
}
Long("overlay-map") => {
let val: PathBuf =
parser.value().map_err(|e| e.to_string())?.into();
args.overlay_maps.push(val);
}
Long("mask") => {
let val = parser.value().map_err(|e| e.to_string())?;
let s = val.to_string_lossy();
if s.is_empty() {
return Err("--mask requires a non-empty path".into());
}
args.mask.push(PathBuf::from(s.into_owned()));
}
Long("deny-path") => {
let val = parser.value().map_err(|e| e.to_string())?;
let s = val.to_string_lossy();
if s.is_empty() {
return Err("--deny-path requires a non-empty path".into());
}
args.deny_paths.push(PathBuf::from(s.into_owned()));
}
Long("mask-except") => {
let val = parser.value().map_err(|e| e.to_string())?;
let s = val.to_string_lossy();
if s.is_empty() {
return Err(
"--mask-except requires a non-empty path".into()
);
}
args.mask_exceptions.push(PathBuf::from(s.into_owned()));
}
Long("deny-path-except") => {
let val = parser.value().map_err(|e| e.to_string())?;
let s = val.to_string_lossy();
if s.is_empty() {
return Err(
"--deny-path-except requires a non-empty path".into()
);
}
args.deny_path_exceptions
.push(PathBuf::from(s.into_owned()));
}
Long("hide-dotdir") => {
let val = parser.value().map_err(|e| e.to_string())?;
let s = val.to_string_lossy().into_owned();
if s.is_empty() {
return Err(
"--hide-dotdir requires a non-empty value".into()
);
}
let normalized = if s.starts_with('.') {
s
} else {
format!(".{s}")
};
args.hide_dotdirs.push(normalized);
}
Long(s @ ("lockdown" | "no-lockdown")) => {
args.lockdown = Some(s == "lockdown");
}
Long(s @ ("private-home" | "no-private-home")) => {
args.private_home = Some(s == "private-home");
}
Long(s @ ("landlock" | "no-landlock")) => {
args.landlock = Some(s == "landlock");
}
Long(s @ ("seccomp" | "no-seccomp")) => {
args.seccomp = Some(s == "seccomp");
}
Long(s @ ("rlimits" | "no-rlimits")) => {
args.rlimits = Some(s == "rlimits");
}
Long(s @ ("systemd-user" | "no-systemd-user")) => {
args.systemd_user = Some(s == "systemd-user");
}
Long("allow-tcp-port") => {
let val: String = parser
.value()
.map_err(|e| e.to_string())?
.to_string_lossy()
.into_owned();
let port: u16 = val
.parse()
.map_err(|_| format!("invalid port number: {val}"))?;
args.allow_tcp_ports.push(port);
}
Long("claude-dir") => {
let val = parser.value().map_err(|e| e.to_string())?;
args.claude_dir =
Some(PathBuf::from(val.to_string_lossy().into_owned()));
}
Long(s @ ("gpu" | "no-gpu")) => {
args.gpu = Some(s == "gpu");
}
Long(s @ ("docker" | "no-docker")) => {
args.docker = Some(s == "docker");
}
Long(s @ ("tailscale" | "no-tailscale")) => {
args.tailscale = Some(s == "tailscale");
}
Long(s @ ("display" | "no-display")) => {
args.display = Some(s == "display");
}
Long(s @ ("network" | "no-network")) => {
args.network = Some(s == "network");
}
Long(s @ ("macos-host-ipc" | "no-macos-host-ipc")) => {
args.macos_host_ipc = Some(s == "macos-host-ipc");
}
Long(s @ ("x11" | "no-x11")) => {
args.x11 = Some(s == "x11");
}
Long(s @ ("host-shm" | "no-host-shm")) => {
args.host_shm = Some(s == "host-shm");
}
Long(s @ ("terminal-passthrough" | "no-terminal-passthrough")) => {
args.terminal_passthrough = Some(s == "terminal-passthrough");
}
Long(s @ ("agent-state" | "no-agent-state")) => {
args.agent_state = Some(s == "agent-state");
}
Long(s @ ("inherit-env" | "no-inherit-env")) => {
args.inherit_env = Some(s == "inherit-env");
}
Long(s @ ("update-check" | "no-update-check")) => {
args.update_check = Some(s == "update-check");
}
Long("env") => {
let val = parser.value().map_err(|e| e.to_string())?;
let s = val.to_string_lossy();
let name = s.split('=').next().unwrap_or("");
if s.is_empty() || name.is_empty() {
return Err(
"--env requires a non-empty NAME or NAME=VALUE".into(),
);
}
args.env.push(s.into_owned());
}
Long(s @ ("worktree" | "no-worktree")) => {
args.worktree = Some(s == "worktree");
}
Long(s @ ("mise" | "no-mise")) => {
args.mise = Some(s == "mise");
}
Long(s @ ("save-config" | "no-save-config")) => {
args.save_config = Some(s == "save-config");
}
Long(s @ ("hide-config" | "no-hide-config")) => {
args.hide_config = Some(s == "hide-config");
}
Long(s @ ("ssh" | "no-ssh")) => {
args.ssh = Some(s == "ssh");
}
Long(s @ ("pictures" | "no-pictures")) => {
args.pictures = Some(s == "pictures");
}
Long("browser") => {
let profile = if let Some(val) = parser.optional_value() {
let s = val.to_string_lossy();
match s.as_ref() {
"hard" | "soft" => s.into_owned(),
_ => {
return Err(format!(
"invalid browser profile: \
{s} (expected 'hard' or 'soft')"
));
}
}
} else {
"hard".into()
};
args.browser_profile = Some(profile);
}
Long("no-browser") => args.browser_profile = Some("off".into()),
Long("status-bar") | Short('s') => {
if let Some(val) = parser.optional_value() {
let s = val.to_string_lossy();
match s.as_ref() {
"dark" | "light" | "pastel" => {
args.status_bar_style = Some(s.into_owned());
}
_ => {
return Err(format!(
"invalid status bar style: \
{s} (expected 'dark', 'light', or 'pastel')"
));
}
}
} else {
args.status_bar_style = Some("pastel".into());
}
}
Long("no-status-bar") => args.status_bar = Some(false),
Long("exec") => {
args.exec = true;
args.status_bar = Some(false);
}
Long("landlock-exec") => args.landlock_exec = true,
Long("landlock-rw-path") => {
if !args.landlock_exec {
return Err(
"--landlock-rw-path is internal and only valid with --landlock-exec"
.into(),
);
}
let val: PathBuf =
parser.value().map_err(|e| e.to_string())?.into();
args.landlock_rw_paths.push(val);
}
Long("landlock-ro-path") => {
if !args.landlock_exec {
return Err(
"--landlock-ro-path is internal and only valid with --landlock-exec"
.into(),
);
}
let val: PathBuf =
parser.value().map_err(|e| e.to_string())?.into();
args.landlock_ro_paths.push(val);
}
Long("clean") => args.clean = true,
Long("dry-run") => args.dry_run = true,
Long("init") => args.init = true,
Long("bootstrap") => args.bootstrap = true,
Short('v') | Long("verbose") => args.verbose = true,
Short('h') | Long("help") => {
print!("{HELP}");
std::process::exit(0);
}
Short('V') | Long("version") => {
println!("ai-jail {VERSION}");
std::process::exit(0);
}
Value(val) => {
let s = val.to_string_lossy().into_owned();
if s == "status" {
args.status = true;
} else {
args.command.push(s);
let mut after_separator = false;
for raw in parser.raw_args().map_err(|e| e.to_string())? {
let raw = raw.to_string_lossy().into_owned();
if raw == "--" {
after_separator = true;
args.command.push(raw);
continue;
}
if !after_separator && is_sandbox_long_flag(&raw) {
return Err(format!(
"flag {raw} after command would be passed to the child; put sandbox flags before the command or use --"
));
}
args.command.push(raw);
}
}
}
Long(other) => return Err(format!("unknown option: --{other}")),
Short(c) => return Err(format!("unknown option: -{c}")),
}
}
Ok(args)
}
fn is_sandbox_long_flag(arg: &str) -> bool {
let flag = arg.split_once('=').map_or(arg, |(flag, _)| flag);
matches!(
flag,
"--private-home"
| "--no-private-home"
| "--lockdown"
| "--no-lockdown"
| "--no-display"
| "--display"
| "--network"
| "--no-network"
| "--macos-host-ipc"
| "--no-macos-host-ipc"
| "--no-gpu"
| "--gpu"
| "--no-docker"
| "--docker"
| "--tailscale"
| "--no-tailscale"
| "--landlock"
| "--no-landlock"
| "--seccomp"
| "--no-seccomp"
| "--rlimits"
| "--no-rlimits"
| "--ssh"
| "--no-ssh"
| "--pictures"
| "--no-pictures"
| "--clean"
| "--exec"
| "--dry-run"
| "--rw-map"
| "--ro-map"
| "--overlay-map"
| "--map"
| "--mask"
| "--deny-path"
| "--mask-except"
| "--deny-path-except"
| "--hide-dotdir"
| "--allow-tcp-port"
| "--systemd-user"
| "--no-systemd-user"
| "--worktree"
| "--no-worktree"
| "--browser"
| "--no-browser"
| "--claude-dir"
| "--x11"
| "--no-x11"
| "--host-shm"
| "--no-host-shm"
| "--terminal-passthrough"
| "--no-terminal-passthrough"
| "--agent-state"
| "--no-agent-state"
| "--inherit-env"
| "--no-inherit-env"
| "--update-check"
| "--no-update-check"
| "--env"
| "--mise"
| "--no-mise"
| "--save-config"
| "--no-save-config"
| "--hide-config"
| "--no-hide-config"
| "--status-bar"
| "--no-status-bar"
| "--init"
| "--bootstrap"
| "--verbose"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_test(args: &[&str]) -> Result<CliArgs, String> {
let parser = lexopt::Parser::from_args(args);
parse_from(parser)
}
#[test]
fn parse_no_args() {
let args = parse_test(&[]).unwrap();
assert!(args.command.is_empty());
assert_eq!(args.lockdown, None);
assert!(!args.dry_run);
assert!(!args.init);
assert!(!args.verbose);
assert!(!args.clean);
assert!(!args.status);
}
#[test]
fn parse_simple_command() {
let args = parse_test(&["claude"]).unwrap();
assert_eq!(args.command, vec!["claude"]);
}
#[test]
fn parse_command_with_args() {
let args = parse_test(&["claude", "--model", "opus"]).unwrap();
assert_eq!(args.command, vec!["claude", "--model", "opus"]);
}
#[test]
fn parse_bash_command() {
let args = parse_test(&["bash"]).unwrap();
assert_eq!(args.command, vec!["bash"]);
}
#[test]
fn parse_pi_command() {
let args = parse_test(&["pi"]).unwrap();
assert_eq!(args.command, vec!["pi"]);
}
#[test]
fn help_lists_pi_preset() {
assert!(HELP.contains("grok, pi, kimi, bash"));
}
#[test]
fn parse_status_command() {
let args = parse_test(&["status"]).unwrap();
assert!(args.status);
assert!(args.command.is_empty());
}
#[test]
fn parse_dry_run() {
let args = parse_test(&["--dry-run", "bash"]).unwrap();
assert!(args.dry_run);
assert_eq!(args.command, vec!["bash"]);
}
#[test]
fn parse_init() {
let args = parse_test(&["--init", "claude"]).unwrap();
assert!(args.init);
assert_eq!(args.command, vec!["claude"]);
}
#[test]
fn parse_clean() {
let args = parse_test(&["--clean", "bash"]).unwrap();
assert!(args.clean);
}
#[test]
fn parse_verbose_short() {
let args = parse_test(&["-v", "bash"]).unwrap();
assert!(args.verbose);
}
#[test]
fn parse_verbose_long() {
let args = parse_test(&["--verbose", "bash"]).unwrap();
assert!(args.verbose);
}
#[test]
fn parse_no_gpu() {
let args = parse_test(&["--no-gpu", "bash"]).unwrap();
assert_eq!(args.gpu, Some(false));
}
#[test]
fn parse_lockdown() {
let args = parse_test(&["--lockdown", "bash"]).unwrap();
assert_eq!(args.lockdown, Some(true));
}
#[test]
fn parse_private_home() {
let args = parse_test(&["--private-home", "bash"]).unwrap();
assert_eq!(args.private_home, Some(true));
}
#[test]
fn parse_no_private_home() {
let args = parse_test(&["--no-private-home", "bash"]).unwrap();
assert_eq!(args.private_home, Some(false));
}
#[test]
fn parse_no_lockdown() {
let args = parse_test(&["--no-lockdown", "bash"]).unwrap();
assert_eq!(args.lockdown, Some(false));
}
#[test]
fn parse_systemd_user() {
let args = parse_test(&["--systemd-user", "bash"]).unwrap();
assert_eq!(args.systemd_user, Some(true));
}
#[test]
fn parse_no_systemd_user() {
let args = parse_test(&["--no-systemd-user", "bash"]).unwrap();
assert_eq!(args.systemd_user, Some(false));
}
#[test]
fn parse_systemd_user_last_wins() {
let args = parse_test(&[
"--systemd-user",
"--no-systemd-user",
"--systemd-user",
"bash",
])
.unwrap();
assert_eq!(args.systemd_user, Some(true));
}
#[test]
fn parse_landlock() {
let args = parse_test(&["--landlock", "bash"]).unwrap();
assert_eq!(args.landlock, Some(true));
}
#[test]
fn parse_no_landlock() {
let args = parse_test(&["--no-landlock", "bash"]).unwrap();
assert_eq!(args.landlock, Some(false));
}
#[test]
fn parse_gpu() {
let args = parse_test(&["--gpu", "bash"]).unwrap();
assert_eq!(args.gpu, Some(true));
}
#[test]
fn parse_no_docker() {
let args = parse_test(&["--no-docker", "bash"]).unwrap();
assert_eq!(args.docker, Some(false));
}
#[test]
fn parse_docker() {
let args = parse_test(&["--docker", "bash"]).unwrap();
assert_eq!(args.docker, Some(true));
}
#[test]
fn parse_no_tailscale() {
let args = parse_test(&["--no-tailscale", "bash"]).unwrap();
assert_eq!(args.tailscale, Some(false));
}
#[test]
fn parse_tailscale() {
let args = parse_test(&["--tailscale", "bash"]).unwrap();
assert_eq!(args.tailscale, Some(true));
}
#[test]
fn parse_no_display() {
let args = parse_test(&["--no-display", "bash"]).unwrap();
assert_eq!(args.display, Some(false));
}
#[test]
fn parse_display() {
let args = parse_test(&["--display", "bash"]).unwrap();
assert_eq!(args.display, Some(true));
}
#[test]
fn parse_network_last_wins() {
let args = parse_test(&["--network", "--no-network", "bash"]).unwrap();
assert_eq!(args.network, Some(false));
}
#[test]
fn parse_macos_host_ipc_last_wins() {
let args =
parse_test(&["--macos-host-ipc", "--no-macos-host-ipc", "bash"])
.unwrap();
assert_eq!(args.macos_host_ipc, Some(false));
}
#[test]
fn sandbox_flag_with_value_after_command_is_rejected() {
let error = parse_test(&["claude", "--browser=soft"]).unwrap_err();
assert!(error.contains("flag --browser=soft after command"));
let error =
parse_test(&["claude", "--allow-tcp-port=443"]).unwrap_err();
assert!(error.contains("flag --allow-tcp-port=443 after command"));
}
#[test]
fn parse_x11() {
let args = parse_test(&["--x11", "--no-x11", "bash"]).unwrap();
assert_eq!(args.x11, Some(false));
}
#[test]
fn parse_host_shm() {
let args = parse_test(&["--host-shm", "bash"]).unwrap();
assert_eq!(args.host_shm, Some(true));
}
#[test]
fn parse_terminal_passthrough() {
let args = parse_test(&[
"--terminal-passthrough",
"--no-terminal-passthrough",
"bash",
])
.unwrap();
assert_eq!(args.terminal_passthrough, Some(false));
}
#[test]
fn sandbox_flag_after_command_is_rejected() {
let error = parse_test(&["claude", "--private-home"]).unwrap_err();
assert!(error.contains("flag --private-home after command"));
}
#[test]
fn unknown_child_flag_after_command_is_preserved() {
let args = parse_test(&["claude", "--foo"]).unwrap();
assert_eq!(args.command, vec!["claude", "--foo"]);
}
#[test]
fn parse_no_mise() {
let args = parse_test(&["--no-mise", "bash"]).unwrap();
assert_eq!(args.mise, Some(false));
}
#[test]
fn parse_worktree() {
let args = parse_test(&["--worktree", "bash"]).unwrap();
assert_eq!(args.worktree, Some(true));
}
#[test]
fn parse_no_worktree() {
let args = parse_test(&["--no-worktree", "bash"]).unwrap();
assert_eq!(args.worktree, Some(false));
}
#[test]
fn parse_mise() {
let args = parse_test(&["--mise", "bash"]).unwrap();
assert_eq!(args.mise, Some(true));
}
#[test]
fn parse_ssh() {
let args = parse_test(&["--ssh", "bash"]).unwrap();
assert_eq!(args.ssh, Some(true));
}
#[test]
fn parse_no_ssh() {
let args = parse_test(&["--no-ssh", "bash"]).unwrap();
assert_eq!(args.ssh, Some(false));
}
#[test]
fn parse_pictures() {
let args = parse_test(&["--pictures", "bash"]).unwrap();
assert_eq!(args.pictures, Some(true));
}
#[test]
fn parse_no_pictures() {
let args = parse_test(&["--no-pictures", "bash"]).unwrap();
assert_eq!(args.pictures, Some(false));
}
#[test]
fn parse_browser_default_hard() {
let args = parse_test(&["--browser", "chromium"]).unwrap();
assert_eq!(args.browser_profile.as_deref(), Some("hard"));
assert_eq!(args.command, vec!["chromium"]);
}
#[test]
fn parse_browser_soft() {
let args = parse_test(&["--browser=soft", "firefox"]).unwrap();
assert_eq!(args.browser_profile.as_deref(), Some("soft"));
assert_eq!(args.command, vec!["firefox"]);
}
#[test]
fn parse_no_browser() {
let args = parse_test(&["--no-browser", "chromium"]).unwrap();
assert_eq!(args.browser_profile.as_deref(), Some("off"));
}
#[test]
fn parse_browser_invalid_errors() {
let result = parse_test(&["--browser=maybe", "chromium"]);
assert!(result.is_err());
}
#[test]
fn parse_save_config() {
let args = parse_test(&["--save-config", "bash"]).unwrap();
assert_eq!(args.save_config, Some(true));
}
#[test]
fn parse_no_save_config() {
let args = parse_test(&["--no-save-config", "bash"]).unwrap();
assert_eq!(args.save_config, Some(false));
}
#[test]
fn parse_hide_config() {
let args = parse_test(&["--hide-config", "bash"]).unwrap();
assert_eq!(args.hide_config, Some(true));
}
#[test]
fn parse_no_hide_config() {
let args = parse_test(&["--no-hide-config", "bash"]).unwrap();
assert_eq!(args.hide_config, Some(false));
}
#[test]
fn parse_mask_single() {
let args = parse_test(&["--mask", ".env", "bash"]).unwrap();
assert_eq!(args.mask, vec![PathBuf::from(".env")]);
}
#[test]
fn parse_mask_multiple() {
let args =
parse_test(&["--mask", ".env", "--mask", ".env.local", "bash"])
.unwrap();
assert_eq!(
args.mask,
vec![PathBuf::from(".env"), PathBuf::from(".env.local")]
);
}
#[test]
fn parse_mask_empty_errors() {
let result = parse_test(&["--mask", "", "bash"]);
assert!(result.is_err());
}
#[test]
fn parse_mask_missing_value_errors() {
let result = parse_test(&["--mask"]);
assert!(result.is_err());
}
#[test]
fn parse_deny_path_single() {
let args = parse_test(&["--deny-path", ".env", "bash"]).unwrap();
assert_eq!(args.deny_paths, vec![PathBuf::from(".env")]);
}
#[test]
fn parse_deny_path_multiple() {
let args = parse_test(&[
"--deny-path",
".env",
"--deny-path",
"secrets/*.json",
"bash",
])
.unwrap();
assert_eq!(
args.deny_paths,
vec![PathBuf::from(".env"), PathBuf::from("secrets/*.json")]
);
}
#[test]
fn parse_mask_and_deny_path_exceptions() {
let args = parse_test(&[
"--mask-except",
"**/target/**",
"--deny-path-except",
"public.key",
"bash",
])
.unwrap();
assert_eq!(args.mask_exceptions, vec![PathBuf::from("**/target/**")]);
assert_eq!(
args.deny_path_exceptions,
vec![PathBuf::from("public.key")]
);
}
#[test]
fn parse_exception_empty_and_missing_values_error() {
assert!(parse_test(&["--mask-except", "", "bash"]).is_err());
assert!(parse_test(&["--deny-path-except", "", "bash"]).is_err());
assert!(parse_test(&["--mask-except"]).is_err());
assert!(parse_test(&["--deny-path-except"]).is_err());
}
#[test]
fn help_describes_explicit_exceptions() {
assert!(HELP.contains("--mask-except <PATH|GLOB>"));
assert!(HELP.contains("weakens protection"));
assert!(HELP.contains("no ! negation"));
}
#[test]
fn parse_deny_path_empty_errors() {
let result = parse_test(&["--deny-path", "", "bash"]);
assert!(result.is_err());
}
#[test]
fn parse_deny_path_missing_value_errors() {
let result = parse_test(&["--deny-path"]);
assert!(result.is_err());
}
#[test]
fn parse_rw_map() {
let args = parse_test(&["--rw-map", "/tmp/test", "bash"]).unwrap();
assert_eq!(args.rw_maps, vec![PathBuf::from("/tmp/test")]);
}
#[test]
fn parse_overlay_map() {
let args = parse_test(&[
"--overlay-map",
"/home/u/.claude",
"--overlay-map",
"/home/u/.config/foo",
"bash",
])
.unwrap();
assert_eq!(
args.overlay_maps,
vec![
PathBuf::from("/home/u/.claude"),
PathBuf::from("/home/u/.config/foo"),
]
);
}
#[test]
fn parse_ro_map() {
let args = parse_test(&["--map", "/opt/data", "bash"]).unwrap();
assert_eq!(args.ro_maps, vec![PathBuf::from("/opt/data")]);
}
#[test]
fn parse_maps_with_alternate_destinations() {
let args = parse_test(&[
"--map",
"~/.ssh/ai-jail:~/.ssh",
"--rw-map",
"data:vendor/data",
"bash",
])
.unwrap();
assert_eq!(args.ro_maps, vec![PathBuf::from("~/.ssh/ai-jail:~/.ssh")]);
assert_eq!(args.rw_maps, vec![PathBuf::from("data:vendor/data")]);
}
#[test]
fn parse_multiple_maps() {
let args = parse_test(&[
"--rw-map", "/tmp/a", "--rw-map", "/tmp/b", "--map", "/opt/c",
"bash",
])
.unwrap();
assert_eq!(
args.rw_maps,
vec![PathBuf::from("/tmp/a"), PathBuf::from("/tmp/b")]
);
assert_eq!(args.ro_maps, vec![PathBuf::from("/opt/c")]);
}
#[test]
fn parse_hide_dotdir() {
let args =
parse_test(&["--hide-dotdir", ".my_secrets", "bash"]).unwrap();
assert_eq!(args.hide_dotdirs, vec![".my_secrets"]);
}
#[test]
fn parse_multiple_hide_dotdirs() {
let args = parse_test(&[
"--hide-dotdir",
".my_secrets",
"--hide-dotdir",
".proton",
"bash",
])
.unwrap();
assert_eq!(args.hide_dotdirs, vec![".my_secrets", ".proton"]);
}
#[test]
fn parse_hide_dotdir_with_maps() {
let args = parse_test(&[
"--hide-dotdir",
".aws",
"--rw-map",
"/tmp/test",
"--map",
"/opt/data",
"bash",
])
.unwrap();
assert_eq!(args.hide_dotdirs, vec![".aws"]);
assert_eq!(args.rw_maps, vec![PathBuf::from("/tmp/test")]);
assert_eq!(args.ro_maps, vec![PathBuf::from("/opt/data")]);
}
#[test]
fn parse_hide_dotdir_normalizes_no_dot() {
let args =
parse_test(&["--hide-dotdir", "my_secrets", "bash"]).unwrap();
assert_eq!(args.hide_dotdirs, vec![".my_secrets"]);
}
#[test]
fn parse_hide_dotdir_keeps_existing_dot() {
let args =
parse_test(&["--hide-dotdir", ".my_secrets", "bash"]).unwrap();
assert_eq!(args.hide_dotdirs, vec![".my_secrets"]);
}
#[test]
fn parse_hide_dotdir_empty_errors() {
let result = parse_test(&["--hide-dotdir", "", "bash"]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("non-empty"));
}
#[test]
fn parse_multiple_flags_combined() {
let args = parse_test(&[
"--dry-run",
"--verbose",
"--no-gpu",
"--no-docker",
"--worktree",
"--rw-map",
"/tmp/test",
"claude",
])
.unwrap();
assert!(args.dry_run);
assert!(args.verbose);
assert_eq!(args.gpu, Some(false));
assert_eq!(args.docker, Some(false));
assert_eq!(args.worktree, Some(true));
assert_eq!(args.rw_maps, vec![PathBuf::from("/tmp/test")]);
assert_eq!(args.command, vec!["claude"]);
}
#[test]
fn parse_init_clean_together() {
let args = parse_test(&["--clean", "--init", "bash"]).unwrap();
assert!(args.clean);
assert!(args.init);
assert_eq!(args.command, vec!["bash"]);
}
#[test]
fn parse_bootstrap() {
let args = parse_test(&["--bootstrap"]).unwrap();
assert!(args.bootstrap);
assert!(args.command.is_empty());
}
#[test]
fn parse_bootstrap_with_verbose() {
let args = parse_test(&["--bootstrap", "-v"]).unwrap();
assert!(args.bootstrap);
assert!(args.verbose);
}
#[test]
fn parse_unknown_flag_errors() {
let result = parse_test(&["--unknown-flag"]);
assert!(result.is_err());
}
#[test]
fn parse_unknown_short_flag_errors() {
let result = parse_test(&["-z"]);
assert!(result.is_err());
}
#[test]
fn parse_rw_map_missing_value_errors() {
let result = parse_test(&["--rw-map"]);
assert!(result.is_err());
}
#[test]
fn parse_hide_dotdir_missing_value_errors() {
let result = parse_test(&["--hide-dotdir"]);
assert!(result.is_err());
}
#[test]
fn parse_landlock_exec() {
let args =
parse_test(&["--landlock-exec", "--", "claude", "--continue"])
.unwrap();
assert!(args.landlock_exec);
assert_eq!(args.command, vec!["claude", "--continue"]);
}
#[test]
fn parse_landlock_exec_with_lockdown() {
let args = parse_test(&[
"--landlock-exec",
"--lockdown",
"--verbose",
"--",
"bash",
])
.unwrap();
assert!(args.landlock_exec);
assert_eq!(args.lockdown, Some(true));
assert!(args.verbose);
assert_eq!(args.command, vec!["bash"]);
}
#[test]
fn parse_internal_landlock_paths_are_opaque() {
let rw = PathBuf::from("/jail/name:/etc");
let ro = PathBuf::from("/jail/ro:name");
let args = parse_test(&[
"--landlock-exec",
"--landlock-rw-path",
rw.to_str().unwrap(),
"--landlock-ro-path",
ro.to_str().unwrap(),
"--",
"bash",
])
.unwrap();
assert_eq!(args.landlock_rw_paths, vec![rw]);
assert_eq!(args.landlock_ro_paths, vec![ro]);
assert!(!HELP.contains("--landlock-rw-path"));
assert!(!HELP.contains("--landlock-ro-path"));
}
#[test]
fn parse_internal_landlock_paths_rejected_without_exec() {
let result =
parse_test(&["--landlock-rw-path", "/jail/rw", "--", "bash"]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("internal"));
}
#[test]
fn parse_exec_simple() {
let args = parse_test(&["--exec", "my-script.sh"]).unwrap();
assert!(args.exec);
assert_eq!(args.status_bar, Some(false));
assert_eq!(args.command, vec!["my-script.sh"]);
}
#[test]
fn parse_exec_with_args() {
let args = parse_test(&[
"--exec",
"--",
"my-script.sh",
"--flag",
"-o",
"out",
])
.unwrap();
assert!(args.exec);
assert_eq!(args.command, vec!["my-script.sh", "--flag", "-o", "out"]);
}
#[test]
fn parse_exec_with_sandbox_flags() {
let args = parse_test(&["--lockdown", "--exec", "--", "cargo", "test"])
.unwrap();
assert!(args.exec);
assert_eq!(args.lockdown, Some(true));
assert_eq!(args.command, vec!["cargo", "test"]);
}
#[test]
fn parse_dashdash_passes_remaining_as_command() {
let args =
parse_test(&["--dry-run", "--", "my-tool", "--some-flag"]).unwrap();
assert!(args.dry_run);
assert_eq!(args.command, vec!["my-tool", "--some-flag"]);
}
#[test]
fn parse_status_bar() {
let args = parse_test(&["--status-bar", "bash"]).unwrap();
assert_eq!(args.status_bar, None);
assert_eq!(args.status_bar_style.as_deref(), Some("pastel"));
}
#[test]
fn parse_no_status_bar() {
let args = parse_test(&["--no-status-bar", "bash"]).unwrap();
assert_eq!(args.status_bar, Some(false));
}
#[test]
fn parse_status_bar_short() {
let args = parse_test(&["-s", "bash"]).unwrap();
assert_eq!(args.status_bar, None);
assert_eq!(args.status_bar_style.as_deref(), Some("pastel"));
}
#[test]
fn parse_status_bar_eq_light() {
let args = parse_test(&["--status-bar=light", "bash"]).unwrap();
assert_eq!(args.status_bar, None);
assert_eq!(args.status_bar_style.as_deref(), Some("light"));
}
#[test]
fn parse_status_bar_eq_dark() {
let args = parse_test(&["--status-bar=dark", "bash"]).unwrap();
assert_eq!(args.status_bar, None);
assert_eq!(args.status_bar_style.as_deref(), Some("dark"));
}
#[test]
fn parse_status_bar_eq_invalid() {
let result = parse_test(&["--status-bar=neon", "bash"]);
assert!(result.is_err());
}
#[test]
fn parse_allow_tcp_port_single() {
let args =
parse_test(&["--lockdown", "--allow-tcp-port", "32000", "bash"])
.unwrap();
assert_eq!(args.allow_tcp_ports, vec![32000]);
assert_eq!(args.lockdown, Some(true));
}
#[test]
fn parse_allow_tcp_port_multiple() {
let args = parse_test(&[
"--allow-tcp-port",
"32000",
"--allow-tcp-port",
"8080",
"bash",
])
.unwrap();
assert_eq!(args.allow_tcp_ports, vec![32000, 8080]);
}
#[test]
fn parse_allow_tcp_port_boundary_values() {
let args = parse_test(&[
"--allow-tcp-port",
"0",
"--allow-tcp-port",
"65535",
"bash",
])
.unwrap();
assert_eq!(args.allow_tcp_ports, vec![0, 65535]);
}
#[test]
fn parse_allow_tcp_port_overflow() {
assert!(parse_test(&["--allow-tcp-port", "65536"]).is_err());
}
#[test]
fn parse_allow_tcp_port_invalid() {
assert!(parse_test(&["--allow-tcp-port", "abc"]).is_err());
}
#[test]
fn parse_allow_tcp_port_missing_value() {
assert!(parse_test(&["--allow-tcp-port"]).is_err());
}
#[test]
fn parse_claude_dir() {
let args = parse_test(&[
"--claude-dir",
"/home/user/.claude-example",
"claude",
])
.unwrap();
assert_eq!(
args.claude_dir,
Some(PathBuf::from("/home/user/.claude-example"))
);
assert_eq!(args.command, vec!["claude"]);
}
#[test]
fn parse_claude_dir_missing_value_errors() {
assert!(parse_test(&["--claude-dir"]).is_err());
}
#[test]
fn parse_last_wins_gpu() {
let args = parse_test(&["--no-gpu", "--gpu", "bash"]).unwrap();
assert_eq!(args.gpu, Some(true));
}
#[test]
fn parse_last_wins_docker() {
let args = parse_test(&["--docker", "--no-docker", "bash"]).unwrap();
assert_eq!(args.docker, Some(false));
}
#[test]
fn parse_last_wins_tailscale() {
let args =
parse_test(&["--tailscale", "--no-tailscale", "bash"]).unwrap();
assert_eq!(args.tailscale, Some(false));
}
#[test]
fn parse_last_wins_save_config_enabled() {
let args =
parse_test(&["--no-save-config", "--save-config", "bash"]).unwrap();
assert_eq!(args.save_config, Some(true));
}
#[test]
fn parse_last_wins_save_config_disabled() {
let args =
parse_test(&["--save-config", "--no-save-config", "bash"]).unwrap();
assert_eq!(args.save_config, Some(false));
}
#[test]
fn parse_agent_state_last_wins() {
let args = parse_test(&["--agent-state", "bash"]).unwrap();
assert_eq!(args.agent_state, Some(true));
let args =
parse_test(&["--agent-state", "--no-agent-state", "bash"]).unwrap();
assert_eq!(args.agent_state, Some(false));
let args =
parse_test(&["--no-agent-state", "--agent-state", "bash"]).unwrap();
assert_eq!(args.agent_state, Some(true));
}
#[test]
fn parse_inherit_env_last_wins() {
let args =
parse_test(&["--inherit-env", "--no-inherit-env", "bash"]).unwrap();
assert_eq!(args.inherit_env, Some(false));
let args =
parse_test(&["--no-inherit-env", "--inherit-env", "bash"]).unwrap();
assert_eq!(args.inherit_env, Some(true));
}
#[test]
fn parse_update_check_last_wins() {
let args = parse_test(&["--update-check", "--no-update-check", "bash"])
.unwrap();
assert_eq!(args.update_check, Some(false));
let args = parse_test(&["--no-update-check", "--update-check", "bash"])
.unwrap();
assert_eq!(args.update_check, Some(true));
}
#[test]
fn parse_env_flag_repeatable_with_name_and_assignment() {
let args = parse_test(&[
"--env",
"ANTHROPIC_API_KEY",
"--env",
"CUSTOM=value",
"bash",
])
.unwrap();
assert_eq!(
args.env,
vec!["ANTHROPIC_API_KEY".to_string(), "CUSTOM=value".to_string()]
);
}
#[test]
fn parse_env_flag_rejects_empty_name() {
assert!(parse_test(&["--env", "", "bash"]).is_err());
assert!(parse_test(&["--env", "=value", "bash"]).is_err());
assert!(parse_test(&["--env"]).is_err());
}
#[test]
fn env_flag_after_command_is_rejected() {
let error = parse_test(&["claude", "--env", "SECRET"]).unwrap_err();
assert!(error.contains("flag --env after command"));
}
}