openlatch-client 0.6.19

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 Leg B ledger (AGT-6 C-8 Leg B *Ledger*; plan AGT-6-P2 D-18, D-21):
//! `<openlatch-dir>/state/opencode-desktop-service.json`, the one file Leg B writes.
//!
//! It names the OpenCode service the daemon started, so the daemon can tell its own service from
//! one OpenCode Desktop started, and tell an armed service from one spawned with keys that no
//! longer hold. It is written atomically and owner-only (`0600` on Unix, a user-only ACL on
//! Windows) through [`crate::core::fs_secure::write_owner_only`] — never the world-readable state
//! writer — because it names a process the daemon will signal. `--purge` removes it with the state
//! directory.

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

use serde::{Deserialize, Serialize};

use super::process::{ProcId, ProcessTable};

/// The file name under `<openlatch-dir>/state/`.
pub const LEDGER_FILE: &str = "opencode-desktop-service.json";

/// Largest ledger a reader accepts. The real file is a few hundred bytes.
const LEDGER_MAX_BYTES: u64 = 16 * 1024;

/// `<openlatch-dir>/state/opencode-desktop-service.json`.
#[must_use]
pub fn ledger_path(openlatch_dir: &Path) -> PathBuf {
    openlatch_dir.join("state").join(LEDGER_FILE)
}

/// The service the daemon started (Spec Data Model).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Ledger {
    pub pid: u32,
    /// The OS-native start identity, as [`super::process`] reads it (D-5): compared only with
    /// another read by the same code, never converted.
    pub start: String,
    /// The executable the process runs, as the OS reported it right after the spawn.
    pub binary: PathBuf,
    /// SHA-256 (lowercase hex) of the binary the daemon ran.
    pub sha256: String,
    /// The bundled CLI version the daemon started (`opencode-cli.version`).
    pub version: String,
    /// When the daemon started it, in milliseconds since the Unix epoch (D-14 reads it).
    pub started_at: u64,
    /// The spawn-time `HTTPS_PROXY` (D-18, D-21).
    pub proxy_url: String,
    /// SHA-256 (lowercase hex) of the relay CA the service was spawned to trust (D-18, D-21). A
    /// `doctor --fix` rotation keeps the CA path, so only the digest tells the roots apart.
    pub ca_sha256: String,
}

/// A registration whose pid the service's own authenticated `/api/info` answer confirmed (D-19).
/// Only the manager's probe constructs one, so holding it is proof the check ran.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRegistration {
    pub(super) pid: u32,
    pub(super) version: Option<String>,
}

impl VerifiedRegistration {
    /// The confirmed pid.
    #[must_use]
    pub fn pid(&self) -> u32 {
        self.pid
    }

    /// The version the service answered, when it gave one.
    #[must_use]
    pub fn version(&self) -> Option<&str> {
        self.version.as_deref()
    }
}

impl Ledger {
    /// The ledger names `current`: pid, start identity and binary all match a fresh read.
    #[must_use]
    pub fn names(&self, current: &ProcId) -> bool {
        current.pid == self.pid
            && current.start == self.start
            && super::process::same_path(&current.exe, &self.binary)
    }

    /// The registered service is the daemon's own (C-8 Leg B *Ledger*): the registration passed
    /// the `/api/info` pid check, and a fresh read of that pid matches the ledger's pid, start
    /// identity and binary. An unreadable identity (`fresh = None`) is never the daemon's own.
    #[must_use]
    pub fn owns(&self, registered: &VerifiedRegistration, fresh: Option<&ProcId>) -> bool {
        registered.pid == self.pid && fresh.is_some_and(|p| self.names(p))
    }

    /// The daemon's own service is armed (D-18): `env.json` is present, its `HTTPS_PROXY` equals
    /// the spawn-time proxy, and the current relay CA's digest equals the spawn-time one. Both
    /// must match, after a daemon restart too.
    #[must_use]
    pub fn armed(
        &self,
        env_json: Option<&crate::hooks::proxy_env_file::EnvJson>,
        ca_sha256: Option<&str>,
    ) -> bool {
        env_json.is_some_and(|e| e.https_proxy == self.proxy_url)
            && ca_sha256.is_some_and(|ca| ca.eq_ignore_ascii_case(&self.ca_sha256))
    }
}

/// The ledger on disk, `None` when absent, unreadable, oversized or not of the ledger's shape.
#[must_use]
pub fn read(openlatch_dir: &Path) -> Option<Ledger> {
    use std::io::Read;
    let file = std::fs::File::open(ledger_path(openlatch_dir)).ok()?;
    let mut raw = Vec::new();
    file.take(LEDGER_MAX_BYTES + 1).read_to_end(&mut raw).ok()?;
    if raw.len() as u64 > LEDGER_MAX_BYTES {
        return None;
    }
    serde_json::from_slice(&raw).ok()
}

/// Write the ledger atomically and owner-only (D-21).
pub fn write(openlatch_dir: &Path, ledger: &Ledger) -> std::io::Result<()> {
    let path = ledger_path(openlatch_dir);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let body = serde_json::to_string_pretty(ledger).map_err(std::io::Error::other)?;
    crate::core::fs_secure::write_owner_only(&path, &body)
}

/// Remove the ledger. Absent is success.
pub fn remove(openlatch_dir: &Path) -> std::io::Result<()> {
    match std::fs::remove_file(ledger_path(openlatch_dir)) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e),
    }
}

/// What [`current`] found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Current {
    /// No ledger, or a stale one — its process confirmed gone, or its pid readably naming another
    /// process — which was removed and is never acted on.
    None,
    /// The ledger, and the fresh identity of the process it names, still the daemon's own.
    Own(Ledger, ProcId),
    /// The ledger's pid runs, but its identity cannot be read (D-19): the ledger is kept, and
    /// nothing is signalled or started on its account until the identity reads again.
    Unverified(Ledger),
}

