Skip to main content

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}