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