Skip to main content

stellar_agent_toolsets_runtime/
lib.rs

1//! Capability→tool matrix, gated resolver, and four-part enforcement for
2//! installed toolsets.
3//!
4//! This crate is the toolset isolation boundary for the Stellar agent wallet. It
5//! provides:
6//!
7//! - [`matrix::grants_for_capability`] — the static `Capability → &[&'static str]`
8//!   UNGATED allowlist of trusted tool names a capability grants.
9//! - [`GATED_MATRIX_ENTRIES`] — the SEPARATE gated tier for
10//!   signing-adjacent capabilities.
11//! - [`SIGNING_DENYLIST`] — the explicit by-name denylist of signing/key/policy
12//!   tools that are NEVER grantable regardless of declared capabilities.
13//! - [`ToolsetRuntimeError`] — closed-set typed refusal variants.
14//! - [`check_toolset_action`] — the four-part enforcement function (ungated).
15//! - [`resolve_toolset_sign_payment_gated`] — the GATED resolver entry point;
16//!   a distinct entry point from [`resolve_toolset_and_check`], NOT a
17//!   `route_to_matrix_tool` arm.
18//! - [`list_pinned_toolsets`] — enumerate installed toolsets + their declared actions.
19//!
20//! ## Security guarantees
21//!
22//! **Signing isolation is STRUCTURAL**: the ungated capability→tool matrix
23//! ([`matrix::grants_for_capability`]) contains no signing/key/policy tool
24//! regardless of any capability declaration. Even a toolset declaring every
25//! capability cannot reach a signing tool via the ungated path. This is the
26//! isolation boundary between the toolset guest code and the wallet's signing
27//! infrastructure.
28//!
29//! **Gated signing path**: `stellar_pay_commit` is reachable ONLY through
30//! `resolve_toolset_sign_payment_gated`, which requires BOTH:
31//! 1. The four-part check (toolset declared `sign-payment`; the gated action
32//!    resolves; tool ∈ `allowed_tools`).
33//! 2. A current, matching first-invoke grant in the [`ToolsetGrantStore`].
34//!
35//! A `DispatchOutcome::Allow` from the policy engine is OVERRIDDEN to
36//! `RequireApproval` for all toolset-routed payments (unconditional per-action
37//! approval).
38//!
39//! ## Primary consumers
40//!
41//! The MCP server crate consumes `stellar_toolset_list`, `stellar_toolset_invoke`,
42//! and the gated `stellar_toolset_invoke` path routing to `stellar_pay_commit`.
43//! The CLI crate consumes `toolset list` and `toolset run <name> <action>`.
44//!
45//! ## What this crate does NOT do
46//!
47//! - Per-action attestation verification gate — performed by the consumer
48//!   (CLI/MCP) layer, not this crate. This crate DOES build HMAC-attested
49//!   grants via [`record_first_invoke_grant`]; the verification of those
50//!   grants on each action invocation is the consumer's responsibility.
51//! - Dynamic tool registration — explicitly out of scope.
52
53#![forbid(unsafe_code)]
54#![deny(missing_docs)]
55
56pub mod error;
57pub mod matrix;
58
59pub use error::ToolsetRuntimeError;
60pub use matrix::{
61    GATED_MATRIX_ENTRIES, SIGN_PAYMENT_GATED_TOOLS, SIGNING_DENYLIST, gated_grants_for_capability,
62    resolve_action,
63};
64
65use std::path::Path;
66
67use serde::Serialize;
68use stellar_agent_core::approval::{
69    DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_BACKOFF, DEFAULT_TTL_MS, PendingApproval,
70    TOOLSET_GRANT_DEFAULT_TTL_MS, ToolsetGrant, ToolsetGrantStore, build_attested_grant,
71    default_toolset_grants_path, open_with_retry,
72};
73use stellar_agent_toolsets::{Capability, CapabilitySet, sanitise_display};
74use stellar_agent_toolsets_install::ToolsetPinRecord;
75use tracing::debug;
76
77// ── Public API ────────────────────────────────────────────────────────────────
78
79/// A single installed-toolset entry as returned by [`list_pinned_toolsets`].
80///
81/// All string fields are run through [`sanitise_display`] before being stored
82/// in this struct. The filesystem path of the installation is NEVER included.
83#[derive(Debug, Clone, Serialize)]
84pub struct ToolsetListEntry {
85    /// Sanitised toolset package name.
86    pub name: String,
87    /// Always empty; reserved for a future human-readable summary.
88    ///
89    /// `ToolsetPinRecord` carries only the fields needed for enforcement and does
90    /// not store a description, so this is populated as an empty string.
91    pub description: String,
92    /// Declared capabilities (display tokens, sorted).
93    pub capabilities: Vec<String>,
94    /// Intersective `allowed_tools` from the pin record (sanitised).
95    ///
96    /// An empty list means the toolset did not declare `allowed_tools`, so the
97    /// full capability grant applies. A non-empty list further restricts which
98    /// tools within a capability grant the toolset may reach.
99    pub allowed_tools: Vec<String>,
100    /// Installed version.
101    pub version: String,
102    /// Tool names reachable through the UNGATED matrix for this toolset's capabilities.
103    ///
104    /// Enumerates only tools from the ungated capability→tool matrix
105    /// ([`matrix::grants_for_capability`]), optionally filtered by `allowed_tools`.
106    /// Gated tools (e.g. `stellar_pay_commit` for `sign-payment`) are reachable
107    /// solely through the first-invoke gated path and are intentionally NOT listed
108    /// here — the declared capability itself is visible in the `capabilities` field.
109    pub actions: Vec<String>,
110}
111
112/// Reads all pinned toolset installs from `toolsets_root` and returns their
113/// [`ToolsetListEntry`] records.
114///
115/// Walks `toolsets_root` for subdirectories, attempts to read the pin record
116/// from each, and skips entries whose pin files are absent (logged at debug)
117/// or malformed (logged at warn). This is intentionally resilient so a single
118/// corrupted install does not block listing all others.
119///
120/// The returned list is sorted by toolset name for deterministic output.
121///
122/// # Errors
123///
124/// - [`ToolsetRuntimeError::Io`] if `toolsets_root` itself cannot be read.
125pub fn list_pinned_toolsets(
126    toolsets_root: &Path,
127) -> Result<Vec<ToolsetListEntry>, ToolsetRuntimeError> {
128    // If the toolsets_root does not exist yet, return an empty list rather than
129    // an error — it's valid to have no toolsets installed.
130    if !toolsets_root.exists() {
131        return Ok(Vec::new());
132    }
133
134    let read_dir = std::fs::read_dir(toolsets_root)
135        .map_err(|e| ToolsetRuntimeError::Io(e.kind().to_string()))?;
136
137    let mut entries: Vec<ToolsetListEntry> = Vec::new();
138
139    for dir_entry in read_dir {
140        let dir_entry = match dir_entry {
141            Ok(e) => e,
142            Err(e) => {
143                tracing::warn!(error = %e, "error reading toolsets_root entry; skipping");
144                continue;
145            }
146        };
147
148        let path = dir_entry.path();
149        if !path.is_dir() {
150            continue;
151        }
152
153        let pkg_name = match path.file_name().and_then(|n| n.to_str()) {
154            Some(n) => n.to_owned(),
155            None => continue,
156        };
157
158        let pin = match stellar_agent_toolsets_install::read_pin(&pkg_name, toolsets_root) {
159            Ok(Some(p)) => p,
160            Ok(None) => {
161                debug!(package = %pkg_name, "no pin record; skipping");
162                continue;
163            }
164            Err(e) => {
165                tracing::warn!(package = %pkg_name, error = %e, "malformed pin; skipping");
166                continue;
167            }
168        };
169
170        entries.push(pin_to_list_entry(&pin));
171    }
172
173    entries.sort_by(|a, b| a.name.cmp(&b.name));
174    Ok(entries)
175}
176
177/// Four-part enforcement check for a toolset action.
178///
179/// Returns the `&'static str` registry tool name `T` that the action resolves
180/// to when ALL four parts pass. The caller MUST route through the returned
181/// constant — NOT through any toolset-supplied string.
182///
183/// ## Four-part logic
184///
185/// (a) The `action` name resolves — via a CLOSED lookup against the matrix —
186///     to a registry tool-name constant `T` (`&'static str`).
187///
188/// (b) `T` is in the grant set of some capability `C`.
189///
190/// (c) `C` is in the toolset's declared [`CapabilitySet`] (from the pin).
191///
192/// (d) `T` is in the toolset's `allowed_tools` (intersective narrowing — can
193///     only SUBTRACT from the capability grant, never add). When
194///     `allowed_tools` is empty the narrowing is vacuously satisfied.
195///
196/// SIGNING IS STRUCTURALLY EXCLUDED: the matrix contains no signing/key/policy
197/// tool, so even a toolset with all capabilities declared can never reach a
198/// signer via the ungated path.
199///
200/// The dispatch gate of the routed tool (`dispatch_gate`) runs AFTER this
201/// check — the toolset gate is ADDITIVE, never substitutive.
202///
203/// # Errors
204///
205/// Returns a distinct [`ToolsetRuntimeError`] variant for each failure mode:
206///
207/// - [`ToolsetRuntimeError::UnknownToolsetAction`] — part (a) failed.
208/// - [`ToolsetRuntimeError::CapabilityNotDeclared`] — part (c) failed
209///   (the tool exists in the matrix but no granting capability is declared
210///   by this toolset).
211/// - [`ToolsetRuntimeError::ToolNotAllowed`] — part (d) failed (`allowed_tools`
212///   narrowing excluded the tool).
213pub fn check_toolset_action(
214    action: &str,
215    capabilities: &CapabilitySet,
216    allowed_tools: &[String],
217) -> Result<&'static str, ToolsetRuntimeError> {
218    // Part (a): resolve action → registry constant T via the CLOSED matrix.
219    let (tool_name, granting_capability) = resolve_action(action)?;
220
221    // Part (b) is already satisfied by resolve_action returning a constant
222    // from the matrix — T ∈ grant set of granting_capability by definition.
223
224    // Part (c): granting_capability ∈ toolset's declared CapabilitySet.
225    if !capabilities.contains(granting_capability) {
226        return Err(ToolsetRuntimeError::CapabilityNotDeclared {
227            action: sanitise_display(action, 128),
228            capability: granting_capability.to_string(),
229        });
230    }
231
232    // Part (d): T ∈ allowed_tools (intersective narrowing).
233    // An empty allowed_tools list is vacuously satisfied (no narrowing).
234    if !allowed_tools.is_empty() && !allowed_tools.iter().any(|t| t == tool_name) {
235        return Err(ToolsetRuntimeError::ToolNotAllowed {
236            tool: sanitise_display(tool_name, 128),
237            action: sanitise_display(action, 128),
238        });
239    }
240
241    Ok(tool_name)
242}
243
244/// Resolves and validates a toolset from its pin record, then runs the four-part
245/// enforcement check.
246///
247/// Validates `toolset_name` against the `[a-z0-9-]` charset BEFORE any filesystem
248/// access (path-traversal defence): names containing `/`, `\`, `.`, `..`, or
249/// any character outside `[a-z0-9-]` are rejected immediately with
250/// [`ToolsetRuntimeError::ToolsetNotInstalled`] and produce NO filesystem read.
251///
252/// Reads the pin ONCE from `toolsets_root` (TOCTOU avoidance) and uses the
253/// snapshot for the entire check. Returns `(tool_name, pin)` on success so the
254/// caller can use the pin record for further context.
255///
256/// ## Dispatch-time content re-verification
257///
258/// If the pin's `toolset_md_shasum` field is `Some`, re-reads the on-disk
259/// `TOOLSET.md` and compares its SHA-256 against the recorded digest. A
260/// mismatch returns [`ToolsetRuntimeError::ContentDigestMismatch`] and refuses
261/// dispatch. Pins without this field skip the check — the capability-source
262/// invariant (capabilities from the pin, not re-parsed `TOOLSET.md`) ensures
263/// tampered manifests cannot escalate capabilities regardless.
264///
265/// # Errors
266///
267/// - [`ToolsetRuntimeError::ToolsetNotInstalled`] — `toolset_name` fails charset
268///   validation (`[a-z0-9-]`), or no pin record exists for the name.
269/// - [`ToolsetRuntimeError::Io`] — I/O error reading the pin.
270/// - [`ToolsetRuntimeError::ContentDigestMismatch`] — on-disk `TOOLSET.md` hash
271///   differs from the install-time digest stored in the pin.
272/// - Any [`check_toolset_action`] error.
273pub fn resolve_toolset_and_check(
274    toolset_name: &str,
275    action: &str,
276    toolsets_root: &Path,
277) -> Result<(&'static str, ToolsetPinRecord), ToolsetRuntimeError> {
278    // Validate toolset_name against [a-z0-9-] BEFORE any filesystem access.
279    // Rejects `/`, `\`, `.`, `..`, and all other chars outside the charset.
280    // Uses the same validator as the install path.
281    stellar_agent_toolsets_install::validate_package_name(toolset_name).map_err(|_| {
282        ToolsetRuntimeError::ToolsetNotInstalled {
283            name: sanitise_display(toolset_name, 64),
284        }
285    })?;
286
287    // Read the pin ONCE — snapshot for the entire check (TOCTOU avoidance).
288    // Map ToolsetInstallError to ToolsetRuntimeError::Io using a kind-level message
289    // to avoid leaking the toolsets_root path via full error Display.
290    let pin = stellar_agent_toolsets_install::read_pin(toolset_name, toolsets_root)
291        .map_err(|e| ToolsetRuntimeError::Io(install_error_kind_str(&e)))?
292        .ok_or_else(|| ToolsetRuntimeError::ToolsetNotInstalled {
293            name: sanitise_display(toolset_name, 64),
294        })?;
295
296    // ── Dispatch-time content re-verification ─────────────────────────────────
297    //
298    // If the pin carries a TOOLSET.md digest, re-read the on-disk TOOLSET.md,
299    // recompute SHA-256, and compare. Mismatch → refuse dispatch.
300    //
301    // Both digests are non-secret hex strings; plain string comparison is
302    // correct (no timing attack).
303    //
304    // On I/O error reading TOOLSET.md: refuse dispatch (fail-closed). The toolset
305    // is installed but the manifest is unreadable — something is wrong.
306    if let Some(ref expected_digest) = pin.toolset_md_shasum {
307        let toolset_md_path = toolsets_root.join(toolset_name).join("TOOLSET.md");
308        let toolset_md_bytes = std::fs::read(&toolset_md_path).map_err(|_| {
309            ToolsetRuntimeError::ContentDigestMismatch {
310                name: sanitise_display(toolset_name, 64),
311            }
312        })?;
313        let actual_digest = stellar_agent_toolsets_install::sha256_hex_of(&toolset_md_bytes);
314        if actual_digest != *expected_digest {
315            tracing::warn!(
316                toolset = %toolset_name,
317                "dispatch-time TOOLSET.md content digest mismatch; refusing dispatch"
318            );
319            return Err(ToolsetRuntimeError::ContentDigestMismatch {
320                name: sanitise_display(toolset_name, 64),
321            });
322        }
323        debug!(toolset = %toolset_name, "dispatch-time TOOLSET.md content digest verified");
324    }
325
326    let capabilities = &pin.capabilities;
327    let allowed_tools = &pin.allowed_tools;
328
329    let tool_name = check_toolset_action(action, capabilities, allowed_tools)?;
330
331    Ok((tool_name, pin))
332}
333
334// ── Internal helpers ──────────────────────────────────────────────────────────
335
336/// Intersective `allowed_tools` narrowing: an empty `allowed_tools` grants every
337/// matrix tool; a non-empty one restricts to its listed members.
338///
339/// Shared by the enforcement path ([`check_toolset_action`] part (d)) and the
340/// listing path ([`pin_to_list_entry`]) so both apply one definition of the
341/// narrowing rule and cannot diverge.
342fn allowed_by_narrowing(allowed_tools: &[String], tool: &str) -> bool {
343    allowed_tools.is_empty() || allowed_tools.iter().any(|t| t == tool)
344}
345
346/// Converts a [`ToolsetPinRecord`] into a [`ToolsetListEntry`].
347///
348/// All author-controlled string fields are sanitised before populating the
349/// entry. The installed filesystem path is NEVER included.
350fn pin_to_list_entry(pin: &ToolsetPinRecord) -> ToolsetListEntry {
351    let name = sanitise_display(&pin.package, 64);
352    let version = sanitise_display(&pin.version, 64);
353
354    let capabilities: Vec<String> = pin.capabilities.iter().map(|c| c.to_string()).collect();
355
356    let allowed_tools: Vec<String> = pin
357        .allowed_tools
358        .iter()
359        .map(|t| sanitise_display(t, 128))
360        .collect();
361
362    // Compute the actions this toolset can invoke: for each declared capability,
363    // look up the grant set in the matrix, optionally filter by allowed_tools.
364    let mut actions: Vec<String> = Vec::new();
365    for cap in pin.capabilities.iter() {
366        let grants = matrix::grants_for_capability(cap);
367        for &tool in grants {
368            // If allowed_tools is non-empty, only include tools present in it.
369            if allowed_by_narrowing(&pin.allowed_tools, tool) {
370                let sanitised = sanitise_display(tool, 128);
371                if !actions.contains(&sanitised) {
372                    actions.push(sanitised);
373                }
374            }
375        }
376    }
377    actions.sort();
378
379    // description: always empty. ToolsetPinRecord carries only enforcement fields,
380    // not a description. Reading the on-disk TOOLSET.md here would (a) embed a
381    // filesystem path and (b) re-introduce TOCTOU, so the field remains empty.
382    let description = String::new();
383
384    ToolsetListEntry {
385        name,
386        description,
387        capabilities,
388        allowed_tools,
389        version,
390        actions,
391    }
392}
393
394// ── Gated resolver ────────────────────────────────────────────────────────────
395
396/// Parameters for the gated toolset resolve + first-invoke gate check.
397///
398/// Carries all fields needed by [`resolve_toolset_sign_payment_gated`] in a
399/// single struct to avoid an excessively long argument list.
400#[derive(Debug)]
401pub struct GatedInvokeParams<'a> {
402    /// Package name of the toolset to invoke.
403    pub toolset_name: &'a str,
404    /// Action name to invoke (must map to a gated tool via the gated matrix).
405    pub action: &'a str,
406    /// Root directory of installed toolsets.
407    pub toolsets_root: &'a Path,
408    /// Profile name (used to locate the approval store + grant store).
409    pub profile_name: &'a str,
410    /// Canonical G-strkey destination from the AUTHORITATIVE envelope decode
411    /// (never from toolset-supplied args).
412    pub authoritative_destination: &'a str,
413    /// Full `"code:issuer"` or `"XLM"` asset from the AUTHORITATIVE envelope.
414    pub authoritative_asset: &'a str,
415    /// Payment amount in stroops from the AUTHORITATIVE envelope.
416    pub authoritative_amount_stroops: i64,
417    /// Current time in Unix milliseconds (for TTL checks).
418    pub now_unix_ms: u64,
419    /// Platform-stable user identity (from `process_uid_for_attestation()`).
420    pub process_uid: &'a str,
421    /// Optional override for the approval store directory (test-only).
422    #[cfg(feature = "test-helpers")]
423    pub approval_dir_override: Option<std::path::PathBuf>,
424    /// Optional override for the grant store path (test-only).
425    #[cfg(feature = "test-helpers")]
426    pub grant_store_path_override: Option<std::path::PathBuf>,
427}
428
429/// Result of the gated toolset resolver.
430///
431/// Returned by [`resolve_toolset_sign_payment_gated`].
432#[derive(Debug)]
433pub enum GatedResolveOutcome {
434    /// The gated tool name (`"stellar_pay_commit"`) was resolved and a current
435    /// grant was found. The per-action approval gate MUST be forced on
436    /// unconditionally by the caller.
437    Resolved {
438        /// The static tool name constant (`"stellar_pay_commit"`).
439        tool_name: &'static str,
440    },
441    /// The first-invoke gate fired: no current grant exists or the parameters
442    /// are novel. A `ToolsetFirstInvokeGate` pending approval was queued;
443    /// `approval_nonce` is the nonce the caller returns to the agent.
444    FirstInvokeApprovalRequired {
445        /// Nonce of the queued `ToolsetFirstInvokeGate` pending approval.
446        approval_nonce: String,
447        /// Sanitised toolset name for the error/response payload.
448        toolset_name: String,
449        /// Capability token (e.g. `"sign-payment"`).
450        capability: String,
451    },
452}
453
454/// The GATED resolver for toolset-routed `sign-payment` invocations.
455///
456/// This is a **DISTINCT entry point** from [`resolve_toolset_and_check`] — it is
457/// NOT a `route_to_matrix_tool` arm. It implements the full enforcement ordering:
458///
459/// 1. **Four-part check** (via the gated matrix): toolset declared `sign-payment`;
460///    the action maps to a gated constant; the tool is in `allowed_tools`.
461/// 2. **First-invoke gate**: check the grant store for a current, matching grant.
462///    If none → queue `ToolsetFirstInvokeGate` approval, return
463///    [`GatedResolveOutcome::FirstInvokeApprovalRequired`], REFUSE.
464/// 3. On grant match → return [`GatedResolveOutcome::Resolved`]. The CALLER
465///    MUST then route to `stellar_pay_commit` with the per-action
466///    `PaymentSimulated` approval FORCED ON UNCONDITIONALLY.
467///
468/// # Security
469///
470/// - All matching is computed from the AUTHORITATIVE envelope params in
471///   `params` — NEVER from toolset-supplied args.
472/// - A `DispatchOutcome::Allow` from the policy engine MUST be overridden to
473///   `RequireApproval` by the caller for toolset-routed payments. This function
474///   returns `GatedResolveOutcome::Resolved` to signal the path is open; the
475///   unconditional per-action approval enforcement is the caller's responsibility.
476/// - A tampered/forged grant can at worst suppress the first-invoke re-prompt;
477///   it CANNOT bypass the forced per-action `PaymentSimulated` approval.
478///
479/// # Errors
480///
481/// - [`ToolsetRuntimeError::ToolsetNotInstalled`] — toolset not installed or
482///   name fails charset validation.
483/// - [`ToolsetRuntimeError::UnknownToolsetAction`] — action not in the gated matrix.
484/// - [`ToolsetRuntimeError::CapabilityNotDeclared`] — `sign-payment` not declared.
485/// - [`ToolsetRuntimeError::ToolNotAllowed`] — tool excluded by `allowed_tools`.
486/// - [`ToolsetRuntimeError::GrantStoreError`] — I/O error accessing the grant store.
487/// - [`ToolsetRuntimeError::Io`] — I/O error accessing the pending-approval store.
488///
489/// # Returns
490///
491/// Returns `Ok(GatedResolveOutcome::FirstInvokeApprovalRequired { .. })` when
492/// the gate fires (NOT an `Err`), so the caller can cleanly distinguish
493/// "gate fired, queue approval" from "hard error".
494#[allow(clippy::too_many_lines)]
495pub fn resolve_toolset_sign_payment_gated(
496    params: &GatedInvokeParams<'_>,
497) -> Result<GatedResolveOutcome, ToolsetRuntimeError> {
498    let toolset_name = params.toolset_name;
499    let action = params.action;
500
501    // ── Step 0: Validate toolset_name charset ───────────────────────────────────
502    stellar_agent_toolsets_install::validate_package_name(toolset_name).map_err(|_| {
503        ToolsetRuntimeError::ToolsetNotInstalled {
504            name: sanitise_display(toolset_name, 64),
505        }
506    })?;
507
508    // ── Step 0b: Read pin ONCE (TOCTOU avoidance) ─────────────────────────────
509    let pin = stellar_agent_toolsets_install::read_pin(toolset_name, params.toolsets_root)
510        .map_err(|e| ToolsetRuntimeError::Io(install_error_kind_str(&e)))?
511        .ok_or_else(|| ToolsetRuntimeError::ToolsetNotInstalled {
512            name: sanitise_display(toolset_name, 64),
513        })?;
514
515    // ── Step 1: Gated four-part check ─────────────────────────────────────────
516    //
517    // Part (a): action must resolve in the GATED matrix (not the ungated one).
518    // The gated matrix returns (tool_name, granting_capability) for sign-payment.
519    let (tool_name, granting_capability) = resolve_gated_action(action)?;
520
521    // Part (b): tool_name IS in the gated matrix by construction (implied by (a)).
522
523    // Part (c): granting_capability must be in the toolset's declared CapabilitySet.
524    if !pin.capabilities.contains(granting_capability) {
525        return Err(ToolsetRuntimeError::CapabilityNotDeclared {
526            action: sanitise_display(action, 128),
527            capability: granting_capability.to_string(),
528        });
529    }
530
531    // Part (d): tool must be in allowed_tools (intersective narrowing).
532    if !allowed_by_narrowing(&pin.allowed_tools, tool_name) {
533        return Err(ToolsetRuntimeError::ToolNotAllowed {
534            tool: sanitise_display(tool_name, 128),
535            action: sanitise_display(action, 128),
536        });
537    }
538
539    // Reject non-positive authoritative amounts before ANY grant-store lookup.
540    // A zero or negative amount from the decoded envelope is structurally invalid
541    // for a payment. Checking here fails closed on BOTH the grant-hit and the
542    // no-grant paths: a grant covering the [0, N] bucket would otherwise match a
543    // zero-stroop invoke and resolve to the signing tool. The caller's
544    // authoritative envelope decode is not trusted to have enforced positivity.
545    let amount = params.authoritative_amount_stroops;
546    if amount <= 0 {
547        return Err(ToolsetRuntimeError::InvalidAuthoritativeAmount {
548            amount_stroops: amount,
549        });
550    }
551
552    // ── Step 2: First-invoke gate check ──────────────────────────────────────
553    //
554    // Load the grant store and look for a current, matching grant.
555    // All matching is computed from the AUTHORITATIVE envelope params.
556    let grant_store_path = {
557        #[cfg(feature = "test-helpers")]
558        {
559            if let Some(ref p) = params.grant_store_path_override {
560                p.clone()
561            } else {
562                default_toolset_grants_path(params.profile_name).map_err(|e| {
563                    ToolsetRuntimeError::GrantStoreError {
564                        detail: format!("grant_store_path: {e}"),
565                    }
566                })?
567            }
568        }
569        #[cfg(not(feature = "test-helpers"))]
570        {
571            default_toolset_grants_path(params.profile_name).map_err(|e| {
572                ToolsetRuntimeError::GrantStoreError {
573                    detail: format!("grant_store_path: {e}"),
574                }
575            })?
576        }
577    };
578
579    let grant_store =
580        ToolsetGrantStore::open(grant_store_path, params.now_unix_ms).map_err(|e| {
581            ToolsetRuntimeError::GrantStoreError {
582                detail: format!("open: {e}"),
583            }
584        })?;
585
586    let capability_str = granting_capability.to_string();
587    let matching_grant = grant_store.find_matching(
588        toolset_name,
589        &capability_str,
590        params.authoritative_destination,
591        params.authoritative_asset,
592        params.authoritative_amount_stroops,
593        params.now_unix_ms,
594    );
595
596    if matching_grant.is_some() {
597        // Grant found — the first-invoke gate is short-circuited.
598        // The CALLER MUST force the per-action PaymentSimulated approval
599        // unconditionally (Override Allow → RequireApproval).
600        tracing::debug!(
601            toolset = %toolset_name,
602            capability = %capability_str,
603            "first-invoke gate: matching grant found; routing to gated tool (per-action approval will be forced on by caller)"
604        );
605        return Ok(GatedResolveOutcome::Resolved { tool_name });
606    }
607
608    // ── No current grant: queue the first-invoke gate approval ───────────────
609    // (Non-positive amounts were already rejected before the grant lookup.)
610
611    // Compute the bucket bounds from the authoritative amount. Conservative:
612    // the bucket is the range [0, amount] — any future invoke with amount >
613    // amount_max_stroops re-prompts. A one-time grant for X stroops does NOT
614    // authorise payments exceeding X.
615    //
616    // amount_min_stroops = 0: the lower bound is 0 (any non-negative amount
617    // ≤ amount_max is within the bucket).
618    // amount_max_stroops = amount: positive (checked above).
619    let amount_min_stroops = 0_i64;
620    let amount_max_stroops = amount;
621    let destination = params.authoritative_destination;
622    let asset = params.authoritative_asset;
623
624    // Use the caller-supplied process_uid directly. The caller is responsible
625    // for providing the platform-stable user identity; honoring it here keeps
626    // the field meaningful and consistent with record_first_invoke_grant, which
627    // also trusts its caller-supplied process_uid.
628    let uid = params.process_uid.to_owned();
629
630    // Queue the ToolsetFirstInvokeGate pending approval.
631    let pending = PendingApproval::new_toolset_first_invoke_gate_pending(
632        toolset_name.to_owned(),
633        capability_str.clone(),
634        destination.to_owned(),
635        asset.to_owned(),
636        amount_min_stroops,
637        amount_max_stroops,
638        uid,
639        DEFAULT_TTL_MS,
640    )
641    .map_err(|e| ToolsetRuntimeError::Io(format!("new_toolset_gate_pending: {e}")))?;
642
643    let approval_nonce = pending.approval_nonce.clone();
644
645    // Persist to the pending-approval store.
646    let approval_store_path = {
647        #[cfg(feature = "test-helpers")]
648        {
649            if let Some(ref override_dir) = params.approval_dir_override {
650                override_dir.join(format!("{}.toml", params.profile_name))
651            } else {
652                build_approval_store_path(params.profile_name).ok_or_else(|| {
653                    ToolsetRuntimeError::Io("approval_store_path: dir unavailable".to_owned())
654                })?
655            }
656        }
657        #[cfg(not(feature = "test-helpers"))]
658        {
659            build_approval_store_path(params.profile_name).ok_or_else(|| {
660                ToolsetRuntimeError::Io("approval_store_path: dir unavailable".to_owned())
661            })?
662        }
663    };
664
665    let mut approval_store = open_with_retry(
666        &approval_store_path,
667        DEFAULT_RETRY_ATTEMPTS,
668        DEFAULT_RETRY_BACKOFF,
669    )
670    .map_err(|e| ToolsetRuntimeError::Io(format!("approval_store_open: {e}")))?;
671
672    approval_store
673        .insert(pending, params.now_unix_ms)
674        .map_err(|e| ToolsetRuntimeError::Io(format!("approval_store_insert: {e}")))?;
675
676    tracing::debug!(
677        toolset = %toolset_name,
678        capability = %capability_str,
679        nonce = %approval_nonce,
680        "first-invoke gate: no current grant; queued ToolsetFirstInvokeGate approval"
681    );
682
683    Ok(GatedResolveOutcome::FirstInvokeApprovalRequired {
684        approval_nonce,
685        toolset_name: sanitise_display(toolset_name, 64),
686        capability: sanitise_display(&capability_str, 64),
687    })
688}
689
690/// Optional override for the grant store path passed to [`record_first_invoke_grant`].
691///
692/// Pass `None` in production — the path is resolved via `default_toolset_grants_path`.
693/// Pass `Some(path)` in integration tests to write to a `TempDir`.
694pub type GrantStorePathOverride = Option<std::path::PathBuf>;
695
696/// Records a confirmed first-invoke grant after the operator approves a
697/// `ToolsetFirstInvokeGate` pending approval.
698///
699/// Called by the CLI `approve` handler after verifying the attestation.
700/// The grant is persisted to the grant store with the supplied attestation key.
701///
702/// Pass `None` for `grant_store_path_override` in production. Integration tests
703/// pass `Some(path)` pointing to a `tempfile::TempDir` to avoid writing to the
704/// real grant store.
705///
706/// # Errors
707///
708/// - [`ToolsetRuntimeError::GrantStoreError`] on grant store I/O failure.
709#[allow(clippy::too_many_arguments)]
710pub fn record_first_invoke_grant(
711    profile_name: &str,
712    toolset_name: &str,
713    capability: &str,
714    destination: &str,
715    asset: &str,
716    amount_min_stroops: i64,
717    amount_max_stroops: i64,
718    process_uid: &str,
719    now_unix_ms: u64,
720    attestation_key: &[u8; 32],
721    binding: &stellar_agent_core::approval::AttestationBinding<'_>,
722    // Optional override for the grant store path.
723    // Pass `None` in production; `Some(path)` in integration tests.
724    grant_store_path_override: GrantStorePathOverride,
725) -> Result<ToolsetGrant, ToolsetRuntimeError> {
726    let grant = build_attested_grant(
727        toolset_name.to_owned(),
728        capability.to_owned(),
729        destination.to_owned(),
730        asset.to_owned(),
731        amount_min_stroops,
732        amount_max_stroops,
733        process_uid.to_owned(),
734        now_unix_ms,
735        TOOLSET_GRANT_DEFAULT_TTL_MS,
736        attestation_key,
737        binding,
738    )
739    .map_err(|e| ToolsetRuntimeError::GrantStoreError {
740        detail: format!("build_attested_grant: {e}"),
741    })?;
742
743    let grant_store_path = if let Some(p) = grant_store_path_override {
744        p
745    } else {
746        default_toolset_grants_path(profile_name).map_err(|e| {
747            ToolsetRuntimeError::GrantStoreError {
748                detail: format!("grant_store_path: {e}"),
749            }
750        })?
751    };
752
753    let mut store = ToolsetGrantStore::open(grant_store_path, now_unix_ms).map_err(|e| {
754        ToolsetRuntimeError::GrantStoreError {
755            detail: format!("open: {e}"),
756        }
757    })?;
758
759    let grant_clone = grant.clone();
760    store
761        .insert(grant)
762        .map_err(|e| ToolsetRuntimeError::GrantStoreError {
763            detail: format!("insert: {e}"),
764        })?;
765
766    Ok(grant_clone)
767}
768
769/// Resolves a gated action string to a `(&'static str, Capability)` pair
770/// via the CLOSED gated matrix lookup.
771///
772/// Returns `Ok((tool_name, granting_capability))` if the action is in the
773/// gated matrix, or `Err(ToolsetRuntimeError::UnknownToolsetAction)` otherwise.
774///
775/// This ensures the gated tool name is a compile-time constant — a
776/// toolset-supplied `String` cannot become a `&'static str`.
777///
778/// # Errors
779///
780/// - [`ToolsetRuntimeError::UnknownToolsetAction`] — the action is not in the
781///   gated matrix.
782pub fn resolve_gated_action(
783    action: &str,
784) -> Result<(&'static str, Capability), ToolsetRuntimeError> {
785    for (cap, tools) in matrix::GATED_MATRIX_ENTRIES {
786        for tool in *tools {
787            if *tool == action {
788                return Ok((tool, *cap));
789            }
790        }
791    }
792    Err(ToolsetRuntimeError::UnknownToolsetAction {
793        action: sanitise_display(action, 128),
794    })
795}
796
797/// Helper: build the pending-approval store path from profile name.
798///
799/// Returns `None` if the approval dir cannot be resolved; the caller converts
800/// to a `ToolsetRuntimeError::Io`.
801fn build_approval_store_path(profile_name: &str) -> Option<std::path::PathBuf> {
802    let dir = stellar_agent_core::profile::schema::default_approval_dir().ok()?;
803    Some(dir.join(format!("{profile_name}.toml")))
804}
805
806// ── Re-exports for consumers ─────────────────────────────────────────────────
807
808/// Re-export [`stellar_agent_toolsets_install::read_pin`] as a crate-level
809/// convenience so MCP/CLI consumers can read pins without adding a direct dep
810/// on `stellar-agent-toolsets-install`.
811pub use stellar_agent_toolsets_install::read_pin;
812
813/// Re-export [`stellar_agent_toolsets_install::validate_package_name`] so callers
814/// can pre-validate toolset names before calling [`resolve_toolset_and_check`].
815pub use stellar_agent_toolsets_install::validate_package_name;
816
817// ── Internal helpers ──────────────────────────────────────────────────────────
818
819/// Maps a [`stellar_agent_toolsets_install::ToolsetInstallError`] to a kind-level
820/// I/O message string for use in [`ToolsetRuntimeError::Io`].
821///
822/// Uses `io::ErrorKind` level granularity to avoid leaking `toolsets_root`
823/// through this conversion (a future path-annotating I/O error cannot reach
824/// `ToolsetRuntimeError::Io` via this function).
825fn install_error_kind_str(e: &stellar_agent_toolsets_install::ToolsetInstallError) -> String {
826    // ToolsetInstallError already sanitises its Io variant internally, but we
827    // want a fixed-class message not the full Display (which may embed detail).
828    // For non-Io variants (PinRecordMalformed etc.) we use the variant name only.
829    use stellar_agent_toolsets_install::ToolsetInstallError;
830    match e {
831        ToolsetInstallError::Io { .. } => "io_error".to_owned(),
832        ToolsetInstallError::PinRecordMalformed { .. } => "pin_record_malformed".to_owned(),
833        _ => "install_error".to_owned(),
834    }
835}
836
837#[cfg(test)]
838#[allow(
839    clippy::unwrap_used,
840    clippy::expect_used,
841    clippy::panic,
842    reason = "test-only; panics acceptable in unit tests"
843)]
844mod tests {
845    use super::*;
846
847    fn all_caps() -> CapabilitySet {
848        // Build a full capability set by parsing the known tokens.
849        stellar_agent_toolsets::parse_capability_value_pub(
850            "read-balance propose-transaction suggest-destination observe-event",
851        )
852        .unwrap()
853    }
854
855    // ── check_toolset_action: part (a) — unknown action ────────────────────────
856
857    #[test]
858    fn unknown_action_returns_error() {
859        let caps = all_caps();
860        let err = check_toolset_action("no-such-action", &caps, &[]).unwrap_err();
861        assert!(
862            matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
863            "expected UnknownToolsetAction, got: {err:?}"
864        );
865    }
866
867    // ── check_toolset_action: part (c) — capability not declared ───────────────
868
869    #[test]
870    fn capability_not_declared_returns_error() {
871        // ReadBalance is not declared; action "stellar_balances" requires it.
872        let empty_caps = CapabilitySet::empty();
873        let err = check_toolset_action("stellar_balances", &empty_caps, &[]).unwrap_err();
874        assert!(
875            matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
876            "expected CapabilityNotDeclared, got: {err:?}"
877        );
878    }
879
880    #[test]
881    fn empty_capability_set_refuses_all_known_actions() {
882        let empty_caps = CapabilitySet::empty();
883        // Every action in the matrix must fail with CapabilityNotDeclared.
884        for (action, _cap) in matrix::ALL_MATRIX_ENTRIES {
885            let err = check_toolset_action(action, &empty_caps, &[]).unwrap_err();
886            assert!(
887                matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
888                "action {action}: expected CapabilityNotDeclared, got: {err:?}"
889            );
890        }
891    }
892
893    // ── check_toolset_action: part (d) — allowed_tools narrowing ───────────────
894
895    #[test]
896    fn allowed_tools_narrowing_excludes_tool() {
897        // ReadBalance is declared, but allowed_tools is non-empty and excludes
898        // stellar_balances.
899        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
900        let err = check_toolset_action("stellar_balances", &caps, &["some-other-tool".to_owned()])
901            .unwrap_err();
902        assert!(
903            matches!(err, ToolsetRuntimeError::ToolNotAllowed { .. }),
904            "expected ToolNotAllowed, got: {err:?}"
905        );
906    }
907
908    #[test]
909    fn allowed_tools_empty_vacuously_satisfied() {
910        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
911        let tool = check_toolset_action("stellar_balances", &caps, &[]).unwrap();
912        assert_eq!(tool, "stellar_balances");
913    }
914
915    // ── Signing-tool-in-allowed_tools still refused ───────────────────────────
916
917    #[test]
918    fn all_caps_plus_signing_in_allowed_tools_still_refused() {
919        // Declare every capability and list a signing tool in allowed_tools.
920        // The matrix has no signing tool, so resolve_action must fail first.
921        let caps = all_caps();
922        let allowed = vec![
923            "stellar_sep43_sign_transaction".to_owned(),
924            "stellar_sep53_sign_message".to_owned(),
925            "stellar_pay_commit".to_owned(),
926        ];
927        for signing_tool in &allowed {
928            let err = check_toolset_action(signing_tool, &caps, &allowed).unwrap_err();
929            assert!(
930                matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
931                "signing tool {signing_tool}: expected UnknownToolsetAction (not in matrix), got: {err:?}"
932            );
933        }
934    }
935
936    // ── read-balance toolset invoking propose action is refused ─────────────────
937
938    #[test]
939    fn read_balance_toolset_cannot_invoke_propose_action() {
940        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
941        // stellar_pay is only granted by ProposeTransaction, not ReadBalance.
942        let err = check_toolset_action("stellar_pay", &caps, &[]).unwrap_err();
943        assert!(
944            matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
945            "expected CapabilityNotDeclared, got: {err:?}"
946        );
947    }
948
949    // ── Dispatcher tools are unreachable via any capability ───────────────────
950
951    #[test]
952    fn dispatcher_tools_not_reachable_via_any_capability() {
953        let caps = all_caps();
954        // stellar_toolset_list and stellar_toolset_invoke must not be in the matrix.
955        for dispatcher_tool in ["stellar_toolset_list", "stellar_toolset_invoke"] {
956            let err = check_toolset_action(dispatcher_tool, &caps, &[]).unwrap_err();
957            assert!(
958                matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
959                "dispatcher tool {dispatcher_tool}: expected UnknownToolsetAction, got: {err:?}"
960            );
961        }
962    }
963
964    // ── Happy-path: ReadBalance → stellar_balances ───────────────────────────
965
966    #[test]
967    fn read_balance_grants_stellar_balances() {
968        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
969        let tool = check_toolset_action("stellar_balances", &caps, &[]).unwrap();
970        assert_eq!(tool, "stellar_balances");
971    }
972
973    // ── Happy-path: ProposeTransaction → stellar_pay ─────────────────────────
974
975    #[test]
976    fn propose_transaction_grants_stellar_pay() {
977        let caps =
978            stellar_agent_toolsets::parse_capability_value_pub("propose-transaction").unwrap();
979        let tool = check_toolset_action("stellar_pay", &caps, &[]).unwrap();
980        assert_eq!(tool, "stellar_pay");
981    }
982
983    // ── Happy-path: ReadRules → stellar_rules_list / stellar_rules_get ───────
984
985    /// A toolset granting ONLY `read-rules` resolves exactly the two
986    /// rules-observability tools through the runtime grant path
987    /// (`check_toolset_action` → `resolve_action` → `grants_for_capability`)
988    /// — the offline guard against the `grants_for_capability` `_ => &[]`
989    /// wildcard silently swallowing the new capability.
990    #[test]
991    fn read_rules_grants_exactly_stellar_rules_list_and_get() {
992        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-rules").unwrap();
993
994        let list_tool = check_toolset_action("stellar_rules_list", &caps, &[]).unwrap();
995        assert_eq!(list_tool, "stellar_rules_list");
996
997        let get_tool = check_toolset_action("stellar_rules_get", &caps, &[]).unwrap();
998        assert_eq!(get_tool, "stellar_rules_get");
999
1000        // Negative: read-rules must NOT grant any other matrix tool.
1001        for other in matrix::ALL_MATRIX_TOOL_NAMES {
1002            if *other == "stellar_rules_list" || *other == "stellar_rules_get" {
1003                continue;
1004            }
1005            let err = check_toolset_action(other, &caps, &[]).unwrap_err();
1006            assert!(
1007                matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
1008                "read-rules must not grant '{other}'; got {err:?}"
1009            );
1010        }
1011    }
1012
1013    /// A toolset that does NOT declare `read-rules` cannot invoke either
1014    /// rules-observability tool.
1015    #[test]
1016    fn toolset_without_read_rules_cannot_invoke_rules_tools() {
1017        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1018        for tool in ["stellar_rules_list", "stellar_rules_get"] {
1019            let err = check_toolset_action(tool, &caps, &[]).unwrap_err();
1020            assert!(
1021                matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
1022                "expected CapabilityNotDeclared for '{tool}', got: {err:?}"
1023            );
1024        }
1025    }
1026
1027    // ── list_pinned_toolsets: empty toolsets_root ─────────────────────────────────
1028
1029    #[test]
1030    fn list_pinned_toolsets_empty_dir() {
1031        let dir = tempfile::TempDir::new().unwrap();
1032        let list = list_pinned_toolsets(dir.path()).unwrap();
1033        assert!(list.is_empty());
1034    }
1035
1036    #[test]
1037    fn list_pinned_toolsets_nonexistent_dir() {
1038        let list =
1039            list_pinned_toolsets(std::path::Path::new("/nonexistent/toolsets_root")).unwrap();
1040        assert!(list.is_empty());
1041    }
1042
1043    // ── Path-traversal adversarial tests ─────────────────────────────────────
1044    //
1045    // Verifies that `resolve_toolset_and_check` rejects attacker-controlled toolset
1046    // names containing path components (slash, backslash, dotdot) BEFORE any
1047    // filesystem read. All cases must return ToolsetNotInstalled with NO filesystem
1048    // access outside the (empty) temp dir.
1049
1050    #[test]
1051    fn path_traversal_dotdot_slash_rejected() {
1052        let dir = tempfile::TempDir::new().unwrap();
1053        let err = resolve_toolset_and_check("../foo", "stellar_balances", dir.path()).unwrap_err();
1054        assert!(
1055            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1056            "expected ToolsetNotInstalled for '../foo', got: {err:?}"
1057        );
1058    }
1059
1060    #[test]
1061    fn path_traversal_slash_in_name_rejected() {
1062        let dir = tempfile::TempDir::new().unwrap();
1063        let err = resolve_toolset_and_check("a/b", "stellar_balances", dir.path()).unwrap_err();
1064        assert!(
1065            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1066            "expected ToolsetNotInstalled for 'a/b', got: {err:?}"
1067        );
1068    }
1069
1070    #[test]
1071    fn path_traversal_backslash_in_name_rejected() {
1072        let dir = tempfile::TempDir::new().unwrap();
1073        // "..\\foo" on any platform: backslash is not in [a-z0-9-], so rejected.
1074        let err = resolve_toolset_and_check("..\\foo", "stellar_balances", dir.path()).unwrap_err();
1075        assert!(
1076            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1077            "expected ToolsetNotInstalled for '..\\\\foo', got: {err:?}"
1078        );
1079    }
1080
1081    #[test]
1082    fn path_traversal_dotdot_alone_rejected() {
1083        let dir = tempfile::TempDir::new().unwrap();
1084        let err = resolve_toolset_and_check("..", "stellar_balances", dir.path()).unwrap_err();
1085        assert!(
1086            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1087            "expected ToolsetNotInstalled for '..', got: {err:?}"
1088        );
1089    }
1090
1091    #[test]
1092    fn path_traversal_dot_alone_rejected() {
1093        let dir = tempfile::TempDir::new().unwrap();
1094        let err = resolve_toolset_and_check(".", "stellar_balances", dir.path()).unwrap_err();
1095        assert!(
1096            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1097            "expected ToolsetNotInstalled for '.', got: {err:?}"
1098        );
1099    }
1100
1101    #[test]
1102    fn path_traversal_uppercase_rejected() {
1103        let dir = tempfile::TempDir::new().unwrap();
1104        let err =
1105            resolve_toolset_and_check("MyToolset", "stellar_balances", dir.path()).unwrap_err();
1106        assert!(
1107            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1108            "expected ToolsetNotInstalled for 'MyToolset', got: {err:?}"
1109        );
1110    }
1111
1112    #[test]
1113    fn path_traversal_null_byte_rejected() {
1114        let dir = tempfile::TempDir::new().unwrap();
1115        let err =
1116            resolve_toolset_and_check("toolset\0name", "stellar_balances", dir.path()).unwrap_err();
1117        assert!(
1118            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1119            "expected ToolsetNotInstalled for null-byte name, got: {err:?}"
1120        );
1121    }
1122
1123    // ── Dispatch-time TOOLSET.md content re-verification ───────────────────────
1124    //
1125    // Three scenarios:
1126    //
1127    //   (A) Pin has toolset_md_shasum; TOOLSET.md is unmodified → dispatch succeeds.
1128    //   (B) Pin has toolset_md_shasum; TOOLSET.md is tampered  → ContentDigestMismatch.
1129    //   (C) Pin has toolset_md_shasum = None (no digest stored) → check skipped.
1130    //
1131    // Setup: write a minimal valid TOOLSET.md + a hand-crafted pin record into a
1132    // tempdir.
1133
1134    /// Writes a minimal valid `TOOLSET.md` to `<toolsets_root>/<pkg>/TOOLSET.md`.
1135    fn write_toolset_md(toolsets_root: &std::path::Path, pkg: &str, content: &str) {
1136        let toolset_dir = toolsets_root.join(pkg);
1137        std::fs::create_dir_all(&toolset_dir).unwrap();
1138        std::fs::write(toolset_dir.join("TOOLSET.md"), content).unwrap();
1139    }
1140
1141    /// Writes a pin record JSON to `<toolsets_root>/<pkg>/.stellar-agent-toolset-pin.json`.
1142    fn write_pin_json(
1143        toolsets_root: &std::path::Path,
1144        pkg: &str,
1145        pin: &stellar_agent_toolsets_install::ToolsetPinRecord,
1146    ) {
1147        let json = serde_json::to_string_pretty(pin).unwrap();
1148        std::fs::write(
1149            toolsets_root
1150                .join(pkg)
1151                .join(".stellar-agent-toolset-pin.json"),
1152            json,
1153        )
1154        .unwrap();
1155    }
1156
1157    /// Returns a minimal valid TOOLSET.md content string for package `pkg` with
1158    /// `read-balance` capability.
1159    fn minimal_toolset_md(pkg: &str) -> String {
1160        format!(
1161            "---\nname: {pkg}\ndescription: test toolset\nstellar-agent-capabilities:\n  - read-balance\n---\n# {pkg}\n"
1162        )
1163    }
1164
1165    // ── (A) Unmodified TOOLSET.md with toolset_md_shasum → dispatches ────────────
1166
1167    #[test]
1168    fn content_digest_match_allows_dispatch() {
1169        let dir = tempfile::TempDir::new().unwrap();
1170        let toolsets_root = dir.path();
1171        let pkg = "my-toolset";
1172
1173        let toolset_md_content = minimal_toolset_md(pkg);
1174        write_toolset_md(toolsets_root, pkg, &toolset_md_content);
1175
1176        // Compute the expected digest of the TOOLSET.md bytes.
1177        let expected_digest =
1178            stellar_agent_toolsets_install::sha256_hex_of(toolset_md_content.as_bytes());
1179
1180        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1181        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1182            pkg,
1183            "1.0.0",
1184            "a".repeat(64),
1185            "GABC1234567890123456789012345678901234567890123456",
1186            "2026-06-12T00:00:00Z",
1187            caps,
1188            vec![],
1189            Some(expected_digest),
1190        );
1191        write_pin_json(toolsets_root, pkg, &pin);
1192
1193        // Dispatch must succeed — TOOLSET.md is unmodified.
1194        let result = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root);
1195        assert!(
1196            result.is_ok(),
1197            "dispatch must succeed when TOOLSET.md digest matches pin: {result:?}"
1198        );
1199        let (tool_name, _) = result.unwrap();
1200        assert_eq!(tool_name, "stellar_balances");
1201    }
1202
1203    // ── (B) Tampered TOOLSET.md with toolset_md_shasum → ContentDigestMismatch ──
1204
1205    #[test]
1206    fn content_digest_mismatch_refuses_dispatch() {
1207        let dir = tempfile::TempDir::new().unwrap();
1208        let toolsets_root = dir.path();
1209        let pkg = "my-toolset";
1210
1211        let original_content = minimal_toolset_md(pkg);
1212        write_toolset_md(toolsets_root, pkg, &original_content);
1213
1214        // Store the digest of the original content.
1215        let original_digest =
1216            stellar_agent_toolsets_install::sha256_hex_of(original_content.as_bytes());
1217
1218        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1219        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1220            pkg,
1221            "1.0.0",
1222            "a".repeat(64),
1223            "GABC1234567890123456789012345678901234567890123456",
1224            "2026-06-12T00:00:00Z",
1225            caps,
1226            vec![],
1227            Some(original_digest),
1228        );
1229        write_pin_json(toolsets_root, pkg, &pin);
1230
1231        // Tamper with TOOLSET.md (write attacker-controlled content).
1232        // The pin's toolset_md_shasum still refers to the original bytes.
1233        let tampered_content = format!(
1234            "---\nname: {pkg}\ndescription: TAMPERED BY ATTACKER\nstellar-agent-capabilities:\n  - read-balance\n---\n"
1235        );
1236        std::fs::write(
1237            toolsets_root.join(pkg).join("TOOLSET.md"),
1238            &tampered_content,
1239        )
1240        .unwrap();
1241
1242        // Dispatch must fail with ContentDigestMismatch.
1243        let err = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root).unwrap_err();
1244        assert!(
1245            matches!(err, ToolsetRuntimeError::ContentDigestMismatch { .. }),
1246            "expected ContentDigestMismatch for tampered TOOLSET.md, got: {err:?}"
1247        );
1248        // Error message must name the toolset.
1249        let msg = err.to_string();
1250        assert!(
1251            msg.contains(pkg),
1252            "ContentDigestMismatch message must include toolset name; got: {msg}"
1253        );
1254    }
1255
1256    // ── (C) Legacy pin (toolset_md_shasum = None) → check skipped ─────────────
1257
1258    #[test]
1259    fn legacy_pin_without_toolset_md_shasum_skips_content_check() {
1260        let dir = tempfile::TempDir::new().unwrap();
1261        let toolsets_root = dir.path();
1262        let pkg = "my-toolset";
1263
1264        write_toolset_md(toolsets_root, pkg, &minimal_toolset_md(pkg));
1265
1266        // Pin has no toolset_md_shasum.
1267        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1268        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1269            pkg,
1270            "1.0.0",
1271            "a".repeat(64),
1272            "GABC1234567890123456789012345678901234567890123456",
1273            "2026-06-12T00:00:00Z",
1274            caps,
1275            vec![],
1276            None,
1277        );
1278        write_pin_json(toolsets_root, pkg, &pin);
1279
1280        // Dispatch must succeed even if TOOLSET.md was modified — pin has no
1281        // digest to compare against, so the check is skipped. The
1282        // capability-source invariant ensures capability escalation is still
1283        // impossible via the on-disk TOOLSET.md.
1284        let result = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root);
1285        assert!(
1286            result.is_ok(),
1287            "pin without toolset_md_shasum must skip content check and dispatch: {result:?}"
1288        );
1289    }
1290
1291    // ── Missing TOOLSET.md when toolset_md_shasum is Some → ContentDigestMismatch
1292
1293    #[test]
1294    fn missing_toolset_md_when_digest_expected_refuses_dispatch() {
1295        let dir = tempfile::TempDir::new().unwrap();
1296        let toolsets_root = dir.path();
1297        let pkg = "my-toolset";
1298
1299        // Create the toolset dir but NO TOOLSET.md file.
1300        std::fs::create_dir_all(toolsets_root.join(pkg)).unwrap();
1301
1302        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1303        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1304            pkg,
1305            "1.0.0",
1306            "a".repeat(64),
1307            "GABC1234567890123456789012345678901234567890123456",
1308            "2026-06-12T00:00:00Z",
1309            caps,
1310            vec![],
1311            Some("a".repeat(64)),
1312        );
1313        write_pin_json(toolsets_root, pkg, &pin);
1314
1315        // TOOLSET.md is missing — I/O error on read → fail-closed ContentDigestMismatch.
1316        let err = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root).unwrap_err();
1317        assert!(
1318            matches!(err, ToolsetRuntimeError::ContentDigestMismatch { .. }),
1319            "missing TOOLSET.md with digest in pin must refuse dispatch: {err:?}"
1320        );
1321    }
1322
1323    // ── list_pinned_toolsets: directory with valid pin records ──────────────────
1324
1325    #[test]
1326    fn list_pinned_toolsets_with_valid_pins() {
1327        let dir = tempfile::TempDir::new().unwrap();
1328        let toolsets_root = dir.path();
1329
1330        // Write two toolsets: "alpha-toolset" and "beta-toolset", in reverse order so
1331        // sorting is verified.
1332        for pkg in ["beta-toolset", "alpha-toolset"] {
1333            write_toolset_md(toolsets_root, pkg, &minimal_toolset_md(pkg));
1334            let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1335            let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1336                pkg,
1337                "1.0.0",
1338                "a".repeat(64),
1339                "GABC1234567890123456789012345678901234567890123456",
1340                "2026-06-12T00:00:00Z",
1341                caps,
1342                vec![],
1343                None,
1344            );
1345            write_pin_json(toolsets_root, pkg, &pin);
1346        }
1347
1348        let list = list_pinned_toolsets(toolsets_root).unwrap();
1349        assert_eq!(list.len(), 2, "should find both toolsets");
1350        // Sorted by name: alpha-toolset before beta-toolset.
1351        assert_eq!(list[0].name, "alpha-toolset");
1352        assert_eq!(list[1].name, "beta-toolset");
1353        // Each should have stellar_balances in actions.
1354        assert!(list[0].actions.contains(&"stellar_balances".to_owned()));
1355        assert!(list[0].version == "1.0.0");
1356        assert!(
1357            list[0].description.is_empty(),
1358            "description is always empty"
1359        );
1360    }
1361
1362    #[test]
1363    fn list_pinned_toolsets_skips_entry_without_pin() {
1364        let dir = tempfile::TempDir::new().unwrap();
1365        let toolsets_root = dir.path();
1366
1367        // Create a subdirectory but with no pin record.
1368        std::fs::create_dir_all(toolsets_root.join("orphan-dir")).unwrap();
1369        // A valid toolset with pin.
1370        write_toolset_md(
1371            toolsets_root,
1372            "good-toolset",
1373            &minimal_toolset_md("good-toolset"),
1374        );
1375        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1376        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1377            "good-toolset",
1378            "2.0.0",
1379            "b".repeat(64),
1380            "GABC1234567890123456789012345678901234567890123456",
1381            "2026-06-12T00:00:00Z",
1382            caps,
1383            vec![],
1384            None,
1385        );
1386        write_pin_json(toolsets_root, "good-toolset", &pin);
1387
1388        let list = list_pinned_toolsets(toolsets_root).unwrap();
1389        assert_eq!(list.len(), 1, "orphan dir without pin must be skipped");
1390        assert_eq!(list[0].name, "good-toolset");
1391    }
1392
1393    #[test]
1394    fn list_pinned_toolsets_with_allowed_tools_narrowing() {
1395        let dir = tempfile::TempDir::new().unwrap();
1396        let toolsets_root = dir.path();
1397        let pkg = "narrow-toolset";
1398
1399        write_toolset_md(toolsets_root, pkg, &minimal_toolset_md(pkg));
1400        let caps =
1401            stellar_agent_toolsets::parse_capability_value_pub("read-balance suggest-destination")
1402                .unwrap();
1403        // allowed_tools is non-empty: only stellar_balances is permitted.
1404        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1405            pkg,
1406            "1.0.0",
1407            "c".repeat(64),
1408            "GABC1234567890123456789012345678901234567890123456",
1409            "2026-06-12T00:00:00Z",
1410            caps,
1411            vec!["stellar_balances".to_owned()],
1412            None,
1413        );
1414        write_pin_json(toolsets_root, pkg, &pin);
1415
1416        let list = list_pinned_toolsets(toolsets_root).unwrap();
1417        assert_eq!(list.len(), 1);
1418        // Only stellar_balances should be in actions (the SuggestDestination tools
1419        // are not in allowed_tools so they must be filtered out).
1420        assert_eq!(list[0].actions, vec!["stellar_balances"]);
1421        assert_eq!(list[0].allowed_tools, vec!["stellar_balances"]);
1422    }
1423
1424    // ── check_toolset_action: allowed_tools narrowing passes ────────────────────
1425
1426    #[test]
1427    fn allowed_tools_narrowing_passes_when_tool_included() {
1428        // ReadBalance declared; allowed_tools is non-empty and INCLUDES stellar_balances.
1429        // The narrowing is satisfied and the tool should be returned.
1430        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1431        let allowed = vec!["stellar_balances".to_owned(), "some-other".to_owned()];
1432        let tool = check_toolset_action("stellar_balances", &caps, &allowed).unwrap();
1433        assert_eq!(tool, "stellar_balances");
1434    }
1435
1436    // ── resolve_toolset_and_check: invalid name → ToolsetNotInstalled ─────────────
1437
1438    #[test]
1439    fn resolve_toolset_and_check_invalid_charset_rejected() {
1440        let dir = tempfile::TempDir::new().unwrap();
1441        // Name with space is outside [a-z0-9-]
1442        let err =
1443            resolve_toolset_and_check("bad name", "stellar_balances", dir.path()).unwrap_err();
1444        assert!(
1445            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1446            "expected ToolsetNotInstalled, got: {err:?}"
1447        );
1448    }
1449
1450    // ── resolve_toolset_and_check: valid name, pin absent → ToolsetNotInstalled ───
1451    //
1452    // Verifies the ToolsetNotInstalled path when the name passes charset
1453    // validation but no pin record exists in the toolsets_root directory.
1454
1455    #[test]
1456    fn resolve_toolset_and_check_valid_name_toolset_not_installed() {
1457        let dir = tempfile::TempDir::new().unwrap();
1458        // "stellar-balances" is a valid [a-z0-9-] name but has no pin record.
1459        let err =
1460            resolve_toolset_and_check("valid-name", "stellar_balances", dir.path()).unwrap_err();
1461        assert!(
1462            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1463            "expected ToolsetNotInstalled for a valid name with no pin record, got: {err:?}"
1464        );
1465    }
1466
1467    // ── Gated resolver tests (require test-helpers feature for overrides) ─────
1468
1469    /// Builds and writes a pin with `sign-payment` capability.
1470    #[cfg(feature = "test-helpers")]
1471    fn write_sign_payment_pin(toolsets_root: &std::path::Path, pkg: &str) {
1472        std::fs::create_dir_all(toolsets_root.join(pkg)).unwrap();
1473        let caps = stellar_agent_toolsets::parse_capability_value_pub("sign-payment").unwrap();
1474        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1475            pkg,
1476            "1.0.0",
1477            "d".repeat(64),
1478            "GABC1234567890123456789012345678901234567890123456",
1479            "2026-06-12T00:00:00Z",
1480            caps,
1481            vec![],
1482            None,
1483        );
1484        write_pin_json(toolsets_root, pkg, &pin);
1485    }
1486
1487    // ── Gated resolver: invalid toolset name → ToolsetNotInstalled ───────────────
1488
1489    #[test]
1490    #[cfg(feature = "test-helpers")]
1491    fn gated_resolver_invalid_toolset_name_rejected() {
1492        let toolsets_dir = tempfile::TempDir::new().unwrap();
1493        let approval_dir = tempfile::TempDir::new().unwrap();
1494        let grant_dir = tempfile::TempDir::new().unwrap();
1495
1496        let params = GatedInvokeParams {
1497            toolset_name: "Bad Name!",
1498            action: "stellar_pay_commit",
1499            toolsets_root: toolsets_dir.path(),
1500            profile_name: "test",
1501            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1502            authoritative_asset: "XLM",
1503            authoritative_amount_stroops: 10_000_000,
1504            now_unix_ms: 1_000_000,
1505            process_uid: "uid-test",
1506            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1507            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1508        };
1509
1510        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
1511        assert!(
1512            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1513            "expected ToolsetNotInstalled, got: {err:?}"
1514        );
1515    }
1516
1517    // ── Gated resolver: toolset not installed → ToolsetNotInstalled ──────────────
1518
1519    #[test]
1520    #[cfg(feature = "test-helpers")]
1521    fn gated_resolver_toolset_not_installed() {
1522        let toolsets_dir = tempfile::TempDir::new().unwrap();
1523        let approval_dir = tempfile::TempDir::new().unwrap();
1524        let grant_dir = tempfile::TempDir::new().unwrap();
1525
1526        let params = GatedInvokeParams {
1527            toolset_name: "not-installed",
1528            action: "stellar_pay_commit",
1529            toolsets_root: toolsets_dir.path(),
1530            profile_name: "test",
1531            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1532            authoritative_asset: "XLM",
1533            authoritative_amount_stroops: 10_000_000,
1534            now_unix_ms: 1_000_000,
1535            process_uid: "uid-test",
1536            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1537            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1538        };
1539
1540        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
1541        assert!(
1542            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1543            "expected ToolsetNotInstalled, got: {err:?}"
1544        );
1545    }
1546
1547    // ── Gated resolver: action not in gated matrix → UnknownToolsetAction ──────
1548
1549    #[test]
1550    #[cfg(feature = "test-helpers")]
1551    fn gated_resolver_ungated_action_rejected() {
1552        let toolsets_dir = tempfile::TempDir::new().unwrap();
1553        let approval_dir = tempfile::TempDir::new().unwrap();
1554        let grant_dir = tempfile::TempDir::new().unwrap();
1555
1556        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
1557
1558        let params = GatedInvokeParams {
1559            toolset_name: "pay-toolset",
1560            // stellar_pay is an ungated action — must not resolve via the gated matrix.
1561            action: "stellar_pay",
1562            toolsets_root: toolsets_dir.path(),
1563            profile_name: "test",
1564            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1565            authoritative_asset: "XLM",
1566            authoritative_amount_stroops: 10_000_000,
1567            now_unix_ms: 1_000_000,
1568            process_uid: "uid-test",
1569            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1570            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1571        };
1572
1573        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
1574        assert!(
1575            matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
1576            "ungated action must not resolve via gated matrix, got: {err:?}"
1577        );
1578    }
1579
1580    // ── Gated resolver: sign-payment not declared → CapabilityNotDeclared ────
1581
1582    #[test]
1583    #[cfg(feature = "test-helpers")]
1584    fn gated_resolver_sign_payment_not_declared() {
1585        let toolsets_dir = tempfile::TempDir::new().unwrap();
1586        let approval_dir = tempfile::TempDir::new().unwrap();
1587        let grant_dir = tempfile::TempDir::new().unwrap();
1588
1589        // Pin with read-balance only — no sign-payment.
1590        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1591        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1592            "read-toolset",
1593            "1.0.0",
1594            "e".repeat(64),
1595            "GABC1234567890123456789012345678901234567890123456",
1596            "2026-06-12T00:00:00Z",
1597            caps,
1598            vec![],
1599            None,
1600        );
1601        std::fs::create_dir_all(toolsets_dir.path().join("read-toolset")).unwrap();
1602        write_pin_json(toolsets_dir.path(), "read-toolset", &pin);
1603
1604        let params = GatedInvokeParams {
1605            toolset_name: "read-toolset",
1606            action: "stellar_pay_commit",
1607            toolsets_root: toolsets_dir.path(),
1608            profile_name: "test",
1609            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1610            authoritative_asset: "XLM",
1611            authoritative_amount_stroops: 10_000_000,
1612            now_unix_ms: 1_000_000,
1613            process_uid: "uid-test",
1614            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1615            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1616        };
1617
1618        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
1619        assert!(
1620            matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
1621            "expected CapabilityNotDeclared, got: {err:?}"
1622        );
1623    }
1624
1625    // ── Gated resolver: allowed_tools excludes stellar_pay_commit → ToolNotAllowed
1626
1627    #[test]
1628    #[cfg(feature = "test-helpers")]
1629    fn gated_resolver_tool_not_in_allowed_tools() {
1630        let toolsets_dir = tempfile::TempDir::new().unwrap();
1631        let approval_dir = tempfile::TempDir::new().unwrap();
1632        let grant_dir = tempfile::TempDir::new().unwrap();
1633
1634        // sign-payment declared but allowed_tools excludes stellar_pay_commit.
1635        let caps = stellar_agent_toolsets::parse_capability_value_pub("sign-payment").unwrap();
1636        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1637            "narrow-pay-toolset",
1638            "1.0.0",
1639            "f".repeat(64),
1640            "GABC1234567890123456789012345678901234567890123456",
1641            "2026-06-12T00:00:00Z",
1642            caps,
1643            vec!["some-other-tool".to_owned()],
1644            None,
1645        );
1646        std::fs::create_dir_all(toolsets_dir.path().join("narrow-pay-toolset")).unwrap();
1647        write_pin_json(toolsets_dir.path(), "narrow-pay-toolset", &pin);
1648
1649        let params = GatedInvokeParams {
1650            toolset_name: "narrow-pay-toolset",
1651            action: "stellar_pay_commit",
1652            toolsets_root: toolsets_dir.path(),
1653            profile_name: "test",
1654            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1655            authoritative_asset: "XLM",
1656            authoritative_amount_stroops: 10_000_000,
1657            now_unix_ms: 1_000_000,
1658            process_uid: "uid-test",
1659            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1660            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1661        };
1662
1663        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
1664        assert!(
1665            matches!(err, ToolsetRuntimeError::ToolNotAllowed { .. }),
1666            "expected ToolNotAllowed, got: {err:?}"
1667        );
1668    }
1669
1670    // ── Gated resolver: no grant → FirstInvokeApprovalRequired ───────────────
1671
1672    #[test]
1673    #[cfg(feature = "test-helpers")]
1674    fn gated_resolver_no_grant_queues_first_invoke_gate() {
1675        let toolsets_dir = tempfile::TempDir::new().unwrap();
1676        let approval_dir = tempfile::TempDir::new().unwrap();
1677        let grant_dir = tempfile::TempDir::new().unwrap();
1678
1679        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
1680
1681        let params = GatedInvokeParams {
1682            toolset_name: "pay-toolset",
1683            action: "stellar_pay_commit",
1684            toolsets_root: toolsets_dir.path(),
1685            profile_name: "test",
1686            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1687            authoritative_asset: "XLM",
1688            authoritative_amount_stroops: 10_000_000,
1689            now_unix_ms: 1_000_000,
1690            process_uid: "uid-test",
1691            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1692            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1693        };
1694
1695        let outcome = resolve_toolset_sign_payment_gated(&params).unwrap();
1696        match outcome {
1697            GatedResolveOutcome::FirstInvokeApprovalRequired {
1698                approval_nonce,
1699                toolset_name,
1700                capability,
1701            } => {
1702                assert!(
1703                    !approval_nonce.is_empty(),
1704                    "approval_nonce must be non-empty"
1705                );
1706                assert_eq!(toolset_name, "pay-toolset");
1707                assert_eq!(capability, "sign-payment");
1708            }
1709            GatedResolveOutcome::Resolved { .. } => {
1710                panic!("expected FirstInvokeApprovalRequired, got Resolved");
1711            }
1712        }
1713    }
1714
1715    // ── Gated resolver: with grant → Resolved ────────────────────────────────
1716
1717    #[test]
1718    #[cfg(feature = "test-helpers")]
1719    fn gated_resolver_with_matching_grant_returns_resolved() {
1720        use stellar_agent_core::approval::process_uid_for_attestation;
1721
1722        let toolsets_dir = tempfile::TempDir::new().unwrap();
1723        let approval_dir = tempfile::TempDir::new().unwrap();
1724        let grant_dir = tempfile::TempDir::new().unwrap();
1725        let grant_path = grant_dir.path().join("grants.json");
1726
1727        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
1728
1729        let now_unix_ms: u64 = 1_000_000;
1730        let destination = "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL";
1731        let asset = "XLM";
1732        let amount_stroops: i64 = 10_000_000;
1733        let attestation_key = [0u8; 32];
1734        let uid = process_uid_for_attestation().unwrap();
1735
1736        // Write a matching grant to the grant store before calling the resolver.
1737        record_first_invoke_grant(
1738            "test",
1739            "pay-toolset",
1740            "sign-payment",
1741            destination,
1742            asset,
1743            0,
1744            amount_stroops,
1745            &uid,
1746            now_unix_ms,
1747            &attestation_key,
1748            &stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
1749            Some(grant_path.clone()),
1750        )
1751        .unwrap();
1752
1753        let params = GatedInvokeParams {
1754            toolset_name: "pay-toolset",
1755            action: "stellar_pay_commit",
1756            toolsets_root: toolsets_dir.path(),
1757            profile_name: "test",
1758            authoritative_destination: destination,
1759            authoritative_asset: asset,
1760            authoritative_amount_stroops: amount_stroops,
1761            now_unix_ms,
1762            process_uid: &uid,
1763            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1764            grant_store_path_override: Some(grant_path),
1765        };
1766
1767        let outcome = resolve_toolset_sign_payment_gated(&params).unwrap();
1768        match outcome {
1769            GatedResolveOutcome::Resolved { tool_name } => {
1770                assert_eq!(tool_name, "stellar_pay_commit");
1771            }
1772            GatedResolveOutcome::FirstInvokeApprovalRequired { .. } => {
1773                panic!("expected Resolved, got FirstInvokeApprovalRequired");
1774            }
1775        }
1776    }
1777
1778    // ── SignRuleCreate gated resolver (Package D, GH issue #8) ────────────────
1779    //
1780    // `resolve_toolset_sign_payment_gated` is reused as-is for `sign-rule-create`
1781    // (it is generic over the GATED matrix / Capability, despite its name): the
1782    // "destination/asset/amount" triple is repurposed as the bucket-matching
1783    // dimension for rule-create grants — `authoritative_destination` carries the
1784    // smart-account C-strkey (the correct re-prompt dimension: a DIFFERENT
1785    // smart account should re-trigger first-invoke consent, exactly like a
1786    // different payment destination does), `authoritative_asset` is a fixed
1787    // sentinel (`"context-rule"`, not a real asset), and
1788    // `authoritative_amount_stroops` is a fixed positive dummy (`1`) since the
1789    // amount dimension carries no independent meaning here. The per-proposal
1790    // `RuleProposalSimulated` attestation (verified inside
1791    // `stellar_rule_create_commit`) remains the REAL, unconditional per-action
1792    // security boundary — this first-invoke gate is only the one-time
1793    // "this toolset may attempt rule-create for this smart account" consent.
1794
1795    use crate::matrix::{SIGN_RULE_CREATE_AMOUNT_SENTINEL, SIGN_RULE_CREATE_ASSET_SENTINEL};
1796
1797    fn write_sign_rule_create_pin(toolsets_root: &std::path::Path, pkg: &str) {
1798        std::fs::create_dir_all(toolsets_root.join(pkg)).unwrap();
1799        let caps = stellar_agent_toolsets::parse_capability_value_pub("sign-rule-create").unwrap();
1800        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1801            pkg,
1802            "1.0.0",
1803            "d".repeat(64),
1804            "GABC1234567890123456789012345678901234567890123456",
1805            "2026-06-12T00:00:00Z",
1806            caps,
1807            vec![],
1808            None,
1809        );
1810        write_pin_json(toolsets_root, pkg, &pin);
1811    }
1812
1813    #[test]
1814    #[cfg(feature = "test-helpers")]
1815    fn gated_resolver_sign_rule_create_no_grant_queues_first_invoke_gate() {
1816        let toolsets_dir = tempfile::TempDir::new().unwrap();
1817        let approval_dir = tempfile::TempDir::new().unwrap();
1818        let grant_dir = tempfile::TempDir::new().unwrap();
1819
1820        write_sign_rule_create_pin(toolsets_dir.path(), "rule-toolset");
1821
1822        let params = GatedInvokeParams {
1823            toolset_name: "rule-toolset",
1824            action: "stellar_rule_create_commit",
1825            toolsets_root: toolsets_dir.path(),
1826            profile_name: "test",
1827            authoritative_destination: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
1828            authoritative_asset: SIGN_RULE_CREATE_ASSET_SENTINEL,
1829            authoritative_amount_stroops: SIGN_RULE_CREATE_AMOUNT_SENTINEL,
1830            now_unix_ms: 1_000_000,
1831            process_uid: "uid-test",
1832            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1833            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1834        };
1835
1836        let outcome = resolve_toolset_sign_payment_gated(&params).unwrap();
1837        match outcome {
1838            GatedResolveOutcome::FirstInvokeApprovalRequired {
1839                approval_nonce,
1840                toolset_name,
1841                capability,
1842            } => {
1843                assert!(
1844                    !approval_nonce.is_empty(),
1845                    "approval_nonce must be non-empty"
1846                );
1847                assert_eq!(toolset_name, "rule-toolset");
1848                assert_eq!(capability, "sign-rule-create");
1849            }
1850            GatedResolveOutcome::Resolved { .. } => {
1851                panic!("expected FirstInvokeApprovalRequired, got Resolved");
1852            }
1853        }
1854    }
1855
1856    #[test]
1857    #[cfg(feature = "test-helpers")]
1858    fn gated_resolver_sign_rule_create_with_matching_grant_returns_resolved() {
1859        use stellar_agent_core::approval::process_uid_for_attestation;
1860
1861        let toolsets_dir = tempfile::TempDir::new().unwrap();
1862        let approval_dir = tempfile::TempDir::new().unwrap();
1863        let grant_dir = tempfile::TempDir::new().unwrap();
1864        let grant_path = grant_dir.path().join("grants.json");
1865
1866        write_sign_rule_create_pin(toolsets_dir.path(), "rule-toolset");
1867
1868        let now_unix_ms: u64 = 1_000_000;
1869        let smart_account = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM";
1870        let attestation_key = [0u8; 32];
1871        let uid = process_uid_for_attestation().unwrap();
1872
1873        record_first_invoke_grant(
1874            "test",
1875            "rule-toolset",
1876            "sign-rule-create",
1877            smart_account,
1878            SIGN_RULE_CREATE_ASSET_SENTINEL,
1879            0,
1880            SIGN_RULE_CREATE_AMOUNT_SENTINEL,
1881            &uid,
1882            now_unix_ms,
1883            &attestation_key,
1884            &stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
1885            Some(grant_path.clone()),
1886        )
1887        .unwrap();
1888
1889        let params = GatedInvokeParams {
1890            toolset_name: "rule-toolset",
1891            action: "stellar_rule_create_commit",
1892            toolsets_root: toolsets_dir.path(),
1893            profile_name: "test",
1894            authoritative_destination: smart_account,
1895            authoritative_asset: SIGN_RULE_CREATE_ASSET_SENTINEL,
1896            authoritative_amount_stroops: SIGN_RULE_CREATE_AMOUNT_SENTINEL,
1897            now_unix_ms,
1898            process_uid: &uid,
1899            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1900            grant_store_path_override: Some(grant_path),
1901        };
1902
1903        let outcome = resolve_toolset_sign_payment_gated(&params).unwrap();
1904        match outcome {
1905            GatedResolveOutcome::Resolved { tool_name } => {
1906                assert_eq!(tool_name, "stellar_rule_create_commit");
1907            }
1908            GatedResolveOutcome::FirstInvokeApprovalRequired { .. } => {
1909                panic!("expected Resolved, got FirstInvokeApprovalRequired");
1910            }
1911        }
1912    }
1913
1914    /// Negative: a toolset granting ONLY `propose-transaction` (ungated) can
1915    /// invoke `stellar_rule_create` (the propose step) but NOT
1916    /// `stellar_rule_create_commit` (gated; requires `sign-rule-create`) via
1917    /// the ungated path.
1918    #[test]
1919    fn propose_transaction_grants_stellar_rule_create_but_not_commit() {
1920        let caps =
1921            stellar_agent_toolsets::parse_capability_value_pub("propose-transaction").unwrap();
1922        let tool = check_toolset_action("stellar_rule_create", &caps, &[]).unwrap();
1923        assert_eq!(tool, "stellar_rule_create");
1924
1925        let err = check_toolset_action("stellar_rule_create_commit", &caps, &[]).unwrap_err();
1926        assert!(matches!(
1927            err,
1928            ToolsetRuntimeError::UnknownToolsetAction { .. }
1929        ));
1930    }
1931
1932    // ── Gated resolver: zero amount rejected EVEN WITH a matching grant ───────
1933    // Regression guard: the positivity check must run BEFORE the grant lookup,
1934    // so a grant covering the [0, N] bucket cannot resolve a zero-stroop invoke
1935    // to the signing tool.
1936
1937    #[test]
1938    #[cfg(feature = "test-helpers")]
1939    fn gated_resolver_grant_present_zero_amount_rejected() {
1940        use stellar_agent_core::approval::process_uid_for_attestation;
1941
1942        let toolsets_dir = tempfile::TempDir::new().unwrap();
1943        let approval_dir = tempfile::TempDir::new().unwrap();
1944        let grant_dir = tempfile::TempDir::new().unwrap();
1945        let grant_path = grant_dir.path().join("grants.json");
1946
1947        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
1948
1949        let now_unix_ms: u64 = 1_000_000;
1950        let destination = "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL";
1951        let asset = "XLM";
1952        let attestation_key = [0u8; 32];
1953        let uid = process_uid_for_attestation().unwrap();
1954
1955        // A grant covering [0, 10_000_000] would match a zero-stroop invoke if
1956        // the positivity check ran after the grant lookup.
1957        record_first_invoke_grant(
1958            "test",
1959            "pay-toolset",
1960            "sign-payment",
1961            destination,
1962            asset,
1963            0,
1964            10_000_000,
1965            &uid,
1966            now_unix_ms,
1967            &attestation_key,
1968            &stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
1969            Some(grant_path.clone()),
1970        )
1971        .unwrap();
1972
1973        let params = GatedInvokeParams {
1974            toolset_name: "pay-toolset",
1975            action: "stellar_pay_commit",
1976            toolsets_root: toolsets_dir.path(),
1977            profile_name: "test",
1978            authoritative_destination: destination,
1979            authoritative_asset: asset,
1980            authoritative_amount_stroops: 0,
1981            now_unix_ms,
1982            process_uid: &uid,
1983            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1984            grant_store_path_override: Some(grant_path),
1985        };
1986
1987        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
1988        assert!(
1989            matches!(
1990                err,
1991                ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: 0 }
1992            ),
1993            "a zero amount must be rejected even when a [0, N] grant exists; got: {err:?}"
1994        );
1995    }
1996
1997    // ── Gated resolver: non-positive amount → InvalidAuthoritativeAmount ─────
1998
1999    #[test]
2000    #[cfg(feature = "test-helpers")]
2001    fn gated_resolver_zero_amount_rejected() {
2002        let toolsets_dir = tempfile::TempDir::new().unwrap();
2003        let approval_dir = tempfile::TempDir::new().unwrap();
2004        let grant_dir = tempfile::TempDir::new().unwrap();
2005
2006        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
2007
2008        let params = GatedInvokeParams {
2009            toolset_name: "pay-toolset",
2010            action: "stellar_pay_commit",
2011            toolsets_root: toolsets_dir.path(),
2012            profile_name: "test",
2013            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2014            authoritative_asset: "XLM",
2015            authoritative_amount_stroops: 0,
2016            now_unix_ms: 1_000_000,
2017            process_uid: "uid-test",
2018            approval_dir_override: Some(approval_dir.path().to_path_buf()),
2019            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
2020        };
2021
2022        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
2023        assert!(
2024            matches!(
2025                err,
2026                ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: 0 }
2027            ),
2028            "expected InvalidAuthoritativeAmount(0), got: {err:?}"
2029        );
2030    }
2031
2032    #[test]
2033    #[cfg(feature = "test-helpers")]
2034    fn gated_resolver_negative_amount_rejected() {
2035        let toolsets_dir = tempfile::TempDir::new().unwrap();
2036        let approval_dir = tempfile::TempDir::new().unwrap();
2037        let grant_dir = tempfile::TempDir::new().unwrap();
2038
2039        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
2040
2041        let params = GatedInvokeParams {
2042            toolset_name: "pay-toolset",
2043            action: "stellar_pay_commit",
2044            toolsets_root: toolsets_dir.path(),
2045            profile_name: "test",
2046            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2047            authoritative_asset: "XLM",
2048            authoritative_amount_stroops: -1,
2049            now_unix_ms: 1_000_000,
2050            process_uid: "uid-test",
2051            approval_dir_override: Some(approval_dir.path().to_path_buf()),
2052            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
2053        };
2054
2055        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
2056        assert!(
2057            matches!(
2058                err,
2059                ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: -1 }
2060            ),
2061            "expected InvalidAuthoritativeAmount(-1), got: {err:?}"
2062        );
2063    }
2064
2065    // ── Gated resolver: queued pending carries caller-supplied process_uid ────
2066    //
2067    // The queued ToolsetFirstInvokeGate pending carries the caller-supplied
2068    // process_uid. This test reads the pending-approval store after queuing and
2069    // asserts the stored process_uid equals the supplied value. The invariant is
2070    // that the resolver HONORS the caller-supplied value and does not recompute
2071    // it internally — the caller controls the identity for attestation purposes.
2072
2073    #[test]
2074    #[cfg(feature = "test-helpers")]
2075    fn gated_resolver_queued_pending_uses_caller_supplied_process_uid() {
2076        let toolsets_dir = tempfile::TempDir::new().unwrap();
2077        let approval_dir = tempfile::TempDir::new().unwrap();
2078        let grant_dir = tempfile::TempDir::new().unwrap();
2079
2080        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
2081
2082        // Synthetic uid that the caller supplies; the resolver must store it
2083        // verbatim without recomputing via process_uid_for_attestation().
2084        let supplied_uid = "1234567890";
2085
2086        let params = GatedInvokeParams {
2087            toolset_name: "pay-toolset",
2088            action: "stellar_pay_commit",
2089            toolsets_root: toolsets_dir.path(),
2090            profile_name: "test",
2091            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2092            authoritative_asset: "XLM",
2093            authoritative_amount_stroops: 10_000_000,
2094            now_unix_ms: 1_000_000,
2095            process_uid: supplied_uid,
2096            approval_dir_override: Some(approval_dir.path().to_path_buf()),
2097            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
2098        };
2099
2100        let outcome = resolve_toolset_sign_payment_gated(&params).unwrap();
2101        let approval_nonce = match outcome {
2102            GatedResolveOutcome::FirstInvokeApprovalRequired { approval_nonce, .. } => {
2103                approval_nonce
2104            }
2105            GatedResolveOutcome::Resolved { .. } => {
2106                panic!("expected FirstInvokeApprovalRequired, got Resolved");
2107            }
2108        };
2109
2110        // Read the persisted pending from the approval store and verify its
2111        // process_uid equals the supplied value. process_uid is a top-level
2112        // field on PendingApproval (not inside ApprovalKind).
2113        let store_path = approval_dir.path().join("test.toml");
2114        let store = stellar_agent_core::approval::PendingApprovalStore::open(store_path).unwrap();
2115        let pending = store
2116            .get(&approval_nonce)
2117            .expect("queued pending must be findable by nonce");
2118
2119        assert_eq!(
2120            pending.process_uid, supplied_uid,
2121            "queued pending's process_uid must equal the caller-supplied value '{}'; \
2122             got '{}' — would differ if the resolver recomputed it internally",
2123            supplied_uid, pending.process_uid
2124        );
2125    }
2126
2127    // ── Gated resolver: over-max amount re-prompts ───────────────────────────
2128    //
2129    // A grant with a bounded amount_max is stored. An invoke with
2130    // authoritative_amount_stroops ABOVE that max must NOT return Resolved —
2131    // the grant does not match, so the resolver re-prompts (queues a new
2132    // ToolsetFirstInvokeGate pending and returns FirstInvokeApprovalRequired).
2133
2134    #[test]
2135    #[cfg(feature = "test-helpers")]
2136    fn gated_resolver_over_max_amount_re_prompts() {
2137        use stellar_agent_core::approval::process_uid_for_attestation;
2138
2139        let toolsets_dir = tempfile::TempDir::new().unwrap();
2140        let approval_dir = tempfile::TempDir::new().unwrap();
2141        let grant_dir = tempfile::TempDir::new().unwrap();
2142        let grant_path = grant_dir.path().join("grants.json");
2143
2144        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
2145
2146        let now_unix_ms: u64 = 1_000_000;
2147        let destination = "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL";
2148        let asset = "XLM";
2149        let grant_max_stroops: i64 = 10_000_000; // 1 XLM
2150        let attestation_key = [0u8; 32];
2151        let uid = process_uid_for_attestation().unwrap();
2152
2153        // Store a grant covering [0, 10_000_000].
2154        record_first_invoke_grant(
2155            "test",
2156            "pay-toolset",
2157            "sign-payment",
2158            destination,
2159            asset,
2160            0,
2161            grant_max_stroops,
2162            &uid,
2163            now_unix_ms,
2164            &attestation_key,
2165            &stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
2166            Some(grant_path.clone()),
2167        )
2168        .unwrap();
2169
2170        // Invoke with an amount ABOVE the grant's max — should NOT match.
2171        let over_max_stroops: i64 = grant_max_stroops + 1;
2172
2173        let params = GatedInvokeParams {
2174            toolset_name: "pay-toolset",
2175            action: "stellar_pay_commit",
2176            toolsets_root: toolsets_dir.path(),
2177            profile_name: "test",
2178            authoritative_destination: destination,
2179            authoritative_asset: asset,
2180            authoritative_amount_stroops: over_max_stroops,
2181            now_unix_ms,
2182            process_uid: &uid,
2183            approval_dir_override: Some(approval_dir.path().to_path_buf()),
2184            grant_store_path_override: Some(grant_path),
2185        };
2186
2187        let outcome = resolve_toolset_sign_payment_gated(&params).unwrap();
2188
2189        // Must NOT return Resolved — the grant's amount_max is exceeded.
2190        assert!(
2191            matches!(
2192                outcome,
2193                GatedResolveOutcome::FirstInvokeApprovalRequired { .. }
2194            ),
2195            "amount above grant max must re-prompt (FirstInvokeApprovalRequired), \
2196             got Resolved — the existing grant must not match an over-max amount"
2197        );
2198
2199        // Verify the outcome carries a non-empty nonce (a new pending was queued).
2200        match outcome {
2201            GatedResolveOutcome::FirstInvokeApprovalRequired { approval_nonce, .. } => {
2202                assert!(
2203                    !approval_nonce.is_empty(),
2204                    "re-prompt must carry a non-empty approval_nonce"
2205                );
2206            }
2207            GatedResolveOutcome::Resolved { .. } => unreachable!(),
2208        }
2209    }
2210
2211    // ── record_first_invoke_grant: happy path ─────────────────────────────────
2212
2213    #[test]
2214    #[cfg(feature = "test-helpers")]
2215    fn record_first_invoke_grant_persists_to_store() {
2216        use stellar_agent_core::approval::process_uid_for_attestation;
2217
2218        let grant_dir = tempfile::TempDir::new().unwrap();
2219        let grant_path = grant_dir.path().join("grants.json");
2220
2221        let now_unix_ms: u64 = 2_000_000;
2222        let uid = process_uid_for_attestation().unwrap();
2223        let attestation_key = [1u8; 32];
2224
2225        let grant = record_first_invoke_grant(
2226            "prod",
2227            "my-toolset",
2228            "sign-payment",
2229            "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2230            "XLM",
2231            0,
2232            50_000_000,
2233            &uid,
2234            now_unix_ms,
2235            &attestation_key,
2236            &stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
2237            Some(grant_path.clone()),
2238        )
2239        .unwrap();
2240
2241        assert_eq!(grant.toolset_name, "my-toolset");
2242        assert_eq!(grant.capability, "sign-payment");
2243
2244        // The security-load-bearing output is the HMAC attestation blob (the
2245        // grant store's matching does NOT verify it). Bind the test to that
2246        // cryptographic output: it must verify against the real key and be
2247        // rejected under a wrong key.
2248        assert!(
2249            grant.verify_attestation(
2250                &attestation_key,
2251                &stellar_agent_core::approval::AttestationBinding::new(
2252                    "default",
2253                    "stellar:testnet"
2254                )
2255            ),
2256            "grant must verify against the attestation key it was built with"
2257        );
2258        assert!(
2259            !grant.verify_attestation(
2260                &[0xff; 32],
2261                &stellar_agent_core::approval::AttestationBinding::new(
2262                    "default",
2263                    "stellar:testnet"
2264                )
2265            ),
2266            "grant must NOT verify against a wrong attestation key"
2267        );
2268
2269        // Re-open the store and verify the grant is persisted.
2270        let store =
2271            stellar_agent_core::approval::ToolsetGrantStore::open(grant_path, now_unix_ms).unwrap();
2272        let found = store.find_matching(
2273            "my-toolset",
2274            "sign-payment",
2275            "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2276            "XLM",
2277            30_000_000, // within [0, 50_000_000]
2278            now_unix_ms,
2279        );
2280        assert!(found.is_some(), "persisted grant must be findable in store");
2281    }
2282}