@wlfi-agent/cli 1.4.16 → 1.4.18

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 (97) hide show
  1. package/Cargo.lock +26 -20
  2. package/Cargo.toml +1 -1
  3. package/README.md +61 -28
  4. package/crates/vault-cli-admin/src/io_utils.rs +149 -1
  5. package/crates/vault-cli-admin/src/main.rs +639 -16
  6. package/crates/vault-cli-admin/src/shared_config.rs +18 -18
  7. package/crates/vault-cli-admin/src/tui/token_rpc.rs +190 -3
  8. package/crates/vault-cli-admin/src/tui/utils.rs +59 -0
  9. package/crates/vault-cli-admin/src/tui.rs +1205 -120
  10. package/crates/vault-cli-agent/Cargo.toml +1 -0
  11. package/crates/vault-cli-agent/src/io_utils.rs +163 -2
  12. package/crates/vault-cli-agent/src/main.rs +648 -32
  13. package/crates/vault-cli-daemon/Cargo.toml +4 -0
  14. package/crates/vault-cli-daemon/src/main.rs +617 -67
  15. package/crates/vault-cli-daemon/src/relay_sync.rs +776 -4
  16. package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +5 -0
  17. package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +32 -1
  18. package/crates/vault-daemon/src/persistence.rs +637 -100
  19. package/crates/vault-daemon/src/tests.rs +1013 -3
  20. package/crates/vault-daemon/src/tests_parts/part2.rs +99 -0
  21. package/crates/vault-daemon/src/tests_parts/part4.rs +11 -7
  22. package/crates/vault-domain/src/nonce.rs +4 -0
  23. package/crates/vault-domain/src/tests.rs +616 -0
  24. package/crates/vault-policy/src/engine.rs +55 -32
  25. package/crates/vault-policy/src/tests.rs +195 -0
  26. package/crates/vault-sdk-agent/src/lib.rs +415 -22
  27. package/crates/vault-signer/Cargo.toml +3 -0
  28. package/crates/vault-signer/src/lib.rs +266 -40
  29. package/crates/vault-transport-unix/src/lib.rs +653 -5
  30. package/crates/vault-transport-xpc/src/tests.rs +531 -3
  31. package/crates/vault-transport-xpc/tests/e2e_flow.rs +3 -0
  32. package/dist/cli.cjs +663 -190
  33. package/dist/cli.cjs.map +1 -1
  34. package/package.json +5 -2
  35. package/packages/cache/.turbo/turbo-build.log +53 -52
  36. package/packages/cache/coverage/clover.xml +529 -394
  37. package/packages/cache/coverage/coverage-final.json +2 -2
  38. package/packages/cache/coverage/index.html +21 -21
  39. package/packages/cache/coverage/src/client/index.html +1 -1
  40. package/packages/cache/coverage/src/client/index.ts.html +1 -1
  41. package/packages/cache/coverage/src/errors/index.html +1 -1
  42. package/packages/cache/coverage/src/errors/index.ts.html +12 -12
  43. package/packages/cache/coverage/src/index.html +1 -1
  44. package/packages/cache/coverage/src/index.ts.html +1 -1
  45. package/packages/cache/coverage/src/service/index.html +21 -21
  46. package/packages/cache/coverage/src/service/index.ts.html +769 -313
  47. package/packages/cache/dist/{chunk-QNK6GOTI.js → chunk-KC53LH5Z.js} +35 -2
  48. package/packages/cache/dist/chunk-KC53LH5Z.js.map +1 -0
  49. package/packages/cache/dist/{chunk-QF4XKEIA.cjs → chunk-UVU7VFE3.cjs} +35 -2
  50. package/packages/cache/dist/chunk-UVU7VFE3.cjs.map +1 -0
  51. package/packages/cache/dist/index.cjs +2 -2
  52. package/packages/cache/dist/index.js +1 -1
  53. package/packages/cache/dist/service/index.cjs +2 -2
  54. package/packages/cache/dist/service/index.js +1 -1
  55. package/packages/cache/node_modules/.bin/tsc +2 -2
  56. package/packages/cache/node_modules/.bin/tsserver +2 -2
  57. package/packages/cache/node_modules/.bin/tsup +2 -2
  58. package/packages/cache/node_modules/.bin/tsup-node +2 -2
  59. package/packages/cache/node_modules/.bin/vitest +4 -4
  60. package/packages/cache/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  61. package/packages/cache/src/service/index.test.ts +165 -19
  62. package/packages/cache/src/service/index.ts +38 -1
  63. package/packages/config/.turbo/turbo-build.log +18 -17
  64. package/packages/config/dist/index.cjs +0 -17
  65. package/packages/config/dist/index.cjs.map +1 -1
  66. package/packages/config/src/index.ts +0 -17
  67. package/packages/rpc/.turbo/turbo-build.log +32 -31
  68. package/packages/rpc/dist/index.cjs +0 -17
  69. package/packages/rpc/dist/index.cjs.map +1 -1
  70. package/packages/rpc/src/index.js +1 -0
  71. package/packages/ui/.turbo/turbo-build.log +44 -43
  72. package/packages/ui/dist/components/badge.d.ts +1 -1
  73. package/packages/ui/dist/components/button.d.ts +1 -1
  74. package/packages/ui/node_modules/.bin/tsc +2 -2
  75. package/packages/ui/node_modules/.bin/tsserver +2 -2
  76. package/packages/ui/node_modules/.bin/tsup +2 -2
  77. package/packages/ui/node_modules/.bin/tsup-node +2 -2
  78. package/scripts/install-cli-launcher.mjs +37 -0
  79. package/scripts/install-rust-binaries.mjs +112 -0
  80. package/scripts/run-tests-isolated.mjs +210 -0
  81. package/src/cli.ts +310 -50
  82. package/src/lib/admin-reset.ts +15 -30
  83. package/src/lib/admin-setup.ts +246 -55
  84. package/src/lib/agent-auth-migrate.ts +5 -1
  85. package/src/lib/asset-broadcast.ts +15 -4
  86. package/src/lib/config-amounts.ts +6 -4
  87. package/src/lib/hidden-tty-prompt.js +1 -0
  88. package/src/lib/hidden-tty-prompt.ts +105 -0
  89. package/src/lib/keychain.ts +1 -0
  90. package/src/lib/local-admin-access.ts +4 -29
  91. package/src/lib/rust.ts +129 -33
  92. package/src/lib/signed-tx.ts +1 -0
  93. package/src/lib/sudo.ts +15 -5
  94. package/src/lib/wallet-profile.ts +3 -0
  95. package/src/lib/wallet-setup.ts +52 -0
  96. package/packages/cache/dist/chunk-QF4XKEIA.cjs.map +0 -1
  97. package/packages/cache/dist/chunk-QNK6GOTI.js.map +0 -1
