ralon 0.1.6

Filesystem policy for AI coding agents: kernel-enforced write protection driven by an agent.lock file
//! Conditions that quietly weaken a policy.
//!
//! Both backends protect *paths*. Anything that gives a protected file a second
//! name, or the project a second mount point, is a way to reach the same bytes
//! without going through a path Ralon restricted. None of it can be fixed by
//! enforcing harder — the kernel is already doing what it was asked — so the
//! only honest response is to say so before the agent starts.

use std::path::Path;

use crate::scan::ProtectedPath;

#[derive(Debug, PartialEq, Eq)]
pub struct Finding {
    pub subject: String,
    pub detail: String,
}

/// Everything worth telling the user before `run` hands over.
pub fn audit(root: &Path, protected: &[ProtectedPath]) -> Vec<Finding> {
    let mut findings = hard_links(protected);
    findings.extend(second_mounts(root));
    findings.extend(already_open_for_writing(protected));
    findings
}

/// Protected files reachable through a directory the policy does not protect.
///
/// Every backend but one closes this by pinning the directories on the way to a
/// protected path — a mount point, a held handle, a `literal` deny node — so
/// none of them can be renamed. The `immutable` backend cannot: macOS conflates
/// "this directory may not be renamed" and "this directory may not accept new
/// entries" into one flag, so pinning `src/` would stop the project ever getting
/// a new file in `src/`, and pinning the project root — which every policy needs,
/// since `agent.lock` lives there — would stop it getting a new file at all.
///
/// What that leaves open is not merely cosmetic, and the wording here says so:
/// rename `src/deep` out of the way, recreate it, and write whatever you like at
/// `src/deep/secret.txt`. The original bytes stay immutable under their new path
/// and are no longer the ones anything reads.
///
/// The fix available to a *developer* is real and worth printing: protect the
/// directory rather than the file inside it. A protected directory carries the
/// flag itself and cannot be renamed, which closes the substitution at that
/// level.
///
/// Pure and platform-independent so the rule is tested everywhere; the caller
/// decides whether the running backend makes it relevant.
pub fn exposed_ancestors(protected: &[ProtectedPath]) -> Vec<Finding> {
    let directories: Vec<&str> = protected
        .iter()
        .filter(|path| path.is_dir)
        .map(|path| path.relative.as_str())
        .collect();

    protected
        .iter()
        .filter(|path| !path.is_dir)
        .filter_map(|path| {
            // Only files nested inside a directory can have one renamed out from
            // under them. A root-level `.env` has no ancestor but the project
            // root, which is a different exposure and not this one.
            let parent = path.relative.rsplit_once('/')?.0;

            // Covered when any directory on the way is itself protected: that
            // one is flagged, so it cannot be renamed, and nothing above it can
            // move the file without moving the protected directory too.
            let covered = directories
                .iter()
                .any(|guarded| parent == *guarded || parent.starts_with(&format!("{guarded}/")));
            if covered {
                return None;
            }

            Some(Finding {
                subject: path.relative.clone(),
                detail: format!(
                    "is protected, but `{parent}` is not — and this backend cannot pin a \
                     directory without freezing it. Renaming `{parent}` and recreating it \
                     puts a different file at that path. Protect `{parent}` instead of the \
                     file inside it to close that"
                ),
            })
        })
        .collect()
}

/// A protected file that something else already has open for writing.
///
/// Not a weakness in the policy — the opposite. It is a file that is *in use*:
/// a live SQLite database, a log a dev server appends to, a state file some
/// daemon rewrites. Protecting it either fails to take the lock, or takes it
/// and breaks the program that was using it, and both look like Ralon
/// misbehaving rather than like a policy naming the wrong thing.
///
/// Windows only, because it is the only platform here that can answer the
/// question. Unix has no mandatory locking, so an open file for writing is
/// indistinguishable from a closed one without walking every process.
#[cfg(windows)]
fn already_open_for_writing(protected: &[ProtectedPath]) -> Vec<Finding> {
    use std::os::windows::fs::OpenOptionsExt;

    /// The share mode enforcement itself will ask for. If this open is refused,
    /// so is that one, and for the same reason.
    const FILE_SHARE_READ: u32 = 0x0000_0001;

    /// `ERROR_SHARING_VIOLATION` and `ERROR_LOCK_VIOLATION`. Only these two
    /// mean "somebody else has it"; a missing file or a permissions problem is
    /// a different complaint with a different answer.
    const IN_USE: [i32; 2] = [32, 33];

    protected
        .iter()
        .filter(|path| !path.is_dir)
        .filter(|path| {
            std::fs::OpenOptions::new()
                .read(true)
                .share_mode(FILE_SHARE_READ)
                .open(&path.absolute)
                .err()
                .and_then(|error| error.raw_os_error())
                .is_some_and(|code| IN_USE.contains(&code))
        })
        .map(|path| Finding {
            subject: path.relative.clone(),
            detail: "is held open by another process, so it cannot be locked — protect \
                     the files a program owns, not the ones it is using"
                .to_string(),
        })
        .collect()
}

