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