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 { .. })` when the
124 /// gate fires. Production never constructs this error variant; the MCP
125 /// match arm must fail closed.
126 ///
127 /// # Recovery
128 ///
129 /// 1. The operator reviews the wallet-rendered summary.
130 /// 2. `stellar-agent approve --id <approval_nonce> --profile <name>` is run.
131 /// 3. The toolset re-invokes the same `sign-payment` action.
132 #[error(
133 "toolset.first_invoke_approval_required: first-invoke gate requires operator approval \
134 (approval_nonce={approval_nonce}; run `stellar-agent approve --id {approval_nonce}` \
135 then re-invoke)"
136 )]
137 FirstInvokeApprovalRequired {
138 /// Nonce of the queued `ToolsetFirstInvokeGate` pending approval.
139 ///
140 /// The MCP response MUST surface this nonce so the agent can pass it
141 /// to `stellar-agent approve --id <nonce> --profile <name>`.
142 approval_nonce: String,
143
144 /// Sanitised toolset name (for the human-readable message).
145 toolset_name: String,
146
147 /// Capability token (e.g. `"sign-payment"`).
148 capability: String,
149 },
150
151 /// Gated path: an I/O error occurred accessing the toolset grant store.
152 #[error("toolset.grant_store_error: {detail}")]
153 GrantStoreError {
154 /// Non-secret diagnostic detail.
155 detail: String,
156 },
157
158 /// Gated path: the authoritative payment amount is not positive.
159 ///
160 /// `authoritative_amount_stroops` must be greater than zero. A zero or
161 /// negative value from the decoded envelope is rejected here before any
162 /// grant-store lookup or approval queuing occurs.
163 #[error(
164 "toolset.invalid_authoritative_amount: authoritative_amount_stroops must be > 0 \
165 (got {amount_stroops})"
166 )]
167 InvalidAuthoritativeAmount {
168 /// The non-positive value that was rejected.
169 amount_stroops: i64,
170 },
171}
172
173#[cfg(test)]
174#[allow(
175 clippy::unwrap_used,
176 clippy::expect_used,
177 reason = "test-only; panics acceptable in unit tests"
178)]
179mod tests {
180 use super::*;
181
182 // All variants must produce distinct Display output so wire-code matching is
183 // unambiguous. When adding a new variant, add it to this list.
184
185 #[test]
186 fn all_variants_have_distinct_display() {
187 let variants = [
188 ToolsetRuntimeError::UnknownToolsetAction {
189 action: "test-action".to_owned(),
190 },
191 ToolsetRuntimeError::CapabilityNotDeclared {
192 action: "test-action".to_owned(),
193 capability: "read-balance".to_owned(),
194 },
195 ToolsetRuntimeError::ToolNotAllowed {
196 tool: "stellar_balances".to_owned(),
197 action: "test-action".to_owned(),
198 },
199 ToolsetRuntimeError::ToolsetNotInstalled {
200 name: "test-toolset".to_owned(),
201 },
202 ToolsetRuntimeError::Io("disk error".to_owned()),
203 ToolsetRuntimeError::ContentDigestMismatch {
204 name: "test-toolset".to_owned(),
205 },
206 ToolsetRuntimeError::FirstInvokeApprovalRequired {
207 approval_nonce: "AbCdEfGhIjKlMnOpQrStUv".to_owned(),
208 toolset_name: "test-toolset".to_owned(),
209 capability: "sign-payment".to_owned(),
210 },
211 ToolsetRuntimeError::GrantStoreError {
212 detail: "test error".to_owned(),
213 },
214 ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: 0 },
215 ];
216
217 // Collect display strings and verify they are all distinct.
218 let displays: Vec<String> = variants.iter().map(|v| v.to_string()).collect();
219 let unique: std::collections::HashSet<&str> = displays.iter().map(String::as_str).collect();
220 assert_eq!(
221 unique.len(),
222 variants.len(),
223 "variant Display strings must all be distinct (closed-set parity): {displays:?}"
224 );
225 }
226
227 // Error codes appear in Display output for wire-code matching.
228
229 #[test]
230 fn error_code_prefixes_present() {
231 let e = ToolsetRuntimeError::UnknownToolsetAction {
232 action: "x".to_owned(),
233 };
234 assert!(e.to_string().contains("toolset.unknown_action"));
235
236 let e = ToolsetRuntimeError::CapabilityNotDeclared {
237 action: "x".to_owned(),
238 capability: "y".to_owned(),
239 };
240 assert!(e.to_string().contains("toolset.capability_not_declared"));
241
242 let e = ToolsetRuntimeError::ToolNotAllowed {
243 tool: "t".to_owned(),
244 action: "x".to_owned(),
245 };
246 assert!(e.to_string().contains("toolset.tool_not_allowed"));
247
248 let e = ToolsetRuntimeError::ToolsetNotInstalled {
249 name: "s".to_owned(),
250 };
251 assert!(e.to_string().contains("toolset.not_installed"));
252
253 let e = ToolsetRuntimeError::Io("err".to_owned());
254 assert!(e.to_string().contains("toolset.io_error"));
255
256 let e = ToolsetRuntimeError::ContentDigestMismatch {
257 name: "my-distinct-toolset".to_owned(),
258 };
259 assert!(e.to_string().contains("toolset.content_digest_mismatch"));
260 assert!(
261 e.to_string().contains("my-distinct-toolset"),
262 "toolset name must appear in message"
263 );
264
265 let e = ToolsetRuntimeError::FirstInvokeApprovalRequired {
266 approval_nonce: "AbCdEfGhIjKlMnOpQrStUv".to_owned(),
267 toolset_name: "s".to_owned(),
268 capability: "sign-payment".to_owned(),
269 };
270 assert!(
271 e.to_string()
272 .contains("toolset.first_invoke_approval_required")
273 );
274 assert!(
275 e.to_string().contains("AbCdEfGhIjKlMnOpQrStUv"),
276 "approval_nonce must appear in the error message for agent recovery"
277 );
278
279 let e = ToolsetRuntimeError::GrantStoreError {
280 detail: "test error".to_owned(),
281 };
282 assert!(e.to_string().contains("toolset.grant_store_error"));
283
284 let e = ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: -1 };
285 assert!(
286 e.to_string()
287 .contains("toolset.invalid_authoritative_amount")
288 );
289 assert!(
290 e.to_string().contains("-1"),
291 "rejected amount must appear in error message"
292 );
293 }
294}