@phnx-labs/agents-cli 1.20.52 → 1.20.53

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 (107) hide show
  1. package/CHANGELOG.md +57 -2
  2. package/README.md +12 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.d.ts +3 -0
  5. package/dist/commands/cloud.js +2 -1
  6. package/dist/commands/exec.js +65 -1
  7. package/dist/commands/feed.d.ts +29 -0
  8. package/dist/commands/feed.js +237 -32
  9. package/dist/commands/memory.d.ts +9 -0
  10. package/dist/commands/memory.js +164 -0
  11. package/dist/commands/message.d.ts +11 -6
  12. package/dist/commands/message.js +140 -5
  13. package/dist/commands/routines.js +12 -0
  14. package/dist/commands/secrets-migrate.d.ts +2 -1
  15. package/dist/commands/secrets-migrate.js +88 -13
  16. package/dist/commands/secrets.js +4 -1
  17. package/dist/commands/sessions.js +10 -1
  18. package/dist/commands/worktree.js +4 -2
  19. package/dist/index.js +16 -21
  20. package/dist/lib/agents.js +249 -17
  21. package/dist/lib/answer-router.d.ts +75 -0
  22. package/dist/lib/answer-router.js +149 -0
  23. package/dist/lib/ask-classifier.d.ts +71 -0
  24. package/dist/lib/ask-classifier.js +197 -0
  25. package/dist/lib/cloud/antigravity.d.ts +0 -2
  26. package/dist/lib/cloud/antigravity.js +2 -17
  27. package/dist/lib/cloud/codex.js +3 -18
  28. package/dist/lib/cloud/rush.js +3 -15
  29. package/dist/lib/cloud/stream.js +2 -0
  30. package/dist/lib/cloud/types.d.ts +21 -0
  31. package/dist/lib/cloud/types.js +81 -0
  32. package/dist/lib/crabbox/cli.d.ts +1 -1
  33. package/dist/lib/crabbox/cli.js +12 -2
  34. package/dist/lib/crabbox/lease.d.ts +13 -0
  35. package/dist/lib/crabbox/lease.js +11 -2
  36. package/dist/lib/crabbox/progress.d.ts +62 -0
  37. package/dist/lib/crabbox/progress.js +129 -0
  38. package/dist/lib/events.js +4 -1
  39. package/dist/lib/exec.js +19 -1
  40. package/dist/lib/feed-outcome.d.ts +101 -0
  41. package/dist/lib/feed-outcome.js +244 -0
  42. package/dist/lib/feed-policy.d.ts +30 -0
  43. package/dist/lib/feed-policy.js +133 -0
  44. package/dist/lib/feed.d.ts +127 -3
  45. package/dist/lib/feed.js +416 -40
  46. package/dist/lib/git.d.ts +17 -1
  47. package/dist/lib/git.js +20 -1
  48. package/dist/lib/hooks.js +522 -12
  49. package/dist/lib/hosts/passthrough.d.ts +3 -3
  50. package/dist/lib/hosts/passthrough.js +3 -4
  51. package/dist/lib/mailbox-gc.d.ts +22 -0
  52. package/dist/lib/mailbox-gc.js +161 -0
  53. package/dist/lib/mailbox.d.ts +26 -2
  54. package/dist/lib/mailbox.js +80 -5
  55. package/dist/lib/mcp.js +82 -0
  56. package/dist/lib/memory.d.ts +55 -0
  57. package/dist/lib/memory.js +274 -0
  58. package/dist/lib/notify.d.ts +16 -0
  59. package/dist/lib/notify.js +61 -0
  60. package/dist/lib/operator.d.ts +26 -0
  61. package/dist/lib/operator.js +107 -0
  62. package/dist/lib/plugins.d.ts +35 -0
  63. package/dist/lib/plugins.js +217 -0
  64. package/dist/lib/remote-agents-json.d.ts +14 -0
  65. package/dist/lib/remote-agents-json.js +94 -0
  66. package/dist/lib/resources/mcp.js +44 -0
  67. package/dist/lib/resources/memory.d.ts +15 -0
  68. package/dist/lib/resources/memory.js +46 -0
  69. package/dist/lib/resources/types.d.ts +2 -2
  70. package/dist/lib/runner.d.ts +43 -0
  71. package/dist/lib/runner.js +323 -74
  72. package/dist/lib/sandbox.js +6 -0
  73. package/dist/lib/secrets/bundles.js +38 -13
  74. package/dist/lib/secrets/icloud-import.d.ts +12 -3
  75. package/dist/lib/secrets/icloud-import.js +37 -7
  76. package/dist/lib/secrets/index.d.ts +106 -2
  77. package/dist/lib/secrets/index.js +603 -28
  78. package/dist/lib/session/active.d.ts +18 -0
  79. package/dist/lib/session/active.js +47 -17
  80. package/dist/lib/session/db.d.ts +9 -1
  81. package/dist/lib/session/db.js +18 -3
  82. package/dist/lib/session/discover.d.ts +13 -0
  83. package/dist/lib/session/discover.js +31 -0
  84. package/dist/lib/session/parse.d.ts +8 -0
  85. package/dist/lib/session/parse.js +42 -21
  86. package/dist/lib/session/remote-active.js +8 -89
  87. package/dist/lib/session/state.d.ts +11 -0
  88. package/dist/lib/session/state.js +37 -0
  89. package/dist/lib/session/tail.d.ts +23 -4
  90. package/dist/lib/session/tail.js +34 -16
  91. package/dist/lib/session/throughput.d.ts +30 -0
  92. package/dist/lib/session/throughput.js +86 -0
  93. package/dist/lib/shim-heal.d.ts +12 -3
  94. package/dist/lib/shim-heal.js +12 -6
  95. package/dist/lib/staleness/detectors/subagents.js +57 -3
  96. package/dist/lib/staleness/writers/hooks.js +7 -3
  97. package/dist/lib/staleness/writers/subagents.js +37 -6
  98. package/dist/lib/startup/command-registry.d.ts +1 -0
  99. package/dist/lib/startup/command-registry.js +2 -0
  100. package/dist/lib/subagents.d.ts +52 -0
  101. package/dist/lib/subagents.js +315 -12
  102. package/dist/lib/teams/worktree.d.ts +8 -0
  103. package/dist/lib/teams/worktree.js +8 -0
  104. package/dist/lib/types.d.ts +10 -2
  105. package/dist/lib/versions.js +38 -48
  106. package/package.json +4 -3
  107. package/scripts/postinstall.js +61 -1
