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