Skip to main content

stellar_agent_toolsets_runtime/
matrix.rs

1//! Capability→tool matrix, gated capability→tool matrix, and explicit signing
2//! denylist.
3//!
4//! The UNGATED matrix ([`grants_for_capability`] / [`ALL_MATRIX_ENTRIES`]) is
5//! the admission path for non-signing toolset actions. Tools NOT listed here are
6//! default-denied regardless of what capabilities a toolset declares.
7//!
8//! The GATED matrix ([`GATED_MATRIX_ENTRIES`]) is a SEPARATE tier for
9//! signing-adjacent capabilities. It routes `SignPayment → stellar_pay_commit`
10//! ONLY through the first-invoke gate. `stellar_pay_commit` STAYS in
11//! `SIGNING_DENYLIST`; `resolve_action` does NOT resolve `sign-payment`; the
12//! ungated `ALL_MATRIX_ENTRIES` invariant tests iterate the ungated tier only.
13//!
14//! ## Security invariants
15//!
16//! ### Ungated tier
17//!
18//! 1. `{ungated matrix grant tools} ∩ {SIGNING_DENYLIST} = ∅` by literal name.
19//! 2. Every ungated matrix tool name EXISTS in the static `inventory` registry.
20//! 3. Every SIGNING_DENYLIST name EXISTS in the static `inventory` registry
21//!    (so the denylist cannot silently rot into referencing renamed/removed tools).
22//! 4. Every ungated matrix tool has `destructive_hint == false` (transitive-signing lock).
23//!
24//! ### Gated tier
25//!
26//! 5. The gated tool (`stellar_pay_commit`) IS in `SIGNING_DENYLIST` — this is
27//!    INTENTIONAL and is the load-bearing invariant proving the ungated path is
28//!    blocked. The gated tier is NOT in `ALL_MATRIX_ENTRIES`.
29//! 6. The gated tool is reachable ONLY via (four-part check AND a current
30//!    first-invoke grant) — the end-to-end gate is verified by the MCP
31//!    server's integration suite once that crate is added (full tool inventory
32//!    required at link time).
33//! 7. `SignPayment` grant ∩ {sep43/sep53 bare-sign tools} = ∅.
34//! 8. The gated tool exists in the static `inventory` registry.
35//! 9. `{flattened GATED_MATRIX_ENTRIES tools} ⊆ SIGNING_DENYLIST` (structural
36//!    proof that no gated tool is reachable via the ungated path). Asserted by
37//!    `gated_matrix_entries_subset_of_signing_denylist`.
38//!
39//! Inventory-based checks (invariants 2, 3, 4, 8 above) require the full MCP
40//! tool inventory at link time and therefore live in the MCP server's
41//! integration suite once that crate is added.
42//!
43//! **Adding a new tool to the ungated matrix requires:**
44//! - Confirming it is NOT a signing/key/policy-mutation tool.
45//! - Adding it to the appropriate capability grant array below.
46//! - Adding a test that verifies it is NOT in `SIGNING_DENYLIST`.
47//! - Verifying it has `destructive_hint == false` in the registry.
48//!
49//! **Adding a new tool to the GATED matrix requires:**
50//! - Confirming it IS a signing-adjacent tool that should remain in `SIGNING_DENYLIST`.
51//! - Adding the capability → `[tool_name]` entry to `GATED_MATRIX_ENTRIES`.
52//! - The tool MUST NOT appear in `ALL_MATRIX_ENTRIES` (ungated tier).
53//! - A new `ApprovalKind` arm may be required if the grant shape differs.
54//!
55//! ## Adding a signer to an ungated-matrix-listed tool
56//!
57//! If a future implementation of an ungated matrix tool adds signing or
58//! submission behaviour (e.g. `stellar_pay` gets an optional sign flag), REMOVE
59//! that tool from its matrix grant row before merging. The precedent: signing
60//! lives only in `*_commit` / `sep4x_sign_*` / `sep53_sign_*` tools, NEVER in
61//! `stellar_pay`.
62
63use stellar_agent_toolsets::Capability;
64
65// ── ReadBalance grant ─────────────────────────────────────────────────────────
66
67/// Tools granted by [`Capability::ReadBalance`].
68///
69/// - `stellar_balances` — read native XLM + trustline balances.
70pub const READ_BALANCE_GRANTS: &[&str] = &["stellar_balances"];
71
72// ── ProposeTransaction grant ──────────────────────────────────────────────────
73
74/// Tools granted by [`Capability::ProposeTransaction`].
75///
76/// - `stellar_pay` — simulate/build an UNSIGNED payment envelope ONLY.
77///   `stellar_pay_commit` is EXPLICITLY excluded (that is the sign+submit tool).
78/// - `stellar_claim` — simulate/build an UNSIGNED `ClaimClaimableBalance`
79///   envelope ONLY. `stellar_claim_commit` is EXPLICITLY excluded (that is the
80///   sign+submit tool; it is denylist-only, unreachable via toolset routing).
81/// - `stellar_rule_create` — resolve/simulate an agent-proposed
82///   `add_context_rule` installation ONLY (mints a pending approval; installs
83///   nothing). `stellar_rule_create_commit` is EXPLICITLY excluded — that is
84///   the gated sign+submit tool, reachable only via `Capability::SignRuleCreate`'s
85///   gated tier.
86///
87/// **Invariant**: `stellar_pay_commit` / `stellar_claim_commit` /
88/// `stellar_rule_create_commit` MUST NOT appear here. If any propose tool
89/// ever gains an integrated sign step, remove it from this list immediately.
90pub const PROPOSE_TRANSACTION_GRANTS: &[&str] =
91    &["stellar_pay", "stellar_claim", "stellar_rule_create"];
92
93// ── SuggestDestination grant ──────────────────────────────────────────────────
94
95/// Tools granted by [`Capability::SuggestDestination`].
96///
97/// - `stellar_sep47_discover` — SEP-47 claim-discovery (read-only).
98/// - `stellar_sep48_preview_invocation` — SEP-48 typed-preview (read-only).
99/// - `stellar_sep7_parse_uri` — SEP-7 inbound URI parse + verify (read-only).
100pub const SUGGEST_DESTINATION_GRANTS: &[&str] = &[
101    "stellar_sep47_discover",
102    "stellar_sep48_preview_invocation",
103    "stellar_sep7_parse_uri",
104];
105
106// ── ObserveEvent grant ────────────────────────────────────────────────────────
107
108/// Tools granted by [`Capability::ObserveEvent`].
109///
110/// EMPTY: no read-only event/stream tool exists yet. An empty grant is valid —
111/// it simply refuses every action for this capability. A tool will be added
112/// here when the event-stream surface lands.
113pub const OBSERVE_EVENT_GRANTS: &[&str] = &[];
114
115// ── ReadRules grant ───────────────────────────────────────────────────────────
116
117/// Tools granted by [`Capability::ReadRules`].
118///
119/// - `stellar_rules_list` — enumerate active context rules (read-only).
120/// - `stellar_rules_get` — read a single context rule's metadata and, when
121///   exactly one spending-limit policy identifies, its budget snapshot
122///   (read-only).
123///
124/// Separately grantable from `read-balance`: rule visibility and balance
125/// visibility are distinct concerns.
126pub const READ_RULES_GRANTS: &[&str] = &["stellar_rules_list", "stellar_rules_get"];
127
128// ── Flat matrix entries for iteration ────────────────────────────────────────
129
130/// All (action_name, granting_capability) pairs in the matrix.
131///
132/// Used for exhaustive invariant tests and for [`ALL_MATRIX_TOOL_NAMES`].
133pub const ALL_MATRIX_ENTRIES: &[(&str, Capability)] = &[
134    // ReadBalance
135    ("stellar_balances", Capability::ReadBalance),
136    // ProposeTransaction
137    ("stellar_pay", Capability::ProposeTransaction),
138    ("stellar_claim", Capability::ProposeTransaction),
139    ("stellar_rule_create", Capability::ProposeTransaction),
140    // SuggestDestination
141    ("stellar_sep47_discover", Capability::SuggestDestination),
142    (
143        "stellar_sep48_preview_invocation",
144        Capability::SuggestDestination,
145    ),
146    ("stellar_sep7_parse_uri", Capability::SuggestDestination),
147    // ObserveEvent — empty; no entries.
148    // ReadRules
149    ("stellar_rules_list", Capability::ReadRules),
150    ("stellar_rules_get", Capability::ReadRules),
151];
152
153/// All tool names that appear in the UNGATED matrix (deduped, in stable order).
154///
155/// This list covers ONLY ungated tools. The gated tool `stellar_pay_commit` is
156/// deliberately absent — it is in `SIGNING_DENYLIST` and in
157/// `GATED_MATRIX_ENTRIES` only.
158pub const ALL_MATRIX_TOOL_NAMES: &[&str] = &[
159    "stellar_balances",
160    "stellar_pay",
161    "stellar_claim",
162    "stellar_rule_create",
163    "stellar_sep47_discover",
164    "stellar_sep48_preview_invocation",
165    "stellar_sep7_parse_uri",
166    "stellar_rules_list",
167    "stellar_rules_get",
168];
169
170// ── GATED capability→tool matrix ─────────────────────────────────────────────
171//
172// THIS IS A SEPARATE TIER from the ungated matrix above. It maps
173// signing-adjacent capabilities that are:
174//   (a) NOT in `ALL_MATRIX_ENTRIES` (the ungated matrix),
175//   (b) NOT reachable via `resolve_action`,
176//   (c) reachable ONLY through the first-invoke gate + per-action approval.
177//
178// `stellar_pay_commit` STAYS in `SIGNING_DENYLIST` — this is load-bearing.
179// The test `gated_matrix_entries_subset_of_signing_denylist` asserts
180// `{flattened GATED_MATRIX_ENTRIES tools} ⊆ SIGNING_DENYLIST`, which is the
181// structural proof that no gated tool is reachable via the ungated path.
182// All gated-tier invariant tests iterate the flattened GATED_MATRIX_ENTRIES
183// directly so they stay in sync with the routing source of truth.
184
185/// The single gated-tier entry for `SignPayment`.
186///
187/// `stellar_pay_commit` routes through the first-invoke gate ONLY. It MUST
188/// remain in `SIGNING_DENYLIST` so the ungated `resolve_action` path is
189/// permanently blocked.
190pub const SIGN_PAYMENT_GATED_TOOLS: &[&str] = &["stellar_pay_commit"];
191
192/// Sentinel `authoritative_asset` value for `resolve_toolset_sign_payment_gated`
193/// calls routing `sign-rule-create` (Package D, GH issue #8).
194///
195/// The `ToolsetFirstInvokeGate` / grant-matching machinery is payment-shaped
196/// (destination + asset + amount bucket); `sign-rule-create` repurposes
197/// `authoritative_destination` to carry the smart-account C-strkey (see
198/// [`SIGN_RULE_CREATE_GATED_TOOLS`]) but has no real "asset" concept, so this
199/// fixed, code:issuer-shaped sentinel is used instead of a real asset. The
200/// issuer half is `stellar_agent_core::constants::SIMULATE_SENTINEL_G` (the
201/// well-known all-zero-key sentinel used elsewhere in this codebase for
202/// simulate-only / non-real account references) — never a real trustline
203/// issuer.
204pub const SIGN_RULE_CREATE_ASSET_SENTINEL: &str =
205    "RULECREATE:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
206
207/// Sentinel `authoritative_amount_stroops` value for `sign-rule-create` gated
208/// calls. The amount dimension carries no independent meaning for rule
209/// creation; a fixed positive value keeps the (required-positive) bucket
210/// check satisfied and makes every `sign-rule-create` grant/invoke land in
211/// the SAME `[0, 1]` bucket, so re-prompting is driven entirely by
212/// `authoritative_destination` (the smart account), not by this value.
213pub const SIGN_RULE_CREATE_AMOUNT_SENTINEL: i64 = 1;
214
215/// The single gated-tier entry for `SignRuleCreate` (Package D, GH issue #8).
216///
217/// `stellar_rule_create_commit` routes through the first-invoke gate ONLY.
218/// It MUST remain in `SIGNING_DENYLIST` so the ungated `resolve_action` path
219/// is permanently blocked.
220pub const SIGN_RULE_CREATE_GATED_TOOLS: &[&str] = &["stellar_rule_create_commit"];
221
222/// All (capability, tool_name) pairs in the GATED matrix.
223///
224/// This structure is iterated by the gated-tier invariant tests and by the
225/// gated resolver to verify the tool name is a known gated constant.
226///
227/// NEVER add a gated entry to `ALL_MATRIX_ENTRIES` — that would bypass the
228/// gate entirely.
229pub const GATED_MATRIX_ENTRIES: &[(Capability, &[&str])] = &[
230    (Capability::SignPayment, SIGN_PAYMENT_GATED_TOOLS),
231    (Capability::SignRuleCreate, SIGN_RULE_CREATE_GATED_TOOLS),
232];
233
234/// Returns the gated grant set for a signing-adjacent capability.
235///
236/// Returns the tool slice if `cap` is a gated capability, or `None` if it is
237/// not gated. Used by the gated resolver's closed-routing invariant check.
238///
239/// # Examples
240///
241/// ```rust
242/// use stellar_agent_toolsets_runtime::matrix::gated_grants_for_capability;
243/// use stellar_agent_toolsets::Capability;
244///
245/// assert!(gated_grants_for_capability(Capability::SignPayment).is_some());
246/// assert!(gated_grants_for_capability(Capability::ReadBalance).is_none());
247/// ```
248#[must_use]
249pub fn gated_grants_for_capability(cap: Capability) -> Option<&'static [&'static str]> {
250    match cap {
251        Capability::SignPayment => Some(SIGN_PAYMENT_GATED_TOOLS),
252        Capability::SignRuleCreate => Some(SIGN_RULE_CREATE_GATED_TOOLS),
253        _ => None,
254    }
255}
256
257// ── Signing / key / policy denylist ──────────────────────────────────────────
258
259/// Explicit by-name denylist of signing, key-derivation, policy-mutation, and
260/// reflexive-escalation tools.
261///
262/// These tools MUST NOT appear in any grant set. Their presence is a
263/// compile-time invariant verified by the `matrix_and_denylist_are_disjoint`
264/// test.
265///
266/// The denylist also includes the generic dispatcher's own tools
267/// (`stellar_toolset_list`, `stellar_toolset_invoke`) to prevent reflexive
268/// escalation — a toolset must not be able to invoke the toolset dispatcher itself.
269///
270/// ## Maintenance rule
271///
272/// When adding a new signing/key/policy MCP tool:
273/// 1. Add it to this list.
274/// 2. Verify it is NOT in any grant array above.
275/// 3. Run `cargo test -p stellar-agent-toolsets-runtime` to confirm the invariant
276///    tests pass.
277pub const SIGNING_DENYLIST: &[&str] = &[
278    // SEP-43 signing tools
279    "stellar_sep43_sign_transaction",
280    "stellar_sep43_sign_and_submit_transaction",
281    "stellar_sep43_sign_auth_entry",
282    "stellar_sep43_sign_message",
283    // SEP-53 sign tool
284    "stellar_sep53_sign_message",
285    // Classic commit (sign+submit) tools
286    "stellar_pay_commit",
287    "stellar_create_account_commit",
288    "stellar_claim_commit",
289    // Agent-proposed context-rule commit (sign+submit) tool
290    "stellar_rule_create_commit",
291    // x402 tools (default-exclude: confirm at impl whether each reaches a signer)
292    "stellar_x402_create_payment",
293    "stellar_x402_parse_receipt",
294    "stellar_x402_authenticated_payment",
295    // Generic toolset dispatcher tools (no reflexive escalation)
296    "stellar_toolset_list",
297    "stellar_toolset_invoke",
298];
299
300/// Returns the UNGATED grant set for a capability.
301///
302/// This is the lookup used by [`resolve_action`] and the listing path.
303/// Signing-adjacent gated capabilities (e.g. [`Capability::SignPayment`])
304/// return an EMPTY slice here — they are NOT ungated and their tool set is
305/// only accessible through the first-invoke gate.
306///
307/// # Examples
308///
309/// ```rust
310/// use stellar_agent_toolsets_runtime::matrix::grants_for_capability;
311/// use stellar_agent_toolsets::Capability;
312///
313/// assert!(grants_for_capability(Capability::ReadBalance).contains(&"stellar_balances"));
314/// // SignPayment is gated — returns empty slice from the ungated path.
315/// assert!(grants_for_capability(Capability::SignPayment).is_empty());
316/// ```
317#[must_use]
318pub fn grants_for_capability(cap: Capability) -> &'static [&'static str] {
319    match cap {
320        Capability::ReadBalance => READ_BALANCE_GRANTS,
321        Capability::ProposeTransaction => PROPOSE_TRANSACTION_GRANTS,
322        Capability::SuggestDestination => SUGGEST_DESTINATION_GRANTS,
323        Capability::ObserveEvent => OBSERVE_EVENT_GRANTS,
324        // SignPayment is gated — ungated path always returns empty.
325        // The gated resolver is the sole admission path.
326        Capability::SignPayment => &[],
327        Capability::ReadRules => READ_RULES_GRANTS,
328        // SignRuleCreate is gated — ungated path always returns empty.
329        // The gated resolver is the sole admission path.
330        Capability::SignRuleCreate => &[],
331        // New variants fail closed (empty grant).
332        _ => &[],
333    }
334}
335
336/// Resolves an `action` string to a `(&'static str, Capability)` pair via the
337/// CLOSED matrix lookup.
338///
339/// Returns `Ok((tool_name, granting_capability))` if the action is in the
340/// matrix, or `Err(ToolsetRuntimeError::UnknownToolsetAction)` otherwise.
341///
342/// This is part (a) + (b) of the four-part enforcement. The returned
343/// `tool_name` is a compile-time constant — it cannot be a toolset-supplied
344/// string.
345///
346/// # Errors
347///
348/// - [`crate::ToolsetRuntimeError::UnknownToolsetAction`] — the action is not in
349///   the matrix.
350pub fn resolve_action(
351    action: &str,
352) -> Result<(&'static str, Capability), crate::ToolsetRuntimeError> {
353    for (tool, cap) in ALL_MATRIX_ENTRIES {
354        if *tool == action {
355            return Ok((tool, *cap));
356        }
357    }
358    Err(crate::ToolsetRuntimeError::UnknownToolsetAction {
359        action: stellar_agent_toolsets::sanitise_display(action, 128),
360    })
361}
362
363// ── Unit tests (no inventory dependency needed here) ─────────────────────────
364//
365// Inventory-based invariant tests (matrix tool exists in registry, denylist
366// tools exist in registry, destructive_hint checks) require the full MCP tool
367// inventory at link time and live in the MCP server's integration suite.
368
369#[cfg(test)]
370#[allow(
371    clippy::unwrap_used,
372    clippy::expect_used,
373    clippy::panic,
374    reason = "test-only; panics acceptable in unit tests"
375)]
376mod tests {
377    use super::*;
378
379    // ── Invariant 1 (pure names, no registry): matrix ∩ denylist = ∅ ─────────
380
381    #[test]
382    fn matrix_and_denylist_are_disjoint() {
383        use std::collections::HashSet;
384        let denylist: HashSet<&str> = SIGNING_DENYLIST.iter().copied().collect();
385        for (tool, cap) in ALL_MATRIX_ENTRIES {
386            assert!(
387                !denylist.contains(tool),
388                "matrix tool '{tool}' (granting {cap:?}) appears in SIGNING_DENYLIST — \
389                 this violates the disjoint invariant"
390            );
391        }
392    }
393
394    // ── ALL_MATRIX_ENTRIES covers all per-capability grant arrays ─────────────
395    //
396    // Prevents a grant added to a per-capability array from escaping the
397    // ALL_MATRIX_ENTRIES iteration used for invariant tests.
398
399    #[test]
400    fn all_matrix_entries_covers_every_per_capability_grant() {
401        use std::collections::HashSet;
402
403        // Collect all tools from per-capability grant arrays.
404        let mut from_grants: HashSet<&str> = HashSet::new();
405        for t in READ_BALANCE_GRANTS {
406            from_grants.insert(t);
407        }
408        for t in PROPOSE_TRANSACTION_GRANTS {
409            from_grants.insert(t);
410        }
411        for t in SUGGEST_DESTINATION_GRANTS {
412            from_grants.insert(t);
413        }
414        for t in OBSERVE_EVENT_GRANTS {
415            from_grants.insert(t);
416        }
417        for t in READ_RULES_GRANTS {
418            from_grants.insert(t);
419        }
420
421        // Collect all tools from ALL_MATRIX_ENTRIES.
422        let from_entries: HashSet<&str> = ALL_MATRIX_ENTRIES.iter().map(|(t, _)| *t).collect();
423
424        // Every tool in per-capability grants must be in ALL_MATRIX_ENTRIES.
425        for t in &from_grants {
426            assert!(
427                from_entries.contains(t),
428                "tool '{t}' is in a per-capability grant array but NOT in \
429                 ALL_MATRIX_ENTRIES — add it to keep single source of truth"
430            );
431        }
432
433        // Every tool in ALL_MATRIX_ENTRIES must be in some per-capability grant array.
434        for t in &from_entries {
435            assert!(
436                from_grants.contains(t),
437                "tool '{t}' is in ALL_MATRIX_ENTRIES but NOT in any per-capability \
438                 grant array — ALL_MATRIX_ENTRIES must be derived from the grant arrays"
439            );
440        }
441    }
442
443    // ── ALL_MATRIX_TOOL_NAMES covers every ALL_MATRIX_ENTRIES tool ────────────
444
445    #[test]
446    fn all_matrix_tool_names_covers_every_matrix_entry() {
447        use std::collections::HashSet;
448        let from_entries: HashSet<&str> = ALL_MATRIX_ENTRIES.iter().map(|(t, _)| *t).collect();
449        let from_names: HashSet<&str> = ALL_MATRIX_TOOL_NAMES.iter().copied().collect();
450        for t in &from_entries {
451            assert!(
452                from_names.contains(t),
453                "tool '{t}' is in ALL_MATRIX_ENTRIES but NOT in ALL_MATRIX_TOOL_NAMES"
454            );
455        }
456        for t in &from_names {
457            assert!(
458                from_entries.contains(t),
459                "tool '{t}' is in ALL_MATRIX_TOOL_NAMES but NOT in ALL_MATRIX_ENTRIES"
460            );
461        }
462    }
463
464    // ── resolve_action: happy paths ───────────────────────────────────────────
465
466    #[test]
467    fn resolve_read_balance() {
468        let (tool, cap) = resolve_action("stellar_balances").unwrap();
469        assert_eq!(tool, "stellar_balances");
470        assert_eq!(cap, Capability::ReadBalance);
471    }
472
473    #[test]
474    fn resolve_stellar_pay() {
475        let (tool, cap) = resolve_action("stellar_pay").unwrap();
476        assert_eq!(tool, "stellar_pay");
477        assert_eq!(cap, Capability::ProposeTransaction);
478    }
479
480    #[test]
481    fn resolve_stellar_rule_create() {
482        let (tool, cap) = resolve_action("stellar_rule_create").unwrap();
483        assert_eq!(tool, "stellar_rule_create");
484        assert_eq!(cap, Capability::ProposeTransaction);
485    }
486
487    #[test]
488    fn resolve_suggest_destination_tools() {
489        for tool in [
490            "stellar_sep47_discover",
491            "stellar_sep48_preview_invocation",
492            "stellar_sep7_parse_uri",
493        ] {
494            let (resolved, cap) = resolve_action(tool).unwrap();
495            assert_eq!(resolved, tool);
496            assert_eq!(cap, Capability::SuggestDestination);
497        }
498    }
499
500    #[test]
501    fn resolve_read_rules_tools() {
502        for tool in ["stellar_rules_list", "stellar_rules_get"] {
503            let (resolved, cap) = resolve_action(tool).unwrap();
504            assert_eq!(resolved, tool);
505            assert_eq!(cap, Capability::ReadRules);
506        }
507    }
508
509    // ── resolve_action: signing tools not in matrix ───────────────────────────
510
511    #[test]
512    fn signing_tools_not_in_matrix() {
513        for tool in SIGNING_DENYLIST {
514            let result = resolve_action(tool);
515            assert!(
516                result.is_err(),
517                "signing/denylist tool '{tool}' must NOT resolve via the matrix"
518            );
519        }
520    }
521
522    // ── ObserveEvent grant is empty ───────────────────────────────────────────
523
524    #[test]
525    fn observe_event_grant_is_empty() {
526        assert!(
527            OBSERVE_EVENT_GRANTS.is_empty(),
528            "ObserveEvent grant must be empty (no event-stream tool exists yet)"
529        );
530    }
531
532    // ── grants_for_capability returns correct slices ──────────────────────────
533
534    #[test]
535    fn grants_for_read_balance() {
536        let grants = grants_for_capability(Capability::ReadBalance);
537        assert_eq!(grants, READ_BALANCE_GRANTS);
538    }
539
540    #[test]
541    fn grants_for_propose_transaction() {
542        let grants = grants_for_capability(Capability::ProposeTransaction);
543        assert_eq!(grants, PROPOSE_TRANSACTION_GRANTS);
544    }
545
546    #[test]
547    fn grants_for_suggest_destination() {
548        let grants = grants_for_capability(Capability::SuggestDestination);
549        assert_eq!(grants, SUGGEST_DESTINATION_GRANTS);
550    }
551
552    #[test]
553    fn grants_for_observe_event() {
554        let grants = grants_for_capability(Capability::ObserveEvent);
555        assert!(grants.is_empty());
556    }
557
558    #[test]
559    fn grants_for_read_rules() {
560        let grants = grants_for_capability(Capability::ReadRules);
561        assert_eq!(grants, READ_RULES_GRANTS);
562        assert_eq!(grants, &["stellar_rules_list", "stellar_rules_get"]);
563    }
564
565    // ── grants_for_capability: SignPayment returns empty from ungated path ────
566
567    #[test]
568    fn grants_for_sign_payment_ungated_is_empty() {
569        // SignPayment is gated — the ungated path MUST return empty.
570        // Any non-empty result here would mean the gated tool is reachable ungated.
571        let grants = grants_for_capability(Capability::SignPayment);
572        assert!(
573            grants.is_empty(),
574            "SignPayment ungated grant must be empty (gated tier only)"
575        );
576    }
577
578    // ── grants_for_capability: SignRuleCreate returns empty from ungated path ─
579
580    #[test]
581    fn grants_for_sign_rule_create_ungated_is_empty() {
582        let grants = grants_for_capability(Capability::SignRuleCreate);
583        assert!(
584            grants.is_empty(),
585            "SignRuleCreate ungated grant must be empty (gated tier only)"
586        );
587    }
588
589    // ── Gated tier invariants ─────────────────────────────────────────────────
590    //
591    // All tests iterate the flattened GATED_MATRIX_ENTRIES (the routing source
592    // of truth) rather than a separate hand-maintained name list. Adding a new
593    // entry to GATED_MATRIX_ENTRIES automatically covers it in every invariant
594    // check below.
595
596    /// Collect all tool names from GATED_MATRIX_ENTRIES by flattening each
597    /// entry's tool slice. This is the single source of truth for the gated
598    /// tier; it matches exactly what resolve_gated_action iterates.
599    fn flattened_gated_tools() -> Vec<&'static str> {
600        GATED_MATRIX_ENTRIES
601            .iter()
602            .flat_map(|(_, tools)| tools.iter().copied())
603            .collect()
604    }
605
606    // (i) Every gated tool is NOT in the ungated matrix (ALL_MATRIX_ENTRIES).
607    #[test]
608    fn gated_tool_not_in_ungated_matrix() {
609        use std::collections::HashSet;
610        let ungated: HashSet<&str> = ALL_MATRIX_ENTRIES.iter().map(|(t, _)| *t).collect();
611        for tool in flattened_gated_tools() {
612            assert!(
613                !ungated.contains(tool),
614                "gated tool '{tool}' must NOT appear in the ungated ALL_MATRIX_ENTRIES \
615                 (would bypass the first-invoke gate)"
616            );
617        }
618    }
619
620    // (ii) Every gated tool IS in SIGNING_DENYLIST.
621    //      This is the load-bearing structural proof: a tool in SIGNING_DENYLIST
622    //      cannot be reached via the ungated resolve_action path (invariant 1
623    //      ensures the ungated matrix and denylist are disjoint).
624    #[test]
625    fn gated_tool_is_in_signing_denylist() {
626        use std::collections::HashSet;
627        let denylist: HashSet<&str> = SIGNING_DENYLIST.iter().copied().collect();
628        for tool in flattened_gated_tools() {
629            assert!(
630                denylist.contains(tool),
631                "gated tool '{tool}' MUST be in SIGNING_DENYLIST — this is the structural \
632                 proof that the ungated resolve_action path is permanently blocked"
633            );
634        }
635    }
636
637    // (ii-full) {flattened GATED_MATRIX_ENTRIES} ⊆ SIGNING_DENYLIST.
638    //           Checks every gated entry against the denylist in one assertion
639    //           so a new gated tool missing from SIGNING_DENYLIST fails here.
640    #[test]
641    fn gated_matrix_entries_subset_of_signing_denylist() {
642        use std::collections::HashSet;
643        let denylist: HashSet<&str> = SIGNING_DENYLIST.iter().copied().collect();
644        for (cap, tools) in GATED_MATRIX_ENTRIES {
645            for tool in *tools {
646                assert!(
647                    denylist.contains(tool),
648                    "gated tool '{tool}' (capability {cap:?}) is in GATED_MATRIX_ENTRIES \
649                     but NOT in SIGNING_DENYLIST — add it to SIGNING_DENYLIST so the \
650                     ungated resolve_action path is permanently blocked"
651                );
652            }
653        }
654    }
655
656    // (iii) SignPayment ∩ {bare-sign tools (sep43/sep53)} = ∅
657    //       (The gated tool for SignPayment must route to stellar_pay_commit ONLY,
658    //        never to any sep43/sep53 signing tool.)
659    #[test]
660    fn sign_payment_gated_tools_do_not_include_bare_sign_tools() {
661        let bare_sign_tools = [
662            "stellar_sep43_sign_transaction",
663            "stellar_sep43_sign_and_submit_transaction",
664            "stellar_sep43_sign_auth_entry",
665            "stellar_sep43_sign_message",
666            "stellar_sep53_sign_message",
667        ];
668        for bare in &bare_sign_tools {
669            assert!(
670                !SIGN_PAYMENT_GATED_TOOLS.contains(bare),
671                "SignPayment gated tools must NOT include bare-sign tool '{bare}'"
672            );
673        }
674        // Also verify the positive invariant: stellar_pay_commit IS in the gated set.
675        assert!(
676            SIGN_PAYMENT_GATED_TOOLS.contains(&"stellar_pay_commit"),
677            "stellar_pay_commit must be in SIGN_PAYMENT_GATED_TOOLS"
678        );
679    }
680
681    // (iv) resolve_action does NOT resolve any gated tool (ungated path is blocked).
682    #[test]
683    fn resolve_action_does_not_resolve_gated_tools() {
684        for tool in flattened_gated_tools() {
685            let result = resolve_action(tool);
686            assert!(
687                result.is_err(),
688                "gated tool '{tool}' must NOT resolve via the ungated resolve_action path \
689                 (gated resolver is the sole admission)"
690            );
691        }
692    }
693
694    // (v) gated_grants_for_capability returns Some for SignPayment, None for others.
695    #[test]
696    fn gated_grants_for_sign_payment() {
697        let grants = gated_grants_for_capability(Capability::SignPayment);
698        assert!(grants.is_some(), "SignPayment must have gated grants");
699        let grants = grants.unwrap();
700        assert!(grants.contains(&"stellar_pay_commit"));
701    }
702
703    #[test]
704    fn gated_grants_for_ungated_capabilities_is_none() {
705        for cap in [
706            Capability::ReadBalance,
707            Capability::ProposeTransaction,
708            Capability::SuggestDestination,
709            Capability::ObserveEvent,
710        ] {
711            assert!(
712                gated_grants_for_capability(cap).is_none(),
713                "capability {cap:?} must NOT have gated grants"
714            );
715        }
716    }
717
718    // ── SignRuleCreate gated-tier tests (Package D, GH issue #8) ──────────────
719    // Mirror the SignPayment tests above exactly.
720
721    #[test]
722    fn gated_grants_for_sign_rule_create() {
723        let grants = gated_grants_for_capability(Capability::SignRuleCreate);
724        assert!(grants.is_some(), "SignRuleCreate must have gated grants");
725        let grants = grants.unwrap();
726        assert!(grants.contains(&"stellar_rule_create_commit"));
727    }
728
729    #[test]
730    fn sign_rule_create_gated_tools_do_not_include_bare_sign_tools() {
731        let bare_sign_tools = [
732            "stellar_sep43_sign_transaction",
733            "stellar_sep43_sign_and_submit_transaction",
734            "stellar_sep43_sign_auth_entry",
735            "stellar_sep43_sign_message",
736            "stellar_sep53_sign_message",
737        ];
738        for bare in &bare_sign_tools {
739            assert!(
740                !SIGN_RULE_CREATE_GATED_TOOLS.contains(bare),
741                "SignRuleCreate gated tools must NOT include bare-sign tool '{bare}'"
742            );
743        }
744        assert!(
745            SIGN_RULE_CREATE_GATED_TOOLS.contains(&"stellar_rule_create_commit"),
746            "stellar_rule_create_commit must be in SIGN_RULE_CREATE_GATED_TOOLS"
747        );
748    }
749
750    #[test]
751    fn resolve_action_does_not_resolve_stellar_rule_create_commit() {
752        // The gated commit tool must NOT resolve via the ungated resolve_action
753        // path — already covered generically by
754        // `resolve_action_does_not_resolve_gated_tools` (which iterates
755        // `flattened_gated_tools()`), but pinned here explicitly for this
756        // specific tool name.
757        assert!(resolve_action("stellar_rule_create_commit").is_err());
758    }
759}