Skip to main content

pmcp_code_mode/
validation.rs

1//! Validation pipeline for Code Mode.
2//!
3//! The pipeline validates code through multiple stages:
4//! 1. Parse (syntax check)
5//! 2. Policy evaluation (PolicyEvaluator trait or basic config checks)
6//! 3. Security analysis
7//! 4. Explanation generation
8//! 5. Token generation
9
10use crate::config::CodeModeConfig;
11use crate::explanation::{ExplanationGenerator, TemplateExplanationGenerator};
12use crate::graphql::{GraphQLQueryInfo, GraphQLValidator};
13use crate::policy::{OperationEntity, PolicyEvaluator};
14use crate::token::{compute_context_hash, HmacTokenGenerator, TokenGenerator, TokenSecret};
15use crate::types::{
16    PolicyViolation, TokenError, UnifiedAction, ValidationError, ValidationMetadata,
17    ValidationResult,
18};
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::Arc;
21use std::time::Instant;
22
23#[cfg(feature = "openapi-code-mode")]
24use crate::javascript::{JavaScriptCodeInfo, JavaScriptValidator};
25
26/// Static flag to ensure the "no policy evaluator" warning is only logged once per process.
27static NO_POLICY_WARNING_LOGGED: AtomicBool = AtomicBool::new(false);
28
29/// Log a warning when Code Mode is enabled without a policy evaluator.
30fn warn_no_policy_configured() {
31    if !NO_POLICY_WARNING_LOGGED.swap(true, Ordering::SeqCst) {
32        tracing::warn!(
33            target: "code_mode",
34            "CODE MODE SECURITY WARNING: Code Mode is enabled but no policy evaluator \
35            is configured. Only basic config checks (allow_mutations, max_depth, etc.) will be \
36            performed. This provides NO real authorization policy evaluation. \
37            For production deployments, configure a policy evaluator (AVP or local Cedar)."
38        );
39    }
40}
41
42/// Context for validation (user, session, schema).
43#[derive(Debug, Clone)]
44pub struct ValidationContext {
45    /// User ID from access token
46    pub user_id: String,
47
48    /// MCP session ID
49    pub session_id: String,
50
51    /// Schema hash for context binding
52    pub schema_hash: String,
53
54    /// Permissions hash for context binding
55    pub permissions_hash: String,
56}
57
58impl ValidationContext {
59    /// Create a new validation context.
60    pub fn new(
61        user_id: impl Into<String>,
62        session_id: impl Into<String>,
63        schema_hash: impl Into<String>,
64        permissions_hash: impl Into<String>,
65    ) -> Self {
66        Self {
67            user_id: user_id.into(),
68            session_id: session_id.into(),
69            schema_hash: schema_hash.into(),
70            permissions_hash: permissions_hash.into(),
71        }
72    }
73
74    /// Compute the combined context hash.
75    pub fn context_hash(&self) -> String {
76        compute_context_hash(&self.schema_hash, &self.permissions_hash)
77    }
78}
79
80/// The validation pipeline that orchestrates all validation stages.
81pub struct ValidationPipeline<
82    T: TokenGenerator = HmacTokenGenerator,
83    E: ExplanationGenerator = TemplateExplanationGenerator,
84> {
85    config: CodeModeConfig,
86    graphql_validator: GraphQLValidator,
87    #[cfg(feature = "openapi-code-mode")]
88    javascript_validator: JavaScriptValidator,
89    token_generator: T,
90    explanation_generator: E,
91    policy_evaluator: Option<Arc<dyn PolicyEvaluator>>,
92}
93
94impl ValidationPipeline<HmacTokenGenerator, TemplateExplanationGenerator> {
95    /// Create a new validation pipeline with default generators.
96    ///
97    /// **Warning**: This constructor does not configure a policy evaluator.
98    /// Only basic config checks will be performed.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`TokenError::SecretTooShort`] if the token secret is shorter
103    /// than [`HmacTokenGenerator::MIN_SECRET_LEN`] (16 bytes).
104    pub fn new(
105        config: CodeModeConfig,
106        token_secret: impl Into<Vec<u8>>,
107    ) -> Result<Self, TokenError> {
108        if config.enabled {
109            warn_no_policy_configured();
110        }
111
112        Ok(Self {
113            graphql_validator: GraphQLValidator::default(),
114            #[cfg(feature = "openapi-code-mode")]
115            javascript_validator: JavaScriptValidator::default()
116                .with_sdk_operations(config.sdk_operations.clone()),
117            token_generator: HmacTokenGenerator::new_from_bytes(token_secret)?,
118            explanation_generator: TemplateExplanationGenerator::new(),
119            policy_evaluator: None,
120            config,
121        })
122    }
123
124    /// Create a new validation pipeline from a [`TokenSecret`].
125    ///
126    /// Convenience constructor for production callers and derive macro generated
127    /// code. Callers never need to call `expose_secret()` directly.
128    ///
129    /// **Security note**: Internally this creates an intermediate `Vec<u8>` copy
130    /// of the secret bytes that is **not** zeroized on drop. For maximum security,
131    /// prefer [`TokenSecret::from_env`] which minimizes secret copies. This
132    /// limitation will be addressed in a future version by adding a
133    /// `HmacTokenGenerator::from_secret_ref` constructor.
134    ///
135    /// **Warning**: This constructor does not configure a policy evaluator.
136    /// Only basic config checks will be performed.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`TokenError::SecretTooShort`] if the token secret is shorter
141    /// than [`HmacTokenGenerator::MIN_SECRET_LEN`] (16 bytes).
142    pub fn from_token_secret(
143        config: CodeModeConfig,
144        secret: &TokenSecret,
145    ) -> Result<Self, TokenError> {
146        Self::new(config, secret.expose_secret().to_vec())
147    }
148
149    /// Create a new validation pipeline with a policy evaluator.
150    ///
151    /// # Errors
152    ///
153    /// Returns [`TokenError::SecretTooShort`] if the token secret is shorter
154    /// than [`HmacTokenGenerator::MIN_SECRET_LEN`] (16 bytes).
155    pub fn with_policy_evaluator(
156        config: CodeModeConfig,
157        token_secret: impl Into<Vec<u8>>,
158        evaluator: Arc<dyn PolicyEvaluator>,
159    ) -> Result<Self, TokenError> {
160        Ok(Self {
161            graphql_validator: GraphQLValidator::default(),
162            #[cfg(feature = "openapi-code-mode")]
163            javascript_validator: JavaScriptValidator::default()
164                .with_sdk_operations(config.sdk_operations.clone()),
165            token_generator: HmacTokenGenerator::new_from_bytes(token_secret)?,
166            explanation_generator: TemplateExplanationGenerator::new(),
167            policy_evaluator: Some(evaluator),
168            config,
169        })
170    }
171
172    /// Create a pipeline from a [`TokenSecret`] with an `Arc` policy evaluator.
173    ///
174    /// Used by derive macro generated code where the policy evaluator is
175    /// stored as `Arc<dyn PolicyEvaluator>` on the parent struct.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`TokenError::SecretTooShort`] if the token secret is shorter
180    /// than [`HmacTokenGenerator::MIN_SECRET_LEN`] (16 bytes).
181    pub fn from_token_secret_with_policy(
182        config: CodeModeConfig,
183        secret: &TokenSecret,
184        evaluator: Arc<dyn PolicyEvaluator>,
185    ) -> Result<Self, TokenError> {
186        Self::with_policy_evaluator(config, secret.expose_secret().to_vec(), evaluator)
187    }
188}
189
190impl<T: TokenGenerator, E: ExplanationGenerator> ValidationPipeline<T, E> {
191    /// Create a pipeline with custom generators.
192    pub fn with_generators(
193        config: CodeModeConfig,
194        token_generator: T,
195        explanation_generator: E,
196    ) -> Self {
197        Self {
198            graphql_validator: GraphQLValidator::default(),
199            #[cfg(feature = "openapi-code-mode")]
200            javascript_validator: JavaScriptValidator::default()
201                .with_sdk_operations(config.sdk_operations.clone()),
202            token_generator,
203            explanation_generator,
204            policy_evaluator: None,
205            config,
206        }
207    }
208
209    /// Set the policy evaluator for this pipeline.
210    pub fn set_policy_evaluator(&mut self, evaluator: Arc<dyn PolicyEvaluator>) {
211        self.policy_evaluator = Some(evaluator);
212    }
213
214    /// Check if a policy evaluator is configured.
215    pub fn has_policy_evaluator(&self) -> bool {
216        self.policy_evaluator.is_some()
217    }
218
219    /// Check mutation and query authorization against config (blocklists, allowlists).
220    ///
221    /// This is the authorization logic shared between the sync and async validation paths.
222    /// It uses the already-parsed `query_info` to avoid re-parsing.
223    fn check_config_authorization(
224        &self,
225        query_info: &GraphQLQueryInfo,
226        start: Instant,
227    ) -> Option<ValidationResult> {
228        // Mutation authorization checks
229        if !query_info.operation_type.is_read_only() {
230            let mutation_name = query_info.root_fields.first().cloned().unwrap_or_default();
231
232            if !self.config.blocked_mutations.is_empty()
233                && self.config.blocked_mutations.contains(&mutation_name)
234            {
235                return Some(ValidationResult::failure(
236                    vec![PolicyViolation::new(
237                        "code_mode",
238                        "blocked_mutation",
239                        &format!("Mutation '{}' is blocked for this server", mutation_name),
240                    )
241                    .with_suggestion("This mutation is in the blocklist and cannot be executed")],
242                    self.build_metadata(query_info, start.elapsed().as_millis() as u64),
243                ));
244            }
245
246            if !self.config.allowed_mutations.is_empty() {
247                if !self.config.allowed_mutations.contains(&mutation_name) {
248                    return Some(ValidationResult::failure(
249                        vec![PolicyViolation::new(
250                            "code_mode",
251                            "mutation_not_allowed",
252                            &format!("Mutation '{}' is not in the allowlist", mutation_name),
253                        )
254                        .with_suggestion(&format!(
255                            "Only these mutations are allowed: {}",
256                            self.config
257                                .allowed_mutations
258                                .iter()
259                                .cloned()
260                                .collect::<Vec<_>>()
261                                .join(", ")
262                        ))],
263                        self.build_metadata(query_info, start.elapsed().as_millis() as u64),
264                    ));
265                }
266            } else if !self.config.allow_mutations {
267                return Some(ValidationResult::failure(
268                    vec![PolicyViolation::new(
269                        "code_mode",
270                        "allow_mutations",
271                        "Mutations are not enabled for this server",
272                    )
273                    .with_suggestion("Only read-only queries are allowed")],
274                    self.build_metadata(query_info, start.elapsed().as_millis() as u64),
275                ));
276            }
277        }
278
279        // Query (read) authorization checks -- mirrors mutation enforcement above
280        if query_info.operation_type.is_read_only() {
281            let query_name = query_info.root_fields.first().cloned().unwrap_or_default();
282
283            if !self.config.blocked_queries.is_empty()
284                && self.config.blocked_queries.contains(&query_name)
285            {
286                return Some(ValidationResult::failure(
287                    vec![PolicyViolation::new(
288                        "code_mode",
289                        "blocked_query",
290                        &format!("Query '{}' is blocked for this server", query_name),
291                    )
292                    .with_suggestion("This query is in the blocklist and cannot be executed")],
293                    self.build_metadata(query_info, start.elapsed().as_millis() as u64),
294                ));
295            }
296
297            if !self.config.allowed_queries.is_empty()
298                && !self.config.allowed_queries.contains(&query_name)
299            {
300                return Some(ValidationResult::failure(
301                    vec![PolicyViolation::new(
302                        "code_mode",
303                        "query_not_allowed",
304                        &format!("Query '{}' is not in the allowlist", query_name),
305                    )
306                    .with_suggestion(&format!(
307                        "Only these queries are allowed: {}",
308                        self.config
309                            .allowed_queries
310                            .iter()
311                            .cloned()
312                            .collect::<Vec<_>>()
313                            .join(", ")
314                    ))],
315                    self.build_metadata(query_info, start.elapsed().as_millis() as u64),
316                ));
317            }
318        }
319
320        None
321    }
322
323    /// Validate a GraphQL query using basic config checks only.
324    pub fn validate_graphql_query(
325        &self,
326        query: &str,
327        context: &ValidationContext,
328    ) -> Result<ValidationResult, ValidationError> {
329        let start = Instant::now();
330
331        if !self.config.enabled {
332            return Err(ValidationError::ConfigError(
333                "Code Mode is not enabled for this server".into(),
334            ));
335        }
336
337        if query.len() > self.config.max_query_length {
338            return Err(ValidationError::SecurityError {
339                message: format!(
340                    "Query length {} exceeds maximum {}",
341                    query.len(),
342                    self.config.max_query_length
343                ),
344                issue: crate::types::SecurityIssueType::HighComplexity,
345            });
346        }
347
348        let query_info = self.graphql_validator.validate(query)?;
349
350        // Config-based authorization checks (mutation blocklist/allowlist, query blocklist/allowlist)
351        if let Some(failure) = self.check_config_authorization(&query_info, start) {
352            return Ok(failure);
353        }
354
355        self.complete_validation(query, &query_info, context, start)
356    }
357
358    /// Validate a GraphQL query using a policy evaluator (async).
359    pub async fn validate_graphql_query_async(
360        &self,
361        query: &str,
362        context: &ValidationContext,
363    ) -> Result<ValidationResult, ValidationError> {
364        let start = Instant::now();
365
366        if !self.config.enabled {
367            return Err(ValidationError::ConfigError(
368                "Code Mode is not enabled for this server".into(),
369            ));
370        }
371
372        if query.len() > self.config.max_query_length {
373            return Err(ValidationError::SecurityError {
374                message: format!(
375                    "Query length {} exceeds maximum {}",
376                    query.len(),
377                    self.config.max_query_length
378                ),
379                issue: crate::types::SecurityIssueType::HighComplexity,
380            });
381        }
382
383        let query_info = self.graphql_validator.validate(query)?;
384
385        // Policy evaluation via trait
386        if let Some(ref evaluator) = self.policy_evaluator {
387            let operation_entity = OperationEntity::from_query_info(&query_info);
388            let server_config = self.config.to_server_config_entity();
389
390            let decision = evaluator
391                .evaluate_operation(&operation_entity, &server_config)
392                .await
393                .map_err(|e| {
394                    ValidationError::InternalError(format!("Policy evaluation error: {}", e))
395                })?;
396
397            if !decision.allowed {
398                let violations: Vec<PolicyViolation> = decision
399                    .determining_policies
400                    .iter()
401                    .map(|policy_id| {
402                        PolicyViolation::new(
403                            "policy",
404                            policy_id.clone(),
405                            "Policy denied the operation",
406                        )
407                    })
408                    .collect();
409
410                return Ok(ValidationResult::failure(
411                    violations,
412                    self.build_metadata(&query_info, start.elapsed().as_millis() as u64),
413                ));
414            }
415        } else {
416            warn_no_policy_configured();
417            tracing::debug!(
418                target: "code_mode",
419                "Falling back to basic config checks (no policy evaluator configured)"
420            );
421            // Reuse already-parsed query_info instead of re-parsing via validate_graphql_query
422            if let Some(failure) = self.check_config_authorization(&query_info, start) {
423                return Ok(failure);
424            }
425        }
426
427        self.complete_validation(query, &query_info, context, start)
428    }
429
430    /// Complete validation after policy check passes.
431    fn complete_validation(
432        &self,
433        query: &str,
434        query_info: &GraphQLQueryInfo,
435        context: &ValidationContext,
436        start: Instant,
437    ) -> Result<ValidationResult, ValidationError> {
438        let security_analysis = self.graphql_validator.analyze_security(query_info);
439        let risk_level = security_analysis.assess_risk();
440
441        if security_analysis
442            .potential_issues
443            .iter()
444            .any(|i| i.is_critical())
445        {
446            let violations: Vec<PolicyViolation> = security_analysis
447                .potential_issues
448                .iter()
449                .filter(|i| i.is_critical())
450                .map(|i| {
451                    PolicyViolation::new("security", format!("{:?}", i.issue_type), &i.message)
452                })
453                .collect();
454
455            return Ok(ValidationResult::failure(
456                violations,
457                self.build_metadata(query_info, start.elapsed().as_millis() as u64),
458            ));
459        }
460
461        let explanation = self
462            .explanation_generator
463            .explain_graphql(query_info, &security_analysis);
464
465        let context_hash = context.context_hash();
466        let token = self.token_generator.generate(
467            query,
468            &context.user_id,
469            &context.session_id,
470            self.config.server_id(),
471            &context_hash,
472            risk_level,
473            self.config.token_ttl_seconds,
474        );
475
476        let token_string = token.encode().map_err(|e| {
477            ValidationError::InternalError(format!("Failed to encode token: {}", e))
478        })?;
479
480        let operation_type_str = format!("{:?}", query_info.operation_type).to_lowercase();
481        let mutation_name = query_info.operation_name.as_deref();
482        let inferred_action = UnifiedAction::from_graphql(&operation_type_str, mutation_name);
483        let action = UnifiedAction::resolve(
484            inferred_action,
485            &self.config.action_tags,
486            query_info.operation_name.as_deref().unwrap_or(""),
487        );
488
489        let metadata = ValidationMetadata {
490            is_read_only: query_info.operation_type.is_read_only(),
491            estimated_rows: security_analysis.estimated_rows,
492            accessed_types: security_analysis.tables_accessed.iter().cloned().collect(),
493            accessed_fields: security_analysis.fields_accessed.iter().cloned().collect(),
494            has_aggregation: security_analysis.has_aggregation,
495            code_type: Some(self.graphql_validator.to_code_type(query_info)),
496            action: Some(action),
497            validation_time_ms: start.elapsed().as_millis() as u64,
498        };
499
500        let mut result = ValidationResult::success(explanation, risk_level, token_string, metadata);
501
502        for issue in &security_analysis.potential_issues {
503            if !issue.is_critical() {
504                result.warnings.push(issue.message.clone());
505            }
506        }
507
508        Ok(result)
509    }
510
511    /// Build metadata from query info.
512    fn build_metadata(
513        &self,
514        query_info: &GraphQLQueryInfo,
515        validation_time_ms: u64,
516    ) -> ValidationMetadata {
517        let operation_type_str = format!("{:?}", query_info.operation_type).to_lowercase();
518        let mutation_name = query_info.operation_name.as_deref();
519        let inferred_action = UnifiedAction::from_graphql(&operation_type_str, mutation_name);
520        let action = UnifiedAction::resolve(
521            inferred_action,
522            &self.config.action_tags,
523            query_info.operation_name.as_deref().unwrap_or(""),
524        );
525
526        ValidationMetadata {
527            is_read_only: query_info.operation_type.is_read_only(),
528            estimated_rows: None,
529            accessed_types: query_info.types_accessed.iter().cloned().collect(),
530            accessed_fields: query_info.fields_accessed.iter().cloned().collect(),
531            has_aggregation: false,
532            code_type: Some(self.graphql_validator.to_code_type(query_info)),
533            action: Some(action),
534            validation_time_ms,
535        }
536    }
537
538    /// Validate JavaScript code for OpenAPI Code Mode.
539    #[cfg(feature = "openapi-code-mode")]
540    pub fn validate_javascript_code(
541        &self,
542        code: &str,
543        context: &ValidationContext,
544    ) -> Result<ValidationResult, ValidationError> {
545        let start = Instant::now();
546
547        if !self.config.enabled {
548            return Err(ValidationError::ConfigError(
549                "Code Mode is not enabled for this server".into(),
550            ));
551        }
552
553        if code.len() > self.config.max_query_length {
554            return Err(ValidationError::SecurityError {
555                message: format!(
556                    "Code length {} exceeds maximum {}",
557                    code.len(),
558                    self.config.max_query_length
559                ),
560                issue: crate::types::SecurityIssueType::HighComplexity,
561            });
562        }
563
564        let code_info = self.javascript_validator.validate(code)?;
565
566        if !code_info.is_read_only {
567            for method in &code_info.methods_used {
568                if !self.config.openapi_blocked_writes.is_empty()
569                    && self.config.openapi_blocked_writes.contains(method)
570                {
571                    return Ok(ValidationResult::failure(
572                        vec![PolicyViolation::new(
573                            "code_mode",
574                            "blocked_method",
575                            &format!("HTTP method '{}' is blocked for this server", method),
576                        )
577                        .with_suggestion("This method is in the blocklist and cannot be used")],
578                        self.build_js_metadata(&code_info, start.elapsed().as_millis() as u64),
579                    ));
580                }
581            }
582
583            if !self.config.openapi_allowed_writes.is_empty() {
584                tracing::debug!(
585                    target: "code_mode",
586                    "Skipping method-level check - policy evaluator will check operation allowlist ({} entries)",
587                    self.config.openapi_allowed_writes.len()
588                );
589            } else if !self.config.openapi_allow_writes {
590                return Ok(ValidationResult::failure(
591                    vec![PolicyViolation::new(
592                        "code_mode",
593                        "allow_mutations",
594                        "Write HTTP methods (POST, PUT, DELETE, PATCH) are not enabled for this server",
595                    )
596                    .with_suggestion("Only read-only methods (GET, HEAD, OPTIONS) are allowed. Contact your administrator to enable write operations.")],
597                    self.build_js_metadata(&code_info, start.elapsed().as_millis() as u64),
598                ));
599            }
600        }
601
602        self.complete_js_validation(code, &code_info, context, start)
603    }
604
605    /// Complete JavaScript validation after policy checks pass.
606    #[cfg(feature = "openapi-code-mode")]
607    fn complete_js_validation(
608        &self,
609        code: &str,
610        code_info: &JavaScriptCodeInfo,
611        context: &ValidationContext,
612        start: Instant,
613    ) -> Result<ValidationResult, ValidationError> {
614        let security_analysis = self.javascript_validator.analyze_security(code_info);
615        let risk_level = security_analysis.assess_risk();
616
617        if security_analysis
618            .potential_issues
619            .iter()
620            .any(|i| i.is_critical())
621        {
622            let violations: Vec<PolicyViolation> = security_analysis
623                .potential_issues
624                .iter()
625                .filter(|i| i.is_critical())
626                .map(|i| {
627                    PolicyViolation::new("security", format!("{:?}", i.issue_type), &i.message)
628                })
629                .collect();
630
631            return Ok(ValidationResult::failure(
632                violations,
633                self.build_js_metadata(code_info, start.elapsed().as_millis() as u64),
634            ));
635        }
636
637        let explanation = self.generate_js_explanation(code_info, &security_analysis);
638
639        let context_hash = context.context_hash();
640        let token = self.token_generator.generate(
641            code,
642            &context.user_id,
643            &context.session_id,
644            self.config.server_id(),
645            &context_hash,
646            risk_level,
647            self.config.token_ttl_seconds,
648        );
649
650        let token_string = token.encode().map_err(|e| {
651            ValidationError::InternalError(format!("Failed to encode token: {}", e))
652        })?;
653
654        let metadata = self.build_js_metadata(code_info, start.elapsed().as_millis() as u64);
655
656        let mut result = ValidationResult::success(explanation, risk_level, token_string, metadata);
657
658        for issue in &security_analysis.potential_issues {
659            if !issue.is_critical() {
660                result.warnings.push(issue.message.clone());
661            }
662        }
663
664        Ok(result)
665    }
666
667    /// Build metadata from JavaScript code info.
668    #[cfg(feature = "openapi-code-mode")]
669    fn build_js_metadata(
670        &self,
671        code_info: &JavaScriptCodeInfo,
672        validation_time_ms: u64,
673    ) -> ValidationMetadata {
674        let action = if !code_info.api_calls.is_empty() {
675            let mut max_action = UnifiedAction::Read;
676            for call in &code_info.api_calls {
677                let method_str = format!("{:?}", call.method);
678                let inferred = UnifiedAction::from_http_method(&method_str);
679                match (&max_action, &inferred) {
680                    (UnifiedAction::Read, _) => max_action = inferred,
681                    (UnifiedAction::Write, UnifiedAction::Delete | UnifiedAction::Admin) => {
682                        max_action = inferred
683                    },
684                    (UnifiedAction::Delete, UnifiedAction::Admin) => max_action = inferred,
685                    _ => {},
686                }
687            }
688            Some(max_action)
689        } else if code_info.is_read_only {
690            Some(UnifiedAction::Read)
691        } else {
692            Some(UnifiedAction::Write)
693        };
694
695        ValidationMetadata {
696            is_read_only: code_info.is_read_only,
697            estimated_rows: None,
698            accessed_types: code_info.endpoints_accessed.iter().cloned().collect(),
699            accessed_fields: code_info.methods_used.iter().cloned().collect(),
700            has_aggregation: false,
701            code_type: Some(self.javascript_validator.to_code_type(code_info)),
702            action,
703            validation_time_ms,
704        }
705    }
706
707    /// Generate a human-readable explanation for JavaScript code.
708    #[cfg(feature = "openapi-code-mode")]
709    fn generate_js_explanation(
710        &self,
711        code_info: &JavaScriptCodeInfo,
712        security_analysis: &crate::types::SecurityAnalysis,
713    ) -> String {
714        let mut parts = Vec::new();
715
716        if code_info.is_read_only {
717            parts.push("This code will perform read-only API requests.".to_string());
718        } else {
719            parts.push("This code will perform API requests that may modify data.".to_string());
720        }
721
722        if !code_info.api_calls.is_empty() {
723            let call_descriptions: Vec<String> = code_info
724                .api_calls
725                .iter()
726                .map(|call| format!("{:?} {}", call.method, call.path))
727                .collect();
728
729            if call_descriptions.len() <= 3 {
730                parts.push(format!("API calls: {}", call_descriptions.join(", ")));
731            } else {
732                parts.push(format!(
733                    "API calls: {} and {} more",
734                    call_descriptions[..2].join(", "),
735                    call_descriptions.len() - 2
736                ));
737            }
738        }
739
740        if code_info.loop_count > 0 {
741            if code_info.all_loops_bounded {
742                parts.push(format!(
743                    "Contains {} bounded loop(s).",
744                    code_info.loop_count
745                ));
746            } else {
747                parts.push(format!(
748                    "Contains {} loop(s) - ensure they are properly bounded.",
749                    code_info.loop_count
750                ));
751            }
752        }
753
754        let risk = security_analysis.assess_risk();
755        parts.push(format!("Risk: {}", risk));
756
757        parts.join(" ")
758    }
759
760    /// Check if a validation result should be auto-approved.
761    pub fn should_auto_approve(&self, result: &ValidationResult) -> bool {
762        result.is_valid && self.config.should_auto_approve(result.risk_level)
763    }
764
765    /// Get the config.
766    pub fn config(&self) -> &CodeModeConfig {
767        &self.config
768    }
769
770    /// Get the token generator.
771    pub fn token_generator(&self) -> &T {
772        &self.token_generator
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use crate::types::RiskLevel;
780
781    fn test_pipeline() -> ValidationPipeline {
782        ValidationPipeline::new(CodeModeConfig::enabled(), b"test-secret-key!".to_vec()).unwrap()
783    }
784
785    fn test_context() -> ValidationContext {
786        ValidationContext::new("user-123", "session-456", "schema-hash", "perms-hash")
787    }
788
789    #[test]
790    fn test_simple_query_validation() {
791        let pipeline = test_pipeline();
792        let ctx = test_context();
793
794        let result = pipeline
795            .validate_graphql_query("query { users { id name } }", &ctx)
796            .unwrap();
797
798        assert!(result.is_valid);
799        assert!(result.approval_token.is_some());
800        assert_eq!(result.risk_level, RiskLevel::Low);
801        assert!(result.explanation.contains("read"));
802    }
803
804    #[test]
805    fn test_mutation_blocked() {
806        let mut config = CodeModeConfig::enabled();
807        config.allow_mutations = false;
808
809        let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
810        let ctx = test_context();
811
812        let result = pipeline
813            .validate_graphql_query("mutation { createUser(name: \"test\") { id } }", &ctx)
814            .unwrap();
815
816        assert!(!result.is_valid);
817        assert!(result
818            .violations
819            .iter()
820            .any(|v| v.rule == "allow_mutations"));
821    }
822
823    #[test]
824    fn test_disabled_code_mode() {
825        let config = CodeModeConfig::default();
826        let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
827        let ctx = test_context();
828
829        let result = pipeline.validate_graphql_query("query { users { id } }", &ctx);
830
831        assert!(matches!(result, Err(ValidationError::ConfigError(_))));
832    }
833
834    #[test]
835    fn test_auto_approve_low_risk() {
836        let pipeline = test_pipeline();
837        let ctx = test_context();
838
839        let result = pipeline
840            .validate_graphql_query("query { users { id } }", &ctx)
841            .unwrap();
842
843        assert!(pipeline.should_auto_approve(&result));
844    }
845
846    #[test]
847    fn test_context_hash() {
848        let ctx = test_context();
849        let hash1 = ctx.context_hash();
850
851        let ctx2 =
852            ValidationContext::new("user-123", "session-456", "different-schema", "perms-hash");
853        let hash2 = ctx2.context_hash();
854
855        assert_ne!(hash1, hash2);
856    }
857
858    #[test]
859    fn test_blocked_query_rejected() {
860        let mut config = CodeModeConfig::enabled();
861        config.blocked_queries.insert("users".to_string());
862
863        let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
864        let ctx = test_context();
865
866        let result = pipeline
867            .validate_graphql_query("query { users { id } }", &ctx)
868            .unwrap();
869
870        assert!(!result.is_valid);
871        assert!(result.violations.iter().any(|v| v.rule == "blocked_query"));
872    }
873
874    #[test]
875    fn test_allowed_queries_enforced() {
876        let mut config = CodeModeConfig::enabled();
877        config.allowed_queries.insert("orders".to_string());
878
879        let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
880        let ctx = test_context();
881
882        // "users" is not in the allowlist -- should be rejected
883        let result = pipeline
884            .validate_graphql_query("query { users { id } }", &ctx)
885            .unwrap();
886
887        assert!(!result.is_valid);
888        assert!(result
889            .violations
890            .iter()
891            .any(|v| v.rule == "query_not_allowed"));
892    }
893}