diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83afb50..b6babd7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,13 @@ its first tagged release.
## [Unreleased]
### Added
+- **Layered policy: `default/` + `user/`** — `pasu-rules` gains `Ruleset::from_dir`
+ (loads `*.yaml` in a directory, sorted by filename — the `rules.d`/`sudoers.d`
+ convention) and `Ruleset::layered` (a user overlay whose rules take precedence,
+ default merged deny-wins). `pasu-daemon --policy-dir
` loads
+ `/default/` (project-shipped, overwritten on upgrade) under `/user/`
+ (customization, preserved) so upgrades never clobber user rules. `--policy
+ ` still works; the two are mutually exclusive.
- **IPv6 kernel egress filtering** — the eBPF guard now enforces default-deny on
IPv6 too (new `ALLOW6` map, v6 destination parsing), closing the bypass where
a tool could exfiltrate over IPv6. Loopback (`::1`) and infrastructure prefixes
diff --git a/crates/pasu-daemon/src/main.rs b/crates/pasu-daemon/src/main.rs
index 132e03b..cc7d420 100644
--- a/crates/pasu-daemon/src/main.rs
+++ b/crates/pasu-daemon/src/main.rs
@@ -13,9 +13,16 @@ use pasu_rules::Ruleset;
#[derive(Debug, Parser)]
struct Opt {
- /// The pasu policy YAML — the SAME file the proxy loads.
+ /// A single pasu policy YAML — the SAME file the proxy loads. Mutually
+ /// exclusive with `--policy-dir`.
#[clap(short, long)]
- policy: std::path::PathBuf,
+ policy: Option,
+ /// A policy directory with `default/` (project-shipped, overwritten on
+ /// upgrade) and `user/` (customization, preserved) subdirs of `*.yaml`
+ /// rules. The user rules layer on top (take precedence). Mutually exclusive
+ /// with `--policy`.
+ #[clap(long)]
+ policy_dir: Option,
/// cgroup v2 path to attach to. Must be a DEDICATED cgroup: default-deny on
/// the root cgroup would cut the host's own egress (SSH included).
#[clap(short, long)]
@@ -29,14 +36,37 @@ struct Opt {
admin_socket: Option,
}
+/// Load the ruleset from exactly one of `--policy ` or `--policy-dir
+/// ` (the latter layers `user/` over `default/`).
+fn load_ruleset(opt: &Opt) -> anyhow::Result {
+ match (&opt.policy, &opt.policy_dir) {
+ (Some(file), None) => {
+ let yaml = std::fs::read_to_string(file)
+ .with_context(|| format!("read policy {}", file.display()))?;
+ Ruleset::from_yaml(&yaml).with_context(|| format!("parse policy {}", file.display()))
+ }
+ (None, Some(dir)) => {
+ let base = Ruleset::from_dir(&dir.join("default"))
+ .with_context(|| format!("read {}/default", dir.display()))?;
+ let user = Ruleset::from_dir(&dir.join("user"))
+ .with_context(|| format!("read {}/user", dir.display()))?;
+ Ok(base.layered(user))
+ }
+ (Some(_), Some(_)) => anyhow::bail!("use only one of --policy or --policy-dir"),
+ (None, None) => anyhow::bail!("provide --policy or --policy-dir "),
+ }
+}
+
fn load(opt: Opt) -> anyhow::Result {
- let yaml = std::fs::read_to_string(&opt.policy)
- .with_context(|| format!("read policy {}", opt.policy.display()))?;
- let ruleset = Ruleset::from_yaml(&yaml)
- .with_context(|| format!("parse policy {}", opt.policy.display()))?;
+ let ruleset = load_ruleset(&opt)?;
let allowlist = ruleset.egress_allowlist()?;
- println!("policy: {}", opt.policy.display());
+ let source = match (&opt.policy, &opt.policy_dir) {
+ (Some(file), _) => file.display().to_string(),
+ (_, Some(dir)) => format!("{}/{{default,user}}", dir.display()),
+ _ => String::new(),
+ };
+ println!("policy: {source}");
for ip in &allowlist.ips {
println!(" kernel allow ip {ip}");
}
@@ -76,7 +106,8 @@ mod tests {
fn opt(policy: &std::path::Path) -> Opt {
Opt {
- policy: policy.to_path_buf(),
+ policy: Some(policy.to_path_buf()),
+ policy_dir: None,
cgroup_path: "/sys/fs/cgroup/pasu-agent".into(),
refresh_secs: 30,
admin_socket: None,
@@ -115,4 +146,53 @@ mod tests {
std::fs::write(&path, "rules: []\ndefault: allow\n").unwrap();
assert!(load(opt(&path)).is_err());
}
+
+ #[test]
+ fn policy_dir_layers_user_over_default() {
+ let root = std::env::temp_dir().join("pasu-daemon-test-policydir");
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(root.join("default")).unwrap();
+ std::fs::create_dir_all(root.join("user")).unwrap();
+ // Project baseline allows 1.1.1.1; user adds 9.9.9.9. Both reach the kernel.
+ std::fs::write(
+ root.join("default/00-base.yaml"),
+ "rules:\n - name: base\n match: { host: \"1.1.1.1\" }\n action: allow\ndefault: deny\n",
+ )
+ .unwrap();
+ std::fs::write(
+ root.join("user/10-mine.yaml"),
+ "rules:\n - name: mine\n match: { host: \"9.9.9.9\" }\n action: allow\ndefault: deny\n",
+ )
+ .unwrap();
+
+ let o = Opt {
+ policy: None,
+ policy_dir: Some(root.clone()),
+ cgroup_path: "/sys/fs/cgroup/pasu-agent".into(),
+ refresh_secs: 30,
+ admin_socket: None,
+ };
+ let cfg = load(o).unwrap();
+ let mut ips = cfg.allow.clone();
+ ips.sort();
+ assert_eq!(
+ ips,
+ vec![
+ std::net::Ipv4Addr::new(1, 1, 1, 1),
+ std::net::Ipv4Addr::new(9, 9, 9, 9)
+ ]
+ );
+ }
+
+ #[test]
+ fn requires_exactly_one_policy_source() {
+ let o = Opt {
+ policy: None,
+ policy_dir: None,
+ cgroup_path: "/sys/fs/cgroup/pasu-agent".into(),
+ refresh_secs: 30,
+ admin_socket: None,
+ };
+ assert!(load(o).is_err());
+ }
}
diff --git a/crates/pasu-rules/src/lib.rs b/crates/pasu-rules/src/lib.rs
index 1fe74ad..c3341ed 100644
--- a/crates/pasu-rules/src/lib.rs
+++ b/crates/pasu-rules/src/lib.rs
@@ -11,6 +11,7 @@
//! swap this for OPA / a DSL later without touching them. Design: docs/rules.md
use std::net::{Ipv4Addr, Ipv6Addr};
+use std::path::{Path, PathBuf};
use pasu_core::{Event, EventKind, RuleEngine, Verdict};
use serde::Deserialize;
@@ -144,6 +145,80 @@ impl Ruleset {
}
Ok(out)
}
+
+ /// Layer a `user` overlay on top of this (project baseline) ruleset.
+ ///
+ /// The overlay's rules take precedence: the engine is first-match, so the
+ /// user's rules are evaluated *before* the baseline's and override them for
+ /// the same target. The merged default action is the stricter of the two —
+ /// `deny` wins (fail-closed). This is how `default/` (project, overwritten
+ /// on upgrade) and `user/` (customization, preserved) compose without either
+ /// clobbering the other; the caller supplies both paths.
+ #[must_use]
+ pub fn layered(mut self, user: Ruleset) -> Ruleset {
+ let mut rules = user.rules;
+ rules.append(&mut self.rules);
+ let default = strictest(self.default, user.default);
+ Ruleset { rules, default }
+ }
+
+ /// Load and concatenate every `*.yaml` / `*.yml` file in `dir`, sorted by
+ /// file name — the `10-…`, `20-…` convention gives explicit ordering, like
+ /// Falco's `rules.d` or `sudoers.d`. A missing directory yields an empty,
+ /// fail-closed (`default: deny`) ruleset. Rules keep file order; the default
+ /// is `deny` unless every file present declares `allow`.
+ pub fn from_dir(dir: &Path) -> std::io::Result {
+ let mut files: Vec = match std::fs::read_dir(dir) {
+ Ok(rd) => rd
+ .filter_map(Result::ok)
+ .map(|e| e.path())
+ .filter(|p| {
+ matches!(
+ p.extension().and_then(|e| e.to_str()),
+ Some("yaml") | Some("yml")
+ )
+ })
+ .collect(),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
+ Err(e) => return Err(e),
+ };
+ files.sort();
+
+ let mut rules = Vec::new();
+ let mut any_file = false;
+ let mut any_deny_default = false;
+ for path in files {
+ any_file = true;
+ let yaml = std::fs::read_to_string(&path)?;
+ let rs = Ruleset::from_yaml(&yaml).map_err(|e| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!("{}: {e}", path.display()),
+ )
+ })?;
+ rules.extend(rs.rules);
+ if matches!(rs.default, Action::Deny) {
+ any_deny_default = true;
+ }
+ }
+ // Fail-closed: deny unless at least one file was present and none of the
+ // files declared a `deny` default.
+ let default = if any_file && !any_deny_default {
+ Action::Allow
+ } else {
+ Action::Deny
+ };
+ Ok(Ruleset { rules, default })
+ }
+}
+
+/// The stricter of two default actions (deny > ask > allow) — fail-closed merge.
+fn strictest(a: Action, b: Action) -> Action {
+ match (a, b) {
+ (Action::Deny, _) | (_, Action::Deny) => Action::Deny,
+ (Action::Ask, _) | (_, Action::Ask) => Action::Ask,
+ _ => Action::Allow,
+ }
}
impl Match {
@@ -204,6 +279,79 @@ impl RuleEngine for RulesetEngine {
mod tests {
use super::*;
+ // --- layered / from_dir (default/ + user/ composition) ---
+
+ #[test]
+ fn user_overlay_rules_take_precedence_over_baseline() {
+ // Baseline allows a tool; the user overlay denies it. First-match means
+ // the user rule (evaluated first) wins.
+ let base = Ruleset::from_yaml(
+ "rules:\n - name: allow-bash\n match: { tool: Bash }\n action: allow\ndefault: deny\n",
+ )
+ .unwrap();
+ let user = Ruleset::from_yaml(
+ "rules:\n - name: deny-bash\n match: { tool: Bash }\n action: deny\ndefault: deny\n",
+ )
+ .unwrap();
+ let engine = RulesetEngine::new(base.layered(user));
+ let ev = Event {
+ kind: EventKind::ToolCall {
+ name: "Bash".into(),
+ input: "{}".into(),
+ },
+ };
+ assert!(matches!(engine.evaluate(&ev), Verdict::Deny(_)));
+ }
+
+ #[test]
+ fn layered_default_is_deny_when_either_layer_denies() {
+ let allow_all = Ruleset {
+ rules: vec![],
+ default: Action::Allow,
+ };
+ let deny = Ruleset {
+ rules: vec![],
+ default: Action::Deny,
+ };
+ assert!(matches!(
+ allow_all.clone().layered(deny).default,
+ Action::Deny
+ ));
+ assert!(matches!(
+ allow_all.clone().layered(allow_all).default,
+ Action::Allow
+ ));
+ }
+
+ #[test]
+ fn from_dir_concatenates_sorted_and_missing_is_fail_closed() {
+ let dir = std::env::temp_dir().join("pasu-rules-fromdir-test");
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ // Filenames drive order: 20 loaded after 10, so 10's rule matches first.
+ std::fs::write(
+ dir.join("20-b.yaml"),
+ "rules:\n - name: b\n match: { tool: T }\n action: deny\ndefault: deny\n",
+ )
+ .unwrap();
+ std::fs::write(
+ dir.join("10-a.yaml"),
+ "rules:\n - name: a\n match: { tool: T }\n action: allow\ndefault: allow\n",
+ )
+ .unwrap();
+ std::fs::write(dir.join("notes.txt"), "ignored, not yaml").unwrap();
+
+ let rs = Ruleset::from_dir(&dir).unwrap();
+ assert_eq!(rs.rules.len(), 2);
+ assert_eq!(rs.rules[0].name, "a"); // 10-a.yaml first
+ assert!(matches!(rs.default, Action::Deny)); // 20-b declares deny → deny wins
+
+ // Missing directory → empty, fail-closed.
+ let missing = Ruleset::from_dir(&dir.join("does-not-exist")).unwrap();
+ assert!(missing.rules.is_empty());
+ assert!(matches!(missing.default, Action::Deny));
+ }
+
const YAML: &str = r#"
rules:
- name: allow-llm