use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU32, Ordering};
const BINARY: &str = env!("CARGO_BIN_EXE_ralon");
struct Project {
root: PathBuf,
}
impl Project {
fn new(policy: Option<&str>) -> Project {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let unique = format!(
"ralon-test-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
);
let root = std::env::temp_dir().join(unique);
fs::create_dir_all(&root).unwrap();
let project = Project { root };
if let Some(policy) = policy {
project.write("agent.lock", policy);
}
project
}
fn write(&self, relative: &str, contents: &str) -> PathBuf {
let path = self.root.join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, contents).unwrap();
path
}
fn run(&self, arguments: &[&str]) -> Output {
Command::new(BINARY)
.arg("--dir")
.arg(&self.root)
.args(arguments)
.current_dir(&self.root)
.output()
.expect("failed to run ralon")
}
#[cfg(windows)]
fn shell(&self, command: &str) {
Command::new("cmd")
.args(["/c", command])
.current_dir(&self.root)
.output()
.expect("failed to run cmd");
}
}
impl Drop for Project {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
fn code(output: &Output) -> i32 {
output.status.code().expect("process was killed")
}
const POLICY: &str = "version: 1\nprotect:\n - src/index.tsx\n - .env\n - config/**\n";
#[test]
fn init_writes_a_usable_policy_and_refuses_to_clobber_it() {
let project = Project::new(None);
let created = project.run(&["init"]);
assert_eq!(code(&created), 0, "{}", stderr(&created));
assert!(project.root.join("agent.lock").is_file());
let again = project.run(&["init"]);
assert_eq!(code(&again), 2);
assert!(
stderr(&again).contains("already exists"),
"{}",
stderr(&again)
);
let status = project.run(&["status"]);
assert_eq!(code(&status), 0, "{}", stderr(&status));
}
#[test]
fn check_reports_protected_paths_and_exits_nonzero() {
let project = Project::new(Some(POLICY));
let protected = project.run(&["check", "src/index.tsx"]);
assert_eq!(code(&protected), 1);
assert!(
stdout(&protected).contains("locked"),
"{}",
stdout(&protected)
);
let writable = project.run(&["check", "src/App.tsx"]);
assert_eq!(code(&writable), 0);
assert!(stdout(&writable).contains("writable"));
}
#[test]
fn check_protects_the_policy_file_itself() {
let project = Project::new(Some(POLICY));
let output = project.run(&["check", "agent.lock"]);
assert_eq!(code(&output), 1, "{}", stdout(&output));
}
#[test]
fn check_covers_paths_inside_a_protected_directory() {
let project = Project::new(Some(POLICY));
let output = project.run(&["check", "config/deep/db.yaml", "src/App.tsx"]);
assert_eq!(code(&output), 1);
let text = stdout(&output);
assert!(text.contains("locked config/deep/db.yaml"), "{text}");
assert!(text.contains("writable src/App.tsx"), "{text}");
}
#[test]
fn check_notices_paths_outside_the_project() {
let project = Project::new(Some(POLICY));
let output = project.run(&["check", "../elsewhere.txt"]);
assert_eq!(code(&output), 0);
assert!(stdout(&output).contains("outside"), "{}", stdout(&output));
}
#[test]
fn check_without_arguments_lists_what_exists() {
let project = Project::new(Some(POLICY));
project.write("src/index.tsx", "locked\n");
project.write("src/App.tsx", "writable\n");
project.write("config/db.yaml", "locked\n");
let output = project.run(&["check"]);
let text = stdout(&output);
assert_eq!(code(&output), 0, "{}", stderr(&output));
assert!(text.contains("agent.lock"), "{text}");
assert!(text.contains("src/index.tsx"), "{text}");
assert!(text.contains("config/"), "{text}");
assert!(!text.contains("config/db.yaml"), "{text}");
assert!(!text.contains("App.tsx"), "{text}");
assert!(
stderr(&output).contains("`.env` matches nothing"),
"{}",
stderr(&output)
);
}
#[test]
fn commands_find_the_policy_from_a_subdirectory() {
let project = Project::new(Some(POLICY));
project.write("src/deep/nested.txt", "x\n");
let output = Command::new(BINARY)
.arg("--dir")
.arg(project.root.join("src").join("deep"))
.args(["check", "../index.tsx"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1), "{}", stdout(&output));
}
#[test]
fn missing_policy_is_an_error_not_a_silent_pass() {
let project = Project::new(None);
let output = project.run(&["check", "anything.txt"]);
assert_eq!(code(&output), 2);
assert!(
stderr(&output).contains("no agent.lock"),
"{}",
stderr(&output)
);
}
#[test]
fn a_broken_policy_stops_everything() {
let project = Project::new(Some("version: 1\nprotect:\n - ../escape\n"));
let output = project.run(&["check", "src/App.tsx"]);
assert_eq!(code(&output), 2);
assert!(stderr(&output).contains(".."), "{}", stderr(&output));
}
#[test]
fn dry_run_describes_what_would_be_locked() {
let project = Project::new(Some(POLICY));
project.write("src/index.tsx", "locked\n");
project.write("config/db.yaml", "locked\n");
let output = project.run(&["run", "--dry-run", "--", "echo", "hello"]);
let text = stdout(&output);
assert!(text.contains("read-only src/index.tsx"), "{text}");
assert!(text.contains("read-only config/"), "{text}");
assert!(text.contains("read-only agent.lock"), "{text}");
assert!(text.contains("echo hello"), "{text}");
match code(&output) {
0 => assert!(!text.contains("would fail"), "{text}"),
1 => assert!(text.contains("would fail"), "{text}"),
other => panic!("unexpected exit code {other}\n{text}\n{}", stderr(&output)),
}
}
#[test]
fn status_lists_backends() {
let project = Project::new(Some(POLICY));
let output = project.run(&["status"]);
let text = stdout(&output);
assert_eq!(code(&output), 0, "{}", stderr(&output));
assert!(text.contains("backends"), "{text}");
assert!(text.contains("mount"), "{text}");
assert!(text.contains("landlock"), "{text}");
assert!(text.contains("version 1"), "{text}");
}
#[test]
#[cfg(windows)]
fn windows_locks_stop_a_write_from_any_process() {
let project = Project::new(Some(POLICY));
let secret = project.write(".env", "SECRET=original\n");
project.write("src/App.tsx", "writable\n");
let blocked = project.run(&["run", "--quiet", "--", "cmd", "/c", "echo hacked > .env"]);
assert_ne!(code(&blocked), 0, "the write should have failed");
assert_eq!(
fs::read_to_string(&secret).unwrap(),
"SECRET=original\n",
"a protected file was modified"
);
project.run(&["run", "--quiet", "--", "cmd", "/c", "del /q .env"]);
assert!(secret.is_file(), "a protected file was deleted");
assert_eq!(fs::read_to_string(&secret).unwrap(), "SECRET=original\n");
project.run(&["run", "--quiet", "--", "cmd", "/c", "ren .env moved.txt"]);
assert!(secret.is_file(), "a protected file was renamed away");
let allowed = project.run(&[
"run",
"--quiet",
"--",
"cmd",
"/c",
"echo edited > src\\App.tsx",
]);
assert_eq!(code(&allowed), 0, "{}", stderr(&allowed));
assert!(fs::read_to_string(project.root.join("src/App.tsx"))
.unwrap()
.contains("edited"));
}
#[test]
#[cfg(windows)]
fn windows_refuses_new_files_in_a_protected_directory() {
let project = Project::new(Some(POLICY));
project.write("config/db.yaml", "locked\n");
for attack in [
"echo hacked > config\\new.yaml",
"mkdir config\\sneaky",
"echo hacked > config\\nested\\deep.yaml",
] {
project.run(&["run", "--quiet", "--", "cmd", "/c", attack]);
}
assert!(
!project.root.join("config/new.yaml").exists(),
"a new file appeared inside a protected directory"
);
assert!(!project.root.join("config/sneaky").exists());
assert!(!project.root.join("config/nested").exists());
project.run(&[
"run",
"--quiet",
"--",
"cmd",
"/c",
"ren config\\db.yaml x.yaml",
]);
assert!(project.root.join("config/db.yaml").is_file());
fs::write(project.root.join("config/after.yaml"), "fine")
.expect("the ACL should have been restored when the command finished");
}
#[test]
#[cfg(windows)]
fn a_file_already_in_use_is_reported_before_it_becomes_a_failure() {
use std::os::windows::fs::OpenOptionsExt;
const FILE_SHARE_READ: u32 = 0x0000_0001;
let project = Project::new(Some("version: 1\nprotect:\n - app.db\n"));
let database = project.write("app.db", "rows\n");
let quiet = stderr(&project.run(&["status"]));
assert!(!quiet.contains("app.db is held open"), "{quiet}");
let holder = fs::OpenOptions::new()
.read(true)
.write(true)
.share_mode(FILE_SHARE_READ)
.open(&database)
.unwrap();
let warned = stderr(&project.run(&["status"]));
assert!(warned.contains("app.db is held open"), "{warned}");
let refused = project.run(&["run", "--quiet", "--", "cmd.exe", "/c", "ver"]);
assert_eq!(code(&refused), 2, "{}", stderr(&refused));
drop(holder);
let quiet_again = stderr(&project.run(&["status"]));
assert!(
!quiet_again.contains("app.db is held open"),
"{quiet_again}"
);
}
#[test]
#[cfg(windows)]
fn windows_guard_protects_a_process_it_did_not_start() {
let project = Project::new(Some(POLICY));
let secret = project.write(".env", "SECRET=original\n");
project.write("src/App.tsx", "writable\n");
project.shell("echo unguarded > .env");
assert!(fs::read_to_string(&secret).unwrap().contains("unguarded"));
fs::write(&secret, "SECRET=original\n").unwrap();
let started = project.run(&["guard", "--detach"]);
assert_eq!(code(&started), 0, "{}", stderr(&started));
let status = stdout(&project.run(&["status"]));
assert!(status.contains("guard running"), "{status}");
project.shell("echo hacked > .env");
project.shell("del /q .env");
project.shell("echo x > config\\new.yaml");
let held = fs::read_to_string(&secret).unwrap() == "SECRET=original\n"
&& !project.root.join("config/new.yaml").exists();
let stopped = project.run(&["guard", "--stop"]);
assert!(held, "a guarded path was modified by an unwrapped process");
assert_eq!(code(&stopped), 0, "{}", stderr(&stopped));
project.shell("echo released > .env");
assert!(fs::read_to_string(&secret).unwrap().contains("released"));
}
#[test]
fn run_refuses_rather_than_running_unprotected_when_the_backend_is_unavailable() {
let project = Project::new(Some(POLICY));
project.write("src/index.tsx", "locked\n");
let elsewhere = if cfg!(target_os = "linux") {
"locks"
} else {
"mount"
};
let marker = project.root.join("should-not-exist.txt");
let (shell, flag, script) = if cfg!(windows) {
("cmd", "/c", format!("type nul > {}", marker.display()))
} else {
("sh", "-c", format!("touch '{}'", marker.display()))
};
let output = project.run(&["run", "--backend", elsewhere, "--", shell, flag, &script]);
assert_eq!(code(&output), 2, "{}", stdout(&output));
let explanation = stderr(&output);
assert!(explanation.contains("unavailable"), "{explanation}");
assert!(!marker.exists(), "the command must not have run");
}
#[test]
fn hook_install_writes_a_hook_that_refuses_protected_paths() {
let project = Project::new(Some(POLICY));
let installed = project.run(&["hook", "install"]);
assert_eq!(code(&installed), 0, "{}", stderr(&installed));
let settings = fs::read_to_string(project.root.join(".claude/settings.json")).unwrap();
assert!(settings.contains("ralon hook check"), "{settings}");
assert!(settings.contains("PreToolUse"), "{settings}");
assert!(!settings.contains("Bash"), "{settings}");
}
#[test]
fn the_installed_hook_denies_and_allows_the_right_paths() {
let project = Project::new(Some(POLICY));
for (relative, expected_deny) in [(".env", true), ("src/App.tsx", false)] {
let request = format!(
r#"{{"tool_name":"Write","tool_input":{{"file_path":{}}}}}"#,
serde_json_string(&project.root.join(relative).to_string_lossy()),
);
let mut child = Command::new(BINARY)
.arg("--dir")
.arg(&project.root)
.args(["hook", "check"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
use std::io::Write as _;
child
.stdin
.take()
.unwrap()
.write_all(request.as_bytes())
.unwrap();
let output = child.wait_with_output().unwrap();
let text = stdout(&output);
assert_eq!(
code(&output),
if expected_deny { 2 } else { 0 },
"{relative}: {text}{}",
stderr(&output)
);
assert_eq!(
text.contains("\"permission\":\"deny\""),
expected_deny,
"Cursor's key is missing: {text}"
);
assert_eq!(
text.contains("\"permissionDecision\":\"deny\""),
expected_deny,
"{relative} produced: {text}"
);
}
}
fn serde_json_string(value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
#[test]
fn the_binary_reports_a_version() {
let output = Command::new(BINARY).arg("--version").output().unwrap();
assert!(String::from_utf8_lossy(&output.stdout).contains(env!("CARGO_PKG_VERSION")));
}