use std::path::{Path, PathBuf};
use regex::Regex;
use sha2::{Digest, Sha256};
use crate::core::hook_state::key::hex;
use crate::core::hook_state::FileDescriptor;
use crate::error::{OlError, ERR_HOOK_WRITE_FAILED};
use crate::hooks::claude_code::pascal_to_snake;
pub const CLINE_HOOK_FILES: [&str; 10] = [
"TaskStart",
"TaskResume",
"TaskCancel",
"TaskComplete",
"TaskError",
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"PreCompact",
"SessionShutdown",
];
pub const HOOK_FILE_MODE: u32 = 0o755;
fn host_is_windows() -> bool {
cfg!(windows)
}
pub fn hook_file_name(event: &str) -> String {
file_name_for(event, host_is_windows())
}
fn file_name_for(event: &str, windows: bool) -> String {
if windows {
format!("{event}.ps1")
} else {
event.to_string()
}
}
fn quote_posix(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
fn quote_powershell(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn shim_around_marker(bin: &Path, ol_dir: &Path, event: &str, windows: bool) -> (String, String) {
let bin = bin.to_string_lossy();
let ol_dir = ol_dir.to_string_lossy();
if windows {
let tail = format!(
"try {{ & {bin} --agent cline --event {event} --openlatch-dir {dir} \
--capture-only *> $null }} catch {{ }}\nWrite-Output '{{}}'\nexit 0\n",
bin = quote_powershell(&bin),
dir = quote_powershell(&ol_dir),
);
(String::new(), tail)
} else {
let tail = format!(
"{bin} --agent cline --event {event} --openlatch-dir {dir} --capture-only \
>/dev/null 2>&1\nprintf '{{}}'\n",
bin = quote_posix(&bin),
dir = quote_posix(&ol_dir),
);
("#!/bin/sh\n".to_string(), tail)
}
}
pub fn shim_body(bin: &Path, ol_dir: &Path, event: &str, entry_id: &str, windows: bool) -> String {
let (head, tail) = shim_around_marker(bin, ol_dir, event, windows);
let unmarked = format!("{head}{tail}");
let digest = sha256_hex(&unmarked);
let uuid = marker_uuid_field(entry_id);
format!("{head}# openlatch-hook {uuid}{digest}\n{tail}")
}
fn marker_uuid_field(entry_id: &str) -> String {
let well_formed = entry_id.len() == 36
&& entry_id
.bytes()
.all(|b| (b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) || b == b'-');
if well_formed {
format!("{entry_id} ")
} else {
String::new()
}
}
pub fn sha256_hex(body: &str) -> String {
sha256_bytes(body.as_bytes())
}
pub fn sha256_bytes(bytes: &[u8]) -> String {
hex::encode(&Sha256::digest(bytes))
}
pub fn is_ours(body: &str) -> bool {
owning_marker_line(body).is_some()
}
fn owning_marker_line(body: &str) -> Option<usize> {
let pattern = marker_pattern();
for (index, line) in body.lines().take(2).enumerate() {
let Some(captures) = pattern.captures(line) else {
continue;
};
let Some(claimed) = captures.get(3) else {
continue;
};
if sha256_hex(&without_line(body, index)) == claimed.as_str() {
return Some(index);
}
}
None
}
fn marker_pattern() -> &'static Regex {
static PATTERN: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
PATTERN.get_or_init(|| {
Regex::new(r"^(#|//)\s*openlatch-hook\s+(?:([0-9a-f-]{36})\s+)?([0-9a-f]{64})\s*$")
.expect("the ownership marker pattern is a literal and compiles")
})
}
fn without_line(body: &str, index: usize) -> String {
body.split_inclusive('\n')
.enumerate()
.filter(|(i, _)| *i != index)
.map(|(_, segment)| segment)
.collect()
}
#[derive(Debug, Clone)]
pub struct WrittenHookFile {
pub event: String,
pub entry_id: String,
pub descriptor: FileDescriptor,
pub replaced: bool,
}
#[derive(Debug, Clone, Default)]
pub struct InstallReport {
pub written: Vec<WrittenHookFile>,
pub left: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default)]
pub struct RemovalReport {
pub removed: Vec<PathBuf>,
pub left: Vec<PathBuf>,
}
pub fn write_all(hooks_dir: &Path, bin: &Path, ol_dir: &Path) -> Result<InstallReport, OlError> {
std::fs::create_dir_all(hooks_dir).map_err(|e| write_failed(hooks_dir, &e))?;
let windows = host_is_windows();
let mut report = InstallReport {
written: Vec::with_capacity(CLINE_HOOK_FILES.len()),
left: Vec::new(),
};
for event in CLINE_HOOK_FILES {
let path = hooks_dir.join(file_name_for(event, windows));
let replaced = match whatever_is_there(&path) {
Existing::Nothing => false,
Existing::Ours => {
back_up(&path)?;
true
}
Existing::Theirs => {
tracing::warn!(
path = %path.display(),
"a file we did not write already holds this hook name; leaving it alone"
);
report.left.push(path);
continue;
}
};
let entry_id = uuid::Uuid::now_v7().to_string();
let body = shim_body(bin, ol_dir, pascal_to_snake(event), &entry_id, windows);
crate::fs_secure::write_executable(&path, &body).map_err(|e| write_failed(&path, &e))?;
report.written.push(WrittenHookFile {
event: event.to_string(),
entry_id,
descriptor: FileDescriptor {
path: path.to_string_lossy().into_owned(),
sha256: sha256_hex(&body),
mode: HOOK_FILE_MODE,
},
replaced,
});
}
Ok(report)
}
pub fn remove_all(hooks_dir: &Path) -> Result<RemovalReport, OlError> {
let windows = host_is_windows();
let mut report = RemovalReport::default();
for event in CLINE_HOOK_FILES {
let path = hooks_dir.join(file_name_for(event, windows));
match whatever_is_there(&path) {
Existing::Nothing => {}
Existing::Ours => {
std::fs::remove_file(&path).map_err(|e| write_failed(&path, &e))?;
report.removed.push(path);
}
Existing::Theirs => {
tracing::warn!(
path = %path.display(),
"hook file is not one of ours; leaving it in place"
);
report.left.push(path);
}
}
}
Ok(report)
}
pub(crate) enum Existing {
Nothing,
Ours,
Theirs,
}
pub(crate) fn whatever_is_there(path: &Path) -> Existing {
match std::fs::read(path) {
Ok(bytes) if is_ours(&String::from_utf8_lossy(&bytes)) => Existing::Ours,
Ok(_) => Existing::Theirs,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Existing::Nothing,
Err(_) => Existing::Theirs,
}
}
pub(crate) fn back_up(path: &Path) -> Result<(), OlError> {
let Some(name) = path.file_name() else {
return Ok(());
};
let mut backup_name = name.to_os_string();
backup_name.push(".bak");
let backup = path.with_file_name(backup_name);
std::fs::copy(path, &backup).map_err(|e| write_failed(&backup, &e))?;
Ok(())
}
fn write_failed(path: &Path, error: &std::io::Error) -> OlError {
OlError::new(
ERR_HOOK_WRITE_FAILED,
format!("Cannot write hook file '{}': {error}", path.display()),
)
.with_suggestion("Check that the agent's hook directory exists and is writable.")
}
#[cfg(test)]
mod tests {
use super::*;
const ENTRY_ID: &str = "01997a1e-0000-7000-8000-00000000000a";
fn hooks_dir(root: &Path) -> PathBuf {
root.join("Hooks")
}
#[cfg(unix)]
fn write_fake_hook_binary(path: &Path, record: &Path) {
let record = record.display().to_string();
let body = String::from("#!/bin/sh\n")
+ "for a in \"$@\"; do printf '%s\\n' \"$a\" >> "
+ "e_posix(&record)
+ "; done\n"
+ "cat >> "
+ "e_posix(&record)
+ "\n"
+ "printf '{\"verdict\":\"deny\",\"reason\":\"rm -rf /\"}'\n";
crate::fs_secure::write_executable(path, &body).expect("write the fake hook binary");
}
#[cfg(unix)]
fn run_shim(shim: &Path, stdin: &str) -> std::process::Output {
use std::io::Write as _;
use std::process::{Command, Stdio};
let mut child = Command::new(shim)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the shim is executable");
child
.stdin
.take()
.expect("piped stdin")
.write_all(stdin.as_bytes())
.expect("feed the shim");
child.wait_with_output().expect("the shim exits")
}
#[test]
#[cfg(unix)]
fn a_deny_verdict_never_reaches_the_file_lane() {
let root = tempfile::tempdir().expect("temp dir");
let record = root.path().join("invocation.txt");
let fake = root.path().join("fake-openlatch-hook");
write_fake_hook_binary(&fake, &record);
let dir = hooks_dir(root.path());
write_all(&dir, &fake, root.path()).expect("write the ten");
let output = run_shim(&dir.join("PreToolUse"), r#"{"toolName":"execute_command"}"#);
let recorded = std::fs::read_to_string(&record)
.expect("the fake binary was never invoked — the shim ran nothing");
let argv: Vec<&str> = recorded.lines().collect();
assert!(
argv.contains(&"--agent") && argv.contains(&"cline"),
"argv did not name the agent: {argv:?}"
);
assert!(
argv.contains(&"--event") && argv.contains(&"pre_tool_use"),
"argv did not carry the snake_case wire event: {argv:?}"
);
assert!(
argv.contains(&"--capture-only"),
"the file lane must never wait on a hold: {argv:?}"
);
assert!(
recorded.contains("execute_command"),
"the event body never reached the binary: {recorded}"
);
assert!(
output.status.success(),
"a shim that exits non-zero aborts the run: {:?}",
output.status
);
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"{}",
"the deny reached the agent; this kills the whole session, not one tool call"
);
}
#[test]
#[cfg(unix)]
fn the_file_lane_still_prints_empty() {
let root = tempfile::tempdir().expect("temp dir");
let fake = root.path().join("fake-openlatch-hook");
crate::fs_secure::write_executable(
&fake,
"#!/bin/sh\ncat >/dev/null\nprintf '{\"skip\":true,\"reason\":\"rm -rf /\"}'\n",
)
.expect("a fake hook binary answering in the PLUGIN lane's shape");
let dir = hooks_dir(root.path());
write_all(&dir, &fake, root.path()).expect("write the ten");
for event in CLINE_HOOK_FILES {
let output = run_shim(
&dir.join(file_name_for(event, false)),
r#"{"toolName":"run_commands","parameters":{"command":"rm -rf /tmp"}}"#,
);
assert!(
output.status.success(),
"{event}: a shim that exits non-zero aborts the run: {:?}",
output.status
);
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"{}",
"{event}: a real verdict reached the file lane; enforcement belongs to \
the plugin, and this kills the whole session rather than one tool call"
);
}
}
#[test]
fn both_platform_shapes_are_generated() {
assert_eq!(file_name_for("PreToolUse", true), "PreToolUse.ps1");
assert_eq!(file_name_for("PreToolUse", false), "PreToolUse");
let bin = Path::new("staged").join("openlatch-hook");
let ol_dir = Path::new("openlatch-dir");
let posix = shim_body(&bin, ol_dir, "pre_tool_use", ENTRY_ID, false);
assert!(
posix.starts_with("#!/bin/sh\n"),
"the POSIX shim needs its shebang: {posix}"
);
assert!(posix.contains(">/dev/null 2>&1"), "{posix}");
assert!(posix.ends_with("printf '{}'\n"), "{posix}");
let powershell = shim_body(&bin, ol_dir, "pre_tool_use", ENTRY_ID, true);
assert!(
!powershell.starts_with("#!"),
"PowerShell has no shebang, which is why the marker may sit on line 1: {powershell}"
);
assert!(
powershell.contains("*> $null"),
"every stream must be suppressed, not just stdout: {powershell}"
);
assert!(
!powershell.contains("| Out-Null"),
"`| Out-Null` discards stdout ALONE — it is not sufficient: {powershell}"
);
assert!(
powershell.contains("try {") && powershell.contains("catch {"),
"a terminating error must not skip the `{{}}`: {powershell}"
);
assert!(
powershell.ends_with("Write-Output '{}'\nexit 0\n"),
"the Windows half must print exactly `{{}}` and exit 0, like the \
POSIX half whose trailing `printf` cannot fail: {powershell}"
);
assert!(
!posix.contains("2>&1 >"),
"redirection order matters: `2>&1 >file` leaves stderr on the terminal"
);
assert!(is_ours(&posix), "{posix}");
assert!(is_ours(&powershell), "{powershell}");
assert_eq!(owning_marker_line(&posix), Some(1));
assert_eq!(owning_marker_line(&powershell), Some(0));
}
#[test]
fn all_ten_are_written_and_executable() {
let root = tempfile::tempdir().expect("temp dir");
let dir = hooks_dir(root.path());
let bin = root.path().join("bin").join("openlatch-hook");
let written = write_all(&dir, &bin, root.path())
.expect("write the ten")
.written;
assert_eq!(written.len(), 10, "ten events, ten files");
let mut got: Vec<String> = std::fs::read_dir(&dir)
.expect("the installer creates the directory")
.map(|e| {
e.expect("dir entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
got.sort();
let mut want: Vec<String> = CLINE_HOOK_FILES.iter().map(|e| hook_file_name(e)).collect();
want.sort();
assert_eq!(got, want);
for file in &written {
let path = Path::new(&file.descriptor.path);
let body = std::fs::read_to_string(path).expect("read back");
assert_eq!(
sha256_hex(&body),
file.descriptor.sha256,
"the descriptor must hash the bytes on disk"
);
assert!(is_ours(&body), "our own file must satisfy the predicate");
assert!(
body.contains(&file.entry_id),
"the marker and the state row must name one uuid"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(path).expect("stat").permissions().mode() & 0o777;
assert_eq!(
mode, HOOK_FILE_MODE,
"{} is not executable; every Cline event would be permission-denied",
file.descriptor.path
);
}
}
}
#[test]
fn a_file_we_did_not_write_is_never_removed() {
let root = tempfile::tempdir().expect("temp dir");
let dir = hooks_dir(root.path());
write_all(&dir, &root.path().join("openlatch-hook"), root.path()).expect("write the ten");
let theirs = dir.join(hook_file_name("PostToolUse"));
std::fs::write(&theirs, "#!/bin/sh\n# my own hook\n").expect("their file");
let edited = dir.join(hook_file_name("PreToolUse"));
let mut body = std::fs::read_to_string(&edited).expect("read ours");
body.push_str("echo tampered\n");
std::fs::write(&edited, &body).expect("edit ours");
let report = remove_all(&dir).expect("remove");
assert_eq!(report.removed.len(), 8, "the untouched eight come out");
assert!(theirs.exists(), "the developer's file was deleted");
assert!(
edited.exists(),
"an edited file is no longer ours to delete"
);
assert!(report.left.contains(&theirs), "{:?}", report.left);
assert!(report.left.contains(&edited), "{:?}", report.left);
assert_eq!(
std::fs::read_to_string(&theirs).expect("read theirs"),
"#!/bin/sh\n# my own hook\n",
"the developer's file was modified"
);
}
#[test]
fn an_unreadable_artefact_never_fails_either_direction() {
let root = tempfile::tempdir().expect("temp dir");
let dir = hooks_dir(root.path());
let occupied = dir.join(hook_file_name("PreToolUse"));
std::fs::create_dir_all(&occupied).expect("occupy the path with a directory");
let report =
write_all(&dir, &root.path().join("openlatch-hook"), root.path()).expect("install");
assert_eq!(report.written.len(), 9, "the other nine still install");
assert_eq!(
report.left.len(),
1,
"and the one we did not write is reported as left alone, not merely absent \
from `written` — this is what lets a caller tell a partial install from a \
complete one without counting against an agent-named constant"
);
let report = remove_all(&dir).expect("uninstall must not error over someone else's path");
assert_eq!(report.removed.len(), 9);
assert!(report.left.contains(&occupied), "{:?}", report.left);
assert!(occupied.is_dir(), "the developer's directory was removed");
}
#[test]
fn a_path_with_an_apostrophe_is_escaped() {
let bin = Path::new("/Users/O'Brien/bin/openlatch-hook");
let ol_dir = Path::new("/Users/O'Brien/.openlatch");
let posix = shim_body(bin, ol_dir, "pre_tool_use", ENTRY_ID, false);
assert!(
posix.contains(r"'/Users/O'\''Brien/bin/openlatch-hook'"),
"POSIX closes, escapes and reopens: {posix}"
);
assert!(posix.contains(r"'/Users/O'\''Brien/.openlatch'"), "{posix}");
let powershell = shim_body(bin, ol_dir, "pre_tool_use", ENTRY_ID, true);
assert!(
powershell.contains("'/Users/O''Brien/bin/openlatch-hook'"),
"PowerShell doubles it: {powershell}"
);
assert!(
powershell.contains("'/Users/O''Brien/.openlatch'"),
"{powershell}"
);
}
#[test]
#[cfg(unix)]
fn an_apostrophe_in_the_path_survives_a_real_shell() {
let root = tempfile::tempdir().expect("temp dir");
let record = root.path().join("invocation.txt");
let odd = root.path().join("O'Brien");
std::fs::create_dir_all(&odd).expect("create the awkward directory");
let fake = odd.join("fake-openlatch-hook");
write_fake_hook_binary(&fake, &record);
let dir = hooks_dir(&odd);
write_all(&dir, &fake, &odd).expect("write the ten");
let output = run_shim(&dir.join("PreToolUse"), "{}");
assert!(
record.exists(),
"the shim never reached the binary: stderr {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(output.status.success(), "{:?}", output.status);
assert_eq!(String::from_utf8_lossy(&output.stdout), "{}");
}
#[test]
fn no_resolver_is_called_from_the_writer() {
let source = include_str!("hook_files.rs");
let forbidden = [
concat!("asset", "_root"),
concat!("document", "_dir"),
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 install"
);
}
}
#[test]
fn a_marker_below_the_second_line_is_not_ours() {
let tail = "echo hello\n";
let digest = sha256_hex(&format!("#!/bin/sh\necho first\n{tail}"));
let body = format!("#!/bin/sh\necho first\n# openlatch-hook {digest}\n{tail}");
assert!(!is_ours(&body), "{body}");
}
#[test]
fn a_marker_without_a_uuid_and_behind_a_slash_prefix_is_ours() {
let tail = "console.log('{}');\n";
let digest = sha256_hex(tail);
let body = format!("// openlatch-hook {digest}\n{tail}");
assert!(is_ours(&body), "{body}");
assert_eq!(owning_marker_line(&body), Some(0));
}
#[test]
fn a_malformed_entry_id_is_dropped_rather_than_written() {
let body = shim_body(
Path::new("openlatch-hook"),
Path::new("ol"),
"pre_tool_use",
"not-a-uuid",
false,
);
assert!(!body.contains("not-a-uuid"), "{body}");
assert!(is_ours(&body), "the file must still be recognisably ours");
let kept = shim_body(
Path::new("openlatch-hook"),
Path::new("ol"),
"pre_tool_use",
ENTRY_ID,
false,
);
assert!(kept.contains(ENTRY_ID), "a well-formed id is kept: {kept}");
assert!(is_ours(&kept));
}
#[test]
fn an_edited_body_stops_being_ours() {
let bin = Path::new("openlatch-hook");
let body = shim_body(bin, Path::new("ol"), "pre_tool_use", ENTRY_ID, false);
assert!(is_ours(&body));
assert!(!is_ours(&format!("{body}echo extra\n")));
assert!(!is_ours(&body.replace("pre_tool_use", "post_tool_use")));
}
#[test]
fn rewriting_our_own_file_leaves_a_backup() {
let root = tempfile::tempdir().expect("temp dir");
let dir = hooks_dir(root.path());
let bin = root.path().join("openlatch-hook");
let first = write_all(&dir, &bin, root.path())
.expect("first install")
.written;
assert!(
first.iter().all(|f| !f.replaced),
"a first install replaces nothing"
);
let path = Path::new(&first[0].descriptor.path).to_path_buf();
let original = std::fs::read_to_string(&path).expect("read the first body");
write_all(&dir, &bin, root.path()).expect("second install");
let backup = dir.join(format!("{}.bak", hook_file_name(CLINE_HOOK_FILES[0])));
assert_eq!(
std::fs::read_to_string(&backup).expect("the backup is there"),
original
);
assert!(path.exists(), "the live script must still be in place");
}
}