Skip to main content

vellaveto_engine/
policy_compile.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//! Policy compilation methods.
9//!
10//! This module contains methods for compiling policies at load time.
11//! Pre-compilation validates all patterns (globs, regexes, domains, CIDRs)
12//! and produces [`CompiledPolicy`] objects that enable zero-lock evaluation.
13
14use crate::compiled::{
15    CompiledConditions, CompiledConstraint, CompiledContextCondition, CompiledIpRules,
16    CompiledNetworkRules, CompiledPathRules, CompiledPolicy,
17};
18use crate::error::PolicyValidationError;
19use crate::matcher::{CompiledToolMatcher, PatternMatcher};
20use crate::normalize::normalize_full;
21use crate::PolicyEngine;
22use globset::Glob;
23use ipnet::IpNet;
24use vellaveto_types::{Policy, PolicyType, MAX_CONDITIONS_SIZE};
25
26impl PolicyEngine {
27    /// Compile a set of policies, validating all patterns at load time.
28    ///
29    /// Returns pre-sorted `Vec<CompiledPolicy>` on success, or a list of
30    /// all validation errors found across all policies on failure.
31    pub fn compile_policies(
32        policies: &[Policy],
33        strict_mode: bool,
34    ) -> Result<Vec<CompiledPolicy>, Vec<PolicyValidationError>> {
35        let mut compiled = Vec::with_capacity(policies.len());
36        let mut errors = Vec::new();
37
38        for policy in policies {
39            match Self::compile_single_policy(policy, strict_mode) {
40                Ok(cp) => compiled.push(cp),
41                Err(e) => errors.push(e),
42            }
43        }
44
45        if !errors.is_empty() {
46            return Err(errors);
47        }
48
49        // Sort compiled policies by priority (same order as sort_policies)
50        compiled.sort_by(|a, b| {
51            let pri = b.policy.priority.cmp(&a.policy.priority);
52            if pri != std::cmp::Ordering::Equal {
53                return pri;
54            }
55            let a_deny = matches!(a.policy.policy_type, PolicyType::Deny);
56            let b_deny = matches!(b.policy.policy_type, PolicyType::Deny);
57            let deny_ord = b_deny.cmp(&a_deny);
58            if deny_ord != std::cmp::Ordering::Equal {
59                return deny_ord;
60            }
61            a.policy.id.cmp(&b.policy.id)
62        });
63        Ok(compiled)
64    }
65
66    /// Compile a single policy, resolving all patterns.
67    fn compile_single_policy(
68        policy: &Policy,
69        strict_mode: bool,
70    ) -> Result<CompiledPolicy, PolicyValidationError> {
71        // SECURITY (FIND-R50-065): Validate policy name length to prevent
72        // unboundedly large deny_reason strings derived from policy.name.
73        // SECURITY (FIND-R110-001): Must match vellaveto-types/src/core.rs MAX_POLICY_NAME_LEN
74        // so that a policy passing validate() also passes compile_single_policy().
75        const MAX_POLICY_NAME_LEN: usize = 512;
76        if policy.name.len() > MAX_POLICY_NAME_LEN {
77            return Err(PolicyValidationError {
78                policy_id: policy.id.clone(),
79                policy_name: policy.name.clone(),
80                reason: format!(
81                    "Policy name is {} bytes, max is {}",
82                    policy.name.len(),
83                    MAX_POLICY_NAME_LEN
84                ),
85            });
86        }
87
88        let tool_matcher = CompiledToolMatcher::compile(&policy.id);
89
90        let CompiledConditions {
91            require_approval,
92            forbidden_parameters,
93            required_parameters,
94            constraints,
95            on_no_match_continue,
96            context_conditions,
97        } = match &policy.policy_type {
98            PolicyType::Allow | PolicyType::Deny => CompiledConditions {
99                require_approval: false,
100                forbidden_parameters: Vec::new(),
101                required_parameters: Vec::new(),
102                constraints: Vec::new(),
103                on_no_match_continue: false,
104                context_conditions: Vec::new(),
105            },
106            PolicyType::Conditional { conditions } => {
107                Self::compile_conditions(policy, conditions, strict_mode)?
108            }
109            // SECURITY (FIND-R46-002): Reject unknown policy types at compile time
110            // instead of silently treating them as Allow. The #[non_exhaustive]
111            // attribute on PolicyType requires this arm, but we fail-closed.
112            _ => {
113                return Err(PolicyValidationError {
114                    policy_id: policy.id.clone(),
115                    policy_name: policy.name.clone(),
116                    reason: format!(
117                        "Unknown policy type variant for policy '{}' — cannot compile",
118                        policy.name
119                    ),
120                });
121            }
122        };
123
124        let deny_reason = format!("Denied by policy '{}'", policy.name);
125        let approval_reason = format!("Approval required by policy '{}'", policy.name);
126        let forbidden_reasons = forbidden_parameters
127            .iter()
128            .map(|p| format!("Parameter '{}' is forbidden by policy '{}'", p, policy.name))
129            .collect();
130        let required_reasons = required_parameters
131            .iter()
132            .map(|p| {
133                format!(
134                    "Required parameter '{}' missing (policy '{}')",
135                    p, policy.name
136                )
137            })
138            .collect();
139
140        // Compile path rules — SECURITY: invalid globs cause a compile error
141        // (fail-closed). Previously, filter_map silently dropped invalid patterns,
142        // meaning a typo in a blocked path glob would silently fail to block.
143        let compiled_path_rules = match policy.path_rules.as_ref() {
144            Some(pr) => {
145                let mut allowed = Vec::with_capacity(pr.allowed.len());
146                for pattern in &pr.allowed {
147                    let g = Glob::new(pattern).map_err(|e| PolicyValidationError {
148                        policy_id: policy.id.clone(),
149                        policy_name: policy.name.clone(),
150                        reason: format!("Invalid allowed path glob '{pattern}': {e}"),
151                    })?;
152                    allowed.push((pattern.clone(), g.compile_matcher()));
153                }
154                let mut blocked = Vec::with_capacity(pr.blocked.len());
155                for pattern in &pr.blocked {
156                    let g = Glob::new(pattern).map_err(|e| PolicyValidationError {
157                        policy_id: policy.id.clone(),
158                        policy_name: policy.name.clone(),
159                        reason: format!("Invalid blocked path glob '{pattern}': {e}"),
160                    })?;
161                    blocked.push((pattern.clone(), g.compile_matcher()));
162                }
163                Some(CompiledPathRules { allowed, blocked })
164            }
165            None => None,
166        };
167
168        // Compile network rules (domain patterns are matched directly, no glob needed)
169        // Validate domain patterns at compile time per RFC 1035.
170        let compiled_network_rules = match policy.network_rules.as_ref() {
171            Some(nr) => {
172                for domain in nr.allowed_domains.iter().chain(nr.blocked_domains.iter()) {
173                    if let Err(reason) = Self::validate_domain_pattern(domain) {
174                        return Err(PolicyValidationError {
175                            policy_id: policy.id.clone(),
176                            policy_name: policy.name.clone(),
177                            reason: format!("Invalid domain pattern: {reason}"),
178                        });
179                    }
180                }
181                Some(CompiledNetworkRules {
182                    allowed_domains: nr.allowed_domains.clone(),
183                    blocked_domains: nr.blocked_domains.clone(),
184                })
185            }
186            None => None,
187        };
188
189        // Compile IP rules — parse CIDRs at compile time (fail-closed on invalid CIDR).
190        let compiled_ip_rules = match policy
191            .network_rules
192            .as_ref()
193            .and_then(|nr| nr.ip_rules.as_ref())
194        {
195            Some(ir) => {
196                let mut blocked_cidrs = Vec::with_capacity(ir.blocked_cidrs.len());
197                for cidr_str in &ir.blocked_cidrs {
198                    let cidr: IpNet = cidr_str.parse().map_err(|e| PolicyValidationError {
199                        policy_id: policy.id.clone(),
200                        policy_name: policy.name.clone(),
201                        reason: format!("Invalid blocked CIDR '{cidr_str}': {e}"),
202                    })?;
203                    blocked_cidrs.push(cidr);
204                }
205                let mut allowed_cidrs = Vec::with_capacity(ir.allowed_cidrs.len());
206                for cidr_str in &ir.allowed_cidrs {
207                    let cidr: IpNet = cidr_str.parse().map_err(|e| PolicyValidationError {
208                        policy_id: policy.id.clone(),
209                        policy_name: policy.name.clone(),
210                        reason: format!("Invalid allowed CIDR '{cidr_str}': {e}"),
211                    })?;
212                    allowed_cidrs.push(cidr);
213                }
214                Some(CompiledIpRules {
215                    block_private: ir.block_private,
216                    blocked_cidrs,
217                    allowed_cidrs,
218                })
219            }
220            None => None,
221        };
222
223        Ok(CompiledPolicy {
224            policy: policy.clone(),
225            tool_matcher,
226            require_approval,
227            forbidden_parameters,
228            required_parameters,
229            constraints,
230            on_no_match_continue,
231            deny_reason,
232            approval_reason,
233            forbidden_reasons,
234            required_reasons,
235            compiled_path_rules,
236            compiled_network_rules,
237            compiled_ip_rules,
238            context_conditions,
239        })
240    }
241
242    /// Compile condition JSON into pre-parsed fields and compiled constraints.
243    fn compile_conditions(
244        policy: &Policy,
245        conditions: &serde_json::Value,
246        strict_mode: bool,
247    ) -> Result<CompiledConditions, PolicyValidationError> {
248        // Validate JSON depth
249        if Self::json_depth(conditions) > 10 {
250            return Err(PolicyValidationError {
251                policy_id: policy.id.clone(),
252                policy_name: policy.name.clone(),
253                reason: "Condition JSON exceeds maximum nesting depth of 10".to_string(),
254            });
255        }
256
257        // Validate JSON size against the canonical limit shared with Policy::validate().
258        // SECURITY (FIND-R111-004): Previously used a hardcoded 100_000 while
259        // Policy::validate() uses MAX_CONDITIONS_SIZE = 65_536. An attacker could craft
260        // conditions that pass the compile path (100K) but are rejected by Policy::validate()
261        // (65K), or conversely, conditions that bypass compile-path limits. Both limits must
262        // be the same canonical value.
263        let size = conditions.to_string().len();
264        if size > MAX_CONDITIONS_SIZE {
265            return Err(PolicyValidationError {
266                policy_id: policy.id.clone(),
267                policy_name: policy.name.clone(),
268                reason: format!(
269                    "Condition JSON too large: {size} bytes (max {MAX_CONDITIONS_SIZE})"
270                ),
271            });
272        }
273
274        // SECURITY (FIND-IMP-013): Fail-closed — if require_approval is present
275        // but not a valid boolean, default to true (require approval).
276        let require_approval = conditions
277            .get("require_approval")
278            .map(|v| v.as_bool().unwrap_or(true))
279            .unwrap_or(false);
280
281        let on_no_match_continue = conditions
282            .get("on_no_match")
283            .and_then(|v| v.as_str())
284            .map(|s| s == "continue")
285            .unwrap_or(false);
286
287        let forbidden_parameters: Vec<String> = conditions
288            .get("forbidden_parameters")
289            .and_then(|v| v.as_array())
290            .map(|arr| {
291                arr.iter()
292                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
293                    .collect()
294            })
295            .unwrap_or_default();
296
297        let required_parameters: Vec<String> = conditions
298            .get("required_parameters")
299            .and_then(|v| v.as_array())
300            .map(|arr| {
301                arr.iter()
302                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
303                    .collect()
304            })
305            .unwrap_or_default();
306
307        // SECURITY (FIND-R46-012): Maximum number of parameter constraints per policy.
308        const MAX_PARAMETER_CONSTRAINTS: usize = 100;
309
310        let constraints = if let Some(constraint_arr) = conditions.get("parameter_constraints") {
311            let arr = constraint_arr
312                .as_array()
313                .ok_or_else(|| PolicyValidationError {
314                    policy_id: policy.id.clone(),
315                    policy_name: policy.name.clone(),
316                    reason: "parameter_constraints must be an array".to_string(),
317                })?;
318
319            // SECURITY (FIND-R46-012): Reject excessively large constraint arrays
320            // to prevent memory exhaustion during policy compilation.
321            if arr.len() > MAX_PARAMETER_CONSTRAINTS {
322                return Err(PolicyValidationError {
323                    policy_id: policy.id.clone(),
324                    policy_name: policy.name.clone(),
325                    reason: format!(
326                        "parameter_constraints has {} entries, max is {}",
327                        arr.len(),
328                        MAX_PARAMETER_CONSTRAINTS
329                    ),
330                });
331            }
332
333            let mut constraints = Vec::with_capacity(arr.len());
334            for constraint_val in arr {
335                constraints.push(Self::compile_constraint(policy, constraint_val)?);
336            }
337            constraints
338        } else {
339            Vec::new()
340        };
341
342        // SECURITY (FIND-R50-060): Maximum number of context conditions per policy.
343        const MAX_CONTEXT_CONDITIONS: usize = 50;
344
345        // Parse context conditions (session-level checks)
346        let context_conditions = if let Some(ctx_arr) = conditions.get("context_conditions") {
347            let arr = ctx_arr.as_array().ok_or_else(|| PolicyValidationError {
348                policy_id: policy.id.clone(),
349                policy_name: policy.name.clone(),
350                reason: "context_conditions must be an array".to_string(),
351            })?;
352
353            // SECURITY (FIND-R50-060): Reject excessively large context condition arrays
354            // to prevent memory exhaustion during policy compilation.
355            if arr.len() > MAX_CONTEXT_CONDITIONS {
356                return Err(PolicyValidationError {
357                    policy_id: policy.id.clone(),
358                    policy_name: policy.name.clone(),
359                    reason: format!(
360                        "context_conditions has {} entries, max is {}",
361                        arr.len(),
362                        MAX_CONTEXT_CONDITIONS
363                    ),
364                });
365            }
366
367            let mut context_conditions = Vec::with_capacity(arr.len());
368            for ctx_val in arr {
369                context_conditions.push(Self::compile_context_condition(policy, ctx_val)?);
370            }
371            context_conditions
372        } else {
373            Vec::new()
374        };
375
376        // Validate strict mode unknown keys
377        if strict_mode {
378            let known_keys = [
379                "require_approval",
380                "forbidden_parameters",
381                "required_parameters",
382                "parameter_constraints",
383                "context_conditions",
384                "on_no_match",
385            ];
386            if let Some(obj) = conditions.as_object() {
387                for key in obj.keys() {
388                    if !known_keys.contains(&key.as_str()) {
389                        return Err(PolicyValidationError {
390                            policy_id: policy.id.clone(),
391                            policy_name: policy.name.clone(),
392                            reason: format!("Unknown condition key '{key}' in strict mode"),
393                        });
394                    }
395                }
396            }
397        }
398
399        // SECURITY (FIND-CREATIVE-006): Warn when on_no_match="continue" is combined
400        // with ALL constraints having on_missing="skip". This combination means:
401        //   1. If the action omits the required parameters, every constraint is skipped.
402        //   2. Since on_no_match="continue", the policy returns None (skip to next).
403        //   3. An attacker who intentionally omits context parameters bypasses ALL
404        //      Conditional policies with this pattern, falling through to the default verdict.
405        // This is always exploitable — the attacker controls which parameters to send.
406        // Operators should set at least one constraint to on_missing="deny" or remove
407        // on_no_match="continue" to ensure fail-closed behavior.
408        //
409        // Exception: blocklist policies where ALL constraints use param="*" (wildcard).
410        // These scan all parameter values and correctly skip when no parameters exist —
411        // a tool call with no parameters has nothing dangerous to block.
412        let all_wildcard_params = constraints.iter().all(|c| c.param() == "*");
413        if on_no_match_continue
414            && !constraints.is_empty()
415            && constraints.iter().all(|c| c.on_missing() == "skip")
416            && forbidden_parameters.is_empty()
417            && required_parameters.is_empty()
418            && !all_wildcard_params
419        {
420            tracing::warn!(
421                policy_id = %policy.id,
422                policy_name = %policy.name,
423                constraint_count = constraints.len(),
424                "Conditional policy has on_no_match=\"continue\" with ALL constraints \
425                 using on_missing=\"skip\" — an attacker can bypass this policy entirely \
426                 by omitting the required parameters. Set at least one constraint to \
427                 on_missing=\"deny\" or remove on_no_match=\"continue\"."
428            );
429        }
430
431        Ok(CompiledConditions {
432            require_approval,
433            forbidden_parameters,
434            required_parameters,
435            constraints,
436            on_no_match_continue,
437            context_conditions,
438        })
439    }
440
441    /// Compile a single constraint JSON object into a `CompiledConstraint`.
442    ///
443    /// SECURITY (FIND-R190-003): Arrays within constraints (patterns, values) are bounded
444    /// at `MAX_CONSTRAINT_ELEMENTS` to prevent OOM from oversized single constraints.
445    fn compile_constraint(
446        policy: &Policy,
447        constraint: &serde_json::Value,
448    ) -> Result<CompiledConstraint, PolicyValidationError> {
449        /// Maximum elements within a single constraint's patterns/values array.
450        const MAX_CONSTRAINT_ELEMENTS: usize = 1000;
451        let obj = constraint
452            .as_object()
453            .ok_or_else(|| PolicyValidationError {
454                policy_id: policy.id.clone(),
455                policy_name: policy.name.clone(),
456                reason: "Each parameter constraint must be a JSON object".to_string(),
457            })?;
458
459        let param = obj
460            .get("param")
461            .and_then(|v| v.as_str())
462            .ok_or_else(|| PolicyValidationError {
463                policy_id: policy.id.clone(),
464                policy_name: policy.name.clone(),
465                reason: "Constraint missing required 'param' string field".to_string(),
466            })?
467            .to_string();
468
469        let op = obj
470            .get("op")
471            .and_then(|v| v.as_str())
472            .ok_or_else(|| PolicyValidationError {
473                policy_id: policy.id.clone(),
474                policy_name: policy.name.clone(),
475                reason: "Constraint missing required 'op' string field".to_string(),
476            })?;
477
478        let on_match = obj
479            .get("on_match")
480            .and_then(|v| v.as_str())
481            .unwrap_or("deny")
482            .to_string();
483        // SECURITY (R8-11): Validate on_match at compile time — a typo like
484        // "alow" would silently become a runtime error instead of a clear deny.
485        match on_match.as_str() {
486            "deny" | "allow" | "require_approval" => {}
487            other => {
488                return Err(PolicyValidationError {
489                    policy_id: policy.id.clone(),
490                    policy_name: policy.name.clone(),
491                    reason: format!(
492                        "Constraint 'on_match' value '{other}' is invalid; expected 'deny', 'allow', or 'require_approval'"
493                    ),
494                });
495            }
496        }
497        let on_missing = obj
498            .get("on_missing")
499            .and_then(|v| v.as_str())
500            .unwrap_or("deny")
501            .to_string();
502        match on_missing.as_str() {
503            "deny" | "skip" => {}
504            other => {
505                return Err(PolicyValidationError {
506                    policy_id: policy.id.clone(),
507                    policy_name: policy.name.clone(),
508                    reason: format!(
509                        "Constraint 'on_missing' value '{other}' is invalid; expected 'deny' or 'skip'"
510                    ),
511                });
512            }
513        }
514
515        match op {
516            "glob" => {
517                let pattern_str = obj
518                    .get("pattern")
519                    .and_then(|v| v.as_str())
520                    .ok_or_else(|| PolicyValidationError {
521                        policy_id: policy.id.clone(),
522                        policy_name: policy.name.clone(),
523                        reason: "glob constraint missing 'pattern' string".to_string(),
524                    })?
525                    .to_string();
526
527                let matcher = Glob::new(&pattern_str)
528                    .map_err(|e| PolicyValidationError {
529                        policy_id: policy.id.clone(),
530                        policy_name: policy.name.clone(),
531                        reason: format!("Invalid glob pattern '{pattern_str}': {e}"),
532                    })?
533                    .compile_matcher();
534
535                Ok(CompiledConstraint::Glob {
536                    param,
537                    matcher,
538                    pattern_str,
539                    on_match,
540                    on_missing,
541                })
542            }
543            "not_glob" => {
544                let patterns = obj
545                    .get("patterns")
546                    .and_then(|v| v.as_array())
547                    .ok_or_else(|| PolicyValidationError {
548                        policy_id: policy.id.clone(),
549                        policy_name: policy.name.clone(),
550                        reason: "not_glob constraint missing 'patterns' array".to_string(),
551                    })?;
552
553                // SECURITY (FIND-R190-003): Bound patterns array to prevent OOM.
554                if patterns.len() > MAX_CONSTRAINT_ELEMENTS {
555                    return Err(PolicyValidationError {
556                        policy_id: policy.id.clone(),
557                        policy_name: policy.name.clone(),
558                        reason: format!(
559                            "not_glob patterns count {} exceeds maximum {}",
560                            patterns.len(),
561                            MAX_CONSTRAINT_ELEMENTS
562                        ),
563                    });
564                }
565
566                let mut matchers = Vec::new();
567                for pat_val in patterns {
568                    let pat_str = pat_val.as_str().ok_or_else(|| PolicyValidationError {
569                        policy_id: policy.id.clone(),
570                        policy_name: policy.name.clone(),
571                        reason: "not_glob patterns must be strings".to_string(),
572                    })?;
573                    let matcher = Glob::new(pat_str)
574                        .map_err(|e| PolicyValidationError {
575                            policy_id: policy.id.clone(),
576                            policy_name: policy.name.clone(),
577                            reason: format!("Invalid glob pattern '{pat_str}': {e}"),
578                        })?
579                        .compile_matcher();
580                    matchers.push((pat_str.to_string(), matcher));
581                }
582
583                Ok(CompiledConstraint::NotGlob {
584                    param,
585                    matchers,
586                    on_match,
587                    on_missing,
588                })
589            }
590            "regex" => {
591                let pattern_str = obj
592                    .get("pattern")
593                    .and_then(|v| v.as_str())
594                    .ok_or_else(|| PolicyValidationError {
595                        policy_id: policy.id.clone(),
596                        policy_name: policy.name.clone(),
597                        reason: "regex constraint missing 'pattern' string".to_string(),
598                    })?
599                    .to_string();
600
601                // H2: ReDoS safety check at policy load time (early rejection)
602                Self::validate_regex_safety(&pattern_str).map_err(|reason| {
603                    PolicyValidationError {
604                        policy_id: policy.id.clone(),
605                        policy_name: policy.name.clone(),
606                        reason,
607                    }
608                })?;
609
610                // SECURITY (FIND-R46-004): Set explicit DFA size limit to prevent
611                // memory exhaustion from regex patterns that compile to large automata.
612                let regex = regex::RegexBuilder::new(&pattern_str)
613                    .dfa_size_limit(256 * 1024)
614                    .size_limit(256 * 1024)
615                    .build()
616                    .map_err(|e| PolicyValidationError {
617                        policy_id: policy.id.clone(),
618                        policy_name: policy.name.clone(),
619                        reason: format!("Invalid regex pattern '{pattern_str}': {e}"),
620                    })?;
621
622                Ok(CompiledConstraint::Regex {
623                    param,
624                    regex,
625                    pattern_str,
626                    on_match,
627                    on_missing,
628                })
629            }
630            "domain_match" => {
631                let pattern = obj
632                    .get("pattern")
633                    .and_then(|v| v.as_str())
634                    .ok_or_else(|| PolicyValidationError {
635                        policy_id: policy.id.clone(),
636                        policy_name: policy.name.clone(),
637                        reason: "domain_match constraint missing 'pattern' string".to_string(),
638                    })?
639                    .to_string();
640
641                // SECURITY (FIND-R190-008): RFC 1035 validation on domain patterns.
642                if let Err(reason) = crate::domain::validate_domain_pattern(&pattern) {
643                    return Err(PolicyValidationError {
644                        policy_id: policy.id.clone(),
645                        policy_name: policy.name.clone(),
646                        reason: format!("domain_match pattern invalid: {reason}"),
647                    });
648                }
649
650                Ok(CompiledConstraint::DomainMatch {
651                    param,
652                    pattern,
653                    on_match,
654                    on_missing,
655                })
656            }
657            "domain_not_in" => {
658                let patterns_arr =
659                    obj.get("patterns")
660                        .and_then(|v| v.as_array())
661                        .ok_or_else(|| PolicyValidationError {
662                            policy_id: policy.id.clone(),
663                            policy_name: policy.name.clone(),
664                            reason: "domain_not_in constraint missing 'patterns' array".to_string(),
665                        })?;
666
667                // SECURITY (FIND-R190-003): Bound patterns array to prevent OOM.
668                if patterns_arr.len() > MAX_CONSTRAINT_ELEMENTS {
669                    return Err(PolicyValidationError {
670                        policy_id: policy.id.clone(),
671                        policy_name: policy.name.clone(),
672                        reason: format!(
673                            "domain_not_in patterns count {} exceeds maximum {}",
674                            patterns_arr.len(),
675                            MAX_CONSTRAINT_ELEMENTS
676                        ),
677                    });
678                }
679
680                let mut patterns = Vec::new();
681                for pat_val in patterns_arr {
682                    let pat_str = pat_val.as_str().ok_or_else(|| PolicyValidationError {
683                        policy_id: policy.id.clone(),
684                        policy_name: policy.name.clone(),
685                        reason: "domain_not_in patterns must be strings".to_string(),
686                    })?;
687                    // SECURITY (FIND-R190-008): RFC 1035 validation on domain patterns.
688                    if let Err(reason) = crate::domain::validate_domain_pattern(pat_str) {
689                        return Err(PolicyValidationError {
690                            policy_id: policy.id.clone(),
691                            policy_name: policy.name.clone(),
692                            reason: format!("domain_not_in pattern invalid: {reason}"),
693                        });
694                    }
695                    patterns.push(pat_str.to_string());
696                }
697
698                Ok(CompiledConstraint::DomainNotIn {
699                    param,
700                    patterns,
701                    on_match,
702                    on_missing,
703                })
704            }
705            "eq" => {
706                let value = obj
707                    .get("value")
708                    .ok_or_else(|| PolicyValidationError {
709                        policy_id: policy.id.clone(),
710                        policy_name: policy.name.clone(),
711                        reason: "eq constraint missing 'value' field".to_string(),
712                    })?
713                    .clone();
714
715                Ok(CompiledConstraint::Eq {
716                    param,
717                    value,
718                    on_match,
719                    on_missing,
720                })
721            }
722            "ne" => {
723                let value = obj
724                    .get("value")
725                    .ok_or_else(|| PolicyValidationError {
726                        policy_id: policy.id.clone(),
727                        policy_name: policy.name.clone(),
728                        reason: "ne constraint missing 'value' field".to_string(),
729                    })?
730                    .clone();
731
732                Ok(CompiledConstraint::Ne {
733                    param,
734                    value,
735                    on_match,
736                    on_missing,
737                })
738            }
739            "one_of" => {
740                let values = obj
741                    .get("values")
742                    .and_then(|v| v.as_array())
743                    .ok_or_else(|| PolicyValidationError {
744                        policy_id: policy.id.clone(),
745                        policy_name: policy.name.clone(),
746                        reason: "one_of constraint missing 'values' array".to_string(),
747                    })?;
748
749                // SECURITY (FIND-R190-003): Bound values array to prevent OOM.
750                if values.len() > MAX_CONSTRAINT_ELEMENTS {
751                    return Err(PolicyValidationError {
752                        policy_id: policy.id.clone(),
753                        policy_name: policy.name.clone(),
754                        reason: format!(
755                            "one_of values count {} exceeds maximum {}",
756                            values.len(),
757                            MAX_CONSTRAINT_ELEMENTS
758                        ),
759                    });
760                }
761                let values = values.clone();
762
763                Ok(CompiledConstraint::OneOf {
764                    param,
765                    values,
766                    on_match,
767                    on_missing,
768                })
769            }
770            "none_of" => {
771                let values = obj
772                    .get("values")
773                    .and_then(|v| v.as_array())
774                    .ok_or_else(|| PolicyValidationError {
775                        policy_id: policy.id.clone(),
776                        policy_name: policy.name.clone(),
777                        reason: "none_of constraint missing 'values' array".to_string(),
778                    })?;
779
780                // SECURITY (FIND-R190-003): Bound values array to prevent OOM.
781                if values.len() > MAX_CONSTRAINT_ELEMENTS {
782                    return Err(PolicyValidationError {
783                        policy_id: policy.id.clone(),
784                        policy_name: policy.name.clone(),
785                        reason: format!(
786                            "none_of values count {} exceeds maximum {}",
787                            values.len(),
788                            MAX_CONSTRAINT_ELEMENTS
789                        ),
790                    });
791                }
792                let values = values.clone();
793
794                Ok(CompiledConstraint::NoneOf {
795                    param,
796                    values,
797                    on_match,
798                    on_missing,
799                })
800            }
801            _ => Err(PolicyValidationError {
802                policy_id: policy.id.clone(),
803                policy_name: policy.name.clone(),
804                reason: format!("Unknown constraint operator '{op}'"),
805            }),
806        }
807    }
808
809    /// Compile a single context condition JSON object into a [`CompiledContextCondition`].
810    fn compile_context_condition(
811        policy: &Policy,
812        value: &serde_json::Value,
813    ) -> Result<CompiledContextCondition, PolicyValidationError> {
814        let obj = value.as_object().ok_or_else(|| PolicyValidationError {
815            policy_id: policy.id.clone(),
816            policy_name: policy.name.clone(),
817            reason: "Each context condition must be a JSON object".to_string(),
818        })?;
819
820        let kind =
821            obj.get("type")
822                .and_then(|v| v.as_str())
823                .ok_or_else(|| PolicyValidationError {
824                    policy_id: policy.id.clone(),
825                    policy_name: policy.name.clone(),
826                    reason: "Context condition missing required 'type' string field".to_string(),
827                })?;
828
829        match kind {
830            "time_window" => {
831                // SECURITY (R19-TRUNC): Validate u64 range BEFORE casting to u8.
832                // Without this, `start_hour: 265` truncates to `265 % 256 = 9` as u8,
833                // silently passing the `> 23` check. An attacker could craft a policy
834                // that appears to restrict hours but actually maps to a different hour.
835                let start_hour_u64 =
836                    obj.get("start_hour")
837                        .and_then(|v| v.as_u64())
838                        .ok_or_else(|| PolicyValidationError {
839                            policy_id: policy.id.clone(),
840                            policy_name: policy.name.clone(),
841                            reason: "time_window missing 'start_hour' integer".to_string(),
842                        })?;
843                let end_hour_u64 =
844                    obj.get("end_hour")
845                        .and_then(|v| v.as_u64())
846                        .ok_or_else(|| PolicyValidationError {
847                            policy_id: policy.id.clone(),
848                            policy_name: policy.name.clone(),
849                            reason: "time_window missing 'end_hour' integer".to_string(),
850                        })?;
851                if start_hour_u64 > 23 || end_hour_u64 > 23 {
852                    return Err(PolicyValidationError {
853                        policy_id: policy.id.clone(),
854                        policy_name: policy.name.clone(),
855                        reason: format!(
856                            "time_window hours must be 0-23, got start={start_hour_u64} end={end_hour_u64}"
857                        ),
858                    });
859                }
860                let start_hour = start_hour_u64 as u8;
861                let end_hour = end_hour_u64 as u8;
862                // SECURITY (R19-TRUNC): Validate day values as u64 BEFORE casting to u8.
863                // Same truncation issue as hours: `day: 258` → `258 % 256 = 2` as u8.
864                let days_u64: Vec<u64> = obj
865                    .get("days")
866                    .and_then(|v| v.as_array())
867                    .map(|arr| arr.iter().filter_map(|v| v.as_u64()).collect())
868                    .unwrap_or_default();
869                for &day in &days_u64 {
870                    if !(1..=7).contains(&day) {
871                        return Err(PolicyValidationError {
872                            policy_id: policy.id.clone(),
873                            policy_name: policy.name.clone(),
874                            reason: format!(
875                                "time_window day value must be 1-7 (Mon-Sun), got {day}"
876                            ),
877                        });
878                    }
879                }
880                let days: Vec<u8> = days_u64.iter().map(|&d| d as u8).collect();
881                // SECURITY (R19-WINDOW-EQ): Reject start_hour == end_hour as a
882                // configuration error. The window check `hour >= X && hour < X` is
883                // always false, creating a permanent deny that looks like a time
884                // restriction but blocks all hours silently.
885                if start_hour == end_hour {
886                    return Err(PolicyValidationError {
887                        policy_id: policy.id.clone(),
888                        policy_name: policy.name.clone(),
889                        reason: format!(
890                            "time_window start_hour and end_hour must differ (both are {start_hour}); \
891                             a zero-width window permanently denies all requests"
892                        ),
893                    });
894                }
895                let deny_reason = format!(
896                    "Outside allowed time window ({:02}:00-{:02}:00) for policy '{}'",
897                    start_hour, end_hour, policy.name
898                );
899                Ok(CompiledContextCondition::TimeWindow {
900                    start_hour,
901                    end_hour,
902                    days,
903                    deny_reason,
904                })
905            }
906            "max_calls" => {
907                // R36-ENG-1: lowercase tool_pattern at compile time so
908                // PatternMatcher::matches() (case-sensitive) agrees with
909                // the lowercased call_count keys built at evaluation time.
910                let tool_pattern = obj
911                    .get("tool_pattern")
912                    .and_then(|v| v.as_str())
913                    .unwrap_or("*")
914                    .to_ascii_lowercase();
915                let max = obj.get("max").and_then(|v| v.as_u64()).ok_or_else(|| {
916                    PolicyValidationError {
917                        policy_id: policy.id.clone(),
918                        policy_name: policy.name.clone(),
919                        reason: "max_calls missing 'max' integer".to_string(),
920                    }
921                })?;
922                let deny_reason = format!(
923                    "Tool call limit ({}) exceeded for pattern '{}' in policy '{}'",
924                    max, tool_pattern, policy.name
925                );
926                Ok(CompiledContextCondition::MaxCalls {
927                    tool_pattern: PatternMatcher::compile(&tool_pattern),
928                    max,
929                    deny_reason,
930                })
931            }
932            "agent_id" => {
933                // SECURITY (FIND-R111-006): Bounds on agent_id allowed/blocked lists.
934                const MAX_AGENT_ID_LIST: usize = 1000;
935
936                // SECURITY: Normalize agent IDs to lowercase at compile time
937                // to prevent case-variation bypasses (e.g., "Agent-A" vs "agent-a").
938                let allowed_raw = obj
939                    .get("allowed")
940                    .and_then(|v| v.as_array())
941                    .map(|arr| arr.as_slice())
942                    .unwrap_or_default();
943                if allowed_raw.len() > MAX_AGENT_ID_LIST {
944                    return Err(PolicyValidationError {
945                        policy_id: policy.id.clone(),
946                        policy_name: policy.name.clone(),
947                        reason: format!(
948                            "agent_id allowed list count {} exceeds max {}",
949                            allowed_raw.len(),
950                            MAX_AGENT_ID_LIST
951                        ),
952                    });
953                }
954                // SECURITY (FIND-R209-002): Normalize homoglyphs at compile time
955                // to prevent Cyrillic/Greek/fullwidth agent IDs from bypassing
956                // blocklists/allowlists.
957                let allowed: Vec<String> = allowed_raw
958                    .iter()
959                    .filter_map(|v| v.as_str().map(normalize_full))
960                    .collect();
961
962                let blocked_raw = obj
963                    .get("blocked")
964                    .and_then(|v| v.as_array())
965                    .map(|arr| arr.as_slice())
966                    .unwrap_or_default();
967                if blocked_raw.len() > MAX_AGENT_ID_LIST {
968                    return Err(PolicyValidationError {
969                        policy_id: policy.id.clone(),
970                        policy_name: policy.name.clone(),
971                        reason: format!(
972                            "agent_id blocked list count {} exceeds max {}",
973                            blocked_raw.len(),
974                            MAX_AGENT_ID_LIST
975                        ),
976                    });
977                }
978                // SECURITY (FIND-R209-002): Normalize homoglyphs at compile time.
979                let blocked: Vec<String> = blocked_raw
980                    .iter()
981                    .filter_map(|v| v.as_str().map(normalize_full))
982                    .collect();
983
984                let deny_reason =
985                    format!("Agent identity not authorized by policy '{}'", policy.name);
986                Ok(CompiledContextCondition::AgentId {
987                    allowed,
988                    blocked,
989                    deny_reason,
990                })
991            }
992            "require_previous_action" => {
993                // SECURITY (FIND-R46-003): Lowercase at compile time to match the
994                // case-insensitive comparison in context_check.rs (R31-ENG-7).
995                // SECURITY (FIND-R206-008): Also normalize homoglyphs at compile time.
996                let required_tool = normalize_full(
997                    obj.get("required_tool")
998                        .and_then(|v| v.as_str())
999                        .ok_or_else(|| PolicyValidationError {
1000                            policy_id: policy.id.clone(),
1001                            policy_name: policy.name.clone(),
1002                            reason: "require_previous_action missing 'required_tool' string"
1003                                .to_string(),
1004                        })?,
1005                );
1006                let deny_reason = format!(
1007                    "Required previous action '{}' not found in session history (policy '{}')",
1008                    required_tool, policy.name
1009                );
1010                Ok(CompiledContextCondition::RequirePreviousAction {
1011                    required_tool,
1012                    deny_reason,
1013                })
1014            }
1015            "forbidden_previous_action" => {
1016                // SECURITY (FIND-R46-003): Lowercase at compile time to match the
1017                // case-insensitive comparison in context_check.rs (R31-ENG-7).
1018                // SECURITY (FIND-R206-007): Also normalize homoglyphs at compile time.
1019                let forbidden_tool = normalize_full(
1020                    obj.get("forbidden_tool")
1021                        .and_then(|v| v.as_str())
1022                        .ok_or_else(|| PolicyValidationError {
1023                            policy_id: policy.id.clone(),
1024                            policy_name: policy.name.clone(),
1025                            reason: "forbidden_previous_action missing 'forbidden_tool' string"
1026                                .to_string(),
1027                        })?,
1028                );
1029                let deny_reason = format!(
1030                    "Forbidden previous action '{}' detected in session history (policy '{}')",
1031                    forbidden_tool, policy.name
1032                );
1033                Ok(CompiledContextCondition::ForbiddenPreviousAction {
1034                    forbidden_tool,
1035                    deny_reason,
1036                })
1037            }
1038            "max_calls_in_window" => {
1039                // R36-ENG-1: lowercase tool_pattern at compile time so
1040                // PatternMatcher::matches() (case-sensitive) agrees with
1041                // the lowercased previous_actions built at evaluation time.
1042                let tool_pattern = obj
1043                    .get("tool_pattern")
1044                    .and_then(|v| v.as_str())
1045                    .unwrap_or("*")
1046                    .to_ascii_lowercase();
1047                let max = obj.get("max").and_then(|v| v.as_u64()).ok_or_else(|| {
1048                    PolicyValidationError {
1049                        policy_id: policy.id.clone(),
1050                        policy_name: policy.name.clone(),
1051                        reason: "max_calls_in_window missing 'max' integer".to_string(),
1052                    }
1053                })?;
1054                // SECURITY (FIND-P2-004, R34-ENG-2): Use try_from instead of `as usize`
1055                // and return a compilation error if the value overflows usize, instead
1056                // of silently clamping to usize::MAX.
1057                let window_raw = obj.get("window").and_then(|v| v.as_u64()).unwrap_or(0);
1058                let window = usize::try_from(window_raw).map_err(|_| PolicyValidationError {
1059                    policy_id: policy.id.clone(),
1060                    policy_name: policy.name.clone(),
1061                    reason: format!(
1062                        "max_calls_in_window 'window' value {} exceeds platform maximum ({})",
1063                        window_raw,
1064                        usize::MAX
1065                    ),
1066                })?;
1067                let deny_reason = format!(
1068                    "Tool '{}' called more than {} times in last {} actions (policy '{}')",
1069                    tool_pattern,
1070                    max,
1071                    if window == 0 {
1072                        "all".to_string()
1073                    } else {
1074                        window.to_string()
1075                    },
1076                    policy.name
1077                );
1078                Ok(CompiledContextCondition::MaxCallsInWindow {
1079                    tool_pattern: PatternMatcher::compile(&tool_pattern),
1080                    max,
1081                    window,
1082                    deny_reason,
1083                })
1084            }
1085            "max_chain_depth" => {
1086                // OWASP ASI08: Multi-agent communication monitoring
1087                // SECURITY (R33-ENG-3): Use try_from instead of `as usize` to
1088                // avoid silent truncation on 32-bit platforms where u64 > usize::MAX.
1089                let raw_depth = obj
1090                    .get("max_depth")
1091                    .and_then(|v| v.as_u64())
1092                    .ok_or_else(|| PolicyValidationError {
1093                        policy_id: policy.id.clone(),
1094                        policy_name: policy.name.clone(),
1095                        reason: "max_chain_depth missing 'max_depth' integer".to_string(),
1096                    })?;
1097                let max_depth = usize::try_from(raw_depth).map_err(|_| PolicyValidationError {
1098                    policy_id: policy.id.clone(),
1099                    policy_name: policy.name.clone(),
1100                    reason: format!("max_chain_depth value {raw_depth} exceeds platform maximum"),
1101                })?;
1102                let deny_reason = format!(
1103                    "Call chain depth exceeds maximum of {} (policy '{}')",
1104                    max_depth, policy.name
1105                );
1106                Ok(CompiledContextCondition::MaxChainDepth {
1107                    max_depth,
1108                    deny_reason,
1109                })
1110            }
1111            "agent_identity" => {
1112                // OWASP ASI07: Agent identity attestation via signed JWT
1113                // SECURITY: Normalize required fields to lowercase + homoglyphs for
1114                // case-insensitive matching, consistent with blocked_issuers/blocked_subjects (R40-ENG-2)
1115                // SECURITY (FIND-R211-002): Normalize homoglyphs at compile time to match
1116                // evaluation-time normalization, preventing Cyrillic/fullwidth bypass.
1117                let required_issuer = obj
1118                    .get("issuer")
1119                    .and_then(|v| v.as_str())
1120                    .map(normalize_full);
1121                let required_subject = obj
1122                    .get("subject")
1123                    .and_then(|v| v.as_str())
1124                    .map(normalize_full);
1125                let required_audience = obj
1126                    .get("audience")
1127                    .and_then(|v| v.as_str())
1128                    .map(normalize_full);
1129
1130                // SECURITY (FIND-R111-005): Per-key/value length bounds on required_claims.
1131                const MAX_REQUIRED_CLAIMS: usize = 64;
1132                const MAX_CLAIM_KEY_LEN: usize = 256;
1133                const MAX_CLAIM_VALUE_LEN: usize = 512;
1134
1135                // Parse required_claims as a map of string -> string
1136                // SECURITY (FIND-044): Lowercase claim values at compile time for
1137                // case-insensitive comparison, matching issuer/subject/audience.
1138                let required_claims = if let Some(m) = obj.get("claims").and_then(|v| v.as_object())
1139                {
1140                    if m.len() > MAX_REQUIRED_CLAIMS {
1141                        return Err(PolicyValidationError {
1142                            policy_id: policy.id.clone(),
1143                            policy_name: policy.name.clone(),
1144                            reason: format!(
1145                                "agent_identity claims count {} exceeds max {}",
1146                                m.len(),
1147                                MAX_REQUIRED_CLAIMS
1148                            ),
1149                        });
1150                    }
1151                    let mut map = std::collections::HashMap::new();
1152                    for (k, v) in m {
1153                        if k.len() > MAX_CLAIM_KEY_LEN {
1154                            return Err(PolicyValidationError {
1155                                policy_id: policy.id.clone(),
1156                                policy_name: policy.name.clone(),
1157                                reason: format!(
1158                                    "agent_identity claim key length {} exceeds max {}",
1159                                    k.len(),
1160                                    MAX_CLAIM_KEY_LEN
1161                                ),
1162                            });
1163                        }
1164                        if let Some(s) = v.as_str() {
1165                            if s.len() > MAX_CLAIM_VALUE_LEN {
1166                                return Err(PolicyValidationError {
1167                                    policy_id: policy.id.clone(),
1168                                    policy_name: policy.name.clone(),
1169                                    reason: format!(
1170                                        "agent_identity claim value for key '{}' length {} exceeds max {}",
1171                                        k,
1172                                        s.len(),
1173                                        MAX_CLAIM_VALUE_LEN
1174                                    ),
1175                                });
1176                            }
1177                            map.insert(k.clone(), normalize_full(s));
1178                        }
1179                    }
1180                    map
1181                } else {
1182                    std::collections::HashMap::new()
1183                };
1184
1185                // SECURITY (FIND-R111-006): Bounds on blocked_issuers and blocked_subjects lists.
1186                const MAX_ISSUER_LIST: usize = 256;
1187                const MAX_SUBJECT_LIST: usize = 256;
1188
1189                // SECURITY: Normalize blocked lists to lowercase + homoglyphs for case-insensitive matching
1190                // SECURITY (FIND-R211-002): Normalize homoglyphs at compile time to match
1191                // evaluation-time normalization, preventing Cyrillic/fullwidth bypass.
1192                let blocked_issuers_raw = obj
1193                    .get("blocked_issuers")
1194                    .and_then(|v| v.as_array())
1195                    .map(|arr| arr.as_slice())
1196                    .unwrap_or_default();
1197                if blocked_issuers_raw.len() > MAX_ISSUER_LIST {
1198                    return Err(PolicyValidationError {
1199                        policy_id: policy.id.clone(),
1200                        policy_name: policy.name.clone(),
1201                        reason: format!(
1202                            "agent_identity blocked_issuers count {} exceeds max {}",
1203                            blocked_issuers_raw.len(),
1204                            MAX_ISSUER_LIST
1205                        ),
1206                    });
1207                }
1208                let blocked_issuers: Vec<String> = blocked_issuers_raw
1209                    .iter()
1210                    .filter_map(|v| v.as_str().map(normalize_full))
1211                    .collect();
1212
1213                let blocked_subjects_raw = obj
1214                    .get("blocked_subjects")
1215                    .and_then(|v| v.as_array())
1216                    .map(|arr| arr.as_slice())
1217                    .unwrap_or_default();
1218                if blocked_subjects_raw.len() > MAX_SUBJECT_LIST {
1219                    return Err(PolicyValidationError {
1220                        policy_id: policy.id.clone(),
1221                        policy_name: policy.name.clone(),
1222                        reason: format!(
1223                            "agent_identity blocked_subjects count {} exceeds max {}",
1224                            blocked_subjects_raw.len(),
1225                            MAX_SUBJECT_LIST
1226                        ),
1227                    });
1228                }
1229                let blocked_subjects: Vec<String> = blocked_subjects_raw
1230                    .iter()
1231                    .filter_map(|v| v.as_str().map(normalize_full))
1232                    .collect();
1233
1234                // When true, fail if no agent_identity is present (require JWT attestation)
1235                let require_attestation = obj
1236                    .get("require_attestation")
1237                    .and_then(|v| v.as_bool())
1238                    .unwrap_or(true); // Default to true for security
1239
1240                let deny_reason = format!(
1241                    "Agent identity attestation failed for policy '{}'",
1242                    policy.name
1243                );
1244
1245                Ok(CompiledContextCondition::AgentIdentityMatch {
1246                    required_issuer,
1247                    required_subject,
1248                    required_audience,
1249                    required_claims,
1250                    blocked_issuers,
1251                    blocked_subjects,
1252                    require_attestation,
1253                    deny_reason,
1254                })
1255            }
1256
1257            // ═══════════════════════════════════════════════════
1258            // MCP 2025-11-25 CONTEXT CONDITIONS
1259            // ═══════════════════════════════════════════════════
1260            "async_task_policy" => {
1261                // MCP 2025-11-25: Async task lifecycle policy
1262                // SECURITY (FIND-R49-005): Warn that this condition is a no-op at the engine level.
1263                tracing::warn!(
1264                    policy_id = %policy.id,
1265                    "Policy condition 'async_task_policy' is not enforced at the engine level; \
1266                     it requires the MCP proxy layer for enforcement"
1267                );
1268                // SECURITY (FIND-P3-015): Return a compilation error if max_concurrent
1269                // overflows usize, instead of silently clamping to usize::MAX.
1270                let max_concurrent_raw = obj
1271                    .get("max_concurrent")
1272                    .and_then(|v| v.as_u64())
1273                    .unwrap_or(0); // 0 = unlimited
1274                let max_concurrent = if max_concurrent_raw == 0 {
1275                    0
1276                } else {
1277                    usize::try_from(max_concurrent_raw).map_err(|_| PolicyValidationError {
1278                        policy_id: policy.id.clone(),
1279                        policy_name: policy.name.clone(),
1280                        reason: format!(
1281                            "async_task_policy 'max_concurrent' value {} exceeds platform maximum ({})",
1282                            max_concurrent_raw,
1283                            usize::MAX
1284                        ),
1285                    })?
1286                };
1287
1288                let max_duration_secs = obj
1289                    .get("max_duration_secs")
1290                    .and_then(|v| v.as_u64())
1291                    .unwrap_or(0); // 0 = unlimited
1292
1293                let require_self_cancel = obj
1294                    .get("require_self_cancel")
1295                    .and_then(|v| v.as_bool())
1296                    .unwrap_or(true); // Default: only creator can cancel
1297
1298                let deny_reason =
1299                    format!("Async task policy violated for policy '{}'", policy.name);
1300
1301                Ok(CompiledContextCondition::AsyncTaskPolicy {
1302                    max_concurrent,
1303                    max_duration_secs,
1304                    require_self_cancel,
1305                    deny_reason,
1306                })
1307            }
1308
1309            "resource_indicator" => {
1310                // RFC 8707: OAuth 2.0 Resource Indicators
1311                // SECURITY (FIND-R111-006): Bound the allowed_resources list.
1312                const MAX_RESOURCE_PATTERNS: usize = 256;
1313
1314                let allowed_resources_raw = obj
1315                    .get("allowed_resources")
1316                    .and_then(|v| v.as_array())
1317                    .map(|arr| arr.as_slice())
1318                    .unwrap_or_default();
1319                if allowed_resources_raw.len() > MAX_RESOURCE_PATTERNS {
1320                    return Err(PolicyValidationError {
1321                        policy_id: policy.id.clone(),
1322                        policy_name: policy.name.clone(),
1323                        reason: format!(
1324                            "resource_indicator allowed_resources count {} exceeds max {}",
1325                            allowed_resources_raw.len(),
1326                            MAX_RESOURCE_PATTERNS
1327                        ),
1328                    });
1329                }
1330                let allowed_resources: Vec<PatternMatcher> = allowed_resources_raw
1331                    .iter()
1332                    .filter_map(|v| v.as_str())
1333                    .map(PatternMatcher::compile)
1334                    .collect();
1335
1336                let require_resource = obj
1337                    .get("require_resource")
1338                    .and_then(|v| v.as_bool())
1339                    .unwrap_or(false);
1340
1341                let deny_reason = format!(
1342                    "Resource indicator validation failed for policy '{}'",
1343                    policy.name
1344                );
1345
1346                Ok(CompiledContextCondition::ResourceIndicator {
1347                    allowed_resources,
1348                    require_resource,
1349                    deny_reason,
1350                })
1351            }
1352
1353            "capability_required" => {
1354                // CIMD: Capability-Indexed Message Dispatch
1355                // SECURITY (FIND-043): Normalize to lowercase at compile time,
1356                // matching the pattern used by AgentId and MaxCalls.
1357
1358                // SECURITY (FIND-R112-003): Bound capability list sizes and per-entry length.
1359                const MAX_CAPABILITY_LIST: usize = 256;
1360                const MAX_CAPABILITY_NAME_LEN: usize = 256;
1361
1362                // SECURITY (FIND-R215-002): Apply normalize_homoglyphs() after
1363                // to_ascii_lowercase() for parity with AgentId, AgentIdentityMatch,
1364                // MaxCalls, RequireCapabilityToken compile-time normalization.
1365                let required_capabilities: Vec<String> = obj
1366                    .get("required_capabilities")
1367                    .and_then(|v| v.as_array())
1368                    .map(|arr| {
1369                        arr.iter()
1370                            .filter_map(|v| v.as_str().map(normalize_full))
1371                            .collect()
1372                    })
1373                    .unwrap_or_default();
1374
1375                if required_capabilities.len() > MAX_CAPABILITY_LIST {
1376                    return Err(PolicyValidationError {
1377                        policy_id: policy.id.clone(),
1378                        policy_name: policy.name.clone(),
1379                        reason: format!(
1380                            "capability_required has {} required_capabilities (max {MAX_CAPABILITY_LIST})",
1381                            required_capabilities.len()
1382                        ),
1383                    });
1384                }
1385                for (i, cap) in required_capabilities.iter().enumerate() {
1386                    if cap.len() > MAX_CAPABILITY_NAME_LEN {
1387                        return Err(PolicyValidationError {
1388                            policy_id: policy.id.clone(),
1389                            policy_name: policy.name.clone(),
1390                            reason: format!(
1391                                "capability_required required_capabilities[{i}] length {} exceeds max {MAX_CAPABILITY_NAME_LEN}",
1392                                cap.len()
1393                            ),
1394                        });
1395                    }
1396                }
1397
1398                let blocked_capabilities: Vec<String> = obj
1399                    .get("blocked_capabilities")
1400                    .and_then(|v| v.as_array())
1401                    .map(|arr| {
1402                        arr.iter()
1403                            .filter_map(|v| v.as_str().map(normalize_full))
1404                            .collect()
1405                    })
1406                    .unwrap_or_default();
1407
1408                if blocked_capabilities.len() > MAX_CAPABILITY_LIST {
1409                    return Err(PolicyValidationError {
1410                        policy_id: policy.id.clone(),
1411                        policy_name: policy.name.clone(),
1412                        reason: format!(
1413                            "capability_required has {} blocked_capabilities (max {MAX_CAPABILITY_LIST})",
1414                            blocked_capabilities.len()
1415                        ),
1416                    });
1417                }
1418                for (i, cap) in blocked_capabilities.iter().enumerate() {
1419                    if cap.len() > MAX_CAPABILITY_NAME_LEN {
1420                        return Err(PolicyValidationError {
1421                            policy_id: policy.id.clone(),
1422                            policy_name: policy.name.clone(),
1423                            reason: format!(
1424                                "capability_required blocked_capabilities[{i}] length {} exceeds max {MAX_CAPABILITY_NAME_LEN}",
1425                                cap.len()
1426                            ),
1427                        });
1428                    }
1429                }
1430
1431                let deny_reason = format!(
1432                    "Capability requirement not met for policy '{}'",
1433                    policy.name
1434                );
1435
1436                Ok(CompiledContextCondition::CapabilityRequired {
1437                    required_capabilities,
1438                    blocked_capabilities,
1439                    deny_reason,
1440                })
1441            }
1442
1443            "step_up_auth" => {
1444                // Step-up authentication
1445                let required_level_u64 = obj
1446                    .get("required_level")
1447                    .and_then(|v| v.as_u64())
1448                    .ok_or_else(|| PolicyValidationError {
1449                        policy_id: policy.id.clone(),
1450                        policy_name: policy.name.clone(),
1451                        reason: "step_up_auth missing 'required_level' integer".to_string(),
1452                    })?;
1453
1454                // Validate level is in valid range (0-4)
1455                if required_level_u64 > 4 {
1456                    return Err(PolicyValidationError {
1457                        policy_id: policy.id.clone(),
1458                        policy_name: policy.name.clone(),
1459                        reason: format!(
1460                            "step_up_auth required_level must be 0-4, got {required_level_u64}"
1461                        ),
1462                    });
1463                }
1464
1465                let required_level = required_level_u64 as u8;
1466
1467                let deny_reason = format!(
1468                    "Step-up authentication required (level {}) for policy '{}'",
1469                    required_level, policy.name
1470                );
1471
1472                Ok(CompiledContextCondition::StepUpAuth {
1473                    required_level,
1474                    deny_reason,
1475                })
1476            }
1477
1478            // ═══════════════════════════════════════════════════
1479            // PHASE 2: ADVANCED THREAT DETECTION CONDITIONS
1480            // ═══════════════════════════════════════════════════
1481            "circuit_breaker" => {
1482                // OWASP ASI08: Cascading failure protection
1483                // SECURITY (FIND-R49-005): Warn that this condition is a no-op at the engine level.
1484                tracing::warn!(
1485                    policy_id = %policy.id,
1486                    "Policy condition 'circuit_breaker' is not enforced at the engine level; \
1487                     it requires the MCP proxy layer for enforcement"
1488                );
1489                let tool_pattern = obj
1490                    .get("tool_pattern")
1491                    .and_then(|v| v.as_str())
1492                    .unwrap_or("*")
1493                    .to_ascii_lowercase();
1494
1495                // SECURITY (R226-ENG-3): Generic deny reason to avoid leaking
1496                // internal tool_pattern to API clients. Pattern details are
1497                // logged server-side at compile time (above tracing::debug!).
1498                let deny_reason = format!("Circuit breaker open (policy '{}')", policy.name);
1499
1500                Ok(CompiledContextCondition::CircuitBreaker {
1501                    tool_pattern: PatternMatcher::compile(&tool_pattern),
1502                    deny_reason,
1503                })
1504            }
1505
1506            "deputy_validation" => {
1507                // OWASP ASI02: Confused deputy prevention
1508                let require_principal = obj
1509                    .get("require_principal")
1510                    .and_then(|v| v.as_bool())
1511                    .unwrap_or(true);
1512
1513                let max_delegation_depth_u64 = obj
1514                    .get("max_delegation_depth")
1515                    .and_then(|v| v.as_u64())
1516                    .unwrap_or(3);
1517
1518                // Validate depth is reasonable
1519                if max_delegation_depth_u64 > 255 {
1520                    return Err(PolicyValidationError {
1521                        policy_id: policy.id.clone(),
1522                        policy_name: policy.name.clone(),
1523                        reason: format!(
1524                            "deputy_validation max_delegation_depth must be 0-255, got {max_delegation_depth_u64}"
1525                        ),
1526                    });
1527                }
1528
1529                let max_delegation_depth = max_delegation_depth_u64 as u8;
1530
1531                let deny_reason = format!("Deputy validation failed for policy '{}'", policy.name);
1532
1533                Ok(CompiledContextCondition::DeputyValidation {
1534                    require_principal,
1535                    max_delegation_depth,
1536                    deny_reason,
1537                })
1538            }
1539
1540            "shadow_agent_check" => {
1541                // Shadow agent detection
1542                // SECURITY (FIND-R49-005): Warn that this condition is a no-op at the engine level.
1543                tracing::warn!(
1544                    policy_id = %policy.id,
1545                    "Policy condition 'shadow_agent_check' is not enforced at the engine level; \
1546                     it requires the MCP proxy layer for enforcement"
1547                );
1548                let require_known_fingerprint = obj
1549                    .get("require_known_fingerprint")
1550                    .and_then(|v| v.as_bool())
1551                    .unwrap_or(false);
1552
1553                let min_trust_level_u64 = obj
1554                    .get("min_trust_level")
1555                    .and_then(|v| v.as_u64())
1556                    .unwrap_or(1); // Default: Low trust
1557
1558                // Validate level is in valid range (0-4)
1559                if min_trust_level_u64 > 4 {
1560                    return Err(PolicyValidationError {
1561                        policy_id: policy.id.clone(),
1562                        policy_name: policy.name.clone(),
1563                        reason: format!(
1564                            "shadow_agent_check min_trust_level must be 0-4, got {min_trust_level_u64}"
1565                        ),
1566                    });
1567                }
1568
1569                let min_trust_level = min_trust_level_u64 as u8;
1570
1571                let deny_reason = format!("Shadow agent check failed for policy '{}'", policy.name);
1572
1573                Ok(CompiledContextCondition::ShadowAgentCheck {
1574                    require_known_fingerprint,
1575                    min_trust_level,
1576                    deny_reason,
1577                })
1578            }
1579
1580            "schema_poisoning_check" => {
1581                // OWASP ASI05: Schema poisoning protection
1582                // SECURITY (FIND-R49-005): Warn that this condition is a no-op at the engine level.
1583                tracing::warn!(
1584                    policy_id = %policy.id,
1585                    "Policy condition 'schema_poisoning_check' is not enforced at the engine level; \
1586                     it requires the MCP proxy layer for enforcement"
1587                );
1588                let mutation_threshold = obj
1589                    .get("mutation_threshold")
1590                    .and_then(|v| v.as_f64())
1591                    .map(|v| v as f32)
1592                    .unwrap_or(0.1); // Default: 10% change triggers alert
1593
1594                // Validate threshold is in valid range
1595                if !mutation_threshold.is_finite() || !(0.0..=1.0).contains(&mutation_threshold) {
1596                    return Err(PolicyValidationError {
1597                        policy_id: policy.id.clone(),
1598                        policy_name: policy.name.clone(),
1599                        reason: format!(
1600                            "schema_poisoning_check mutation_threshold must be in [0.0, 1.0], got {mutation_threshold}"
1601                        ),
1602                    });
1603                }
1604
1605                let deny_reason = format!("Schema poisoning detected for policy '{}'", policy.name);
1606
1607                Ok(CompiledContextCondition::SchemaPoisoningCheck {
1608                    mutation_threshold,
1609                    deny_reason,
1610                })
1611            }
1612
1613            "require_capability_token" => {
1614                // SECURITY (FIND-R111-006): Bound the required_issuers list.
1615                const MAX_ISSUER_LIST_CAP: usize = 256;
1616
1617                // Parse required_issuers (optional array of strings)
1618                let required_issuers_raw = obj
1619                    .get("required_issuers")
1620                    .and_then(|v| v.as_array())
1621                    .map(|arr| arr.as_slice())
1622                    .unwrap_or_default();
1623                if required_issuers_raw.len() > MAX_ISSUER_LIST_CAP {
1624                    return Err(PolicyValidationError {
1625                        policy_id: policy.id.clone(),
1626                        policy_name: policy.name.clone(),
1627                        reason: format!(
1628                            "require_capability_token required_issuers count {} exceeds max {}",
1629                            required_issuers_raw.len(),
1630                            MAX_ISSUER_LIST_CAP
1631                        ),
1632                    });
1633                }
1634                // SECURITY (IMP-R216-005): Apply homoglyph normalization for parity
1635                // with agent_identity issuer/subject/audience checks.
1636                let required_issuers: Vec<String> = required_issuers_raw
1637                    .iter()
1638                    .filter_map(|v| v.as_str().map(normalize_full))
1639                    .collect();
1640
1641                // Parse min_remaining_depth (optional, default 0)
1642                let min_remaining_depth = obj
1643                    .get("min_remaining_depth")
1644                    .and_then(|v| v.as_u64())
1645                    .unwrap_or(0);
1646                if min_remaining_depth > 16 {
1647                    return Err(PolicyValidationError {
1648                        policy_id: policy.id.clone(),
1649                        policy_name: policy.name.clone(),
1650                        reason: format!(
1651                            "require_capability_token min_remaining_depth must be 0-16, got {min_remaining_depth}"
1652                        ),
1653                    });
1654                }
1655
1656                let deny_reason = format!("Capability token required for policy '{}'", policy.name);
1657
1658                Ok(CompiledContextCondition::RequireCapabilityToken {
1659                    required_issuers,
1660                    min_remaining_depth: min_remaining_depth as u8,
1661                    deny_reason,
1662                })
1663            }
1664
1665            "min_verification_tier" => {
1666                // Parse required_tier as integer or string name
1667                let required_tier = if let Some(level_val) = obj.get("required_tier") {
1668                    if let Some(level_u64) = level_val.as_u64() {
1669                        if level_u64 > 4 {
1670                            return Err(PolicyValidationError {
1671                                policy_id: policy.id.clone(),
1672                                policy_name: policy.name.clone(),
1673                                reason: format!(
1674                                    "min_verification_tier required_tier must be 0-4, got {level_u64}"
1675                                ),
1676                            });
1677                        }
1678                        level_u64 as u8
1679                    } else if let Some(name) = level_val.as_str() {
1680                        vellaveto_types::VerificationTier::from_name(name)
1681                            .map(|t| t.level())
1682                            .ok_or_else(|| PolicyValidationError {
1683                                policy_id: policy.id.clone(),
1684                                policy_name: policy.name.clone(),
1685                                reason: format!("min_verification_tier unknown tier name '{name}'"),
1686                            })?
1687                    } else {
1688                        return Err(PolicyValidationError {
1689                            policy_id: policy.id.clone(),
1690                            policy_name: policy.name.clone(),
1691                            reason: "min_verification_tier required_tier must be an integer (0-4) or tier name string".to_string(),
1692                        });
1693                    }
1694                } else {
1695                    return Err(PolicyValidationError {
1696                        policy_id: policy.id.clone(),
1697                        policy_name: policy.name.clone(),
1698                        reason: "min_verification_tier missing 'required_tier' field".to_string(),
1699                    });
1700                };
1701
1702                let deny_reason = format!(
1703                    "Verification tier below minimum (required level {}) for policy '{}'",
1704                    required_tier, policy.name
1705                );
1706
1707                Ok(CompiledContextCondition::MinVerificationTier {
1708                    required_tier,
1709                    deny_reason,
1710                })
1711            }
1712
1713            "session_state_required" => {
1714                // SECURITY (FIND-R112-002): Bound allowed_states to prevent
1715                // memory exhaustion from attacker-controlled policy JSON.
1716                const MAX_ALLOWED_STATES: usize = 1000;
1717                const MAX_STATE_NAME_LEN: usize = 256;
1718
1719                // Parse allowed_states (required array of strings)
1720                let raw_arr = obj
1721                    .get("allowed_states")
1722                    .and_then(|v| v.as_array())
1723                    .cloned()
1724                    .unwrap_or_default();
1725
1726                if raw_arr.len() > MAX_ALLOWED_STATES {
1727                    return Err(PolicyValidationError {
1728                        policy_id: policy.id.clone(),
1729                        policy_name: policy.name.clone(),
1730                        reason: format!(
1731                            "session_state_required allowed_states has {} entries, max {}",
1732                            raw_arr.len(),
1733                            MAX_ALLOWED_STATES,
1734                        ),
1735                    });
1736                }
1737
1738                let mut allowed_states = Vec::with_capacity(raw_arr.len());
1739                for entry in &raw_arr {
1740                    if let Some(s) = entry.as_str() {
1741                        if s.len() > MAX_STATE_NAME_LEN {
1742                            return Err(PolicyValidationError {
1743                                policy_id: policy.id.clone(),
1744                                policy_name: policy.name.clone(),
1745                                reason: format!(
1746                                    "session_state_required allowed_states entry length {} exceeds max {}",
1747                                    s.len(),
1748                                    MAX_STATE_NAME_LEN,
1749                                ),
1750                            });
1751                        }
1752                        // SECURITY (FIND-R215-003): Apply normalize_homoglyphs()
1753                        // after to_ascii_lowercase() for parity with other conditions.
1754                        allowed_states.push(normalize_full(s));
1755                    }
1756                }
1757
1758                if allowed_states.is_empty() {
1759                    return Err(PolicyValidationError {
1760                        policy_id: policy.id.clone(),
1761                        policy_name: policy.name.clone(),
1762                        reason:
1763                            "session_state_required must have at least one allowed_states entry"
1764                                .to_string(),
1765                    });
1766                }
1767
1768                let deny_reason = format!(
1769                    "Session state not in allowed states for policy '{}'",
1770                    policy.name
1771                );
1772
1773                Ok(CompiledContextCondition::SessionStateRequired {
1774                    allowed_states,
1775                    deny_reason,
1776                })
1777            }
1778
1779            // ═══════════════════════════════════════════════════
1780            // PHASE 40: WORKFLOW-LEVEL POLICY CONSTRAINTS
1781            // ═══════════════════════════════════════════════════
1782            "required_action_sequence" => Self::compile_action_sequence(obj, policy, true),
1783
1784            "forbidden_action_sequence" => Self::compile_action_sequence(obj, policy, false),
1785
1786            "workflow_template" => Self::compile_workflow_template(obj, policy),
1787
1788            _ => Err(PolicyValidationError {
1789                policy_id: policy.id.clone(),
1790                policy_name: policy.name.clone(),
1791                reason: format!("Unknown context condition type '{kind}'"),
1792            }),
1793        }
1794    }
1795
1796    /// Compile a `required_action_sequence` or `forbidden_action_sequence` condition.
1797    ///
1798    /// Both share identical parsing; only the resulting variant differs.
1799    fn compile_action_sequence(
1800        obj: &serde_json::Map<String, serde_json::Value>,
1801        policy: &vellaveto_types::Policy,
1802        is_required: bool,
1803    ) -> Result<CompiledContextCondition, PolicyValidationError> {
1804        const MAX_SEQUENCE_STEPS: usize = 20;
1805        // SECURITY (FIND-R50-051): Bound tool name length in sequences.
1806        const MAX_TOOL_NAME_LEN: usize = 256;
1807
1808        let kind = if is_required {
1809            "required_action_sequence"
1810        } else {
1811            "forbidden_action_sequence"
1812        };
1813
1814        let arr = obj
1815            .get("sequence")
1816            .and_then(|v| v.as_array())
1817            .ok_or_else(|| PolicyValidationError {
1818                policy_id: policy.id.clone(),
1819                policy_name: policy.name.clone(),
1820                reason: format!("{kind} requires a 'sequence' array"),
1821            })?;
1822
1823        if arr.is_empty() {
1824            return Err(PolicyValidationError {
1825                policy_id: policy.id.clone(),
1826                policy_name: policy.name.clone(),
1827                reason: format!("{kind} sequence must not be empty"),
1828            });
1829        }
1830
1831        if arr.len() > MAX_SEQUENCE_STEPS {
1832            return Err(PolicyValidationError {
1833                policy_id: policy.id.clone(),
1834                policy_name: policy.name.clone(),
1835                reason: format!(
1836                    "{kind} sequence has {} steps (max {MAX_SEQUENCE_STEPS})",
1837                    arr.len()
1838                ),
1839            });
1840        }
1841
1842        let mut sequence = Vec::with_capacity(arr.len());
1843        for (i, val) in arr.iter().enumerate() {
1844            let s = val.as_str().ok_or_else(|| PolicyValidationError {
1845                policy_id: policy.id.clone(),
1846                policy_name: policy.name.clone(),
1847                reason: format!("{kind} sequence[{i}] must be a string"),
1848            })?;
1849
1850            if s.is_empty() {
1851                return Err(PolicyValidationError {
1852                    policy_id: policy.id.clone(),
1853                    policy_name: policy.name.clone(),
1854                    reason: format!("{kind} sequence[{i}] must not be empty"),
1855                });
1856            }
1857
1858            // SECURITY (FIND-R112-004): Reject control and Unicode format characters in tool names.
1859            if s.chars()
1860                .any(|c| c.is_control() || vellaveto_types::is_unicode_format_char(c))
1861            {
1862                return Err(PolicyValidationError {
1863                    policy_id: policy.id.clone(),
1864                    policy_name: policy.name.clone(),
1865                    reason: format!("{kind} sequence[{i}] contains control or format characters"),
1866                });
1867            }
1868
1869            // SECURITY (FIND-R50-051): Reject excessively long tool names.
1870            if s.len() > MAX_TOOL_NAME_LEN {
1871                return Err(PolicyValidationError {
1872                    policy_id: policy.id.clone(),
1873                    policy_name: policy.name.clone(),
1874                    reason: format!(
1875                        "{kind} sequence[{i}] tool name is {} bytes, max is {MAX_TOOL_NAME_LEN}",
1876                        s.len()
1877                    ),
1878                });
1879            }
1880
1881            // SECURITY (FIND-R206-005, FIND-R220-006): normalize_full at compile time
1882            // so evaluation-time normalization on history entries will match.
1883            sequence.push(normalize_full(s));
1884        }
1885
1886        let ordered = obj.get("ordered").and_then(|v| v.as_bool()).unwrap_or(true);
1887
1888        if is_required {
1889            let deny_reason = format!(
1890                "Required action sequence not satisfied for policy '{}'",
1891                policy.name
1892            );
1893            Ok(CompiledContextCondition::RequiredActionSequence {
1894                sequence,
1895                ordered,
1896                deny_reason,
1897            })
1898        } else {
1899            let deny_reason = format!(
1900                "Forbidden action sequence detected for policy '{}'",
1901                policy.name
1902            );
1903            Ok(CompiledContextCondition::ForbiddenActionSequence {
1904                sequence,
1905                ordered,
1906                deny_reason,
1907            })
1908        }
1909    }
1910
1911    /// Compile a `workflow_template` condition.
1912    ///
1913    /// Parses the `steps` array, computes governed tools, entry points,
1914    /// and validates acyclicity via Kahn's algorithm.
1915    fn compile_workflow_template(
1916        obj: &serde_json::Map<String, serde_json::Value>,
1917        policy: &vellaveto_types::Policy,
1918    ) -> Result<CompiledContextCondition, PolicyValidationError> {
1919        use std::collections::{HashMap, HashSet, VecDeque};
1920
1921        const MAX_WORKFLOW_STEPS: usize = 50;
1922        // SECURITY (FIND-R50-061): Maximum successors per workflow step.
1923        const MAX_SUCCESSORS_PER_STEP: usize = 50;
1924        // SECURITY (FIND-R50-051): Maximum tool name length in workflows.
1925        const MAX_TOOL_NAME_LEN: usize = 256;
1926
1927        let steps =
1928            obj.get("steps")
1929                .and_then(|v| v.as_array())
1930                .ok_or_else(|| PolicyValidationError {
1931                    policy_id: policy.id.clone(),
1932                    policy_name: policy.name.clone(),
1933                    reason: "workflow_template requires a 'steps' array".to_string(),
1934                })?;
1935
1936        if steps.is_empty() {
1937            return Err(PolicyValidationError {
1938                policy_id: policy.id.clone(),
1939                policy_name: policy.name.clone(),
1940                reason: "workflow_template steps must not be empty".to_string(),
1941            });
1942        }
1943
1944        if steps.len() > MAX_WORKFLOW_STEPS {
1945            return Err(PolicyValidationError {
1946                policy_id: policy.id.clone(),
1947                policy_name: policy.name.clone(),
1948                reason: format!(
1949                    "workflow_template has {} steps (max {MAX_WORKFLOW_STEPS})",
1950                    steps.len()
1951                ),
1952            });
1953        }
1954
1955        let enforce = obj
1956            .get("enforce")
1957            .and_then(|v| v.as_str())
1958            .unwrap_or("strict");
1959
1960        let strict = match enforce {
1961            "strict" => true,
1962            "warn" => false,
1963            other => {
1964                return Err(PolicyValidationError {
1965                    policy_id: policy.id.clone(),
1966                    policy_name: policy.name.clone(),
1967                    reason: format!(
1968                        "workflow_template enforce must be 'strict' or 'warn', got '{other}'"
1969                    ),
1970                });
1971            }
1972        };
1973
1974        let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
1975        let mut governed_tools: HashSet<String> = HashSet::new();
1976        let mut seen_tools: HashSet<String> = HashSet::new();
1977
1978        for (i, step) in steps.iter().enumerate() {
1979            let step_obj = step.as_object().ok_or_else(|| PolicyValidationError {
1980                policy_id: policy.id.clone(),
1981                policy_name: policy.name.clone(),
1982                reason: format!("workflow_template steps[{i}] must be an object"),
1983            })?;
1984
1985            let tool = step_obj
1986                .get("tool")
1987                .and_then(|v| v.as_str())
1988                .ok_or_else(|| PolicyValidationError {
1989                    policy_id: policy.id.clone(),
1990                    policy_name: policy.name.clone(),
1991                    reason: format!("workflow_template steps[{i}] requires a 'tool' string"),
1992                })?;
1993
1994            if tool.is_empty() {
1995                return Err(PolicyValidationError {
1996                    policy_id: policy.id.clone(),
1997                    policy_name: policy.name.clone(),
1998                    reason: format!("workflow_template steps[{i}].tool must not be empty"),
1999                });
2000            }
2001
2002            // SECURITY (FIND-R112-004): Reject control and Unicode format characters.
2003            if tool
2004                .chars()
2005                .any(|c| c.is_control() || vellaveto_types::is_unicode_format_char(c))
2006            {
2007                return Err(PolicyValidationError {
2008                    policy_id: policy.id.clone(),
2009                    policy_name: policy.name.clone(),
2010                    reason: format!(
2011                        "workflow_template steps[{i}].tool contains control or format characters"
2012                    ),
2013                });
2014            }
2015
2016            // SECURITY (FIND-R50-051): Reject excessively long tool names.
2017            if tool.len() > MAX_TOOL_NAME_LEN {
2018                return Err(PolicyValidationError {
2019                    policy_id: policy.id.clone(),
2020                    policy_name: policy.name.clone(),
2021                    reason: format!(
2022                        "workflow_template steps[{i}].tool is {} bytes, max is {MAX_TOOL_NAME_LEN}",
2023                        tool.len()
2024                    ),
2025                });
2026            }
2027
2028            // SECURITY (FIND-R206-005, FIND-R220-006): normalize_full at compile time.
2029            let tool_lower = normalize_full(tool);
2030
2031            if !seen_tools.insert(tool_lower.clone()) {
2032                return Err(PolicyValidationError {
2033                    policy_id: policy.id.clone(),
2034                    policy_name: policy.name.clone(),
2035                    reason: format!("workflow_template has duplicate step tool '{tool}'"),
2036                });
2037            }
2038
2039            let then_arr = step_obj
2040                .get("then")
2041                .and_then(|v| v.as_array())
2042                .ok_or_else(|| PolicyValidationError {
2043                    policy_id: policy.id.clone(),
2044                    policy_name: policy.name.clone(),
2045                    reason: format!("workflow_template steps[{i}] requires a 'then' array"),
2046                })?;
2047
2048            // SECURITY (FIND-R50-061): Reject excessively large successor arrays
2049            // to prevent inflated Kahn's algorithm in-degree computation.
2050            if then_arr.len() > MAX_SUCCESSORS_PER_STEP {
2051                return Err(PolicyValidationError {
2052                    policy_id: policy.id.clone(),
2053                    policy_name: policy.name.clone(),
2054                    reason: format!(
2055                        "workflow_template steps[{i}].then has {} entries, max is {MAX_SUCCESSORS_PER_STEP}",
2056                        then_arr.len()
2057                    ),
2058                });
2059            }
2060
2061            let mut successors = Vec::with_capacity(then_arr.len());
2062            // SECURITY (FIND-R50-068): Track seen successors to deduplicate.
2063            let mut seen_successors: HashSet<String> = HashSet::new();
2064            for (j, v) in then_arr.iter().enumerate() {
2065                let s = v.as_str().ok_or_else(|| PolicyValidationError {
2066                    policy_id: policy.id.clone(),
2067                    policy_name: policy.name.clone(),
2068                    reason: format!("workflow_template steps[{i}].then[{j}] must be a string"),
2069                })?;
2070
2071                if s.is_empty() {
2072                    return Err(PolicyValidationError {
2073                        policy_id: policy.id.clone(),
2074                        policy_name: policy.name.clone(),
2075                        reason: format!("workflow_template steps[{i}].then[{j}] must not be empty"),
2076                    });
2077                }
2078
2079                // SECURITY (FIND-R112-004): Reject control and Unicode format characters.
2080                if s.chars()
2081                    .any(|c| c.is_control() || vellaveto_types::is_unicode_format_char(c))
2082                {
2083                    return Err(PolicyValidationError {
2084                        policy_id: policy.id.clone(),
2085                        policy_name: policy.name.clone(),
2086                        reason: format!(
2087                            "workflow_template steps[{i}].then[{j}] contains control or format characters"
2088                        ),
2089                    });
2090                }
2091
2092                // SECURITY (FIND-R50-051): Reject excessively long tool names.
2093                if s.len() > MAX_TOOL_NAME_LEN {
2094                    return Err(PolicyValidationError {
2095                        policy_id: policy.id.clone(),
2096                        policy_name: policy.name.clone(),
2097                        reason: format!(
2098                            "workflow_template steps[{i}].then[{j}] is {} bytes, max is {MAX_TOOL_NAME_LEN}",
2099                            s.len()
2100                        ),
2101                    });
2102                }
2103
2104                // SECURITY (FIND-R206-005): Normalize homoglyphs at compile time.
2105                let lowered = normalize_full(s);
2106                // SECURITY (FIND-R50-068): Silently deduplicate successors to prevent
2107                // duplicate entries from inflating Kahn's algorithm in-degree counts.
2108                if seen_successors.insert(lowered.clone()) {
2109                    successors.push(lowered);
2110                }
2111            }
2112
2113            governed_tools.insert(tool_lower.clone());
2114            for succ in &successors {
2115                governed_tools.insert(succ.clone());
2116            }
2117
2118            adjacency.insert(tool_lower, successors);
2119        }
2120
2121        // Compute entry points: governed tools with no predecessors.
2122        let mut has_predecessor: HashSet<&str> = HashSet::new();
2123        for successors in adjacency.values() {
2124            for succ in successors {
2125                has_predecessor.insert(succ.as_str());
2126            }
2127        }
2128        // SECURITY (FIND-R50-016): Sort entry points for deterministic ordering
2129        // across HashSet iterations, ensuring reproducible policy compilation.
2130        let mut entry_points: Vec<String> = governed_tools
2131            .iter()
2132            .filter(|t| !has_predecessor.contains(t.as_str()))
2133            .cloned()
2134            .collect();
2135        entry_points.sort();
2136
2137        if entry_points.is_empty() {
2138            return Err(PolicyValidationError {
2139                policy_id: policy.id.clone(),
2140                policy_name: policy.name.clone(),
2141                reason: "workflow_template has no entry points (implies cycle)".to_string(),
2142            });
2143        }
2144
2145        // Cycle detection via Kahn's algorithm (topological sort).
2146        let mut in_degree: HashMap<&str, usize> = HashMap::new();
2147        for tool in &governed_tools {
2148            in_degree.insert(tool.as_str(), 0);
2149        }
2150        for successors in adjacency.values() {
2151            for succ in successors {
2152                if let Some(deg) = in_degree.get_mut(succ.as_str()) {
2153                    *deg = deg.saturating_add(1);
2154                }
2155            }
2156        }
2157
2158        let mut queue: VecDeque<&str> = VecDeque::new();
2159        for (tool, deg) in &in_degree {
2160            if *deg == 0 {
2161                queue.push_back(tool);
2162            }
2163        }
2164
2165        let mut visited_count: usize = 0;
2166        while let Some(node) = queue.pop_front() {
2167            visited_count += 1;
2168            if let Some(successors) = adjacency.get(node) {
2169                for succ in successors {
2170                    if let Some(deg) = in_degree.get_mut(succ.as_str()) {
2171                        *deg = deg.saturating_sub(1);
2172                        if *deg == 0 {
2173                            queue.push_back(succ.as_str());
2174                        }
2175                    }
2176                }
2177            }
2178        }
2179
2180        if visited_count < governed_tools.len() {
2181            return Err(PolicyValidationError {
2182                policy_id: policy.id.clone(),
2183                policy_name: policy.name.clone(),
2184                reason: "workflow_template contains a cycle".to_string(),
2185            });
2186        }
2187
2188        let deny_reason = format!("Workflow template violation for policy '{}'", policy.name);
2189
2190        Ok(CompiledContextCondition::WorkflowTemplate {
2191            adjacency,
2192            governed_tools,
2193            entry_points,
2194            strict,
2195            deny_reason,
2196        })
2197    }
2198}
2199
2200#[cfg(test)]
2201mod tests {
2202    use super::*;
2203    use serde_json::json;
2204    use vellaveto_types::{IpRules, NetworkRules, PathRules, Policy, PolicyType};
2205
2206    /// Helper: create a minimal Allow policy with the given id/name.
2207    fn allow_policy(id: &str, name: &str, priority: i32) -> Policy {
2208        Policy {
2209            id: id.to_string(),
2210            name: name.to_string(),
2211            policy_type: PolicyType::Allow,
2212            priority,
2213            path_rules: None,
2214            network_rules: None,
2215        }
2216    }
2217
2218    /// Helper: create a minimal Deny policy.
2219    fn deny_policy(id: &str, name: &str, priority: i32) -> Policy {
2220        Policy {
2221            id: id.to_string(),
2222            name: name.to_string(),
2223            policy_type: PolicyType::Deny,
2224            priority,
2225            path_rules: None,
2226            network_rules: None,
2227        }
2228    }
2229
2230    /// Helper: create a Conditional policy with given conditions JSON.
2231    fn conditional_policy(
2232        id: &str,
2233        name: &str,
2234        priority: i32,
2235        conditions: serde_json::Value,
2236    ) -> Policy {
2237        Policy {
2238            id: id.to_string(),
2239            name: name.to_string(),
2240            policy_type: PolicyType::Conditional { conditions },
2241            priority,
2242            path_rules: None,
2243            network_rules: None,
2244        }
2245    }
2246
2247    // ═══════════════════════════════════════════════════
2248    // 1. compile_policies — basic scenarios
2249    // ═══════════════════════════════════════════════════
2250
2251    #[test]
2252    fn test_compile_policies_empty_list_succeeds() {
2253        let result = PolicyEngine::compile_policies(&[], false);
2254        assert!(result.is_ok());
2255        assert!(result.unwrap().is_empty());
2256    }
2257
2258    #[test]
2259    fn test_compile_policies_single_allow_policy_succeeds() {
2260        let policies = vec![allow_policy("tool:read", "Read Only", 10)];
2261        let result = PolicyEngine::compile_policies(&policies, false);
2262        assert!(result.is_ok());
2263        let compiled = result.unwrap();
2264        assert_eq!(compiled.len(), 1);
2265        assert_eq!(compiled[0].policy.id, "tool:read");
2266        assert_eq!(compiled[0].deny_reason, "Denied by policy 'Read Only'");
2267    }
2268
2269    #[test]
2270    fn test_compile_policies_strict_mode_rejects_unknown_condition_keys() {
2271        let conditions = json!({
2272            "unknown_key": true,
2273            "require_approval": false
2274        });
2275        let policies = vec![conditional_policy("p1", "Strict Test", 10, conditions)];
2276        let result = PolicyEngine::compile_policies(&policies, true);
2277        assert!(result.is_err());
2278        let errors = result.unwrap_err();
2279        assert_eq!(errors.len(), 1);
2280        assert!(errors[0].reason.contains("Unknown condition key"));
2281        assert!(errors[0].reason.contains("strict mode"));
2282    }
2283
2284    #[test]
2285    fn test_compile_policies_non_strict_allows_unknown_condition_keys() {
2286        let conditions = json!({
2287            "unknown_key": true,
2288            "require_approval": false
2289        });
2290        let policies = vec![conditional_policy("p1", "Non-strict Test", 10, conditions)];
2291        let result = PolicyEngine::compile_policies(&policies, false);
2292        assert!(result.is_ok());
2293    }
2294
2295    // ═══════════════════════════════════════════════════
2296    // 2. Priority sorting — Deny-before-Allow at same priority
2297    // ═══════════════════════════════════════════════════
2298
2299    #[test]
2300    fn test_compile_policies_sorted_by_priority_descending() {
2301        let policies = vec![
2302            allow_policy("low:*", "Low", 1),
2303            allow_policy("high:*", "High", 100),
2304            allow_policy("mid:*", "Mid", 50),
2305        ];
2306        let compiled = PolicyEngine::compile_policies(&policies, false).unwrap();
2307        assert_eq!(compiled[0].policy.priority, 100);
2308        assert_eq!(compiled[1].policy.priority, 50);
2309        assert_eq!(compiled[2].policy.priority, 1);
2310    }
2311
2312    #[test]
2313    fn test_compile_policies_deny_before_allow_at_same_priority() {
2314        let policies = vec![
2315            allow_policy("allow:*", "Allow All", 10),
2316            deny_policy("deny:*", "Deny All", 10),
2317        ];
2318        let compiled = PolicyEngine::compile_policies(&policies, false).unwrap();
2319        // At same priority, Deny should come first
2320        assert!(matches!(compiled[0].policy.policy_type, PolicyType::Deny));
2321        assert!(matches!(compiled[1].policy.policy_type, PolicyType::Allow));
2322    }
2323
2324    #[test]
2325    fn test_compile_policies_stable_sort_by_id_at_same_priority_and_type() {
2326        let policies = vec![allow_policy("b:*", "B", 10), allow_policy("a:*", "A", 10)];
2327        let compiled = PolicyEngine::compile_policies(&policies, false).unwrap();
2328        // Same priority, same type -> sort by id ascending
2329        assert_eq!(compiled[0].policy.id, "a:*");
2330        assert_eq!(compiled[1].policy.id, "b:*");
2331    }
2332
2333    // ═══════════════════════════════════════════════════
2334    // 3. Glob pattern compilation
2335    // ═══════════════════════════════════════════════════
2336
2337    #[test]
2338    fn test_compile_policies_valid_path_globs_compile() {
2339        let mut policy = allow_policy("tool:fs", "FS Policy", 10);
2340        policy.path_rules = Some(PathRules {
2341            allowed: vec!["/home/**".to_string(), "/tmp/*".to_string()],
2342            blocked: vec!["/etc/shadow".to_string()],
2343        });
2344        let result = PolicyEngine::compile_policies(&[policy], false);
2345        assert!(result.is_ok());
2346        let compiled = result.unwrap();
2347        let path_rules = compiled[0].compiled_path_rules.as_ref().unwrap();
2348        assert_eq!(path_rules.allowed.len(), 2);
2349        assert_eq!(path_rules.blocked.len(), 1);
2350    }
2351
2352    #[test]
2353    fn test_compile_policies_invalid_path_glob_fails() {
2354        let mut policy = allow_policy("tool:fs", "FS Policy", 10);
2355        policy.path_rules = Some(PathRules {
2356            allowed: vec!["[invalid".to_string()],
2357            blocked: vec![],
2358        });
2359        let result = PolicyEngine::compile_policies(&[policy], false);
2360        assert!(result.is_err());
2361        let errors = result.unwrap_err();
2362        assert!(errors[0].reason.contains("Invalid allowed path glob"));
2363    }
2364
2365    #[test]
2366    fn test_compile_policies_invalid_blocked_path_glob_fails() {
2367        let mut policy = allow_policy("tool:fs", "FS Policy", 10);
2368        policy.path_rules = Some(PathRules {
2369            allowed: vec![],
2370            blocked: vec!["[bad-glob".to_string()],
2371        });
2372        let result = PolicyEngine::compile_policies(&[policy], false);
2373        assert!(result.is_err());
2374        let errors = result.unwrap_err();
2375        assert!(errors[0].reason.contains("Invalid blocked path glob"));
2376    }
2377
2378    // ═══════════════════════════════════════════════════
2379    // 4. Regex pattern compilation + ReDoS detection
2380    // ═══════════════════════════════════════════════════
2381
2382    #[test]
2383    fn test_compile_constraint_valid_regex_succeeds() {
2384        let conditions = json!({
2385            "parameter_constraints": [{
2386                "param": "url",
2387                "op": "regex",
2388                "pattern": "^https://.*\\.example\\.com$"
2389            }]
2390        });
2391        let policy = conditional_policy("p1", "Regex Test", 10, conditions);
2392        let result = PolicyEngine::compile_policies(&[policy], false);
2393        assert!(result.is_ok());
2394    }
2395
2396    #[test]
2397    fn test_compile_constraint_invalid_regex_fails() {
2398        let conditions = json!({
2399            "parameter_constraints": [{
2400                "param": "url",
2401                "op": "regex",
2402                "pattern": "(unclosed"
2403            }]
2404        });
2405        let policy = conditional_policy("p1", "Bad Regex", 10, conditions);
2406        let result = PolicyEngine::compile_policies(&[policy], false);
2407        assert!(result.is_err());
2408        let errors = result.unwrap_err();
2409        assert!(errors[0].reason.contains("Invalid regex pattern"));
2410    }
2411
2412    #[test]
2413    fn test_compile_constraint_redos_regex_rejected() {
2414        // Nested quantifiers (a+)+ is a classic ReDoS pattern
2415        let conditions = json!({
2416            "parameter_constraints": [{
2417                "param": "input",
2418                "op": "regex",
2419                "pattern": "(a+)+"
2420            }]
2421        });
2422        let policy = conditional_policy("p1", "ReDoS", 10, conditions);
2423        let result = PolicyEngine::compile_policies(&[policy], false);
2424        assert!(result.is_err());
2425        // Should be rejected by validate_regex_safety
2426    }
2427
2428    // ═══════════════════════════════════════════════════
2429    // 5. Domain constraint compilation
2430    // ═══════════════════════════════════════════════════
2431
2432    #[test]
2433    fn test_compile_policies_valid_network_domains() {
2434        let mut policy = allow_policy("tool:http", "HTTP Policy", 10);
2435        policy.network_rules = Some(NetworkRules {
2436            allowed_domains: vec!["example.com".to_string(), "*.example.org".to_string()],
2437            blocked_domains: vec!["evil.com".to_string()],
2438            ip_rules: None,
2439        });
2440        let result = PolicyEngine::compile_policies(&[policy], false);
2441        assert!(result.is_ok());
2442        let compiled = result.unwrap();
2443        let net_rules = compiled[0].compiled_network_rules.as_ref().unwrap();
2444        assert_eq!(net_rules.allowed_domains.len(), 2);
2445        assert_eq!(net_rules.blocked_domains.len(), 1);
2446    }
2447
2448    #[test]
2449    fn test_compile_policies_invalid_domain_pattern_fails() {
2450        let mut policy = allow_policy("tool:http", "HTTP Policy", 10);
2451        policy.network_rules = Some(NetworkRules {
2452            allowed_domains: vec!["".to_string()], // empty domain is invalid
2453            blocked_domains: vec![],
2454            ip_rules: None,
2455        });
2456        let result = PolicyEngine::compile_policies(&[policy], false);
2457        assert!(result.is_err());
2458        let errors = result.unwrap_err();
2459        assert!(errors[0].reason.contains("Invalid domain pattern"));
2460    }
2461
2462    #[test]
2463    fn test_compile_constraint_domain_match_valid() {
2464        let conditions = json!({
2465            "parameter_constraints": [{
2466                "param": "host",
2467                "op": "domain_match",
2468                "pattern": "*.example.com"
2469            }]
2470        });
2471        let policy = conditional_policy("p1", "Domain Match", 10, conditions);
2472        let result = PolicyEngine::compile_policies(&[policy], false);
2473        assert!(result.is_ok());
2474    }
2475
2476    // ═══════════════════════════════════════════════════
2477    // 6. CIDR/IP rule compilation
2478    // ═══════════════════════════════════════════════════
2479
2480    #[test]
2481    fn test_compile_policies_valid_cidr_rules() {
2482        let mut policy = allow_policy("tool:net", "Net Policy", 10);
2483        policy.network_rules = Some(NetworkRules {
2484            allowed_domains: vec![],
2485            blocked_domains: vec![],
2486            ip_rules: Some(IpRules {
2487                block_private: true,
2488                blocked_cidrs: vec!["10.0.0.0/8".to_string()],
2489                allowed_cidrs: vec!["192.168.1.0/24".to_string()],
2490            }),
2491        });
2492        let result = PolicyEngine::compile_policies(&[policy], false);
2493        assert!(result.is_ok());
2494        let compiled = result.unwrap();
2495        let ip_rules = compiled[0].compiled_ip_rules.as_ref().unwrap();
2496        assert!(ip_rules.block_private);
2497        assert_eq!(ip_rules.blocked_cidrs.len(), 1);
2498        assert_eq!(ip_rules.allowed_cidrs.len(), 1);
2499    }
2500
2501    #[test]
2502    fn test_compile_policies_invalid_blocked_cidr_fails() {
2503        let mut policy = allow_policy("tool:net", "Net Policy", 10);
2504        policy.network_rules = Some(NetworkRules {
2505            allowed_domains: vec![],
2506            blocked_domains: vec![],
2507            ip_rules: Some(IpRules {
2508                block_private: false,
2509                blocked_cidrs: vec!["not-a-cidr".to_string()],
2510                allowed_cidrs: vec![],
2511            }),
2512        });
2513        let result = PolicyEngine::compile_policies(&[policy], false);
2514        assert!(result.is_err());
2515        let errors = result.unwrap_err();
2516        assert!(errors[0].reason.contains("Invalid blocked CIDR"));
2517    }
2518
2519    #[test]
2520    fn test_compile_policies_invalid_allowed_cidr_fails() {
2521        let mut policy = allow_policy("tool:net", "Net Policy", 10);
2522        policy.network_rules = Some(NetworkRules {
2523            allowed_domains: vec![],
2524            blocked_domains: vec![],
2525            ip_rules: Some(IpRules {
2526                block_private: false,
2527                blocked_cidrs: vec![],
2528                allowed_cidrs: vec!["999.999.999.999/32".to_string()],
2529            }),
2530        });
2531        let result = PolicyEngine::compile_policies(&[policy], false);
2532        assert!(result.is_err());
2533        let errors = result.unwrap_err();
2534        assert!(errors[0].reason.contains("Invalid allowed CIDR"));
2535    }
2536
2537    #[test]
2538    fn test_compile_policies_ipv6_cidr_succeeds() {
2539        let mut policy = allow_policy("tool:net", "Net Policy", 10);
2540        policy.network_rules = Some(NetworkRules {
2541            allowed_domains: vec![],
2542            blocked_domains: vec![],
2543            ip_rules: Some(IpRules {
2544                block_private: false,
2545                blocked_cidrs: vec!["2001:db8::/32".to_string()],
2546                allowed_cidrs: vec!["fd00::/8".to_string()],
2547            }),
2548        });
2549        let result = PolicyEngine::compile_policies(&[policy], false);
2550        assert!(result.is_ok());
2551    }
2552
2553    // ═══════════════════════════════════════════════════
2554    // 7. Context condition compilation — time_window
2555    // ═══════════════════════════════════════════════════
2556
2557    #[test]
2558    fn test_compile_context_condition_time_window_valid() {
2559        let conditions = json!({
2560            "context_conditions": [{
2561                "type": "time_window",
2562                "start_hour": 9,
2563                "end_hour": 17,
2564                "days": [1, 2, 3, 4, 5]
2565            }]
2566        });
2567        let policy = conditional_policy("p1", "Business Hours", 10, conditions);
2568        let result = PolicyEngine::compile_policies(&[policy], false);
2569        assert!(result.is_ok());
2570    }
2571
2572    #[test]
2573    fn test_compile_context_condition_time_window_invalid_hour_over_23() {
2574        let conditions = json!({
2575            "context_conditions": [{
2576                "type": "time_window",
2577                "start_hour": 25,
2578                "end_hour": 17
2579            }]
2580        });
2581        let policy = conditional_policy("p1", "Bad Hours", 10, conditions);
2582        let result = PolicyEngine::compile_policies(&[policy], false);
2583        assert!(result.is_err());
2584        let errors = result.unwrap_err();
2585        assert!(errors[0].reason.contains("hours must be 0-23"));
2586    }
2587
2588    #[test]
2589    fn test_compile_context_condition_time_window_equal_hours_rejected() {
2590        // start_hour == end_hour creates a zero-width window (permanent deny)
2591        let conditions = json!({
2592            "context_conditions": [{
2593                "type": "time_window",
2594                "start_hour": 10,
2595                "end_hour": 10
2596            }]
2597        });
2598        let policy = conditional_policy("p1", "Zero Window", 10, conditions);
2599        let result = PolicyEngine::compile_policies(&[policy], false);
2600        assert!(result.is_err());
2601        let errors = result.unwrap_err();
2602        assert!(errors[0]
2603            .reason
2604            .contains("start_hour and end_hour must differ"));
2605    }
2606
2607    #[test]
2608    fn test_compile_context_condition_time_window_invalid_day_rejected() {
2609        let conditions = json!({
2610            "context_conditions": [{
2611                "type": "time_window",
2612                "start_hour": 9,
2613                "end_hour": 17,
2614                "days": [0]
2615            }]
2616        });
2617        let policy = conditional_policy("p1", "Bad Day", 10, conditions);
2618        let result = PolicyEngine::compile_policies(&[policy], false);
2619        assert!(result.is_err());
2620        let errors = result.unwrap_err();
2621        assert!(errors[0].reason.contains("day value must be 1-7"));
2622    }
2623
2624    // ═══════════════════════════════════════════════════
2625    // 8. Constraint on_match / on_missing validation
2626    // ═══════════════════════════════════════════════════
2627
2628    #[test]
2629    fn test_compile_constraint_invalid_on_match_rejected() {
2630        let conditions = json!({
2631            "parameter_constraints": [{
2632                "param": "path",
2633                "op": "glob",
2634                "pattern": "/safe/*",
2635                "on_match": "alow"
2636            }]
2637        });
2638        let policy = conditional_policy("p1", "Typo on_match", 10, conditions);
2639        let result = PolicyEngine::compile_policies(&[policy], false);
2640        assert!(result.is_err());
2641        let errors = result.unwrap_err();
2642        assert!(errors[0].reason.contains("on_match"));
2643        assert!(errors[0].reason.contains("alow"));
2644    }
2645
2646    #[test]
2647    fn test_compile_constraint_invalid_on_missing_rejected() {
2648        let conditions = json!({
2649            "parameter_constraints": [{
2650                "param": "path",
2651                "op": "glob",
2652                "pattern": "/safe/*",
2653                "on_missing": "ignore"
2654            }]
2655        });
2656        let policy = conditional_policy("p1", "Bad on_missing", 10, conditions);
2657        let result = PolicyEngine::compile_policies(&[policy], false);
2658        assert!(result.is_err());
2659        let errors = result.unwrap_err();
2660        assert!(errors[0].reason.contains("on_missing"));
2661        assert!(errors[0].reason.contains("ignore"));
2662    }
2663
2664    #[test]
2665    fn test_compile_constraint_valid_on_match_require_approval() {
2666        let conditions = json!({
2667            "parameter_constraints": [{
2668                "param": "path",
2669                "op": "glob",
2670                "pattern": "/safe/*",
2671                "on_match": "require_approval",
2672                "on_missing": "skip"
2673            }]
2674        });
2675        let policy = conditional_policy("p1", "Approval", 10, conditions);
2676        let result = PolicyEngine::compile_policies(&[policy], false);
2677        assert!(result.is_ok());
2678    }
2679
2680    // ═══════════════════════════════════════════════════
2681    // 9. Error accumulation — multiple errors collected
2682    // ═══════════════════════════════════════════════════
2683
2684    #[test]
2685    fn test_compile_policies_accumulates_errors_across_policies() {
2686        let mut bad_glob = allow_policy("p1", "Bad Glob", 10);
2687        bad_glob.path_rules = Some(PathRules {
2688            allowed: vec!["[invalid".to_string()],
2689            blocked: vec![],
2690        });
2691        let mut bad_cidr = allow_policy("p2", "Bad CIDR", 10);
2692        bad_cidr.network_rules = Some(NetworkRules {
2693            allowed_domains: vec![],
2694            blocked_domains: vec![],
2695            ip_rules: Some(IpRules {
2696                block_private: false,
2697                blocked_cidrs: vec!["not-valid".to_string()],
2698                allowed_cidrs: vec![],
2699            }),
2700        });
2701        let result = PolicyEngine::compile_policies(&[bad_glob, bad_cidr], false);
2702        assert!(result.is_err());
2703        let errors = result.unwrap_err();
2704        // Should have collected errors from BOTH policies
2705        assert_eq!(errors.len(), 2);
2706        assert_eq!(errors[0].policy_id, "p1");
2707        assert_eq!(errors[1].policy_id, "p2");
2708    }
2709
2710    // ═══════════════════════════════════════════════════
2711    // 10. Edge cases
2712    // ═══════════════════════════════════════════════════
2713
2714    #[test]
2715    fn test_compile_policies_no_path_or_network_rules() {
2716        let policy = allow_policy("tool:*", "Minimal", 10);
2717        let result = PolicyEngine::compile_policies(&[policy], false);
2718        assert!(result.is_ok());
2719        let compiled = result.unwrap();
2720        assert!(compiled[0].compiled_path_rules.is_none());
2721        assert!(compiled[0].compiled_network_rules.is_none());
2722        assert!(compiled[0].compiled_ip_rules.is_none());
2723    }
2724
2725    #[test]
2726    fn test_compile_policies_policy_name_too_long_rejected() {
2727        let long_name = "x".repeat(513);
2728        let policy = allow_policy("p1", &long_name, 10);
2729        let result = PolicyEngine::compile_policies(&[policy], false);
2730        assert!(result.is_err());
2731        let errors = result.unwrap_err();
2732        assert!(errors[0].reason.contains("Policy name is 513 bytes"));
2733    }
2734
2735    #[test]
2736    fn test_compile_constraint_unknown_operator_rejected() {
2737        let conditions = json!({
2738            "parameter_constraints": [{
2739                "param": "foo",
2740                "op": "fuzzy_match"
2741            }]
2742        });
2743        let policy = conditional_policy("p1", "Unknown Op", 10, conditions);
2744        let result = PolicyEngine::compile_policies(&[policy], false);
2745        assert!(result.is_err());
2746        let errors = result.unwrap_err();
2747        assert!(errors[0].reason.contains("Unknown constraint operator"));
2748        assert!(errors[0].reason.contains("fuzzy_match"));
2749    }
2750
2751    #[test]
2752    fn test_compile_context_condition_unknown_type_rejected() {
2753        let conditions = json!({
2754            "context_conditions": [{
2755                "type": "moon_phase"
2756            }]
2757        });
2758        let policy = conditional_policy("p1", "Unknown Ctx", 10, conditions);
2759        let result = PolicyEngine::compile_policies(&[policy], false);
2760        assert!(result.is_err());
2761        let errors = result.unwrap_err();
2762        assert!(errors[0].reason.contains("Unknown context condition type"));
2763    }
2764
2765    #[test]
2766    fn test_compile_conditions_json_depth_exceeded() {
2767        // Build deeply nested JSON to exceed depth 10
2768        let mut val = json!("leaf");
2769        for _ in 0..12 {
2770            val = json!({ "nested": val });
2771        }
2772        let conditions = json!({
2773            "parameter_constraints": [val]
2774        });
2775        let policy = conditional_policy("p1", "Deep JSON", 10, conditions);
2776        let result = PolicyEngine::compile_policies(&[policy], false);
2777        assert!(result.is_err());
2778        let errors = result.unwrap_err();
2779        assert!(errors[0].reason.contains("maximum nesting depth"));
2780    }
2781
2782    #[test]
2783    fn test_compile_constraint_glob_in_constraint_invalid() {
2784        let conditions = json!({
2785            "parameter_constraints": [{
2786                "param": "path",
2787                "op": "glob",
2788                "pattern": "[invalid-glob"
2789            }]
2790        });
2791        let policy = conditional_policy("p1", "Bad Constraint Glob", 10, conditions);
2792        let result = PolicyEngine::compile_policies(&[policy], false);
2793        assert!(result.is_err());
2794        let errors = result.unwrap_err();
2795        assert!(errors[0].reason.contains("Invalid glob pattern"));
2796    }
2797
2798    #[test]
2799    fn test_compile_constraint_eq_compiles_successfully() {
2800        let conditions = json!({
2801            "parameter_constraints": [{
2802                "param": "mode",
2803                "op": "eq",
2804                "value": "read-only"
2805            }]
2806        });
2807        let policy = conditional_policy("p1", "Eq Test", 10, conditions);
2808        let result = PolicyEngine::compile_policies(&[policy], false);
2809        assert!(result.is_ok());
2810    }
2811
2812    #[test]
2813    fn test_compile_context_condition_max_calls_compiles() {
2814        let conditions = json!({
2815            "context_conditions": [{
2816                "type": "max_calls",
2817                "tool_pattern": "file:*",
2818                "max": 10
2819            }]
2820        });
2821        let policy = conditional_policy("p1", "Rate Limit", 10, conditions);
2822        let result = PolicyEngine::compile_policies(&[policy], false);
2823        assert!(result.is_ok());
2824    }
2825
2826    #[test]
2827    fn test_compile_context_condition_agent_id_compiles() {
2828        let conditions = json!({
2829            "context_conditions": [{
2830                "type": "agent_id",
2831                "allowed": ["agent-a", "agent-b"],
2832                "blocked": ["rogue-agent"]
2833            }]
2834        });
2835        let policy = conditional_policy("p1", "Agent Restrict", 10, conditions);
2836        let result = PolicyEngine::compile_policies(&[policy], false);
2837        assert!(result.is_ok());
2838    }
2839
2840    #[test]
2841    fn test_compile_conditional_require_approval_flag() {
2842        let conditions = json!({
2843            "require_approval": true
2844        });
2845        let policy = conditional_policy("p1", "Approval Policy", 10, conditions);
2846        let compiled = PolicyEngine::compile_policies(&[policy], false).unwrap();
2847        assert!(compiled[0].require_approval);
2848    }
2849
2850    #[test]
2851    fn test_compile_conditional_require_approval_non_bool_defaults_true() {
2852        // SECURITY: non-boolean require_approval defaults to true (fail-closed)
2853        let conditions = json!({
2854            "require_approval": "yes"
2855        });
2856        let policy = conditional_policy("p1", "Fail Closed", 10, conditions);
2857        let compiled = PolicyEngine::compile_policies(&[policy], false).unwrap();
2858        assert!(compiled[0].require_approval);
2859    }
2860
2861    #[test]
2862    fn test_compile_constraint_parameter_constraints_not_array_rejected() {
2863        let conditions = json!({
2864            "parameter_constraints": "not-an-array"
2865        });
2866        let policy = conditional_policy("p1", "Bad Constraints", 10, conditions);
2867        let result = PolicyEngine::compile_policies(&[policy], false);
2868        assert!(result.is_err());
2869        let errors = result.unwrap_err();
2870        assert!(errors[0]
2871            .reason
2872            .contains("parameter_constraints must be an array"));
2873    }
2874
2875    #[test]
2876    fn test_compile_constraint_missing_param_field_rejected() {
2877        let conditions = json!({
2878            "parameter_constraints": [{
2879                "op": "eq",
2880                "value": "test"
2881            }]
2882        });
2883        let policy = conditional_policy("p1", "No Param", 10, conditions);
2884        let result = PolicyEngine::compile_policies(&[policy], false);
2885        assert!(result.is_err());
2886        let errors = result.unwrap_err();
2887        assert!(errors[0].reason.contains("missing required 'param'"));
2888    }
2889
2890    #[test]
2891    fn test_compile_constraint_missing_op_field_rejected() {
2892        let conditions = json!({
2893            "parameter_constraints": [{
2894                "param": "path"
2895            }]
2896        });
2897        let policy = conditional_policy("p1", "No Op", 10, conditions);
2898        let result = PolicyEngine::compile_policies(&[policy], false);
2899        assert!(result.is_err());
2900        let errors = result.unwrap_err();
2901        assert!(errors[0].reason.contains("missing required 'op'"));
2902    }
2903}