Skip to main content

leviath_cli/commands/
policy.rs

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