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 binding: &stellar_agent_core::approval::AttestationBinding<'_>,
722 grant_store_path_override: GrantStorePathOverride,
725) -> Result<ToolsetGrant, ToolsetRuntimeError> {
726 let grant = build_attested_grant(
727 toolset_name.to_owned(),
728 capability.to_owned(),
729 destination.to_owned(),
730 asset.to_owned(),
731 amount_min_stroops,
732 amount_max_stroops,
733 process_uid.to_owned(),
734 now_unix_ms,
735 TOOLSET_GRANT_DEFAULT_TTL_MS,
736 attestation_key,
737 binding,
738 )
739 .map_err(|e| ToolsetRuntimeError::GrantStoreError {
740 detail: format!("build_attested_grant: {e}"),
741 })?;
742
743 let grant_store_path = if let Some(p) = grant_store_path_override {
744 p
745 } else {
746 default_toolset_grants_path(profile_name).map_err(|e| {
747 ToolsetRuntimeError::GrantStoreError {
748 detail: format!("grant_store_path: {e}"),
749 }
750 })?
751 };
752
753 let mut store = ToolsetGrantStore::open(grant_store_path, now_unix_ms).map_err(|e| {
754 ToolsetRuntimeError::GrantStoreError {
755 detail: format!("open: {e}"),
756 }
757 })?;
758
759 let grant_clone = grant.clone();
760 store
761 .insert(grant)
762 .map_err(|e| ToolsetRuntimeError::GrantStoreError {
763 detail: format!("insert: {e}"),
764 })?;
765
766 Ok(grant_clone)
767}
768
769pub fn resolve_gated_action(
783 action: &str,
784) -> Result<(&'static str, Capability), ToolsetRuntimeError> {
785 for (cap, tools) in matrix::GATED_MATRIX_ENTRIES {
786 for tool in *tools {
787 if *tool == action {
788 return Ok((tool, *cap));
789 }
790 }
791 }
792 Err(ToolsetRuntimeError::UnknownToolsetAction {
793 action: sanitise_display(action, 128),
794 })
795}
796
797fn build_approval_store_path(profile_name: &str) -> Option<std::path::PathBuf> {
802 let dir = stellar_agent_core::profile::schema::default_approval_dir().ok()?;
803 Some(dir.join(format!("{profile_name}.toml")))
804}
805
806pub use stellar_agent_toolsets_install::read_pin;
812
813pub use stellar_agent_toolsets_install::validate_package_name;
816
817fn install_error_kind_str(e: &stellar_agent_toolsets_install::ToolsetInstallError) -> String {
826 use stellar_agent_toolsets_install::ToolsetInstallError;
830 match e {
831 ToolsetInstallError::Io { .. } => "io_error".to_owned(),
832 ToolsetInstallError::PinRecordMalformed { .. } => "pin_record_malformed".to_owned(),
833 _ => "install_error".to_owned(),
834 }
835}
836
837#[cfg(test)]
838#[allow(
839 clippy::unwrap_used,
840 clippy::expect_used,
841 clippy::panic,
842 reason = "test-only; panics acceptable in unit tests"
843)]
844mod tests {
845 use super::*;
846
847 fn all_caps() -> CapabilitySet {
848 stellar_agent_toolsets::parse_capability_value_pub(
850 "read-balance propose-transaction suggest-destination observe-event",
851 )
852 .unwrap()
853 }
854
855 #[test]
858 fn unknown_action_returns_error() {
859 let caps = all_caps();
860 let err = check_toolset_action("no-such-action", &caps, &[]).unwrap_err();
861 assert!(
862 matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
863 "expected UnknownToolsetAction, got: {err:?}"
864 );
865 }
866
867 #[test]
870 fn capability_not_declared_returns_error() {
871 let empty_caps = CapabilitySet::empty();
873 let err = check_toolset_action("stellar_balances", &empty_caps, &[]).unwrap_err();
874 assert!(
875 matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
876 "expected CapabilityNotDeclared, got: {err:?}"
877 );
878 }
879
880 #[test]
881 fn empty_capability_set_refuses_all_known_actions() {
882 let empty_caps = CapabilitySet::empty();
883 for (action, _cap) in matrix::ALL_MATRIX_ENTRIES {
885 let err = check_toolset_action(action, &empty_caps, &[]).unwrap_err();
886 assert!(
887 matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
888 "action {action}: expected CapabilityNotDeclared, got: {err:?}"
889 );
890 }
891 }
892
893 #[test]
896 fn allowed_tools_narrowing_excludes_tool() {
897 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
900 let err = check_toolset_action("stellar_balances", &caps, &["some-other-tool".to_owned()])
901 .unwrap_err();
902 assert!(
903 matches!(err, ToolsetRuntimeError::ToolNotAllowed { .. }),
904 "expected ToolNotAllowed, got: {err:?}"
905 );
906 }
907
908 #[test]
909 fn allowed_tools_empty_vacuously_satisfied() {
910 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
911 let tool = check_toolset_action("stellar_balances", &caps, &[]).unwrap();
912 assert_eq!(tool, "stellar_balances");
913 }
914
915 #[test]
918 fn all_caps_plus_signing_in_allowed_tools_still_refused() {
919 let caps = all_caps();
922 let allowed = vec![
923 "stellar_sep43_sign_transaction".to_owned(),
924 "stellar_sep53_sign_message".to_owned(),
925 "stellar_pay_commit".to_owned(),
926 ];
927 for signing_tool in &allowed {
928 let err = check_toolset_action(signing_tool, &caps, &allowed).unwrap_err();
929 assert!(
930 matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
931 "signing tool {signing_tool}: expected UnknownToolsetAction (not in matrix), got: {err:?}"
932 );
933 }
934 }
935
936 #[test]
939 fn read_balance_toolset_cannot_invoke_propose_action() {
940 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
941 let err = check_toolset_action("stellar_pay", &caps, &[]).unwrap_err();
943 assert!(
944 matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
945 "expected CapabilityNotDeclared, got: {err:?}"
946 );
947 }
948
949 #[test]
952 fn dispatcher_tools_not_reachable_via_any_capability() {
953 let caps = all_caps();
954 for dispatcher_tool in ["stellar_toolset_list", "stellar_toolset_invoke"] {
956 let err = check_toolset_action(dispatcher_tool, &caps, &[]).unwrap_err();
957 assert!(
958 matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
959 "dispatcher tool {dispatcher_tool}: expected UnknownToolsetAction, got: {err:?}"
960 );
961 }
962 }
963
964 #[test]
967 fn read_balance_grants_stellar_balances() {
968 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
969 let tool = check_toolset_action("stellar_balances", &caps, &[]).unwrap();
970 assert_eq!(tool, "stellar_balances");
971 }
972
973 #[test]
976 fn propose_transaction_grants_stellar_pay() {
977 let caps =
978 stellar_agent_toolsets::parse_capability_value_pub("propose-transaction").unwrap();
979 let tool = check_toolset_action("stellar_pay", &caps, &[]).unwrap();
980 assert_eq!(tool, "stellar_pay");
981 }
982
983 #[test]
991 fn read_rules_grants_exactly_stellar_rules_list_and_get() {
992 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-rules").unwrap();
993
994 let list_tool = check_toolset_action("stellar_rules_list", &caps, &[]).unwrap();
995 assert_eq!(list_tool, "stellar_rules_list");
996
997 let get_tool = check_toolset_action("stellar_rules_get", &caps, &[]).unwrap();
998 assert_eq!(get_tool, "stellar_rules_get");
999
1000 for other in matrix::ALL_MATRIX_TOOL_NAMES {
1002 if *other == "stellar_rules_list" || *other == "stellar_rules_get" {
1003 continue;
1004 }
1005 let err = check_toolset_action(other, &caps, &[]).unwrap_err();
1006 assert!(
1007 matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
1008 "read-rules must not grant '{other}'; got {err:?}"
1009 );
1010 }
1011 }
1012
1013 #[test]
1016 fn toolset_without_read_rules_cannot_invoke_rules_tools() {
1017 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1018 for tool in ["stellar_rules_list", "stellar_rules_get"] {
1019 let err = check_toolset_action(tool, &caps, &[]).unwrap_err();
1020 assert!(
1021 matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
1022 "expected CapabilityNotDeclared for '{tool}', got: {err:?}"
1023 );
1024 }
1025 }
1026
1027 #[test]
1030 fn list_pinned_toolsets_empty_dir() {
1031 let dir = tempfile::TempDir::new().unwrap();
1032 let list = list_pinned_toolsets(dir.path()).unwrap();
1033 assert!(list.is_empty());
1034 }
1035
1036 #[test]
1037 fn list_pinned_toolsets_nonexistent_dir() {
1038 let list =
1039 list_pinned_toolsets(std::path::Path::new("/nonexistent/toolsets_root")).unwrap();
1040 assert!(list.is_empty());
1041 }
1042
1043 #[test]
1051 fn path_traversal_dotdot_slash_rejected() {
1052 let dir = tempfile::TempDir::new().unwrap();
1053 let err = resolve_toolset_and_check("../foo", "stellar_balances", dir.path()).unwrap_err();
1054 assert!(
1055 matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1056 "expected ToolsetNotInstalled for '../foo', got: {err:?}"
1057 );
1058 }
1059
1060 #[test]
1061 fn path_traversal_slash_in_name_rejected() {
1062 let dir = tempfile::TempDir::new().unwrap();
1063 let err = resolve_toolset_and_check("a/b", "stellar_balances", dir.path()).unwrap_err();
1064 assert!(
1065 matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1066 "expected ToolsetNotInstalled for 'a/b', got: {err:?}"
1067 );
1068 }
1069
1070 #[test]
1071 fn path_traversal_backslash_in_name_rejected() {
1072 let dir = tempfile::TempDir::new().unwrap();
1073 let err = resolve_toolset_and_check("..\\foo", "stellar_balances", dir.path()).unwrap_err();
1075 assert!(
1076 matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1077 "expected ToolsetNotInstalled for '..\\\\foo', got: {err:?}"
1078 );
1079 }
1080
1081 #[test]
1082 fn path_traversal_dotdot_alone_rejected() {
1083 let dir = tempfile::TempDir::new().unwrap();
1084 let err = resolve_toolset_and_check("..", "stellar_balances", dir.path()).unwrap_err();
1085 assert!(
1086 matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1087 "expected ToolsetNotInstalled for '..', got: {err:?}"
1088 );
1089 }
1090
1091 #[test]
1092 fn path_traversal_dot_alone_rejected() {
1093 let dir = tempfile::TempDir::new().unwrap();
1094 let err = resolve_toolset_and_check(".", "stellar_balances", dir.path()).unwrap_err();
1095 assert!(
1096 matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1097 "expected ToolsetNotInstalled for '.', got: {err:?}"
1098 );
1099 }
1100
1101 #[test]
1102 fn path_traversal_uppercase_rejected() {
1103 let dir = tempfile::TempDir::new().unwrap();
1104 let err =
1105 resolve_toolset_and_check("MyToolset", "stellar_balances", dir.path()).unwrap_err();
1106 assert!(
1107 matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1108 "expected ToolsetNotInstalled for 'MyToolset', got: {err:?}"
1109 );
1110 }
1111
1112 #[test]
1113 fn path_traversal_null_byte_rejected() {
1114 let dir = tempfile::TempDir::new().unwrap();
1115 let err =
1116 resolve_toolset_and_check("toolset\0name", "stellar_balances", dir.path()).unwrap_err();
1117 assert!(
1118 matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1119 "expected ToolsetNotInstalled for null-byte name, got: {err:?}"
1120 );
1121 }
1122
1123 fn write_toolset_md(toolsets_root: &std::path::Path, pkg: &str, content: &str) {
1136 let toolset_dir = toolsets_root.join(pkg);
1137 std::fs::create_dir_all(&toolset_dir).unwrap();
1138 std::fs::write(toolset_dir.join("TOOLSET.md"), content).unwrap();
1139 }
1140
1141 fn write_pin_json(
1143 toolsets_root: &std::path::Path,
1144 pkg: &str,
1145 pin: &stellar_agent_toolsets_install::ToolsetPinRecord,
1146 ) {
1147 let json = serde_json::to_string_pretty(pin).unwrap();
1148 std::fs::write(
1149 toolsets_root
1150 .join(pkg)
1151 .join(".stellar-agent-toolset-pin.json"),
1152 json,
1153 )
1154 .unwrap();
1155 }
1156
1157 fn minimal_toolset_md(pkg: &str) -> String {
1160 format!(
1161 "---\nname: {pkg}\ndescription: test toolset\nstellar-agent-capabilities:\n - read-balance\n---\n# {pkg}\n"
1162 )
1163 }
1164
1165 #[test]
1168 fn content_digest_match_allows_dispatch() {
1169 let dir = tempfile::TempDir::new().unwrap();
1170 let toolsets_root = dir.path();
1171 let pkg = "my-toolset";
1172
1173 let toolset_md_content = minimal_toolset_md(pkg);
1174 write_toolset_md(toolsets_root, pkg, &toolset_md_content);
1175
1176 let expected_digest =
1178 stellar_agent_toolsets_install::sha256_hex_of(toolset_md_content.as_bytes());
1179
1180 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1181 let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1182 pkg,
1183 "1.0.0",
1184 "a".repeat(64),
1185 "GABC1234567890123456789012345678901234567890123456",
1186 "2026-06-12T00:00:00Z",
1187 caps,
1188 vec![],
1189 Some(expected_digest),
1190 );
1191 write_pin_json(toolsets_root, pkg, &pin);
1192
1193 let result = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root);
1195 assert!(
1196 result.is_ok(),
1197 "dispatch must succeed when TOOLSET.md digest matches pin: {result:?}"
1198 );
1199 let (tool_name, _) = result.unwrap();
1200 assert_eq!(tool_name, "stellar_balances");
1201 }
1202
1203 #[test]
1206 fn content_digest_mismatch_refuses_dispatch() {
1207 let dir = tempfile::TempDir::new().unwrap();
1208 let toolsets_root = dir.path();
1209 let pkg = "my-toolset";
1210
1211 let original_content = minimal_toolset_md(pkg);
1212 write_toolset_md(toolsets_root, pkg, &original_content);
1213
1214 let original_digest =
1216 stellar_agent_toolsets_install::sha256_hex_of(original_content.as_bytes());
1217
1218 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1219 let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1220 pkg,
1221 "1.0.0",
1222 "a".repeat(64),
1223 "GABC1234567890123456789012345678901234567890123456",
1224 "2026-06-12T00:00:00Z",
1225 caps,
1226 vec![],
1227 Some(original_digest),
1228 );
1229 write_pin_json(toolsets_root, pkg, &pin);
1230
1231 let tampered_content = format!(
1234 "---\nname: {pkg}\ndescription: TAMPERED BY ATTACKER\nstellar-agent-capabilities:\n - read-balance\n---\n"
1235 );
1236 std::fs::write(
1237 toolsets_root.join(pkg).join("TOOLSET.md"),
1238 &tampered_content,
1239 )
1240 .unwrap();
1241
1242 let err = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root).unwrap_err();
1244 assert!(
1245 matches!(err, ToolsetRuntimeError::ContentDigestMismatch { .. }),
1246 "expected ContentDigestMismatch for tampered TOOLSET.md, got: {err:?}"
1247 );
1248 let msg = err.to_string();
1250 assert!(
1251 msg.contains(pkg),
1252 "ContentDigestMismatch message must include toolset name; got: {msg}"
1253 );
1254 }
1255
1256 #[test]
1259 fn legacy_pin_without_toolset_md_shasum_skips_content_check() {
1260 let dir = tempfile::TempDir::new().unwrap();
1261 let toolsets_root = dir.path();
1262 let pkg = "my-toolset";
1263
1264 write_toolset_md(toolsets_root, pkg, &minimal_toolset_md(pkg));
1265
1266 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1268 let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1269 pkg,
1270 "1.0.0",
1271 "a".repeat(64),
1272 "GABC1234567890123456789012345678901234567890123456",
1273 "2026-06-12T00:00:00Z",
1274 caps,
1275 vec![],
1276 None,
1277 );
1278 write_pin_json(toolsets_root, pkg, &pin);
1279
1280 let result = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root);
1285 assert!(
1286 result.is_ok(),
1287 "pin without toolset_md_shasum must skip content check and dispatch: {result:?}"
1288 );
1289 }
1290
1291 #[test]
1294 fn missing_toolset_md_when_digest_expected_refuses_dispatch() {
1295 let dir = tempfile::TempDir::new().unwrap();
1296 let toolsets_root = dir.path();
1297 let pkg = "my-toolset";
1298
1299 std::fs::create_dir_all(toolsets_root.join(pkg)).unwrap();
1301
1302 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1303 let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1304 pkg,
1305 "1.0.0",
1306 "a".repeat(64),
1307 "GABC1234567890123456789012345678901234567890123456",
1308 "2026-06-12T00:00:00Z",
1309 caps,
1310 vec![],
1311 Some("a".repeat(64)),
1312 );
1313 write_pin_json(toolsets_root, pkg, &pin);
1314
1315 let err = resolve_toolset_and_check(pkg, "stellar_balances", toolsets_root).unwrap_err();
1317 assert!(
1318 matches!(err, ToolsetRuntimeError::ContentDigestMismatch { .. }),
1319 "missing TOOLSET.md with digest in pin must refuse dispatch: {err:?}"
1320 );
1321 }
1322
1323 #[test]
1326 fn list_pinned_toolsets_with_valid_pins() {
1327 let dir = tempfile::TempDir::new().unwrap();
1328 let toolsets_root = dir.path();
1329
1330 for pkg in ["beta-toolset", "alpha-toolset"] {
1333 write_toolset_md(toolsets_root, pkg, &minimal_toolset_md(pkg));
1334 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1335 let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1336 pkg,
1337 "1.0.0",
1338 "a".repeat(64),
1339 "GABC1234567890123456789012345678901234567890123456",
1340 "2026-06-12T00:00:00Z",
1341 caps,
1342 vec![],
1343 None,
1344 );
1345 write_pin_json(toolsets_root, pkg, &pin);
1346 }
1347
1348 let list = list_pinned_toolsets(toolsets_root).unwrap();
1349 assert_eq!(list.len(), 2, "should find both toolsets");
1350 assert_eq!(list[0].name, "alpha-toolset");
1352 assert_eq!(list[1].name, "beta-toolset");
1353 assert!(list[0].actions.contains(&"stellar_balances".to_owned()));
1355 assert!(list[0].version == "1.0.0");
1356 assert!(
1357 list[0].description.is_empty(),
1358 "description is always empty"
1359 );
1360 }
1361
1362 #[test]
1363 fn list_pinned_toolsets_skips_entry_without_pin() {
1364 let dir = tempfile::TempDir::new().unwrap();
1365 let toolsets_root = dir.path();
1366
1367 std::fs::create_dir_all(toolsets_root.join("orphan-dir")).unwrap();
1369 write_toolset_md(
1371 toolsets_root,
1372 "good-toolset",
1373 &minimal_toolset_md("good-toolset"),
1374 );
1375 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1376 let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1377 "good-toolset",
1378 "2.0.0",
1379 "b".repeat(64),
1380 "GABC1234567890123456789012345678901234567890123456",
1381 "2026-06-12T00:00:00Z",
1382 caps,
1383 vec![],
1384 None,
1385 );
1386 write_pin_json(toolsets_root, "good-toolset", &pin);
1387
1388 let list = list_pinned_toolsets(toolsets_root).unwrap();
1389 assert_eq!(list.len(), 1, "orphan dir without pin must be skipped");
1390 assert_eq!(list[0].name, "good-toolset");
1391 }
1392
1393 #[test]
1394 fn list_pinned_toolsets_with_allowed_tools_narrowing() {
1395 let dir = tempfile::TempDir::new().unwrap();
1396 let toolsets_root = dir.path();
1397 let pkg = "narrow-toolset";
1398
1399 write_toolset_md(toolsets_root, pkg, &minimal_toolset_md(pkg));
1400 let caps =
1401 stellar_agent_toolsets::parse_capability_value_pub("read-balance suggest-destination")
1402 .unwrap();
1403 let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1405 pkg,
1406 "1.0.0",
1407 "c".repeat(64),
1408 "GABC1234567890123456789012345678901234567890123456",
1409 "2026-06-12T00:00:00Z",
1410 caps,
1411 vec!["stellar_balances".to_owned()],
1412 None,
1413 );
1414 write_pin_json(toolsets_root, pkg, &pin);
1415
1416 let list = list_pinned_toolsets(toolsets_root).unwrap();
1417 assert_eq!(list.len(), 1);
1418 assert_eq!(list[0].actions, vec!["stellar_balances"]);
1421 assert_eq!(list[0].allowed_tools, vec!["stellar_balances"]);
1422 }
1423
1424 #[test]
1427 fn allowed_tools_narrowing_passes_when_tool_included() {
1428 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1431 let allowed = vec!["stellar_balances".to_owned(), "some-other".to_owned()];
1432 let tool = check_toolset_action("stellar_balances", &caps, &allowed).unwrap();
1433 assert_eq!(tool, "stellar_balances");
1434 }
1435
1436 #[test]
1439 fn resolve_toolset_and_check_invalid_charset_rejected() {
1440 let dir = tempfile::TempDir::new().unwrap();
1441 let err =
1443 resolve_toolset_and_check("bad name", "stellar_balances", dir.path()).unwrap_err();
1444 assert!(
1445 matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1446 "expected ToolsetNotInstalled, got: {err:?}"
1447 );
1448 }
1449
1450 #[test]
1456 fn resolve_toolset_and_check_valid_name_toolset_not_installed() {
1457 let dir = tempfile::TempDir::new().unwrap();
1458 let err =
1460 resolve_toolset_and_check("valid-name", "stellar_balances", dir.path()).unwrap_err();
1461 assert!(
1462 matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1463 "expected ToolsetNotInstalled for a valid name with no pin record, got: {err:?}"
1464 );
1465 }
1466
1467 #[cfg(feature = "test-helpers")]
1471 fn write_sign_payment_pin(toolsets_root: &std::path::Path, pkg: &str) {
1472 std::fs::create_dir_all(toolsets_root.join(pkg)).unwrap();
1473 let caps = stellar_agent_toolsets::parse_capability_value_pub("sign-payment").unwrap();
1474 let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1475 pkg,
1476 "1.0.0",
1477 "d".repeat(64),
1478 "GABC1234567890123456789012345678901234567890123456",
1479 "2026-06-12T00:00:00Z",
1480 caps,
1481 vec![],
1482 None,
1483 );
1484 write_pin_json(toolsets_root, pkg, &pin);
1485 }
1486
1487 #[test]
1490 #[cfg(feature = "test-helpers")]
1491 fn gated_resolver_invalid_toolset_name_rejected() {
1492 let toolsets_dir = tempfile::TempDir::new().unwrap();
1493 let approval_dir = tempfile::TempDir::new().unwrap();
1494 let grant_dir = tempfile::TempDir::new().unwrap();
1495
1496 let params = GatedInvokeParams {
1497 toolset_name: "Bad Name!",
1498 action: "stellar_pay_commit",
1499 toolsets_root: toolsets_dir.path(),
1500 profile_name: "test",
1501 authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1502 authoritative_asset: "XLM",
1503 authoritative_amount_stroops: 10_000_000,
1504 now_unix_ms: 1_000_000,
1505 process_uid: "uid-test",
1506 approval_dir_override: Some(approval_dir.path().to_path_buf()),
1507 grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1508 };
1509
1510 let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
1511 assert!(
1512 matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1513 "expected ToolsetNotInstalled, got: {err:?}"
1514 );
1515 }
1516
1517 #[test]
1520 #[cfg(feature = "test-helpers")]
1521 fn gated_resolver_toolset_not_installed() {
1522 let toolsets_dir = tempfile::TempDir::new().unwrap();
1523 let approval_dir = tempfile::TempDir::new().unwrap();
1524 let grant_dir = tempfile::TempDir::new().unwrap();
1525
1526 let params = GatedInvokeParams {
1527 toolset_name: "not-installed",
1528 action: "stellar_pay_commit",
1529 toolsets_root: toolsets_dir.path(),
1530 profile_name: "test",
1531 authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1532 authoritative_asset: "XLM",
1533 authoritative_amount_stroops: 10_000_000,
1534 now_unix_ms: 1_000_000,
1535 process_uid: "uid-test",
1536 approval_dir_override: Some(approval_dir.path().to_path_buf()),
1537 grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1538 };
1539
1540 let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
1541 assert!(
1542 matches!(err, ToolsetRuntimeError::ToolsetNotInstalled { .. }),
1543 "expected ToolsetNotInstalled, got: {err:?}"
1544 );
1545 }
1546
1547 #[test]
1550 #[cfg(feature = "test-helpers")]
1551 fn gated_resolver_ungated_action_rejected() {
1552 let toolsets_dir = tempfile::TempDir::new().unwrap();
1553 let approval_dir = tempfile::TempDir::new().unwrap();
1554 let grant_dir = tempfile::TempDir::new().unwrap();
1555
1556 write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
1557
1558 let params = GatedInvokeParams {
1559 toolset_name: "pay-toolset",
1560 action: "stellar_pay",
1562 toolsets_root: toolsets_dir.path(),
1563 profile_name: "test",
1564 authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1565 authoritative_asset: "XLM",
1566 authoritative_amount_stroops: 10_000_000,
1567 now_unix_ms: 1_000_000,
1568 process_uid: "uid-test",
1569 approval_dir_override: Some(approval_dir.path().to_path_buf()),
1570 grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1571 };
1572
1573 let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
1574 assert!(
1575 matches!(err, ToolsetRuntimeError::UnknownToolsetAction { .. }),
1576 "ungated action must not resolve via gated matrix, got: {err:?}"
1577 );
1578 }
1579
1580 #[test]
1583 #[cfg(feature = "test-helpers")]
1584 fn gated_resolver_sign_payment_not_declared() {
1585 let toolsets_dir = tempfile::TempDir::new().unwrap();
1586 let approval_dir = tempfile::TempDir::new().unwrap();
1587 let grant_dir = tempfile::TempDir::new().unwrap();
1588
1589 let caps = stellar_agent_toolsets::parse_capability_value_pub("read-balance").unwrap();
1591 let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1592 "read-toolset",
1593 "1.0.0",
1594 "e".repeat(64),
1595 "GABC1234567890123456789012345678901234567890123456",
1596 "2026-06-12T00:00:00Z",
1597 caps,
1598 vec![],
1599 None,
1600 );
1601 std::fs::create_dir_all(toolsets_dir.path().join("read-toolset")).unwrap();
1602 write_pin_json(toolsets_dir.path(), "read-toolset", &pin);
1603
1604 let params = GatedInvokeParams {
1605 toolset_name: "read-toolset",
1606 action: "stellar_pay_commit",
1607 toolsets_root: toolsets_dir.path(),
1608 profile_name: "test",
1609 authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1610 authoritative_asset: "XLM",
1611 authoritative_amount_stroops: 10_000_000,
1612 now_unix_ms: 1_000_000,
1613 process_uid: "uid-test",
1614 approval_dir_override: Some(approval_dir.path().to_path_buf()),
1615 grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1616 };
1617
1618 let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
1619 assert!(
1620 matches!(err, ToolsetRuntimeError::CapabilityNotDeclared { .. }),
1621 "expected CapabilityNotDeclared, got: {err:?}"
1622 );
1623 }
1624
1625 #[test]
1628 #[cfg(feature = "test-helpers")]
1629 fn gated_resolver_tool_not_in_allowed_tools() {
1630 let toolsets_dir = tempfile::TempDir::new().unwrap();
1631 let approval_dir = tempfile::TempDir::new().unwrap();
1632 let grant_dir = tempfile::TempDir::new().unwrap();
1633
1634 let caps = stellar_agent_toolsets::parse_capability_value_pub("sign-payment").unwrap();
1636 let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1637 "narrow-pay-toolset",
1638 "1.0.0",
1639 "f".repeat(64),
1640 "GABC1234567890123456789012345678901234567890123456",
1641 "2026-06-12T00:00:00Z",
1642 caps,
1643 vec!["some-other-tool".to_owned()],
1644 None,
1645 );
1646 std::fs::create_dir_all(toolsets_dir.path().join("narrow-pay-toolset")).unwrap();
1647 write_pin_json(toolsets_dir.path(), "narrow-pay-toolset", &pin);
1648
1649 let params = GatedInvokeParams {
1650 toolset_name: "narrow-pay-toolset",
1651 action: "stellar_pay_commit",
1652 toolsets_root: toolsets_dir.path(),
1653 profile_name: "test",
1654 authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1655 authoritative_asset: "XLM",
1656 authoritative_amount_stroops: 10_000_000,
1657 now_unix_ms: 1_000_000,
1658 process_uid: "uid-test",
1659 approval_dir_override: Some(approval_dir.path().to_path_buf()),
1660 grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1661 };
1662
1663 let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
1664 assert!(
1665 matches!(err, ToolsetRuntimeError::ToolNotAllowed { .. }),
1666 "expected ToolNotAllowed, got: {err:?}"
1667 );
1668 }
1669
1670 #[test]
1673 #[cfg(feature = "test-helpers")]
1674 fn gated_resolver_no_grant_queues_first_invoke_gate() {
1675 let toolsets_dir = tempfile::TempDir::new().unwrap();
1676 let approval_dir = tempfile::TempDir::new().unwrap();
1677 let grant_dir = tempfile::TempDir::new().unwrap();
1678
1679 write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
1680
1681 let params = GatedInvokeParams {
1682 toolset_name: "pay-toolset",
1683 action: "stellar_pay_commit",
1684 toolsets_root: toolsets_dir.path(),
1685 profile_name: "test",
1686 authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
1687 authoritative_asset: "XLM",
1688 authoritative_amount_stroops: 10_000_000,
1689 now_unix_ms: 1_000_000,
1690 process_uid: "uid-test",
1691 approval_dir_override: Some(approval_dir.path().to_path_buf()),
1692 grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1693 };
1694
1695 let outcome = resolve_toolset_sign_payment_gated(¶ms).unwrap();
1696 match outcome {
1697 GatedResolveOutcome::FirstInvokeApprovalRequired {
1698 approval_nonce,
1699 toolset_name,
1700 capability,
1701 } => {
1702 assert!(
1703 !approval_nonce.is_empty(),
1704 "approval_nonce must be non-empty"
1705 );
1706 assert_eq!(toolset_name, "pay-toolset");
1707 assert_eq!(capability, "sign-payment");
1708 }
1709 GatedResolveOutcome::Resolved { .. } => {
1710 panic!("expected FirstInvokeApprovalRequired, got Resolved");
1711 }
1712 }
1713 }
1714
1715 #[test]
1718 #[cfg(feature = "test-helpers")]
1719 fn gated_resolver_with_matching_grant_returns_resolved() {
1720 use stellar_agent_core::approval::process_uid_for_attestation;
1721
1722 let toolsets_dir = tempfile::TempDir::new().unwrap();
1723 let approval_dir = tempfile::TempDir::new().unwrap();
1724 let grant_dir = tempfile::TempDir::new().unwrap();
1725 let grant_path = grant_dir.path().join("grants.json");
1726
1727 write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
1728
1729 let now_unix_ms: u64 = 1_000_000;
1730 let destination = "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL";
1731 let asset = "XLM";
1732 let amount_stroops: i64 = 10_000_000;
1733 let attestation_key = [0u8; 32];
1734 let uid = process_uid_for_attestation().unwrap();
1735
1736 record_first_invoke_grant(
1738 "test",
1739 "pay-toolset",
1740 "sign-payment",
1741 destination,
1742 asset,
1743 0,
1744 amount_stroops,
1745 &uid,
1746 now_unix_ms,
1747 &attestation_key,
1748 &stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
1749 Some(grant_path.clone()),
1750 )
1751 .unwrap();
1752
1753 let params = GatedInvokeParams {
1754 toolset_name: "pay-toolset",
1755 action: "stellar_pay_commit",
1756 toolsets_root: toolsets_dir.path(),
1757 profile_name: "test",
1758 authoritative_destination: destination,
1759 authoritative_asset: asset,
1760 authoritative_amount_stroops: amount_stroops,
1761 now_unix_ms,
1762 process_uid: &uid,
1763 approval_dir_override: Some(approval_dir.path().to_path_buf()),
1764 grant_store_path_override: Some(grant_path),
1765 };
1766
1767 let outcome = resolve_toolset_sign_payment_gated(¶ms).unwrap();
1768 match outcome {
1769 GatedResolveOutcome::Resolved { tool_name } => {
1770 assert_eq!(tool_name, "stellar_pay_commit");
1771 }
1772 GatedResolveOutcome::FirstInvokeApprovalRequired { .. } => {
1773 panic!("expected Resolved, got FirstInvokeApprovalRequired");
1774 }
1775 }
1776 }
1777
1778 use crate::matrix::{SIGN_RULE_CREATE_AMOUNT_SENTINEL, SIGN_RULE_CREATE_ASSET_SENTINEL};
1796
1797 fn write_sign_rule_create_pin(toolsets_root: &std::path::Path, pkg: &str) {
1798 std::fs::create_dir_all(toolsets_root.join(pkg)).unwrap();
1799 let caps = stellar_agent_toolsets::parse_capability_value_pub("sign-rule-create").unwrap();
1800 let pin = stellar_agent_toolsets_install::ToolsetPinRecord::build_for_test(
1801 pkg,
1802 "1.0.0",
1803 "d".repeat(64),
1804 "GABC1234567890123456789012345678901234567890123456",
1805 "2026-06-12T00:00:00Z",
1806 caps,
1807 vec![],
1808 None,
1809 );
1810 write_pin_json(toolsets_root, pkg, &pin);
1811 }
1812
1813 #[test]
1814 #[cfg(feature = "test-helpers")]
1815 fn gated_resolver_sign_rule_create_no_grant_queues_first_invoke_gate() {
1816 let toolsets_dir = tempfile::TempDir::new().unwrap();
1817 let approval_dir = tempfile::TempDir::new().unwrap();
1818 let grant_dir = tempfile::TempDir::new().unwrap();
1819
1820 write_sign_rule_create_pin(toolsets_dir.path(), "rule-toolset");
1821
1822 let params = GatedInvokeParams {
1823 toolset_name: "rule-toolset",
1824 action: "stellar_rule_create_commit",
1825 toolsets_root: toolsets_dir.path(),
1826 profile_name: "test",
1827 authoritative_destination: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
1828 authoritative_asset: SIGN_RULE_CREATE_ASSET_SENTINEL,
1829 authoritative_amount_stroops: SIGN_RULE_CREATE_AMOUNT_SENTINEL,
1830 now_unix_ms: 1_000_000,
1831 process_uid: "uid-test",
1832 approval_dir_override: Some(approval_dir.path().to_path_buf()),
1833 grant_store_path_override: Some(grant_dir.path().join("grants.json")),
1834 };
1835
1836 let outcome = resolve_toolset_sign_payment_gated(¶ms).unwrap();
1837 match outcome {
1838 GatedResolveOutcome::FirstInvokeApprovalRequired {
1839 approval_nonce,
1840 toolset_name,
1841 capability,
1842 } => {
1843 assert!(
1844 !approval_nonce.is_empty(),
1845 "approval_nonce must be non-empty"
1846 );
1847 assert_eq!(toolset_name, "rule-toolset");
1848 assert_eq!(capability, "sign-rule-create");
1849 }
1850 GatedResolveOutcome::Resolved { .. } => {
1851 panic!("expected FirstInvokeApprovalRequired, got Resolved");
1852 }
1853 }
1854 }
1855
1856 #[test]
1857 #[cfg(feature = "test-helpers")]
1858 fn gated_resolver_sign_rule_create_with_matching_grant_returns_resolved() {
1859 use stellar_agent_core::approval::process_uid_for_attestation;
1860
1861 let toolsets_dir = tempfile::TempDir::new().unwrap();
1862 let approval_dir = tempfile::TempDir::new().unwrap();
1863 let grant_dir = tempfile::TempDir::new().unwrap();
1864 let grant_path = grant_dir.path().join("grants.json");
1865
1866 write_sign_rule_create_pin(toolsets_dir.path(), "rule-toolset");
1867
1868 let now_unix_ms: u64 = 1_000_000;
1869 let smart_account = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM";
1870 let attestation_key = [0u8; 32];
1871 let uid = process_uid_for_attestation().unwrap();
1872
1873 record_first_invoke_grant(
1874 "test",
1875 "rule-toolset",
1876 "sign-rule-create",
1877 smart_account,
1878 SIGN_RULE_CREATE_ASSET_SENTINEL,
1879 0,
1880 SIGN_RULE_CREATE_AMOUNT_SENTINEL,
1881 &uid,
1882 now_unix_ms,
1883 &attestation_key,
1884 &stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
1885 Some(grant_path.clone()),
1886 )
1887 .unwrap();
1888
1889 let params = GatedInvokeParams {
1890 toolset_name: "rule-toolset",
1891 action: "stellar_rule_create_commit",
1892 toolsets_root: toolsets_dir.path(),
1893 profile_name: "test",
1894 authoritative_destination: smart_account,
1895 authoritative_asset: SIGN_RULE_CREATE_ASSET_SENTINEL,
1896 authoritative_amount_stroops: SIGN_RULE_CREATE_AMOUNT_SENTINEL,
1897 now_unix_ms,
1898 process_uid: &uid,
1899 approval_dir_override: Some(approval_dir.path().to_path_buf()),
1900 grant_store_path_override: Some(grant_path),
1901 };
1902
1903 let outcome = resolve_toolset_sign_payment_gated(¶ms).unwrap();
1904 match outcome {
1905 GatedResolveOutcome::Resolved { tool_name } => {
1906 assert_eq!(tool_name, "stellar_rule_create_commit");
1907 }
1908 GatedResolveOutcome::FirstInvokeApprovalRequired { .. } => {
1909 panic!("expected Resolved, got FirstInvokeApprovalRequired");
1910 }
1911 }
1912 }
1913
1914 #[test]
1919 fn propose_transaction_grants_stellar_rule_create_but_not_commit() {
1920 let caps =
1921 stellar_agent_toolsets::parse_capability_value_pub("propose-transaction").unwrap();
1922 let tool = check_toolset_action("stellar_rule_create", &caps, &[]).unwrap();
1923 assert_eq!(tool, "stellar_rule_create");
1924
1925 let err = check_toolset_action("stellar_rule_create_commit", &caps, &[]).unwrap_err();
1926 assert!(matches!(
1927 err,
1928 ToolsetRuntimeError::UnknownToolsetAction { .. }
1929 ));
1930 }
1931
1932 #[test]
1938 #[cfg(feature = "test-helpers")]
1939 fn gated_resolver_grant_present_zero_amount_rejected() {
1940 use stellar_agent_core::approval::process_uid_for_attestation;
1941
1942 let toolsets_dir = tempfile::TempDir::new().unwrap();
1943 let approval_dir = tempfile::TempDir::new().unwrap();
1944 let grant_dir = tempfile::TempDir::new().unwrap();
1945 let grant_path = grant_dir.path().join("grants.json");
1946
1947 write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
1948
1949 let now_unix_ms: u64 = 1_000_000;
1950 let destination = "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL";
1951 let asset = "XLM";
1952 let attestation_key = [0u8; 32];
1953 let uid = process_uid_for_attestation().unwrap();
1954
1955 record_first_invoke_grant(
1958 "test",
1959 "pay-toolset",
1960 "sign-payment",
1961 destination,
1962 asset,
1963 0,
1964 10_000_000,
1965 &uid,
1966 now_unix_ms,
1967 &attestation_key,
1968 &stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
1969 Some(grant_path.clone()),
1970 )
1971 .unwrap();
1972
1973 let params = GatedInvokeParams {
1974 toolset_name: "pay-toolset",
1975 action: "stellar_pay_commit",
1976 toolsets_root: toolsets_dir.path(),
1977 profile_name: "test",
1978 authoritative_destination: destination,
1979 authoritative_asset: asset,
1980 authoritative_amount_stroops: 0,
1981 now_unix_ms,
1982 process_uid: &uid,
1983 approval_dir_override: Some(approval_dir.path().to_path_buf()),
1984 grant_store_path_override: Some(grant_path),
1985 };
1986
1987 let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
1988 assert!(
1989 matches!(
1990 err,
1991 ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: 0 }
1992 ),
1993 "a zero amount must be rejected even when a [0, N] grant exists; got: {err:?}"
1994 );
1995 }
1996
1997 #[test]
2000 #[cfg(feature = "test-helpers")]
2001 fn gated_resolver_zero_amount_rejected() {
2002 let toolsets_dir = tempfile::TempDir::new().unwrap();
2003 let approval_dir = tempfile::TempDir::new().unwrap();
2004 let grant_dir = tempfile::TempDir::new().unwrap();
2005
2006 write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
2007
2008 let params = GatedInvokeParams {
2009 toolset_name: "pay-toolset",
2010 action: "stellar_pay_commit",
2011 toolsets_root: toolsets_dir.path(),
2012 profile_name: "test",
2013 authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2014 authoritative_asset: "XLM",
2015 authoritative_amount_stroops: 0,
2016 now_unix_ms: 1_000_000,
2017 process_uid: "uid-test",
2018 approval_dir_override: Some(approval_dir.path().to_path_buf()),
2019 grant_store_path_override: Some(grant_dir.path().join("grants.json")),
2020 };
2021
2022 let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
2023 assert!(
2024 matches!(
2025 err,
2026 ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: 0 }
2027 ),
2028 "expected InvalidAuthoritativeAmount(0), got: {err:?}"
2029 );
2030 }
2031
2032 #[test]
2033 #[cfg(feature = "test-helpers")]
2034 fn gated_resolver_negative_amount_rejected() {
2035 let toolsets_dir = tempfile::TempDir::new().unwrap();
2036 let approval_dir = tempfile::TempDir::new().unwrap();
2037 let grant_dir = tempfile::TempDir::new().unwrap();
2038
2039 write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
2040
2041 let params = GatedInvokeParams {
2042 toolset_name: "pay-toolset",
2043 action: "stellar_pay_commit",
2044 toolsets_root: toolsets_dir.path(),
2045 profile_name: "test",
2046 authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2047 authoritative_asset: "XLM",
2048 authoritative_amount_stroops: -1,
2049 now_unix_ms: 1_000_000,
2050 process_uid: "uid-test",
2051 approval_dir_override: Some(approval_dir.path().to_path_buf()),
2052 grant_store_path_override: Some(grant_dir.path().join("grants.json")),
2053 };
2054
2055 let err = resolve_toolset_sign_payment_gated(¶ms).unwrap_err();
2056 assert!(
2057 matches!(
2058 err,
2059 ToolsetRuntimeError::InvalidAuthoritativeAmount { amount_stroops: -1 }
2060 ),
2061 "expected InvalidAuthoritativeAmount(-1), got: {err:?}"
2062 );
2063 }
2064
2065 #[test]
2074 #[cfg(feature = "test-helpers")]
2075 fn gated_resolver_queued_pending_uses_caller_supplied_process_uid() {
2076 let toolsets_dir = tempfile::TempDir::new().unwrap();
2077 let approval_dir = tempfile::TempDir::new().unwrap();
2078 let grant_dir = tempfile::TempDir::new().unwrap();
2079
2080 write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
2081
2082 let supplied_uid = "1234567890";
2085
2086 let params = GatedInvokeParams {
2087 toolset_name: "pay-toolset",
2088 action: "stellar_pay_commit",
2089 toolsets_root: toolsets_dir.path(),
2090 profile_name: "test",
2091 authoritative_destination: "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2092 authoritative_asset: "XLM",
2093 authoritative_amount_stroops: 10_000_000,
2094 now_unix_ms: 1_000_000,
2095 process_uid: supplied_uid,
2096 approval_dir_override: Some(approval_dir.path().to_path_buf()),
2097 grant_store_path_override: Some(grant_dir.path().join("grants.json")),
2098 };
2099
2100 let outcome = resolve_toolset_sign_payment_gated(¶ms).unwrap();
2101 let approval_nonce = match outcome {
2102 GatedResolveOutcome::FirstInvokeApprovalRequired { approval_nonce, .. } => {
2103 approval_nonce
2104 }
2105 GatedResolveOutcome::Resolved { .. } => {
2106 panic!("expected FirstInvokeApprovalRequired, got Resolved");
2107 }
2108 };
2109
2110 let store_path = approval_dir.path().join("test.toml");
2114 let store = stellar_agent_core::approval::PendingApprovalStore::open(store_path).unwrap();
2115 let pending = store
2116 .get(&approval_nonce)
2117 .expect("queued pending must be findable by nonce");
2118
2119 assert_eq!(
2120 pending.process_uid, supplied_uid,
2121 "queued pending's process_uid must equal the caller-supplied value '{}'; \
2122 got '{}' — would differ if the resolver recomputed it internally",
2123 supplied_uid, pending.process_uid
2124 );
2125 }
2126
2127 #[test]
2135 #[cfg(feature = "test-helpers")]
2136 fn gated_resolver_over_max_amount_re_prompts() {
2137 use stellar_agent_core::approval::process_uid_for_attestation;
2138
2139 let toolsets_dir = tempfile::TempDir::new().unwrap();
2140 let approval_dir = tempfile::TempDir::new().unwrap();
2141 let grant_dir = tempfile::TempDir::new().unwrap();
2142 let grant_path = grant_dir.path().join("grants.json");
2143
2144 write_sign_payment_pin(toolsets_dir.path(), "pay-toolset");
2145
2146 let now_unix_ms: u64 = 1_000_000;
2147 let destination = "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL";
2148 let asset = "XLM";
2149 let grant_max_stroops: i64 = 10_000_000; let attestation_key = [0u8; 32];
2151 let uid = process_uid_for_attestation().unwrap();
2152
2153 record_first_invoke_grant(
2155 "test",
2156 "pay-toolset",
2157 "sign-payment",
2158 destination,
2159 asset,
2160 0,
2161 grant_max_stroops,
2162 &uid,
2163 now_unix_ms,
2164 &attestation_key,
2165 &stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
2166 Some(grant_path.clone()),
2167 )
2168 .unwrap();
2169
2170 let over_max_stroops: i64 = grant_max_stroops + 1;
2172
2173 let params = GatedInvokeParams {
2174 toolset_name: "pay-toolset",
2175 action: "stellar_pay_commit",
2176 toolsets_root: toolsets_dir.path(),
2177 profile_name: "test",
2178 authoritative_destination: destination,
2179 authoritative_asset: asset,
2180 authoritative_amount_stroops: over_max_stroops,
2181 now_unix_ms,
2182 process_uid: &uid,
2183 approval_dir_override: Some(approval_dir.path().to_path_buf()),
2184 grant_store_path_override: Some(grant_path),
2185 };
2186
2187 let outcome = resolve_toolset_sign_payment_gated(¶ms).unwrap();
2188
2189 assert!(
2191 matches!(
2192 outcome,
2193 GatedResolveOutcome::FirstInvokeApprovalRequired { .. }
2194 ),
2195 "amount above grant max must re-prompt (FirstInvokeApprovalRequired), \
2196 got Resolved — the existing grant must not match an over-max amount"
2197 );
2198
2199 match outcome {
2201 GatedResolveOutcome::FirstInvokeApprovalRequired { approval_nonce, .. } => {
2202 assert!(
2203 !approval_nonce.is_empty(),
2204 "re-prompt must carry a non-empty approval_nonce"
2205 );
2206 }
2207 GatedResolveOutcome::Resolved { .. } => unreachable!(),
2208 }
2209 }
2210
2211 #[test]
2214 #[cfg(feature = "test-helpers")]
2215 fn record_first_invoke_grant_persists_to_store() {
2216 use stellar_agent_core::approval::process_uid_for_attestation;
2217
2218 let grant_dir = tempfile::TempDir::new().unwrap();
2219 let grant_path = grant_dir.path().join("grants.json");
2220
2221 let now_unix_ms: u64 = 2_000_000;
2222 let uid = process_uid_for_attestation().unwrap();
2223 let attestation_key = [1u8; 32];
2224
2225 let grant = record_first_invoke_grant(
2226 "prod",
2227 "my-toolset",
2228 "sign-payment",
2229 "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2230 "XLM",
2231 0,
2232 50_000_000,
2233 &uid,
2234 now_unix_ms,
2235 &attestation_key,
2236 &stellar_agent_core::approval::AttestationBinding::new("default", "stellar:testnet"),
2237 Some(grant_path.clone()),
2238 )
2239 .unwrap();
2240
2241 assert_eq!(grant.toolset_name, "my-toolset");
2242 assert_eq!(grant.capability, "sign-payment");
2243
2244 assert!(
2249 grant.verify_attestation(
2250 &attestation_key,
2251 &stellar_agent_core::approval::AttestationBinding::new(
2252 "default",
2253 "stellar:testnet"
2254 )
2255 ),
2256 "grant must verify against the attestation key it was built with"
2257 );
2258 assert!(
2259 !grant.verify_attestation(
2260 &[0xff; 32],
2261 &stellar_agent_core::approval::AttestationBinding::new(
2262 "default",
2263 "stellar:testnet"
2264 )
2265 ),
2266 "grant must NOT verify against a wrong attestation key"
2267 );
2268
2269 let store =
2271 stellar_agent_core::approval::ToolsetGrantStore::open(grant_path, now_unix_ms).unwrap();
2272 let found = store.find_matching(
2273 "my-toolset",
2274 "sign-payment",
2275 "GBPXXOA5N4JYPESHAADMQKBPWZWQDQ64ZV6ZL2S3LAGW4SY7NTCMWIVL",
2276 "XLM",
2277 30_000_000, now_unix_ms,
2279 );
2280 assert!(found.is_some(), "persisted grant must be findable in store");
2281 }
2282}