@@ -23,6 +23,7 @@
23
23
  * rather than the system's cloud-keychain path.
24
24
  */
25
25
  import { execFileSync, spawnSync } from 'child_process';
26
+ import { createHmac, randomBytes } from 'node:crypto';
26
27
  import * as fs from 'fs';
27
28
  import * as os from 'os';
28
29
  import * as path from 'path';
@@ -97,6 +98,10 @@ let backend = null;
97
98
  export function setKeychainBackendForTest(b) {
98
99
  const prev = backend;
99
100
  backend = b;
101
+ // The hashing state depends on whether a backend is installed — never let a
102
+ // state resolved against the real keychain leak into a backend-driven test.
103
+ hashStateCache = null;
104
+ autoRekeyAttempted = false;
100
105
  return prev;
101
106
  }
102
107
  /** True when a test backend is installed (real keychain / biometry bypassed).
@@ -118,8 +123,550 @@ export function isKeychainBackendOverridden() {
118
123
  function isOurItem(item) {
119
124
  return item.startsWith('agents-cli.');
120
125
  }
126
+ // ─── Hashed service names (GitHub #316, Finding 1) ──────────────────────────
127
+ //
128
+ // The helper's `list` never decrypts and never prompts (by design), which made
129
+ // service names enumerable metadata: any same-user process could silently read
130
+ // every bundle, key, and provider name (`agents-cli.secrets.<bundle>.<KEY>`,
131
+ // `agents-cli.<provider>.token`) and build a target list before ever popping
132
+ // Touch ID. To close that, on macOS every item in our namespace is stored
133
+ // under an opaque HMAC-SHA256-hashed service name:
134
+ //
135
+ // agents-cli.bundles.<name> → agents-cli.h.<ns>.m
136
+ // agents-cli.secrets.<bundle>.<KEY> → agents-cli.h.<ns>.k.<kh>
137
+ // agents-cli.<anything else> → agents-cli.h.o.<ih>
138
+ //
139
+ // where <ns> = HMAC(key, 'ns\0'+bundle) and <kh>/<ih> are per-item HMACs
140
+ // (first 32 hex chars each). The per-bundle <ns> segment is deliberate: a
141
+ // bundle's value items keep a common silent-enumerable prefix
142
+ // (`agents-cli.h.<ns>.k.`), so readAndResolveBundleEnv still fetches metadata
143
+ // + all values in ONE get-batch behind ONE Touch ID — a flat hash of the full
144
+ // name would have forced a second prompt on every bundle read. Names still
145
+ // start with `agents-cli.`, so the helper's JIT-migration guard and prefix
146
+ // gates keep working. What an enumerator learns shrinks to item grouping and
147
+ // counts — never a bundle, key, or provider name.
148
+ //
149
+ // The HMAC key is 32 random bytes in `agents-cli.hmackey`, written through the
150
+ // helper's no-ACL path (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
151
+ // device-local, access-group-pinned). Deliberately NO user-presence ACL: the
152
+ // key protects metadata confidentiality only, and gating it behind Touch ID
153
+ // would make every silent operation (list/has) prompt. Per-machine is fine —
154
+ // sync re-materializes items locally through the same primitives, so hashed
155
+ // names never leave the machine. Deriving the key from machine constants was
156
+ // rejected: that would hand any local process a dictionary-confirmation
157
+ // oracle without even touching the keychain.
158
+ //
159
+ // Hashing activates only after the one-time re-key migration
160
+ // (rekeyServiceNames below / `agents secrets rekey`) has moved every existing
161
+ // cleartext-named item; until then all operations use cleartext names exactly
162
+ // as before. The sentinel lives INSIDE the hmackey record — in the keychain,
163
+ // not on disk — so it can never desync from the items it describes (e.g. a
164
+ // keychain restored from a backup brings its matching state along).
165
+ const HASHED_SERVICE_PREFIX = `${SERVICE_PREFIX}.h.`;
166
+ export const HMAC_KEY_ITEM = `${SERVICE_PREFIX}.hmackey`;
167
+ const HASHED_META_RE = /^agents-cli\.h\.[0-9a-f]{32}\.m$/;
168
+ let hashStateCache = null;
169
+ let forcedTestKey = null;
170
+ let rawScopeDepth = 0;
171
+ let rekeyRunning = false;
172
+ let autoRekeyAttempted = false;
173
+ /** Force hashed service names on with a fixed key (test only). Pass null to
174
+ * restore lazy production resolution. Composes with setKeychainBackendForTest
175
+ * so unit tests exercise the exact transform production uses. */
176
+ export function setKeychainServiceHashingForTest(key) {
177
+ forcedTestKey = key;
178
+ hashStateCache = null;
179
+ autoRekeyAttempted = false;
180
+ }
181
+ /**
182
+ * Run `fn` with service-name hashing suspended: every primitive uses the
183
+ * literal names it is given. For migration flows ONLY — they enumerate raw
184
+ * names from the helper (which may be pre-re-key cleartext leftovers) and must
185
+ * read/delete those exact items, not their hashed transforms.
186
+ */
187
+ export function withRawKeychainServiceNames(fn) {
188
+ rawScopeDepth++;
189
+ try {
190
+ return fn();
191
+ }
192
+ finally {
193
+ rawScopeDepth--;
194
+ }
195
+ }
196
+ function hmacHex32(key, input) {
197
+ return createHmac('sha256', key).update(input, 'utf8').digest('hex').slice(0, 32);
198
+ }
199
+ function bundleNamespaceHash(bundle, key) {
200
+ return hmacHex32(key, `ns\0${bundle}`);
201
+ }
202
+ /** The hashed (storage) service name for a cleartext item name. Exported for
203
+ * the re-key migration and tests; runtime callers go through the primitives,
204
+ * which apply this transparently. */
205
+ export function hashedServiceName(item, key) {
206
+ if (item.startsWith(BUNDLES_ITEM_PREFIX)) {
207
+ const name = item.slice(BUNDLES_ITEM_PREFIX.length);
208
+ return `${HASHED_SERVICE_PREFIX}${bundleNamespaceHash(name, key)}.m`;
209
+ }
210
+ if (item.startsWith(SECRETS_ITEM_PREFIX)) {
211
+ // Bundle names may contain dots; env keys and wallet ids never do — the
212
+ // LAST dot is the unambiguous bundle/key split.
213
+ const rest = item.slice(SECRETS_ITEM_PREFIX.length);
214
+ const dot = rest.lastIndexOf('.');
215
+ if (dot > 0 && dot < rest.length - 1) {
216
+ const bundle = rest.slice(0, dot);
217
+ const keyName = rest.slice(dot + 1);
218
+ return `${HASHED_SERVICE_PREFIX}${bundleNamespaceHash(bundle, key)}.k.${hmacHex32(key, `kv\0${bundle}\0${keyName}`)}`;
219
+ }
220
+ }
221
+ return `${HASHED_SERVICE_PREFIX}o.${hmacHex32(key, `it\0${item}`)}`;
222
+ }
223
+ function parseHmacKeyRecord(raw) {
224
+ try {
225
+ const rec = JSON.parse(raw);
226
+ if (rec && typeof rec === 'object' && rec.v === 1 && typeof rec.k === 'string' && /^[0-9a-f]{64}$/.test(rec.k)) {
227
+ return rec;
228
+ }
229
+ }
230
+ catch {
231
+ /* malformed — treated as absent */
232
+ }
233
+ return null;
234
+ }
235
+ function readHmacKeyRecord() {
236
+ // HMAC_KEY_ITEM is exempt from the transform, so this routes to the helper
237
+ // (or the test backend) under its literal name. The item is no-ACL, so the
238
+ // read is silent.
239
+ let raw;
240
+ try {
241
+ raw = getKeychainToken(HMAC_KEY_ITEM);
242
+ }
243
+ catch {
244
+ return null;
245
+ }
246
+ return parseHmacKeyRecord(raw);
247
+ }
248
+ function writeHmacKeyRecord(rec) {
249
+ // JSON.stringify drops undefined fields (used to clear pendingDeletes).
250
+ // noAcl: reads of this record must stay prompt-free; an old pinned helper
251
+ // without the set-no-acl path rejects this loudly (see setKeychainToken),
252
+ // which is exactly the "old helper never half-runs the re-key" gate.
253
+ setKeychainToken(HMAC_KEY_ITEM, JSON.stringify(rec), { noAcl: true });
254
+ hashStateCache = null;
255
+ }
256
+ function resolveHashState() {
257
+ if (forcedTestKey)
258
+ return { active: true, key: forcedTestKey, record: null };
259
+ if (hashStateCache)
260
+ return hashStateCache;
261
+ if (backend || process.platform !== 'darwin' || process.env.AGENTS_SECRETS_HASH_NAMES === '0') {
262
+ hashStateCache = { active: false, key: null, record: null };
263
+ return hashStateCache;
264
+ }
265
+ const record = readHmacKeyRecord();
266
+ const key = record ? Buffer.from(record.k, 'hex') : null;
267
+ // AGENTS_SECRETS_HASH_NAMES=1 forces hashing on before the machine-wide
268
+ // sentinel flips — used to verify a partial (--prefix) re-key end-to-end.
269
+ const active = !!record && (record.migrated || process.env.AGENTS_SECRETS_HASH_NAMES === '1');
270
+ hashStateCache = { active, key, record };
271
+ return hashStateCache;
272
+ }
273
+ /**
274
+ * The storage-layer service name for `item`: hashed when hashing is active,
275
+ * the item itself otherwise. For callers that mix helper-enumerated
276
+ * (already-hashed) names with computed cleartext names in one lookup map —
277
+ * see readAndResolveBundleEnv.
278
+ */
279
+ export function keychainServiceAlias(item) {
280
+ return prepareServiceName(item);
281
+ }
282
+ function prepareServiceName(item, opts) {
283
+ if (rawScopeDepth > 0)
284
+ return item;
285
+ if (!isOurItem(item))
286
+ return item;
287
+ if (item === HMAC_KEY_ITEM || item.startsWith(HASHED_SERVICE_PREFIX))
288
+ return item;
289
+ if (opts?.autoRekey)
290
+ maybeAutoRekey();
291
+ const st = resolveHashState();
292
+ if (!st.active || !st.key)
293
+ return item;
294
+ return hashedServiceName(item, st.key);
295
+ }
296
+ /**
297
+ * Map a cleartext enumeration prefix to its hashed-storage equivalent. Only
298
+ * two shapes are ever enumerated at sub-namespace granularity (bundle
299
+ * metadata, and one bundle's value items); both map to a broad `agents-cli.`
300
+ * helper query plus a client-side filter. Every mapped filter is a UNION with
301
+ * the original cleartext prefix so mid-migration leftovers (or items written
302
+ * by an older CLI on this machine) stay visible to migration tooling.
303
+ */
304
+ function prepareListPrefix(prefix) {
305
+ if (rawScopeDepth > 0)
306
+ return { prefix };
307
+ if (!prefix.startsWith(`${SERVICE_PREFIX}.`))
308
+ return { prefix };
309
+ if (prefix.startsWith(HASHED_SERVICE_PREFIX))
310
+ return { prefix };
311
+ maybeAutoRekey();
312
+ const st = resolveHashState();
313
+ if (!st.active || !st.key)
314
+ return { prefix };
315
+ if (prefix === BUNDLES_ITEM_PREFIX) {
316
+ return {
317
+ prefix: `${SERVICE_PREFIX}.`,
318
+ filter: (s) => HASHED_META_RE.test(s) || s.startsWith(BUNDLES_ITEM_PREFIX),
319
+ };
320
+ }
321
+ if (prefix.startsWith(SECRETS_ITEM_PREFIX) && prefix.endsWith('.') && prefix.length > SECRETS_ITEM_PREFIX.length + 1) {
322
+ const bundle = prefix.slice(SECRETS_ITEM_PREFIX.length, -1);
323
+ const hashedValuePrefix = `${HASHED_SERVICE_PREFIX}${bundleNamespaceHash(bundle, st.key)}.k.`;
324
+ return {
325
+ prefix: `${SERVICE_PREFIX}.`,
326
+ filter: (s) => s.startsWith(hashedValuePrefix) || s.startsWith(prefix),
327
+ };
328
+ }
329
+ return { prefix };
330
+ }
331
+ function listCleartextServices(prefixes) {
332
+ const all = withRawKeychainServiceNames(() => listKeychainItems(`${SERVICE_PREFIX}.`));
333
+ return all.filter((s) => s.startsWith(`${SERVICE_PREFIX}.`) &&
334
+ !s.startsWith(HASHED_SERVICE_PREFIX) &&
335
+ s !== HMAC_KEY_ITEM &&
336
+ (!prefixes || prefixes.some((p) => s.startsWith(p))));
337
+ }
338
+ function ensureHmacKeyRecord(markMigratedIfCreating) {
339
+ const existing = readHmacKeyRecord();
340
+ if (existing)
341
+ return existing;
342
+ const fresh = { v: 1, k: randomBytes(32).toString('hex'), migrated: markMigratedIfCreating };
343
+ writeHmacKeyRecord(fresh);
344
+ // If two processes raced the first write, the keychain holds exactly one
345
+ // winner — adopt whatever is stored NOW so both sides converge on a single
346
+ // key before hashing anything under it.
347
+ return readHmacKeyRecord() ?? fresh;
348
+ }
349
+ /**
350
+ * Guard against a silently-degraded enumeration. The helper's `list` skips the
351
+ * data-protection pass wholesale when the DP keybag is locked (screen lock —
352
+ * see keychain-helper.swift, errSecInteractionNotAllowed handling), returning
353
+ * an EMPTY result even though items exist and no-ACL reads/writes still work.
354
+ * Observed live on macOS 26: `set-no-acl` + `get` succeed while `list` of the
355
+ * just-written item returns nothing. Without this probe, a re-key run in that
356
+ * state would see "zero cleartext items" and wrongly activate hashed naming,
357
+ * making every existing cleartext item invisible after unlock.
358
+ *
359
+ * The probe requires the hmackey record (a DP item that provably exists — the
360
+ * caller just ensured it) to appear in a raw enumeration. Trivially true for
361
+ * the in-memory test backend.
362
+ */
363
+ function assertEnumerationTrustworthy() {
364
+ const all = withRawKeychainServiceNames(() => listKeychainItems(`${SERVICE_PREFIX}.`));
365
+ if (!all.includes(HMAC_KEY_ITEM)) {
366
+ throw new Error('keychain enumeration is unavailable (locked keybag / screen lock?) — refusing to decide the re-key on an empty listing. Retry while unlocked.');
367
+ }
368
+ }
369
+ function finishPendingDeletes(rec) {
370
+ const pending = rec.pendingDeletes ?? [];
371
+ if (pending.length === 0)
372
+ return;
373
+ withRawKeychainServiceNames(() => {
374
+ for (const service of pending)
375
+ deleteKeychainToken(service);
376
+ });
377
+ writeHmacKeyRecord({ ...rec, pendingDeletes: undefined });
378
+ }
379
+ /**
380
+ * One-shot per process: activate hashing on machines with nothing to move,
381
+ * finish a crash-interrupted delete phase (silent), and run the interactive
382
+ * one-time re-key when cleartext-named items exist and a human is present.
383
+ * Never throws — a failed attempt leaves the process on cleartext names
384
+ * (exact pre-#316 behavior) and the next process retries.
385
+ */
386
+ function maybeAutoRekey() {
387
+ if (autoRekeyAttempted || rekeyRunning || rawScopeDepth > 0)
388
+ return;
389
+ autoRekeyAttempted = true;
390
+ if (forcedTestKey || backend)
391
+ return;
392
+ // Never auto-mutate the developer's real keychain from a test runner.
393
+ if (process.env.VITEST)
394
+ return;
395
+ if (process.platform !== 'darwin')
396
+ return;
397
+ if (process.env.AGENTS_SECRETS_NO_AUTO_REKEY === '1')
398
+ return;
399
+ if (process.env.AGENTS_SECRETS_HASH_NAMES === '0')
400
+ return;
401
+ const st = resolveHashState();
402
+ if (st.active) {
403
+ if (st.record?.pendingDeletes?.length) {
404
+ try {
405
+ finishPendingDeletes(st.record);
406
+ }
407
+ catch {
408
+ /* next process retries */
409
+ }
410
+ }
411
+ return;
412
+ }
413
+ let cleartext;
414
+ try {
415
+ cleartext = listCleartextServices();
416
+ }
417
+ catch {
418
+ return;
419
+ }
420
+ // Moving real items pops Touch ID — only auto-run with a human present. An
421
+ // empty listing still goes through rekeyServiceNames (prompt-free): it
422
+ // verifies the enumeration is trustworthy before activating on "nothing to
423
+ // migrate", so a locked keybag can never masquerade as a fresh machine.
424
+ const interactive = process.stdin.isTTY && process.stderr.isTTY;
425
+ if (cleartext.length > 0 && !interactive)
426
+ return;
427
+ try {
428
+ rekeyServiceNames({ announce: cleartext.length > 0, log: (line) => console.error(line) });
429
+ }
430
+ catch (err) {
431
+ // Headless processes stay quiet (e.g. locked-keybag probe failures would
432
+ // otherwise spam every background run); a human gets the pointer.
433
+ if (interactive) {
434
+ console.error(`agents secrets: one-time re-key did not complete (${err.message}). ` +
435
+ `Keychain service names remain enumerable; run 'agents secrets rekey' to retry.`);
436
+ }
437
+ }
438
+ }
439
+ /**
440
+ * Build the old→new mapping for a set of cleartext services. Bundle metadata
441
+ * is parsed first to (a) recover each bundle's prompt policy — the persisted
442
+ * `tier` token, where `none`/`never` means the item must be re-written through
443
+ * the no-ACL path — and (b) inject the cleartext `name` into the JSON, because
444
+ * after hashing the service name can no longer carry it (listBundles reads it
445
+ * back from the payload). A value item's tier is resolved from its bundle's
446
+ * metadata PAYLOAD in `values` — under the cleartext metadata name or its
447
+ * hashed transform — never from the metadata item being part of the same
448
+ * `services` batch: a --prefix run can scope a bundle's value items alone, and
449
+ * rekeyServiceNames supplies the out-of-scope metadata reads (see the
450
+ * supplemental batch there). Exported for unit tests.
451
+ */
452
+ export function computeRekeyPlan(services, values, key) {
453
+ const items = [];
454
+ const unreadable = [];
455
+ const bundleNoAcl = (bundle) => {
456
+ const meta = `${BUNDLES_ITEM_PREFIX}${bundle}`;
457
+ const raw = values.get(meta) ?? values.get(hashedServiceName(meta, key));
458
+ if (raw === undefined)
459
+ return false;
460
+ try {
461
+ const parsed = JSON.parse(raw);
462
+ if (parsed && typeof parsed === 'object')
463
+ return parsed.tier === 'none' || parsed.tier === 'never';
464
+ }
465
+ catch {
466
+ /* malformed JSON — ACL'd */
467
+ }
468
+ return false;
469
+ };
470
+ for (const service of services) {
471
+ if (!service.startsWith(BUNDLES_ITEM_PREFIX))
472
+ continue;
473
+ const value = values.get(service);
474
+ if (value === undefined) {
475
+ unreadable.push(service);
476
+ continue;
477
+ }
478
+ const name = service.slice(BUNDLES_ITEM_PREFIX.length);
479
+ let payload;
480
+ let noAcl = false;
481
+ try {
482
+ const parsed = JSON.parse(value);
483
+ if (parsed && typeof parsed === 'object') {
484
+ const tier = parsed.tier;
485
+ noAcl = tier === 'none' || tier === 'never';
486
+ payload = JSON.stringify({ ...parsed, name });
487
+ }
488
+ }
489
+ catch {
490
+ /* malformed JSON — copy verbatim, ACL'd */
491
+ }
492
+ items.push({ oldService: service, newService: hashedServiceName(service, key), noAcl, payload });
493
+ }
494
+ for (const service of services) {
495
+ if (service.startsWith(BUNDLES_ITEM_PREFIX))
496
+ continue;
497
+ const value = values.get(service);
498
+ if (value === undefined) {
499
+ unreadable.push(service);
500
+ continue;
501
+ }
502
+ let noAcl = false;
503
+ if (service.startsWith(SECRETS_ITEM_PREFIX)) {
504
+ const rest = service.slice(SECRETS_ITEM_PREFIX.length);
505
+ const dot = rest.lastIndexOf('.');
506
+ if (dot > 0)
507
+ noAcl = bundleNoAcl(rest.slice(0, dot));
508
+ }
509
+ items.push({ oldService: service, newService: hashedServiceName(service, key), noAcl });
510
+ }
511
+ return { items, unreadable };
512
+ }
513
+ /**
514
+ * The one-time re-key: move every cleartext-named `agents-cli.*` item to its
515
+ * hashed service name. Composed entirely from the existing helper primitives —
516
+ * no new Swift command:
517
+ *
518
+ * 1. Enumerate cleartext services (silent) and batch-read every value behind
519
+ * ONE Touch ID (`get-batch`; readItem also sweeps legacy/orphaned copies).
520
+ * 2. Write each hashed copy (`set`/`set-no-acl` never prompt), preserving
521
+ * the no-ACL tier for `never`-policy bundles.
522
+ * 3. Batch-verify every copy round-trips (second Touch ID).
523
+ * 4. Only then activate hashing (sentinel + pendingDeletes) and delete the
524
+ * old items (silent).
525
+ *
526
+ * Add-before-delete throughout: a cancel/crash/failure anywhere before step 4
527
+ * leaves every old item intact and hashing OFF — same rationale as the
528
+ * helper's migrate-orphans, which is also why no pre-write backup is taken.
529
+ * On ANY per-item failure nothing is deleted and the sentinel stays off
530
+ * (all-or-nothing activation); the report names every failed item. A crash
531
+ * between the sentinel write and the deletes is resumed silently by the next
532
+ * process (pendingDeletes). Idempotent: re-running converges.
533
+ */
534
+ export function rekeyServiceNames(opts = {}) {
535
+ const log = opts.log ?? (() => { });
536
+ if (!backend && process.platform !== 'darwin') {
537
+ throw new Error('secrets rekey is macOS-only — service names are enumerable only via the macOS keychain helper.');
538
+ }
539
+ if (rekeyRunning)
540
+ throw new Error('re-key already running in this process.');
541
+ rekeyRunning = true;
542
+ try {
543
+ const partial = !!opts.prefixes?.length;
544
+ let record = ensureHmacKeyRecord(false);
545
+ const key = Buffer.from(record.k, 'hex');
546
+ // The record we just ensured is a DP item — if enumeration can't see it,
547
+ // every listing below is lying (locked keybag) and no decision — least of
548
+ // all "nothing to migrate, activate" — can be made on it.
549
+ assertEnumerationTrustworthy();
550
+ if (record.pendingDeletes?.length) {
551
+ log(`Finishing interrupted re-key: removing ${record.pendingDeletes.length} already-copied cleartext item(s)…`);
552
+ finishPendingDeletes(record);
553
+ record = readHmacKeyRecord() ?? record;
554
+ }
555
+ const cleartext = listCleartextServices(opts.prefixes);
556
+ if (cleartext.length === 0) {
557
+ if (!record.migrated && !partial) {
558
+ writeHmacKeyRecord({ ...record, migrated: true });
559
+ log('No cleartext-named keychain items found — hashed service names are now active.');
560
+ return { migrated: [], failed: [], activated: true, nothingToDo: true };
561
+ }
562
+ return { migrated: [], failed: [], activated: record.migrated, nothingToDo: true };
563
+ }
564
+ if (opts.announce) {
565
+ log(`One-time secrets re-key: replacing ${cleartext.length} enumerable keychain service name(s) with opaque hashed names (GitHub #316).`);
566
+ log('Touch ID will prompt twice (read + verify). Cancelling is safe — the re-key resumes on a later run.');
567
+ }
568
+ // A --prefix run can scope a bundle's value items WITHOUT its metadata
569
+ // item (`agents-cli.bundles.<bundle>`); the tier decision must still come
570
+ // from the keychain, not from batch membership — otherwise a partial
571
+ // re-key of a `never`-policy bundle would silently re-attach a biometry
572
+ // ACL to its values (and delete the cleartext originals, one-way). Read
573
+ // every such bundle's metadata alongside the values in the same batch:
574
+ // the cleartext name for a not-yet-moved metadata item, its hashed
575
+ // transform for one an earlier partial run already moved. Absent both (a
576
+ // standalone item with no backing bundle), the value stays ACL'd. A full
577
+ // run adds nothing here — every metadata item is already in scope.
578
+ const inScope = new Set(cleartext);
579
+ const supplementalMetaReads = new Set();
580
+ for (const service of cleartext) {
581
+ if (!service.startsWith(SECRETS_ITEM_PREFIX))
582
+ continue;
583
+ const rest = service.slice(SECRETS_ITEM_PREFIX.length);
584
+ const dot = rest.lastIndexOf('.');
585
+ if (dot <= 0)
586
+ continue;
587
+ const meta = `${BUNDLES_ITEM_PREFIX}${rest.slice(0, dot)}`;
588
+ if (inScope.has(meta))
589
+ continue;
590
+ supplementalMetaReads.add(meta);
591
+ supplementalMetaReads.add(hashedServiceName(meta, key));
592
+ }
593
+ const values = withRawKeychainServiceNames(() => getKeychainTokens([...cleartext, ...supplementalMetaReads]));
594
+ const { items, unreadable } = computeRekeyPlan(cleartext, values, key);
595
+ const failed = unreadable.map((item) => ({
596
+ item,
597
+ detail: 'read failed or item absent',
598
+ }));
599
+ const added = [];
600
+ for (const plan of items) {
601
+ try {
602
+ setKeychainToken(plan.newService, plan.payload ?? values.get(plan.oldService), { noAcl: plan.noAcl });
603
+ added.push(plan);
604
+ }
605
+ catch (err) {
606
+ failed.push({ item: plan.oldService, detail: `write: ${err.message}` });
607
+ }
608
+ }
609
+ const verified = [];
610
+ if (added.length > 0) {
611
+ const readBack = getKeychainTokens(added.map((p) => p.newService));
612
+ for (const plan of added) {
613
+ const expected = plan.payload ?? values.get(plan.oldService);
614
+ if (readBack.get(plan.newService) === expected)
615
+ verified.push(plan);
616
+ else
617
+ failed.push({ item: plan.oldService, detail: 'verify: value mismatch after rewrite' });
618
+ }
619
+ }
620
+ if (failed.length > 0) {
621
+ log(`Re-key INCOMPLETE — ${failed.length} of ${cleartext.length} item(s) could not be moved; nothing was deleted and hashed naming stays OFF:`);
622
+ for (const f of failed)
623
+ log(` ${f.item}: ${f.detail}`);
624
+ return { migrated: [], failed, activated: false, nothingToDo: false };
625
+ }
626
+ if (partial) {
627
+ withRawKeychainServiceNames(() => {
628
+ for (const plan of verified)
629
+ deleteKeychainToken(plan.oldService);
630
+ });
631
+ log(`Re-keyed ${verified.length} item(s) (partial run — hashed naming NOT activated).`);
632
+ return { migrated: verified.map((p) => p.oldService), failed: [], activated: record.migrated, nothingToDo: false };
633
+ }
634
+ writeHmacKeyRecord({ ...record, migrated: true, pendingDeletes: verified.map((p) => p.oldService) });
635
+ withRawKeychainServiceNames(() => {
636
+ for (const plan of verified)
637
+ deleteKeychainToken(plan.oldService);
638
+ });
639
+ writeHmacKeyRecord({ ...record, migrated: true, pendingDeletes: undefined });
640
+ log(`Re-keyed ${verified.length} keychain item(s); service names are now opaque (agents-cli.h.*).`);
641
+ return { migrated: verified.map((p) => p.oldService), failed: [], activated: true, nothingToDo: false };
642
+ }
643
+ finally {
644
+ rekeyRunning = false;
645
+ }
646
+ }
647
+ /** Re-key state snapshot for `agents secrets rekey --status`. */
648
+ export function rekeyStatus() {
649
+ const rec = readHmacKeyRecord();
650
+ let enumerationOk = true;
651
+ if (rec) {
652
+ try {
653
+ assertEnumerationTrustworthy();
654
+ }
655
+ catch {
656
+ enumerationOk = false;
657
+ }
658
+ }
659
+ return {
660
+ migrated: !!rec?.migrated,
661
+ hasKey: !!rec,
662
+ pendingDeletes: rec?.pendingDeletes?.length ?? 0,
663
+ cleartext: listCleartextServices(),
664
+ enumerationOk,
665
+ };
666
+ }
121
667
  /** Check if a keychain/keyring item exists. Never prompts for biometry. */
