@wlfi-agent/cli 1.4.14 → 1.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/Cargo.lock +1 -0
  2. package/Cargo.toml +1 -1
  3. package/README.md +10 -2
  4. package/crates/vault-cli-admin/src/main.rs +21 -2
  5. package/crates/vault-cli-admin/src/tui.rs +634 -129
  6. package/crates/vault-cli-daemon/Cargo.toml +1 -0
  7. package/crates/vault-cli-daemon/src/bin/wlfi-agent-system-keychain.rs +122 -8
  8. package/crates/vault-cli-daemon/src/main.rs +24 -4
  9. package/crates/vault-cli-daemon/src/relay_sync.rs +155 -35
  10. package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +23 -18
  11. package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +6 -0
  12. package/crates/vault-daemon/src/daemon_parts/types_api_rpc.rs +6 -0
  13. package/crates/vault-daemon/src/tests.rs +2 -2
  14. package/crates/vault-daemon/src/tests_parts/part4.rs +110 -0
  15. package/crates/vault-transport-unix/src/lib.rs +22 -3
  16. package/crates/vault-transport-xpc/src/lib.rs +20 -2
  17. package/dist/cli.cjs +20842 -25552
  18. package/dist/cli.cjs.map +1 -1
  19. package/package.json +18 -18
  20. package/packages/cache/.turbo/turbo-build.log +20 -20
  21. package/packages/cache/coverage/base.css +224 -0
  22. package/packages/cache/coverage/block-navigation.js +87 -0
  23. package/packages/cache/coverage/clover.xml +585 -0
  24. package/packages/cache/coverage/coverage-final.json +5 -0
  25. package/packages/cache/coverage/favicon.png +0 -0
  26. package/packages/cache/coverage/index.html +161 -0
  27. package/packages/cache/coverage/prettify.css +1 -0
  28. package/packages/cache/coverage/prettify.js +2 -0
  29. package/packages/cache/coverage/sort-arrow-sprite.png +0 -0
  30. package/packages/cache/coverage/sorter.js +210 -0
  31. package/packages/cache/coverage/src/client/index.html +116 -0
  32. package/packages/cache/coverage/src/client/index.ts.html +253 -0
  33. package/packages/cache/coverage/src/errors/index.html +116 -0
  34. package/packages/cache/coverage/src/errors/index.ts.html +244 -0
  35. package/packages/cache/coverage/src/index.html +116 -0
  36. package/packages/cache/coverage/src/index.ts.html +94 -0
  37. package/packages/cache/coverage/src/service/index.html +116 -0
  38. package/packages/cache/coverage/src/service/index.ts.html +2212 -0
  39. package/packages/cache/dist/{chunk-ALQ6H7KG.cjs → chunk-QF4XKEIA.cjs} +189 -45
  40. package/packages/cache/dist/chunk-QF4XKEIA.cjs.map +1 -0
  41. package/packages/cache/dist/{chunk-FGJEEF5N.js → chunk-QNK6GOTI.js} +182 -38
  42. package/packages/cache/dist/chunk-QNK6GOTI.js.map +1 -0
  43. package/packages/cache/dist/index.cjs +2 -2
  44. package/packages/cache/dist/index.js +1 -1
  45. package/packages/cache/dist/service/index.cjs +2 -2
  46. package/packages/cache/dist/service/index.d.cts +2 -0
  47. package/packages/cache/dist/service/index.d.ts +2 -0
  48. package/packages/cache/dist/service/index.js +1 -1
  49. package/packages/cache/node_modules/.bin/jiti +0 -0
  50. package/packages/cache/node_modules/.bin/tsc +0 -0
  51. package/packages/cache/node_modules/.bin/tsserver +0 -0
  52. package/packages/cache/node_modules/.bin/tsup +0 -0
  53. package/packages/cache/node_modules/.bin/tsup-node +0 -0
  54. package/packages/cache/node_modules/.bin/tsx +0 -0
  55. package/packages/cache/node_modules/.bin/vitest +0 -0
  56. package/packages/cache/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  57. package/packages/cache/src/service/index.test.ts +575 -0
  58. package/packages/cache/src/service/index.ts +234 -51
  59. package/packages/config/.turbo/turbo-build.log +17 -18
  60. package/packages/config/dist/index.cjs +0 -0
  61. package/packages/config/node_modules/.bin/jiti +0 -0
  62. package/packages/config/node_modules/.bin/tsc +2 -2
  63. package/packages/config/node_modules/.bin/tsserver +2 -2
  64. package/packages/config/node_modules/.bin/tsup +2 -2
  65. package/packages/config/node_modules/.bin/tsup-node +2 -2
  66. package/packages/config/node_modules/.bin/tsx +0 -0
  67. package/packages/rpc/.turbo/turbo-build.log +31 -32
  68. package/packages/rpc/dist/_esm-BCLXDO2R.cjs +0 -0
  69. package/packages/rpc/dist/ccip-OWJLAW55.cjs +0 -0
  70. package/packages/rpc/dist/chunk-APQIFZ3B.cjs +0 -0
  71. package/packages/rpc/dist/chunk-CDO2GWRD.cjs +0 -0
  72. package/packages/rpc/dist/chunk-QGTNTFJ7.cjs +0 -0
  73. package/packages/rpc/dist/chunk-TZDTAHWR.cjs +0 -0
  74. package/packages/rpc/dist/index.cjs +0 -0
  75. package/packages/rpc/dist/secp256k1-WCNM675D.cjs +0 -0
  76. package/packages/rpc/node_modules/.bin/jiti +0 -0
  77. package/packages/rpc/node_modules/.bin/tsc +2 -2
  78. package/packages/rpc/node_modules/.bin/tsserver +2 -2
  79. package/packages/rpc/node_modules/.bin/tsup +2 -2
  80. package/packages/rpc/node_modules/.bin/tsup-node +2 -2
  81. package/packages/rpc/node_modules/.bin/tsx +0 -0
  82. package/packages/ui/.turbo/turbo-build.log +43 -44
  83. package/packages/ui/node_modules/.bin/jiti +0 -0
  84. package/packages/ui/node_modules/.bin/tsc +0 -0
  85. package/packages/ui/node_modules/.bin/tsserver +0 -0
  86. package/packages/ui/node_modules/.bin/tsup +0 -0
  87. package/packages/ui/node_modules/.bin/tsup-node +0 -0
  88. package/packages/ui/node_modules/.bin/tsx +0 -0
  89. package/scripts/install-rust-binaries.mjs +164 -58
  90. package/scripts/launchd/install-user-daemon.sh +0 -0
  91. package/scripts/launchd/run-vault-daemon.sh +0 -0
  92. package/scripts/launchd/run-wlfi-agent-daemon.sh +0 -0
  93. package/scripts/launchd/uninstall-user-daemon.sh +0 -0
  94. package/src/cli.ts +51 -39
  95. package/src/lib/admin-passthrough.js +1 -0
  96. package/src/lib/admin-reset.js +1 -0
  97. package/src/lib/admin-reset.ts +26 -16
  98. package/src/lib/admin-setup.js +1 -0
  99. package/src/lib/admin-setup.ts +32 -20
  100. package/src/lib/agent-auth-revoke.js +1 -0
  101. package/src/lib/agent-auth-rotate.js +1 -0
  102. package/src/lib/agent-auth.js +1 -0
  103. package/src/lib/config-mutation.js +1 -0
  104. package/src/lib/launchd-assets.js +1 -0
  105. package/src/lib/launchd-assets.ts +29 -0
  106. package/src/lib/local-admin-access.js +1 -0
  107. package/src/lib/rust.ts +1 -1
  108. package/src/lib/status-repair-cli.js +1 -0
  109. package/packages/cache/dist/chunk-ALQ6H7KG.cjs.map +0 -1
  110. package/packages/cache/dist/chunk-FGJEEF5N.js.map +0 -1
