openlatch-client 0.5.8

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Where OpenLatch remembers the Codex hook trust it granted — so `doctor` can
//! say who granted it, and `uninstall` can take back exactly that and no more.
//!
//! Codex records a `trusted_hash` and nothing about who wrote it. Once
//! OpenLatch writes that entry itself, `trusted` on disk no longer implies a
//! person read the command in `/hooks`; this file is where the difference is
//! kept. A sibling of [`crate::hooks::model_relay_endpoints`], and for the same
//! reason: the fact belongs to OpenLatch, not to the agent's config.

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

use serde::{Deserialize, Serialize};

use crate::error::{OlError, ERR_STATE_FILE_CORRUPT, ERR_STATE_FILE_WRITE_FAILED};
use crate::hooks::codex_cli::Grant;

/// One grant: what was written, by which build, and when.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantRecord {
    pub trusted_hash: String,
    pub client_version: String,
    /// RFC 3339.
    pub granted_at: String,
}

/// `[hooks.state]` key → grant. The key embeds the absolute `hooks.json`
/// path, so two Codex directories never collide.
type Records = BTreeMap<String, GrantRecord>;

/// `<openlatch_dir>/codex-trust-grants.json`.
///
/// The directory is a parameter, never a read of `$OPENLATCH_DIR` here: callers
/// pass [`crate::config::openlatch_dir`], the seam every isolated instance
/// depends on, and a test passes its own tempdir without touching the process
/// environment at all.
fn record_path(openlatch_dir: &Path) -> PathBuf {
    openlatch_dir.join("codex-trust-grants.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
/// pass can both grant at once.
fn mutate<T>(openlatch_dir: &Path, f: impl FnOnce(&mut Records) -> T) -> Result<T, OlError> {
    let path = record_path(openlatch_dir);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).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 Codex trust 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 Codex trust record: {e}"),
                )
            })?;
        }
        Ok(out)
    })
    .map_err(|e| {
        OlError::new(
            ERR_STATE_FILE_WRITE_FAILED,
            format!("Cannot lock the Codex trust record: {e}"),
        )
    })?
}

/// Record grants just written, stamped with this build and the time.
pub fn record(openlatch_dir: &Path, grants: &[Grant]) -> Result<(), OlError> {
    if grants.is_empty() {
        return Ok(());
    }
    let granted_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
    mutate(openlatch_dir, |records| {
        for grant in grants {
            records.insert(
                grant.key.clone(),
                GrantRecord {
                    trusted_hash: grant.trusted_hash.clone(),
                    client_version: env!("OPENLATCH_VERSION").to_string(),
                    granted_at: granted_at.clone(),
                },
            );
        }
    })
}

/// The grant recorded under `key`, if any. An unreadable file answers `None`:
/// this only ever feeds a line of `doctor` detail.
pub fn lookup(openlatch_dir: &Path, key: &str) -> Option<GrantRecord> {
    load(openlatch_dir).ok().flatten()?.remove(key)
}

/// Remove and return every grant made into the `hooks.json` at `hooks_json`.
///
/// Scoped to one Codex directory, so an uninstall from one `$CODEX_HOME`
/// cannot take back trust granted in another.
pub fn take_for(openlatch_dir: &Path, hooks_json: &Path) -> Result<Vec<Grant>, OlError> {
    mutate(openlatch_dir, |records| {
        let keys: Vec<String> = records
            .keys()
            .filter(|key| crate::hooks::codex_cli::key_names_file(key, hooks_json))
            .cloned()
            .collect();
        keys.into_iter()
            .filter_map(|key| {
                let record = records.remove(&key)?;
                Some(Grant {
                    key,
                    trusted_hash: record.trusted_hash,
                })
            })
            .collect()
    })
}

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

    fn grant(key: &str, hash: &str) -> Grant {
        Grant {
            key: key.to_string(),
            trusted_hash: hash.to_string(),
        }
    }

    /// A grant is recorded with this build and a time, found again by its key,
    /// and taken back only for the Codex directory it was made in.
    #[test]
    fn grants_round_trip_scoped_to_one_codex_dir() {
        let ol = tempfile::tempdir().expect("state dir");
        let codex = tempfile::tempdir().expect("codex dir");
        let other = tempfile::tempdir().expect("another codex dir");
        let ours = codex.path().join("hooks.json");
        let theirs = other.path().join("hooks.json");
        let key = |file: &Path, rest: &str| format!("{}:{rest}", file.display());

        record(
            ol.path(),
            &[
                grant(&key(&ours, "pre_tool_use:1:0"), "sha256:a"),
                grant(&key(&theirs, "pre_tool_use:0:0"), "sha256:b"),
            ],
        )
        .expect("record");

        let found = lookup(ol.path(), &key(&ours, "pre_tool_use:1:0")).expect("recorded");
        assert_eq!(found.trusted_hash, "sha256:a");
        assert_eq!(found.client_version, env!("OPENLATCH_VERSION"));
        assert!(!found.granted_at.is_empty());

        assert_eq!(
            take_for(ol.path(), &ours).expect("take"),
            [grant(&key(&ours, "pre_tool_use:1:0"), "sha256:a")]
        );
        assert!(lookup(ol.path(), &key(&ours, "pre_tool_use:1:0")).is_none());
        assert!(
            lookup(ol.path(), &key(&theirs, "pre_tool_use:0:0")).is_some(),
            "another directory's grant is not taken"
        );
    }

    /// An unreadable record is never overwritten: a write over it would lose
    /// grants uninstall still has to take back.
    #[test]
    fn an_unreadable_record_is_not_overwritten() {
        let ol = tempfile::tempdir().expect("state dir");
        std::fs::write(record_path(ol.path()), "not json").expect("seed");
        assert!(record(ol.path(), &[grant("k:pre_tool_use:0:0", "h")]).is_err());
        assert_eq!(
            std::fs::read_to_string(record_path(ol.path())).expect("read"),
            "not json"
        );
        assert!(lookup(ol.path(), "k:pre_tool_use:0:0").is_none());
    }
}