Skip to main content

everruns_core/capabilities/
declarative.rs

1use super::{
2    CapabilityStatus, MountAccess, MountPoint, RiskLevel, SKILLS_DISCOVERY_PATH, SkillContribution,
3};
4use crate::capability_types::{CapabilityId, MountSource, plugin_capability_id};
5use crate::{CapabilityInfo, ScopedMcpServers, validate_skill_name};
6use serde::{Deserialize, Serialize};
7
8pub const DECLARATIVE_CAPABILITY_PREFIX: &str = "declarative:";
9// Capability refs are persisted in existing VARCHAR(50) capability columns.
10// `declarative:` is 12 bytes, leaving 38 bytes for the unique name.
11const MAX_NAME_BYTES: usize = 38;
12const MAX_DISPLAY_NAME_BYTES: usize = 80;
13const MAX_PROMPT_BYTES: usize = 64 * 1024;
14const MAX_FILES: usize = 32;
15const MAX_FILE_BYTES: usize = 64 * 1024;
16const MAX_SKILLS: usize = 16;
17const MAX_SKILL_BYTES: usize = 64 * 1024;
18const MAX_MCP_SERVERS: usize = 16;
19
20pub fn declarative_capability_id(name: &str) -> String {
21    format!("{DECLARATIVE_CAPABILITY_PREFIX}{name}")
22}
23
24pub fn is_declarative_capability(capability_id: &str) -> bool {
25    capability_id.starts_with(DECLARATIVE_CAPABILITY_PREFIX)
26}
27
28pub fn parse_declarative_capability_id(capability_id: &str) -> Option<&str> {
29    capability_id.strip_prefix(DECLARATIVE_CAPABILITY_PREFIX)
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DeclarativeCapabilityDefinition {
34    pub name: String,
35    #[serde(default)]
36    pub display_name: Option<String>,
37    pub description: String,
38    #[serde(default = "default_status")]
39    pub status: CapabilityStatus,
40    #[serde(default)]
41    pub icon: Option<String>,
42    #[serde(default)]
43    pub category: Option<String>,
44    #[serde(default)]
45    pub system_prompt: Option<String>,
46    #[serde(default)]
47    pub mcp_servers: Option<ScopedMcpServers>,
48    #[serde(default)]
49    pub skills: Vec<DeclarativeCapabilitySkill>,
50    #[serde(default)]
51    pub files: Vec<DeclarativeCapabilityFile>,
52    #[serde(default)]
53    pub dependencies: Vec<String>,
54    #[serde(default)]
55    pub features: Vec<String>,
56    #[serde(default = "default_risk_level")]
57    pub risk_level: RiskLevel,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct DeclarativeCapabilityFile {
62    pub path: String,
63    pub content: String,
64    #[serde(default)]
65    pub access: MountAccess,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct DeclarativeCapabilitySkill {
70    pub name: String,
71    pub description: String,
72    pub instructions: String,
73    #[serde(default)]
74    pub files: Vec<DeclarativeCapabilitySkillFile>,
75    #[serde(default = "default_true")]
76    pub user_invocable: bool,
77    #[serde(default)]
78    pub disable_model_invocation: bool,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct DeclarativeCapabilitySkillFile {
83    pub path: String,
84    pub content: String,
85}
86
87fn default_true() -> bool {
88    true
89}
90
91fn default_status() -> CapabilityStatus {
92    CapabilityStatus::Available
93}
94
95fn default_risk_level() -> RiskLevel {
96    RiskLevel::Low
97}
98
99impl Default for DeclarativeCapabilityDefinition {
100    fn default() -> Self {
101        Self {
102            name: String::new(),
103            display_name: None,
104            description: String::new(),
105            status: CapabilityStatus::Available,
106            icon: Some("puzzle".to_string()),
107            category: Some("Declarative".to_string()),
108            system_prompt: None,
109            mcp_servers: None,
110            skills: Vec::new(),
111            files: Vec::new(),
112            dependencies: Vec::new(),
113            features: Vec::new(),
114            risk_level: RiskLevel::Low,
115        }
116    }
117}
118
119impl DeclarativeCapabilityDefinition {
120    pub fn mounts(&self, capability_id: &str) -> Vec<MountPoint> {
121        self.files
122            .iter()
123            .map(|file| {
124                let source = MountSource::text_file(file.content.clone());
125                match file.access {
126                    MountAccess::ReadOnly => {
127                        MountPoint::readonly(file.path.clone(), source, capability_id)
128                    }
129                    MountAccess::ReadWrite => {
130                        MountPoint::readwrite(file.path.clone(), source, capability_id)
131                    }
132                }
133            })
134            .collect()
135    }
136
137    pub fn skill_contributions(&self) -> Vec<SkillContribution> {
138        self.skills
139            .iter()
140            .map(|skill| {
141                SkillContribution::new(
142                    skill.name.clone(),
143                    skill.description.clone(),
144                    skill.instructions.clone(),
145                )
146                .with_files(
147                    skill
148                        .files
149                        .iter()
150                        .map(|file| (file.path.clone(), file.content.clone()))
151                        .collect(),
152                )
153                .with_user_invocable(skill.user_invocable)
154                .with_disable_model_invocation(skill.disable_model_invocation)
155            })
156            .collect()
157    }
158}
159
160pub fn hydrate_declarative_capability_config(
161    _config: serde_json::Value,
162    definition: &DeclarativeCapabilityDefinition,
163) -> serde_json::Value {
164    serde_json::to_value(definition).unwrap_or_default()
165}
166
167pub fn declarative_capability_info(
168    name: &str,
169    definition: DeclarativeCapabilityDefinition,
170) -> CapabilityInfo {
171    CapabilityInfo {
172        id: CapabilityId::new(declarative_capability_id(name)),
173        name: definition.display_name.unwrap_or(definition.name),
174        description: definition.description,
175        status: definition.status,
176        icon: definition.icon.or_else(|| Some("puzzle".to_string())),
177        category: definition
178            .category
179            .or_else(|| Some("Declarative".to_string())),
180        system_prompt: definition.system_prompt,
181        tool_definitions: Vec::new(),
182        is_mcp: false,
183        is_skill: false,
184        is_guardrail: false,
185        dependencies: definition.dependencies,
186        features: definition.features,
187        config_schema: None,
188        config_ui_schema: None,
189        risk_level: definition.risk_level,
190        agent_count: 0,
191        harness_count: 0,
192        docs_slug: None,
193        localizations: Default::default(),
194    }
195}
196
197/// Hydrate a `plugin:` capability config: same logic as the declarative counterpart.
198///
199/// The per-agent config for a `plugin:` capability ref is the serialized
200/// `DeclarativeCapabilityDefinition` produced by the compiler. Hydration simply
201/// re-serializes the definition so callers get a canonical JSON value.
202pub fn hydrate_plugin_capability_config(
203    config: serde_json::Value,
204    definition: &DeclarativeCapabilityDefinition,
205) -> serde_json::Value {
206    // Identical to the declarative path: discard the incoming config and
207    // return the canonical definition. The `plugin:` namespace keeps refs
208    // from colliding with `declarative:` refs.
209    hydrate_declarative_capability_config(config, definition)
210}
211
212/// Build a `CapabilityInfo` DTO for a `plugin:{name}` capability.
213///
214/// The id field uses `plugin:{name}` so the capability appears under its own
215/// namespace in the capability picker, distinct from `declarative:{name}`.
216pub fn plugin_capability_info(
217    name: &str,
218    definition: DeclarativeCapabilityDefinition,
219) -> CapabilityInfo {
220    CapabilityInfo {
221        id: CapabilityId::new(plugin_capability_id(name)),
222        name: definition
223            .display_name
224            .clone()
225            .unwrap_or_else(|| definition.name.clone()),
226        description: definition.description.clone(),
227        status: definition.status,
228        icon: definition
229            .icon
230            .clone()
231            .or_else(|| Some("puzzle".to_string())),
232        category: definition
233            .category
234            .clone()
235            .or_else(|| Some("Plugin".to_string())),
236        system_prompt: definition.system_prompt.clone(),
237        tool_definitions: Vec::new(),
238        is_mcp: false,
239        is_skill: false,
240        is_guardrail: false,
241        dependencies: definition.dependencies.clone(),
242        features: definition.features.clone(),
243        config_schema: None,
244        config_ui_schema: None,
245        risk_level: definition.risk_level,
246        agent_count: 0,
247        harness_count: 0,
248        docs_slug: None,
249        localizations: Default::default(),
250    }
251}
252
253pub fn validate_declarative_capability_definition(
254    definition: &DeclarativeCapabilityDefinition,
255) -> Result<(), String> {
256    validate_name(&definition.name)?;
257    if let Some(display_name) = &definition.display_name {
258        validate_non_empty("display_name", display_name, MAX_DISPLAY_NAME_BYTES)?;
259    }
260    validate_non_empty("description", &definition.description, 512)?;
261
262    if let Some(prompt) = &definition.system_prompt {
263        validate_size("system_prompt", prompt, MAX_PROMPT_BYTES)?;
264    }
265    if let Some(servers) = &definition.mcp_servers
266        && servers.len() > MAX_MCP_SERVERS
267    {
268        return Err(format!(
269            "mcp_servers cannot contain more than {MAX_MCP_SERVERS} entries"
270        ));
271    }
272    if definition.files.len() > MAX_FILES {
273        return Err(format!(
274            "files cannot contain more than {MAX_FILES} entries"
275        ));
276    }
277    if definition.skills.len() > MAX_SKILLS {
278        return Err(format!(
279            "skills cannot contain more than {MAX_SKILLS} entries"
280        ));
281    }
282
283    for dependency in &definition.dependencies {
284        if is_declarative_capability(dependency) {
285            return Err("declarative capability dependencies cannot reference other declarative capabilities".to_string());
286        }
287    }
288
289    for file in &definition.files {
290        validate_mount_path(&file.path)?;
291        validate_size(
292            &format!("file {}", file.path),
293            &file.content,
294            MAX_FILE_BYTES,
295        )?;
296        if file.path.starts_with(SKILLS_DISCOVERY_PATH) {
297            return Err(format!(
298                "file path {} is reserved; use skills[] for skill contributions",
299                file.path
300            ));
301        }
302    }
303
304    for skill in &definition.skills {
305        validate_skill_name(&skill.name).map_err(|errors| {
306            format!("invalid skill name '{}': {}", skill.name, errors.join("; "))
307        })?;
308        validate_non_empty("skill.description", &skill.description, 512)?;
309        validate_size(
310            &format!("skill {} instructions", skill.name),
311            &skill.instructions,
312            MAX_SKILL_BYTES,
313        )?;
314        for file in &skill.files {
315            validate_relative_path(&file.path)?;
316            validate_size(
317                &format!("skill {} file {}", skill.name, file.path),
318                &file.content,
319                MAX_FILE_BYTES,
320            )?;
321        }
322    }
323
324    Ok(())
325}
326
327fn validate_non_empty(field: &str, value: &str, max: usize) -> Result<(), String> {
328    if value.trim().is_empty() {
329        return Err(format!("{field} is required"));
330    }
331    validate_size(field, value, max)
332}
333
334fn validate_name(name: &str) -> Result<(), String> {
335    validate_non_empty("name", name, MAX_NAME_BYTES)?;
336    let mut chars = name.chars();
337    let Some(first) = chars.next() else {
338        return Err("name is required".to_string());
339    };
340    if !first.is_ascii_lowercase() {
341        return Err("name must start with a lowercase letter".to_string());
342    }
343    if !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-') {
344        return Err("name may contain only lowercase letters, digits, '_' and '-'".to_string());
345    }
346    if name.ends_with('_') || name.ends_with('-') {
347        return Err("name cannot end with '_' or '-'".to_string());
348    }
349    Ok(())
350}
351
352fn validate_size(field: &str, value: &str, max: usize) -> Result<(), String> {
353    if value.len() > max {
354        return Err(format!("{field} cannot exceed {max} bytes"));
355    }
356    Ok(())
357}
358
359fn validate_mount_path(path: &str) -> Result<(), String> {
360    if !path.starts_with('/') || path.contains("..") || path.contains("//") {
361        return Err(format!("invalid mount path: {path}"));
362    }
363    Ok(())
364}
365
366fn validate_relative_path(path: &str) -> Result<(), String> {
367    if path.starts_with('/') || path.contains("..") || path.contains("//") || path.trim().is_empty()
368    {
369        return Err(format!("invalid relative file path: {path}"));
370    }
371    Ok(())
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    fn valid_definition() -> DeclarativeCapabilityDefinition {
379        DeclarativeCapabilityDefinition {
380            name: "research_pack".to_string(),
381            display_name: Some("Research Pack".to_string()),
382            description: "Curated research behavior".to_string(),
383            ..Default::default()
384        }
385    }
386
387    #[test]
388    fn declarative_capability_ref_uses_unique_name() {
389        assert_eq!(
390            declarative_capability_id("research_pack"),
391            "declarative:research_pack"
392        );
393        assert_eq!(
394            parse_declarative_capability_id("declarative:research_pack"),
395            Some("research_pack")
396        );
397    }
398
399    #[test]
400    fn validation_accepts_name_and_display_name() {
401        validate_declarative_capability_definition(&valid_definition()).unwrap();
402    }
403
404    #[test]
405    fn validation_rejects_names_that_do_not_fit_capability_ref_columns() {
406        let mut definition = valid_definition();
407        definition.name = "a".repeat(MAX_NAME_BYTES + 1);
408        let err = validate_declarative_capability_definition(&definition).unwrap_err();
409        assert!(err.contains("name cannot exceed"));
410    }
411
412    #[test]
413    fn capability_info_uses_display_name_for_title() {
414        let info = declarative_capability_info("research_pack", valid_definition());
415        assert_eq!(info.id.as_str(), "declarative:research_pack");
416        assert_eq!(info.name, "Research Pack");
417    }
418}