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}