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