use super::group_paths_by_parent;
use secafs_sdk::{SecAFS, SecAFSOptions, HostFS, OverlayFS};
use anyhow::{bail, Context, Result};
use std::{
cmp::Reverse,
ffi::CString,
fs,
io::BufRead,
os::unix::ffi::OsStrExt,
os::unix::fs::MetadataExt,
os::unix::io::AsRawFd,
path::{Path, PathBuf},
sync::{
atomic::{AtomicI32, Ordering},
Arc,
},
};
use tokio::sync::Mutex;
static CHILD_PID: AtomicI32 = AtomicI32::new(0);
static TERM_SIGNAL_COUNT: AtomicI32 = AtomicI32::new(0);
use crate::mount::{is_mountpoint, mount_fs, MountBackend, MountHandle, MountOpts};
const EXIT_COMMAND_NOT_FOUND: i32 = 127;
const FUSE_MOUNT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const SKIP_MOUNT_PREFIXES: &[&str] = &["/proc", "/sys", "/dev", "/tmp"];
const DEFAULT_ALLOWED_DIRS: &[&str] = &[
".amp",
".cache",
".claude",
".claude.json",
".codex",
".gemini",
".local",
".npm",
];
const MOUNTINFO_MOUNT_POINT_FIELD: usize = 4;
extern "C" fn forward_signal_to_child(sig: libc::c_int) {
let pid = CHILD_PID.load(Ordering::SeqCst);
if pid > 0 {
let count = TERM_SIGNAL_COUNT.fetch_add(1, Ordering::SeqCst);
unsafe {
if count == 0 {
libc::kill(pid, sig);
} else {
libc::kill(pid, libc::SIGKILL);
}
}
}
}
fn install_signal_handlers() {
TERM_SIGNAL_COUNT.store(0, Ordering::SeqCst);
unsafe {
let mut sigset: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut sigset);
libc::sigaddset(&mut sigset, libc::SIGTERM);
libc::sigaddset(&mut sigset, libc::SIGINT);
libc::pthread_sigmask(libc::SIG_UNBLOCK, &sigset, std::ptr::null_mut());
let mut sa: libc::sigaction = std::mem::zeroed();
libc::sigemptyset(&mut sa.sa_mask);
sa.sa_sigaction = forward_signal_to_child as *const () as usize;
sa.sa_flags = libc::SA_RESTART;
if libc::sigaction(libc::SIGTERM, &sa, std::ptr::null_mut()) != 0 {
panic!(
"failed to install SIGTERM handler: {}",
std::io::Error::last_os_error()
);
}
if libc::sigaction(libc::SIGINT, &sa, std::ptr::null_mut()) != 0 {
panic!(
"failed to install SIGINT handler: {}",
std::io::Error::last_os_error()
);
}
}
}
const DEFAULT_PG_PREFIX: &str = "postgres://localhost";
fn resolve_session_pg_url(user_url: &Option<String>, session_id: &str) -> Result<String> {
match user_url {
Some(url) => Ok(url.clone()),
None => {
let db_name = format!("secafs_{}", session_id.replace('-', "_"));
Ok(format!("{}/{}", DEFAULT_PG_PREFIX, db_name))
}
}
}
pub async fn run_cmd(
allow: Vec<PathBuf>,
no_default_allows: bool,
session_id: Option<String>,
system: bool,
postgres_url: Option<String>,
command: PathBuf,
args: Vec<String>,
) -> Result<()> {
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let allowed_paths = build_allowed_paths(&allow, no_default_allows)?;
let session = setup_run_directory(session_id)?;
if is_mountpoint(&session.fuse_mountpoint) {
let overlay_base = std::fs::read_to_string(&session.base_path_file)
.context("Failed to read session base path")?;
let overlay_base = PathBuf::from(overlay_base.trim());
eprintln!("Joining existing session: {}", session.run_id);
eprintln!();
return run_in_existing_session(
&overlay_base,
&session.fuse_mountpoint,
&allowed_paths,
command,
args,
&session.run_id,
);
}
let pg_url = resolve_session_pg_url(&postgres_url, &session.run_id)?;
print_welcome_banner(&cwd, &allowed_paths, &session.run_id);
let cwd_fd = std::fs::File::open(&cwd).context("Failed to open current directory")?;
let fd_num = cwd_fd.as_raw_fd();
let fd_path = format!("/proc/self/fd/{}", fd_num);
let options = SecAFSOptions::with_postgres_url(&pg_url);
let secafs_inst = SecAFS::open(options)
.await
.context("Failed to create delta SecAFS")?;
let hostfs = HostFS::new(&fd_path).context("Failed to create HostFS")?;
#[cfg(target_family = "unix")]
let hostfs = {
let mountpoint_inode = fs::metadata(&session.fuse_mountpoint)
.map(|m| m.ino())
.context("Failed to get mountpoint inode")?;
hostfs.with_fuse_mountpoint(mountpoint_inode)
};
let base = Arc::new(hostfs);
let overlay = OverlayFS::new(base, secafs_inst.fs);
let cwd_str = cwd
.to_str()
.context("Current directory path contains non-UTF8 characters")?;
overlay
.init(cwd_str)
.await
.context("Failed to initialize overlay")?;
std::fs::write(&session.base_path_file, cwd_str)
.context("Failed to write session base path")?;
let uid = unsafe { libc::getuid() };
let gid = unsafe { libc::getgid() };
let mount_opts = MountOpts {
mountpoint: session.fuse_mountpoint.clone(),
backend: MountBackend::Fuse,
fsname: format!("secafs:{}", session.run_id),
uid: Some(uid),
gid: Some(gid),
allow_other: system,
allow_root: false,
auto_unmount: false,
lazy_unmount: true,
timeout: FUSE_MOUNT_TIMEOUT,
};
let mount_handle = mount_fs(Arc::new(Mutex::new(overlay)), mount_opts).await?;
let (pipe_to_child, pipe_to_parent) = create_sync_pipes()?;
let child_pid = unsafe { libc::fork() };
if child_pid < 0 {
bail!("Failed to fork: {}", std::io::Error::last_os_error());
}
if child_pid == 0 {
unsafe {
libc::close(pipe_to_child[1]);
libc::close(pipe_to_parent[0]);
}
drop(cwd_fd);
run_child(
&cwd,
&session.fuse_mountpoint,
&allowed_paths,
command,
args,
&session.run_id,
pipe_to_child[0],
pipe_to_parent[1],
);
} else {
unsafe {
libc::close(pipe_to_child[0]);
libc::close(pipe_to_parent[1]);
}
if !wait_for_pipe_signal(pipe_to_parent[0]) {
eprintln!("Error: Failed to read sync signal from child process");
abort_child(pipe_to_child[1], child_pid);
}
write_namespace_mappings(child_pid, uid, gid, pipe_to_child[1]);
unsafe {
libc::write(pipe_to_child[1], b"x".as_ptr() as *const libc::c_void, 1);
libc::close(pipe_to_child[1]);
libc::close(pipe_to_parent[0]);
}
if let Err(e) =
crate::cmd::ps::write_proc_file(&session.run_id, true, &command.to_string_lossy(), &cwd)
{
eprintln!("Warning: Failed to write proc file: {}", e);
}
run_parent(child_pid, cwd_fd, mount_handle, &session.run_id);
}
}
fn run_in_existing_session(
cwd: &Path,
fuse_mountpoint: &Path,
allowed_paths: &[PathBuf],
command: PathBuf,
args: Vec<String>,
session_id: &str,
) -> Result<()> {
let uid = unsafe { libc::getuid() };
let gid = unsafe { libc::getgid() };
let (pipe_to_child, pipe_to_parent) = create_sync_pipes()?;
let child_pid = unsafe { libc::fork() };
if child_pid < 0 {
bail!("Failed to fork: {}", std::io::Error::last_os_error());
}
if child_pid == 0 {
unsafe {
libc::close(pipe_to_child[1]);
libc::close(pipe_to_parent[0]);
}
run_child(
cwd,
fuse_mountpoint,
allowed_paths,
command,
args,
session_id,
pipe_to_child[0],
pipe_to_parent[1],
);
} else {
unsafe {
libc::close(pipe_to_child[0]);
libc::close(pipe_to_parent[1]);
}
if !wait_for_pipe_signal(pipe_to_parent[0]) {
eprintln!("Error: Failed to read sync signal from child process");
abort_child(pipe_to_child[1], child_pid);
}
write_namespace_mappings(child_pid, uid, gid, pipe_to_child[1]);
unsafe {
libc::write(pipe_to_child[1], b"x".as_ptr() as *const libc::c_void, 1);
libc::close(pipe_to_child[1]);
libc::close(pipe_to_parent[0]);
}
if let Err(e) =
crate::cmd::ps::write_proc_file(session_id, false, &command.to_string_lossy(), cwd)
{
eprintln!("Warning: Failed to write proc file: {}", e);
}
CHILD_PID.store(child_pid, Ordering::SeqCst);
install_signal_handlers();
let exit_code = wait_for_child(child_pid);
crate::cmd::ps::remove_proc_file(session_id);
std::process::exit(exit_code);
}
}
fn print_welcome_banner(cwd: &Path, allowed_paths: &[PathBuf], session_id: &str) {
eprintln!("Welcome to SecAFS!");
eprintln!();
eprintln!("The following directories are writable:");
eprintln!();
eprintln!(" - {} (copy-on-write)", cwd.display());
for grouped_path in group_paths_by_parent(allowed_paths) {
eprintln!(" - {}", grouped_path);
}
eprintln!();
eprintln!("🔒 Everything else is read-only.");
eprintln!();
eprintln!("To join this session from another terminal:");
eprintln!();
eprintln!(" secafs run --session {} <command>", session_id);
eprintln!();
}
struct RunSession {
run_id: String,
fuse_mountpoint: PathBuf,
base_path_file: PathBuf,
}
fn setup_run_directory(session_id: Option<String>) -> Result<RunSession> {
let run_id = session_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let home_dir = dirs::home_dir().context("Failed to get home directory")?;
let run_dir = home_dir.join(".secafs").join("run").join(&run_id);
std::fs::create_dir_all(&run_dir).context("Failed to create run directory")?;
let fuse_mountpoint = run_dir.join("mnt");
let base_path_file = run_dir.join("base_path");
std::fs::create_dir_all(&fuse_mountpoint).context("Failed to create FUSE mountpoint")?;
Ok(RunSession {
run_id,
fuse_mountpoint,
base_path_file,
})
}
fn create_sync_pipes() -> Result<([libc::c_int; 2], [libc::c_int; 2])> {
let mut child_pipe: [libc::c_int; 2] = [0; 2];
let mut parent_pipe: [libc::c_int; 2] = [0; 2];
if unsafe { libc::pipe(child_pipe.as_mut_ptr()) } != 0 {
bail!("Failed to create pipe: {}", std::io::Error::last_os_error());
}
if unsafe { libc::pipe(parent_pipe.as_mut_ptr()) } != 0 {
unsafe {
libc::close(child_pipe[0]);
libc::close(child_pipe[1]);
}
bail!("Failed to create pipe: {}", std::io::Error::last_os_error());
}
Ok((child_pipe, parent_pipe))
}
fn wait_for_pipe_signal(fd: libc::c_int) -> bool {
let mut buf = [0u8; 1];
let result = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, 1) };
result > 0
}
fn abort_child(pipe_write_fd: libc::c_int, child_pid: libc::pid_t) -> ! {
unsafe {
libc::close(pipe_write_fd);
let mut status: libc::c_int = 0;
libc::waitpid(child_pid, &mut status, 0);
}
std::process::exit(1)
}
fn write_namespace_mappings(
child_pid: libc::pid_t,
uid: libc::uid_t,
gid: libc::gid_t,
pipe_write_fd: libc::c_int,
) {
let uid_map_path = format!("/proc/{}/uid_map", child_pid);
let gid_map_path = format!("/proc/{}/gid_map", child_pid);
let setgroups_path = format!("/proc/{}/setgroups", child_pid);
if let Err(e) = std::fs::write(&uid_map_path, format!("{} {} 1\n", uid, uid)) {
eprintln!("Error: Could not write uid_map: {}", e);
eprintln!("This may indicate missing unprivileged user namespace support.");
abort_child(pipe_write_fd, child_pid);
}
if let Err(e) = std::fs::write(&setgroups_path, "deny") {
eprintln!("Error: Could not write setgroups: {}", e);
abort_child(pipe_write_fd, child_pid);
}
if let Err(e) = std::fs::write(&gid_map_path, format!("{} {} 1\n", gid, gid)) {
eprintln!("Error: Could not write gid_map: {}", e);
abort_child(pipe_write_fd, child_pid);
}
}
fn path_to_cstring(path: &Path, description: &str) -> CString {
match CString::new(path.as_os_str().as_bytes()) {
Ok(s) => s,
Err(_) => {
eprintln!(
"Invalid {} (contains NUL byte): {}",
description,
path.display()
);
unsafe { libc::_exit(1) }
}
}
}
fn child_exit_with_code(msg: &str, code: i32) -> ! {
eprintln!("{}", msg);
unsafe { libc::_exit(code) }
}
fn child_exit(msg: &str) -> ! {
child_exit_with_code(msg, 1)
}
#[allow(clippy::too_many_arguments)]
fn run_child(
cwd: &Path,
fuse_mountpoint: &Path,
allowed_paths: &[PathBuf],
command: PathBuf,
args: Vec<String>,
session_id: &str,
pipe_from_parent: libc::c_int,
pipe_to_parent: libc::c_int,
) -> ! {
if unsafe { libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) } != 0 {
child_exit(&format!(
"Failed to unshare namespaces: {}",
std::io::Error::last_os_error()
));
}
unsafe {
libc::write(pipe_to_parent, b"x".as_ptr() as *const libc::c_void, 1);
libc::close(pipe_to_parent);
}
if !wait_for_pipe_signal(pipe_from_parent) {
child_exit("Failed to read sync signal from parent: pipe closed unexpectedly");
}
unsafe { libc::close(pipe_from_parent) };
let root = CString::new("/").unwrap();
if unsafe {
libc::mount(
std::ptr::null(),
root.as_ptr(),
std::ptr::null(),
libc::MS_REC | libc::MS_PRIVATE,
std::ptr::null(),
)
} != 0
{
child_exit(&format!(
"Failed to make mounts private: {}",
std::io::Error::last_os_error()
));
}
let fuse_cstr = path_to_cstring(fuse_mountpoint, "FUSE mountpoint path");
let cwd_cstr = path_to_cstring(cwd, "working directory path");
if unsafe {
libc::mount(
fuse_cstr.as_ptr(),
cwd_cstr.as_ptr(),
std::ptr::null(),
libc::MS_BIND,
std::ptr::null(),
)
} != 0
{
child_exit(&format!(
"Failed to bind mount FUSE overlay: {}",
std::io::Error::last_os_error()
));
}
if std::env::set_current_dir(cwd).is_err() {
child_exit("Failed to change to working directory");
}
if let Err(e) = remount_all_readonly_except(cwd, allowed_paths) {
child_exit(&format!("Failed to remount filesystems read-only: {}", e));
}
exec_command(command, args, session_id);
}
fn remount_all_readonly_except(
writable_path: &Path,
allowed_paths: &[PathBuf],
) -> std::io::Result<()> {
for allowed in allowed_paths {
let path_cstr = match CString::new(allowed.as_os_str().as_bytes()) {
Ok(s) => s,
Err(_) => continue,
};
let bind_result = unsafe {
libc::mount(
path_cstr.as_ptr(),
path_cstr.as_ptr(),
std::ptr::null(),
libc::MS_BIND,
std::ptr::null(),
)
};
if bind_result == 0 {
let _ = unsafe {
libc::mount(
std::ptr::null(),
path_cstr.as_ptr(),
std::ptr::null(),
libc::MS_BIND | libc::MS_REMOUNT,
std::ptr::null(),
)
};
}
}
let mountinfo = std::fs::File::open("/proc/self/mountinfo")?;
let reader = std::io::BufReader::new(mountinfo);
let mut mounts: Vec<PathBuf> = Vec::new();
for line in reader.lines() {
let line = line?;
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() > MOUNTINFO_MOUNT_POINT_FIELD {
let mount_point = unescape_mountinfo(fields[MOUNTINFO_MOUNT_POINT_FIELD]);
mounts.push(PathBuf::from(mount_point));
}
}
mounts.sort_by_key(|b| Reverse(b.as_os_str().len()));
let writable_canonical = writable_path
.canonicalize()
.unwrap_or_else(|_| writable_path.to_path_buf());
let allowed_canonical: Vec<PathBuf> = allowed_paths
.iter()
.filter_map(|p| p.canonicalize().ok())
.collect();
for mount_point in &mounts {
let mount_canonical = mount_point
.canonicalize()
.unwrap_or_else(|_| mount_point.clone());
if mount_canonical == writable_canonical {
continue;
}
if allowed_canonical.contains(&mount_canonical) {
continue;
}
if skip_mount(mount_point) {
continue;
}
let mount_cstr = match CString::new(mount_point.as_os_str().as_bytes()) {
Ok(s) => s,
Err(_) => continue,
};
let bind_result = unsafe {
libc::mount(
mount_cstr.as_ptr(),
mount_cstr.as_ptr(),
std::ptr::null(),
libc::MS_BIND | libc::MS_REC,
std::ptr::null(),
)
};
if bind_result != 0 {
continue;
}
let _ = unsafe {
libc::mount(
std::ptr::null(),
mount_cstr.as_ptr(),
std::ptr::null(),
libc::MS_BIND | libc::MS_REMOUNT | libc::MS_RDONLY,
std::ptr::null(),
)
};
}
Ok(())
}
fn skip_mount(path: &Path) -> bool {
let path_str = path.to_string_lossy();
SKIP_MOUNT_PREFIXES
.iter()
.any(|prefix| path_str.starts_with(prefix))
}
fn build_allowed_paths(user_allowed: &[PathBuf], no_default_allows: bool) -> Result<Vec<PathBuf>> {
let mut allowed = Vec::new();
if !no_default_allows {
if let Some(home) = dirs::home_dir() {
for dir in DEFAULT_ALLOWED_DIRS {
let path = home.join(dir);
if path.exists() {
allowed.push(path);
}
}
}
}
for path in user_allowed {
let canonical = path.canonicalize().with_context(|| {
format!(
"Failed to canonicalize allowed path '{}'. Does it exist?",
path.display()
)
})?;
allowed.push(canonical);
}
Ok(allowed)
}
fn unescape_mountinfo(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
// Try to read octal escape sequence (digits 0-7 only)
let mut octal = String::new();
for _ in 0..3 {
if let Some(&next) = chars.peek() {
if ('0'..='7').contains(&next) {
octal.push(chars.next().unwrap());
} else {
break;
}
}
}
if octal.len() == 3 {
// Use u32 to handle values > 255 (max octal 777 = 511)
if let Ok(code) = u32::from_str_radix(&octal, 8) {
if code <= 255 {
result.push(code as u8 as char);
continue;
}
}
}
// Not a valid escape, keep the backslash and octal chars
result.push(c);
result.push_str(&octal);
} else {
result.push(c);
}
}
result
}
/// Parent process: wait for child to exit, then clean up.
///
/// The MountHandle automatically unmounts when dropped. We explicitly drop it
/// before calling exit() to ensure cleanup happens.
fn run_parent(
child_pid: i32,
cwd_fd: std::fs::File,
mount_handle: MountHandle,
session_id: &str,
) -> ! {
// Store child PID and install signal handlers before waiting
CHILD_PID.store(child_pid, Ordering::SeqCst);
install_signal_handlers();
// Wait for child process to exit, retrying on EINTR (signal interruption)
let exit_code = wait_for_child(child_pid);
// Clean up proc file
crate::cmd::ps::remove_proc_file(session_id);
// Get mountpoint before dropping handle
let fuse_mountpoint = mount_handle.mountpoint().to_path_buf();
// Release the underlying directory fd (was kept alive for HostFS)
drop(cwd_fd);
// Drop the mount handle to unmount (this also moves away from mountpoint)
drop(mount_handle);
// Clean up the FUSE mountpoint directory (but keep the delta database)
if let Err(e) = std::fs::remove_dir_all(&fuse_mountpoint) {
eprintln!(
"Warning: Failed to clean up mountpoint {}: {}",
fuse_mountpoint.display(),
e
);
}
// Clean up procs directory if empty
let procs_dir = crate::cmd::ps::procs_dir(session_id);
let _ = std::fs::remove_dir(&procs_dir);
// Print session info for the user
eprintln!();
eprintln!("Session: {}", session_id);
eprintln!();
eprintln!("To resume this session:");
eprintln!(" secafs run --session {}", session_id);
eprintln!();
eprintln!("To see what changed:");
eprintln!(" secafs diff <postgres_url>");
std::process::exit(exit_code);
}
/// Execute the command, replacing the current process.
fn exec_command(command: PathBuf, args: Vec<String>, session_id: &str) -> ! {
setup_env_vars(session_id);
let cmd_cstr = match CString::new(command.as_os_str().as_bytes()) {
Ok(s) => s,
Err(_) => {
child_exit_with_code(
&format!("Invalid command (contains NUL byte): {}", command.display()),
EXIT_COMMAND_NOT_FOUND,
);
}
};
let mut argv: Vec<CString> = vec![cmd_cstr.clone()];
for arg in &args {
match CString::new(arg.as_str()) {
Ok(s) => argv.push(s),
Err(_) => {
child_exit_with_code(
&format!("Invalid argument (contains NUL byte): {}", arg),
EXIT_COMMAND_NOT_FOUND,
);
}
}
}
let argv_ptrs: Vec<*const libc::c_char> = argv
.iter()
.map(|s| s.as_ptr())
.chain(std::iter::once(std::ptr::null()))
.collect();
unsafe {
libc::execvp(cmd_cstr.as_ptr(), argv_ptrs.as_ptr());
}
child_exit_with_code(
&format!(
"Failed to execute {}: {}",
command.display(),
std::io::Error::last_os_error()
),
EXIT_COMMAND_NOT_FOUND,
);
}
/// Setup environment variables for the sandbox.
fn setup_env_vars(session_id: &str) {
std::env::set_var("SECAFS", "1");
std::env::set_var("SECAFS_SANDBOX", "linux-namespace");
std::env::set_var("SECAFS_SESSION", session_id);
std::env::set_var("PS1", "\\u@\\h:\\w\\$ ");
// Configure SSH to skip system config files.
// Inside the user namespace, root-owned files in /etc/ssh/ssh_config.d/
// appear with invalid ownership (unmapped uid), causing SSH to reject them.
// Using only ~/.ssh/config avoids this issue while preserving user settings.
if let Some(home) = dirs::home_dir() {
let user_ssh_config = home.join(".ssh/config");
// Use user's config if it exists, otherwise use /dev/null (no config)
let config_path = if user_ssh_config.exists() {
user_ssh_config.to_string_lossy().to_string()
} else {
"/dev/null".to_string()
};
std::env::set_var("GIT_SSH_COMMAND", format!("ssh -F {}", config_path));
}
}
fn wait_for_child(child_pid: libc::pid_t) -> i32 {
let mut status: libc::c_int = 0;
loop {
let result = unsafe { libc::waitpid(child_pid, &mut status, 0) };
if result == -1 {
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::EINTR) {
continue;
}
return 1;
}
break;
}
wait_status_to_exit_code(status)
}
fn wait_status_to_exit_code(status: libc::c_int) -> i32 {
if libc::WIFEXITED(status) {
libc::WEXITSTATUS(status)
} else if libc::WIFSIGNALED(status) {
128 + libc::WTERMSIG(status)
} else {
1
}
}