@@ -24,5 +24,6 @@ vault-signer = { path = "../vault-signer" }
24
24
  vault-transport-unix = { path = "../vault-transport-unix" }
25
25
 
26
26
  [target.'cfg(target_os = "macos")'.dependencies]
27
+ core-foundation.workspace = true
27
28
  security-framework.workspace = true
28
29
  security-framework-sys.workspace = true
@@ -165,14 +165,18 @@ fn replace_generic_password(
165
165
  }
166
166
  }
167
167
 
168
- keychain
169
- .add_generic_password(&service, &account, password.as_bytes())
170
- .with_context(|| {
171
- format!(
172
- "failed to store generic password for service '{}' and account '{}'",
173
- service, account
174
- )
175
- })?;
168
+ add_generic_password_restricted_to_creator(
169
+ &keychain,
170
+ &service,
171
+ &account,
172
+ password.as_bytes(),
173
+ )
174
+ .with_context(|| {
175
+ format!(
176
+ "failed to store generic password for service '{}' and account '{}'",
177
+ service, account
178
+ )
179
+ })?;
176
180
  Ok(())
177
181
  })();
178
182
 
@@ -180,6 +184,116 @@ fn replace_generic_password(
180
184
  result
181
185
  }
182
186
 
187
+ #[cfg(target_os = "macos")]
188
+ fn add_generic_password_restricted_to_creator(
189
+ keychain: &security_framework::os::macos::keychain::SecKeychain,
190
+ service: &str,
191
+ account: &str,
192
+ password: &[u8],
193
+ ) -> Result<()> {
194
+ use core_foundation::array::{CFArray, CFArrayRef};
195
+ use core_foundation::base::{CFType, TCFType};
196
+ use core_foundation::string::CFString;
197
+ use security_framework::base::Error as SecurityError;
198
+ use security_framework::os::macos::access::SecAccess;
199
+ use security_framework::os::macos::keychain_item::SecKeychainItem;
200
+ use security_framework_sys::base::{
201
+ SecAccessRef, SecKeychainAttribute, SecKeychainAttributeList, SecKeychainItemRef,
202
+ };
203
+
204
+ const K_SEC_GENERIC_PASSWORD_ITEM_CLASS: u32 = u32::from_be_bytes(*b"genp");
205
+ const K_SEC_SERVICE_ITEM_ATTR: u32 = u32::from_be_bytes(*b"svce");
206
+ const K_SEC_ACCOUNT_ITEM_ATTR: u32 = u32::from_be_bytes(*b"acct");
207
+
208
+ unsafe extern "C" {
209
+ fn SecAccessCreate(
210
+ descriptor: core_foundation::string::CFStringRef,
211
+ trustedlist: CFArrayRef,
212
+ access_ref: *mut SecAccessRef,
213
+ ) -> core_foundation::base::OSStatus;
214
+
215
+ fn SecKeychainItemCreateFromContent(
216
+ item_class: u32,
217
+ attr_list: *mut SecKeychainAttributeList,
218
+ length: u32,
219
+ data: *const libc::c_void,
220
+ keychain_ref: security_framework_sys::base::SecKeychainRef,
221
+ initial_access: SecAccessRef,
222
+ item_ref: *mut SecKeychainItemRef,
223
+ ) -> core_foundation::base::OSStatus;
224
+
225
+ fn SecTrustedApplicationCreateFromPath(
226
+ path: *const libc::c_char,
227
+ app: *mut *mut libc::c_void,
228
+ ) -> core_foundation::base::OSStatus;
229
+ }
230
+
231
+ let descriptor = CFString::from("wlfi-agent-system-keychain");
232
+ let mut trusted_app_ref: *mut libc::c_void = std::ptr::null_mut();
233
+ let trusted_app_status =
234
+ unsafe { SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut trusted_app_ref) };
235
+ if trusted_app_status != 0 {
236
+ return Err(SecurityError::from_code(trusted_app_status))
237
+ .context("failed to create trusted-application ACL entry for helper");
238
+ }
239
+ let trusted_app = unsafe { CFType::wrap_under_create_rule(trusted_app_ref.cast()) };
240
+ let trusted_apps = CFArray::from_CFTypes(&[trusted_app]);
241
+
242
+ let mut access_ref: SecAccessRef = std::ptr::null_mut();
243
+ let access_status = unsafe {
244
+ SecAccessCreate(
245
+ descriptor.as_concrete_TypeRef(),
246
+ trusted_apps.as_concrete_TypeRef(),
247
+ &mut access_ref,
248
+ )
249
+ };
250
+ if access_status != 0 {
251
+ return Err(SecurityError::from_code(access_status))
252
+ .context("failed to create keychain item access rules");
253
+ }
254
+ let access = unsafe { SecAccess::wrap_under_create_rule(access_ref) };
255
+
256
+ let mut service_bytes = service.as_bytes().to_vec();
257
+ let mut account_bytes = account.as_bytes().to_vec();
258
+ let mut attributes = [
259
+ SecKeychainAttribute {
260
+ tag: K_SEC_SERVICE_ITEM_ATTR,
261
+ length: service_bytes.len() as u32,
262
+ data: service_bytes.as_mut_ptr().cast(),
263
+ },
264
+ SecKeychainAttribute {
265
+ tag: K_SEC_ACCOUNT_ITEM_ATTR,
266
+ length: account_bytes.len() as u32,
267
+ data: account_bytes.as_mut_ptr().cast(),
268
+ },
269
+ ];
270
+ let mut attribute_list = SecKeychainAttributeList {
271
+ count: attributes.len() as u32,
272
+ attr: attributes.as_mut_ptr(),
273
+ };
274
+ let mut item_ref: SecKeychainItemRef = std::ptr::null_mut();
275
+
276
+ let create_status = unsafe {
277
+ SecKeychainItemCreateFromContent(
278
+ K_SEC_GENERIC_PASSWORD_ITEM_CLASS,
279
+ &mut attribute_list,
280
+ password.len() as u32,
281
+ password.as_ptr().cast(),
282
+ keychain.as_CFTypeRef() as security_framework_sys::base::SecKeychainRef,
283
+ access.as_concrete_TypeRef(),
284
+ &mut item_ref,
285
+ )
286
+ };
287
+ let _item =
288
+ (!item_ref.is_null()).then(|| unsafe { SecKeychainItem::wrap_under_create_rule(item_ref) });
289
+ if create_status != 0 {
290
+ return Err(SecurityError::from_code(create_status))
291
+ .context("failed to create restricted generic password item");
292
+ }
293
+
294
+ Ok(())
295
+ }
296
+
183
297
  #[cfg(not(target_os = "macos"))]
