Skip to main content

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}