1#![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#[derive(Debug, Clone, Serialize)]
84pub struct ToolsetListEntry {
85 pub name: String,
87 pub description: String,
92 pub capabilities: Vec<String>,
94 pub allowed_tools: Vec<String>,
100 pub version: String,
102 pub actions: Vec<String>,
110}
111
112pub fn list_pinned_toolsets(
126 toolsets_root: &Path,
127) -> Result<Vec<ToolsetListEntry>, ToolsetRuntimeError> {
128 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
177pub fn check_toolset_action(
214 action: &str,
215 capabilities: &CapabilitySet,
216 allowed_tools: &[String],
217) -> Result<&'static str, ToolsetRuntimeError> {
218 let (tool_name, granting_capability) = resolve_action(action)?;
220
221 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 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
244pub fn resolve_toolset_and_check(
274 toolset_name: &str,
275 action: &str,
276 toolsets_root: &Path,
277) -> Result<(&'static str, ToolsetPinRecord), ToolsetRuntimeError> {
278 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 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 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
334fn allowed_by_narrowing(allowed_tools: &[String], tool: &str) -> bool {
343 allowed_tools.is_empty() || allowed_tools.iter().any(|t| t == tool)
344}
345
346fn 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 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_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 let description = String::new();
383
384 ToolsetListEntry {
385 name,
386 description,
387 capabilities,
388 allowed_tools,
389 version,
390 actions,
391 }
392}
393
394#[derive(Debug)]
401pub struct GatedInvokeParams<'a> {
402 pub toolset_name: &'a str,
404 pub action: &'a str,
406 pub toolsets_root: &'a Path,
408 pub profile_name: &'a str,
410 pub authoritative_destination: &'a str,
413 pub authoritative_asset: &'a str,
415 pub authoritative_amount_stroops: i64,
417 pub now_unix_ms: u64,
419 pub process_uid: &'a str,
421 #[cfg(feature = "test-helpers")]
423 pub approval_dir_override: Option<std::path::PathBuf>,
424 #[cfg(feature = "test-helpers")]
426 pub grant_store_path_override: Option<std::path::PathBuf>,
427}
428
429#[derive(Debug)]
433pub enum GatedResolveOutcome {
434 Resolved {
438 tool_name: &'static str,
440 },
441 FirstInvokeApprovalRequired {
445 approval_nonce: String,
447 toolset_name: String,
449 capability: String,
451 },
452}
453
454#[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 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 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 let (tool_name, granting_capability) = resolve_gated_action(action)?;
520
521 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 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 let amount = params.authoritative_amount_stroops;
546 if amount <= 0 {
547 return Err(ToolsetRuntimeError::InvalidAuthoritativeAmount {
548 amount_stroops: amount,
549 });
550 }
551
552 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 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 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 let uid = params.process_uid.to_owned();
629
630 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 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
690pub type GrantStorePathOverride = Option<std::path::PathBuf>;
695
696#[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 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
767pub 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
795fn 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
804pub use stellar_agent_toolsets_install::read_pin;
810
811pub use stellar_agent_toolsets_install::validate_package_name;
814
815fn install_error_kind_str(e: &stellar_agent_toolsets_install::ToolsetInstallError) -> String {
824 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 stellar_agent_toolsets::parse_capability_value_pub(
848 "read-balance propose-transaction suggest-destination observe-event",
849 )
850 .unwrap()
851 }
852
853 #[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 #[test]
868 fn capability_not_declared_returns_error() {
869 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 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 #[test]
894 fn allowed_tools_narrowing_excludes_tool() {
895 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 #[test]
916 fn all_caps_plus_signing_in_allowed_tools_still_refused() {
917 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 #[test]
937 fn read_balance_toolset_cannot_invoke_propose_action() {
938 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
939 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 #[test]
950 fn dispatcher_tools_not_reachable_via_any_capability() {
951 let caps = all_caps();
952 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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 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 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 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 #[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 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 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 #[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 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 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 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 let msg = err.to_string();
1248 assert!(
1249 msg.contains(pkg),
1250 "ContentDigestMismatch message must include toolset name; got: {msg}"
1251 );
1252 }
1253
1254 #[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 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 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 #[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 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 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 #[test]
1324 fn list_pinned_toolsets_with_valid_pins() {
1325 let dir = tempfile::TempDir::new().unwrap();
1326 let toolsets_root = dir.path();
1327
1328 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 assert_eq!(list[0].name, "alpha-toolset");
1350 assert_eq!(list[1].name, "beta-toolset");
1351 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 std::fs::create_dir_all(toolsets_root.join("orphan-dir")).unwrap();
1367 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 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 assert_eq!(list[0].actions, vec!["stellar_balances"]);
1419 assert_eq!(list[0].allowed_tools, vec!["stellar_balances"]);
1420 }
1421
1422 #[test]
1425 fn allowed_tools_narrowing_passes_when_tool_included() {
1426 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 #[test]
1437 fn resolve_toolset_and_check_invalid_charset_rejected() {
1438 let dir = tempfile::TempDir::new().unwrap();
1439 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 #[test]
1454 fn resolve_toolset_and_check_valid_name_toolset_not_installed() {
1455 let dir = tempfile::TempDir::new().unwrap();
1456 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 #[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 #[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(¶ms).unwrap_err();
1509 assert!(
1510 matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1511 "expected ToolsetNotInstalled, got: {err:?}"
1512 );
1513 }
1514
1515 #[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(¶ms).unwrap_err();
1539 assert!(
1540 matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1541 "expected ToolsetNotInstalled, got: {err:?}"
1542 );
1543 }
1544
1545 #[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 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(¶ms).unwrap_err();
1572 assert!(
1573 matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
1574 "ungated action must not resolve via gated matrix, got: {err:?}"
1575 );
1576 }
1577
1578 #[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 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(¶ms).unwrap_err();
1617 assert!(
1618 matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
1619 "expected CapabilityNotDeclared, got: {err:?}"
1620 );
1621 }
1622
1623 #[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 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(¶ms).unwrap_err();
1662 assert!(
1663 matches!(err, ToolsetRuntimeError::ToolNotAllowed { .. }),
1664 "expected ToolNotAllowed, got: {err:?}"
1665 );
1666 }
1667
1668 #[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(¶ms).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 #[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 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(¶ms).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 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(¶ms).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(¶ms).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 #[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 #[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 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(¶ms).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 #[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(¶ms).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(¶ms).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 #[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 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(¶ms).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 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 #[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; let attestation_key = [0u8; 32];
2146 let uid = process_uid_for_attestation().unwrap();
2147
2148 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 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(¶ms).unwrap();
2182
2183 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 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 #[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 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 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, now_unix_ms,
2260 );
2261 assert!(found.is_some(), "persisted grant must be findable in store");
2262 }
2263}