openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! Owner-only protection for the files that hold secrets.
//!
//! Four call sites wrote a secret and then set mode `0600` under
//! `#[cfg(unix)]` — the credentials file, the HMAC key, the hook state file
//! and the daemon token. Windows got nothing, on the rationale (written at the
//! token site) that `%APPDATA%` is already ACL-restricted to the user.
//!
//! That rationale holds for the default directory and stops holding the moment
//! `OPENLATCH_DIR` points elsewhere — `C:\ProgramData`, a second volume, a
//! mapped share — all of which inherit a DACL that grants `Users` read access.
//! The secret is then world-readable on the machine, silently, and no test
//! could have caught it: every `mode_0600` assertion in the tree is itself
//! `#[cfg(unix)]`.
//!
//! So the platform answer lives here, once, instead of four times at the call
//! sites: `0600` on Unix, an explicit owner-only DACL on Windows.

use std::io;
use std::path::Path;

/// Restrict `path` so that only its owner can read or write it.
///
/// Unix: mode `0600`, propagating failures exactly as `set_permissions` did at
/// the call sites this replaced. Windows: strip inherited ACEs and grant full
/// control to the object's owner alone — **warn-and-continue on failure**.
/// Elsewhere: `Ok(())`; there is no third platform in this crate's target list.
///
/// The asymmetry is deliberate and temporary. Windows previously did nothing
/// here, so a hardening step that cannot complete leaves the status quo, while
/// propagating would turn `openlatch init` into a hard failure on the one
/// platform where this code has never run — trading a gap for an outage. The
/// effect is asserted by `windows_leaves_only_an_owner_ace`, which runs in
/// `.github/workflows/windows-checks.yml`; once that has been green on main,
/// this can propagate like the Unix half.
///
/// Errors that do reach the caller are theirs to weigh: a store that hard-fails
/// when it cannot protect a secret propagates, and a best-effort site (the hook
/// state file) keeps ignoring the result as it always did.
pub fn restrict_to_owner(path: &Path) -> io::Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
    }
    #[cfg(windows)]
    {
        if let Err(e) = restrict_windows(path) {
            tracing::warn!(
                error = %e,
                path = %path.display(),
                "could not restrict file to its owner; it keeps the directory's inherited ACL"
            );
        }
        Ok(())
    }
    #[cfg(not(any(unix, windows)))]
    {
        let _ = path;
        Ok(())
    }
}

/// `icacls <path> /inheritance:r /grant:r *S-1-3-4:(F)`.
///
/// `/inheritance:r` drops every inherited ACE — the ones that make a file in
/// `C:\ProgramData` readable by `Users` — leaving only what we grant next.
/// Both flags go in one invocation so the file is never left with an empty
/// DACL between two calls.
///
/// The grantee is `S-1-3-4` (OWNER RIGHTS), the well-known SID that resolves to
/// whoever currently owns the object, rather than a `DOMAIN\user` string. The
/// account name would have to come from `%USERNAME%` or `GetUserNameW`, and a
/// bare name is ambiguous on a domain-joined machine (a local account and a
/// domain account can share it); OWNER RIGHTS needs no lookup, cannot resolve
/// to the wrong principal, and survives an account rename. On an elevated
/// process the owner defaults to `BUILTIN\Administrators` — still owner-only,
/// still no `Users` access, which is the invariant being protected.
///
/// Shelling out to `icacls` rather than calling `SetNamedSecurityInfoW`: the
/// API route needs `SetEntriesInAclW`, a hand-built `EXPLICIT_ACCESS` and three
/// `LocalFree`s of unsafe code that no Linux developer here can execute before
/// merging. `icacls` is one auditable line, ships with Windows, and these are
/// cold paths — a credential write, a key generation, a token creation.
#[cfg(windows)]
fn restrict_windows(path: &Path) -> io::Result<()> {
    use std::process::{Command, Stdio};

    /// OWNER RIGHTS. `*` prefixes a SID in icacls' grantee syntax.
    const OWNER_RIGHTS_SID: &str = "*S-1-3-4:(F)";

    let output = Command::new("icacls")
        .arg(path)
        .args(["/inheritance:r", "/grant:r", OWNER_RIGHTS_SID])
        .stdin(Stdio::null())
        .output()?;

    if output.status.success() {
        return Ok(());
    }
    // icacls reports the reason on stdout, not stderr — a volume without ACL
    // support (FAT32 / exFAT on a removable drive) lands here, and the honest
    // answer is that the secret cannot be protected on it.
    let detail = String::from_utf8_lossy(&output.stdout);
    Err(io::Error::other(format!(
        "icacls failed to restrict '{}' to its owner ({}): {}",
        path.display(),
        output.status,
        detail.trim()
    )))
}

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

    fn write_secret(dir: &Path, name: &str) -> std::path::PathBuf {
        let path = dir.join(name);
        std::fs::write(&path, b"secret").unwrap();
        path
    }

    #[test]
    fn restricting_keeps_the_file_readable_by_this_process() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_secret(dir.path(), "token");

        restrict_to_owner(&path).unwrap();

        assert_eq!(std::fs::read(&path).unwrap(), b"secret");
    }

    /// Unix-only: the Windows arm warns instead of propagating, so a missing
    /// file is `Ok(())` there by design (see `restrict_to_owner`).
    #[test]
    #[cfg(unix)]
    fn restricting_a_missing_file_is_an_error() {
        let dir = tempfile::tempdir().unwrap();
        let missing = dir.path().join("nope");

        assert!(restrict_to_owner(&missing).is_err());
    }

    #[test]
    #[cfg(unix)]
    fn unix_sets_mode_0600() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().unwrap();
        let path = write_secret(dir.path(), "token");
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();

        restrict_to_owner(&path).unwrap();

        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
    }

    /// The Windows half of `unix_sets_mode_0600`, and the reason
    /// `.github/workflows/windows-checks.yml` exists: nothing else in this
    /// repository has ever asserted that a secret file is unreadable by other
    /// accounts on Windows.
    #[test]
    #[cfg(windows)]
    fn windows_leaves_only_an_owner_ace() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_secret(dir.path(), "token");

        restrict_to_owner(&path).unwrap();

        let out = std::process::Command::new("icacls")
            .arg(&path)
            .output()
            .expect("icacls runs");
        let acl = String::from_utf8_lossy(&out.stdout);

        assert!(
            acl.contains("OWNER RIGHTS") || acl.contains("S-1-3-4"),
            "owner ACE missing from DACL: {acl}"
        );
        // `(I)` marks an inherited ACE — `/inheritance:r` must have removed
        // every one of them, including whatever granted `Users` access.
        assert!(
            !acl.contains("(I)"),
            "inherited ACEs survived, secret may be readable by others: {acl}"
        );
        assert!(
            !acl.contains("\\Users:") && !acl.contains("BUILTIN\\Users"),
            "Users group still has access: {acl}"
        );
    }
}