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