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