Skip to main content

ai_agent/utils/settings/
validation.rs

1// Source: ~/claudecode/openclaudecode/src/utils/settings/validation.ts
2//! Settings validation - parses and validates settings JSON files.
3
4use serde_json::Value;
5
6use crate::services::mcp::ConfigScope;
7use crate::utils::settings::permission_validation::validate_permission_rule;
8
9/// Field path in dot notation (e.g., "permissions.defaultMode")
10pub type FieldPath = String;
11
12/// A validation error with location and context.
13#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
14pub struct ValidationError {
15    pub file: Option<String>,
16    pub path: FieldPath,
17    pub message: String,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub expected: Option<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub invalid_value: Option<serde_json::Value>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub suggestion: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub doc_link: Option<String>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub mcp_error_metadata: Option<McpErrorMetadata>,
28}
29
30/// MCP-specific metadata attached to validation errors.
31#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
32pub struct McpErrorMetadata {
33    pub scope: ConfigScope,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub server_name: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub severity: Option<McpErrorSeverity>,
38}
39
40#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum McpErrorSeverity {
43    Fatal,
44    Warning,
45}
46
47/// Validated settings with any accumulated errors.
48#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
49pub struct SettingsWithErrors {
50    pub settings: Value,
51    pub errors: Vec<ValidationError>,
52}
53
54/// Gets the Rust type name for a JSON value.
55fn received_type(v: &Value) -> &str {
56    match v {
57        Value::Null => "null",
58        Value::Bool(_) => "boolean",
59        Value::Number(_) => "number",
60        Value::String(_) => "string",
61        Value::Array(_) => "array",
62        Value::Object(_) => "object",
63    }
64}
65
66/// Validates that settings JSON conforms to expected structure.
67/// Returns a SettingsWithErrors containing the parsed JSON and any validation errors.
68pub fn validate_settings_json(data: &Value) -> SettingsWithErrors {
69    let mut errors = Vec::new();
70
71    // Validate top-level is an object
72    if !data.is_object() {
73        return SettingsWithErrors {
74            settings: Value::Object(serde_json::Map::new()),
75            errors: vec![ValidationError {
76                path: "".into(),
77                message: "Settings must be a JSON object".into(),
78                expected: Some("object".into()),
79                invalid_value: Some(data.clone()),
80                suggestion: None,
81                doc_link: None,
82                file: None,
83                mcp_error_metadata: None,
84            }],
85        };
86    }
87
88    // Validate permissions section
89    if let Some(perms) = data.get("permissions") {
90        if !perms.is_object() {
91            errors.push(ValidationError {
92                path: "permissions".into(),
93                message: "Expected permissions to be an object".into(),
94                expected: Some("object".into()),
95                invalid_value: Some(received_type(perms).into()),
96                suggestion: None,
97                doc_link: None,
98                file: None,
99                mcp_error_metadata: None,
100            });
101        } else if let Some(perms_obj) = perms.as_object() {
102            // Validate defaultMode
103            if let Some(mode) = perms_obj.get("defaultMode") {
104                if let Some(mode_str) = mode.as_str() {
105                    match mode_str {
106                        "allow" | "deny" | "ask" => {} // valid
107                        _ => {
108                            errors.push(ValidationError {
109                                path: "permissions.defaultMode".into(),
110                                message: format!("Invalid permission mode: \"{}\"", mode_str),
111                                expected: Some("\"allow\", \"deny\", or \"ask\"".into()),
112                                invalid_value: Some(mode.clone()),
113                                suggestion: Some("Use \"allow\", \"deny\", or \"ask\"".into()),
114                                doc_link: None,
115                                file: None,
116                                mcp_error_metadata: None,
117                            });
118                        }
119                    }
120                } else {
121                    errors.push(ValidationError {
122                        path: "permissions.defaultMode".into(),
123                        message: "Expected defaultMode to be a string".into(),
124                        expected: Some("string".into()),
125                        invalid_value: Some(received_type(mode).into()),
126                        suggestion: None,
127                        doc_link: None,
128                        file: None,
129                        mcp_error_metadata: None,
130                    });
131                }
132            }
133
134            // Validate permission rule arrays
135            for key in ["allow", "deny", "ask"] {
136                if let Some(rules) = perms_obj.get(key) {
137                    if !rules.is_array() {
138                        errors.push(ValidationError {
139                            path: format!("permissions.{}", key),
140                            message: format!("Expected permissions.{} to be an array", key),
141                            expected: Some("array".into()),
142                            invalid_value: Some(received_type(rules).into()),
143                            suggestion: None,
144                            doc_link: None,
145                            file: None,
146                            mcp_error_metadata: None,
147                        });
148                    } else if let Some(rules_arr) = rules.as_array() {
149                        for (idx, rule) in rules_arr.iter().enumerate() {
150                            if !rule.is_string() {
151                                errors.push(ValidationError {
152                                    path: format!("permissions.{}.{}", key, idx),
153                                    message: format!(
154                                        "Non-string value in {} array was removed",
155                                        key
156                                    ),
157                                    expected: Some("string".into()),
158                                    invalid_value: Some(rule.clone()),
159                                    suggestion: None,
160                                    doc_link: None,
161                                    file: None,
162                                    mcp_error_metadata: None,
163                                });
164                            } else if let Some(rule_str) = rule.as_str() {
165                                let result = validate_permission_rule(rule_str);
166                                if !result.valid {
167                                    errors.push(ValidationError {
168                                        path: format!("permissions.{}.{}", key, idx),
169                                        message: format!(
170                                            "Invalid permission rule \"{}\": {}",
171                                            rule_str,
172                                            result.error.unwrap_or_else(|| "unknown error".into())
173                                        ),
174                                        expected: None,
175                                        suggestion: result.suggestion,
176                                        invalid_value: Some(rule.clone()),
177                                        doc_link: None,
178                                        file: None,
179                                        mcp_error_metadata: None,
180                                    });
181                                }
182                            }
183                        }
184                    }
185                }
186            }
187
188            // Validate additionalDirectories
189            if let Some(dirs) = perms_obj.get("additionalDirectories") {
190                if !dirs.is_array() {
191                    errors.push(ValidationError {
192                        path: "permissions.additionalDirectories".into(),
193                        message: "Expected additionalDirectories to be an array".into(),
194                        expected: Some("array".into()),
195                        invalid_value: Some(received_type(dirs).into()),
196                        suggestion: None,
197                        doc_link: None,
198                        file: None,
199                        mcp_error_metadata: None,
200                    });
201                } else if let Some(dirs_arr) = dirs.as_array() {
202                    for (idx, dir) in dirs_arr.iter().enumerate() {
203                        if !dir.is_string() {
204                            errors.push(ValidationError {
205                                path: format!("permissions.additionalDirectories.{}", idx),
206                                message: "Non-string value in additionalDirectories".into(),
207                                expected: Some("string".into()),
208                                invalid_value: Some(dir.clone()),
209                                suggestion: None,
210                                doc_link: None,
211                                file: None,
212                                mcp_error_metadata: None,
213                            });
214                        }
215                    }
216                }
217            }
218        }
219    }
220
221    // Validate env section
222    if let Some(env) = data.get("env") {
223        if !env.is_object() {
224            errors.push(ValidationError {
225                path: "env".into(),
226                message: "Expected env to be an object".into(),
227                expected: Some("object".into()),
228                invalid_value: Some(received_type(env).into()),
229                suggestion: None,
230                doc_link: None,
231                file: None,
232                mcp_error_metadata: None,
233            });
234        } else if let Some(env_obj) = env.as_object() {
235            for (key, val) in env_obj {
236                if !val.is_string() && val.is_null() {
237                    // null values are allowed for removing env vars
238                    continue;
239                }
240                if !val.is_string() {
241                    errors.push(ValidationError {
242                        path: format!("env.{}", key),
243                        message: format!("Expected env.{} to be a string or null", key),
244                        expected: Some("string or null".into()),
245                        invalid_value: Some(received_type(val).into()),
246                        suggestion: None,
247                        doc_link: None,
248                        file: None,
249                        mcp_error_metadata: None,
250                    });
251                }
252            }
253        }
254    }
255
256    // Validate model section
257    if let Some(model) = data.get("model") {
258        if let Some(model_obj) = model.as_object() {
259            if let Some(name) = model_obj.get("name") {
260                if !name.is_string() {
261                    errors.push(ValidationError {
262                        path: "model.name".into(),
263                        message: "Expected model.name to be a string".into(),
264                        expected: Some("string".into()),
265                        invalid_value: Some(received_type(name).into()),
266                        suggestion: None,
267                        doc_link: None,
268                        file: None,
269                        mcp_error_metadata: None,
270                    });
271                }
272            }
273            if let Some(max_tokens) = model_obj.get("maxTokens") {
274                if !max_tokens.is_number() {
275                    errors.push(ValidationError {
276                        path: "model.maxTokens".into(),
277                        message: "Expected model.maxTokens to be a number".into(),
278                        expected: Some("number".into()),
279                        invalid_value: Some(received_type(max_tokens).into()),
280                        suggestion: None,
281                        doc_link: None,
282                        file: None,
283                        mcp_error_metadata: None,
284                    });
285                }
286            }
287        } else if !model.is_string() {
288            errors.push(ValidationError {
289                path: "model".into(),
290                message: "Expected model to be a string or object".into(),
291                expected: Some("string or object".into()),
292                invalid_value: Some(received_type(model).into()),
293                suggestion: None,
294                doc_link: None,
295                file: None,
296                mcp_error_metadata: None,
297            });
298        }
299    }
300
301    // Validate hooks section
302    if let Some(hooks) = data.get("hooks") {
303        if !hooks.is_object() {
304            errors.push(ValidationError {
305                path: "hooks".into(),
306                message: "Expected hooks to be an object".into(),
307                expected: Some("object".into()),
308                invalid_value: Some(received_type(hooks).into()),
309                suggestion: None,
310                doc_link: None,
311                file: None,
312                mcp_error_metadata: None,
313            });
314        }
315    }
316
317    SettingsWithErrors {
318        settings: data.clone(),
319        errors,
320    }
321}
322
323/// Validates settings file content string, parsing JSON and validating structure.
324pub fn validate_settings_file_content(content: &str) -> SettingsWithErrors {
325    match serde_json::from_str(content) {
326        Ok(json) => validate_settings_json(&json),
327        Err(e) => SettingsWithErrors {
328            settings: Value::Object(serde_json::Map::new()),
329            errors: vec![ValidationError {
330                path: "".into(),
331                message: format!("Invalid JSON: {}", e),
332                expected: Some("valid JSON object".into()),
333                invalid_value: None,
334                suggestion: Some("Check for trailing commas, missing quotes, or mismatched braces"
335                    .into()),
336                doc_link: None,
337                file: None,
338                mcp_error_metadata: None,
339            }],
340        },
341    }
342}
343
344/// Filters invalid permission rules from raw parsed JSON data before schema validation.
345/// Returns warnings for each filtered rule.
346pub fn filter_invalid_permission_rules(
347    data: &Value,
348    file_path: &str,
349) -> Vec<ValidationError> {
350    let mut warnings = Vec::new();
351
352    let Some(obj) = data.as_object() else {
353        return warnings;
354    };
355    let Some(perms) = obj.get("permissions") else {
356        return warnings;
357    };
358    let Some(perms_obj) = perms.as_object() else {
359        return warnings;
360    };
361
362    for key in ["allow", "deny", "ask"] {
363        let Some(rules) = perms_obj.get(key) else {
364            continue;
365        };
366        let Some(rules_arr) = rules.as_array() else {
367            continue;
368        };
369
370        let valid_rules: Vec<Value> = rules_arr
371            .iter()
372            .filter_map(|rule| {
373                if !rule.is_string() {
374                    warnings.push(ValidationError {
375                        file: Some(file_path.to_string()),
376                        path: format!("permissions.{}", key),
377                        message: format!("Non-string value in {} array was removed", key),
378                        expected: Some("string".into()),
379                        invalid_value: Some(rule.clone()),
380                        suggestion: None,
381                        doc_link: None,
382                        mcp_error_metadata: None,
383                    });
384                    return None;
385                }
386                if let Some(rule_str) = rule.as_str() {
387                    let result = validate_permission_rule(rule_str);
388                    if !result.valid {
389                        let mut msg = format!("Invalid permission rule \"{}\" was skipped", rule_str);
390                        if let Some(ref err) = result.error {
391                            msg += ": ";
392                            msg += err;
393                        }
394                        warnings.push(ValidationError {
395                            file: Some(file_path.to_string()),
396                            path: format!("permissions.{}", key),
397                            message: msg,
398                            expected: None,
399                            invalid_value: Some(rule.clone()),
400                            suggestion: result.suggestion,
401                            doc_link: None,
402                            mcp_error_metadata: None,
403                        });
404                        return None;
405                    }
406                }
407                Some(rule.clone())
408            })
409            .collect();
410
411        // Replace the rules array with only valid rules (conceptual - caller handles mutation)
412        let _ = valid_rules;
413    }
414
415    warnings
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_validate_empty_object() {
424        let result = validate_settings_json(&Value::Object(serde_json::Map::new()));
425        assert!(result.errors.is_empty());
426    }
427
428    #[test]
429    fn test_validate_not_object() {
430        let result = validate_settings_json(&Value::String("not an object".into()));
431        assert!(!result.errors.is_empty());
432    }
433
434    #[test]
435    fn test_validate_invalid_mode() {
436        let json = serde_json::json!({
437            "permissions": {
438                "defaultMode": "invalid"
439            }
440        });
441        let result = validate_settings_json(&json);
442        assert!(!result.errors.is_empty());
443        assert_eq!(result.errors[0].path, "permissions.defaultMode");
444    }
445
446    #[test]
447    fn test_validate_valid_mode() {
448        for mode in &["allow", "deny", "ask"] {
449            let json = serde_json::json!({
450                "permissions": {
451                    "defaultMode": mode
452                }
453            });
454            let result = validate_settings_json(&json);
455            assert!(
456                result.errors.is_empty(),
457                "mode '{}' should be valid",
458                mode
459            );
460        }
461    }
462
463    #[test]
464    fn test_validate_invalid_permission_rule() {
465        let json = serde_json::json!({
466            "permissions": {
467                "allow": ["read(*.ts)", "Read()"]
468            }
469        });
470        let result = validate_settings_json(&json);
471        // lowercase 'read' should fail, empty parens should fail
472        assert!(!result.errors.is_empty());
473    }
474
475    #[test]
476    fn test_validate_valid_settings() {
477        let json = serde_json::json!({
478            "permissions": {
479                "defaultMode": "allow",
480                "allow": ["Read(*.ts)", "Bash"]
481            },
482            "env": {
483                "DEBUG": "true"
484            }
485        });
486        let result = validate_settings_json(&json);
487        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
488    }
489
490    #[test]
491    fn test_validate_invalid_json() {
492        let result = validate_settings_file_content("{ invalid json }");
493        assert!(!result.errors.is_empty());
494    }
495
496    #[test]
497    fn test_validate_valid_json_string() {
498        let result = validate_settings_file_content(r#"{"permissions": {"defaultMode": "allow"}}"#);
499        assert!(result.errors.is_empty());
500    }
501
502    #[test]
503    fn test_filter_invalid_permission_rules() {
504        let json = serde_json::json!({
505            "permissions": {
506                "allow": ["Read(*.ts)", "read()", 123]
507            }
508        });
509        let warnings = filter_invalid_permission_rules(&json, "test.json");
510        // "read()" lowercase + empty parens, and 123 non-string
511        assert!(!warnings.is_empty());
512    }
513
514    #[test]
515    fn test_env_validation() {
516        let json = serde_json::json!({
517            "env": {
518                "VALID": "string",
519                "ALSO_VALID": null,
520                "INVALID": 123
521            }
522        });
523        let result = validate_settings_json(&json);
524        assert!(!result.errors.is_empty());
525        assert_eq!(result.errors[0].path, "env.INVALID");
526    }
527
528    #[test]
529    fn test_model_validation() {
530        let json = serde_json::json!({
531            "model": {
532                "name": 123
533            }
534        });
535        let result = validate_settings_json(&json);
536        assert!(!result.errors.is_empty());
537    }
538}