#[cfg(not(windows))]
fn already_open_for_writing(_protected: &[ProtectedPath]) -> Vec<Finding> {
    Vec::new()
}

/// A protected file with more than one name.
///
/// The other names are ordinary files: not bind-mounted, not carved out of the
/// Landlock grant, and writing one changes the protected file's contents. This
/// is invisible unless you go looking, which is why it is checked every run.
#[cfg(unix)]
fn hard_links(protected: &[ProtectedPath]) -> Vec<Finding> {
    use std::os::unix::fs::MetadataExt;

    protected
        .iter()
        .filter(|path| !path.is_dir)
        .filter_map(|path| {
            let links = std::fs::metadata(&path.absolute).ok()?.nlink();
            (links > 1).then(|| Finding {
                subject: path.relative.clone(),
                detail: format!(
                    "has {links} hard links; the other names are not protected and \
                     writing one changes this file"
                ),
            })
        })
        .collect()
}

#[cfg(not(unix))]
fn hard_links(_protected: &[ProtectedPath]) -> Vec<Finding> {
    Vec::new()
}

/// The project reachable at a second mount point.
///
/// A bind mount made before the sandbox starts is a complete bypass of both
/// backends: the protected paths were restricted, that path was not. The
/// sandboxed process cannot create one, so finding it here is the only warning
/// anyone gets.
#[cfg(target_os = "linux")]
fn second_mounts(root: &Path) -> Vec<Finding> {
    let Ok(table) = std::fs::read_to_string("/proc/self/mountinfo") else {
        return Vec::new();
    };
    second_mounts_from(root, &table)
}

#[cfg(not(target_os = "linux"))]
fn second_mounts(_root: &Path) -> Vec<Finding> {
    Vec::new()
}

/// One line of `/proc/self/mountinfo`, reduced to what matters here.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
struct Mount<'a> {
    device: &'a str,
    /// The subtree of the filesystem this mount exposes.
    root: &'a str,
    point: &'a str,
}

#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn parse(table: &str) -> Vec<Mount<'_>> {
    table
        .lines()
        .filter_map(|line| {
            let mut fields = line.split(' ');
            let root = fields.nth(3)?;
            let point = fields.next()?;
            // Optional fields run until a lone "-", then: fstype, source, opts.
            let device = fields.skip_while(|field| *field != "-").nth(2)?;
            Some(Mount {
                device,
                root,
                point,
            })
        })
        .collect()
}

/// Split so the parsing can be tested without a filesystem to mount things on.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn second_mounts_from(root: &Path, table: &str) -> Vec<Finding> {
    let mounts = parse(table);
    let Some(root) = root.to_str() else {
        return Vec::new();
    };

    // The mount the project actually lives on is the longest mount point that
    // is a prefix of it.
    let Some(home) = mounts
        .iter()
        .filter(|mount| under(root, mount.point))
        .max_by_key(|mount| mount.point.len())
    else {
        return Vec::new();
    };

    // Where the project sits inside its filesystem, which is what another mount
    // of the same filesystem would have to expose to reach it.
    let inside = join(home.root, strip(root, home.point));

    mounts
        .iter()
        .filter(|mount| mount.point != home.point)
        .filter(|mount| mount.device == home.device)
        .filter(|mount| under(&inside, mount.root))
        .map(|mount| Finding {
            subject: join(mount.point, strip(&inside, mount.root)),
            detail: "is a second path to this project; writes through it are not \
                     restricted by either backend"
                .to_string(),
        })
        .collect()
}

/// Whether `path` is at or below `prefix`, comparing whole components.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn under(path: &str, prefix: &str) -> bool {
    if prefix == "/" {
        return true;
    }
    let prefix = prefix.trim_end_matches('/');
    path == prefix || path.starts_with(&format!("{prefix}/"))
}

/// The part of `path` below `prefix`, without a leading separator.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn strip<'a>(path: &'a str, prefix: &str) -> &'a str {
    if prefix == "/" {
        return path.trim_start_matches('/');
    }
    path.strip_prefix(prefix.trim_end_matches('/'))
        .unwrap_or("")
        .trim_start_matches('/')
}

