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 { .. })` — 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}