openlatch-client 0.6.14

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 OpenCode enforcement plugin: one drop-in file, `<config root>/plugins/openlatch.js`
//! (AGT-5 C-1, C-2).
//!
//! Mirrors [`crate::hooks::cline_plugin`]: the template is embedded at compile
//! time, the OpenLatch directory and the payload cap are rendered into it, and
//! line 2 carries the self-describing ownership marker that
//! [`hook_files::is_ours`] checks. The marker decides **ownership only**: heal
//! and tamper detection compare the file byte for byte with the expected render,
//! so an edit that recomputes line 2 is still healed.
//!
//! Three differences from Cline's plugin, each a property of OpenCode:
//!
//! - the file sits directly in a folder the developer may share with other
//!   plugins, so removal takes our file and **never** the folder (AC-13a);
//! - a backup of an edited file goes under the OpenLatch directory, never beside
//!   the plugin, where OpenCode would see it and the folder would no longer match
//!   its pre-install contents;
//! - plugins load only when OpenCode's server starts, so a write takes effect at
//!   the next start (`init` says so).

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

use crate::core::hook_state::FileDescriptor;
use crate::error::{OlError, ERR_HOOK_WRITE_FAILED};
use crate::hooks::hook_files::{self, Existing};

/// The plugin file's mode: OpenCode's Bun process imports it.
pub const PLUGIN_FILE_MODE: u32 = 0o644;

/// The state row's `hook_event` for the plugin: it has no hook event of its own.
pub const PLUGIN_ENTRY_EVENT: &str = "__plugin";

/// The template. `__OPENLATCH_DIR__` and `__CAP_BYTES__` are bare placeholders,
/// replaced from code so they cannot drift from what the hook enforces.
const PLUGIN_TEMPLATE: &str = include_str!("../../assets/opencode/openlatch-plugin.js");

const OL_DIR_PLACEHOLDER: &str = "__OPENLATCH_DIR__";
const CAP_PLACEHOLDER: &str = "__CAP_BYTES__";

/// The payload cap the plugin renders: the hook's stdin limit minus 4 KiB of
/// headroom (C-3).
pub const CAP_BYTES: usize = crate::hook_output::MAX_INPUT_SIZE - 4096;

/// The plugin file under a plugins folder.
#[must_use]
pub fn entry_path(plugins_dir: &Path) -> PathBuf {
    plugins_dir.join(crate::hooks::opencode::PLUGIN_FILE_NAME)
}

/// Where an edited plugin is backed up before heal rewrites it: under the
/// OpenLatch directory, never beside the plugin.
#[must_use]
pub fn backup_path(ol_dir: &Path) -> PathBuf {
    ol_dir.join("backups").join("opencode-plugin.js.bak")
}

/// The exact bytes we write, for `ol_dir`.
#[must_use]
pub fn plugin_body(ol_dir: &Path) -> String {
    // The cap first: the directory is the developer's text and may itself
    // contain `__CAP_BYTES__`, which a later global replace would rewrite.
    let unmarked = PLUGIN_TEMPLATE
        .replace(CAP_PLACEHOLDER, &CAP_BYTES.to_string())
        .replace(OL_DIR_PLACEHOLDER, &js_string_literal(ol_dir));
    let digest = hook_files::sha256_hex(&unmarked);
    let (head, tail) = split_after_first_line(&unmarked);
    format!("{head}// openlatch-hook {digest}\n{tail}")
}

fn js_string_literal(path: &Path) -> String {
    serde_json::to_string(path.to_string_lossy().as_ref()).unwrap_or_else(|_| "\"\"".to_string())
}

fn split_after_first_line(body: &str) -> (&str, &str) {
    match body.find('\n') {
        Some(index) => body.split_at(index + 1),
        None => (body, ""),
    }
}

/// The descriptor of the file we would write for `ol_dir` at `plugins_dir`.
#[must_use]
pub fn expected_descriptor(plugins_dir: &Path, ol_dir: &Path) -> FileDescriptor {
    FileDescriptor {
        path: entry_path(plugins_dir).to_string_lossy().into_owned(),
        sha256: hook_files::sha256_hex(&plugin_body(ol_dir)),
        mode: PLUGIN_FILE_MODE,
    }
}

