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    // Optional override for the grant store path.
722    // Pass `None` in production; `Some(path)` in integration tests.
723    grant_store_path_override: GrantStorePathOverride,
724) -> Result<ToolsetGrant, ToolsetRuntimeError> {
725    let grant = build_attested_grant(
726        toolset_name.to_owned(),
727        capability.to_owned(),
728        destination.to_owned(),
729        asset.to_owned(),
730        amount_min_stroops,
731        amount_max_stroops,
732        process_uid.to_owned(),
733        now_unix_ms,
734        TOOLSET_GRANT_DEFAULT_TTL_MS,
735        attestation_key,
736    )
737    .map_err(|e| ToolsetRuntimeError::GrantStoreError {
738        detail: format!("build_attested_grant: {e}"),
739    })?;
740
741    let grant_store_path = if let Some(p) = grant_store_path_override {
742        p
743    } else {
744        default_toolset_grants_path(profile_name).map_err(|e| {
745            ToolsetRuntimeError::GrantStoreError {
746                detail: format!("grant_store_path: {e}"),
747            }
748        })?
749    };
750
751    let mut store = ToolsetGrantStore::open(grant_store_path, now_unix_ms).map_err(|e| {
752        ToolsetRuntimeError::GrantStoreError {
753            detail: format!("open: {e}"),
754        }
755    })?;
756
757    let grant_clone = grant.clone();
758    store
759        .insert(grant)
760        .map_err(|e| ToolsetRuntimeError::GrantStoreError {
761            detail: format!("insert: {e}"),
762        })?;
763
764    Ok(grant_clone)
765}
766
767/// Resolves a gated action string to a `(&'static str, Capability)` pair
768/// via the CLOSED gated matrix lookup.
769///
770/// Returns `Ok((tool_name, granting_capability))` if the action is in the
771/// gated matrix, or `Err(ToolsetRuntimeError::UnknownToolsetAction)` otherwise.
772///
773/// This ensures the gated tool name is a compile-time constant — a
774/// toolset-supplied `String` cannot become a `&'static str`.
775///
776/// # Errors
777///
778/// - [`ToolsetRuntimeError::UnknownToolsetAction`] — the action is not in the
779///   gated matrix.
780pub fn resolve_gated_action(
781    action: &str,
782) -> Result<(&'static str, Capability), ToolsetRuntimeError> {
783    for (cap, tools) in matrix::GATED_MATRIX_ENTRIES {
784        for tool in *tools {
785            if *tool == action {
786                return Ok((tool, *cap));
787            }
788        }
789    }
790    Err(ToolsetRuntimeError::UnknownToolsetAction {
791        action: sanitise_display(action, 128),
792    })
793}
794
795/// Helper: build the pending-approval store path from profile name.
796///
797/// Returns `None` if the approval dir cannot be resolved; the caller converts
798/// to a `ToolsetRuntimeError::Io`.
799fn build_approval_store_path(profile_name: &str) -> Option<std::path::PathBuf> {
800    let dir = stellar_agent_core::profile::schema::default_approval_dir().ok()?;
801    Some(dir.join(format!("{profile_name}.toml")))
802}
803
804// ── Re-exports for consumers ─────────────────────────────────────────────────
805
806/// Re-export [`stellar_agent_toolsets_install::read_pin`] as a crate-level
807/// convenience so MCP/CLI consumers can read pins without adding a direct dep
808/// on `stellar-agent-toolsets-install`.
809pub use stellar_agent_toolsets_install::read_pin;
810
811/// Re-export [`stellar_agent_toolsets_install::validate_package_name`] so callers
812/// can pre-validate toolset names before calling [`resolve_toolset_and_check`].
813pub use stellar_agent_toolsets_install::validate_package_name;
814
815// ── Internal helpers ──────────────────────────────────────────────────────────
816
817/// Maps a [`stellar_agent_toolsets_install::ToolsetInstallError`] to a kind-level
818/// I/O message string for use in [`ToolsetRuntimeError::Io`].
819///
820/// Uses `io::ErrorKind` level granularity to avoid leaking `toolsets_root`
821/// through this conversion (a future path-annotating I/O error cannot reach
822/// `ToolsetRuntimeError::Io` via this function).
823fn install_error_kind_str(e: &stellar_agent_toolsets_install::ToolsetInstallError) -> String {
824    // ToolsetInstallError already sanitises its Io variant internally, but we
825    // want a fixed-class message not the full Display (which may embed detail).
826    // For non-Io variants (PinRecordMalformed etc.) we use the variant name only.
827    use stellar_agent_toolsets_install::ToolsetInstallError;
828    match e {
829        ToolsetInstallError::Io { .. } => "io_error".to_owned(),
830        ToolsetInstallError::PinRecordMalformed { .. } => "pin_record_malformed".to_owned(),
831        _ => "install_error".to_owned(),
832    }
833}
834
835#[cfg(test)]
836#[allow(
837    clippy::unwrap_used,
838    clippy::expect_used,
839    clippy::panic,
840    reason = "test-only; panics acceptable in unit tests"
841)]
842mod tests {
843    use super::*;
844
845    fn all_caps() -> CapabilitySet {
846        // Build a full capability set by parsing the known tokens.
847        stellar_agent_toolsets::parse_capability_value_pub(
848            "read-balance propose-transaction suggest-destination observe-event",
849        )
850        .unwrap()
851    }
852
853    // ── check_toolset_action: part (a) — unknown action ────────────────────────
854
855    #[test]
856    fn unknown_action_returns_error() {
857        let caps = all_caps();
858        let err = check_toolset_action("no-such-action", &caps, &[]).unwrap_err();
859        assert!(
860            matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
861            "expected UnknownToolsetAction, got: {err:?}"
862        );
863    }
864
865    // ── check_toolset_action: part (c) — capability not declared ───────────────
866
867    #[test]
868    fn capability_not_declared_returns_error() {
869        // ReadBalance is not declared; action "stellar_balances" requires it.
870        let empty_caps = CapabilitySet::empty();
871        let err = check_toolset_action("stellar_balances", &empty_caps, &[]).unwrap_err();
872        assert!(
873            matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
874            "expected CapabilityNotDeclared, got: {err:?}"
875        );
876    }
877
878    #[test]
879    fn empty_capability_set_refuses_all_known_actions() {
880        let empty_caps = CapabilitySet::empty();
881        // Every action in the matrix must fail with CapabilityNotDeclared.
882        for (action, _cap) in matrix::ALL_MATRIX_ENTRIES {
883            let err = check_toolset_action(action, &empty_caps, &[]).unwrap_err();
884            assert!(
885                matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
886                "action {action}: expected CapabilityNotDeclared, got: {err:?}"
887            );
888        }
889    }
890
891    // ── check_toolset_action: part (d) — allowed_tools narrowing ───────────────
892
893    #[test]
894    fn allowed_tools_narrowing_excludes_tool() {
895        // ReadBalance is declared, but allowed_tools is non-empty and excludes
896        // stellar_balances.
897        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
898        let err = check_toolset_action("stellar_balances", &caps, &["some-other-tool".to_owned()])
899            .unwrap_err();
900        assert!(
901            matches!(err, ToolsetRuntimeError::ToolNotAllowed { .. }),
902            "expected ToolNotAllowed, got: {err:?}"
903        );
904    }
905
906    #[test]
907    fn allowed_tools_empty_vacuously_satisfied() {
908        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
909        let tool = check_toolset_action("stellar_balances", &caps, &[]).unwrap();
910        assert_eq!(tool, "stellar_balances");
911    }
912
913    // ── Signing-tool-in-allowed_tools still refused ───────────────────────────
914
915    #[test]
916    fn all_caps_plus_signing_in_allowed_tools_still_refused() {
917        // Declare every capability and list a signing tool in allowed_tools.
918        // The matrix has no signing tool, so resolve_action must fail first.
919        let caps = all_caps();
920        let allowed = vec![
921            "stellar_sep43_sign_transaction".to_owned(),
922            "stellar_sep53_sign_message".to_owned(),
923            "stellar_pay_commit".to_owned(),
924        ];
925        for signing_tool in &allowed {
926            let err = check_toolset_action(signing_tool, &caps, &allowed).unwrap_err();
927            assert!(
928                matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
929                "signing tool {signing_tool}: expected UnknownToolsetAction (not in matrix), got: {err:?}"
930            );
931        }
932    }
933
934    // ── read-balance toolset invoking propose action is refused ─────────────────
935
936    #[test]
937    fn read_balance_toolset_cannot_invoke_propose_action() {
938        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
939        // stellar_pay is only granted by ProposeTransaction, not ReadBalance.
940        let err = check_toolset_action("stellar_pay", &caps, &[]).unwrap_err();
941        assert!(
942            matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
943            "expected CapabilityNotDeclared, got: {err:?}"
944        );
945    }
946
947    // ── Dispatcher tools are unreachable via any capability ───────────────────
948
949    #[test]
950    fn dispatcher_tools_not_reachable_via_any_capability() {
951        let caps = all_caps();
952        // stellar_toolset_list and stellar_toolset_invoke must not be in the matrix.
953        for dispatcher_tool in ["stellar_toolset_list", "stellar_toolset_invoke"] {
954            let err = check_toolset_action(dispatcher_tool, &caps, &[]).unwrap_err();
955            assert!(
956                matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
957                "dispatcher tool {dispatcher_tool}: expected UnknownToolsetAction, got: {err:?}"
958            );
959        }
960    }
961
962    // ── Happy-path: ReadBalance → stellar_balances ───────────────────────────
963
964    #[test]
965    fn read_balance_grants_stellar_balances() {
966        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
967        let tool = check_toolset_action("stellar_balances", &caps, &[]).unwrap();
968        assert_eq!(tool, "stellar_balances");
969    }
970
971    // ── Happy-path: ProposeTransaction → stellar_pay ─────────────────────────
972
973    #[test]
974    fn propose_transaction_grants_stellar_pay() {
975        let caps =
976            stellar_agent_toolsets::parse_capability_value_pub("propose-transaction").unwrap();
977        let tool = check_toolset_action("stellar_pay", &caps, &[]).unwrap();
978        assert_eq!(tool, "stellar_pay");
979    }
980
981    // ── Happy-path: ReadRules → stellar_rules_list / stellar_rules_get ───────
982
983    /// A toolset granting ONLY `read-rules` resolves exactly the two
984    /// rules-observability tools through the runtime grant path
985    /// (`check_toolset_action` → `resolve_action` → `grants_for_capability`)
986    /// — the offline guard against the `grants_for_capability` `_ => &[]`
987    /// wildcard silently swallowing the new capability.
988    #[test]
989    fn read_rules_grants_exactly_stellar_rules_list_and_get() {
990        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-rules").unwrap();
991
992        let list_tool = check_toolset_action("stellar_rules_list", &caps, &[]).unwrap();
993        assert_eq!(list_tool, "stellar_rules_list");
994
995        let get_tool = check_toolset_action("stellar_rules_get", &caps, &[]).unwrap();
996        assert_eq!(get_tool, "stellar_rules_get");
997
998        // Negative: read-rules must NOT grant any other matrix tool.
999        for other in matrix::ALL_MATRIX_TOOL_NAMES {
1000            if *other == "stellar_rules_list" || *other == "stellar_rules_get" {
1001                continue;
1002            }
1003            let err = check_toolset_action(other, &caps, &[]).unwrap_err();
1004            assert!(
1005                matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
1006                "read-rules must not grant '{other}'; got {err:?}"
1007            );
1008        }
1009    }
1010
1011    /// A toolset that does NOT declare `read-rules` cannot invoke either
1012    /// rules-observability tool.
1013    #[test]
1014    fn toolset_without_read_rules_cannot_invoke_rules_tools() {
1015        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1016        for tool in ["stellar_rules_list", "stellar_rules_get"] {
1017            let err = check_toolset_action(tool, &caps, &[]).unwrap_err();
1018            assert!(
1019                matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
1020                "expected CapabilityNotDeclared for '{tool}', got: {err:?}"
1021            );
1022        }
1023    }
1024
1025    // ── list_pinned_toolsets: empty toolsets_root ─────────────────────────────────
1026
1027    #[test]
1028    fn list_pinned_toolsets_empty_dir() {
1029        let dir = tempfile::TempDir::new().unwrap();
1030        let list = list_pinned_toolsets(dir.path()).unwrap();
1031        assert!(list.is_empty());
1032    }
1033
1034    #[test]
1035    fn list_pinned_toolsets_nonexistent_dir() {
1036        let list =
1037            list_pinned_toolsets(std::path::Path::new("/nonexistent/toolsets_root")).unwrap();
1038        assert!(list.is_empty());
1039    }
1040
1041    // ── Path-traversal adversarial tests ─────────────────────────────────────
1042    //
1043    // Verifies that `resolve_toolset_and_check` rejects attacker-controlled toolset
1044    // names containing path components (slash, backslash, dotdot) BEFORE any
1045    // filesystem read. All cases must return ToolsetNotInstalled with NO filesystem
1046    // access outside the (empty) temp dir.
1047
1048    #[test]
1049    fn path_traversal_dotdot_slash_rejected() {
1050        let dir = tempfile::TempDir::new().unwrap();
1051        let err = resolve_toolset_and_check("../foo", "stellar_balances", dir.path()).unwrap_err();
1052        assert!(
1053            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1054            "expected ToolsetNotInstalled for '../foo', got: {err:?}"
1055        );
1056    }
1057
1058    #[test]
1059    fn path_traversal_slash_in_name_rejected() {
1060        let dir = tempfile::TempDir::new().unwrap();
1061        let err = resolve_toolset_and_check("a/b", "stellar_balances", dir.path()).unwrap_err();
1062        assert!(
1063            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1064            "expected ToolsetNotInstalled for 'a/b', got: {err:?}"
1065        );
1066    }
1067
1068    #[test]
1069    fn path_traversal_backslash_in_name_rejected() {
1070        let dir = tempfile::TempDir::new().unwrap();
1071        // "..\\foo" on any platform: backslash is not in [a-z0-9-], so rejected.
1072        let err = resolve_toolset_and_check("..\\foo", "stellar_balances", dir.path()).unwrap_err();
1073        assert!(
1074            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1075            "expected ToolsetNotInstalled for '..\\\\foo', got: {err:?}"
1076        );
1077    }
1078
1079    #[test]
1080    fn path_traversal_dotdot_alone_rejected() {
1081        let dir = tempfile::TempDir::new().unwrap();
1082        let err = resolve_toolset_and_check("..", "stellar_balances", dir.path()).unwrap_err();
1083        assert!(
1084            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1085            "expected ToolsetNotInstalled for '..', got: {err:?}"
1086        );
1087    }
1088
1089    #[test]
1090    fn path_traversal_dot_alone_rejected() {
1091        let dir = tempfile::TempDir::new().unwrap();
1092        let err = resolve_toolset_and_check(".", "stellar_balances", dir.path()).unwrap_err();
1093        assert!(
1094            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1095            "expected ToolsetNotInstalled for '.', got: {err:?}"
1096        );
1097    }
1098
1099    #[test]
1100    fn path_traversal_uppercase_rejected() {
1101        let dir = tempfile::TempDir::new().unwrap();
1102        let err =
1103            resolve_toolset_and_check("MyToolset", "stellar_balances", dir.path()).unwrap_err();
1104        assert!(
1105            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1106            "expected ToolsetNotInstalled for 'MyToolset', got: {err:?}"
1107        );
1108    }
1109
1110    #[test]
1111    fn path_traversal_null_byte_rejected() {
1112        let dir = tempfile::TempDir::new().unwrap();
1113        let err =
1114            resolve_toolset_and_check("toolset\0name", "stellar_balances", dir.path()).unwrap_err();
1115        assert!(
1116            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1117            "expected ToolsetNotInstalled for null-byte name, got: {err:?}"
1118        );
1119    }
1120
1121    // ── Dispatch-time TOOLSET.md content re-verification ───────────────────────
1122    //
1123    // Three scenarios:
1124    //
1125    //   (A) Pin has toolset_md_shasum; TOOLSET.md is unmodified → dispatch succeeds.
1126    //   (B) Pin has toolset_md_shasum; TOOLSET.md is tampered  → ContentDigestMismatch.
1127    //   (C) Pin has toolset_md_shasum = None (no digest stored) → check skipped.
1128    //
1129    // Setup: write a minimal valid TOOLSET.md + a hand-crafted pin record into a
1130    // tempdir.
1131
1132    /// Writes a minimal valid `TOOLSET.md` to `<toolsets_root>/<pkg>/TOOLSET.md`.
1133    fn write_toolset_md(toolsets_root: &std::path::Path, pkg: &str, content: &str) {
1134        let toolset_dir = toolsets_root.join(pkg);
1135        std::fs::create_dir_all(&toolset_dir).unwrap();
1136        std::fs::write(toolset_dir.join("TOOLSET.md"), content).unwrap();
1137    }
1138
1139    /// Writes a pin record JSON to `<toolsets_root>/<pkg>/.stellar-agent-toolset-pin.json`.
1140    fn write_pin_json(
1141        toolsets_root: &std::path::Path,
1142        pkg: &str,
1143        pin: &stellar_agent_toolsets_install::ToolsetPinRecord,
1144    ) {
1145        let json = serde_json::to_string_pretty(pin).unwrap();
1146        std::fs::write(
1147            toolsets_root
1148                .join(pkg)
1149                .join(".stellar-agent-toolset-pin.json"),
1150            json,
1151        )
1152        .unwrap();
1153    }
1154
1155    /// Returns a minimal valid TOOLSET.md content string for package `pkg` with
1156    /// `read-balance` capability.
1157    fn minimal_toolset_md(pkg: &str) -> String {
1158        format!(
1159            "---\nname: {pkg}\ndescription: test toolset\nstellar-agent-capabilities:\n  - read-balance\n---\n# {pkg}\n"
1160        )
1161    }
1162
1163    // ── (A) Unmodified TOOLSET.md with toolset_md_shasum → dispatches ────────────
1164
1165    #[test]
1166    fn content_digest_match_allows_dispatch() {
1167        let dir = tempfile::TempDir::new().unwrap();
1168        let toolsets_root = dir.path();
1169        let pkg = "my-toolset";
1170
1171        let toolset_md_content = minimal_toolset_md(pkg);
1172        write_toolset_md(toolsets_root, pkg, &toolset_md_content);
1173
1174        // Compute the expected digest of the TOOLSET.md bytes.
1175        let expected_digest =
1176            stellar_agent_toolsets_install::sha256_hex_of(toolset_md_content.as_bytes());
1177
1178        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1179        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1180            pkg,
1181            "1.0.0",
1182            "a".repeat(64),
1183            "GABC1234567890123456789012345678901234567890123456",
1184            "2026-06-12T00:00:00Z",
1185            caps,
1186            vec![],
1187            Some(expected_digest),
1188        );
1189        write_pin_json(toolsets_root, pkg, &pin);
1190
1191        // Dispatch must succeed — TOOLSET.md is unmodified.
1192        let result = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root);
1193        assert!(
1194            result.is_ok(),
1195            "dispatch must succeed when TOOLSET.md digest matches pin: {result:?}"
1196        );
1197        let (tool_name, _) = result.unwrap();
1198        assert_eq!(tool_name, "stellar_balances");
1199    }
1200
1201    // ── (B) Tampered TOOLSET.md with toolset_md_shasum → ContentDigestMismatch ──
1202
1203    #[test]
1204    fn content_digest_mismatch_refuses_dispatch() {
1205        let dir = tempfile::TempDir::new().unwrap();
1206        let toolsets_root = dir.path();
1207        let pkg = "my-toolset";
1208
1209        let original_content = minimal_toolset_md(pkg);
1210        write_toolset_md(toolsets_root, pkg, &original_content);
1211
1212        // Store the digest of the original content.
1213        let original_digest =
1214            stellar_agent_toolsets_install::sha256_hex_of(original_content.as_bytes());
1215
1216        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1217        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1218            pkg,
1219            "1.0.0",
1220            "a".repeat(64),
1221            "GABC1234567890123456789012345678901234567890123456",
1222            "2026-06-12T00:00:00Z",
1223            caps,
1224            vec![],
1225            Some(original_digest),
1226        );
1227        write_pin_json(toolsets_root, pkg, &pin);
1228
1229        // Tamper with TOOLSET.md (write attacker-controlled content).
1230        // The pin's toolset_md_shasum still refers to the original bytes.
1231        let tampered_content = format!(
1232            "---\nname: {pkg}\ndescription: TAMPERED BY ATTACKER\nstellar-agent-capabilities:\n  - read-balance\n---\n"
1233        );
1234        std::fs::write(
1235            toolsets_root.join(pkg).join("TOOLSET.md"),
1236            &tampered_content,
1237        )
1238        .unwrap();
1239
1240        // Dispatch must fail with ContentDigestMismatch.
1241        let err = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root).unwrap_err();
1242        assert!(
1243            matches!(err, ToolsetRuntimeError::ContentDigestMismatch { .. }),
1244            "expected ContentDigestMismatch for tampered TOOLSET.md, got: {err:?}"
1245        );
1246        // Error message must name the toolset.
1247        let msg = err.to_string();
1248        assert!(
1249            msg.contains(pkg),
1250            "ContentDigestMismatch message must include toolset name; got: {msg}"
1251        );
1252    }
1253
1254    // ── (C) Legacy pin (toolset_md_shasum = None) → check skipped ─────────────
1255
1256    #[test]
1257    fn legacy_pin_without_toolset_md_shasum_skips_content_check() {
1258        let dir = tempfile::TempDir::new().unwrap();
1259        let toolsets_root = dir.path();
1260        let pkg = "my-toolset";
1261
1262        write_toolset_md(toolsets_root, pkg, &minimal_toolset_md(pkg));
1263
1264        // Pin has no toolset_md_shasum.
1265        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1266        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1267            pkg,
1268            "1.0.0",
1269            "a".repeat(64),
1270            "GABC1234567890123456789012345678901234567890123456",
1271            "2026-06-12T00:00:00Z",
1272            caps,
1273            vec![],
1274            None,
1275        );
1276        write_pin_json(toolsets_root, pkg, &pin);
1277
1278        // Dispatch must succeed even if TOOLSET.md was modified — pin has no
1279        // digest to compare against, so the check is skipped. The
1280        // capability-source invariant ensures capability escalation is still
1281        // impossible via the on-disk TOOLSET.md.
1282        let result = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root);
1283        assert!(
1284            result.is_ok(),
1285            "pin without toolset_md_shasum must skip content check and dispatch: {result:?}"
1286        );
1287    }
1288
1289    // ── Missing TOOLSET.md when toolset_md_shasum is Some → ContentDigestMismatch
1290
1291    #[test]
1292    fn missing_toolset_md_when_digest_expected_refuses_dispatch() {
1293        let dir = tempfile::TempDir::new().unwrap();
1294        let toolsets_root = dir.path();
1295        let pkg = "my-toolset";
1296
1297        // Create the toolset dir but NO TOOLSET.md file.
1298        std::fs::create_dir_all(toolsets_root.join(pkg)).unwrap();
1299
1300        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1301        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1302            pkg,
1303            "1.0.0",
1304            "a".repeat(64),
1305            "GABC1234567890123456789012345678901234567890123456",
1306            "2026-06-12T00:00:00Z",
1307            caps,
1308            vec![],
1309            Some("a".repeat(64)),
1310        );
1311        write_pin_json(toolsets_root, pkg, &pin);
1312
1313        // TOOLSET.md is missing — I/O error on read → fail-closed ContentDigestMismatch.
1314        let err = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root).unwrap_err();
1315        assert!(
1316            matches!(err, ToolsetRuntimeError::ContentDigestMismatch { .. }),
1317            "missing TOOLSET.md with digest in pin must refuse dispatch: {err:?}"
1318        );
1319    }
1320
1321    // ── list_pinned_toolsets: directory with valid pin records ──────────────────
1322
1323    #[test]
1324    fn list_pinned_toolsets_with_valid_pins() {
1325        let dir = tempfile::TempDir::new().unwrap();
1326        let toolsets_root = dir.path();
1327
1328        // Write two toolsets: "alpha-toolset" and "beta-toolset", in reverse order so
1329        // sorting is verified.
1330        for pkg in ["beta-toolset", "alpha-toolset"] {
1331            write_toolset_md(toolsets_root, pkg, &minimal_toolset_md(pkg));
1332            let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1333            let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1334                pkg,
1335                "1.0.0",
1336                "a".repeat(64),
1337                "GABC1234567890123456789012345678901234567890123456",
1338                "2026-06-12T00:00:00Z",
1339                caps,
1340                vec![],
1341                None,
1342            );
1343            write_pin_json(toolsets_root, pkg, &pin);
1344        }
1345
1346        let list = list_pinned_toolsets(toolsets_root).unwrap();
1347        assert_eq!(list.len(), 2, "should find both toolsets");
1348        // Sorted by name: alpha-toolset before beta-toolset.
1349        assert_eq!(list[0].name, "alpha-toolset");
1350        assert_eq!(list[1].name, "beta-toolset");
1351        // Each should have stellar_balances in actions.
1352        assert!(list[0].actions.contains(&"stellar_balances".to_owned()));
1353        assert!(list[0].version == "1.0.0");
1354        assert!(
1355            list[0].description.is_empty(),
1356            "description is always empty"
1357        );
1358    }
1359
1360    #[test]
1361    fn list_pinned_toolsets_skips_entry_without_pin() {
1362        let dir = tempfile::TempDir::new().unwrap();
1363        let toolsets_root = dir.path();
1364
1365        // Create a subdirectory but with no pin record.
1366        std::fs::create_dir_all(toolsets_root.join("orphan-dir")).unwrap();
1367        // A valid toolset with pin.
1368        write_toolset_md(
1369            toolsets_root,
1370            "good-toolset",
1371            &minimal_toolset_md("good-toolset"),
1372        );
1373        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1374        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1375            "good-toolset",
1376            "2.0.0",
1377            "b".repeat(64),
1378            "GABC1234567890123456789012345678901234567890123456",
1379            "2026-06-12T00:00:00Z",
1380            caps,
1381            vec![],
1382            None,
1383        );
1384        write_pin_json(toolsets_root, "good-toolset", &pin);
1385
1386        let list = list_pinned_toolsets(toolsets_root).unwrap();
1387        assert_eq!(list.len(), 1, "orphan dir without pin must be skipped");
1388        assert_eq!(list[0].name, "good-toolset");
1389    }
1390
1391    #[test]
1392    fn list_pinned_toolsets_with_allowed_tools_narrowing() {
1393        let dir = tempfile::TempDir::new().unwrap();
1394        let toolsets_root = dir.path();
1395        let pkg = "narrow-toolset";
1396
1397        write_toolset_md(toolsets_root, pkg, &minimal_toolset_md(pkg));
1398        let caps =
1399            stellar_agent_toolsets::parse_capability_value_pub("read-balance suggest-destination")
1400                .unwrap();
1401        // allowed_tools is non-empty: only stellar_balances is permitted.
1402        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1403            pkg,
1404            "1.0.0",
1405            "c".repeat(64),
1406            "GABC1234567890123456789012345678901234567890123456",
1407            "2026-06-12T00:00:00Z",
1408            caps,
1409            vec!["stellar_balances".to_owned()],
1410            None,
1411        );
1412        write_pin_json(toolsets_root, pkg, &pin);
1413
1414        let list = list_pinned_toolsets(toolsets_root).unwrap();
1415        assert_eq!(list.len(), 1);
1416        // Only stellar_balances should be in actions (the SuggestDestination tools
1417        // are not in allowed_tools so they must be filtered out).
1418        assert_eq!(list[0].actions, vec!["stellar_balances"]);
1419        assert_eq!(list[0].allowed_tools, vec!["stellar_balances"]);
1420    }
1421
1422    // ── check_toolset_action: allowed_tools narrowing passes ────────────────────
1423
1424    #[test]
1425    fn allowed_tools_narrowing_passes_when_tool_included() {
1426        // ReadBalance declared; allowed_tools is non-empty and INCLUDES stellar_balances.
1427        // The narrowing is satisfied and the tool should be returned.
1428        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1429        let allowed = vec!["stellar_balances".to_owned(), "some-other".to_owned()];
1430        let tool = check_toolset_action("stellar_balances", &caps, &allowed).unwrap();
1431        assert_eq!(tool, "stellar_balances");
1432    }
1433
1434    // ── resolve_toolset_and_check: invalid name → ToolsetNotInstalled ─────────────
1435
1436    #[test]
1437    fn resolve_toolset_and_check_invalid_charset_rejected() {
1438        let dir = tempfile::TempDir::new().unwrap();
1439        // Name with space is outside [a-z0-9-]
1440        let err =
1441            resolve_toolset_and_check("bad name", "stellar_balances", dir.path()).unwrap_err();
1442        assert!(
1443            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1444            "expected ToolsetNotInstalled, got: {err:?}"
1445        );
1446    }
1447
1448    // ── resolve_toolset_and_check: valid name, pin absent → ToolsetNotInstalled ───
1449    //
1450    // Verifies the ToolsetNotInstalled path when the name passes charset
1451    // validation but no pin record exists in the toolsets_root directory.
1452
1453    #[test]
1454    fn resolve_toolset_and_check_valid_name_toolset_not_installed() {
1455        let dir = tempfile::TempDir::new().unwrap();
1456        // "stellar-balances" is a valid [a-z0-9-] name but has no pin record.
1457        let err =
1458            resolve_toolset_and_check("valid-name", "stellar_balances", dir.path()).unwrap_err();
1459        assert!(
1460            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1461            "expected ToolsetNotInstalled for a valid name with no pin record, got: {err:?}"
1462        );
1463    }
1464
1465    // ── Gated resolver tests (require test-helpers feature for overrides) ─────
1466
1467    /// Builds and writes a pin with `sign-payment` capability.
1468    #[cfg(feature = "test-helpers")]
1469    fn write_sign_payment_pin(toolsets_root: &std::path::Path, pkg: &str) {
1470        std::fs::create_dir_all(toolsets_root.join(pkg)).unwrap();
1471        let caps = stellar_agent_toolsets::parse_capability_value_pub("sign-payment").unwrap();
1472        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1473            pkg,
1474            "1.0.0",
1475            "d".repeat(64),
1476            "GABC1234567890123456789012345678901234567890123456",
1477            "2026-06-12T00:00:00Z",
1478            caps,
1479            vec![],
1480            None,
1481        );
1482        write_pin_json(toolsets_root, pkg, &pin);
1483    }
1484
1485    // ── Gated resolver: invalid toolset name → ToolsetNotInstalled ───────────────
1486
1487    #[test]
1488    #[cfg(feature = "test-helpers")]
1489    fn gated_resolver_invalid_toolset_name_rejected() {
1490        let toolsets_dir = tempfile::TempDir::new().unwrap();
1491        let approval_dir = tempfile::TempDir::new().unwrap();
1492        let grant_dir = tempfile::TempDir::new().unwrap();
1493
1494        let params = GatedInvokeParams {
1495            toolset_name: "Bad Name!",
1496            action: "stellar_pay_commit",
1497            toolsets_root: toolsets_dir.path(),
1498            profile_name: "test",
1499            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1500            authoritative_asset: "XLM",
1501            authoritative_amount_stroops: 10_000_000,
1502            now_unix_ms: 1_000_000,
1503            process_uid: "uid-test",
1504            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1505            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1506        };
1507
1508        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
1509        assert!(
1510            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1511            "expected ToolsetNotInstalled, got: {err:?}"
1512        );
1513    }
1514
1515    // ── Gated resolver: toolset not installed → ToolsetNotInstalled ──────────────
1516
1517    #[test]
1518    #[cfg(feature = "test-helpers")]
1519    fn gated_resolver_toolset_not_installed() {
1520        let toolsets_dir = tempfile::TempDir::new().unwrap();
1521        let approval_dir = tempfile::TempDir::new().unwrap();
1522        let grant_dir = tempfile::TempDir::new().unwrap();
1523
1524        let params = GatedInvokeParams {
1525            toolset_name: "not-installed",
1526            action: "stellar_pay_commit",
1527            toolsets_root: toolsets_dir.path(),
1528            profile_name: "test",
1529            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1530            authoritative_asset: "XLM",
1531            authoritative_amount_stroops: 10_000_000,
1532            now_unix_ms: 1_000_000,
1533            process_uid: "uid-test",
1534            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1535            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1536        };
1537
1538        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
1539        assert!(
1540            matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1541            "expected ToolsetNotInstalled, got: {err:?}"
1542        );
1543    }
1544
1545    // ── Gated resolver: action not in gated matrix → UnknownToolsetAction ──────
1546
1547    #[test]
1548    #[cfg(feature = "test-helpers")]
1549    fn gated_resolver_ungated_action_rejected() {
1550        let toolsets_dir = tempfile::TempDir::new().unwrap();
1551        let approval_dir = tempfile::TempDir::new().unwrap();
1552        let grant_dir = tempfile::TempDir::new().unwrap();
1553
1554        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
1555
1556        let params = GatedInvokeParams {
1557            toolset_name: "pay-toolset",
1558            // stellar_pay is an ungated action — must not resolve via the gated matrix.
1559            action: "stellar_pay",
1560            toolsets_root: toolsets_dir.path(),
1561            profile_name: "test",
1562            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1563            authoritative_asset: "XLM",
1564            authoritative_amount_stroops: 10_000_000,
1565            now_unix_ms: 1_000_000,
1566            process_uid: "uid-test",
1567            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1568            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1569        };
1570
1571        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
1572        assert!(
1573            matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
1574            "ungated action must not resolve via gated matrix, got: {err:?}"
1575        );
1576    }
1577
1578    // ── Gated resolver: sign-payment not declared → CapabilityNotDeclared ────
1579
1580    #[test]
1581    #[cfg(feature = "test-helpers")]
1582    fn gated_resolver_sign_payment_not_declared() {
1583        let toolsets_dir = tempfile::TempDir::new().unwrap();
1584        let approval_dir = tempfile::TempDir::new().unwrap();
1585        let grant_dir = tempfile::TempDir::new().unwrap();
1586
1587        // Pin with read-balance only — no sign-payment.
1588        let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1589        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1590            "read-toolset",
1591            "1.0.0",
1592            "e".repeat(64),
1593            "GABC1234567890123456789012345678901234567890123456",
1594            "2026-06-12T00:00:00Z",
1595            caps,
1596            vec![],
1597            None,
1598        );
1599        std::fs::create_dir_all(toolsets_dir.path().join("read-toolset")).unwrap();
1600        write_pin_json(toolsets_dir.path(), "read-toolset", &pin);
1601
1602        let params = GatedInvokeParams {
1603            toolset_name: "read-toolset",
1604            action: "stellar_pay_commit",
1605            toolsets_root: toolsets_dir.path(),
1606            profile_name: "test",
1607            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1608            authoritative_asset: "XLM",
1609            authoritative_amount_stroops: 10_000_000,
1610            now_unix_ms: 1_000_000,
1611            process_uid: "uid-test",
1612            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1613            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1614        };
1615
1616        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
1617        assert!(
1618            matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
1619            "expected CapabilityNotDeclared, got: {err:?}"
1620        );
1621    }
1622
1623    // ── Gated resolver: allowed_tools excludes stellar_pay_commit → ToolNotAllowed
1624
1625    #[test]
1626    #[cfg(feature = "test-helpers")]
1627    fn gated_resolver_tool_not_in_allowed_tools() {
1628        let toolsets_dir = tempfile::TempDir::new().unwrap();
1629        let approval_dir = tempfile::TempDir::new().unwrap();
1630        let grant_dir = tempfile::TempDir::new().unwrap();
1631
1632        // sign-payment declared but allowed_tools excludes stellar_pay_commit.
1633        let caps = stellar_agent_toolsets::parse_capability_value_pub("sign-payment").unwrap();
1634        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1635            "narrow-pay-toolset",
1636            "1.0.0",
1637            "f".repeat(64),
1638            "GABC1234567890123456789012345678901234567890123456",
1639            "2026-06-12T00:00:00Z",
1640            caps,
1641            vec!["some-other-tool".to_owned()],
1642            None,
1643        );
1644        std::fs::create_dir_all(toolsets_dir.path().join("narrow-pay-toolset")).unwrap();
1645        write_pin_json(toolsets_dir.path(), "narrow-pay-toolset", &pin);
1646
1647        let params = GatedInvokeParams {
1648            toolset_name: "narrow-pay-toolset",
1649            action: "stellar_pay_commit",
1650            toolsets_root: toolsets_dir.path(),
1651            profile_name: "test",
1652            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1653            authoritative_asset: "XLM",
1654            authoritative_amount_stroops: 10_000_000,
1655            now_unix_ms: 1_000_000,
1656            process_uid: "uid-test",
1657            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1658            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1659        };
1660
1661        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
1662        assert!(
1663            matches!(err, ToolsetRuntimeError::ToolNotAllowed { .. }),
1664            "expected ToolNotAllowed, got: {err:?}"
1665        );
1666    }
1667
1668    // ── Gated resolver: no grant → FirstInvokeApprovalRequired ───────────────
1669
1670    #[test]
1671    #[cfg(feature = "test-helpers")]
1672    fn gated_resolver_no_grant_queues_first_invoke_gate() {
1673        let toolsets_dir = tempfile::TempDir::new().unwrap();
1674        let approval_dir = tempfile::TempDir::new().unwrap();
1675        let grant_dir = tempfile::TempDir::new().unwrap();
1676
1677        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
1678
1679        let params = GatedInvokeParams {
1680            toolset_name: "pay-toolset",
1681            action: "stellar_pay_commit",
1682            toolsets_root: toolsets_dir.path(),
1683            profile_name: "test",
1684            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1685            authoritative_asset: "XLM",
1686            authoritative_amount_stroops: 10_000_000,
1687            now_unix_ms: 1_000_000,
1688            process_uid: "uid-test",
1689            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1690            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1691        };
1692
1693        let outcome = resolve_toolset_sign_payment_gated(&params).unwrap();
1694        match outcome {
1695            GatedResolveOutcome::FirstInvokeApprovalRequired {
1696                approval_nonce,
1697                toolset_name,
1698                capability,
1699            } => {
1700                assert!(
1701                    !approval_nonce.is_empty(),
1702                    "approval_nonce must be non-empty"
1703                );
1704                assert_eq!(toolset_name, "pay-toolset");
1705                assert_eq!(capability, "sign-payment");
1706            }
1707            GatedResolveOutcome::Resolved { .. } => {
1708                panic!("expected FirstInvokeApprovalRequired, got Resolved");
1709            }
1710        }
1711    }
1712
1713    // ── Gated resolver: with grant → Resolved ────────────────────────────────
1714
1715    #[test]
1716    #[cfg(feature = "test-helpers")]
1717    fn gated_resolver_with_matching_grant_returns_resolved() {
1718        use stellar_agent_core::approval::process_uid_for_attestation;
1719
1720        let toolsets_dir = tempfile::TempDir::new().unwrap();
1721        let approval_dir = tempfile::TempDir::new().unwrap();
1722        let grant_dir = tempfile::TempDir::new().unwrap();
1723        let grant_path = grant_dir.path().join("grants.json");
1724
1725        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
1726
1727        let now_unix_ms: u64 = 1_000_000;
1728        let destination = "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL";
1729        let asset = "XLM";
1730        let amount_stroops: i64 = 10_000_000;
1731        let attestation_key = [0u8; 32];
1732        let uid = process_uid_for_attestation().unwrap();
1733
1734        // Write a matching grant to the grant store before calling the resolver.
1735        record_first_invoke_grant(
1736            "test",
1737            "pay-toolset",
1738            "sign-payment",
1739            destination,
1740            asset,
1741            0,
1742            amount_stroops,
1743            &uid,
1744            now_unix_ms,
1745            &attestation_key,
1746            Some(grant_path.clone()),
1747        )
1748        .unwrap();
1749
1750        let params = GatedInvokeParams {
1751            toolset_name: "pay-toolset",
1752            action: "stellar_pay_commit",
1753            toolsets_root: toolsets_dir.path(),
1754            profile_name: "test",
1755            authoritative_destination: destination,
1756            authoritative_asset: asset,
1757            authoritative_amount_stroops: amount_stroops,
1758            now_unix_ms,
1759            process_uid: &uid,
1760            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1761            grant_store_path_override: Some(grant_path),
1762        };
1763
1764        let outcome = resolve_toolset_sign_payment_gated(&params).unwrap();
1765        match outcome {
1766            GatedResolveOutcome::Resolved { tool_name } => {
1767                assert_eq!(tool_name, "stellar_pay_commit");
1768            }
1769            GatedResolveOutcome::FirstInvokeApprovalRequired { .. } => {
1770                panic!("expected Resolved, got FirstInvokeApprovalRequired");
1771            }
1772        }
1773    }
1774
1775    // ── SignRuleCreate gated resolver (Package D, GH issue #8) ────────────────
1776    //
1777    // `resolve_toolset_sign_payment_gated` is reused as-is for `sign-rule-create`
1778    // (it is generic over the GATED matrix / Capability, despite its name): the
1779    // "destination/asset/amount" triple is repurposed as the bucket-matching
1780    // dimension for rule-create grants — `authoritative_destination` carries the
1781    // smart-account C-strkey (the correct re-prompt dimension: a DIFFERENT
1782    // smart account should re-trigger first-invoke consent, exactly like a
1783    // different payment destination does), `authoritative_asset` is a fixed
1784    // sentinel (`"context-rule"`, not a real asset), and
1785    // `authoritative_amount_stroops` is a fixed positive dummy (`1`) since the
1786    // amount dimension carries no independent meaning here. The per-proposal
1787    // `RuleProposalSimulated` attestation (verified inside
1788    // `stellar_rule_create_commit`) remains the REAL, unconditional per-action
1789    // security boundary — this first-invoke gate is only the one-time
1790    // "this toolset may attempt rule-create for this smart account" consent.
1791
1792    use crate::matrix::{SIGN_RULE_CREATE_AMOUNT_SENTINEL, SIGN_RULE_CREATE_ASSET_SENTINEL};
1793
1794    fn write_sign_rule_create_pin(toolsets_root: &std::path::Path, pkg: &str) {
1795        std::fs::create_dir_all(toolsets_root.join(pkg)).unwrap();
1796        let caps = stellar_agent_toolsets::parse_capability_value_pub("sign-rule-create").unwrap();
1797        let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1798            pkg,
1799            "1.0.0",
1800            "d".repeat(64),
1801            "GABC1234567890123456789012345678901234567890123456",
1802            "2026-06-12T00:00:00Z",
1803            caps,
1804            vec![],
1805            None,
1806        );
1807        write_pin_json(toolsets_root, pkg, &pin);
1808    }
1809
1810    #[test]
1811    #[cfg(feature = "test-helpers")]
1812    fn gated_resolver_sign_rule_create_no_grant_queues_first_invoke_gate() {
1813        let toolsets_dir = tempfile::TempDir::new().unwrap();
1814        let approval_dir = tempfile::TempDir::new().unwrap();
1815        let grant_dir = tempfile::TempDir::new().unwrap();
1816
1817        write_sign_rule_create_pin(toolsets_dir.path(), "rule-toolset");
1818
1819        let params = GatedInvokeParams {
1820            toolset_name: "rule-toolset",
1821            action: "stellar_rule_create_commit",
1822            toolsets_root: toolsets_dir.path(),
1823            profile_name: "test",
1824            authoritative_destination: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
1825            authoritative_asset: SIGN_RULE_CREATE_ASSET_SENTINEL,
1826            authoritative_amount_stroops: SIGN_RULE_CREATE_AMOUNT_SENTINEL,
1827            now_unix_ms: 1_000_000,
1828            process_uid: "uid-test",
1829            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1830            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1831        };
1832
1833        let outcome = resolve_toolset_sign_payment_gated(&params).unwrap();
1834        match outcome {
1835            GatedResolveOutcome::FirstInvokeApprovalRequired {
1836                approval_nonce,
1837                toolset_name,
1838                capability,
1839            } => {
1840                assert!(
1841                    !approval_nonce.is_empty(),
1842                    "approval_nonce must be non-empty"
1843                );
1844                assert_eq!(toolset_name, "rule-toolset");
1845                assert_eq!(capability, "sign-rule-create");
1846            }
1847            GatedResolveOutcome::Resolved { .. } => {
1848                panic!("expected FirstInvokeApprovalRequired, got Resolved");
1849            }
1850        }
1851    }
1852
1853    #[test]
1854    #[cfg(feature = "test-helpers")]
1855    fn gated_resolver_sign_rule_create_with_matching_grant_returns_resolved() {
1856        use stellar_agent_core::approval::process_uid_for_attestation;
1857
1858        let toolsets_dir = tempfile::TempDir::new().unwrap();
1859        let approval_dir = tempfile::TempDir::new().unwrap();
1860        let grant_dir = tempfile::TempDir::new().unwrap();
1861        let grant_path = grant_dir.path().join("grants.json");
1862
1863        write_sign_rule_create_pin(toolsets_dir.path(), "rule-toolset");
1864
1865        let now_unix_ms: u64 = 1_000_000;
1866        let smart_account = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM";
1867        let attestation_key = [0u8; 32];
1868        let uid = process_uid_for_attestation().unwrap();
1869
1870        record_first_invoke_grant(
1871            "test",
1872            "rule-toolset",
1873            "sign-rule-create",
1874            smart_account,
1875            SIGN_RULE_CREATE_ASSET_SENTINEL,
1876            0,
1877            SIGN_RULE_CREATE_AMOUNT_SENTINEL,
1878            &uid,
1879            now_unix_ms,
1880            &attestation_key,
1881            Some(grant_path.clone()),
1882        )
1883        .unwrap();
1884
1885        let params = GatedInvokeParams {
1886            toolset_name: "rule-toolset",
1887            action: "stellar_rule_create_commit",
1888            toolsets_root: toolsets_dir.path(),
1889            profile_name: "test",
1890            authoritative_destination: smart_account,
1891            authoritative_asset: SIGN_RULE_CREATE_ASSET_SENTINEL,
1892            authoritative_amount_stroops: SIGN_RULE_CREATE_AMOUNT_SENTINEL,
1893            now_unix_ms,
1894            process_uid: &uid,
1895            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1896            grant_store_path_override: Some(grant_path),
1897        };
1898
1899        let outcome = resolve_toolset_sign_payment_gated(&params).unwrap();
1900        match outcome {
1901            GatedResolveOutcome::Resolved { tool_name } => {
1902                assert_eq!(tool_name, "stellar_rule_create_commit");
1903            }
1904            GatedResolveOutcome::FirstInvokeApprovalRequired { .. } => {
1905                panic!("expected Resolved, got FirstInvokeApprovalRequired");
1906            }
1907        }
1908    }
1909
1910    /// Negative: a toolset granting ONLY `propose-transaction` (ungated) can
1911    /// invoke `stellar_rule_create` (the propose step) but NOT
1912    /// `stellar_rule_create_commit` (gated; requires `sign-rule-create`) via
1913    /// the ungated path.
1914    #[test]
1915    fn propose_transaction_grants_stellar_rule_create_but_not_commit() {
1916        let caps =
1917            stellar_agent_toolsets::parse_capability_value_pub("propose-transaction").unwrap();
1918        let tool = check_toolset_action("stellar_rule_create", &caps, &[]).unwrap();
1919        assert_eq!(tool, "stellar_rule_create");
1920
1921        let err = check_toolset_action("stellar_rule_create_commit", &caps, &[]).unwrap_err();
1922        assert!(matches!(
1923            err,
1924            ToolsetRuntimeError::UnknownToolsetAction { .. }
1925        ));
1926    }
1927
1928    // ── Gated resolver: zero amount rejected EVEN WITH a matching grant ───────
1929    // Regression guard: the positivity check must run BEFORE the grant lookup,
1930    // so a grant covering the [0, N] bucket cannot resolve a zero-stroop invoke
1931    // to the signing tool.
1932
1933    #[test]
1934    #[cfg(feature = "test-helpers")]
1935    fn gated_resolver_grant_present_zero_amount_rejected() {
1936        use stellar_agent_core::approval::process_uid_for_attestation;
1937
1938        let toolsets_dir = tempfile::TempDir::new().unwrap();
1939        let approval_dir = tempfile::TempDir::new().unwrap();
1940        let grant_dir = tempfile::TempDir::new().unwrap();
1941        let grant_path = grant_dir.path().join("grants.json");
1942
1943        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
1944
1945        let now_unix_ms: u64 = 1_000_000;
1946        let destination = "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL";
1947        let asset = "XLM";
1948        let attestation_key = [0u8; 32];
1949        let uid = process_uid_for_attestation().unwrap();
1950
1951        // A grant covering [0, 10_000_000] would match a zero-stroop invoke if
1952        // the positivity check ran after the grant lookup.
1953        record_first_invoke_grant(
1954            "test",
1955            "pay-toolset",
1956            "sign-payment",
1957            destination,
1958            asset,
1959            0,
1960            10_000_000,
1961            &uid,
1962            now_unix_ms,
1963            &attestation_key,
1964            Some(grant_path.clone()),
1965        )
1966        .unwrap();
1967
1968        let params = GatedInvokeParams {
1969            toolset_name: "pay-toolset",
1970            action: "stellar_pay_commit",
1971            toolsets_root: toolsets_dir.path(),
1972            profile_name: "test",
1973            authoritative_destination: destination,
1974            authoritative_asset: asset,
1975            authoritative_amount_stroops: 0,
1976            now_unix_ms,
1977            process_uid: &uid,
1978            approval_dir_override: Some(approval_dir.path().to_path_buf()),
1979            grant_store_path_override: Some(grant_path),
1980        };
1981
1982        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
1983        assert!(
1984            matches!(
1985                err,
1986                ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: 0 }
1987            ),
1988            "a zero amount must be rejected even when a [0, N] grant exists; got: {err:?}"
1989        );
1990    }
1991
1992    // ── Gated resolver: non-positive amount → InvalidAuthoritativeAmount ─────
1993
1994    #[test]
1995    #[cfg(feature = "test-helpers")]
1996    fn gated_resolver_zero_amount_rejected() {
1997        let toolsets_dir = tempfile::TempDir::new().unwrap();
1998        let approval_dir = tempfile::TempDir::new().unwrap();
1999        let grant_dir = tempfile::TempDir::new().unwrap();
2000
2001        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
2002
2003        let params = GatedInvokeParams {
2004            toolset_name: "pay-toolset",
2005            action: "stellar_pay_commit",
2006            toolsets_root: toolsets_dir.path(),
2007            profile_name: "test",
2008            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2009            authoritative_asset: "XLM",
2010            authoritative_amount_stroops: 0,
2011            now_unix_ms: 1_000_000,
2012            process_uid: "uid-test",
2013            approval_dir_override: Some(approval_dir.path().to_path_buf()),
2014            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
2015        };
2016
2017        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
2018        assert!(
2019            matches!(
2020                err,
2021                ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: 0 }
2022            ),
2023            "expected InvalidAuthoritativeAmount(0), got: {err:?}"
2024        );
2025    }
2026
2027    #[test]
2028    #[cfg(feature = "test-helpers")]
2029    fn gated_resolver_negative_amount_rejected() {
2030        let toolsets_dir = tempfile::TempDir::new().unwrap();
2031        let approval_dir = tempfile::TempDir::new().unwrap();
2032        let grant_dir = tempfile::TempDir::new().unwrap();
2033
2034        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
2035
2036        let params = GatedInvokeParams {
2037            toolset_name: "pay-toolset",
2038            action: "stellar_pay_commit",
2039            toolsets_root: toolsets_dir.path(),
2040            profile_name: "test",
2041            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2042            authoritative_asset: "XLM",
2043            authoritative_amount_stroops: -1,
2044            now_unix_ms: 1_000_000,
2045            process_uid: "uid-test",
2046            approval_dir_override: Some(approval_dir.path().to_path_buf()),
2047            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
2048        };
2049
2050        let err = resolve_toolset_sign_payment_gated(&params).unwrap_err();
2051        assert!(
2052            matches!(
2053                err,
2054                ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: -1 }
2055            ),
2056            "expected InvalidAuthoritativeAmount(-1), got: {err:?}"
2057        );
2058    }
2059
2060    // ── Gated resolver: queued pending carries caller-supplied process_uid ────
2061    //
2062    // The queued ToolsetFirstInvokeGate pending carries the caller-supplied
2063    // process_uid. This test reads the pending-approval store after queuing and
2064    // asserts the stored process_uid equals the supplied value. The invariant is
2065    // that the resolver HONORS the caller-supplied value and does not recompute
2066    // it internally — the caller controls the identity for attestation purposes.
2067
2068    #[test]
2069    #[cfg(feature = "test-helpers")]
2070    fn gated_resolver_queued_pending_uses_caller_supplied_process_uid() {
2071        let toolsets_dir = tempfile::TempDir::new().unwrap();
2072        let approval_dir = tempfile::TempDir::new().unwrap();
2073        let grant_dir = tempfile::TempDir::new().unwrap();
2074
2075        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
2076
2077        // Synthetic uid that the caller supplies; the resolver must store it
2078        // verbatim without recomputing via process_uid_for_attestation().
2079        let supplied_uid = "1234567890";
2080
2081        let params = GatedInvokeParams {
2082            toolset_name: "pay-toolset",
2083            action: "stellar_pay_commit",
2084            toolsets_root: toolsets_dir.path(),
2085            profile_name: "test",
2086            authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2087            authoritative_asset: "XLM",
2088            authoritative_amount_stroops: 10_000_000,
2089            now_unix_ms: 1_000_000,
2090            process_uid: supplied_uid,
2091            approval_dir_override: Some(approval_dir.path().to_path_buf()),
2092            grant_store_path_override: Some(grant_dir.path().join("grants.json")),
2093        };
2094
2095        let outcome = resolve_toolset_sign_payment_gated(&params).unwrap();
2096        let approval_nonce = match outcome {
2097            GatedResolveOutcome::FirstInvokeApprovalRequired { approval_nonce, .. } => {
2098                approval_nonce
2099            }
2100            GatedResolveOutcome::Resolved { .. } => {
2101                panic!("expected FirstInvokeApprovalRequired, got Resolved");
2102            }
2103        };
2104
2105        // Read the persisted pending from the approval store and verify its
2106        // process_uid equals the supplied value. process_uid is a top-level
2107        // field on PendingApproval (not inside ApprovalKind).
2108        let store_path = approval_dir.path().join("test.toml");
2109        let store = stellar_agent_core::approval::PendingApprovalStore::open(store_path).unwrap();
2110        let pending = store
2111            .get(&approval_nonce)
2112            .expect("queued pending must be findable by nonce");
2113
2114        assert_eq!(
2115            pending.process_uid, supplied_uid,
2116            "queued pending's process_uid must equal the caller-supplied value '{}'; \
2117             got '{}' — would differ if the resolver recomputed it internally",
2118            supplied_uid, pending.process_uid
2119        );
2120    }
2121
2122    // ── Gated resolver: over-max amount re-prompts ───────────────────────────
2123    //
2124    // A grant with a bounded amount_max is stored. An invoke with
2125    // authoritative_amount_stroops ABOVE that max must NOT return Resolved —
2126    // the grant does not match, so the resolver re-prompts (queues a new
2127    // ToolsetFirstInvokeGate pending and returns FirstInvokeApprovalRequired).
2128
2129    #[test]
2130    #[cfg(feature = "test-helpers")]
2131    fn gated_resolver_over_max_amount_re_prompts() {
2132        use stellar_agent_core::approval::process_uid_for_attestation;
2133
2134        let toolsets_dir = tempfile::TempDir::new().unwrap();
2135        let approval_dir = tempfile::TempDir::new().unwrap();
2136        let grant_dir = tempfile::TempDir::new().unwrap();
2137        let grant_path = grant_dir.path().join("grants.json");
2138
2139        write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
2140
2141        let now_unix_ms: u64 = 1_000_000;
2142        let destination = "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL";
2143        let asset = "XLM";
2144        let grant_max_stroops: i64 = 10_000_000; // 1 XLM
2145        let attestation_key = [0u8; 32];
2146        let uid = process_uid_for_attestation().unwrap();
2147
2148        // Store a grant covering [0, 10_000_000].
2149        record_first_invoke_grant(
2150            "test",
2151            "pay-toolset",
2152            "sign-payment",
2153            destination,
2154            asset,
2155            0,
2156            grant_max_stroops,
2157            &uid,
2158            now_unix_ms,
2159            &attestation_key,
2160            Some(grant_path.clone()),
2161        )
2162        .unwrap();
2163
2164        // Invoke with an amount ABOVE the grant's max — should NOT match.
2165        let over_max_stroops: i64 = grant_max_stroops + 1;
2166
2167        let params = GatedInvokeParams {
2168            toolset_name: "pay-toolset",
2169            action: "stellar_pay_commit",
2170            toolsets_root: toolsets_dir.path(),
2171            profile_name: "test",
2172            authoritative_destination: destination,
2173            authoritative_asset: asset,
2174            authoritative_amount_stroops: over_max_stroops,
2175            now_unix_ms,
2176            process_uid: &uid,
2177            approval_dir_override: Some(approval_dir.path().to_path_buf()),
2178            grant_store_path_override: Some(grant_path),
2179        };
2180
2181        let outcome = resolve_toolset_sign_payment_gated(&params).unwrap();
2182
2183        // Must NOT return Resolved — the grant's amount_max is exceeded.
2184        assert!(
2185            matches!(
2186                outcome,
2187                GatedResolveOutcome::FirstInvokeApprovalRequired { .. }
2188            ),
2189            "amount above grant max must re-prompt (FirstInvokeApprovalRequired), \
2190             got Resolved — the existing grant must not match an over-max amount"
2191        );
2192
2193        // Verify the outcome carries a non-empty nonce (a new pending was queued).
2194        match outcome {
2195            GatedResolveOutcome::FirstInvokeApprovalRequired { approval_nonce, .. } => {
2196                assert!(
2197                    !approval_nonce.is_empty(),
2198                    "re-prompt must carry a non-empty approval_nonce"
2199                );
2200            }
2201            GatedResolveOutcome::Resolved { .. } => unreachable!(),
2202        }
2203    }
2204
2205    // ── record_first_invoke_grant: happy path ─────────────────────────────────
2206
2207    #[test]
2208    #[cfg(feature = "test-helpers")]
2209    fn record_first_invoke_grant_persists_to_store() {
2210        use stellar_agent_core::approval::process_uid_for_attestation;
2211
2212        let grant_dir = tempfile::TempDir::new().unwrap();
2213        let grant_path = grant_dir.path().join("grants.json");
2214
2215        let now_unix_ms: u64 = 2_000_000;
2216        let uid = process_uid_for_attestation().unwrap();
2217        let attestation_key = [1u8; 32];
2218
2219        let grant = record_first_invoke_grant(
2220            "prod",
2221            "my-toolset",
2222            "sign-payment",
2223            "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2224            "XLM",
2225            0,
2226            50_000_000,
2227            &uid,
2228            now_unix_ms,
2229            &attestation_key,
2230            Some(grant_path.clone()),
2231        )
2232        .unwrap();
2233
2234        assert_eq!(grant.toolset_name, "my-toolset");
2235        assert_eq!(grant.capability, "sign-payment");
2236
2237        // The security-load-bearing output is the HMAC attestation blob (the
2238        // grant store's matching does NOT verify it). Bind the test to that
2239        // cryptographic output: it must verify against the real key and be
2240        // rejected under a wrong key.
2241        assert!(
2242            grant.verify_attestation(&attestation_key),
2243            "grant must verify against the attestation key it was built with"
2244        );
2245        assert!(
2246            !grant.verify_attestation(&[0xff; 32]),
2247            "grant must NOT verify against a wrong attestation key"
2248        );
2249
2250        // Re-open the store and verify the grant is persisted.
2251        let store =
2252            stellar_agent_core::approval::ToolsetGrantStore::open(grant_path, now_unix_ms).unwrap();
2253        let found = store.find_matching(
2254            "my-toolset",
2255            "sign-payment",
2256            "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2257            "XLM",
2258            30_000_000, // within [0, 50_000_000]
2259            now_unix_ms,
2260        );
2261        assert!(found.is_some(), "persisted grant must be findable in store");
2262    }
2263}