@phnx-labs/agents-cli 1.22.56 → 1.22.58
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.
- package/CHANGELOG.md +70 -0
- package/README.md +4 -4
- package/dist/bootstrap.js +11 -2
- package/dist/cli/command-registry.d.ts +0 -1
- package/dist/cli/command-registry.js +0 -3
- package/dist/commands/accounts.js +7 -3
- package/dist/commands/apply.js +10 -2
- package/dist/commands/exec.js +1 -1
- package/dist/commands/fork.d.ts +23 -10
- package/dist/commands/fork.js +115 -58
- package/dist/commands/hooks.js +4 -4
- package/dist/commands/insights.d.ts +7 -5
- package/dist/commands/insights.js +16 -9
- package/dist/commands/monitors.js +11 -0
- package/dist/commands/perf.d.ts +16 -7
- package/dist/commands/perf.js +29 -20
- package/dist/commands/prune.js +5 -3
- package/dist/commands/routines.d.ts +8 -0
- package/dist/commands/routines.js +57 -3
- package/dist/commands/rules.js +1 -1
- package/dist/commands/sessions-picker.d.ts +11 -0
- package/dist/commands/sessions-picker.js +16 -0
- package/dist/commands/sessions.js +1 -0
- package/dist/commands/share.d.ts +14 -0
- package/dist/commands/share.js +43 -2
- package/dist/commands/ssh.js +24 -14
- package/dist/commands/status.js +1 -1
- package/dist/commands/sync.js +83 -7
- package/dist/commands/traces.js +7 -0
- package/dist/commands/trash.d.ts +2 -2
- package/dist/commands/trash.js +2 -6
- package/dist/commands/versions.d.ts +2 -2
- package/dist/commands/versions.js +1 -10
- package/dist/commands/view.d.ts +2 -2
- package/dist/commands/view.js +7 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +14 -0
- package/dist/lib/account-registry.d.ts +5 -1
- package/dist/lib/account-registry.js +47 -14
- package/dist/lib/accounting/capacity.d.ts +18 -7
- package/dist/lib/accounting/capacity.js +19 -8
- package/dist/lib/accounting/usage-ingest.d.ts +1 -0
- package/dist/lib/accounting/usage-ingest.js +75 -0
- package/dist/lib/accounting/usage-sync.d.ts +97 -0
- package/dist/lib/accounting/usage-sync.js +203 -0
- package/dist/lib/accounting/usage.d.ts +48 -2
- package/dist/lib/accounting/usage.js +79 -2
- package/dist/lib/agent-spec/agents.js +1 -1
- package/dist/lib/analytics/mix-commands.d.ts +8 -7
- package/dist/lib/analytics/mix-commands.js +50 -73
- package/dist/lib/auth-mint.d.ts +11 -1
- package/dist/lib/auth-mint.js +21 -6
- package/dist/lib/browser/ipc.d.ts +8 -0
- package/dist/lib/browser/ipc.js +87 -0
- package/dist/lib/browser/service.d.ts +19 -0
- package/dist/lib/browser/service.js +96 -11
- package/dist/lib/browser/sessions-list.js +10 -1
- package/dist/lib/daemon/daemon.js +5 -0
- package/dist/lib/daemon/runner.d.ts +3 -0
- package/dist/lib/daemon/runner.js +95 -53
- package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
- package/dist/lib/daemon/usage-sync-service.js +42 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +17 -6
- package/dist/lib/device-config.js +25 -11
- package/dist/lib/devices/connect.d.ts +17 -8
- package/dist/lib/devices/connect.js +31 -14
- package/dist/lib/devices/pool.d.ts +4 -3
- package/dist/lib/devices/pool.js +13 -5
- package/dist/lib/doctor-diff.js +77 -7
- package/dist/lib/exec.d.ts +6 -41
- package/dist/lib/exec.js +6 -41
- package/dist/lib/fleet/manifest.d.ts +17 -0
- package/dist/lib/fleet/manifest.js +26 -0
- package/dist/lib/git.d.ts +13 -1
- package/dist/lib/git.js +36 -7
- package/dist/lib/harness/adapter.d.ts +7 -7
- package/dist/lib/harness/adapters/claude.js +3 -2
- package/dist/lib/hooks/install.d.ts +27 -11
- package/dist/lib/hooks/install.js +42 -17
- package/dist/lib/hosts/reconnect.d.ts +52 -203
- package/dist/lib/hosts/reconnect.js +64 -284
- package/dist/lib/hosts/remote-cmd.d.ts +9 -0
- package/dist/lib/hosts/remote-cmd.js +22 -0
- package/dist/lib/installations/migrate.d.ts +6 -120
- package/dist/lib/installations/migrate.js +27 -259
- package/dist/lib/installations/shims.d.ts +13 -95
- package/dist/lib/installations/shims.js +22 -139
- package/dist/lib/installations/store.js +1 -1
- package/dist/lib/installations/versions.d.ts +26 -133
- package/dist/lib/installations/versions.js +41 -204
- package/dist/lib/perf/db.d.ts +1 -1
- package/dist/lib/perf/db.js +1 -1
- package/dist/lib/plugins/skills.d.ts +8 -1
- package/dist/lib/plugins/skills.js +18 -2
- package/dist/lib/refresh.d.ts +9 -0
- package/dist/lib/refresh.js +3 -1
- package/dist/lib/routine-readiness.d.ts +15 -1
- package/dist/lib/routine-readiness.js +41 -0
- package/dist/lib/sandbox.d.ts +4 -1
- package/dist/lib/sandbox.js +30 -1
- package/dist/lib/secrets/agent.d.ts +80 -225
- package/dist/lib/secrets/agent.js +139 -401
- package/dist/lib/secrets/bundles.d.ts +73 -222
- package/dist/lib/secrets/bundles.js +168 -467
- package/dist/lib/secrets/reaper.d.ts +28 -70
- package/dist/lib/secrets/reaper.js +30 -85
- package/dist/lib/secrets/remote.d.ts +42 -129
- package/dist/lib/secrets/remote.js +55 -173
- package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
- package/dist/lib/self-heal/checks/install-staging.js +96 -0
- package/dist/lib/self-heal/registry.js +2 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/self-update.d.ts +23 -0
- package/dist/lib/self-update.js +50 -0
- package/dist/lib/session/active.d.ts +16 -32
- package/dist/lib/session/active.js +10 -68
- package/dist/lib/session/db.d.ts +24 -36
- package/dist/lib/session/db.js +143 -44
- package/dist/lib/session/discover.d.ts +6 -58
- package/dist/lib/session/discover.js +5 -43
- package/dist/lib/session/fork.d.ts +45 -26
- package/dist/lib/session/fork.js +32 -95
- package/dist/lib/session/parse.d.ts +1 -19
- package/dist/lib/session/parse.js +2 -15
- package/dist/lib/session/tool-calls.d.ts +43 -1
- package/dist/lib/session/tool-calls.js +74 -44
- package/dist/lib/session/tool-store.d.ts +33 -2
- package/dist/lib/session/tool-store.js +56 -3
- package/dist/lib/staleness/writers/sources.d.ts +5 -0
- package/dist/lib/staleness/writers/sources.js +2 -1
- package/dist/lib/startup/command-registry.d.ts +8 -2
- package/dist/lib/startup/command-registry.js +12 -4
- package/dist/lib/sync-status.d.ts +22 -0
- package/dist/lib/sync-status.js +27 -0
- package/dist/lib/sync-umbrella.d.ts +9 -0
- package/dist/lib/sync-umbrella.js +21 -2
- package/dist/lib/traces/insights.d.ts +47 -14
- package/dist/lib/traces/insights.js +92 -21
- package/dist/lib/traces/phenotype.d.ts +23 -3
- package/dist/lib/traces/phenotype.js +72 -24
- package/dist/lib/traces/sync.d.ts +15 -0
- package/dist/lib/traces/sync.js +104 -19
- package/dist/lib/traces/worker-template.js +154 -1
- package/package.json +1 -1
|
@@ -1,24 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Secret bundles — named sets of environment variables backed by a secret store.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
* `agents-cli.secrets.<bundle>.<key>`. Two backends carry those items:
|
|
7
|
-
*
|
|
8
|
-
* - `keychain` (default): the macOS Keychain (device-local, Touch ID / device
|
|
9
|
-
* passcode gated) or Linux libsecret — see src/lib/secrets/index.ts.
|
|
10
|
-
* - `file`: an AES-256-GCM encrypted-file store keyed by a passphrase
|
|
11
|
-
* (src/lib/secrets/filestore.ts). Opt-in, for headless / remote runs where
|
|
12
|
-
* no biometry prompt can be satisfied (e.g. a release on a remote Mac over
|
|
13
|
-
* SSH). The item-name scheme is identical, so the only difference is where
|
|
14
|
-
* bytes land. A file-backed bundle is discovered by the presence of its
|
|
15
|
-
* metadata item in the file store.
|
|
16
|
-
* - `vault`: a single age-encrypted ~/.agents/vault.age file unlocked by
|
|
17
|
-
* `agents secrets vault unlock`; intended for user-managed cross-machine file sync.
|
|
18
|
-
*
|
|
19
|
-
* Server-backed cross-machine sync is handled by src/lib/secrets/sync.ts via
|
|
20
|
-
* an explicit encrypted export/import flow; the bundle layer also supports the
|
|
21
|
-
* local vault backend for user-managed file sync.
|
|
3
|
+
* Metadata lives under `agents-cli.bundles.<name>`; values under
|
|
4
|
+
* `agents-cli.secrets.<bundle>.<key>`. Backends: `keychain` (default),
|
|
5
|
+
* `file` (headless/passphrase), and `vault` (age-encrypted, user-synced).
|
|
22
6
|
*/
|
|
23
7
|
import * as fs from 'fs';
|
|
24
8
|
import * as os from 'os';
|
|
@@ -47,11 +31,9 @@ const keychainStore = {
|
|
|
47
31
|
delete: deleteKeychainToken,
|
|
48
32
|
list: listKeychainItems,
|
|
49
33
|
};
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// set, type, or remember, and no Touch ID. Set AGENTS_SECRETS_PASSPHRASE to opt
|
|
54
|
-
// into an off-disk key.
|
|
34
|
+
// File store auto-provisions a machine-local 0600 key on every platform,
|
|
35
|
+
// so `agents secrets` works headless without a passphrase. Override with
|
|
36
|
+
// AGENTS_SECRETS_PASSPHRASE.
|
|
55
37
|
const fileItemStore = {
|
|
56
38
|
has: (item) => fileStore.has(item),
|
|
57
39
|
get: (item) => fileStore.get(item),
|
|
@@ -87,10 +69,8 @@ function itemStore(backend) {
|
|
|
87
69
|
return keychainStore;
|
|
88
70
|
}
|
|
89
71
|
/**
|
|
90
|
-
* Discover a bundle's backend by location
|
|
91
|
-
*
|
|
92
|
-
* check — no passphrase, no Touch ID — so it sidesteps the chicken-and-egg of
|
|
93
|
-
* "read metadata to learn where metadata lives." Absent ⇒ keychain.
|
|
72
|
+
* Discover a bundle's backend by location. File store is checked first (a plain
|
|
73
|
+
* existence test, no passphrase); absent/locked vault falls back to keychain.
|
|
94
74
|
*/
|
|
95
75
|
export function bundleBackend(name) {
|
|
96
76
|
const item = BUNDLE_META_PREFIX + name;
|
|
@@ -125,7 +105,7 @@ export const SECRET_TYPES = [
|
|
|
125
105
|
'webhook',
|
|
126
106
|
'note',
|
|
127
107
|
];
|
|
128
|
-
/**
|
|
108
|
+
/** Throttle last_used writes so the keychain isn't touched on every injection. */
|
|
129
109
|
const LAST_USED_THROTTLE_MS = 60_000;
|
|
130
110
|
export const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
|
|
131
111
|
export const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -138,11 +118,8 @@ export const RESERVED_ENV_NAMES = new Set([
|
|
|
138
118
|
'TMPDIR', 'TMP', 'TEMP', 'LOGNAME', 'UID', 'EUID', 'HOSTNAME',
|
|
139
119
|
]);
|
|
140
120
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* so they never pop Touch ID and they can cross the fleet. A keychain- or
|
|
144
|
-
* vault-backed bundle of this name is a misconfiguration: the consumer used to
|
|
145
|
-
* return null (SEC-GAP-3) and silently fall through to Touch ID.
|
|
121
|
+
* Reserved FILE-BACKED bundle for long-lived setup tokens. Must be file-backed;
|
|
122
|
+
* a keychain/vault `auth` bundle is a misconfiguration and is ignored.
|
|
146
123
|
*/
|
|
147
124
|
export const AUTH_BUNDLE_NAME = 'auth';
|
|
148
125
|
export const AUTH_BUNDLE_BACKEND = 'file';
|
|
@@ -163,7 +140,7 @@ export class ReservedBundleWrongBackendError extends Error {
|
|
|
163
140
|
this.backend = backend;
|
|
164
141
|
}
|
|
165
142
|
}
|
|
166
|
-
/** Fail loud when
|
|
143
|
+
/** Fail loud when a reserved bundle is on the wrong backend. */
|
|
167
144
|
export function assertReservedBundleBackend(name, backend) {
|
|
168
145
|
if (!isReservedBundleName(name))
|
|
169
146
|
return;
|
|
@@ -172,21 +149,33 @@ export function assertReservedBundleBackend(name, backend) {
|
|
|
172
149
|
}
|
|
173
150
|
}
|
|
174
151
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
152
|
+
* Check the reserved `auth` bundle. `ok` is true when absent or file-backed.
|
|
153
|
+
*
|
|
154
|
+
* Read-only status probe (`agents doctor`, `agents fleet apply`) — unlike the
|
|
155
|
+
* destructive-write guards `bundleExists()`/`hasKeychainToken()` document
|
|
156
|
+
* themselves as failing loud for, a diagnostic MUST NOT crash the whole
|
|
157
|
+
* command because ONE optional finding could not reach the keychain (e.g. the
|
|
158
|
+
* macOS Keychain helper source is unavailable — PHNX-3385). Treat an
|
|
159
|
+
* unreachable keychain the same as "bundle absent": nothing to warn about.
|
|
177
160
|
*/
|
|
178
161
|
export function inspectReservedAuthBundle() {
|
|
179
|
-
|
|
162
|
+
let exists;
|
|
163
|
+
try {
|
|
164
|
+
exists = bundleExists(AUTH_BUNDLE_NAME);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return { exists: false, backend: null, ok: true };
|
|
168
|
+
}
|
|
169
|
+
if (!exists) {
|
|
180
170
|
return { exists: false, backend: null, ok: true };
|
|
181
171
|
}
|
|
182
172
|
const backend = bundleBackend(AUTH_BUNDLE_NAME);
|
|
183
173
|
return { exists: true, backend, ok: backend === AUTH_BUNDLE_BACKEND };
|
|
184
174
|
}
|
|
185
175
|
/**
|
|
186
|
-
* After a file-backed import,
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* the destination daemon does not hold still counted as success.
|
|
176
|
+
* After a file-backed import, verify the keys actually decrypt. Import used to
|
|
177
|
+
* report success for ciphertext sealed under a forwarded passphrase this process
|
|
178
|
+
* does not hold.
|
|
190
179
|
*/
|
|
191
180
|
export function assertFileBundleDecryptable(name, keys) {
|
|
192
181
|
if (keys.length === 0)
|
|
@@ -273,9 +262,8 @@ export function validateSecretType(t) {
|
|
|
273
262
|
}
|
|
274
263
|
}
|
|
275
264
|
/**
|
|
276
|
-
* Validate
|
|
277
|
-
*
|
|
278
|
-
* "today" is treated as past (per spec).
|
|
265
|
+
* Validate a future `expires` date. Accepts strict 'YYYY-MM-DD'; end-of-day UTC
|
|
266
|
+
* means "today" is past.
|
|
279
267
|
*/
|
|
280
268
|
export function validateExpiresFutureDated(iso) {
|
|
281
269
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
|
|
@@ -296,12 +284,9 @@ export function bundleExists(name) {
|
|
|
296
284
|
return itemStore(bundleBackend(name)).has(bundleMetaItem(name));
|
|
297
285
|
}
|
|
298
286
|
/**
|
|
299
|
-
* Thrown
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
* store). It is deliberately narrow — a bundle that is merely *locked for this
|
|
303
|
-
* run* (headless macOS without `AGENTS_SECRETS_PASSPHRASE`, or a vault that is
|
|
304
|
-
* not logged in) is recoverable and must not be collapsed into this.
|
|
287
|
+
* Thrown for file-store ciphertext that will not decrypt (lost/rotated key or
|
|
288
|
+
* tampered store). Narrow: temporarily-locked bundles are recoverable and must
|
|
289
|
+
* not be collapsed here.
|
|
305
290
|
*/
|
|
306
291
|
export class BundleUndecryptableError extends Error {
|
|
307
292
|
constructor(message) {
|
|
@@ -310,18 +295,9 @@ export class BundleUndecryptableError extends Error {
|
|
|
310
295
|
}
|
|
311
296
|
}
|
|
312
297
|
/**
|
|
313
|
-
* Read a bundle,
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* Deleting such a bundle is the only way out of that state, and deletion needs
|
|
318
|
-
* no plaintext, so it must not be gated behind a successful decrypt. Every other
|
|
319
|
-
* failure rethrows: a genuinely missing bundle ("not found"), and — critically —
|
|
320
|
-
* a bundle that is only *temporarily locked* for this run (headless macOS with no
|
|
321
|
-
* `AGENTS_SECRETS_PASSPHRASE`, or a not-logged-in vault). Collapsing that
|
|
322
|
-
* recoverable "set the env / log in" state into "unreadable, safe to delete"
|
|
323
|
-
* would let `secrets delete <name> --yes` silently destroy a perfectly healthy
|
|
324
|
-
* bundle from a cron/launchd run that merely forgot to export the passphrase.
|
|
298
|
+
* Read a bundle, returning null only when metadata is present but permanently
|
|
299
|
+
* unreadable (`BundleUndecryptableError`). Other failures — including temporary
|
|
300
|
+
* lockout — rethrow so a healthy bundle is never deleted by mistake.
|
|
325
301
|
*/
|
|
326
302
|
export function readBundleIfDecryptable(name) {
|
|
327
303
|
try {
|
|
@@ -340,37 +316,28 @@ export function readBundle(name) {
|
|
|
340
316
|
assertVaultBackendUsable(name);
|
|
341
317
|
let json;
|
|
342
318
|
try {
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
// a headless `readBundle` never trips the fail-fast. (A legacy
|
|
346
|
-
// pre-metadata-heal ACL'd metadata item can still prompt once; it heals on
|
|
347
|
-
// the next interactive read.)
|
|
319
|
+
// Metadata is no-ACL by contract; attest silentNoAcl so headless reads don't
|
|
320
|
+
// trip the raw-read storm guard. A legacy ACL'd item may prompt once, then heals.
|
|
348
321
|
json = backend === 'keychain'
|
|
349
322
|
? getKeychainToken(bundleMetaItem(name), { silentNoAcl: true })
|
|
350
323
|
: itemStore(backend).get(bundleMetaItem(name));
|
|
351
324
|
}
|
|
352
325
|
catch (err) {
|
|
353
|
-
//
|
|
354
|
-
// wrong-passphrase error, not a missing bundle — surface that clearly.
|
|
326
|
+
// File-backed metadata that fails to decrypt is a wrong-passphrase error.
|
|
355
327
|
if (backend === 'file' && fileStore.has(bundleMetaItem(name))) {
|
|
356
328
|
throw new BundleUndecryptableError(`Bundle '${name}': failed to decrypt — wrong AGENTS_SECRETS_PASSPHRASE or tampered file store. (${err.message})`);
|
|
357
329
|
}
|
|
358
330
|
if (vaultExists() && !getVaultSession().loggedIn) {
|
|
359
331
|
throw new Error(`Synced secrets are locked. Run: agents secrets vault unlock`);
|
|
360
332
|
}
|
|
361
|
-
//
|
|
362
|
-
// (a locked login keychain, or a legacy ACL'd metadata item before first
|
|
363
|
-
// unlock). `has` counts an unreadable item as present, so a metadata item
|
|
364
|
-
// that exists but could not be read must not report as "not found" — an
|
|
365
|
-
// existence answer and a read answer may not contradict (RUSH-2253).
|
|
333
|
+
// A present-but-unreadable keychain item must not report "not found".
|
|
366
334
|
if (backend === 'keychain') {
|
|
367
335
|
let present;
|
|
368
336
|
try {
|
|
369
337
|
present = hasKeychainToken(bundleMetaItem(name));
|
|
370
338
|
}
|
|
371
339
|
catch (probeErr) {
|
|
372
|
-
// Keychain unreachable
|
|
373
|
-
// add-the-key — surface the reachability failure, not a false absence.
|
|
340
|
+
// Keychain unreachable — fail loud rather than report false absence.
|
|
374
341
|
throw new Error(`Secrets bundle '${name}': ${probeErr.message}`);
|
|
375
342
|
}
|
|
376
343
|
if (present) {
|
|
@@ -390,9 +357,7 @@ export function readBundle(name) {
|
|
|
390
357
|
if (!parsed || typeof parsed !== 'object') {
|
|
391
358
|
throw new Error(`Bundle '${name}' is malformed.`);
|
|
392
359
|
}
|
|
393
|
-
//
|
|
394
|
-
// here; the SecretsBundle shape is the only source of truth. `backend` is
|
|
395
|
-
// authoritative from location discovery, not the persisted field.
|
|
360
|
+
// Drop unknown fields; `backend` is authoritative from location discovery.
|
|
396
361
|
const bundle = {
|
|
397
362
|
name,
|
|
398
363
|
description: parsed.description,
|
|
@@ -418,12 +383,7 @@ export function readBundle(name) {
|
|
|
418
383
|
}
|
|
419
384
|
return bundle;
|
|
420
385
|
}
|
|
421
|
-
/** Normalize the persisted
|
|
422
|
-
* tokens for cross-version compatibility: `session` ⇒ `hold`, `biometry` ⇒ an
|
|
423
|
-
* explicit `always`. An absent token ⇒ undefined, which resolves to the
|
|
424
|
-
* configured default policy (`hold`). Persisting an explicit `always` as the
|
|
425
|
-
* legacy `biometry` token keeps older CLIs correct — they don't know `hold`,
|
|
426
|
-
* read `biometry` as undefined, and fall back to their own always default. */
|
|
386
|
+
/** Normalize the persisted `tier` token to the current policy vocabulary. */
|
|
427
387
|
function parsePolicy(raw) {
|
|
428
388
|
if (raw === 'hold' || raw === 'daily' || raw === 'session')
|
|
429
389
|
return 'hold';
|
|
@@ -433,11 +393,7 @@ function parsePolicy(raw) {
|
|
|
433
393
|
return 'never';
|
|
434
394
|
return undefined;
|
|
435
395
|
}
|
|
436
|
-
/**
|
|
437
|
-
* policy. Configurable via `secrets.policy` in agents.yaml; `hold` (one Touch ID
|
|
438
|
-
* per hold window — `secrets.agent.holdMs`, 7d by default) unless the user
|
|
439
|
-
* explicitly opts back into prompt-every-time with `always`. Best-effort: an
|
|
440
|
-
* unreadable config falls back to the `hold` default. */
|
|
396
|
+
/** Default policy for bundles without an explicit one (`secrets.policy`). */
|
|
441
397
|
export function secretsDefaultPolicy() {
|
|
442
398
|
try {
|
|
443
399
|
return readMeta().secrets?.policy === 'always' ? 'always' : 'hold';
|
|
@@ -446,17 +402,14 @@ export function secretsDefaultPolicy() {
|
|
|
446
402
|
return 'hold';
|
|
447
403
|
}
|
|
448
404
|
}
|
|
449
|
-
/**
|
|
405
|
+
/** Effective prompt policy of a bundle. */
|
|
450
406
|
export function bundlePolicy(bundle) {
|
|
451
407
|
return bundle.policy ?? secretsDefaultPolicy();
|
|
452
408
|
}
|
|
453
409
|
/**
|
|
454
|
-
* Whether a bundle write should evict the broker-held copy.
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
* kill-switch the read fast-path honors), or when a test keychain backend is
|
|
458
|
-
* installed (an in-memory backend has no real keychain behind it, and a test
|
|
459
|
-
* writing bundle 'prod' must never evict the user's real 'prod' unlock).
|
|
410
|
+
* Whether a bundle write should evict the broker-held copy. Exported for tests.
|
|
411
|
+
* Skips when the writer opted out, the broker is disabled, or a test backend is
|
|
412
|
+
* active (so tests don't evict the user's real unlocks).
|
|
460
413
|
*/
|
|
461
414
|
export function shouldEvictAfterBundleWrite(skipRequested, noAgentEnv, backendOverridden) {
|
|
462
415
|
if (skipRequested)
|
|
@@ -476,7 +429,7 @@ function prepareBundleWrite(bundle) {
|
|
|
476
429
|
for (const key of Object.keys(bundle.vars)) {
|
|
477
430
|
validateEnvKey(key);
|
|
478
431
|
}
|
|
479
|
-
// Strip empty
|
|
432
|
+
// Strip empty meta entries so the JSON stays tidy.
|
|
480
433
|
let meta;
|
|
481
434
|
if (bundle.meta) {
|
|
482
435
|
for (const [key, m] of Object.entries(bundle.meta)) {
|
|
@@ -494,27 +447,18 @@ function prepareBundleWrite(bundle) {
|
|
|
494
447
|
}
|
|
495
448
|
}
|
|
496
449
|
}
|
|
497
|
-
//
|
|
498
|
-
// is sticky — once set we never overwrite it, including on legacy bundles
|
|
499
|
-
// that already carry one. updated_at always advances.
|
|
450
|
+
// created_at is sticky; updated_at always advances.
|
|
500
451
|
const now = new Date().toISOString();
|
|
501
452
|
if (!bundle.created_at)
|
|
502
453
|
bundle.created_at = now;
|
|
503
454
|
bundle.updated_at = now;
|
|
504
455
|
const payload = {
|
|
505
|
-
//
|
|
506
|
-
// the keychain item name is opaque, so listBundles recovers the display
|
|
507
|
-
// name from this field. Older CLIs drop unknown fields on read — safe.
|
|
456
|
+
// Persist the display name so hashed keychain items remain listable.
|
|
508
457
|
name: bundle.name,
|
|
509
458
|
description: bundle.description,
|
|
510
459
|
allow_exec: bundle.allow_exec ? true : undefined,
|
|
511
460
|
backend: backend === 'keychain' ? undefined : backend,
|
|
512
|
-
//
|
|
513
|
-
// versions on other synced machines keep reading it — `hold`⇒`session`,
|
|
514
|
-
// explicit `always`⇒`biometry`, `never`⇒`none`. An absent policy omits the
|
|
515
|
-
// token entirely and resolves to the configured default (`hold`) on read.
|
|
516
|
-
// An older CLI that doesn't know `none` reads it as undefined and falls back
|
|
517
|
-
// to its own default — safe, since it also lacks the no-ACL write path.
|
|
461
|
+
// Legacy wire token for cross-version sync.
|
|
518
462
|
tier: bundle.policy === 'hold' ? 'session'
|
|
519
463
|
: bundle.policy === 'always' ? 'biometry'
|
|
520
464
|
: bundle.policy === 'never' ? 'none'
|
|
@@ -533,25 +477,17 @@ function prepareBundleWrite(bundle) {
|
|
|
533
477
|
}
|
|
534
478
|
function finishBundleWrite(bundle, opts) {
|
|
535
479
|
emit('secrets.set', { module: 'secrets', bundle: bundle.name });
|
|
536
|
-
//
|
|
537
|
-
// re-resolves from the keychain instead of serving stale values.
|
|
480
|
+
// Evict the broker-held snapshot and durable session so the next read resolves fresh.
|
|
538
481
|
if (shouldEvictAfterBundleWrite(Boolean(opts.skipBrokerEviction), process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
|
|
539
482
|
agentEvictSync(bundle.name);
|
|
540
|
-
// Also drop any durable session snapshot, or a broker restart would rehydrate
|
|
541
|
-
// the stale env after a rotate/rename (session-store.ts).
|
|
542
483
|
deleteSession(bundle.name);
|
|
543
484
|
}
|
|
544
485
|
}
|
|
545
486
|
export function writeBundle(bundle, opts = {}) {
|
|
546
487
|
const prepared = prepareBundleWrite(bundle);
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
// separate agents-cli.secrets.* items that keep the bundle's policy ACL — so a
|
|
551
|
-
// no-ACL metadata item is what lets `secrets list` and crabbox's `agents
|
|
552
|
-
// devices list` enumerate bundles with no Touch ID prompt (RUSH-1759). On an
|
|
553
|
-
// un-updated pinned helper this write fails loudly (the no-ACL command is
|
|
554
|
-
// missing) rather than silently landing an ACL'd item.
|
|
488
|
+
// Metadata is non-sensitive by contract and stored no-ACL so `secrets list`
|
|
489
|
+
// can enumerate without Touch ID. A pinned helper without the no-ACL command
|
|
490
|
+
// fails loudly rather than silently landing an ACL'd item.
|
|
555
491
|
itemStore(prepared.backend).set(prepared.metadataItem, prepared.metadataJson, { noAcl: true });
|
|
556
492
|
if (prepared.backend === 'keychain')
|
|
557
493
|
addBundleToMetaIndex(bundle.name);
|
|
@@ -561,13 +497,9 @@ export function writeBundleWithItems(bundle, items, opts = {}) {
|
|
|
561
497
|
const prepared = prepareBundleWrite(bundle);
|
|
562
498
|
const store = itemStore(prepared.backend);
|
|
563
499
|
if (prepared.backend === 'keychain') {
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
// over both would either strip biometry off the real secrets or re-ACL the
|
|
568
|
-
// metadata. Write the values first, then the metadata last: bundle discovery
|
|
569
|
-
// keys on the metadata item's presence, so metadata-last means a partial
|
|
570
|
-
// write reads as "no bundle yet", never as a bundle with missing values.
|
|
500
|
+
// Keychain values carry the policy ACL; metadata is always no-ACL. They cannot
|
|
501
|
+
// share one batch flag, and metadata is written last so partial writes read as
|
|
502
|
+
// "no bundle yet".
|
|
571
503
|
if (items.size > 0) {
|
|
572
504
|
store.setBatch(new Map(items), { noAcl: bundle.policy === 'never' });
|
|
573
505
|
}
|
|
@@ -575,8 +507,7 @@ export function writeBundleWithItems(bundle, items, opts = {}) {
|
|
|
575
507
|
addBundleToMetaIndex(bundle.name);
|
|
576
508
|
}
|
|
577
509
|
else {
|
|
578
|
-
//
|
|
579
|
-
// both correct and cheaper — e.g. a single age re-encrypt for the vault.
|
|
510
|
+
// File/vault have no ACL; one batched write is cheaper.
|
|
580
511
|
const batch = new Map(items);
|
|
581
512
|
batch.set(prepared.metadataItem, prepared.metadataJson);
|
|
582
513
|
store.setBatch(batch, { noAcl: bundle.policy === 'never' });
|
|
@@ -599,16 +530,9 @@ export function deleteBundle(name) {
|
|
|
599
530
|
return deleted;
|
|
600
531
|
}
|
|
601
532
|
/**
|
|
602
|
-
* Parse a stored metadata JSON blob into a SecretsBundle,
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
* null to skip.
|
|
606
|
-
*
|
|
607
|
-
* `nameHint` is the name recovered from a cleartext service name (Linux, the
|
|
608
|
-
* file store, pre-re-key items) — authoritative when present, and the only
|
|
609
|
-
* source for legacy metadata that predates the persisted `name` field. With
|
|
610
|
-
* hashed service names (macOS, #316) the hint is undefined and the name comes
|
|
611
|
-
* from the JSON payload written by writeBundle.
|
|
533
|
+
* Parse a stored metadata JSON blob into a SecretsBundle, skipping malformed
|
|
534
|
+
* bundles. `backend` is authoritative. `nameHint` is cleartext (Linux/file/
|
|
535
|
+
* legacy); hashed keychain items recover the name from the persisted payload.
|
|
612
536
|
*/
|
|
613
537
|
function parseBundleMeta(nameHint, json, backend) {
|
|
614
538
|
let parsed;
|
|
@@ -646,9 +570,8 @@ function parseBundleMeta(nameHint, json, backend) {
|
|
|
646
570
|
}
|
|
647
571
|
return bundle;
|
|
648
572
|
}
|
|
649
|
-
// Sentinel
|
|
650
|
-
//
|
|
651
|
-
// a cache wipe just re-runs the heal — harmless, since it is idempotent.
|
|
573
|
+
// Sentinel for the one-time metadata-ACL heal. Lives in the regenerable helpers
|
|
574
|
+
// dir, so a cache wipe re-runs it harmlessly.
|
|
652
575
|
const METADATA_NOACL_SENTINEL = 'bundles-metadata-noacl-healed';
|
|
653
576
|
function metadataNoAclSentinelPath() {
|
|
654
577
|
return path.join(getHelpersDir(), 'secrets-agent', METADATA_NOACL_SENTINEL);
|
|
@@ -668,46 +591,26 @@ function markBundleMetadataAclHealed() {
|
|
|
668
591
|
fs.writeFileSync(file, '', 'utf8');
|
|
669
592
|
}
|
|
670
593
|
catch {
|
|
671
|
-
// Best effort —
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
// that also MATCHES the ACL'd secret VALUE items. On some machines macOS
|
|
680
|
-
// evaluates those value ACLs during that attributes-only scan and pops a generic
|
|
681
|
-
// "Agents CLI needs to authenticate" sheet — on EVERY launch (session-title
|
|
682
|
-
// generation, `agents devices list`, every agent run), because listBundles runs
|
|
683
|
-
// on essentially every secrets touch. Neither UIFail nor LAContext can list the
|
|
684
|
-
// no-ACL items while skipping the ACL'd ones (both return nothing), so the fix is
|
|
685
|
-
// to NOT do the broad scan: keep a per-machine index of the metadata items'
|
|
686
|
-
// STORAGE names in the regenerable helpers dir and read THAT (a silent file read)
|
|
687
|
-
// instead. The index holds opaque hashes only — no cleartext bundle names, so it
|
|
688
|
-
// leaks nothing #316 didn't already. It self-heals: absent/unbuilt → listBundles
|
|
689
|
-
// rebuilds it from the one-time broad scan; a stale entry only makes `secrets
|
|
690
|
-
// list` cosmetically incomplete and never affects a resolve-by-name (which
|
|
691
|
-
// computes the hashed name directly, never through this index).
|
|
594
|
+
// Best effort — missing sentinel just re-runs the idempotent heal later.
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// No-ACL bundle-metadata name index. With hashed service names (#316), listing
|
|
598
|
+
// metadata falls back to a broad `agents-cli.` scan that matches ACL'd secret
|
|
599
|
+
// values and pops Touch ID. We keep a per-machine index of opaque metadata
|
|
600
|
+
// storage names in the regenerable helpers dir to avoid that scan. Stale entries
|
|
601
|
+
// only make `secrets list` cosmetically incomplete; resolve-by-name never uses it.
|
|
692
602
|
function bundleMetaIndexPath() {
|
|
693
|
-
// Test-only
|
|
694
|
-
// temp so unit tests never touch the real helpers dir. Never set in prod.
|
|
603
|
+
// Test-only redirect so the suite never touches the real helpers dir.
|
|
695
604
|
return (process.env.AGENTS_SECRETS_META_INDEX_FILE ||
|
|
696
605
|
path.join(getHelpersDir(), 'secrets-agent', 'bundle-meta-index.json'));
|
|
697
606
|
}
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
// an OLD key reads as absent and is rebuilt — otherwise a re-key would leave
|
|
701
|
-
// stale hashed names that resolve to nothing and make every bundle "vanish".
|
|
607
|
+
// Fingerprint invalidates the index when the hashing key changes (#316 re-key),
|
|
608
|
+
// so stale storage names don't make bundles "vanish".
|
|
702
609
|
function metaIndexFingerprint() {
|
|
703
610
|
return keychainServiceAlias(`${BUNDLE_META_PREFIX}meta-index-fingerprint`);
|
|
704
611
|
}
|
|
705
612
|
function readBundleMetaIndex() {
|
|
706
|
-
//
|
|
707
|
-
// reading (and later writing) the real ~/.agents index from a mock-backend test
|
|
708
|
-
// would leak fixture bundle names into a developer's live cache. Same guard the
|
|
709
|
-
// sibling healKeychainBundleMetadataAclOnce uses — treat the index as absent so
|
|
710
|
-
// listBundles falls back to the (mock) scan.
|
|
613
|
+
// Never read/write the real index from a mock-backend test.
|
|
711
614
|
if (isKeychainBackendOverridden())
|
|
712
615
|
return null;
|
|
713
616
|
try {
|
|
@@ -729,24 +632,18 @@ function writeBundleMetaIndex(services) {
|
|
|
729
632
|
const file = bundleMetaIndexPath();
|
|
730
633
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
731
634
|
const payload = { fp: metaIndexFingerprint(), services: [...new Set(services)].sort() };
|
|
732
|
-
// Atomic write
|
|
733
|
-
//
|
|
734
|
-
// race two concurrent BUNDLE mutations and drop an entry, but that only makes
|
|
735
|
-
// a `secrets list` cosmetically incomplete (never a resolve-by-name) and
|
|
736
|
-
// self-heals on the next rebuild — an acceptable trade for rare bundle edits.
|
|
635
|
+
// Atomic write. Concurrent edits may still race and drop an entry, but that
|
|
636
|
+
// only makes `secrets list` cosmetically incomplete and self-heals.
|
|
737
637
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
738
638
|
fs.writeFileSync(tmp, JSON.stringify(payload), 'utf8');
|
|
739
639
|
fs.renameSync(tmp, file);
|
|
740
640
|
}
|
|
741
641
|
catch {
|
|
742
|
-
// Best effort —
|
|
743
|
-
// one-time broad scan on the next enumeration.
|
|
642
|
+
// Best effort — listBundles rebuilds from a broad scan on the next enumeration.
|
|
744
643
|
}
|
|
745
644
|
}
|
|
746
|
-
// Append a
|
|
747
|
-
// index
|
|
748
|
-
// hide every OTHER bundle — listBundles builds the complete index on its first
|
|
749
|
-
// scan, and this newly-written bundle is included in that scan.
|
|
645
|
+
// Append a storage name to an already-built index. No-op when null: a one-entry
|
|
646
|
+
// index would hide every other bundle until the next rebuild.
|
|
750
647
|
function addBundleToMetaIndex(name) {
|
|
751
648
|
const cur = readBundleMetaIndex();
|
|
752
649
|
if (cur === null)
|
|
@@ -771,20 +668,16 @@ export const __metaIndexForTest = {
|
|
|
771
668
|
remove: removeBundleFromMetaIndex,
|
|
772
669
|
};
|
|
773
670
|
/**
|
|
774
|
-
* Re-write
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
* delete-then-add `set-no-acl`) — contents and updated_at are preserved and no
|
|
778
|
-
* extra keychain read is issued. Exported for tests. Returns the count healed.
|
|
671
|
+
* Re-write keychain metadata items without the biometry ACL. The caller supplies
|
|
672
|
+
* the JSON already read, so contents are preserved and no extra read is issued.
|
|
673
|
+
* Exported for tests.
|
|
779
674
|
*/
|
|
780
675
|
export function healKeychainBundleMetadata(metaJsonByName) {
|
|
781
676
|
let healed = 0;
|
|
782
677
|
for (const [name, json] of metaJsonByName) {
|
|
783
678
|
try {
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
// place, no-ACL. A per-item failure (e.g. a pinned helper without
|
|
787
|
-
// set-no-acl) must not abort the rest.
|
|
679
|
+
// keychainStore.set hashes the cleartext name back to the same service,
|
|
680
|
+
// overwriting in place no-ACL. Per-item failures must not abort the rest.
|
|
788
681
|
keychainStore.set(bundleMetaItem(name), json, { noAcl: true });
|
|
789
682
|
healed++;
|
|
790
683
|
}
|
|
@@ -795,10 +688,8 @@ export function healKeychainBundleMetadata(metaJsonByName) {
|
|
|
795
688
|
return healed;
|
|
796
689
|
}
|
|
797
690
|
/**
|
|
798
|
-
* One-time driver
|
|
799
|
-
*
|
|
800
|
-
* backend has no real keychain — and gated by a sentinel so it runs at most
|
|
801
|
-
* once. Best-effort: a heal failure never breaks bundle listing.
|
|
691
|
+
* One-time driver for healKeychainBundleMetadata. macOS + real keychain only;
|
|
692
|
+
* gated by a sentinel. Best-effort: a heal failure never breaks listing.
|
|
802
693
|
*/
|
|
803
694
|
export function healKeychainBundleMetadataAclOnce(metaJsonByName) {
|
|
804
695
|
if (metaJsonByName.size === 0)
|
|
@@ -819,17 +710,8 @@ export function healKeychainBundleMetadataAclOnce(metaJsonByName) {
|
|
|
819
710
|
}
|
|
820
711
|
export function listBundles() {
|
|
821
712
|
const out = [];
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
// as secret values), so a naive loop over readBundle() spawns a fresh
|
|
825
|
-
// LAContext per item — meaning N biometric prompts for `secrets list`.
|
|
826
|
-
//
|
|
827
|
-
// SKIP this entirely when the keychain backend is routing to the encrypted
|
|
828
|
-
// file store (Linux headless / locked-collection fallback): there,
|
|
829
|
-
// listKeychainItems() returns the SAME items the file enumeration below
|
|
830
|
-
// reads, so running both would list every file-backed bundle twice — once
|
|
831
|
-
// mislabeled `keychain`, once correctly `[file]`. Under the fallback the
|
|
832
|
-
// file store is the single source of truth, so the block below covers all.
|
|
713
|
+
// Batch keychain metadata reads behind one prompt. Skip when the keychain
|
|
714
|
+
// backend is routing to the file fallback to avoid listing file bundles twice.
|
|
833
715
|
if (!keychainUsesFileFallback()) {
|
|
834
716
|
let keychainServices = [];
|
|
835
717
|
// Prefer the no-ACL metadata-name index (a silent file read) over the broad
|
|
@@ -857,19 +739,9 @@ export function listBundles() {
|
|
|
857
739
|
// pre-re-key items) still carry the name; it's kept as the parse hint so
|
|
858
740
|
// legacy metadata without the persisted `name` field keeps listing.
|
|
859
741
|
if (keychainServices.length > 0) {
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
//
|
|
863
|
-
// this listing. Serve a broker-cached metadata snapshot when one is held,
|
|
864
|
-
// so only the first list per ~7d prompts. The cache key is a hash of the
|
|
865
|
-
// current keychain name-set (enumerated silently above): add / remove /
|
|
866
|
-
// rename a bundle and the key changes, so the stale snapshot is never
|
|
867
|
-
// served. A same-name metadata edit (e.g. `secrets policy <b> always`)
|
|
868
|
-
// does NOT change the key, so the POLICY column in `secrets list` can lag
|
|
869
|
-
// by up to the hold window (~7d) until the next name-set change or `lock`.
|
|
870
|
-
// This is cosmetic only — enforcement always reads the bundle's live
|
|
871
|
-
// policy (readBundle), never this snapshot, and `secrets view <b>` shows
|
|
872
|
-
// the fresh value immediately. Values are never cached here; metadata only.
|
|
742
|
+
// Serve a broker-cached metadata snapshot so only the first list per hold
|
|
743
|
+
// window prompts. Cache key is the name-set hash; policy edits can lag
|
|
744
|
+
// cosmetically, but enforcement always reads live policy. Values are not cached.
|
|
873
745
|
const useAgent = process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
|
|
874
746
|
!isKeychainBackendOverridden() &&
|
|
875
747
|
secretsAgentAutoEnabled();
|
|
@@ -883,12 +755,8 @@ export function listBundles() {
|
|
|
883
755
|
out.push(bundle);
|
|
884
756
|
}
|
|
885
757
|
else {
|
|
886
|
-
// Metadata
|
|
887
|
-
//
|
|
888
|
-
// to the raw-read storm guard — a headless `listBundles` (session
|
|
889
|
-
// start, crabbox env, devices fan-out) must never fail fast on the
|
|
890
|
-
// guard nor pop a sheet. (A legacy pre-heal ACL'd metadata item can
|
|
891
|
-
// still prompt once; it heals on the next interactive scan.)
|
|
758
|
+
// Metadata is no-ACL by contract; attest silentNoAcl so headless listing
|
|
759
|
+
// doesn't fail fast or pop a sheet.
|
|
892
760
|
const fetched = getKeychainTokens(keychainServices, { silentNoAcl: true });
|
|
893
761
|
const keychainBundles = [];
|
|
894
762
|
for (const service of keychainServices) {
|
|
@@ -905,19 +773,14 @@ export function listBundles() {
|
|
|
905
773
|
}
|
|
906
774
|
for (const bundle of keychainBundles)
|
|
907
775
|
out.push(bundle);
|
|
908
|
-
// Populate the broker
|
|
909
|
-
// Same configurable cap as the value read-path — otherwise `secrets list`
|
|
910
|
-
// would keep serving a stale metadata snapshot for 7d even when the user
|
|
911
|
-
// capped the hold at 24h via secrets.agent.holdMs.
|
|
776
|
+
// Populate the broker using the same hold cap as value reads.
|
|
912
777
|
if (useAgent && keychainBundles.length > 0) {
|
|
913
778
|
agentAutoLoadMetaSync(nameSetHash, keychainBundles, secretsHoldMs());
|
|
914
779
|
}
|
|
915
780
|
}
|
|
916
781
|
}
|
|
917
782
|
}
|
|
918
|
-
// File-backed bundles
|
|
919
|
-
// silent directory listing; only decryption needs the passphrase, so a
|
|
920
|
-
// `secrets list` without one still shows the names (values stay sealed).
|
|
783
|
+
// File-backed bundles: enumeration is silent; only decryption needs the passphrase.
|
|
921
784
|
let fileServices = [];
|
|
922
785
|
try {
|
|
923
786
|
fileServices = fileStore.list(BUNDLE_META_PREFIX);
|
|
@@ -934,8 +797,7 @@ export function listBundles() {
|
|
|
934
797
|
json = fileItemStore.get(bundleMetaItem(name));
|
|
935
798
|
}
|
|
936
799
|
catch {
|
|
937
|
-
// No passphrase
|
|
938
|
-
// invisible, with empty vars. `agents secrets view` reports the error.
|
|
800
|
+
// No passphrase: surface the name with empty vars so it isn't invisible.
|
|
939
801
|
out.push({ name, backend: 'file', vars: {} });
|
|
940
802
|
continue;
|
|
941
803
|
}
|
|
@@ -984,19 +846,10 @@ export function describeBundle(bundle) {
|
|
|
984
846
|
}
|
|
985
847
|
return out;
|
|
986
848
|
}
|
|
987
|
-
// Bump `last_used`
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
// Set AGENTS_NO_USAGE_TRACK=1 to disable
|
|
991
|
-
//
|
|
992
|
-
// The passed bundle is often the BROKER'S snapshot (this fires on every broker
|
|
993
|
-
// hit), which can be stale — a detached auto-load captured before a mutating
|
|
994
|
-
// write can land after its eviction. Persisting that snapshot wholesale used to
|
|
995
|
-
// write its whole `vars` map back over the authoritative store, resurrecting
|
|
996
|
-
// removed keys (or, for a since-deleted bundle, the bundle itself). The stamp
|
|
997
|
-
// is telemetry: it re-reads the store's own current copy (bundle metadata is a
|
|
998
|
-
// silent no-ACL read) and writes ONLY the timestamp onto that.
|
|
999
|
-
// Exported for regression coverage, like shouldEvictAfterBundleWrite.
|
|
849
|
+
// Bump `last_used` at most once per throttle window. The passed bundle is often
|
|
850
|
+
// the broker's snapshot, which can be stale, so re-read the authoritative
|
|
851
|
+
// metadata and write ONLY the timestamp. Failures are swallowed.
|
|
852
|
+
// Set AGENTS_NO_USAGE_TRACK=1 to disable entirely.
|
|
1000
853
|
export function stampLastUsed(bundle) {
|
|
1001
854
|
if (process.env.AGENTS_NO_USAGE_TRACK)
|
|
1002
855
|
return;
|
|
@@ -1010,10 +863,8 @@ export function stampLastUsed(bundle) {
|
|
|
1010
863
|
const fresh = readBundle(bundle.name); // throws if the bundle is gone — swallowed below
|
|
1011
864
|
const stamp = new Date(nowMs).toISOString();
|
|
1012
865
|
fresh.last_used = stamp;
|
|
1013
|
-
// Keep the caller's (possibly broker-held) copy throttling correctly.
|
|
1014
866
|
bundle.last_used = stamp;
|
|
1015
|
-
//
|
|
1016
|
-
// evict would make the cache destroy itself on first use.
|
|
867
|
+
// Stamping fires on every broker hit; evicting would destroy the cache.
|
|
1017
868
|
writeBundle(fresh, { skipBrokerEviction: true });
|
|
1018
869
|
}
|
|
1019
870
|
catch {
|
|
@@ -1021,9 +872,7 @@ export function stampLastUsed(bundle) {
|
|
|
1021
872
|
}
|
|
1022
873
|
}
|
|
1023
874
|
/**
|
|
1024
|
-
* Abort if any
|
|
1025
|
-
* Bundle-level expiry is not a concept today (expiry is per-key via `meta`),
|
|
1026
|
-
* so we iterate only the per-key meta entries.
|
|
875
|
+
* Abort if any selected key's per-key `expires` date is in the past.
|
|
1027
876
|
*/
|
|
1028
877
|
function assertNotExpired(bundle, selectedKeys, allowExpired) {
|
|
1029
878
|
if (allowExpired)
|
|
@@ -1045,9 +894,7 @@ function assertNotExpired(bundle, selectedKeys, allowExpired) {
|
|
|
1045
894
|
}
|
|
1046
895
|
}
|
|
1047
896
|
/**
|
|
1048
|
-
*
|
|
1049
|
-
* fail-loud error listing available keys if any requested key is absent. When
|
|
1050
|
-
* `requested` is undefined or empty, every key in the bundle is selected.
|
|
897
|
+
* Select the requested key subset, failing loud if any key is absent.
|
|
1051
898
|
*/
|
|
1052
899
|
function selectRequestedKeys(bundle, requested) {
|
|
1053
900
|
const req = requested?.length ? requested : undefined;
|
|
@@ -1122,34 +969,21 @@ export function canCacheResolvedEnv(bundle, selectedKeys, keyMode) {
|
|
|
1122
969
|
return true;
|
|
1123
970
|
}
|
|
1124
971
|
/**
|
|
1125
|
-
* Apply
|
|
1126
|
-
* the
|
|
1127
|
-
* scoped lease env, so a naive fast-path return could silently defeat --keys
|
|
1128
|
-
* and inject expired values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
|
|
1129
|
-
* `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
|
|
1130
|
-
*
|
|
1131
|
-
* Exported for tests; production callers reach it via the fast-path branch in
|
|
1132
|
-
* `readAndResolveBundleEnv`.
|
|
972
|
+
* Apply --keys and --allow-expired to a broker snapshot so the fast path
|
|
973
|
+
* mirrors the slow path's gates. Exported for tests.
|
|
1133
974
|
*/
|
|
1134
975
|
export function filterAgentHitBySubsetAndExpiry(hit, opts) {
|
|
1135
976
|
const selectedKeys = selectRequestedKeys(hit.bundle, opts.keys);
|
|
1136
977
|
assertNotExpired(hit.bundle, [...selectedKeys], opts.allowExpired ?? false);
|
|
1137
978
|
const env = projectResolvedEnv(hit.bundle, hit.env, selectedKeys, opts.keyMode);
|
|
1138
|
-
//
|
|
1139
|
-
// same reference the agent handed back, so no per-call allocation on the hot path.
|
|
979
|
+
// Return the cached reference unchanged when no subset/projection was applied.
|
|
1140
980
|
if (env === hit.env)
|
|
1141
981
|
return hit;
|
|
1142
982
|
return { bundle: hit.bundle, env };
|
|
1143
983
|
}
|
|
1144
984
|
/**
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
* yet. Silently applying them would inject the full remote env or an expired
|
|
1148
|
-
* value, defeating the least-privilege intent, so we fail loud.
|
|
1149
|
-
*
|
|
1150
|
-
* Exported so `agents run --secrets bundle@host` and `agents secrets exec
|
|
1151
|
-
* --device` share the exact same error text; the tests exercise this helper
|
|
1152
|
-
* directly instead of driving the whole CLI.
|
|
985
|
+
* Fail loud when remote bundle resolution is asked for flags the SSH resolver
|
|
986
|
+
* does not yet thread. Exported so callers share the same error text.
|
|
1153
987
|
*/
|
|
1154
988
|
export function assertRemoteBundleFlagsUnsupported(bundleName, host, opts, flagLabels) {
|
|
1155
989
|
const hasKeys = Array.isArray(opts.keys) && opts.keys.length > 0;
|
|
@@ -1159,24 +993,9 @@ export function assertRemoteBundleFlagsUnsupported(bundleName, host, opts, flagL
|
|
|
1159
993
|
`Drop the flag or resolve the bundle locally.`);
|
|
1160
994
|
}
|
|
1161
995
|
/**
|
|
1162
|
-
*
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1165
|
-
* RUSH-2253). `view`'s "stored" badge comes from `hasKeychainToken` — the exact
|
|
1166
|
-
* existence probe used here — which counts a biometry-ACL'd or locked-keychain
|
|
1167
|
-
* item (`errSecInteractionNotAllowed`) as present. So:
|
|
1168
|
-
*
|
|
1169
|
-
* - present ⇒ the item exists but this context could not read it (keychain
|
|
1170
|
-
* locked, or Touch ID not granted). Report HOW to unlock; NEVER
|
|
1171
|
-
* "add the key", whose remediation (`secrets add`) would overwrite a good
|
|
1172
|
-
* secret.
|
|
1173
|
-
* - absent ⇒ genuinely not stored on this machine — the honest "not found"
|
|
1174
|
-
* with the `secrets add` remediation.
|
|
1175
|
-
* - probe throws ⇒ the keychain itself is unreachable (RUSH-2235 fail-loud):
|
|
1176
|
-
* neither absent nor add-the-key — surface the reachability failure verbatim.
|
|
1177
|
-
*
|
|
1178
|
-
* Only the keychain backend has a locked/biometry state; a file/vault miss is
|
|
1179
|
-
* genuinely absent.
|
|
996
|
+
* Build the right error for a missing `keychain:` ref. Classifies
|
|
997
|
+
* present-but-unreadable (locked/denied) vs genuinely absent so `view` and reads
|
|
998
|
+
* stay consistent. Only keychain has a locked state; file/vault misses are absent.
|
|
1180
999
|
*/
|
|
1181
1000
|
function missingBundleKeychainItemError(bundleName, key, item, backendKind) {
|
|
1182
1001
|
if (backendKind === 'keychain') {
|
|
@@ -1198,17 +1017,9 @@ function missingBundleKeychainItemError(bundleName, key, item, backendKind) {
|
|
|
1198
1017
|
`Run: agents secrets add ${bundleName} ${key}`);
|
|
1199
1018
|
}
|
|
1200
1019
|
/**
|
|
1201
|
-
*
|
|
1202
|
-
*
|
|
1203
|
-
*
|
|
1204
|
-
* the missing-item classification can never diverge again (RUSH-2252: the two
|
|
1205
|
-
* paths drifted — one did the hashed-alias fallback lookup and one did not, and
|
|
1206
|
-
* only one classified a missing item honestly).
|
|
1207
|
-
*
|
|
1208
|
-
* The keychain lookup tries the cleartext name first (Linux / file store), then
|
|
1209
|
-
* its hashed storage alias (macOS with #316 hashing active) — the batch keys its
|
|
1210
|
-
* results by the names it was ASKED for, which for the metadata + declared keys
|
|
1211
|
-
* is the cleartext form and for an enumerated leftover is the hashed form.
|
|
1020
|
+
* Shared per-key resolver for `resolveBundleEnv` and `readAndResolveBundleEnv`.
|
|
1021
|
+
* Looks up keychain items by cleartext name then hashed alias; classifies misses
|
|
1022
|
+
* consistently so the two paths cannot diverge.
|
|
1212
1023
|
*/
|
|
1213
1024
|
function assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, keyMode, backendKind) {
|
|
1214
1025
|
const env = {};
|
|
@@ -1243,16 +1054,10 @@ function assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, keyMode,
|
|
|
1243
1054
|
}
|
|
1244
1055
|
return env;
|
|
1245
1056
|
}
|
|
1246
|
-
//
|
|
1247
|
-
//
|
|
1248
|
-
// bundle — including the metadata fetch that already happened in readBundle
|
|
1249
|
-
// (the helper's auth context survives across separate invocations only via
|
|
1250
|
-
// the per-process LAContext, so we still get one prompt for the batch even
|
|
1251
|
-
// if metadata triggered an earlier one). Literals/env/file/exec refs are
|
|
1252
|
-
// resolved inline and never reach the keychain.
|
|
1057
|
+
// Resolve the bundle into a flat env map, batching keychain refs into one read
|
|
1058
|
+
// so macOS shows one Touch ID prompt. Literals/env/file/exec refs resolve inline.
|
|
1253
1059
|
export function resolveBundleEnv(bundle, _opts = {}) {
|
|
1254
1060
|
stampLastUsed(bundle);
|
|
1255
|
-
// Key-subset validation and expiry pre-check.
|
|
1256
1061
|
const selectedKeys = selectRequestedKeys(bundle, _opts.keys);
|
|
1257
1062
|
assertNotExpired(bundle, [...selectedKeys], _opts.allowExpired ?? false);
|
|
1258
1063
|
const parsedByKey = new Map();
|
|
@@ -1267,18 +1072,15 @@ export function resolveBundleEnv(bundle, _opts = {}) {
|
|
|
1267
1072
|
}
|
|
1268
1073
|
}
|
|
1269
1074
|
const store = itemStore(bundle.backend ?? 'keychain');
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
// and stays readable in a headless context, while an ACL'd policy hits the
|
|
1273
|
-
// raw-read storm guard and fails fast there.
|
|
1075
|
+
// Direct getKeychainTokens so `never`-policy bundles attest silentNoAcl in
|
|
1076
|
+
// headless contexts, while ACL'd bundles fail fast.
|
|
1274
1077
|
const fetched = keychainItemsToFetch.length > 0
|
|
1275
1078
|
? (bundle.backend ?? 'keychain') === 'keychain'
|
|
1276
1079
|
? getKeychainTokens(keychainItemsToFetch, { silentNoAcl: bundlePolicy(bundle) === 'never' })
|
|
1277
1080
|
: store.getBatch(keychainItemsToFetch)
|
|
1278
1081
|
: new Map();
|
|
1279
1082
|
const env = assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, _opts.keyMode, bundle.backend ?? 'keychain');
|
|
1280
|
-
|
|
1281
|
-
void _opts.caller;
|
|
1083
|
+
void _opts.caller; // informational only
|
|
1282
1084
|
return env;
|
|
1283
1085
|
}
|
|
1284
1086
|
/**
|
|
@@ -1291,32 +1093,17 @@ export function resolveBundleEnv(bundle, _opts = {}) {
|
|
|
1291
1093
|
export { isHeadlessSecretsContext, isAgentInvocationContext } from './headless.js';
|
|
1292
1094
|
/**
|
|
1293
1095
|
* Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
1296
|
-
* (metadata read via `get-auth`, then secret values via `get-batch`) which
|
|
1297
|
-
* surfaced as two consecutive Touch ID prompts. macOS does not honor
|
|
1298
|
-
* "Always Allow" for items protected with `kSecAttrAccessControl`+biometry,
|
|
1299
|
-
* so caching at the OS level was never an option. This collapses both reads
|
|
1300
|
-
* into one `get-batch` call: we enumerate the bundle's secret items first
|
|
1301
|
-
* (silent — `list` returns attrs only and does not trigger biometry) and
|
|
1302
|
-
* include the metadata item in the same batch. One prompt, correctly scoped
|
|
1303
|
-
* to the bundle name and caller.
|
|
1096
|
+
* `readBundle` + `resolveBundleEnv` used to issue two LAContext calls (two
|
|
1097
|
+
* prompts). This collapses them into one batch that includes the metadata item.
|
|
1304
1098
|
*/
|
|
1305
1099
|
export function readAndResolveBundleEnv(name, opts = {}) {
|
|
1306
1100
|
validateBundleName(name);
|
|
1307
1101
|
assertNameActiveInResourceProfile('secrets', name);
|
|
1308
1102
|
const backend = bundleBackend(name);
|
|
1309
|
-
// Fast-path:
|
|
1310
|
-
//
|
|
1311
|
-
// ID. Soft — any failure falls through to the real keychain read below. macOS
|
|
1312
|
-
// / keychain only — the agent exists to dedup Touch ID prompts, and a
|
|
1313
|
-
// file-backed bundle has none to dedup. The never-unlocked path is a single
|
|
1314
|
-
// stat (agentSocketExists) so it costs nothing when the agent isn't running.
|
|
1103
|
+
// Fast-path: broker-held snapshot ⇒ no Touch ID. Soft: any failure falls
|
|
1104
|
+
// through to the real keychain read. macOS/keychain only.
|
|
1315
1105
|
if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
|
|
1316
|
-
//
|
|
1317
|
-
// literal `'cli'` harness — the broker and the durable store both resolve
|
|
1318
|
-
// own-harness → global (bundleScopeChain), so an unscoped unlock is visible
|
|
1319
|
-
// here whether this process was launched by an agent or typed in a terminal.
|
|
1106
|
+
// Falls back to GLOBAL so an unscoped unlock is visible in any harness.
|
|
1320
1107
|
const harness = opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS;
|
|
1321
1108
|
const hit = agentGetSync(name, harness);
|
|
1322
1109
|
if (hit) {
|
|
@@ -1325,10 +1112,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1325
1112
|
emitSecretAudit({ event: 'secrets.lease-denied', bundle: name, operation: opts.caller, source: 'agent', status: 'error', keys: denied, keyCount: denied.length, agent: harness, error: 'key outside lease scope' });
|
|
1326
1113
|
throw new Error(`Secret lease '${hit.lease?.id}' does not grant key(s): ${denied.join(', ')}`);
|
|
1327
1114
|
}
|
|
1328
|
-
//
|
|
1329
|
-
// expiry gate as the slow path — without this, `--secrets-keys X` would
|
|
1330
|
-
// silently inject every key and an expired key would flow through after
|
|
1331
|
-
// the first cache-populating run.
|
|
1115
|
+
// Apply the same subset and expiry gates as the slow path.
|
|
1332
1116
|
const filtered = filterAgentHitBySubsetAndExpiry(hit, opts);
|
|
1333
1117
|
stampLastUsed(filtered.bundle);
|
|
1334
1118
|
emitSecretAudit({
|
|
@@ -1342,12 +1126,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1342
1126
|
});
|
|
1343
1127
|
return filtered;
|
|
1344
1128
|
}
|
|
1345
|
-
// Durable-session fallback
|
|
1346
|
-
//
|
|
1347
|
-
// unlock persisted a no-ACL session item (session-store.ts) that reads with NO
|
|
1348
|
-
// Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
|
|
1349
|
-
// across restart — this fixes BOTH the interactive re-prompt and the headless
|
|
1350
|
-
// throw below (which now fires only when there is genuinely no session).
|
|
1129
|
+
// Durable-session fallback: after restart the broker RAM is empty, but a
|
|
1130
|
+
// no-ACL session item lets us re-warm the broker without Touch ID.
|
|
1351
1131
|
const resolved = resolveSession(name, Date.now(), harness);
|
|
1352
1132
|
if (resolved) {
|
|
1353
1133
|
const session = resolved.entry;
|
|
@@ -1358,14 +1138,9 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1358
1138
|
}
|
|
1359
1139
|
const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
|
|
1360
1140
|
stampLastUsed(filtered.bundle);
|
|
1361
|
-
// Re-warm the
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1364
|
-
// grant as `claude` would silently narrow it for every other harness.
|
|
1365
|
-
// No snapshotAt: the session's bundle was read at unlock time, not now —
|
|
1366
|
-
// claiming freshness here would defeat the broker's eviction tombstones.
|
|
1367
|
-
// An undated load is accepted (legacy behavior); the durable-session
|
|
1368
|
-
// staleness window itself is a known, separate concern.
|
|
1141
|
+
// Re-warm under the scope the grant was made in so a global grant isn't
|
|
1142
|
+
// narrowed to the asking harness. No snapshotAt: the session bundle predates
|
|
1143
|
+
// this read; claiming freshness would defeat eviction tombstones.
|
|
1369
1144
|
agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness, session.lease);
|
|
1370
1145
|
emitSecretAudit({
|
|
1371
1146
|
event: 'secrets.get',
|
|
@@ -1379,12 +1154,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1379
1154
|
return filtered;
|
|
1380
1155
|
}
|
|
1381
1156
|
}
|
|
1382
|
-
// Never/no-ACL bundles remain prompt-free regardless. Every ordinary caller
|
|
1383
|
-
// sets agentOnly; only the unlock handler opts into interactive authentication.
|
|
1384
1157
|
const interactiveUnlock = opts.interactiveUnlock ?? false;
|
|
1385
|
-
// A `never`-policy bundle
|
|
1386
|
-
// check below proves that, the batch read is silent even in a headless
|
|
1387
|
-
// context — attest it to the raw-read storm guard via `silentNoAcl`.
|
|
1158
|
+
// A `never`-policy bundle is prompt-free; attest silentNoAcl once verified.
|
|
1388
1159
|
let verifiedNoAclBundle = false;
|
|
1389
1160
|
if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock && !keychainAgentOnlyBypassForTest) {
|
|
1390
1161
|
try {
|
|
@@ -1397,9 +1168,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1397
1168
|
`never raises a Touch ID sheet on its own.`);
|
|
1398
1169
|
}
|
|
1399
1170
|
}
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
// bundles are verified below and remain silent; vault/file backends are unaffected.
|
|
1171
|
+
// Fail loud when the broker is disabled and this would otherwise prompt.
|
|
1172
|
+
// Never-policy bundles remain silent; vault/file are unaffected.
|
|
1403
1173
|
if (backend === 'keychain' && !verifiedNoAclBundle && !isSecretsBrokerEnabled() && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
|
|
1404
1174
|
throw new Error(`Secrets broker is disabled — re-enable with 'agents daemon services enable secrets-broker'. ` +
|
|
1405
1175
|
`If you meant to read directly from the keychain, set AGENTS_SECRETS_NO_AGENT=1.`);
|
|
@@ -1410,11 +1180,9 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1410
1180
|
const metaItem = bundleMetaItem(name);
|
|
1411
1181
|
const bundleSecretPrefix = `${SECRETS_ITEM_PREFIX}${name}.`;
|
|
1412
1182
|
let enumeratedSecretItems = [];
|
|
1413
|
-
// Agent launches must
|
|
1414
|
-
// prefix
|
|
1415
|
-
//
|
|
1416
|
-
// Interactive reads retain the existing enumeration side of the union so
|
|
1417
|
-
// legacy/aliased items keep their established behavior.
|
|
1183
|
+
// Agent-only launches must not enumerate the keychain: hashed names turn a
|
|
1184
|
+
// per-bundle prefix into a broad scan that evaluates unrelated ACLs (RUSH-2440).
|
|
1185
|
+
// Interactive reads keep the legacy enumeration path.
|
|
1418
1186
|
if (backend !== 'keychain' || !opts.agentOnly) {
|
|
1419
1187
|
try {
|
|
1420
1188
|
enumeratedSecretItems = store.list(bundleSecretPrefix);
|
|
@@ -1426,16 +1194,11 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1426
1194
|
const reason = opts.caller
|
|
1427
1195
|
? `read ${name} secrets (for ${opts.caller})`
|
|
1428
1196
|
: `read ${name} secrets`;
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
// snapshot, so the conservative (earliest) timestamp is the correct one.
|
|
1197
|
+
// Capture snapshotAt before the first read so broker eviction tombstones beat
|
|
1198
|
+
// any concurrent load.
|
|
1432
1199
|
const snapshotAt = Date.now();
|
|
1433
|
-
// Fetch metadata
|
|
1434
|
-
//
|
|
1435
|
-
// Keychain scan that triggered Touch ID on every run (RUSH-2440). The
|
|
1436
|
-
// metadata is authoritative for declared keys; undeclared keys (union with
|
|
1437
|
-
// legacy/orphaned items) are not supported in agent-only mode and would
|
|
1438
|
-
// fail the agentOnly gate anyway.
|
|
1200
|
+
// Fetch metadata (always no-ACL) and derive secret item names from declared
|
|
1201
|
+
// keys, eliminating the broad keychain scan that triggered Touch ID (RUSH-2440).
|
|
1439
1202
|
const metaFetched = backend === 'keychain'
|
|
1440
1203
|
? getKeychainTokens([metaItem], { silentNoAcl: true })
|
|
1441
1204
|
: store.getBatch([...new Set([metaItem, ...enumeratedSecretItems])]);
|
|
@@ -1456,9 +1219,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1456
1219
|
if (!parsed || typeof parsed !== 'object') {
|
|
1457
1220
|
throw new Error(`Bundle '${name}' is malformed.`);
|
|
1458
1221
|
}
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1461
|
-
// item names from Object.keys(vars) would silently read the wrong secret.
|
|
1222
|
+
// Derive exact storage names from declared keychain refs. The env key and
|
|
1223
|
+
// stored item name may differ, so vars keys alone would read the wrong secret.
|
|
1462
1224
|
const declaredSecretItems = [];
|
|
1463
1225
|
if (parsed.vars && typeof parsed.vars === 'object') {
|
|
1464
1226
|
for (const raw of Object.values(parsed.vars)) {
|
|
@@ -1469,15 +1231,11 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1469
1231
|
}
|
|
1470
1232
|
}
|
|
1471
1233
|
const secretItems = [...new Set([...enumeratedSecretItems, ...declaredSecretItems])];
|
|
1472
|
-
//
|
|
1473
|
-
// The interactive path keeps the enumeration + declared-item union; the
|
|
1474
|
-
// agent-only path contains declared items only and therefore never scans.
|
|
1234
|
+
// Fetch metadata and secret values in one batch.
|
|
1475
1235
|
const fetched = backend === 'keychain'
|
|
1476
1236
|
? getKeychainTokens([...new Set([metaItem, ...secretItems])], {
|
|
1477
1237
|
agent: opts.agent || process.env.AGENTS_AGENT_NAME || 'Agents CLI',
|
|
1478
1238
|
bundle: name,
|
|
1479
|
-
// The session that triggered the read, so a Touch ID prompt is
|
|
1480
|
-
// attributable when several agents run at once. Exported by exec.ts.
|
|
1481
1239
|
sessionId: process.env.AGENT_SESSION_ID || process.env.AGENTS_SESSION_ID,
|
|
1482
1240
|
reason: opts.caller ? `to ${opts.caller}` : reason,
|
|
1483
1241
|
duration: opts.duration || humanUnlockDuration(secretsHoldMs()),
|
|
@@ -1485,16 +1243,13 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1485
1243
|
forceDuration: Boolean(opts.duration),
|
|
1486
1244
|
silentNoAcl: verifiedNoAclBundle,
|
|
1487
1245
|
})
|
|
1488
|
-
// File/vault
|
|
1489
|
-
// batch so metadata-first Keychain safety does not double-decrypt every
|
|
1490
|
-
// file-backed credential read.
|
|
1246
|
+
// File/vault: reuse the initial batch to avoid double-decrypting.
|
|
1491
1247
|
: metaFetched;
|
|
1492
1248
|
const bundle = {
|
|
1493
1249
|
name,
|
|
1494
1250
|
description: parsed.description,
|
|
1495
1251
|
allow_exec: Boolean(parsed.allow_exec),
|
|
1496
1252
|
backend: backend === 'keychain' ? undefined : backend,
|
|
1497
|
-
// Legacy wire key: the policy is persisted under `tier` (`session` == `hold`).
|
|
1498
1253
|
policy: parsePolicy(parsed.tier),
|
|
1499
1254
|
vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
|
|
1500
1255
|
};
|
|
@@ -1509,7 +1264,6 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1509
1264
|
for (const key of Object.keys(bundle.vars)) {
|
|
1510
1265
|
validateEnvKey(key);
|
|
1511
1266
|
}
|
|
1512
|
-
// Key-subset validation and expiry pre-check (mirrors resolveBundleEnv logic).
|
|
1513
1267
|
const selectedKeys = selectRequestedKeys(bundle, opts.keys);
|
|
1514
1268
|
assertNotExpired(bundle, [...selectedKeys], opts.allowExpired ?? false);
|
|
1515
1269
|
stampLastUsed(bundle);
|
|
@@ -1544,19 +1298,10 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1544
1298
|
});
|
|
1545
1299
|
};
|
|
1546
1300
|
try {
|
|
1547
|
-
// Shared per-key resolver: same keychain lookup (cleartext name, then hashed
|
|
1548
|
-
// storage alias) and same missing-item classification as resolveBundleEnv, so
|
|
1549
|
-
// the two paths can never diverge again (RUSH-2252, RUSH-2253).
|
|
1550
1301
|
const env = assembleBundleEnv(bundle, selectedKeys, parsedByKey, fetched, opts.keyMode, backend);
|
|
1551
1302
|
emitReadAudit('success');
|
|
1552
|
-
// Auto-cache
|
|
1553
|
-
//
|
|
1554
|
-
// enabled `secrets.agent.auto`, populate the broker so the next concurrent
|
|
1555
|
-
// run reads silently. Skipped when noAgent (e.g. `unlock`, which loads the
|
|
1556
|
-
// agent itself). When a broker is already up this warms it synchronously
|
|
1557
|
-
// (bounded ~3s) so `daily` reliably sticks; only a cold-start broker uses the
|
|
1558
|
-
// detached fire-and-forget path (see agentAutoLoadSync). The costly Touch ID
|
|
1559
|
-
// prompt already happened, so the bounded wait is invisible.
|
|
1303
|
+
// Auto-cache into the broker so the next read is silent. Synchronous warm
|
|
1304
|
+
// when a broker is already up; cold-start uses the detached path.
|
|
1560
1305
|
if (backend === 'keychain' &&
|
|
1561
1306
|
!opts.noAgent &&
|
|
1562
1307
|
process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
|
|
@@ -1587,9 +1332,8 @@ export function keychainRef(key) {
|
|
|
1587
1332
|
return `keychain:${key}`;
|
|
1588
1333
|
}
|
|
1589
1334
|
/**
|
|
1590
|
-
* Rotate a keychain-backed secret
|
|
1591
|
-
*
|
|
1592
|
-
* unless `clearMeta` or a `meta` patch is supplied.
|
|
1335
|
+
* Rotate a keychain-backed secret. Errors if the key is absent; preserves meta
|
|
1336
|
+
* unless cleared or patched.
|
|
1593
1337
|
*/
|
|
1594
1338
|
export function rotateBundleSecret(bundle, key, opts) {
|
|
1595
1339
|
validateBundleName(bundle.name);
|
|
@@ -1598,8 +1342,7 @@ export function rotateBundleSecret(bundle, key, opts) {
|
|
|
1598
1342
|
throw new Error(`Key '${key}' not in bundle '${bundle.name}'. Use 'agents secrets add' to add a new key.`);
|
|
1599
1343
|
}
|
|
1600
1344
|
const raw = bundle.vars[key];
|
|
1601
|
-
//
|
|
1602
|
-
// the same sense — pivot the user back to add/remove.
|
|
1345
|
+
// Only keychain-backed values are rotated.
|
|
1603
1346
|
if (typeof raw !== 'string' || !raw.startsWith('keychain:')) {
|
|
1604
1347
|
throw new Error(`Key '${key}' in bundle '${bundle.name}' is not keychain-backed; cannot rotate.`);
|
|
1605
1348
|
}
|
|
@@ -1626,68 +1369,39 @@ export function rotateBundleSecret(bundle, key, opts) {
|
|
|
1626
1369
|
writeBundle(bundle);
|
|
1627
1370
|
}
|
|
1628
1371
|
/**
|
|
1629
|
-
* Reconcile
|
|
1630
|
-
*
|
|
1631
|
-
*
|
|
1632
|
-
*
|
|
1633
|
-
* every value item carrying the ACL it was created with — and macOS gates each
|
|
1634
|
-
* read on the ITEM's ACL, not the bundle's declared tier (spec SEC-19). Without
|
|
1635
|
-
* this reconcile, `agents secrets policy <b> never` reports "silent" while the
|
|
1636
|
-
* still-ACL'd value keeps popping Touch ID on every read, forever.
|
|
1637
|
-
*
|
|
1638
|
-
* hold/always -> never strips the biometry ACL (helper `set-no-acl`: delete+add)
|
|
1639
|
-
* never -> hold/always re-attaches it (helper `set`)
|
|
1640
|
-
*
|
|
1641
|
-
* The current values are read in ONE batch, so the reconcile costs at most a
|
|
1642
|
-
* single Touch ID — the last prompt a hold->never bundle will ever raise (a
|
|
1643
|
-
* never->* flip reads silently, since the items are already no-ACL). No-op on the
|
|
1644
|
-
* ACL to write for non-keychain backends (file/vault have no biometry concept),
|
|
1645
|
-
* and a metadata-only write when the bundle has no keychain-backed values.
|
|
1372
|
+
* Reconcile keychain value items to the bundle's current policy. macOS gates
|
|
1373
|
+
* reads on each item's ACL, not the bundle's declared policy, so a policy change
|
|
1374
|
+
* alone would leave stale ACLs. hold/always → never strips ACL; never → *
|
|
1375
|
+
* re-attaches it. Non-keychain backends no-op.
|
|
1646
1376
|
*/
|
|
1647
1377
|
export function reAclBundleItems(bundle) {
|
|
1648
1378
|
if ((bundle.backend ?? 'keychain') !== 'keychain') {
|
|
1649
|
-
// No biometry ACL off the keychain backend — only metadata needs persisting.
|
|
1650
1379
|
writeBundle(bundle);
|
|
1651
1380
|
return;
|
|
1652
1381
|
}
|
|
1653
1382
|
const store = itemStore('keychain');
|
|
1654
|
-
// keychainItemsForBundle already returns ONLY keychain-backed value items
|
|
1655
|
-
// (via parseBundleValue), so no extra ref-shape filtering here.
|
|
1656
1383
|
const entries = keychainItemsForBundle(bundle);
|
|
1657
1384
|
if (entries.length === 0) {
|
|
1658
|
-
// Literal/ref-only bundle: nothing to re-ACL, just refresh metadata.
|
|
1659
1385
|
writeBundle(bundle);
|
|
1660
1386
|
return;
|
|
1661
1387
|
}
|
|
1662
|
-
// One
|
|
1388
|
+
// One batch read ⇒ at most one Touch ID for the whole reconcile.
|
|
1663
1389
|
const values = store.getBatch(entries.map((e) => e.item));
|
|
1664
1390
|
const rewrite = new Map();
|
|
1665
1391
|
for (const { item } of entries) {
|
|
1666
1392
|
const value = values.get(item);
|
|
1667
|
-
// A key
|
|
1668
|
-
// corruption, not something to silently skip (no fallbacks — fail loud).
|
|
1393
|
+
// A declared key with no readable value is corruption — fail loud.
|
|
1669
1394
|
if (value === undefined) {
|
|
1670
1395
|
throw new Error(`Cannot change policy for '${bundle.name}': a keychain value is missing or unreadable. Rotate that key, then retry.`);
|
|
1671
1396
|
}
|
|
1672
1397
|
rewrite.set(item, value);
|
|
1673
1398
|
}
|
|
1674
|
-
// writeBundleWithItems
|
|
1675
|
-
// and the metadata no-ACL (metadata-last), and evicts any broker-held copy.
|
|
1399
|
+
// writeBundleWithItems applies the correct noAcl flag and evicts the broker.
|
|
1676
1400
|
writeBundleWithItems(bundle, rewrite);
|
|
1677
1401
|
}
|
|
1678
1402
|
/**
|
|
1679
|
-
* Rename a bundle:
|
|
1680
|
-
*
|
|
1681
|
-
* Sequence is ordered so the source stays intact if anything in the copy
|
|
1682
|
-
* phase fails:
|
|
1683
|
-
* 1) read source, validate dest
|
|
1684
|
-
* 2) purge dest if --force, refuse otherwise
|
|
1685
|
-
* 3) copy each keychain value source -> dest
|
|
1686
|
-
* 4) write new bundle metadata
|
|
1687
|
-
* 5) delete the old per-key keychain items + old metadata
|
|
1688
|
-
*
|
|
1689
|
-
* Steps 1-4 are reversible. If 5 partially fails, running `rename` again is
|
|
1690
|
-
* a safe no-op for the source items.
|
|
1403
|
+
* Rename a bundle: copy metadata + keychain values to the new name, then delete
|
|
1404
|
+
* the source. Steps are ordered so a copy-phase failure leaves the source intact.
|
|
1691
1405
|
*/
|
|
1692
1406
|
export function renameBundle(oldName, newName, opts = {}) {
|
|
1693
1407
|
validateBundleName(oldName);
|
|
@@ -1699,8 +1413,6 @@ export function renameBundle(oldName, newName, opts = {}) {
|
|
|
1699
1413
|
throw new Error(`Bundle '${oldName}' not found.`);
|
|
1700
1414
|
}
|
|
1701
1415
|
const source = readBundle(oldName);
|
|
1702
|
-
// Rename stays within the source's backend. The store carries both the
|
|
1703
|
-
// per-key secret items and (via writeBundle/deleteBundle) the metadata.
|
|
1704
1416
|
const store = itemStore(source.backend ?? 'keychain');
|
|
1705
1417
|
if (bundleExists(newName)) {
|
|
1706
1418
|
if (!opts.force) {
|
|
@@ -1713,8 +1425,7 @@ export function renameBundle(oldName, newName, opts = {}) {
|
|
|
1713
1425
|
}
|
|
1714
1426
|
deleteBundle(newName);
|
|
1715
1427
|
}
|
|
1716
|
-
// Copy
|
|
1717
|
-
// until step 5 so a partial failure here leaves the source intact.
|
|
1428
|
+
// Copy to the new name, leaving old items in place until cleanup.
|
|
1718
1429
|
const sourceItems = keychainItemsForBundle(source);
|
|
1719
1430
|
for (const { key, item: oldItem } of sourceItems) {
|
|
1720
1431
|
const raw = source.vars[key];
|
|
@@ -1725,8 +1436,6 @@ export function renameBundle(oldName, newName, opts = {}) {
|
|
|
1725
1436
|
const value = store.get(oldItem);
|
|
1726
1437
|
store.set(newItem, value, { noAcl: bundlePolicy(source) === 'never' });
|
|
1727
1438
|
}
|
|
1728
|
-
// writeBundle preserves source.created_at, refreshes updated_at, and keeps
|
|
1729
|
-
// the source backend (spread carries source.backend).
|
|
1730
1439
|
const renamed = { ...source, name: newName };
|
|
1731
1440
|
writeBundle(renamed);
|
|
1732
1441
|
// Cleanup: delete the old per-key items, then the old metadata.
|
|
@@ -1737,24 +1446,17 @@ export function renameBundle(oldName, newName, opts = {}) {
|
|
|
1737
1446
|
emit('secrets.rename', { module: 'secrets', from: oldName, to: newName });
|
|
1738
1447
|
}
|
|
1739
1448
|
/**
|
|
1740
|
-
* The store (keychain or encrypted file)
|
|
1741
|
-
*
|
|
1742
|
-
* secretsKeychainItem) in the same store as the bundle's metadata, for `add` /
|
|
1743
|
-
* `import` / `remove` / `delete`. Pass the bundle's resolved backend
|
|
1744
|
-
* (`bundle.backend ?? 'keychain'`).
|
|
1449
|
+
* The item store (keychain or encrypted file) for a bundle's per-key secrets.
|
|
1450
|
+
* Pass the resolved backend (`bundle.backend ?? 'keychain'`).
|
|
1745
1451
|
*/
|
|
1746
1452
|
export function bundleItemStore(backend, opts) {
|
|
1747
1453
|
const store = itemStore(backend ?? 'keychain');
|
|
1748
|
-
// `never`-policy bundles write
|
|
1749
|
-
// (same rationale as the metadata write in writeBundle). Wrap `set` so every
|
|
1750
|
-
// value the add/import paths write inherits the no-ACL flag; reads, deletes,
|
|
1751
|
-
// and existence checks are ACL-independent and pass through untouched.
|
|
1454
|
+
// `never`-policy bundles write per-key values without the biometry ACL.
|
|
1752
1455
|
if (opts?.noAcl) {
|
|
1753
1456
|
return { ...store, set: (item, value) => store.set(item, value, { noAcl: true }) };
|
|
1754
1457
|
}
|
|
1755
1458
|
return store;
|
|
1756
1459
|
}
|
|
1757
|
-
// Iterate all keychain-backed keys in a bundle for cleanup on rm/unset.
|
|
1758
1460
|
export function keychainItemsForBundle(bundle) {
|
|
1759
1461
|
const items = [];
|
|
1760
1462
|
for (const [key, raw] of Object.entries(bundle.vars)) {
|
|
@@ -1765,7 +1467,6 @@ export function keychainItemsForBundle(bundle) {
|
|
|
1765
1467
|
}
|
|
1766
1468
|
return items;
|
|
1767
1469
|
}
|
|
1768
|
-
// Parse a dotenv string into key=value pairs, preserving last-wins on duplicates.
|
|
1769
1470
|
export function parseDotenv(content) {
|
|
1770
1471
|
const out = {};
|
|
1771
1472
|
for (const raw of content.split('\n')) {
|