Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>` loads
`<dir>/default/` (project-shipped, overwritten on upgrade) under `<dir>/user/`
(customization, preserved) so upgrades never clobber user rules. `--policy
<file>` 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
Expand Down
96 changes: 88 additions & 8 deletions crates/pasu-daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf>,
/// 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<std::path::PathBuf>,
/// 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)]
Expand All @@ -29,14 +36,37 @@ struct Opt {
admin_socket: Option<std::path::PathBuf>,
}

/// Load the ruleset from exactly one of `--policy <file>` or `--policy-dir
/// <dir>` (the latter layers `user/` over `default/`).
fn load_ruleset(opt: &Opt) -> anyhow::Result<Ruleset> {
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 <file> or --policy-dir <dir>"),
}
}

fn load(opt: Opt) -> anyhow::Result<GuardConfig> {
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}");
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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());
}
}
148 changes: 148 additions & 0 deletions crates/pasu-rules/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Ruleset> {
let mut files: Vec<PathBuf> = 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 {
Expand Down Expand Up @@ -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
Expand Down
Loading