daat-locus 0.1.1

A long-running local agent runtime with memory, workflows, apps, and sleep-time self-improvement.
use std::{
    env,
    path::{Path, PathBuf},
};

use miette::{Result, miette};

use super::{
    FileSystemSandboxPolicy, RuntimeSandboxPolicy, SandboxSpawnSpec, StrongFilesystemSandboxMode,
    path_is_denied, policy_paths_with_resolved,
};

const BWRAP_PROGRAM: &str = "bwrap";

pub fn wrap_shell_command(
    policy: &RuntimeSandboxPolicy,
    program: &str,
    args: Vec<String>,
) -> Result<SandboxSpawnSpec> {
    wrap_command(policy, PathBuf::from(program), args)
}

pub fn wrap_command(
    policy: &RuntimeSandboxPolicy,
    program: PathBuf,
    args: Vec<String>,
) -> Result<SandboxSpawnSpec> {
    if policy.strong_filesystem == StrongFilesystemSandboxMode::Off
        || !requires_backend(&policy.filesystem)
    {
        return Ok(unwrapped_spawn_spec(program, args));
    }

    let Some(bwrap) = find_bwrap_in_path() else {
        return match policy.strong_filesystem {
            StrongFilesystemSandboxMode::Auto => {
                tracing::warn!(
                    "Linux strong filesystem sandbox requested in auto mode, but `bwrap` was not found on PATH"
                );
                Ok(unwrapped_spawn_spec(program, args))
            }
            StrongFilesystemSandboxMode::Required => {
                Err(miette!("Linux filesystem sandbox requires `bwrap` on PATH"))
            }
            StrongFilesystemSandboxMode::Off => Ok(unwrapped_spawn_spec(program, args)),
        };
    };

    let mut command = Vec::with_capacity(args.len() + 1);
    command.push(program.to_string_lossy().into_owned());
    command.extend(args);
    Ok(SandboxSpawnSpec {
        program: bwrap,
        args: create_bwrap_args(&policy.filesystem, command),
    })
}

fn unwrapped_spawn_spec(program: PathBuf, args: Vec<String>) -> SandboxSpawnSpec {
    SandboxSpawnSpec { program, args }
}

fn requires_backend(policy: &FileSystemSandboxPolicy) -> bool {
    !(policy.full_disk_read
        && policy.full_disk_write
        && policy.readable_roots.is_empty()
        && policy.writable_roots.is_empty()
        && policy.deny_read_paths.is_empty()
        && policy.deny_write_paths.is_empty())
}

fn find_bwrap_in_path() -> Option<PathBuf> {
    let search_path = env::var_os("PATH")?;
    let cwd = env::current_dir().ok()?;
    env::split_paths(&search_path)
        .map(|entry| entry.join(BWRAP_PROGRAM))
        .filter_map(|candidate| {
            if !candidate.is_file() {
                return None;
            }
            Some(std::fs::canonicalize(&candidate).unwrap_or(candidate))
        })
        .find(|candidate| !candidate.starts_with(&cwd))
}

fn create_bwrap_args(policy: &FileSystemSandboxPolicy, command: Vec<String>) -> Vec<String> {
    let mut args = vec![
        "--new-session".to_string(),
        "--die-with-parent".to_string(),
        "--unshare-user".to_string(),
        "--unshare-pid".to_string(),
    ];

    push_root_mounts(&mut args, policy);
    push_writable_root_mounts(&mut args, policy);
    push_deny_write_mounts(&mut args, policy);
    push_deny_read_mounts(&mut args, policy);

    args.push("--".to_string());
    args.extend(command);
    args
}

fn push_root_mounts(args: &mut Vec<String>, policy: &FileSystemSandboxPolicy) {
    if policy.full_disk_write {
        push_mount(args, "--bind", Path::new("/"), Path::new("/"));
    } else if policy.full_disk_read {
        push_mount(args, "--ro-bind", Path::new("/"), Path::new("/"));
    } else {
        args.push("--tmpfs".to_string());
        args.push("/".to_string());
        for root in policy_paths_with_resolved(&policy.readable_roots) {
            push_existing_mount(args, "--ro-bind", &root);
        }
    }

    args.push("--dev".to_string());
    args.push("/dev".to_string());
    args.push("--proc".to_string());
    args.push("/proc".to_string());
}

fn push_writable_root_mounts(args: &mut Vec<String>, policy: &FileSystemSandboxPolicy) {
    for writable_root in &policy.writable_roots {
        for root in policy_paths_with_resolved(std::slice::from_ref(&writable_root.root)) {
            push_existing_mount(args, "--bind", &root);
        }
        for subpath in policy_paths_with_resolved(&writable_root.read_only_subpaths) {
            push_existing_mount(args, "--ro-bind", &subpath);
        }
    }
}

fn push_deny_write_mounts(args: &mut Vec<String>, policy: &FileSystemSandboxPolicy) {
    for path in policy_paths_with_resolved(&policy.deny_write_paths) {
        push_existing_mount(args, "--ro-bind", &path);
    }
}

