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::assert_matches;
393 use std::collections::HashMap;
394
395 use zeph_config::ProviderName;
396
397 use super::*;
398
399 fn make_context(trust: SkillTrustLevel) -> PolicyContext {
400 PolicyContext {
401 trust_level: trust,
402 env: HashMap::new(),
403 }
404 }
405
406 fn make_params(key: &str, value: &str) -> serde_json::Map<String, serde_json::Value> {
407 let mut m = serde_json::Map::new();
408 m.insert(key.to_owned(), serde_json::Value::String(value.to_owned()));
409 m
410 }
411
412 fn empty_params() -> serde_json::Map<String, serde_json::Value> {
413 serde_json::Map::new()
414 }
415
416 #[test]
419 fn test_path_normalization() {
420 let config = PolicyConfig {
422 enabled: true,
423 default_effect: DefaultEffect::Allow,
424 rules: vec![PolicyRuleConfig {
425 effect: PolicyEffect::Deny,
426 tool: "shell".to_owned(),
427 paths: vec!["/etc/*".to_owned()],
428 env: vec![],
429 trust_level: None,
430 args_match: None,
431 capabilities: vec![],
432 }],
433 policy_file: None,
434 policy_provider: ProviderName::default(),
435 };
436 let enforcer = PolicyEnforcer::compile(&config).unwrap();
437 let params = make_params("file_path", "/tmp/../etc/passwd");
438 let ctx = make_context(SkillTrustLevel::Trusted);
439 assert!(
440 matches!(
441 enforcer.evaluate("shell", ¶ms, &ctx),
442 PolicyDecision::Deny { .. }
443 ),
444 "path traversal must be caught after normalization"
445 );
446 }
447
448 #[test]
449 fn test_path_normalization_dot_segments() {
450 let config = PolicyConfig {
451 enabled: true,
452 default_effect: DefaultEffect::Allow,
453 rules: vec![PolicyRuleConfig {
454 effect: PolicyEffect::Deny,
455 tool: "shell".to_owned(),
456 paths: vec!["/etc/*".to_owned()],
457 env: vec![],
458 trust_level: None,
459 args_match: None,
460 capabilities: vec![],
461 }],
462 policy_file: None,
463 policy_provider: ProviderName::default(),
464 };
465 let enforcer = PolicyEnforcer::compile(&config).unwrap();
466 let params = make_params("file_path", "/etc/./shadow");
467 let ctx = make_context(SkillTrustLevel::Trusted);
468 assert_matches!(
469 enforcer.evaluate("shell", ¶ms, &ctx),
470 PolicyDecision::Deny { .. }
471 );
472 }
473
474 #[test]
477 fn test_tool_name_normalization() {
478 let config = PolicyConfig {
480 enabled: true,
481 default_effect: DefaultEffect::Allow,
482 rules: vec![PolicyRuleConfig {
483 effect: PolicyEffect::Deny,
484 tool: "Shell".to_owned(),
485 paths: vec![],
486 env: vec![],
487 trust_level: None,
488 args_match: None,
489 capabilities: vec![],
490 }],
491 policy_file: None,
492 policy_provider: ProviderName::default(),
493 };
494 let enforcer = PolicyEnforcer::compile(&config).unwrap();
495 let ctx = make_context(SkillTrustLevel::Trusted);
496 assert_matches!(
497 enforcer.evaluate("shell", &empty_params(), &ctx),
498 PolicyDecision::Deny { .. }
499 );
500 assert_matches!(
502 enforcer.evaluate("SHELL", &empty_params(), &ctx),
503 PolicyDecision::Deny { .. }
504 );
505 }
506
507 #[test]
510 fn test_deny_wins() {
511 let config = PolicyConfig {
513 enabled: true,
514 default_effect: DefaultEffect::Allow,
515 rules: vec![
516 PolicyRuleConfig {
517 effect: PolicyEffect::Allow,
518 tool: "shell".to_owned(),
519 paths: vec!["/tmp/*".to_owned()],
520 env: vec![],
521 trust_level: None,
522 args_match: None,
523 capabilities: vec![],
524 },
525 PolicyRuleConfig {
526 effect: PolicyEffect::Deny,
527 tool: "shell".to_owned(),
528 paths: vec!["/tmp/secret.sh".to_owned()],
529 env: vec![],
530 trust_level: None,
531 args_match: None,
532 capabilities: vec![],
533 },
534 ],
535 policy_file: None,
536 policy_provider: ProviderName::default(),
537 };
538 let enforcer = PolicyEnforcer::compile(&config).unwrap();
539 let params = make_params("file_path", "/tmp/secret.sh");
540 let ctx = make_context(SkillTrustLevel::Trusted);
541 assert!(
542 matches!(
543 enforcer.evaluate("shell", ¶ms, &ctx),
544 PolicyDecision::Deny { .. }
545 ),
546 "deny must win over allow for the same path"
547 );
548 }
549
550 #[test]
552 fn deny_wins_deny_first() {
553 let config = PolicyConfig {
555 enabled: true,
556 default_effect: DefaultEffect::Allow,
557 rules: vec![
558 PolicyRuleConfig {
559 effect: PolicyEffect::Deny,
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 PolicyRuleConfig {
568 effect: PolicyEffect::Allow,
569 tool: "shell".to_owned(),
570 paths: vec!["/etc/*".to_owned()],
571 env: vec![],
572 trust_level: None,
573 args_match: None,
574 capabilities: vec![],
575 },
576 ],
577 policy_file: None,
578 policy_provider: ProviderName::default(),
579 };
580 let enforcer = PolicyEnforcer::compile(&config).unwrap();
581 let params = make_params("file_path", "/etc/passwd");
582 let ctx = make_context(SkillTrustLevel::Trusted);
583 assert!(
584 matches!(
585 enforcer.evaluate("shell", ¶ms, &ctx),
586 PolicyDecision::Deny { .. }
587 ),
588 "deny must win when deny rule is first"
589 );
590 }
591
592 #[test]
593 fn deny_wins_deny_last() {
594 let config = PolicyConfig {
596 enabled: true,
597 default_effect: DefaultEffect::Allow,
598 rules: vec![
599 PolicyRuleConfig {
600 effect: PolicyEffect::Allow,
601 tool: "shell".to_owned(),
602 paths: vec!["/etc/*".to_owned()],
603 env: vec![],
604 trust_level: None,
605 args_match: None,
606 capabilities: vec![],
607 },
608 PolicyRuleConfig {
609 effect: PolicyEffect::Deny,
610 tool: "shell".to_owned(),
611 paths: vec!["/etc/*".to_owned()],
612 env: vec![],
613 trust_level: None,
614 args_match: None,
615 capabilities: vec![],
616 },
617 ],
618 policy_file: None,
619 policy_provider: ProviderName::default(),
620 };
621 let enforcer = PolicyEnforcer::compile(&config).unwrap();
622 let params = make_params("file_path", "/etc/passwd");
623 let ctx = make_context(SkillTrustLevel::Trusted);
624 assert!(
625 matches!(
626 enforcer.evaluate("shell", ¶ms, &ctx),
627 PolicyDecision::Deny { .. }
628 ),
629 "deny must win even when deny rule is last"
630 );
631 }
632
633 #[test]
636 fn test_default_deny() {
637 let config = PolicyConfig {
638 enabled: true,
639 default_effect: DefaultEffect::Deny,
640 rules: vec![],
641 policy_file: None,
642 policy_provider: ProviderName::default(),
643 };
644 let enforcer = PolicyEnforcer::compile(&config).unwrap();
645 let ctx = make_context(SkillTrustLevel::Trusted);
646 assert_matches!(
647 enforcer.evaluate("bash", &empty_params(), &ctx),
648 PolicyDecision::Deny { .. }
649 );
650 }
651
652 #[test]
653 fn test_default_allow() {
654 let config = PolicyConfig {
655 enabled: true,
656 default_effect: DefaultEffect::Allow,
657 rules: vec![],
658 policy_file: None,
659 policy_provider: ProviderName::default(),
660 };
661 let enforcer = PolicyEnforcer::compile(&config).unwrap();
662 let ctx = make_context(SkillTrustLevel::Trusted);
663 assert_matches!(
664 enforcer.evaluate("bash", &empty_params(), &ctx),
665 PolicyDecision::Allow { .. }
666 );
667 }
668
669 #[test]
672 fn test_trust_level_condition() {
673 let config = PolicyConfig {
676 enabled: true,
677 default_effect: DefaultEffect::Deny,
678 rules: vec![PolicyRuleConfig {
679 effect: PolicyEffect::Allow,
680 tool: "shell".to_owned(),
681 paths: vec![],
682 env: vec![],
683 trust_level: Some(SkillTrustLevel::Verified),
684 args_match: None,
685 capabilities: vec![],
686 }],
687 policy_file: None,
688 policy_provider: ProviderName::default(),
689 };
690 let enforcer = PolicyEnforcer::compile(&config).unwrap();
691
692 let trusted_ctx = make_context(SkillTrustLevel::Trusted);
693 assert!(
694 matches!(
695 enforcer.evaluate("shell", &empty_params(), &trusted_ctx),
696 PolicyDecision::Allow { .. }
697 ),
698 "Trusted (severity 0) <= Verified threshold (severity 1) -> Allow"
699 );
700
701 let quarantined_ctx = make_context(SkillTrustLevel::Quarantined);
702 assert!(
703 matches!(
704 enforcer.evaluate("shell", &empty_params(), &quarantined_ctx),
705 PolicyDecision::Deny { .. }
706 ),
707 "Quarantined (severity 2) > Verified threshold (severity 1) -> falls through to default deny"
708 );
709 }
710
711 #[test]
714 fn test_too_many_rules_rejected() {
715 let rules: Vec<PolicyRuleConfig> = (0..=MAX_RULES)
716 .map(|i| PolicyRuleConfig {
717 effect: PolicyEffect::Allow,
718 tool: format!("tool_{i}"),
719 paths: vec![],
720 env: vec![],
721 trust_level: None,
722 args_match: None,
723 capabilities: vec![],
724 })
725 .collect();
726 let config = PolicyConfig {
727 enabled: true,
728 default_effect: DefaultEffect::Deny,
729 rules,
730 policy_file: None,
731 policy_provider: ProviderName::default(),
732 };
733 assert_matches!(
734 PolicyEnforcer::compile(&config),
735 Err(PolicyCompileError::TooManyRules { .. })
736 );
737 }
738
739 #[test]
740 fn deep_dotdot_traversal_blocked_by_deny_rule() {
741 let config = PolicyConfig {
743 enabled: true,
744 default_effect: DefaultEffect::Allow,
745 rules: vec![PolicyRuleConfig {
746 effect: PolicyEffect::Deny,
747 tool: "shell".to_owned(),
748 paths: vec!["/etc/*".to_owned()],
749 env: vec![],
750 trust_level: None,
751 args_match: None,
752 capabilities: vec![],
753 }],
754 policy_file: None,
755 policy_provider: ProviderName::default(),
756 };
757 let enforcer = PolicyEnforcer::compile(&config).unwrap();
758 let params = make_params("file_path", "/a/b/c/d/../../../../../../etc/passwd");
759 let ctx = make_context(SkillTrustLevel::Trusted);
760 assert!(
761 matches!(
762 enforcer.evaluate("shell", ¶ms, &ctx),
763 PolicyDecision::Deny { .. }
764 ),
765 "deep .. chain traversal to /etc/passwd must be caught"
766 );
767 }
768
769 #[test]
772 fn test_args_match_matches_param_value() {
773 let config = PolicyConfig {
774 enabled: true,
775 default_effect: DefaultEffect::Allow,
776 rules: vec![PolicyRuleConfig {
777 effect: PolicyEffect::Deny,
778 tool: "bash".to_owned(),
779 paths: vec![],
780 env: vec![],
781 trust_level: None,
782 args_match: Some(".*sudo.*".to_owned()),
783 capabilities: vec![],
784 }],
785 policy_file: None,
786 policy_provider: ProviderName::default(),
787 };
788 let enforcer = PolicyEnforcer::compile(&config).unwrap();
789 let ctx = make_context(SkillTrustLevel::Trusted);
790
791 let params = make_params("command", "sudo rm -rf /");
792 assert_matches!(
793 enforcer.evaluate("bash", ¶ms, &ctx),
794 PolicyDecision::Deny { .. }
795 );
796
797 let safe_params = make_params("command", "echo hello");
798 assert_matches!(
799 enforcer.evaluate("bash", &safe_params, &ctx),
800 PolicyDecision::Allow { .. }
801 );
802 }
803
804 #[test]
807 fn policy_config_toml_round_trip() {
808 let toml_str = r#"
809 enabled = true
810 default_effect = "deny"
811
812 [[rules]]
813 effect = "deny"
814 tool = "shell"
815 paths = ["/etc/*"]
816
817 [[rules]]
818 effect = "allow"
819 tool = "shell"
820 paths = ["/tmp/*"]
821 trust_level = "verified"
822 "#;
823 let config: PolicyConfig = toml::from_str(toml_str).unwrap();
824 assert!(config.enabled);
825 assert_eq!(config.default_effect, DefaultEffect::Deny);
826 assert_eq!(config.rules.len(), 2);
827 assert_eq!(config.rules[0].effect, PolicyEffect::Deny);
828 assert_eq!(config.rules[0].paths[0], "/etc/*");
829 assert_eq!(config.rules[1].trust_level, Some(SkillTrustLevel::Verified));
830 }
831
832 #[test]
833 fn policy_config_default_is_disabled_deny() {
834 let config = PolicyConfig::default();
835 assert!(!config.enabled);
836 assert_eq!(config.default_effect, DefaultEffect::Deny);
837 assert!(config.rules.is_empty());
838 }
839
840 #[test]
843 fn policy_file_loaded_from_cwd_subdir() {
844 let dir = tempfile::tempdir().unwrap();
845 let original_cwd = std::env::current_dir().unwrap();
847 std::env::set_current_dir(dir.path()).unwrap();
848
849 let policy_path = dir.path().join("policy.toml");
850 std::fs::write(
851 &policy_path,
852 r#"[[rules]]
853effect = "deny"
854tool = "shell"
855"#,
856 )
857 .unwrap();
858
859 let config = PolicyConfig {
860 enabled: true,
861 default_effect: DefaultEffect::Allow,
862 rules: vec![],
863 policy_file: Some(policy_path.to_string_lossy().into_owned()),
864 policy_provider: ProviderName::default(),
865 };
866 let result = PolicyEnforcer::compile(&config);
867 std::env::set_current_dir(&original_cwd).unwrap();
868 assert!(result.is_ok(), "policy file within cwd must be accepted");
869 }
870
871 #[cfg(unix)]
872 #[test]
873 fn policy_file_symlink_escaping_project_root_is_rejected() {
874 use std::os::unix::fs::symlink;
875
876 let outside = tempfile::tempdir().unwrap();
877 let inside = tempfile::tempdir().unwrap();
878
879 std::fs::write(
880 outside.path().join("outside.toml"),
881 "[[rules]]\neffect = \"deny\"\ntool = \"*\"\n",
882 )
883 .unwrap();
884
885 let link = inside.path().join("evil.toml");
887 symlink(outside.path().join("outside.toml"), &link).unwrap();
888
889 let original_cwd = std::env::current_dir().unwrap();
890 std::env::set_current_dir(inside.path()).unwrap();
891
892 let config = PolicyConfig {
893 enabled: true,
894 default_effect: DefaultEffect::Allow,
895 rules: vec![],
896 policy_file: Some(link.to_string_lossy().into_owned()),
897 policy_provider: ProviderName::default(),
898 };
899 let result = PolicyEnforcer::compile(&config);
900 std::env::set_current_dir(&original_cwd).unwrap();
901
902 assert!(
903 matches!(result, Err(PolicyCompileError::FileEscapesRoot { .. })),
904 "symlink escaping project root must be rejected"
905 );
906 }
907
908 #[test]
912 fn alias_shell_rule_matches_bash_tool_id() {
913 let config = PolicyConfig {
914 enabled: true,
915 default_effect: DefaultEffect::Allow,
916 rules: vec![PolicyRuleConfig {
917 effect: PolicyEffect::Deny,
918 tool: "shell".to_owned(),
919 paths: vec![],
920 env: vec![],
921 trust_level: None,
922 args_match: None,
923 capabilities: vec![],
924 }],
925 policy_file: None,
926 policy_provider: ProviderName::default(),
927 };
928 let enforcer = PolicyEnforcer::compile(&config).unwrap();
929 let ctx = make_context(SkillTrustLevel::Trusted);
930 assert!(
931 matches!(
932 enforcer.evaluate("bash", &empty_params(), &ctx),
933 PolicyDecision::Deny { .. }
934 ),
935 "rule tool='shell' must match runtime tool_id='bash' via alias"
936 );
937 }
938
939 #[test]
941 fn alias_bash_rule_matches_bash_tool_id() {
942 let config = PolicyConfig {
943 enabled: true,
944 default_effect: DefaultEffect::Allow,
945 rules: vec![PolicyRuleConfig {
946 effect: PolicyEffect::Deny,
947 tool: "bash".to_owned(),
948 paths: vec![],
949 env: vec![],
950 trust_level: None,
951 args_match: None,
952 capabilities: vec![],
953 }],
954 policy_file: None,
955 policy_provider: ProviderName::default(),
956 };
957 let enforcer = PolicyEnforcer::compile(&config).unwrap();
958 let ctx = make_context(SkillTrustLevel::Trusted);
959 assert!(
960 matches!(
961 enforcer.evaluate("bash", &empty_params(), &ctx),
962 PolicyDecision::Deny { .. }
963 ),
964 "rule tool='bash' must still match runtime tool_id='bash'"
965 );
966 }
967
968 #[test]
970 fn alias_sh_rule_matches_bash_tool_id() {
971 let config = PolicyConfig {
972 enabled: true,
973 default_effect: DefaultEffect::Allow,
974 rules: vec![PolicyRuleConfig {
975 effect: PolicyEffect::Deny,
976 tool: "sh".to_owned(),
977 paths: vec![],
978 env: vec![],
979 trust_level: None,
980 args_match: None,
981 capabilities: vec![],
982 }],
983 policy_file: None,
984 policy_provider: ProviderName::default(),
985 };
986 let enforcer = PolicyEnforcer::compile(&config).unwrap();
987 let ctx = make_context(SkillTrustLevel::Trusted);
988 assert!(
989 matches!(
990 enforcer.evaluate("bash", &empty_params(), &ctx),
991 PolicyDecision::Deny { .. }
992 ),
993 "rule tool='sh' must match runtime tool_id='bash' via alias"
994 );
995 }
996
997 #[test]
1001 fn max_rules_exactly_256_compiles() {
1002 let rules: Vec<PolicyRuleConfig> = (0..MAX_RULES)
1003 .map(|i| PolicyRuleConfig {
1004 effect: PolicyEffect::Allow,
1005 tool: format!("tool_{i}"),
1006 paths: vec![],
1007 env: vec![],
1008 trust_level: None,
1009 args_match: None,
1010 capabilities: vec![],
1011 })
1012 .collect();
1013 let config = PolicyConfig {
1014 enabled: true,
1015 default_effect: DefaultEffect::Deny,
1016 rules,
1017 policy_file: None,
1018 policy_provider: ProviderName::default(),
1019 };
1020 assert!(
1021 PolicyEnforcer::compile(&config).is_ok(),
1022 "exactly {MAX_RULES} rules must compile successfully"
1023 );
1024 }
1025
1026 #[test]
1034 fn policy_file_happy_path() {
1035 let cwd = std::env::current_dir().unwrap();
1036 let dir = tempfile::tempdir_in(&cwd).unwrap();
1037 let policy_path = dir.path().join("policy.toml");
1038 std::fs::write(
1039 &policy_path,
1040 "[[rules]]\neffect = \"deny\"\ntool = \"shell\"\npaths = [\"/etc/*\"]\n",
1041 )
1042 .unwrap();
1043 let config = PolicyConfig {
1044 enabled: true,
1045 default_effect: DefaultEffect::Allow,
1046 rules: vec![],
1047 policy_file: Some(policy_path.to_string_lossy().into_owned()),
1048 policy_provider: ProviderName::default(),
1049 };
1050 let enforcer = PolicyEnforcer::compile(&config).unwrap();
1051 let params = make_params("file_path", "/etc/passwd");
1052 let ctx = make_context(SkillTrustLevel::Trusted);
1053 assert!(
1054 matches!(
1055 enforcer.evaluate("shell", ¶ms, &ctx),
1056 PolicyDecision::Deny { .. }
1057 ),
1058 "deny rule loaded from file must block the matching call"
1059 );
1060 }
1061
1062 #[test]
1064 fn policy_file_too_large() {
1065 let cwd = std::env::current_dir().unwrap();
1066 let dir = tempfile::tempdir_in(&cwd).unwrap();
1067 let policy_path = dir.path().join("big.toml");
1068 std::fs::write(&policy_path, vec![b'x'; 256 * 1024 + 1]).unwrap();
1069 let config = PolicyConfig {
1070 enabled: true,
1071 default_effect: DefaultEffect::Allow,
1072 rules: vec![],
1073 policy_file: Some(policy_path.to_string_lossy().into_owned()),
1074 policy_provider: ProviderName::default(),
1075 };
1076 assert!(
1077 matches!(
1078 PolicyEnforcer::compile(&config),
1079 Err(PolicyCompileError::FileTooLarge { .. })
1080 ),
1081 "file exceeding 256 KiB must return FileTooLarge"
1082 );
1083 }
1084
1085 #[test]
1088 fn policy_file_load_error() {
1089 let config = PolicyConfig {
1090 enabled: true,
1091 default_effect: DefaultEffect::Allow,
1092 rules: vec![],
1093 policy_file: Some("/tmp/__zeph_no_such_policy_file__.toml".to_owned()),
1094 policy_provider: ProviderName::default(),
1095 };
1096 assert!(
1097 matches!(
1098 PolicyEnforcer::compile(&config),
1099 Err(PolicyCompileError::FileLoad { .. })
1100 ),
1101 "nonexistent policy file must return FileLoad"
1102 );
1103 }
1104
1105 #[test]
1107 fn policy_file_parse_error() {
1108 let cwd = std::env::current_dir().unwrap();
1109 let dir = tempfile::tempdir_in(&cwd).unwrap();
1110 let policy_path = dir.path().join("bad.toml");
1111 std::fs::write(&policy_path, "not valid toml = = =\n[[[\n").unwrap();
1112 let config = PolicyConfig {
1113 enabled: true,
1114 default_effect: DefaultEffect::Allow,
1115 rules: vec![],
1116 policy_file: Some(policy_path.to_string_lossy().into_owned()),
1117 policy_provider: ProviderName::default(),
1118 };
1119 assert!(
1120 matches!(
1121 PolicyEnforcer::compile(&config),
1122 Err(PolicyCompileError::FileParse { .. })
1123 ),
1124 "malformed TOML must return FileParse"
1125 );
1126 }
1127
1128 #[test]
1130 fn alias_unknown_tool_unaffected() {
1131 let config = PolicyConfig {
1132 enabled: true,
1133 default_effect: DefaultEffect::Allow,
1134 rules: vec![PolicyRuleConfig {
1135 effect: PolicyEffect::Deny,
1136 tool: "shell".to_owned(),
1137 paths: vec![],
1138 env: vec![],
1139 trust_level: None,
1140 args_match: None,
1141 capabilities: vec![],
1142 }],
1143 policy_file: None,
1144 policy_provider: ProviderName::default(),
1145 };
1146 let enforcer = PolicyEnforcer::compile(&config).unwrap();
1147 let ctx = make_context(SkillTrustLevel::Trusted);
1148 assert!(
1150 matches!(
1151 enforcer.evaluate("web_scrape", &empty_params(), &ctx),
1152 PolicyDecision::Allow { .. }
1153 ),
1154 "unknown tool names must not be affected by alias resolution"
1155 );
1156 }
1157}