/// The ledger, with a stale one deleted (C-8 Leg B *Ledger*): only a confirmed exit or a readable
/// identity mismatch makes a ledger stale. An unreadable identity is not a disappearance — the
/// ledger stays, fail closed ([`Current::Unverified`]).
pub fn current(openlatch_dir: &Path, table: &dyn ProcessTable) -> Current {
    let Some(ledger) = read(openlatch_dir) else {
        return Current::None;
    };
    let reason = match table.read(ledger.pid) {
        Some(p) if ledger.names(&p) => return Current::Own(ledger, p),
        Some(_) => "stale ledger: pid reused by another process",
        None if table.exists(ledger.pid) => return Current::Unverified(ledger),
        None => "stale ledger: process gone",
    };
    tracing::info!(
        pid = ledger.pid,
        binary = %ledger.binary.display(),
        reason,
        "removing the OpenCode service ledger"
    );
    if let Err(e) = remove(openlatch_dir) {
        tracing::warn!(error = %e, "could not remove the OpenCode service ledger");
    }
    Current::None
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::hooks::proxy_env_file::EnvJson;

    fn sample() -> Ledger {
        Ledger {
            pid: 4242,
            start: "987654".into(),
            binary: PathBuf::from("opencode-cli"),
            sha256: "ab".repeat(32),
            version: "2.0.26".into(),
            started_at: 1_760_000_000_000,
            proxy_url: "http://127.0.0.1:47811".into(),
            ca_sha256: "cd".repeat(32),
        }
    }

    fn env(proxy: &str) -> EnvJson {
        EnvJson {
            https_proxy: proxy.into(),
            http_proxy: proxy.into(),
            no_proxy: "localhost".into(),
            node_extra_ca_certs: "/ol/ca.pem".into(),
            instance_id: "0".repeat(32),
        }
    }

    #[test]
    fn round_trips_and_removes() {
        let dir = tempfile::tempdir().unwrap();
        assert!(read(dir.path()).is_none());
        write(dir.path(), &sample()).unwrap();
        assert_eq!(read(dir.path()), Some(sample()));
        remove(dir.path()).unwrap();
        assert!(read(dir.path()).is_none());
        remove(dir.path()).unwrap();
    }

    #[test]
    fn an_oversized_or_foreign_shaped_ledger_is_not_read() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join("state")).unwrap();
        std::fs::write(ledger_path(dir.path()), "x".repeat(20 * 1024)).unwrap();
        assert!(read(dir.path()).is_none());
        std::fs::write(ledger_path(dir.path()), r#"{"pid":1,"extra":true}"#).unwrap();
        assert!(read(dir.path()).is_none());
    }

    #[cfg(unix)]
    #[test]
    fn the_ledger_is_owner_only_0600() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempfile::tempdir().unwrap();
        write(dir.path(), &sample()).unwrap();
        let mode = std::fs::metadata(ledger_path(dir.path()))
            .unwrap()
            .permissions()
            .mode();
        assert_eq!(mode & 0o777, 0o600, "expected 0600, got {:o}", mode & 0o777);
    }

    /// The Windows half (D-21), as `proxy_env_file`'s `env.json` test asserts it.
    #[cfg(windows)]
    #[test]
    fn the_ledger_is_owner_only_on_windows() {
        let dir = tempfile::tempdir().unwrap();
        write(dir.path(), &sample()).unwrap();
        let out = std::process::Command::new("icacls")
            .arg(ledger_path(dir.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: {acl}"
        );
        assert!(!acl.contains("(I)"), "inherited ACEs survived: {acl}");
        assert!(
            !acl.contains("\\Users:") && !acl.contains("BUILTIN\\Users"),
            "Users group still has access: {acl}"
        );
    }

    #[test]
    fn owns_needs_the_verified_pid_and_a_matching_fresh_read() {
        let l = sample();
        let fresh = ProcId {
            pid: 4242,
            start: "987654".into(),
            exe: PathBuf::from("opencode-cli"),
        };
        let verified = VerifiedRegistration {
            pid: 4242,
            version: Some("2.0.26".into()),
        };
        assert!(l.owns(&verified, Some(&fresh)));
        // Unreadable identity: never ours.
        assert!(!l.owns(&verified, None));
        // Reused pid: a different start identity.
        let reused = ProcId {
            start: "111".into(),
            ..fresh.clone()
        };
        assert!(!l.owns(&verified, Some(&reused)));
        // Another binary.
        let other = ProcId {
            exe: PathBuf::from("opencode"),
            ..fresh.clone()
        };
        assert!(!l.owns(&verified, Some(&other)));
        // A registration naming another pid.
        let elsewhere = VerifiedRegistration {
            pid: 7,
            version: None,
        };
        assert!(!l.owns(&elsewhere, Some(&fresh)));
    }

    #[test]
    fn armed_needs_both_the_proxy_and_the_ca_digest() {
        let l = sample();
        let ca = "cd".repeat(32);
        assert!(l.armed(Some(&env("http://127.0.0.1:47811")), Some(&ca)));
        assert!(!l.armed(None, Some(&ca)), "env.json withdrawn");
        assert!(
            !l.armed(Some(&env("http://127.0.0.1:1")), Some(&ca)),
            "proxy moved"
        );
        assert!(
            !l.armed(Some(&env("http://127.0.0.1:47811")), Some(&"ef".repeat(32))),
            "CA rotated in place"
        );
        assert!(
            !l.armed(Some(&env("http://127.0.0.1:47811")), None),
            "no CA"
        );
    }
}