mod fs;
mod profiles;
mod redact;
use std::ffi::OsString;
use std::os::unix::process::ExitStatusExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{anyhow, Context, Result};
use clap::Parser;
use fuser::{Config, MountOption};
use nix::errno::Errno;
use nix::fcntl::{open, OFlag};
use nix::mount::{mount, MsFlags};
use nix::sched::{unshare, CloneFlags};
use nix::sys::stat::Mode;
use crate::fs::OverlayFs;
use crate::profiles::Profile;
#[derive(Parser)]
#[command(
version,
about = "Run a program with secrets redacted, and (for package managers) file access gated"
)]
struct Cli {
#[arg(long)]
allow_unknown_program: bool,
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(long)]
debug: bool,
#[arg(
trailing_var_arg = true,
required = true,
value_name = "PROGRAM [ARGS...]"
)]
command: Vec<OsString>,
}
fn main() {
let cli = Cli::parse();
let (program, program_args) = cli
.command
.split_first()
.expect("clap requires a program");
let profile = if let Some(name) = &cli.profile {
match profiles::by_name(name) {
Some(p) => p,
None => {
eprintln!(
"airgap: unknown profile '{name}' (expected one of: {})",
profiles::names().join(", ")
);
std::process::exit(2);
}
}
} else {
match profiles::resolve(program) {
Some(p) => p,
None if cli.allow_unknown_program => profiles::unrestricted(),
None => {
let name = profiles::program_basename(program).to_string_lossy();
eprintln!(
"airgap: refusing to run '{name}': not a recognized program ({}).\n \
airgap applies a per-program profile; to run something without one, \
pass --allow-unknown-program:\n\n \
airgap --allow-unknown-program {name} [args...]",
profiles::permitted_programs().join(", ")
);
std::process::exit(1);
}
}
};
match run(program, program_args, profile.as_ref(), cli.debug) {
Ok(code) => std::process::exit(code),
Err(e) => {
eprintln!("airgap: {e:#}");
std::process::exit(1);
}
}
}
fn run(
program: &OsString,
program_args: &[OsString],
profile: &dyn Profile,
debug: bool,
) -> Result<i32> {
unshare(CloneFlags::CLONE_NEWNS).map_err(|e| match e {
Errno::EPERM => anyhow!(missing_cap_sys_admin_msg()),
other => anyhow!("unshare(CLONE_NEWNS) failed: {other}"),
})?;
mount(
None::<&str>,
"/",
None::<&str>,
MsFlags::MS_REC | MsFlags::MS_PRIVATE,
None::<&str>,
)
.context("making mounts private")?;
let cwd = std::env::current_dir().context("getting current dir")?;
let targets = overlay_targets(&cwd);
let redact = profile.redaction();
let gate = profile.directory_gate(program, debug);
let mut config = Config::default();
config.mount_options = vec![MountOption::FSName("airgap".into())];
let mut sessions = Vec::new();
for dir in &targets {
let root = open(
dir,
OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC,
Mode::empty(),
)
.with_context(|| format!("opening {}", dir.display()))?;
let overlay = OverlayFs::new(root, dir.clone(), redact, gate.clone());
let session = fuser::spawn_mount2(overlay, dir, &config)
.with_context(|| format!("mounting overlay at {}", dir.display()))?;
sessions.push(session);
}
std::env::set_current_dir(&cwd)
.with_context(|| format!("re-entering working directory {}", cwd.display()))?;
let result = spawn_and_wait(program, program_args);
drop(sessions); result
}
fn overlay_targets(cwd: &Path) -> Vec<PathBuf> {
let mut candidates = vec![cwd.to_path_buf()];
if let Some(home) = std::env::var_os("HOME").filter(|h| !h.is_empty())
&& let Ok(home) = std::fs::canonicalize(&home)
{
candidates.push(home);
}
dedup_targets(candidates)
}
fn dedup_targets(candidates: Vec<PathBuf>) -> Vec<PathBuf> {
let mut targets: Vec<PathBuf> = Vec::new();
for dir in candidates {
if targets.iter().any(|kept| dir.starts_with(kept)) {
continue;
}
targets.retain(|kept| !kept.starts_with(&dir));
targets.push(dir);
}
targets
}
fn missing_cap_sys_admin_msg() -> String {
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "airgap".to_string());
format!(
"missing CAP_SYS_ADMIN, required to create a mount namespace and mount \
the FUSE overlay.\n Grant it to the binary once with:\n\n sudo \
setcap cap_sys_admin+ep {exe}\n\n \
(the capability is lost on rebuild/copy, so re-run it after each \
`cargo build` or `cargo install`), or run airgap inside an unprivileged user namespace."
)
}
fn spawn_and_wait(program: &OsString, program_args: &[OsString]) -> Result<i32> {
let status = Command::new(program)
.args(program_args)
.status()
.with_context(|| format!("running {program:?}"))?;
Ok(match status.code() {
Some(code) => code,
None => 128 + status.signal().unwrap_or(0),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn paths(items: &[&str]) -> Vec<PathBuf> {
items.iter().map(PathBuf::from).collect()
}
#[test]
fn keeps_disjoint_dirs() {
assert_eq!(
dedup_targets(paths(&["/tmp/work", "/home/sven"])),
paths(&["/tmp/work", "/home/sven"])
);
}
#[test]
fn drops_cwd_nested_in_home() {
assert_eq!(
dedup_targets(paths(&["/home/sven/proj", "/home/sven"])),
paths(&["/home/sven"])
);
}
#[test]
fn drops_home_nested_in_cwd() {
assert_eq!(
dedup_targets(paths(&["/", "/home/sven"])),
paths(&["/"])
);
}
#[test]
fn collapses_exact_duplicate() {
assert_eq!(
dedup_targets(paths(&["/home/sven", "/home/sven"])),
paths(&["/home/sven"])
);
}
#[test]
fn sibling_prefix_is_not_nesting() {
assert_eq!(
dedup_targets(paths(&["/home/sven", "/home/sven2"])),
paths(&["/home/sven", "/home/sven2"])
);
}
fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
Cli::try_parse_from(std::iter::once("airgap").chain(args.iter().copied()))
}
fn osvec(items: &[&str]) -> Vec<OsString> {
items.iter().map(OsString::from).collect()
}
#[test]
fn parses_program_and_args() {
let cli = parse(&["claude", "--model", "opus"]).unwrap();
assert!(!cli.allow_unknown_program);
assert_eq!(cli.command, osvec(&["claude", "--model", "opus"]));
}
#[test]
fn parses_allow_unknown_flag() {
let cli = parse(&["--allow-unknown-program", "cat", ".env"]).unwrap();
assert!(cli.allow_unknown_program);
assert_eq!(cli.command, osvec(&["cat", ".env"]));
}
#[test]
fn parses_profile_override() {
let cli = parse(&["--profile", "npm", "cat", "x"]).unwrap();
assert_eq!(cli.profile.as_deref(), Some("npm"));
assert_eq!(cli.command, osvec(&["cat", "x"]));
}
#[test]
fn profile_without_value_is_an_error() {
assert!(parse(&["--profile"]).is_err());
}
#[test]
fn parses_debug_flag() {
let cli = parse(&["--debug", "npm", "install"]).unwrap();
assert!(cli.debug);
assert_eq!(cli.command, osvec(&["npm", "install"]));
assert!(!parse(&["npm"]).unwrap().debug);
}
#[test]
fn flags_after_program_belong_to_the_child() {
let cli = parse(&["claude", "--allow-unknown-program"]).unwrap();
assert!(!cli.allow_unknown_program);
assert_eq!(cli.command, osvec(&["claude", "--allow-unknown-program"]));
}
#[test]
fn double_dash_ends_flag_parsing() {
let cli = parse(&["--", "--weird-name", "arg"]).unwrap();
assert!(!cli.allow_unknown_program);
assert_eq!(cli.command, osvec(&["--weird-name", "arg"]));
}
#[test]
fn missing_program_is_a_usage_error() {
assert!(parse(&[]).is_err());
assert!(parse(&["--allow-unknown-program"]).is_err());
assert!(parse(&["--"]).is_err());
}
#[test]
fn unknown_flag_is_rejected() {
assert!(parse(&["--nope", "claude"]).is_err());
}
}