#[cfg(not(any(target_os = "linux", target_os = "macos")))]
compile_error!("ai-jail only supports Linux and macOS");
mod bootstrap;
mod cli;
mod config;
mod fsutil;
mod output;
mod pty;
mod sandbox;
mod signals;
mod statusbar;
#[cfg(test)]
mod test_utils;
fn command_basename(command: &[String]) -> Option<&str> {
command.first().and_then(|cmd| {
std::path::Path::new(cmd)
.file_name()
.and_then(|name| name.to_str())
})
}
fn command_needs_direct_tty(command: &[String]) -> bool {
matches!(command_basename(command), Some("crush") | Some("opencode"))
}
fn command_is_browser(command: &[String]) -> bool {
command_basename(command).is_some_and(sandbox::is_browser_command_name)
}
fn resolve_browser_profile(
config: &config::Config,
) -> Option<config::BrowserProfile> {
if config.browser_profile_disabled() {
return None;
}
config.browser_profile().or_else(|| {
if command_is_browser(&config.command) {
Some(config::BrowserProfile::Hard)
} else {
None
}
})
}
fn apply_browser_profile(config: &mut config::Config) {
let Some(profile) = resolve_browser_profile(config) else {
return;
};
config.browser_profile = Some(profile.as_str().into());
config.no_gpu.get_or_insert(true);
config.no_docker = Some(true);
config.no_display = Some(false);
config.no_worktree = Some(true);
config.no_mise = Some(true);
config.no_save_config = Some(true);
config.ssh = Some(false);
config.pictures = Some(false);
config.lockdown = Some(false);
config.no_status_bar = Some(true);
}
fn running_inside_multiplexer() -> Option<&'static str> {
if std::env::var_os("TMUX").is_some() {
Some("tmux")
} else if std::env::var_os("ZELLIJ").is_some() {
Some("zellij")
} else {
None
}
}
fn default_resize_redraw_key(command: &[String]) -> Option<&'static str> {
match command_basename(command) {
Some("codex") => Some("ctrl-shift-l"),
_ => None,
}
}
fn run_landlock_exec(cli: &cli::CliArgs) -> Result<i32, String> {
use std::os::unix::process::CommandExt;
if cli.command.is_empty() {
return Err("--landlock-exec requires a command".into());
}
let project_dir = std::env::current_dir()
.map_err(|e| format!("Cannot determine current directory: {e}"))?;
let mut config = config::merge(cli, config::Config::default());
config::absolutize_user_paths(&mut config, &project_dir);
sandbox::apply_landlock(&config, &project_dir, cli.verbose)?;
sandbox::apply_seccomp(&config, cli.verbose)?;
#[cfg(target_os = "linux")]
sandbox::rlimits::apply_nproc(&config, cli.verbose);
let err = std::process::Command::new(&cli.command[0])
.args(&cli.command[1..])
.exec();
Err(format!("Failed to exec {}: {err}", cli.command[0]))
}
fn validate_write_flags(cli: &cli::CliArgs) -> Result<(), String> {
if cli.init && cli.save_config == Some(false) {
return Err("--init conflicts with --no-save-config".into());
}
Ok(())
}
fn should_save_global_preferences(cli: &cli::CliArgs) -> bool {
!cli.dry_run && (cli.status_bar.is_some() || cli.status_bar_style.is_some())
}
fn should_auto_save_project_config(
cli: &cli::CliArgs,
config: &config::Config,
) -> bool {
!cli.dry_run && !config.lockdown_enabled() && config.save_config_enabled()
}
fn run() -> Result<i32, String> {
let cli = cli::parse()?;
validate_write_flags(&cli)?;
if cli.exec {
output::set_quiet(true);
}
if cli.landlock_exec {
if std::env::var("AI_JAIL_QUIET").is_ok() {
output::set_quiet(true);
}
return run_landlock_exec(&cli);
}
let global = config::load_global();
let local = if cli.clean {
config::Config::default()
} else {
config::load()
};
let existing = config::merge_with_global(global, local);
let stored_command = existing.command.clone();
let mut config = config::merge(&cli, existing);
let invocation_cwd = std::env::current_dir()
.map_err(|e| format!("Cannot determine current directory: {e}"))?;
config::absolutize_user_paths(&mut config, &invocation_cwd);
apply_browser_profile(&mut config);
if cli.status {
config::display_status(&config);
return Ok(0);
}
if should_save_global_preferences(&cli) {
config::save_global(&config);
}
if cli.init {
config::save(&config);
output::info("Config saved to .ai-jail");
return Ok(0);
}
if cli.bootstrap {
bootstrap::run(cli.verbose, config.claude_dir.as_deref())?;
return Ok(0);
}
sandbox::check()?;
sandbox::platform_notes(&config);
let guard = sandbox::prepare()?;
let project_dir = std::env::current_dir()
.map_err(|e| format!("Cannot determine current directory: {e}"))?;
if should_auto_save_project_config(&cli, &config) {
let mut to_save = config.clone();
if !stored_command.is_empty() && !cli.command.is_empty() {
to_save.command = stored_command;
}
config::save(&to_save);
}
if cli.dry_run {
let formatted =
sandbox::dry_run(&guard, &config, &project_dir, cli.verbose)?;
output::dry_run_line(&formatted);
return Ok(0);
}
output::info(&format!("Jail Active: {}", project_dir.display()));
signals::install_handlers();
let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
let stdin_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdin());
let needs_direct_tty = command_needs_direct_tty(&config.command);
let multiplexer = running_inside_multiplexer();
let explicit_status_bar =
cli.status_bar_style.is_some() || config.no_status_bar == Some(false);
let multiplexer_skip = multiplexer.is_some() && !explicit_status_bar;
let use_status_bar = config.status_bar_enabled()
&& stdout_is_tty
&& stdin_is_tty
&& !cli.exec
&& !needs_direct_tty
&& !multiplexer_skip;
if cli.verbose {
if config.status_bar_enabled() {
if needs_direct_tty {
output::verbose(&format!(
"Status bar: skipped ({} requires direct terminal passthrough)",
command_basename(&config.command).unwrap_or("command")
));
} else if multiplexer_skip {
output::verbose(&format!(
"Status bar: auto-disabled ({} detected; pass -s to force-enable)",
multiplexer.unwrap()
));
} else if stdout_is_tty && stdin_is_tty {
output::verbose("Status bar: enabled");
} else {
output::verbose("Status bar: skipped (stdio is not a tty)");
}
} else {
output::verbose(
"Status bar: off (use --no-status-bar to disable globally)",
);
}
}
if use_status_bar {
statusbar::setup(
&project_dir,
&config.command,
config.status_bar_style(),
&config,
);
statusbar::check_update_background();
}
let mut cmd = sandbox::build(&guard, &config, &project_dir, cli.verbose)?;
sandbox::rlimits::apply(&config, cli.verbose);
let exit_code = if use_status_bar {
let resize_redraw_key =
match config.resize_redraw_key.as_deref() {
Some(spec) => match pty::parse_resize_redraw_key(spec) {
Ok(seq) => seq,
Err(e) => {
output::warn(&format!(
"Ignoring invalid resize_redraw_key {spec:?}: {e}"
));
None
}
},
None => default_resize_redraw_key(&config.command).and_then(
|spec| pty::parse_resize_redraw_key(spec).ok().flatten(),
),
};
if cli.verbose {
match (&resize_redraw_key, config.resize_redraw_key.as_deref()) {
(Some(_), Some(spec)) => output::verbose(&format!(
"Resize redraw key: {spec} (used on terminal resize)"
)),
(None, Some(spec)) => output::verbose(&format!(
"Resize redraw key: {spec} (disabled)"
)),
(Some(_), None)
if default_resize_redraw_key(&config.command).is_some() =>
{
output::verbose(
"Resize redraw key: ctrl-shift-l (codex default)",
);
}
_ => {}
}
}
match pty::run(&mut cmd, resize_redraw_key.as_deref()) {
Ok(code) => {
statusbar::teardown();
code
}
Err(e) => {
statusbar::teardown();
return Err(e);
}
}
} else {
let child = cmd
.spawn()
.map_err(|e| format!("Failed to start sandbox: {e}"))?;
let pid = child.id() as i32;
signals::set_child_pid(pid);
let code = signals::wait_child(pid);
std::mem::forget(child);
output::terminal_reset();
code
};
drop(guard);
Ok(exit_code)
}
fn main() {
match run() {
Ok(code) => std::process::exit(code),
Err(msg) => {
output::error(&msg);
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::{
apply_browser_profile, command_is_browser, command_needs_direct_tty,
resolve_browser_profile, running_inside_multiplexer,
should_auto_save_project_config, should_save_global_preferences,
validate_write_flags,
};
use crate::cli::CliArgs;
use crate::config::{BrowserProfile, Config};
use crate::test_utils::{ENV_LOCK, EnvVarGuard};
#[test]
fn crush_requires_direct_tty() {
assert!(command_needs_direct_tty(&["crush".into()]));
assert!(command_needs_direct_tty(&["/usr/bin/crush".into()]));
}
#[test]
fn opencode_requires_direct_tty() {
assert!(command_needs_direct_tty(&["opencode".into()]));
assert!(command_needs_direct_tty(&[
"/home/x/.opencode/bin/opencode".into()
]));
}
#[test]
fn multiplexer_detects_tmux() {
let _guard = ENV_LOCK.lock().unwrap();
let _zellij = EnvVarGuard::remove("ZELLIJ");
let _tmux = EnvVarGuard::set("TMUX", "/tmp/fake");
assert_eq!(running_inside_multiplexer(), Some("tmux"));
}
#[test]
fn multiplexer_detects_zellij() {
let _guard = ENV_LOCK.lock().unwrap();
let _tmux = EnvVarGuard::remove("TMUX");
let _zellij = EnvVarGuard::set("ZELLIJ", "session-name");
assert_eq!(running_inside_multiplexer(), Some("zellij"));
}
#[test]
fn multiplexer_none_when_neither_set() {
let _guard = ENV_LOCK.lock().unwrap();
let _tmux = EnvVarGuard::remove("TMUX");
let _zellij = EnvVarGuard::remove("ZELLIJ");
assert_eq!(running_inside_multiplexer(), None);
}
#[test]
fn other_commands_do_not_require_direct_tty() {
assert!(!command_needs_direct_tty(&[]));
assert!(!command_needs_direct_tty(&["codex".into()]));
assert!(!command_needs_direct_tty(&["/usr/bin/bash".into()]));
}
#[test]
fn browser_detection_matches_common_browser_names() {
assert!(command_is_browser(&["chromium".into()]));
assert!(command_is_browser(&["/usr/bin/firefox".into()]));
assert!(command_is_browser(&["google-chrome-stable".into()]));
assert!(!command_is_browser(&["codex".into()]));
}
#[test]
fn browser_profile_auto_defaults_to_hard_for_browsers() {
let config = Config {
command: vec!["chromium".into()],
..Config::default()
};
assert_eq!(
resolve_browser_profile(&config),
Some(BrowserProfile::Hard)
);
}
#[test]
fn browser_profile_explicit_soft_wins() {
let config = Config {
command: vec!["chromium".into()],
browser_profile: Some("soft".into()),
..Config::default()
};
assert_eq!(
resolve_browser_profile(&config),
Some(BrowserProfile::Soft)
);
}
#[test]
fn browser_profile_can_be_disabled_for_browser_command() {
let config = Config {
command: vec!["chromium".into()],
browser_profile: Some("off".into()),
..Config::default()
};
assert_eq!(resolve_browser_profile(&config), None);
}
#[test]
fn browser_profile_applies_hardened_defaults() {
let mut config = Config {
command: vec!["chromium".into()],
..Config::default()
};
apply_browser_profile(&mut config);
assert_eq!(config.browser_profile.as_deref(), Some("hard"));
assert_eq!(config.no_gpu, Some(true));
assert_eq!(config.no_docker, Some(true));
assert_eq!(config.no_display, Some(false));
assert_eq!(config.no_worktree, Some(true));
assert_eq!(config.no_mise, Some(true));
assert_eq!(config.no_save_config, Some(true));
assert_eq!(config.ssh, Some(false));
assert_eq!(config.pictures, Some(false));
assert_eq!(config.lockdown, Some(false));
assert_eq!(config.no_status_bar, Some(true));
}
#[test]
fn validate_write_flags_rejects_init_with_no_save_config() {
let cli = CliArgs {
init: true,
save_config: Some(false),
..CliArgs::default()
};
assert!(validate_write_flags(&cli).is_err());
}
#[test]
fn validate_write_flags_allows_init_alone() {
let cli = CliArgs {
init: true,
..CliArgs::default()
};
assert!(validate_write_flags(&cli).is_ok());
}
#[test]
fn validate_write_flags_allows_init_with_save_config() {
let cli = CliArgs {
init: true,
save_config: Some(true),
..CliArgs::default()
};
assert!(validate_write_flags(&cli).is_ok());
}
#[test]
fn validate_write_flags_allows_no_save_config_alone() {
let cli = CliArgs {
save_config: Some(false),
..CliArgs::default()
};
assert!(validate_write_flags(&cli).is_ok());
}
#[test]
fn dry_run_skips_project_auto_save() {
let cli = CliArgs {
dry_run: true,
..CliArgs::default()
};
let config = Config::default();
assert!(!should_auto_save_project_config(&cli, &config));
}
#[test]
fn normal_run_allows_project_auto_save_by_default() {
let cli = CliArgs::default();
let config = Config::default();
assert!(should_auto_save_project_config(&cli, &config));
}
#[test]
fn dry_run_skips_global_preference_save() {
let cli = CliArgs {
dry_run: true,
status_bar_style: Some("dark".into()),
..CliArgs::default()
};
assert!(!should_save_global_preferences(&cli));
}
#[test]
fn status_bar_option_allows_global_preference_save() {
let cli = CliArgs {
status_bar_style: Some("dark".into()),
..CliArgs::default()
};
assert!(should_save_global_preferences(&cli));
}
}