use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde_json::{json, Map, Value};
use crate::matcher::{relative_path, Matcher};
use crate::policy::{self, Policy};
pub mod antigravity;
pub mod claude;
pub mod cline;
pub mod codex;
pub mod copilot;
pub mod cursor;
pub mod gemini;
pub mod opencode;
pub mod windsurf;
use crate::cli::Agent;
const PATH_KEYS: &[&str] = &[
"filepath",
"path",
"notebookpath",
"targetfile",
"abspath",
"absolutepath",
"oldpath",
"newpath",
"destination",
];
fn is_path_key(key: &str) -> bool {
let normalised: String = key
.chars()
.filter(|character| *character != '_' && *character != '-')
.flat_map(char::to_lowercase)
.collect();
PATH_KEYS.contains(&normalised.as_str())
}
fn targets(request: &Value) -> Vec<String> {
let mut found = Vec::new();
collect(request, &mut found);
found
}
const READ_ONLY_TOOLS: &[&str] = &[
"read", "view", "open", "cat", "grep", "search", "glob", "list", "ls", "find", "fetch",
];
fn tool_name(request: &Value) -> Option<&str> {
request
.get("tool_name")
.or_else(|| request.get("toolName"))
.or_else(|| request.get("tool"))
.or_else(|| request.get("toolCall").and_then(|call| call.get("name")))
.and_then(Value::as_str)
}
fn only_reads(request: &Value) -> bool {
let Some(tool) = tool_name(request) else {
return false;
};
let tool = tool.to_lowercase();
let writes = [
"write", "edit", "create", "replace", "patch", "insert", "delete", "remove",
];
READ_ONLY_TOOLS.iter().any(|name| tool.contains(name))
&& !writes.iter().any(|name| tool.contains(name))
}
fn collect(value: &Value, found: &mut Vec<String>) {
match value {
Value::Object(fields) => {
for (key, child) in fields {
if is_path_key(key) {
if let Some(path) = child.as_str() {
found.push(path.to_string());
}
}
collect(child, found);
}
}
Value::Array(items) => items.iter().for_each(|item| collect(item, found)),
_ => {}
}
}
#[derive(Debug)]
pub struct Installed {
pub path: PathBuf,
pub replaced: bool,
}
pub fn install_for(root: &Path, agent: Agent, dry_run: bool) -> Result<Vec<Installed>> {
match agent {
Agent::Claude => Ok(vec![install(root, dry_run)?]),
Agent::Cursor => Ok(vec![cursor::install(root, dry_run)?]),
Agent::Opencode => Ok(vec![opencode::install(root, dry_run)?]),
Agent::Copilot => Ok(vec![copilot::install(root, dry_run)?]),
Agent::Codex => Ok(vec![install_codex(root, dry_run)?]),
Agent::Gemini => Ok(vec![install_gemini(root, dry_run)?]),
Agent::Antigravity => Ok(vec![antigravity::install(root, dry_run)?]),
Agent::Windsurf => Ok(vec![install_windsurf(root, dry_run)?]),
Agent::Cline => Ok(vec![cline::install(root, dry_run)?]),
Agent::All => Ok(vec![
install(root, dry_run)?,
cursor::install(root, dry_run)?,
opencode::install(root, dry_run)?,
copilot::install(root, dry_run)?,
install_codex(root, dry_run)?,
install_gemini(root, dry_run)?,
antigravity::install(root, dry_run)?,
install_windsurf(root, dry_run)?,
cline::install(root, dry_run)?,
]),
}
}
fn install_windsurf(root: &Path, dry_run: bool) -> Result<Installed> {
install_settings(
root,
dry_run,
windsurf::SETTINGS,
windsurf::EVENT,
windsurf::entry(),
windsurf::is_ours,
)
}
fn install_codex(root: &Path, dry_run: bool) -> Result<Installed> {
install_settings(
root,
dry_run,
codex::SETTINGS,
codex::EVENT,
codex::entry(),
codex::is_ours,
)
}
fn install_gemini(root: &Path, dry_run: bool) -> Result<Installed> {
install_settings(
root,
dry_run,
gemini::SETTINGS,
gemini::EVENT,
gemini::entry(),
gemini::is_ours,
)
}
pub fn install(root: &Path, dry_run: bool) -> Result<Installed> {
install_settings(
root,
dry_run,
claude::SETTINGS,
claude::EVENT,
claude::entry(),
claude::is_ours,
)
}
fn install_settings(
root: &Path,
dry_run: bool,
settings_file: &str,
event: &str,
entry: Value,
is_ours: fn(&Value) -> bool,
) -> Result<Installed> {
let path = root.join(settings_file);
let mut settings: Value = if path.is_file() {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
serde_json::from_str(&text).with_context(|| {
format!(
"{} is not valid JSON, so it will not be modified",
path.display()
)
})?
} else {
Value::Object(Map::new())
};
if !settings.is_object() {
anyhow::bail!("{} does not contain a JSON object", path.display());
}
let events = settings
.as_object_mut()
.expect("checked above")
.entry("hooks")
.or_insert_with(|| Value::Object(Map::new()));
if !events.is_object() {
anyhow::bail!("{}: `hooks` is not an object", path.display());
}
let pre = events
.as_object_mut()
.expect("checked above")
.entry(event.to_string())
.or_insert_with(|| Value::Array(Vec::new()));
let Some(list) = pre.as_array_mut() else {
anyhow::bail!("{}: `hooks.{}` is not an array", path.display(), event);
};
let existing = list.iter().position(is_ours);
let replaced = existing.is_some();
match existing {
Some(index) => list[index] = entry,
None => list.push(entry),
}
let rendered = format!("{}\n", serde_json::to_string_pretty(&settings)?);
if !dry_run {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(&path, rendered)
.with_context(|| format!("failed to write {}", path.display()))?;
} else {
print!("{rendered}");
}
Ok(Installed { path, replaced })
}
pub enum Decision {
Allow,
Deny { reason: String },
}
impl Decision {
pub fn render(&self) -> Option<String> {
match self {
Decision::Allow => None,
Decision::Deny { reason } => Some(
json!({
"decision": "deny",
"reason": reason,
"systemMessage": format!("ralon: {reason}"),
"cancel": true,
"errorMessage": reason,
"permission": "deny",
"agent_message": reason,
"user_message": format!("ralon: {reason}"),
"hookSpecificOutput": {
"hookEventName": claude::EVENT,
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
})
.to_string(),
),
}
}
pub fn reason(&self) -> Option<&str> {
match self {
Decision::Allow => None,
Decision::Deny { reason } => Some(reason),
}
}
}
pub fn decide(request: &str, start: &Path) -> Result<Decision> {
let Ok(value) = serde_json::from_str::<Value>(request) else {
return Ok(Decision::Allow);
};
if only_reads(&value) {
return Ok(Decision::Allow);
}
for target in targets(&value) {
let target = policy::absolute(Path::new(&target))?;
let lookup = target.parent().unwrap_or(start);
let Ok(policy) = Policy::load(lookup).or_else(|_| Policy::load(start)) else {
continue;
};
let matcher = Matcher::new(&policy.patterns)?;
let Some(relative) = relative_path(&policy.root, &target) else {
continue;
};
if let Some(pattern) = matcher.matched_pattern(&relative) {
return Ok(Decision::Deny {
reason: format!(
"{relative} is protected by Ralon — it is listed in agent.lock \
(matches `{pattern}`), so writes to it are refused. \
Edit something else, or ask the developer to change the policy — \
you cannot change it yourself."
),
});
}
}
Ok(Decision::Allow)
}
pub fn check(start: &Path) -> Result<Decision> {
let mut request = String::new();
std::io::stdin()
.read_to_string(&mut request)
.context("failed to read the hook request from stdin")?;
decide(&request, start)
}
#[cfg(test)]
mod tests {
use super::*;
use claude::SETTINGS;
use serde_json::json;
fn project(policy: &str) -> tempdir::TempDir {
let dir = tempdir::TempDir::new();
std::fs::write(dir.path().join("agent.lock"), policy).unwrap();
dir
}
mod tempdir {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new() -> TempDir {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let path = std::env::temp_dir().join(format!(
"ralon-hook-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&path).unwrap();
TempDir(path)
}
pub fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
}
fn request(path: &Path) -> String {
json!({
"tool_name": "Write",
"tool_input": { "file_path": path.to_string_lossy() }
})
.to_string()
}
#[test]
fn denies_a_protected_path() {
let dir = project("version: 1\nprotect:\n - .env\n");
let decision = decide(&request(&dir.path().join(".env")), dir.path()).unwrap();
let rendered = decision.render().expect("should deny");
assert!(
rendered.contains("\"permissionDecision\":\"deny\""),
"{rendered}"
);
assert!(rendered.contains(".env is protected"), "{rendered}");
}
#[test]
fn denies_the_policy_file_itself() {
let dir = project("version: 1\nprotect: []\n");
let decision = decide(&request(&dir.path().join("agent.lock")), dir.path()).unwrap();
assert!(decision.render().is_some());
}
#[test]
fn allows_an_unprotected_path() {
let dir = project("version: 1\nprotect:\n - .env\n");
let decision = decide(&request(&dir.path().join("src/App.tsx")), dir.path()).unwrap();
assert!(decision.render().is_none());
}
#[test]
fn allows_when_there_is_no_policy() {
let dir = tempdir::TempDir::new();
let decision = decide(&request(&dir.path().join("anything.txt")), dir.path()).unwrap();
assert!(decision.render().is_none());
}
#[test]
fn allows_a_request_it_cannot_parse() {
let dir = project("version: 1\nprotect:\n - .env\n");
for request in ["", "not json", "{}", r#"{"tool_input":{}}"#] {
let decision = decide(request, dir.path()).unwrap();
assert!(decision.render().is_none(), "blocked on `{request}`");
}
}
#[test]
fn reading_a_protected_file_is_never_refused() {
let dir = project("version: 1\nprotect:\n - .env\n");
let target = dir.path().join(".env");
for tool in ["Read", "read_file", "view", "Glob", "grep_search"] {
let request = json!({
"tool_name": tool,
"tool_input": { "file_path": target.to_string_lossy() }
})
.to_string();
let decision = decide(&request, dir.path()).unwrap();
assert!(decision.render().is_none(), "{tool} was refused a read");
}
for tool in ["Write", "write_file", "apply_patch", "replace_file_content"] {
let request = json!({
"tool_name": tool,
"tool_input": { "file_path": target.to_string_lossy() }
})
.to_string();
let decision = decide(&request, dir.path()).unwrap();
assert!(decision.render().is_some(), "{tool} was allowed to write");
}
}
#[test]
fn a_path_is_found_whatever_the_key_is_called() {
let dir = project("version: 1\nprotect:\n - .env\n");
let target = dir.path().join(".env").to_string_lossy().into_owned();
for key in [
"file_path",
"filePath",
"FilePath",
"TargetFile",
"AbsolutePath",
"abs_path",
] {
let request =
json!({ "tool_name": "Write", "tool_input": { key: target } }).to_string();
let decision = decide(&request, dir.path()).unwrap();
assert!(decision.render().is_some(), "missed the path under `{key}`");
}
}
#[test]
fn a_nested_tool_call_is_understood() {
let dir = project("version: 1\nprotect:\n - .env\n");
let target = dir.path().join(".env").to_string_lossy().into_owned();
let write = json!({
"toolCall": { "name": "replace_file_content", "args": { "TargetFile": target } }
})
.to_string();
assert!(decide(&write, dir.path()).unwrap().render().is_some());
let read = json!({
"toolCall": { "name": "view_file", "args": { "TargetFile": target } }
})
.to_string();
assert!(decide(&read, dir.path()).unwrap().render().is_none());
}
#[test]
fn the_refusal_speaks_every_agents_language() {
let reason = "protected".to_string();
let rendered = Decision::Deny { reason }.render().unwrap();
let value: Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["hookSpecificOutput"]["permissionDecision"], "deny");
assert_eq!(value["decision"], "deny");
assert!(value["reason"].is_string());
assert_eq!(value["permission"], "deny");
}
#[test]
fn every_agent_gets_a_hook_and_installing_twice_replaces_it() {
let dir = project("version: 1\nprotect:\n - .env\n");
let first = install_for(dir.path(), Agent::All, false).unwrap();
assert_eq!(first.len(), 9, "an agent was dropped from `--agent all`");
for installed in &first {
assert!(
installed.path.is_file(),
"{:?} was not written",
installed.path
);
assert!(!installed.replaced);
}
for installed in install_for(dir.path(), Agent::All, false).unwrap() {
assert!(
installed.replaced,
"{:?} was written twice instead of replaced",
installed.path
);
}
}
#[test]
fn install_creates_settings_and_is_idempotent() {
let dir = project("version: 1\nprotect:\n - .env\n");
let first = install(dir.path(), false).unwrap();
assert!(!first.replaced);
assert!(first.path.is_file());
let second = install(dir.path(), false).unwrap();
assert!(
second.replaced,
"a second install should replace, not stack"
);
let text = std::fs::read_to_string(&second.path).unwrap();
let value: Value = serde_json::from_str(&text).unwrap();
let list = value["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(list.len(), 1, "duplicated the hook: {text}");
}
#[test]
fn install_preserves_settings_it_did_not_write() {
let dir = project("version: 1\nprotect:\n - .env\n");
std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
std::fs::write(
dir.path().join(SETTINGS),
r#"{"model":"opus","hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]}}"#,
)
.unwrap();
install(dir.path(), false).unwrap();
let text = std::fs::read_to_string(dir.path().join(SETTINGS)).unwrap();
let value: Value = serde_json::from_str(&text).unwrap();
assert_eq!(value["model"], "opus", "dropped an unrelated setting");
let list = value["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(list.len(), 2, "dropped an unrelated hook: {text}");
}
#[test]
fn install_refuses_to_touch_unparseable_settings() {
let dir = project("version: 1\nprotect:\n - .env\n");
std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
std::fs::write(dir.path().join(SETTINGS), "{ not json").unwrap();
let error = install(dir.path(), false).unwrap_err().to_string();
assert!(error.contains("not valid JSON"), "{error}");
assert_eq!(
std::fs::read_to_string(dir.path().join(SETTINGS)).unwrap(),
"{ not json",
"modified a file it could not parse"
);
}
}