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