184
298
  fn replace_generic_password(
185
299
  _keychain: PathBuf,
@@ -160,6 +160,7 @@ async fn main() -> Result<()> {
160
160
  );
161
161
  eprintln!("==> press Ctrl+C to stop");
162
162
 
163
+ let signer_backend_label = relay_signer_backend_label(cli.signer_backend);
163
164
  match cli.signer_backend {
164
165
  SignerBackendKind::SecureEnclave => {
165
166
  let daemon = Arc::new(
@@ -172,7 +173,7 @@ async fn main() -> Result<()> {
172
173
  .context("failed to initialize daemon")?,
173
174
  );
174
175
  let relay_task =
175
- relay_sync::spawn_relay_sync_task(Arc::clone(&daemon), "secure-enclave");
176
+ relay_sync::spawn_relay_sync_task(Arc::clone(&daemon), signer_backend_label);
176
177
  vault_password.zeroize();
177
178
  server
178
179
  .run_until_shutdown(daemon, async {
@@ -193,7 +194,7 @@ async fn main() -> Result<()> {
193
194
  .context("failed to initialize daemon")?,
194
195
  );
195
196
  let relay_task =
196
- relay_sync::spawn_relay_sync_task(Arc::clone(&daemon), "secure-enclave");
197
+ relay_sync::spawn_relay_sync_task(Arc::clone(&daemon), signer_backend_label);
197
198
  vault_password.zeroize();
198
199
  server
199
200
  .run_until_shutdown(daemon, async {
@@ -208,6 +209,13 @@ async fn main() -> Result<()> {
208
209
  Ok(())
209
210
  }
210
211
 
212
+ fn relay_signer_backend_label(backend: SignerBackendKind) -> &'static str {
213
+ match backend {
214
+ SignerBackendKind::SecureEnclave => "secure-enclave",
215
+ SignerBackendKind::Software => "software",
216
+ }
217
+ }
218
+
211
219
  fn validate_signer_backend_runtime(backend: SignerBackendKind) -> Result<()> {
212
220
  #[cfg(not(target_os = "macos"))]
213
221
  {
@@ -506,8 +514,8 @@ fn lock_path(path: &Path) -> PathBuf {
506
514
  #[cfg(test)]
507
515
  mod tests {
508
516
  use super::{
509
- ensure_file_parent, resolve_allowed_peer_euids_with_sudo_uid, resolve_vault_password,
510
- validate_password, Cli,
517
+ ensure_file_parent, relay_signer_backend_label, resolve_allowed_peer_euids_with_sudo_uid,
518
+ resolve_vault_password, validate_password, Cli, SignerBackendKind,
511
519
  };
512
520
  use clap::Parser;
513
521
  use std::collections::BTreeSet;
@@ -608,6 +616,18 @@ mod tests {
608
616
  assert_eq!(resolved.agent, BTreeSet::from([0, 22, 33]));
609
617
  }
610
618
 
619
+ #[test]
620
+ fn relay_signer_backend_label_matches_runtime_backend() {
621
+ assert_eq!(
622
+ relay_signer_backend_label(SignerBackendKind::SecureEnclave),
623
+ "secure-enclave"
624
+ );
625
+ assert_eq!(
626
+ relay_signer_backend_label(SignerBackendKind::Software),
627
+ "software"
628
+ );
629
+ }
630
+
611
631
  #[cfg(unix)]
612
632
  #[test]
613
633
  fn assert_allowed_directory_owner_rejects_non_root_owner_for_root_runtime() {
@@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
11
11
  use tokio::task::JoinHandle;
12
12
  use tokio::time::{self, MissedTickBehavior};
13
13
  use uuid::Uuid;
14
- use vault_daemon::{InMemoryDaemon, RelayRegistrationSnapshot};
14
+ use vault_daemon::{DaemonError, InMemoryDaemon, RelayRegistrationSnapshot};
15
15
  use vault_domain::{
16
16
  manual_approval_capability_hash, manual_approval_capability_token, AgentAction, AssetId,
17
17
  EntityScope, ManualApprovalDecision, ManualApprovalStatus, PolicyType, SpendingPolicy,
@@ -178,6 +178,72 @@ struct ProcessedFeedback {
178
178
  status: &'static str,
179
179
  }
180
180
 
181
+ fn manual_approval_feedback(
182
+ approval_request_id: Uuid,
183
+ status: ManualApprovalStatus,
184
+ note: Option<&str>,
185
+ message: String,
186
+ ) -> ProcessedFeedback {
187
+ let mut details = BTreeMap::new();
188
+ details.insert("approvalRequestId".to_string(), approval_request_id.to_string());
189
+ details.insert(
190
+ "manualApprovalStatus".to_string(),
191
+ map_approval_status(status).to_string(),
192
+ );
193
+ if let Some(note) = note.filter(|value| !value.trim().is_empty()) {
194
+ details.insert("note".to_string(), note.to_string());
195
+ }
196
+
197
+ ProcessedFeedback {
198
+ details: Some(details),
199
+ message: Some(message),
200
+ status: "applied",
201
+ }
202
+ }
203
+
204
+ fn manual_approval_error_feedback(
205
+ approval_request_id: Uuid,
206
+ decision: ManualApprovalDecision,
207
+ note: Option<&str>,
208
+ error: &DaemonError,
209
+ ) -> ProcessedFeedback {
210
+ if let DaemonError::ManualApprovalRequestNotPending { status, .. } = error {
211
+ let same_decision_already_applied = matches!(
212
+ (decision, status),
213
+ (
214
+ ManualApprovalDecision::Approve,
215
+ ManualApprovalStatus::Approved | ManualApprovalStatus::Completed,
216
+ ) | (ManualApprovalDecision::Reject, ManualApprovalStatus::Rejected)
217
+ );
218
+
219
+ if same_decision_already_applied {
220
+ return manual_approval_feedback(
221
+ approval_request_id,
222
+ *status,
223
+ note,
224
+ format!(
225
+ "manual approval {} was already applied to {}",
226
+ match decision {
227
+ ManualApprovalDecision::Approve => "approve",
228
+ ManualApprovalDecision::Reject => "reject",
229
+ },
230
+ approval_request_id
231
+ ),
232
+ );
233
+ }
234
+ }
235
+
236
+ ProcessedFeedback {
237
+ details: None,
238
+ message: Some(error.to_string()),
239
+ status: if matches!(decision, ManualApprovalDecision::Reject) {
240
+ "rejected"
241
+ } else {
242
+ "failed"
243
+ },
244
+ }
245
+ }
246
+
181
247
  pub fn spawn_relay_sync_task<B>(
182
248
  daemon: Arc<InMemoryDaemon<B>>,
183
249
  signer_backend: &'static str,
@@ -528,38 +594,25 @@ where
528
594
  )
529
595
  .await
530
596
  {
531
- Ok(request) => {
532
- let mut details = BTreeMap::new();
533
- details.insert("approvalRequestId".to_string(), request.id.to_string());
534
- details.insert(
535
- "manualApprovalStatus".to_string(),
536
- map_approval_status(request.status).to_string(),
537
- );
538
- if let Some(note) = payload.note.filter(|value| !value.trim().is_empty()) {
539
- details.insert("note".to_string(), note);
540
- }
541
- ProcessedFeedback {
542
- details: Some(details),
543
- message: Some(format!(
544
- "manual approval {} applied to {}",
545
- match decision {
546
- ManualApprovalDecision::Approve => "approve",
547
- ManualApprovalDecision::Reject => "reject",
548
- },
549
- request.id
550
- )),
551
- status: "applied",
552
- }
553
- }
554
- Err(error) => ProcessedFeedback {
555
- details: None,
556
- message: Some(error.to_string()),
557
- status: if matches!(decision, ManualApprovalDecision::Reject) {
558
- "rejected"
559
- } else {
560
- "failed"
561
- },
562
- },
597
+ Ok(request) => manual_approval_feedback(
598
+ request.id,
599
+ request.status,
600
+ payload.note.as_deref(),
601
+ format!(
602
+ "manual approval {} applied to {}",
603
+ match decision {
604
+ ManualApprovalDecision::Approve => "approve",
605
+ ManualApprovalDecision::Reject => "reject",
606
+ },
607
+ request.id
608
+ ),
609
+ ),
610
+ Err(error) => manual_approval_error_feedback(
611
+ approval_request_id,
612
+ decision,
613
+ payload.note.as_deref(),
614
+ &error,
615
+ ),
563
616
  }
564
617
  }
565
618
 
@@ -775,14 +828,18 @@ fn format_time(value: OffsetDateTime) -> Result<String> {
775
828
 
776
829
  #[cfg(test)]
777
830
  mod tests {
778
- use super::{approval_metadata, resolve_relay_daemon_token_with};
831
+ use super::{approval_metadata, manual_approval_error_feedback, resolve_relay_daemon_token_with};
779
832
  use std::collections::BTreeMap;
780
833
  use std::fs;
781
834
  use std::path::Path;
782
835
  use std::time::{SystemTime, UNIX_EPOCH};
783
836
  use time::OffsetDateTime;
784
837
  use uuid::Uuid;
785
- use vault_domain::{AgentAction, AssetId, ManualApprovalRequest, ManualApprovalStatus};
838
+ use vault_daemon::DaemonError;
839
+ use vault_domain::{
840
+ AgentAction, AssetId, ManualApprovalDecision, ManualApprovalRequest,
841
+ ManualApprovalStatus,
842
+ };
786
843
 
787
844
  #[test]
788
845
  fn approval_metadata_includes_admin_reissue_token_and_public_hash_for_pending_requests() {
@@ -891,4 +948,67 @@ mod tests {
891
948
 
892
949
  assert_eq!(token, None);
893
950
  }
951
+
952
+ #[test]
953
+ fn manual_approval_error_feedback_marks_replayed_approve_updates_as_applied() {
954
+ let approval_request_id =
955
+ Uuid::parse_str("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").expect("uuid");
956
+
957
+ let feedback = manual_approval_error_feedback(
958
+ approval_request_id,
959
+ ManualApprovalDecision::Approve,
960
+ Some("approved earlier"),
961
+ &DaemonError::ManualApprovalRequestNotPending {
962
+ approval_request_id,
963
+ status: ManualApprovalStatus::Completed,
964
+ },
965
+ );
966
+
967
+ assert_eq!(feedback.status, "applied");
968
+ assert_eq!(
969
+ feedback
970
+ .details
971
+ .as_ref()
972
+ .and_then(|details| details.get("manualApprovalStatus")),
973
+ Some(&"completed".to_string())
974
+ );
975
+ assert_eq!(
976
+ feedback
977
+ .details
978
+ .as_ref()
979
+ .and_then(|details| details.get("note")),
980
+ Some(&"approved earlier".to_string())
981
+ );
982
+ assert!(
983
+ feedback
984
+ .message
985
+ .as_deref()
986
+ .is_some_and(|message| message.contains("already applied"))
987
+ );
988
+ }
989
+
990
+ #[test]
991
+ fn manual_approval_error_feedback_keeps_conflicting_approve_updates_failed() {
992
+ let approval_request_id =
993
+ Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").expect("uuid");
994
+
995
+ let feedback = manual_approval_error_feedback(
996
+ approval_request_id,
997
+ ManualApprovalDecision::Approve,
998
+ None,
999
+ &DaemonError::ManualApprovalRequestNotPending {
1000
+ approval_request_id,
1001
+ status: ManualApprovalStatus::Rejected,
1002
+ },
1003
+ );
1004
+
1005
+ assert_eq!(feedback.status, "failed");
1006
+ assert!(feedback.details.is_none());
1007
+ assert!(
1008
+ feedback
1009
+ .message
1010
+ .as_deref()
1011
+ .is_some_and(|message| message.contains("already Rejected"))
1012
+ );
1013
+ }
894
1014
  }
@@ -6,6 +6,12 @@ mod macos {
6
6
  use std::process::{Command, Output, Stdio};
7
7
  use std::time::{SystemTime, UNIX_EPOCH};
8
8
 
9
+ use security_framework::os::macos::keychain::SecKeychain;
10
+ use security_framework_sys::base::errSecAuthFailed;
11
+
12
+ const ERR_SEC_AUTH_FAILED: i32 = errSecAuthFailed;
13
+ const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25308;
14
+
9
15
  fn unique_temp_dir() -> PathBuf {
10
16
  let unique = SystemTime::now()
11
17
  .duration_since(UNIX_EPOCH)
@@ -57,7 +63,7 @@ mod macos {
57
63
  }
58
64
 
59
65
  #[test]
60
- fn helper_owned_items_are_not_readable_via_security_cli() {
66
+ fn helper_owned_items_are_not_readable_without_interaction() {
61
67
  let helper = PathBuf::from(env!("CARGO_BIN_EXE_wlfi-agent-system-keychain"));
62
68
  let temp_dir = unique_temp_dir();
63
69
  let keychain_path = temp_dir.join("acl-test.keychain-db");
@@ -117,23 +123,22 @@ mod macos {
117
123
  String::from_utf8_lossy(&replace.stderr)
118
124
  );
119
125
 
120
- let raw_security = run(
121
- "security",
122
- &[
123
- "find-generic-password",
124
- "-w",
125
- "-s",
126
- service,
127
- "-a",
128
- account,
129
- keychain_path_str,
130
- ],
131
- );
132
- assert!(
133
- !raw_security.status.success(),
134
- "security CLI unexpectedly read helper-owned password: {}",
135
- String::from_utf8_lossy(&raw_security.stdout)
136
- );
126
+ let _interaction_guard =
127
+ SecKeychain::disable_user_interaction().expect("disable keychain UI for test process");
128
+ let keychain = SecKeychain::open(&keychain_path).expect("open test keychain");
129
+ let non_helper_read = keychain.find_generic_password(service, account);
130
+ match non_helper_read {
131
+ Ok((password, _)) => panic!(
132
+ "untrusted process unexpectedly read helper-owned password: {}",
133
+ String::from_utf8_lossy(password.as_ref())
134
+ ),
135
+ Err(error)
136
+ if matches!(
137
+ error.code(),
138
+ ERR_SEC_INTERACTION_NOT_ALLOWED | ERR_SEC_AUTH_FAILED
139
+ ) => {}
140
+ Err(error) => panic!("unexpected keychain error for untrusted read: {error:?}"),
141
+ }
137
142
 
138
143
  let helper_read = run(
139
144
  helper.to_str().expect("helper path utf-8"),
@@ -292,6 +292,12 @@ where
292
292
  let request = requests.get_mut(&approval_request_id).ok_or(
293
293
  DaemonError::UnknownManualApprovalRequest(approval_request_id),
294
294
  )?;
295
+ if request.status != ManualApprovalStatus::Pending {
296
+ return Err(DaemonError::ManualApprovalRequestNotPending {
297
+ approval_request_id,
298
+ status: request.status,
299
+ });
300
+ }
295
301
  request.updated_at = now;
296
302
  match decision {
297
303
  ManualApprovalDecision::Approve => {
@@ -81,6 +81,12 @@ pub enum DaemonError {
81
81
  /// Existing manual approval request was rejected.
82
82
  #[error("manual approval request {approval_request_id} was rejected")]
83
83
  ManualApprovalRejected { approval_request_id: Uuid },
84
+ /// Manual approval request was already resolved and cannot be decided again.
85
+ #[error("manual approval request {approval_request_id} is already {status:?}")]
86
+ ManualApprovalRequestNotPending {
87
+ approval_request_id: Uuid,
88
+ status: ManualApprovalStatus,
89
+ },
84
90
  /// Policy engine denied request.
85
91
  #[error("policy check failed: {0}")]
86
92
  Policy(#[from] PolicyError),
@@ -8,8 +8,8 @@ use serde_json::to_vec;
8
8
  use uuid::Uuid;
9
9
  use vault_domain::{
10
10
  AgentAction, AgentCredentials, AssetId, BroadcastTx, EntityScope, EvmAddress, KeySource, Lease,
11
- NonceReleaseRequest, NonceReservationRequest, PolicyAttachment, PolicyType, SignRequest,
12
- SpendingPolicy,
11
+ ManualApprovalDecision, ManualApprovalStatus, NonceReleaseRequest, NonceReservationRequest,
12
+ PolicyAttachment, PolicyType, SignRequest, SpendingPolicy,
13
13
  };
14
14
  use vault_signer::SoftwareSignerBackend;
15
15
 
@@ -602,3 +602,113 @@ async fn persistent_store_rejects_group_readable_state_file() {
602
602
  .expect("restore state file permissions for cleanup");
603
603
  std::fs::remove_file(&state_path).expect("cleanup state file");
604
604
  }
605
+
606
+ #[tokio::test]
607
+ async fn manual_approval_requests_cannot_be_decided_after_resolution() {
608
+ let daemon = InMemoryDaemon::new(
609
+ "vault-password",
610
+ SoftwareSignerBackend::default(),
611
+ DaemonConfig::default(),
612
+ )
613
+ .expect("daemon");
614
+
615
+ let lease = daemon.issue_lease("vault-password").await.expect("lease");
616
+ let session = AdminSession {
617
+ vault_password: "vault-password".to_string(),
618
+ lease,
619
+ };
620
+
621
+ daemon
622
+ .add_policy(
623
+ &session,
624
+ SpendingPolicy::new_manual_approval(
625
+ 1,
626
+ 1,
627
+ 1_000_000_000_000_000_000,
628
+ EntityScope::All,
629
+ EntityScope::All,
630
+ EntityScope::All,
631
+ )
632
+ .expect("manual approval policy"),
633
+ )
634
+ .await
635
+ .expect("add policy");
636
+
637
+ let key = daemon
638
+ .create_vault_key(&session, KeyCreateRequest::Generate)
639
+ .await
640
+ .expect("key");
641
+ let agent_credentials = daemon
642
+ .create_agent_key(&session, key.id, PolicyAttachment::AllPolicies)
643
+ .await
644
+ .expect("agent");
645
+
646
+ let request = sign_request(
647
+ &agent_credentials,
648
+ AgentAction::Transfer {
649
+ chain_id: 1,
650
+ token: "0x1000000000000000000000000000000000000000"
651
+ .parse()
652
+ .expect("token"),
653
+ to: "0x2000000000000000000000000000000000000000"
654
+ .parse()
655
+ .expect("recipient"),
656
+ amount_wei: 42,
657
+ },
658
+ );
659
+ let approval_request_id = match daemon.sign_for_agent(request.clone()).await {
660
+ Err(DaemonError::ManualApprovalRequired {
661
+ approval_request_id, ..
662
+ }) => approval_request_id,
663
+ other => panic!("expected manual approval request, got {other:?}"),
664
+ };
665
+
666
+ daemon
667
+ .decide_manual_approval_request(
668
+ &session,
669
+ approval_request_id,
670
+ ManualApprovalDecision::Approve,
671
+ None,
672
+ )
673
+ .await
674
+ .expect("approve request");
675
+
676
+ let err = daemon
677
+ .decide_manual_approval_request(
678
+ &session,
679
+ approval_request_id,
680
+ ManualApprovalDecision::Reject,
681
+ Some("late rejection".to_string()),
682
+ )
683
+ .await
684
+ .expect_err("resolved request must reject a second decision");
685
+ assert!(matches!(
686
+ err,
687
+ DaemonError::ManualApprovalRequestNotPending {
688
+ approval_request_id: id,
689
+ status: ManualApprovalStatus::Approved,
690
+ } if id == approval_request_id
691
+ ));
692
+
693
+ daemon
694
+ .sign_for_agent(request)
695
+ .await
696
+ .expect("approved request should sign");
697
+
698
+ let err = daemon
699
+ .apply_relay_manual_approval_decision(
700
+ "vault-password",
701
+ approval_request_id,
702
+ ManualApprovalDecision::Reject,
703
+ Some("too late".to_string()),
704
+ )
705
+ .await
706
+ .expect_err("completed request must reject relay retries");
707
+ assert!(matches!(
708
+ err,
709
+ DaemonError::ManualApprovalRequestNotPending {
710
+ approval_request_id: id,
711
+ status: ManualApprovalStatus::Completed,
712
+ } if id == approval_request_id
713
+ ));
714
+ }