1use std::path::{Path, PathBuf};
10
11use regex::Regex;
12use serde::Deserialize;
13
14use crate::SkillTrustLevel;
15
16pub(crate) use zeph_config::tools::{DefaultEffect, PolicyConfig, PolicyEffect, PolicyRuleConfig};
17
18const MAX_RULES: usize = 256;
20const MAX_REGEX_LEN: usize = 1024;
22
23#[derive(Debug, Clone)]
25pub struct PolicyContext {
26 pub trust_level: SkillTrustLevel,
27 pub env: std::collections::HashMap<String, String>,
28}
29
30#[derive(Debug, Clone)]
32pub enum PolicyDecision {
33 Allow { trace: String },
34 Deny { trace: String },
35}
36
37#[derive(Debug, thiserror::Error)]
39pub enum PolicyCompileError {
40 #[error("invalid glob pattern in rule {index}: {source}")]
41 InvalidGlob {
42 index: usize,
43 source: glob::PatternError,
44 },
45
46 #[error("invalid regex in rule {index}: {source}")]
47 InvalidRegex { index: usize, source: regex::Error },
48
49 #[error("regex pattern in rule {index} exceeds maximum length ({MAX_REGEX_LEN} bytes)")]
50 RegexTooLong { index: usize },
51
52 #[error("too many rules: {count} exceeds maximum of {MAX_RULES}")]
53 TooManyRules { count: usize },
54
55 #[error("failed to load policy file {path}: {source}")]
56 FileLoad {
57 path: PathBuf,
58 source: std::io::Error,
59 },
60
61 #[error("policy file too large: {path}")]
62 FileTooLarge { path: PathBuf },
63
64 #[error("policy file escapes project root: {path}")]
65 FileEscapesRoot { path: PathBuf },
66
67 #[error("failed to parse policy file {path}: {source}")]
68 FileParse {
69 path: PathBuf,
70 source: toml::de::Error,
71 },
72}
73
74#[derive(Debug)]
76struct CompiledRule {
77 effect: PolicyEffect,
78 tool_matcher: glob::Pattern,
79 path_matchers: Vec<glob::Pattern>,
80 env_required: Vec<String>,
81 trust_threshold: Option<SkillTrustLevel>,
82 args_regex: Option<Regex>,
83 source_index: usize,
84}
85
86impl CompiledRule {
87 fn matches(
89 &self,
90 tool_name: &str,
91 params: &serde_json::Map<String, serde_json::Value>,
92 context: &PolicyContext,
93 ) -> bool {
94 if !self.tool_matcher.matches(tool_name) {
96 return false;
97 }
98
99 if !self.path_matchers.is_empty() {
101 let paths = extract_paths(params);
102 let any_path_matches = paths.iter().any(|p| {
103 let normalized = crate::file::normalize_path(Path::new(p))
104 .to_string_lossy()
105 .into_owned();
106 self.path_matchers
107 .iter()
108 .any(|pat| pat.matches(&normalized))
109 });
110 if !any_path_matches {
111 return false;
112 }
113 }
114
115 if !self
117 .env_required
118 .iter()
119 .all(|k| context.env.contains_key(k.as_str()))
120 {
121 return false;
122 }
123
124 if self
126 .trust_threshold
127 .is_some_and(|t| context.trust_level.severity() > t.severity())
128 {
129 return false;
130 }
131
132 if let Some(re) = &self.args_regex {
134 let any_matches = params.values().any(|v| {
135 if let Some(s) = v.as_str() {
136 re.is_match(s)
137 } else {
138 false
139 }
140 });
141 if !any_matches {
142 return false;
143 }
144 }
145
146 true
147 }
148}
149
150#[derive(Debug)]
152pub struct PolicyEnforcer {
153 rules: Vec<CompiledRule>,
154 default_effect: DefaultEffect,
155}
156
157impl PolicyEnforcer {
158 pub fn compile(config: &PolicyConfig) -> Result<Self, PolicyCompileError> {
165 let rule_configs: Vec<PolicyRuleConfig> = if let Some(path) = &config.policy_file {
166 load_policy_file(Path::new(path))?
167 } else {
168 config.rules.clone()
169 };
170
171 if rule_configs.len() > MAX_RULES {
172 return Err(PolicyCompileError::TooManyRules {
173 count: rule_configs.len(),
174 });
175 }
176
177 let mut rules = Vec::with_capacity(rule_configs.len());
178 for (i, rule) in rule_configs.iter().enumerate() {
179 let normalized_tool =
181 resolve_tool_alias(rule.tool.trim().to_lowercase().as_str()).to_owned();
182
183 let tool_matcher = glob::Pattern::new(&normalized_tool)
184 .map_err(|source| PolicyCompileError::InvalidGlob { index: i, source })?;
185
186 let path_matchers = rule
187 .paths
188 .iter()
189 .map(|p| {
190 glob::Pattern::new(p)
191 .map_err(|source| PolicyCompileError::InvalidGlob { index: i, source })
192 })
193 .collect::<Result<Vec<_>, _>>()?;
194
195 let args_regex = if let Some(pattern) = &rule.args_match {
196 if pattern.len() > MAX_REGEX_LEN {
197 return Err(PolicyCompileError::RegexTooLong { index: i });
198 }
199 Some(
200 Regex::new(pattern)
201 .map_err(|source| PolicyCompileError::InvalidRegex { index: i, source })?,
202 )
203 } else {
204 None
205 };
206
207 rules.push(CompiledRule {
208 effect: rule.effect,
209 tool_matcher,
210 path_matchers,
211 env_required: rule.env.clone(),
212 trust_threshold: rule.trust_level,
213 args_regex,
214 source_index: i,
215 });
216 }
217
218 Ok(Self {
219 rules,
220 default_effect: config.default_effect,
221 })
222 }
223
224 #[must_use]
226 pub fn rule_count(&self) -> usize {
227 self.rules.len()
228 }
229
230 #[must_use]
238 pub fn evaluate(
239 &self,
240 tool_name: &str,
241 params: &serde_json::Map<String, serde_json::Value>,
242 context: &PolicyContext,
243 ) -> PolicyDecision {
244 let normalized = resolve_tool_alias(tool_name.trim().to_lowercase().as_str()).to_owned();
245
246 for rule in &self.rules {
248 if rule.effect == PolicyEffect::Deny && rule.matches(&normalized, params, context) {
249 let trace = format!(
250 "rule[{}] deny: tool={} matched {}",
251 rule.source_index, tool_name, rule.tool_matcher
252 );
253 return PolicyDecision::Deny { trace };
254 }
255 }
256
257 for rule in &self.rules {
259 if rule.effect != PolicyEffect::Deny && rule.matches(&normalized, params, context) {
260 let trace = format!(
261 "rule[{}] allow: tool={} matched {}",
262 rule.source_index, tool_name, rule.tool_matcher
263 );
264 return PolicyDecision::Allow { trace };
265 }
266 }
267
268 match self.default_effect {
270 DefaultEffect::Allow => PolicyDecision::Allow {
271 trace: "default: allow (no matching rules)".to_owned(),
272 },
273 DefaultEffect::Deny => PolicyDecision::Deny {
274 trace: "default: deny (no matching rules)".to_owned(),
275 },
276 }
277 }
278}
279
280fn resolve_tool_alias(name: &str) -> &str {
285 match name {
286 "bash" | "sh" => "shell",
287 other => other,
288 }
289}
290
291fn load_policy_file(path: &Path) -> Result<Vec<PolicyRuleConfig>, PolicyCompileError> {
298 const MAX_POLICY_FILE_BYTES: u64 = 256 * 1024;
300
301 #[derive(Deserialize)]
302 struct PolicyFile {
303 #[serde(default)]
304 rules: Vec<PolicyRuleConfig>,
305 }
306
307 let canonical = std::fs::canonicalize(path).map_err(|source| PolicyCompileError::FileLoad {
309 path: path.to_owned(),
310 source,
311 })?;
312
313 let canonical_base = std::env::current_dir()
315 .and_then(std::fs::canonicalize)
316 .map_err(|source| PolicyCompileError::FileLoad {
317 path: path.to_owned(),
318 source,
319 })?;
320
321 if !canonical.starts_with(&canonical_base) {
322 tracing::warn!(
323 path = %canonical.display(),
324 "policy file escapes project root, rejecting"
325 );
326 return Err(PolicyCompileError::FileEscapesRoot {
327 path: path.to_owned(),
328 });
329 }
330
331 let meta = std::fs::metadata(&canonical).map_err(|source| PolicyCompileError::FileLoad {
333 path: path.to_owned(),
334 source,
335 })?;
336 if meta.len() > MAX_POLICY_FILE_BYTES {
337 return Err(PolicyCompileError::FileTooLarge {
338 path: path.to_owned(),
339 });
340 }
341
342 let content =
343 std::fs::read_to_string(&canonical).map_err(|source| PolicyCompileError::FileLoad {
344 path: path.to_owned(),
345 source,
346 })?;
347
348 let parsed: PolicyFile =
349 toml::from_str(&content).map_err(|source| PolicyCompileError::FileParse {
350 path: path.to_owned(),
351 source,
352 })?;
353
354 Ok(parsed.rules)
355}
356
357fn extract_paths(params: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
362 static ABS_PATH_RE: std::sync::LazyLock<Regex> =
363 std::sync::LazyLock::new(|| Regex::new(r"(/[^\s;|&<>]+)").expect("valid regex"));
364
365 let mut paths = Vec::new();
366
367 for key in &["file_path", "path", "uri", "url", "query"] {
368 if let Some(v) = params.get(*key).and_then(|v| v.as_str()) {
369 paths.push(v.to_owned());
370 }
371 }
372
373 if let Some(cmd) = params.get("command").and_then(|v| v.as_str()) {
375 for cap in ABS_PATH_RE.captures_iter(cmd) {
376 if let Some(m) = cap.get(1) {
377 paths.push(m.as_str().to_owned());
378 }
379 }
380 }
381
382 paths
383}
384
385#[cfg(test)]
386mod tests {
387 use std::collections::HashMap;
388
389 use super::*;
390
391 fn make_context(trust: SkillTrustLevel) -> PolicyContext {
392 PolicyContext {
393 trust_level: trust,
394 env: HashMap::new(),
395 }
396 }
397
398 fn make_params(key: &str, value: &str) -> serde_json::Map<String, serde_json::Value> {
399 let mut m = serde_json::Map::new();
400 m.insert(key.to_owned(), serde_json::Value::String(value.to_owned()));
401 m
402 }
403
404 fn empty_params() -> serde_json::Map<String, serde_json::Value> {
405 serde_json::Map::new()
406 }
407
408 #[test]
411 fn test_path_normalization() {
412 let config = PolicyConfig {
414 enabled: true,
415 default_effect: DefaultEffect::Allow,
416 rules: vec![PolicyRuleConfig {
417 effect: PolicyEffect::Deny,
418 tool: "shell".to_owned(),
419 paths: vec!["/etc/*".to_owned()],
420 env: vec![],
421 trust_level: None,
422 args_match: None,
423 capabilities: vec![],
424 }],
425 policy_file: None,
426 };
427 let enforcer = PolicyEnforcer::compile(&config).unwrap();
428 let params = make_params("file_path", "/tmp/../etc/passwd");
429 let ctx = make_context(SkillTrustLevel::Trusted);
430 assert!(
431 matches!(
432 enforcer.evaluate("shell", ¶ms, &ctx),
433 PolicyDecision::Deny { .. }
434 ),
435 "path traversal must be caught after normalization"
436 );
437 }
438
439 #[test]
440 fn test_path_normalization_dot_segments() {
441 let config = PolicyConfig {
442 enabled: true,
443 default_effect: DefaultEffect::Allow,
444 rules: vec![PolicyRuleConfig {
445 effect: PolicyEffect::Deny,
446 tool: "shell".to_owned(),
447 paths: vec!["/etc/*".to_owned()],
448 env: vec![],
449 trust_level: None,
450 args_match: None,
451 capabilities: vec![],
452 }],
453 policy_file: None,
454 };
455 let enforcer = PolicyEnforcer::compile(&config).unwrap();
456 let params = make_params("file_path", "/etc/./shadow");
457 let ctx = make_context(SkillTrustLevel::Trusted);
458 assert!(matches!(
459 enforcer.evaluate("shell", ¶ms, &ctx),
460 PolicyDecision::Deny { .. }
461 ));
462 }
463
464 #[test]
467 fn test_tool_name_normalization() {
468 let config = PolicyConfig {
470 enabled: true,
471 default_effect: DefaultEffect::Allow,
472 rules: vec![PolicyRuleConfig {
473 effect: PolicyEffect::Deny,
474 tool: "Shell".to_owned(),
475 paths: vec![],
476 env: vec![],
477 trust_level: None,
478 args_match: None,
479 capabilities: vec![],
480 }],
481 policy_file: None,
482 };
483 let enforcer = PolicyEnforcer::compile(&config).unwrap();
484 let ctx = make_context(SkillTrustLevel::Trusted);
485 assert!(matches!(
486 enforcer.evaluate("shell", &empty_params(), &ctx),
487 PolicyDecision::Deny { .. }
488 ));
489 assert!(matches!(
491 enforcer.evaluate("SHELL", &empty_params(), &ctx),
492 PolicyDecision::Deny { .. }
493 ));
494 }
495
496 #[test]
499 fn test_deny_wins() {
500 let config = PolicyConfig {
502 enabled: true,
503 default_effect: DefaultEffect::Allow,
504 rules: vec![
505 PolicyRuleConfig {
506 effect: PolicyEffect::Allow,
507 tool: "shell".to_owned(),
508 paths: vec!["/tmp/*".to_owned()],
509 env: vec![],
510 trust_level: None,
511 args_match: None,
512 capabilities: vec![],
513 },
514 PolicyRuleConfig {
515 effect: PolicyEffect::Deny,
516 tool: "shell".to_owned(),
517 paths: vec!["/tmp/secret.sh".to_owned()],
518 env: vec![],
519 trust_level: None,
520 args_match: None,
521 capabilities: vec![],
522 },
523 ],
524 policy_file: None,
525 };
526 let enforcer = PolicyEnforcer::compile(&config).unwrap();
527 let params = make_params("file_path", "/tmp/secret.sh");
528 let ctx = make_context(SkillTrustLevel::Trusted);
529 assert!(
530 matches!(
531 enforcer.evaluate("shell", ¶ms, &ctx),
532 PolicyDecision::Deny { .. }
533 ),
534 "deny must win over allow for the same path"
535 );
536 }
537
538 #[test]
540 fn deny_wins_deny_first() {
541 let config = PolicyConfig {
543 enabled: true,
544 default_effect: DefaultEffect::Allow,
545 rules: vec![
546 PolicyRuleConfig {
547 effect: PolicyEffect::Deny,
548 tool: "shell".to_owned(),
549 paths: vec!["/etc/*".to_owned()],
550 env: vec![],
551 trust_level: None,
552 args_match: None,
553 capabilities: vec![],
554 },
555 PolicyRuleConfig {
556 effect: PolicyEffect::Allow,
557 tool: "shell".to_owned(),
558 paths: vec!["/etc/*".to_owned()],
559 env: vec![],
560 trust_level: None,
561 args_match: None,
562 capabilities: vec![],
563 },
564 ],
565 policy_file: None,
566 };
567 let enforcer = PolicyEnforcer::compile(&config).unwrap();
568 let params = make_params("file_path", "/etc/passwd");
569 let ctx = make_context(SkillTrustLevel::Trusted);
570 assert!(
571 matches!(
572 enforcer.evaluate("shell", ¶ms, &ctx),
573 PolicyDecision::Deny { .. }
574 ),
575 "deny must win when deny rule is first"
576 );
577 }
578
579 #[test]
580 fn deny_wins_deny_last() {
581 let config = PolicyConfig {
583 enabled: true,
584 default_effect: DefaultEffect::Allow,
585 rules: vec![
586 PolicyRuleConfig {
587 effect: PolicyEffect::Allow,
588 tool: "shell".to_owned(),
589 paths: vec!["/etc/*".to_owned()],
590 env: vec![],
591 trust_level: None,
592 args_match: None,
593 capabilities: vec![],
594 },
595 PolicyRuleConfig {
596 effect: PolicyEffect::Deny,
597 tool: "shell".to_owned(),
598 paths: vec!["/etc/*".to_owned()],
599 env: vec![],
600 trust_level: None,
601 args_match: None,
602 capabilities: vec![],
603 },
604 ],
605 policy_file: None,
606 };
607 let enforcer = PolicyEnforcer::compile(&config).unwrap();
608 let params = make_params("file_path", "/etc/passwd");
609 let ctx = make_context(SkillTrustLevel::Trusted);
610 assert!(
611 matches!(
612 enforcer.evaluate("shell", ¶ms, &ctx),
613 PolicyDecision::Deny { .. }
614 ),
615 "deny must win even when deny rule is last"
616 );
617 }
618
619 #[test]
622 fn test_default_deny() {
623 let config = PolicyConfig {
624 enabled: true,
625 default_effect: DefaultEffect::Deny,
626 rules: vec![],
627 policy_file: None,
628 };
629 let enforcer = PolicyEnforcer::compile(&config).unwrap();
630 let ctx = make_context(SkillTrustLevel::Trusted);
631 assert!(matches!(
632 enforcer.evaluate("bash", &empty_params(), &ctx),
633 PolicyDecision::Deny { .. }
634 ));
635 }
636
637 #[test]
638 fn test_default_allow() {
639 let config = PolicyConfig {
640 enabled: true,
641 default_effect: DefaultEffect::Allow,
642 rules: vec![],
643 policy_file: None,
644 };
645 let enforcer = PolicyEnforcer::compile(&config).unwrap();
646 let ctx = make_context(SkillTrustLevel::Trusted);
647 assert!(matches!(
648 enforcer.evaluate("bash", &empty_params(), &ctx),
649 PolicyDecision::Allow { .. }
650 ));
651 }
652
653 #[test]
656 fn test_trust_level_condition() {
657 let config = PolicyConfig {
660 enabled: true,
661 default_effect: DefaultEffect::Deny,
662 rules: vec![PolicyRuleConfig {
663 effect: PolicyEffect::Allow,
664 tool: "shell".to_owned(),
665 paths: vec![],
666 env: vec![],
667 trust_level: Some(SkillTrustLevel::Verified),
668 args_match: None,
669 capabilities: vec![],
670 }],
671 policy_file: None,
672 };
673 let enforcer = PolicyEnforcer::compile(&config).unwrap();
674
675 let trusted_ctx = make_context(SkillTrustLevel::Trusted);
676 assert!(
677 matches!(
678 enforcer.evaluate("shell", &empty_params(), &trusted_ctx),
679 PolicyDecision::Allow { .. }
680 ),
681 "Trusted (severity 0) <= Verified threshold (severity 1) -> Allow"
682 );
683
684 let quarantined_ctx = make_context(SkillTrustLevel::Quarantined);
685 assert!(
686 matches!(
687 enforcer.evaluate("shell", &empty_params(), &quarantined_ctx),
688 PolicyDecision::Deny { .. }
689 ),
690 "Quarantined (severity 2) > Verified threshold (severity 1) -> falls through to default deny"
691 );
692 }
693
694 #[test]
697 fn test_too_many_rules_rejected() {
698 let rules: Vec<PolicyRuleConfig> = (0..=MAX_RULES)
699 .map(|i| PolicyRuleConfig {
700 effect: PolicyEffect::Allow,
701 tool: format!("tool_{i}"),
702 paths: vec![],
703 env: vec![],
704 trust_level: None,
705 args_match: None,
706 capabilities: vec![],
707 })
708 .collect();
709 let config = PolicyConfig {
710 enabled: true,
711 default_effect: DefaultEffect::Deny,
712 rules,
713 policy_file: None,
714 };
715 assert!(matches!(
716 PolicyEnforcer::compile(&config),
717 Err(PolicyCompileError::TooManyRules { .. })
718 ));
719 }
720
721 #[test]
722 fn deep_dotdot_traversal_blocked_by_deny_rule() {
723 let config = PolicyConfig {
725 enabled: true,
726 default_effect: DefaultEffect::Allow,
727 rules: vec![PolicyRuleConfig {
728 effect: PolicyEffect::Deny,
729 tool: "shell".to_owned(),
730 paths: vec!["/etc/*".to_owned()],
731 env: vec![],
732 trust_level: None,
733 args_match: None,
734 capabilities: vec![],
735 }],
736 policy_file: None,
737 };
738 let enforcer = PolicyEnforcer::compile(&config).unwrap();
739 let params = make_params("file_path", "/a/b/c/d/../../../../../../etc/passwd");
740 let ctx = make_context(SkillTrustLevel::Trusted);
741 assert!(
742 matches!(
743 enforcer.evaluate("shell", ¶ms, &ctx),
744 PolicyDecision::Deny { .. }
745 ),
746 "deep .. chain traversal to /etc/passwd must be caught"
747 );
748 }
749
750 #[test]
753 fn test_args_match_matches_param_value() {
754 let config = PolicyConfig {
755 enabled: true,
756 default_effect: DefaultEffect::Allow,
757 rules: vec![PolicyRuleConfig {
758 effect: PolicyEffect::Deny,
759 tool: "bash".to_owned(),
760 paths: vec![],
761 env: vec![],
762 trust_level: None,
763 args_match: Some(".*sudo.*".to_owned()),
764 capabilities: vec![],
765 }],
766 policy_file: None,
767 };
768 let enforcer = PolicyEnforcer::compile(&config).unwrap();
769 let ctx = make_context(SkillTrustLevel::Trusted);
770
771 let params = make_params("command", "sudo rm -rf /");
772 assert!(matches!(
773 enforcer.evaluate("bash", ¶ms, &ctx),
774 PolicyDecision::Deny { .. }
775 ));
776
777 let safe_params = make_params("command", "echo hello");
778 assert!(matches!(
779 enforcer.evaluate("bash", &safe_params, &ctx),
780 PolicyDecision::Allow { .. }
781 ));
782 }
783
784 #[test]
787 fn policy_config_toml_round_trip() {
788 let toml_str = r#"
789 enabled = true
790 default_effect = "deny"
791
792 [[rules]]
793 effect = "deny"
794 tool = "shell"
795 paths = ["/etc/*"]
796
797 [[rules]]
798 effect = "allow"
799 tool = "shell"
800 paths = ["/tmp/*"]
801 trust_level = "verified"
802 "#;
803 let config: PolicyConfig = toml::from_str(toml_str).unwrap();
804 assert!(config.enabled);
805 assert_eq!(config.default_effect, DefaultEffect::Deny);
806 assert_eq!(config.rules.len(), 2);
807 assert_eq!(config.rules[0].effect, PolicyEffect::Deny);
808 assert_eq!(config.rules[0].paths[0], "/etc/*");
809 assert_eq!(config.rules[1].trust_level, Some(SkillTrustLevel::Verified));
810 }
811
812 #[test]
813 fn policy_config_default_is_disabled_deny() {
814 let config = PolicyConfig::default();
815 assert!(!config.enabled);
816 assert_eq!(config.default_effect, DefaultEffect::Deny);
817 assert!(config.rules.is_empty());
818 }
819
820 #[test]
823 fn policy_file_loaded_from_cwd_subdir() {
824 let dir = tempfile::tempdir().unwrap();
825 let original_cwd = std::env::current_dir().unwrap();
827 std::env::set_current_dir(dir.path()).unwrap();
828
829 let policy_path = dir.path().join("policy.toml");
830 std::fs::write(
831 &policy_path,
832 r#"[[rules]]
833effect = "deny"
834tool = "shell"
835"#,
836 )
837 .unwrap();
838
839 let config = PolicyConfig {
840 enabled: true,
841 default_effect: DefaultEffect::Allow,
842 rules: vec![],
843 policy_file: Some(policy_path.to_string_lossy().into_owned()),
844 };
845 let result = PolicyEnforcer::compile(&config);
846 std::env::set_current_dir(&original_cwd).unwrap();
847 assert!(result.is_ok(), "policy file within cwd must be accepted");
848 }
849
850 #[cfg(unix)]
851 #[test]
852 fn policy_file_symlink_escaping_project_root_is_rejected() {
853 use std::os::unix::fs::symlink;
854
855 let outside = tempfile::tempdir().unwrap();
856 let inside = tempfile::tempdir().unwrap();
857
858 std::fs::write(
859 outside.path().join("outside.toml"),
860 "[[rules]]\neffect = \"deny\"\ntool = \"*\"\n",
861 )
862 .unwrap();
863
864 let link = inside.path().join("evil.toml");
866 symlink(outside.path().join("outside.toml"), &link).unwrap();
867
868 let original_cwd = std::env::current_dir().unwrap();
869 std::env::set_current_dir(inside.path()).unwrap();
870
871 let config = PolicyConfig {
872 enabled: true,
873 default_effect: DefaultEffect::Allow,
874 rules: vec![],
875 policy_file: Some(link.to_string_lossy().into_owned()),
876 };
877 let result = PolicyEnforcer::compile(&config);
878 std::env::set_current_dir(&original_cwd).unwrap();
879
880 assert!(
881 matches!(result, Err(PolicyCompileError::FileEscapesRoot { .. })),
882 "symlink escaping project root must be rejected"
883 );
884 }
885
886 #[test]
890 fn alias_shell_rule_matches_bash_tool_id() {
891 let config = PolicyConfig {
892 enabled: true,
893 default_effect: DefaultEffect::Allow,
894 rules: vec![PolicyRuleConfig {
895 effect: PolicyEffect::Deny,
896 tool: "shell".to_owned(),
897 paths: vec![],
898 env: vec![],
899 trust_level: None,
900 args_match: None,
901 capabilities: vec![],
902 }],
903 policy_file: None,
904 };
905 let enforcer = PolicyEnforcer::compile(&config).unwrap();
906 let ctx = make_context(SkillTrustLevel::Trusted);
907 assert!(
908 matches!(
909 enforcer.evaluate("bash", &empty_params(), &ctx),
910 PolicyDecision::Deny { .. }
911 ),
912 "rule tool='shell' must match runtime tool_id='bash' via alias"
913 );
914 }
915
916 #[test]
918 fn alias_bash_rule_matches_bash_tool_id() {
919 let config = PolicyConfig {
920 enabled: true,
921 default_effect: DefaultEffect::Allow,
922 rules: vec![PolicyRuleConfig {
923 effect: PolicyEffect::Deny,
924 tool: "bash".to_owned(),
925 paths: vec![],
926 env: vec![],
927 trust_level: None,
928 args_match: None,
929 capabilities: vec![],
930 }],
931 policy_file: None,
932 };
933 let enforcer = PolicyEnforcer::compile(&config).unwrap();
934 let ctx = make_context(SkillTrustLevel::Trusted);
935 assert!(
936 matches!(
937 enforcer.evaluate("bash", &empty_params(), &ctx),
938 PolicyDecision::Deny { .. }
939 ),
940 "rule tool='bash' must still match runtime tool_id='bash'"
941 );
942 }
943
944 #[test]
946 fn alias_sh_rule_matches_bash_tool_id() {
947 let config = PolicyConfig {
948 enabled: true,
949 default_effect: DefaultEffect::Allow,
950 rules: vec![PolicyRuleConfig {
951 effect: PolicyEffect::Deny,
952 tool: "sh".to_owned(),
953 paths: vec![],
954 env: vec![],
955 trust_level: None,
956 args_match: None,
957 capabilities: vec![],
958 }],
959 policy_file: None,
960 };
961 let enforcer = PolicyEnforcer::compile(&config).unwrap();
962 let ctx = make_context(SkillTrustLevel::Trusted);
963 assert!(
964 matches!(
965 enforcer.evaluate("bash", &empty_params(), &ctx),
966 PolicyDecision::Deny { .. }
967 ),
968 "rule tool='sh' must match runtime tool_id='bash' via alias"
969 );
970 }
971
972 #[test]
976 fn max_rules_exactly_256_compiles() {
977 let rules: Vec<PolicyRuleConfig> = (0..MAX_RULES)
978 .map(|i| PolicyRuleConfig {
979 effect: PolicyEffect::Allow,
980 tool: format!("tool_{i}"),
981 paths: vec![],
982 env: vec![],
983 trust_level: None,
984 args_match: None,
985 capabilities: vec![],
986 })
987 .collect();
988 let config = PolicyConfig {
989 enabled: true,
990 default_effect: DefaultEffect::Deny,
991 rules,
992 policy_file: None,
993 };
994 assert!(
995 PolicyEnforcer::compile(&config).is_ok(),
996 "exactly {MAX_RULES} rules must compile successfully"
997 );
998 }
999
1000 #[test]
1008 fn policy_file_happy_path() {
1009 let cwd = std::env::current_dir().unwrap();
1010 let dir = tempfile::tempdir_in(&cwd).unwrap();
1011 let policy_path = dir.path().join("policy.toml");
1012 std::fs::write(
1013 &policy_path,
1014 "[[rules]]\neffect = \"deny\"\ntool = \"shell\"\npaths = [\"/etc/*\"]\n",
1015 )
1016 .unwrap();
1017 let config = PolicyConfig {
1018 enabled: true,
1019 default_effect: DefaultEffect::Allow,
1020 rules: vec![],
1021 policy_file: Some(policy_path.to_string_lossy().into_owned()),
1022 };
1023 let enforcer = PolicyEnforcer::compile(&config).unwrap();
1024 let params = make_params("file_path", "/etc/passwd");
1025 let ctx = make_context(SkillTrustLevel::Trusted);
1026 assert!(
1027 matches!(
1028 enforcer.evaluate("shell", ¶ms, &ctx),
1029 PolicyDecision::Deny { .. }
1030 ),
1031 "deny rule loaded from file must block the matching call"
1032 );
1033 }
1034
1035 #[test]
1037 fn policy_file_too_large() {
1038 let cwd = std::env::current_dir().unwrap();
1039 let dir = tempfile::tempdir_in(&cwd).unwrap();
1040 let policy_path = dir.path().join("big.toml");
1041 std::fs::write(&policy_path, vec![b'x'; 256 * 1024 + 1]).unwrap();
1042 let config = PolicyConfig {
1043 enabled: true,
1044 default_effect: DefaultEffect::Allow,
1045 rules: vec![],
1046 policy_file: Some(policy_path.to_string_lossy().into_owned()),
1047 };
1048 assert!(
1049 matches!(
1050 PolicyEnforcer::compile(&config),
1051 Err(PolicyCompileError::FileTooLarge { .. })
1052 ),
1053 "file exceeding 256 KiB must return FileTooLarge"
1054 );
1055 }
1056
1057 #[test]
1060 fn policy_file_load_error() {
1061 let config = PolicyConfig {
1062 enabled: true,
1063 default_effect: DefaultEffect::Allow,
1064 rules: vec![],
1065 policy_file: Some("/tmp/__zeph_no_such_policy_file__.toml".to_owned()),
1066 };
1067 assert!(
1068 matches!(
1069 PolicyEnforcer::compile(&config),
1070 Err(PolicyCompileError::FileLoad { .. })
1071 ),
1072 "nonexistent policy file must return FileLoad"
1073 );
1074 }
1075
1076 #[test]
1078 fn policy_file_parse_error() {
1079 let cwd = std::env::current_dir().unwrap();
1080 let dir = tempfile::tempdir_in(&cwd).unwrap();
1081 let policy_path = dir.path().join("bad.toml");
1082 std::fs::write(&policy_path, "not valid toml = = =\n[[[\n").unwrap();
1083 let config = PolicyConfig {
1084 enabled: true,
1085 default_effect: DefaultEffect::Allow,
1086 rules: vec![],
1087 policy_file: Some(policy_path.to_string_lossy().into_owned()),
1088 };
1089 assert!(
1090 matches!(
1091 PolicyEnforcer::compile(&config),
1092 Err(PolicyCompileError::FileParse { .. })
1093 ),
1094 "malformed TOML must return FileParse"
1095 );
1096 }
1097
1098 #[test]
1100 fn alias_unknown_tool_unaffected() {
1101 let config = PolicyConfig {
1102 enabled: true,
1103 default_effect: DefaultEffect::Allow,
1104 rules: vec![PolicyRuleConfig {
1105 effect: PolicyEffect::Deny,
1106 tool: "shell".to_owned(),
1107 paths: vec![],
1108 env: vec![],
1109 trust_level: None,
1110 args_match: None,
1111 capabilities: vec![],
1112 }],
1113 policy_file: None,
1114 };
1115 let enforcer = PolicyEnforcer::compile(&config).unwrap();
1116 let ctx = make_context(SkillTrustLevel::Trusted);
1117 assert!(
1119 matches!(
1120 enforcer.evaluate("web_scrape", &empty_params(), &ctx),
1121 PolicyDecision::Allow { .. }
1122 ),
1123 "unknown tool names must not be affected by alias resolution"
1124 );
1125 }
1126}