1use clap::{Args, Subcommand};
4
5#[derive(Args)]
7pub struct PolicyArgs {
8 #[command(subcommand)]
10 pub command: PolicyCommand,
11}
12
13#[derive(Subcommand)]
15pub enum PolicyCommand {
16 List(PolicyListArgs),
18 Add(PolicyAddArgs),
20 Test(PolicyTestArgs),
22}
23
24#[derive(Args)]
26pub struct PolicyListArgs {}
27
28#[derive(Args)]
30pub struct PolicyAddArgs {
31 #[arg(value_name = "TOOL")]
33 pub tool: String,
34 #[arg(long)]
36 pub target: Option<String>,
37 #[arg(long, default_value = "internal")]
39 pub max_sensitivity: String,
40}
41
42#[derive(Args)]
44pub struct PolicyTestArgs {
45 #[arg(value_name = "TOOL")]
47 pub tool: String,
48 #[arg(long)]
50 pub target: Option<String>,
51 #[arg(long, default_value = "private")]
53 pub taint: String,
54}
55
56pub 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
65pub(crate) fn load_policy() -> anyhow::Result<leviath_core::PolicyConfig> {
67 load_policy_from(&policy_path())
68}
69
70fn 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
82fn 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
99fn policy_path() -> std::path::PathBuf {
101 leviath_config_dir(dirs::config_dir(), dirs::home_dir()).join("policy.toml")
102}
103
104pub(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
113fn 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 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
209fn 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 if let Some(parent) = path.parent() {
249 std::fs::create_dir_all(parent)?;
250 }
251
252 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
280fn 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
301fn 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 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 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 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 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 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 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 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 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 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 fn no_rules() -> tempfile::TempDir {
528 tempfile::tempdir().unwrap()
529 }
530
531 #[test]
532 fn execute_test_with_allowlist_rule_hit() {
533 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 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 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 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 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 let result = execute_list().await;
648 assert!(result.is_ok());
649 }
650
651 #[tokio::test]
652 async fn execute_dispatches_subcommands() {
653 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 let dir = tempfile::tempdir().unwrap();
722 let policy_file = dir.path().join("policy.toml");
723
724 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 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 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 assert!(class.check_clearance(leviath_core::TaintLevel::Public));
827
828 assert!(!class.check_clearance(leviath_core::TaintLevel::Internal));
830
831 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 #[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 #[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 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 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}