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 _ => PolicyDecision::Deny {
277 trace: "default: deny (unknown effect)".to_owned(),
278 },
279 }
280 }
281}
282
283fn resolve_tool_alias(name: &str) -> &str {
288 match name {
289 "bash" | "sh" => "shell",
290 other => other,
291 }
292}
293
294fn load_policy_file(path: &Path) -> Result<Vec<PolicyRuleConfig>, PolicyCompileError> {
301 const MAX_POLICY_FILE_BYTES: u64 = 256 * 1024;
303
304 #[derive(Deserialize)]
305 struct PolicyFile {
306 #[serde(default)]
307 rules: Vec<PolicyRuleConfig>,
308 }
309
310 let canonical = std::fs::canonicalize(path).map_err(|source| PolicyCompileError::FileLoad {
312 path: path.to_owned(),
313 source,
314 })?;
315
316 let canonical_base = std::env::current_dir()
318 .and_then(std::fs::canonicalize)
319 .map_err(|source| PolicyCompileError::FileLoad {
320 path: path.to_owned(),
321 source,
322 })?;
323
324 if !canonical.starts_with(&canonical_base) {
325 tracing::warn!(
326 path = %canonical.display(),
327 "policy file escapes project root, rejecting"
328 );
329 return Err(PolicyCompileError::FileEscapesRoot {
330 path: path.to_owned(),
331 });
332 }
333
334 let meta = std::fs::metadata(&canonical).map_err(|source| PolicyCompileError::FileLoad {
336 path: path.to_owned(),
337 source,
338 })?;
339 if meta.len() > MAX_POLICY_FILE_BYTES {
340 return Err(PolicyCompileError::FileTooLarge {
341 path: path.to_owned(),
342 });
343 }
344
345 let content =
346 std::fs::read_to_string(&canonical).map_err(|source| PolicyCompileError::FileLoad {
347 path: path.to_owned(),
348 source,
349 })?;
350
351 let parsed: PolicyFile =
352 toml::from_str(&content).map_err(|source| PolicyCompileError::FileParse {
353 path: path.to_owned(),
354 source,
355 })?;
356
357 Ok(parsed.rules)
358}
359
360fn extract_paths(params: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
365 static ABS_PATH_RE: std::sync::LazyLock<Regex> =
366 std::sync::LazyLock::new(|| Regex::new(r"(/[^\s;|&<>]+)").expect("valid regex"));
367
368 let mut paths = Vec::new();
369
370 for key in &["file_path", "path", "uri", "url", "query"] {
371 if let Some(v) = params.get(*key).and_then(|v| v.as_str()) {
372 paths.push(v.to_owned());
373 }
374 }
375
376 if let Some(cmd) = params.get("command").and_then(|v| v.as_str()) {
378 for cap in ABS_PATH_RE.captures_iter(cmd) {
379 if let Some(m) = cap.get(1) {
380 paths.push(m.as_str().to_owned());
381 }
382 }
383 }
384
385 paths
386}
387
388#[cfg(test)]
389mod tests {
390 use std::collections::HashMap;
391
392 use super::*;
393
394 fn make_context(trust: SkillTrustLevel) -> PolicyContext {
395 PolicyContext {
396 trust_level: trust,
397 env: HashMap::new(),
398 }
399 }
400
401 fn make_params(key: &str, value: &str) -> serde_json::Map<String, serde_json::Value> {
402 let mut m = serde_json::Map::new();
403 m.insert(key.to_owned(), serde_json::Value::String(value.to_owned()));
404 m
405 }
406
407 fn empty_params() -> serde_json::Map<String, serde_json::Value> {
408 serde_json::Map::new()
409 }
410
411 #[test]
414 fn test_path_normalization() {
415 let config = PolicyConfig {
417 enabled: true,
418 default_effect: DefaultEffect::Allow,
419 rules: vec![PolicyRuleConfig {
420 effect: PolicyEffect::Deny,
421 tool: "shell".to_owned(),
422 paths: vec!["/etc/*".to_owned()],
423 env: vec![],
424 trust_level: None,
425 args_match: None,
426 capabilities: vec![],
427 }],
428 policy_file: None,
429 };
430 let enforcer = PolicyEnforcer::compile(&config).unwrap();
431 let params = make_params("file_path", "/tmp/../etc/passwd");
432 let ctx = make_context(SkillTrustLevel::Trusted);
433 assert!(
434 matches!(
435 enforcer.evaluate("shell", ¶ms, &ctx),
436 PolicyDecision::Deny { .. }
437 ),
438 "path traversal must be caught after normalization"
439 );
440 }
441
442 #[test]
443 fn test_path_normalization_dot_segments() {
444 let config = PolicyConfig {
445 enabled: true,
446 default_effect: DefaultEffect::Allow,
447 rules: vec![PolicyRuleConfig {
448 effect: PolicyEffect::Deny,
449 tool: "shell".to_owned(),
450 paths: vec!["/etc/*".to_owned()],
451 env: vec![],
452 trust_level: None,
453 args_match: None,
454 capabilities: vec![],
455 }],
456 policy_file: None,
457 };
458 let enforcer = PolicyEnforcer::compile(&config).unwrap();
459 let params = make_params("file_path", "/etc/./shadow");
460 let ctx = make_context(SkillTrustLevel::Trusted);
461 assert!(matches!(
462 enforcer.evaluate("shell", ¶ms, &ctx),
463 PolicyDecision::Deny { .. }
464 ));
465 }
466
467 #[test]
470 fn test_tool_name_normalization() {
471 let config = PolicyConfig {
473 enabled: true,
474 default_effect: DefaultEffect::Allow,
475 rules: vec![PolicyRuleConfig {
476 effect: PolicyEffect::Deny,
477 tool: "Shell".to_owned(),
478 paths: vec![],
479 env: vec![],
480 trust_level: None,
481 args_match: None,
482 capabilities: vec![],
483 }],
484 policy_file: None,
485 };
486 let enforcer = PolicyEnforcer::compile(&config).unwrap();
487 let ctx = make_context(SkillTrustLevel::Trusted);
488 assert!(matches!(
489 enforcer.evaluate("shell", &empty_params(), &ctx),
490 PolicyDecision::Deny { .. }
491 ));
492 assert!(matches!(
494 enforcer.evaluate("SHELL", &empty_params(), &ctx),
495 PolicyDecision::Deny { .. }
496 ));
497 }
498
499 #[test]
502 fn test_deny_wins() {
503 let config = PolicyConfig {
505 enabled: true,
506 default_effect: DefaultEffect::Allow,
507 rules: vec![
508 PolicyRuleConfig {
509 effect: PolicyEffect::Allow,
510 tool: "shell".to_owned(),
511 paths: vec!["/tmp/*".to_owned()],
512 env: vec![],
513 trust_level: None,
514 args_match: None,
515 capabilities: vec![],
516 },
517 PolicyRuleConfig {
518 effect: PolicyEffect::Deny,
519 tool: "shell".to_owned(),
520 paths: vec!["/tmp/secret.sh".to_owned()],
521 env: vec![],
522 trust_level: None,
523 args_match: None,
524 capabilities: vec![],
525 },
526 ],
527 policy_file: None,
528 };
529 let enforcer = PolicyEnforcer::compile(&config).unwrap();
530 let params = make_params("file_path", "/tmp/secret.sh");
531 let ctx = make_context(SkillTrustLevel::Trusted);
532 assert!(
533 matches!(
534 enforcer.evaluate("shell", ¶ms, &ctx),
535 PolicyDecision::Deny { .. }
536 ),
537 "deny must win over allow for the same path"
538 );
539 }
540
541 #[test]
543 fn deny_wins_deny_first() {
544 let config = PolicyConfig {
546 enabled: true,
547 default_effect: DefaultEffect::Allow,
548 rules: vec![
549 PolicyRuleConfig {
550 effect: PolicyEffect::Deny,
551 tool: "shell".to_owned(),
552 paths: vec!["/etc/*".to_owned()],
553 env: vec![],
554 trust_level: None,
555 args_match: None,
556 capabilities: vec![],
557 },
558 PolicyRuleConfig {
559 effect: PolicyEffect::Allow,
560 tool: "shell".to_owned(),
561 paths: vec!["/etc/*".to_owned()],
562 env: vec![],
563 trust_level: None,
564 args_match: None,
565 capabilities: vec![],
566 },
567 ],
568 policy_file: None,
569 };
570 let enforcer = PolicyEnforcer::compile(&config).unwrap();
571 let params = make_params("file_path", "/etc/passwd");
572 let ctx = make_context(SkillTrustLevel::Trusted);
573 assert!(
574 matches!(
575 enforcer.evaluate("shell", ¶ms, &ctx),
576 PolicyDecision::Deny { .. }
577 ),
578 "deny must win when deny rule is first"
579 );
580 }
581
582 #[test]
583 fn deny_wins_deny_last() {
584 let config = PolicyConfig {
586 enabled: true,
587 default_effect: DefaultEffect::Allow,
588 rules: vec![
589 PolicyRuleConfig {
590 effect: PolicyEffect::Allow,
591 tool: "shell".to_owned(),
592 paths: vec!["/etc/*".to_owned()],
593 env: vec![],
594 trust_level: None,
595 args_match: None,
596 capabilities: vec![],
597 },
598 PolicyRuleConfig {
599 effect: PolicyEffect::Deny,
600 tool: "shell".to_owned(),
601 paths: vec!["/etc/*".to_owned()],
602 env: vec![],
603 trust_level: None,
604 args_match: None,
605 capabilities: vec![],
606 },
607 ],
608 policy_file: None,
609 };
610 let enforcer = PolicyEnforcer::compile(&config).unwrap();
611 let params = make_params("file_path", "/etc/passwd");
612 let ctx = make_context(SkillTrustLevel::Trusted);
613 assert!(
614 matches!(
615 enforcer.evaluate("shell", ¶ms, &ctx),
616 PolicyDecision::Deny { .. }
617 ),
618 "deny must win even when deny rule is last"
619 );
620 }
621
622 #[test]
625 fn test_default_deny() {
626 let config = PolicyConfig {
627 enabled: true,
628 default_effect: DefaultEffect::Deny,
629 rules: vec![],
630 policy_file: None,
631 };
632 let enforcer = PolicyEnforcer::compile(&config).unwrap();
633 let ctx = make_context(SkillTrustLevel::Trusted);
634 assert!(matches!(
635 enforcer.evaluate("bash", &empty_params(), &ctx),
636 PolicyDecision::Deny { .. }
637 ));
638 }
639
640 #[test]
641 fn test_default_allow() {
642 let config = PolicyConfig {
643 enabled: true,
644 default_effect: DefaultEffect::Allow,
645 rules: vec![],
646 policy_file: None,
647 };
648 let enforcer = PolicyEnforcer::compile(&config).unwrap();
649 let ctx = make_context(SkillTrustLevel::Trusted);
650 assert!(matches!(
651 enforcer.evaluate("bash", &empty_params(), &ctx),
652 PolicyDecision::Allow { .. }
653 ));
654 }
655
656 #[test]
659 fn test_trust_level_condition() {
660 let config = PolicyConfig {
663 enabled: true,
664 default_effect: DefaultEffect::Deny,
665 rules: vec![PolicyRuleConfig {
666 effect: PolicyEffect::Allow,
667 tool: "shell".to_owned(),
668 paths: vec![],
669 env: vec![],
670 trust_level: Some(SkillTrustLevel::Verified),
671 args_match: None,
672 capabilities: vec![],
673 }],
674 policy_file: None,
675 };
676 let enforcer = PolicyEnforcer::compile(&config).unwrap();
677
678 let trusted_ctx = make_context(SkillTrustLevel::Trusted);
679 assert!(
680 matches!(
681 enforcer.evaluate("shell", &empty_params(), &trusted_ctx),
682 PolicyDecision::Allow { .. }
683 ),
684 "Trusted (severity 0) <= Verified threshold (severity 1) -> Allow"
685 );
686
687 let quarantined_ctx = make_context(SkillTrustLevel::Quarantined);
688 assert!(
689 matches!(
690 enforcer.evaluate("shell", &empty_params(), &quarantined_ctx),
691 PolicyDecision::Deny { .. }
692 ),
693 "Quarantined (severity 2) > Verified threshold (severity 1) -> falls through to default deny"
694 );
695 }
696
697 #[test]
700 fn test_too_many_rules_rejected() {
701 let rules: Vec<PolicyRuleConfig> = (0..=MAX_RULES)
702 .map(|i| PolicyRuleConfig {
703 effect: PolicyEffect::Allow,
704 tool: format!("tool_{i}"),
705 paths: vec![],
706 env: vec![],
707 trust_level: None,
708 args_match: None,
709 capabilities: vec![],
710 })
711 .collect();
712 let config = PolicyConfig {
713 enabled: true,
714 default_effect: DefaultEffect::Deny,
715 rules,
716 policy_file: None,
717 };
718 assert!(matches!(
719 PolicyEnforcer::compile(&config),
720 Err(PolicyCompileError::TooManyRules { .. })
721 ));
722 }
723
724 #[test]
725 fn deep_dotdot_traversal_blocked_by_deny_rule() {
726 let config = PolicyConfig {
728 enabled: true,
729 default_effect: DefaultEffect::Allow,
730 rules: vec![PolicyRuleConfig {
731 effect: PolicyEffect::Deny,
732 tool: "shell".to_owned(),
733 paths: vec!["/etc/*".to_owned()],
734 env: vec![],
735 trust_level: None,
736 args_match: None,
737 capabilities: vec![],
738 }],
739 policy_file: None,
740 };
741 let enforcer = PolicyEnforcer::compile(&config).unwrap();
742 let params = make_params("file_path", "/a/b/c/d/../../../../../../etc/passwd");
743 let ctx = make_context(SkillTrustLevel::Trusted);
744 assert!(
745 matches!(
746 enforcer.evaluate("shell", ¶ms, &ctx),
747 PolicyDecision::Deny { .. }
748 ),
749 "deep .. chain traversal to /etc/passwd must be caught"
750 );
751 }
752
753 #[test]
756 fn test_args_match_matches_param_value() {
757 let config = PolicyConfig {
758 enabled: true,
759 default_effect: DefaultEffect::Allow,
760 rules: vec![PolicyRuleConfig {
761 effect: PolicyEffect::Deny,
762 tool: "bash".to_owned(),
763 paths: vec![],
764 env: vec![],
765 trust_level: None,
766 args_match: Some(".*sudo.*".to_owned()),
767 capabilities: vec![],
768 }],
769 policy_file: None,
770 };
771 let enforcer = PolicyEnforcer::compile(&config).unwrap();
772 let ctx = make_context(SkillTrustLevel::Trusted);
773
774 let params = make_params("command", "sudo rm -rf /");
775 assert!(matches!(
776 enforcer.evaluate("bash", ¶ms, &ctx),
777 PolicyDecision::Deny { .. }
778 ));
779
780 let safe_params = make_params("command", "echo hello");
781 assert!(matches!(
782 enforcer.evaluate("bash", &safe_params, &ctx),
783 PolicyDecision::Allow { .. }
784 ));
785 }
786
787 #[test]
790 fn policy_config_toml_round_trip() {
791 let toml_str = r#"
792 enabled = true
793 default_effect = "deny"
794
795 [[rules]]
796 effect = "deny"
797 tool = "shell"
798 paths = ["/etc/*"]
799
800 [[rules]]
801 effect = "allow"
802 tool = "shell"
803 paths = ["/tmp/*"]
804 trust_level = "verified"
805 "#;
806 let config: PolicyConfig = toml::from_str(toml_str).unwrap();
807 assert!(config.enabled);
808 assert_eq!(config.default_effect, DefaultEffect::Deny);
809 assert_eq!(config.rules.len(), 2);
810 assert_eq!(config.rules[0].effect, PolicyEffect::Deny);
811 assert_eq!(config.rules[0].paths[0], "/etc/*");
812 assert_eq!(config.rules[1].trust_level, Some(SkillTrustLevel::Verified));
813 }
814
815 #[test]
816 fn policy_config_default_is_disabled_deny() {
817 let config = PolicyConfig::default();
818 assert!(!config.enabled);
819 assert_eq!(config.default_effect, DefaultEffect::Deny);
820 assert!(config.rules.is_empty());
821 }
822
823 #[test]
826 fn policy_file_loaded_from_cwd_subdir() {
827 let dir = tempfile::tempdir().unwrap();
828 let original_cwd = std::env::current_dir().unwrap();
830 std::env::set_current_dir(dir.path()).unwrap();
831
832 let policy_path = dir.path().join("policy.toml");
833 std::fs::write(
834 &policy_path,
835 r#"[[rules]]
836effect = "deny"
837tool = "shell"
838"#,
839 )
840 .unwrap();
841
842 let config = PolicyConfig {
843 enabled: true,
844 default_effect: DefaultEffect::Allow,
845 rules: vec![],
846 policy_file: Some(policy_path.to_string_lossy().into_owned()),
847 };
848 let result = PolicyEnforcer::compile(&config);
849 std::env::set_current_dir(&original_cwd).unwrap();
850 assert!(result.is_ok(), "policy file within cwd must be accepted");
851 }
852
853 #[cfg(unix)]
854 #[test]
855 fn policy_file_symlink_escaping_project_root_is_rejected() {
856 use std::os::unix::fs::symlink;
857
858 let outside = tempfile::tempdir().unwrap();
859 let inside = tempfile::tempdir().unwrap();
860
861 std::fs::write(
862 outside.path().join("outside.toml"),
863 "[[rules]]\neffect = \"deny\"\ntool = \"*\"\n",
864 )
865 .unwrap();
866
867 let link = inside.path().join("evil.toml");
869 symlink(outside.path().join("outside.toml"), &link).unwrap();
870
871 let original_cwd = std::env::current_dir().unwrap();
872 std::env::set_current_dir(inside.path()).unwrap();
873
874 let config = PolicyConfig {
875 enabled: true,
876 default_effect: DefaultEffect::Allow,
877 rules: vec![],
878 policy_file: Some(link.to_string_lossy().into_owned()),
879 };
880 let result = PolicyEnforcer::compile(&config);
881 std::env::set_current_dir(&original_cwd).unwrap();
882
883 assert!(
884 matches!(result, Err(PolicyCompileError::FileEscapesRoot { .. })),
885 "symlink escaping project root must be rejected"
886 );
887 }
888
889 #[test]
893 fn alias_shell_rule_matches_bash_tool_id() {
894 let config = PolicyConfig {
895 enabled: true,
896 default_effect: DefaultEffect::Allow,
897 rules: vec![PolicyRuleConfig {
898 effect: PolicyEffect::Deny,
899 tool: "shell".to_owned(),
900 paths: vec![],
901 env: vec![],
902 trust_level: None,
903 args_match: None,
904 capabilities: vec![],
905 }],
906 policy_file: None,
907 };
908 let enforcer = PolicyEnforcer::compile(&config).unwrap();
909 let ctx = make_context(SkillTrustLevel::Trusted);
910 assert!(
911 matches!(
912 enforcer.evaluate("bash", &empty_params(), &ctx),
913 PolicyDecision::Deny { .. }
914 ),
915 "rule tool='shell' must match runtime tool_id='bash' via alias"
916 );
917 }
918
919 #[test]
921 fn alias_bash_rule_matches_bash_tool_id() {
922 let config = PolicyConfig {
923 enabled: true,
924 default_effect: DefaultEffect::Allow,
925 rules: vec![PolicyRuleConfig {
926 effect: PolicyEffect::Deny,
927 tool: "bash".to_owned(),
928 paths: vec![],
929 env: vec![],
930 trust_level: None,
931 args_match: None,
932 capabilities: vec![],
933 }],
934 policy_file: None,
935 };
936 let enforcer = PolicyEnforcer::compile(&config).unwrap();
937 let ctx = make_context(SkillTrustLevel::Trusted);
938 assert!(
939 matches!(
940 enforcer.evaluate("bash", &empty_params(), &ctx),
941 PolicyDecision::Deny { .. }
942 ),
943 "rule tool='bash' must still match runtime tool_id='bash'"
944 );
945 }
946
947 #[test]
949 fn alias_sh_rule_matches_bash_tool_id() {
950 let config = PolicyConfig {
951 enabled: true,
952 default_effect: DefaultEffect::Allow,
953 rules: vec![PolicyRuleConfig {
954 effect: PolicyEffect::Deny,
955 tool: "sh".to_owned(),
956 paths: vec![],
957 env: vec![],
958 trust_level: None,
959 args_match: None,
960 capabilities: vec![],
961 }],
962 policy_file: None,
963 };
964 let enforcer = PolicyEnforcer::compile(&config).unwrap();
965 let ctx = make_context(SkillTrustLevel::Trusted);
966 assert!(
967 matches!(
968 enforcer.evaluate("bash", &empty_params(), &ctx),
969 PolicyDecision::Deny { .. }
970 ),
971 "rule tool='sh' must match runtime tool_id='bash' via alias"
972 );
973 }
974
975 #[test]
979 fn max_rules_exactly_256_compiles() {
980 let rules: Vec<PolicyRuleConfig> = (0..MAX_RULES)
981 .map(|i| PolicyRuleConfig {
982 effect: PolicyEffect::Allow,
983 tool: format!("tool_{i}"),
984 paths: vec![],
985 env: vec![],
986 trust_level: None,
987 args_match: None,
988 capabilities: vec![],
989 })
990 .collect();
991 let config = PolicyConfig {
992 enabled: true,
993 default_effect: DefaultEffect::Deny,
994 rules,
995 policy_file: None,
996 };
997 assert!(
998 PolicyEnforcer::compile(&config).is_ok(),
999 "exactly {MAX_RULES} rules must compile successfully"
1000 );
1001 }
1002
1003 #[test]
1011 fn policy_file_happy_path() {
1012 let cwd = std::env::current_dir().unwrap();
1013 let dir = tempfile::tempdir_in(&cwd).unwrap();
1014 let policy_path = dir.path().join("policy.toml");
1015 std::fs::write(
1016 &policy_path,
1017 "[[rules]]\neffect = \"deny\"\ntool = \"shell\"\npaths = [\"/etc/*\"]\n",
1018 )
1019 .unwrap();
1020 let config = PolicyConfig {
1021 enabled: true,
1022 default_effect: DefaultEffect::Allow,
1023 rules: vec![],
1024 policy_file: Some(policy_path.to_string_lossy().into_owned()),
1025 };
1026 let enforcer = PolicyEnforcer::compile(&config).unwrap();
1027 let params = make_params("file_path", "/etc/passwd");
1028 let ctx = make_context(SkillTrustLevel::Trusted);
1029 assert!(
1030 matches!(
1031 enforcer.evaluate("shell", ¶ms, &ctx),
1032 PolicyDecision::Deny { .. }
1033 ),
1034 "deny rule loaded from file must block the matching call"
1035 );
1036 }
1037
1038 #[test]
1040 fn policy_file_too_large() {
1041 let cwd = std::env::current_dir().unwrap();
1042 let dir = tempfile::tempdir_in(&cwd).unwrap();
1043 let policy_path = dir.path().join("big.toml");
1044 std::fs::write(&policy_path, vec![b'x'; 256 * 1024 + 1]).unwrap();
1045 let config = PolicyConfig {
1046 enabled: true,
1047 default_effect: DefaultEffect::Allow,
1048 rules: vec![],
1049 policy_file: Some(policy_path.to_string_lossy().into_owned()),
1050 };
1051 assert!(
1052 matches!(
1053 PolicyEnforcer::compile(&config),
1054 Err(PolicyCompileError::FileTooLarge { .. })
1055 ),
1056 "file exceeding 256 KiB must return FileTooLarge"
1057 );
1058 }
1059
1060 #[test]
1063 fn policy_file_load_error() {
1064 let config = PolicyConfig {
1065 enabled: true,
1066 default_effect: DefaultEffect::Allow,
1067 rules: vec![],
1068 policy_file: Some("/tmp/__zeph_no_such_policy_file__.toml".to_owned()),
1069 };
1070 assert!(
1071 matches!(
1072 PolicyEnforcer::compile(&config),
1073 Err(PolicyCompileError::FileLoad { .. })
1074 ),
1075 "nonexistent policy file must return FileLoad"
1076 );
1077 }
1078
1079 #[test]
1081 fn policy_file_parse_error() {
1082 let cwd = std::env::current_dir().unwrap();
1083 let dir = tempfile::tempdir_in(&cwd).unwrap();
1084 let policy_path = dir.path().join("bad.toml");
1085 std::fs::write(&policy_path, "not valid toml = = =\n[[[\n").unwrap();
1086 let config = PolicyConfig {
1087 enabled: true,
1088 default_effect: DefaultEffect::Allow,
1089 rules: vec![],
1090 policy_file: Some(policy_path.to_string_lossy().into_owned()),
1091 };
1092 assert!(
1093 matches!(
1094 PolicyEnforcer::compile(&config),
1095 Err(PolicyCompileError::FileParse { .. })
1096 ),
1097 "malformed TOML must return FileParse"
1098 );
1099 }
1100
1101 #[test]
1103 fn alias_unknown_tool_unaffected() {
1104 let config = PolicyConfig {
1105 enabled: true,
1106 default_effect: DefaultEffect::Allow,
1107 rules: vec![PolicyRuleConfig {
1108 effect: PolicyEffect::Deny,
1109 tool: "shell".to_owned(),
1110 paths: vec![],
1111 env: vec![],
1112 trust_level: None,
1113 args_match: None,
1114 capabilities: vec![],
1115 }],
1116 policy_file: None,
1117 };
1118 let enforcer = PolicyEnforcer::compile(&config).unwrap();
1119 let ctx = make_context(SkillTrustLevel::Trusted);
1120 assert!(
1122 matches!(
1123 enforcer.evaluate("web_scrape", &empty_params(), &ctx),
1124 PolicyDecision::Allow { .. }
1125 ),
1126 "unknown tool names must not be affected by alias resolution"
1127 );
1128 }
1129}