#![forbid(unsafe_code)]
#![deny(missing_docs)]
pub mod error;
pub mod matrix;
pub use error::ToolsetRuntimeError;
pub use matrix::{
GATED_MATRIX_ENTRIES, SIGN_PAYMENT_GATED_TOOLS, SIGNING_DENYLIST, gated_grants_for_capability,
resolve_action,
};
use std::path::Path;
use serde::Serialize;
use stellar_agent_core::approval::{
DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_BACKOFF, DEFAULT_TTL_MS, PendingApproval,
TOOLSET_GRANT_DEFAULT_TTL_MS, ToolsetGrant, ToolsetGrantStore, build_attested_grant,
default_toolset_grants_path, open_with_retry,
};
use stellar_agent_toolsets::{Capability, CapabilitySet, sanitise_display};
use stellar_agent_toolsets_install::ToolsetPinRecord;
use tracing::debug;
#[derive(Debug, Clone, Serialize)]
pub struct ToolsetListEntry {
pub name: String,
pub description: String,
pub capabilities: Vec<String>,
pub allowed_tools: Vec<String>,
pub version: String,
pub actions: Vec<String>,
}
pub fn list_pinned_toolsets(
toolsets_root: &Path,
) -> Result<Vec<ToolsetListEntry>, ToolsetRuntimeError> {
if !toolsets_root.exists() {
return Ok(Vec::new());
}
let read_dir = std::fs::read_dir(toolsets_root)
.map_err(|e| ToolsetRuntimeError::Io(e.kind().to_string()))?;
let mut entries: Vec<ToolsetListEntry> = Vec::new();
for dir_entry in read_dir {
let dir_entry = match dir_entry {
Ok(e) => e,
Err(e) => {
tracing::warn!(error = %e, "error reading toolsets_root entry; skipping");
continue;
}
};
let path = dir_entry.path();
if !path.is_dir() {
continue;
}
let pkg_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_owned(),
None => continue,
};
let pin = match stellar_agent_toolsets_install::read_pin(&pkg_name, toolsets_root) {
Ok(Some(p)) => p,
Ok(None) => {
debug!(package = %pkg_name, "no pin record; skipping");
continue;
}
Err(e) => {
tracing::warn!(package = %pkg_name, error = %e, "malformed pin; skipping");
continue;
}
};
entries.push(pin_to_list_entry(&pin));
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
pub fn check_toolset_action(
action: &str,
capabilities: &CapabilitySet,
allowed_tools: &[String],
) -> Result<&'static str, ToolsetRuntimeError> {
let (tool_name, granting_capability) = resolve_action(action)?;
if !capabilities.contains(granting_capability) {
return Err(ToolsetRuntimeError::CapabilityNotDeclared {
action: sanitise_display(action, 128),
capability: granting_capability.to_string(),
});
}
if !allowed_tools.is_empty() && !allowed_tools.iter().any(|t| t == tool_name) {
return Err(ToolsetRuntimeError::ToolNotAllowed {
tool: sanitise_display(tool_name, 128),
action: sanitise_display(action, 128),
});
}
Ok(tool_name)
}
pub fn resolve_toolset_and_check(
toolset_name: &str,
action: &str,
toolsets_root: &Path,
) -> Result<(&'static str, ToolsetPinRecord), ToolsetRuntimeError> {
stellar_agent_toolsets_install::validate_package_name(toolset_name).map_err(|_| {
ToolsetRuntimeError::ToolsetNotInstalled {
name: sanitise_display(toolset_name, 64),
}
})?;
let pin = stellar_agent_toolsets_install::read_pin(toolset_name, toolsets_root)
.map_err(|e| ToolsetRuntimeError::Io(install_error_kind_str(&e)))?
.ok_or_else(|| ToolsetRuntimeError::ToolsetNotInstalled {
name: sanitise_display(toolset_name, 64),
})?;
if let Some(ref expected_digest) = pin.toolset_md_shasum {
let toolset_md_path = toolsets_root.join(toolset_name).join("TOOLSET.md");
let toolset_md_bytes = std::fs::read(&toolset_md_path).map_err(|_| {
ToolsetRuntimeError::ContentDigestMismatch {
name: sanitise_display(toolset_name, 64),
}
})?;
let actual_digest = stellar_agent_toolsets_install::sha256_hex_of(&toolset_md_bytes);
if actual_digest != *expected_digest {
tracing::warn!(
toolset = %toolset_name,
"dispatch-time TOOLSET.md content digest mismatch; refusing dispatch"
);
return Err(ToolsetRuntimeError::ContentDigestMismatch {
name: sanitise_display(toolset_name, 64),
});
}
debug!(toolset = %toolset_name, "dispatch-time TOOLSET.md content digest verified");
}
let capabilities = &pin.capabilities;
let allowed_tools = &pin.allowed_tools;
let tool_name = check_toolset_action(action, capabilities, allowed_tools)?;
Ok((tool_name, pin))
}
fn allowed_by_narrowing(allowed_tools: &[String], tool: &str) -> bool {
allowed_tools.is_empty() || allowed_tools.iter().any(|t| t == tool)
}
fn pin_to_list_entry(pin: &ToolsetPinRecord) -> ToolsetListEntry {
let name = sanitise_display(&pin.package, 64);
let version = sanitise_display(&pin.version, 64);
let capabilities: Vec<String> = pin.capabilities.iter().map(|c| c.to_string()).collect();
let allowed_tools: Vec<String> = pin
.allowed_tools
.iter()
.map(|t| sanitise_display(t, 128))
.collect();
let mut actions: Vec<String> = Vec::new();
for cap in pin.capabilities.iter() {
let grants = matrix::grants_for_capability(cap);
for &tool in grants {
if allowed_by_narrowing(&pin.allowed_tools, tool) {
let sanitised = sanitise_display(tool, 128);
if !actions.contains(&sanitised) {
actions.push(sanitised);
}
}
}
}
actions.sort();
let description = String::new();
ToolsetListEntry {
name,
description,
capabilities,
allowed_tools,
version,
actions,
}
}
#[derive(Debug)]
pub struct GatedInvokeParams<'a> {
pub toolset_name: &'a str,
pub action: &'a str,
pub toolsets_root: &'a Path,
pub profile_name: &'a str,
pub authoritative_destination: &'a str,
pub authoritative_asset: &'a str,
pub authoritative_amount_stroops: i64,
pub now_unix_ms: u64,
pub process_uid: &'a str,
#[cfg(feature = "test-helpers")]
pub approval_dir_override: Option<std::path::PathBuf>,
#[cfg(feature = "test-helpers")]
pub grant_store_path_override: Option<std::path::PathBuf>,
}
#[derive(Debug)]
pub enum GatedResolveOutcome {
Resolved {
tool_name: &'static str,
},
FirstInvokeApprovalRequired {
approval_nonce: String,
toolset_name: String,
capability: String,
},
}
#[allow(clippy::too_many_lines)]
pub fn resolve_toolset_sign_payment_gated(
params: &GatedInvokeParams<'_>,
) -> Result<GatedResolveOutcome, ToolsetRuntimeError> {
let toolset_name = params.toolset_name;
let action = params.action;
stellar_agent_toolsets_install::validate_package_name(toolset_name).map_err(|_| {
ToolsetRuntimeError::ToolsetNotInstalled {
name: sanitise_display(toolset_name, 64),
}
})?;
let pin = stellar_agent_toolsets_install::read_pin(toolset_name, params.toolsets_root)
.map_err(|e| ToolsetRuntimeError::Io(install_error_kind_str(&e)))?
.ok_or_else(|| ToolsetRuntimeError::ToolsetNotInstalled {
name: sanitise_display(toolset_name, 64),
})?;
let (tool_name, granting_capability) = resolve_gated_action(action)?;
if !pin.capabilities.contains(granting_capability) {
return Err(ToolsetRuntimeError::CapabilityNotDeclared {
action: sanitise_display(action, 128),
capability: granting_capability.to_string(),
});
}
if !allowed_by_narrowing(&pin.allowed_tools, tool_name) {
return Err(ToolsetRuntimeError::ToolNotAllowed {
tool: sanitise_display(tool_name, 128),
action: sanitise_display(action, 128),
});
}
let amount = params.authoritative_amount_stroops;
if amount <= 0 {
return Err(ToolsetRuntimeError::InvalidAuthoritativeAmount {
amount_stroops: amount,
});
}
let grant_store_path = {
#[cfg(feature = "test-helpers")]
{
if let Some(ref p) = params.grant_store_path_override {
p.clone()
} else {
default_toolset_grants_path(params.profile_name).map_err(|e| {
ToolsetRuntimeError::GrantStoreError {
detail: format!("grant_store_path: {e}"),
}
})?
}
}
#[cfg(not(feature = "test-helpers"))]
{
default_toolset_grants_path(params.profile_name).map_err(|e| {
ToolsetRuntimeError::GrantStoreError {
detail: format!("grant_store_path: {e}"),
}
})?
}
};
let grant_store =
ToolsetGrantStore::open(grant_store_path, params.now_unix_ms).map_err(|e| {
ToolsetRuntimeError::GrantStoreError {
detail: format!("open: {e}"),
}
})?;
let capability_str = granting_capability.to_string();
let matching_grant = grant_store.find_matching(
toolset_name,
&capability_str,
params.authoritative_destination,
params.authoritative_asset,
params.authoritative_amount_stroops,
params.now_unix_ms,
);
if matching_grant.is_some() {
tracing::debug!(
toolset = %toolset_name,
capability = %capability_str,
"first-invoke gate: matching grant found; routing to gated tool (per-action approval will be forced on by caller)"
);
return Ok(GatedResolveOutcome::Resolved { tool_name });
}
let amount_min_stroops = 0_i64;
let amount_max_stroops = amount;
let destination = params.authoritative_destination;
let asset = params.authoritative_asset;
let uid = params.process_uid.to_owned();
let pending = PendingApproval::new_toolset_first_invoke_gate_pending(
toolset_name.to_owned(),
capability_str.clone(),
destination.to_owned(),
asset.to_owned(),
amount_min_stroops,
amount_max_stroops,
uid,
DEFAULT_TTL_MS,
)
.map_err(|e| ToolsetRuntimeError::Io(format!("new_toolset_gate_pending: {e}")))?;
let approval_nonce = pending.approval_nonce.clone();
let approval_store_path = {
#[cfg(feature = "test-helpers")]
{
if let Some(ref override_dir) = params.approval_dir_override {
override_dir.join(format!("{}.toml", params.profile_name))
} else {
build_approval_store_path(params.profile_name).ok_or_else(|| {
ToolsetRuntimeError::Io("approval_store_path: dir unavailable".to_owned())
})?
}
}
#[cfg(not(feature = "test-helpers"))]
{
build_approval_store_path(params.profile_name).ok_or_else(|| {
ToolsetRuntimeError::Io("approval_store_path: dir unavailable".to_owned())
})?
}
};
let mut approval_store = open_with_retry(
&approval_store_path,
DEFAULT_RETRY_ATTEMPTS,
DEFAULT_RETRY_BACKOFF,
)
.map_err(|e| ToolsetRuntimeError::Io(format!("approval_store_open: {e}")))?;
approval_store
.insert(pending, params.now_unix_ms)
.map_err(|e| ToolsetRuntimeError::Io(format!("approval_store_insert: {e}")))?;
tracing::debug!(
toolset = %toolset_name,
capability = %capability_str,
nonce = %approval_nonce,
"first-invoke gate: no current grant; queued ToolsetFirstInvokeGate approval"
);
Ok(GatedResolveOutcome::FirstInvokeApprovalRequired {
approval_nonce,
toolset_name: sanitise_display(toolset_name, 64),
capability: sanitise_display(&capability_str, 64),
})
}
pub type GrantStorePathOverride = Option<std::path::PathBuf>;
#[allow(clippy::too_many_arguments)]
pub fn record_first_invoke_grant(
profile_name: &str,
toolset_name: &str,
capability: &str,
destination: &str,
asset: &str,
amount_min_stroops: i64,
amount_max_stroops: i64,
process_uid: &str,
now_unix_ms: u64,
attestation_key: &[u8; 32],
binding: &stellar_agent_core::approval::AttestationBinding<'_>,
grant_store_path_override: GrantStorePathOverride,
) -> Result<ToolsetGrant, ToolsetRuntimeError> {
let grant = build_attested_grant(
toolset_name.to_owned(),
capability.to_owned(),
destination.to_owned(),
asset.to_owned(),
amount_min_stroops,
amount_max_stroops,
process_uid.to_owned(),
now_unix_ms,
TOOLSET_GRANT_DEFAULT_TTL_MS,
attestation_key,
binding,
)
.map_err(|e| ToolsetRuntimeError::GrantStoreError {
detail: format!("build_attested_grant: {e}"),
})?;
let grant_store_path = if let Some(p) = grant_store_path_override {
p
} else {
default_toolset_grants_path(profile_name).map_err(|e| {
ToolsetRuntimeError::GrantStoreError {
detail: format!("grant_store_path: {e}"),
}
})?
};
let mut store = ToolsetGrantStore::open(grant_store_path, now_unix_ms).map_err(|e| {
ToolsetRuntimeError::GrantStoreError {
detail: format!("open: {e}"),
}
})?;
let grant_clone = grant.clone();
store
.insert(grant)
.map_err(|e| ToolsetRuntimeError::GrantStoreError {
detail: format!("insert: {e}"),
})?;
Ok(grant_clone)
}
pub fn resolve_gated_action(
action: &str,
) -> Result<(&'static str, Capability), ToolsetRuntimeError> {
for (cap, tools) in matrix::GATED_MATRIX_ENTRIES {
for tool in *tools {
if *tool == action {
return Ok((tool, *cap));
}
}
}
Err(ToolsetRuntimeError::UnknownToolsetAction {
action: sanitise_display(action, 128),
})
}
fn build_approval_store_path(profile_name: &str) -> Option<std::path::PathBuf> {
let dir = stellar_agent_core::profile::schema::default_approval_dir().ok()?;
Some(dir.join(format!("{profile_name}.toml")))
}
pub use stellar_agent_toolsets_install::read_pin;
pub use stellar_agent_toolsets_install::validate_package_name;
fn install_error_kind_str(e: &stellar_agent_toolsets_install::ToolsetInstallError) -> String {
use stellar_agent_toolsets_install::ToolsetInstallError;
match e {
ToolsetInstallError::Io { .. } => "io_error".to_owned(),
ToolsetInstallError::PinRecordMalformed { .. } => "pin_record_malformed".to_owned(),
_ => "install_error".to_owned(),
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "test-only; panics acceptable in unit tests"
)]
mod tests {
use super::*;
fn all_caps() -> CapabilitySet {
stellar_agent_toolsets::parse_capability_value_pub(
"read-balance propose-transaction suggest-destination observe-event",
)
.unwrap()
}
#[test]
fn unknown_action_returns_error() {
let caps = all_caps();
let err = check_toolset_action("no-such-action", &caps, &[]).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
"expected UnknownToolsetAction, got: {err:?}"
);
}
#[test]
fn capability_not_declared_returns_error() {
let empty_caps = CapabilitySet::empty();
let err = check_toolset_action("stellar_balances", &empty_caps, &[]).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
"expected CapabilityNotDeclared, got: {err:?}"
);
}
#[test]
fn empty_capability_set_refuses_all_known_actions() {
let empty_caps = CapabilitySet::empty();
for (action, _cap) in matrix::ALL_MATRIX_ENTRIES {
let err = check_toolset_action(action, &empty_caps, &[]).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
"action {action}: expected CapabilityNotDeclared, got: {err:?}"
);
}
}
#[test]
fn allowed_tools_narrowing_excludes_tool() {
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
let err = check_toolset_action("stellar_balances", &caps, &["some-other-tool".to_owned()])
.unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ToolNotAllowed { .. }),
"expected ToolNotAllowed, got: {err:?}"
);
}
#[test]
fn allowed_tools_empty_vacuously_satisfied() {
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
let tool = check_toolset_action("stellar_balances", &caps, &[]).unwrap();
assert_eq!(tool, "stellar_balances");
}
#[test]
fn all_caps_plus_signing_in_allowed_tools_still_refused() {
let caps = all_caps();
let allowed = vec![
"stellar_sep43_sign_transaction".to_owned(),
"stellar_sep53_sign_message".to_owned(),
"stellar_pay_commit".to_owned(),
];
for signing_tool in &allowed {
let err = check_toolset_action(signing_tool, &caps, &allowed).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
"signing tool {signing_tool}: expected UnknownToolsetAction (not in matrix), got: {err:?}"
);
}
}
#[test]
fn read_balance_toolset_cannot_invoke_propose_action() {
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
let err = check_toolset_action("stellar_pay", &caps, &[]).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
"expected CapabilityNotDeclared, got: {err:?}"
);
}
#[test]
fn dispatcher_tools_not_reachable_via_any_capability() {
let caps = all_caps();
for dispatcher_tool in ["stellar_toolset_list", "stellar_toolset_invoke"] {
let err = check_toolset_action(dispatcher_tool, &caps, &[]).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
"dispatcher tool {dispatcher_tool}: expected UnknownToolsetAction, got: {err:?}"
);
}
}
#[test]
fn read_balance_grants_stellar_balances() {
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
let tool = check_toolset_action("stellar_balances", &caps, &[]).unwrap();
assert_eq!(tool, "stellar_balances");
}
#[test]
fn propose_transaction_grants_stellar_pay() {
let caps =
stellar_agent_toolsets::parse_capability_value_pub("propose-transaction").unwrap();
let tool = check_toolset_action("stellar_pay", &caps, &[]).unwrap();
assert_eq!(tool, "stellar_pay");
}
#[test]
fn read_rules_grants_exactly_stellar_rules_list_and_get() {
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-rules").unwrap();
let list_tool = check_toolset_action("stellar_rules_list", &caps, &[]).unwrap();
assert_eq!(list_tool, "stellar_rules_list");
let get_tool = check_toolset_action("stellar_rules_get", &caps, &[]).unwrap();
assert_eq!(get_tool, "stellar_rules_get");
for other in matrix::ALL_MATRIX_TOOL_NAMES {
if *other == "stellar_rules_list" || *other == "stellar_rules_get" {
continue;
}
let err = check_toolset_action(other, &caps, &[]).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
"read-rules must not grant '{other}'; got {err:?}"
);
}
}
#[test]
fn toolset_without_read_rules_cannot_invoke_rules_tools() {
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
for tool in ["stellar_rules_list", "stellar_rules_get"] {
let err = check_toolset_action(tool, &caps, &[]).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
"expected CapabilityNotDeclared for '{tool}', got: {err:?}"
);
}
}
#[test]
fn list_pinned_toolsets_empty_dir() {
let dir = tempfile::TempDir::new().unwrap();
let list = list_pinned_toolsets(dir.path()).unwrap();
assert!(list.is_empty());
}
#[test]
fn list_pinned_toolsets_nonexistent_dir() {
let list =
list_pinned_toolsets(std::path::Path::new("/nonexistent/toolsets_root")).unwrap();
assert!(list.is_empty());
}
#[test]
fn path_traversal_dotdot_slash_rejected() {
let dir = tempfile::TempDir::new().unwrap();
let err = resolve_toolset_and_check("../foo", "stellar_balances", dir.path()).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
"expected ToolsetNotInstalled for '../foo', got: {err:?}"
);
}
#[test]
fn path_traversal_slash_in_name_rejected() {
let dir = tempfile::TempDir::new().unwrap();
let err = resolve_toolset_and_check("a/b", "stellar_balances", dir.path()).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
"expected ToolsetNotInstalled for 'a/b', got: {err:?}"
);
}
#[test]
fn path_traversal_backslash_in_name_rejected() {
let dir = tempfile::TempDir::new().unwrap();
let err = resolve_toolset_and_check("..\\foo", "stellar_balances", dir.path()).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
"expected ToolsetNotInstalled for '..\\\\foo', got: {err:?}"
);
}
#[test]
fn path_traversal_dotdot_alone_rejected() {
let dir = tempfile::TempDir::new().unwrap();
let err = resolve_toolset_and_check("..", "stellar_balances", dir.path()).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
"expected ToolsetNotInstalled for '..', got: {err:?}"
);
}
#[test]
fn path_traversal_dot_alone_rejected() {
let dir = tempfile::TempDir::new().unwrap();
let err = resolve_toolset_and_check(".", "stellar_balances", dir.path()).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
"expected ToolsetNotInstalled for '.', got: {err:?}"
);
}
#[test]
fn path_traversal_uppercase_rejected() {
let dir = tempfile::TempDir::new().unwrap();
let err =
resolve_toolset_and_check("MyToolset", "stellar_balances", dir.path()).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
"expected ToolsetNotInstalled for 'MyToolset', got: {err:?}"
);
}
#[test]
fn path_traversal_null_byte_rejected() {
let dir = tempfile::TempDir::new().unwrap();
let err =
resolve_toolset_and_check("toolset\0name", "stellar_balances", dir.path()).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
"expected ToolsetNotInstalled for null-byte name, got: {err:?}"
);
}
fn write_toolset_md(toolsets_root: &std::path::Path, pkg: &str, content: &str) {
let toolset_dir = toolsets_root.join(pkg);
std::fs::create_dir_all(&toolset_dir).unwrap();
std::fs::write(toolset_dir.join("TOOLSET.md"), content).unwrap();
}
fn write_pin_json(
toolsets_root: &std::path::Path,
pkg: &str,
pin: &stellar_agent_toolsets_install::ToolsetPinRecord,
) {
let json = serde_json::to_string_pretty(pin).unwrap();
std::fs::write(
toolsets_root
.join(pkg)
.join(".stellar-agent-toolset-pin.json"),
json,
)
.unwrap();
}
fn minimal_toolset_md(pkg: &str) -> String {
format!(
"---\nname: {pkg}\ndescription: test toolset\nstellar-agent-capabilities:\n - read-balance\n---\n# {pkg}\n"
)
}
#[test]
fn content_digest_match_allows_dispatch() {
let dir = tempfile::TempDir::new().unwrap();
let toolsets_root = dir.path();
let pkg = "my-toolset";
let toolset_md_content = minimal_toolset_md(pkg);
write_toolset_md(toolsets_root, pkg, &toolset_md_content);
let expected_digest =
stellar_agent_toolsets_install::sha256_hex_of(toolset_md_content.as_bytes());
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
pkg,
"1.0.0",
"a".repeat(64),
"GABC1234567890123456789012345678901234567890123456",
"2026-06-12T00:00:00Z",
caps,
vec![],
Some(expected_digest),
);
write_pin_json(toolsets_root, pkg, &pin);
let result = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root);
assert!(
result.is_ok(),
"dispatch must succeed when TOOLSET.md digest matches pin: {result:?}"
);
let (tool_name, _) = result.unwrap();
assert_eq!(tool_name, "stellar_balances");
}
#[test]
fn content_digest_mismatch_refuses_dispatch() {
let dir = tempfile::TempDir::new().unwrap();
let toolsets_root = dir.path();
let pkg = "my-toolset";
let original_content = minimal_toolset_md(pkg);
write_toolset_md(toolsets_root, pkg, &original_content);
let original_digest =
stellar_agent_toolsets_install::sha256_hex_of(original_content.as_bytes());
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
pkg,
"1.0.0",
"a".repeat(64),
"GABC1234567890123456789012345678901234567890123456",
"2026-06-12T00:00:00Z",
caps,
vec![],
Some(original_digest),
);
write_pin_json(toolsets_root, pkg, &pin);
let tampered_content = format!(
"---\nname: {pkg}\ndescription: TAMPERED BY ATTACKER\nstellar-agent-capabilities:\n - read-balance\n---\n"
);
std::fs::write(
toolsets_root.join(pkg).join("TOOLSET.md"),
&tampered_content,
)
.unwrap();
let err = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ContentDigestMismatch { .. }),
"expected ContentDigestMismatch for tampered TOOLSET.md, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains(pkg),
"ContentDigestMismatch message must include toolset name; got: {msg}"
);
}
#[test]
fn legacy_pin_without_toolset_md_shasum_skips_content_check() {
let dir = tempfile::TempDir::new().unwrap();
let toolsets_root = dir.path();
let pkg = "my-toolset";
write_toolset_md(toolsets_root, pkg, &minimal_toolset_md(pkg));
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
pkg,
"1.0.0",
"a".repeat(64),
"GABC1234567890123456789012345678901234567890123456",
"2026-06-12T00:00:00Z",
caps,
vec![],
None,
);
write_pin_json(toolsets_root, pkg, &pin);
let result = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root);
assert!(
result.is_ok(),
"pin without toolset_md_shasum must skip content check and dispatch: {result:?}"
);
}
#[test]
fn missing_toolset_md_when_digest_expected_refuses_dispatch() {
let dir = tempfile::TempDir::new().unwrap();
let toolsets_root = dir.path();
let pkg = "my-toolset";
std::fs::create_dir_all(toolsets_root.join(pkg)).unwrap();
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
pkg,
"1.0.0",
"a".repeat(64),
"GABC1234567890123456789012345678901234567890123456",
"2026-06-12T00:00:00Z",
caps,
vec![],
Some("a".repeat(64)),
);
write_pin_json(toolsets_root, pkg, &pin);
let err = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ContentDigestMismatch { .. }),
"missing TOOLSET.md with digest in pin must refuse dispatch: {err:?}"
);
}
#[test]
fn list_pinned_toolsets_with_valid_pins() {
let dir = tempfile::TempDir::new().unwrap();
let toolsets_root = dir.path();
for pkg in ["beta-toolset", "alpha-toolset"] {
write_toolset_md(toolsets_root, pkg, &minimal_toolset_md(pkg));
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
pkg,
"1.0.0",
"a".repeat(64),
"GABC1234567890123456789012345678901234567890123456",
"2026-06-12T00:00:00Z",
caps,
vec![],
None,
);
write_pin_json(toolsets_root, pkg, &pin);
}
let list = list_pinned_toolsets(toolsets_root).unwrap();
assert_eq!(list.len(), 2, "should find both toolsets");
assert_eq!(list[0].name, "alpha-toolset");
assert_eq!(list[1].name, "beta-toolset");
assert!(list[0].actions.contains(&"stellar_balances".to_owned()));
assert!(list[0].version == "1.0.0");
assert!(
list[0].description.is_empty(),
"description is always empty"
);
}
#[test]
fn list_pinned_toolsets_skips_entry_without_pin() {
let dir = tempfile::TempDir::new().unwrap();
let toolsets_root = dir.path();
std::fs::create_dir_all(toolsets_root.join("orphan-dir")).unwrap();
write_toolset_md(
toolsets_root,
"good-toolset",
&minimal_toolset_md("good-toolset"),
);
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
"good-toolset",
"2.0.0",
"b".repeat(64),
"GABC1234567890123456789012345678901234567890123456",
"2026-06-12T00:00:00Z",
caps,
vec![],
None,
);
write_pin_json(toolsets_root, "good-toolset", &pin);
let list = list_pinned_toolsets(toolsets_root).unwrap();
assert_eq!(list.len(), 1, "orphan dir without pin must be skipped");
assert_eq!(list[0].name, "good-toolset");
}
#[test]
fn list_pinned_toolsets_with_allowed_tools_narrowing() {
let dir = tempfile::TempDir::new().unwrap();
let toolsets_root = dir.path();
let pkg = "narrow-toolset";
write_toolset_md(toolsets_root, pkg, &minimal_toolset_md(pkg));
let caps =
stellar_agent_toolsets::parse_capability_value_pub("read-balance suggest-destination")
.unwrap();
let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
pkg,
"1.0.0",
"c".repeat(64),
"GABC1234567890123456789012345678901234567890123456",
"2026-06-12T00:00:00Z",
caps,
vec!["stellar_balances".to_owned()],
None,
);
write_pin_json(toolsets_root, pkg, &pin);
let list = list_pinned_toolsets(toolsets_root).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].actions, vec!["stellar_balances"]);
assert_eq!(list[0].allowed_tools, vec!["stellar_balances"]);
}
#[test]
fn allowed_tools_narrowing_passes_when_tool_included() {
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
let allowed = vec!["stellar_balances".to_owned(), "some-other".to_owned()];
let tool = check_toolset_action("stellar_balances", &caps, &allowed).unwrap();
assert_eq!(tool, "stellar_balances");
}
#[test]
fn resolve_toolset_and_check_invalid_charset_rejected() {
let dir = tempfile::TempDir::new().unwrap();
let err =
resolve_toolset_and_check("bad name", "stellar_balances", dir.path()).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
"expected ToolsetNotInstalled, got: {err:?}"
);
}
#[test]
fn resolve_toolset_and_check_valid_name_toolset_not_installed() {
let dir = tempfile::TempDir::new().unwrap();
let err =
resolve_toolset_and_check("valid-name", "stellar_balances", dir.path()).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
"expected ToolsetNotInstalled for a valid name with no pin record, got: {err:?}"
);
}
#[cfg(feature = "test-helpers")]
fn write_sign_payment_pin(toolsets_root: &std::path::Path, pkg: &str) {
std::fs::create_dir_all(toolsets_root.join(pkg)).unwrap();
let caps = stellar_agent_toolsets::parse_capability_value_pub("sign-payment").unwrap();
let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
pkg,
"1.0.0",
"d".repeat(64),
"GABC1234567890123456789012345678901234567890123456",
"2026-06-12T00:00:00Z",
caps,
vec![],
None,
);
write_pin_json(toolsets_root, pkg, &pin);
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_invalid_toolset_name_rejected() {
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
let params = GatedInvokeParams {
toolset_name: "Bad Name!",
action: "stellar_pay_commit",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
authoritative_asset: "XLM",
authoritative_amount_stroops: 10_000_000,
now_unix_ms: 1_000_000,
process_uid: "uid-test",
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_dir.path().join("grants.json")),
};
let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
"expected ToolsetNotInstalled, got: {err:?}"
);
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_toolset_not_installed() {
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
let params = GatedInvokeParams {
toolset_name: "not-installed",
action: "stellar_pay_commit",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
authoritative_asset: "XLM",
authoritative_amount_stroops: 10_000_000,
now_unix_ms: 1_000_000,
process_uid: "uid-test",
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_dir.path().join("grants.json")),
};
let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
"expected ToolsetNotInstalled, got: {err:?}"
);
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_ungated_action_rejected() {
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
let params = GatedInvokeParams {
toolset_name: "pay-toolset",
action: "stellar_pay",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
authoritative_asset: "XLM",
authoritative_amount_stroops: 10_000_000,
now_unix_ms: 1_000_000,
process_uid: "uid-test",
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_dir.path().join("grants.json")),
};
let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
"ungated action must not resolve via gated matrix, got: {err:?}"
);
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_sign_payment_not_declared() {
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
"read-toolset",
"1.0.0",
"e".repeat(64),
"GABC1234567890123456789012345678901234567890123456",
"2026-06-12T00:00:00Z",
caps,
vec![],
None,
);
std::fs::create_dir_all(toolsets_dir.path().join("read-toolset")).unwrap();
write_pin_json(toolsets_dir.path(), "read-toolset", &pin);
let params = GatedInvokeParams {
toolset_name: "read-toolset",
action: "stellar_pay_commit",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
authoritative_asset: "XLM",
authoritative_amount_stroops: 10_000_000,
now_unix_ms: 1_000_000,
process_uid: "uid-test",
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_dir.path().join("grants.json")),
};
let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
"expected CapabilityNotDeclared, got: {err:?}"
);
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_tool_not_in_allowed_tools() {
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
let caps = stellar_agent_toolsets::parse_capability_value_pub("sign-payment").unwrap();
let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
"narrow-pay-toolset",
"1.0.0",
"f".repeat(64),
"GABC1234567890123456789012345678901234567890123456",
"2026-06-12T00:00:00Z",
caps,
vec!["some-other-tool".to_owned()],
None,
);
std::fs::create_dir_all(toolsets_dir.path().join("narrow-pay-toolset")).unwrap();
write_pin_json(toolsets_dir.path(), "narrow-pay-toolset", &pin);
let params = GatedInvokeParams {
toolset_name: "narrow-pay-toolset",
action: "stellar_pay_commit",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
authoritative_asset: "XLM",
authoritative_amount_stroops: 10_000_000,
now_unix_ms: 1_000_000,
process_uid: "uid-test",
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_dir.path().join("grants.json")),
};
let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
assert!(
matches!(err, ToolsetRuntimeError::ToolNotAllowed { .. }),
"expected ToolNotAllowed, got: {err:?}"
);
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_no_grant_queues_first_invoke_gate() {
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
let params = GatedInvokeParams {
toolset_name: "pay-toolset",
action: "stellar_pay_commit",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
authoritative_asset: "XLM",
authoritative_amount_stroops: 10_000_000,
now_unix_ms: 1_000_000,
process_uid: "uid-test",
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_dir.path().join("grants.json")),
};
let outcome = resolve_toolset_sign_payment_gated(¶ms).unwrap();
match outcome {
GatedResolveOutcome::FirstInvokeApprovalRequired {
approval_nonce,
toolset_name,
capability,
} => {
assert!(
!approval_nonce.is_empty(),
"approval_nonce must be non-empty"
);
assert_eq!(toolset_name, "pay-toolset");
assert_eq!(capability, "sign-payment");
}
GatedResolveOutcome::Resolved { .. } => {
panic!("expected FirstInvokeApprovalRequired, got Resolved");
}
}
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_with_matching_grant_returns_resolved() {
use stellar_agent_core::approval::process_uid_for_attestation;
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
let grant_path = grant_dir.path().join("grants.json");
write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
let now_unix_ms: u64 = 1_000_000;
let destination = "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL";
let asset = "XLM";
let amount_stroops: i64 = 10_000_000;
let attestation_key = [0u8; 32];
let uid = process_uid_for_attestation().unwrap();
record_first_invoke_grant(
"test",
"pay-toolset",
"sign-payment",
destination,
asset,
0,
amount_stroops,
&uid,
now_unix_ms,
&attestation_key,
&stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
Some(grant_path.clone()),
)
.unwrap();
let params = GatedInvokeParams {
toolset_name: "pay-toolset",
action: "stellar_pay_commit",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: destination,
authoritative_asset: asset,
authoritative_amount_stroops: amount_stroops,
now_unix_ms,
process_uid: &uid,
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_path),
};
let outcome = resolve_toolset_sign_payment_gated(¶ms).unwrap();
match outcome {
GatedResolveOutcome::Resolved { tool_name } => {
assert_eq!(tool_name, "stellar_pay_commit");
}
GatedResolveOutcome::FirstInvokeApprovalRequired { .. } => {
panic!("expected Resolved, got FirstInvokeApprovalRequired");
}
}
}
use crate::matrix::{SIGN_RULE_CREATE_AMOUNT_SENTINEL, SIGN_RULE_CREATE_ASSET_SENTINEL};
fn write_sign_rule_create_pin(toolsets_root: &std::path::Path, pkg: &str) {
std::fs::create_dir_all(toolsets_root.join(pkg)).unwrap();
let caps = stellar_agent_toolsets::parse_capability_value_pub("sign-rule-create").unwrap();
let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
pkg,
"1.0.0",
"d".repeat(64),
"GABC1234567890123456789012345678901234567890123456",
"2026-06-12T00:00:00Z",
caps,
vec![],
None,
);
write_pin_json(toolsets_root, pkg, &pin);
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_sign_rule_create_no_grant_queues_first_invoke_gate() {
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
write_sign_rule_create_pin(toolsets_dir.path(), "rule-toolset");
let params = GatedInvokeParams {
toolset_name: "rule-toolset",
action: "stellar_rule_create_commit",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
authoritative_asset: SIGN_RULE_CREATE_ASSET_SENTINEL,
authoritative_amount_stroops: SIGN_RULE_CREATE_AMOUNT_SENTINEL,
now_unix_ms: 1_000_000,
process_uid: "uid-test",
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_dir.path().join("grants.json")),
};
let outcome = resolve_toolset_sign_payment_gated(¶ms).unwrap();
match outcome {
GatedResolveOutcome::FirstInvokeApprovalRequired {
approval_nonce,
toolset_name,
capability,
} => {
assert!(
!approval_nonce.is_empty(),
"approval_nonce must be non-empty"
);
assert_eq!(toolset_name, "rule-toolset");
assert_eq!(capability, "sign-rule-create");
}
GatedResolveOutcome::Resolved { .. } => {
panic!("expected FirstInvokeApprovalRequired, got Resolved");
}
}
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_sign_rule_create_with_matching_grant_returns_resolved() {
use stellar_agent_core::approval::process_uid_for_attestation;
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
let grant_path = grant_dir.path().join("grants.json");
write_sign_rule_create_pin(toolsets_dir.path(), "rule-toolset");
let now_unix_ms: u64 = 1_000_000;
let smart_account = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM";
let attestation_key = [0u8; 32];
let uid = process_uid_for_attestation().unwrap();
record_first_invoke_grant(
"test",
"rule-toolset",
"sign-rule-create",
smart_account,
SIGN_RULE_CREATE_ASSET_SENTINEL,
0,
SIGN_RULE_CREATE_AMOUNT_SENTINEL,
&uid,
now_unix_ms,
&attestation_key,
&stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
Some(grant_path.clone()),
)
.unwrap();
let params = GatedInvokeParams {
toolset_name: "rule-toolset",
action: "stellar_rule_create_commit",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: smart_account,
authoritative_asset: SIGN_RULE_CREATE_ASSET_SENTINEL,
authoritative_amount_stroops: SIGN_RULE_CREATE_AMOUNT_SENTINEL,
now_unix_ms,
process_uid: &uid,
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_path),
};
let outcome = resolve_toolset_sign_payment_gated(¶ms).unwrap();
match outcome {
GatedResolveOutcome::Resolved { tool_name } => {
assert_eq!(tool_name, "stellar_rule_create_commit");
}
GatedResolveOutcome::FirstInvokeApprovalRequired { .. } => {
panic!("expected Resolved, got FirstInvokeApprovalRequired");
}
}
}
#[test]
fn propose_transaction_grants_stellar_rule_create_but_not_commit() {
let caps =
stellar_agent_toolsets::parse_capability_value_pub("propose-transaction").unwrap();
let tool = check_toolset_action("stellar_rule_create", &caps, &[]).unwrap();
assert_eq!(tool, "stellar_rule_create");
let err = check_toolset_action("stellar_rule_create_commit", &caps, &[]).unwrap_err();
assert!(matches!(
err,
ToolsetRuntimeError::UnknownToolsetAction { .. }
));
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_grant_present_zero_amount_rejected() {
use stellar_agent_core::approval::process_uid_for_attestation;
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
let grant_path = grant_dir.path().join("grants.json");
write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
let now_unix_ms: u64 = 1_000_000;
let destination = "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL";
let asset = "XLM";
let attestation_key = [0u8; 32];
let uid = process_uid_for_attestation().unwrap();
record_first_invoke_grant(
"test",
"pay-toolset",
"sign-payment",
destination,
asset,
0,
10_000_000,
&uid,
now_unix_ms,
&attestation_key,
&stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
Some(grant_path.clone()),
)
.unwrap();
let params = GatedInvokeParams {
toolset_name: "pay-toolset",
action: "stellar_pay_commit",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: destination,
authoritative_asset: asset,
authoritative_amount_stroops: 0,
now_unix_ms,
process_uid: &uid,
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_path),
};
let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
assert!(
matches!(
err,
ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: 0 }
),
"a zero amount must be rejected even when a [0, N] grant exists; got: {err:?}"
);
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_zero_amount_rejected() {
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
let params = GatedInvokeParams {
toolset_name: "pay-toolset",
action: "stellar_pay_commit",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
authoritative_asset: "XLM",
authoritative_amount_stroops: 0,
now_unix_ms: 1_000_000,
process_uid: "uid-test",
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_dir.path().join("grants.json")),
};
let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
assert!(
matches!(
err,
ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: 0 }
),
"expected InvalidAuthoritativeAmount(0), got: {err:?}"
);
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_negative_amount_rejected() {
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
let params = GatedInvokeParams {
toolset_name: "pay-toolset",
action: "stellar_pay_commit",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
authoritative_asset: "XLM",
authoritative_amount_stroops: -1,
now_unix_ms: 1_000_000,
process_uid: "uid-test",
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_dir.path().join("grants.json")),
};
let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
assert!(
matches!(
err,
ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: -1 }
),
"expected InvalidAuthoritativeAmount(-1), got: {err:?}"
);
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_queued_pending_uses_caller_supplied_process_uid() {
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
let supplied_uid = "1234567890";
let params = GatedInvokeParams {
toolset_name: "pay-toolset",
action: "stellar_pay_commit",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
authoritative_asset: "XLM",
authoritative_amount_stroops: 10_000_000,
now_unix_ms: 1_000_000,
process_uid: supplied_uid,
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_dir.path().join("grants.json")),
};
let outcome = resolve_toolset_sign_payment_gated(¶ms).unwrap();
let approval_nonce = match outcome {
GatedResolveOutcome::FirstInvokeApprovalRequired { approval_nonce, .. } => {
approval_nonce
}
GatedResolveOutcome::Resolved { .. } => {
panic!("expected FirstInvokeApprovalRequired, got Resolved");
}
};
let store_path = approval_dir.path().join("test.toml");
let store = stellar_agent_core::approval::PendingApprovalStore::open(store_path).unwrap();
let pending = store
.get(&approval_nonce)
.expect("queued pending must be findable by nonce");
assert_eq!(
pending.process_uid, supplied_uid,
"queued pending's process_uid must equal the caller-supplied value '{}'; \
got '{}' — would differ if the resolver recomputed it internally",
supplied_uid, pending.process_uid
);
}
#[test]
#[cfg(feature = "test-helpers")]
fn gated_resolver_over_max_amount_re_prompts() {
use stellar_agent_core::approval::process_uid_for_attestation;
let toolsets_dir = tempfile::TempDir::new().unwrap();
let approval_dir = tempfile::TempDir::new().unwrap();
let grant_dir = tempfile::TempDir::new().unwrap();
let grant_path = grant_dir.path().join("grants.json");
write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
let now_unix_ms: u64 = 1_000_000;
let destination = "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL";
let asset = "XLM";
let grant_max_stroops: i64 = 10_000_000; let attestation_key = [0u8; 32];
let uid = process_uid_for_attestation().unwrap();
record_first_invoke_grant(
"test",
"pay-toolset",
"sign-payment",
destination,
asset,
0,
grant_max_stroops,
&uid,
now_unix_ms,
&attestation_key,
&stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
Some(grant_path.clone()),
)
.unwrap();
let over_max_stroops: i64 = grant_max_stroops + 1;
let params = GatedInvokeParams {
toolset_name: "pay-toolset",
action: "stellar_pay_commit",
toolsets_root: toolsets_dir.path(),
profile_name: "test",
authoritative_destination: destination,
authoritative_asset: asset,
authoritative_amount_stroops: over_max_stroops,
now_unix_ms,
process_uid: &uid,
approval_dir_override: Some(approval_dir.path().to_path_buf()),
grant_store_path_override: Some(grant_path),
};
let outcome = resolve_toolset_sign_payment_gated(¶ms).unwrap();
assert!(
matches!(
outcome,
GatedResolveOutcome::FirstInvokeApprovalRequired { .. }
),
"amount above grant max must re-prompt (FirstInvokeApprovalRequired), \
got Resolved — the existing grant must not match an over-max amount"
);
match outcome {
GatedResolveOutcome::FirstInvokeApprovalRequired { approval_nonce, .. } => {
assert!(
!approval_nonce.is_empty(),
"re-prompt must carry a non-empty approval_nonce"
);
}
GatedResolveOutcome::Resolved { .. } => unreachable!(),
}
}
#[test]
#[cfg(feature = "test-helpers")]
fn record_first_invoke_grant_persists_to_store() {
use stellar_agent_core::approval::process_uid_for_attestation;
let grant_dir = tempfile::TempDir::new().unwrap();
let grant_path = grant_dir.path().join("grants.json");
let now_unix_ms: u64 = 2_000_000;
let uid = process_uid_for_attestation().unwrap();
let attestation_key = [1u8; 32];
let grant = record_first_invoke_grant(
"prod",
"my-toolset",
"sign-payment",
"GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
"XLM",
0,
50_000_000,
&uid,
now_unix_ms,
&attestation_key,
&stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
Some(grant_path.clone()),
)
.unwrap();
assert_eq!(grant.toolset_name, "my-toolset");
assert_eq!(grant.capability, "sign-payment");
assert!(
grant.verify_attestation(
&attestation_key,
&stellar_agent_core::approval::AttestationBinding::new(
"default",
"stellar:testnet"
)
),
"grant must verify against the attestation key it was built with"
);
assert!(
!grant.verify_attestation(
&[0xff; 32],
&stellar_agent_core::approval::AttestationBinding::new(
"default",
"stellar:testnet"
)
),
"grant must NOT verify against a wrong attestation key"
);
let store =
stellar_agent_core::approval::ToolsetGrantStore::open(grant_path, now_unix_ms).unwrap();
let found = store.find_matching(
"my-toolset",
"sign-payment",
"GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
"XLM",
30_000_000, now_unix_ms,
);
assert!(found.is_some(), "persisted grant must be findable in store");
}
}