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