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};
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        dependencies: definition.dependencies,
185        features: definition.features,
186        config_schema: None,
187        config_ui_schema: None,
188        risk_level: definition.risk_level,
189        agent_count: 0,
190        harness_count: 0,
191        docs_slug: None,
192    }
193}
194
195pub fn validate_declarative_capability_definition(
196    definition: &DeclarativeCapabilityDefinition,
197) -> Result<(), String> {
198    validate_name(&definition.name)?;
199    if let Some(display_name) = &definition.display_name {
200        validate_non_empty("display_name", display_name, MAX_DISPLAY_NAME_BYTES)?;
201    }
202    validate_non_empty("description", &definition.description, 512)?;
203
204    if let Some(prompt) = &definition.system_prompt {
205        validate_size("system_prompt", prompt, MAX_PROMPT_BYTES)?;
206    }
207    if let Some(servers) = &definition.mcp_servers
208        && servers.len() > MAX_MCP_SERVERS
209    {
210        return Err(format!(
211            "mcp_servers cannot contain more than {MAX_MCP_SERVERS} entries"
212        ));
213    }
214    if definition.files.len() > MAX_FILES {
215        return Err(format!(
216            "files cannot contain more than {MAX_FILES} entries"
217        ));
218    }
219    if definition.skills.len() > MAX_SKILLS {
220        return Err(format!(
221            "skills cannot contain more than {MAX_SKILLS} entries"
222        ));
223    }
224
225    for dependency in &definition.dependencies {
226        if is_declarative_capability(dependency) {
227            return Err("declarative capability dependencies cannot reference other declarative capabilities".to_string());
228        }
229    }
230
231    for file in &definition.files {
232        validate_mount_path(&file.path)?;
233        validate_size(
234            &format!("file {}", file.path),
235            &file.content,
236            MAX_FILE_BYTES,
237        )?;
238        if file.path.starts_with(SKILLS_DISCOVERY_PATH) {
239            return Err(format!(
240                "file path {} is reserved; use skills[] for skill contributions",
241                file.path
242            ));
243        }
244    }
245
246    for skill in &definition.skills {
247        validate_skill_name(&skill.name).map_err(|errors| {
248            format!("invalid skill name '{}': {}", skill.name, errors.join("; "))
249        })?;
250        validate_non_empty("skill.description", &skill.description, 512)?;
251        validate_size(
252            &format!("skill {} instructions", skill.name),
253            &skill.instructions,
254            MAX_SKILL_BYTES,
255        )?;
256        for file in &skill.files {
257            validate_relative_path(&file.path)?;
258            validate_size(
259                &format!("skill {} file {}", skill.name, file.path),
260                &file.content,
261                MAX_FILE_BYTES,
262            )?;
263        }
264    }
265
266    Ok(())
267}
268
269fn validate_non_empty(field: &str, value: &str, max: usize) -> Result<(), String> {
270    if value.trim().is_empty() {
271        return Err(format!("{field} is required"));
272    }
273    validate_size(field, value, max)
274}
275
276fn validate_name(name: &str) -> Result<(), String> {
277    validate_non_empty("name", name, MAX_NAME_BYTES)?;
278    let mut chars = name.chars();
279    let Some(first) = chars.next() else {
280        return Err("name is required".to_string());
281    };
282    if !first.is_ascii_lowercase() {
283        return Err("name must start with a lowercase letter".to_string());
284    }
285    if !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-') {
286        return Err("name may contain only lowercase letters, digits, '_' and '-'".to_string());
287    }
288    if name.ends_with('_') || name.ends_with('-') {
289        return Err("name cannot end with '_' or '-'".to_string());
290    }
291    Ok(())
292}
293
294fn validate_size(field: &str, value: &str, max: usize) -> Result<(), String> {
295    if value.len() > max {
296        return Err(format!("{field} cannot exceed {max} bytes"));
297    }
298    Ok(())
299}
300
301fn validate_mount_path(path: &str) -> Result<(), String> {
302    if !path.starts_with('/') || path.contains("..") || path.contains("//") {
303        return Err(format!("invalid mount path: {path}"));
304    }
305    Ok(())
306}
307
308fn validate_relative_path(path: &str) -> Result<(), String> {
309    if path.starts_with('/') || path.contains("..") || path.contains("//") || path.trim().is_empty()
310    {
311        return Err(format!("invalid relative file path: {path}"));
312    }
313    Ok(())
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn valid_definition() -> DeclarativeCapabilityDefinition {
321        DeclarativeCapabilityDefinition {
322            name: "research_pack".to_string(),
323            display_name: Some("Research Pack".to_string()),
324            description: "Curated research behavior".to_string(),
325            ..Default::default()
326        }
327    }
328
329    #[test]
330    fn declarative_capability_ref_uses_unique_name() {
331        assert_eq!(
332            declarative_capability_id("research_pack"),
333            "declarative:research_pack"
334        );
335        assert_eq!(
336            parse_declarative_capability_id("declarative:research_pack"),
337            Some("research_pack")
338        );
339    }
340
341    #[test]
342    fn validation_accepts_name_and_display_name() {
343        validate_declarative_capability_definition(&valid_definition()).unwrap();
344    }
345
346    #[test]
347    fn validation_rejects_names_that_do_not_fit_capability_ref_columns() {
348        let mut definition = valid_definition();
349        definition.name = "a".repeat(MAX_NAME_BYTES + 1);
350        let err = validate_declarative_capability_definition(&definition).unwrap_err();
351        assert!(err.contains("name cannot exceed"));
352    }
353
354    #[test]
355    fn capability_info_uses_display_name_for_title() {
356        let info = declarative_capability_info("research_pack", valid_definition());
357        assert_eq!(info.id.as_str(), "declarative:research_pack");
358        assert_eq!(info.name, "Research Pack");
359    }
360}