#[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 output;
mod pty;
mod sandbox;
mod signals;
mod statusbar;
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 {
command_basename(command) == Some("crush")
}
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 config = config::merge(cli, config::Config::default());
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 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 config = config::merge(&cli, existing);
if cli.status {
config::display_status(&config);
return Ok(0);
}
if cli.status_bar.is_some() || cli.status_bar_style.is_some() {
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)?;
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 !config.lockdown_enabled() && config.save_config_enabled() {
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 use_status_bar = config.status_bar_enabled()
&& stdout_is_tty
&& stdin_is_tty
&& !cli.exec
&& !needs_direct_tty;
if cli.verbose {
if config.status_bar_enabled() {
if needs_direct_tty {
output::verbose(
"Status bar: skipped (crush requires direct terminal passthrough)",
);
} 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);
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::{command_needs_direct_tty, validate_write_flags};
use crate::cli::CliArgs;
#[test]
fn crush_requires_direct_tty() {
assert!(command_needs_direct_tty(&["crush".into()]));
assert!(command_needs_direct_tty(&["/usr/bin/crush".into()]));
}
#[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 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());
}
}