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};
pub const PLUGIN_ID: &str = "openlatch";
pub const PLUGIN_ENTRY_FILE_NAME: &str = "index.js";
pub const PLUGIN_FILE_MODE: u32 = 0o644;
pub const PLUGIN_ENTRY_EVENT: &str = "__plugin";
const PLUGIN_TEMPLATE: &str = include_str!("../../assets/cline/openlatch-plugin.js");
const OL_DIR_PLACEHOLDER: &str = "__OPENLATCH_DIR__";
const SHELL_TOOLS_PLACEHOLDER: &str = "__OPENLATCH_SHELL_TOOLS__";
#[must_use]
pub fn entry_path(plugin_dir: &Path) -> PathBuf {
plugin_dir.join(PLUGIN_ENTRY_FILE_NAME)
}
#[must_use]
pub fn plugin_body(ol_dir: &Path) -> String {
let unmarked = PLUGIN_TEMPLATE
.replace(OL_DIR_PLACEHOLDER, &js_string_literal(ol_dir))
.replace(SHELL_TOOLS_PLACEHOLDER, &shell_tools_literal());
let digest = hook_files::sha256_hex(&unmarked);
let (head, tail) = split_after_first_line(&unmarked);
format!("{head}// openlatch-hook {digest}\n{tail}")
}
fn shell_tools_literal() -> String {
serde_json::to_string(crate::core::policy::SHELL_TOOL_NAMES)
.unwrap_or_else(|_| "[]".to_string())
}
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, ""),
}
}
#[must_use]
pub fn is_disabled(disabled_plugins: Option<&[String]>) -> bool {
disabled_plugins.is_some_and(|list| list.iter().any(|id| id == PLUGIN_ID))
}
#[must_use]
pub fn plugin_dir(plugins_dir: &Path) -> PathBuf {
plugins_dir.join(PLUGIN_ID)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnforcementSurface {
Plugin,
Disabled,
None,
}
impl EnforcementSurface {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Plugin => "plugin",
Self::Disabled => "disabled",
Self::None => "none",
}
}
#[must_use]
pub fn is_enforcing(self) -> bool {
matches!(self, Self::Plugin)
}
}
#[must_use]
pub fn enforcement_surface(
plugin_dir: &Path,
disabled_plugins: Option<&[String]>,
) -> EnforcementSurface {
match hook_files::whatever_is_there(&entry_path(plugin_dir)) {
Existing::Ours if is_disabled(disabled_plugins) => EnforcementSurface::Disabled,
Existing::Ours => EnforcementSurface::Plugin,
Existing::Nothing | Existing::Theirs => EnforcementSurface::None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginWrite {
Written {
descriptor: FileDescriptor,
replaced: bool,
},
AlreadyCurrent(FileDescriptor),
LeftAlone(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginRemoval {
Removed(PathBuf),
Nothing,
LeftAlone(PathBuf),
}
pub fn install(plugin_dir: &Path, ol_dir: &Path) -> Result<PluginWrite, OlError> {
let path = entry_path(plugin_dir);
if matches!(hook_files::whatever_is_there(&path), Existing::Theirs) {
tracing::warn!(
path = %path.display(),
"a file we did not write already holds Cline's plugin entry point; \
leaving it alone — enforcement stays off rather than overwriting it"
);
return Ok(PluginWrite::LeftAlone(path));
}
repair(plugin_dir, ol_dir)
}
pub fn repair(plugin_dir: &Path, ol_dir: &Path) -> Result<PluginWrite, OlError> {
let path = entry_path(plugin_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(plugin_dir).map_err(|e| write_failed(plugin_dir, &e))?;
let replaced = present.is_some();
if replaced {
hook_files::back_up(&path)?;
}
crate::fs_secure::write_readable(&path, &body).map_err(|e| write_failed(&path, &e))?;
Ok(PluginWrite::Written {
descriptor,
replaced,
})
}
pub fn remove(plugin_dir: &Path) -> Result<PluginRemoval, OlError> {
let path = entry_path(plugin_dir);
match hook_files::whatever_is_there(&path) {
Existing::Nothing => Ok(PluginRemoval::Nothing),
Existing::Theirs => {
tracing::warn!(
path = %path.display(),
"Cline's plugin entry point is not one of ours; leaving it in place"
);
Ok(PluginRemoval::LeftAlone(path))
}
Existing::Ours => {
std::fs::remove_file(&path).map_err(|e| write_failed(&path, &e))?;
let _ = std::fs::remove_dir(plugin_dir);
Ok(PluginRemoval::Removed(path))
}
}
}
fn write_failed(path: &Path, error: &std::io::Error) -> OlError {
OlError::new(
ERR_HOOK_WRITE_FAILED,
format!("Cannot write Cline plugin '{}': {error}", path.display()),
)
.with_suggestion("Check that Cline's store root exists and is writable.")
}
#[cfg(test)]
mod tests {
use super::*;
fn plugin_dir(root: &Path) -> PathBuf {
super::plugin_dir(&root.join("plugins"))
}
#[test]
fn no_resolver_is_called_from_the_plugin_writer() {
let source = include_str!("cline_plugin.rs");
let forbidden = [
concat!("store", "_root"),
concat!("asset", "_root"),
concat!("data", "_root"),
concat!("hook", "_config_path"),
];
for needle in forbidden {
assert!(
!source.contains(needle),
"this module resolves a path through `{needle}`; it must take its \
directory as a parameter, or a unit test can reach the developer's \
real Cline store — which holds plaintext API keys"
);
}
}
#[test]
fn the_body_carries_every_shell_tool_name() {
assert!(
PLUGIN_TEMPLATE.contains(SHELL_TOOLS_PLACEHOLDER),
"the asset no longer carries the shell-tool placeholder; the plugin \
would ship gating on nothing"
);
let body = plugin_body(Path::new("/tmp/ol"));
assert!(
!body.contains(SHELL_TOOLS_PLACEHOLDER),
"the placeholder survived into the written body: {body}"
);
for name in crate::core::policy::SHELL_TOOL_NAMES {
assert!(
body.contains(&format!("\"{name}\"")),
"`{name}` is in SHELL_TOOL_NAMES but not in the rendered plugin, \
so a rule matching it would never be consulted: {body}"
);
}
assert!(
body.contains("SHELL_TOOLS.has(toolCall?.toolName)"),
"the list is in the body but nothing gates on it: {body}"
);
}
#[test]
fn the_ol_dir_placeholder_is_substituted() {
assert!(
PLUGIN_TEMPLATE.contains(OL_DIR_PLACEHOLDER),
"the asset no longer carries the placeholder the installer replaces; the \
plugin would ship with no way to find its daemon"
);
let body = plugin_body(Path::new("/tmp/ol"));
assert!(
!body.contains(OL_DIR_PLACEHOLDER),
"the placeholder survived into the written body: {body}"
);
assert!(
body.contains("\"/tmp/ol\""),
"the openlatch directory is not in the body as a quoted literal: {body}"
);
}
#[test]
fn an_awkward_path_survives_as_a_javascript_literal() {
let body = plugin_body(Path::new(r"/Users/O'Brien/\ol"));
assert!(
body.contains(r#""/Users/O'Brien/\\ol""#),
"the path was not escaped as a JSON/JS string literal: {body}"
);
}
#[test]
fn the_plugin_is_ours_by_the_shared_predicate() {
let body = plugin_body(Path::new("/tmp/ol"));
assert!(
hook_files::is_ours(&body),
"the plugin must be recognisable as ours from its own bytes: {body}"
);
let marker = body.lines().nth(1).expect("a second line");
assert!(
marker.starts_with("// openlatch-hook "),
"the marker belongs on line 2, behind a `//` prefix: {marker}"
);
assert_eq!(
marker.split_whitespace().count(),
3,
"the plugin has no hook event and therefore no uuid to carry: {marker}"
);
}
#[test]
fn an_edited_plugin_stops_being_ours() {
let body = plugin_body(Path::new("/tmp/ol"));
assert!(!hook_files::is_ours(&format!("{body}// trailing edit\n")));
}
#[test]
fn the_artefact_is_index_js_under_the_id_directory() {
let root = tempfile::tempdir().expect("temp dir");
let dir = plugin_dir(root.path());
let entry = entry_path(&dir);
assert_eq!(entry.file_name().expect("a file name"), "index.js");
assert_eq!(
entry
.parent()
.expect("a parent")
.file_name()
.expect("a directory name"),
PLUGIN_ID,
"the plugin id IS the directory name"
);
}
#[test]
fn a_first_install_writes_one_readable_file() {
let root = tempfile::tempdir().expect("temp dir");
let dir = plugin_dir(root.path());
assert!(
!dir.exists(),
"the fixture must not pre-create the directory"
);
let write = install(&dir, root.path()).expect("the plugin is written");
let PluginWrite::Written {
descriptor,
replaced,
} = write
else {
panic!("a free path is a write, not a collision: {write:?}");
};
assert!(!replaced, "the path was free");
assert_eq!(descriptor.mode, PLUGIN_FILE_MODE);
let path = entry_path(&dir);
let body = std::fs::read_to_string(&path).expect("a readable plugin");
assert_eq!(descriptor.sha256, hook_files::sha256_hex(&body));
assert!(hook_files::is_ours(&body));
let entries: Vec<String> = std::fs::read_dir(&dir)
.expect("the installer created the directory")
.map(|e| {
e.expect("a readable entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
assert_eq!(
entries,
vec![PLUGIN_ENTRY_FILE_NAME.to_string()],
"one artefact, exactly this name"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path)
.expect("a stat-able plugin")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o644,
"Node imports this file; 0755 would claim it is an executable"
);
}
}
#[test]
fn rewriting_our_own_plugin_leaves_a_backup() {
let root = tempfile::tempdir().expect("temp dir");
let dir = plugin_dir(root.path());
install(&dir, &root.path().join("first")).expect("first install");
let first = std::fs::read_to_string(entry_path(&dir)).expect("the first body");
let write = install(&dir, &root.path().join("second")).expect("second install");
assert!(
matches!(write, PluginWrite::Written { replaced: true, .. }),
"a rewrite over our own file is a replacement: {write:?}"
);
let backup = dir.join("index.js.bak");
assert_eq!(
std::fs::read_to_string(&backup).expect("a backup beside the plugin"),
first,
"the backup must hold the bytes that were there before the rewrite"
);
assert_ne!(
std::fs::read_to_string(entry_path(&dir)).expect("the new body"),
first,
"the second install pointed the plugin at a different openlatch directory"
);
}
#[test]
fn a_file_we_did_not_write_is_never_touched() {
let root = tempfile::tempdir().expect("temp dir");
let dir = plugin_dir(root.path());
std::fs::create_dir_all(&dir).expect("the developer's own plugin directory");
let path = entry_path(&dir);
std::fs::write(&path, "export default { name: 'mine' };\n").expect("their plugin");
let write = install(&dir, root.path()).expect("a collision is not an error");
assert_eq!(write, PluginWrite::LeftAlone(path.clone()));
assert_eq!(
std::fs::read_to_string(&path).expect("still readable"),
"export default { name: 'mine' };\n",
"their file was rewritten"
);
assert!(
!dir.join("index.js.bak").exists(),
"a file we refuse to write must not be backed up either"
);
let removal = remove(&dir).expect("a collision is not an error");
assert_eq!(removal, PluginRemoval::LeftAlone(path.clone()));
assert!(path.exists(), "uninstall removed a file that is not ours");
}
#[test]
fn uninstall_removes_our_plugin_and_the_directory_it_created() {
let root = tempfile::tempdir().expect("temp dir");
let dir = plugin_dir(root.path());
assert_eq!(
remove(&dir).expect("removing nothing is not an error"),
PluginRemoval::Nothing
);
install(&dir, root.path()).expect("install");
let removal = remove(&dir).expect("uninstall");
assert_eq!(removal, PluginRemoval::Removed(entry_path(&dir)));
assert!(!dir.exists(), "the directory install created is gone too");
}
#[test]
fn uninstall_never_takes_a_backup_with_it() {
let root = tempfile::tempdir().expect("temp dir");
let dir = plugin_dir(root.path());
install(&dir, root.path()).expect("first install");
install(&dir, &root.path().join("second")).expect("second install, leaving a backup");
remove(&dir).expect("uninstall");
assert!(!entry_path(&dir).exists(), "our plugin is gone");
assert!(
dir.join("index.js.bak").exists(),
"`doctor --restore` is what the backup exists for"
);
}
#[test]
fn the_plugin_entry_event_collides_with_no_hook() {
assert!(
!hook_files::CLINE_HOOK_FILES.contains(&PLUGIN_ENTRY_EVENT),
"`{PLUGIN_ENTRY_EVENT}` is now one of Cline's own hook events; the plugin's \
state row aliases that hook file's and one of the two is lost"
);
for event in hook_files::CLINE_HOOK_FILES {
assert_ne!(
hook_files::hook_file_name(event),
PLUGIN_ENTRY_EVENT,
"a hook file is discovered under the plugin's own key"
);
}
}
#[test]
fn repair_rewrites_a_plugin_whose_marker_no_longer_parses() {
let root = tempfile::tempdir().expect("temp dir");
let dir = plugin_dir(root.path());
install(&dir, root.path()).expect("install");
let want = std::fs::read_to_string(entry_path(&dir)).expect("the body we wrote");
std::fs::write(entry_path(&dir), "corrupted\n").expect("corrupt it");
assert!(
!hook_files::is_ours("corrupted\n"),
"the premise: corruption breaks the ownership predicate"
);
assert_eq!(
install(&dir, root.path()).expect("a collision is not an error"),
PluginWrite::LeftAlone(entry_path(&dir)),
"install must still refuse it — that is what `repair` exists beside"
);
let write = repair(&dir, root.path()).expect("repair");
assert!(
matches!(write, PluginWrite::Written { replaced: true, .. }),
"{write:?}"
);
assert_eq!(
std::fs::read_to_string(entry_path(&dir)).expect("a readable plugin"),
want,
"the repair did not restore the body install writes"
);
assert_eq!(
std::fs::read_to_string(dir.join("index.js.bak")).expect("a backup"),
"corrupted\n",
"the damaged original is what the backup is for"
);
}
#[test]
fn an_unchanged_plugin_is_neither_rewritten_nor_backed_up() {
let root = tempfile::tempdir().expect("temp dir");
let dir = plugin_dir(root.path());
install(&dir, root.path()).expect("install");
std::fs::write(dir.join("index.js.bak"), "the original\n").expect("an earlier backup");
for write in [
install(&dir, root.path()).expect("a second install"),
repair(&dir, root.path()).expect("a repair over our own current file"),
] {
assert!(
matches!(write, PluginWrite::AlreadyCurrent(_)),
"an identical body must not be rewritten: {write:?}"
);
}
assert_eq!(
std::fs::read_to_string(dir.join("index.js.bak")).expect("the backup"),
"the original\n",
"a write that did not happen overwrote the backup anyway"
);
}
#[test]
fn the_disabled_predicate_matches_the_id_and_nothing_else() {
assert!(is_disabled(Some(&["openlatch".to_string()])));
assert!(is_disabled(Some(&[
"cosmos-guardrails".to_string(),
"openlatch".to_string(),
])));
assert!(!is_disabled(Some(&["openlatch-hook".to_string()])));
assert!(!is_disabled(Some(&[])));
assert!(
!is_disabled(None),
"we could not tell is not the developer having switched us off"
);
}
#[test]
fn the_detector_answers_from_the_file_and_the_switch() {
let root = tempfile::tempdir().expect("temp dir");
let dir = plugin_dir(root.path());
let ol_dir = root.path().join("openlatch");
assert_eq!(
enforcement_surface(&dir, None),
EnforcementSurface::None,
"nothing installed is nothing to enforce with"
);
install(&dir, &ol_dir).expect("install the plugin");
assert_eq!(enforcement_surface(&dir, None), EnforcementSurface::Plugin);
assert_eq!(
enforcement_surface(&dir, Some(&["something-else".to_string()])),
EnforcementSurface::Plugin,
"another plugin's id on the list says nothing about ours"
);
assert_eq!(
enforcement_surface(&dir, Some(&[PLUGIN_ID.to_string()])),
EnforcementSurface::Disabled,
"ours, current, and switched off by the developer — a third state, not `none`"
);
std::fs::write(entry_path(&dir), "// someone else's plugin\n").expect("their file");
assert_eq!(
enforcement_surface(&dir, None),
EnforcementSurface::None,
"a file we refused to write is not an enforcement surface of ours"
);
}
#[test]
fn the_surface_vocabulary_is_closed_at_three() {
for (surface, spelling, enforcing) in [
(EnforcementSurface::Plugin, "plugin", true),
(EnforcementSurface::Disabled, "disabled", false),
(EnforcementSurface::None, "none", false),
] {
assert_eq!(surface.as_str(), spelling);
assert_eq!(
surface.is_enforcing(),
enforcing,
"{spelling} must not be a pass: off is never a pass"
);
}
}
#[test]
fn install_never_touches_global_settings() {
let source = include_str!("cline_plugin.rs");
let needle = concat!("global-", "settings.json");
let in_code: Vec<&str> = source
.lines()
.filter(|line| line.contains(needle))
.filter(|line| !line.trim_start().starts_with("//"))
.collect();
assert!(
in_code.is_empty(),
"this module names `{needle}` outside a comment; it must never read or \
write the developer's plugin switch: {in_code:?}"
);
}
#[cfg(unix)]
fn node_or_skip() -> Option<std::path::PathBuf> {
let found = std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.map(|dir| dir.join("node"))
.find(|candidate| candidate.is_file());
assert!(
found.is_some() || std::env::var_os("CI").is_none(),
"node is not on PATH, and every runner image this repo builds on ships it: \
a CI run without node cannot prove the plugin's fail-open"
);
found
}
#[cfg(unix)]
fn stage_plugin_for_node(root: &Path, ol_dir: &Path) -> PathBuf {
let dir = plugin_dir(root);
install(&dir, ol_dir).expect("install the plugin");
std::fs::write(dir.join("package.json"), r#"{"type":"module"}"#)
.expect("the ESM marker plain node needs");
entry_path(&dir)
}
#[cfg(unix)]
fn run_before_tool(
node: &Path,
entry: &Path,
tool_name: &str,
input_json: &str,
) -> serde_json::Value {
let driver = entry.with_file_name("driver.mjs");
std::fs::write(
&driver,
format!(
"import plugin from {entry};\n\
const out = await plugin.hooks.beforeTool({{ toolCall: {{ toolName: {tool} }}, \
input: {input} }});\n\
process.stdout.write(JSON.stringify(out === undefined ? null : out));\n",
entry = serde_json::to_string(&entry.to_string_lossy().as_ref())
.expect("a path serializes"),
tool = serde_json::to_string(tool_name).expect("a name serializes"),
input = input_json,
),
)
.expect("write the driver");
let output = std::process::Command::new(node)
.arg(&driver)
.output()
.expect("node runs");
assert!(
output.status.success(),
"the plugin threw, which kills the developer's whole run: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"the plugin did not resolve with JSON ({e}): {:?}",
String::from_utf8_lossy(&output.stdout)
)
})
}
#[cfg(unix)]
fn write_fake_hook_binary(ol_dir: &Path, record: &Path) {
let bin_dir = ol_dir.join("bin");
std::fs::create_dir_all(&bin_dir).expect("the staged bin directory");
let record = record.display().to_string();
let body = format!(
"#!/bin/sh\ncat >> '{record}'\nprintf '{{\"skip\":true,\"reason\":\"rm -rf /\"}}'\n"
);
crate::fs_secure::write_executable(&bin_dir.join("openlatch-hook"), &body)
.expect("write the fake hook binary");
}
#[test]
#[cfg(unix)]
fn the_plugin_never_throws() {
let Some(node) = node_or_skip() else {
return;
};
let root = tempfile::tempdir().expect("temp dir");
let ol_dir = root.path().join("openlatch");
let entry = stage_plugin_for_node(root.path(), &ol_dir);
let verdict = run_before_tool(
&node,
&entry,
"run_commands",
r#"{"command":"rm -rf /tmp"}"#,
);
assert_eq!(
verdict,
serde_json::Value::Null,
"an unreachable hook binary must be the sanctioned fail-open, not a skip \
and not a throw"
);
}
#[test]
#[cfg(unix)]
fn an_empty_answer_is_not_a_skip() {
let Some(node) = node_or_skip() else {
return;
};
let root = tempfile::tempdir().expect("temp dir");
let ol_dir = root.path().join("openlatch");
let bin_dir = ol_dir.join("bin");
std::fs::create_dir_all(&bin_dir).expect("the staged bin directory");
crate::fs_secure::write_executable(
&bin_dir.join("openlatch-hook"),
"#!/bin/sh\ncat >/dev/null\nprintf '{}'\n",
)
.expect("a fake hook binary that allows");
let entry = stage_plugin_for_node(root.path(), &ol_dir);
let verdict = run_before_tool(&node, &entry, "run_commands", r#"{"command":"ls"}"#);
assert_eq!(verdict, serde_json::Value::Null);
}
#[test]
#[cfg(unix)]
fn a_mixed_input_survives_the_round_trip_and_the_deny_comes_back() {
let Some(node) = node_or_skip() else {
return;
};
let root = tempfile::tempdir().expect("temp dir");
let ol_dir = root.path().join("openlatch");
let record = root.path().join("stdin.json");
write_fake_hook_binary(&ol_dir, &record);
let entry = stage_plugin_for_node(root.path(), &ol_dir);
let input =
r#"{"command":"rm -rf /tmp","options":{"cwd":"/srv","timeout":5},"args":["-r","-f"]}"#;
let verdict = run_before_tool(&node, &entry, "run_commands", input);
assert_eq!(
verdict,
serde_json::json!({"skip": true, "reason": "rm -rf /"}),
"the plugin lane's refusal is `{{skip, reason}}`"
);
let sent: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(&record)
.expect("the hook binary was never invoked — the plugin spawned nothing"),
)
.expect("the plugin wrote JSON on stdin");
assert_eq!(
sent,
serde_json::json!({
"toolName": "run_commands",
"parameters": {
"command": "rm -rf /tmp",
"options": {"cwd": "/srv", "timeout": 5},
"args": ["-r", "-f"],
},
}),
"the plugin re-typed or dropped part of Cline's parameters"
);
}
}