openlatch-client 0.6.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Which agent hook files OpenLatch brought into existence — so `uninstall`
//! can delete one it created and left empty, and keep every file that was
//! there before it.
//!
//! Removing our entries from a file we created leaves a husk: Codex's
//! `hooks.json` as `{}`, a Claude `settings.json` as `{}`. Nobody asked for
//! that file, and a customer reading their config directory afterwards sees a
//! leftover. But "the file is `{}`" is not ownership — a customer can keep an
//! empty file of their own — so the fact is recorded at install, the moment it
//! is known, in a sibling of [`crate::hooks::hook_trust_grants`] under the
//! state directory.
//!
//! Generic over the `ConfigFile` surface, not Codex-specific: any agent whose
//! hook file we create gets the same rule.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use crate::error::{OlError, ERR_STATE_FILE_CORRUPT, ERR_STATE_FILE_WRITE_FAILED};

type Records = BTreeSet<PathBuf>;

/// `<openlatch_dir>/created-hook-files.json`.
fn record_path(openlatch_dir: &Path) -> PathBuf {
    openlatch_dir.join("created-hook-files.json")
}

/// `Ok(None)` when absent; `Err` when present and unreadable, so a write never
/// replaces a file it could not read.
fn load(openlatch_dir: &Path) -> Result<Option<Records>, OlError> {
    let path = record_path(openlatch_dir);
    let raw = match std::fs::read_to_string(&path) {
        Ok(raw) => raw,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => {
            return Err(OlError::new(
                ERR_STATE_FILE_CORRUPT,
                format!("cannot read {}: {e}", path.display()),
            ))
        }
    };
    serde_json::from_str(&raw).map(Some).map_err(|e| {
        OlError::new(
            ERR_STATE_FILE_CORRUPT,
            format!("{} is not valid JSON: {e}", path.display()),
        )
    })
}

/// How old a lock must be before it is taken to belong to a dead process.
const LOCK_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(30);

/// Read-modify-write under the file's lock: `init` and the daemon's drift
/// heal can both install at once.
fn mutate<T>(openlatch_dir: &Path, f: impl FnOnce(&mut Records) -> T) -> Result<T, OlError> {
    let path = record_path(openlatch_dir);
    std::fs::create_dir_all(openlatch_dir).map_err(|e| {
        OlError::new(
            ERR_STATE_FILE_WRITE_FAILED,
            format!("Cannot create the OpenLatch directory: {e}"),
        )
    })?;
    let lock = path.with_extension("json.lock");
    crate::fs_secure::with_lockfile(&lock, LOCK_STALE_AFTER, || {
        let mut records = load(openlatch_dir)?.unwrap_or_default();
        let before = records.clone();
        let out = f(&mut records);
        if records != before {
            let content = serde_json::to_string_pretty(&records).map_err(|e| {
                OlError::new(
                    ERR_STATE_FILE_WRITE_FAILED,
                    format!("Cannot serialize the created hook file record: {e}"),
                )
            })?;
            crate::fs_secure::write_preserving_mode(&path, content.as_bytes()).map_err(|e| {
                OlError::new(
                    ERR_STATE_FILE_WRITE_FAILED,
                    format!("Cannot write the created hook file record: {e}"),
                )
            })?;
        }
        Ok(out)
    })
    .map_err(|e| {
        OlError::new(
            ERR_STATE_FILE_WRITE_FAILED,
            format!("Cannot lock the created hook file record: {e}"),
        )
    })?
}

/// Record that install created `file`.
pub fn record(openlatch_dir: &Path, file: &Path) -> Result<(), OlError> {
    mutate(openlatch_dir, |records| {
        records.insert(file.to_path_buf());
    })
}

/// Did install create `file`? An unreadable record answers `false`: the file
/// is then kept, which is the safe way to be wrong.
pub fn is_recorded(openlatch_dir: &Path, file: &Path) -> bool {
    load(openlatch_dir)
        .ok()
        .flatten()
        .is_some_and(|records| records.contains(file))
}

/// Drop `file` from the record, once uninstall has deleted it.
pub fn forget(openlatch_dir: &Path, file: &Path) -> Result<(), OlError> {
    mutate(openlatch_dir, |records| {
        records.remove(file);
    })
}

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

    #[test]
    fn a_recorded_file_is_found_until_forgotten() {
        let ol = tempfile::tempdir().expect("state dir");
        let file = ol.path().join("codex").join("hooks.json");
        assert!(!is_recorded(ol.path(), &file));
        record(ol.path(), &file).expect("record");
        assert!(is_recorded(ol.path(), &file));
        assert!(!is_recorded(ol.path(), &ol.path().join("other.json")));
        forget(ol.path(), &file).expect("forget");
        assert!(!is_recorded(ol.path(), &file));
    }

    #[test]
    fn an_unreadable_record_is_not_overwritten_and_owns_nothing() {
        let ol = tempfile::tempdir().expect("state dir");
        std::fs::write(record_path(ol.path()), "not json").expect("seed");
        assert!(record(ol.path(), &ol.path().join("f.json")).is_err());
        assert!(!is_recorded(ol.path(), &ol.path().join("f.json")));
        assert_eq!(
            std::fs::read_to_string(record_path(ol.path())).expect("read"),
            "not json"
        );
    }
}