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 nix::unistd::{getgid, getuid};
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> {
enter_namespaces()?;
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);
if debug {
eprintln!(
"airgap[debug]: overlay targets (redaction {}): {}",
if profile.redaction() { "on" } else { "off" },
targets
.iter()
.map(|t| t.display().to_string())
.collect::<Vec<_>>()
.join(", ")
);
}
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()))?;
if debug {
eprintln!("airgap[debug]: mounted FUSE 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 enter_namespaces() -> Result<()> {
let uid = getuid().as_raw();
let gid = getgid().as_raw();
match unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS) {
Ok(()) => map_ids_to_self(uid, gid).context("configuring user namespace id maps"),
Err(Errno::EPERM) => unshare(CloneFlags::CLONE_NEWNS).map_err(|e| match e {
Errno::EPERM => anyhow!(missing_privilege_msg()),
other => anyhow!("unshare(CLONE_NEWNS) failed: {other}"),
}),
Err(other) => Err(anyhow!("unshare(CLONE_NEWUSER|CLONE_NEWNS) failed: {other}")),
}
}
fn map_ids_to_self(uid: u32, gid: u32) -> Result<()> {
std::fs::write("/proc/self/setgroups", "deny").context("/proc/self/setgroups")?;
std::fs::write("/proc/self/uid_map", format!("{uid} {uid} 1")).context("/proc/self/uid_map")?;
std::fs::write("/proc/self/gid_map", format!("{gid} {gid} 1")).context("/proc/self/gid_map")?;
Ok(())
}
fn missing_privilege_msg() -> String {
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "airgap".to_string());
format!(
"could not create a mount namespace: unprivileged user namespaces appear \
to be disabled, and the binary lacks CAP_SYS_ADMIN.\n\n \
Either enable unprivileged user namespaces (pick what applies):\n\n \
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 # Ubuntu 24.04+\n \
sudo sysctl -w kernel.unprivileged_userns_clone=1 # some Debian/Ubuntu\n\n \
or grant the capability to the binary once:\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`)."
)
}
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());
}
}