@phnx-labs/agents-cli 1.20.35 → 1.20.36

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 (225) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +185 -21
  3. package/README.md +8 -4
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer.js +81 -0
  11. package/dist/commands/daemon.js +4 -1
  12. package/dist/commands/doctor.js +1 -89
  13. package/dist/commands/events.js +3 -3
  14. package/dist/commands/exec.d.ts +19 -0
  15. package/dist/commands/exec.js +277 -11
  16. package/dist/commands/hosts.js +10 -6
  17. package/dist/commands/inspect.js +8 -9
  18. package/dist/commands/lock.d.ts +12 -0
  19. package/dist/commands/lock.js +70 -0
  20. package/dist/commands/message.d.ts +15 -0
  21. package/dist/commands/message.js +56 -0
  22. package/dist/commands/routines.js +101 -5
  23. package/dist/commands/secrets-migrate.js +106 -57
  24. package/dist/commands/secrets.d.ts +31 -18
  25. package/dist/commands/secrets.js +156 -75
  26. package/dist/commands/serve.d.ts +10 -0
  27. package/dist/commands/serve.js +37 -0
  28. package/dist/commands/sessions-inject.d.ts +14 -0
  29. package/dist/commands/sessions-inject.js +111 -0
  30. package/dist/commands/sessions-picker.d.ts +2 -0
  31. package/dist/commands/sessions-picker.js +24 -3
  32. package/dist/commands/sessions-resume.js +20 -8
  33. package/dist/commands/sessions.d.ts +71 -1
  34. package/dist/commands/sessions.js +365 -37
  35. package/dist/commands/setup.js +4 -2
  36. package/dist/commands/sync.d.ts +3 -1
  37. package/dist/commands/sync.js +156 -4
  38. package/dist/commands/teams.js +217 -0
  39. package/dist/commands/versions.js +2 -4
  40. package/dist/commands/watchdog.d.ts +18 -0
  41. package/dist/commands/watchdog.js +238 -0
  42. package/dist/index.js +25 -2
  43. package/dist/lib/audit/log.d.ts +92 -0
  44. package/dist/lib/audit/log.js +177 -0
  45. package/dist/lib/auto-pull.js +2 -1
  46. package/dist/lib/browser/chrome.d.ts +10 -0
  47. package/dist/lib/browser/chrome.js +18 -7
  48. package/dist/lib/browser/drivers/ssh.js +2 -1
  49. package/dist/lib/browser/har.d.ts +84 -0
  50. package/dist/lib/browser/har.js +77 -0
  51. package/dist/lib/browser/ipc.js +24 -3
  52. package/dist/lib/browser/profiles.d.ts +1 -1
  53. package/dist/lib/browser/profiles.js +8 -10
  54. package/dist/lib/browser/refs.d.ts +65 -0
  55. package/dist/lib/browser/refs.js +73 -1
  56. package/dist/lib/browser/runtime-state.js +1 -0
  57. package/dist/lib/browser/service.d.ts +38 -2
  58. package/dist/lib/browser/service.js +112 -8
  59. package/dist/lib/browser/types.d.ts +14 -1
  60. package/dist/lib/budget/live-cloud.d.ts +42 -0
  61. package/dist/lib/budget/live-cloud.js +79 -0
  62. package/dist/lib/budget/live-team.d.ts +31 -0
  63. package/dist/lib/budget/live-team.js +115 -0
  64. package/dist/lib/cloud/codex.js +4 -0
  65. package/dist/lib/cloud/rush.d.ts +12 -1
  66. package/dist/lib/cloud/rush.js +13 -3
  67. package/dist/lib/cloud/types.d.ts +9 -0
  68. package/dist/lib/computer/dispatch.d.ts +8 -0
  69. package/dist/lib/computer/dispatch.js +125 -0
  70. package/dist/lib/computer/loop.d.ts +62 -0
  71. package/dist/lib/computer/loop.js +98 -0
  72. package/dist/lib/computer/model.d.ts +44 -0
  73. package/dist/lib/computer/model.js +157 -0
  74. package/dist/lib/concurrency.d.ts +19 -0
  75. package/dist/lib/concurrency.js +33 -0
  76. package/dist/lib/daemon.d.ts +57 -0
  77. package/dist/lib/daemon.js +192 -16
  78. package/dist/lib/devices/registry.d.ts +7 -0
  79. package/dist/lib/devices/registry.js +24 -0
  80. package/dist/lib/devices/tailscale.js +1 -1
  81. package/dist/lib/drift.d.ts +52 -0
  82. package/dist/lib/drift.js +112 -0
  83. package/dist/lib/events.d.ts +1 -1
  84. package/dist/lib/events.js +31 -13
  85. package/dist/lib/exec.d.ts +17 -0
  86. package/dist/lib/exec.js +79 -13
  87. package/dist/lib/git.d.ts +27 -0
  88. package/dist/lib/git.js +56 -1
  89. package/dist/lib/hooks/cache.d.ts +6 -0
  90. package/dist/lib/hooks/cache.js +54 -12
  91. package/dist/lib/hooks.d.ts +27 -0
  92. package/dist/lib/hooks.js +127 -8
  93. package/dist/lib/hosts/dispatch.d.ts +15 -0
  94. package/dist/lib/hosts/dispatch.js +39 -6
  95. package/dist/lib/hosts/logs.js +30 -1
  96. package/dist/lib/hosts/option.js +1 -1
  97. package/dist/lib/hosts/passthrough.js +3 -1
  98. package/dist/lib/hosts/ready.d.ts +29 -6
  99. package/dist/lib/hosts/ready.js +66 -15
  100. package/dist/lib/hosts/registry.d.ts +19 -2
  101. package/dist/lib/hosts/registry.js +58 -2
  102. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  103. package/dist/lib/hosts/remote-cmd.js +70 -1
  104. package/dist/lib/hosts/remote-os.d.ts +17 -0
  105. package/dist/lib/hosts/remote-os.js +30 -0
  106. package/dist/lib/hosts/session-index.d.ts +34 -0
  107. package/dist/lib/hosts/session-index.js +56 -0
  108. package/dist/lib/hosts/tasks.d.ts +14 -0
  109. package/dist/lib/hosts/tasks.js +15 -0
  110. package/dist/lib/lock.d.ts +93 -0
  111. package/dist/lib/lock.js +207 -0
  112. package/dist/lib/loop.js +16 -1
  113. package/dist/lib/machine-id.d.ts +21 -0
  114. package/dist/lib/machine-id.js +26 -0
  115. package/dist/lib/mailbox-target.d.ts +36 -0
  116. package/dist/lib/mailbox-target.js +45 -0
  117. package/dist/lib/mailbox.d.ts +47 -0
  118. package/dist/lib/mailbox.js +194 -0
  119. package/dist/lib/mcp.d.ts +5 -0
  120. package/dist/lib/mcp.js +24 -8
  121. package/dist/lib/migrate.d.ts +19 -0
  122. package/dist/lib/migrate.js +134 -26
  123. package/dist/lib/overdue.js +3 -0
  124. package/dist/lib/picker.d.ts +2 -0
  125. package/dist/lib/picker.js +4 -1
  126. package/dist/lib/platform/exec.d.ts +46 -0
  127. package/dist/lib/platform/exec.js +74 -0
  128. package/dist/lib/platform/process.d.ts +31 -0
  129. package/dist/lib/platform/process.js +34 -1
  130. package/dist/lib/platform/winpath.js +2 -0
  131. package/dist/lib/plugins.js +16 -6
  132. package/dist/lib/profiles.d.ts +25 -0
  133. package/dist/lib/profiles.js +22 -6
  134. package/dist/lib/pty-client.js +2 -1
  135. package/dist/lib/rotate.d.ts +61 -0
  136. package/dist/lib/rotate.js +52 -0
  137. package/dist/lib/routines.d.ts +40 -2
  138. package/dist/lib/routines.js +66 -8
  139. package/dist/lib/runner.d.ts +11 -2
  140. package/dist/lib/runner.js +49 -7
  141. package/dist/lib/scheduler.js +6 -1
  142. package/dist/lib/secrets/bundles.d.ts +60 -4
  143. package/dist/lib/secrets/bundles.js +131 -12
  144. package/dist/lib/secrets/filestore.d.ts +3 -0
  145. package/dist/lib/secrets/filestore.js +42 -16
  146. package/dist/lib/secrets/index.d.ts +43 -2
  147. package/dist/lib/secrets/index.js +102 -3
  148. package/dist/lib/secrets/mcp.d.ts +93 -0
  149. package/dist/lib/secrets/mcp.js +205 -0
  150. package/dist/lib/secrets/remote.js +12 -5
  151. package/dist/lib/secrets/sync.js +83 -4
  152. package/dist/lib/secrets/windows.js +14 -3
  153. package/dist/lib/serve/data.d.ts +81 -0
  154. package/dist/lib/serve/data.js +91 -0
  155. package/dist/lib/serve/page.d.ts +7 -0
  156. package/dist/lib/serve/page.js +140 -0
  157. package/dist/lib/serve/server.d.ts +46 -0
  158. package/dist/lib/serve/server.js +115 -0
  159. package/dist/lib/session/active.d.ts +54 -0
  160. package/dist/lib/session/active.js +190 -19
  161. package/dist/lib/session/discover.d.ts +37 -0
  162. package/dist/lib/session/discover.js +111 -28
  163. package/dist/lib/session/inject.d.ts +18 -0
  164. package/dist/lib/session/inject.js +21 -0
  165. package/dist/lib/session/parse.js +23 -20
  166. package/dist/lib/session/pid-registry.d.ts +1 -0
  167. package/dist/lib/session/pid-registry.js +24 -0
  168. package/dist/lib/session/provenance.d.ts +14 -2
  169. package/dist/lib/session/provenance.js +39 -8
  170. package/dist/lib/session/remote-active.js +19 -7
  171. package/dist/lib/session/remote-list.d.ts +51 -0
  172. package/dist/lib/session/remote-list.js +213 -0
  173. package/dist/lib/session/remote.d.ts +7 -1
  174. package/dist/lib/session/remote.js +16 -2
  175. package/dist/lib/session/sync/config.d.ts +1 -15
  176. package/dist/lib/session/sync/config.js +4 -20
  177. package/dist/lib/session/types.d.ts +17 -0
  178. package/dist/lib/shims.d.ts +36 -6
  179. package/dist/lib/shims.js +91 -29
  180. package/dist/lib/ssh-exec.js +2 -0
  181. package/dist/lib/ssh-tunnel.js +2 -1
  182. package/dist/lib/startup/command-registry.d.ts +6 -0
  183. package/dist/lib/startup/command-registry.js +13 -1
  184. package/dist/lib/state.d.ts +13 -0
  185. package/dist/lib/state.js +103 -9
  186. package/dist/lib/sync-umbrella.d.ts +14 -7
  187. package/dist/lib/sync-umbrella.js +17 -9
  188. package/dist/lib/teams/forEach.d.ts +110 -0
  189. package/dist/lib/teams/forEach.js +186 -0
  190. package/dist/lib/teams/index.d.ts +1 -0
  191. package/dist/lib/teams/index.js +1 -0
  192. package/dist/lib/teams/pr-watch.d.ts +226 -0
  193. package/dist/lib/teams/pr-watch.js +371 -0
  194. package/dist/lib/teams/supervisor.d.ts +14 -1
  195. package/dist/lib/teams/supervisor.js +19 -0
  196. package/dist/lib/teams/worktree.d.ts +9 -0
  197. package/dist/lib/teams/worktree.js +32 -0
  198. package/dist/lib/terminal/backends/index.d.ts +2 -1
  199. package/dist/lib/terminal/backends/index.js +3 -1
  200. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  201. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  202. package/dist/lib/terminal/index.d.ts +4 -1
  203. package/dist/lib/terminal/index.js +4 -1
  204. package/dist/lib/terminal/inject.d.ts +204 -0
  205. package/dist/lib/terminal/inject.js +247 -0
  206. package/dist/lib/terminal/resolve.d.ts +64 -0
  207. package/dist/lib/terminal/resolve.js +90 -0
  208. package/dist/lib/terminal/types.d.ts +1 -1
  209. package/dist/lib/triggers/webhook.d.ts +85 -0
  210. package/dist/lib/triggers/webhook.js +141 -0
  211. package/dist/lib/versions.d.ts +23 -0
  212. package/dist/lib/versions.js +119 -13
  213. package/dist/lib/watchdog/index.d.ts +3 -0
  214. package/dist/lib/watchdog/index.js +5 -0
  215. package/dist/lib/watchdog/read.d.ts +35 -0
  216. package/dist/lib/watchdog/read.js +149 -0
  217. package/dist/lib/watchdog/runner.d.ts +127 -0
  218. package/dist/lib/watchdog/runner.js +322 -0
  219. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  220. package/dist/lib/watchdog/watchdog.js +166 -0
  221. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  222. package/dist/lib/watchdog/watchdogTail.js +154 -0
  223. package/dist/lib/workflows.d.ts +166 -0
  224. package/dist/lib/workflows.js +193 -0
  225. package/package.json +5 -4
