everruns_core/capabilities/
declarative.rs1use 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:";
9const 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}