/// What a write did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginWrite {
    /// Written; `replaced` when a file of ours was there before.
    Written {
        /// What the state row stores.
        descriptor: FileDescriptor,
        /// A file was there before and was rewritten.
        replaced: bool,
    },
    /// The file on disk is already byte-identical to the expected render.
    AlreadyCurrent(FileDescriptor),
    /// A file we do not own holds our path; never overwritten.
    LeftAlone(PathBuf),
}

/// What a removal did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginRemoval {
    /// Our file was removed.
    Removed(PathBuf),
    /// Nothing was there.
    Nothing,
    /// A file we do not own is there; left in place.
    LeftAlone(PathBuf),
}

/// Whether the file at `plugins_dir` is ours (marker check).
#[must_use]
pub fn is_ours(plugins_dir: &Path) -> bool {
    matches!(
        hook_files::whatever_is_there(&entry_path(plugins_dir)),
        Existing::Ours
    )
}

/// Install: write the plugin unless a file we do not own holds the path.
///
/// # Errors
///
/// `OL-1401` when the folder or the file cannot be written.
pub fn install(plugins_dir: &Path, ol_dir: &Path) -> Result<PluginWrite, OlError> {
    let path = entry_path(plugins_dir);
    if matches!(hook_files::whatever_is_there(&path), Existing::Theirs) {
        tracing::warn!(
            path = %path.display(),
            "a file we did not write already holds OpenCode's OpenLatch plugin path; leaving it alone"
        );
        return Ok(PluginWrite::LeftAlone(path));
    }
    repair(plugins_dir, ol_dir)
}

/// Heal: make the file byte-identical to the expected render. The caller holds
/// the authority (a state row, or ownership just established by [`install`]);
/// an edited file of any content is backed up under `ol_dir` first.
///
/// # Errors
///
/// `OL-1401` when the folder, the backup or the file cannot be written.
pub fn repair(plugins_dir: &Path, ol_dir: &Path) -> Result<PluginWrite, OlError> {
    let path = entry_path(plugins_dir);
    let body = plugin_body(ol_dir);
    let descriptor = FileDescriptor {
        path: path.to_string_lossy().into_owned(),
        sha256: hook_files::sha256_hex(&body),
        mode: PLUGIN_FILE_MODE,
    };

    let present = std::fs::read(&path).ok();
    if present.as_deref() == Some(body.as_bytes()) {
        return Ok(PluginWrite::AlreadyCurrent(descriptor));
    }

    std::fs::create_dir_all(plugins_dir).map_err(|e| write_failed(plugins_dir, &e))?;
    let replaced = present.is_some();
    if let Some(previous) = present {
        let backup = backup_path(ol_dir);
        if let Some(parent) = backup.parent() {
            std::fs::create_dir_all(parent).map_err(|e| write_failed(parent, &e))?;
        }
        std::fs::write(&backup, previous).map_err(|e| write_failed(&backup, &e))?;
    }
    crate::fs_secure::write_readable(&path, &body).map_err(|e| write_failed(&path, &e))?;

    Ok(PluginWrite::Written {
        descriptor,
        replaced,
    })
}

/// Remove our file, and only our file. The plugins folder stays, whoever made
/// it: it may hold the developer's own plugins, and AC-13a wants it back as it
/// was before install.
///
/// # Errors
///
/// `OL-1401` when the file cannot be removed.
pub fn remove(plugins_dir: &Path) -> Result<PluginRemoval, OlError> {
    let path = entry_path(plugins_dir);
    match hook_files::whatever_is_there(&path) {
        Existing::Nothing => Ok(PluginRemoval::Nothing),
        Existing::Theirs => {
            tracing::warn!(
                path = %path.display(),
                "OpenCode's OpenLatch plugin path holds a file that is not ours; leaving it in place"
            );
            Ok(PluginRemoval::LeftAlone(path))
        }
        Existing::Ours => {
            std::fs::remove_file(&path).map_err(|e| write_failed(&path, &e))?;
            Ok(PluginRemoval::Removed(path))
        }
    }
}