package/Cargo.lock CHANGED
@@ -823,9 +823,9 @@ dependencies = [
823
823
 
824
824
  [[package]]
825
825
  name = "darling"
826
- version = "0.23.0"
826
+ version = "0.20.11"
827
827
  source = "registry+https://github.com/rust-lang/crates.io-index"
828
- checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
828
+ checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
829
829
  dependencies = [
830
830
  "darling_core",
831
831
  "darling_macro",
@@ -833,10 +833,11 @@ dependencies = [
833
833
 
834
834
  [[package]]
835
835
  name = "darling_core"
836
- version = "0.23.0"
836
+ version = "0.20.11"
837
837
  source = "registry+https://github.com/rust-lang/crates.io-index"
838
- checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
838
+ checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
839
839
  dependencies = [
840
+ "fnv",
840
841
  "ident_case",
841
842
  "proc-macro2",
842
843
  "quote",
@@ -846,9 +847,9 @@ dependencies = [
846
847
 
847
848
  [[package]]
848
849
  name = "darling_macro"
849
- version = "0.23.0"
850
+ version = "0.20.11"
850
851
  source = "registry+https://github.com/rust-lang/crates.io-index"
851
- checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
852
+ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
852
853
  dependencies = [
853
854
  "darling_core",
854
855
  "quote",
@@ -867,12 +868,12 @@ dependencies = [
867
868
 
868
869
  [[package]]
869
870
  name = "deranged"
870
- version = "0.5.8"
871
+ version = "0.3.11"
871
872
  source = "registry+https://github.com/rust-lang/crates.io-index"
872
- checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
873
+ checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
873
874
  dependencies = [
874
875
  "powerfmt",
875
- "serde_core",
876
+ "serde",
876
877
  ]
877
878
 
878
879
  [[package]]
@@ -1523,9 +1524,9 @@ dependencies = [
1523
1524
 
1524
1525
  [[package]]
1525
1526
  name = "instability"
1526
- version = "0.3.11"
1527
+ version = "0.3.10"
1527
1528
  source = "registry+https://github.com/rust-lang/crates.io-index"
1528
- checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d"
1529
+ checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c"
1529
1530
  dependencies = [
1530
1531
  "darling",
1531
1532
  "indoc",
@@ -1752,9 +1753,9 @@ dependencies = [
1752
1753
 
1753
1754
  [[package]]
1754
1755
  name = "num-conv"
1755
- version = "0.2.0"
1756
+ version = "0.1.0"
1756
1757
  source = "registry+https://github.com/rust-lang/crates.io-index"
1757
- checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
1758
+ checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
1758
1759
 
1759
1760
  [[package]]
1760
1761
  name = "num-integer"
@@ -2822,30 +2823,30 @@ dependencies = [
2822
2823
 
2823
2824
  [[package]]
2824
2825
  name = "time"
2825
- version = "0.3.47"
2826
+ version = "0.3.36"
2826
2827
  source = "registry+https://github.com/rust-lang/crates.io-index"
2827
- checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
2828
+ checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
2828
2829
  dependencies = [
2829
2830
  "deranged",
2830
2831
  "itoa",
2831
2832
  "num-conv",
2832
2833
  "powerfmt",
2833
- "serde_core",
2834
+ "serde",
2834
2835
  "time-core",
2835
2836
  "time-macros",
2836
2837
  ]
2837
2838
 
2838
2839
  [[package]]
2839
2840
  name = "time-core"
2840
- version = "0.1.8"
2841
+ version = "0.1.2"
2841
2842
  source = "registry+https://github.com/rust-lang/crates.io-index"
2842
- checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
2843
+ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
2843
2844
 
2844
2845
  [[package]]
2845
2846
  name = "time-macros"
2846
- version = "0.2.27"
2847
+ version = "0.2.18"
2847
2848
  source = "registry+https://github.com/rust-lang/crates.io-index"
2848
- checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
2849
+ checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
2849
2850
  dependencies = [
2850
2851
  "num-conv",
2851
2852
  "time-core",
@@ -3776,6 +3777,7 @@ name = "wlfi-agent-agent"
3776
3777
  version = "0.1.0"
3777
3778
  dependencies = [
3778
3779
  "anyhow",
3780
+ "async-trait",
3779
3781
  "clap",
3780
3782
  "hex",
3781
3783
  "libc",
@@ -3798,8 +3800,11 @@ name = "wlfi-agent-daemon"
3798
3800
  version = "0.1.0"
3799
3801
  dependencies = [
3800
3802
  "anyhow",
3803
+ "async-trait",
3804
+ "chacha20poly1305",
3801
3805
  "clap",
3802
3806
  "core-foundation",
3807
+ "hex",
3803
3808
  "libc",
3804
3809
  "nix",
3805
3810
  "reqwest",
@@ -3815,6 +3820,7 @@ dependencies = [
3815
3820
  "vault-domain",
3816
3821
  "vault-signer",
3817
3822
  "vault-transport-unix",
3823
+ "x25519-dalek",
3818
3824
  "zeroize",
3819
3825
  ]
3820
3826
 
package/Cargo.toml CHANGED
@@ -43,7 +43,7 @@ serde = { version = "1", features = ["derive"] }
43
43
  serde_json = "1"
44
44
  sha2 = "0.10"
45
45
  thiserror = "2"
46
- time = { version = "0.3", features = ["macros", "serde", "formatting", "parsing"] }
46
+ time = { version = "=0.3.36", features = ["macros", "serde", "formatting", "parsing"] }
47
47
  tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "net", "io-util", "time", "signal"] }
48
48
  uuid = { version = "1", features = ["serde", "v4"] }
49
49
  zeroize = "1"
package/README.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  WLFI Agentic SDK is a root-managed local signing daemon with policy enforcement, a single `wlfi-agent` CLI, and an optional relay + web approval flow.
4
4
 
5
+ ## Install
6
+
7
+ ### Prerequisites
8
+
9
+ - macOS
10
+ - Rust toolchain on `PATH` (`cargo`, `rustc`) with Rust `1.87.0` or newer
11
+ - Xcode Command Line Tools (`xcode-select --install`)
12
+
13
+ ### Install from npm
14
+
15
+ ```bash
16
+ npm i -g @wlfi-agent/cli
17
+ ```
18
+
19
+ `npm i -g @wlfi-agent/cli` builds the local Rust runtime during `postinstall`. If the prerequisites above are already installed, this is the normal one-step install path. If `cargo` or the macOS Command Line Tools are missing, installation fails immediately and tells you how to install the missing prerequisite before retrying.
20
+
21
+ ### Work from this repo
22
+
23
+ ```bash
24
+ pnpm install
25
+ npm run build
26
+ npm run install:cli-launcher
27
+ npm run install:rust-binaries
28
+ ```
29
+
30
+ On macOS, add `export PATH="$HOME/.wlfi_agent/bin:$PATH"` to `~/.zshrc`, then reload your shell with `source ~/.zshrc`.
31
+
32
+ On Linux, add `export PATH="$HOME/.wlfi_agent/bin:$PATH"` to your shell startup file such as `~/.bashrc`, `~/.zshrc`, or `~/.profile`, then reload that file or open a new shell.
33
+
34
+ `npm run install:cli-launcher` installs the `wlfi-agent` launcher into `~/.wlfi_agent/bin`, and `npm run install:rust-binaries` installs the Rust runtime into the same directory.
35
+
36
+ ### Reinstall Rust daemon
37
+
38
+ If you update Rust daemon code, rerun `npm run install:rust-binaries` so the root-managed daemon uses the new installed binaries under `~/.wlfi_agent/bin`.
39
+
40
+ ## Usage
41
+
5
42
  The main user path is:
6
43
 
7
44
  1. run `wlfi-agent admin setup`
@@ -15,6 +52,7 @@ User-facing examples below avoid shell env vars on purpose. Prefer prompts, conf
15
52
 
16
53
  - `wlfi-agent admin setup`
17
54
  - first-run setup
55
+ - `--reuse-existing-wallet` reattaches the current local vault when you need to recover the daemon or refresh local credentials without creating a fresh wallet
18
56
  - stores the vault password in macOS System Keychain
19
57
  - installs the root LaunchDaemon
20
58
  - creates a vault key + agent key
@@ -41,49 +79,36 @@ User-facing examples below avoid shell env vars on purpose. Prefer prompts, conf
41
79
  - `wlfi-agent daemon`
42
80
  - not a user entrypoint; daemon lifecycle is managed by `wlfi-agent admin setup`
43
81
 
44
- ## Install
45
-
46
- ### Prerequisites
47
-
48
- - macOS
49
- - Rust toolchain on `PATH` (`cargo`, `rustc`)
50
- - Xcode Command Line Tools (`xcode-select --install`)
82
+ ## Easiest wallet setup
51
83
 
52
- ### Install from npm
84
+ Run this once:
53
85
 
54
86
  ```bash
55
- npm i -g @wlfi-agent/cli
87
+ wlfi-agent admin setup
56
88
  ```
57
89
 
58
- `npm i -g @wlfi-agent/cli` builds the local Rust runtime during `postinstall`. If the prerequisites above are already installed, this is the normal one-step install path. If `cargo` or the macOS Command Line Tools are missing, installation fails immediately and tells you how to install the missing prerequisite before retrying.
59
-
60
- ### Work from this repo
90
+ Preview the exact sanitized setup plan first:
61
91
 
62
92
  ```bash
63
- pnpm install
64
- npm run build
65
- npm run install:rust-binaries
93
+ wlfi-agent admin setup --plan
66
94
  ```
67
95
 
68
- If you update Rust daemon code, rerun `npm run install:rust-binaries` so the root-managed daemon uses the new installed binaries under `~/.wlfi_agent/bin`.
96
+ The preview is read-only. It does not prompt for the vault password, does not touch sudo, and does not mutate wallet or policy state. It prints the planned Rust command, trust preflight results, overwrite risk, and the password transport mode that would be used for the real setup.
69
97
 
70
- ## Easiest wallet setup
98
+ During a real `wlfi-agent admin setup`, you may be prompted for two different secrets:
71
99
 
72
- Run this once:
100
+ - `Vault password`: the wallet password you choose for encrypted local state
101
+ - `macOS admin password for sudo`: your macOS login/admin password, used only when setup needs elevated privileges to install or recover the root LaunchDaemon
73
102
 
74
- ```bash
75
- wlfi-agent admin setup
76
- ```
77
-
78
- Preview the exact sanitized setup plan first:
103
+ If the local vault already exists and you only need to recover the managed daemon or refresh local setup state, reuse the current wallet instead of creating a fresh one:
79
104
 
80
105
  ```bash
81
- wlfi-agent admin setup --plan
106
+ wlfi-agent admin setup --reuse-existing-wallet
82
107
  ```
83
108
 
84
- The preview is read-only. It does not prompt for the vault password, does not touch sudo, and does not mutate wallet or policy state. It prints the planned Rust command, trust preflight results, overwrite risk, and the password transport mode that would be used for the real setup.
109
+ This reuse path keeps the current vault address, prompts for `REUSE` in interactive mode, and still requires `--yes` in non-interactive mode.
85
110
 
86
- You will be prompted for the vault password. The command:
111
+ After that, the command:
87
112
 
88
113
  - installs or refreshes the root daemon
89
114
  - waits for the daemon to come up
@@ -372,10 +397,18 @@ When relay is configured and a request requires manual approval:
372
397
  3. the frontend encrypts the vault password + decision to the daemon’s advertised X25519 public key
373
398
  4. the relay queues the encrypted update
374
399
  5. the daemon polls, decrypts, applies the decision, and reports status back
375
- 6. the original request can be retried and signed
400
+ 6. for `wlfi-agent transfer --broadcast`, `wlfi-agent transfer-native --broadcast`, and `wlfi-agent approve --broadcast`, the original CLI command keeps waiting on that same approval request and continues automatically after approval
401
+ 7. commands outside those broadcast flows still print the approval details and exit, so operators can approve or reject the request separately
376
402
 
377
403
  If the frontend link is unavailable, operators can always fall back to the local admin CLI approval command printed by the agent CLI.
378
404
 
405
+ For the auto-waiting broadcast flows above:
406
+
407
+ - do not rerun the original command after approving in the browser
408
+ - the CLI polls every 2 seconds for up to 5 minutes
409
+ - if the daemon returns a different approval request id while waiting, the CLI stops and tells you to inspect the approval status before rerunning
410
+ - approval details and waiting events go to `stderr`; the final successful `--json` result still goes to `stdout`
411
+
379
412
  ## Operational notes
380
413
 
381
414
  - The daemon state file lives at `/var/db/wlfi-agent/daemon-state.enc` and is intended to be root-only.
@@ -438,7 +471,7 @@ wlfi-agent admin tui
438
471
  wlfi-agent admin uninstall
439
472
  wlfi-agent admin get-relay-config
440
473
  wlfi-agent admin list-manual-approval-requests
441
- wlfi-agent wallet status
474
+ wlfi-agent wallet
442
475
  npm run install:rust-binaries
443
476
  pnpm build
444
477
  pnpm typecheck
@@ -62,9 +62,11 @@ fn read_secret_from_reader(mut reader: impl Read, label: &str) -> Result<String>
62
62
  #[cfg(test)]
63
63
  mod tests {
64
64
  use super::{
65
- ensure_output_parent, read_secret_from_reader, resolve_vault_password, validate_password,
65
+ emit_output, ensure_output_parent, print_status, read_secret_from_reader,
66
+ resolve_output_target, resolve_vault_password, should_print_status, validate_password,
66
67
  write_output_file,
67
68
  };
69
+ use crate::{OutputFormat, OutputTarget};
68
70
  use std::fs;
69
71
  use std::io::Cursor;
70
72
  use std::path::PathBuf;
@@ -95,12 +97,57 @@ mod tests {
95
97
  assert!(err.to_string().contains("must not exceed"));
96
98
  }
97
99
 
100
+ #[test]
101
+ fn read_secret_from_reader_trims_newlines_and_accepts_valid_secret() {
102
+ let secret =
103
+ read_secret_from_reader(Cursor::new(b"vault-secret\r\n".to_vec()), "vault password")
104
+ .expect("valid stdin secret");
105
+ assert_eq!(secret, "vault-secret");
106
+ }
107
+
108
+ #[test]
109
+ fn validate_password_accepts_non_empty_secret() {
110
+ let password = validate_password("vault-secret".to_string(), "prompt")
111
+ .expect("valid password");
112
+ assert_eq!(password, "vault-secret");
113
+ }
114
+
98
115
  #[test]
99
116
  fn resolve_vault_password_requires_stdin_in_non_interactive_mode() {
100
117
  let err = resolve_vault_password(false, true).expect_err("must fail");
101
118
  assert!(err.to_string().contains("use --vault-password-stdin"));
102
119
  }
103
120
 
121
+ #[test]
122
+ fn resolve_output_target_covers_stdout_and_file_paths() {
123
+ let stdout_target = resolve_output_target(None, false).expect("default stdout");
124
+ assert!(matches!(stdout_target, OutputTarget::Stdout));
125
+
126
+ let file_target = resolve_output_target(Some(PathBuf::from("out.json")), false)
127
+ .expect("file output target");
128
+ assert!(matches!(
129
+ file_target,
130
+ OutputTarget::File {
131
+ path,
132
+ overwrite: false
133
+ } if path == PathBuf::from("out.json")
134
+ ));
135
+
136
+ let err = resolve_output_target(Some(PathBuf::from("-")), true).expect_err("must fail");
137
+ assert!(err.to_string().contains("--overwrite cannot be used with --output -"));
138
+ }
139
+
140
+ #[test]
141
+ fn emit_output_to_stdout_and_print_status_cover_text_paths() {
142
+ emit_output("stdout output", &OutputTarget::Stdout).expect("stdout output");
143
+ assert!(should_print_status(OutputFormat::Text, false));
144
+ assert!(!should_print_status(OutputFormat::Json, false));
145
+ assert!(!should_print_status(OutputFormat::Text, true));
146
+ print_status("status output", OutputFormat::Text, false);
147
+ print_status("quiet output", OutputFormat::Text, true);
148
+ print_status("json output", OutputFormat::Json, false);
149
+ }
150
+
104
151
  #[test]
105
152
  #[cfg(unix)]
106
153
  fn ensure_output_parent_rejects_symlinked_parent_directory() {
@@ -188,6 +235,46 @@ mod tests {
188
235
  fs::remove_dir_all(&root).expect("cleanup temp tree");
189
236
  }
190
237
 
238
+ #[test]
239
+ #[cfg(unix)]
240
+ fn ensure_output_parent_rejects_directory_path() {
241
+ use std::os::unix::fs::PermissionsExt;
242
+
243
+ let root = temp_path("vault-cli-admin-output-dir-path");
244
+ fs::create_dir_all(&root).expect("create root");
245
+ fs::set_permissions(&root, fs::Permissions::from_mode(0o700))
246
+ .expect("secure root permissions");
247
+
248
+ let err = ensure_output_parent(&root).expect_err("directory path must fail");
249
+ assert!(err
250
+ .to_string()
251
+ .contains("is a directory; provide a file path"));
252
+
253
+ fs::remove_dir_all(&root).expect("cleanup temp tree");
254
+ }
255
+
256
+ #[test]
257
+ #[cfg(unix)]
258
+ fn ensure_output_parent_allows_sticky_world_writable_ancestor() {
259
+ use std::os::unix::fs::PermissionsExt;
260
+
261
+ let root = temp_path("vault-cli-admin-output-sticky-ancestor");
262
+ let shared = root.join("shared");
263
+ let nested = shared.join("nested");
264
+ fs::create_dir_all(&nested).expect("create nested directory");
265
+ fs::set_permissions(&shared, fs::Permissions::from_mode(0o1777))
266
+ .expect("set sticky shared permissions");
267
+ fs::set_permissions(&nested, fs::Permissions::from_mode(0o700))
268
+ .expect("set secure nested permissions");
269
+
270
+ ensure_output_parent(&nested.join("output.json"))
271
+ .expect("sticky world-writable ancestor should be allowed");
272
+
273
+ fs::set_permissions(&shared, fs::Permissions::from_mode(0o700))
274
+ .expect("restore cleanup permissions");
275
+ fs::remove_dir_all(&root).expect("cleanup temp tree");
276
+ }
277
+
191
278
  #[test]
192
279
  #[cfg(unix)]
193
280
  fn write_output_file_overwrite_replaces_existing_hard_link_instead_of_mutating_shared_inode() {
@@ -218,6 +305,67 @@ mod tests {
218
305
 
219
306
  fs::remove_dir_all(&root).expect("cleanup temp tree");
220
307
  }
308
+
309
+ #[test]
310
+ #[cfg(unix)]
311
+ fn write_output_file_creates_new_file_and_rejects_existing_without_overwrite() {
312
+ use std::os::unix::fs::PermissionsExt;
313
+
314
+ let root = temp_path("vault-cli-admin-output-create-file");
315
+ fs::create_dir_all(&root).expect("create root directory");
316
+ fs::set_permissions(&root, fs::Permissions::from_mode(0o700))
317
+ .expect("secure root directory permissions");
318
+
319
+ let output_path = root.join("output.json");
320
+ write_output_file(&output_path, "created", false).expect("write new output file");
321
+ assert_eq!(
322
+ fs::read_to_string(&output_path).expect("read created output"),
323
+ "created\n"
324
+ );
325
+ assert_eq!(
326
+ fs::metadata(&output_path)
327
+ .expect("metadata")
328
+ .permissions()
329
+ .mode()
330
+ & 0o777,
331
+ 0o600
332
+ );
333
+
334
+ let err = write_output_file(&output_path, "second", false)
335
+ .expect_err("existing file without overwrite must fail");
336
+ assert!(err
337
+ .to_string()
338
+ .contains("already exists; pass --overwrite to replace it"));
339
+
340
+ fs::remove_dir_all(&root).expect("cleanup temp tree");
341
+ }
342
+
343
+ #[test]
344
+ #[cfg(unix)]
345
+ fn emit_output_to_file_target_writes_expected_content() {
346
+ use std::os::unix::fs::PermissionsExt;
347
+
348
+ let root = temp_path("vault-cli-admin-emit-output-file");
349
+ fs::create_dir_all(&root).expect("create root directory");
350
+ fs::set_permissions(&root, fs::Permissions::from_mode(0o700))
351
+ .expect("secure root directory permissions");
352
+
353
+ let output_path = root.join("output.json");
354
+ emit_output(
355
+ "file output",
356
+ &OutputTarget::File {
357
+ path: output_path.clone(),
358
+ overwrite: false,
359
+ },
360
+ )
361
+ .expect("emit output to file");
362
+ assert_eq!(
363
+ fs::read_to_string(&output_path).expect("read emitted file"),
364
+ "file output\n"
365
+ );
366
+
367
+ fs::remove_dir_all(&root).expect("cleanup temp tree");
368
+ }
221
369
  }
222
370
 
223
371
  pub(crate) fn resolve_output_format(