@@ -257,6 +257,8 @@ function parsePolicy(raw) {
257
257
  return 'daily';
258
258
  if (raw === 'always' || raw === 'biometry')
259
259
  return 'always';
260
+ if (raw === 'never' || raw === 'none')
261
+ return 'never';
260
262
  return undefined;
261
263
  }
262
264
  /** The default prompt policy applied to bundles without an explicit per-bundle
@@ -314,9 +316,14 @@ export function writeBundle(bundle) {
314
316
  backend: backend === 'file' ? 'file' : undefined,
315
317
  // Wire format: persist the policy under the legacy `tier` token so older CLI
316
318
  // versions on other synced machines keep reading it — `daily`⇒`session`,
317
- // explicit `always`⇒`biometry`. An absent policy omits the token entirely
318
- // and resolves to the configured default (`daily`) on read.
319
- tier: bundle.policy === 'daily' ? 'session' : bundle.policy === 'always' ? 'biometry' : undefined,
319
+ // explicit `always`⇒`biometry`, `never`⇒`none`. An absent policy omits the
320
+ // token entirely and resolves to the configured default (`daily`) on read.
321
+ // An older CLI that doesn't know `none` reads it as undefined and falls back
322
+ // to its own default — safe, since it also lacks the no-ACL write path.
323
+ tier: bundle.policy === 'daily' ? 'session'
324
+ : bundle.policy === 'always' ? 'biometry'
325
+ : bundle.policy === 'never' ? 'none'
326
+ : undefined,
320
327
  created_at: bundle.created_at,
321
328
  updated_at: bundle.updated_at,
322
329
  last_used: bundle.last_used,
@@ -324,7 +331,11 @@ export function writeBundle(bundle) {
324
331
  meta,
325
332
  };
326
333
  const json = JSON.stringify(payload);
327
- itemStore(backend).set(bundleMetaItem(bundle.name), json);
334
+ // A `never` bundle's metadata is stored without the biometry ACL too, so
335
+ // `view` and the metadata half of a read resolve silently — the whole point
336
+ // of the tier. On an un-updated pinned helper this write fails loudly (the
337
+ // no-ACL command is missing) rather than silently landing an ACL'd item.
338
+ itemStore(backend).set(bundleMetaItem(bundle.name), json, { noAcl: bundle.policy === 'never' });
328
339
  emit('secrets.set', { bundle: bundle.name });
329
340
  }
330
341
  export function deleteBundle(name) {
@@ -504,6 +515,87 @@ function stampLastUsed(bundle) {
504
515
  // Swallow — telemetry must never block secret resolution.
505
516
  }
506
517
  }
518
+ /**
519
+ * Abort if any of the selected keys has an `expires` date in the past.
520
+ * Bundle-level expiry is not a concept today (expiry is per-key via `meta`),
521
+ * so we iterate only the per-key meta entries.
522
+ */
523
+ function assertNotExpired(bundle, selectedKeys, allowExpired) {
524
+ if (allowExpired)
525
+ return;
526
+ if (!bundle.meta)
527
+ return;
528
+ const now = Date.now();
529
+ for (const key of selectedKeys) {
530
+ const meta = bundle.meta[key];
531
+ if (!meta?.expires)
532
+ continue;
533
+ // expires is 'YYYY-MM-DD'; treat as end-of-day UTC.
534
+ const expiry = new Date(meta.expires + 'T23:59:59Z').getTime();
535
+ if (expiry < now) {
536
+ throw new Error(`Bundle '${bundle.name}' key '${key}' expired on ${meta.expires}. ` +
537
+ `Rotate it with: agents secrets rotate ${bundle.name} ${key}` +
538
+ ` (or pass --allow-expired to skip this check).`);
539
+ }
540
+ }
541
+ }
542
+ /**
543
+ * Resolve the requested key subset against a bundle's `vars` map. Throws a
544
+ * fail-loud error listing available keys if any requested key is absent. When
545
+ * `requested` is undefined or empty, every key in the bundle is selected.
546
+ */
547
+ function selectRequestedKeys(bundle, requested) {
548
+ const req = requested?.length ? requested : undefined;
549
+ if (req) {
550
+ const missing = req.filter((k) => !(k in bundle.vars));
551
+ if (missing.length > 0) {
552
+ const available = Object.keys(bundle.vars).join(', ') || '(none)';
553
+ throw new Error(`Bundle '${bundle.name}' does not contain key(s): ${missing.join(', ')}. Available: ${available}.`);
554
+ }
555
+ }
556
+ return new Set(req ?? Object.keys(bundle.vars));
557
+ }
558
+ /**
559
+ * Apply the --keys subset + expiry gate to an already-resolved snapshot from
560
+ * the secrets-agent fast-path. The agent stores the FULL bundle env, so a
561
+ * naive fast-path return would silently defeat --keys and inject expired
562
+ * values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
563
+ * `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
564
+ *
565
+ * Exported for tests; production callers reach it via the fast-path branch in
566
+ * `readAndResolveBundleEnv`.
567
+ */
568
+ export function filterAgentHitBySubsetAndExpiry(hit, opts) {
569
+ const selectedKeys = selectRequestedKeys(hit.bundle, opts.keys);
570
+ assertNotExpired(hit.bundle, [...selectedKeys], opts.allowExpired ?? false);
571
+ // When no subset was requested, return the cached env untouched — same
572
+ // reference the agent handed back, so no per-call allocation on the hot path.
573
+ if (!opts.keys?.length)
574
+ return hit;
575
+ const env = {};
576
+ for (const key of selectedKeys) {
577
+ if (key in hit.env)
578
+ env[key] = hit.env[key];
579
+ }
580
+ return { bundle: hit.bundle, env };
581
+ }
582
+ /**
583
+ * Guard for remote-bundle callers (`bundle@host` / `--host`) — the SSH
584
+ * resolver in `remoteResolveEnv` does not thread --keys or --allow-expired
585
+ * yet. Silently applying them would inject the full remote env or an expired
586
+ * value, defeating the least-privilege intent, so we fail loud.
587
+ *
588
+ * Exported so `agents run --secrets bundle@host` and `agents secrets exec
589
+ * --host` share the exact same error text; the tests exercise this helper
590
+ * directly instead of driving the whole CLI.
591
+ */
592
+ export function assertRemoteBundleFlagsUnsupported(bundleName, host, opts, flagLabels) {
593
+ const hasKeys = Array.isArray(opts.keys) && opts.keys.length > 0;
594
+ if (!hasKeys && !opts.allowExpired)
595
+ return;
596
+ throw new Error(`Bundle '${bundleName}@${host}': ${flagLabels.keysFlag} and ${flagLabels.allowExpiredFlag} are not supported for remote (bundle@host) bundles yet. ` +
597
+ `Drop the flag or resolve the bundle locally.`);
598
+ }
507
599
  // Walk the bundle and produce a flat env map. Every keychain: ref is gathered
