use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::{OnceLock, PoisonError, RwLock};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Act {
Read,
Write,
Exec,
Net,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Effect {
Allow,
Ask,
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rule {
pub act: Act,
pub effect: Effect,
pub pattern: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Layer {
pub name: String,
pub rules: Vec<Rule>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Defaults {
pub read: Effect,
pub write: Effect,
pub exec: Effect,
#[serde(default = "deny")]
pub net: Effect,
}
fn deny() -> Effect {
Effect::Deny
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verdict {
pub effect: Effect,
pub rule: Option<String>,
pub layer: Option<String>,
}
const SECRET_PATTERNS: &[&str] = &[".env", "*.pem", "id_rsa", "id_ed25519", "*.key"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Policy {
pub layers: Vec<Layer>,
pub defaults: Defaults,
}
impl Default for Policy {
fn default() -> Self {
let mut rules = Vec::new();
for pat in SECRET_PATTERNS {
for act in [Act::Read, Act::Write] {
rules.push(Rule {
act,
effect: Effect::Deny,
pattern: (*pat).to_string(),
});
}
}
let exec = Layer {
name: "builtin-exec".into(),
rules: ["rustc", "<test-binary>"]
.iter()
.map(|p| Rule {
act: Act::Exec,
effect: Effect::Allow,
pattern: (*p).to_string(),
})
.collect(),
};
Self {
layers: vec![
Layer {
name: "builtin-secrets".into(),
rules,
},
exec,
],
defaults: Defaults {
read: Effect::Allow,
write: Effect::Ask,
exec: Effect::Ask,
net: Effect::Deny,
},
}
}
}
impl Policy {
pub fn permissive() -> Self {
Self {
layers: Vec::new(),
defaults: Defaults {
read: Effect::Allow,
write: Effect::Allow,
exec: Effect::Allow,
net: Effect::Allow,
},
}
}
pub fn is_permissive(&self) -> bool {
self.layers.iter().all(|l| l.rules.is_empty())
&& self.defaults.read == Effect::Allow
&& self.defaults.write == Effect::Allow
&& self.defaults.exec == Effect::Allow
&& self.defaults.net == Effect::Allow
}
pub fn layer(mut self, name: impl Into<String>) -> Self {
self.layers.push(Layer {
name: name.into(),
rules: Vec::new(),
});
self
}
pub fn rule(mut self, act: Act, effect: Effect, pattern: impl Into<String>) -> Self {
if self.layers.is_empty() {
self = self.layer("policy");
}
let pattern = pattern.into();
let layer = self.layers.last_mut().expect("a layer exists");
layer.rules.push(Rule {
act,
effect,
pattern,
});
self
}
pub fn allow_read(self, pattern: impl Into<String>) -> Self {
self.rule(Act::Read, Effect::Allow, pattern)
}
pub fn deny_read(self, pattern: impl Into<String>) -> Self {
self.rule(Act::Read, Effect::Deny, pattern)
}
pub fn allow_write(self, pattern: impl Into<String>) -> Self {
self.rule(Act::Write, Effect::Allow, pattern)
}
pub fn deny_write(self, pattern: impl Into<String>) -> Self {
self.rule(Act::Write, Effect::Deny, pattern)
}
pub fn ask_write(self, pattern: impl Into<String>) -> Self {
self.rule(Act::Write, Effect::Ask, pattern)
}
pub fn allow_exec(self, pattern: impl Into<String>) -> Self {
self.rule(Act::Exec, Effect::Allow, pattern)
}
pub fn deny_exec(self, pattern: impl Into<String>) -> Self {
self.rule(Act::Exec, Effect::Deny, pattern)
}
pub fn allow_net(self, pattern: impl Into<String>) -> Self {
self.rule(Act::Net, Effect::Allow, pattern)
}
pub fn deny_net(self, pattern: impl Into<String>) -> Self {
self.rule(Act::Net, Effect::Deny, pattern)
}
pub fn ask_net(self, pattern: impl Into<String>) -> Self {
self.rule(Act::Net, Effect::Ask, pattern)
}
pub fn merge(mut self, overlay: Policy) -> Self {
self.layers.extend(overlay.layers);
self.defaults = Defaults {
read: self.defaults.read.max(overlay.defaults.read),
write: self.defaults.write.max(overlay.defaults.write),
exec: self.defaults.exec.max(overlay.defaults.exec),
net: self.defaults.net.max(overlay.defaults.net),
};
self
}
pub fn contain(&self, child: &Policy) -> Policy {
let mut layers = self.layers.clone();
for l in &child.layers {
let rules: Vec<Rule> = l
.rules
.iter()
.filter(|r| r.effect != Effect::Allow)
.cloned()
.collect();
if !rules.is_empty() {
layers.push(Layer {
name: l.name.clone(),
rules,
});
}
}
Policy {
layers,
defaults: Defaults {
read: self.defaults.read.max(child.defaults.read),
write: self.defaults.write.max(child.defaults.write),
exec: self.defaults.exec.max(child.defaults.exec),
net: self.defaults.net.max(child.defaults.net),
},
}
}
pub fn explain(&self, act: Act, target: &str) -> Verdict {
let forms: Vec<&str> = match (act, target.rsplit_once(':')) {
(Act::Net, Some((host, port)))
if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) =>
{
vec![target, host]
}
_ => vec![target],
};
for effect in [Effect::Deny, Effect::Ask, Effect::Allow] {
for layer in &self.layers {
for rule in &layer.rules {
if rule.act == act
&& rule.effect == effect
&& forms.iter().any(|t| matches(act, &rule.pattern, t))
{
return Verdict {
effect,
rule: Some(rule.pattern.clone()),
layer: Some(layer.name.clone()),
};
}
}
}
}
Verdict {
effect: match act {
Act::Read => self.defaults.read,
Act::Write => self.defaults.write,
Act::Exec => self.defaults.exec,
Act::Net => self.defaults.net,
},
rule: None,
layer: None,
}
}
pub fn check(&self, act: Act, target: &str) -> Verdict {
self.explain(act, target)
}
}
#[cfg(windows)]
fn normalise_path(s: &str) -> Cow<'_, str> {
let stripped = match s.strip_prefix(r"\\?\UNC\") {
Some(rest) => Cow::Owned(format!(r"\\{rest}")),
None => Cow::Borrowed(s.strip_prefix(r"\\?\").unwrap_or(s)),
};
match stripped.contains('\\') {
true => Cow::Owned(stripped.replace('\\', "/")),
false => stripped,
}
}
#[cfg(not(windows))]
fn normalise_path(s: &str) -> Cow<'_, str> {
Cow::Borrowed(s)
}
static GLOBS: OnceLock<RwLock<HashMap<String, Option<regex::Regex>>>> = OnceLock::new();
fn compiled(pattern: &str) -> Option<regex::Regex> {
let cache = GLOBS.get_or_init(Default::default);
if let Some(hit) = cache
.read()
.unwrap_or_else(PoisonError::into_inner)
.get(pattern)
{
return hit.clone();
}
let re = glob_to_regex(pattern).ok();
cache
.write()
.unwrap_or_else(PoisonError::into_inner)
.insert(pattern.to_string(), re.clone());
re
}
fn matches(act: Act, pattern: &str, target: &str) -> bool {
let (pattern, target) = match act {
Act::Read | Act::Write => (normalise_path(pattern), normalise_path(target)),
Act::Exec | Act::Net => (Cow::Borrowed(pattern), Cow::Borrowed(target)),
};
let Some(re) = compiled(&pattern) else {
return false; };
if re.is_match(&target) {
return true;
}
match target.rsplit('/').next() {
Some(base) if base != target => re.is_match(base),
_ => false,
}
}
fn glob_to_regex(glob: &str) -> Result<regex::Regex> {
let mut re = String::from("(?s)^");
for ch in glob.chars() {
match ch {
'*' => re.push_str(".*"),
'?' => re.push('.'),
c => re.push_str(®ex::escape(&c.to_string())),
}
}
re.push('$');
regex::Regex::new(&re).map_err(|e| Error::Config(format!("bad policy glob: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Policy {
Policy::default()
.layer("base")
.allow_read("src/*")
.allow_write("src/*")
.deny_read("secrets/*")
.deny_write("secrets/*")
}
#[test]
fn deny_beats_allow_even_when_both_match() {
let p = Policy::default()
.layer("l")
.allow_write("src/*")
.deny_write("src/generated/*");
assert_eq!(p.check(Act::Write, "src/a.rs").effect, Effect::Allow);
assert_eq!(
p.check(Act::Write, "src/generated/x.rs").effect,
Effect::Deny
);
}
#[test]
fn default_policy_denies_dotenv_read_inside_a_readable_tree() {
let p = Policy::default().layer("l").allow_read("*");
assert_eq!(p.check(Act::Read, "src/a.rs").effect, Effect::Allow);
assert_eq!(p.check(Act::Read, ".env").effect, Effect::Deny);
assert_eq!(p.check(Act::Read, "config/.env").effect, Effect::Deny);
assert_eq!(p.check(Act::Read, "keys/id_rsa").effect, Effect::Deny);
}
#[test]
fn serde_roundtrip_enforces_identically() {
let p = base();
let json = serde_json::to_string(&p).unwrap();
let back: Policy = serde_json::from_str(&json).unwrap();
for (act, path) in [
(Act::Write, "src/a.rs"),
(Act::Write, "secrets/key.txt"),
(Act::Read, "secrets/key.txt"),
(Act::Read, ".env"),
(Act::Exec, "rustc"),
] {
assert_eq!(
p.check(act, path).effect,
back.check(act, path).effect,
"{act:?} {path}"
);
}
}
#[test]
fn an_overlay_cannot_reallow_what_the_base_denied() {
let overlay = Policy::default().layer("app").allow_write("secrets/*");
let merged = base().merge(overlay);
assert_eq!(
merged.check(Act::Write, "secrets/key.txt").effect,
Effect::Deny
);
}
#[test]
fn an_overlay_only_allow_grants_capability_the_base_lacked() {
let overlay = Policy::default().layer("app").allow_write("docs/*");
let merged = base().merge(overlay);
assert_eq!(merged.check(Act::Write, "docs/x.md").effect, Effect::Allow);
assert_eq!(merged.check(Act::Write, "src/a.rs").effect, Effect::Allow);
}
#[test]
fn merging_is_deterministic_and_merging_a_layer_with_itself_changes_nothing() {
let a = base();
let probes = [
(Act::Write, "src/a.rs"),
(Act::Write, "secrets/k"),
(Act::Read, "docs/x"),
];
let once = a.clone().merge(base());
let twice = a.clone().merge(base()).merge(base());
for (act, path) in probes {
assert_eq!(a.check(act, path).effect, once.check(act, path).effect);
assert_eq!(once.check(act, path).effect, twice.check(act, path).effect);
}
}
#[test]
fn an_empty_overlay_leaves_the_base_unchanged() {
let merged = base().merge(Policy::default());
for (act, path) in [(Act::Write, "src/a.rs"), (Act::Write, "secrets/k")] {
assert_eq!(
base().check(act, path).effect,
merged.check(act, path).effect
);
}
}
#[test]
fn explain_names_the_rule_and_the_layer_that_decided() {
let merged = base().merge(Policy::default().layer("app").allow_write("docs/*"));
let denied = merged.explain(Act::Write, "secrets/key.txt");
assert_eq!(denied.effect, Effect::Deny);
assert_eq!(denied.layer.as_deref(), Some("base"));
assert_eq!(denied.rule.as_deref(), Some("secrets/*"));
let allowed = merged.explain(Act::Write, "docs/x.md");
assert_eq!(allowed.effect, Effect::Allow);
assert_eq!(allowed.layer.as_deref(), Some("app"));
let fallback = merged.explain(Act::Write, "elsewhere/x");
assert_eq!(fallback.effect, Effect::Ask);
assert_eq!(fallback.layer, None);
}
#[test]
fn a_child_overlay_allow_cannot_reach_a_path_the_parent_denies() {
let parent = base(); let child = Policy::permissive()
.layer("child")
.allow_read("secrets/*")
.allow_write("secrets/*");
let contained = parent.contain(&child);
assert_eq!(
contained.check(Act::Write, "secrets/key.txt").effect,
Effect::Deny
);
assert_eq!(
contained.check(Act::Read, "secrets/key.txt").effect,
Effect::Deny
);
}
#[test]
fn a_child_overlay_allow_cannot_widen_a_parent_default() {
let parent = Policy::default().layer("parent").allow_write("src/*");
let child = Policy::permissive().layer("child").allow_write("docs/*");
assert_eq!(
parent
.clone()
.merge(child.clone())
.check(Act::Write, "docs/x.md")
.effect,
Effect::Allow,
"merge widens (0.4.0 behaviour)"
);
assert_eq!(
parent.contain(&child).check(Act::Write, "docs/x.md").effect,
Effect::Ask,
"contain does not widen"
);
}
#[test]
fn a_child_overlay_deny_narrows_the_parent() {
let parent = Policy::default().layer("parent").allow_write("src/*");
let child = Policy::permissive()
.layer("child")
.deny_write("src/generated/*");
let contained = parent.contain(&child);
assert_eq!(
contained.check(Act::Write, "src/a.rs").effect,
Effect::Allow
);
assert_eq!(
contained.check(Act::Write, "src/generated/x.rs").effect,
Effect::Deny
);
}
#[test]
fn containment_holds_downward_through_depth() {
let root = base(); let child = Policy::permissive()
.layer("child")
.deny_write("src/vendor/*");
let grandchild = Policy::permissive()
.layer("grandchild")
.allow_write("secrets/*") .allow_write("docs/*"); let effective = root.contain(&child).contain(&grandchild);
assert_eq!(
effective.check(Act::Write, "secrets/key.txt").effect,
Effect::Deny
);
assert_eq!(effective.check(Act::Write, "docs/x.md").effect, Effect::Ask);
assert_eq!(
effective.check(Act::Write, "src/vendor/x.rs").effect,
Effect::Deny
);
assert_eq!(
effective.check(Act::Write, "src/a.rs").effect,
Effect::Allow
);
}
#[test]
fn net_is_denied_by_default_and_allowed_only_where_a_rule_says_so() {
let p = Policy::default()
.layer("egress")
.allow_net("api.example.com");
assert_eq!(
p.check(Act::Net, "api.example.com:443").effect,
Effect::Allow
);
assert_eq!(
p.check(Act::Net, "evil.example.com:443").effect,
Effect::Deny
);
let tighter = p.layer("lockdown").deny_net("api.example.com");
assert_eq!(
tighter.check(Act::Net, "api.example.com:443").effect,
Effect::Deny
);
}
#[test]
fn a_net_rule_matches_with_or_without_the_port() {
let bare = Policy::default().layer("l").allow_net("api.example.com");
assert_eq!(
bare.check(Act::Net, "api.example.com:443").effect,
Effect::Allow
);
assert_eq!(
bare.check(Act::Net, "api.example.com:8080").effect,
Effect::Allow
);
let ported = Policy::default()
.layer("l")
.allow_net("api.example.com:443");
assert_eq!(
ported.check(Act::Net, "api.example.com:443").effect,
Effect::Allow
);
assert_eq!(
ported.check(Act::Net, "api.example.com:8080").effect,
Effect::Deny
);
let wild = Policy::default().layer("l").allow_net("*.example.com");
assert_eq!(
wild.check(Act::Net, "api.example.com:443").effect,
Effect::Allow
);
assert_eq!(wild.check(Act::Net, "example.org:443").effect, Effect::Deny);
}
#[test]
fn net_narrows_downward_and_never_widens() {
let root = Policy::default().layer("root").allow_net("api.example.com");
let child = Policy::permissive()
.layer("child")
.allow_net("evil.example.com") .deny_net("api.example.com"); let effective = root.contain(&child);
assert_eq!(
effective.check(Act::Net, "evil.example.com:443").effect,
Effect::Deny
);
assert_eq!(
effective.check(Act::Net, "api.example.com:443").effect,
Effect::Deny
);
}
#[test]
fn a_pre_0_8_policy_deserialises_with_network_denied() {
let old = r#"{"layers":[],"defaults":{"read":"allow","write":"ask","exec":"ask"}}"#;
let p: Policy = serde_json::from_str(old).unwrap();
assert_eq!(p.defaults.net, Effect::Deny);
assert_eq!(
p.check(Act::Net, "anywhere.example.com:443").effect,
Effect::Deny
);
}
#[test]
fn net_rules_survive_a_serde_roundtrip() {
let p = Policy::default()
.layer("egress")
.allow_net("api.example.com")
.deny_net("evil.example.com")
.ask_net("maybe.example.com");
let back: Policy = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
for host in [
"api.example.com:443",
"evil.example.com:443",
"maybe.example.com:443",
] {
assert_eq!(p.check(Act::Net, host), back.check(Act::Net, host));
}
}
#[test]
fn permissive_still_enforces_nothing_including_the_network() {
let p = Policy::permissive();
assert!(p.is_permissive());
assert_eq!(
p.check(Act::Net, "anywhere.example.com:443").effect,
Effect::Allow
);
}
#[test]
fn the_glob_cache_cannot_make_one_pattern_answer_another_pattern_s_question() {
let p = Policy::permissive()
.layer("l")
.deny_read("secrets/*")
.allow_read("secrets-public/*")
.deny_read("a?.txt")
.deny_read("build/*/out")
.allow_read("build/x/out.bak");
let probes = [
("secrets/k", Effect::Deny),
("secrets-public/k", Effect::Allow),
("ab.txt", Effect::Deny),
("abc.txt", Effect::Allow), ("build/x/out", Effect::Deny),
("build/x/out.bak", Effect::Allow),
];
for pass in 0..2 {
for (path, want) in probes {
assert_eq!(p.check(Act::Read, path).effect, want, "pass {pass}: {path}");
}
}
let allow_first = Policy::permissive()
.layer("a")
.allow_exec("tool")
.layer("b")
.deny_exec("tool");
let deny_first = Policy::permissive()
.layer("b")
.deny_exec("tool")
.layer("a")
.allow_exec("tool");
for p in [&allow_first, &deny_first] {
let v = p.check(Act::Exec, "tool");
assert_eq!(v.effect, Effect::Deny);
assert_eq!(v.layer.as_deref(), Some("b"));
}
let alone = Policy::permissive().layer("a").allow_exec("tool");
assert_eq!(alone.check(Act::Exec, "tool").effect, Effect::Allow);
let parent = Policy::permissive().layer("parent").deny_write("shared/*");
let child = Policy::permissive().layer("child").allow_write("shared/*");
assert_eq!(
parent.contain(&child).check(Act::Write, "shared/f").effect,
Effect::Deny
);
assert_eq!(child.check(Act::Write, "shared/f").effect, Effect::Allow);
}
#[cfg(windows)]
#[test]
fn a_path_rule_matches_whichever_separator_either_side_spelled() {
let p = Policy::permissive()
.layer("base")
.deny_read(r"\\?\C:\Users\me\skills/*");
assert_eq!(
p.check(Act::Read, r"\\?\C:\Users\me\skills\beta\SKILL.md")
.effect,
Effect::Deny
);
let plain = Policy::permissive().layer("base").deny_read("C:/secrets/*");
assert_eq!(
plain.check(Act::Read, r"\\?\C:\secrets\token.txt").effect,
Effect::Deny
);
let backslash = Policy::permissive()
.layer("base")
.deny_write(r"C:\secrets\*");
assert_eq!(
backslash.check(Act::Write, r"C:\secrets\token.txt").effect,
Effect::Deny
);
assert_eq!(
backslash.check(Act::Write, "C:/secrets/token.txt").effect,
Effect::Deny
);
}
#[cfg(windows)]
#[test]
fn a_unc_share_matches_with_or_without_the_verbatim_prefix() {
let p = Policy::permissive()
.layer("base")
.deny_read(r"\\srv\share\*");
assert_eq!(
p.check(Act::Read, r"\\?\UNC\srv\share\secret.txt").effect,
Effect::Deny
);
}
#[cfg(windows)]
#[test]
fn normalising_separators_does_not_make_two_different_paths_match() {
let p = Policy::permissive()
.layer("base")
.deny_read(r"C:\secrets\*")
.deny_read(r"C:\a\b.txt");
for allowed in [
r"C:\secrets-public\k", r"D:\secrets\k", r"C:\other\secrets.txt", r"C:\a\b.txt.bak", r"C:\ab.txt", ] {
assert_eq!(
p.check(Act::Read, allowed).effect,
Effect::Allow,
"{allowed} must not be caught by a rule for a different path"
);
}
let names = Policy::permissive()
.layer("base")
.deny_exec(r"tools\build.exe")
.deny_net(r"a\b.example.com");
assert_eq!(
names.check(Act::Exec, "tools/build.exe").effect,
Effect::Allow
);
assert_eq!(
names.check(Act::Exec, r"tools\build.exe").effect,
Effect::Deny
);
assert_eq!(
names.check(Act::Net, "a/b.example.com:443").effect,
Effect::Allow
);
}
#[cfg(not(windows))]
#[test]
fn a_backslash_is_an_ordinary_filename_character_on_unix() {
let p = Policy::permissive().layer("base").deny_read(r"a/b.txt");
assert_eq!(p.check(Act::Read, r"a\b.txt").effect, Effect::Allow);
assert_eq!(p.check(Act::Read, "a/b.txt").effect, Effect::Deny);
let literal = Policy::permissive().layer("base").deny_read(r"a\b.txt");
assert_eq!(literal.check(Act::Read, r"a\b.txt").effect, Effect::Deny);
assert_eq!(literal.check(Act::Read, "a/b.txt").effect, Effect::Allow);
}
#[test]
fn explain_and_check_never_disagree() {
let p = base();
for (act, path) in [
(Act::Read, "src/a.rs"),
(Act::Write, "secrets/k"),
(Act::Write, "elsewhere/x"),
(Act::Read, ".env"),
(Act::Exec, "rustc"),
] {
assert_eq!(p.check(act, path).effect, p.explain(act, path).effect);
}
}
}