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