1use super::{
2 CapabilityStatus, MountAccess, MountPoint, RiskLevel, SKILLS_DISCOVERY_PATH, SkillContribution,
3};
4use crate::capability_types::MountSource;
5use crate::{CapabilityInfo, ScopedMcpServers, validate_skill_name};
6use everruns_capability::{CapabilityId, plugin_capability_id};
7use serde::{Deserialize, Serialize};
8
9pub const DECLARATIVE_CAPABILITY_PREFIX: &str = "declarative:";
10const MAX_NAME_BYTES: usize = 38;
13const MAX_DISPLAY_NAME_BYTES: usize = 80;
14const MAX_PROMPT_BYTES: usize = 64 * 1024;
15const MAX_FILES: usize = 32;
16const MAX_FILE_BYTES: usize = 64 * 1024;
17const MAX_SKILLS: usize = 16;
18const MAX_SKILL_BYTES: usize = 64 * 1024;
19const MAX_MCP_SERVERS: usize = 16;
20
21pub fn declarative_capability_id(name: &str) -> String {
22 format!("{DECLARATIVE_CAPABILITY_PREFIX}{name}")
23}
24
25pub fn is_declarative_capability(capability_id: &str) -> bool {
26 capability_id.starts_with(DECLARATIVE_CAPABILITY_PREFIX)
27}
28
29pub fn parse_declarative_capability_id(capability_id: &str) -> Option<&str> {
30 capability_id.strip_prefix(DECLARATIVE_CAPABILITY_PREFIX)
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct DeclarativeCapabilityDefinition {
35 pub name: String,
36 #[serde(default)]
37 pub display_name: Option<String>,
38 pub description: String,
39 #[serde(default = "default_status")]
40 pub status: CapabilityStatus,
41 #[serde(default)]
42 pub icon: Option<String>,
43 #[serde(default)]
44 pub category: Option<String>,
45 #[serde(default)]
46 pub system_prompt: Option<String>,
47 #[serde(default)]
48 pub mcp_servers: Option<ScopedMcpServers>,
49 #[serde(default)]
50 pub skills: Vec<DeclarativeCapabilitySkill>,
51 #[serde(default)]
52 pub files: Vec<DeclarativeCapabilityFile>,
53 #[serde(default)]
54 pub dependencies: Vec<String>,
55 #[serde(default)]
56 pub features: Vec<String>,
57 #[serde(default = "default_risk_level")]
58 pub risk_level: RiskLevel,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct DeclarativeCapabilityFile {
63 pub path: String,
64 pub content: String,
65 #[serde(default)]
66 pub access: MountAccess,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct DeclarativeCapabilitySkill {
71 pub name: String,
72 pub description: String,
73 pub instructions: String,
74 #[serde(default)]
75 pub files: Vec<DeclarativeCapabilitySkillFile>,
76 #[serde(default = "default_true")]
77 pub user_invocable: bool,
78 #[serde(default)]
79 pub disable_model_invocation: bool,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct DeclarativeCapabilitySkillFile {
84 pub path: String,
85 pub content: String,
86}
87
88fn default_true() -> bool {
89 true
90}
91
92fn default_status() -> CapabilityStatus {
93 CapabilityStatus::Available
94}
95
96fn default_risk_level() -> RiskLevel {
97 RiskLevel::Low
98}
99
100impl Default for DeclarativeCapabilityDefinition {
101 fn default() -> Self {
102 Self {
103 name: String::new(),
104 display_name: None,
105 description: String::new(),
106 status: CapabilityStatus::Available,
107 icon: Some("puzzle".to_string()),
108 category: Some("Declarative".to_string()),
109 system_prompt: None,
110 mcp_servers: None,
111 skills: Vec::new(),
112 files: Vec::new(),
113 dependencies: Vec::new(),
114 features: Vec::new(),
115 risk_level: RiskLevel::Low,
116 }
117 }
118}
119
120impl DeclarativeCapabilityDefinition {
121 pub fn mounts(&self, capability_id: &str) -> Vec<MountPoint> {
122 self.files
123 .iter()
124 .map(|file| {
125 let source = MountSource::text_file(file.content.clone());
126 match file.access {
127 MountAccess::ReadOnly => {
128 MountPoint::readonly(file.path.clone(), source, capability_id)
129 }
130 MountAccess::ReadWrite => {
131 MountPoint::readwrite(file.path.clone(), source, capability_id)
132 }
133 }
134 })
135 .collect()
136 }
137
138 pub fn skill_contributions(&self) -> Vec<SkillContribution> {
139 self.skills
140 .iter()
141 .map(|skill| {
142 SkillContribution::new(
143 skill.name.clone(),
144 skill.description.clone(),
145 skill.instructions.clone(),
146 )
147 .with_files(
148 skill
149 .files
150 .iter()
151 .map(|file| (file.path.clone(), file.content.clone()))
152 .collect(),
153 )
154 .with_user_invocable(skill.user_invocable)
155 .with_disable_model_invocation(skill.disable_model_invocation)
156 })
157 .collect()
158 }
159}
160
161pub fn hydrate_declarative_capability_config(
162 _config: serde_json::Value,
163 definition: &DeclarativeCapabilityDefinition,
164) -> serde_json::Value {
165 serde_json::to_value(definition).unwrap_or_default()
166}
167
168pub fn declarative_capability_info(
169 name: &str,
170 definition: DeclarativeCapabilityDefinition,
171) -> CapabilityInfo {
172 CapabilityInfo {
173 id: CapabilityId::new(declarative_capability_id(name)),
174 name: definition.display_name.unwrap_or(definition.name),
175 description: definition.description,
176 status: definition.status,
177 icon: definition.icon.or_else(|| Some("puzzle".to_string())),
178 category: definition
179 .category
180 .or_else(|| Some("Declarative".to_string())),
181 system_prompt: definition.system_prompt,
182 tool_definitions: Vec::new(),
183 is_mcp: false,
184 is_skill: false,
185 is_guardrail: false,
186 dependencies: definition.dependencies,
187 features: definition.features,
188 config_schema: None,
189 config_ui_schema: None,
190 risk_level: definition.risk_level,
191 agent_count: 0,
192 harness_count: 0,
193 docs_slug: None,
194 localizations: Default::default(),
195 }
196}
197
198pub fn hydrate_plugin_capability_config(
204 config: serde_json::Value,
205 definition: &DeclarativeCapabilityDefinition,
206) -> serde_json::Value {
207 hydrate_declarative_capability_config(config, definition)
211}
212
213pub fn plugin_capability_info(
218 identity: &str,
219 definition: DeclarativeCapabilityDefinition,
220) -> CapabilityInfo {
221 CapabilityInfo {
222 id: CapabilityId::new(plugin_capability_id(identity)),
223 name: definition
224 .display_name
225 .clone()
226 .unwrap_or_else(|| definition.name.clone()),
227 description: definition.description.clone(),
228 status: definition.status,
229 icon: definition
230 .icon
231 .clone()
232 .or_else(|| Some("puzzle".to_string())),
233 category: definition
234 .category
235 .clone()
236 .or_else(|| Some("Plugin".to_string())),
237 system_prompt: definition.system_prompt.clone(),
238 tool_definitions: Vec::new(),
239 is_mcp: false,
240 is_skill: false,
241 is_guardrail: false,
242 dependencies: definition.dependencies.clone(),
243 features: definition.features.clone(),
244 config_schema: None,
245 config_ui_schema: None,
246 risk_level: definition.risk_level,
247 agent_count: 0,
248 harness_count: 0,
249 docs_slug: None,
250 localizations: Default::default(),
251 }
252}
253
254pub fn validate_declarative_capability_definition(
255 definition: &DeclarativeCapabilityDefinition,
256) -> Result<(), String> {
257 validate_name(&definition.name)?;
258 if let Some(display_name) = &definition.display_name {
259 validate_non_empty("display_name", display_name, MAX_DISPLAY_NAME_BYTES)?;
260 }
261 validate_non_empty("description", &definition.description, 512)?;
262
263 if let Some(prompt) = &definition.system_prompt {
264 validate_size("system_prompt", prompt, MAX_PROMPT_BYTES)?;
265 }
266 if let Some(servers) = &definition.mcp_servers
267 && servers.len() > MAX_MCP_SERVERS
268 {
269 return Err(format!(
270 "mcp_servers cannot contain more than {MAX_MCP_SERVERS} entries"
271 ));
272 }
273 if definition.files.len() > MAX_FILES {
274 return Err(format!(
275 "files cannot contain more than {MAX_FILES} entries"
276 ));
277 }
278 if definition.skills.len() > MAX_SKILLS {
279 return Err(format!(
280 "skills cannot contain more than {MAX_SKILLS} entries"
281 ));
282 }
283
284 for dependency in &definition.dependencies {
285 if is_declarative_capability(dependency) {
286 return Err("declarative capability dependencies cannot reference other declarative capabilities".to_string());
287 }
288 }
289
290 for file in &definition.files {
291 validate_mount_path(&file.path)?;
292 validate_size(
293 &format!("file {}", file.path),
294 &file.content,
295 MAX_FILE_BYTES,
296 )?;
297 if file.path == SKILLS_DISCOVERY_PATH
298 || file
299 .path
300 .strip_prefix(SKILLS_DISCOVERY_PATH)
301 .is_some_and(|rest| rest.starts_with('/'))
302 {
303 return Err(format!(
304 "file path {} is reserved; use skills[] for skill contributions",
305 file.path
306 ));
307 }
308 }
309
310 for skill in &definition.skills {
311 validate_skill_name(&skill.name).map_err(|errors| {
312 format!("invalid skill name '{}': {}", skill.name, errors.join("; "))
313 })?;
314 validate_non_empty("skill.description", &skill.description, 512)?;
315 validate_size(
316 &format!("skill {} instructions", skill.name),
317 &skill.instructions,
318 MAX_SKILL_BYTES,
319 )?;
320 for file in &skill.files {
321 validate_relative_path(&file.path)?;
322 validate_size(
323 &format!("skill {} file {}", skill.name, file.path),
324 &file.content,
325 MAX_FILE_BYTES,
326 )?;
327 }
328 }
329
330 Ok(())
331}
332
333fn validate_non_empty(field: &str, value: &str, max: usize) -> Result<(), String> {
334 if value.trim().is_empty() {
335 return Err(format!("{field} is required"));
336 }
337 validate_size(field, value, max)
338}
339
340fn validate_name(name: &str) -> Result<(), String> {
341 validate_non_empty("name", name, MAX_NAME_BYTES)?;
342 let mut chars = name.chars();
343 let Some(first) = chars.next() else {
344 return Err("name is required".to_string());
345 };
346 if !first.is_ascii_lowercase() {
347 return Err("name must start with a lowercase letter".to_string());
348 }
349 if !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-') {
350 return Err("name may contain only lowercase letters, digits, '_' and '-'".to_string());
351 }
352 if name.ends_with('_') || name.ends_with('-') {
353 return Err("name cannot end with '_' or '-'".to_string());
354 }
355 Ok(())
356}
357
358fn validate_size(field: &str, value: &str, max: usize) -> Result<(), String> {
359 if value.len() > max {
360 return Err(format!("{field} cannot exceed {max} bytes"));
361 }
362 Ok(())
363}
364
365fn validate_mount_path(path: &str) -> Result<(), String> {
366 if !path.starts_with('/') || path.contains("..") || path.contains("//") {
367 return Err(format!("invalid mount path: {path}"));
368 }
369 Ok(())
370}
371
372fn validate_relative_path(path: &str) -> Result<(), String> {
373 if path.starts_with('/') || path.contains("..") || path.contains("//") || path.trim().is_empty()
374 {
375 return Err(format!("invalid relative file path: {path}"));
376 }
377 Ok(())
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 fn valid_definition() -> DeclarativeCapabilityDefinition {
385 DeclarativeCapabilityDefinition {
386 name: "research_pack".to_string(),
387 display_name: Some("Research Pack".to_string()),
388 description: "Curated research behavior".to_string(),
389 ..Default::default()
390 }
391 }
392
393 #[test]
394 fn declarative_capability_ref_uses_unique_name() {
395 assert_eq!(
396 declarative_capability_id("research_pack"),
397 "declarative:research_pack"
398 );
399 assert_eq!(
400 parse_declarative_capability_id("declarative:research_pack"),
401 Some("research_pack")
402 );
403 assert!(is_declarative_capability("declarative:research_pack"));
404 for other in ["plugin:research_pack", "research_pack", ""] {
405 assert!(!is_declarative_capability(other));
406 assert_eq!(parse_declarative_capability_id(other), None);
407 }
408 }
409
410 #[test]
411 fn names_and_required_text_use_literal_byte_boundaries() {
412 for name in ["a", "a0_b-c", &"a".repeat(38)] {
413 let mut d = valid_definition();
414 d.name = name.into();
415 validate_declarative_capability_definition(&d).unwrap();
416 }
417 for (name, error) in [
418 ("".to_string(), "name is required"),
419 (" ".to_string(), "name is required"),
420 ("a".repeat(39), "name cannot exceed 38 bytes"),
421 ("Aname".into(), "name must start with a lowercase letter"),
422 ("0name".into(), "name must start with a lowercase letter"),
423 (
424 "a.b".into(),
425 "name may contain only lowercase letters, digits, '_' and '-'",
426 ),
427 (
428 "aα".into(),
429 "name may contain only lowercase letters, digits, '_' and '-'",
430 ),
431 ("a_".into(), "name cannot end with '_' or '-'"),
432 ("a-".into(), "name cannot end with '_' or '-'"),
433 ] {
434 let mut d = valid_definition();
435 d.name = name;
436 assert_eq!(
437 validate_declarative_capability_definition(&d),
438 Err(error.into())
439 );
440 }
441 for (field, max) in [
442 ("display_name", 80),
443 ("description", 512),
444 ("skill.description", 512),
445 ] {
446 for (text, expected) in [
447 ("α".repeat(max / 2), Ok(())),
448 (
449 "α".repeat(max / 2) + "x",
450 Err(format!("{field} cannot exceed {max} bytes")),
451 ),
452 (" \t".into(), Err(format!("{field} is required"))),
453 ] {
454 let mut d = valid_definition();
455 match field {
456 "display_name" => d.display_name = Some(text),
457 "description" => d.description = text,
458 _ => {
459 d.skills = vec![DeclarativeCapabilitySkill {
460 name: "ops".into(),
461 description: text,
462 instructions: "Body".into(),
463 files: vec![],
464 user_invocable: true,
465 disable_model_invocation: false,
466 }]
467 }
468 }
469 assert_eq!(
470 validate_declarative_capability_definition(&d),
471 expected,
472 "{field}"
473 );
474 }
475 }
476 }
477
478 fn skill() -> DeclarativeCapabilitySkill {
479 DeclarativeCapabilitySkill {
480 name: "ops".into(),
481 description: "Operations".into(),
482 instructions: "Body".into(),
483 files: vec![],
484 user_invocable: true,
485 disable_model_invocation: false,
486 }
487 }
488
489 #[test]
490 fn contribution_collections_accept_limits_and_reject_one_more() {
491 for (field, max) in [("files", 32), ("skills", 16), ("mcp_servers", 16)] {
492 for count in [max, max + 1] {
493 let mut d = valid_definition();
494 match field {
495 "files" => {
496 d.files = (0..count)
497 .map(|i| DeclarativeCapabilityFile {
498 path: format!("/file-{i}"),
499 content: "x".into(),
500 access: MountAccess::ReadOnly,
501 })
502 .collect()
503 }
504 "skills" => {
505 d.skills = (0..count)
506 .map(|i| DeclarativeCapabilitySkill {
507 name: format!("skill-{i}"),
508 ..skill()
509 })
510 .collect()
511 }
512 _ => {
513 d.mcp_servers = Some(
514 serde_json::from_value(serde_json::Value::Object(
515 (0..count)
516 .map(|i| {
517 (
518 format!("server-{i}"),
519 serde_json::json!({"url":"https://example.com/mcp"}),
520 )
521 })
522 .collect(),
523 ))
524 .unwrap(),
525 )
526 }
527 }
528 let expected = if count == max {
529 Ok(())
530 } else {
531 Err(format!("{field} cannot contain more than {max} entries"))
532 };
533 assert_eq!(validate_declarative_capability_definition(&d), expected);
534 }
535 }
536 }
537
538 #[test]
539 fn content_limits_count_utf8_bytes_for_every_contribution_surface() {
540 for field in [
541 "system_prompt",
542 "file /notes",
543 "skill ops instructions",
544 "skill ops file ref.txt",
545 ] {
546 for extra in [false, true] {
547 let mut d = valid_definition();
548 let content = "α".repeat(32768) + if extra { "x" } else { "" };
549 match field {
550 "system_prompt" => d.system_prompt = Some(content),
551 "file /notes" => {
552 d.files = vec![DeclarativeCapabilityFile {
553 path: "/notes".into(),
554 content,
555 access: MountAccess::ReadOnly,
556 }]
557 }
558 "skill ops instructions" => {
559 d.skills = vec![DeclarativeCapabilitySkill {
560 instructions: content,
561 ..skill()
562 }]
563 }
564 _ => {
565 d.skills = vec![DeclarativeCapabilitySkill {
566 files: vec![DeclarativeCapabilitySkillFile {
567 path: "ref.txt".into(),
568 content,
569 }],
570 ..skill()
571 }]
572 }
573 }
574 let expected = if extra {
575 Err(format!("{field} cannot exceed 65536 bytes"))
576 } else {
577 Ok(())
578 };
579 assert_eq!(validate_declarative_capability_definition(&d), expected);
580 }
581 }
582 }
583
584 #[test]
585 fn path_validation_rejects_traversal_and_reserves_only_the_skill_directory() {
586 for path in [
587 "/notes.txt",
588 "/.agents/skills-extra/readme",
589 "/.agents/skills-backup",
590 ] {
591 let mut d = valid_definition();
592 d.files = vec![DeclarativeCapabilityFile {
593 path: path.into(),
594 content: "x".into(),
595 access: MountAccess::ReadOnly,
596 }];
597 assert_eq!(
598 validate_declarative_capability_definition(&d),
599 Ok(()),
600 "{path}"
601 );
602 }
603 for path in [
604 "relative",
605 "/../secret",
606 "/a//b",
607 "/.agents/skills",
608 "/.agents/skills/ops/SKILL.md",
609 ] {
610 let mut d = valid_definition();
611 d.files = vec![DeclarativeCapabilityFile {
612 path: path.into(),
613 content: "x".into(),
614 access: MountAccess::ReadOnly,
615 }];
616 let error = if path.starts_with("/.agents/skills") {
617 format!("file path {path} is reserved; use skills[] for skill contributions")
618 } else {
619 format!("invalid mount path: {path}")
620 };
621 assert_eq!(validate_declarative_capability_definition(&d), Err(error));
622 }
623 for path in ["", " ", "/absolute", "../secret", "a//b"] {
624 let mut d = valid_definition();
625 d.skills = vec![DeclarativeCapabilitySkill {
626 files: vec![DeclarativeCapabilitySkillFile {
627 path: path.into(),
628 content: "x".into(),
629 }],
630 ..skill()
631 }];
632 assert_eq!(
633 validate_declarative_capability_definition(&d),
634 Err(format!("invalid relative file path: {path}"))
635 );
636 }
637 let mut d = valid_definition();
638 d.skills = vec![DeclarativeCapabilitySkill {
639 files: vec![DeclarativeCapabilitySkillFile {
640 path: "scripts/run.sh".into(),
641 content: "x".into(),
642 }],
643 ..skill()
644 }];
645 assert_eq!(validate_declarative_capability_definition(&d), Ok(()));
646 }
647
648 #[test]
649 fn declarative_dependencies_are_rejected_but_other_namespaces_remain_valid() {
650 let mut d = valid_definition();
651 d.dependencies = vec!["session_file_system".into(), "plugin:tools".into()];
652 assert_eq!(validate_declarative_capability_definition(&d), Ok(()));
653 d.dependencies.push("declarative:other".into());
654 assert_eq!(validate_declarative_capability_definition(&d),Err("declarative capability dependencies cannot reference other declarative capabilities".into()));
655 d.dependencies.clear();
656 d.skills = vec![DeclarativeCapabilitySkill {
657 name: "../invalid".into(),
658 ..skill()
659 }];
660 assert!(
661 validate_declarative_capability_definition(&d)
662 .unwrap_err()
663 .starts_with("invalid skill name '../invalid':")
664 );
665 }
666
667 #[test]
668 fn catalog_projection_and_hydration_preserve_definition_not_incoming_overrides() {
669 let mut d = valid_definition();
670 d.system_prompt = Some("Exact prompt".into());
671 d.dependencies = vec!["filesystem".into()];
672 d.features = vec!["files".into()];
673 d.risk_level = RiskLevel::High;
674 d.status = CapabilityStatus::ComingSoon;
675 d.icon = None;
676 d.category = None;
677 for (info, id, category) in [
678 (
679 declarative_capability_info("external-id", d.clone()),
680 "declarative:external-id",
681 "Declarative",
682 ),
683 (
684 plugin_capability_info("install-42", d.clone()),
685 "plugin:install-42",
686 "Plugin",
687 ),
688 ] {
689 assert_eq!(info.id.as_str(), id);
690 assert_eq!(info.name, "Research Pack");
691 assert_eq!(info.description, "Curated research behavior");
692 assert_eq!(info.system_prompt.as_deref(), Some("Exact prompt"));
693 assert_eq!(info.dependencies, ["filesystem"]);
694 assert_eq!(info.features, ["files"]);
695 assert_eq!(info.risk_level, RiskLevel::High);
696 assert_eq!(info.status, CapabilityStatus::ComingSoon);
697 assert_eq!(info.icon.as_deref(), Some("puzzle"));
698 assert_eq!(info.category.as_deref(), Some(category));
699 assert!(info.tool_definitions.is_empty());
700 }
701 for hydrate in [
702 hydrate_declarative_capability_config,
703 hydrate_plugin_capability_config,
704 ] {
705 let value = hydrate(
706 serde_json::json!({"name":"attacker","system_prompt":"override","unknown":42}),
707 &d,
708 );
709 assert_eq!(value["name"], "research_pack");
710 assert_eq!(value["system_prompt"], "Exact prompt");
711 assert!(value.get("unknown").is_none());
712 assert_eq!(value, serde_json::to_value(&d).unwrap());
713 }
714 d.display_name = None;
715 d.icon = Some("custom".into());
716 d.category = Some("Custom".into());
717 for info in [
718 declarative_capability_info("id", d.clone()),
719 plugin_capability_info("id", d),
720 ] {
721 assert_eq!(info.name, "research_pack");
722 assert_eq!(info.icon.as_deref(), Some("custom"));
723 assert_eq!(info.category.as_deref(), Some("Custom"));
724 }
725 }
726
727 #[test]
728 fn mount_and_skill_projection_preserve_contents_owners_and_access() {
729 let mut d = valid_definition();
730 d.files = vec![
731 DeclarativeCapabilityFile {
732 path: "/readonly".into(),
733 content: "Read α".into(),
734 access: MountAccess::ReadOnly,
735 },
736 DeclarativeCapabilityFile {
737 path: "/writable".into(),
738 content: "Write β".into(),
739 access: MountAccess::ReadWrite,
740 },
741 ];
742 assert_eq!(
743 d.mounts("owner"),
744 vec![
745 MountPoint::readonly("/readonly", MountSource::text_file("Read α"), "owner"),
746 MountPoint::readwrite("/writable", MountSource::text_file("Write β"), "owner")
747 ]
748 );
749 d.skills = vec![DeclarativeCapabilitySkill {
750 user_invocable: false,
751 disable_model_invocation: true,
752 files: vec![DeclarativeCapabilitySkillFile {
753 path: "reference.txt".into(),
754 content: "Reference".into(),
755 }],
756 ..skill()
757 }];
758 let skills = d.skill_contributions();
759 assert_eq!(skills.len(), 1);
760 let s = &skills[0];
761 assert_eq!(s.name, "ops");
762 assert_eq!(s.description, "Operations");
763 assert_eq!(s.instructions, "Body");
764 assert_eq!(s.files, [("reference.txt".into(), "Reference".into())]);
765 assert!(!s.user_invocable);
766 assert!(s.disable_model_invocation);
767 }
768}