openlatch-client 0.6.16

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! The agents OpenLatch has already made a decision about on this host.
//!
//! The daemon wires an agent that appears after it started — a desktop app
//! installed later — without anyone running `init` again. It must never do
//! that to an agent somebody chose to leave out: one `init --agent` excluded,
//! or one whose hooks were removed by hand. Both are indistinguishable from a
//! new arrival on disk, so the difference is kept here: every agent `init`
//! saw is recorded, wired or not, and only an agent missing from this set is
//! new.
//!
//! A sibling of [`crate::hooks::hook_trust_grants`], and for the same reason:
//! the fact belongs to OpenLatch, not to any agent's config.

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

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

/// Agent types (`claude-code`, `codex-cli`, …).
pub type Decided = BTreeSet<String>;

/// `<openlatch_dir>/decided-agents.json`.
///
/// The directory is a parameter, never a read of `$OPENLATCH_DIR` here, so a
/// test passes its own tempdir without touching the process environment.
pub fn record_path(openlatch_dir: &Path) -> PathBuf {
    openlatch_dir.join("decided-agents.json")
}

/// `Ok(None)` when absent — a host from before this record existed; `Err`
/// when present and unreadable, so a write never replaces what it could not
/// read and the daemon never takes an unreadable record for "nothing decided".
pub fn load(openlatch_dir: &Path) -> Result<Option<Decided>, 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);

/// Add `agents` to the record, creating it when absent. Under the file's
/// lock: `init` and the daemon can both record at once.
pub fn record<'a>(
    openlatch_dir: &Path,
    agents: impl IntoIterator<Item = &'a str>,
) -> Result<(), 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 existing = load(openlatch_dir)?;
        let mut decided = existing.clone().unwrap_or_default();
        decided.extend(agents.into_iter().map(str::to_string));
        if existing.as_ref() == Some(&decided) {
            return Ok(());
        }
        let content = serde_json::to_string_pretty(&decided).map_err(|e| {
            OlError::new(
                ERR_STATE_FILE_WRITE_FAILED,
                format!("Cannot serialize the decided-agents 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 decided-agents record: {e}"),
            )
        })?;
        crate::fs_secure::restrict_to_owner(&path).map_err(|e| {
            OlError::new(
                ERR_STATE_FILE_WRITE_FAILED,
                format!("Cannot restrict the decided-agents record: {e}"),
            )
        })
    })
    .map_err(|e| {
        OlError::new(
            ERR_STATE_FILE_WRITE_FAILED,
            format!("Cannot lock the decided-agents record: {e}"),
        )
    })?
}

/// Forget every decision — `uninstall`. Ok when absent.
pub fn remove(openlatch_dir: &Path) -> std::io::Result<()> {
    match std::fs::remove_file(record_path(openlatch_dir)) {
        Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
        _ => Ok(()),
    }
}

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

    #[test]
    fn absent_is_none_and_records_accumulate() {
        let dir = tempfile::tempdir().expect("temp dir");
        assert_eq!(load(dir.path()).expect("load"), None);

        record(dir.path(), ["claude-code", "codex-cli"]).expect("record");
        record(dir.path(), ["cursor", "claude-code"]).expect("record again");
        let decided = load(dir.path()).expect("load").expect("present");
        assert_eq!(
            decided.into_iter().collect::<Vec<_>>(),
            ["claude-code", "codex-cli", "cursor"]
        );
    }

    #[test]
    fn an_empty_record_is_present_not_absent() {
        // `init` on a host where it saw nothing still decided: an empty set
        // must not read as "migrate me".
        let dir = tempfile::tempdir().expect("temp dir");
        record(dir.path(), std::iter::empty()).expect("record");
        assert_eq!(load(dir.path()).expect("load"), Some(Decided::new()));
    }

    #[test]
    fn an_unreadable_record_is_an_error_and_is_not_overwritten() {
        let dir = tempfile::tempdir().expect("temp dir");
        std::fs::write(record_path(dir.path()), "not json").expect("seed");
        assert!(load(dir.path()).is_err());
        assert!(record(dir.path(), ["codex-cli"]).is_err());
        assert_eq!(
            std::fs::read_to_string(record_path(dir.path())).expect("read"),
            "not json"
        );
    }

    #[test]
    fn remove_forgets_and_tolerates_absence() {
        let dir = tempfile::tempdir().expect("temp dir");
        record(dir.path(), ["codex-cli"]).expect("record");
        remove(dir.path()).expect("remove");
        assert_eq!(load(dir.path()).expect("load"), None);
        remove(dir.path()).expect("remove again");
    }
}