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: {} bytes (max {})",
270                    size, MAX_CONDITIONS_SIZE
271                ),
272            });
273        }
274
275        // SECURITY (FIND-IMP-013): Fail-closed — if require_approval is present
276        // but not a valid boolean, default to true (require approval).
277        let require_approval = conditions
278            .get("require_approval")
279            .map(|v| v.as_bool().unwrap_or(true))
280            .unwrap_or(false);
281
282        let on_no_match_continue = conditions
283            .get("on_no_match")
284            .and_then(|v| v.as_str())
285            .map(|s| s == "continue")
286            .unwrap_or(false);
287
288        let forbidden_parameters: Vec<String> = conditions
289            .get("forbidden_parameters")
290            .and_then(|v| v.as_array())
291            .map(|arr| {
292                arr.iter()
293                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
294                    .collect()
295            })
296            .unwrap_or_default();
297
298        let required_parameters: Vec<String> = conditions
299            .get("required_parameters")
300            .and_then(|v| v.as_array())
301            .map(|arr| {
302                arr.iter()
303                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
304                    .collect()
305            })
306            .unwrap_or_default();
307
308        // SECURITY (FIND-R46-012): Maximum number of parameter constraints per policy.
309        const MAX_PARAMETER_CONSTRAINTS: usize = 100;
310
311        let constraints = if let Some(constraint_arr) = conditions.get("parameter_constraints") {
312            let arr = constraint_arr
313                .as_array()
314                .ok_or_else(|| PolicyValidationError {
315                    policy_id: policy.id.clone(),
316                    policy_name: policy.name.clone(),
317                    reason: "parameter_constraints must be an array".to_string(),
318                })?;
319
320            // SECURITY (FIND-R46-012): Reject excessively large constraint arrays
321            // to prevent memory exhaustion during policy compilation.
322            if arr.len() > MAX_PARAMETER_CONSTRAINTS {
323                return Err(PolicyValidationError {
324                    policy_id: policy.id.clone(),
325                    policy_name: policy.name.clone(),
326                    reason: format!(
327                        "parameter_constraints has {} entries, max is {}",
328                        arr.len(),
329                        MAX_PARAMETER_CONSTRAINTS
330                    ),
331                });
332            }
333
334            let mut constraints = Vec::with_capacity(arr.len());
335            for constraint_val in arr {
336                constraints.push(Self::compile_constraint(policy, constraint_val)?);
337            }
338            constraints
339        } else {
340            Vec::new()
341        };
342
343        // SECURITY (FIND-R50-060): Maximum number of context conditions per policy.
344        const MAX_CONTEXT_CONDITIONS: usize = 50;
345
346        // Parse context conditions (session-level checks)
347        let context_conditions = if let Some(ctx_arr) = conditions.get("context_conditions") {
348            let arr = ctx_arr.as_array().ok_or_else(|| PolicyValidationError {
349                policy_id: policy.id.clone(),
350                policy_name: policy.name.clone(),
351                reason: "context_conditions must be an array".to_string(),
352            })?;
353
354            // SECURITY (FIND-R50-060): Reject excessively large context condition arrays
355            // to prevent memory exhaustion during policy compilation.
356            if arr.len() > MAX_CONTEXT_CONDITIONS {
357                return Err(PolicyValidationError {
358                    policy_id: policy.id.clone(),
359                    policy_name: policy.name.clone(),
360                    reason: format!(
361                        "context_conditions has {} entries, max is {}",
362                        arr.len(),
363                        MAX_CONTEXT_CONDITIONS
364                    ),
365                });
366            }
367
368            let mut context_conditions = Vec::with_capacity(arr.len());
369            for ctx_val in arr {
370                context_conditions.push(Self::compile_context_condition(policy, ctx_val)?);
371            }
372            context_conditions
373        } else {
374            Vec::new()
375        };
376
377        // Validate strict mode unknown keys
378        if strict_mode {
379            let known_keys = [
380                "require_approval",
381                "forbidden_parameters",
382                "required_parameters",
383                "parameter_constraints",
384                "context_conditions",
385                "on_no_match",
386            ];
387            if let Some(obj) = conditions.as_object() {
388                for key in obj.keys() {
389                    if !known_keys.contains(&key.as_str()) {
390                        return Err(PolicyValidationError {
391                            policy_id: policy.id.clone(),
392                            policy_name: policy.name.clone(),
393                            reason: format!("Unknown condition key '{}' in strict mode", key),
394                        });
395                    }
396                }
397            }
398        }
399
400        // SECURITY (FIND-CREATIVE-006): Warn when on_no_match="continue" is combined
401        // with ALL constraints having on_missing="skip". This combination means:
402        //   1. If the action omits the required parameters, every constraint is skipped.
403        //   2. Since on_no_match="continue", the policy returns None (skip to next).
404        //   3. An attacker who intentionally omits context parameters bypasses ALL
405        //      Conditional policies with this pattern, falling through to the default verdict.
406        // This is always exploitable — the attacker controls which parameters to send.
407        // Operators should set at least one constraint to on_missing="deny" or remove
408        // on_no_match="continue" to ensure fail-closed behavior.
409        //
410        // Exception: blocklist policies where ALL constraints use param="*" (wildcard).
411        // These scan all parameter values and correctly skip when no parameters exist —
412        // a tool call with no parameters has nothing dangerous to block.
413        let all_wildcard_params = constraints.iter().all(|c| c.param() == "*");
414        if on_no_match_continue
415            && !constraints.is_empty()
416            && constraints.iter().all(|c| c.on_missing() == "skip")
417            && forbidden_parameters.is_empty()
418            && required_parameters.is_empty()
419            && !all_wildcard_params
420        {
421            tracing::warn!(
422                policy_id = %policy.id,
423                policy_name = %policy.name,
424                constraint_count = constraints.len(),
425                "Conditional policy has on_no_match=\"continue\" with ALL constraints \
426                 using on_missing=\"skip\" — an attacker can bypass this policy entirely \
427                 by omitting the required parameters. Set at least one constraint to \
428                 on_missing=\"deny\" or remove on_no_match=\"continue\"."
429            );
430        }
431
432        Ok(CompiledConditions {
433            require_approval,
434            forbidden_parameters,
435            required_parameters,
436            constraints,
437            on_no_match_continue,
438            context_conditions,
439        })
440    }
441
442    /// Compile a single constraint JSON object into a `CompiledConstraint`.
443    ///
444    /// SECURITY (FIND-R190-003): Arrays within constraints (patterns, values) are bounded
445    /// at `MAX_CONSTRAINT_ELEMENTS` to prevent OOM from oversized single constraints.
446    fn compile_constraint(
447        policy: &Policy,
448        constraint: &serde_json::Value,
449    ) -> Result<CompiledConstraint, PolicyValidationError> {
450        /// Maximum elements within a single constraint's patterns/values array.
451        const MAX_CONSTRAINT_ELEMENTS: usize = 1000;
452        let obj = constraint
453            .as_object()
454            .ok_or_else(|| PolicyValidationError {
455                policy_id: policy.id.clone(),
456                policy_name: policy.name.clone(),
457                reason: "Each parameter constraint must be a JSON object".to_string(),
458            })?;
459
460        let param = obj
461            .get("param")
462            .and_then(|v| v.as_str())
463            .ok_or_else(|| PolicyValidationError {
464                policy_id: policy.id.clone(),
465                policy_name: policy.name.clone(),
466                reason: "Constraint missing required 'param' string field".to_string(),
467            })?
468            .to_string();
469
470        let op = obj
471            .get("op")
472            .and_then(|v| v.as_str())
473            .ok_or_else(|| PolicyValidationError {
474                policy_id: policy.id.clone(),
475                policy_name: policy.name.clone(),
476                reason: "Constraint missing required 'op' string field".to_string(),
477            })?;
478
479        let on_match = obj
480            .get("on_match")
481            .and_then(|v| v.as_str())
482            .unwrap_or("deny")
483            .to_string();
484        // SECURITY (R8-11): Validate on_match at compile time — a typo like
485        // "alow" would silently become a runtime error instead of a clear deny.
486        match on_match.as_str() {
487            "deny" | "allow" | "require_approval" => {}
488            other => {
489                return Err(PolicyValidationError {
490                    policy_id: policy.id.clone(),
491                    policy_name: policy.name.clone(),
492                    reason: format!(
493                        "Constraint 'on_match' value '{}' is invalid; expected 'deny', 'allow', or 'require_approval'",
494                        other
495                    ),
496                });
497            }
498        }
499        let on_missing = obj
500            .get("on_missing")
501            .and_then(|v| v.as_str())
502            .unwrap_or("deny")
503            .to_string();
504        match on_missing.as_str() {
505            "deny" | "skip" => {}
506            other => {
507                return Err(PolicyValidationError {
508                    policy_id: policy.id.clone(),
509                    policy_name: policy.name.clone(),
510                    reason: format!(
511                        "Constraint 'on_missing' value '{}' is invalid; expected 'deny' or 'skip'",
512                        other
513                    ),
514                });
515            }
516        }
517
518        match op {
519            "glob" => {
520                let pattern_str = obj
521                    .get("pattern")
522                    .and_then(|v| v.as_str())
523                    .ok_or_else(|| PolicyValidationError {
524                        policy_id: policy.id.clone(),
525                        policy_name: policy.name.clone(),
526                        reason: "glob constraint missing 'pattern' string".to_string(),
527                    })?
528                    .to_string();
529
530                let matcher = Glob::new(&pattern_str)
531                    .map_err(|e| PolicyValidationError {
532                        policy_id: policy.id.clone(),
533                        policy_name: policy.name.clone(),
534                        reason: format!("Invalid glob pattern '{}': {}", pattern_str, e),
535                    })?
536                    .compile_matcher();
537
538                Ok(CompiledConstraint::Glob {
539                    param,
540                    matcher,
541                    pattern_str,
542                    on_match,
543                    on_missing,
544                })
545            }
546            "not_glob" => {
547                let patterns = obj
548                    .get("patterns")
549                    .and_then(|v| v.as_array())
550                    .ok_or_else(|| PolicyValidationError {
551                        policy_id: policy.id.clone(),
552                        policy_name: policy.name.clone(),
553                        reason: "not_glob constraint missing 'patterns' array".to_string(),
554                    })?;
555
556                // SECURITY (FIND-R190-003): Bound patterns array to prevent OOM.
557                if patterns.len() > MAX_CONSTRAINT_ELEMENTS {
558                    return Err(PolicyValidationError {
559                        policy_id: policy.id.clone(),
560                        policy_name: policy.name.clone(),
561                        reason: format!(
562                            "not_glob patterns count {} exceeds maximum {}",
563                            patterns.len(),
564                            MAX_CONSTRAINT_ELEMENTS
565                        ),
566                    });
567                }
568
569                let mut matchers = Vec::new();
570                for pat_val in patterns {
571                    let pat_str = pat_val.as_str().ok_or_else(|| PolicyValidationError {
572                        policy_id: policy.id.clone(),
573                        policy_name: policy.name.clone(),
574                        reason: "not_glob patterns must be strings".to_string(),
575                    })?;
576                    let matcher = Glob::new(pat_str)
577                        .map_err(|e| PolicyValidationError {
578                            policy_id: policy.id.clone(),
579                            policy_name: policy.name.clone(),
580                            reason: format!("Invalid glob pattern '{}': {}", pat_str, e),
581                        })?
582                        .compile_matcher();
583                    matchers.push((pat_str.to_string(), matcher));
584                }
585
586                Ok(CompiledConstraint::NotGlob {
587                    param,
588                    matchers,
589                    on_match,
590                    on_missing,
591                })
592            }
593            "regex" => {
594                let pattern_str = obj
595                    .get("pattern")
596                    .and_then(|v| v.as_str())
597                    .ok_or_else(|| PolicyValidationError {
598                        policy_id: policy.id.clone(),
599                        policy_name: policy.name.clone(),
600                        reason: "regex constraint missing 'pattern' string".to_string(),
601                    })?
602                    .to_string();
603
604                // H2: ReDoS safety check at policy load time (early rejection)
605                Self::validate_regex_safety(&pattern_str).map_err(|reason| {
606                    PolicyValidationError {
607                        policy_id: policy.id.clone(),
608                        policy_name: policy.name.clone(),
609                        reason,
610                    }
611                })?;
612
613                // SECURITY (FIND-R46-004): Set explicit DFA size limit to prevent
614                // memory exhaustion from regex patterns that compile to large automata.
615                let regex = regex::RegexBuilder::new(&pattern_str)
616                    .dfa_size_limit(256 * 1024)
617                    .size_limit(256 * 1024)
618                    .build()
619                    .map_err(|e| PolicyValidationError {
620                        policy_id: policy.id.clone(),
621                        policy_name: policy.name.clone(),
622                        reason: format!("Invalid regex pattern '{}': {}", pattern_str, e),
623                    })?;
624
625                Ok(CompiledConstraint::Regex {
626                    param,
627                    regex,
628                    pattern_str,
629                    on_match,
630                    on_missing,
631                })
632            }
633            "domain_match" => {
634                let pattern = obj
635                    .get("pattern")
636                    .and_then(|v| v.as_str())
637                    .ok_or_else(|| PolicyValidationError {
638                        policy_id: policy.id.clone(),
639                        policy_name: policy.name.clone(),
640                        reason: "domain_match constraint missing 'pattern' string".to_string(),
641                    })?
642                    .to_string();
643
644                // SECURITY (FIND-R190-008): RFC 1035 validation on domain patterns.
645                if let Err(reason) = crate::domain::validate_domain_pattern(&pattern) {
646                    return Err(PolicyValidationError {
647                        policy_id: policy.id.clone(),
648                        policy_name: policy.name.clone(),
649                        reason: format!("domain_match pattern invalid: {}", reason),
650                    });
651                }
652
653                Ok(CompiledConstraint::DomainMatch {
654                    param,
655                    pattern,
656                    on_match,
657                    on_missing,
658                })
659            }
660            "domain_not_in" => {
661                let patterns_arr =
662                    obj.get("patterns")
663                        .and_then(|v| v.as_array())
664                        .ok_or_else(|| PolicyValidationError {
665                            policy_id: policy.id.clone(),
666                            policy_name: policy.name.clone(),
667                            reason: "domain_not_in constraint missing 'patterns' array".to_string(),
668                        })?;
669
670                // SECURITY (FIND-R190-003): Bound patterns array to prevent OOM.
671                if patterns_arr.len() > MAX_CONSTRAINT_ELEMENTS {
672                    return Err(PolicyValidationError {
673                        policy_id: policy.id.clone(),
674                        policy_name: policy.name.clone(),
675                        reason: format!(
676                            "domain_not_in patterns count {} exceeds maximum {}",
677                            patterns_arr.len(),
678                            MAX_CONSTRAINT_ELEMENTS
679                        ),
680                    });
681                }
682
683                let mut patterns = Vec::new();
684                for pat_val in patterns_arr {
685                    let pat_str = pat_val.as_str().ok_or_else(|| PolicyValidationError {
686                        policy_id: policy.id.clone(),
687                        policy_name: policy.name.clone(),
688                        reason: "domain_not_in patterns must be strings".to_string(),
689                    })?;
690                    // SECURITY (FIND-R190-008): RFC 1035 validation on domain patterns.
691                    if let Err(reason) = crate::domain::validate_domain_pattern(pat_str) {
692                        return Err(PolicyValidationError {
693                            policy_id: policy.id.clone(),
694                            policy_name: policy.name.clone(),
695                            reason: format!("domain_not_in pattern invalid: {}", reason),
696                        });
697                    }
698                    patterns.push(pat_str.to_string());
699                }
700
701                Ok(CompiledConstraint::DomainNotIn {
702                    param,
703                    patterns,
704                    on_match,
705                    on_missing,
706                })
707            }
708            "eq" => {
709                let value = obj
710                    .get("value")
711                    .ok_or_else(|| PolicyValidationError {
712                        policy_id: policy.id.clone(),
713                        policy_name: policy.name.clone(),
714                        reason: "eq constraint missing 'value' field".to_string(),
715                    })?
716                    .clone();
717
718                Ok(CompiledConstraint::Eq {
719                    param,
720                    value,
721                    on_match,
722                    on_missing,
723                })
724            }
725            "ne" => {
726                let value = obj
727                    .get("value")
728                    .ok_or_else(|| PolicyValidationError {
729                        policy_id: policy.id.clone(),
730                        policy_name: policy.name.clone(),
731                        reason: "ne constraint missing 'value' field".to_string(),
732                    })?
733                    .clone();
734
735                Ok(CompiledConstraint::Ne {
736                    param,
737                    value,
738                    on_match,
739                    on_missing,
740                })
741            }
742            "one_of" => {
743                let values = obj
744                    .get("values")
745                    .and_then(|v| v.as_array())
746                    .ok_or_else(|| PolicyValidationError {
747                        policy_id: policy.id.clone(),
748                        policy_name: policy.name.clone(),
749                        reason: "one_of constraint missing 'values' array".to_string(),
750                    })?;
751
752                // SECURITY (FIND-R190-003): Bound values array to prevent OOM.
753                if values.len() > MAX_CONSTRAINT_ELEMENTS {
754                    return Err(PolicyValidationError {
755                        policy_id: policy.id.clone(),
756                        policy_name: policy.name.clone(),
757                        reason: format!(
758                            "one_of values count {} exceeds maximum {}",
759                            values.len(),
760                            MAX_CONSTRAINT_ELEMENTS
761                        ),
762                    });
763                }
764                let values = values.clone();
765
766                Ok(CompiledConstraint::OneOf {
767                    param,
768                    values,
769                    on_match,
770                    on_missing,
771                })
772            }
773            "none_of" => {
774                let values = obj
775                    .get("values")
776                    .and_then(|v| v.as_array())
777                    .ok_or_else(|| PolicyValidationError {
778                        policy_id: policy.id.clone(),
779                        policy_name: policy.name.clone(),
780                        reason: "none_of constraint missing 'values' array".to_string(),
781                    })?;
782
783                // SECURITY (FIND-R190-003): Bound values array to prevent OOM.
784                if values.len() > MAX_CONSTRAINT_ELEMENTS {
785                    return Err(PolicyValidationError {
786                        policy_id: policy.id.clone(),
787                        policy_name: policy.name.clone(),
788                        reason: format!(
789                            "none_of values count {} exceeds maximum {}",
790                            values.len(),
791                            MAX_CONSTRAINT_ELEMENTS
792                        ),
793                    });
794                }
795                let values = values.clone();
796
797                Ok(CompiledConstraint::NoneOf {
798                    param,
799                    values,
800                    on_match,
801                    on_missing,
802                })
803            }
804            _ => Err(PolicyValidationError {
805                policy_id: policy.id.clone(),
806                policy_name: policy.name.clone(),
807                reason: format!("Unknown constraint operator '{}'", op),
808            }),
809        }
810    }
811
812    /// Compile a single context condition JSON object into a [`CompiledContextCondition`].
813    fn compile_context_condition(
814        policy: &Policy,
815        value: &serde_json::Value,
816    ) -> Result<CompiledContextCondition, PolicyValidationError> {
817        let obj = value.as_object().ok_or_else(|| PolicyValidationError {
818            policy_id: policy.id.clone(),
819            policy_name: policy.name.clone(),
820            reason: "Each context condition must be a JSON object".to_string(),
821        })?;
822
823        let kind =
824            obj.get("type")
825                .and_then(|v| v.as_str())
826                .ok_or_else(|| PolicyValidationError {
827                    policy_id: policy.id.clone(),
828                    policy_name: policy.name.clone(),
829                    reason: "Context condition missing required 'type' string field".to_string(),
830                })?;
831
832        match kind {
833            "time_window" => {
834                // SECURITY (R19-TRUNC): Validate u64 range BEFORE casting to u8.
835                // Without this, `start_hour: 265` truncates to `265 % 256 = 9` as u8,
836                // silently passing the `> 23` check. An attacker could craft a policy
837                // that appears to restrict hours but actually maps to a different hour.
838                let start_hour_u64 =
839                    obj.get("start_hour")
840                        .and_then(|v| v.as_u64())
841                        .ok_or_else(|| PolicyValidationError {
842                            policy_id: policy.id.clone(),
843                            policy_name: policy.name.clone(),
844                            reason: "time_window missing 'start_hour' integer".to_string(),
845                        })?;
846                let end_hour_u64 =
847                    obj.get("end_hour")
848                        .and_then(|v| v.as_u64())
849                        .ok_or_else(|| PolicyValidationError {
850                            policy_id: policy.id.clone(),
851                            policy_name: policy.name.clone(),
852                            reason: "time_window missing 'end_hour' integer".to_string(),
853                        })?;
854                if start_hour_u64 > 23 || end_hour_u64 > 23 {
855                    return Err(PolicyValidationError {
856                        policy_id: policy.id.clone(),
857                        policy_name: policy.name.clone(),
858                        reason: format!(
859                            "time_window hours must be 0-23, got start={} end={}",
860                            start_hour_u64, end_hour_u64
861                        ),
862                    });
863                }
864                let start_hour = start_hour_u64 as u8;
865                let end_hour = end_hour_u64 as u8;
866                // SECURITY (R19-TRUNC): Validate day values as u64 BEFORE casting to u8.
867                // Same truncation issue as hours: `day: 258` → `258 % 256 = 2` as u8.
868                let days_u64: Vec<u64> = obj
869                    .get("days")
870                    .and_then(|v| v.as_array())
871                    .map(|arr| arr.iter().filter_map(|v| v.as_u64()).collect())
872                    .unwrap_or_default();
873                for &day in &days_u64 {
874                    if !(1..=7).contains(&day) {
875                        return Err(PolicyValidationError {
876                            policy_id: policy.id.clone(),
877                            policy_name: policy.name.clone(),
878                            reason: format!(
879                                "time_window day value must be 1-7 (Mon-Sun), got {}",
880                                day
881                            ),
882                        });
883                    }
884                }
885                let days: Vec<u8> = days_u64.iter().map(|&d| d as u8).collect();
886                // SECURITY (R19-WINDOW-EQ): Reject start_hour == end_hour as a
887                // configuration error. The window check `hour >= X && hour < X` is
888                // always false, creating a permanent deny that looks like a time
889                // restriction but blocks all hours silently.
890                if start_hour == end_hour {
891                    return Err(PolicyValidationError {
892                        policy_id: policy.id.clone(),
893                        policy_name: policy.name.clone(),
894                        reason: format!(
895                            "time_window start_hour and end_hour must differ (both are {}); \
896                             a zero-width window permanently denies all requests",
897                            start_hour
898                        ),
899                    });
900                }
901                let deny_reason = format!(
902                    "Outside allowed time window ({:02}:00-{:02}:00) for policy '{}'",
903                    start_hour, end_hour, policy.name
904                );
905                Ok(CompiledContextCondition::TimeWindow {
906                    start_hour,
907                    end_hour,
908                    days,
909                    deny_reason,
910                })
911            }
912            "max_calls" => {
913                // R36-ENG-1: lowercase tool_pattern at compile time so
914                // PatternMatcher::matches() (case-sensitive) agrees with
915                // the lowercased call_count keys built at evaluation time.
916                let tool_pattern = obj
917                    .get("tool_pattern")
918                    .and_then(|v| v.as_str())
919                    .unwrap_or("*")
920                    .to_ascii_lowercase();
921                let max = obj.get("max").and_then(|v| v.as_u64()).ok_or_else(|| {
922                    PolicyValidationError {
923                        policy_id: policy.id.clone(),
924                        policy_name: policy.name.clone(),
925                        reason: "max_calls missing 'max' integer".to_string(),
926                    }
927                })?;
928                let deny_reason = format!(
929                    "Tool call limit ({}) exceeded for pattern '{}' in policy '{}'",
930                    max, tool_pattern, policy.name
931                );
932                Ok(CompiledContextCondition::MaxCalls {
933                    tool_pattern: PatternMatcher::compile(&tool_pattern),
934                    max,
935                    deny_reason,
936                })
937            }
938            "agent_id" => {
939                // SECURITY (FIND-R111-006): Bounds on agent_id allowed/blocked lists.
940                const MAX_AGENT_ID_LIST: usize = 1000;
941
942                // SECURITY: Normalize agent IDs to lowercase at compile time
943                // to prevent case-variation bypasses (e.g., "Agent-A" vs "agent-a").
944                let allowed_raw = obj
945                    .get("allowed")
946                    .and_then(|v| v.as_array())
947                    .map(|arr| arr.as_slice())
948                    .unwrap_or_default();
949                if allowed_raw.len() > MAX_AGENT_ID_LIST {
950                    return Err(PolicyValidationError {
951                        policy_id: policy.id.clone(),
952                        policy_name: policy.name.clone(),
953                        reason: format!(
954                            "agent_id allowed list count {} exceeds max {}",
955                            allowed_raw.len(),
956                            MAX_AGENT_ID_LIST
957                        ),
958                    });
959                }
960                // SECURITY (FIND-R209-002): Normalize homoglyphs at compile time
961                // to prevent Cyrillic/Greek/fullwidth agent IDs from bypassing
962                // blocklists/allowlists.
963                let allowed: Vec<String> = allowed_raw
964                    .iter()
965                    .filter_map(|v| v.as_str().map(normalize_full))
966                    .collect();
967
968                let blocked_raw = obj
969                    .get("blocked")
970                    .and_then(|v| v.as_array())
971                    .map(|arr| arr.as_slice())
972                    .unwrap_or_default();
973                if blocked_raw.len() > MAX_AGENT_ID_LIST {
974                    return Err(PolicyValidationError {
975                        policy_id: policy.id.clone(),
976                        policy_name: policy.name.clone(),
977                        reason: format!(
978                            "agent_id blocked list count {} exceeds max {}",
979                            blocked_raw.len(),
980                            MAX_AGENT_ID_LIST
981                        ),
982                    });
983                }
984                // SECURITY (FIND-R209-002): Normalize homoglyphs at compile time.
985                let blocked: Vec<String> = blocked_raw
986                    .iter()
987                    .filter_map(|v| v.as_str().map(normalize_full))
988                    .collect();
989
990                let deny_reason =
991                    format!("Agent identity not authorized by policy '{}'", policy.name);
992                Ok(CompiledContextCondition::AgentId {
993                    allowed,
994                    blocked,
995                    deny_reason,
996                })
997            }
998            "require_previous_action" => {
999                // SECURITY (FIND-R46-003): Lowercase at compile time to match the
1000                // case-insensitive comparison in context_check.rs (R31-ENG-7).
1001                // SECURITY (FIND-R206-008): Also normalize homoglyphs at compile time.
1002                let required_tool = normalize_full(
1003                    obj.get("required_tool")
1004                        .and_then(|v| v.as_str())
1005                        .ok_or_else(|| PolicyValidationError {
1006                            policy_id: policy.id.clone(),
1007                            policy_name: policy.name.clone(),
1008                            reason: "require_previous_action missing 'required_tool' string"
1009                                .to_string(),
1010                        })?,
1011                );
1012                let deny_reason = format!(
1013                    "Required previous action '{}' not found in session history (policy '{}')",
1014                    required_tool, policy.name
1015                );
1016                Ok(CompiledContextCondition::RequirePreviousAction {
1017                    required_tool,
1018                    deny_reason,
1019                })
1020            }
1021            "forbidden_previous_action" => {
1022                // SECURITY (FIND-R46-003): Lowercase at compile time to match the
1023                // case-insensitive comparison in context_check.rs (R31-ENG-7).
1024                // SECURITY (FIND-R206-007): Also normalize homoglyphs at compile time.
1025                let forbidden_tool = normalize_full(
1026                    obj.get("forbidden_tool")
1027                        .and_then(|v| v.as_str())
1028                        .ok_or_else(|| PolicyValidationError {
1029                            policy_id: policy.id.clone(),
1030                            policy_name: policy.name.clone(),
1031                            reason: "forbidden_previous_action missing 'forbidden_tool' string"
1032                                .to_string(),
1033                        })?,
1034                );
1035                let deny_reason = format!(
1036                    "Forbidden previous action '{}' detected in session history (policy '{}')",
1037                    forbidden_tool, policy.name
1038                );
1039                Ok(CompiledContextCondition::ForbiddenPreviousAction {
1040                    forbidden_tool,
1041                    deny_reason,
1042                })
1043            }
1044            "max_calls_in_window" => {
1045                // R36-ENG-1: lowercase tool_pattern at compile time so
1046                // PatternMatcher::matches() (case-sensitive) agrees with
1047                // the lowercased previous_actions built at evaluation time.
1048                let tool_pattern = obj
1049                    .get("tool_pattern")
1050                    .and_then(|v| v.as_str())
1051                    .unwrap_or("*")
1052                    .to_ascii_lowercase();
1053                let max = obj.get("max").and_then(|v| v.as_u64()).ok_or_else(|| {
1054                    PolicyValidationError {
1055                        policy_id: policy.id.clone(),
1056                        policy_name: policy.name.clone(),
1057                        reason: "max_calls_in_window missing 'max' integer".to_string(),
1058                    }
1059                })?;
1060                // SECURITY (FIND-P2-004, R34-ENG-2): Use try_from instead of `as usize`
1061                // and return a compilation error if the value overflows usize, instead
1062                // of silently clamping to usize::MAX.
1063                let window_raw = obj.get("window").and_then(|v| v.as_u64()).unwrap_or(0);
1064                let window = usize::try_from(window_raw).map_err(|_| PolicyValidationError {
1065                    policy_id: policy.id.clone(),
1066                    policy_name: policy.name.clone(),
1067                    reason: format!(
1068                        "max_calls_in_window 'window' value {} exceeds platform maximum ({})",
1069                        window_raw,
1070                        usize::MAX
1071                    ),
1072                })?;
1073                let deny_reason = format!(
1074                    "Tool '{}' called more than {} times in last {} actions (policy '{}')",
1075                    tool_pattern,
1076                    max,
1077                    if window == 0 {
1078                        "all".to_string()
1079                    } else {
1080                        window.to_string()
1081                    },
1082                    policy.name
1083                );
1084                Ok(CompiledContextCondition::MaxCallsInWindow {
1085                    tool_pattern: PatternMatcher::compile(&tool_pattern),
1086                    max,
1087                    window,
1088                    deny_reason,
1089                })
1090            }
1091            "max_chain_depth" => {
1092                // OWASP ASI08: Multi-agent communication monitoring
1093                // SECURITY (R33-ENG-3): Use try_from instead of `as usize` to
1094                // avoid silent truncation on 32-bit platforms where u64 > usize::MAX.
1095                let raw_depth = obj
1096                    .get("max_depth")
1097                    .and_then(|v| v.as_u64())
1098                    .ok_or_else(|| PolicyValidationError {
1099                        policy_id: policy.id.clone(),
1100                        policy_name: policy.name.clone(),
1101                        reason: "max_chain_depth missing 'max_depth' integer".to_string(),
1102                    })?;
1103                let max_depth = usize::try_from(raw_depth).map_err(|_| PolicyValidationError {
1104                    policy_id: policy.id.clone(),
1105                    policy_name: policy.name.clone(),
1106                    reason: format!(
1107                        "max_chain_depth value {} exceeds platform maximum",
1108                        raw_depth
1109                    ),
1110                })?;
1111                let deny_reason = format!(
1112                    "Call chain depth exceeds maximum of {} (policy '{}')",
1113                    max_depth, policy.name
1114                );
1115                Ok(CompiledContextCondition::MaxChainDepth {
1116                    max_depth,
1117                    deny_reason,
1118                })
1119            }
1120            "agent_identity" => {
1121                // OWASP ASI07: Agent identity attestation via signed JWT
1122                // SECURITY: Normalize required fields to lowercase + homoglyphs for
1123                // case-insensitive matching, consistent with blocked_issuers/blocked_subjects (R40-ENG-2)
1124                // SECURITY (FIND-R211-002): Normalize homoglyphs at compile time to match
1125                // evaluation-time normalization, preventing Cyrillic/fullwidth bypass.
1126                let required_issuer = obj
1127                    .get("issuer")
1128                    .and_then(|v| v.as_str())
1129                    .map(normalize_full);
1130                let required_subject = obj
1131                    .get("subject")
1132                    .and_then(|v| v.as_str())
1133                    .map(normalize_full);
1134                let required_audience = obj
1135                    .get("audience")
1136                    .and_then(|v| v.as_str())
1137                    .map(normalize_full);
1138
1139                // SECURITY (FIND-R111-005): Per-key/value length bounds on required_claims.
1140                const MAX_REQUIRED_CLAIMS: usize = 64;
1141                const MAX_CLAIM_KEY_LEN: usize = 256;
1142                const MAX_CLAIM_VALUE_LEN: usize = 512;
1143
1144                // Parse required_claims as a map of string -> string
1145                // SECURITY (FIND-044): Lowercase claim values at compile time for
1146                // case-insensitive comparison, matching issuer/subject/audience.
1147                let required_claims = if let Some(m) = obj.get("claims").and_then(|v| v.as_object())
1148                {
1149                    if m.len() > MAX_REQUIRED_CLAIMS {
1150                        return Err(PolicyValidationError {
1151                            policy_id: policy.id.clone(),
1152                            policy_name: policy.name.clone(),
1153                            reason: format!(
1154                                "agent_identity claims count {} exceeds max {}",
1155                                m.len(),
1156                                MAX_REQUIRED_CLAIMS
1157                            ),
1158                        });
1159                    }
1160                    let mut map = std::collections::HashMap::new();
1161                    for (k, v) in m {
1162                        if k.len() > MAX_CLAIM_KEY_LEN {
1163                            return Err(PolicyValidationError {
1164                                policy_id: policy.id.clone(),
1165                                policy_name: policy.name.clone(),
1166                                reason: format!(
1167                                    "agent_identity claim key length {} exceeds max {}",
1168                                    k.len(),
1169                                    MAX_CLAIM_KEY_LEN
1170                                ),
1171                            });
1172                        }
1173                        if let Some(s) = v.as_str() {
1174                            if s.len() > MAX_CLAIM_VALUE_LEN {
1175                                return Err(PolicyValidationError {
1176                                    policy_id: policy.id.clone(),
1177                                    policy_name: policy.name.clone(),
1178                                    reason: format!(
1179                                        "agent_identity claim value for key '{}' length {} exceeds max {}",
1180                                        k,
1181                                        s.len(),
1182                                        MAX_CLAIM_VALUE_LEN
1183                                    ),
1184                                });
1185                            }
1186                            map.insert(k.clone(), normalize_full(s));
1187                        }
1188                    }
1189                    map
1190                } else {
1191                    std::collections::HashMap::new()
1192                };
1193
1194                // SECURITY (FIND-R111-006): Bounds on blocked_issuers and blocked_subjects lists.
1195                const MAX_ISSUER_LIST: usize = 256;
1196                const MAX_SUBJECT_LIST: usize = 256;
1197
1198                // SECURITY: Normalize blocked lists to lowercase + homoglyphs for case-insensitive matching
1199                // SECURITY (FIND-R211-002): Normalize homoglyphs at compile time to match
1200                // evaluation-time normalization, preventing Cyrillic/fullwidth bypass.
1201                let blocked_issuers_raw = obj
1202                    .get("blocked_issuers")
1203                    .and_then(|v| v.as_array())
1204                    .map(|arr| arr.as_slice())
1205                    .unwrap_or_default();
1206                if blocked_issuers_raw.len() > MAX_ISSUER_LIST {
1207                    return Err(PolicyValidationError {
1208                        policy_id: policy.id.clone(),
1209                        policy_name: policy.name.clone(),
1210                        reason: format!(
1211                            "agent_identity blocked_issuers count {} exceeds max {}",
1212                            blocked_issuers_raw.len(),
1213                            MAX_ISSUER_LIST
1214                        ),
1215                    });
1216                }
1217                let blocked_issuers: Vec<String> = blocked_issuers_raw
1218                    .iter()
1219                    .filter_map(|v| v.as_str().map(normalize_full))
1220                    .collect();
1221
1222                let blocked_subjects_raw = obj
1223                    .get("blocked_subjects")
1224                    .and_then(|v| v.as_array())
1225                    .map(|arr| arr.as_slice())
1226                    .unwrap_or_default();
1227                if blocked_subjects_raw.len() > MAX_SUBJECT_LIST {
1228                    return Err(PolicyValidationError {
1229                        policy_id: policy.id.clone(),
1230                        policy_name: policy.name.clone(),
1231                        reason: format!(
1232                            "agent_identity blocked_subjects count {} exceeds max {}",
1233                            blocked_subjects_raw.len(),
1234                            MAX_SUBJECT_LIST
1235                        ),
1236                    });
1237                }
1238                let blocked_subjects: Vec<String> = blocked_subjects_raw
1239                    .iter()
1240                    .filter_map(|v| v.as_str().map(normalize_full))
1241                    .collect();
1242
1243                // When true, fail if no agent_identity is present (require JWT attestation)
1244                let require_attestation = obj
1245                    .get("require_attestation")
1246                    .and_then(|v| v.as_bool())
1247                    .unwrap_or(true); // Default to true for security
1248
1249                let deny_reason = format!(
1250                    "Agent identity attestation failed for policy '{}'",
1251                    policy.name
1252                );
1253
1254                Ok(CompiledContextCondition::AgentIdentityMatch {
1255                    required_issuer,
1256                    required_subject,
1257                    required_audience,
1258                    required_claims,
1259                    blocked_issuers,
1260                    blocked_subjects,
1261                    require_attestation,
1262                    deny_reason,
1263                })
1264            }
1265
1266            // ═══════════════════════════════════════════════════
1267            // MCP 2025-11-25 CONTEXT CONDITIONS
1268            // ═══════════════════════════════════════════════════
1269            "async_task_policy" => {
1270                // MCP 2025-11-25: Async task lifecycle policy
1271                // SECURITY (FIND-R49-005): Warn that this condition is a no-op at the engine level.
1272                tracing::warn!(
1273                    policy_id = %policy.id,
1274                    "Policy condition 'async_task_policy' is not enforced at the engine level; \
1275                     it requires the MCP proxy layer for enforcement"
1276                );
1277                // SECURITY (FIND-P3-015): Return a compilation error if max_concurrent
1278                // overflows usize, instead of silently clamping to usize::MAX.
1279                let max_concurrent_raw = obj
1280                    .get("max_concurrent")
1281                    .and_then(|v| v.as_u64())
1282                    .unwrap_or(0); // 0 = unlimited
1283                let max_concurrent = if max_concurrent_raw == 0 {
1284                    0
1285                } else {
1286                    usize::try_from(max_concurrent_raw).map_err(|_| PolicyValidationError {
1287                        policy_id: policy.id.clone(),
1288                        policy_name: policy.name.clone(),
1289                        reason: format!(
1290                            "async_task_policy 'max_concurrent' value {} exceeds platform maximum ({})",
1291                            max_concurrent_raw,
1292                            usize::MAX
1293                        ),
1294                    })?
1295                };
1296
1297                let max_duration_secs = obj
1298                    .get("max_duration_secs")
1299                    .and_then(|v| v.as_u64())
1300                    .unwrap_or(0); // 0 = unlimited
1301
1302                let require_self_cancel = obj
1303                    .get("require_self_cancel")
1304                    .and_then(|v| v.as_bool())
1305                    .unwrap_or(true); // Default: only creator can cancel
1306
1307                let deny_reason =
1308                    format!("Async task policy violated for policy '{}'", policy.name);
1309
1310                Ok(CompiledContextCondition::AsyncTaskPolicy {
1311                    max_concurrent,
1312                    max_duration_secs,
1313                    require_self_cancel,
1314                    deny_reason,
1315                })
1316            }
1317
1318            "resource_indicator" => {
1319                // RFC 8707: OAuth 2.0 Resource Indicators
1320                // SECURITY (FIND-R111-006): Bound the allowed_resources list.
1321                const MAX_RESOURCE_PATTERNS: usize = 256;
1322
1323                let allowed_resources_raw = obj
1324                    .get("allowed_resources")
1325                    .and_then(|v| v.as_array())
1326                    .map(|arr| arr.as_slice())
1327                    .unwrap_or_default();
1328                if allowed_resources_raw.len() > MAX_RESOURCE_PATTERNS {
1329                    return Err(PolicyValidationError {
1330                        policy_id: policy.id.clone(),
1331                        policy_name: policy.name.clone(),
1332                        reason: format!(
1333                            "resource_indicator allowed_resources count {} exceeds max {}",
1334                            allowed_resources_raw.len(),
1335                            MAX_RESOURCE_PATTERNS
1336                        ),
1337                    });
1338                }
1339                let allowed_resources: Vec<PatternMatcher> = allowed_resources_raw
1340                    .iter()
1341                    .filter_map(|v| v.as_str())
1342                    .map(PatternMatcher::compile)
1343                    .collect();
1344
1345                let require_resource = obj
1346                    .get("require_resource")
1347                    .and_then(|v| v.as_bool())
1348                    .unwrap_or(false);
1349
1350                let deny_reason = format!(
1351                    "Resource indicator validation failed for policy '{}'",
1352                    policy.name
1353                );
1354
1355                Ok(CompiledContextCondition::ResourceIndicator {
1356                    allowed_resources,
1357                    require_resource,
1358                    deny_reason,
1359                })
1360            }
1361
1362            "capability_required" => {
1363                // CIMD: Capability-Indexed Message Dispatch
1364                // SECURITY (FIND-043): Normalize to lowercase at compile time,
1365                // matching the pattern used by AgentId and MaxCalls.
1366
1367                // SECURITY (FIND-R112-003): Bound capability list sizes and per-entry length.
1368                const MAX_CAPABILITY_LIST: usize = 256;
1369                const MAX_CAPABILITY_NAME_LEN: usize = 256;
1370
1371                // SECURITY (FIND-R215-002): Apply normalize_homoglyphs() after
1372                // to_ascii_lowercase() for parity with AgentId, AgentIdentityMatch,
1373                // MaxCalls, RequireCapabilityToken compile-time normalization.
1374                let required_capabilities: Vec<String> = obj
1375                    .get("required_capabilities")
1376                    .and_then(|v| v.as_array())
1377                    .map(|arr| {
1378                        arr.iter()
1379                            .filter_map(|v| v.as_str().map(normalize_full))
1380                            .collect()
1381                    })
1382                    .unwrap_or_default();
1383
1384                if required_capabilities.len() > MAX_CAPABILITY_LIST {
1385                    return Err(PolicyValidationError {
1386                        policy_id: policy.id.clone(),
1387                        policy_name: policy.name.clone(),
1388                        reason: format!(
1389                            "capability_required has {} required_capabilities (max {MAX_CAPABILITY_LIST})",
1390                            required_capabilities.len()
1391                        ),
1392                    });
1393                }
1394                for (i, cap) in required_capabilities.iter().enumerate() {
1395                    if cap.len() > MAX_CAPABILITY_NAME_LEN {
1396                        return Err(PolicyValidationError {
1397                            policy_id: policy.id.clone(),
1398                            policy_name: policy.name.clone(),
1399                            reason: format!(
1400                                "capability_required required_capabilities[{i}] length {} exceeds max {MAX_CAPABILITY_NAME_LEN}",
1401                                cap.len()
1402                            ),
1403                        });
1404                    }
1405                }
1406
1407                let blocked_capabilities: Vec<String> = obj
1408                    .get("blocked_capabilities")
1409                    .and_then(|v| v.as_array())
1410                    .map(|arr| {
1411                        arr.iter()
1412                            .filter_map(|v| v.as_str().map(normalize_full))
1413                            .collect()
1414                    })
1415                    .unwrap_or_default();
1416
1417                if blocked_capabilities.len() > MAX_CAPABILITY_LIST {
1418                    return Err(PolicyValidationError {
1419                        policy_id: policy.id.clone(),
1420                        policy_name: policy.name.clone(),
1421                        reason: format!(
1422                            "capability_required has {} blocked_capabilities (max {MAX_CAPABILITY_LIST})",
1423                            blocked_capabilities.len()
1424                        ),
1425                    });
1426                }
1427                for (i, cap) in blocked_capabilities.iter().enumerate() {
1428                    if cap.len() > MAX_CAPABILITY_NAME_LEN {
1429                        return Err(PolicyValidationError {
1430                            policy_id: policy.id.clone(),
1431                            policy_name: policy.name.clone(),
1432                            reason: format!(
1433                                "capability_required blocked_capabilities[{i}] length {} exceeds max {MAX_CAPABILITY_NAME_LEN}",
1434                                cap.len()
1435                            ),
1436                        });
1437                    }
1438                }
1439
1440                let deny_reason = format!(
1441                    "Capability requirement not met for policy '{}'",
1442                    policy.name
1443                );
1444
1445                Ok(CompiledContextCondition::CapabilityRequired {
1446                    required_capabilities,
1447                    blocked_capabilities,
1448                    deny_reason,
1449                })
1450            }
1451
1452            "step_up_auth" => {
1453                // Step-up authentication
1454                let required_level_u64 = obj
1455                    .get("required_level")
1456                    .and_then(|v| v.as_u64())
1457                    .ok_or_else(|| PolicyValidationError {
1458                        policy_id: policy.id.clone(),
1459                        policy_name: policy.name.clone(),
1460                        reason: "step_up_auth missing 'required_level' integer".to_string(),
1461                    })?;
1462
1463                // Validate level is in valid range (0-4)
1464                if required_level_u64 > 4 {
1465                    return Err(PolicyValidationError {
1466                        policy_id: policy.id.clone(),
1467                        policy_name: policy.name.clone(),
1468                        reason: format!(
1469                            "step_up_auth required_level must be 0-4, got {}",
1470                            required_level_u64
1471                        ),
1472                    });
1473                }
1474
1475                let required_level = required_level_u64 as u8;
1476
1477                let deny_reason = format!(
1478                    "Step-up authentication required (level {}) for policy '{}'",
1479                    required_level, policy.name
1480                );
1481
1482                Ok(CompiledContextCondition::StepUpAuth {
1483                    required_level,
1484                    deny_reason,
1485                })
1486            }
1487
1488            // ═══════════════════════════════════════════════════
1489            // PHASE 2: ADVANCED THREAT DETECTION CONDITIONS
1490            // ═══════════════════════════════════════════════════
1491            "circuit_breaker" => {
1492                // OWASP ASI08: Cascading failure protection
1493                // SECURITY (FIND-R49-005): Warn that this condition is a no-op at the engine level.
1494                tracing::warn!(
1495                    policy_id = %policy.id,
1496                    "Policy condition 'circuit_breaker' is not enforced at the engine level; \
1497                     it requires the MCP proxy layer for enforcement"
1498                );
1499                let tool_pattern = obj
1500                    .get("tool_pattern")
1501                    .and_then(|v| v.as_str())
1502                    .unwrap_or("*")
1503                    .to_ascii_lowercase();
1504
1505                // SECURITY (R226-ENG-3): Generic deny reason to avoid leaking
1506                // internal tool_pattern to API clients. Pattern details are
1507                // logged server-side at compile time (above tracing::debug!).
1508                let deny_reason = format!("Circuit breaker open (policy '{}')", policy.name);
1509
1510                Ok(CompiledContextCondition::CircuitBreaker {
1511                    tool_pattern: PatternMatcher::compile(&tool_pattern),
1512                    deny_reason,
1513                })
1514            }
1515
1516            "deputy_validation" => {
1517                // OWASP ASI02: Confused deputy prevention
1518                let require_principal = obj
1519                    .get("require_principal")
1520                    .and_then(|v| v.as_bool())
1521                    .unwrap_or(true);
1522
1523                let max_delegation_depth_u64 = obj
1524                    .get("max_delegation_depth")
1525                    .and_then(|v| v.as_u64())
1526                    .unwrap_or(3);
1527
1528                // Validate depth is reasonable
1529                if max_delegation_depth_u64 > 255 {
1530                    return Err(PolicyValidationError {
1531                        policy_id: policy.id.clone(),
1532                        policy_name: policy.name.clone(),
1533                        reason: format!(
1534                            "deputy_validation max_delegation_depth must be 0-255, got {}",
1535                            max_delegation_depth_u64
1536                        ),
1537                    });
1538                }
1539
1540                let max_delegation_depth = max_delegation_depth_u64 as u8;
1541
1542                let deny_reason = format!("Deputy validation failed for policy '{}'", policy.name);
1543
1544                Ok(CompiledContextCondition::DeputyValidation {
1545                    require_principal,
1546                    max_delegation_depth,
1547                    deny_reason,
1548                })
1549            }
1550
1551            "shadow_agent_check" => {
1552                // Shadow agent detection
1553                // SECURITY (FIND-R49-005): Warn that this condition is a no-op at the engine level.
1554                tracing::warn!(
1555                    policy_id = %policy.id,
1556                    "Policy condition 'shadow_agent_check' is not enforced at the engine level; \
1557                     it requires the MCP proxy layer for enforcement"
1558                );
1559                let require_known_fingerprint = obj
1560                    .get("require_known_fingerprint")
1561                    .and_then(|v| v.as_bool())
1562                    .unwrap_or(false);
1563
1564                let min_trust_level_u64 = obj
1565                    .get("min_trust_level")
1566                    .and_then(|v| v.as_u64())
1567                    .unwrap_or(1); // Default: Low trust
1568
1569                // Validate level is in valid range (0-4)
1570                if min_trust_level_u64 > 4 {
1571                    return Err(PolicyValidationError {
1572                        policy_id: policy.id.clone(),
1573                        policy_name: policy.name.clone(),
1574                        reason: format!(
1575                            "shadow_agent_check min_trust_level must be 0-4, got {}",
1576                            min_trust_level_u64
1577                        ),
1578                    });
1579                }
1580
1581                let min_trust_level = min_trust_level_u64 as u8;
1582
1583                let deny_reason = format!("Shadow agent check failed for policy '{}'", policy.name);
1584
1585                Ok(CompiledContextCondition::ShadowAgentCheck {
1586                    require_known_fingerprint,
1587                    min_trust_level,
1588                    deny_reason,
1589                })
1590            }
1591
1592            "schema_poisoning_check" => {
1593                // OWASP ASI05: Schema poisoning protection
1594                // SECURITY (FIND-R49-005): Warn that this condition is a no-op at the engine level.
1595                tracing::warn!(
1596                    policy_id = %policy.id,
1597                    "Policy condition 'schema_poisoning_check' is not enforced at the engine level; \
1598                     it requires the MCP proxy layer for enforcement"
1599                );
1600                let mutation_threshold = obj
1601                    .get("mutation_threshold")
1602                    .and_then(|v| v.as_f64())
1603                    .map(|v| v as f32)
1604                    .unwrap_or(0.1); // Default: 10% change triggers alert
1605
1606                // Validate threshold is in valid range
1607                if !mutation_threshold.is_finite() || !(0.0..=1.0).contains(&mutation_threshold) {
1608                    return Err(PolicyValidationError {
1609                        policy_id: policy.id.clone(),
1610                        policy_name: policy.name.clone(),
1611                        reason: format!(
1612                            "schema_poisoning_check mutation_threshold must be in [0.0, 1.0], got {}",
1613                            mutation_threshold
1614                        ),
1615                    });
1616                }
1617
1618                let deny_reason = format!("Schema poisoning detected for policy '{}'", policy.name);
1619
1620                Ok(CompiledContextCondition::SchemaPoisoningCheck {
1621                    mutation_threshold,
1622                    deny_reason,
1623                })
1624            }
1625
1626            "require_capability_token" => {
1627                // SECURITY (FIND-R111-006): Bound the required_issuers list.
1628                const MAX_ISSUER_LIST_CAP: usize = 256;
1629
1630                // Parse required_issuers (optional array of strings)
1631                let required_issuers_raw = obj
1632                    .get("required_issuers")
1633                    .and_then(|v| v.as_array())
1634                    .map(|arr| arr.as_slice())
1635                    .unwrap_or_default();
1636                if required_issuers_raw.len() > MAX_ISSUER_LIST_CAP {
1637                    return Err(PolicyValidationError {
1638                        policy_id: policy.id.clone(),
1639                        policy_name: policy.name.clone(),
1640                        reason: format!(
1641                            "require_capability_token required_issuers count {} exceeds max {}",
1642                            required_issuers_raw.len(),
1643                            MAX_ISSUER_LIST_CAP
1644                        ),
1645                    });
1646                }
1647                // SECURITY (IMP-R216-005): Apply homoglyph normalization for parity
1648                // with agent_identity issuer/subject/audience checks.
1649                let required_issuers: Vec<String> = required_issuers_raw
1650                    .iter()
1651                    .filter_map(|v| v.as_str().map(normalize_full))
1652                    .collect();
1653
1654                // Parse min_remaining_depth (optional, default 0)
1655                let min_remaining_depth = obj
1656                    .get("min_remaining_depth")
1657                    .and_then(|v| v.as_u64())
1658                    .unwrap_or(0);
1659                if min_remaining_depth > 16 {
1660                    return Err(PolicyValidationError {
1661                        policy_id: policy.id.clone(),
1662                        policy_name: policy.name.clone(),
1663                        reason: format!(
1664                            "require_capability_token min_remaining_depth must be 0-16, got {}",
1665                            min_remaining_depth
1666                        ),
1667                    });
1668                }
1669
1670                let deny_reason = format!("Capability token required for policy '{}'", policy.name);
1671
1672                Ok(CompiledContextCondition::RequireCapabilityToken {
1673                    required_issuers,
1674                    min_remaining_depth: min_remaining_depth as u8,
1675                    deny_reason,
1676                })
1677            }
1678
1679            "min_verification_tier" => {
1680                // Parse required_tier as integer or string name
1681                let required_tier = if let Some(level_val) = obj.get("required_tier") {
1682                    if let Some(level_u64) = level_val.as_u64() {
1683                        if level_u64 > 4 {
1684                            return Err(PolicyValidationError {
1685                                policy_id: policy.id.clone(),
1686                                policy_name: policy.name.clone(),
1687                                reason: format!(
1688                                    "min_verification_tier required_tier must be 0-4, got {}",
1689                                    level_u64
1690                                ),
1691                            });
1692                        }
1693                        level_u64 as u8
1694                    } else if let Some(name) = level_val.as_str() {
1695                        vellaveto_types::VerificationTier::from_name(name)
1696                            .map(|t| t.level())
1697                            .ok_or_else(|| PolicyValidationError {
1698                                policy_id: policy.id.clone(),
1699                                policy_name: policy.name.clone(),
1700                                reason: format!(
1701                                    "min_verification_tier unknown tier name '{}'",
1702                                    name
1703                                ),
1704                            })?
1705                    } else {
1706                        return Err(PolicyValidationError {
1707                            policy_id: policy.id.clone(),
1708                            policy_name: policy.name.clone(),
1709                            reason: "min_verification_tier required_tier must be an integer (0-4) or tier name string".to_string(),
1710                        });
1711                    }
1712                } else {
1713                    return Err(PolicyValidationError {
1714                        policy_id: policy.id.clone(),
1715                        policy_name: policy.name.clone(),
1716                        reason: "min_verification_tier missing 'required_tier' field".to_string(),
1717                    });
1718                };
1719
1720                let deny_reason = format!(
1721                    "Verification tier below minimum (required level {}) for policy '{}'",
1722                    required_tier, policy.name
1723                );
1724
1725                Ok(CompiledContextCondition::MinVerificationTier {
1726                    required_tier,
1727                    deny_reason,
1728                })
1729            }
1730
1731            "session_state_required" => {
1732                // SECURITY (FIND-R112-002): Bound allowed_states to prevent
1733                // memory exhaustion from attacker-controlled policy JSON.
1734                const MAX_ALLOWED_STATES: usize = 1000;
1735                const MAX_STATE_NAME_LEN: usize = 256;
1736
1737                // Parse allowed_states (required array of strings)
1738                let raw_arr = obj
1739                    .get("allowed_states")
1740                    .and_then(|v| v.as_array())
1741                    .cloned()
1742                    .unwrap_or_default();
1743
1744                if raw_arr.len() > MAX_ALLOWED_STATES {
1745                    return Err(PolicyValidationError {
1746                        policy_id: policy.id.clone(),
1747                        policy_name: policy.name.clone(),
1748                        reason: format!(
1749                            "session_state_required allowed_states has {} entries, max {}",
1750                            raw_arr.len(),
1751                            MAX_ALLOWED_STATES,
1752                        ),
1753                    });
1754                }
1755
1756                let mut allowed_states = Vec::with_capacity(raw_arr.len());
1757                for entry in &raw_arr {
1758                    if let Some(s) = entry.as_str() {
1759                        if s.len() > MAX_STATE_NAME_LEN {
1760                            return Err(PolicyValidationError {
1761                                policy_id: policy.id.clone(),
1762                                policy_name: policy.name.clone(),
1763                                reason: format!(
1764                                    "session_state_required allowed_states entry length {} exceeds max {}",
1765                                    s.len(),
1766                                    MAX_STATE_NAME_LEN,
1767                                ),
1768                            });
1769                        }
1770                        // SECURITY (FIND-R215-003): Apply normalize_homoglyphs()
1771                        // after to_ascii_lowercase() for parity with other conditions.
1772                        allowed_states.push(normalize_full(s));
1773                    }
1774                }
1775
1776                if allowed_states.is_empty() {
1777                    return Err(PolicyValidationError {
1778                        policy_id: policy.id.clone(),
1779                        policy_name: policy.name.clone(),
1780                        reason:
1781                            "session_state_required must have at least one allowed_states entry"
1782                                .to_string(),
1783                    });
1784                }
1785
1786                let deny_reason = format!(
1787                    "Session state not in allowed states for policy '{}'",
1788                    policy.name
1789                );
1790
1791                Ok(CompiledContextCondition::SessionStateRequired {
1792                    allowed_states,
1793                    deny_reason,
1794                })
1795            }
1796
1797            // ═══════════════════════════════════════════════════
1798            // PHASE 40: WORKFLOW-LEVEL POLICY CONSTRAINTS
1799            // ═══════════════════════════════════════════════════
1800            "required_action_sequence" => Self::compile_action_sequence(obj, policy, true),
1801
1802            "forbidden_action_sequence" => Self::compile_action_sequence(obj, policy, false),
1803
1804            "workflow_template" => Self::compile_workflow_template(obj, policy),
1805
1806            _ => Err(PolicyValidationError {
1807                policy_id: policy.id.clone(),
1808                policy_name: policy.name.clone(),
1809                reason: format!("Unknown context condition type '{}'", kind),
1810            }),
1811        }
1812    }
1813
1814    /// Compile a `required_action_sequence` or `forbidden_action_sequence` condition.
1815    ///
1816    /// Both share identical parsing; only the resulting variant differs.
1817    fn compile_action_sequence(
1818        obj: &serde_json::Map<String, serde_json::Value>,
1819        policy: &vellaveto_types::Policy,
1820        is_required: bool,
1821    ) -> Result<CompiledContextCondition, PolicyValidationError> {
1822        const MAX_SEQUENCE_STEPS: usize = 20;
1823        // SECURITY (FIND-R50-051): Bound tool name length in sequences.
1824        const MAX_TOOL_NAME_LEN: usize = 256;
1825
1826        let kind = if is_required {
1827            "required_action_sequence"
1828        } else {
1829            "forbidden_action_sequence"
1830        };
1831
1832        let arr = obj
1833            .get("sequence")
1834            .and_then(|v| v.as_array())
1835            .ok_or_else(|| PolicyValidationError {
1836                policy_id: policy.id.clone(),
1837                policy_name: policy.name.clone(),
1838                reason: format!("{kind} requires a 'sequence' array"),
1839            })?;
1840
1841        if arr.is_empty() {
1842            return Err(PolicyValidationError {
1843                policy_id: policy.id.clone(),
1844                policy_name: policy.name.clone(),
1845                reason: format!("{kind} sequence must not be empty"),
1846            });
1847        }
1848
1849        if arr.len() > MAX_SEQUENCE_STEPS {
1850            return Err(PolicyValidationError {
1851                policy_id: policy.id.clone(),
1852                policy_name: policy.name.clone(),
1853                reason: format!(
1854                    "{kind} sequence has {} steps (max {MAX_SEQUENCE_STEPS})",
1855                    arr.len()
1856                ),
1857            });
1858        }
1859
1860        let mut sequence = Vec::with_capacity(arr.len());
1861        for (i, val) in arr.iter().enumerate() {
1862            let s = val.as_str().ok_or_else(|| PolicyValidationError {
1863                policy_id: policy.id.clone(),
1864                policy_name: policy.name.clone(),
1865                reason: format!("{kind} sequence[{i}] must be a string"),
1866            })?;
1867
1868            if s.is_empty() {
1869                return Err(PolicyValidationError {
1870                    policy_id: policy.id.clone(),
1871                    policy_name: policy.name.clone(),
1872                    reason: format!("{kind} sequence[{i}] must not be empty"),
1873                });
1874            }
1875
1876            // SECURITY (FIND-R112-004): Reject control and Unicode format characters in tool names.
1877            if s.chars()
1878                .any(|c| c.is_control() || vellaveto_types::is_unicode_format_char(c))
1879            {
1880                return Err(PolicyValidationError {
1881                    policy_id: policy.id.clone(),
1882                    policy_name: policy.name.clone(),
1883                    reason: format!("{kind} sequence[{i}] contains control or format characters"),
1884                });
1885            }
1886
1887            // SECURITY (FIND-R50-051): Reject excessively long tool names.
1888            if s.len() > MAX_TOOL_NAME_LEN {
1889                return Err(PolicyValidationError {
1890                    policy_id: policy.id.clone(),
1891                    policy_name: policy.name.clone(),
1892                    reason: format!(
1893                        "{kind} sequence[{i}] tool name is {} bytes, max is {MAX_TOOL_NAME_LEN}",
1894                        s.len()
1895                    ),
1896                });
1897            }
1898
1899            // SECURITY (FIND-R206-005, FIND-R220-006): normalize_full at compile time
1900            // so evaluation-time normalization on history entries will match.
1901            sequence.push(normalize_full(s));
1902        }
1903
1904        let ordered = obj.get("ordered").and_then(|v| v.as_bool()).unwrap_or(true);
1905
1906        if is_required {
1907            let deny_reason = format!(
1908                "Required action sequence not satisfied for policy '{}'",
1909                policy.name
1910            );
1911            Ok(CompiledContextCondition::RequiredActionSequence {
1912                sequence,
1913                ordered,
1914                deny_reason,
1915            })
1916        } else {
1917            let deny_reason = format!(
1918                "Forbidden action sequence detected for policy '{}'",
1919                policy.name
1920            );
1921            Ok(CompiledContextCondition::ForbiddenActionSequence {
1922                sequence,
1923                ordered,
1924                deny_reason,
1925            })
1926        }
1927    }
1928
1929    /// Compile a `workflow_template` condition.
1930    ///
1931    /// Parses the `steps` array, computes governed tools, entry points,
1932    /// and validates acyclicity via Kahn's algorithm.
1933    fn compile_workflow_template(
1934        obj: &serde_json::Map<String, serde_json::Value>,
1935        policy: &vellaveto_types::Policy,
1936    ) -> Result<CompiledContextCondition, PolicyValidationError> {
1937        use std::collections::{HashMap, HashSet, VecDeque};
1938
1939        const MAX_WORKFLOW_STEPS: usize = 50;
1940        // SECURITY (FIND-R50-061): Maximum successors per workflow step.
1941        const MAX_SUCCESSORS_PER_STEP: usize = 50;
1942        // SECURITY (FIND-R50-051): Maximum tool name length in workflows.
1943        const MAX_TOOL_NAME_LEN: usize = 256;
1944
1945        let steps =
1946            obj.get("steps")
1947                .and_then(|v| v.as_array())
1948                .ok_or_else(|| PolicyValidationError {
1949                    policy_id: policy.id.clone(),
1950                    policy_name: policy.name.clone(),
1951                    reason: "workflow_template requires a 'steps' array".to_string(),
1952                })?;
1953
1954        if steps.is_empty() {
1955            return Err(PolicyValidationError {
1956                policy_id: policy.id.clone(),
1957                policy_name: policy.name.clone(),
1958                reason: "workflow_template steps must not be empty".to_string(),
1959            });
1960        }
1961
1962        if steps.len() > MAX_WORKFLOW_STEPS {
1963            return Err(PolicyValidationError {
1964                policy_id: policy.id.clone(),
1965                policy_name: policy.name.clone(),
1966                reason: format!(
1967                    "workflow_template has {} steps (max {MAX_WORKFLOW_STEPS})",
1968                    steps.len()
1969                ),
1970            });
1971        }
1972
1973        let enforce = obj
1974            .get("enforce")
1975            .and_then(|v| v.as_str())
1976            .unwrap_or("strict");
1977
1978        let strict = match enforce {
1979            "strict" => true,
1980            "warn" => false,
1981            other => {
1982                return Err(PolicyValidationError {
1983                    policy_id: policy.id.clone(),
1984                    policy_name: policy.name.clone(),
1985                    reason: format!(
1986                        "workflow_template enforce must be 'strict' or 'warn', got '{other}'"
1987                    ),
1988                });
1989            }
1990        };
1991
1992        let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
1993        let mut governed_tools: HashSet<String> = HashSet::new();
1994        let mut seen_tools: HashSet<String> = HashSet::new();
1995
1996        for (i, step) in steps.iter().enumerate() {
1997            let step_obj = step.as_object().ok_or_else(|| PolicyValidationError {
1998                policy_id: policy.id.clone(),
1999                policy_name: policy.name.clone(),
2000                reason: format!("workflow_template steps[{i}] must be an object"),
2001            })?;
2002
2003            let tool = step_obj
2004                .get("tool")
2005                .and_then(|v| v.as_str())
2006                .ok_or_else(|| PolicyValidationError {
2007                    policy_id: policy.id.clone(),
2008                    policy_name: policy.name.clone(),
2009                    reason: format!("workflow_template steps[{i}] requires a 'tool' string"),
2010                })?;
2011
2012            if tool.is_empty() {
2013                return Err(PolicyValidationError {
2014                    policy_id: policy.id.clone(),
2015                    policy_name: policy.name.clone(),
2016                    reason: format!("workflow_template steps[{i}].tool must not be empty"),
2017                });
2018            }
2019
2020            // SECURITY (FIND-R112-004): Reject control and Unicode format characters.
2021            if tool
2022                .chars()
2023                .any(|c| c.is_control() || vellaveto_types::is_unicode_format_char(c))
2024            {
2025                return Err(PolicyValidationError {
2026                    policy_id: policy.id.clone(),
2027                    policy_name: policy.name.clone(),
2028                    reason: format!(
2029                        "workflow_template steps[{i}].tool contains control or format characters"
2030                    ),
2031                });
2032            }
2033
2034            // SECURITY (FIND-R50-051): Reject excessively long tool names.
2035            if tool.len() > MAX_TOOL_NAME_LEN {
2036                return Err(PolicyValidationError {
2037                    policy_id: policy.id.clone(),
2038                    policy_name: policy.name.clone(),
2039                    reason: format!(
2040                        "workflow_template steps[{i}].tool is {} bytes, max is {MAX_TOOL_NAME_LEN}",
2041                        tool.len()
2042                    ),
2043                });
2044            }
2045
2046            // SECURITY (FIND-R206-005, FIND-R220-006): normalize_full at compile time.
2047            let tool_lower = normalize_full(tool);
2048
2049            if !seen_tools.insert(tool_lower.clone()) {
2050                return Err(PolicyValidationError {
2051                    policy_id: policy.id.clone(),
2052                    policy_name: policy.name.clone(),
2053                    reason: format!("workflow_template has duplicate step tool '{tool}'"),
2054                });
2055            }
2056
2057            let then_arr = step_obj
2058                .get("then")
2059                .and_then(|v| v.as_array())
2060                .ok_or_else(|| PolicyValidationError {
2061                    policy_id: policy.id.clone(),
2062                    policy_name: policy.name.clone(),
2063                    reason: format!("workflow_template steps[{i}] requires a 'then' array"),
2064                })?;
2065
2066            // SECURITY (FIND-R50-061): Reject excessively large successor arrays
2067            // to prevent inflated Kahn's algorithm in-degree computation.
2068            if then_arr.len() > MAX_SUCCESSORS_PER_STEP {
2069                return Err(PolicyValidationError {
2070                    policy_id: policy.id.clone(),
2071                    policy_name: policy.name.clone(),
2072                    reason: format!(
2073                        "workflow_template steps[{i}].then has {} entries, max is {MAX_SUCCESSORS_PER_STEP}",
2074                        then_arr.len()
2075                    ),
2076                });
2077            }
2078
2079            let mut successors = Vec::with_capacity(then_arr.len());
2080            // SECURITY (FIND-R50-068): Track seen successors to deduplicate.
2081            let mut seen_successors: HashSet<String> = HashSet::new();
2082            for (j, v) in then_arr.iter().enumerate() {
2083                let s = v.as_str().ok_or_else(|| PolicyValidationError {
2084                    policy_id: policy.id.clone(),
2085                    policy_name: policy.name.clone(),
2086                    reason: format!("workflow_template steps[{i}].then[{j}] must be a string"),
2087                })?;
2088
2089                if s.is_empty() {
2090                    return Err(PolicyValidationError {
2091                        policy_id: policy.id.clone(),
2092                        policy_name: policy.name.clone(),
2093                        reason: format!("workflow_template steps[{i}].then[{j}] must not be empty"),
2094                    });
2095                }
2096
2097                // SECURITY (FIND-R112-004): Reject control and Unicode format characters.
2098                if s.chars()
2099                    .any(|c| c.is_control() || vellaveto_types::is_unicode_format_char(c))
2100                {
2101                    return Err(PolicyValidationError {
2102                        policy_id: policy.id.clone(),
2103                        policy_name: policy.name.clone(),
2104                        reason: format!(
2105                            "workflow_template steps[{i}].then[{j}] contains control or format characters"
2106                        ),
2107                    });
2108                }
2109
2110                // SECURITY (FIND-R50-051): Reject excessively long tool names.
2111                if s.len() > MAX_TOOL_NAME_LEN {
2112                    return Err(PolicyValidationError {
2113                        policy_id: policy.id.clone(),
2114                        policy_name: policy.name.clone(),
2115                        reason: format!(
2116                            "workflow_template steps[{i}].then[{j}] is {} bytes, max is {MAX_TOOL_NAME_LEN}",
2117                            s.len()
2118                        ),
2119                    });
2120                }
2121
2122                // SECURITY (FIND-R206-005): Normalize homoglyphs at compile time.
2123                let lowered = normalize_full(s);
2124                // SECURITY (FIND-R50-068): Silently deduplicate successors to prevent
2125                // duplicate entries from inflating Kahn's algorithm in-degree counts.
2126                if seen_successors.insert(lowered.clone()) {
2127                    successors.push(lowered);
2128                }
2129            }
2130
2131            governed_tools.insert(tool_lower.clone());
2132            for succ in &successors {
2133                governed_tools.insert(succ.clone());
2134            }
2135
2136            adjacency.insert(tool_lower, successors);
2137        }
2138
2139        // Compute entry points: governed tools with no predecessors.
2140        let mut has_predecessor: HashSet<&str> = HashSet::new();
2141        for successors in adjacency.values() {
2142            for succ in successors {
2143                has_predecessor.insert(succ.as_str());
2144            }
2145        }
2146        // SECURITY (FIND-R50-016): Sort entry points for deterministic ordering
2147        // across HashSet iterations, ensuring reproducible policy compilation.
2148        let mut entry_points: Vec<String> = governed_tools
2149            .iter()
2150            .filter(|t| !has_predecessor.contains(t.as_str()))
2151            .cloned()
2152            .collect();
2153        entry_points.sort();
2154
2155        if entry_points.is_empty() {
2156            return Err(PolicyValidationError {
2157                policy_id: policy.id.clone(),
2158                policy_name: policy.name.clone(),
2159                reason: "workflow_template has no entry points (implies cycle)".to_string(),
2160            });
2161        }
2162
2163        // Cycle detection via Kahn's algorithm (topological sort).
2164        let mut in_degree: HashMap<&str, usize> = HashMap::new();
2165        for tool in &governed_tools {
2166            in_degree.insert(tool.as_str(), 0);
2167        }
2168        for successors in adjacency.values() {
2169            for succ in successors {
2170                if let Some(deg) = in_degree.get_mut(succ.as_str()) {
2171                    *deg = deg.saturating_add(1);
2172                }
2173            }
2174        }
2175
2176        let mut queue: VecDeque<&str> = VecDeque::new();
2177        for (tool, deg) in &in_degree {
2178            if *deg == 0 {
2179                queue.push_back(tool);
2180            }
2181        }
2182
2183        let mut visited_count: usize = 0;
2184        while let Some(node) = queue.pop_front() {
2185            visited_count += 1;
2186            if let Some(successors) = adjacency.get(node) {
2187                for succ in successors {
2188                    if let Some(deg) = in_degree.get_mut(succ.as_str()) {
2189                        *deg = deg.saturating_sub(1);
2190                        if *deg == 0 {
2191                            queue.push_back(succ.as_str());
2192                        }
2193                    }
2194                }
2195            }
2196        }
2197
2198        if visited_count < governed_tools.len() {
2199            return Err(PolicyValidationError {
2200                policy_id: policy.id.clone(),
2201                policy_name: policy.name.clone(),
2202                reason: "workflow_template contains a cycle".to_string(),
2203            });
2204        }
2205
2206        let deny_reason = format!("Workflow template violation for policy '{}'", policy.name);
2207
2208        Ok(CompiledContextCondition::WorkflowTemplate {
2209            adjacency,
2210            governed_tools,
2211            entry_points,
2212            strict,
2213            deny_reason,
2214        })
2215    }
2216}