stellar_agent_toolsets_runtime/error.rs
1//! Closed-set typed error variants for toolset capability enforcement.
2//!
3//! Each variant corresponds to exactly one failure mode in the four-part
4//! enforcement check. No variant leaks filesystem paths, private keys, or
5//! internal implementation details.
6
7use thiserror::Error;
8
9/// Typed refusal errors for toolset capability enforcement.
10///
11/// Closed set: one variant per failure mode in the four-part check.
12/// All author-controlled string fields are pre-sanitised by callers via
13/// [`stellar_agent_toolsets::sanitise_display`] before being stored here.
14///
15/// # Variants
16///
17/// | Variant | Four-part step | Description |
18/// |---------|---------------|-------------|
19/// | [`ToolsetRuntimeError::UnknownToolsetAction`] | (a) | Action not in matrix. |
20/// | [`ToolsetRuntimeError::CapabilityNotDeclared`] | (c) | Granting capability not in toolset's `CapabilitySet`. |
21/// | [`ToolsetRuntimeError::ToolNotAllowed`] | (d) | Tool excluded by toolset's `allowed_tools` narrowing. |
22/// | [`ToolsetRuntimeError::ToolsetNotInstalled`] | pre-check | Toolset name has no pin record. |
23/// | [`ToolsetRuntimeError::Io`] | pre-check | I/O error reading the pin. |
24/// | [`ToolsetRuntimeError::ContentDigestMismatch`] | pre-check | On-disk `TOOLSET.md` hash differs from install-time digest. |
25#[derive(Debug, Error)]
26#[non_exhaustive]
27pub enum ToolsetRuntimeError {
28 /// Part (a): the action name is not in the capability→tool matrix.
29 ///
30 /// The action is either a signing/key/policy tool (explicitly excluded),
31 /// a dispatcher tool (`stellar_toolset_list` / `stellar_toolset_invoke`), or
32 /// simply not a recognised wallet tool name.
33 ///
34 /// ## Security note
35 ///
36 /// This variant fires before any capability check — a toolset cannot probe
37 /// whether a signing tool "would be allowed" by its capabilities.
38 #[error("toolset.unknown_action: action '{action}' is not in the capability→tool matrix")]
39 UnknownToolsetAction {
40 /// Sanitised action name from the invocation request.
41 action: String,
42 },
43
44 /// Part (c): the action's granting capability is not in the toolset's
45 /// declared [`stellar_agent_toolsets::CapabilitySet`].
46 ///
47 /// The toolset would need to declare the named capability to invoke this action.
48 #[error(
49 "toolset.capability_not_declared: action '{action}' requires capability \
50 '{capability}' which this toolset did not declare"
51 )]
52 CapabilityNotDeclared {
53 /// Sanitised action name.
54 action: String,
55 /// Display token of the required capability (e.g. `"read-balance"`).
56 capability: String,
57 },
58
59 /// Part (d): the resolved tool is not in the toolset's `allowed_tools` list.
60 ///
61 /// The toolset's `allowed_tools` narrows the capability grant — it can only
62 /// subtract, never add. The tool is grantable by the toolset's declared
63 /// capabilities but has been excluded by the intersective narrowing.
64 #[error(
65 "toolset.tool_not_allowed: tool '{tool}' is not in this toolset's allowed_tools list \
66 (action '{action}')"
67 )]
68 ToolNotAllowed {
69 /// Sanitised registry tool name.
70 tool: String,
71 /// Sanitised action name.
72 action: String,
73 },
74
75 /// Pre-check: the toolset name has no pin record in the toolsets directory.
76 ///
77 /// The toolset is not installed, or has been uninstalled since the invocation
78 /// was queued.
79 #[error("toolset.not_installed: toolset '{name}' is not installed")]
80 ToolsetNotInstalled {
81 /// Sanitised toolset package name.
82 name: String,
83 },
84
85 /// Pre-check: an I/O error occurred while reading the pin record.
86 #[error("toolset.io_error: {0}")]
87 Io(String),
88
89 /// Pre-check: the on-disk `TOOLSET.md` SHA-256 digest does not match the
90 /// digest recorded in the pin at install time.
91 ///
92 /// Fires when the pin's `toolset_md_shasum` field is `Some` and the current
93 /// file's hash differs. This indicates post-install modification of the
94 /// manifest file.
95 ///
96 /// ## Recovery
97 ///
98 /// Reinstall the toolset from the signed package:
99 /// ```text
100 /// stellar-agent toolset install <package> --force
101 /// ```
102 ///
103 /// ## Security note
104 ///
105 /// The capability-source invariant (capabilities are read from the pin,
106 /// never re-parsed from the on-disk `TOOLSET.md`) ensures that a tampered
107 /// manifest CANNOT escalate capabilities even before this check fires.
108 /// This check adds tamper-evidence for the manifest text and refuses
109 /// dispatch to surface the incident rather than silently continuing with
110 /// stale metadata.
111 #[error(
112 "toolset.content_digest_mismatch: TOOLSET.md for '{name}' has been modified since install \
113 (dispatch-time content digest check failed; reinstall the toolset to resolve)"
114 )]
115 ContentDigestMismatch {
116 /// Sanitised toolset package name.
117 name: String,
118 },
119
120 /// Gated path: the first-invoke gate requires out-of-band approval.
121 ///
122 /// The gated resolver (`resolve_toolset_sign_payment_gated`) returns
123 /// `Ok(GatedResolveOutcome::FirstInvokeApprovalRequired { .. })` — NOT this
124 /// error variant — when the gate fires. This variant is produced by the
125 /// MCP/CLI consumer layer when it surfaces that gate outcome to the client as
126 /// a typed error response. It carries the same `approval_nonce`, `toolset_name`,
127 /// and `capability` from the `GatedResolveOutcome`.
128 ///
129 /// # Recovery
130 ///
131 /// 1. The operator reviews the wallet-rendered summary.
132 /// 2. `stellar-agent approve --id <approval_nonce>` is run.
133 /// 3. The toolset re-invokes the same `sign-payment` action.
134 #[error(
135 "toolset.first_invoke_approval_required: first-invoke gate requires operator approval \
136 (approval_nonce={approval_nonce}; run `stellar-agent approve --id {approval_nonce}` \
137 then re-invoke)"
138 )]
139 FirstInvokeApprovalRequired {
140 /// Nonce of the queued `ToolsetFirstInvokeGate` pending approval.
141 ///
142 /// The MCP response MUST surface this nonce so the agent can pass it
143 /// to `stellar-agent approve --id <nonce>`.
144 approval_nonce: String,
145
146 /// Sanitised toolset name (for the human-readable message).
147 toolset_name: String,
148
149 /// Capability token (e.g. `"sign-payment"`).
150 capability: String,
151 },
152
153 /// Gated path: an I/O error occurred accessing the toolset grant store.
154 #[error("toolset.grant_store_error: {detail}")]
155 GrantStoreError {
156 /// Non-secret diagnostic detail.
157 detail: String,
158 },
159
160 /// Gated path: the authoritative payment amount is not positive.
161 ///
162 /// `authoritative_amount_stroops` must be greater than zero. A zero or
163 /// negative value from the decoded envelope is rejected here before any
164 /// grant-store lookup or approval queuing occurs.
165 #[error(
166 "toolset.invalid_authoritative_amount: authoritative_amount_stroops must be > 0 \
167 (got {amount_stroops})"
168 )]
169 InvalidAuthoritativeAmount {
170 /// The non-positive value that was rejected.
171 amount_stroops: i64,
172 },
173}
174
175#[cfg(test)]
176#[allow(
177 clippy::unwrap_used,
178 clippy::expect_used,
179 reason = "test-only; panics acceptable in unit tests"
180)]
181mod tests {
182 use super::*;
183
184 // All variants must produce distinct Display output so wire-code matching is
185 // unambiguous. When adding a new variant, add it to this list.
186
187 #[test]
188 fn all_variants_have_distinct_display() {
189 let variants = [
190 ToolsetRuntimeError::UnknownToolsetAction {
191 action: "test-action".to_owned(),
192 },
193 ToolsetRuntimeError::CapabilityNotDeclared {
194 action: "test-action".to_owned(),
195 capability: "read-balance".to_owned(),
196 },
197 ToolsetRuntimeError::ToolNotAllowed {
198 tool: "stellar_balances".to_owned(),
199 action: "test-action".to_owned(),
200 },
201 ToolsetRuntimeError::ToolsetNotInstalled {
202 name: "test-toolset".to_owned(),
203 },
204 ToolsetRuntimeError::Io("disk error".to_owned()),
205 ToolsetRuntimeError::ContentDigestMismatch {
206 name: "test-toolset".to_owned(),
207 },
208 ToolsetRuntimeError::FirstInvokeApprovalRequired {
209 approval_nonce: "AbCdEfGhIjKlMnOpQrStUv".to_owned(),
210 toolset_name: "test-toolset".to_owned(),
211 capability: "sign-payment".to_owned(),
212 },
213 ToolsetRuntimeError::GrantStoreError {
214 detail: "test error".to_owned(),
215 },
216 ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: 0 },
217 ];
218
219 // Collect display strings and verify they are all distinct.
220 let displays: Vec<String> = variants.iter().map(|v| v.to_string()).collect();
221 let unique: std::collections::HashSet<&str> = displays.iter().map(String::as_str).collect();
222 assert_eq!(
223 unique.len(),
224 variants.len(),
225 "variant Display strings must all be distinct (closed-set parity): {displays:?}"
226 );
227 }
228
229 // Error codes appear in Display output for wire-code matching.
230
231 #[test]
232 fn error_code_prefixes_present() {
233 let e = ToolsetRuntimeError::UnknownToolsetAction {
234 action: "x".to_owned(),
235 };
236 assert!(e.to_string().contains("toolset.unknown_action"));
237
238 let e = ToolsetRuntimeError::CapabilityNotDeclared {
239 action: "x".to_owned(),
240 capability: "y".to_owned(),
241 };
242 assert!(e.to_string().contains("toolset.capability_not_declared"));
243
244 let e = ToolsetRuntimeError::ToolNotAllowed {
245 tool: "t".to_owned(),
246 action: "x".to_owned(),
247 };
248 assert!(e.to_string().contains("toolset.tool_not_allowed"));
249
250 let e = ToolsetRuntimeError::ToolsetNotInstalled {
251 name: "s".to_owned(),
252 };
253 assert!(e.to_string().contains("toolset.not_installed"));
254
255 let e = ToolsetRuntimeError::Io("err".to_owned());
256 assert!(e.to_string().contains("toolset.io_error"));
257
258 let e = ToolsetRuntimeError::ContentDigestMismatch {
259 name: "my-distinct-toolset".to_owned(),
260 };
261 assert!(e.to_string().contains("toolset.content_digest_mismatch"));
262 assert!(
263 e.to_string().contains("my-distinct-toolset"),
264 "toolset name must appear in message"
265 );
266
267 let e = ToolsetRuntimeError::FirstInvokeApprovalRequired {
268 approval_nonce: "AbCdEfGhIjKlMnOpQrStUv".to_owned(),
269 toolset_name: "s".to_owned(),
270 capability: "sign-payment".to_owned(),
271 };
272 assert!(
273 e.to_string()
274 .contains("toolset.first_invoke_approval_required")
275 );
276 assert!(
277 e.to_string().contains("AbCdEfGhIjKlMnOpQrStUv"),
278 "approval_nonce must appear in the error message for agent recovery"
279 );
280
281 let e = ToolsetRuntimeError::GrantStoreError {
282 detail: "test error".to_owned(),
283 };
284 assert!(e.to_string().contains("toolset.grant_store_error"));
285
286 let e = ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: -1 };
287 assert!(
288 e.to_string()
289 .contains("toolset.invalid_authoritative_amount")
290 );
291 assert!(
292 e.to_string().contains("-1"),
293 "rejected amount must appear in error message"
294 );
295 }
296}