hatchling_skill/error.rs
1//! Typed errors for skill admission and verification.
2//!
3//! Every refusal is a typed error, never a silent pass. The broker
4//! logs these loudly; the cell only ever sees the refusal, never the
5//! reason's internals.
6
7use hatchling_core::CapabilityName;
8use thiserror::Error;
9
10/// All ways a skill can be refused.
11#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum Error {
14 /// The manifest has no signature block. Unsigned skills do not run.
15 #[error("skill '{name}' refused: manifest is not signed")]
16 Unsigned {
17 /// The skill name from the manifest.
18 name: String,
19 },
20
21 /// The signature is present but does not verify. The skill was
22 /// tampered with after signing, or signed by an untrusted key.
23 #[error("skill '{name}' refused: signature verification failed")]
24 BadSignature {
25 /// The skill name from the manifest.
26 name: String,
27 },
28
29 /// The manifest declares capabilities beyond the user's grants.
30 /// Refused at admission — the skill may never claim these.
31 #[error(
32 "skill '{name}' refused: manifest declares capabilities beyond user grants: {}",
33 list(.excess)
34 )]
35 OverPrivileged {
36 /// The skill name from the manifest.
37 name: String,
38 /// The declared capabilities that exceed the user's grants.
39 excess: Vec<CapabilityName>,
40 },
41
42 /// The skill tried to use a capability it did not declare.
43 /// Denied at call time — declaration is not enough, every call
44 /// re-checks the grant.
45 #[error("skill '{name}' denied: capability '{capability}' was not declared in the manifest")]
46 UndeclaredCapability {
47 /// The skill name from the manifest.
48 name: String,
49 /// The capability the skill tried to use.
50 capability: CapabilityName,
51 },
52
53 /// The manifest is malformed.
54 #[error("skill manifest invalid: {0}")]
55 InvalidManifest(String),
56
57 /// The trust root (public key) is malformed or unknown.
58 #[error("trust root invalid: {0}")]
59 InvalidTrustRoot(String),
60
61 /// The skill's code changed under its pinned identity.
62 /// The code hash at load/call time doesn't match the hash pinned
63 /// at admission. Refused — this is the drift rule.
64 #[error("skill '{id}' refused: code drift detected (expected {expected}, found {found})")]
65 IdentityDrift {
66 /// The skill's registry id.
67 id: String,
68 /// The pinned code hash.
69 expected: String,
70 /// The hash of the presented code.
71 found: String,
72 },
73
74 /// No skill with this id is registered.
75 #[error("unknown skill: '{0}'")]
76 UnknownSkill(String),
77
78 /// A skill with this id is already registered. Re-registration is
79 /// refused — explicit `unregister` followed by `register` is the
80 /// only update path. Silent replacement would let a compromised or
81 /// confused caller swap a skill's code without any signal.
82 #[error("skill '{0}' is already registered: unregister first to replace it")]
83 AlreadyRegistered(String),
84}
85
86/// Comma-separated names, for refusal messages.
87fn list(names: &[CapabilityName]) -> String {
88 names
89 .iter()
90 .map(CapabilityName::as_str)
91 .collect::<Vec<_>>()
92 .join(", ")
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 #[test]
100 fn capability_refusals_name_the_capabilities() {
101 let cap = |raw: &str| raw.parse::<CapabilityName>().unwrap();
102 let over = Error::OverPrivileged {
103 name: String::from("notes"),
104 excess: vec![cap("fs.write"), cap("net.egress")],
105 };
106 assert_eq!(
107 over.to_string(),
108 "skill 'notes' refused: manifest declares capabilities beyond user grants: \
109 fs.write, net.egress"
110 );
111 let undeclared = Error::UndeclaredCapability {
112 name: String::from("notes"),
113 capability: cap("fs.read"),
114 };
115 assert_eq!(
116 undeclared.to_string(),
117 "skill 'notes' denied: capability 'fs.read' was not declared in the manifest"
118 );
119 }
120}