fn push_deny_read_mounts(args: &mut Vec<String>, policy: &FileSystemSandboxPolicy) {
    for path in policy_paths_with_resolved(&policy.deny_read_paths) {
        if path.is_dir() {
            args.push("--tmpfs".to_string());
            args.push(path_to_arg(&path));
            if path_is_denied(&path, &policy.deny_write_paths) {
                args.push("--remount-ro".to_string());
                args.push(path_to_arg(&path));
            }
        } else if path.is_file() {
            push_mount(args, "--ro-bind", Path::new("/dev/null"), &path);
        }
    }
}

fn push_existing_mount(args: &mut Vec<String>, flag: &str, path: &Path) {
    if path.exists() {
        push_mount(args, flag, path, path);
    }
}

fn push_mount(args: &mut Vec<String>, flag: &str, source: &Path, destination: &Path) {
    args.push(flag.to_string());
    args.push(path_to_arg(source));
    args.push(path_to_arg(destination));
}

fn path_to_arg(path: &Path) -> String {
    path.to_string_lossy().into_owned()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sandbox::WritableRoot;

    #[test]
    fn bwrap_args_protect_full_disk_policy_with_denies() {
        let tempdir = tempfile::tempdir().expect("tempdir");
        let protected_home = tempdir.path().join("private").join("daat");
        let source_root = tempdir.path().join("workspace").join("source");
        std::fs::create_dir_all(&protected_home).expect("create protected home");
        std::fs::create_dir_all(&source_root).expect("create source root");
        let policy = FileSystemSandboxPolicy {
            full_disk_read: true,
            full_disk_write: true,
            readable_roots: Vec::new(),
            writable_roots: Vec::new(),
            deny_read_paths: vec![protected_home.clone()],
            deny_write_paths: vec![source_root.clone()],
        };

        let args = create_bwrap_args(&policy, vec!["sh".to_string(), "-lc".to_string()]);

        assert!(contains_triplet(&args, "--bind", "/", "/"));
        assert!(contains_pair(
            &args,
            "--tmpfs",
            protected_home.to_string_lossy().as_ref()
        ));
        assert!(contains_triplet(
            &args,
            "--ro-bind",
            source_root.to_string_lossy().as_ref(),
            source_root.to_string_lossy().as_ref()
        ));
        assert_eq!(args.iter().filter(|arg| *arg == "--").count(), 1);
        assert_eq!(args.last().map(String::as_str), Some("-lc"));
    }

    #[test]
    fn bwrap_args_hide_read_and_write_denied_directories_as_read_only() {
        let tempdir = tempfile::tempdir().expect("tempdir");
        let protected_home = tempdir.path().join(".daat-locus");
        std::fs::create_dir_all(&protected_home).expect("create protected home");
        let policy = FileSystemSandboxPolicy {
            full_disk_read: true,
            full_disk_write: true,
            readable_roots: Vec::new(),
            writable_roots: Vec::new(),
            deny_read_paths: vec![protected_home.clone()],
            deny_write_paths: vec![protected_home.clone()],
        };

        let args = create_bwrap_args(&policy, vec!["sh".to_string()]);

        let protected_home = protected_home.to_string_lossy();
        assert!(contains_pair(&args, "--tmpfs", protected_home.as_ref()));
        assert!(contains_pair(
            &args,
            "--remount-ro",
            protected_home.as_ref()
        ));
    }

    #[test]
    fn bwrap_args_reopen_writable_roots_under_read_only_root() {
        let tempdir = tempfile::tempdir().expect("tempdir");
        let workspace = tempdir.path().join("workspace");
        let git_dir = workspace.join(".git");
        std::fs::create_dir_all(&git_dir).expect("create git dir");
        let policy = FileSystemSandboxPolicy {
            full_disk_read: true,
            full_disk_write: false,
            readable_roots: Vec::new(),
            writable_roots: vec![WritableRoot {
                root: workspace.clone(),
                read_only_subpaths: vec![git_dir.clone()],
            }],
            deny_read_paths: Vec::new(),
            deny_write_paths: Vec::new(),
        };

        let args = create_bwrap_args(&policy, vec!["sh".to_string()]);

        assert!(contains_triplet(&args, "--ro-bind", "/", "/"));
        assert!(contains_triplet(
            &args,
            "--bind",
            workspace.to_string_lossy().as_ref(),
            workspace.to_string_lossy().as_ref()
        ));
        assert!(contains_triplet(
            &args,
            "--ro-bind",
            git_dir.to_string_lossy().as_ref(),
            git_dir.to_string_lossy().as_ref()
        ));
    }

    #[test]
    fn no_backend_needed_for_unrestricted_policy() {
        let policy = FileSystemSandboxPolicy {
            full_disk_read: true,
            full_disk_write: true,
            readable_roots: Vec::new(),
            writable_roots: Vec::new(),
            deny_read_paths: Vec::new(),
            deny_write_paths: Vec::new(),
        };

        assert!(!requires_backend(&policy));
    }

    fn contains_triplet(args: &[String], flag: &str, source: &str, destination: &str) -> bool {
        args.windows(3).any(|item| {
            item[0].as_str() == flag
                && item[1].as_str() == source
                && item[2].as_str() == destination
        })
    }

    fn contains_pair(args: &[String], first: &str, second: &str) -> bool {
        args.windows(2)
            .any(|item| item[0].as_str() == first && item[1].as_str() == second)
    }
}