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}