Skip to main content

hatchery_node/
plugins.rs

1//! Signed session plugins (node-owned session environment).
2//!
3//! A plugin is a directory with a manifest `plugin.json` and a detached signature
4//! `plugin.sig`. The manifest names the plugin, its version, the providers it applies to
5//! and every file with its SHA-256. The signature is Ed25519 over the exact manifest
6//! bytes, by a publisher key the node trusts (`trusted-keys` file: one hex public key
7//! per line, `#` comments allowed). The node verifies the signature, every file hash,
8//! and that the package holds no extra files, links or path escapes before it copies
9//! the plugin into its plugin root. Core never sees plugin contents.
10
11use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::collections::BTreeMap;
15use std::path::{Component, Path, PathBuf};
16
17pub const MANIFEST: &str = "plugin.json";
18pub const SIGNATURE: &str = "plugin.sig";
19const MAX_FILES: usize = 4096;
20const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
21
22#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
23#[serde(deny_unknown_fields)]
24pub struct PluginManifest {
25    pub name: String,
26    pub version: String,
27    /// Provider ids this plugin applies to (empty = all).
28    #[serde(default)]
29    pub providers: Vec<String>,
30    /// Relative path -> lowercase hex SHA-256.
31    pub files: BTreeMap<String, String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Installed {
36    pub name: String,
37    pub version: String,
38    pub path: PathBuf,
39    pub signer: String,
40}
41
42fn ident_ok(s: &str) -> bool {
43    !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)) && !s.starts_with('.')
44}
45
46fn rel_ok(p: &str) -> bool {
47    let path = Path::new(p);
48    !p.is_empty() && p != MANIFEST && p != SIGNATURE && path.components().all(|c| matches!(c, Component::Normal(_)))
49}
50
51pub fn load_trusted_keys(file: &Path) -> Result<Vec<VerifyingKey>, String> {
52    let text = std::fs::read_to_string(file).map_err(|e| format!("trusted keys: {e}"))?;
53    let mut keys = Vec::new();
54    for line in text.lines() {
55        let line = line.split('#').next().unwrap_or("").trim();
56        if line.is_empty() {
57            continue;
58        }
59        let hex_key = line.split_whitespace().next().unwrap_or("");
60        let bytes: [u8; 32] = hex::decode(hex_key).ok().and_then(|b| b.try_into().ok()).ok_or("trusted keys: bad key")?;
61        keys.push(VerifyingKey::from_bytes(&bytes).map_err(|_| "trusted keys: bad key".to_owned())?);
62    }
63    Ok(keys)
64}
65
66fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
67    for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
68        let entry = entry.map_err(|e| e.to_string())?;
69        let ft = entry.file_type().map_err(|e| e.to_string())?;
70        let path = entry.path();
71        if ft.is_symlink() {
72            return Err(format!("link not allowed: {}", path.strip_prefix(root).unwrap_or(&path).display()));
73        } else if ft.is_dir() {
74            walk(root, &path, out)?;
75        } else if ft.is_file() {
76            let rel = path.strip_prefix(root).map_err(|e| e.to_string())?;
77            out.push(rel.to_str().ok_or("non-UTF-8 path")?.replace('\\', "/"));
78        } else {
79            return Err("special file not allowed".into());
80        }
81        if out.len() > MAX_FILES + 2 {
82            return Err("too many files".into());
83        }
84    }
85    Ok(())
86}
87
88fn sha256_file(path: &Path) -> Result<String, String> {
89    let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
90    Ok(hex::encode(Sha256::digest(&bytes)))
91}
92
93/// Verify a plugin package. Returns the manifest and the hex key that signed it.
94pub fn verify(pkg: &Path, trusted: &[VerifyingKey]) -> Result<(PluginManifest, String), String> {
95    let manifest_bytes = std::fs::read(pkg.join(MANIFEST)).map_err(|e| format!("manifest: {e}"))?;
96    let sig_text = std::fs::read_to_string(pkg.join(SIGNATURE)).map_err(|e| format!("signature: {e}"))?;
97    let sig_bytes: [u8; 64] = hex::decode(sig_text.trim()).ok().and_then(|b| b.try_into().ok()).ok_or("signature: malformed")?;
98    let sig = Signature::from_bytes(&sig_bytes);
99    let signer = trusted
100        .iter()
101        .find(|k| k.verify(&manifest_bytes, &sig).is_ok())
102        .ok_or("signature: not signed by a trusted key")?;
103    let manifest: PluginManifest = serde_json::from_slice(&manifest_bytes).map_err(|e| format!("manifest: {e}"))?;
104    if !ident_ok(&manifest.name) || !ident_ok(&manifest.version) {
105        return Err("manifest: bad name or version".into());
106    }
107    if manifest.files.len() > MAX_FILES || manifest.files.keys().any(|p| !rel_ok(p)) {
108        return Err("manifest: bad file list".into());
109    }
110    let mut present = Vec::new();
111    walk(pkg, pkg, &mut present)?;
112    present.retain(|p| p != MANIFEST && p != SIGNATURE);
113    present.sort();
114    let declared: Vec<&String> = manifest.files.keys().collect();
115    if present.iter().collect::<Vec<_>>() != declared {
116        return Err("package files do not match the manifest".into());
117    }
118    let mut total = 0u64;
119    for (rel, want) in &manifest.files {
120        let path = pkg.join(rel);
121        total += std::fs::metadata(&path).map_err(|e| e.to_string())?.len();
122        if total > MAX_TOTAL_BYTES {
123            return Err("package too large".into());
124        }
125        if sha256_file(&path)? != want.to_ascii_lowercase() {
126            return Err(format!("hash mismatch: {rel}"));
127        }
128    }
129    Ok((manifest, hex::encode(signer.as_bytes())))
130}
131
132/// Verify, then copy into `<root>/<name>/<version>` (atomic rename). An existing
133/// identical version is replaced only after the new copy verified.
134pub fn install(pkg: &Path, root: &Path, trusted: &[VerifyingKey]) -> Result<Installed, String> {
135    let (manifest, signer) = verify(pkg, trusted)?;
136    let dest = root.join(&manifest.name).join(&manifest.version);
137    let parent = dest.parent().expect("has parent");
138    std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
139    let tmp = parent.join(format!(".{}.tmp-{}", manifest.version, std::process::id()));
140    let _ = std::fs::remove_dir_all(&tmp);
141    for rel in manifest.files.keys().map(String::as_str).chain([MANIFEST, SIGNATURE]) {
142        let to = tmp.join(rel);
143        std::fs::create_dir_all(to.parent().expect("parent")).map_err(|e| e.to_string())?;
144        std::fs::copy(pkg.join(rel), &to).map_err(|e| e.to_string())?;
145    }
146    // Re-verify the copy (guards against the source changing during the copy).
147    if let Err(e) = verify(&tmp, trusted) {
148        let _ = std::fs::remove_dir_all(&tmp);
149        return Err(format!("copy failed verification: {e}"));
150    }
151    if dest.exists() {
152        std::fs::remove_dir_all(&dest).map_err(|e| e.to_string())?;
153    }
154    std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
155    Ok(Installed { name: manifest.name, version: manifest.version, path: dest, signer })
156}
157
158/// List installed plugins that still verify (tampered ones are skipped).
159pub fn list(root: &Path, trusted: &[VerifyingKey]) -> Vec<Result<Installed, (PathBuf, String)>> {
160    let mut out = Vec::new();
161    let Ok(names) = std::fs::read_dir(root) else { return out };
162    for name in names.flatten() {
163        let Ok(versions) = std::fs::read_dir(name.path()) else { continue };
164        for v in versions.flatten() {
165            let path = v.path();
166            if v.file_name().to_string_lossy().starts_with('.') {
167                continue;
168            }
169            out.push(match verify(&path, trusted) {
170                Ok((m, signer)) => Ok(Installed { name: m.name, version: m.version, path, signer }),
171                Err(e) => Err((path, e)),
172            });
173        }
174    }
175    out
176}
177
178/// Publisher side: write `plugin.json` for the files in `pkg` and sign it.
179/// `secret_hex` is the 32-byte Ed25519 seed (hex); it is never printed.
180pub fn sign(pkg: &Path, name: &str, version: &str, providers: Vec<String>, secret_hex: &str) -> Result<String, String> {
181    let seed: [u8; 32] = hex::decode(secret_hex.trim()).ok().and_then(|b| b.try_into().ok()).ok_or("bad secret key")?;
182    let key = SigningKey::from_bytes(&seed);
183    let mut rels = Vec::new();
184    walk(pkg, pkg, &mut rels)?;
185    rels.retain(|p| p != MANIFEST && p != SIGNATURE);
186    let mut files = BTreeMap::new();
187    for rel in rels {
188        files.insert(rel.clone(), sha256_file(&pkg.join(&rel))?);
189    }
190    let manifest = PluginManifest { name: name.into(), version: version.into(), providers, files };
191    let bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?;
192    std::fs::write(pkg.join(MANIFEST), &bytes).map_err(|e| e.to_string())?;
193    std::fs::write(pkg.join(SIGNATURE), hex::encode(key.sign(&bytes).to_bytes())).map_err(|e| e.to_string())?;
194    Ok(hex::encode(key.verifying_key().as_bytes()))
195}
196
197/// New publisher key: returns (secret seed hex, public key hex).
198pub fn keygen() -> (String, String) {
199    let mut seed = [0u8; 32];
200    getrandom::fill(&mut seed).expect("os randomness");
201    let key = SigningKey::from_bytes(&seed);
202    (hex::encode(seed), hex::encode(key.verifying_key().as_bytes()))
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn setup() -> (tempfile::TempDir, PathBuf, Vec<VerifyingKey>, String) {
210        let dir = tempfile::tempdir().unwrap();
211        let pkg = dir.path().join("pkg");
212        std::fs::create_dir_all(pkg.join("skills/review")).unwrap();
213        std::fs::write(pkg.join("skills/review/SKILL.md"), b"review").unwrap();
214        std::fs::write(pkg.join("README"), b"hi").unwrap();
215        let (secret, public) = keygen();
216        sign(&pkg, "review-kit", "1.0.0", vec!["claude".into()], &secret).unwrap();
217        let key = VerifyingKey::from_bytes(&hex::decode(&public).unwrap().try_into().unwrap()).unwrap();
218        (dir, pkg, vec![key], secret)
219    }
220
221    #[test]
222    fn signed_plugin_installs_and_lists() {
223        let (dir, pkg, trusted, _) = setup();
224        let root = dir.path().join("plugins");
225        let inst = install(&pkg, &root, &trusted).unwrap();
226        assert_eq!(inst.path, root.join("review-kit/1.0.0"));
227        assert!(inst.path.join("skills/review/SKILL.md").is_file());
228        let listed = list(&root, &trusted);
229        assert_eq!(listed.len(), 1);
230        assert!(listed[0].is_ok());
231        // Tampering after install is detected on listing.
232        std::fs::write(inst.path.join("README"), b"evil").unwrap();
233        assert!(list(&root, &trusted)[0].is_err());
234    }
235
236    #[test]
237    fn refuses_untrusted_tampered_extra_and_links() {
238        let (dir, pkg, trusted, _) = setup();
239        let root = dir.path().join("plugins");
240        let (_, other) = keygen();
241        let stranger = vec![VerifyingKey::from_bytes(&hex::decode(other).unwrap().try_into().unwrap()).unwrap()];
242        assert!(install(&pkg, &root, &stranger).unwrap_err().contains("trusted"));
243        std::fs::write(pkg.join("README"), b"changed").unwrap();
244        assert!(install(&pkg, &root, &trusted).unwrap_err().contains("hash mismatch"));
245        std::fs::write(pkg.join("README"), b"hi").unwrap();
246        std::fs::write(pkg.join("extra"), b"x").unwrap();
247        assert!(install(&pkg, &root, &trusted).unwrap_err().contains("do not match"));
248        std::fs::remove_file(pkg.join("extra")).unwrap();
249        #[cfg(unix)]
250        {
251            std::os::unix::fs::symlink("/etc/passwd", pkg.join("link")).unwrap();
252            assert!(install(&pkg, &root, &trusted).unwrap_err().contains("link"));
253            std::fs::remove_file(pkg.join("link")).unwrap();
254        }
255        // Manifest edit breaks the signature.
256        let m = std::fs::read_to_string(pkg.join(MANIFEST)).unwrap().replace("1.0.0", "9.9.9");
257        std::fs::write(pkg.join(MANIFEST), m).unwrap();
258        assert!(install(&pkg, &root, &trusted).is_err());
259        assert!(!root.join("review-kit").exists());
260    }
261
262    #[test]
263    fn rejects_path_escape_in_manifest() {
264        assert!(!rel_ok("../x"));
265        assert!(!rel_ok("/etc/x"));
266        assert!(!rel_ok(MANIFEST));
267        assert!(rel_ok("skills/a/SKILL.md"));
268    }
269
270    #[test]
271    fn trusted_keys_file_parses_comments() {
272        let dir = tempfile::tempdir().unwrap();
273        let (_, public) = keygen();
274        let f = dir.path().join("keys");
275        std::fs::write(&f, format!("# publishers\n{public} owner\n\n")).unwrap();
276        assert_eq!(load_trusted_keys(&f).unwrap().len(), 1);
277        std::fs::write(&f, "zz\n").unwrap();
278        assert!(load_trusted_keys(&f).is_err());
279    }
280}
281
282/// Provider config directory (relative to the provider HOME) a plugin's files land in.
283pub fn provider_dir(provider: &str) -> Option<&'static str> {
284    Some(match provider {
285        "claude" => ".claude",
286        "codex" => ".codex",
287        "kimi" => ".kimi",
288        "grok" => ".grok",
289        "gemini" => ".gemini",
290        _ => return None,
291    })
292}
293
294const MANAGED_LIST: &str = ".hatchery-plugins.json";
295
296/// Load verified plugins into provider homes so new sessions see them: each file goes to
297/// `<home>/<provider dir>/<path>` for every provider the plugin names (all known ones when
298/// it names none). Only files this sync placed are ever replaced or removed (tracked in
299/// `<home>/.hatchery-plugins.json`); an existing foreign file is left alone and reported.
300/// Tampered plugins are skipped. Returns (placed, skipped-with-reason).
301pub fn sync_into_home(root: &Path, trusted: &[VerifyingKey], home: &Path) -> (usize, Vec<String>) {
302    const ALL: &[&str] = &["claude", "codex", "kimi", "grok", "gemini"];
303    let list_path = home.join(MANAGED_LIST);
304    let previous: BTreeMap<String, String> = std::fs::read(&list_path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
305    let mut managed: BTreeMap<String, String> = BTreeMap::new();
306    let mut skipped = Vec::new();
307    // Newest version per plugin name wins (lexicographic by version dir).
308    let mut chosen: BTreeMap<String, Installed> = BTreeMap::new();
309    for item in list(root, trusted) {
310        match item {
311            Ok(i) => {
312                if chosen.get(&i.name).is_none_or(|c| c.version < i.version) {
313                    chosen.insert(i.name.clone(), i);
314                }
315            }
316            Err((p, e)) => skipped.push(format!("{}: {e}", p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default())),
317        }
318    }
319    for inst in chosen.values() {
320        let Ok(bytes) = std::fs::read(inst.path.join(MANIFEST)) else { continue };
321        let Ok(m) = serde_json::from_slice::<PluginManifest>(&bytes) else { continue };
322        let providers: Vec<&str> = if m.providers.is_empty() { ALL.to_vec() } else { m.providers.iter().map(String::as_str).collect() };
323        for p in providers {
324            let Some(dir) = provider_dir(p) else { continue };
325            for (rel, hash) in &m.files {
326                let target_rel = format!("{dir}/{rel}");
327                let target = home.join(&target_rel);
328                let ours = previous.contains_key(&target_rel) || managed.contains_key(&target_rel);
329                if target.exists() && !ours {
330                    if sha256_file(&target).ok().as_deref() != Some(hash.as_str()) {
331                        skipped.push(format!("{target_rel}: exists and is not managed"));
332                    }
333                    continue;
334                }
335                if sha256_file(&target).ok().as_deref() != Some(hash.as_str()) {
336                    let _ = std::fs::create_dir_all(target.parent().expect("parent"));
337                    if let Err(e) = std::fs::copy(inst.path.join(rel), &target) {
338                        skipped.push(format!("{target_rel}: {e}"));
339                        continue;
340                    }
341                }
342                managed.insert(target_rel, hash.clone());
343            }
344        }
345    }
346    // Remove files we placed earlier that no plugin provides any more (only if unchanged).
347    for (rel, hash) in &previous {
348        if !managed.contains_key(rel) {
349            let path = home.join(rel);
350            if sha256_file(&path).ok().as_deref() == Some(hash.as_str()) {
351                let _ = std::fs::remove_file(&path);
352                // Drop directories the plugin left empty, below `<provider dir>/<kind>`
353                // (e.g. `.claude/skills/x`); remove_dir refuses non-empty ones.
354                let mut dir = std::path::Path::new(rel).parent();
355                while let Some(d) = dir.filter(|d| d.components().count() > 2) {
356                    if std::fs::remove_dir(home.join(d)).is_err() {
357                        break;
358                    }
359                    dir = d.parent();
360                }
361            }
362        }
363    }
364    let placed = managed.len();
365    let _ = std::fs::write(&list_path, serde_json::to_vec_pretty(&managed).unwrap_or_default());
366    (placed, skipped)
367}
368
369#[cfg(test)]
370mod sync_tests {
371    use super::*;
372
373    #[test]
374    fn sync_places_updates_and_removes_only_managed_files() {
375        let dir = tempfile::tempdir().unwrap();
376        let (secret, public) = keygen();
377        let trusted = vec![VerifyingKey::from_bytes(&hex::decode(public).unwrap().try_into().unwrap()).unwrap()];
378        let pkg = dir.path().join("pkg");
379        std::fs::create_dir_all(pkg.join("skills/review")).unwrap();
380        std::fs::write(pkg.join("skills/review/SKILL.md"), b"v1").unwrap();
381        sign(&pkg, "kit", "1.0.0", vec!["claude".into()], &secret).unwrap();
382        let root = dir.path().join("plugins");
383        install(&pkg, &root, &trusted).unwrap();
384        let home = dir.path().join("home");
385        std::fs::create_dir_all(home.join(".claude/skills/mine")).unwrap();
386        std::fs::write(home.join(".claude/skills/mine/SKILL.md"), b"user").unwrap();
387
388        let (placed, skipped) = sync_into_home(&root, &trusted, &home);
389        assert_eq!((placed, skipped.len()), (1, 0));
390        assert_eq!(std::fs::read(home.join(".claude/skills/review/SKILL.md")).unwrap(), b"v1");
391        assert!(!home.join(".codex").exists());
392
393        // Newer version replaces our file.
394        std::fs::write(pkg.join("skills/review/SKILL.md"), b"v2").unwrap();
395        sign(&pkg, "kit", "1.1.0", vec!["claude".into()], &secret).unwrap();
396        install(&pkg, &root, &trusted).unwrap();
397        sync_into_home(&root, &trusted, &home);
398        assert_eq!(std::fs::read(home.join(".claude/skills/review/SKILL.md")).unwrap(), b"v2");
399
400        // Plugin gone -> our file removed; the user's own skill stays.
401        std::fs::remove_dir_all(&root).unwrap();
402        let (placed, _) = sync_into_home(&root, &trusted, &home);
403        assert_eq!(placed, 0);
404        assert!(!home.join(".claude/skills/review/SKILL.md").exists());
405        assert!(!home.join(".claude/skills/review").exists());
406        assert!(home.join(".claude/skills/mine").exists());
407        assert_eq!(std::fs::read(home.join(".claude/skills/mine/SKILL.md")).unwrap(), b"user");
408    }
409
410    #[test]
411    fn foreign_file_is_not_overwritten() {
412        let dir = tempfile::tempdir().unwrap();
413        let (secret, public) = keygen();
414        let trusted = vec![VerifyingKey::from_bytes(&hex::decode(public).unwrap().try_into().unwrap()).unwrap()];
415        let pkg = dir.path().join("pkg");
416        std::fs::create_dir_all(&pkg).unwrap();
417        std::fs::write(pkg.join("AGENTS.md"), b"plugin").unwrap();
418        sign(&pkg, "kit", "1.0.0", vec!["codex".into()], &secret).unwrap();
419        let root = dir.path().join("plugins");
420        install(&pkg, &root, &trusted).unwrap();
421        let home = dir.path().join("home");
422        std::fs::create_dir_all(home.join(".codex")).unwrap();
423        std::fs::write(home.join(".codex/AGENTS.md"), b"mine").unwrap();
424        let (placed, skipped) = sync_into_home(&root, &trusted, &home);
425        assert_eq!(placed, 0);
426        assert!(skipped[0].contains("not managed"));
427        assert_eq!(std::fs::read(home.join(".codex/AGENTS.md")).unwrap(), b"mine");
428    }
429}