Skip to main content

leviath_cli/daemon/
gate_rules.rs

1//! Scripted taint-gate rules: load `~/.config/leviath/rules/*.rhai` and build the
2//! [`ScriptRuleChecker`] the runtime's taint gate consults after the static
3//! allowlist. The daemon owns the Rhai engine (leviath-runtime has no scripting
4//! dependency), so the checker is installed as a world resource.
5
6use std::path::Path;
7use std::sync::Arc;
8
9use leviath_runtime::taint::ScriptRuleChecker;
10
11/// Build a [`ScriptRuleChecker`] from every `*.rhai` file in `rules_dir`. When the
12/// directory is absent/unreadable or holds no rule scripts, a no-op checker (that
13/// never allows anything) is returned, so the daemon can install it
14/// unconditionally. Each script receives a `context` map
15/// (`tool` / `target` / `taint_level`) and should evaluate to `true` to allow the
16/// call; the first script that allows wins and its file stem is the rule name.
17pub fn build_gate_script_checker(rules_dir: &Path) -> Arc<ScriptRuleChecker> {
18    let scripts: Vec<(String, String)> = std::fs::read_dir(rules_dir)
19        .ok()
20        .into_iter()
21        .flatten() // ReadDir → Result<DirEntry>
22        .flatten() // drop per-entry errors
23        .filter_map(|entry| {
24            let path = entry.path();
25            if path.extension().and_then(|e| e.to_str()) != Some("rhai") {
26                return None;
27            }
28            let name = path
29                .file_stem()
30                .and_then(|s| s.to_str())
31                .unwrap_or("rule")
32                .to_string();
33            std::fs::read_to_string(&path)
34                .ok()
35                .map(|source| (name, source))
36        })
37        .collect();
38    if scripts.is_empty() {
39        return Arc::new(|_tool, _target, _taint| None);
40    }
41
42    let engine = leviath_scripting::ScriptEngine::new();
43    Arc::new(
44        move |tool: &str,
45              target: Option<&str>,
46              taint: leviath_core::TaintLevel|
47              -> Option<String> {
48            scripts.iter().find_map(|(name, source)| {
49                engine
50                    .check_gate_rule(source, tool, target, taint.as_str())
51                    .unwrap_or(false)
52                    .then(|| name.clone())
53            })
54        },
55    )
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use leviath_core::TaintLevel;
62
63    fn write_rule(dir: &Path, name: &str, body: &str) {
64        std::fs::write(dir.join(name), body).unwrap();
65    }
66
67    #[test]
68    fn missing_or_empty_dir_yields_a_noop_checker() {
69        // Nonexistent directory ⇒ a checker that never allows anything.
70        let dir = tempfile::tempdir().unwrap();
71        let noop = build_gate_script_checker(&dir.path().join("nope"));
72        assert_eq!(noop("shell", None, TaintLevel::Public), None);
73        // Present but no `.rhai` files (a stray non-rule file is ignored).
74        write_rule(dir.path(), "notes.txt", "ignored");
75        let noop2 = build_gate_script_checker(dir.path());
76        assert_eq!(noop2("shell", None, TaintLevel::Public), None);
77    }
78
79    #[test]
80    fn a_matching_rule_allows_and_names_itself() {
81        let dir = tempfile::tempdir().unwrap();
82        // Allow `shell` regardless of taint; deny everything else.
83        write_rule(dir.path(), "company.rhai", r#"context.tool == "shell""#);
84        let checker = build_gate_script_checker(dir.path());
85
86        // Matching tool ⇒ Some(rule name).
87        assert_eq!(
88            checker("shell", None, TaintLevel::Internal),
89            Some("company".to_string())
90        );
91        // Non-matching tool ⇒ None (rule evaluated false).
92        assert_eq!(checker("read_file", None, TaintLevel::Internal), None);
93    }
94
95    #[test]
96    fn a_rule_can_key_on_target_and_taint() {
97        let dir = tempfile::tempdir().unwrap();
98        write_rule(
99            dir.path(),
100            "internal_only.rhai",
101            r#"context.taint_level == "internal" && context.target == "ops@corp""#,
102        );
103        let checker = build_gate_script_checker(dir.path());
104        assert!(checker("send_email", Some("ops@corp"), TaintLevel::Internal).is_some());
105        assert!(checker("send_email", Some("ops@corp"), TaintLevel::Private).is_none());
106    }
107
108    #[test]
109    fn a_script_that_errors_is_treated_as_no_match() {
110        let dir = tempfile::tempdir().unwrap();
111        // Not a bool expression ⇒ eval error ⇒ unwrap_or(false) ⇒ no match.
112        write_rule(dir.path(), "broken.rhai", "this is not valid rhai @@@");
113        let checker = build_gate_script_checker(dir.path());
114        assert_eq!(checker("shell", None, TaintLevel::Public), None);
115    }
116}