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}