Skip to main content

stellar_agent_toolsets/
error.rs

1//! Typed error surface for toolset format parse and validation failures.
2//!
3//! [`ToolsetFormatError`] is the closed-set of all distinct refusal reasons returned
4//! by [`crate::parse_toolset`].  Every variant carries enough context to surface a
5//! useful error message without echoing secret material (toolsets contain no key
6//! material, but can contain attacker-controlled strings whose Display output is
7//! sanitised to prevent terminal-spoof and log-injection).
8
9use crate::sanitise::sanitise_display;
10
11/// Maximum byte length echoed in rendered error variants that carry
12/// attacker-controlled content.  Strings longer than this are truncated.
13const ECHO_CAP: usize = 256;
14
15/// All distinct reasons a toolset directory can be refused by [`crate::parse_toolset`].
16///
17/// The set is closed: a match on `ToolsetFormatError` that is exhaustive today will
18/// require an update if new variants are added.  The `#[non_exhaustive]` attribute
19/// is intentionally absent — the parity test in this module locks the surface by
20/// failing to compile when a variant is added without updating the test.
21#[derive(Debug, thiserror::Error)]
22pub enum ToolsetFormatError {
23    /// The `TOOLSET.md` file could not be read from the toolset directory.
24    ///
25    /// The `detail` string is the sanitised I/O error message; it never contains
26    /// attacker-controlled path contents longer than `ECHO_CAP` bytes.
27    #[error("I/O error reading TOOLSET.md: {}", sanitise_display(.detail, ECHO_CAP))]
28    Io {
29        /// Sanitised I/O error description.
30        detail: String,
31    },
32
33    /// The `TOOLSET.md` file exceeds the 256 KiB size cap (enforced before parsing).
34    #[error("TOOLSET.md is too large: {size} bytes (cap is {cap} bytes)")]
35    ToolsetFileTooLarge {
36        /// Actual file size in bytes.
37        size: u64,
38        /// Maximum permitted size in bytes.
39        cap: u64,
40    },
41
42    /// The `TOOLSET.md` bytes are not valid UTF-8.
43    #[error("TOOLSET.md is not valid UTF-8")]
44    NotUtf8,
45
46    /// The file does not begin with a `---` YAML frontmatter fence.
47    #[error("TOOLSET.md has no frontmatter (file must begin with '---')")]
48    MissingFrontmatter,
49
50    /// The YAML inside the frontmatter fence could not be parsed.
51    ///
52    /// `detail` is the parser's error message (not a file-content snippet).
53    #[error("TOOLSET.md frontmatter is malformed YAML: {}", sanitise_display(.detail, ECHO_CAP))]
54    MalformedFrontmatter {
55        /// Parser error description.
56        detail: String,
57    },
58
59    /// The frontmatter YAML contains an anchor (`&a`) or alias (`*a`).
60    ///
61    /// Anchors and aliases are forbidden to prevent alias-expansion DoS
62    /// (billion-laughs attack class).  The parser rejects them before any tree
63    /// is materialised.
64    #[error("TOOLSET.md frontmatter contains YAML anchors or aliases (forbidden)")]
65    YamlAnchorsForbidden,
66
67    /// The frontmatter nesting depth exceeds the limit of 8.
68    ///
69    /// Deeply-nested input is refused before full materialisation to prevent
70    /// stack-overflow or runaway recursion during mapping.
71    #[error("TOOLSET.md frontmatter nesting exceeds the maximum depth of 8")]
72    FrontmatterTooDeep,
73
74    /// A mapping key appears more than once in the frontmatter (at the top level
75    /// or inside the `metadata` block).
76    ///
77    /// Duplicate keys are a common confusion vector: a human reviewer sees one
78    /// value while a parser resolves another.  We refuse rather than silently
79    /// resolving to first-wins or last-wins.
80    #[error("TOOLSET.md frontmatter has a duplicate mapping key: {}", sanitise_display(.key, ECHO_CAP))]
81    DuplicateKey {
82        /// The key that appeared more than once.
83        key: String,
84    },
85
86    /// A `metadata` key begins with the reserved prefix `stellar-agent-` but is
87    /// not a recognised wallet key.
88    ///
89    /// Currently the only recognised wallet-reserved key is
90    /// `stellar-agent-capabilities`.  Any other `stellar-agent-`-prefixed key is
91    /// refused to prevent future collision with wallet-defined extensions.
92    #[error(
93        "TOOLSET.md metadata key '{}' uses the reserved 'stellar-agent-' prefix",
94        sanitise_display(.key, ECHO_CAP)
95    )]
96    ReservedMetadataKey {
97        /// The offending metadata key.
98        key: String,
99    },
100
101    /// The `name` field is absent from the frontmatter.
102    #[error("TOOLSET.md frontmatter is missing the required 'name' field")]
103    MissingName,
104
105    /// The `name` field is present but empty.
106    #[error("TOOLSET.md 'name' is empty")]
107    NameEmpty,
108
109    /// The `name` field exceeds 64 characters.
110    #[error("TOOLSET.md 'name' exceeds 64 characters")]
111    NameTooLong,
112
113    /// The `name` field contains a character outside `[a-z0-9-]`.
114    ///
115    /// The agentskills spec's "unicode lowercase alphanumeric" wording is resolved
116    /// in favour of the ASCII range it explicitly enumerates (`a-z`, `0-9`).
117    /// Non-ASCII characters — including unicode homoglyphs of ASCII letters — are
118    /// refused here rather than silently normalised.
119    #[error("TOOLSET.md 'name' contains a character outside [a-z0-9-]")]
120    NameInvalidChar,
121
122    /// The `name` field starts or ends with a hyphen.
123    #[error("TOOLSET.md 'name' must not start or end with a hyphen")]
124    NameLeadingTrailingHyphen,
125
126    /// The `name` field contains consecutive hyphens (`--`).
127    #[error("TOOLSET.md 'name' must not contain consecutive hyphens ('--')")]
128    NameConsecutiveHyphens,
129
130    /// The `name` field does not match the parent directory name (byte-exact).
131    ///
132    /// Because `name` is constrained to ASCII `[a-z0-9-]` and the comparison is
133    /// byte-exact, a unicode-homoglyph directory name (e.g. containing Cyrillic
134    /// look-alikes) always fails here rather than matching by visual equivalence.
135    #[error(
136        "TOOLSET.md 'name' ('{}') does not match the toolset directory name ('{}')",
137        sanitise_display(.name, ECHO_CAP),
138        sanitise_display(.dir, ECHO_CAP),
139    )]
140    NameDirMismatch {
141        /// The `name` value from the frontmatter.
142        name: String,
143        /// The actual directory name the toolset was loaded from.
144        dir: String,
145    },
146
147    /// The `description` field is absent from the frontmatter.
148    #[error("TOOLSET.md frontmatter is missing the required 'description' field")]
149    MissingDescription,
150
151    /// The `description` field is present but empty (or whitespace-only).
152    #[error("TOOLSET.md 'description' must not be empty")]
153    DescriptionEmpty,
154
155    /// The `description` field exceeds 1024 characters.
156    #[error("TOOLSET.md 'description' exceeds 1024 characters")]
157    DescriptionTooLong,
158
159    /// The `compatibility` field (if present) exceeds 500 characters.
160    #[error("TOOLSET.md 'compatibility' exceeds 500 characters")]
161    CompatibilityTooLong,
162
163    /// A capability token contains a character outside `[a-z0-9-]`.
164    ///
165    /// The charset gate is applied BEFORE name-matching so that no casing variant
166    /// of a recognised or forbidden token can reach the matching step.
167    /// `Sign-Transaction`, `SIGN-TRANSACTION`, `sign_transaction`, a tab-padded
168    /// token, or a unicode-homoglyph are each refused here.
169    #[error(
170        "capability token '{}' contains a character outside [a-z0-9-]",
171        sanitise_display(.token, ECHO_CAP)
172    )]
173    CapabilityTokenInvalidChar {
174        /// The offending token (sanitised, length-capped).
175        token: String,
176    },
177
178    /// A capability token passed the charset gate but is not in the recognised
179    /// taxonomy.
180    ///
181    /// Unknown tokens are refused rather than silently ignored so that a toolset
182    /// author's typo (`reed-balance`) does not silently produce an empty
183    /// capability set.
184    #[error(
185        "capability token '{}' is not in the recognised taxonomy",
186        sanitise_display(.token, ECHO_CAP)
187    )]
188    UnknownCapability {
189        /// The unrecognised token (sanitised, length-capped).
190        token: String,
191    },
192
193    /// The token `sign-transaction` was found in the capability manifest.
194    ///
195    /// Signing is not grantable as a flat capability declaration.  Toolsets may not
196    /// declare `sign-transaction`; the signing path is governed by the first-invoke
197    /// gate and the attestation gate.
198    ///
199    /// This is a distinct error from [`ToolsetFormatError::UnknownCapability`] to
200    /// make the "no bare sign" rule legible: an author who writes
201    /// `sign-transaction` gets a clear explanation rather than a generic
202    /// "unknown token" message.
203    #[error(
204        "capability token 'sign-transaction' is not grantable; signing is \
205        gated and cannot be declared as a flat capability"
206    )]
207    BareSignTransactionForbidden,
208
209    /// The `stellar-agent-capabilities` metadata value is not a YAML string.
210    ///
211    /// The agentskills spec defines `metadata` values as strings; a YAML list or
212    /// mapping value is a spec violation and is refused.
213    #[error(
214        "stellar-agent-capabilities metadata value is malformed: {}",
215        sanitise_display(.detail, ECHO_CAP)
216    )]
217    CapabilityManifestMalformed {
218        /// Description of the malformation.
219        detail: String,
220    },
221}
222
223#[cfg(test)]
224mod tests {
225    #![allow(
226        clippy::unwrap_used,
227        clippy::expect_used,
228        reason = "test-only; panics acceptable in unit tests"
229    )]
230
231    use super::*;
232
233    /// Parity test: locks the closed-set variant count so adding a new variant
234    /// without updating this test causes a compile error.
235    ///
236    /// Update this count when a new variant is added, and add a corresponding
237    /// unit test in `parse` or `capability` that exercises the new variant.
238    #[test]
239    fn variant_count_parity() {
240        // 24 variants. Count the arms in the match below.
241        fn count_variants(e: &ToolsetFormatError) -> u32 {
242            match e {
243                ToolsetFormatError::Io { .. } => 1,
244                ToolsetFormatError::ToolsetFileTooLarge { .. } => 2,
245                ToolsetFormatError::NotUtf8 => 3,
246                ToolsetFormatError::MissingFrontmatter => 4,
247                ToolsetFormatError::MalformedFrontmatter { .. } => 5,
248                ToolsetFormatError::YamlAnchorsForbidden => 6,
249                ToolsetFormatError::FrontmatterTooDeep => 7,
250                ToolsetFormatError::DuplicateKey { .. } => 8,
251                ToolsetFormatError::ReservedMetadataKey { .. } => 9,
252                ToolsetFormatError::MissingName => 10,
253                ToolsetFormatError::NameEmpty => 11,
254                ToolsetFormatError::NameTooLong => 12,
255                ToolsetFormatError::NameInvalidChar => 13,
256                ToolsetFormatError::NameLeadingTrailingHyphen => 14,
257                ToolsetFormatError::NameConsecutiveHyphens => 15,
258                ToolsetFormatError::NameDirMismatch { .. } => 16,
259                ToolsetFormatError::MissingDescription => 17,
260                ToolsetFormatError::DescriptionEmpty => 18,
261                ToolsetFormatError::DescriptionTooLong => 19,
262                ToolsetFormatError::CompatibilityTooLong => 20,
263                ToolsetFormatError::CapabilityTokenInvalidChar { .. } => 21,
264                ToolsetFormatError::UnknownCapability { .. } => 22,
265                ToolsetFormatError::BareSignTransactionForbidden => 23,
266                ToolsetFormatError::CapabilityManifestMalformed { .. } => 24,
267            }
268        }
269
270        // Construct one instance of each variant and call count_variants.
271        let variants: &[ToolsetFormatError] = &[
272            ToolsetFormatError::Io {
273                detail: String::new(),
274            },
275            ToolsetFormatError::ToolsetFileTooLarge { size: 0, cap: 0 },
276            ToolsetFormatError::NotUtf8,
277            ToolsetFormatError::MissingFrontmatter,
278            ToolsetFormatError::MalformedFrontmatter {
279                detail: String::new(),
280            },
281            ToolsetFormatError::YamlAnchorsForbidden,
282            ToolsetFormatError::FrontmatterTooDeep,
283            ToolsetFormatError::DuplicateKey { key: String::new() },
284            ToolsetFormatError::ReservedMetadataKey { key: String::new() },
285            ToolsetFormatError::MissingName,
286            ToolsetFormatError::NameEmpty,
287            ToolsetFormatError::NameTooLong,
288            ToolsetFormatError::NameInvalidChar,
289            ToolsetFormatError::NameLeadingTrailingHyphen,
290            ToolsetFormatError::NameConsecutiveHyphens,
291            ToolsetFormatError::NameDirMismatch {
292                name: String::new(),
293                dir: String::new(),
294            },
295            ToolsetFormatError::MissingDescription,
296            ToolsetFormatError::DescriptionEmpty,
297            ToolsetFormatError::DescriptionTooLong,
298            ToolsetFormatError::CompatibilityTooLong,
299            ToolsetFormatError::CapabilityTokenInvalidChar {
300                token: String::new(),
301            },
302            ToolsetFormatError::UnknownCapability {
303                token: String::new(),
304            },
305            ToolsetFormatError::BareSignTransactionForbidden,
306            ToolsetFormatError::CapabilityManifestMalformed {
307                detail: String::new(),
308            },
309        ];
310
311        let max = variants.iter().map(count_variants).max().unwrap_or(0);
312        // Assert both the highest assigned number AND the array length so that an
313        // out-of-sync constructor array (e.g. a variant added to the enum but not
314        // to `variants`) is caught even if the match numbering looks correct.
315        assert_eq!(
316            variants.len(),
317            24,
318            "constructor array length changed; add the new variant to `variants`"
319        );
320        assert_eq!(max, 24, "variant count changed; update parity test + doc");
321    }
322
323    /// The BareSignTransactionForbidden Display message must describe the refusal
324    /// in user-facing terms without referencing internal development artefacts.
325    #[test]
326    fn bare_sign_transaction_message_is_user_facing() {
327        let msg = ToolsetFormatError::BareSignTransactionForbidden.to_string();
328        assert!(
329            msg.contains("sign-transaction"),
330            "message must name the token: {msg}"
331        );
332        assert!(
333            msg.contains("gated") || msg.contains("not grantable"),
334            "message must explain that signing is gated: {msg}"
335        );
336        // Must not contain internal development references.
337        assert!(
338            !msg.contains("PR-"),
339            "message must not contain PR refs: {msg}"
340        );
341        assert!(
342            !msg.contains("Phase-"),
343            "message must not contain Phase refs: {msg}"
344        );
345    }
346}