508
600
  // into a single batch read so macOS shows ONE Touch ID prompt for the whole
509
601
  // bundle — including the metadata fetch that already happened in readBundle
@@ -513,9 +605,14 @@ function stampLastUsed(bundle) {
513
605
  // resolved inline and never reach the keychain.
514
606
  export function resolveBundleEnv(bundle, _opts = {}) {
515
607
  stampLastUsed(bundle);
608
+ // Key-subset validation and expiry pre-check.
609
+ const selectedKeys = selectRequestedKeys(bundle, _opts.keys);
610
+ assertNotExpired(bundle, [...selectedKeys], _opts.allowExpired ?? false);
516
611
  const parsedByKey = new Map();
517
612
  const keychainItemsToFetch = [];
518
613
  for (const [key, raw] of Object.entries(bundle.vars)) {
614
+ if (!selectedKeys.has(key))
615
+ continue;
519
616
  const parsed = parseBundleValue(raw);
520
617
  parsedByKey.set(key, parsed);
521
618
  if ('ref' in parsed && parsed.ref.provider === 'keychain') {
@@ -528,6 +625,8 @@ export function resolveBundleEnv(bundle, _opts = {}) {
528
625
  : new Map();
529
626
  const env = {};
530
627
  for (const [key, raw] of Object.entries(bundle.vars)) {
628
+ if (!selectedKeys.has(key))
629
+ continue;
531
630
  const parsed = parsedByKey.get(key);
532
631
  if ('literal' in parsed) {
533
632
  env[key] = parsed.literal;
@@ -582,15 +681,20 @@ export function readAndResolveBundleEnv(name, opts = {}) {
582
681
  if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
583
682
  const hit = agentGetSync(name);
584
683
  if (hit) {
585
- stampLastUsed(hit.bundle);
684
+ // The agent stores the FULL bundle env. Apply the same subset filter and
685
+ // expiry gate as the slow path — without this, `--secrets-keys X` would
686
+ // silently inject every key and an expired key would flow through after
687
+ // the first cache-populating run.
688
+ const filtered = filterAgentHitBySubsetAndExpiry(hit, opts);
689
+ stampLastUsed(filtered.bundle);
586
690
  emit('secrets.get', {
587
691
  bundle: name,
588
692
  caller: opts.caller,
589
693
  status: 'success',
590
694
  source: 'agent',
591
- keyCount: Object.keys(hit.env).length,
695
+ keyCount: Object.keys(filtered.env).length,
592
696
  });
593
- return hit;
697
+ return filtered;
594
698
  }
595
699
  }
596
700
  if (backend === 'file')
@@ -651,11 +755,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
651
755
  for (const key of Object.keys(bundle.vars)) {
652
756
  validateEnvKey(key);
653
757
  }
758
+ // Key-subset validation and expiry pre-check (mirrors resolveBundleEnv logic).
759
+ const selectedKeys = selectRequestedKeys(bundle, opts.keys);
760
+ assertNotExpired(bundle, [...selectedKeys], opts.allowExpired ?? false);
654
761
  stampLastUsed(bundle);
655
762
  const parsedByKey = new Map();
656
763
  const keychainKeys = [];
657
764
  const kindCounts = {};
658
765
  for (const [key, raw] of Object.entries(bundle.vars)) {
766
+ if (!selectedKeys.has(key))
767
+ continue;
659
768
  const p = parseBundleValue(raw);
660
769
  parsedByKey.set(key, p);
661
770
  const kind = 'literal' in p ? 'literal' : p.ref.provider;
@@ -664,7 +773,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
664
773
  keychainKeys.push(key);
665
774
  }
666
775
  }
667
- const keys = Object.keys(bundle.vars).sort();
776
+ const keys = [...selectedKeys].sort();
668
777
  keychainKeys.sort();
669
778
  const emitReadAudit = (status, err) => {
670
779
  emit('secrets.get', {
@@ -681,6 +790,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
681
790
  try {
682
791
  const env = {};
683
792
  for (const [key] of Object.entries(bundle.vars)) {
793
+ if (!selectedKeys.has(key))
794
+ continue;
684
795
  const p = parsedByKey.get(key);
685
796
  if ('literal' in p) {
686
797
  env[key] = p.literal;
@@ -749,7 +860,7 @@ export function rotateBundleSecret(bundle, key, opts) {
749
860
  }
750
861
  const shortId = raw.slice('keychain:'.length);
751
862
  const item = secretsKeychainItem(bundle.name, shortId);
752
- itemStore(bundle.backend ?? 'keychain').set(item, opts.newValue);
863
+ itemStore(bundle.backend ?? 'keychain').set(item, opts.newValue, { noAcl: bundlePolicy(bundle) === 'never' });
753
864
  if (opts.clearMeta) {
754
865
  if (bundle.meta)
755
866
  delete bundle.meta[key];
@@ -817,7 +928,7 @@ export function renameBundle(oldName, newName, opts = {}) {
817
928
  const shortId = raw.slice('keychain:'.length);
818
929
  const newItem = secretsKeychainItem(newName, shortId);
819
930
  const value = store.get(oldItem);
820
- store.set(newItem, value);
931
+ store.set(newItem, value, { noAcl: bundlePolicy(source) === 'never' });
821
932
  }
822
933
  // writeBundle preserves source.created_at, refreshes updated_at, and keeps
823
934
  // the source backend (spread carries source.backend).
@@ -837,8 +948,16 @@ export function renameBundle(oldName, newName, opts = {}) {
837
948
  * `import` / `remove` / `delete`. Pass the bundle's resolved backend
838
949
  * (`bundle.backend ?? 'keychain'`).
839
950
  */
840
- export function bundleItemStore(backend) {
841
- return itemStore(backend ?? 'keychain');
951
+ export function bundleItemStore(backend, opts) {
952
+ const store = itemStore(backend ?? 'keychain');
953
+ // `never`-policy bundles write their per-key values without the biometry ACL
954
+ // (same rationale as the metadata write in writeBundle). Wrap `set` so every
955
+ // value the add/import paths write inherits the no-ACL flag; reads, deletes,
956
+ // and existence checks are ACL-independent and pass through untouched.
957
+ if (opts?.noAcl) {
958
+ return { ...store, set: (item, value) => store.set(item, value, { noAcl: true }) };
959
+ }
960
+ return store;
842
961
  }
843
962
  // Iterate all keychain-backed keys in a bundle for cleanup on rm/unset.
844
963
  export function keychainItemsForBundle(bundle) {
@@ -74,9 +74,12 @@ export declare const fileStore: {
74
74
  /** File-only KeychainBackend (exported for tests; the Linux backend uses these
75
75
  * ops with auto-provision allowed). */
76
76
  export declare const fileBackend: KeychainBackend;
77
+ /** Resolved passphrase directory (exported for integration tests). */
78
+ export declare function resolvePassphraseDir(): string;
77
79
  /** Test-only: reset module state (file dir + cached passphrase). */
78
80
  export declare function _resetFileStoreForTest(opts?: {
79
81
  fileDir?: string | null;
82
+ passphraseDir?: string | null;
80
83
  passphrase?: string | null;
81
84
  }): void;
82
85
  export {};
@@ -28,6 +28,7 @@ import * as path from 'path';
28
28
  import { encodePwshBase64 } from '../pwsh.js';
29
29
  // ---------- file store location ----------
30
30
  let fileDirOverride = null;
31
+ let passphraseDirOverride = null;
31
32
  let cachedPassphrase = null;
32
33
  let warnedAutoPassphrase = false;
33
34
  export function fileDir() {
@@ -106,29 +107,40 @@ function readPassphraseFromTty() {
106
107
  fs.closeSync(fd);
107
108
  }
108
109
  }
109
- /** Path of the auto-provisioned machine-local passphrase. Lives alongside the
110
- * encrypted items but is never itself an item (no `.enc` suffix, so it's
111
- * excluded from list/has/get and from fileFallbackPreviouslyActivated). */
110
+ /**
111
+ * Directory for the auto-provisioned machine-local passphrase. Kept outside
112
+ * `fileDir()` so a scan of the encrypted store never co-locates key + ciphertext.
113
+ */
114
+ function passphraseDir() {
115
+ return passphraseDirOverride ?? path.join(os.homedir(), '.agents', '.secrets-key');
116
+ }
117
+ function ensurePassphraseDir() {
118
+ fs.mkdirSync(passphraseDir(), { recursive: true, mode: 0o700 });
119
+ }
120
+ /** Path of the auto-provisioned machine-local passphrase (not an `.enc` item). */
112
121
  function passphraseFilePath() {
122
+ return path.join(passphraseDir(), 'passphrase');
123
+ }
124
+ /** Legacy co-located path — read-only for machines provisioned before #479. */
125
+ function legacyPassphraseFilePath() {
113
126
  return path.join(fileDir(), '.passphrase');
114
127
  }
115
128
  /** True if a machine-local passphrase has already been provisioned. */
116
129
  export function machinePassphraseExists() {
117
- try {
118
- return fs.readFileSync(passphraseFilePath(), 'utf8').trim().length > 0;
119
- }
120
- catch {
121
- return false;
122
- }
130
+ return readMachinePassphrase() !== null;
123
131
  }
124
132
  function readMachinePassphrase() {
125
- try {
126
- const p = fs.readFileSync(passphraseFilePath(), 'utf8').trim();
127
- return p.length > 0 ? p : null;
128
- }
129
- catch {
130
- return null;
133
+ for (const fp of [passphraseFilePath(), legacyPassphraseFilePath()]) {
134
+ try {
135
+ const p = fs.readFileSync(fp, 'utf8').trim();
136
+ if (p.length > 0)
137
+ return p;
138
+ }
139
+ catch {
140
+ // try next location
141
+ }
131
142
  }
143
+ return null;
132
144
  }
133
145
  /**
134
146
  * Provision (or read back) a stable machine-local passphrase for the encrypted
@@ -146,7 +158,7 @@ function provisionMachinePassphrase() {
146
158
  const existing = readMachinePassphrase();
147
159
  if (existing)
148
160
  return existing;
149
- ensureFileDir();
161
+ ensurePassphraseDir();
150
162
  const generated = randomBytes(32).toString('base64');
151
163
  const fp = passphraseFilePath();
152
164
  try {
@@ -317,9 +329,23 @@ export const fileBackend = {
317
329
  delete: fileDelete,
318
330
  list: fileList,
319
331
  };
332
+ /** Resolved passphrase directory (exported for integration tests). */
333
+ export function resolvePassphraseDir() {
334
+ return passphraseDir();
335
+ }
320
336
  /** Test-only: reset module state (file dir + cached passphrase). */
321
337
  export function _resetFileStoreForTest(opts = {}) {
322
338
  fileDirOverride = opts.fileDir ?? null;
339
+ if (opts.passphraseDir !== undefined) {
340
+ passphraseDirOverride = opts.passphraseDir;
341
+ }
342
+ else if (opts.fileDir) {
343
+ // Hermetic sibling when only the store dir is overridden (linux.test.ts).
344
+ passphraseDirOverride = path.resolve(opts.fileDir, '..', `${path.basename(opts.fileDir)}-key`);
345
+ }
346
+ else {
347
+ passphraseDirOverride = null;
348
+ }
323
349
  cachedPassphrase = opts.passphrase ?? null;
324
350
  warnedAutoPassphrase = false;
325
351
  }
@@ -103,8 +103,16 @@ export declare function getKeychainToken(item: string): string;
103
103
  * lookups — no biometric prompt path on those platforms.
104
104
  */
105
105
  export declare function getKeychainTokens(items: string[]): Map<string, string>;
106
- /** Store or update a secret value in the keychain/keyring. Device-local; biometry-gated on macOS. */
107
- export declare function setKeychainToken(item: string, value: string): void;
106
+ /** Store or update a secret value in the keychain/keyring. Device-local;
107
+ * biometry-gated on macOS. `opts.noAcl` (the `never` prompt-policy) writes our
108
+ * item WITHOUT the biometry access control so later reads are fully silent — it
109
+ * routes through the signed helper's `set-no-acl` path. A pinned helper that
110
+ * predates that path rejects the unknown command (exit 2) and this throws,
111
+ * rather than silently falling back to an ACL'd `set` (which would behave like
112
+ * `always`). Ignored by the Linux/Windows/test backends, which have no ACL. */
113
+ export declare function setKeychainToken(item: string, value: string, opts?: {
114
+ noAcl?: boolean;
115
+ }): void;
108
116
  /** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
109
117
  export declare function deleteKeychainToken(item: string): boolean;
110
118
  /**
@@ -140,6 +148,39 @@ export declare function listLegacyKeychainItems(prefix: string): string[];
140
148
  * concept, so the call is a no-op there.
141
149
  */
142
150
  export declare function migrateKeychainItem(item: string): boolean;
151
+ /**
152
+ * Enumerate data-protection items whose service starts with `prefix` that live
153
+ * under a NON-concrete access group — pre-#279 "orphans" filed under the implicit
154
+ * default group (the literal `2HTP252L87.*`) that the pinned-group queries can't
155
+ * see. Attributes only: never decrypts, never prompts. macOS only — Linux/Windows
156
+ * and the test backend have no access-group concept, so this returns [].
157
+ */
158
+ export declare function listOrphanedKeychainItems(prefix: string): string[];
159
+ /** Outcome of re-homing one orphaned keychain item. */
160
+ export interface OrphanMigrationResult {
161
+ item: string;
162
+ status: 'ok' | 'warn' | 'fail';
163
+ detail?: string;
164
+ }
165
+ /**
166
+ * Parse the `migrate-orphans` helper summary (one record per line):
167
+ * OK <service> re-homed
168
+ * WARN <service> <detail> pinned copy written but orphan not removed
169
+ * FAIL <service> <detail> could not re-home (orphan left intact)
170
+ * Unknown lines are ignored. Exported for unit testing without a keychain.
171
+ */
172
+ export declare function parseOrphanMigrationOutput(stdout: string): OrphanMigrationResult[];
173
+ /**
174
+ * Re-home every pre-#279 orphaned data-protection item under `prefix` into the
175
+ * concrete access group, behind a SINGLE Touch ID prompt for the whole batch.
176
+ * The helper reads each orphan by its exact persistent ref, adds the pinned copy
177
+ * (add-before-delete: a failed add leaves the orphan intact), then deletes the
178
+ * orphan by ref. Returns one result per item. macOS only — no-op elsewhere.
179
+ *
180
+ * Throws on Touch ID cancellation (exit 4) so callers can distinguish "user
181
+ * aborted" from "nothing to do" (empty array).
182
+ */
183
+ export declare function migrateOrphanedKeychainItems(prefix: string): OrphanMigrationResult[];
143
184
  /** Options controlling how secret refs are resolved. */
144
185
  export interface ResolveOptions {
145
186
  /** Translate a short keychain ID to a fully namespaced item name. */
@@ -264,8 +264,14 @@ export function getKeychainTokens(items) {
264
264
  }
265
265
  return result;
266
266
  }
267
- /** Store or update a secret value in the keychain/keyring. Device-local; biometry-gated on macOS. */
268
- export function setKeychainToken(item, value) {
267
+ /** Store or update a secret value in the keychain/keyring. Device-local;
268
+ * biometry-gated on macOS. `opts.noAcl` (the `never` prompt-policy) writes our
269
+ * item WITHOUT the biometry access control so later reads are fully silent — it
270
+ * routes through the signed helper's `set-no-acl` path. A pinned helper that
271
+ * predates that path rejects the unknown command (exit 2) and this throws,
272
+ * rather than silently falling back to an ACL'd `set` (which would behave like
273
+ * `always`). Ignored by the Linux/Windows/test backends, which have no ACL. */
274
+ export function setKeychainToken(item, value, opts) {
269
275
  if (backend) {
270
276
  backend.set(item, value);
271
277
  return;
@@ -303,12 +309,22 @@ export function setKeychainToken(item, value) {
303
309
  return;
304
310
  }
305
311
  const bin = getKeychainHelperPath();
306
- const result = spawnSync(bin, ['set', item, os.userInfo().username], {
312
+ // `never` policy no-ACL write. The `set-no-acl` subcommand exists only in a
313
+ // re-notarized helper; an older pinned helper dies with "Unknown command:
314
+ // set-no-acl" (exit 2), surfaced below — never a silent ACL'd downgrade.
315
+ const helperCmd = opts?.noAcl ? 'set-no-acl' : 'set';
316
+ const result = spawnSync(bin, [helperCmd, item, os.userInfo().username], {
307
317
  input: value,
308
318
  stdio: ['pipe', 'pipe', 'pipe'],
309
319
  });
310
320
  if (result.status !== 0) {
311
321
  const msg = result.stderr?.toString().trim();
322
+ if (opts?.noAcl && /unknown command/i.test(msg ?? '')) {
323
+ throw new Error(`The 'never' prompt-policy needs a Keychain helper with the no-ACL write path, ` +
324
+ `but the installed helper does not support it. Rebuild + re-notarize the signed ` +
325
+ `helper (scripts/build-keychain-helper.sh) and re-pin its sha, then retry. ` +
326
+ `(helper said: ${msg})`);
327
+ }
312
328
  throw new Error(msg || `Failed to write keychain item '${item}'.`);
313
329
  }
314
330
  }
@@ -419,6 +435,89 @@ export function migrateKeychainItem(item) {
419
435
  const msg = result.stderr?.toString().trim();
420
436
  throw new Error(msg || `Failed to migrate keychain item '${item}'.`);
421
437
  }
438
+ /**
439
+ * Enumerate data-protection items whose service starts with `prefix` that live
440
+ * under a NON-concrete access group — pre-#279 "orphans" filed under the implicit
441
+ * default group (the literal `2HTP252L87.*`) that the pinned-group queries can't
442
+ * see. Attributes only: never decrypts, never prompts. macOS only — Linux/Windows
443
+ * and the test backend have no access-group concept, so this returns [].
444
+ */
445
+ export function listOrphanedKeychainItems(prefix) {
446
+ if (backend)
447
+ return [];
448
+ assertSupportedPlatform();
449
+ if (isLinux() || isWindows())
450
+ return [];
451
+ const bin = getKeychainHelperPath();
452
+ const result = spawnSync(bin, ['list-orphans', prefix, os.userInfo().username], {
453
+ stdio: ['ignore', 'pipe', 'pipe'],
454
+ });
455
+ if (result.status !== 0) {
456
+ const msg = result.stderr?.toString().trim();
457
+ throw new Error(msg || `Failed to enumerate orphaned keychain items with prefix '${prefix}'.`);
458
+ }
459
+ const out = result.stdout?.toString() || '';
460
+ return out.split('\n').map((s) => s.trim()).filter(Boolean);
461
+ }
462
+ /**
463
+ * Parse the `migrate-orphans` helper summary (one record per line):
464
+ * OK <service> re-homed
465
+ * WARN <service> <detail> pinned copy written but orphan not removed
466
+ * FAIL <service> <detail> could not re-home (orphan left intact)
467
+ * Unknown lines are ignored. Exported for unit testing without a keychain.
468
+ */
469
+ export function parseOrphanMigrationOutput(stdout) {
470
+ const results = [];
471
+ for (const line of stdout.split('\n')) {
472
+ const trimmed = line.trim();
473
+ if (!trimmed)
474
+ continue;
475
+ const sep = trimmed.indexOf(' ');
476
+ const tag = sep === -1 ? trimmed : trimmed.slice(0, sep);
477
+ const rest = sep === -1 ? '' : trimmed.slice(sep + 1);
478
+ if (tag === 'OK') {
479
+ // OK carries only the service name (no trailing detail).
480
+ results.push({ item: rest, status: 'ok' });
481
+ }
482
+ else if (tag === 'WARN' || tag === 'FAIL') {
483
+ // WARN/FAIL are 'TAG <service> <detail>'. Service names are space-free
484
+ // (validateBundleName / validateEnvKey), so the first token IS the exact
485
+ // service — this stays consistent with listOrphanedKeychainItems for the
486
+ // healed-set reconciliation in migrate-acl.
487
+ const item = rest.split(' ')[0] ?? rest;
488
+ results.push({ item, status: tag === 'WARN' ? 'warn' : 'fail', detail: rest });
489
+ }
490
+ }
491
+ return results;
492
+ }
493
+ /**
494
+ * Re-home every pre-#279 orphaned data-protection item under `prefix` into the
495
+ * concrete access group, behind a SINGLE Touch ID prompt for the whole batch.
496
+ * The helper reads each orphan by its exact persistent ref, adds the pinned copy
497
+ * (add-before-delete: a failed add leaves the orphan intact), then deletes the
498
+ * orphan by ref. Returns one result per item. macOS only — no-op elsewhere.
499
+ *
500
+ * Throws on Touch ID cancellation (exit 4) so callers can distinguish "user
501
+ * aborted" from "nothing to do" (empty array).
502
+ */
503
+ export function migrateOrphanedKeychainItems(prefix) {
504
+ if (backend)
505
+ return [];
506
+ assertSupportedPlatform();
507
+ if (isLinux() || isWindows())
508
+ return [];
509
+ const bin = getKeychainHelperPath();
510
+ const result = spawnSync(bin, ['migrate-orphans', prefix, os.userInfo().username], {
511
+ stdio: ['ignore', 'pipe', 'pipe'],
512
+ });
513
+ if (result.status === 4)
514
+ throw new Error('Touch ID cancelled during orphan migration.');
515
+ if (result.status !== 0) {
516
+ const msg = result.stderr?.toString().trim();
517
+ throw new Error(msg || `Failed to migrate orphaned keychain items with prefix '${prefix}'.`);
518
+ }
519
+ return parseOrphanMigrationOutput(result.stdout?.toString() || '');
520
+ }
422
521
  function expandHome(p) {
423
522
  if (p.startsWith('~/') || p === '~') {
424
523
  return path.join(os.homedir(), p.slice(1));
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Zero-knowledge / JIT secret delivery over MCP (issue #333).
3
+ *
4
+ * A stdio MCP server exposing a single `get_secret(bundle, key)` tool. Unlike
5
+ * `agents secrets exec`, which spawns the child process with EVERY resolved
6
+ * value baked into `process.env` (any subprocess can `printenv` the lot — see
7
+ * buildSecretsExecEnv in src/commands/secrets.ts), this server hands an
8
+ * MCP-speaking agent framework ONE credential at a time, by name, at call
9
+ * time. The raw value is returned only inside the tool result: it never enters
10
+ * the child's environment, is never logged, and never appears in `tools/list`
11
+ * metadata (names only).
12
+ *
13
+ * Resolution reuses the canonical bundle read path
14
+ * (readAndResolveBundleEnv -> getKeychainToken); no keychain access is
15
+ * re-implemented here. On Linux the libsecret backend (or its encrypted-file
16
+ * fallback for locked/headless collections) applies transparently through that
17
+ * abstraction, so the server stays backend-agnostic and works unchanged on
18
+ * macOS.
19
+ *
20
+ * Transport: the MCP stdio transport is newline-delimited JSON-RPC 2.0 (one
21
+ * message per line, UTF-8, no embedded newlines). That framing is trivial, so
22
+ * we implement it directly rather than pull in the @modelcontextprotocol/sdk
23
+ * server dependency — keeping the tree dependency-free and the handler pure and
24
+ * unit-testable in-process.
25
+ */
26
+ /** MCP protocol revision this server negotiates. */
27
+ export declare const MCP_PROTOCOL_VERSION = "2024-11-05";
28
+ /** The single tool this server exposes. */
29
+ export declare const GET_SECRET_TOOL = "get_secret";
30
+ /** serverInfo.name reported at `initialize`. */
31
+ export declare const MCP_SERVER_NAME = "agents-secrets";
32
+ export interface JsonRpcRequest {
33
+ jsonrpc?: string;
34
+ id?: string | number | null;
35
+ method?: string;
36
+ params?: unknown;
37
+ }
38
+ interface JsonRpcSuccess {
39
+ jsonrpc: '2.0';
40
+ id: string | number | null;
41
+ result: unknown;
42
+ }
43
+ interface JsonRpcErrorResponse {
44
+ jsonrpc: '2.0';
45
+ id: string | number | null;
46
+ error: {
47
+ code: number;
48
+ message: string;
49
+ };
50
+ }
51
+ export type JsonRpcResponse = JsonRpcSuccess | JsonRpcErrorResponse;
52
+ /** Per-bundle metadata for `tools/list` — names only, NEVER values. */
53
+ export interface BundleKeyMetadata {
54
+ bundle: string;
55
+ description?: string;
56
+ keys: string[];
57
+ }
58
+ /**
59
+ * Enumerate every bundle and its key names WITHOUT resolving or reading any
60
+ * value. Mirrors how `agents secrets list` surfaces bundle metadata: names are
61
+ * safe to expose, values are not. `listBundles()` reads only the bundle
62
+ * metadata items (var definitions), never the secret items behind them.
63
+ */
64
+ export declare function listSecretMetadata(): BundleKeyMetadata[];
65
+ /**
66
+ * Resolve a single secret value by bundle + key through the canonical read
67
+ * path. Throws a clear error when the bundle or the key is absent.
68
+ *
69
+ * The bundle metadata is read first (readBundle) so a missing key is reported
70
+ * without triggering resolution — and, on macOS, without prompting Touch ID —
71
+ * for the other keys in the bundle.
72
+ */
73
+ export declare function resolveSecret(bundle: string, key: string): string;
74
+ /**
75
+ * Handle one parsed JSON-RPC request. Returns the response object, or `null`
76
+ * for notifications (which take no reply). Pure — no I/O, no logging — so tests
77
+ * drive it directly.
78
+ */
79
+ export declare function handleMcpRequest(req: JsonRpcRequest, ctx?: {
80
+ version?: string;
81
+ }): JsonRpcResponse | null;
82
+ /**
83
+ * Run the stdio MCP server: read newline-delimited JSON-RPC requests from
84
+ * `input`, write responses to `output`. Resolves when the input stream ends.
85
+ * Streams are injectable so tests can drive a full request/response loop
86
+ * without spawning a process.
87
+ */
88
+ export declare function runSecretsMcpServer(opts?: {
89
+ input?: NodeJS.ReadableStream;
90
+ output?: NodeJS.WritableStream;
91
+ version?: string;
92
+ }): Promise<void>;
93
+ export {};