Skip to main content

hatchling_skill/
registry.rs

1//! The skill registry: admission + identity pinning.
2//!
3//! The broker (or a broker-mediated registry) holds admitted skills.
4//! Registration pins the skill's code hash at admission; every load
5//! and every call re-verifies the code against the pinned hash.
6//! Drift — code changed under a pinned identity — is refused.
7//!
8//! Never trust a worker's self-declaration.
9//! The broker validates the skill's claimed identity against the
10//! pinned identity. The signature covers the manifest; the
11//! registry binds the manifest to the code it admits.
12
13use std::collections::HashMap;
14use std::fmt;
15use std::str::FromStr;
16
17use hatchling_core::CapabilityName;
18use sha2::{Digest, Sha256};
19
20use crate::{Admission, Error};
21
22/// The SHA-256 hash of a skill's code, pinned at admission.
23///
24/// This is the skill's identity for drift detection. It is computed
25/// by the broker from the actual code bytes at registration, never
26/// taken from the skill's self-declaration.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub struct CodeHash([u8; 32]);
29
30impl CodeHash {
31    /// Compute the hash of skill code bytes.
32    #[must_use]
33    pub fn of(code: &[u8]) -> Self {
34        let mut hasher = Sha256::new();
35        hasher.update(code);
36        let result = hasher.finalize();
37        let mut bytes = [0u8; 32];
38        bytes.copy_from_slice(&result);
39        Self(bytes)
40    }
41
42    /// The raw hash bytes.
43    #[must_use]
44    pub const fn as_bytes(&self) -> &[u8; 32] {
45        &self.0
46    }
47}
48
49impl fmt::Display for CodeHash {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        for byte in self.0 {
52            write!(f, "{byte:02x}")?;
53        }
54        Ok(())
55    }
56}
57
58/// A unique identifier for a registered skill.
59///
60/// Derived from the manifest name + version at registration. The
61/// broker uses this to look up skills; the code hash pins the
62/// identity against drift.
63#[derive(Debug, Clone, PartialEq, Eq, Hash)]
64pub struct SkillId(String);
65
66impl SkillId {
67    /// Build from manifest name and version.
68    #[must_use]
69    pub fn new(name: &str, version: &str) -> Self {
70        Self(format!("{name}@{version}"))
71    }
72
73    /// The string form (`name@version`).
74    #[must_use]
75    pub fn as_str(&self) -> &str {
76        &self.0
77    }
78}
79
80impl FromStr for SkillId {
81    type Err = Error;
82
83    /// Parses the exact `name@version` form, as a caller names a skill.
84    ///
85    /// The split is at the first `@`: admission refuses `@` in names,
86    /// so that is the only decomposition a registered id can have, and
87    /// the parsed id is byte-identical to the input. A bare name, or an
88    /// empty name or version, names no skill.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`Error::UnknownSkill`] for anything not of that form.
93    fn from_str(raw: &str) -> Result<Self, Self::Err> {
94        match raw.split_once('@') {
95            Some((name, version)) if !name.is_empty() && !version.is_empty() => {
96                Ok(Self::new(name, version))
97            }
98            _ => Err(Error::UnknownSkill(raw.to_owned())),
99        }
100    }
101}
102
103impl fmt::Display for SkillId {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        write!(f, "{}", self.0)
106    }
107}
108
109/// A skill admitted to the registry: the verified admission plus the
110/// pinned code hash.
111///
112/// Only the registry constructs one, so holding a `RegisteredSkill`
113/// proves the code matched the pinned hash at registration.
114#[derive(Debug, Clone)]
115pub struct RegisteredSkill {
116    id: SkillId,
117    admission: Admission,
118    code_hash: CodeHash,
119}
120
121impl RegisteredSkill {
122    /// The skill's registry identifier.
123    #[must_use]
124    pub const fn id(&self) -> &SkillId {
125        &self.id
126    }
127
128    /// The verified admission (manifest + granted capabilities).
129    #[must_use]
130    pub const fn admission(&self) -> &Admission {
131        &self.admission
132    }
133
134    /// The pinned code hash.
135    #[must_use]
136    pub const fn code_hash(&self) -> &CodeHash {
137        &self.code_hash
138    }
139
140    /// Check a capability call against the admitted manifest.
141    ///
142    /// This is the broker's call-time enforcement: every skill tool
143    /// call re-checks the capability against the declarations.
144    /// Returns `Ok(())` for declared capabilities, `Err` for
145    /// undeclared ones.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`Error::UndeclaredCapability`] if the capability was
150    /// not declared in the manifest.
151    pub fn check_call(&self, capability: &CapabilityName) -> Result<(), Error> {
152        self.admission.check_call(capability)
153    }
154}
155
156/// The broker's registry of admitted skills.
157///
158/// Registration is the trust boundary: the broker takes an `Admission`
159/// (already verified by ) and the skill's code bytes, pins the
160/// code hash, and only then lists the skill. Every lookup can re-verify
161/// the code against the pinned hash to detect drift.
162#[derive(Debug, Default)]
163pub struct SkillRegistry {
164    skills: HashMap<SkillId, RegisteredSkill>,
165}
166
167impl SkillRegistry {
168    /// Create an empty registry.
169    #[must_use]
170    pub fn new() -> Self {
171        Self::default()
172    }
173
174    /// Register an admitted skill with its code bytes.
175    ///
176    /// The code hash is computed from the actual bytes and pinned.
177    /// If a skill with the same id is already registered, registration
178    /// is refused — even if the code hash matches. Silent re-registration
179    /// would let a caller swap a skill's code without any signal; the
180    /// explicit `unregister` + `register` sequence is the only update
181    /// path (both are auditable broker operations).
182    ///
183    /// # Errors
184    ///
185    /// Returns [`Error::AlreadyRegistered`] if the id is already
186    /// registered.
187    pub fn register(&mut self, admission: Admission, code: &[u8]) -> Result<SkillId, Error> {
188        let manifest = admission.manifest();
189        let id = SkillId::new(&manifest.name, &manifest.version);
190        let code_hash = CodeHash::of(code);
191
192        if self.skills.contains_key(&id) {
193            return Err(Error::AlreadyRegistered(id.to_string()));
194        }
195
196        self.skills.insert(
197            id.clone(),
198            RegisteredSkill {
199                id: id.clone(),
200                admission,
201                code_hash,
202            },
203        );
204        Ok(id)
205    }
206
207    /// Remove a skill from the registry by id.
208    ///
209    /// This is the explicit half of the update path: `unregister` then
210    /// `register` replaces a skill's code with a clear audit trail.
211    /// Returns `true` if a skill was removed, `false` if the id was
212    /// not registered.
213    pub fn unregister(&mut self, id: &SkillId) -> bool {
214        self.skills.remove(id).is_some()
215    }
216
217    /// Look up a registered skill by id.
218    #[must_use]
219    pub fn get(&self, id: &SkillId) -> Option<&RegisteredSkill> {
220        self.skills.get(id)
221    }
222
223    /// Verify code bytes against the pinned hash for a registered skill.
224    ///
225    /// Call this at load time and at call time: if the code on disk
226    /// (or presented by the cell) doesn't match the pinned hash,
227    /// the skill has drifted and must be refused.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`Error::UnknownSkill`] if the id is not registered.
232    /// Returns [`Error::IdentityDrift`] if the hash doesn't match.
233    pub fn verify_code(&self, id: &SkillId, code: &[u8]) -> Result<(), Error> {
234        let skill = self
235            .skills
236            .get(id)
237            .ok_or_else(|| Error::UnknownSkill(id.to_string()))?;
238        let found = CodeHash::of(code);
239        if skill.code_hash != found {
240            return Err(Error::IdentityDrift {
241                id: id.to_string(),
242                expected: skill.code_hash.to_string(),
243                found: found.to_string(),
244            });
245        }
246        Ok(())
247    }
248
249    /// List all registered skill ids.
250    #[must_use]
251    pub fn list(&self) -> Vec<SkillId> {
252        self.skills.keys().cloned().collect()
253    }
254
255    /// The number of registered skills.
256    #[must_use]
257    pub fn len(&self) -> usize {
258        self.skills.len()
259    }
260
261    /// Whether the registry is empty.
262    #[must_use]
263    pub fn is_empty(&self) -> bool {
264        self.skills.is_empty()
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::SignedManifest;
272    use ed25519_dalek::{Signer, SigningKey};
273
274    fn test_keypair() -> (SigningKey, [u8; 32]) {
275        // Fixed test key: 32 bytes, deterministic for tests.
276        let secret = [
277            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
278            0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
279            0x1d, 0x1e, 0x1f, 0x20,
280        ];
281        let signing = SigningKey::from_bytes(&secret);
282        let verifying = signing.verifying_key().to_bytes();
283        (signing, verifying)
284    }
285
286    fn signed_manifest(signing: &SigningKey, name: &str) -> SignedManifest {
287        use crate::{Manifest, SignedManifest};
288        let manifest = Manifest {
289            name: name.to_string(),
290            version: "1.0.0".to_string(),
291            capabilities: vec!["fs.read".parse().unwrap()],
292            entry_point: "main.py".to_string(),
293        };
294        let canonical = manifest.canonical_bytes().unwrap();
295        let signature = signing.sign(&canonical);
296        SignedManifest {
297            manifest,
298            signature: Some(base64::Engine::encode(
299                &base64::engine::general_purpose::STANDARD,
300                signature.to_bytes(),
301            )),
302        }
303    }
304
305    fn admit(name: &str) -> Admission {
306        let (signing, verify_bytes) = test_keypair();
307        let signed = signed_manifest(&signing, name);
308        let grants: std::collections::HashSet<CapabilityName> =
309            std::iter::once("fs.read".parse().unwrap()).collect();
310        crate::verify_admission(&signed, &verify_bytes, &grants).expect("admit")
311    }
312
313    #[test]
314    fn registry_counts_and_lists_every_skill() {
315        let mut registry = SkillRegistry::new();
316        assert_eq!(registry.len(), 0);
317        assert!(registry.is_empty());
318        assert!(registry.list().is_empty());
319        registry
320            .register(admit("alpha"), b"a")
321            .expect("register alpha");
322        registry
323            .register(admit("beta"), b"b")
324            .expect("register beta");
325        assert_eq!(registry.len(), 2);
326        assert!(!registry.is_empty());
327        let mut ids: Vec<String> = registry.list().iter().map(ToString::to_string).collect();
328        ids.sort();
329        assert_eq!(ids, vec!["alpha@1.0.0", "beta@1.0.0"]);
330    }
331
332    #[test]
333    fn ids_and_hashes_display_canonically() {
334        assert_eq!(SkillId::new("web", "1.0.0").to_string(), "web@1.0.0");
335        // SHA-256 of the empty string, as lowercase hex.
336        assert_eq!(
337            CodeHash::of(b"").to_string(),
338            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
339        );
340    }
341
342    #[test]
343    fn register_and_lookup() {
344        let admission = admit("test-skill");
345        let mut registry = SkillRegistry::new();
346        let code = b"print('hello')";
347        let id = registry.register(admission, code).expect("register");
348        assert_eq!(id.as_str(), "test-skill@1.0.0");
349        let skill = registry.get(&id).expect("lookup");
350        assert_eq!(skill.code_hash(), &CodeHash::of(code));
351    }
352
353    #[test]
354    fn verify_code_detects_drift() {
355        let admission = admit("drift-skill");
356        let mut registry = SkillRegistry::new();
357        let id = registry.register(admission, b"v1 code").expect("register");
358        // Same code: ok.
359        registry.verify_code(&id, b"v1 code").expect("same code");
360        // Changed code: drift refused.
361        let err = registry.verify_code(&id, b"v2 code").expect_err("drift");
362        assert!(matches!(err, Error::IdentityDrift { .. }));
363    }
364
365    #[test]
366    fn register_same_id_is_refused_even_with_same_code() {
367        let admission = admit("dup-skill");
368        let mut registry = SkillRegistry::new();
369        registry
370            .register(admission.clone(), b"code a")
371            .expect("first");
372        // Re-registration is refused even with identical code: silent
373        // replacement is never allowed.
374        let err = registry
375            .register(admission.clone(), b"code a")
376            .expect_err("re-register with same code refused");
377        assert!(matches!(err, Error::AlreadyRegistered(_)));
378        // Different code under same id: also refused.
379        let err = registry
380            .register(admission, b"code b")
381            .expect_err("re-register with different code refused");
382        assert!(matches!(err, Error::AlreadyRegistered(_)));
383    }
384
385    #[test]
386    fn unregister_then_register_replaces_the_skill() {
387        let admission = admit("update-skill");
388        let mut registry = SkillRegistry::new();
389        let id = registry
390            .register(admission.clone(), b"code a")
391            .expect("first");
392        // Explicit unregister is the update path.
393        assert!(registry.unregister(&id));
394        assert!(!registry.unregister(&id));
395        // Re-register with new code succeeds.
396        let id2 = registry
397            .register(admission, b"code b")
398            .expect("re-register after unregister");
399        assert_eq!(id, id2);
400        assert!(registry.verify_code(&id2, b"code b").is_ok());
401    }
402
403    #[test]
404    fn check_call_enforces_declared_capabilities() {
405        let admission = admit("call-skill");
406        let mut registry = SkillRegistry::new();
407        let id = registry.register(admission, b"code").expect("register");
408        let skill = registry.get(&id).expect("lookup");
409        skill
410            .check_call(&"fs.read".parse().unwrap())
411            .expect("declared");
412        let err = skill
413            .check_call(&"fs.write".parse().unwrap())
414            .expect_err("undeclared");
415        assert!(matches!(err, Error::UndeclaredCapability { .. }));
416    }
417
418    #[test]
419    fn skill_ids_parse_exactly_and_round_trip() {
420        let id: SkillId = "web@1.0.0".parse().unwrap();
421        assert_eq!(id, SkillId::new("web", "1.0.0"));
422        // The split is at the first `@`; the rest is the version, and
423        // the parsed id is the input byte for byte.
424        let id: SkillId = "a@1@2".parse().unwrap();
425        assert_eq!(id, SkillId::new("a", "1@2"));
426        assert_eq!(id.as_str(), "a@1@2");
427        for raw in ["web", "", "@", "@1.0.0", "web@"] {
428            assert!(
429                matches!(raw.parse::<SkillId>(), Err(Error::UnknownSkill(ref s)) if s == raw),
430                "{raw:?} must name no skill"
431            );
432        }
433    }
434
435    #[test]
436    fn verify_code_unknown_skill() {
437        let registry = SkillRegistry::new();
438        let id = SkillId::new("nope", "1.0.0");
439        let err = registry.verify_code(&id, b"code").expect_err("unknown");
440        assert!(matches!(err, Error::UnknownSkill(_)));
441    }
442}