@phnx-labs/agents-cli 1.22.84 → 1.22.85

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 (257) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +80 -53
  3. package/dist/bootstrap.d.ts +7 -6
  4. package/dist/bootstrap.js +20 -27
  5. package/dist/cli/command-registry.js +5 -1
  6. package/dist/commands/accounts.d.ts +6 -13
  7. package/dist/commands/accounts.js +330 -143
  8. package/dist/commands/apply.js +1 -1
  9. package/dist/commands/auth-mint.d.ts +7 -3
  10. package/dist/commands/auth-mint.js +21 -9
  11. package/dist/commands/auth.js +2 -3
  12. package/dist/commands/browser.js +10 -10
  13. package/dist/commands/daemon.d.ts +7 -3
  14. package/dist/commands/daemon.js +40 -25
  15. package/dist/commands/doctor.js +28 -2
  16. package/dist/commands/exec.js +87 -39
  17. package/dist/commands/fleet-capture.js +21 -2
  18. package/dist/commands/harness-wizard.js +2 -2
  19. package/dist/commands/lease.js +7 -12
  20. package/dist/commands/profiles.d.ts +2 -2
  21. package/dist/commands/profiles.js +20 -13
  22. package/dist/commands/repo.js +3 -3
  23. package/dist/commands/run-account-picker.d.ts +3 -3
  24. package/dist/commands/run-account-picker.js +29 -30
  25. package/dist/commands/secrets-passthrough.d.ts +20 -0
  26. package/dist/commands/secrets-passthrough.js +43 -0
  27. package/dist/commands/setup-accounts.d.ts +3 -2
  28. package/dist/commands/setup-accounts.js +4 -4
  29. package/dist/commands/setup-secrets.d.ts +16 -21
  30. package/dist/commands/setup-secrets.js +43 -224
  31. package/dist/commands/ssh.js +2 -2
  32. package/dist/commands/sync.js +1 -1
  33. package/dist/commands/update.d.ts +10 -0
  34. package/dist/commands/update.js +43 -40
  35. package/dist/commands/versions.d.ts +11 -11
  36. package/dist/commands/versions.js +131 -83
  37. package/dist/commands/view.d.ts +1 -9
  38. package/dist/commands/view.js +41 -55
  39. package/dist/commands/webhook.js +5 -5
  40. package/dist/commands/workflows.js +2 -1
  41. package/dist/index.d.ts +12 -12
  42. package/dist/index.js +13 -42
  43. package/dist/lib/account-capabilities.d.ts +1 -1
  44. package/dist/lib/account-capabilities.js +1 -1
  45. package/dist/lib/account-catalog.d.ts +121 -6
  46. package/dist/lib/account-catalog.js +421 -7
  47. package/dist/lib/account-registry.d.ts +8 -30
  48. package/dist/lib/account-registry.js +36 -79
  49. package/dist/lib/account-schema.d.ts +1 -1
  50. package/dist/lib/account-schema.js +1 -1
  51. package/dist/lib/accounting/account-pool-collect.js +16 -4
  52. package/dist/lib/accounting/rotate.d.ts +20 -7
  53. package/dist/lib/accounting/rotate.js +89 -13
  54. package/dist/lib/accounting/usage.js +11 -16
  55. package/dist/lib/accounts/add.d.ts +138 -0
  56. package/dist/lib/accounts/add.js +651 -0
  57. package/dist/lib/accounts/migrate.d.ts +117 -0
  58. package/dist/lib/accounts/migrate.js +536 -0
  59. package/dist/lib/accounts/slots.d.ts +13 -0
  60. package/dist/lib/accounts/slots.js +100 -0
  61. package/dist/lib/agent-spec/agents.d.ts +16 -0
  62. package/dist/lib/agent-spec/agents.js +32 -4
  63. package/dist/lib/app-bundle-install.js +5 -4
  64. package/dist/lib/auth-health.d.ts +3 -0
  65. package/dist/lib/auth-health.js +11 -0
  66. package/dist/lib/auth-mint.d.ts +52 -16
  67. package/dist/lib/auth-mint.js +117 -39
  68. package/dist/lib/browser/chrome.d.ts +1 -1
  69. package/dist/lib/browser/chrome.js +5 -5
  70. package/dist/lib/byok-usage.js +3 -3
  71. package/dist/lib/claude-account-token.d.ts +47 -3
  72. package/dist/lib/claude-account-token.js +172 -50
  73. package/dist/lib/cloud/antigravity.js +4 -4
  74. package/dist/lib/cloud/cursor.js +4 -4
  75. package/dist/lib/crabbox/cli.d.ts +1 -1
  76. package/dist/lib/crabbox/cli.js +6 -6
  77. package/dist/lib/crabbox/runtimes.d.ts +3 -3
  78. package/dist/lib/crabbox/runtimes.js +5 -5
  79. package/dist/lib/daemon/account-state-daemon-service.d.ts +37 -1
  80. package/dist/lib/daemon/account-state-daemon-service.js +199 -3
  81. package/dist/lib/daemon/auth-sync-service.js +28 -1
  82. package/dist/lib/daemon/daemon.d.ts +6 -16
  83. package/dist/lib/daemon/daemon.js +52 -97
  84. package/dist/lib/daemon/daemon.test-fixture.d.ts +7 -9
  85. package/dist/lib/daemon/daemon.test-fixture.js +22 -40
  86. package/dist/lib/daemon/harness-update-service.d.ts +1 -1
  87. package/dist/lib/daemon/harness-update-service.js +1 -1
  88. package/dist/lib/daemon/runner.d.ts +15 -3
  89. package/dist/lib/daemon/runner.js +49 -24
  90. package/dist/lib/daemon-health.d.ts +1 -2
  91. package/dist/lib/daemon-health.js +7 -6
  92. package/dist/lib/daemon-services.d.ts +1 -1
  93. package/dist/lib/daemon-services.js +1 -11
  94. package/dist/lib/daemon-webhooks.d.ts +8 -7
  95. package/dist/lib/daemon-webhooks.js +10 -9
  96. package/dist/lib/device-config.js +1 -1
  97. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  98. package/dist/lib/devices/harness-inventory.d.ts +39 -0
  99. package/dist/lib/devices/harness-inventory.js +126 -4
  100. package/dist/lib/doctor-diff.js +2 -1
  101. package/dist/lib/exec-account-home.d.ts +38 -0
  102. package/dist/lib/exec-account-home.js +164 -0
  103. package/dist/lib/exec.d.ts +30 -0
  104. package/dist/lib/exec.js +101 -31
  105. package/dist/lib/fleet/apply.d.ts +2 -2
  106. package/dist/lib/fleet/apply.js +4 -4
  107. package/dist/lib/fleet/auth-sync.js +4 -3
  108. package/dist/lib/fleet-shared-repo-sync.js +6 -9
  109. package/dist/lib/harness/adapter.d.ts +7 -7
  110. package/dist/lib/harness/adapter.js +10 -6
  111. package/dist/lib/harness/adapters/grok.js +8 -3
  112. package/dist/lib/harness/adapters/muse.js +1 -1
  113. package/dist/lib/harness/adapters/opencode.js +12 -3
  114. package/dist/lib/harness-auth-capabilities.d.ts +61 -0
  115. package/dist/lib/harness-auth-capabilities.js +52 -0
  116. package/dist/lib/helper-versions.d.ts +6 -4
  117. package/dist/lib/helper-versions.js +5 -4
  118. package/dist/lib/hosts/credential-transport.d.ts +10 -0
  119. package/dist/lib/hosts/credential-transport.js +37 -0
  120. package/dist/lib/hosts/dispatch.d.ts +7 -0
  121. package/dist/lib/hosts/dispatch.js +13 -6
  122. package/dist/lib/identity/client.d.ts +3 -3
  123. package/dist/lib/identity/client.js +3 -3
  124. package/dist/lib/installations/index.d.ts +1 -1
  125. package/dist/lib/installations/index.js +1 -1
  126. package/dist/lib/installations/migrate.js +9 -0
  127. package/dist/lib/installations/resolve.js +1 -1
  128. package/dist/lib/installations/shims.d.ts +15 -0
  129. package/dist/lib/installations/shims.js +69 -0
  130. package/dist/lib/installations/store.d.ts +43 -0
  131. package/dist/lib/installations/store.js +75 -7
  132. package/dist/lib/installations/versions.js +1 -1
  133. package/dist/lib/menubar/install-menubar.d.ts +3 -3
  134. package/dist/lib/menubar/install-menubar.js +3 -3
  135. package/dist/lib/native-accounts.d.ts +35 -0
  136. package/dist/lib/native-accounts.js +41 -0
  137. package/dist/lib/net-close.d.ts +11 -0
  138. package/dist/lib/net-close.js +25 -0
  139. package/dist/lib/openclaw-keychain.js +27 -1
  140. package/dist/lib/profiles.js +22 -9
  141. package/dist/lib/project-resources.js +1 -1
  142. package/dist/lib/reserved-stores.d.ts +53 -0
  143. package/dist/lib/reserved-stores.js +120 -0
  144. package/dist/lib/secrets-client.d.ts +191 -0
  145. package/dist/lib/secrets-client.js +710 -0
  146. package/dist/lib/secrets-policy.d.ts +222 -0
  147. package/dist/lib/secrets-policy.js +483 -0
  148. package/dist/lib/secrets-types.d.ts +188 -0
  149. package/dist/lib/secrets-types.js +24 -0
  150. package/dist/lib/service-manifest.js +3 -4
  151. package/dist/lib/session/db.d.ts +18 -0
  152. package/dist/lib/session/db.js +47 -14
  153. package/dist/lib/session/sync/config.js +2 -2
  154. package/dist/lib/sha256-asset.d.ts +11 -16
  155. package/dist/lib/sha256-asset.js +11 -16
  156. package/dist/lib/share/config.js +14 -5
  157. package/dist/lib/signin-badge.d.ts +26 -0
  158. package/dist/lib/signin-badge.js +42 -0
  159. package/dist/lib/staleness/detectors/workflows.js +3 -105
  160. package/dist/lib/staleness/writers/workflows.js +2 -1
  161. package/dist/lib/state.d.ts +7 -4
  162. package/dist/lib/state.js +18 -7
  163. package/dist/lib/sync-umbrella.js +2 -2
  164. package/dist/lib/teams/agents.js +1 -1
  165. package/dist/lib/types.d.ts +74 -33
  166. package/dist/lib/view-types.d.ts +6 -3
  167. package/dist/lib/workflows-registry.d.ts +71 -0
  168. package/dist/lib/workflows-registry.js +280 -0
  169. package/dist/lib/workflows.d.ts +21 -21
  170. package/dist/lib/workflows.js +12 -327
  171. package/package.json +2 -3
  172. package/scripts/postinstall.js +4 -29
  173. package/dist/commands/secrets-import.d.ts +0 -18
  174. package/dist/commands/secrets-import.js +0 -74
  175. package/dist/commands/secrets-migrate.d.ts +0 -25
  176. package/dist/commands/secrets-migrate.js +0 -334
  177. package/dist/commands/secrets-rotate-passphrase.d.ts +0 -17
  178. package/dist/commands/secrets-rotate-passphrase.js +0 -96
  179. package/dist/commands/secrets-sync.d.ts +0 -11
  180. package/dist/commands/secrets-sync.js +0 -153
  181. package/dist/commands/secrets-vault.d.ts +0 -10
  182. package/dist/commands/secrets-vault.js +0 -130
  183. package/dist/commands/secrets.d.ts +0 -212
  184. package/dist/commands/secrets.js +0 -3030
  185. package/dist/lib/accounts/connect.d.ts +0 -176
  186. package/dist/lib/accounts/connect.js +0 -453
  187. package/dist/lib/daemon/keychain-reap-service.d.ts +0 -17
  188. package/dist/lib/daemon/keychain-reap-service.js +0 -32
  189. package/dist/lib/daemon/secrets-broker-service.d.ts +0 -21
  190. package/dist/lib/daemon/secrets-broker-service.js +0 -51
  191. package/dist/lib/secrets/agent.d.ts +0 -358
  192. package/dist/lib/secrets/agent.js +0 -1291
  193. package/dist/lib/secrets/audit.d.ts +0 -46
  194. package/dist/lib/secrets/audit.js +0 -101
  195. package/dist/lib/secrets/bundles.d.ts +0 -290
  196. package/dist/lib/secrets/bundles.js +0 -1547
  197. package/dist/lib/secrets/download-keychain.d.ts +0 -47
  198. package/dist/lib/secrets/download-keychain.js +0 -70
  199. package/dist/lib/secrets/drivers/rush.d.ts +0 -14
  200. package/dist/lib/secrets/drivers/rush.js +0 -90
  201. package/dist/lib/secrets/fallback.d.ts +0 -48
  202. package/dist/lib/secrets/fallback.js +0 -48
  203. package/dist/lib/secrets/filestore.d.ts +0 -222
  204. package/dist/lib/secrets/filestore.js +0 -1099
  205. package/dist/lib/secrets/headless.d.ts +0 -43
  206. package/dist/lib/secrets/headless.js +0 -56
  207. package/dist/lib/secrets/icloud-import.d.ts +0 -79
  208. package/dist/lib/secrets/icloud-import.js +0 -206
  209. package/dist/lib/secrets/index.d.ts +0 -451
  210. package/dist/lib/secrets/index.js +0 -1568
  211. package/dist/lib/secrets/install-helper.d.ts +0 -72
  212. package/dist/lib/secrets/install-helper.js +0 -245
  213. package/dist/lib/secrets/lease.d.ts +0 -25
  214. package/dist/lib/secrets/lease.js +0 -44
  215. package/dist/lib/secrets/linux.d.ts +0 -77
  216. package/dist/lib/secrets/linux.js +0 -393
  217. package/dist/lib/secrets/list-filter.d.ts +0 -109
  218. package/dist/lib/secrets/list-filter.js +0 -261
  219. package/dist/lib/secrets/mcp.d.ts +0 -93
  220. package/dist/lib/secrets/mcp.js +0 -211
  221. package/dist/lib/secrets/profiles.d.ts +0 -10
  222. package/dist/lib/secrets/profiles.js +0 -13
  223. package/dist/lib/secrets/push.d.ts +0 -133
  224. package/dist/lib/secrets/push.js +0 -273
  225. package/dist/lib/secrets/rc-hygiene.d.ts +0 -63
  226. package/dist/lib/secrets/rc-hygiene.js +0 -143
  227. package/dist/lib/secrets/read-backoff.d.ts +0 -27
  228. package/dist/lib/secrets/read-backoff.js +0 -64
  229. package/dist/lib/secrets/reaper.d.ts +0 -90
  230. package/dist/lib/secrets/reaper.js +0 -243
  231. package/dist/lib/secrets/remote.d.ts +0 -152
  232. package/dist/lib/secrets/remote.js +0 -344
  233. package/dist/lib/secrets/reserved-sync.d.ts +0 -66
  234. package/dist/lib/secrets/reserved-sync.js +0 -147
  235. package/dist/lib/secrets/scope.d.ts +0 -26
  236. package/dist/lib/secrets/scope.js +0 -29
  237. package/dist/lib/secrets/session-store.d.ts +0 -107
  238. package/dist/lib/secrets/session-store.js +0 -342
  239. package/dist/lib/secrets/sync-backend.d.ts +0 -48
  240. package/dist/lib/secrets/sync-backend.js +0 -13
  241. package/dist/lib/secrets/sync-commands.d.ts +0 -21
  242. package/dist/lib/secrets/sync-commands.js +0 -21
  243. package/dist/lib/secrets/sync.d.ts +0 -48
  244. package/dist/lib/secrets/sync.js +0 -237
  245. package/dist/lib/secrets/unlock-hints.d.ts +0 -27
  246. package/dist/lib/secrets/unlock-hints.js +0 -36
  247. package/dist/lib/secrets/usage-db.d.ts +0 -46
  248. package/dist/lib/secrets/usage-db.js +0 -96
  249. package/dist/lib/secrets/vault-age-helper.d.ts +0 -1
  250. package/dist/lib/secrets/vault-age-helper.js +0 -34
  251. package/dist/lib/secrets/vault.d.ts +0 -49
  252. package/dist/lib/secrets/vault.js +0 -399
  253. package/dist/lib/secrets/windows.d.ts +0 -81
  254. package/dist/lib/secrets/windows.js +0 -556
  255. package/scripts/install-helper.js +0 -97
  256. /package/dist/lib/{secrets/sync-passphrase.d.ts → sync-passphrase.d.ts} +0 -0
  257. /package/dist/lib/{secrets/sync-passphrase.js → sync-passphrase.js} +0 -0