122
668
  export function hasKeychainToken(item) {
669
+ item = prepareServiceName(item);
123
670
  if (backend)
124
671
  return backend.has(item);
125
672
  assertSupportedPlatform();
@@ -145,6 +692,10 @@ export function hasKeychainToken(item) {
145
692
  * single biometric prompt covers every key in the batch.
146
693
  */
147
694
  export function getKeychainToken(item) {
695
+ // Errors keep the requested (human-readable) name; the storage name may be
696
+ // an opaque hash.
697
+ const requested = item;
698
+ item = prepareServiceName(item, { autoRekey: true });
148
699
  if (backend)
149
700
  return backend.get(item);
150
701
  assertSupportedPlatform();
@@ -161,23 +712,23 @@ export function getKeychainToken(item) {
161
712
  if (token)
162
713
  return token;
163
714
  }
164
- throw new Error(`Keychain item '${item}' not found.`);
715
+ throw new Error(`Keychain item '${requested}' not found.`);
165
716
  }
166
717
  const bin = getKeychainHelperPath();
167
718
  const result = spawnSync(bin, ['get', item, os.userInfo().username], {
168
719
  stdio: ['ignore', 'pipe', 'pipe'],
169
720
  });
170
721
  if (result.status === 1)
171
- throw new Error(`Keychain item '${item}' not found.`);
722
+ throw new Error(`Keychain item '${requested}' not found.`);
172
723
  if (result.status === 4)
173
- throw new Error(`Touch ID cancelled while reading '${item}'.`);
724
+ throw new Error(`Touch ID cancelled while reading '${requested}'.`);
174
725
  if (result.status !== 0) {
175
726
  const msg = result.stderr?.toString().trim();
176
- throw new Error(msg || `Failed to read keychain item '${item}'.`);
727
+ throw new Error(msg || `Failed to read keychain item '${requested}'.`);
177
728
  }
178
729
  const token = result.stdout?.toString();
179
730
  if (!token)
180
- throw new Error(`Keychain item '${item}' exists but is empty.`);
731
+ throw new Error(`Keychain item '${requested}' exists but is empty.`);
181
732
  return token;
182
733
  }
183
734
  /**
@@ -194,10 +745,23 @@ export function getKeychainTokens(items) {
194
745
  const result = new Map();
195
746
  if (items.length === 0)
196
747
  return result;
748
+ // Resolve storage names up front, remembering which requested name each one
749
+ // answers for — the returned map is keyed by the names the CALLER passed,
750
+ // whether those were cleartext (hashed here) or already-hashed (enumerated).
751
+ const requestedByStorage = new Map();
752
+ const storageItems = items.map((item) => {
753
+ const storage = prepareServiceName(item, { autoRekey: true });
754
+ if (!requestedByStorage.has(storage))
755
+ requestedByStorage.set(storage, item);
756
+ return storage;
757
+ });
758
+ const record = (storage, value) => {
759
+ result.set(requestedByStorage.get(storage) ?? storage, value);
760
+ };
197
761
  if (backend) {
198
- for (const item of items) {
762
+ for (const storage of storageItems) {
199
763
  try {
200
- result.set(item, backend.get(item));
764
+ record(storage, backend.get(storage));
201
765
  }
202
766
  catch { /* missing — skip */ }
