Skip to main content

vellaveto_engine/
compiled.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright 2026 Paolo Vella
6// SPDX-License-Identifier: MPL-2.0
7
8//! Pre-compiled policy types.
9//!
10//! This module contains the pre-compiled policy types that are created at policy
11//! load time. Pre-compilation allows policy evaluation to be performed without
12//! any runtime pattern compilation or lock contention.
13
14use crate::matcher::{CompiledToolMatcher, PatternMatcher};
15use globset::GlobMatcher;
16use ipnet::IpNet;
17use regex::Regex;
18use std::collections::{HashMap, HashSet};
19use vellaveto_types::Policy;
20
21/// A single pre-compiled parameter constraint with all patterns resolved at load time.
22#[derive(Debug, Clone)]
23pub enum CompiledConstraint {
24    Glob {
25        param: String,
26        matcher: GlobMatcher,
27        pattern_str: String,
28        on_match: String,
29        on_missing: String,
30    },
31    NotGlob {
32        param: String,
33        matchers: Vec<(String, GlobMatcher)>,
34        on_match: String,
35        on_missing: String,
36    },
37    Regex {
38        param: String,
39        regex: Regex,
40        pattern_str: String,
41        on_match: String,
42        on_missing: String,
43    },
44    DomainMatch {
45        param: String,
46        pattern: String,
47        on_match: String,
48        on_missing: String,
49    },
50    DomainNotIn {
51        param: String,
52        patterns: Vec<String>,
53        on_match: String,
54        on_missing: String,
55    },
56    Eq {
57        param: String,
58        value: serde_json::Value,
59        on_match: String,
60        on_missing: String,
61    },
62    Ne {
63        param: String,
64        value: serde_json::Value,
65        on_match: String,
66        on_missing: String,
67    },
68    OneOf {
69        param: String,
70        values: Vec<serde_json::Value>,
71        on_match: String,
72        on_missing: String,
73    },
74    NoneOf {
75        param: String,
76        values: Vec<serde_json::Value>,
77        on_match: String,
78        on_missing: String,
79    },
80}
81
82impl CompiledConstraint {
83    pub(crate) fn param(&self) -> &str {
84        match self {
85            Self::Glob { param, .. }
86            | Self::NotGlob { param, .. }
87            | Self::Regex { param, .. }
88            | Self::DomainMatch { param, .. }
89            | Self::DomainNotIn { param, .. }
90            | Self::Eq { param, .. }
91            | Self::Ne { param, .. }
92            | Self::OneOf { param, .. }
93            | Self::NoneOf { param, .. } => param,
94        }
95    }
96
97    pub(crate) fn on_match(&self) -> &str {
98        match self {
99            Self::Glob { on_match, .. }
100            | Self::NotGlob { on_match, .. }
101            | Self::Regex { on_match, .. }
102            | Self::DomainMatch { on_match, .. }
103            | Self::DomainNotIn { on_match, .. }
104            | Self::Eq { on_match, .. }
105            | Self::Ne { on_match, .. }
106            | Self::OneOf { on_match, .. }
107            | Self::NoneOf { on_match, .. } => on_match,
108        }
109    }
110
111    pub(crate) fn on_missing(&self) -> &str {
112        match self {
113            Self::Glob { on_missing, .. }
114            | Self::NotGlob { on_missing, .. }
115            | Self::Regex { on_missing, .. }
116            | Self::DomainMatch { on_missing, .. }
117            | Self::DomainNotIn { on_missing, .. }
118            | Self::Eq { on_missing, .. }
119            | Self::Ne { on_missing, .. }
120            | Self::OneOf { on_missing, .. }
121            | Self::NoneOf { on_missing, .. } => on_missing,
122        }
123    }
124}
125
126/// Pre-compiled path rule glob matchers for a single policy.
127#[derive(Debug, Clone)]
128pub struct CompiledPathRules {
129    pub allowed: Vec<(String, GlobMatcher)>,
130    pub blocked: Vec<(String, GlobMatcher)>,
131}
132
133/// Pre-compiled network rule domain patterns for a single policy.
134#[derive(Debug, Clone)]
135pub struct CompiledNetworkRules {
136    pub allowed_domains: Vec<String>,
137    pub blocked_domains: Vec<String>,
138}
139
140/// Pre-compiled IP access control rules for DNS rebinding protection.
141///
142/// CIDRs are parsed at policy compile time so evaluation is a fast
143/// prefix-length comparison with no parsing overhead.
144#[derive(Debug, Clone)]
145pub struct CompiledIpRules {
146    pub block_private: bool,
147    pub blocked_cidrs: Vec<IpNet>,
148    pub allowed_cidrs: Vec<IpNet>,
149}
150
151/// A pre-compiled context condition for session-level policy evaluation.
152///
153/// Context conditions are checked after tool match and path/network rules,
154/// but before policy type dispatch. They require an [`vellaveto_types::EvaluationContext`]
155/// to evaluate — when no context is provided, all context conditions are skipped.
156#[derive(Debug, Clone)]
157pub enum CompiledContextCondition {
158    /// Allow tool calls only within a time window.
159    TimeWindow {
160        start_hour: u8,
161        end_hour: u8,
162        /// ISO weekday numbers (1=Mon, 7=Sun). Empty = all days.
163        days: Vec<u8>,
164        deny_reason: String,
165    },
166    /// Limit how many times a tool (or tool pattern) can be called per session.
167    MaxCalls {
168        tool_pattern: PatternMatcher,
169        max: u64,
170        deny_reason: String,
171    },
172    /// Restrict which agent identities can use this policy.
173    AgentId {
174        allowed: Vec<String>,
175        blocked: Vec<String>,
176        deny_reason: String,
177    },
178    /// Require that a specific tool was called earlier in the session.
179    RequirePreviousAction {
180        required_tool: String,
181        deny_reason: String,
182    },
183    /// Deny if a specific tool was called earlier in the session.
184    ///
185    /// Inverse of `RequirePreviousAction` — detects forbidden sequences like
186    /// read-then-exfiltrate (if `read_file` was called, deny `http_request`).
187    ForbiddenPreviousAction {
188        /// Tool name that, if present in session history, triggers denial.
189        forbidden_tool: String,
190        deny_reason: String,
191    },
192    /// Deny if a tool pattern appears more than `max` times in the last `window`
193    /// entries of the session history.
194    ///
195    /// Provides sliding-window rate limiting without requiring wall-clock
196    /// timestamps. A `window` of 0 means the entire session history.
197    MaxCallsInWindow {
198        tool_pattern: PatternMatcher,
199        max: u64,
200        /// Number of most-recent history entries to consider. 0 = all.
201        window: usize,
202        deny_reason: String,
203    },
204    /// OWASP ASI08: Limit the depth of multi-agent call chains.
205    ///
206    /// In multi-hop MCP scenarios, an agent can request another agent to perform
207    /// actions on its behalf. This condition limits how deep such chains can go
208    /// to prevent privilege escalation through agent chaining.
209    MaxChainDepth {
210        /// Maximum allowed chain depth. This is an exclusive upper bound: a call
211        /// chain with `len > max_depth` entries is denied. A value of 0 means only
212        /// direct calls are allowed (empty chain); any upstream hop is denied.
213        /// A value of 1 allows exactly one upstream agent, etc.
214        max_depth: usize,
215        deny_reason: String,
216    },
217    /// OWASP ASI07: Match on cryptographically attested agent identity claims.
218    ///
219    /// Requires a valid `X-Agent-Identity` JWT header. Policies can match on:
220    /// - `issuer`: Required JWT issuer (`iss` claim)
221    /// - `subject`: Required JWT subject (`sub` claim)
222    /// - `audience`: Required audience (`aud` claim must contain this value)
223    /// - `claims.<key>`: Custom claim matching (e.g., `claims.role == "admin"`)
224    ///
225    /// Unlike `AgentId` which matches on a simple string, this condition provides
226    /// cryptographic attestation of the agent's identity via JWT signature verification.
227    AgentIdentityMatch {
228        /// Required JWT issuer. If set, the identity's `iss` claim must match.
229        required_issuer: Option<String>,
230        /// Required JWT subject. If set, the identity's `sub` claim must match.
231        required_subject: Option<String>,
232        /// Required audience. If set, the identity's `aud` claim must contain this value.
233        required_audience: Option<String>,
234        /// Required custom claims. All specified claims must match.
235        /// Keys are claim names, values are expected string values.
236        required_claims: HashMap<String, String>,
237        /// Blocked issuers. If the identity's `iss` matches any, deny.
238        blocked_issuers: Vec<String>,
239        /// Blocked subjects. If the identity's `sub` matches any, deny.
240        blocked_subjects: Vec<String>,
241        /// When true, fail-closed if no agent_identity is present.
242        /// When false, fall back to legacy agent_id matching.
243        require_attestation: bool,
244        deny_reason: String,
245    },
246
247    // ═══════════════════════════════════════════════════
248    // MCP 2025-11-25 CONTEXT CONDITIONS
249    // ═══════════════════════════════════════════════════
250    /// MCP 2025-11-25: Async task lifecycle policy.
251    ///
252    /// Controls the creation and cancellation of async MCP tasks. Policies can:
253    /// - Limit maximum concurrent tasks per session/agent
254    /// - Set maximum task duration before automatic expiry
255    /// - Restrict task cancellation to the creating agent only
256    AsyncTaskPolicy {
257        /// Maximum number of concurrent active tasks. 0 = unlimited.
258        max_concurrent: usize,
259        /// Maximum task duration in seconds. 0 = unlimited.
260        max_duration_secs: u64,
261        /// When true, only the agent that created a task can cancel it.
262        require_self_cancel: bool,
263        deny_reason: String,
264    },
265
266    /// RFC 8707: OAuth 2.0 Resource Indicator validation.
267    ///
268    /// Validates that OAuth tokens include the expected resource indicators.
269    /// Resource indicators prevent token replay attacks by binding tokens
270    /// to specific API endpoints or resource servers.
271    ResourceIndicator {
272        /// Patterns for allowed resource URIs. Supports glob patterns.
273        /// If non-empty, at least one pattern must match the token's resource.
274        allowed_resources: Vec<PatternMatcher>,
275        /// When true, deny if the token has no resource indicator.
276        require_resource: bool,
277        deny_reason: String,
278    },
279
280    /// CIMD: Capability-Indexed Message Dispatch.
281    ///
282    /// MCP 2025-11-25 introduces capability negotiation. This condition
283    /// checks that the client has declared the required capabilities
284    /// and has not declared any blocked capabilities.
285    CapabilityRequired {
286        /// Capabilities that must be declared by the client.
287        /// All listed capabilities must be present.
288        required_capabilities: Vec<String>,
289        /// Capabilities that must NOT be declared by the client.
290        /// If any listed capability is present, deny.
291        blocked_capabilities: Vec<String>,
292        deny_reason: String,
293    },
294
295    /// Step-up authentication trigger.
296    ///
297    /// When the current authentication level is below the required level,
298    /// the policy triggers a step-up authentication challenge instead of
299    /// denying outright. This allows sensitive operations to require
300    /// stronger authentication without blocking the session.
301    StepUpAuth {
302        /// Required authentication level (maps to AuthLevel enum).
303        /// 0=None, 1=Basic, 2=OAuth, 3=OAuthMfa, 4=HardwareKey
304        required_level: u8,
305        deny_reason: String,
306    },
307
308    // ═══════════════════════════════════════════════════
309    // PHASE 2: ADVANCED THREAT DETECTION CONDITIONS
310    // ═══════════════════════════════════════════════════
311    /// Circuit breaker check (OWASP ASI08).
312    ///
313    /// Prevents cascading failures by temporarily blocking requests to
314    /// tools that have been failing. The circuit breaker pattern has
315    /// three states: Closed (normal), Open (blocking), HalfOpen (testing).
316    CircuitBreaker {
317        /// Pattern to match tool names for circuit breaker tracking.
318        tool_pattern: PatternMatcher,
319        deny_reason: String,
320    },
321
322    /// Confused deputy validation (OWASP ASI02).
323    ///
324    /// Validates that the current principal is authorized to perform
325    /// the requested action, preventing confused deputy attacks where
326    /// a privileged agent is tricked into acting on behalf of an
327    /// unprivileged attacker.
328    DeputyValidation {
329        /// When true, a principal must be identified in the context.
330        require_principal: bool,
331        /// Maximum allowed delegation depth. 0 = direct only.
332        max_delegation_depth: u8,
333        deny_reason: String,
334    },
335
336    /// Shadow agent detection.
337    ///
338    /// Detects when an unknown agent claims to be a known agent,
339    /// indicating potential impersonation or shadow agent attack.
340    /// Fingerprints agents based on JWT claims, client ID, and IP.
341    ShadowAgentCheck {
342        /// When true, require the fingerprint to match a known agent.
343        require_known_fingerprint: bool,
344        /// Minimum trust level required (0-4).
345        /// 0=Unknown, 1=Low, 2=Medium, 3=High, 4=Verified
346        min_trust_level: u8,
347        deny_reason: String,
348    },
349
350    /// Schema poisoning protection (OWASP ASI05).
351    ///
352    /// Tracks tool schema changes over time and alerts or blocks
353    /// when schemas change beyond the configured threshold.
354    /// Prevents rug-pull attacks where tool behavior changes maliciously.
355    SchemaPoisoningCheck {
356        /// Schema similarity threshold (0.0-1.0). Changes above this trigger denial.
357        mutation_threshold: f32,
358        deny_reason: String,
359    },
360
361    /// Minimum verification tier enforcement.
362    ///
363    /// Requires the agent's verification tier to meet or exceed a minimum level.
364    /// Fail-closed: if no verification tier is present in the context, denies.
365    MinVerificationTier {
366        /// Required tier level (0-4).
367        /// 0=Unverified, 1=EmailVerified, 2=PhoneVerified, 3=DidVerified, 4=FullyVerified
368        required_tier: u8,
369        deny_reason: String,
370    },
371
372    /// Capability-based delegation token enforcement.
373    ///
374    /// Requires a valid capability token to be present in the evaluation context.
375    /// Checks that the token's holder matches the agent_id, and optionally
376    /// restricts which issuers are trusted and requires minimum delegation depth.
377    ///
378    /// # Security (MCP Gap #3 — Capability Delegation)
379    ///
380    /// - Fail-closed: missing token = Deny
381    /// - Holder must match agent_id (prevents token theft)
382    /// - Issuer allowlist prevents unauthorized token sources
383    /// - Grant coverage is verified by the proxy layer before attaching to context
384    RequireCapabilityToken {
385        /// If non-empty, the token's issuer must be in this list.
386        required_issuers: Vec<String>,
387        /// Minimum remaining delegation depth required (0 = terminal tokens accepted).
388        min_remaining_depth: u8,
389        deny_reason: String,
390    },
391
392    /// Session state requirement (Phase 23.5).
393    ///
394    /// Only allows actions when the session is in one of the specified states.
395    /// Fail-closed: if no session_state is present in context, denies.
396    SessionStateRequired {
397        /// Allowed session states (e.g., ["active", "init"]).
398        /// State names are compared case-insensitively.
399        allowed_states: Vec<String>,
400        deny_reason: String,
401    },
402
403    // ═══════════════════════════════════════════════════
404    // PHASE 40: WORKFLOW-LEVEL POLICY CONSTRAINTS
405    // ═══════════════════════════════════════════════════
406    /// Require an ordered (or unordered) sequence of tools in session history.
407    ///
408    /// **Ordered:** subsequence match — tools must appear in order, not necessarily
409    /// consecutive (e.g., A→C→B history matches A→B sequence).
410    /// **Unordered:** all tools must appear anywhere in history (set semantics).
411    ///
412    /// Max 20 steps. Fail-closed: empty/shorter history → Deny.
413    RequiredActionSequence {
414        /// Tool names (lowercased at compile time).
415        sequence: Vec<String>,
416        /// `true` = ordered subsequence, `false` = unordered set.
417        ordered: bool,
418        deny_reason: String,
419    },
420
421    /// Deny if a sequence of tools has appeared in session history.
422    ///
423    /// **Ordered:** subsequence match — if all tools found in order → Deny.
424    /// **Unordered:** if all tools present anywhere → Deny.
425    ///
426    /// Max 20 steps. Empty history → Allow (nothing forbidden yet).
427    ///
428    /// # Known limitation (FIND-CREATIVE-004)
429    ///
430    /// `previous_actions` is bounded at `MAX_PREVIOUS_ACTIONS` (10,000 entries).
431    /// If an attacker performs the forbidden prefix actions and then issues enough
432    /// additional tool calls to push the prefix out of the retained history window,
433    /// the forbidden sequence will no longer be detected. This is an inherent
434    /// trade-off of bounded history. When the history is at capacity, a warning
435    /// is emitted so operators can investigate or increase monitoring. Consider
436    /// pairing `ForbiddenActionSequence` with `ForbiddenPreviousAction` for
437    /// individual high-risk tools that must never appear at all.
438    ForbiddenActionSequence {
439        /// Tool names (lowercased at compile time).
440        sequence: Vec<String>,
441        /// `true` = ordered subsequence, `false` = unordered set.
442        ordered: bool,
443        deny_reason: String,
444    },
445
446    /// Workflow template: DAG of allowed tool transitions.
447    ///
448    /// Non-governed tools pass through (no restriction). Governed tools must
449    /// follow the DAG edges: the current tool must be a valid successor of the
450    /// most recent governed tool in history, or an entry point if no governed
451    /// tool has been called yet.
452    ///
453    /// Max 50 steps. Cycles rejected at compile time via Kahn's algorithm.
454    WorkflowTemplate {
455        /// Tool → valid successor tools.
456        adjacency: HashMap<String, Vec<String>>,
457        /// All tools appearing in the DAG.
458        governed_tools: HashSet<String>,
459        /// Tools with no predecessors (valid starting points).
460        entry_points: Vec<String>,
461        /// `true` = Deny on violation, `false` = warn only.
462        strict: bool,
463        deny_reason: String,
464    },
465}
466
467/// Pre-parsed fields extracted from a policy's `conditions` JSON.
468///
469/// Returned by [`PolicyEngine::compile_conditions`] to avoid a complex tuple return type.
470#[derive(Debug, Clone)]
471pub(crate) struct CompiledConditions {
472    pub require_approval: bool,
473    pub forbidden_parameters: Vec<String>,
474    pub required_parameters: Vec<String>,
475    pub constraints: Vec<CompiledConstraint>,
476    pub on_no_match_continue: bool,
477    pub context_conditions: Vec<CompiledContextCondition>,
478}
479
480/// A policy with all patterns pre-compiled for zero-lock evaluation.
481///
482/// Created by [`crate::PolicyEngine::compile_policies`] or [`crate::PolicyEngine::with_policies`].
483/// Stores the original [`Policy`](vellaveto_types::Policy) alongside pre-compiled matchers so that
484/// `evaluate_action` requires zero Mutex acquisitions.
485#[derive(Debug, Clone)]
486pub struct CompiledPolicy {
487    pub policy: Policy,
488    pub tool_matcher: CompiledToolMatcher,
489    pub require_approval: bool,
490    pub forbidden_parameters: Vec<String>,
491    pub required_parameters: Vec<String>,
492    pub constraints: Vec<CompiledConstraint>,
493    /// When true, return None (skip to next policy) instead of Allow when no
494    /// constraints fire. Set via `on_no_match: "continue"` in conditions JSON.
495    pub on_no_match_continue: bool,
496    /// Pre-computed "Denied by policy 'NAME'" reason string.
497    pub deny_reason: String,
498    /// Pre-computed "Approval required by policy 'NAME'" reason string.
499    pub approval_reason: String,
500    /// Pre-computed "Parameter 'P' is forbidden by policy 'NAME'" for each forbidden param.
501    pub forbidden_reasons: Vec<String>,
502    /// Pre-computed "Required parameter 'P' missing (policy 'NAME')" for each required param.
503    pub required_reasons: Vec<String>,
504    /// Pre-compiled path access control rules (from policy.path_rules).
505    pub compiled_path_rules: Option<CompiledPathRules>,
506    /// Pre-compiled network access control rules (from policy.network_rules).
507    pub compiled_network_rules: Option<CompiledNetworkRules>,
508    /// Pre-compiled IP access control rules (DNS rebinding protection).
509    pub compiled_ip_rules: Option<CompiledIpRules>,
510    /// Pre-compiled context conditions (from conditions JSON `context_conditions` key).
511    pub context_conditions: Vec<CompiledContextCondition>,
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use serde_json::json;
518
519    fn make_glob_constraint(param: &str, pattern: &str) -> CompiledConstraint {
520        let glob = globset::GlobBuilder::new(pattern)
521            .literal_separator(true)
522            .build()
523            .unwrap()
524            .compile_matcher();
525        CompiledConstraint::Glob {
526            param: param.to_string(),
527            matcher: glob,
528            pattern_str: pattern.to_string(),
529            on_match: "deny".to_string(),
530            on_missing: "skip".to_string(),
531        }
532    }
533
534    fn make_eq_constraint(param: &str, value: serde_json::Value) -> CompiledConstraint {
535        CompiledConstraint::Eq {
536            param: param.to_string(),
537            value,
538            on_match: "deny".to_string(),
539            on_missing: "deny".to_string(),
540        }
541    }
542
543    fn make_ne_constraint(param: &str, value: serde_json::Value) -> CompiledConstraint {
544        CompiledConstraint::Ne {
545            param: param.to_string(),
546            value,
547            on_match: "deny".to_string(),
548            on_missing: "skip".to_string(),
549        }
550    }
551
552    fn make_one_of_constraint(param: &str, values: Vec<serde_json::Value>) -> CompiledConstraint {
553        CompiledConstraint::OneOf {
554            param: param.to_string(),
555            values,
556            on_match: "allow".to_string(),
557            on_missing: "deny".to_string(),
558        }
559    }
560
561    fn make_none_of_constraint(param: &str, values: Vec<serde_json::Value>) -> CompiledConstraint {
562        CompiledConstraint::NoneOf {
563            param: param.to_string(),
564            values,
565            on_match: "deny".to_string(),
566            on_missing: "skip".to_string(),
567        }
568    }
569
570    // ---- CompiledConstraint accessor tests ----
571
572    #[test]
573    fn test_compiled_constraint_param_glob() {
574        let c = make_glob_constraint("file_path", "/tmp/**");
575        assert_eq!(c.param(), "file_path");
576    }
577
578    #[test]
579    fn test_compiled_constraint_param_eq() {
580        let c = make_eq_constraint("mode", json!("read"));
581        assert_eq!(c.param(), "mode");
582    }
583
584    #[test]
585    fn test_compiled_constraint_on_match_returns_correct_value() {
586        let c = make_glob_constraint("path", "*.exe");
587        assert_eq!(c.on_match(), "deny");
588    }
589
590    #[test]
591    fn test_compiled_constraint_on_missing_returns_correct_value() {
592        let c = make_glob_constraint("path", "*.exe");
593        assert_eq!(c.on_missing(), "skip");
594    }
595
596    #[test]
597    fn test_compiled_constraint_on_match_ne() {
598        let c = make_ne_constraint("level", json!(0));
599        assert_eq!(c.on_match(), "deny");
600    }
601
602    #[test]
603    fn test_compiled_constraint_on_missing_ne() {
604        let c = make_ne_constraint("level", json!(0));
605        assert_eq!(c.on_missing(), "skip");
606    }
607
608    #[test]
609    fn test_compiled_constraint_on_match_one_of() {
610        let c = make_one_of_constraint("env", vec![json!("dev"), json!("staging")]);
611        assert_eq!(c.on_match(), "allow");
612    }
613
614    #[test]
615    fn test_compiled_constraint_on_missing_one_of() {
616        let c = make_one_of_constraint("env", vec![json!("dev")]);
617        assert_eq!(c.on_missing(), "deny");
618    }
619
620    #[test]
621    fn test_compiled_constraint_param_none_of() {
622        let c = make_none_of_constraint("action", vec![json!("delete")]);
623        assert_eq!(c.param(), "action");
624    }
625
626    #[test]
627    fn test_compiled_constraint_param_all_variants() {
628        // Verify param() returns correct value for every variant
629        let domain_match = CompiledConstraint::DomainMatch {
630            param: "url".to_string(),
631            pattern: "*.example.com".to_string(),
632            on_match: "deny".to_string(),
633            on_missing: "deny".to_string(),
634        };
635        assert_eq!(domain_match.param(), "url");
636
637        let domain_not_in = CompiledConstraint::DomainNotIn {
638            param: "endpoint".to_string(),
639            patterns: vec!["example.com".to_string()],
640            on_match: "deny".to_string(),
641            on_missing: "skip".to_string(),
642        };
643        assert_eq!(domain_not_in.param(), "endpoint");
644
645        let regex_c = CompiledConstraint::Regex {
646            param: "input".to_string(),
647            regex: Regex::new("^[a-z]+$").unwrap(),
648            pattern_str: "^[a-z]+$".to_string(),
649            on_match: "allow".to_string(),
650            on_missing: "deny".to_string(),
651        };
652        assert_eq!(regex_c.param(), "input");
653
654        let not_glob = CompiledConstraint::NotGlob {
655            param: "file".to_string(),
656            matchers: vec![],
657            on_match: "deny".to_string(),
658            on_missing: "deny".to_string(),
659        };
660        assert_eq!(not_glob.param(), "file");
661    }
662
663    #[test]
664    fn test_compiled_constraint_on_match_all_variants() {
665        let domain_match = CompiledConstraint::DomainMatch {
666            param: "url".to_string(),
667            pattern: "evil.com".to_string(),
668            on_match: "require_approval".to_string(),
669            on_missing: "deny".to_string(),
670        };
671        assert_eq!(domain_match.on_match(), "require_approval");
672
673        let regex_c = CompiledConstraint::Regex {
674            param: "q".to_string(),
675            regex: Regex::new(".*").unwrap(),
676            pattern_str: ".*".to_string(),
677            on_match: "allow".to_string(),
678            on_missing: "skip".to_string(),
679        };
680        assert_eq!(regex_c.on_match(), "allow");
681    }
682
683    #[test]
684    fn test_compiled_constraint_on_missing_all_variants() {
685        let domain_not_in = CompiledConstraint::DomainNotIn {
686            param: "host".to_string(),
687            patterns: vec![],
688            on_match: "deny".to_string(),
689            on_missing: "deny".to_string(),
690        };
691        assert_eq!(domain_not_in.on_missing(), "deny");
692    }
693
694    // ---- CompiledPathRules tests ----
695
696    #[test]
697    fn test_compiled_path_rules_empty_allowed_and_blocked() {
698        let rules = CompiledPathRules {
699            allowed: vec![],
700            blocked: vec![],
701        };
702        assert!(rules.allowed.is_empty());
703        assert!(rules.blocked.is_empty());
704    }
705
706    // ---- CompiledNetworkRules tests ----
707
708    #[test]
709    fn test_compiled_network_rules_empty() {
710        let rules = CompiledNetworkRules {
711            allowed_domains: vec![],
712            blocked_domains: vec![],
713        };
714        assert!(rules.allowed_domains.is_empty());
715        assert!(rules.blocked_domains.is_empty());
716    }
717
718    // ---- CompiledIpRules tests ----
719
720    #[test]
721    fn test_compiled_ip_rules_block_private_default() {
722        let rules = CompiledIpRules {
723            block_private: false,
724            blocked_cidrs: vec![],
725            allowed_cidrs: vec![],
726        };
727        assert!(!rules.block_private);
728        assert!(rules.blocked_cidrs.is_empty());
729    }
730
731    #[test]
732    fn test_compiled_ip_rules_with_cidrs() {
733        let blocked: ipnet::IpNet = "10.0.0.0/8".parse().unwrap();
734        let allowed: ipnet::IpNet = "192.168.1.0/24".parse().unwrap();
735        let rules = CompiledIpRules {
736            block_private: true,
737            blocked_cidrs: vec![blocked],
738            allowed_cidrs: vec![allowed],
739        };
740        assert!(rules.block_private);
741        assert_eq!(rules.blocked_cidrs.len(), 1);
742        assert_eq!(rules.allowed_cidrs.len(), 1);
743    }
744
745    // ---- CompiledConditions tests ----
746
747    #[test]
748    fn test_compiled_conditions_default_state() {
749        let conds = CompiledConditions {
750            require_approval: false,
751            forbidden_parameters: vec![],
752            required_parameters: vec![],
753            constraints: vec![],
754            on_no_match_continue: false,
755            context_conditions: vec![],
756        };
757        assert!(!conds.require_approval);
758        assert!(conds.constraints.is_empty());
759        assert!(!conds.on_no_match_continue);
760    }
761
762    #[test]
763    fn test_compiled_conditions_with_approval() {
764        let conds = CompiledConditions {
765            require_approval: true,
766            forbidden_parameters: vec!["secret".to_string()],
767            required_parameters: vec!["token".to_string()],
768            constraints: vec![],
769            on_no_match_continue: false,
770            context_conditions: vec![],
771        };
772        assert!(conds.require_approval);
773        assert_eq!(conds.forbidden_parameters, vec!["secret"]);
774        assert_eq!(conds.required_parameters, vec!["token"]);
775    }
776}