@@ -1,1568 +0,0 @@
1
- /**
2
- * Cross-platform secure credential storage.
3
- *
4
- * macOS: every keychain operation goes through the signed `Agents CLI.app`
5
- * helper. The helper attaches a biometry-or-passcode access control to every
6
- * item it writes, so the OS itself gates decryption with Touch ID. A single
7
- * LAContext lives for the helper's process lifetime, so a batch read pops
8
- * Touch ID once and reuses the assertion for every item in the same batch.
9
- * No /usr/bin/security fast path: that path bypasses the helper's ACL,
10
- * exposes items to the legacy password sheet, and would defeat the model.
11
- *
12
- * Linux: libsecret (GNOME Keyring) via the `secret-tool` CLI. No biometry —
13
- * items are unlocked when the keyring is open.
14
- *
15
- * Windows: Windows Credential Manager (CRED_TYPE_GENERIC,
16
- * CRED_PERSIST_LOCAL_MACHINE) via a PowerShell P/Invoke shim, with the same
17
- * AES-256-GCM encrypted-file fallback used on Linux when the credential store
18
- * is unreachable (no logon session / no powershell.exe). No biometry.
19
- *
20
- * Items are device-local: the biometry access control requires the OS to
21
- * treat them as bound to this device, so cross-machine propagation goes
22
- * through the explicit export/import flow in src/lib/secrets/sync.ts
23
- * rather than the system's cloud-keychain path.
24
- */
25
- import { execFileSync, spawnSync } from 'child_process';
26
- import { createHmac, randomBytes } from 'node:crypto';
27
- import * as fs from 'fs';
28
- import * as os from 'os';
29
- import * as path from 'path';
30
- import { linuxBackend, usesFileFallback as linuxUsesFileFallback, importNativeSecretToolItems } from './linux.js';
31
- import { windowsBackend, usesFileFallback as windowsUsesFileFallback, importNativeCredManItems } from './windows.js';
32
- import { isHeadlessSecretsContext } from './headless.js';
33
- import { KEYCHAIN_READ_BACKOFF_TTL_MS, clearKeychainReadBackoff, isKeychainReadBackedOff, noteKeychainReadFailure, } from './read-backoff.js';
34
- import { getKeychainHelperPath } from './install-helper.js';
35
- import { deriveShortId } from '../text/short-id.js';
36
- const SERVICE_PREFIX = 'agents-cli';
37
- export const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
38
- const BUNDLES_ITEM_PREFIX = `${SERVICE_PREFIX}.bundles.`;
39
- /** Timeout for keychain-helper verbs that never raise a user prompt. */
40
- const KEYCHAIN_SILENT_TIMEOUT_MS = 8_000;
41
- /** Timeout for verbs that may raise Touch ID / password auth UI. */
42
- const KEYCHAIN_INTERACTIVE_TIMEOUT_MS = 60_000;
43
- /**
44
- * Thrown when a keychain helper / security spawnSync is killed because it
45
- * exceeded its timeout. A wedged coreauthd / LocalAuthentication dialog can hang
46
- * the parent forever; this makes the failure explicit and arms the read back-off.
47
- */
48
- export class KeychainHelperTimeoutError extends Error {
49
- bin;
50
- args;
51
- constructor(bin, args) {
52
- super(`keychain helper timed out (${bin} ${args.join(' ')}) — keychain locked / ` +
53
- `LocalAuthentication unresponsive — retry; if it persists, lock/unlock the screen or reboot`);
54
- this.bin = bin;
55
- this.args = args;
56
- this.name = 'KeychainHelperTimeoutError';
57
- }
58
- }
59
- let keychainDaemonBootEnabled = true;
60
- let keychainDaemonBootAttempted = false;
61
- /** Test seam: suppress the side-effect daemon boot in unit tests. */
62
- export function setKeychainDaemonBootForTest(enabled) {
63
- keychainDaemonBootEnabled = enabled;
64
- }
65
- /**
66
- * Single wrapper for every keychain-helper (and /usr/bin/security) spawnSync.
67
- * Applies a hard timeout + SIGKILL so a wedged coreauthd can never hang the
68
- * parent process. Throws {@link KeychainHelperTimeoutError} when the child is
69
- * killed by the timeout. Also boots the daemon once per process so the reaper
70
- * can clean up any stuck helpers this or prior invocations left behind.
71
- */
72
- function spawnKeychainHelper(bin, args, opts, timeoutMs) {
73
- if (keychainDaemonBootEnabled && !keychainDaemonBootAttempted) {
74
- keychainDaemonBootAttempted = true;
75
- // Fire-and-forget: daemon start must not block the foreground secrets op.
76
- import('../daemon/daemon.js')
77
- .then(({ ensureDaemonStarted }) => ensureDaemonStarted())
78
- .catch(() => { });
79
- }
80
- const result = spawnSync(bin, args, { ...opts, timeout: timeoutMs, killSignal: 'SIGKILL' });
81
- if (result.signal) {
82
- throw new KeychainHelperTimeoutError(bin, args);
83
- }
84
- return result;
85
- }
86
- /** Test seam: exercise the timeout wrapper with an arbitrary binary. */
87
- export function spawnKeychainHelperForTest(bin, args, opts, timeoutMs) {
88
- return spawnKeychainHelper(bin, args, opts, timeoutMs);
89
- }
90
- const REF_PATTERN = /^(keychain|env|file|exec):(.+)$/s;
91
- /** Parse a bundle value into either a literal string or a typed secret ref. */
92
- export function parseBundleValue(raw) {
93
- if (typeof raw === 'object' && raw !== null && typeof raw.value === 'string') {
94
- return { literal: raw.value };
95
- }
96
- if (typeof raw !== 'string') {
97
- throw new Error(`Invalid bundle value (expected string or {value: string}): ${JSON.stringify(raw)}`);
98
- }
99
- const match = REF_PATTERN.exec(raw);
100
- if (!match)
101
- return { literal: raw };
102
- return { ref: { provider: match[1], value: match[2] } };
103
- }
104
- /** Serialize a secret ref back to its `provider:value` string form. */
105
- export function serializeRef(ref) {
106
- return `${ref.provider}:${ref.value}`;
107
- }
108
- function assertSupportedPlatform() {
109
- if (process.platform !== 'darwin' && process.platform !== 'linux' && process.platform !== 'win32') {
110
- throw new Error('agents secrets requires macOS Keychain, Linux libsecret, or Windows Credential Manager.\n' +
111
- 'Use environment variables or a .env file on unsupported platforms.');
112
- }
113
- }
114
- function isLinux() {
115
- return process.platform === 'linux';
116
- }
117
- function isWindows() {
118
- return process.platform === 'win32';
119
- }
120
- /**
121
- * Guard a secret value before it is written to the current platform's primary
122
- * backend.
123
- *
124
- * A value is empty on every platform → always rejected. Embedded newlines are
125
- * rejected ONLY on darwin: the macOS batch read path (`get-batch`, see
126
- * getKeychainTokens) is newline-delimited, so a value with a newline would
127
- * corrupt record framing on read. Linux (secret-tool), Windows (Credential
128
- * Manager stores the raw UTF-8 blob and emits base64), and the encrypted-file
129
- * fallback all store raw bytes and round-trip multiline values (PEM / SSH keys)
130
- * faithfully, so they accept newlines. `platform` is injectable for tests.
131
- */
132
- export function assertValueStorable(value, platform = process.platform) {
133
- if (!value || !value.trim())
134
- throw new Error('Secret value is empty.');
135
- if (platform === 'darwin' && /[\r\n]/.test(value)) {
136
- throw new Error('Secret value contains newlines, which are not supported.');
137
- }
138
- }
139
- /** Build the keychain item name for a profile provider token. */
140
- export function profileKeychainItem(provider) {
141
- return `${SERVICE_PREFIX}.${provider}.token`;
142
- }
143
- /** Build the keychain item name for a secrets-bundle key. */
144
- export function secretsKeychainItem(bundle, key) {
145
- return `${SECRETS_ITEM_PREFIX}${bundle}.${key}`;
146
- }
147
- function keychainItemRequiresUserPresence(item) {
148
- return item.startsWith(SECRETS_ITEM_PREFIX) || item.startsWith(BUNDLES_ITEM_PREFIX);
149
- }
150
- let backend = null;
151
- /** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
152
- export function setKeychainBackendForTest(b) {
153
- const prev = backend;
154
- backend = b;
155
- // The hashing state depends on whether a backend is installed — never let a
156
- // state resolved against the real keychain leak into a backend-driven test.
157
- hashStateCache = null;
158
- autoRekeyAttempted = false;
159
- return prev;
160
- }
161
- /** True when a test backend is installed (real keychain / biometry bypassed).
162
- * Callers that gate on the live secrets-agent broker use this to stay hermetic —
163
- * with an in-memory backend there is no real keychain to dedup, so the broker
164
- * fast-path must not engage. Always false in production (`backend` is null). */
165
- export function isKeychainBackendOverridden() {
166
- return backend !== null;
167
- }
168
- /**
169
- * Items whose name does NOT start with `agents-cli.` belong to another
170
- * application (e.g. Anthropic's `Claude Code-credentials-*`). Their ACL
171
- * trusts THEIR writer, not our signed helper, so routing them through our
172
- * helper produces a legacy password sheet. `/usr/bin/security` reads them
173
- * silently because it's in the default trusted-app list on most user-owned
174
- * keychain items. And we MUST NOT JIT-migrate them — the owning app
175
- * expects to re-write the item with its own ACL design.
176
- */
177
- function isOurItem(item) {
178
- return item.startsWith('agents-cli.');
179
- }
180
- // ─── Hashed service names (GitHub #316, Finding 1) ──────────────────────────
181
- //
182
- // The helper's `list` never decrypts and never prompts (by design), which made
183
- // service names enumerable metadata: any same-user process could silently read
184
- // every bundle, key, and provider name (`agents-cli.secrets.<bundle>.<KEY>`,
185
- // `agents-cli.<provider>.token`) and build a target list before ever popping
186
- // Touch ID. To close that, on macOS every item in our namespace is stored
187
- // under an opaque HMAC-SHA256-hashed service name:
188
- //
189
- // agents-cli.bundles.<name> → agents-cli.h.<ns>.m
190
- // agents-cli.secrets.<bundle>.<KEY> → agents-cli.h.<ns>.k.<kh>
191
- // agents-cli.<anything else> → agents-cli.h.o.<ih>
192
- //
193
- // where <ns> = HMAC(key, 'ns\0'+bundle) and <kh>/<ih> are per-item HMACs
194
- // (first 32 hex chars each). The per-bundle <ns> segment is deliberate: a
195
- // bundle's value items keep a common silent-enumerable prefix
196
- // (`agents-cli.h.<ns>.k.`), so readAndResolveBundleEnv still fetches metadata
197
- // + all values in ONE get-batch behind ONE Touch ID — a flat hash of the full
198
- // name would have forced a second prompt on every bundle read. Names still
199
- // start with `agents-cli.`, so the helper's JIT-migration guard and prefix
200
- // gates keep working. What an enumerator learns shrinks to item grouping and
201
- // counts — never a bundle, key, or provider name.
202
- //
203
- // The HMAC key is 32 random bytes in `agents-cli.hmackey`, written through the
204
- // helper's no-ACL path (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
205
- // device-local, access-group-pinned). Deliberately NO user-presence ACL: the
206
- // key protects metadata confidentiality only, and gating it behind Touch ID
207
- // would make every silent operation (list/has) prompt. Per-machine is fine —
208
- // sync re-materializes items locally through the same primitives, so hashed
209
- // names never leave the machine. Deriving the key from machine constants was
210
- // rejected: that would hand any local process a dictionary-confirmation
211
- // oracle without even touching the keychain.
212
- //
213
- // Hashing activates only after the one-time re-key migration
214
- // (rekeyServiceNames below / `agents secrets rekey`) has moved every existing
215
- // cleartext-named item; until then all operations use cleartext names exactly
216
- // as before. The sentinel lives INSIDE the hmackey record — in the keychain,
217
- // not on disk — so it can never desync from the items it describes (e.g. a
218
- // keychain restored from a backup brings its matching state along).
219
- const HASHED_SERVICE_PREFIX = `${SERVICE_PREFIX}.h.`;
220
- export const HMAC_KEY_ITEM = `${SERVICE_PREFIX}.hmackey`;
221
- const HASHED_META_RE = /^agents-cli\.h\.[0-9a-f]{32}\.m$/;
222
- let hashStateCache = null;
223
- let forcedTestKey = null;
224
- let rawScopeDepth = 0;
225
- let rekeyRunning = false;
226
- let autoRekeyAttempted = false;
227
- /** Force hashed service names on with a fixed key (test only). Pass null to
228
- * restore lazy production resolution. Composes with setKeychainBackendForTest
229
- * so unit tests exercise the exact transform production uses. */
230
- export function setKeychainServiceHashingForTest(key) {
231
- forcedTestKey = key;
232
- hashStateCache = null;
233
- autoRekeyAttempted = false;
234
- }
235
- /**
236
- * Run `fn` with service-name hashing suspended: every primitive uses the
237
- * literal names it is given. For migration flows ONLY — they enumerate raw
238
- * names from the helper (which may be pre-re-key cleartext leftovers) and must
239
- * read/delete those exact items, not their hashed transforms.
240
- */
241
- export function withRawKeychainServiceNames(fn) {
242
- rawScopeDepth++;
243
- try {
244
- return fn();
245
- }
246
- finally {
247
- rawScopeDepth--;
248
- }
249
- }
250
- function hmacHex32(key, input) {
251
- return createHmac('sha256', key).update(input, 'utf8').digest('hex').slice(0, 32);
252
- }
253
- function bundleNamespaceHash(bundle, key) {
254
- return hmacHex32(key, `ns\0${bundle}`);
255
- }
256
- /** The hashed (storage) service name for a cleartext item name. Exported for
257
- * the re-key migration and tests; runtime callers go through the primitives,
258
- * which apply this transparently. */
259
- export function hashedServiceName(item, key) {
260
- if (item.startsWith(BUNDLES_ITEM_PREFIX)) {
261
- const name = item.slice(BUNDLES_ITEM_PREFIX.length);
262
- return `${HASHED_SERVICE_PREFIX}${bundleNamespaceHash(name, key)}.m`;
263
- }
264
- if (item.startsWith(SECRETS_ITEM_PREFIX)) {
265
- // Bundle names may contain dots; env keys and wallet ids never do — the
266
- // LAST dot is the unambiguous bundle/key split.
267
- const rest = item.slice(SECRETS_ITEM_PREFIX.length);
268
- const dot = rest.lastIndexOf('.');
269
- if (dot > 0 && dot < rest.length - 1) {
270
- const bundle = rest.slice(0, dot);
271
- const keyName = rest.slice(dot + 1);
272
- return `${HASHED_SERVICE_PREFIX}${bundleNamespaceHash(bundle, key)}.k.${hmacHex32(key, `kv\0${bundle}\0${keyName}`)}`;
273
- }
274
- }
275
- return `${HASHED_SERVICE_PREFIX}o.${hmacHex32(key, `it\0${item}`)}`;
276
- }
277
- function parseHmacKeyRecord(raw) {
278
- try {
279
- const rec = JSON.parse(raw);
280
- if (rec && typeof rec === 'object' && rec.v === 1 && typeof rec.k === 'string' && /^[0-9a-f]{64}$/.test(rec.k)) {
281
- return rec;
282
- }
283
- }
284
- catch {
285
- /* malformed — treated as absent */
286
- }
287
- return null;
288
- }
289
- export function readHmacKeyRecord() {
290
- // HMAC_KEY_ITEM is exempt from the transform, so this routes to the helper
291
- // (or the test backend) under its literal name. The item is no-ACL, so the
292
- // read is silent — attest that to the storm guard so a headless hashed-name
293
- // resolution never trips the fail-fast.
294
- let raw;
295
- try {
296
- raw = getKeychainToken(HMAC_KEY_ITEM, { silentNoAcl: true });
297
- }
298
- catch {
299
- return null;
300
- }
301
- const record = parseHmacKeyRecord(raw);
302
- // Converge a stale-ACL'd hmackey to silent, on the HOT read path (RUSH-2441 /
303
- // restore of v1.22.7 / 391017461). An old helper re-stamped this
304
- // contractually-no-ACL item with a biometry ACL, so the read just above pops
305
- // the generic "Agents CLI needs to authenticate" sheet on EVERY hashed
306
- // lookup — the `agents devices list` stats probe the SessionStart hook runs,
307
- // and every other background hashed read. `maybeAutoRekey`'s one-shot heal
308
- // only fires on a cleartext-bundle resolve and is bypassed for the
309
- // hmackey/hashed-name path (`prepareServiceName` returns early for
310
- // HMAC_KEY_ITEM before maybeAutoRekey), so it never converged exactly these
311
- // reads and the machine prompted forever. Re-store the record no-ACL once
312
- // per machine here (the read that produced it has already happened — and
313
- // already prompted if the item was ACL'd); every subsequent read, in this
314
- // process and all future ones, is silent.
315
- //
316
- // v1.22.10 (bf79dc885 / #1995) moved the heal back into maybeAutoRekey and
317
- // left the changelog claim in 1.22.7 false until this restore.
318
- if (record && !record.healedNoAcl) {
319
- try {
320
- healHmacKeyNoAclOnce(record);
321
- record.healedNoAcl = true;
322
- }
323
- catch {
324
- // A failed no-ACL re-store leaves the item still ACL'd (a still-prompting
325
- // read) rather than a silent wrong state; the next process retries.
326
- }
327
- }
328
- return record;
329
- }
330
- function writeHmacKeyRecord(rec) {
331
- // JSON.stringify drops undefined fields (used to clear pendingDeletes).
332
- // noAcl: reads of this record must stay prompt-free; an old pinned helper
333
- // without the set-no-acl path rejects this loudly (see setKeychainToken),
334
- // which is exactly the "old helper never half-runs the re-key" gate.
335
- setKeychainToken(HMAC_KEY_ITEM, JSON.stringify(rec), { noAcl: true });
336
- hashStateCache = null;
337
- }
338
- /**
339
- * Heal a `hmackey` item that an OLD helper (pre the metadata/hmackey no-ACL
340
- * migration fix) re-stamped with a biometry ACL. Such an item makes EVERY hashed
341
- * keychain lookup pop the generic "Agents CLI needs to authenticate" sheet,
342
- * because the HMAC key is read before every hashed name resolves. The migration
343
- * fix stopped the re-stamping but never un-stamped an already-damaged item, and
344
- * nothing else re-stores it once hashing is already active — so it prompts forever.
345
- *
346
- * This re-stores the record no-ACL exactly once per machine (guarded by
347
- * `healedNoAcl`), turning every future read silent. The read that produced `rec`
348
- * has already happened (and already prompted if it was ACL'd); this only writes.
349
- * Returns true if it healed. Exported for tests. No-op when already healed.
350
- */
351
- export function healHmacKeyNoAclOnce(rec) {
352
- if (rec.healedNoAcl)
353
- return false;
354
- writeHmacKeyRecord({ ...rec, healedNoAcl: true });
355
- return true;
356
- }
357
- function resolveHashState() {
358
- if (forcedTestKey)
359
- return { active: true, key: forcedTestKey, record: null };
360
- if (hashStateCache)
361
- return hashStateCache;
362
- if (backend || process.platform !== 'darwin' || process.env.AGENTS_SECRETS_HASH_NAMES === '0') {
363
- hashStateCache = { active: false, key: null, record: null };
364
- return hashStateCache;
365
- }
366
- const record = readHmacKeyRecord();
367
- const key = record ? Buffer.from(record.k, 'hex') : null;
368
- // AGENTS_SECRETS_HASH_NAMES=1 forces hashing on before the machine-wide
369
- // sentinel flips — used to verify a partial (--prefix) re-key end-to-end.
370
- const active = !!record && (record.migrated || process.env.AGENTS_SECRETS_HASH_NAMES === '1');
371
- hashStateCache = { active, key, record };
372
- return hashStateCache;
373
- }
374
- /**
375
- * The storage-layer service name for `item`: hashed when hashing is active,
376
- * the item itself otherwise. For callers that mix helper-enumerated
377
- * (already-hashed) names with computed cleartext names in one lookup map —
378
- * see readAndResolveBundleEnv.
379
- */
380
- export function keychainServiceAlias(item) {
381
- return prepareServiceName(item);
382
- }
383
- function prepareServiceName(item) {
384
- if (rawScopeDepth > 0)
385
- return item;
386
- if (!isOurItem(item))
387
- return item;
388
- if (item === HMAC_KEY_ITEM || item.startsWith(HASHED_SERVICE_PREFIX))
389
- return item;
390
- const st = resolveHashState();
391
- if (!st.active || !st.key)
392
- return item;
393
- return hashedServiceName(item, st.key);
394
- }
395
- /**
396
- * Map a cleartext enumeration prefix to its hashed-storage equivalent. Only
397
- * two shapes are ever enumerated at sub-namespace granularity (bundle
398
- * metadata, and one bundle's value items); both map to a broad `agents-cli.`
399
- * helper query plus a client-side filter. Every mapped filter is a UNION with
400
- * the original cleartext prefix so mid-migration leftovers (or items written
401
- * by an older CLI on this machine) stay visible to migration tooling.
402
- */
403
- function prepareListPrefix(prefix) {
404
- if (rawScopeDepth > 0)
405
- return { prefix };
406
- if (!prefix.startsWith(`${SERVICE_PREFIX}.`))
407
- return { prefix };
408
- if (prefix.startsWith(HASHED_SERVICE_PREFIX))
409
- return { prefix };
410
- const st = resolveHashState();
411
- if (!st.active || !st.key)
412
- return { prefix };
413
- if (prefix === BUNDLES_ITEM_PREFIX) {
414
- return {
415
- prefix: `${SERVICE_PREFIX}.`,
416
- filter: (s) => HASHED_META_RE.test(s) || s.startsWith(BUNDLES_ITEM_PREFIX),
417
- };
418
- }
419
- if (prefix.startsWith(SECRETS_ITEM_PREFIX) && prefix.endsWith('.') && prefix.length > SECRETS_ITEM_PREFIX.length + 1) {
420
- const bundle = prefix.slice(SECRETS_ITEM_PREFIX.length, -1);
421
- const hashedValuePrefix = `${HASHED_SERVICE_PREFIX}${bundleNamespaceHash(bundle, st.key)}.k.`;
422
- return {
423
- prefix: `${SERVICE_PREFIX}.`,
424
- filter: (s) => s.startsWith(hashedValuePrefix) || s.startsWith(prefix),
425
- };
426
- }
427
- return { prefix };
428
- }
429
- function listCleartextServices(prefixes) {
430
- const all = withRawKeychainServiceNames(() => listKeychainItems(`${SERVICE_PREFIX}.`));
431
- return all.filter((s) => s.startsWith(`${SERVICE_PREFIX}.`) &&
432
- !s.startsWith(HASHED_SERVICE_PREFIX) &&
433
- s !== HMAC_KEY_ITEM &&
434
- (!prefixes || prefixes.some((p) => s.startsWith(p))));
435
- }
436
- function ensureHmacKeyRecord(markMigratedIfCreating) {
437
- const existing = readHmacKeyRecord();
438
- if (existing)
439
- return existing;
440
- const fresh = { v: 1, k: randomBytes(32).toString('hex'), migrated: markMigratedIfCreating };
441
- writeHmacKeyRecord(fresh);
442
- // If two processes raced the first write, the keychain holds exactly one
443
- // winner — adopt whatever is stored NOW so both sides converge on a single
444
- // key before hashing anything under it.
445
- return readHmacKeyRecord() ?? fresh;
446
- }
447
- /**
448
- * Guard against a silently-degraded enumeration. The helper's `list` skips the
449
- * data-protection pass wholesale when the DP keybag is locked (screen lock —
450
- * see keychain-helper.swift, errSecInteractionNotAllowed handling), returning
451
- * an EMPTY result even though items exist and no-ACL reads/writes still work.
452
- * Observed live on macOS 26: `set-no-acl` + `get` succeed while `list` of the
453
- * just-written item returns nothing. Without this probe, a re-key run in that
454
- * state would see "zero cleartext items" and wrongly activate hashed naming,
455
- * making every existing cleartext item invisible after unlock.
456
- *
457
- * The probe requires the hmackey record (a DP item that provably exists — the
458
- * caller just ensured it) to appear in a raw enumeration. Trivially true for
459
- * the in-memory test backend.
460
- */
461
- function assertEnumerationTrustworthy() {
462
- const all = withRawKeychainServiceNames(() => listKeychainItems(`${SERVICE_PREFIX}.`));
463
- if (!all.includes(HMAC_KEY_ITEM)) {
464
- throw new Error('keychain enumeration is unavailable (locked keybag / screen lock?) — refusing to decide the re-key on an empty listing. Retry while unlocked.');
465
- }
466
- }
467
- function finishPendingDeletes(rec) {
468
- const pending = rec.pendingDeletes ?? [];
469
- if (pending.length === 0)
470
- return;
471
- withRawKeychainServiceNames(() => {
472
- for (const service of pending)
473
- deleteKeychainToken(service);
474
- });
475
- writeHmacKeyRecord({ ...rec, pendingDeletes: undefined });
476
- }
477
- /**
478
- * One-shot per process: activate hashing on machines with nothing to move,
479
- * finish a crash-interrupted delete phase (silent), and run the interactive
480
- * one-time re-key when cleartext-named items exist and a human is present.
481
- * Never throws — a failed attempt leaves the process on cleartext names
482
- * (exact pre-#316 behavior) and the next process retries.
483
- */
484
- export function maybeAutoRekey() {
485
- if (autoRekeyAttempted || rekeyRunning || rawScopeDepth > 0)
486
- return;
487
- autoRekeyAttempted = true;
488
- if (forcedTestKey || backend)
489
- return;
490
- // Never auto-mutate the developer's real keychain from a test runner.
491
- if (process.env.VITEST)
492
- return;
493
- if (process.platform !== 'darwin')
494
- return;
495
- if (process.env.AGENTS_SECRETS_NO_AUTO_REKEY === '1')
496
- return;
497
- if (process.env.AGENTS_SECRETS_HASH_NAMES === '0')
498
- return;
499
- const st = resolveHashState();
500
- if (st.active) {
501
- // The stale-ACL'd-hmackey heal runs on the hot read path
502
- // (readHmacKeyRecord), which resolveHashState() above just went through —
503
- // so st.record is already healed here regardless of how this machine
504
- // reached "hashing active". Nothing to do but finish any pending deletes.
505
- if (st.record?.pendingDeletes?.length) {
506
- try {
507
- finishPendingDeletes(st.record);
508
- }
509
- catch {
510
- /* next process retries */
511
- }
512
- }
513
- return;
514
- }
515
- let cleartext;
516
- try {
517
- cleartext = listCleartextServices();
518
- }
519
- catch {
520
- return;
521
- }
522
- // Moving real items pops Touch ID — only auto-run with a human present. An
523
- // empty listing still goes through rekeyServiceNames (prompt-free): it
524
- // verifies the enumeration is trustworthy before activating on "nothing to
525
- // migrate", so a locked keybag can never masquerade as a fresh machine.
526
- const interactive = process.stdin.isTTY && process.stderr.isTTY;
527
- if (cleartext.length > 0 && !interactive)
528
- return;
529
- try {
530
- rekeyServiceNames({ announce: cleartext.length > 0, log: (line) => console.error(line) });
531
- }
532
- catch (err) {
533
- // Headless processes stay quiet (e.g. locked-keybag probe failures would
534
- // otherwise spam every background run); a human gets the pointer.
535
- if (interactive) {
536
- console.error(`agents secrets: one-time re-key did not complete (${err.message}). ` +
537
- `Keychain service names remain enumerable; run 'agents secrets rekey' to retry.`);
538
- }
539
- }
540
- }
541
- /**
542
- * Build the old→new mapping for a set of cleartext services. Bundle metadata
543
- * is parsed first to (a) recover each bundle's prompt policy — the persisted
544
- * `tier` token, where `none`/`never` means the item must be re-written through
545
- * the no-ACL path — and (b) inject the cleartext `name` into the JSON, because
546
- * after hashing the service name can no longer carry it (listBundles reads it
547
- * back from the payload). A value item's tier is resolved from its bundle's
548
- * metadata PAYLOAD in `values` — under the cleartext metadata name or its
549
- * hashed transform — never from the metadata item being part of the same
550
- * `services` batch: a --prefix run can scope a bundle's value items alone, and
551
- * rekeyServiceNames supplies the out-of-scope metadata reads (see the
552
- * supplemental batch there). Exported for unit tests.
553
- */
554
- export function computeRekeyPlan(services, values, key) {
555
- const items = [];
556
- const unreadable = [];
557
- const bundleNoAcl = (bundle) => {
558
- const meta = `${BUNDLES_ITEM_PREFIX}${bundle}`;
559
- const raw = values.get(meta) ?? values.get(hashedServiceName(meta, key));
560
- if (raw === undefined)
561
- return false;
562
- try {
563
- const parsed = JSON.parse(raw);
564
- if (parsed && typeof parsed === 'object')
565
- return parsed.tier === 'none' || parsed.tier === 'never';
566
- }
567
- catch {
568
- /* malformed JSON — ACL'd */
569
- }
570
- return false;
571
- };
572
- for (const service of services) {
573
- if (!service.startsWith(BUNDLES_ITEM_PREFIX))
574
- continue;
575
- const value = values.get(service);
576
- if (value === undefined) {
577
- unreadable.push(service);
578
- continue;
579
- }
580
- const name = service.slice(BUNDLES_ITEM_PREFIX.length);
581
- let payload;
582
- let noAcl = false;
583
- try {
584
- const parsed = JSON.parse(value);
585
- if (parsed && typeof parsed === 'object') {
586
- // Bundle metadata is non-sensitive by contract and stored no-ACL at
587
- // EVERY tier (matches writeBundle in bundles.ts), so `secrets list` /
588
- // crabbox's `agents devices list` enumerate bundles with no Touch ID
589
- // (RUSH-1759). Re-home metadata no-ACL regardless of the bundle's policy;
590
- // the real secret values (second loop) still carry their per-bundle ACL.
591
- noAcl = true;
592
- payload = JSON.stringify({ ...parsed, name });
593
- }
594
- }
595
- catch {
596
- /* malformed JSON — copy verbatim, ACL'd */
597
- }
598
- items.push({ oldService: service, newService: hashedServiceName(service, key), noAcl, payload });
599
- }
600
- for (const service of services) {
601
- if (service.startsWith(BUNDLES_ITEM_PREFIX))
602
- continue;
603
- const value = values.get(service);
604
- if (value === undefined) {
605
- unreadable.push(service);
606
- continue;
607
- }
608
- let noAcl = false;
609
- if (service.startsWith(SECRETS_ITEM_PREFIX)) {
610
- const rest = service.slice(SECRETS_ITEM_PREFIX.length);
611
- const dot = rest.lastIndexOf('.');
612
- if (dot > 0)
613
- noAcl = bundleNoAcl(rest.slice(0, dot));
614
- }
615
- // Durable "unlocked session" items (agents-cli.session.*, see session-store.ts)
616
- // are always no-ACL — re-wrapping them in a biometry ACL on rekey would break
617
- // their silent (no-Touch-ID) reads that the rehydrate/fallback paths depend on.
618
- if (service.startsWith('agents-cli.session.'))
619
- noAcl = true;
620
- items.push({ oldService: service, newService: hashedServiceName(service, key), noAcl });
621
- }
622
- return { items, unreadable };
623
- }
624
- /**
625
- * The one-time re-key: move every cleartext-named `agents-cli.*` item to its
626
- * hashed service name. Composed entirely from the existing helper primitives —
627
- * no new Swift command:
628
- *
629
- * 1. Enumerate cleartext services (silent) and batch-read every value behind
630
- * ONE Touch ID (`get-batch`; readItem also sweeps legacy/orphaned copies).
631
- * 2. Write each hashed copy (`set`/`set-no-acl` never prompt), preserving
632
- * the no-ACL tier for `never`-policy bundles.
633
- * 3. Batch-verify every copy round-trips (second Touch ID).
634
- * 4. Only then activate hashing (sentinel + pendingDeletes) and delete the
635
- * old items (silent).
636
- *
637
- * Add-before-delete throughout: a cancel/crash/failure anywhere before step 4
638
- * leaves every old item intact and hashing OFF — same rationale as the
639
- * helper's migrate-orphans, which is also why no pre-write backup is taken.
640
- * On ANY per-item failure nothing is deleted and the sentinel stays off
641
- * (all-or-nothing activation); the report names every failed item. A crash
642
- * between the sentinel write and the deletes is resumed silently by the next
643
- * process (pendingDeletes). Idempotent: re-running converges.
644
- */
645
- export function rekeyServiceNames(opts = {}) {
646
- const log = opts.log ?? (() => { });
647
- if (!backend && process.platform !== 'darwin') {
648
- throw new Error('secrets rekey is macOS-only — service names are enumerable only via the macOS keychain helper.');
649
- }
650
- if (rekeyRunning)
651
- throw new Error('re-key already running in this process.');
652
- rekeyRunning = true;
653
- try {
654
- const partial = !!opts.prefixes?.length;
655
- let record = ensureHmacKeyRecord(false);
656
- const key = Buffer.from(record.k, 'hex');
657
- // The record we just ensured is a DP item — if enumeration can't see it,
658
- // every listing below is lying (locked keybag) and no decision — least of
659
- // all "nothing to migrate, activate" — can be made on it.
660
- assertEnumerationTrustworthy();
661
- if (record.pendingDeletes?.length) {
662
- log(`Finishing interrupted re-key: removing ${record.pendingDeletes.length} already-copied cleartext item(s)…`);
663
- finishPendingDeletes(record);
664
- record = readHmacKeyRecord() ?? record;
665
- }
666
- const cleartext = listCleartextServices(opts.prefixes);
667
- if (cleartext.length === 0) {
668
- if (!record.migrated && !partial) {
669
- writeHmacKeyRecord({ ...record, migrated: true });
670
- log('No cleartext-named keychain items found — hashed service names are now active.');
671
- return { migrated: [], failed: [], activated: true, nothingToDo: true };
672
- }
673
- return { migrated: [], failed: [], activated: record.migrated, nothingToDo: true };
674
- }
675
- if (opts.announce) {
676
- log(`One-time secrets re-key: replacing ${cleartext.length} enumerable keychain service name(s) with opaque hashed names (GitHub #316).`);
677
- log('Touch ID will prompt twice (read + verify). Cancelling is safe — the re-key resumes on a later run.');
678
- }
679
- // A --prefix run can scope a bundle's value items WITHOUT its metadata
680
- // item (`agents-cli.bundles.<bundle>`); the tier decision must still come
681
- // from the keychain, not from batch membership — otherwise a partial
682
- // re-key of a `never`-policy bundle would silently re-attach a biometry
683
- // ACL to its values (and delete the cleartext originals, one-way). Read
684
- // every such bundle's metadata alongside the values in the same batch:
685
- // the cleartext name for a not-yet-moved metadata item, its hashed
686
- // transform for one an earlier partial run already moved. Absent both (a
687
- // standalone item with no backing bundle), the value stays ACL'd. A full
688
- // run adds nothing here — every metadata item is already in scope.
689
- const inScope = new Set(cleartext);
690
- const supplementalMetaReads = new Set();
691
- for (const service of cleartext) {
692
- if (!service.startsWith(SECRETS_ITEM_PREFIX))
693
- continue;
694
- const rest = service.slice(SECRETS_ITEM_PREFIX.length);
695
- const dot = rest.lastIndexOf('.');
696
- if (dot <= 0)
697
- continue;
698
- const meta = `${BUNDLES_ITEM_PREFIX}${rest.slice(0, dot)}`;
699
- if (inScope.has(meta))
700
- continue;
701
- supplementalMetaReads.add(meta);
702
- supplementalMetaReads.add(hashedServiceName(meta, key));
703
- }
704
- const values = withRawKeychainServiceNames(() => getKeychainTokens([...cleartext, ...supplementalMetaReads]));
705
- const { items, unreadable } = computeRekeyPlan(cleartext, values, key);
706
- const failed = unreadable.map((item) => ({
707
- item,
708
- detail: 'read failed or item absent',
709
- }));
710
- const added = [];
711
- for (const plan of items) {
712
- try {
713
- setKeychainToken(plan.newService, plan.payload ?? values.get(plan.oldService), { noAcl: plan.noAcl });
714
- added.push(plan);
715
- }
716
- catch (err) {
717
- failed.push({ item: plan.oldService, detail: `write: ${err.message}` });
718
- }
719
- }
720
- const verified = [];
721
- if (added.length > 0) {
722
- const readBack = getKeychainTokens(added.map((p) => p.newService));
723
- for (const plan of added) {
724
- const expected = plan.payload ?? values.get(plan.oldService);
725
- if (readBack.get(plan.newService) === expected)
726
- verified.push(plan);
727
- else
728
- failed.push({ item: plan.oldService, detail: 'verify: value mismatch after rewrite' });
729
- }
730
- }
731
- if (failed.length > 0) {
732
- log(`Re-key INCOMPLETE — ${failed.length} of ${cleartext.length} item(s) could not be moved; nothing was deleted and hashed naming stays OFF:`);
733
- for (const f of failed)
734
- log(` ${f.item}: ${f.detail}`);
735
- return { migrated: [], failed, activated: false, nothingToDo: false };
736
- }
737
- if (partial) {
738
- withRawKeychainServiceNames(() => {
739
- for (const plan of verified)
740
- deleteKeychainToken(plan.oldService);
741
- });
742
- log(`Re-keyed ${verified.length} item(s) (partial run — hashed naming NOT activated).`);
743
- return { migrated: verified.map((p) => p.oldService), failed: [], activated: record.migrated, nothingToDo: false };
744
- }
745
- writeHmacKeyRecord({ ...record, migrated: true, pendingDeletes: verified.map((p) => p.oldService) });
746
- withRawKeychainServiceNames(() => {
747
- for (const plan of verified)
748
- deleteKeychainToken(plan.oldService);
749
- });
750
- writeHmacKeyRecord({ ...record, migrated: true, pendingDeletes: undefined });
751
- log(`Re-keyed ${verified.length} keychain item(s); service names are now opaque (agents-cli.h.*).`);
752
- return { migrated: verified.map((p) => p.oldService), failed: [], activated: true, nothingToDo: false };
753
- }
754
- finally {
755
- rekeyRunning = false;
756
- }
757
- }
758
- /** Re-key state snapshot for `agents secrets rekey --status`. */
759
- export function rekeyStatus() {
760
- const rec = readHmacKeyRecord();
761
- let enumerationOk = true;
762
- if (rec) {
763
- try {
764
- assertEnumerationTrustworthy();
765
- }
766
- catch {
767
- enumerationOk = false;
768
- }
769
- }
770
- return {
771
- migrated: !!rec?.migrated,
772
- hasKey: !!rec,
773
- pendingDeletes: rec?.pendingDeletes?.length ?? 0,
774
- cleartext: listCleartextServices(),
775
- enumerationOk,
776
- };
777
- }
778
- /**
779
- * Check if a keychain/keyring item exists. Never prompts for biometry.
780
- *
781
- * Throws when the item cannot be reached — on macOS, when the signed helper is
782
- * unavailable. That is deliberate: this primitive gates destructive writes as
783
- * well as reads. Through `bundleExists()` it guards the `--force` overwrite
784
- * checks in `agents secrets create` (`../../commands/secrets.ts`) and the
785
- * bundle-rename purge (`./bundles.ts`), and the pull-rollback bookkeeping
786
- * (`./sync.ts`). A false "absent" silently disarms every one of them, so an
787
- * unreachable keychain must fail loudly rather than answer "no".
788
- *
789
- * Tests needing this path without a helper install a backend via
790
- * `setKeychainBackendForTest()`, which short-circuits on the next line.
791
- */
792
- export function hasKeychainToken(item) {
793
- item = prepareServiceName(item);
794
- if (backend)
795
- return backend.has(item);
796
- assertSupportedPlatform();
797
- if (isLinux())
798
- return linuxBackend.has(item);
799
- if (isWindows())
800
- return windowsBackend.has(item);
801
- if (!isOurItem(item)) {
802
- return spawnKeychainHelper('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item], {
803
- stdio: ['ignore', 'ignore', 'ignore'],
804
- }, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
805
- }
806
- const bin = getKeychainHelperPath();
807
- const r = spawnKeychainHelper(bin, ['has', item, os.userInfo().username], {
808
- stdio: ['ignore', 'pipe', 'pipe'],
809
- }, KEYCHAIN_SILENT_TIMEOUT_MS);
810
- // The helper exits 0 = present (incl. errSecInteractionNotAllowed — a locked or
811
- // biometry-ACL'd item still EXISTS), 1 = genuinely absent. Any other outcome
812
- // (bad args, helper failure, spawn error) means the keychain could not be
813
- // reached — and a false "absent" silently disarms every destructive-write guard
814
- // that calls this (see the docblock), so it MUST fail loud rather than answer
815
- // "no" (RUSH-2235). Timeouts already throw inside spawnKeychainHelper.
816
- if (r.status === 0)
817
- return true;
818
- if (r.status === 1)
819
- return false;
820
- const stderr = r.stderr?.toString().trim();
821
- throw new Error(stderr ||
822
- `keychain existence check for '${item}' failed (helper exit ${r.status ?? 'null'}` +
823
- `${r.error ? `: ${r.error.message}` : ''}) — keychain unreachable, not a proven absence.`);
824
- }
825
- /**
826
- * The detector the raw-read storm guard consults, overridable so tests on any
827
- * platform exercise the fail-fast path — the real detector self-gates to
828
- * darwin (the only platform with a Touch ID sheet to suppress), which would
829
- * make the throw unreachable from a Linux CI run. Same parameterization
830
- * argument as the injected env/platform/tty on isHeadlessSecretsContext.
831
- */
832
- let rawReadHeadlessDetector = () => isHeadlessSecretsContext();
833
- export function setKeychainHeadlessDetectorForTest(detector) {
834
- rawReadHeadlessDetector = detector ?? (() => isHeadlessSecretsContext());
835
- }
836
- /**
837
- * The raw-read storm guard, consulted by every getKeychainToken /
838
- * getKeychainTokens read that could reach a prompting keychain query. Two
839
- * fail-fast gates, both skipped for `silentNoAcl` (provably prompt-free)
840
- * reads:
841
- *
842
- * 1. Headless fail-fast. A non-interactive process (AGENTS_RUNTIME set, or
843
- * no TTY — see isHeadlessSecretsContext) must NEVER raise a Touch ID sheet
844
- * on the interactive user's screen: the sheet has no one to answer it, a
845
- * polling caller re-raises it every few seconds, and a cancel just feeds
846
- * the next poll. Throw an actionable error naming the item instead.
847
- * 2. Back-off. A read whose prompt recently failed or was cancelled is
848
- * suppressed for KEYCHAIN_READ_BACKOFF_TTL_MS so an interactive-context
849
- * poller (TTY but unwatched — a tmux pane, a VS Code task terminal) can't
850
- * storm sheets either. A successful read or write clears the memo.
851
- *
852
- * Placement note: this runs BEFORE the platform branches so the back-off memo
853
- * is honored identically everywhere, and the headless gate is a no-op off
854
- * darwin (the detector returns false there) — Linux/Windows reads never
855
- * prompt, so there is nothing to guard.
856
- */
857
- function assertRawKeychainReadAllowed(key, context, label) {
858
- if (context.silentNoAcl)
859
- return;
860
- const what = label ?? `Keychain item '${key}'`;
861
- if (rawReadHeadlessDetector()) {
862
- const hint = context.bundle
863
- ? `Run 'agents secrets unlock ${context.bundle}' in a terminal first`
864
- : `Provision a prompt-free credential for headless use (a file-based setup token, or an item stored without the biometry ACL), ` +
865
- `or read it once from an interactive terminal`;
866
- throw new Error(`${what} requires Touch ID, but this process is non-interactive — ` +
867
- `a prompt would appear on screen with no one to answer it. ${hint}.`);
868
- }
869
- if (isKeychainReadBackedOff(key)) {
870
- throw new Error(`${what} is in read back-off: a Touch ID prompt for it failed or was cancelled within the last ` +
871
- `${Math.round(KEYCHAIN_READ_BACKOFF_TTL_MS / 60000)} minutes, and retrying is suppressed so a polling caller can't storm prompts. ` +
872
- `Read it once interactively or wait out the back-off.`);
873
- }
874
- }
875
- export function keychainOperationPrompt(context = {}) {
876
- const agent = context.agent || 'Agents CLI';
877
- const bundle = context.bundle ? ` the '${context.bundle}' bundle` : ' secrets';
878
- // Which session triggered the read — the short-id disambiguates an unexpected
879
- // prompt when several agents run at once (interactive + headless + exec).
880
- const session = context.sessionId ? ` (session ${deriveShortId(context.sessionId)})` : '';
881
- const duration = context.duration ? ` for ${context.duration}` : '';
882
- const reason = context.reason ? ` ${context.reason}` : '';
883
- return `${agent} is requesting to unlock${bundle}${session}${duration}${reason}.`;
884
- }
885
- export function getKeychainToken(item, context = {}) {
886
- // Errors keep the requested (human-readable) name; the storage name may be
887
- // an opaque hash.
888
- const requested = item;
889
- item = prepareServiceName(item);
890
- if (backend)
891
- return backend.get(item);
892
- assertRawKeychainReadAllowed(requested, context);
893
- assertSupportedPlatform();
894
- if (isLinux())
895
- return linuxBackend.get(item);
896
- if (isWindows())
897
- return windowsBackend.get(item);
898
- if (!isOurItem(item)) {
899
- const sec = spawnKeychainHelper('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
900
- stdio: ['ignore', 'pipe', 'pipe'],
901
- }, KEYCHAIN_SILENT_TIMEOUT_MS);
902
- if (sec.status === 0) {
903
- const token = sec.stdout?.toString().trim();
904
- if (token) {
905
- clearKeychainReadBackoff(requested);
906
- return token;
907
- }
908
- }
909
- throw new Error(`Keychain item '${requested}' not found.`);
910
- }
911
- const bin = getKeychainHelperPath();
912
- let result;
913
- try {
914
- result = spawnKeychainHelper(bin, ['get', item, os.userInfo().username], {
915
- env: {
916
- ...process.env,
917
- AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
918
- AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
919
- AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
920
- },
921
- stdio: ['ignore', 'pipe', 'pipe'],
922
- }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
923
- }
924
- catch (err) {
925
- if (err instanceof KeychainHelperTimeoutError && !context.silentNoAcl) {
926
- noteKeychainReadFailure(requested);
927
- }
928
- throw err;
929
- }
930
- // Exit 1 is a plain miss — no prompt was raised, so it opens no back-off.
931
- if (result.status === 1)
932
- throw new Error(`Keychain item '${requested}' not found.`);
933
- if (result.status !== 0) {
934
- // A cancel (4) or helper failure after a prompted read: open the back-off
935
- // window BEFORE throwing, so the next poll of the same item fails fast
936
- // instead of re-raising the sheet.
937
- if (!context.silentNoAcl)
938
- noteKeychainReadFailure(requested);
939
- if (result.status === 4)
940
- throw new Error(`Touch ID cancelled while reading '${requested}'.`);
941
- const msg = result.stderr?.toString().trim();
942
- throw new Error(msg || `Failed to read keychain item '${requested}'.`);
943
- }
944
- const token = result.stdout?.toString();
945
- if (!token)
946
- throw new Error(`Keychain item '${requested}' exists but is empty.`);
947
- clearKeychainReadBackoff(requested);
948
- return token;
949
- }
950
- /**
951
- * Batch-read multiple keychain items behind a single Touch ID prompt. The
952
- * macOS helper holds one LAContext for its whole process: the first protected
953
- * item triggers Touch ID, every later item in the same invocation reuses the
954
- * assertion. Missing items are absent from the returned map (caller decides
955
- * whether that's an error).
956
- *
957
- * On Linux or when a test backend is installed, falls back to individual
958
- * lookups — no biometric prompt path on those platforms.
959
- */
960
- export function getKeychainTokens(items, context = {}) {
961
- const result = new Map();
962
- if (items.length === 0)
963
- return result;
964
- // Resolve storage names up front, remembering which requested name each one
965
- // answers for — the returned map is keyed by the names the CALLER passed,
966
- // whether those were cleartext (hashed here) or already-hashed (enumerated).
967
- const requestedByStorage = new Map();
968
- const storageItems = items.map((item) => {
969
- const storage = prepareServiceName(item);
970
- if (!requestedByStorage.has(storage))
971
- requestedByStorage.set(storage, item);
972
- return storage;
973
- });
974
- const record = (storage, value) => {
975
- result.set(requestedByStorage.get(storage) ?? storage, value);
976
- };
977
- if (backend) {
978
- for (const storage of storageItems) {
979
- try {
980
- record(storage, backend.get(storage));
981
- }
982
- catch { /* missing — skip */ }
983
- }
984
- return result;
985
- }
986
- // One back-off memo covers the whole batch: the batch raises at most one
987
- // prompt, so a cancel/failure suppresses retrying the same batch, keyed by
988
- // the requested names (never the storage hashes) for a stable identity. The
989
- // guard runs before the platform branches so the headless fail-fast is
990
- // exercisable on any platform (the real detector self-gates to darwin).
991
- const backoffKey = `batch:${items.join('\n')}`;
992
- assertRawKeychainReadAllowed(backoffKey, context, `Batch read of ${items.length} keychain item(s)`);
993
- assertSupportedPlatform();
994
- if (isLinux()) {
995
- for (const storage of storageItems) {
996
- try {
997
- record(storage, linuxBackend.get(storage));
998
- }
999
- catch { /* missing — skip */ }
1000
- }
1001
- return result;
1002
- }
1003
- if (isWindows()) {
1004
- for (const storage of storageItems) {
1005
- try {
1006
- record(storage, windowsBackend.get(storage));
1007
- }
1008
- catch { /* missing — skip */ }
1009
- }
1010
- return result;
1011
- }
1012
- const bin = getKeychainHelperPath();
1013
- let child;
1014
- try {
1015
- child = spawnKeychainHelper(bin, ['get-batch', os.userInfo().username, ...storageItems], {
1016
- env: {
1017
- ...process.env,
1018
- AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
1019
- AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
1020
- AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
1021
- // The signed helper's own vocabulary is unchanged (it predates the rename
1022
- // and ships as a separately-versioned binary), so map to its legacy token.
1023
- AGENTS_KEYCHAIN_DEFAULT_POLICY: (context.defaultPolicy ?? 'hold') === 'hold' ? 'daily' : context.defaultPolicy,
1024
- AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
1025
- },
1026
- stdio: ['ignore', 'pipe', 'pipe'],
1027
- }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
1028
- }
1029
- catch (err) {
1030
- if (err instanceof KeychainHelperTimeoutError && !context.silentNoAcl) {
1031
- noteKeychainReadFailure(backoffKey);
1032
- }
1033
- throw err;
1034
- }
1035
- if (child.status !== 0 && !context.silentNoAcl)
1036
- noteKeychainReadFailure(backoffKey);
1037
- if (child.status === 4) {
1038
- throw new Error(`Touch ID cancelled while reading ${items.length} keychain item(s).`);
1039
- }
1040
- if (child.status !== 0) {
1041
- const msg = child.stderr?.toString().trim();
1042
- throw new Error(msg || `Failed to batch-read ${items.length} keychain items.`);
1043
- }
1044
- clearKeychainReadBackoff(backoffKey);
1045
- const out = child.stdout?.toString() ?? '';
1046
- parseBatchRecords(out, record);
1047
- return result;
1048
- }
1049
- /**
1050
- * Parse the helper's batch-read output, routing each present record through
1051
- * `record(service, value)` — getKeychainTokens uses that to reverse-map hashed
1052
- * storage names back to the names the caller asked with. The format is shared
1053
- * by `get-batch` and `get-batch-synced` — a sequence of records, one per
1054
- * service in input order:
1055
- * "V <service>\n<value>\n" (present)
1056
- * "M <service>\n" (missing)
1057
- * Service names are validated newline/'='-free by setKeychainToken below
1058
- * and values are rejected if they contain newlines — so splitting on '\n'
1059
- * and walking line-by-line is unambiguous.
1060
- */
1061
- function parseBatchRecords(out, record) {
1062
- const lines = out.split('\n');
1063
- let i = 0;
1064
- while (i < lines.length) {
1065
- const line = lines[i];
1066
- if (line === '' && i === lines.length - 1)
1067
- break;
1068
- if (line.startsWith('V ')) {
1069
- const service = line.slice(2);
1070
- const value = lines[i + 1] ?? '';
1071
- record(service, value);
1072
- i += 2;
1073
- }
1074
- else if (line.startsWith('M ')) {
1075
- i += 1;
1076
- }
1077
- else if (line === '') {
1078
- i += 1;
1079
- }
1080
- else {
1081
- throw new Error(`Malformed get-batch output line: ${JSON.stringify(line)}`);
1082
- }
1083
- }
1084
- }
1085
- /** Store or update a secret value in the keychain/keyring. Device-local;
1086
- * biometry-gated on macOS. `opts.noAcl` (the `never` prompt-policy) writes our
1087
- * item WITHOUT the biometry access control so later reads are fully silent — it
1088
- * routes through the signed helper's `set-no-acl` path. A pinned helper that
1089
- * predates that path rejects the unknown command (exit 2) and this throws,
1090
- * rather than silently falling back to an ACL'd `set` (which would behave like
1091
- * `always`). Ignored by the Linux/Windows/test backends, which have no ACL. */
1092
- /**
1093
- * argv for writing a bare (non-`agents-cli.`) keychain item via
1094
- * `/usr/bin/security add-generic-password`, deliberately WITHOUT the value: the
1095
- * secret travels over stdin (see setKeychainToken) so it never lands in argv or
1096
- * a `ps` snapshot. Exported so a test can assert the value is absent from argv.
1097
- */
1098
- export function buildAddGenericPasswordArgs(account, item) {
1099
- return ['add-generic-password', '-U', '-a', account, '-s', item, '-w'];
1100
- }
1101
- /**
1102
- * spawnSync options for the bare `-w` keychain write. Pure so the two
1103
- * load-bearing properties are unit-testable without touching the real keychain:
1104
- * - `input` pipes the value TWICE (bare `-w` prompts enter+confirm; one line
1105
- * fails the confirm and stores an empty secret).
1106
- * - `detached: true` runs `security` in a new session with no controlling
1107
- * terminal, so readpassphrase(3) falls back to our piped stdin instead of
1108
- * prompting the user's `/dev/tty` in an interactive shell (see setKeychainToken).
1109
- */
1110
- export function buildAddGenericPasswordSpawnOptions(value) {
1111
- // `detached` is honored by spawnSync at runtime (libuv setsid) but is not
1112
- // declared on Node's SpawnSyncOptions type, so widen the return explicitly.
1113
- return {
1114
- input: `${value}\n${value}\n`,
1115
- stdio: ['pipe', 'pipe', 'pipe'],
1116
- timeout: 10_000,
1117
- detached: true,
1118
- };
1119
- }
1120
- export function setKeychainToken(item, value, opts) {
1121
- // Validate the CLEARTEXT name (a hashed storage name is always clean), then
1122
- // resolve the storage name.
1123
- if (/[\x00=\r\n]/.test(item))
1124
- throw new Error('Secret item name contains invalid characters.');
1125
- const requested = item;
1126
- item = prepareServiceName(item);
1127
- if (backend) {
1128
- backend.set(item, value, opts);
1129
- return;
1130
- }
1131
- assertSupportedPlatform();
1132
- assertValueStorable(value);
1133
- if (isLinux()) {
1134
- linuxBackend.set(item, value);
1135
- return;
1136
- }
1137
- if (isWindows()) {
1138
- windowsBackend.set(item, value);
1139
- return;
1140
- }
1141
- // Bare (non-`agents-cli.`) items are written WITHOUT the biometry ACL so
1142
- // they round-trip with the no-prompt read path in getKeychainToken (which
1143
- // also uses /usr/bin/security for non-our items). This is what lets a
1144
- // SessionStart hook read e.g. `linear-api-key` silently on every launch.
1145
- // Routing these through the helper would attach a Touch ID ACL that the
1146
- // /usr/bin/security read can't satisfy without popping the legacy password
1147
- // sheet. -U upserts so repeated sets overwrite in place.
1148
- if (!isOurItem(item)) {
1149
- // The secret VALUE must never appear in argv — a `ps` snapshot on a shared
1150
- // host would leak it (RUSH-1764). `security add-generic-password` has no
1151
- // stdin-password flag; instead a BARE `-w` as the LAST option makes it prompt
1152
- // ("password data for new item:" / "retype...") and read the secret from fd 0
1153
- // via readpassphrase(3) -- so the value travels over stdin, never on the
1154
- // command line. The prompt asks twice (enter + confirm), so we pipe the value
1155
- // TWICE; a single line fails the confirm and would store an empty secret. The
1156
- // item stays ACL-free (no biometry gate), so the no-prompt /usr/bin/security
1157
- // read path in getKeychainToken still works. Values are newline-free on darwin
1158
- // (assertValueStorable above), so each line carries the whole secret verbatim
1159
- // (no shell/quoting layer). `timeout` bounds the call so a context that cannot
1160
- // read the prompt fails loudly instead of hanging.
1161
- //
1162
- // `detached: true` is load-bearing, not an afterthought: readpassphrase(3)
1163
- // reads from the *controlling terminal* (`/dev/tty`) when one exists, and only
1164
- // falls back to fd 0 when it cannot open one. So piping over stdin works in a
1165
- // headless/CI context (no controlling tty) but is IGNORED in an interactive
1166
- // shell — there `security` prompts the real user ("password data for new
1167
- // item:") and hangs to the timeout, and any keystroke would be stored AS the
1168
- // secret. `detached` runs the child in a new session (setsid) with no
1169
- // controlling terminal, so readpassphrase always falls back to our piped
1170
- // stdin. Without it, an interactive `agents view` (refreshing+saving a Claude
1171
- // OAuth token) or `agents secrets add` pops a keychain password sheet.
1172
- const sec = spawnKeychainHelper('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), buildAddGenericPasswordSpawnOptions(value), KEYCHAIN_SILENT_TIMEOUT_MS);
1173
- if (sec.status !== 0) {
1174
- const msg = sec.stderr?.toString().trim();
1175
- throw new Error(msg || `Failed to write keychain item '${item}'.`);
1176
- }
1177
- clearKeychainReadBackoff(requested);
1178
- return;
1179
- }
1180
- const bin = getKeychainHelperPath();
1181
- // `never` policy → no-ACL write. The `set-no-acl` subcommand exists only in a
1182
- // re-notarized helper; an older pinned helper dies with "Unknown command:
1183
- // set-no-acl" (exit 2), surfaced below — never a silent ACL'd downgrade.
1184
- const helperCmd = opts?.noAcl ? 'set-no-acl' : 'set';
1185
- const result = spawnKeychainHelper(bin, [helperCmd, item, os.userInfo().username], {
1186
- input: value,
1187
- stdio: ['pipe', 'pipe', 'pipe'],
1188
- }, KEYCHAIN_SILENT_TIMEOUT_MS);
1189
- if (result.status !== 0) {
1190
- const msg = result.stderr?.toString().trim();
1191
- if (opts?.noAcl && /unknown command/i.test(msg ?? '')) {
1192
- throw new Error(`The 'never' prompt-policy needs a Keychain helper with the no-ACL write path, ` +
1193
- `but the installed helper does not support it. Rebuild + re-notarize the signed ` +
1194
- `helper (scripts/build-keychain-helper.sh) and re-pin its sha, then retry. ` +
1195
- `(helper said: ${msg})`);
1196
- }
1197
- throw new Error(msg || `Failed to write keychain item '${item}'.`);
1198
- }
1199
- // A successful write supersedes any open back-off: the item is known-good
1200
- // now, so the next read must not be suppressed by a stale failure memo.
1201
- clearKeychainReadBackoff(requested);
1202
- }
1203
- /** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
1204
- export function deleteKeychainToken(item) {
1205
- const requested = item;
1206
- item = prepareServiceName(item);
1207
- if (backend)
1208
- return backend.delete(item);
1209
- assertSupportedPlatform();
1210
- if (isLinux())
1211
- return linuxBackend.delete(item);
1212
- if (isWindows())
1213
- return windowsBackend.delete(item);
1214
- const bin = getKeychainHelperPath();
1215
- const r = spawnKeychainHelper(bin, ['delete', item, os.userInfo().username], {
1216
- stdio: ['ignore', 'pipe', 'pipe'],
1217
- }, KEYCHAIN_SILENT_TIMEOUT_MS);
1218
- // The helper exits 0 = an item was removed from at least one keychain, 1 =
1219
- // nothing to delete (genuinely absent). Any other outcome means the keychain
1220
- // could not be reached; like hasKeychainToken this must fail loud rather than
1221
- // report a false "nothing was there" (RUSH-2235) — a swallowed failure lets a
1222
- // rename/purge believe it cleared a name it did not.
1223
- if (r.status === 0) {
1224
- // A deleted item must fail its next read as plain "not found", not with a
1225
- // stale back-off error left over from a pre-delete cancel.
1226
- clearKeychainReadBackoff(requested);
1227
- return true;
1228
- }
1229
- if (r.status === 1)
1230
- return false;
1231
- const stderr = r.stderr?.toString().trim();
1232
- throw new Error(stderr ||
1233
- `keychain delete for '${item}' failed (helper exit ${r.status ?? 'null'}` +
1234
- `${r.error ? `: ${r.error.message}` : ''}) — keychain unreachable, deletion unproven.`);
1235
- }
1236
- /**
1237
- * True when the active keychain backend transparently routes reads/writes to
1238
- * the encrypted-file store instead of the OS credential store. This only
1239
- * happens on Linux under the headless / locked-collection fallback
1240
- * (src/lib/secrets/linux.ts); macOS and the test backend always return false.
1241
- *
1242
- * Callers that ALSO enumerate the file store directly (e.g. `listBundles`)
1243
- * use this to avoid double-counting: under the fallback `listKeychainItems`
1244
- * and the direct file enumeration return the same items.
1245
- */
1246
- export function keychainUsesFileFallback() {
1247
- if (backend)
1248
- return false;
1249
- if (isLinux())
1250
- return linuxUsesFileFallback();
1251
- if (isWindows())
1252
- return windowsUsesFileFallback();
1253
- return false;
1254
- }
1255
- /** Enumerate keychain/keyring item names starting with the given prefix.
1256
- * With hashed service names active, the two sub-namespace prefixes callers
1257
- * use (bundle metadata; one bundle's value items) are mapped to their hashed
1258
- * shapes — the returned names are then storage (opaque) names. */
1259
- export function listKeychainItems(prefix) {
1260
- const mapped = prepareListPrefix(prefix);
1261
- const apply = (names) => (mapped.filter ? names.filter(mapped.filter) : names);
1262
- if (backend)
1263
- return apply(backend.list(mapped.prefix));
1264
- assertSupportedPlatform();
1265
- if (isLinux())
1266
- return apply(linuxBackend.list(mapped.prefix));
1267
- if (isWindows())
1268
- return apply(windowsBackend.list(mapped.prefix));
1269
- const bin = getKeychainHelperPath();
1270
- const result = spawnKeychainHelper(bin, ['list', mapped.prefix], {
1271
- stdio: ['ignore', 'pipe', 'pipe'],
1272
- }, KEYCHAIN_SILENT_TIMEOUT_MS);
1273
- if (result.status !== 0) {
1274
- const msg = result.stderr?.toString().trim();
1275
- throw new Error(msg || `Failed to enumerate keychain items with prefix '${prefix}'.`);
1276
- }
1277
- const out = result.stdout?.toString() || '';
1278
- return apply(out.split('\n').map((s) => s.trim()).filter(Boolean));
1279
- }
1280
- /**
1281
- * Enumerate ONLY legacy file-based-keychain item names with the given prefix —
1282
- * the items that still carry a pre-migration (trusted-app) ACL and pop a
1283
- * separate auth sheet on read. Items already in the data-protection keychain are
1284
- * excluded (they need no migration). Silent (attributes only, never decrypts).
1285
- *
1286
- * macOS only: on Linux / the test backend there is no separate legacy keychain,
1287
- * so this returns []. Used by `agents secrets migrate-acl` to rewrite only the
1288
- * stragglers instead of every item (which would be a Touch ID storm).
1289
- */
1290
- export function listLegacyKeychainItems(prefix) {
1291
- if (backend)
1292
- return [];
1293
- assertSupportedPlatform();
1294
- if (isLinux())
1295
- return [];
1296
- const bin = getKeychainHelperPath();
1297
- const result = spawnKeychainHelper(bin, ['list-legacy', prefix], {
1298
- stdio: ['ignore', 'pipe', 'pipe'],
1299
- }, KEYCHAIN_SILENT_TIMEOUT_MS);
1300
- if (result.status !== 0) {
1301
- const msg = result.stderr?.toString().trim();
1302
- throw new Error(msg || `Failed to enumerate legacy keychain items with prefix '${prefix}'.`);
1303
- }
1304
- const out = result.stdout?.toString() || '';
1305
- return out.split('\n').map((s) => s.trim()).filter(Boolean);
1306
- }
1307
- let syncedBackend = null;
1308
- export function setSyncedKeychainBackendForTest(b) {
1309
- const prev = syncedBackend;
1310
- syncedBackend = b;
1311
- return prev;
1312
- }
1313
- /**
1314
- * Enumerate LEGACY SYNCHRONIZABLE (iCloud Keychain) item names with the given
1315
- * prefix — bundles written by the pre-biometry helper era, which defaulted
1316
- * secrets to iCloud Keychain sync. The device-local cutover orphaned them:
1317
- * every modern query pins synchronizable=false, so only the helper's
1318
- * `list-synced` verb can see them. Silent (attributes only, never decrypts).
1319
- * macOS only — Linux/Windows never had iCloud Keychain sync, so this returns [].
1320
- */
1321
- export function listSyncedKeychainItems(prefix) {
1322
- if (syncedBackend)
1323
- return syncedBackend.list(prefix);
1324
- if (backend)
1325
- return [];
1326
- assertSupportedPlatform();
1327
- if (isLinux() || isWindows())
1328
- return [];
1329
- const bin = getKeychainHelperPath();
1330
- const result = spawnKeychainHelper(bin, ['list-synced', prefix], {
1331
- stdio: ['ignore', 'pipe', 'pipe'],
1332
- }, KEYCHAIN_SILENT_TIMEOUT_MS);
1333
- if (result.status !== 0) {
1334
- const msg = result.stderr?.toString().trim();
1335
- throw new Error(msg || `Failed to enumerate iCloud keychain items with prefix '${prefix}'.`);
1336
- }
1337
- const out = result.stdout?.toString() || '';
1338
- return out.split('\n').map((s) => s.trim()).filter(Boolean);
1339
- }
1340
- /**
1341
- * Batch-read LEGACY SYNCHRONIZABLE (iCloud Keychain) items. Returns a map of
1342
- * item name → value; missing items are simply absent. Pre-biometry items carry
1343
- * no biometry ACL, so this does not normally prompt. macOS only — returns an
1344
- * empty map on Linux/Windows.
1345
- */
1346
- export function getSyncedKeychainTokens(items) {
1347
- const result = new Map();
1348
- if (items.length === 0)
1349
- return result;
1350
- if (syncedBackend)
1351
- return syncedBackend.getBatch(items);
1352
- if (backend)
1353
- return result;
1354
- assertSupportedPlatform();
1355
- if (isLinux() || isWindows())
1356
- return result;
1357
- const bin = getKeychainHelperPath();
1358
- const child = spawnKeychainHelper(bin, ['get-batch-synced', os.userInfo().username, ...items], {
1359
- stdio: ['ignore', 'pipe', 'pipe'],
1360
- }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
1361
- if (child.status === 4) {
1362
- throw new Error(`Auth cancelled while reading ${items.length} iCloud keychain item(s).`);
1363
- }
1364
- if (child.status !== 0) {
1365
- const msg = child.stderr?.toString().trim();
1366
- throw new Error(msg || `Failed to batch-read ${items.length} iCloud keychain items.`);
1367
- }
1368
- parseBatchRecords(child.stdout?.toString() ?? '', (service, value) => { result.set(service, value); });
1369
- return result;
1370
- }
1371
- /**
1372
- * Delete a LEGACY SYNCHRONIZABLE (iCloud Keychain) item after a successful
1373
- * import (`--purge`). Matches synchronizable items only — the device-local
1374
- * copy the import wrote is untouched. iCloud propagates the deletion to the
1375
- * user's other devices. Returns true if a copy was removed.
1376
- */
1377
- export function deleteSyncedKeychainItem(item) {
1378
- if (syncedBackend)
1379
- return syncedBackend.delete(item);
1380
- if (backend)
1381
- return false;
1382
- assertSupportedPlatform();
1383
- if (isLinux() || isWindows())
1384
- return false;
1385
- const bin = getKeychainHelperPath();
1386
- return spawnKeychainHelper(bin, ['delete-synced', item, os.userInfo().username], {
1387
- stdio: ['ignore', 'pipe', 'pipe'],
1388
- }, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
1389
- }
1390
- /**
1391
- * One-time upgrade for a keychain item that was written by a previous helper
1392
- * generation with a trusted-app ACL. The helper reads the legacy item
1393
- * (which may pop the password sheet once), then deletes and re-adds it with
1394
- * the biometry access control. Returns true if the item was rewritten, false
1395
- * if no item by that name exists. macOS only — Linux backends have no ACL
1396
- * concept, so the call is a no-op there.
1397
- */
1398
- export function migrateKeychainItem(item) {
1399
- if (backend)
1400
- return backend.has(item);
1401
- assertSupportedPlatform();
1402
- if (isLinux())
1403
- return linuxBackend.has(item);
1404
- if (isWindows())
1405
- return windowsBackend.has(item);
1406
- const bin = getKeychainHelperPath();
1407
- const result = spawnKeychainHelper(bin, ['migrate-acl', item, os.userInfo().username], {
1408
- stdio: ['ignore', 'pipe', 'pipe'],
1409
- }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
1410
- if (result.status === 0)
1411
- return true;
1412
- if (result.status === 1)
1413
- return false;
1414
- const msg = result.stderr?.toString().trim();
1415
- throw new Error(msg || `Failed to migrate keychain item '${item}'.`);
1416
- }
1417
- /**
1418
- * Enumerate data-protection items whose service starts with `prefix` that live
1419
- * under a NON-concrete access group — pre-#279 "orphans" filed under the implicit
1420
- * default group (the literal `2HTP252L87.*`) that the pinned-group queries can't
1421
- * see. Attributes only: never decrypts, never prompts. macOS only — Linux/Windows
1422
- * and the test backend have no access-group concept, so this returns [].
1423
- */
1424
- export function listOrphanedKeychainItems(prefix) {
1425
- if (backend)
1426
- return [];
1427
- assertSupportedPlatform();
1428
- if (isLinux() || isWindows())
1429
- return [];
1430
- const bin = getKeychainHelperPath();
1431
- const result = spawnKeychainHelper(bin, ['list-orphans', prefix, os.userInfo().username], {
1432
- stdio: ['ignore', 'pipe', 'pipe'],
1433
- }, KEYCHAIN_SILENT_TIMEOUT_MS);
1434
- if (result.status !== 0) {
1435
- const msg = result.stderr?.toString().trim();
1436
- throw new Error(msg || `Failed to enumerate orphaned keychain items with prefix '${prefix}'.`);
1437
- }
1438
- const out = result.stdout?.toString() || '';
1439
- return out.split('\n').map((s) => s.trim()).filter(Boolean);
1440
- }
1441
- /**
1442
- * Parse the `migrate-orphans` helper summary (one record per line):
1443
- * OK <service> re-homed
1444
- * WARN <service> <detail> pinned copy written but orphan not removed
1445
- * FAIL <service> <detail> could not re-home (orphan left intact)
1446
- * Unknown lines are ignored. Exported for unit testing without a keychain.
1447
- */
1448
- export function parseOrphanMigrationOutput(stdout) {
1449
- const results = [];
1450
- for (const line of stdout.split('\n')) {
1451
- const trimmed = line.trim();
1452
- if (!trimmed)
1453
- continue;
1454
- const sep = trimmed.indexOf(' ');
1455
- const tag = sep === -1 ? trimmed : trimmed.slice(0, sep);
1456
- const rest = sep === -1 ? '' : trimmed.slice(sep + 1);
1457
- if (tag === 'OK') {
1458
- // OK carries only the service name (no trailing detail).
1459
- results.push({ item: rest, status: 'ok' });
1460
- }
1461
- else if (tag === 'WARN' || tag === 'FAIL') {
1462
- // WARN/FAIL are 'TAG <service> <detail>'. Service names are space-free
1463
- // (validateBundleName / validateEnvKey), so the first token IS the exact
1464
- // service — this stays consistent with listOrphanedKeychainItems for the
1465
- // healed-set reconciliation in migrate-acl.
1466
- const item = rest.split(' ')[0] ?? rest;
1467
- results.push({ item, status: tag === 'WARN' ? 'warn' : 'fail', detail: rest });
1468
- }
1469
- }
1470
- return results;
1471
- }
1472
- /**
1473
- * Re-home every pre-#279 orphaned data-protection item under `prefix` into the
1474
- * concrete access group, behind a SINGLE Touch ID prompt for the whole batch.
1475
- * The helper reads each orphan by its exact persistent ref, adds the pinned copy
1476
- * (add-before-delete: a failed add leaves the orphan intact), then deletes the
1477
- * orphan by ref. Returns one result per item. macOS only — no-op elsewhere.
1478
- *
1479
- * Throws on Touch ID cancellation (exit 4) so callers can distinguish "user
1480
- * aborted" from "nothing to do" (empty array).
1481
- */
1482
- export function migrateOrphanedKeychainItems(prefix) {
1483
- if (backend)
1484
- return [];
1485
- assertSupportedPlatform();
1486
- if (isLinux() || isWindows())
1487
- return [];
1488
- const bin = getKeychainHelperPath();
1489
- const result = spawnKeychainHelper(bin, ['migrate-orphans', prefix, os.userInfo().username], {
1490
- stdio: ['ignore', 'pipe', 'pipe'],
1491
- }, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
1492
- if (result.status === 4)
1493
- throw new Error('Touch ID cancelled during orphan migration.');
1494
- if (result.status !== 0) {
1495
- const msg = result.stderr?.toString().trim();
1496
- throw new Error(msg || `Failed to migrate orphaned keychain items with prefix '${prefix}'.`);
1497
- }
1498
- return parseOrphanMigrationOutput(result.stdout?.toString() || '');
1499
- }
1500
- /**
1501
- * Import agents-cli secrets from the native store (GNOME Keyring / Windows
1502
- * Credential Manager) into the encrypted file store — the Linux/Windows
1503
- * analogue of the macOS orphan/legacy migration, exposed as
1504
- * `agents secrets import-keyring`. Requires the native store to be
1505
- * reachable/unlocked; `commit=false` is a dry-run. macOS returns an empty
1506
- * report (it has no file fallback and uses `migrate-acl` instead).
1507
- */
1508
- export function importNativeItems(prefix, commit) {
1509
- if (backend)
1510
- return { available: false, locked: false, results: [] };
1511
- assertSupportedPlatform();
1512
- if (isLinux())
1513
- return importNativeSecretToolItems(prefix, commit);
1514
- if (isWindows())
1515
- return importNativeCredManItems(prefix, commit);
1516
- return { available: false, locked: false, results: [] };
1517
- }
1518
- function expandHome(p) {
1519
- if (p.startsWith('~/') || p === '~') {
1520
- return path.join(os.homedir(), p.slice(1));
1521
- }
1522
- return p;
1523
- }
1524
- /** Resolve a secret ref to its plaintext value using the appropriate provider. */
1525
- export function resolveRef(ref, opts = {}) {
1526
- switch (ref.provider) {
1527
- case 'keychain': {
1528
- const item = opts.keychainItemFor ? opts.keychainItemFor(ref.value) : ref.value;
1529
- return getKeychainToken(item);
1530
- }
1531
- case 'env': {
1532
- const name = ref.value;
1533
- if (opts.envAllowlist && !opts.envAllowlist.includes(name)) {
1534
- throw new Error(`env: ref '${name}' not in allowlist.`);
1535
- }
1536
- const val = process.env[name];
1537
- if (val === undefined) {
1538
- throw new Error(`env: ref '${name}' not set in parent environment.`);
1539
- }
1540
- return val;
1541
- }
1542
- case 'file': {
1543
- const target = expandHome(ref.value);
1544
- if (!fs.existsSync(target)) {
1545
- throw new Error(`file: ref '${ref.value}' does not exist.`);
1546
- }
1547
- return fs.readFileSync(target, 'utf-8').trim();
1548
- }
1549
- case 'exec': {
1550
- if (!opts.allowExec) {
1551
- throw new Error(`exec: ref '${ref.value}' blocked. Set 'allow_exec: true' in the bundle to enable.`);
1552
- }
1553
- // shell: false — the bundle author controls the command; no injection
1554
- // from secret identifiers. Parse a simple space-separated command.
1555
- const parts = ref.value.match(/(?:[^\s"]+|"[^"]*")+/g)?.map((p) => p.replace(/^"|"$/g, '')) || [];
1556
- if (parts.length === 0) {
1557
- throw new Error(`exec: ref '${ref.value}' is empty.`);
1558
- }
1559
- const [cmd, ...args] = parts;
1560
- try {
1561
- return execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
1562
- }
1563
- catch (err) {
1564
- throw new Error(`exec: ref '${ref.value}' failed: ${err.message}`);
1565
- }
1566
- }
1567
- }
1568
- }