203
767
  }
@@ -205,25 +769,25 @@ export function getKeychainTokens(items) {
205
769
  }
206
770
  assertSupportedPlatform();
207
771
  if (isLinux()) {
208
- for (const item of items) {
772
+ for (const storage of storageItems) {
209
773
  try {
210
- result.set(item, linuxBackend.get(item));
774
+ record(storage, linuxBackend.get(storage));
211
775
  }
212
776
  catch { /* missing — skip */ }
213
777
  }
214
778
  return result;
215
779
  }
216
780
  if (isWindows()) {
217
- for (const item of items) {
781
+ for (const storage of storageItems) {
218
782
  try {
219
- result.set(item, windowsBackend.get(item));
783
+ record(storage, windowsBackend.get(storage));
220
784
  }
221
785
  catch { /* missing — skip */ }
222
786
  }
223
787
  return result;
224
788
  }
225
789
  const bin = getKeychainHelperPath();
226
- const child = spawnSync(bin, ['get-batch', os.userInfo().username, ...items], {
790
+ const child = spawnSync(bin, ['get-batch', os.userInfo().username, ...storageItems], {
227
791
  stdio: ['ignore', 'pipe', 'pipe'],
228
792
  });
229
793
  if (child.status === 4) {
@@ -234,20 +798,22 @@ export function getKeychainTokens(items) {
234
798
  throw new Error(msg || `Failed to batch-read ${items.length} keychain items.`);
235
799
  }
236
800
  const out = child.stdout?.toString() ?? '';
237
- parseBatchRecords(out, result);
801
+ parseBatchRecords(out, record);
238
802
  return result;
239
803
  }
240
804
  /**
241
- * Parse the helper's batch-read output into `into`. The format is shared by
242
- * `get-batch` and `get-batch-synced`a sequence of records, one per service
243
- * in input order:
805
+ * Parse the helper's batch-read output, routing each present record through
806
+ * `record(service, value)` — getKeychainTokens uses that to reverse-map hashed
807
+ * storage names back to the names the caller asked with. The format is shared
808
+ * by `get-batch` and `get-batch-synced` — a sequence of records, one per
809
+ * service in input order:
244
810
  * "V <service>\n<value>\n" (present)
245
811
  * "M <service>\n" (missing)
246
812
  * Service names are validated newline/'='-free by setKeychainToken below
247
813
  * and values are rejected if they contain newlines — so splitting on '\n'
248
814
  * and walking line-by-line is unambiguous.
249
815
  */
250
- function parseBatchRecords(out, into) {
816
+ function parseBatchRecords(out, record) {
251
817
  const lines = out.split('\n');
252
818
  let i = 0;
253
819
  while (i < lines.length) {
@@ -257,7 +823,7 @@ function parseBatchRecords(out, into) {
257
823
  if (line.startsWith('V ')) {
258
824
  const service = line.slice(2);
259
825
  const value = lines[i + 1] ?? '';
260
- into.set(service, value);
826
+ record(service, value);
261
827
  i += 2;
262
828
  }
263
829
  else if (line.startsWith('M ')) {
@@ -279,14 +845,17 @@ function parseBatchRecords(out, into) {
279
845
  * rather than silently falling back to an ACL'd `set` (which would behave like
280
846
  * `always`). Ignored by the Linux/Windows/test backends, which have no ACL. */
281
847
  export function setKeychainToken(item, value, opts) {
848
+ // Validate the CLEARTEXT name (a hashed storage name is always clean), then
849
+ // resolve the storage name.
850
+ if (/[\x00=\r\n]/.test(item))
851
+ throw new Error('Secret item name contains invalid characters.');
852
+ item = prepareServiceName(item, { autoRekey: true });
282
853
  if (backend) {
283
- backend.set(item, value);
854
+ backend.set(item, value, opts);
284
855
  return;
285
856
  }
286
857
  assertSupportedPlatform();
287
858
  assertValueStorable(value);
288
- if (/[\x00=\r\n]/.test(item))
289
- throw new Error('Secret item name contains invalid characters.');
290
859
  if (isLinux()) {
291
860
  linuxBackend.set(item, value);
292
861
  return;
@@ -337,6 +906,7 @@ export function setKeychainToken(item, value, opts) {
337
906
  }
338
907
  /** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
339
908
  export function deleteKeychainToken(item) {
909
+ item = prepareServiceName(item);
340
910
  if (backend)
341
911
  return backend.delete(item);
342
912
  assertSupportedPlatform();
@@ -368,17 +938,22 @@ export function keychainUsesFileFallback() {
368
938
  return windowsUsesFileFallback();
369
939
  return false;
370
940
  }
371
- /** Enumerate keychain/keyring item names starting with the given prefix. */
941
+ /** Enumerate keychain/keyring item names starting with the given prefix.
942
+ * With hashed service names active, the two sub-namespace prefixes callers
943
+ * use (bundle metadata; one bundle's value items) are mapped to their hashed
944
+ * shapes — the returned names are then storage (opaque) names. */
372
945
  export function listKeychainItems(prefix) {
946
+ const mapped = prepareListPrefix(prefix);
947
+ const apply = (names) => (mapped.filter ? names.filter(mapped.filter) : names);
373
948
  if (backend)
374
- return backend.list(prefix);
949
+ return apply(backend.list(mapped.prefix));
375
950
  assertSupportedPlatform();
376
951
  if (isLinux())
377
- return linuxBackend.list(prefix);
952
+ return apply(linuxBackend.list(mapped.prefix));
378
953
  if (isWindows())
379
- return windowsBackend.list(prefix);
954
+ return apply(windowsBackend.list(mapped.prefix));
380
955
  const bin = getKeychainHelperPath();
381
- const result = spawnSync(bin, ['list', prefix], {
956
+ const result = spawnSync(bin, ['list', mapped.prefix], {
382
957
  stdio: ['ignore', 'pipe', 'pipe'],
383
958
  });
384
959
  if (result.status !== 0) {
@@ -386,7 +961,7 @@ export function listKeychainItems(prefix) {
386
961
  throw new Error(msg || `Failed to enumerate keychain items with prefix '${prefix}'.`);
387
962
  }
388
963
  const out = result.stdout?.toString() || '';
389
- return out.split('\n').map((s) => s.trim()).filter(Boolean);
964
+ return apply(out.split('\n').map((s) => s.trim()).filter(Boolean));
390
965
  }
391
966
  /**
392
967
  * Enumerate ONLY legacy file-based-keychain item names with the given prefix —
@@ -476,7 +1051,7 @@ export function getSyncedKeychainTokens(items) {
476
1051
  const msg = child.stderr?.toString().trim();
477
1052
  throw new Error(msg || `Failed to batch-read ${items.length} iCloud keychain items.`);
478
1053
  }
479
- parseBatchRecords(child.stdout?.toString() ?? '', result);
1054
+ parseBatchRecords(child.stdout?.toString() ?? '', (service, value) => { result.set(service, value); });
480
1055
  return result;
481
1056
  }
482
1057
  /**