#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileOp {
Write,
Read,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileRuleMatch {
pub rule_name: &'static str,
pub reason: &'static str,
}
struct FileRule {
name: &'static str,
op: FileOp,
reason: &'static str,
matches: fn(path: &str, home: &str, file_name: &str) -> bool,
}
use super::sensitive::under;
const PROFILE_NAMES: &[&str] = &[
".zshenv",
".zshrc",
".zprofile",
".bashrc",
".bash_profile",
".profile",
];
fn rules() -> &'static [FileRule] {
&[
FileRule {
name: "file_write_shell_profile",
op: FileOp::Write,
reason: "Writing to a shell startup file (persistence, CVE-2026-55607 class)",
matches: |path, home, file_name| {
!home.is_empty()
&& PROFILE_NAMES.contains(&file_name)
&& path == format!("{home}/{file_name}")
},
},
FileRule {
name: "file_write_ssh_config",
op: FileOp::Write,
reason: "Writing under ~/.ssh (persistent access)",
matches: |path, home, _| under(path, &format!("{home}/.ssh")),
},
FileRule {
name: "file_write_git_hooks",
op: FileOp::Write,
reason: "Writing a git hook (persistence)",
matches: |path, _, _| path.contains("/.git/hooks/"),
},
FileRule {
name: "file_write_crontab_dir",
op: FileOp::Write,
reason: "Writing a cron file (persistence)",
matches: |path, _, _| {
path == "/etc/crontab"
|| path
.strip_prefix("/etc/cron.")
.is_some_and(|r| r.contains('/'))
|| under(path, "/var/spool/cron")
},
},
FileRule {
name: "file_write_launch_agents",
op: FileOp::Write,
reason: "Writing a LaunchAgent/LaunchDaemon (persistence)",
matches: |path, home, _| {
under(path, &format!("{home}/library/launchagents"))
|| under(path, "/library/launchagents")
|| under(path, "/library/launchdaemons")
},
},
FileRule {
name: "file_write_systemd_user",
op: FileOp::Write,
reason: "Writing a systemd user unit (persistence)",
matches: |path, home, _| under(path, &format!("{home}/.config/systemd/user")),
},
FileRule {
name: "file_write_agent_config",
op: FileOp::Write,
reason: "Writing to an AI agent config/hook file (possible hook injection)",
matches: |path, _, file_name| {
path.ends_with("/.claude/settings.json")
|| path.ends_with("/.claude/settings.local.json")
|| path.ends_with("/.cursor/hooks.json")
|| path.ends_with("/.codex/hooks.json")
|| path.ends_with("/.codex/config.toml")
|| path.ends_with("/.gemini/settings.json")
|| file_name == ".mcp.json"
},
},
FileRule {
name: "file_write_vallum",
op: FileOp::Write,
reason: "Writing to Vallum's own config/state directory (guardrail self-disable)",
matches: |path, home, _| under(path, &format!("{home}/.vallum")),
},
FileRule {
name: "file_read_sensitive",
op: FileOp::Read,
reason: "Reading a private key, credential file, or shadow password file",
matches: super::sensitive::is_hard_path,
},
]
}
fn normalize(raw: &str, home: &str) -> String {
let trimmed = raw.trim();
let mut p = if trimmed == "~" || trimmed == "$HOME" {
home.to_string()
} else if let Some(rest) = trimmed.strip_prefix("~/") {
format!("{home}/{rest}")
} else if let Some(rest) = trimmed.strip_prefix("$HOME/") {
format!("{home}/{rest}")
} else {
trimmed.to_string()
};
if !p.starts_with('/') {
if let Ok(cwd) = std::env::current_dir() {
p = format!("{}/{}", cwd.to_string_lossy(), p);
}
}
let mut out: Vec<&str> = Vec::new();
for seg in p.split('/') {
match seg {
"" | "." => {}
".." => {
out.pop();
}
s => out.push(s),
}
}
format!("/{}", out.join("/"))
}
pub fn evaluate(op: FileOp, raw_path: &str, disabled: &[String]) -> Option<FileRuleMatch> {
let home = dirs::home_dir()
.map(|h| h.to_string_lossy().into_owned())
.unwrap_or_default();
let path = normalize(raw_path, &home).to_ascii_lowercase();
let home = home.to_ascii_lowercase();
let file_name = path.rsplit('/').next().unwrap_or("");
rules()
.iter()
.filter(|r| r.op == op)
.filter(|r| !disabled.iter().any(|d| d == r.name))
.find(|r| (r.matches)(&path, &home, file_name))
.map(|r| FileRuleMatch {
rule_name: r.name,
reason: r.reason,
})
}
pub fn rule_names() -> Vec<&'static str> {
rules().iter().map(|r| r.name).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn home() -> String {
dirs::home_dir().unwrap().to_string_lossy().into_owned()
}
fn write_hit(path: &str) -> Option<&'static str> {
evaluate(FileOp::Write, path, &[]).map(|m| m.rule_name)
}
fn read_hit(path: &str) -> Option<&'static str> {
evaluate(FileOp::Read, path, &[]).map(|m| m.rule_name)
}
#[test]
fn matching_is_ascii_case_insensitive() {
assert_eq!(read_hit("~/.SSH/id_rsa"), Some("file_read_sensitive"));
assert_eq!(write_hit("~/.Zshenv"), Some("file_write_shell_profile"));
assert_eq!(
write_hit("~/.SSH/authorized_keys"),
Some("file_write_ssh_config")
);
assert_eq!(
write_hit("~/.VALLUM/config.toml"),
Some("file_write_vallum")
);
assert_eq!(
write_hit("/Users/x/proj/.GIT/hooks/pre-commit"),
Some("file_write_git_hooks")
);
assert_eq!(read_hit("~/.ssh/id_rsa.PUB"), None);
}
#[test]
fn tilde_and_home_var_expand() {
assert_eq!(write_hit("~/.zshenv"), Some("file_write_shell_profile"));
assert_eq!(write_hit("$HOME/.zshrc"), Some("file_write_shell_profile"));
assert_eq!(
write_hit(&format!("{}/.bashrc", home())),
Some("file_write_shell_profile")
);
}
#[test]
fn dotdot_traversal_is_resolved() {
assert_eq!(
write_hit(&format!("{}/project/../.zshenv", home())),
Some("file_write_shell_profile")
);
}
#[test]
fn profile_names_are_home_anchored() {
assert_eq!(write_hit(&format!("{}/proj/.zshrc", home())), None);
assert_eq!(write_hit("~/.zshrc.bak"), None);
}
#[test]
fn ssh_dir_writes_ask() {
assert_eq!(
write_hit("~/.ssh/authorized_keys"),
Some("file_write_ssh_config")
);
assert_eq!(write_hit("~/.ssh/config"), Some("file_write_ssh_config"));
assert_eq!(write_hit(&format!("{}/notssh/config", home())), None);
}
#[test]
fn git_hooks_anywhere() {
assert_eq!(
write_hit("/Users/x/proj/.git/hooks/pre-commit"),
Some("file_write_git_hooks")
);
assert_eq!(write_hit("/Users/x/proj/.github/workflows/ci.yml"), None);
}
#[test]
fn cron_paths() {
assert_eq!(write_hit("/etc/crontab"), Some("file_write_crontab_dir"));
assert_eq!(
write_hit("/etc/cron.d/backdoor"),
Some("file_write_crontab_dir")
);
assert_eq!(
write_hit("/var/spool/cron/crontabs/root"),
Some("file_write_crontab_dir")
);
assert_eq!(write_hit("/etc/cronicle/conf.json"), None);
}
#[test]
fn launch_agents_and_daemons() {
assert_eq!(
write_hit("~/Library/LaunchAgents/com.evil.plist"),
Some("file_write_launch_agents")
);
assert_eq!(
write_hit("/Library/LaunchDaemons/com.evil.plist"),
Some("file_write_launch_agents")
);
assert_eq!(write_hit("~/Library/Application Support/x.plist"), None);
}
#[test]
fn systemd_user_units() {
assert_eq!(
write_hit("~/.config/systemd/user/evil.service"),
Some("file_write_systemd_user")
);
assert_eq!(write_hit("~/.config/systemd/other.conf"), None);
}
#[test]
fn agent_configs() {
assert_eq!(
write_hit("/Users/x/proj/.claude/settings.json"),
Some("file_write_agent_config")
);
assert_eq!(
write_hit("~/.claude/settings.local.json"),
Some("file_write_agent_config")
);
assert_eq!(
write_hit("~/.codex/config.toml"),
Some("file_write_agent_config")
);
assert_eq!(
write_hit("/Users/x/proj/.mcp.json"),
Some("file_write_agent_config")
);
assert_eq!(write_hit("~/.claude/projects/foo.md"), None);
}
#[test]
fn vallum_dir_is_self_protected() {
assert_eq!(
write_hit("~/.vallum/config.toml"),
Some("file_write_vallum")
);
assert_eq!(
write_hit("~/.vallum/logs/policy.log"),
Some("file_write_vallum")
);
assert_eq!(
write_hit(&format!("{}/proj/.vallum-notes.md", home())),
None
);
}
#[test]
fn sensitive_reads() {
assert_eq!(read_hit("~/.ssh/id_rsa"), Some("file_read_sensitive"));
assert_eq!(read_hit("~/.ssh/id_ed25519"), Some("file_read_sensitive"));
assert_eq!(read_hit("~/.ssh/id_rsa.pub"), None);
assert_eq!(read_hit("~/.aws/credentials"), Some("file_read_sensitive"));
assert_eq!(read_hit("/etc/shadow"), Some("file_read_sensitive"));
assert_eq!(
read_hit("~/.vallum/logs/approval.secret"),
Some("file_read_sensitive")
);
assert_eq!(read_hit("~/.zshenv"), None);
assert_eq!(write_hit("/etc/shadow"), None);
}
#[test]
fn benign_everyday_paths_pass() {
for p in [
"/Users/x/proj/README.md",
"/Users/x/proj/src/main.rs",
"~/.config/git/ignore",
"~/Downloads/notes.txt",
"/tmp/scratch.json",
] {
assert_eq!(write_hit(p), None, "write {p}");
assert_eq!(read_hit(p), None, "read {p}");
}
}
#[test]
fn disabled_list_suppresses_a_rule() {
let disabled = vec!["file_write_shell_profile".to_string()];
assert!(evaluate(FileOp::Write, "~/.zshenv", &disabled).is_none());
assert!(evaluate(FileOp::Write, "~/.ssh/config", &disabled).is_some());
}
#[test]
fn widened_file_reads_ask() {
let h = home();
for p in [
format!("{h}/.netrc"),
format!("{h}/.git-credentials"),
format!("{h}/.claude/.credentials.json"),
format!("{h}/.codex/auth.json"),
format!("{h}/.gemini/oauth_creds.json"),
format!("{h}/.config/gh/hosts.yml"),
format!("{h}/.gnupg/secring.gpg"),
"/proc/self/environ".to_string(),
] {
assert_eq!(
read_hit(&p),
Some("file_read_sensitive"),
"{p} should be gated"
);
}
}
#[test]
fn egress_only_files_are_free_to_read_via_file_tool() {
let h = home();
for p in [
format!("{h}/proj/.env"),
format!("{h}/.npmrc"),
format!("{h}/.kube/config"),
] {
assert_eq!(read_hit(&p), None, "{p}");
}
}
#[test]
fn rule_names_lists_all_nine() {
let names = rule_names();
assert_eq!(names.len(), 9);
assert!(names.contains(&"file_write_vallum"));
assert!(names.contains(&"file_read_sensitive"));
}
}