Skip to main content

awaken_runtime_contract/config_validation/
mod.rs

1use serde::de::DeserializeOwned;
2use serde_json::Value;
3use std::collections::HashSet;
4
5use crate::agent_spec_patch::AgentSpecPatch;
6use crate::config_record::{ConfigRecord, ConfigRecordError, ConfigRecordMerge};
7use crate::contract::lifecycle::StopConditionSpec;
8use crate::registry_spec::{
9    A2A_BACKEND_KIND, AWAKEN_BACKEND_KIND, AgentBackendSpec, AgentSpec, Modality, ModelPoolSpec,
10    ModelSpec, PoolMemberRole, ProviderSpec,
11};
12use crate::skill_allowed_tools::{
13    is_skill_allowed_tool_pattern, parse_skill_allowed_tools, validate_skill_allowed_tool_pattern,
14};
15use crate::skill_spec::SkillSpec;
16
17/// Unknown-field behavior for a serializable config surface.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum UnknownFieldPolicy {
20    Reject,
21    Ignore,
22}
23
24/// `AgentSpec` and `AgentSpecPatch` reject unknown fields.
25pub const AGENT_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
26pub const AGENT_SPEC_PATCH_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
27/// `ProviderSpec`'s serde implementation is intentionally lenient for
28/// read-time compatibility, but config write/validate surfaces reject unknown
29/// fields so operators do not persist silently ignored provider settings.
30pub const PROVIDER_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
31pub const MODEL_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
32pub const MODEL_POOL_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
33pub const SKILL_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
34
35const PROVIDER_SPEC_FIELDS: &[&str] = &[
36    "id",
37    "adapter",
38    "api_key",
39    "base_url",
40    "timeout_secs",
41    "adapter_options",
42];
43const MODEL_SPEC_FIELDS: &[&str] = &[
44    "id",
45    "provider_id",
46    "upstream_model",
47    "context_window",
48    "max_output_tokens",
49    "modalities",
50    "knowledge_cutoff",
51    "input_token_price_per_million_usd",
52    "output_token_price_per_million_usd",
53];
54const MODEL_POOL_SPEC_FIELDS: &[&str] = &["id", "members", "routing", "switch"];
55const SKILL_SPEC_FIELDS: &[&str] = &[
56    "id",
57    "name",
58    "description",
59    "instructions_md",
60    "allowed_tools",
61    "when_to_use",
62    "arguments",
63    "argument_hint",
64    "user_invocable",
65    "model_invocable",
66    "model_override",
67    "context",
68    "paths",
69];
70
71const MAX_STOP_TIMEOUT_SECONDS: u64 = 24 * 60 * 60;
72const MAX_STOP_TOKEN_BUDGET_TOTAL: usize = 100_000_000;
73const MAX_CONTENT_MATCH_PATTERN_CHARS: usize = 1024;
74const MAX_LOOP_DETECTION_WINDOW: usize = 64;
75
76#[derive(Debug, thiserror::Error)]
77pub enum ConfigValidationError {
78    #[error("invalid agent spec: {0}")]
79    AgentSpec(#[source] serde_json::Error),
80    #[error("invalid agent spec patch: {0}")]
81    AgentSpecPatch(#[source] serde_json::Error),
82    #[error("invalid provider spec: {0}")]
83    ProviderSpec(#[source] serde_json::Error),
84    #[error("invalid model spec: {0}")]
85    ModelSpec(#[source] serde_json::Error),
86    #[error("invalid model pool spec: {0}")]
87    ModelPoolSpec(#[source] serde_json::Error),
88    #[error("invalid skill spec: {0}")]
89    SkillSpec(#[source] serde_json::Error),
90    #[error("invalid {surface}: unknown field '{field}'")]
91    UnknownField {
92        surface: &'static str,
93        field: String,
94    },
95    #[error("invalid {surface}: field '{field}' cannot be empty")]
96    EmptyField {
97        surface: &'static str,
98        field: &'static str,
99    },
100    #[error("invalid config record: {0}")]
101    ConfigRecord(#[from] ConfigRecordError),
102    #[error("duplicate model id '{id}'")]
103    DuplicateModelId { id: String },
104    #[error("invalid {surface}: {message}")]
105    Invalid {
106        surface: &'static str,
107        message: String,
108    },
109}
110
111/// Validate and decode an `AgentSpec`.
112///
113/// Unknown fields are rejected by `AgentSpec`'s serde definition.
114pub fn validate_agent_spec(value: Value) -> Result<AgentSpec, ConfigValidationError> {
115    let spec: AgentSpec =
116        serde_json::from_value(value).map_err(ConfigValidationError::AgentSpec)?;
117    validate_backend_spec("agent spec", &spec.backend)?;
118    validate_stop_conditions("agent spec", &spec.stop_conditions)?;
119    Ok(spec)
120}
121
122/// Validate and decode an `AgentSpecPatch`.
123///
124/// Unknown fields are rejected by `AgentSpecPatch`'s serde definition.
125pub fn validate_agent_spec_patch(value: Value) -> Result<AgentSpecPatch, ConfigValidationError> {
126    let patch: AgentSpecPatch =
127        serde_json::from_value(value).map_err(ConfigValidationError::AgentSpecPatch)?;
128    if patch.backend.is_some() && patch.endpoint.is_some() {
129        return Err(ConfigValidationError::Invalid {
130            surface: "agent spec patch",
131            message: "backend and endpoint cannot be patched in the same request".into(),
132        });
133    }
134    if let Some(backend) = &patch.backend {
135        validate_backend_spec("agent spec patch", backend)?;
136    }
137    if let Some(stop_conditions) = &patch.stop_conditions {
138        validate_stop_conditions("agent spec patch", stop_conditions)?;
139    }
140    Ok(patch)
141}
142
143fn validate_backend_spec(
144    surface: &'static str,
145    backend: &AgentBackendSpec,
146) -> Result<(), ConfigValidationError> {
147    backend
148        .validate()
149        .map_err(|error| ConfigValidationError::Invalid {
150            surface,
151            message: error.to_string(),
152        })?;
153    if !matches!(
154        backend.kind.as_str(),
155        AWAKEN_BACKEND_KIND | A2A_BACKEND_KIND
156    ) {
157        return Err(ConfigValidationError::Invalid {
158            surface,
159            message: format!("unsupported backend kind '{}'", backend.kind),
160        });
161    }
162    Ok(())
163}
164
165fn validate_stop_conditions(
166    surface: &'static str,
167    stop_conditions: &[StopConditionSpec],
168) -> Result<(), ConfigValidationError> {
169    for condition in stop_conditions {
170        match condition {
171            StopConditionSpec::Timeout { seconds } if *seconds > MAX_STOP_TIMEOUT_SECONDS => {
172                return Err(ConfigValidationError::Invalid {
173                    surface,
174                    message: format!(
175                        "timeout.seconds must be <= {MAX_STOP_TIMEOUT_SECONDS}, got {seconds}"
176                    ),
177                });
178            }
179            StopConditionSpec::TokenBudget { max_total }
180                if *max_total > MAX_STOP_TOKEN_BUDGET_TOTAL =>
181            {
182                return Err(ConfigValidationError::Invalid {
183                    surface,
184                    message: format!(
185                        "token_budget.max_total must be <= {MAX_STOP_TOKEN_BUDGET_TOTAL}, got {max_total}"
186                    ),
187                });
188            }
189            StopConditionSpec::ContentMatch { pattern } => {
190                reject_max_chars(
191                    surface,
192                    "content_match.pattern",
193                    pattern,
194                    MAX_CONTENT_MATCH_PATTERN_CHARS,
195                )?;
196                if !pattern.is_empty() {
197                    regex::Regex::new(pattern).map_err(|error| ConfigValidationError::Invalid {
198                        surface,
199                        message: format!("content_match.pattern must be valid regex: {error}"),
200                    })?;
201                }
202            }
203            StopConditionSpec::LoopDetection { window } if *window > MAX_LOOP_DETECTION_WINDOW => {
204                return Err(ConfigValidationError::Invalid {
205                    surface,
206                    message: format!(
207                        "loop_detection.window must be <= {MAX_LOOP_DETECTION_WINDOW}, got {window}"
208                    ),
209                });
210            }
211            _ => {}
212        }
213    }
214    Ok(())
215}
216
217/// Validate and decode a `ProviderSpec` for config write surfaces.
218///
219/// Unknown fields are rejected here even though `ProviderSpec` deserialization
220/// remains lenient for read-time compatibility with future/older envelopes.
221/// Adapter support is intentionally not hard-coded in `awaken-contract`;
222/// runtime/server builders validate whether the linked provider backend
223/// supports a non-empty adapter string.
224pub fn validate_provider_spec(value: Value) -> Result<ProviderSpec, ConfigValidationError> {
225    reject_unknown_fields(&value, "provider spec", PROVIDER_SPEC_FIELDS)?;
226    validate_provider_adapter_options(&value)?;
227    let spec: ProviderSpec =
228        serde_json::from_value(value).map_err(ConfigValidationError::ProviderSpec)?;
229    reject_empty("provider spec", "id", &spec.id)?;
230    reject_empty("provider spec", "adapter", &spec.adapter)?;
231    Ok(spec)
232}
233
234fn validate_provider_adapter_options(value: &Value) -> Result<(), ConfigValidationError> {
235    let Some(options) = value
236        .get("adapter_options")
237        .and_then(|value| value.as_object())
238    else {
239        return Ok(());
240    };
241
242    if let Some(schema) = options.get("model_discovery_schema") {
243        let Some(schema) = schema.as_str() else {
244            return Err(ConfigValidationError::Invalid {
245                surface: "provider spec",
246                message: "'adapter_options.model_discovery_schema' must be a string".into(),
247            });
248        };
249        let normalized = schema.to_ascii_lowercase();
250        if !matches!(
251            normalized.as_str(),
252            "openai" | "openai-compatible" | "openrouter" | "gemini" | "google"
253        ) {
254            return Err(ConfigValidationError::Invalid {
255                surface: "provider spec",
256                message: format!(
257                    "'adapter_options.model_discovery_schema' must be one of openai, \
258                     openai-compatible, openrouter, gemini, google; got '{schema}'"
259                ),
260            });
261        }
262    }
263
264    if let Some(auth) = options.get("model_discovery_auth") {
265        let Some(auth) = auth.as_str() else {
266            return Err(ConfigValidationError::Invalid {
267                surface: "provider spec",
268                message: "'adapter_options.model_discovery_auth' must be a string".into(),
269            });
270        };
271        let normalized = auth.to_ascii_lowercase();
272        if !matches!(
273            normalized.as_str(),
274            "bearer"
275                | "authorization-bearer"
276                | "x-goog-api-key"
277                | "google-api-key"
278                | "gemini-api-key"
279                | "none"
280                | "no-auth"
281                | "disabled"
282        ) {
283            return Err(ConfigValidationError::Invalid {
284                surface: "provider spec",
285                message: format!(
286                    "'adapter_options.model_discovery_auth' must be one of bearer, \
287                     authorization-bearer, x-goog-api-key, google-api-key, gemini-api-key, \
288                     none, no-auth, disabled; got '{auth}'"
289                ),
290            });
291        }
292    }
293
294    Ok(())
295}
296
297/// Validate and decode a `ModelSpec` from JSON for config write surfaces.
298///
299/// Rejects unknown fields (read-time deserialization stays lenient), then
300/// delegates every semantic rule to [`validate_model_spec_struct`] so the
301/// JSON path, the runtime builder, and the model registry all share one
302/// definition of a valid `ModelSpec`.
303pub fn validate_model_spec(value: Value) -> Result<ModelSpec, ConfigValidationError> {
304    reject_unknown_fields(&value, "model spec", MODEL_SPEC_FIELDS)?;
305    let spec: ModelSpec =
306        serde_json::from_value(value).map_err(ConfigValidationError::ModelSpec)?;
307    validate_model_spec_struct(&spec)?;
308    Ok(spec)
309}
310
311/// Validate an already-constructed `ModelSpec`.
312///
313/// This is the single source of truth for `ModelSpec` validity. Both the
314/// JSON config surface ([`validate_model_spec`]) and in-memory construction
315/// paths (the runtime builder and model registry) call it so a `ModelSpec`
316/// cannot enter any registry with values the config API would reject.
317pub fn validate_model_spec_struct(spec: &ModelSpec) -> Result<(), ConfigValidationError> {
318    reject_empty("model spec", "id", &spec.id)?;
319    reject_empty("model spec", "provider_id", &spec.provider_id)?;
320    reject_empty("model spec", "upstream_model", &spec.upstream_model)?;
321    if let Some(cutoff) = spec.knowledge_cutoff.as_deref() {
322        reject_empty("model spec", "knowledge_cutoff", cutoff)?;
323        validate_knowledge_cutoff_format(cutoff)?;
324    }
325    reject_zero_capability("context_window", spec.context_window)?;
326    reject_zero_capability("max_output_tokens", spec.max_output_tokens)?;
327    if let (Some(ctx), Some(out)) = (spec.context_window, spec.max_output_tokens)
328        && out > ctx
329    {
330        return Err(ConfigValidationError::Invalid {
331            surface: "model spec",
332            message: format!("max_output_tokens ({out}) must not exceed context_window ({ctx})"),
333        });
334    }
335    reject_invalid_price(
336        "input_token_price_per_million_usd",
337        spec.input_token_price_per_million_usd,
338    )?;
339    reject_invalid_price(
340        "output_token_price_per_million_usd",
341        spec.output_token_price_per_million_usd,
342    )?;
343    reject_duplicate_modalities("input", &spec.modalities.input)?;
344    reject_duplicate_modalities("output", &spec.modalities.output)?;
345    Ok(())
346}
347
348fn reject_invalid_price(field: &str, value: Option<f64>) -> Result<(), ConfigValidationError> {
349    if let Some(price) = value
350        && (!price.is_finite() || price < 0.0)
351    {
352        return Err(ConfigValidationError::Invalid {
353            surface: "model spec",
354            message: format!("'{field}' must be a finite non-negative number, got {price}"),
355        });
356    }
357    Ok(())
358}
359
360fn validate_knowledge_cutoff_format(value: &str) -> Result<(), ConfigValidationError> {
361    let bytes = value.as_bytes();
362    let valid_shape = match bytes.len() {
363        7 => {
364            bytes[4] == b'-'
365                && bytes[..4].iter().all(|b| b.is_ascii_digit())
366                && bytes[5..].iter().all(|b| b.is_ascii_digit())
367        }
368        10 => {
369            bytes[4] == b'-'
370                && bytes[7] == b'-'
371                && bytes[..4].iter().all(|b| b.is_ascii_digit())
372                && bytes[5..7].iter().all(|b| b.is_ascii_digit())
373                && bytes[8..].iter().all(|b| b.is_ascii_digit())
374        }
375        _ => false,
376    };
377    if !valid_shape {
378        return Err(ConfigValidationError::Invalid {
379            surface: "model spec",
380            message: format!(
381                "'knowledge_cutoff' must be ISO date 'YYYY-MM' or 'YYYY-MM-DD', got '{value}'"
382            ),
383        });
384    }
385    let year: i32 = value[..4].parse().unwrap_or(0);
386    let month: u32 = value[5..7].parse().unwrap_or(0);
387    if !(1..=12).contains(&month) {
388        return Err(ConfigValidationError::Invalid {
389            surface: "model spec",
390            message: format!("'knowledge_cutoff' month must be 01-12, got '{value}'"),
391        });
392    }
393    if bytes.len() == 10 {
394        let day: u32 = value[8..10].parse().unwrap_or(0);
395        // Real calendar validity, not just 01-31 shape: rejects 2026-02-31,
396        // 2026-04-31, etc., and honors leap years for February.
397        let max_day = days_in_month(year, month);
398        if day < 1 || day > max_day {
399            return Err(ConfigValidationError::Invalid {
400                surface: "model spec",
401                message: format!(
402                    "'knowledge_cutoff' day must be 01-{max_day:02} for {year:04}-{month:02}, got '{value}'"
403                ),
404            });
405        }
406    }
407    Ok(())
408}
409
410/// Days in a Gregorian month. `month` is assumed already validated to 1..=12.
411fn days_in_month(year: i32, month: u32) -> u32 {
412    match month {
413        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
414        4 | 6 | 9 | 11 => 30,
415        2 => {
416            let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
417            if leap { 29 } else { 28 }
418        }
419        _ => 31,
420    }
421}
422
423fn reject_duplicate_modalities(
424    field: &str,
425    items: &[Modality],
426) -> Result<(), ConfigValidationError> {
427    let mut seen = HashSet::new();
428    for m in items {
429        if !seen.insert(*m) {
430            return Err(ConfigValidationError::Invalid {
431                surface: "model spec",
432                message: format!("'modalities.{field}' contains duplicate '{m:?}'"),
433            });
434        }
435    }
436    Ok(())
437}
438
439fn reject_zero_capability(
440    field: &'static str,
441    value: Option<u32>,
442) -> Result<(), ConfigValidationError> {
443    match value {
444        Some(0) => Err(ConfigValidationError::Invalid {
445            surface: "model spec",
446            message: format!("field '{field}' must be greater than zero"),
447        }),
448        _ => Ok(()),
449    }
450}
451
452/// Validate and decode a `SkillSpec` for config write surfaces.
453pub fn validate_skill_spec(value: Value) -> Result<SkillSpec, ConfigValidationError> {
454    reject_unknown_fields(&value, "skill spec", SKILL_SPEC_FIELDS)?;
455    let spec: SkillSpec =
456        serde_json::from_value(value).map_err(ConfigValidationError::SkillSpec)?;
457    validate_skill_id("skill spec", &spec.id)?;
458    reject_empty("skill spec", "name", &spec.name)?;
459    reject_empty("skill spec", "description", &spec.description)?;
460    reject_empty("skill spec", "instructions_md", &spec.instructions_md)?;
461    reject_max_chars("skill spec", "name", &spec.name, 128)?;
462    reject_max_chars("skill spec", "description", &spec.description, 1024)?;
463    if let Some(value) = &spec.when_to_use {
464        reject_empty("skill spec", "when_to_use", value)?;
465    }
466    if let Some(value) = &spec.argument_hint {
467        reject_empty("skill spec", "argument_hint", value)?;
468    }
469    if let Some(value) = &spec.model_override {
470        reject_empty("skill spec", "model_override", value)?;
471    }
472    let mut argument_names = HashSet::new();
473    for argument in &spec.arguments {
474        reject_empty("skill spec", "arguments.name", &argument.name)?;
475        let argument_name = argument.name.trim();
476        if argument_name != argument.name {
477            return Err(ConfigValidationError::Invalid {
478                surface: "skill spec",
479                message: format!(
480                    "argument name '{}' must not contain surrounding whitespace",
481                    argument.name
482                ),
483            });
484        }
485        if !argument_names.insert(argument_name.to_string()) {
486            return Err(ConfigValidationError::Invalid {
487                surface: "skill spec",
488                message: format!("duplicate argument name '{}'", argument.name),
489            });
490        }
491        if let Some(description) = &argument.description {
492            reject_empty("skill spec", "arguments.description", description)?;
493        }
494    }
495    for tool in &spec.allowed_tools {
496        validate_allowed_tool_token(tool)?;
497    }
498    if !spec.paths.is_empty() {
499        return Err(ConfigValidationError::Invalid {
500            surface: "skill spec",
501            message: "paths are not supported for DB-managed skills until resources are persisted"
502                .into(),
503        });
504    }
505    Ok(spec)
506}
507
508/// Validate and decode a `ModelPoolSpec` from JSON for config write surfaces.
509///
510/// Rejects unknown fields, then delegates every semantic rule to
511/// [`validate_model_pool_spec_struct`] so the JSON path and in-memory
512/// construction share one definition of a valid pool.
513pub fn validate_model_pool_spec(value: Value) -> Result<ModelPoolSpec, ConfigValidationError> {
514    reject_unknown_fields(&value, "model pool spec", MODEL_POOL_SPEC_FIELDS)?;
515    let spec: ModelPoolSpec =
516        serde_json::from_value(value).map_err(ConfigValidationError::ModelPoolSpec)?;
517    validate_model_pool_spec_struct(&spec)?;
518    Ok(spec)
519}
520
521/// Validate an already-constructed `ModelPoolSpec`.
522///
523/// Single source of truth for pool validity. Member `model_id` references are
524/// checked against the surrounding registry elsewhere (resolution); this
525/// validates the pool in isolation.
526pub fn validate_model_pool_spec_struct(spec: &ModelPoolSpec) -> Result<(), ConfigValidationError> {
527    reject_empty("model pool spec", "id", &spec.id)?;
528    if spec.members.is_empty() {
529        return Err(ConfigValidationError::Invalid {
530            surface: "model pool spec",
531            message: "must declare at least one member".into(),
532        });
533    }
534    let mut seen = HashSet::new();
535    let mut has_home_member = false;
536    for member in &spec.members {
537        reject_empty("model pool spec", "members.model_id", &member.model_id)?;
538        if member.weight == Some(0) {
539            return Err(ConfigValidationError::Invalid {
540                surface: "model pool spec",
541                message: format!(
542                    "member '{}' weight must be greater than zero",
543                    member.model_id
544                ),
545            });
546        }
547        if !seen.insert(member.model_id.as_str()) {
548            return Err(ConfigValidationError::Invalid {
549                surface: "model pool spec",
550                message: format!("duplicate member model_id '{}'", member.model_id),
551            });
552        }
553        if member.role == PoolMemberRole::Member {
554            has_home_member = true;
555        }
556    }
557    if !has_home_member {
558        return Err(ConfigValidationError::Invalid {
559            surface: "model pool spec",
560            message: "at least one member must be home-eligible (role 'member'); \
561                      a pool of only 'failover_only' members has no home target"
562                .into(),
563        });
564    }
565    Ok(())
566}
567
568/// Validate that a slice of `ModelSpec` contains no duplicate `id` values.
569///
570/// Returns the first duplicate id encountered in input order. Intended for
571/// collection-holders (e.g. `ManagedConfig.models`) to call at write time so
572/// downstream registry assembly never observes shadowed entries.
573pub fn validate_unique_model_ids(specs: &[ModelSpec]) -> Result<(), ConfigValidationError> {
574    let mut seen = HashSet::new();
575    for spec in specs {
576        if !seen.insert(spec.id.as_str()) {
577            return Err(ConfigValidationError::DuplicateModelId {
578                id: spec.id.clone(),
579            });
580        }
581    }
582    Ok(())
583}
584
585/// Validate and decode a config record envelope, accepting legacy bare specs.
586/// `RecordMeta::user_overrides` must decode as the patch type for `T`.
587pub fn validate_config_record<T>(value: Value) -> Result<ConfigRecord<T>, ConfigValidationError>
588where
589    T: DeserializeOwned + ConfigRecordMerge,
590{
591    crate::config_record::validate_config_record(value).map_err(ConfigValidationError::ConfigRecord)
592}
593
594fn reject_unknown_fields(
595    value: &Value,
596    surface: &'static str,
597    allowed: &[&str],
598) -> Result<(), ConfigValidationError> {
599    let Some(object) = value.as_object() else {
600        return Ok(());
601    };
602    if let Some(field) = object
603        .keys()
604        .find(|field| !allowed.contains(&field.as_str()))
605    {
606        return Err(ConfigValidationError::UnknownField {
607            surface,
608            field: field.clone(),
609        });
610    }
611    Ok(())
612}
613
614fn reject_empty(
615    surface: &'static str,
616    field: &'static str,
617    value: &str,
618) -> Result<(), ConfigValidationError> {
619    if value.trim().is_empty() {
620        Err(ConfigValidationError::EmptyField { surface, field })
621    } else {
622        Ok(())
623    }
624}
625
626fn reject_max_chars(
627    surface: &'static str,
628    field: &'static str,
629    value: &str,
630    max_chars: usize,
631) -> Result<(), ConfigValidationError> {
632    if value.chars().count() > max_chars {
633        Err(ConfigValidationError::Invalid {
634            surface,
635            message: format!("field '{field}' must be <= {max_chars} characters"),
636        })
637    } else {
638        Ok(())
639    }
640}
641
642fn validate_skill_id(surface: &'static str, value: &str) -> Result<(), ConfigValidationError> {
643    let id = value.trim();
644    reject_empty(surface, "id", id)?;
645    if id != value {
646        return Err(ConfigValidationError::Invalid {
647            surface,
648            message: "field 'id' must not contain leading or trailing whitespace".into(),
649        });
650    }
651    let len = id.chars().count();
652    if len > 64 {
653        return Err(ConfigValidationError::Invalid {
654            surface,
655            message: "field 'id' must be <= 64 characters".into(),
656        });
657    }
658    if id != id.to_lowercase() {
659        return Err(ConfigValidationError::Invalid {
660            surface,
661            message: "field 'id' must be lowercase".into(),
662        });
663    }
664    if id.starts_with('-') || id.ends_with('-') || id.contains("--") {
665        return Err(ConfigValidationError::Invalid {
666            surface,
667            message: "field 'id' must not start/end with '-' or contain consecutive '-'".into(),
668        });
669    }
670    if !id.chars().all(|c| c.is_alphanumeric() || c == '-') {
671        return Err(ConfigValidationError::Invalid {
672            surface,
673            message: "field 'id' contains invalid characters".into(),
674        });
675    }
676    Ok(())
677}
678
679fn validate_allowed_tool_token(value: &str) -> Result<(), ConfigValidationError> {
680    let token = value.trim();
681    if token.is_empty() {
682        return Err(ConfigValidationError::Invalid {
683            surface: "skill spec",
684            message: "allowed_tools entries must be non-empty".into(),
685        });
686    }
687    if token != value {
688        return Err(ConfigValidationError::Invalid {
689            surface: "skill spec",
690            message: format!(
691                "allowed_tools entry '{token}' must not contain surrounding whitespace"
692            ),
693        });
694    }
695    let parsed =
696        parse_skill_allowed_tools(token).map_err(|error| ConfigValidationError::Invalid {
697            surface: "skill spec",
698            message: format!("invalid allowed_tools entry '{token}': {error}"),
699        })?;
700    if parsed.len() != 1 || parsed[0].raw != token {
701        return Err(ConfigValidationError::Invalid {
702            surface: "skill spec",
703            message: format!("allowed_tools entry '{token}' must contain exactly one token"),
704        });
705    }
706    if parsed[0].scope.is_some() {
707        return Err(ConfigValidationError::Invalid {
708            surface: "skill spec",
709            message: format!(
710                "scoped allowed_tools entry '{token}' is not supported for DB-managed skills"
711            ),
712        });
713    }
714    if is_skill_allowed_tool_pattern(&parsed[0].tool_id) {
715        validate_skill_allowed_tool_pattern(&parsed[0].tool_id).map_err(|error| {
716            ConfigValidationError::Invalid {
717                surface: "skill spec",
718                message: error.to_string(),
719            }
720        })?;
721    }
722    Ok(())
723}
724
725#[cfg(test)]
726mod tests;