1use 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(PolicyListArgs),
15 Add(PolicyAddArgs),
17 Test(PolicyTestArgs),
19}
20
21#[derive(Args)]
22pub struct PolicyListArgs {}
23
24#[derive(Args)]
25pub struct PolicyAddArgs {
26 #[arg(value_name = "TOOL")]
28 pub tool: String,
29 #[arg(long)]
31 pub target: Option<String>,
32 #[arg(long, default_value = "internal")]
34 pub max_sensitivity: String,
35}
36
37#[derive(Args)]
38pub struct PolicyTestArgs {
39 #[arg(value_name = "TOOL")]
41 pub tool: String,
42 #[arg(long)]
44 pub target: Option<String>,
45 #[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
58pub(crate) fn load_policy() -> anyhow::Result<leviath_core::PolicyConfig> {
60 load_policy_from(&policy_path())
61}
62
63fn 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
75fn 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
92fn policy_path() -> std::path::PathBuf {
94 leviath_config_dir(dirs::config_dir(), dirs::home_dir()).join("policy.toml")
95}
96
97pub(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
106fn 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 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
202fn 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 if let Some(parent) = path.parent() {
242 std::fs::create_dir_all(parent)?;
243 }
244
245 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
273fn 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
294fn 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 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 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 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 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 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 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 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 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 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 fn no_rules() -> tempfile::TempDir {
521 tempfile::tempdir().unwrap()
522 }
523
524 #[test]
525 fn execute_test_with_allowlist_rule_hit() {
526 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 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 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 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 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 let result = execute_list().await;
641 assert!(result.is_ok());
642 }
643
644 #[tokio::test]
645 async fn execute_dispatches_subcommands() {
646 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 let dir = tempfile::tempdir().unwrap();
715 let policy_file = dir.path().join("policy.toml");
716
717 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 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 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 assert!(class.check_clearance(leviath_core::TaintLevel::Public));
820
821 assert!(!class.check_clearance(leviath_core::TaintLevel::Internal));
823
824 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 #[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 #[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 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 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}