Skip to main content

stellar_agent_toolsets/
args_error.rs

1//! Typed error surface for pre-canonicalisation argument validation failures.
2//!
3//! [`ToolsetArgsError`] is the closed-set of all distinct refusal reasons returned
4//! by [`crate::validate_toolset_tool_args`].  This is a SEPARATE error type from
5//! [`crate::ToolsetFormatError`], which is the TOOLSET.md-parse error enum.
6//!
7//! The distinction matters: `ToolsetFormatError` covers static manifest format
8//! violations detected at parse/install time; `ToolsetArgsError` covers runtime
9//! argument-payload violations detected at dispatch time.  They live on different
10//! code paths, are returned to different callers, and carry different semantics.
11//!
12//! ## Redaction discipline
13//!
14//! No variant echoes any attacker-controlled input KEY (the input key string is
15//! not included — only the matched `&'static str` denylist constant is referenced),
16//! and no variant echoes any VALUE byte from the argument payload.  This ensures
17//! that a carefully-crafted argument payload containing secret-shaped content (e.g.
18//! a key that looks like a mnemonic) can never leak through the error Display or
19//! Debug output.
20
21/// All distinct reasons [`crate::validate_toolset_tool_args`] can reject a payload.
22///
23/// The set is `#[non_exhaustive]` — the validator may grow new check classes in
24/// future versions and callers should match with a `_ =>` fallback.
25/// (Unlike [`crate::ToolsetFormatError`], which is exhaustive by design, this type
26/// intentionally carries the `#[non_exhaustive]` attribute.)
27///
28/// # Redaction guarantee
29///
30/// No variant Display output ever contains:
31/// - The inbound argument key string (even the rejected one).
32/// - Any byte from any argument value.
33///
34/// Error messages reference only compile-time `&'static str` constants from the
35/// denylist, ensuring that a crafted payload carrying secret-shaped values cannot
36/// leak through Display.
37#[non_exhaustive]
38#[derive(Debug, thiserror::Error)]
39pub enum ToolsetArgsError {
40    /// The argument payload contains a key that is in the JS-runtime-dangerous
41    /// denylist at some depth (including within arrays).
42    ///
43    /// The `matched_key` field holds the matched `&'static str` constant from
44    /// [`crate::ARGS_KEY_DENYLIST`], NOT the input key string.  This ensures the
45    /// error message never echoes attacker-controlled bytes.
46    ///
47    /// ## Why this class of key is dangerous
48    ///
49    /// When the wallet's JSON output is consumed by a downstream JavaScript agent
50    /// runtime, certain property names have special semantics that can be exploited:
51    ///
52    /// - `toJSON` — custom serialisation hook; overrides `JSON.stringify`.
53    /// - `then` — presence makes an object "thenable", hijacking `await`/`Promise.resolve`.
54    /// - `__proto__` — prototype pollution via `Object.assign` or spread.
55    /// - `constructor` / `prototype` — class-hierarchy tampering.
56    /// - `toString` / `valueOf` — coercion hooks invoked in string + number contexts.
57    /// - `__defineGetter__` / `__defineSetter__` / `__lookupGetter__` /
58    ///   `__lookupSetter__` — `Object.prototype` accessor-injection vectors.
59    ///
60    /// These keys have NO legitimate use in any matrix-tool argument struct
61    /// (`StellarPayArgs`, `StellarPayCommitArgs`, `StellarBalancesArgs`, SEP
62    /// tool args).  Their presence at any depth is unambiguously attacker-authored.
63    #[error(
64        "toolset args payload contains a JS-runtime-dangerous key \
65         (matched denylist constant: {matched_key}); payload rejected"
66    )]
67    DangerousKey {
68        /// The matched `&'static str` constant from [`crate::ARGS_KEY_DENYLIST`].
69        ///
70        /// This is NOT the input key string — it is the constant from the denylist
71        /// that the input key matched.  The Display output is therefore
72        /// attacker-controlled-bytes-free.
73        matched_key: &'static str,
74    },
75
76    /// The argument payload nesting depth exceeds [`crate::TOOLSET_ARGS_MAX_DEPTH`].
77    ///
78    /// Excessively-nested payloads are refused to prevent work-stack exhaustion
79    /// and to bound the cost of the iterative walk.  The depth bound is set
80    /// substantially above the deepest legitimate matrix-tool arg shape
81    /// (see [`crate::TOOLSET_ARGS_MAX_DEPTH`] documentation for the sizing rationale).
82    ///
83    /// `depth` is the exact depth of the first node that exceeded the bound
84    /// (the walk short-circuits at that node and never descends further).
85    #[error(
86        "toolset args payload nesting depth {depth} exceeds the maximum of {max_depth}; \
87         payload rejected"
88    )]
89    NestingTooDeep {
90        /// The exact depth of the first node that exceeded the bound.
91        ///
92        /// The walk short-circuits at this node; no further nodes are visited.
93        depth: usize,
94        /// The depth bound that was exceeded (`TOOLSET_ARGS_MAX_DEPTH`).
95        max_depth: usize,
96    },
97
98    /// The argument payload total node count exceeds [`crate::TOOLSET_ARGS_MAX_NODES`].
99    ///
100    /// Payloads with an excessive number of nodes (deep OR wide) are refused to
101    /// bound the total work performed by the iterative walk.
102    ///
103    /// ## Why this bound is necessary
104    ///
105    /// The depth bound (`TOOLSET_ARGS_MAX_DEPTH`) prevents stack-like traversal cost
106    /// but does not bound WIDTH: a flat object or array with N million elements
107    /// pushes N million references onto the work-stack in a single iteration.  The
108    /// node-count cap closes this O(payload-width) unbounded case.
109    ///
110    /// The MCP transport bounds message size via the frame-size limit, but the
111    /// CLI `--args` consumer does NOT have that guard.  This cap ensures the walk
112    /// is bounded on both transports.
113    ///
114    /// `count_limit` is the only field — no attacker-controlled value is echoed.
115    #[error(
116        "toolset args payload node count exceeds the maximum of {count_limit}; \
117         payload rejected"
118    )]
119    TooManyNodes {
120        /// The node-count limit that was exceeded (`TOOLSET_ARGS_MAX_NODES`).
121        count_limit: usize,
122    },
123}