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