/// Joins a mount point to a remainder with exactly one separator. Both sides
/// can be "/", which is what made the naive version silently produce paths
/// with no leading slash — and match nothing.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn join(base: &str, remainder: &str) -> String {
    let base = base.trim_end_matches('/');
    if remainder.is_empty() {
        return if base.is_empty() {
            "/".into()
        } else {
            base.into()
        };
    }
    format!("{base}/{remainder}")
}

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

    /// Two entries: the root filesystem, and a bind mount of /home/dev/proj
    /// exposed a second time at /mnt/copy.
    const TABLE: &str = "\
25 0 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw
26 25 8:1 /home/dev/proj /mnt/copy rw,relatime shared:1 - ext4 /dev/sda1 rw
27 25 0:22 / /proc rw,relatime shared:2 - proc proc rw";

    fn found(relative: &str, is_dir: bool) -> ProtectedPath {
        ProtectedPath {
            relative: relative.to_string(),
            absolute: std::path::PathBuf::from("/p").join(relative),
            is_dir,
            pattern: relative.to_string(),
        }
    }

    #[test]
    fn a_nested_file_with_no_protected_ancestor_is_reported() {
        let findings = exposed_ancestors(&[found("src/deep/secret.txt", false)]);
        assert_eq!(findings.len(), 1, "{findings:?}");
        assert_eq!(findings[0].subject, "src/deep/secret.txt");
        // The way out has to be in the message; a warning nobody can act on is
        // noise that teaches people to ignore warnings.
        assert!(
            findings[0].detail.contains("Protect `src/deep` instead"),
            "{}",
            findings[0].detail
        );
    }

    #[test]
    fn a_file_inside_a_protected_directory_is_not_reported() {
        // `src/deep` carries the flag itself, so it cannot be renamed and the
        // substitution has nowhere to happen.
        let findings =
            exposed_ancestors(&[found("src/deep", true), found("src/deep/secret.txt", false)]);
        assert!(findings.is_empty(), "{findings:?}");
    }

    #[test]
    fn a_file_deep_inside_a_protected_directory_is_not_reported() {
        let findings =
            exposed_ancestors(&[found("config", true), found("config/a/b/db.yaml", false)]);
        assert!(findings.is_empty(), "{findings:?}");
    }

    #[test]
    fn a_root_level_file_is_not_reported() {
        // `.env` and `agent.lock` sit in the project root. Renaming *that* is a
        // different exposure — it moves the policy too — and reporting it for
        // every policy ever written would be noise.
        let findings = exposed_ancestors(&[found(".env", false), found("agent.lock", false)]);
        assert!(findings.is_empty(), "{findings:?}");
    }

    #[test]
    fn a_sibling_directory_with_a_shared_prefix_does_not_count_as_cover() {
        // `src/deepish` is protected; `src/deep` is not. Matching on the string
        // prefix alone would call this covered and say nothing.
        let findings = exposed_ancestors(&[
            found("src/deepish", true),
            found("src/deep/secret.txt", false),
        ]);
        assert_eq!(findings.len(), 1, "{findings:?}");
    }

    #[test]
    fn finds_a_bind_mount_of_the_project() {
        let findings = second_mounts_from(Path::new("/home/dev/proj"), TABLE);
        assert_eq!(findings.len(), 1, "{findings:?}");
        assert_eq!(findings[0].subject, "/mnt/copy");
    }

    #[test]
    fn finds_the_project_inside_a_bind_mounted_parent() {
        let findings = second_mounts_from(Path::new("/home/dev/proj/src"), TABLE);
        assert_eq!(findings[0].subject, "/mnt/copy/src");
    }

    #[test]
    fn a_project_that_is_mounted_once_reports_nothing() {
        let findings = second_mounts_from(Path::new("/home/dev/other"), TABLE);
        assert!(findings.is_empty(), "{findings:?}");
    }

    #[test]
    fn a_different_filesystem_at_another_point_is_not_a_second_path() {
        let table = "\
25 0 8:1 / / rw - ext4 /dev/sda1 rw
26 25 8:2 / /mnt/other rw - ext4 /dev/sdb1 rw";
        assert!(second_mounts_from(Path::new("/home/dev/proj"), table).is_empty());
    }

    #[test]
    fn prefix_matching_respects_component_boundaries() {
        assert!(under("/a/b", "/a"));
        assert!(under("/a", "/a"));
        assert!(under("/anything", "/"));
        assert!(!under("/ab", "/a"), "/ab is not inside /a");
    }
}