Skip to main content

zeph_tools/
policy.rs

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