#![cfg(target_os = "macos")]
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU32, Ordering};
const BINARY: &str = env!("CARGO_BIN_EXE_ralon");
const POLICY: &str = "version: 1\nprotect:\n - .env\n - config/**\n";
struct Project {
root: PathBuf,
}
impl Project {
fn new() -> Project {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let root = std::env::temp_dir().join(format!(
"ralon-flags-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(root.join("config")).unwrap();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("agent.lock"), POLICY).unwrap();
fs::write(root.join(".env"), "SECRET=original").unwrap();
fs::write(root.join("config/db.yaml"), "original").unwrap();
fs::write(root.join("src/App.tsx"), "original").unwrap();
Project {
root: fs::canonicalize(&root).unwrap(),
}
}
fn ralon(&self, arguments: &[&str]) -> Output {
Command::new(BINARY)
.arg("--dir")
.arg(&self.root)
.args(arguments)
.current_dir(&self.root)
.output()
.expect("failed to run ralon")
}
fn path(&self, relative: &str) -> PathBuf {
self.root.join(relative)
}
fn contents(&self, relative: &str) -> String {
fs::read_to_string(self.path(relative)).unwrap_or_default()
}
fn attack(&self, command: &str) {
let _ = Command::new("sh")
.args(["-c", command])
.current_dir(&self.root)
.output();
}
}
impl Drop for Project {
fn drop(&mut self) {
let _ = self.ralon(&["guard", "--stop"]);
let _ = Command::new("chflags")
.args(["-R", "nouchg"])
.arg(&self.root)
.output();
let _ = fs::remove_dir_all(&self.root);
}
}
fn flagged(path: &Path) -> bool {
let flags = Command::new("stat")
.args(["-f", "%Sf"])
.arg(path)
.output()
.expect("failed to run stat");
String::from_utf8_lossy(&flags.stdout)
.trim()
.split(',')
.any(|flag| flag == "uchg")
}
#[test]
fn a_guard_makes_the_protected_paths_immutable() {
let project = Project::new();
assert!(project.ralon(&["guard", "--detach"]).status.success());
assert!(flagged(&project.path(".env")), ".env carries no flag");
assert!(
flagged(&project.path("agent.lock")),
"the policy protects itself"
);
assert!(
flagged(&project.path("config")),
"the directory carries no flag"
);
assert!(
flagged(&project.path("config/db.yaml")),
"a file inside a protected directory carries no flag — the flag on the \
directory only governs its entries, not their contents"
);
assert!(
!flagged(&project.path("src/App.tsx")),
"an unprotected file was flagged"
);
}
#[test]
fn every_ordinary_write_is_refused() {
let project = Project::new();
project.ralon(&["guard", "--detach"]);
for attack in [
"echo pwned > .env",
"rm -f .env",
"mv .env .env.bak",
"cat src/App.tsx > .env",
"sed -i '' 's/original/pwned/' .env",
"printf x >> .env",
] {
project.attack(attack);
assert_eq!(
project.contents(".env"),
"SECRET=original",
"`{attack}` got through"
);
}
}
#[test]
fn a_protected_directory_refuses_new_entries() {
let project = Project::new();
project.ralon(&["guard", "--detach"]);
project.attack("echo x > config/slipped-in.yaml");
assert!(
!project.path("config/slipped-in.yaml").exists(),
"a new file was created inside a protected directory"
);
project.attack("echo x > src/allowed.tsx");
assert!(
project.path("src/allowed.tsx").exists(),
"an unprotected directory stopped accepting files"
);
}
#[test]
fn stopping_hands_everything_back() {
let project = Project::new();
project.ralon(&["guard", "--detach"]);
assert!(flagged(&project.path(".env")));
assert!(project.ralon(&["guard", "--stop"]).status.success());
assert!(!flagged(&project.path(".env")), "the flag was left behind");
assert!(
!flagged(&project.path("config")),
"the flag was left behind"
);
project.attack("echo released > .env");
assert_eq!(project.contents(".env").trim(), "released");
}
#[test]
fn starting_twice_is_the_same_as_starting_once() {
let project = Project::new();
assert!(project.ralon(&["guard", "--detach"]).status.success());
assert!(
project.ralon(&["guard", "--detach"]).status.success(),
"a second start failed instead of being a no-op"
);
project.ralon(&["guard", "--stop"]);
assert!(!flagged(&project.path(".env")));
}
#[test]
fn enforcement_outlives_the_process_that_applied_it() {
let project = Project::new();
project.ralon(&["guard", "--detach"]);
assert!(flagged(&project.path(".env")));
project.attack("echo pwned > .env");
assert_eq!(project.contents(".env"), "SECRET=original");
}
#[test]
fn an_agent_can_undo_it_which_is_why_this_is_not_a_sandbox() {
let project = Project::new();
project.ralon(&["guard", "--detach"]);
assert_eq!(project.contents(".env"), "SECRET=original");
project.attack("chflags nouchg .env && echo pwned > .env");
assert_eq!(
project.contents(".env").trim(),
"pwned",
"chflags nouchg no longer works — the threat model in security.md is out of date"
);
}
#[test]
fn a_protected_directory_cannot_itself_be_renamed() {
let project = Project::new();
project.ralon(&["guard", "--detach"]);
project.attack("mv config config-moved");
assert!(
!project.path("config-moved").exists(),
"a protected directory was renamed"
);
assert!(
project.path("config").is_dir(),
"a protected directory went missing"
);
}
#[test]
fn renaming_an_unprotected_ancestor_moves_the_path_out_from_under_the_policy() {
let project = Project::new();
fs::create_dir_all(project.path("src/deep")).unwrap();
fs::write(project.path("src/deep/secret.txt"), "original").unwrap();
fs::write(
project.path("agent.lock"),
"version: 1\nprotect:\n - src/deep/secret.txt\n",
)
.unwrap();
project.ralon(&["guard", "--detach"]);
assert!(flagged(&project.path("src/deep/secret.txt")));
assert!(
!flagged(&project.path("src/deep")),
"the ancestor was flagged, so this test is no longer about the gap it names"
);
project.attack("mv src/deep src/moved");
assert!(
project.path("src/moved").exists(),
"the ancestor rename was refused — if ancestors are pinned now, \
immutable.rs is out of date"
);
project.attack("echo pwned > src/moved/secret.txt");
assert_eq!(project.contents("src/moved/secret.txt"), "original");
}
#[test]
fn substituting_a_file_at_the_protected_path_is_the_limit_of_this_backend() {
let project = Project::new();
fs::create_dir_all(project.path("src/deep")).unwrap();
fs::write(project.path("src/deep/secret.txt"), "ORIGINAL").unwrap();
fs::write(
project.path("agent.lock"),
"version: 1\nprotect:\n - src/deep/secret.txt\n",
)
.unwrap();
project.ralon(&["guard", "--detach"]);
project
.attack("mv src/deep src/moved && mkdir -p src/deep && echo PWNED > src/deep/secret.txt");
assert_eq!(
project.contents("src/deep/secret.txt").trim(),
"PWNED",
"the ancestor substitution was refused — the backend is stronger than \
documented, and security.md now understates it"
);
assert_eq!(project.contents("src/moved/secret.txt"), "ORIGINAL");
}
#[test]
fn protecting_the_directory_closes_the_substitution() {
let project = Project::new();
fs::create_dir_all(project.path("src/deep")).unwrap();
fs::write(project.path("src/deep/secret.txt"), "ORIGINAL").unwrap();
fs::write(
project.path("agent.lock"),
"version: 1\nprotect:\n - src/deep\n",
)
.unwrap();
project.ralon(&["guard", "--detach"]);
project
.attack("mv src/deep src/moved && mkdir -p src/deep && echo PWNED > src/deep/secret.txt");
assert_eq!(
project.contents("src/deep/secret.txt"),
"ORIGINAL",
"protecting the directory did not stop the substitution, so the advice \
in audit.rs is wrong"
);
}
#[test]
fn a_policy_with_an_exposed_ancestor_says_so_before_the_agent_starts() {
let project = Project::new();
fs::create_dir_all(project.path("src/deep")).unwrap();
fs::write(project.path("src/deep/secret.txt"), "ORIGINAL").unwrap();
fs::write(
project.path("agent.lock"),
"version: 1\nprotect:\n - src/deep/secret.txt\n",
)
.unwrap();
let started = project.ralon(&["guard", "--detach"]);
let said = String::from_utf8_lossy(&started.stderr);
assert!(
said.contains("src/deep/secret.txt") && said.contains("`src/deep` is not"),
"the exposure was not reported: {said}"
);
assert!(
said.contains("Protect `src/deep` instead"),
"the warning did not say how to close it: {said}"
);
let clean = Project::new();
let quiet = clean.ralon(&["guard", "--detach"]);
assert!(
!String::from_utf8_lossy(&quiet.stderr).contains("instead of the file inside it"),
"warned about a policy that has no exposed ancestor"
);
}
#[test]
fn a_path_that_cannot_be_flagged_is_reported_and_never_silently_skipped() {
let project = Project::new();
fs::write(
project.path("agent.lock"),
"version: 1\nprotect:\n - .env\n - missing.txt\n",
)
.unwrap();
let started = project.ralon(&["guard", "--detach"]);
let said = String::from_utf8_lossy(&started.stderr);
assert!(
said.contains("matches nothing on disk") || said.contains("not protected"),
"an unenforceable entry was accepted without a word: {said}"
);
assert!(flagged(&project.path(".env")));
}
#[test]
fn a_guard_that_was_killed_leaves_the_flags_on_which_is_the_safe_direction() {
let project = Project::new();
project.ralon(&["guard", "--detach"]);
let status = project.ralon(&["status"]);
let said = String::from_utf8_lossy(&status.stdout);
assert!(said.contains("guard running"), "{said}");
}