fn write_failed(path: &Path, error: &std::io::Error) -> OlError {
    OlError::new(
        ERR_HOOK_WRITE_FAILED,
        format!("Cannot write OpenCode plugin '{}': {error}", path.display()),
    )
    .with_suggestion("Check that OpenCode's config folder exists and is writable.")
}

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

    #[test]
    fn the_rendered_body_is_ours_and_carries_the_marker_on_line_2() {
        let body = plugin_body(Path::new("/home/dev/.openlatch"));
        let mut lines = body.lines();
        assert!(lines.next().unwrap().starts_with("// OpenLatch"));
        assert!(lines.next().unwrap().starts_with("// openlatch-hook "));
        assert!(hook_files::is_ours(&body));
        assert!(!body.contains(OL_DIR_PLACEHOLDER));
        assert!(!body.contains(CAP_PLACEHOLDER));
        assert!(body.contains(&format!("const CAP_BYTES = {CAP_BYTES};")));
        assert!(body.contains(r#"const OL_DIR = "/home/dev/.openlatch";"#));
    }

    #[test]
    fn a_windows_path_with_spaces_and_backslashes_is_a_valid_js_string() {
        let body = plugin_body(Path::new(r"C:\Users\Dev User\.openlatch"));
        assert!(body.contains(r#"const OL_DIR = "C:\\Users\\Dev User\\.openlatch";"#));
    }

    #[test]
    fn a_directory_containing_a_placeholder_is_rendered_verbatim() {
        let body = plugin_body(Path::new("/tmp/__CAP_BYTES__/x"));
        assert!(body.contains(r#"const OL_DIR = "/tmp/__CAP_BYTES__/x";"#));
        assert!(body.contains(&format!("const CAP_BYTES = {CAP_BYTES};")));
    }

    #[test]
    fn the_plugin_exports_exactly_one_default_function() {
        let exports: Vec<&str> = PLUGIN_TEMPLATE
            .lines()
            .filter(|l| l.trim_start().starts_with("export "))
            .collect();
        assert_eq!(exports, ["export default async function openlatch(ctx) {"]);
        assert!(
            !PLUGIN_TEMPLATE.contains("import "),
            "the plugin has no imports"
        );
    }

    #[test]
    fn install_writes_heal_restores_and_remove_keeps_the_folder() {
        let root = tempfile::tempdir().unwrap();
        let ol = root.path().join("ol");
        let plugins = root.path().join("cfg").join("plugins");

        let written = install(&plugins, &ol).unwrap();
        assert!(matches!(
            written,
            PluginWrite::Written {
                replaced: false,
                ..
            }
        ));
        assert!(is_ours(&plugins));
        assert!(matches!(
            install(&plugins, &ol).unwrap(),
            PluginWrite::AlreadyCurrent(_)
        ));

        // An edit that keeps a valid marker is still healed: bytes decide.
        let edited = plugin_body(&root.path().join("elsewhere"));
        std::fs::write(entry_path(&plugins), &edited).unwrap();
        assert!(is_ours(&plugins));
        assert!(matches!(
            repair(&plugins, &ol).unwrap(),
            PluginWrite::Written { replaced: true, .. }
        ));
        assert_eq!(
            std::fs::read_to_string(entry_path(&plugins)).unwrap(),
            plugin_body(&ol)
        );
        assert_eq!(std::fs::read_to_string(backup_path(&ol)).unwrap(), edited);
        assert_eq!(
            std::fs::read_dir(&plugins).unwrap().count(),
            1,
            "no backup beside the plugin"
        );

        assert_eq!(
            remove(&plugins).unwrap(),
            PluginRemoval::Removed(entry_path(&plugins))
        );
        assert!(plugins.is_dir(), "the plugins folder stays");
        assert_eq!(std::fs::read_dir(&plugins).unwrap().count(), 0);
        assert_eq!(remove(&plugins).unwrap(), PluginRemoval::Nothing);
    }

    #[test]
    fn a_foreign_file_at_our_path_is_never_overwritten_or_removed() {
        let root = tempfile::tempdir().unwrap();
        let plugins = root.path().join("plugins");
        std::fs::create_dir_all(&plugins).unwrap();
        std::fs::write(entry_path(&plugins), "export default async () => ({})\n").unwrap();
        assert!(matches!(
            install(&plugins, root.path()).unwrap(),
            PluginWrite::LeftAlone(_)
        ));
        assert!(matches!(
            remove(&plugins).unwrap(),
            PluginRemoval::LeftAlone(_)
        ));
        assert_eq!(
            std::fs::read_to_string(entry_path(&plugins)).unwrap(),
            "export default async () => ({})\n"
        );
    }
}