Skip to main content

leviath_cli/commands/
policy.rs

1//! Policy management commands: list, add, test.
2
3use clap::{Args, Subcommand};
4
5#[derive(Args)]
6pub struct PolicyArgs {
7    #[command(subcommand)]
8    pub command: PolicyCommand,
9}
10
11#[derive(Subcommand)]
12pub enum PolicyCommand {
13    /// List current policy rules (static + scripted)
14    List(PolicyListArgs),
15    /// Add a new allowlist rule interactively
16    Add(PolicyAddArgs),
17    /// Test whether a tool would be gated under current policy
18    Test(PolicyTestArgs),
19}
20
21#[derive(Args)]
22pub struct PolicyListArgs {}
23
24#[derive(Args)]
25pub struct PolicyAddArgs {
26    /// Tool name to create a rule for
27    #[arg(value_name = "TOOL")]
28    pub tool: String,
29    /// Target pattern (e.g., "megan@*")
30    #[arg(long)]
31    pub target: Option<String>,
32    /// Maximum sensitivity level (public, internal, private)
33    #[arg(long, default_value = "internal")]
34    pub max_sensitivity: String,
35}
36
37#[derive(Args)]
38pub struct PolicyTestArgs {
39    /// Tool name to test
40    #[arg(value_name = "TOOL")]
41    pub tool: String,
42    /// Optional target to test against
43    #[arg(long)]
44    pub target: Option<String>,
45    /// Taint level to test with (public, internal, private)
46    #[arg(long, default_value = "private")]
47    pub taint: String,
48}
49
50pub async fn execute(args: PolicyArgs) -> anyhow::Result<()> {
51    match args.command {
52        PolicyCommand::List(_) => execute_list().await,
53        PolicyCommand::Add(args) => execute_add(args).await,
54        PolicyCommand::Test(args) => execute_test(args).await,
55    }
56}
57
58/// Load the policy config from the default path.
59pub(crate) fn load_policy() -> anyhow::Result<leviath_core::PolicyConfig> {
60    load_policy_from(&policy_path())
61}
62
63/// Load the policy config from a specific path - split out from `load_policy`
64/// (which injects the real default path) so the parse-error and missing-file
65/// arms are unit-testable without touching the user's real config.
66fn load_policy_from(path: &std::path::Path) -> anyhow::Result<leviath_core::PolicyConfig> {
67    if path.exists() {
68        let content = std::fs::read_to_string(path)?;
69        leviath_core::PolicyConfig::from_toml(&content).map_err(|e| anyhow::anyhow!("{}", e))
70    } else {
71        Ok(leviath_core::PolicyConfig::default())
72    }
73}
74
75/// Resolve the base `…/leviath` config directory, preferring the platform
76/// config dir and falling back to `~/.config`. Split out with injected dirs so
77/// the fallback arm is unit-testable (the real `dirs::config_dir()` is `Some`
78/// on CI runners, so the fallback would otherwise never be exercised).
79fn leviath_config_dir(
80    config_dir: Option<std::path::PathBuf>,
81    home_dir: Option<std::path::PathBuf>,
82) -> std::path::PathBuf {
83    config_dir
84        .unwrap_or_else(|| {
85            home_dir
86                .expect("no config or home directory")
87                .join(".config")
88        })
89        .join("leviath")
90}
91
92/// Get the default policy file path.
93fn policy_path() -> std::path::PathBuf {
94    leviath_config_dir(dirs::config_dir(), dirs::home_dir()).join("policy.toml")
95}
96
97/// The scripted rules directory (`<config>/leviath/rules`).
98pub(crate) fn rules_dir() -> std::path::PathBuf {
99    leviath_config_dir(dirs::config_dir(), dirs::home_dir()).join("rules")
100}
101
102async fn execute_list() -> anyhow::Result<()> {
103    execute_list_from(load_policy(), &rules_dir())
104}
105
106/// Core of [`execute_list`] with the loaded policy passed in as a `Result` so
107/// the `load_policy()?` error arm is unit-testable without a corrupt real
108/// config file.
109fn execute_list_from(
110    loaded: anyhow::Result<leviath_core::PolicyConfig>,
111    rules: &std::path::Path,
112) -> anyhow::Result<()> {
113    execute_list_with(&loaded?, rules)
114}
115
116fn execute_list_with(
117    config: &leviath_core::PolicyConfig,
118    rules: &std::path::Path,
119) -> anyhow::Result<()> {
120    println!("Taint Policy Rules");
121    println!("==================");
122    println!();
123
124    if config.allowlist.is_empty() {
125        println!("No static allowlist rules configured.");
126    } else {
127        println!("Static Rules ({}):", config.allowlist.len());
128        for (i, rule) in config.allowlist.iter().enumerate() {
129            let targets = if !rule.to.is_empty() {
130                format!(" → {}", rule.to.join(", "))
131            } else if !rule.channel.is_empty() {
132                format!(" → {}", rule.channel.join(", "))
133            } else {
134                " → (any target)".to_string()
135            };
136            println!(
137                "  {}. {} [max: {}]{}",
138                i + 1,
139                rule.tool,
140                rule.max_sensitivity,
141                targets
142            );
143        }
144    }
145
146    println!();
147
148    // List scripted rules
149    if rules.exists() {
150        let mut scripts: Vec<_> = std::fs::read_dir(rules)?
151            .filter_map(|e| e.ok())
152            .filter(|e| {
153                e.path()
154                    .extension()
155                    .map(|ext| ext == "rhai")
156                    .unwrap_or(false)
157            })
158            .collect();
159        scripts.sort_by_key(|e| e.file_name());
160
161        if scripts.is_empty() {
162            println!("No scripted rules found.");
163        } else {
164            println!("Scripted Rules ({}):", scripts.len());
165            for entry in &scripts {
166                let name = entry
167                    .path()
168                    .file_stem()
169                    .unwrap_or_default()
170                    .to_string_lossy()
171                    .to_string();
172                println!("  - {} ({})", name, entry.path().display());
173            }
174        }
175    } else {
176        println!("No scripted rules directory found.");
177    }
178
179    if !config.mcp_overrides.is_empty() {
180        println!();
181        println!("MCP Tool Overrides ({}):", config.mcp_overrides.len());
182        for (key, ovr) in &config.mcp_overrides {
183            let parts: Vec<String> = [
184                ovr.sensitivity.map(|s| format!("sensitivity={}", s)),
185                ovr.direction.as_ref().map(|d| format!("direction={}", d)),
186                ovr.clearance.map(|c| format!("clearance={}", c)),
187            ]
188            .into_iter()
189            .flatten()
190            .collect();
191            println!("  {} [{}]", key, parts.join(", "));
192        }
193    }
194
195    Ok(())
196}
197
198async fn execute_add(args: PolicyAddArgs) -> anyhow::Result<()> {
199    execute_add_from(args, &policy_path(), load_policy())
200}
201
202/// Core of [`execute_add`] with the loaded policy passed in as a `Result` so
203/// the `load_policy()?` error arm is unit-testable without a corrupt real
204/// config file.
205fn execute_add_from(
206    args: PolicyAddArgs,
207    path: &std::path::Path,
208    loaded: anyhow::Result<leviath_core::PolicyConfig>,
209) -> anyhow::Result<()> {
210    execute_add_with(args, path, &loaded?)
211}
212
213fn execute_add_with(
214    args: PolicyAddArgs,
215    path: &std::path::Path,
216    existing: &leviath_core::PolicyConfig,
217) -> anyhow::Result<()> {
218    let sensitivity =
219        leviath_core::TaintLevel::from_str_loose(&args.max_sensitivity).ok_or_else(|| {
220            anyhow::anyhow!(
221                "Invalid sensitivity level: '{}'. Use: public, internal, private",
222                args.max_sensitivity
223            )
224        })?;
225
226    let rule = leviath_core::AllowlistRule {
227        tool: args.tool.clone(),
228        to: args
229            .target
230            .as_ref()
231            .map(|t| vec![t.clone()])
232            .unwrap_or_default(),
233        channel: vec![],
234        max_sensitivity: sensitivity,
235    };
236
237    let mut config = existing.clone();
238    config.allowlist.push(rule);
239
240    // Ensure directory exists
241    if let Some(parent) = path.parent() {
242        std::fs::create_dir_all(parent)?;
243    }
244
245    // Serialize and write. `PolicyConfig` is a struct of arrays-of-tables
246    // (`allowlist`) followed by a table (`mcp_overrides`), whose entries hold
247    // only inline values - there is no primitive-after-table ordering hazard,
248    // so TOML serialization cannot fail.
249    let toml_str = toml::to_string_pretty(&config)
250        .expect("infallible: PolicyConfig always serializes to TOML");
251    std::fs::write(path, toml_str)?;
252
253    println!("Added rule: {} [max: {}]", args.tool, args.max_sensitivity);
254    if let Some(target) = &args.target {
255        println!("  Target: {}", target);
256    }
257    println!("Saved to: {}", path.display());
258
259    Ok(())
260}
261
262async fn execute_test(args: PolicyTestArgs) -> anyhow::Result<()> {
263    let taint = leviath_core::TaintLevel::from_str_loose(&args.taint).ok_or_else(|| {
264        anyhow::anyhow!(
265            "Invalid taint level: '{}'. Use: public, internal, private",
266            args.taint
267        )
268    })?;
269
270    execute_test_with(&args, taint, load_policy(), &rules_dir())
271}
272
273/// Build a one-region context window carrying `taint`, so the diagnostic runs
274/// the same gate code as the daemon instead of re-deriving the verdict.
275fn window_with_taint(
276    taint: leviath_core::TaintLevel,
277) -> leviath_runtime::components::ContextWindow {
278    let mut window = leviath_runtime::components::ContextWindow::new(1024);
279    let mut region = leviath_core::Region::new(
280        "scenario".to_string(),
281        leviath_core::RegionKind::Temporary,
282        512,
283    );
284    region.enable_taint_tracking();
285    window.add_region(region);
286    if taint != leviath_core::TaintLevel::Public {
287        window
288            .add_tainted_to_region("scenario", "sample".to_string(), 8, taint)
289            .expect("infallible: the region was just added");
290    }
291    window
292}
293
294/// Core of [`execute_test`] with the parsed taint level, the loaded policy
295/// (as a `Result`), and the scripted-rules directory passed in, so every
296/// verdict arm is unit-testable with crafted configs and temp rule dirs.
297///
298/// The verdict comes from the same [`leviath_runtime::TaintGate`] the daemon
299/// attaches at spawn - `[mcp_overrides]` applied, static allowlist and
300/// scripted rules consulted - because a diagnostic that re-derives gate
301/// semantics drifts from the enforcer and then lies about it.
302fn execute_test_with(
303    args: &PolicyTestArgs,
304    taint: leviath_core::TaintLevel,
305    loaded: anyhow::Result<leviath_core::PolicyConfig>,
306    rules: &std::path::Path,
307) -> anyhow::Result<()> {
308    use leviath_core::taint::{GateDecision, GateDecisionSource, SecurityConfig};
309
310    let config = loaded?;
311    let mut gate = leviath_runtime::TaintGate::new(SecurityConfig {
312        taint_tracking: true,
313    });
314    gate.apply_mcp_overrides(&config.mcp_overrides);
315    let classification = gate.tool_classification(&args.tool);
316
317    println!("Tool: {}", args.tool);
318    println!("  Sensitivity: {}", classification.sensitivity);
319    println!("  Direction: {}", classification.direction);
320    println!("  Clearance: {}", classification.clearance);
321    println!();
322    println!("Test scenario:");
323    println!("  Taint level: {}", taint);
324    if let Some(target) = &args.target {
325        println!("  Target: {}", target);
326    }
327    println!();
328
329    let window = window_with_taint(taint);
330    let checker = crate::daemon::gate_rules::build_gate_script_checker(rules);
331    let decision = gate.check_with_policy(
332        "policy-test",
333        &args.tool,
334        &window,
335        args.target.as_deref(),
336        &config,
337        Some(checker.as_ref()),
338    );
339
340    match decision {
341        GateDecision::Allowed => {
342            let source = gate.audit_log().last().map(|e| e.decision_source.clone());
343            match source {
344                Some(GateDecisionSource::AllowlistRule { rule_index }) => {
345                    println!("Result: ALLOWED by allowlist rule #{}", rule_index + 1);
346                }
347                Some(GateDecisionSource::ScriptedRule { script_name }) => {
348                    println!("Result: ALLOWED by scripted rule '{}'", script_name);
349                }
350                _ if !classification.is_outbound() => {
351                    println!("Result: ALLOWED (tool is not outbound - no gate check needed)");
352                }
353                _ => {
354                    println!(
355                        "Result: ALLOWED (taint {} <= clearance {})",
356                        taint, classification.clearance
357                    );
358                }
359            }
360        }
361        GateDecision::Blocked {
362            taint_level,
363            clearance,
364            ..
365        } => {
366            println!(
367                "Gate fires: taint {} > clearance {}",
368                taint_level, clearance
369            );
370            println!("Result: BLOCKED (no allowlist or scripted rule matched)");
371            println!("  The user would be prompted to allow/deny.");
372        }
373    }
374
375    Ok(())
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn policy_path_returns_valid_path() {
384        let path = policy_path();
385        assert!(path.to_str().unwrap().contains("leviath"));
386        assert!(path.to_str().unwrap().contains("policy.toml"));
387    }
388
389    #[test]
390    fn rules_dir_returns_valid_path() {
391        let path = rules_dir();
392        assert!(path.to_str().unwrap().contains("leviath"));
393        assert!(path.to_str().unwrap().contains("rules"));
394    }
395
396    #[test]
397    fn leviath_config_dir_prefers_config_dir() {
398        let p = leviath_config_dir(
399            Some(std::path::PathBuf::from("/cfg")),
400            Some(std::path::PathBuf::from("/home/u")),
401        );
402        assert_eq!(p, std::path::PathBuf::from("/cfg/leviath"));
403    }
404
405    #[test]
406    fn leviath_config_dir_falls_back_to_home_config() {
407        // No platform config dir → fall back to ~/.config/leviath
408        let p = leviath_config_dir(None, Some(std::path::PathBuf::from("/home/u")));
409        assert_eq!(p, std::path::PathBuf::from("/home/u/.config/leviath"));
410    }
411
412    #[test]
413    fn load_policy_from_missing_file_returns_default() {
414        let dir = tempfile::tempdir().unwrap();
415        let missing = dir.path().join("nope.toml");
416        let config = load_policy_from(&missing).unwrap();
417        assert!(config.allowlist.is_empty());
418    }
419
420    #[test]
421    fn load_policy_from_invalid_toml_is_err() {
422        // Exercises the parse-error map_err arm of load_policy_from.
423        let dir = tempfile::tempdir().unwrap();
424        let path = dir.path().join("policy.toml");
425        std::fs::write(&path, "{{ not valid toml").unwrap();
426        assert!(load_policy_from(&path).is_err());
427    }
428
429    #[test]
430    fn load_policy_from_read_error_propagates() {
431        // The path exists but is a directory, so read_to_string fails -
432        // exercising load_policy_from's `read_to_string(path)?` error arm.
433        let dir = tempfile::tempdir().unwrap();
434        assert!(load_policy_from(dir.path()).is_err());
435    }
436
437    #[test]
438    fn load_policy_returns_default_when_no_file() {
439        let config = load_policy().unwrap();
440        assert!(config.allowlist.is_empty());
441    }
442
443    #[test]
444    fn execute_list_with_read_dir_error_propagates() {
445        // `rules` exists but is a regular file (not a directory), so read_dir
446        // fails - exercising execute_list_with's `read_dir(rules)?` error arm.
447        let dir = tempfile::tempdir().unwrap();
448        let rules_file = dir.path().join("rules");
449        std::fs::write(&rules_file, "i am a file, not a dir").unwrap();
450        let config = leviath_core::PolicyConfig::default();
451        assert!(execute_list_with(&config, &rules_file).is_err());
452    }
453
454    #[test]
455    fn execute_list_from_propagates_load_error() {
456        // The `loaded?` error arm of execute_list_from.
457        let dir = tempfile::tempdir().unwrap();
458        assert!(execute_list_from(Err(anyhow::anyhow!("boom")), dir.path()).is_err());
459    }
460
461    #[test]
462    fn execute_add_from_propagates_load_error() {
463        // The `loaded?` error arm of execute_add_from.
464        let dir = tempfile::tempdir().unwrap();
465        let path = dir.path().join("policy.toml");
466        let args = PolicyAddArgs {
467            tool: "shell".to_string(),
468            target: None,
469            max_sensitivity: "internal".to_string(),
470        };
471        assert!(execute_add_from(args, &path, Err(anyhow::anyhow!("boom"))).is_err());
472    }
473
474    #[test]
475    fn execute_add_from_ok_writes_policy() {
476        // The Ok path of execute_add_from delegates to execute_add_with.
477        let dir = tempfile::tempdir().unwrap();
478        let path = dir.path().join("policy.toml");
479        let args = PolicyAddArgs {
480            tool: "shell".to_string(),
481            target: None,
482            max_sensitivity: "internal".to_string(),
483        };
484        assert!(execute_add_from(args, &path, Ok(leviath_core::PolicyConfig::default())).is_ok());
485        assert!(path.exists());
486    }
487
488    #[test]
489    fn execute_add_with_write_error_and_no_parent() {
490        // path == "/" has no parent (covers the `if let Some(parent)` None arm)
491        // and cannot be written as a file (covers `std::fs::write(path, ..)?`).
492        let args = PolicyAddArgs {
493            tool: "shell".to_string(),
494            target: None,
495            max_sensitivity: "internal".to_string(),
496        };
497        let config = leviath_core::PolicyConfig::default();
498        assert!(execute_add_with(args, std::path::Path::new("/"), &config).is_err());
499    }
500
501    #[test]
502    fn execute_test_with_propagates_load_error() {
503        // The `loaded?` error arm of execute_test_with.
504        let args = PolicyTestArgs {
505            tool: "shell".to_string(),
506            target: None,
507            taint: "private".to_string(),
508        };
509        let res = execute_test_with(
510            &args,
511            leviath_core::TaintLevel::Private,
512            Err(anyhow::anyhow!("boom")),
513            std::env::temp_dir().as_path(),
514        );
515        assert!(res.is_err());
516    }
517
518    /// An empty scripted-rules directory, so a test exercises only the arm it
519    /// crafts.
520    fn no_rules() -> tempfile::TempDir {
521        tempfile::tempdir().unwrap()
522    }
523
524    #[test]
525    fn execute_test_with_allowlist_rule_hit() {
526        // An outbound tool blocked by clearance but permitted by a matching
527        // allowlist rule exercises the `AllowlistRule` verdict arm.
528        let args = PolicyTestArgs {
529            tool: "shell".to_string(),
530            target: None,
531            taint: "private".to_string(),
532        };
533        let config = leviath_core::PolicyConfig {
534            allowlist: vec![leviath_core::AllowlistRule {
535                tool: "shell".to_string(),
536                to: vec![],
537                channel: vec![],
538                max_sensitivity: leviath_core::TaintLevel::Private,
539            }],
540            mcp_overrides: Default::default(),
541        };
542        let rules = no_rules();
543        let res = execute_test_with(
544            &args,
545            leviath_core::TaintLevel::Private,
546            Ok(config),
547            rules.path(),
548        );
549        assert!(res.is_ok());
550    }
551
552    #[test]
553    fn execute_test_with_scripted_rule_flips_the_verdict() {
554        // A rules/*.rhai script that allows the call must be reflected by the
555        // diagnostic - the daemon consults scripted rules, so `lev policy
556        // test` has to as well or it reports BLOCKED for a call the runtime
557        // would allow.
558        let args = PolicyTestArgs {
559            tool: "shell".to_string(),
560            target: None,
561            taint: "private".to_string(),
562        };
563        let rules = tempfile::tempdir().unwrap();
564        std::fs::write(
565            rules.path().join("allow-shell.rhai"),
566            "context.tool == \"shell\"",
567        )
568        .unwrap();
569        let res = execute_test_with(
570            &args,
571            leviath_core::TaintLevel::Private,
572            Ok(leviath_core::PolicyConfig::default()),
573            rules.path(),
574        );
575        assert!(res.is_ok());
576    }
577
578    #[test]
579    fn execute_test_with_mcp_override_changes_the_classification() {
580        // An [mcp_overrides] entry making an unknown (default-internal) MCP
581        // tool outbound with a public clearance must make the gate fire.
582        let args = PolicyTestArgs {
583            tool: "srv.share".to_string(),
584            target: None,
585            taint: "private".to_string(),
586        };
587        let config = leviath_core::PolicyConfig {
588            allowlist: vec![],
589            mcp_overrides: std::collections::HashMap::from([(
590                "srv.share".to_string(),
591                leviath_core::policy::McpToolOverride {
592                    sensitivity: None,
593                    direction: Some("outbound".to_string()),
594                    clearance: Some(leviath_core::TaintLevel::Public),
595                },
596            )]),
597        };
598        let rules = no_rules();
599        let res = execute_test_with(
600            &args,
601            leviath_core::TaintLevel::Private,
602            Ok(config),
603            rules.path(),
604        );
605        assert!(res.is_ok());
606    }
607
608    #[test]
609    fn execute_test_with_non_outbound_and_clearance_allow_arms() {
610        let rules = no_rules();
611        // read_file is inbound: the not-outbound arm.
612        let res = execute_test_with(
613            &PolicyTestArgs {
614                tool: "read_file".to_string(),
615                target: None,
616                taint: "private".to_string(),
617            },
618            leviath_core::TaintLevel::Private,
619            Ok(leviath_core::PolicyConfig::default()),
620            rules.path(),
621        );
622        assert!(res.is_ok());
623        // shell at public taint: outbound, within clearance.
624        let res = execute_test_with(
625            &PolicyTestArgs {
626                tool: "shell".to_string(),
627                target: Some("localhost".to_string()),
628                taint: "public".to_string(),
629            },
630            leviath_core::TaintLevel::Public,
631            Ok(leviath_core::PolicyConfig::default()),
632            rules.path(),
633        );
634        assert!(res.is_ok());
635    }
636
637    #[tokio::test]
638    async fn execute_list_succeeds() {
639        // Just verify it doesn't panic
640        let result = execute_list().await;
641        assert!(result.is_ok());
642    }
643
644    #[tokio::test]
645    async fn execute_dispatches_subcommands() {
646        // Exercise the top-level `execute` dispatcher for the read-only arms.
647        assert!(
648            execute(PolicyArgs {
649                command: PolicyCommand::List(PolicyListArgs {}),
650            })
651            .await
652            .is_ok()
653        );
654        assert!(
655            execute(PolicyArgs {
656                command: PolicyCommand::Test(PolicyTestArgs {
657                    tool: "read_file".to_string(),
658                    target: None,
659                    taint: "internal".to_string(),
660                }),
661            })
662            .await
663            .is_ok()
664        );
665    }
666
667    #[tokio::test]
668    async fn execute_test_non_outbound_tool() {
669        let args = PolicyTestArgs {
670            tool: "read_file".to_string(),
671            target: None,
672            taint: "private".to_string(),
673        };
674        let result = execute_test(args).await;
675        assert!(result.is_ok());
676    }
677
678    #[tokio::test]
679    async fn execute_test_outbound_allowed() {
680        let args = PolicyTestArgs {
681            tool: "shell".to_string(),
682            target: None,
683            taint: "public".to_string(),
684        };
685        let result = execute_test(args).await;
686        assert!(result.is_ok());
687    }
688
689    #[tokio::test]
690    async fn execute_test_outbound_blocked() {
691        let args = PolicyTestArgs {
692            tool: "shell".to_string(),
693            target: None,
694            taint: "private".to_string(),
695        };
696        let result = execute_test(args).await;
697        assert!(result.is_ok());
698    }
699
700    #[tokio::test]
701    async fn execute_test_invalid_taint_level() {
702        let args = PolicyTestArgs {
703            tool: "shell".to_string(),
704            target: None,
705            taint: "invalid".to_string(),
706        };
707        let result = execute_test(args).await;
708        assert!(result.is_err());
709    }
710
711    #[tokio::test]
712    async fn execute_add_and_list() {
713        // Use a temp dir for the policy file
714        let dir = tempfile::tempdir().unwrap();
715        let policy_file = dir.path().join("policy.toml");
716
717        // Create a minimal policy
718        std::fs::write(&policy_file, "").unwrap();
719
720        let args = PolicyAddArgs {
721            tool: "send_email".to_string(),
722            target: Some("test@*".to_string()),
723            max_sensitivity: "private".to_string(),
724        };
725        // execute_add writes to the real config path, so we test the logic
726        // instead of calling it directly
727        let sensitivity = leviath_core::TaintLevel::from_str_loose(&args.max_sensitivity);
728        assert!(sensitivity.is_some());
729        assert_eq!(sensitivity.unwrap(), leviath_core::TaintLevel::Private);
730    }
731
732    #[tokio::test]
733    async fn execute_add_invalid_sensitivity() {
734        let args = PolicyAddArgs {
735            tool: "shell".to_string(),
736            target: None,
737            max_sensitivity: "invalid".to_string(),
738        };
739        let result = execute_add(args).await;
740        assert!(result.is_err());
741    }
742
743    #[tokio::test]
744    async fn execute_test_with_target() {
745        let args = PolicyTestArgs {
746            tool: "shell".to_string(),
747            target: Some("example.com".to_string()),
748            taint: "private".to_string(),
749        };
750        let result = execute_test(args).await;
751        assert!(result.is_ok());
752    }
753
754    #[tokio::test]
755    async fn execute_test_outbound_with_internal_taint() {
756        let args = PolicyTestArgs {
757            tool: "shell".to_string(),
758            target: None,
759            taint: "internal".to_string(),
760        };
761        let result = execute_test(args).await;
762        assert!(result.is_ok());
763    }
764
765    #[test]
766    fn policy_add_args_parse_sensitivity_public() {
767        let sensitivity = leviath_core::TaintLevel::from_str_loose("public");
768        assert!(sensitivity.is_some());
769        assert_eq!(sensitivity.unwrap(), leviath_core::TaintLevel::Public);
770    }
771
772    #[test]
773    fn policy_add_args_parse_sensitivity_internal() {
774        let sensitivity = leviath_core::TaintLevel::from_str_loose("internal");
775        assert!(sensitivity.is_some());
776        assert_eq!(sensitivity.unwrap(), leviath_core::TaintLevel::Internal);
777    }
778
779    #[test]
780    fn policy_add_args_build_rule_with_target() {
781        let args = PolicyAddArgs {
782            tool: "send_email".to_string(),
783            target: Some("alice@*".to_string()),
784            max_sensitivity: "private".to_string(),
785        };
786        let sensitivity = leviath_core::TaintLevel::from_str_loose(&args.max_sensitivity).unwrap();
787        let rule = leviath_core::AllowlistRule {
788            tool: args.tool.clone(),
789            to: args
790                .target
791                .as_ref()
792                .map(|t| vec![t.clone()])
793                .unwrap_or_default(),
794            channel: vec![],
795            max_sensitivity: sensitivity,
796        };
797        assert_eq!(rule.tool, "send_email");
798        assert_eq!(rule.to, vec!["alice@*".to_string()]);
799        assert_eq!(rule.max_sensitivity, leviath_core::TaintLevel::Private);
800    }
801
802    #[test]
803    fn policy_test_classification_lookup() {
804        // Verify that builtin_tool_classification returns expected values
805        // for tools we use in execute_test
806        let shell_class = leviath_core::taint::builtin_tool_classification("shell");
807        assert!(shell_class.is_outbound());
808        assert_eq!(shell_class.clearance, leviath_core::TaintLevel::Public);
809
810        let read_class = leviath_core::taint::builtin_tool_classification("read_file");
811        assert!(!read_class.is_outbound());
812    }
813
814    #[test]
815    fn policy_test_clearance_check_scenarios() {
816        let class = leviath_core::taint::builtin_tool_classification("shell");
817
818        // Public taint within Public clearance
819        assert!(class.check_clearance(leviath_core::TaintLevel::Public));
820
821        // Internal taint exceeds Public clearance
822        assert!(!class.check_clearance(leviath_core::TaintLevel::Internal));
823
824        // Private taint exceeds Public clearance
825        assert!(!class.check_clearance(leviath_core::TaintLevel::Private));
826    }
827
828    #[test]
829    fn load_policy_returns_empty_mcp_overrides() {
830        let config = load_policy().unwrap();
831        assert!(config.mcp_overrides.is_empty());
832    }
833
834    #[test]
835    fn rules_dir_is_under_leviath_config() {
836        let path = rules_dir();
837        let path_str = path.to_str().unwrap();
838        assert!(path_str.contains("leviath"));
839        assert!(path_str.ends_with("rules"));
840    }
841
842    // ─── execute_list_with coverage ─────────────────────────────────────────
843
844    #[test]
845    fn list_with_allowlist_rules_to_targets() {
846        let config = leviath_core::PolicyConfig {
847            allowlist: vec![leviath_core::AllowlistRule {
848                tool: "send_email".into(),
849                to: vec!["alice@*".into(), "bob@*".into()],
850                channel: vec![],
851                max_sensitivity: leviath_core::TaintLevel::Private,
852            }],
853            mcp_overrides: Default::default(),
854        };
855        let dir = tempfile::tempdir().unwrap();
856        let result = execute_list_with(&config, dir.path());
857        assert!(result.is_ok());
858    }
859
860    #[test]
861    fn list_with_allowlist_rules_channel_targets() {
862        let config = leviath_core::PolicyConfig {
863            allowlist: vec![leviath_core::AllowlistRule {
864                tool: "post_to_slack".into(),
865                to: vec![],
866                channel: vec!["#general".into()],
867                max_sensitivity: leviath_core::TaintLevel::Internal,
868            }],
869            mcp_overrides: Default::default(),
870        };
871        let dir = tempfile::tempdir().unwrap();
872        let result = execute_list_with(&config, dir.path());
873        assert!(result.is_ok());
874    }
875
876    #[test]
877    fn list_with_allowlist_rules_any_target() {
878        let config = leviath_core::PolicyConfig {
879            allowlist: vec![leviath_core::AllowlistRule {
880                tool: "shell".into(),
881                to: vec![],
882                channel: vec![],
883                max_sensitivity: leviath_core::TaintLevel::Public,
884            }],
885            mcp_overrides: Default::default(),
886        };
887        let dir = tempfile::tempdir().unwrap();
888        let result = execute_list_with(&config, dir.path());
889        assert!(result.is_ok());
890    }
891
892    #[test]
893    fn list_with_scripted_rules() {
894        let dir = tempfile::tempdir().unwrap();
895        let rules = dir.path().join("rules");
896        std::fs::create_dir_all(&rules).unwrap();
897        std::fs::write(rules.join("company.rhai"), "// rule").unwrap();
898        std::fs::write(rules.join("other.rhai"), "// rule").unwrap();
899        std::fs::write(rules.join("not_a_rule.txt"), "ignored").unwrap();
900
901        let config = leviath_core::PolicyConfig::default();
902        let result = execute_list_with(&config, &rules);
903        assert!(result.is_ok());
904    }
905
906    #[test]
907    fn list_with_empty_scripted_rules_dir() {
908        let dir = tempfile::tempdir().unwrap();
909        let rules = dir.path().join("rules");
910        std::fs::create_dir_all(&rules).unwrap();
911
912        let config = leviath_core::PolicyConfig::default();
913        let result = execute_list_with(&config, &rules);
914        assert!(result.is_ok());
915    }
916
917    #[test]
918    fn list_with_mcp_overrides() {
919        let mut overrides = std::collections::HashMap::new();
920        overrides.insert(
921            "server.tool_a".to_string(),
922            leviath_core::McpToolOverride {
923                sensitivity: Some(leviath_core::TaintLevel::Private),
924                direction: Some("outbound".to_string()),
925                clearance: Some(leviath_core::TaintLevel::Internal),
926            },
927        );
928        let config = leviath_core::PolicyConfig {
929            allowlist: vec![],
930            mcp_overrides: overrides,
931        };
932        let dir = tempfile::tempdir().unwrap();
933        let result = execute_list_with(&config, dir.path());
934        assert!(result.is_ok());
935    }
936
937    // ─── execute_add_with coverage ──────────────────────────────────────────
938
939    #[test]
940    fn add_with_target_writes_policy() {
941        let dir = tempfile::tempdir().unwrap();
942        let path = dir.path().join("policy.toml");
943        let config = leviath_core::PolicyConfig::default();
944        let args = PolicyAddArgs {
945            tool: "send_email".to_string(),
946            target: Some("test@example.com".to_string()),
947            max_sensitivity: "private".to_string(),
948        };
949        let result = execute_add_with(args, &path, &config);
950        assert!(result.is_ok());
951        let content = std::fs::read_to_string(&path).unwrap();
952        assert!(content.contains("send_email"));
953    }
954
955    #[test]
956    fn add_without_target_writes_policy() {
957        let dir = tempfile::tempdir().unwrap();
958        let path = dir.path().join("policy.toml");
959        let config = leviath_core::PolicyConfig::default();
960        let args = PolicyAddArgs {
961            tool: "shell".to_string(),
962            target: None,
963            max_sensitivity: "internal".to_string(),
964        };
965        let result = execute_add_with(args, &path, &config);
966        assert!(result.is_ok());
967        let content = std::fs::read_to_string(&path).unwrap();
968        assert!(content.contains("shell"));
969    }
970
971    #[test]
972    fn add_invalid_sensitivity_errors() {
973        let dir = tempfile::tempdir().unwrap();
974        let path = dir.path().join("policy.toml");
975        let config = leviath_core::PolicyConfig::default();
976        let args = PolicyAddArgs {
977            tool: "shell".to_string(),
978            target: None,
979            max_sensitivity: "bogus".to_string(),
980        };
981        let result = execute_add_with(args, &path, &config);
982        assert!(result.is_err());
983    }
984
985    #[test]
986    fn add_appends_to_existing_config() {
987        let dir = tempfile::tempdir().unwrap();
988        let path = dir.path().join("policy.toml");
989        let existing = leviath_core::PolicyConfig {
990            allowlist: vec![leviath_core::AllowlistRule {
991                tool: "existing".into(),
992                to: vec![],
993                channel: vec![],
994                max_sensitivity: leviath_core::TaintLevel::Public,
995            }],
996            mcp_overrides: Default::default(),
997        };
998        let args = PolicyAddArgs {
999            tool: "new_tool".to_string(),
1000            target: None,
1001            max_sensitivity: "private".to_string(),
1002        };
1003        let result = execute_add_with(args, &path, &existing);
1004        assert!(result.is_ok());
1005        let content = std::fs::read_to_string(&path).unwrap();
1006        assert!(content.contains("existing"));
1007        assert!(content.contains("new_tool"));
1008    }
1009
1010    #[test]
1011    fn add_creates_parent_dirs() {
1012        let dir = tempfile::tempdir().unwrap();
1013        let path = dir.path().join("sub").join("dir").join("policy.toml");
1014        let config = leviath_core::PolicyConfig::default();
1015        let args = PolicyAddArgs {
1016            tool: "shell".to_string(),
1017            target: None,
1018            max_sensitivity: "public".to_string(),
1019        };
1020        let result = execute_add_with(args, &path, &config);
1021        assert!(result.is_ok());
1022        assert!(path.exists());
1023    }
1024
1025    #[test]
1026    fn add_with_parent_that_is_a_file_errors() {
1027        // When the target path's parent is an existing regular file,
1028        // create_dir_all fails and the `?` propagates - covers the error arm.
1029        let dir = tempfile::tempdir().unwrap();
1030        let file_as_parent = dir.path().join("iamafile");
1031        std::fs::write(&file_as_parent, "not a dir").unwrap();
1032        let path = file_as_parent.join("policy.toml");
1033        let config = leviath_core::PolicyConfig::default();
1034        let args = PolicyAddArgs {
1035            tool: "shell".to_string(),
1036            target: None,
1037            max_sensitivity: "public".to_string(),
1038        };
1039        let result = execute_add_with(args, &path, &config);
1040        assert!(result.is_err());
1041    }
1042
1043    #[tokio::test]
1044    async fn execute_dispatches_add_subcommand() {
1045        // Route the top-level dispatcher through the Add arm. An invalid
1046        // sensitivity makes execute_add fail before it writes anything to the
1047        // real config path, so no filesystem side effects occur.
1048        let result = execute(PolicyArgs {
1049            command: PolicyCommand::Add(PolicyAddArgs {
1050                tool: "shell".to_string(),
1051                target: None,
1052                max_sensitivity: "definitely-not-valid".to_string(),
1053            }),
1054        })
1055        .await;
1056        assert!(result.is_err());
1057    }
1058}