@phnx-labs/agents-cli 1.22.84 → 1.22.86
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 +96 -0
- package/README.md +80 -53
- package/dist/bootstrap.d.ts +7 -6
- package/dist/bootstrap.js +20 -27
- package/dist/cli/command-registry.js +5 -1
- package/dist/commands/accounts.d.ts +6 -13
- package/dist/commands/accounts.js +330 -143
- package/dist/commands/alias.js +7 -3
- package/dist/commands/apply.js +1 -1
- package/dist/commands/auth-mint.d.ts +7 -3
- package/dist/commands/auth-mint.js +21 -9
- package/dist/commands/auth.js +2 -3
- package/dist/commands/browser.js +10 -10
- package/dist/commands/daemon.d.ts +7 -3
- package/dist/commands/daemon.js +40 -25
- package/dist/commands/doctor.js +28 -2
- package/dist/commands/exec.js +87 -39
- package/dist/commands/fleet-capture.js +21 -2
- package/dist/commands/harness-wizard.js +2 -2
- package/dist/commands/lease.js +7 -12
- package/dist/commands/profiles.d.ts +2 -2
- package/dist/commands/profiles.js +20 -13
- package/dist/commands/repo.js +3 -3
- package/dist/commands/run-account-picker.d.ts +3 -3
- package/dist/commands/run-account-picker.js +29 -30
- package/dist/commands/secrets-passthrough.d.ts +20 -0
- package/dist/commands/secrets-passthrough.js +43 -0
- package/dist/commands/setup-accounts.d.ts +3 -2
- package/dist/commands/setup-accounts.js +4 -4
- package/dist/commands/setup-secrets.d.ts +16 -21
- package/dist/commands/setup-secrets.js +43 -224
- package/dist/commands/ssh.js +2 -2
- package/dist/commands/sync.js +1 -1
- package/dist/commands/update.d.ts +10 -0
- package/dist/commands/update.js +43 -40
- package/dist/commands/versions.d.ts +11 -11
- package/dist/commands/versions.js +131 -83
- package/dist/commands/view.d.ts +1 -9
- package/dist/commands/view.js +41 -55
- package/dist/commands/webhook.js +5 -5
- package/dist/commands/workflows.js +2 -1
- package/dist/index.d.ts +12 -12
- package/dist/index.js +13 -42
- package/dist/lib/account-capabilities.d.ts +1 -1
- package/dist/lib/account-capabilities.js +1 -1
- package/dist/lib/account-catalog.d.ts +121 -6
- package/dist/lib/account-catalog.js +421 -7
- package/dist/lib/account-registry.d.ts +8 -30
- package/dist/lib/account-registry.js +36 -79
- package/dist/lib/account-schema.d.ts +1 -1
- package/dist/lib/account-schema.js +1 -1
- package/dist/lib/accounting/account-pool-collect.js +16 -4
- package/dist/lib/accounting/rotate.d.ts +20 -7
- package/dist/lib/accounting/rotate.js +89 -13
- package/dist/lib/accounting/usage.js +11 -16
- package/dist/lib/accounts/add.d.ts +138 -0
- package/dist/lib/accounts/add.js +651 -0
- package/dist/lib/accounts/migrate.d.ts +117 -0
- package/dist/lib/accounts/migrate.js +536 -0
- package/dist/lib/accounts/slots.d.ts +13 -0
- package/dist/lib/accounts/slots.js +100 -0
- package/dist/lib/agent-spec/agents.d.ts +16 -0
- package/dist/lib/agent-spec/agents.js +38 -4
- package/dist/lib/app-bundle-install.js +5 -4
- package/dist/lib/auth-health.d.ts +3 -0
- package/dist/lib/auth-health.js +11 -0
- package/dist/lib/auth-mint.d.ts +52 -16
- package/dist/lib/auth-mint.js +117 -39
- package/dist/lib/browser/chrome.d.ts +1 -1
- package/dist/lib/browser/chrome.js +5 -5
- package/dist/lib/byok-usage.js +3 -3
- package/dist/lib/claude-account-token.d.ts +47 -3
- package/dist/lib/claude-account-token.js +172 -50
- package/dist/lib/cloud/antigravity.js +4 -4
- package/dist/lib/cloud/cursor.js +4 -4
- package/dist/lib/crabbox/cli.d.ts +1 -1
- package/dist/lib/crabbox/cli.js +6 -6
- package/dist/lib/crabbox/runtimes.d.ts +3 -3
- package/dist/lib/crabbox/runtimes.js +5 -5
- package/dist/lib/daemon/account-state-daemon-service.d.ts +37 -1
- package/dist/lib/daemon/account-state-daemon-service.js +199 -3
- package/dist/lib/daemon/auth-sync-service.js +32 -1
- package/dist/lib/daemon/daemon.d.ts +6 -16
- package/dist/lib/daemon/daemon.js +52 -97
- package/dist/lib/daemon/daemon.test-fixture.d.ts +7 -9
- package/dist/lib/daemon/daemon.test-fixture.js +22 -40
- package/dist/lib/daemon/harness-update-service.d.ts +1 -1
- package/dist/lib/daemon/harness-update-service.js +1 -1
- package/dist/lib/daemon/runner.d.ts +15 -3
- package/dist/lib/daemon/runner.js +49 -24
- package/dist/lib/daemon-health.d.ts +1 -2
- package/dist/lib/daemon-health.js +7 -6
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +1 -11
- package/dist/lib/daemon-webhooks.d.ts +8 -7
- package/dist/lib/daemon-webhooks.js +10 -9
- package/dist/lib/device-config.js +1 -1
- package/dist/lib/devices/doctor-findings.d.ts +1 -1
- package/dist/lib/devices/harness-inventory.d.ts +39 -0
- package/dist/lib/devices/harness-inventory.js +126 -4
- package/dist/lib/doctor-diff.js +2 -1
- package/dist/lib/exec-account-home.d.ts +38 -0
- package/dist/lib/exec-account-home.js +164 -0
- package/dist/lib/exec.d.ts +30 -0
- package/dist/lib/exec.js +101 -31
- package/dist/lib/fleet/apply.d.ts +2 -2
- package/dist/lib/fleet/apply.js +4 -4
- package/dist/lib/fleet/auth-sync.js +4 -3
- package/dist/lib/fleet-shared-repo-sync.js +6 -9
- package/dist/lib/harness/adapter.d.ts +7 -7
- package/dist/lib/harness/adapter.js +10 -6
- package/dist/lib/harness/adapters/grok.js +8 -3
- package/dist/lib/harness/adapters/muse.js +1 -1
- package/dist/lib/harness/adapters/opencode.js +12 -3
- package/dist/lib/harness-auth-capabilities.d.ts +61 -0
- package/dist/lib/harness-auth-capabilities.js +52 -0
- package/dist/lib/helper-versions.d.ts +6 -4
- package/dist/lib/helper-versions.js +5 -4
- package/dist/lib/hosts/credential-transport.d.ts +10 -0
- package/dist/lib/hosts/credential-transport.js +37 -0
- package/dist/lib/hosts/dispatch.d.ts +7 -0
- package/dist/lib/hosts/dispatch.js +13 -6
- package/dist/lib/identity/client.d.ts +3 -3
- package/dist/lib/identity/client.js +3 -3
- package/dist/lib/installations/index.d.ts +1 -1
- package/dist/lib/installations/index.js +1 -1
- package/dist/lib/installations/migrate.js +9 -0
- package/dist/lib/installations/resolve.js +1 -1
- package/dist/lib/installations/shims.d.ts +21 -4
- package/dist/lib/installations/shims.js +86 -5
- package/dist/lib/installations/store.d.ts +43 -0
- package/dist/lib/installations/store.js +75 -7
- package/dist/lib/installations/versions.js +1 -1
- package/dist/lib/menubar/install-menubar.d.ts +3 -3
- package/dist/lib/menubar/install-menubar.js +3 -3
- package/dist/lib/native-accounts.d.ts +35 -0
- package/dist/lib/native-accounts.js +41 -0
- package/dist/lib/net-close.d.ts +11 -0
- package/dist/lib/net-close.js +25 -0
- package/dist/lib/openclaw-keychain.js +27 -1
- package/dist/lib/profiles.js +22 -9
- package/dist/lib/project-resources.js +1 -1
- package/dist/lib/reserved-stores.d.ts +53 -0
- package/dist/lib/reserved-stores.js +120 -0
- package/dist/lib/secrets-client.d.ts +199 -0
- package/dist/lib/secrets-client.js +718 -0
- package/dist/lib/secrets-policy.d.ts +235 -0
- package/dist/lib/secrets-policy.js +505 -0
- package/dist/lib/secrets-types.d.ts +188 -0
- package/dist/lib/secrets-types.js +24 -0
- package/dist/lib/self-heal/checks/shims.js +5 -3
- package/dist/lib/service-manifest.js +3 -4
- package/dist/lib/session/db.d.ts +18 -0
- package/dist/lib/session/db.js +47 -14
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/sha256-asset.d.ts +11 -16
- package/dist/lib/sha256-asset.js +11 -16
- package/dist/lib/share/config.js +14 -5
- package/dist/lib/signin-badge.d.ts +26 -0
- package/dist/lib/signin-badge.js +42 -0
- package/dist/lib/staleness/detectors/workflows.js +3 -105
- package/dist/lib/staleness/writers/workflows.js +2 -1
- package/dist/lib/state.d.ts +7 -4
- package/dist/lib/state.js +18 -7
- package/dist/lib/sync-umbrella.js +2 -2
- package/dist/lib/teams/agents.js +1 -1
- package/dist/lib/types.d.ts +74 -33
- package/dist/lib/view-types.d.ts +6 -3
- package/dist/lib/workflows-registry.d.ts +71 -0
- package/dist/lib/workflows-registry.js +280 -0
- package/dist/lib/workflows.d.ts +21 -21
- package/dist/lib/workflows.js +12 -327
- package/package.json +2 -3
- package/scripts/postinstall.js +35 -30
- package/dist/commands/secrets-import.d.ts +0 -18
- package/dist/commands/secrets-import.js +0 -74
- package/dist/commands/secrets-migrate.d.ts +0 -25
- package/dist/commands/secrets-migrate.js +0 -334
- package/dist/commands/secrets-rotate-passphrase.d.ts +0 -17
- package/dist/commands/secrets-rotate-passphrase.js +0 -96
- package/dist/commands/secrets-sync.d.ts +0 -11
- package/dist/commands/secrets-sync.js +0 -153
- package/dist/commands/secrets-vault.d.ts +0 -10
- package/dist/commands/secrets-vault.js +0 -130
- package/dist/commands/secrets.d.ts +0 -212
- package/dist/commands/secrets.js +0 -3030
- package/dist/lib/accounts/connect.d.ts +0 -176
- package/dist/lib/accounts/connect.js +0 -453
- package/dist/lib/daemon/keychain-reap-service.d.ts +0 -17
- package/dist/lib/daemon/keychain-reap-service.js +0 -32
- package/dist/lib/daemon/secrets-broker-service.d.ts +0 -21
- package/dist/lib/daemon/secrets-broker-service.js +0 -51
- package/dist/lib/secrets/agent.d.ts +0 -358
- package/dist/lib/secrets/agent.js +0 -1291
- package/dist/lib/secrets/audit.d.ts +0 -46
- package/dist/lib/secrets/audit.js +0 -101
- package/dist/lib/secrets/bundles.d.ts +0 -290
- package/dist/lib/secrets/bundles.js +0 -1547
- package/dist/lib/secrets/download-keychain.d.ts +0 -47
- package/dist/lib/secrets/download-keychain.js +0 -70
- package/dist/lib/secrets/drivers/rush.d.ts +0 -14
- package/dist/lib/secrets/drivers/rush.js +0 -90
- package/dist/lib/secrets/fallback.d.ts +0 -48
- package/dist/lib/secrets/fallback.js +0 -48
- package/dist/lib/secrets/filestore.d.ts +0 -222
- package/dist/lib/secrets/filestore.js +0 -1099
- package/dist/lib/secrets/headless.d.ts +0 -43
- package/dist/lib/secrets/headless.js +0 -56
- package/dist/lib/secrets/icloud-import.d.ts +0 -79
- package/dist/lib/secrets/icloud-import.js +0 -206
- package/dist/lib/secrets/index.d.ts +0 -451
- package/dist/lib/secrets/index.js +0 -1568
- package/dist/lib/secrets/install-helper.d.ts +0 -72
- package/dist/lib/secrets/install-helper.js +0 -245
- package/dist/lib/secrets/lease.d.ts +0 -25
- package/dist/lib/secrets/lease.js +0 -44
- package/dist/lib/secrets/linux.d.ts +0 -77
- package/dist/lib/secrets/linux.js +0 -393
- package/dist/lib/secrets/list-filter.d.ts +0 -109
- package/dist/lib/secrets/list-filter.js +0 -261
- package/dist/lib/secrets/mcp.d.ts +0 -93
- package/dist/lib/secrets/mcp.js +0 -211
- package/dist/lib/secrets/profiles.d.ts +0 -10
- package/dist/lib/secrets/profiles.js +0 -13
- package/dist/lib/secrets/push.d.ts +0 -133
- package/dist/lib/secrets/push.js +0 -273
- package/dist/lib/secrets/rc-hygiene.d.ts +0 -63
- package/dist/lib/secrets/rc-hygiene.js +0 -143
- package/dist/lib/secrets/read-backoff.d.ts +0 -27
- package/dist/lib/secrets/read-backoff.js +0 -64
- package/dist/lib/secrets/reaper.d.ts +0 -90
- package/dist/lib/secrets/reaper.js +0 -243
- package/dist/lib/secrets/remote.d.ts +0 -152
- package/dist/lib/secrets/remote.js +0 -344
- package/dist/lib/secrets/reserved-sync.d.ts +0 -66
- package/dist/lib/secrets/reserved-sync.js +0 -147
- package/dist/lib/secrets/scope.d.ts +0 -26
- package/dist/lib/secrets/scope.js +0 -29
- package/dist/lib/secrets/session-store.d.ts +0 -107
- package/dist/lib/secrets/session-store.js +0 -342
- package/dist/lib/secrets/sync-backend.d.ts +0 -48
- package/dist/lib/secrets/sync-backend.js +0 -13
- package/dist/lib/secrets/sync-commands.d.ts +0 -21
- package/dist/lib/secrets/sync-commands.js +0 -21
- package/dist/lib/secrets/sync.d.ts +0 -48
- package/dist/lib/secrets/sync.js +0 -237
- package/dist/lib/secrets/unlock-hints.d.ts +0 -27
- package/dist/lib/secrets/unlock-hints.js +0 -36
- package/dist/lib/secrets/usage-db.d.ts +0 -46
- package/dist/lib/secrets/usage-db.js +0 -96
- package/dist/lib/secrets/vault-age-helper.d.ts +0 -1
- package/dist/lib/secrets/vault-age-helper.js +0 -34
- package/dist/lib/secrets/vault.d.ts +0 -49
- package/dist/lib/secrets/vault.js +0 -399
- package/dist/lib/secrets/windows.d.ts +0 -81
- package/dist/lib/secrets/windows.js +0 -556
- package/scripts/install-helper.js +0 -97
- /package/dist/lib/{secrets/sync-passphrase.d.ts → sync-passphrase.d.ts} +0 -0
- /package/dist/lib/{secrets/sync-passphrase.js → sync-passphrase.js} +0 -0
package/dist/commands/secrets.js
DELETED
|
@@ -1,3030 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Secrets bundle management commands.
|
|
3
|
-
*
|
|
4
|
-
* Registers the `agents secrets` command tree for creating, viewing,
|
|
5
|
-
* and managing named bundles of environment variables backed by macOS
|
|
6
|
-
* Keychain. Bundles are injected at run time via `agents run --secrets`.
|
|
7
|
-
*/
|
|
8
|
-
import { Option } from 'commander';
|
|
9
|
-
import chalk from 'chalk';
|
|
10
|
-
import { padVisible, readStdinSync } from '../lib/format.js';
|
|
11
|
-
import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
|
|
12
|
-
import * as fs from 'fs';
|
|
13
|
-
import * as os from 'os';
|
|
14
|
-
import * as path from 'path';
|
|
15
|
-
import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
|
|
16
|
-
import { quoteWin32ExecArg, composeWin32CommandLine } from '../lib/platform/index.js';
|
|
17
|
-
import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon/daemon.js';
|
|
18
|
-
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, } from '../lib/secrets/remote.js';
|
|
19
|
-
import { resolveBundleForPush, pushResolvedBundleToHost, bundleEnvToDotenv } from '../lib/secrets/push.js';
|
|
20
|
-
export { bundleEnvToDotenv };
|
|
21
|
-
import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, isHeadlessSecretsContext, isAgentInvocationContext, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, assertFileBundleDecryptable, isReservedBundleName, ReservedBundleWrongBackendError, SECRET_TYPES, } from '../lib/secrets/bundles.js';
|
|
22
|
-
import { parseListFilters, bundleMatchesFilter, bundleExpiry, filterIsActive, describeFilter, parseSortField, sortBundles, SORT_FIELDS, REF_KINDS, DEFAULT_EXPIRING_DAYS, } from '../lib/secrets/list-filter.js';
|
|
23
|
-
import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
|
|
24
|
-
import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, maybeAutoRekey, } from '../lib/secrets/index.js';
|
|
25
|
-
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
26
|
-
import { GLOBAL_HARNESS } from '../lib/secrets/scope.js';
|
|
27
|
-
import { createSecretLease, selectLeasedEnv } from '../lib/secrets/lease.js';
|
|
28
|
-
import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, isSecretsBrokerEnabled, } from '../lib/secrets/agent.js';
|
|
29
|
-
import { saveSession, deleteBundleSessions, deleteAllSessions } from '../lib/secrets/session-store.js';
|
|
30
|
-
import { getCliVersionFresh } from '../lib/version.js';
|
|
31
|
-
import { readMeta } from '../lib/state.js';
|
|
32
|
-
import { parseDuration } from '../lib/hooks/cache.js';
|
|
33
|
-
import { emit, query } from '../lib/feed/events.js';
|
|
34
|
-
import { emitSecretAudit } from '../lib/secrets/audit.js';
|
|
35
|
-
import { SYNC_PASSPHRASE_ENV, resolveSyncPassphraseFromEnv } from '../lib/secrets/sync-passphrase.js';
|
|
36
|
-
import { getBundleUsage, getAllBundleUsage, getUsageHistory, SECRET_USAGE_EVENTS, } from '../lib/secrets/usage-db.js';
|
|
37
|
-
import { frequentlyPromptedBundles } from '../lib/secrets/unlock-hints.js';
|
|
38
|
-
import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
39
|
-
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
40
|
-
import { discoverSyncedBundles, importSyncedBundle, } from '../lib/secrets/icloud-import.js';
|
|
41
|
-
import { registerSecretsSyncCommands } from './secrets-sync.js';
|
|
42
|
-
import { registerSecretsMigrateAclCommand } from './secrets-migrate.js';
|
|
43
|
-
import { registerSecretsImportKeyringCommand } from './secrets-import.js';
|
|
44
|
-
import { registerSecretsRotatePassphraseCommand } from './secrets-rotate-passphrase.js';
|
|
45
|
-
import { registerSecretsVaultCommands } from './secrets-vault.js';
|
|
46
|
-
/** Prompt the user for a secret value with masked input. Requires an interactive TTY. */
|
|
47
|
-
async function promptForSecret(message) {
|
|
48
|
-
if (!isInteractiveTerminal()) {
|
|
49
|
-
throw new Error('A secret is required but the shell is not interactive. Pass --value, --value-stdin, or run from a TTY.');
|
|
50
|
-
}
|
|
51
|
-
const { password } = await import('@inquirer/prompts');
|
|
52
|
-
return await password({ message, mask: true });
|
|
53
|
-
}
|
|
54
|
-
/** Prompt the user to pick an existing bundle by name. Requires an interactive TTY. */
|
|
55
|
-
async function pickBundleName(action) {
|
|
56
|
-
const bundles = listBundles();
|
|
57
|
-
if (bundles.length === 0) {
|
|
58
|
-
throw new Error('No secrets bundles configured. Try: agents secrets create <name>');
|
|
59
|
-
}
|
|
60
|
-
if (!isInteractiveTerminal()) {
|
|
61
|
-
throw new Error('A bundle name is required. Pass it as an argument or run from a TTY.');
|
|
62
|
-
}
|
|
63
|
-
const { select } = await import('@inquirer/prompts');
|
|
64
|
-
return await select({
|
|
65
|
-
message: `Which bundle to ${action}?`,
|
|
66
|
-
choices: bundles.map((b) => ({
|
|
67
|
-
name: b.name,
|
|
68
|
-
value: b.name,
|
|
69
|
-
description: b.description || undefined,
|
|
70
|
-
})),
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
/** Prompt the user to type a new bundle name. Requires an interactive TTY. */
|
|
74
|
-
async function promptBundleName() {
|
|
75
|
-
if (!isInteractiveTerminal()) {
|
|
76
|
-
throw new Error('A bundle name is required. Pass it as an argument or run from a TTY.');
|
|
77
|
-
}
|
|
78
|
-
const { input } = await import('@inquirer/prompts');
|
|
79
|
-
return await input({
|
|
80
|
-
message: 'Bundle name',
|
|
81
|
-
validate: (value) => {
|
|
82
|
-
try {
|
|
83
|
-
validateBundleName(value);
|
|
84
|
-
return true;
|
|
85
|
-
}
|
|
86
|
-
catch (err) {
|
|
87
|
-
return err.message;
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
/** Prompt the user to pick an existing key from a bundle. Requires an interactive TTY. */
|
|
93
|
-
async function pickKey(bundle, action) {
|
|
94
|
-
const keys = Object.keys(bundle.vars);
|
|
95
|
-
if (keys.length === 0) {
|
|
96
|
-
throw new Error(`Bundle '${bundle.name}' has no keys.`);
|
|
97
|
-
}
|
|
98
|
-
if (!isInteractiveTerminal()) {
|
|
99
|
-
throw new Error('A key name is required. Pass it as an argument or run from a TTY.');
|
|
100
|
-
}
|
|
101
|
-
const { select } = await import('@inquirer/prompts');
|
|
102
|
-
return await select({
|
|
103
|
-
message: `Which key to ${action}?`,
|
|
104
|
-
choices: keys.map((k) => ({ name: k, value: k })),
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
/** Prompt the user to type a new key name for a bundle. Requires an interactive TTY. */
|
|
108
|
-
async function promptKeyName(bundleName) {
|
|
109
|
-
if (!isInteractiveTerminal()) {
|
|
110
|
-
throw new Error('A key name is required. Pass it as an argument or run from a TTY.');
|
|
111
|
-
}
|
|
112
|
-
const { input } = await import('@inquirer/prompts');
|
|
113
|
-
return await input({
|
|
114
|
-
message: `Key name to add to '${bundleName}'`,
|
|
115
|
-
validate: (value) => {
|
|
116
|
-
try {
|
|
117
|
-
validateEnvKey(value);
|
|
118
|
-
return true;
|
|
119
|
-
}
|
|
120
|
-
catch (err) {
|
|
121
|
-
return err.message;
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
/** Resolve a 1Password vault name — use the provided value, or prompt interactively. */
|
|
127
|
-
async function resolveVault(vaultOpt) {
|
|
128
|
-
if (vaultOpt)
|
|
129
|
-
return vaultOpt;
|
|
130
|
-
const vaults = listVaults();
|
|
131
|
-
if (vaults.length === 0)
|
|
132
|
-
throw new Error('No 1Password vaults found. Make sure you are signed in: op signin');
|
|
133
|
-
if (vaults.length === 1)
|
|
134
|
-
return vaults[0].name;
|
|
135
|
-
if (!isInteractiveTerminal()) {
|
|
136
|
-
throw new Error(`Multiple vaults found. Pass --vault <name> (available: ${vaults.map((v) => v.name).join(', ')})`);
|
|
137
|
-
}
|
|
138
|
-
const { select } = await import('@inquirer/prompts');
|
|
139
|
-
return await select({
|
|
140
|
-
message: 'Which 1Password vault?',
|
|
141
|
-
choices: vaults.map((v) => ({ name: v.name, value: v.name })),
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
/** Read all available data from stdin synchronously, trimmed. */
|
|
145
|
-
/**
|
|
146
|
-
* Read the raw `.env` text for `import --from <path|->`. A `-` reads the .env
|
|
147
|
-
* from stdin (the SSH push path: `export --device` pipes the resolved dotenv over
|
|
148
|
-
* ssh stdin, which has no `/dev/stdin` on a Windows remote); any other value is
|
|
149
|
-
* a filesystem path.
|
|
150
|
-
*/
|
|
151
|
-
export function readImportDotenv(from) {
|
|
152
|
-
return from === '-' ? readStdinSync() : fs.readFileSync(from, 'utf-8');
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* Parse the unified `--from <source>` value: a .env path (`-` reads stdin),
|
|
156
|
-
* `1password:<vault>` (bare `1password` prompts for the vault), or `icloud`
|
|
157
|
-
* (legacy iCloud Keychain bundles). The deprecated `--from-1password --vault`
|
|
158
|
-
* pair maps onto the 1password source. A file literally named `icloud` or
|
|
159
|
-
* `1password` can still be imported via an explicit path (`./icloud`).
|
|
160
|
-
*/
|
|
161
|
-
export function parseImportSource(opts) {
|
|
162
|
-
if (opts.from && opts.from1password) {
|
|
163
|
-
throw new Error('--from and --from-1password are mutually exclusive.');
|
|
164
|
-
}
|
|
165
|
-
if (opts.from1password)
|
|
166
|
-
return { kind: '1password', vault: opts.vault };
|
|
167
|
-
if (!opts.from) {
|
|
168
|
-
throw new Error("Pass --from <source>: a .env path (- reads stdin), '1password:<vault>', or 'icloud'.");
|
|
169
|
-
}
|
|
170
|
-
if (opts.from === 'icloud')
|
|
171
|
-
return { kind: 'icloud' };
|
|
172
|
-
if (opts.from === '1password')
|
|
173
|
-
return { kind: '1password', vault: opts.vault };
|
|
174
|
-
if (opts.from.startsWith('1password:')) {
|
|
175
|
-
const vault = opts.from.slice('1password:'.length);
|
|
176
|
-
return { kind: '1password', vault: vault || opts.vault };
|
|
177
|
-
}
|
|
178
|
-
return { kind: 'dotenv', path: opts.from };
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* `secrets import --from icloud` — recover bundles stranded in the iCloud
|
|
182
|
-
* Keychain by the device-local cutover. With a bundle name, imports exactly
|
|
183
|
-
* that bundle; without one, interactively multi-selects from everything
|
|
184
|
-
* discovered (all pre-checked — the common case is "bring them all back").
|
|
185
|
-
*/
|
|
186
|
-
async function importFromICloud(bundleName, opts) {
|
|
187
|
-
if (process.platform !== 'darwin') {
|
|
188
|
-
throw new Error('--from icloud reads the macOS iCloud Keychain and is only available on macOS.');
|
|
189
|
-
}
|
|
190
|
-
const candidates = discoverSyncedBundles();
|
|
191
|
-
if (candidates.length === 0) {
|
|
192
|
-
console.log('No legacy iCloud Keychain bundles found.');
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
const describe = (c) => `${c.name} (${c.keys.length} key${c.keys.length === 1 ? '' : 's'}${c.hasMeta ? '' : ', no metadata'})`;
|
|
196
|
-
let chosen;
|
|
197
|
-
if (bundleName) {
|
|
198
|
-
const hit = candidates.find((c) => c.name === bundleName);
|
|
199
|
-
if (!hit) {
|
|
200
|
-
throw new Error(`No iCloud Keychain bundle named '${bundleName}'. Found: ${candidates.map((c) => c.name).join(', ')}`);
|
|
201
|
-
}
|
|
202
|
-
chosen = [hit];
|
|
203
|
-
}
|
|
204
|
-
else if (!isInteractiveTerminal()) {
|
|
205
|
-
throw new Error(`Found ${candidates.length} iCloud Keychain bundle(s): ${candidates.map((c) => c.name).join(', ')}. ` +
|
|
206
|
-
'Pass a bundle name to import non-interactively.');
|
|
207
|
-
}
|
|
208
|
-
else {
|
|
209
|
-
const { checkbox } = await import('@inquirer/prompts');
|
|
210
|
-
chosen = await checkbox({
|
|
211
|
-
message: 'Which iCloud Keychain bundles to import?',
|
|
212
|
-
choices: candidates.map((c) => ({ name: describe(c), value: c, checked: true })),
|
|
213
|
-
});
|
|
214
|
-
if (chosen.length === 0) {
|
|
215
|
-
console.log('Nothing selected.');
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
for (const candidate of chosen) {
|
|
220
|
-
const result = importSyncedBundle(candidate, opts);
|
|
221
|
-
const parts = [`imported ${result.added} key(s)`];
|
|
222
|
-
if (result.skipped)
|
|
223
|
-
parts.push(`skipped ${result.skipped} (already set, pass --force)`);
|
|
224
|
-
if (result.missing.length)
|
|
225
|
-
parts.push(`unreadable (left in iCloud): ${result.missing.join(', ')}`);
|
|
226
|
-
if (result.unimportable.length)
|
|
227
|
-
parts.push(`reserved, not importable (left in iCloud): ${result.unimportable.join(', ')}`);
|
|
228
|
-
if (opts.purge)
|
|
229
|
-
parts.push(`purged ${result.purged} iCloud item(s)`);
|
|
230
|
-
const line = `${candidate.name}: ${parts.join(', ')}`;
|
|
231
|
-
const warn = result.missing.length > 0 || result.unimportable.length > 0;
|
|
232
|
-
console.log(warn ? chalk.yellow(line) : chalk.green(line));
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* Printed under a read failure: if the name matches a bundle stranded in the
|
|
237
|
-
* iCloud Keychain (pre-device-local-cutover era), point at the recovery command
|
|
238
|
-
* instead of leaving a dead end.
|
|
239
|
-
*
|
|
240
|
-
* `stillPresent` distinguishes the two failures, because they need different
|
|
241
|
-
* commands. A MISSING bundle imports straight from iCloud. A bundle that is on
|
|
242
|
-
* disk but undecryptable does not: `import` reads the existing bundle before
|
|
243
|
-
* writing into it, so it fails the same way the read just did. That one has to
|
|
244
|
-
* be deleted first — which is why naming the wrong command here bricked the
|
|
245
|
-
* name entirely.
|
|
246
|
-
*/
|
|
247
|
-
function maybePrintSyncedHint(name, stillPresent) {
|
|
248
|
-
if (process.platform !== 'darwin')
|
|
249
|
-
return;
|
|
250
|
-
try {
|
|
251
|
-
if (!discoverSyncedBundles().some((c) => c.name === name))
|
|
252
|
-
return;
|
|
253
|
-
console.error(chalk.yellow(stillPresent
|
|
254
|
-
? `A legacy iCloud Keychain copy of '${name}' exists, but the local copy is unreadable and ` +
|
|
255
|
-
`import writes into it. Delete the local copy first, then recover:\n` +
|
|
256
|
-
` agents secrets delete ${name}\n` +
|
|
257
|
-
` agents secrets import ${name} --from icloud`
|
|
258
|
-
: `A legacy iCloud Keychain copy of '${name}' exists. Recover it with: agents secrets import ${name} --from icloud`));
|
|
259
|
-
}
|
|
260
|
-
catch {
|
|
261
|
-
// Hint only — never mask the original error.
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
/**
|
|
265
|
-
* Build the remote `agents secrets unlock` argv for `unlock --device`. `--all`
|
|
266
|
-
* forwards verbatim; otherwise the explicit bundle names. A `--ttl` is passed
|
|
267
|
-
* through as-is so the REMOTE parses its own duration (its platform rules, its
|
|
268
|
-
* defaults). Shared with the command action so the wiring is unit-testable
|
|
269
|
-
* without a live SSH session.
|
|
270
|
-
*/
|
|
271
|
-
export function resolveUnlockTtlMs(ttl, until, now = Date.now()) {
|
|
272
|
-
if (ttl && until)
|
|
273
|
-
throw new Error('--ttl and --until are mutually exclusive.');
|
|
274
|
-
if (until) {
|
|
275
|
-
const expiresAt = Date.parse(until);
|
|
276
|
-
if (!Number.isFinite(expiresAt))
|
|
277
|
-
throw new Error("Invalid --until '" + until + "'. Use an ISO date or timestamp.");
|
|
278
|
-
if (expiresAt <= now)
|
|
279
|
-
throw new Error("Invalid --until '" + until + "': date must be in the future.");
|
|
280
|
-
return expiresAt - now;
|
|
281
|
-
}
|
|
282
|
-
if (ttl) {
|
|
283
|
-
const secs = parseDuration(ttl);
|
|
284
|
-
if (!secs)
|
|
285
|
-
throw new Error("Invalid --ttl '" + ttl + "'. Use e.g. 30m, 2h, 8h, 3d.");
|
|
286
|
-
return secs * 1000;
|
|
287
|
-
}
|
|
288
|
-
return secretsHoldMs();
|
|
289
|
-
}
|
|
290
|
-
/**
|
|
291
|
-
* Decide what an `unlock` holds: the whole bundle env, or — with --keys — only
|
|
292
|
-
* the resolved subset behind a lease that scopes the broker entry (agent.ts
|
|
293
|
-
* re-selects on load) and its own expiry. Fails closed: createSecretLease throws
|
|
294
|
-
* on an unknown or empty key subset. The unlock action's single scoping seam,
|
|
295
|
-
* exported so the whole-bundle vs scoped-subset decision is unit-testable without
|
|
296
|
-
* a live broker.
|
|
297
|
-
*/
|
|
298
|
-
export function scopeHeldEnv(input) {
|
|
299
|
-
// null = no --keys → hold the whole bundle. An empty-string --keys is a
|
|
300
|
-
// deliberate (if empty) scope request and falls through to createSecretLease,
|
|
301
|
-
// which fails closed ('requires at least one key') rather than holding it all.
|
|
302
|
-
if (input.keys === null)
|
|
303
|
-
return { heldEnv: input.env };
|
|
304
|
-
const lease = createSecretLease({
|
|
305
|
-
bundle: input.bundle,
|
|
306
|
-
keys: input.keys.split(','),
|
|
307
|
-
availableKeys: Object.keys(input.env),
|
|
308
|
-
ttlMs: input.ttlMs,
|
|
309
|
-
harness: input.harness,
|
|
310
|
-
sleepPersist: input.sleepPersist,
|
|
311
|
-
});
|
|
312
|
-
return { heldEnv: selectLeasedEnv(lease, input.env), lease };
|
|
313
|
-
}
|
|
314
|
-
export function buildRemoteUnlockArgs(names, opts) {
|
|
315
|
-
return [
|
|
316
|
-
'unlock',
|
|
317
|
-
...(opts.all ? ['--all'] : names),
|
|
318
|
-
...(opts.ttl ? ['--ttl', opts.ttl] : []),
|
|
319
|
-
...(opts.until ? ['--until', opts.until] : []),
|
|
320
|
-
// Forward --durable so a remote unlock honors it too; without this the remote
|
|
321
|
-
// silently falls back to its own secrets.agent.durable default (off).
|
|
322
|
-
...(opts.durable ? ['--durable'] : []),
|
|
323
|
-
];
|
|
324
|
-
}
|
|
325
|
-
/**
|
|
326
|
-
* Build the remote `agents secrets list` argv for `list --device`. Every filter
|
|
327
|
-
* must be forwarded: `browseRemote` sends this argv verbatim, so a flag left out
|
|
328
|
-
* here is not an error — the remote just lists everything, and
|
|
329
|
-
* `secrets list --device zion --expired` reports every bundle on zion as expired.
|
|
330
|
-
* Silent and wrong beats loud and wrong only for the person who wrote the bug.
|
|
331
|
-
*
|
|
332
|
-
* Values pass through unparsed so the REMOTE validates them under its own rules,
|
|
333
|
-
* matching how `--ttl` is forwarded by buildRemoteUnlockArgs.
|
|
334
|
-
*/
|
|
335
|
-
export function buildRemoteListArgs(opts, query) {
|
|
336
|
-
return [
|
|
337
|
-
'list',
|
|
338
|
-
...(query ? [query] : []),
|
|
339
|
-
...(opts.json ? ['--json'] : []),
|
|
340
|
-
...(opts.policy ? ['--policy', opts.policy] : []),
|
|
341
|
-
...(opts.backend ? ['--backend', opts.backend] : []),
|
|
342
|
-
...(opts.type ? ['--type', opts.type] : []),
|
|
343
|
-
...(opts.kind ? ['--kind', opts.kind] : []),
|
|
344
|
-
...(opts.held ? ['--held'] : []),
|
|
345
|
-
...(opts.notHeld ? ['--not-held'] : []),
|
|
346
|
-
...(opts.expired ? ['--expired'] : []),
|
|
347
|
-
// `--expiring` is optional-value: `true` means the bare flag was passed.
|
|
348
|
-
...(opts.expiring === true ? ['--expiring'] : opts.expiring ? ['--expiring', String(opts.expiring)] : []),
|
|
349
|
-
...(opts.unused ? ['--unused', opts.unused] : []),
|
|
350
|
-
...(opts.sort ? ['--sort', opts.sort] : []),
|
|
351
|
-
...(opts.limit ? ['--limit', opts.limit] : []),
|
|
352
|
-
];
|
|
353
|
-
}
|
|
354
|
-
// SSH target validation is defined canonically in src/lib/ssh-exec.ts and
|
|
355
|
-
// re-exported here for back-compat with existing importers of these symbols.
|
|
356
|
-
export { SSH_TARGET_RE, assertValidSshTarget };
|
|
357
|
-
/**
|
|
358
|
-
* Build the child environment for `agents secrets exec`. Strips
|
|
359
|
-
* loader/interpreter hijack vars (matching agent spawns in exec.ts) and never
|
|
360
|
-
* forwards AGENTS_SECRETS_PASSPHRASE — the master decryption key must not reach
|
|
361
|
-
* the executed command.
|
|
362
|
-
*/
|
|
363
|
-
export function buildSecretsExecEnv(parentEnv, secretEnv) {
|
|
364
|
-
const env = { ...sanitizeProcessEnv(parentEnv), ...secretEnv };
|
|
365
|
-
delete env.AGENTS_SECRETS_PASSPHRASE;
|
|
366
|
-
return env;
|
|
367
|
-
}
|
|
368
|
-
/**
|
|
369
|
-
* Resolve the CLI version for the MCP server's `serverInfo.version`. package.json
|
|
370
|
-
* sits at the repo root — two levels up from both `src/commands/` (bun/tsx dev)
|
|
371
|
-
* and `dist/commands/` (built). Cosmetic only, so any failure falls back cleanly.
|
|
372
|
-
*/
|
|
373
|
-
function getCliVersion() {
|
|
374
|
-
try {
|
|
375
|
-
const pkgPath = new URL('../../package.json', import.meta.url);
|
|
376
|
-
return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version ?? '0.0.0';
|
|
377
|
-
}
|
|
378
|
-
catch {
|
|
379
|
-
return '0.0.0';
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
/**
|
|
383
|
-
* Encrypt a resolved env map to an offline bundle file using AES-256-GCM
|
|
384
|
-
* (the same EncFile envelope as the per-item file store). Inner plaintext is
|
|
385
|
-
* JSON so multi-line values round-trip losslessly. Written with mode 0600;
|
|
386
|
-
* the passphrase must be supplied explicitly — never auto-provisioned.
|
|
387
|
-
*/
|
|
388
|
-
export function exportBundleToFile(env, filePath, passphrase) {
|
|
389
|
-
const enc = encryptForFallback(JSON.stringify(env), passphrase);
|
|
390
|
-
fs.writeFileSync(filePath, JSON.stringify(enc), { mode: 0o600 });
|
|
391
|
-
}
|
|
392
|
-
/**
|
|
393
|
-
* Decrypt and parse an offline bundle file produced by exportBundleToFile.
|
|
394
|
-
* Throws on a missing file, an invalid JSON envelope, or a wrong passphrase.
|
|
395
|
-
*/
|
|
396
|
-
export function importBundleFromFile(filePath, passphrase) {
|
|
397
|
-
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
398
|
-
let enc;
|
|
399
|
-
try {
|
|
400
|
-
enc = JSON.parse(raw);
|
|
401
|
-
}
|
|
402
|
-
catch {
|
|
403
|
-
throw new Error(`Encrypted bundle file ${filePath} is corrupt (not valid JSON).`);
|
|
404
|
-
}
|
|
405
|
-
let plaintext;
|
|
406
|
-
try {
|
|
407
|
-
plaintext = decryptForFallback(enc, passphrase);
|
|
408
|
-
}
|
|
409
|
-
catch {
|
|
410
|
-
throw new Error(`Failed to decrypt bundle file ${filePath}. Wrong passphrase or tampered file.`);
|
|
411
|
-
}
|
|
412
|
-
let parsed;
|
|
413
|
-
try {
|
|
414
|
-
parsed = JSON.parse(plaintext);
|
|
415
|
-
}
|
|
416
|
-
catch {
|
|
417
|
-
throw new Error(`Decrypted bundle file ${filePath} has invalid content (expected JSON).`);
|
|
418
|
-
}
|
|
419
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
420
|
-
throw new Error(`Bundle file ${filePath} has unexpected structure.`);
|
|
421
|
-
}
|
|
422
|
-
const result = {};
|
|
423
|
-
for (const [k, v] of Object.entries(parsed)) {
|
|
424
|
-
result[k] = typeof v === 'string' ? v : String(v);
|
|
425
|
-
}
|
|
426
|
-
return result;
|
|
427
|
-
}
|
|
428
|
-
/**
|
|
429
|
-
* Browse `agents secrets <args>` on one or more remote hosts over SSH and print
|
|
430
|
-
* each host's stdout verbatim (lossless — no parsing). With >1 host the output
|
|
431
|
-
* is grouped under a `── <host> ──` header. `tty` forces an interactive ssh
|
|
432
|
-
* session (run sequentially) so a remote Touch-ID / passphrase prompt can
|
|
433
|
-
* surface (e.g. `view --reveal`); otherwise hosts are queried in parallel.
|
|
434
|
-
* Exits non-zero if any host fails.
|
|
435
|
-
*
|
|
436
|
-
* `secret` marks a browse that streams plaintext VALUES back (`view --reveal`)
|
|
437
|
-
* rather than only key names (`list`, masked `view`). A secret browse rides the
|
|
438
|
-
* credential-transport posture — managed host-key pinning + no multiplex — so a
|
|
439
|
-
* revealed value can't be intercepted on an unpinned/multiplexed connection
|
|
440
|
-
* (RUSH-2527). It composes with `tty`: `view --reveal` at a terminal is both.
|
|
441
|
-
*/
|
|
442
|
-
async function browseRemote(targets, args, tty, secret = false) {
|
|
443
|
-
const multi = targets.length > 1;
|
|
444
|
-
let failures = 0;
|
|
445
|
-
const render = (name, res) => {
|
|
446
|
-
if (multi)
|
|
447
|
-
console.log(chalk.bold.cyan(`\n── ${name} ──`));
|
|
448
|
-
if (res.code === 0) {
|
|
449
|
-
if (res.stdout)
|
|
450
|
-
process.stdout.write(res.stdout.endsWith('\n') ? res.stdout : `${res.stdout}\n`);
|
|
451
|
-
if (res.stderr.trim())
|
|
452
|
-
process.stderr.write(chalk.gray(res.stderr));
|
|
453
|
-
}
|
|
454
|
-
else {
|
|
455
|
-
failures++;
|
|
456
|
-
const msg = (res.stderr || res.stdout || '').trim();
|
|
457
|
-
const why = res.timedOut ? 'timed out' : res.code === null ? 'ssh failed' : `exit ${res.code}`;
|
|
458
|
-
console.error(chalk.red(`${name}: ${why}${msg ? `: ${msg}` : ''}`));
|
|
459
|
-
}
|
|
460
|
-
};
|
|
461
|
-
if (tty) {
|
|
462
|
-
for (const t of targets) {
|
|
463
|
-
const target = await resolveHostSshTarget(t);
|
|
464
|
-
render(t, remoteSecretsRaw(target, args, { tty: true, osLookupName: t, secret }));
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
else {
|
|
468
|
-
const resolved = await Promise.all(targets.map(async (t) => ({ name: t, target: await resolveHostSshTarget(t) })));
|
|
469
|
-
const results = resolved.map(({ name, target }) => remoteSecretsRaw(target, args, { osLookupName: name, secret }));
|
|
470
|
-
targets.forEach((t, i) => render(t, results[i]));
|
|
471
|
-
}
|
|
472
|
-
if (failures > 0)
|
|
473
|
-
process.exit(1);
|
|
474
|
-
}
|
|
475
|
-
/** Strip ANSI escape sequences so padding can be computed on visible width. */
|
|
476
|
-
/** Render an ISO-8601 timestamp as a compact relative age: "now", "5m", "1h", "3d", "2w", "4mo", "1y". */
|
|
477
|
-
function relativeAge(iso) {
|
|
478
|
-
const t = Date.parse(iso);
|
|
479
|
-
if (!Number.isFinite(t))
|
|
480
|
-
return '-';
|
|
481
|
-
const deltaMs = Date.now() - t;
|
|
482
|
-
if (deltaMs < 0)
|
|
483
|
-
return 'now';
|
|
484
|
-
const sec = Math.floor(deltaMs / 1000);
|
|
485
|
-
if (sec < 60)
|
|
486
|
-
return 'now';
|
|
487
|
-
const min = Math.floor(sec / 60);
|
|
488
|
-
if (min < 60)
|
|
489
|
-
return `${min}m`;
|
|
490
|
-
const hr = Math.floor(min / 60);
|
|
491
|
-
if (hr < 24)
|
|
492
|
-
return `${hr}h`;
|
|
493
|
-
const day = Math.floor(hr / 24);
|
|
494
|
-
if (day < 7)
|
|
495
|
-
return `${day}d`;
|
|
496
|
-
if (day < 30)
|
|
497
|
-
return `${Math.floor(day / 7)}w`;
|
|
498
|
-
const mo = Math.floor(day / 30);
|
|
499
|
-
if (mo < 12)
|
|
500
|
-
return `${mo}mo`;
|
|
501
|
-
return `${Math.floor(day / 365)}y`;
|
|
502
|
-
}
|
|
503
|
-
/** Long-form relative age for the `view` command. "now" stays as "now"; otherwise appends " ago". */
|
|
504
|
-
function humanAge(iso) {
|
|
505
|
-
const age = relativeAge(iso);
|
|
506
|
-
if (age === 'now' || age === '-')
|
|
507
|
-
return age;
|
|
508
|
-
return `${age} ago`;
|
|
509
|
-
}
|
|
510
|
-
/** Compact span for a fixed duration: "45m" / "19h" / "2d". Shared by
|
|
511
|
-
* `compactRemaining` (a countdown) and the POLICY column's hold-window
|
|
512
|
-
* annotation (a fixed length) so the two can never round onto different unit
|
|
513
|
-
* thresholds and disagree about the same number of milliseconds. */
|
|
514
|
-
export function compactDurationMs(ms) {
|
|
515
|
-
const mins = Math.round(ms / 60000);
|
|
516
|
-
if (mins < 60)
|
|
517
|
-
return `${mins}m`;
|
|
518
|
-
const hours = Math.round(mins / 60);
|
|
519
|
-
if (hours < 24)
|
|
520
|
-
return `${hours}h`;
|
|
521
|
-
return `${Math.round(hours / 24)}d`;
|
|
522
|
-
}
|
|
523
|
-
/** Compact remaining-time for the list POLICY column: "19h" / "45m" / "2d". */
|
|
524
|
-
function compactRemaining(expiresAt) {
|
|
525
|
-
const ms = expiresAt - Date.now();
|
|
526
|
-
if (ms <= 0)
|
|
527
|
-
return 'expired';
|
|
528
|
-
return compactDurationMs(ms);
|
|
529
|
-
}
|
|
530
|
-
/** The POLICY column for `secrets list`. `hold` is a duration, not a mode — it
|
|
531
|
-
* means "prompt once, then stay silent for this long" — so the column states
|
|
532
|
-
* the window (`hold 7d`) rather than the bare tier name, and appends `· held Nd`
|
|
533
|
-
* while the secrets-agent is actually caching the bundle. `always` and `never`
|
|
534
|
-
* carry no window and gain nothing here. `holdMs` is the configured global hold
|
|
535
|
-
* (`secretsHoldMs()`), passed in rather than read so this stays pure; `held`
|
|
536
|
-
* maps bundle name → expiry epoch-ms (from agentStatus()). */
|
|
537
|
-
export function renderPolicyCol(b, holdMs, held) {
|
|
538
|
-
// `never` is loud on purpose — it's the only tier with no user-presence gate.
|
|
539
|
-
if (bundlePolicy(b) === 'never')
|
|
540
|
-
return chalk.red.bold('never · no prompt');
|
|
541
|
-
if (bundlePolicy(b) === 'always')
|
|
542
|
-
return chalk.yellow('always · prompt');
|
|
543
|
-
const window = compactDurationMs(holdMs);
|
|
544
|
-
// A lapsed entry is not held. Without this guard a stale broker row renders
|
|
545
|
-
// the countdown's `expired` sentinel as if it were a live hold.
|
|
546
|
-
const exp = liveHold(held?.get(b.name));
|
|
547
|
-
return exp !== null
|
|
548
|
-
? chalk.green(`hold ${window} · held ${compactRemaining(exp)}`)
|
|
549
|
-
: chalk.gray(`hold ${window}`);
|
|
550
|
-
}
|
|
551
|
-
/** The hold-window line at the top of `secrets status`. Names the `hold` policy
|
|
552
|
-
* the window belongs to — the rename in #1604 left this surface still saying
|
|
553
|
-
* "daily", the one name the CLI no longer accepts in its own help. Pure so the
|
|
554
|
-
* vocabulary is pinned by a test rather than re-drifting on the next rename. */
|
|
555
|
-
export function renderHoldSummary(holdStr, configured) {
|
|
556
|
-
const source = configured ? ' (secrets.agent.holdMs)' : ' (default)';
|
|
557
|
-
return `hold: ${holdStr}${source} — a bundle on the hold policy prompts once, then stays silent for this long or until sleep/logout.`;
|
|
558
|
-
}
|
|
559
|
-
/** The empty-broker line under the hold summary. Named here, beside
|
|
560
|
-
* `renderHoldSummary`, for the same reason: it is the second line the rename
|
|
561
|
-
* left saying `daily`, and a test pins both. */
|
|
562
|
-
export const NO_BUNDLES_HELD_LINE = 'No bundles held. The next read of each hold-policy bundle will prompt once, then hold.';
|
|
563
|
-
/** Human-readable hold window for `secrets status`. Sub-hour values render in
|
|
564
|
-
* minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
|
|
565
|
-
* hours up to 2 days, whole days beyond. Pure — unit-tested. */
|
|
566
|
-
export function formatHoldWindow(ms) {
|
|
567
|
-
if (ms < 3_600_000) { // under an hour → minutes (never a confusing "0 hours")
|
|
568
|
-
const mins = Math.max(1, Math.round(ms / 60_000));
|
|
569
|
-
if (mins < 60)
|
|
570
|
-
return `${mins} minute${mins === 1 ? '' : 's'}`;
|
|
571
|
-
// 59.99m rounds to 60 — call it 1 hour rather than "60 minutes".
|
|
572
|
-
}
|
|
573
|
-
const hrs = Math.round(ms / 3_600_000);
|
|
574
|
-
if (hrs < 48)
|
|
575
|
-
return `${hrs} hour${hrs === 1 ? '' : 's'}`;
|
|
576
|
-
const days = Math.round(hrs / 24);
|
|
577
|
-
return `${days} day${days === 1 ? '' : 's'}`;
|
|
578
|
-
}
|
|
579
|
-
/** Below this width the fixed date columns no longer fit; `list` uses cards. */
|
|
580
|
-
const SECRETS_WIDE = 96;
|
|
581
|
-
/** A broker hold expiry, or null once it has lapsed. One definition of "held",
|
|
582
|
-
* shared by the POLICY column, the `--held` filter, and the JSON payload, so the
|
|
583
|
-
* three can never disagree about the same bundle. */
|
|
584
|
-
export function liveHold(expiresAt, now = Date.now()) {
|
|
585
|
-
return expiresAt !== undefined && expiresAt > now ? expiresAt : null;
|
|
586
|
-
}
|
|
587
|
-
/** Width of the POLICY column. The widest cell is `hold 30d · held 30d` (19
|
|
588
|
-
* visible chars) — `clampHoldMs` caps the window at 30d and `renderPolicyCol`
|
|
589
|
-
* drops lapsed holds, so nothing longer can be produced. */
|
|
590
|
-
const POLICY_COL_WIDTH = 20;
|
|
591
|
-
/** Format a single bundle as a table row for the `secrets list` output. */
|
|
592
|
-
function renderBundleRow(b, holdMs, held, cols = terminalWidth()) {
|
|
593
|
-
const entries = describeBundle(b);
|
|
594
|
-
const keys = entries.length;
|
|
595
|
-
const expiring = renderExpiringCol(b);
|
|
596
|
-
// Timestamp distinction:
|
|
597
|
-
// "?" -> legacy bundle, never written under the timestamping code.
|
|
598
|
-
// "never" -> bundle has been written but the action never happened
|
|
599
|
-
// (currently only used for USED — CREATED/UPDATED are always
|
|
600
|
-
// set together by writeBundle).
|
|
601
|
-
// <age> -> real data.
|
|
602
|
-
const created = b.created_at ? relativeAge(b.created_at) : chalk.gray('?');
|
|
603
|
-
const updated = b.updated_at ? relativeAge(b.updated_at) : chalk.gray('?');
|
|
604
|
-
const used = b.last_used
|
|
605
|
-
? relativeAge(b.last_used)
|
|
606
|
-
: (b.created_at ? chalk.gray('never') : chalk.gray('?'));
|
|
607
|
-
const head = `${chalk.cyan(b.name.padEnd(20))} ` +
|
|
608
|
-
`${String(keys).padEnd(5)} ` +
|
|
609
|
-
`${padVisible(renderPolicyCol(b, holdMs, held), POLICY_COL_WIDTH)} ` +
|
|
610
|
-
`${padVisible(expiring, 9)} ` +
|
|
611
|
-
`${padVisible(created, 9)} ` +
|
|
612
|
-
`${padVisible(updated, 9)} ` +
|
|
613
|
-
`${padVisible(used, 7)}`;
|
|
614
|
-
// Mark non-keychain bundles so `list` distinguishes storage at a glance.
|
|
615
|
-
const tag = b.backend === 'file'
|
|
616
|
-
? chalk.magenta('[file] ')
|
|
617
|
-
: b.backend === 'vault'
|
|
618
|
-
? chalk.blue('[synced] ')
|
|
619
|
-
: '';
|
|
620
|
-
// Cap the free-form description to whatever space is left on the line so a long
|
|
621
|
-
// description can't push the row to 200+ chars and wrap into a smear.
|
|
622
|
-
const budget = cols - stringWidth(head) - 1 - stringWidth(tag);
|
|
623
|
-
const desc = b.description && budget > 3
|
|
624
|
-
? chalk.gray(truncateToWidth(safePrint(b.description), budget))
|
|
625
|
-
: '';
|
|
626
|
-
const trailer = `${tag}${desc}`.trimEnd();
|
|
627
|
-
return trailer ? `${head} ${trailer}` : head.trimEnd();
|
|
628
|
-
}
|
|
629
|
-
/** Narrow-terminal card: name + compact meta on one line, description below. */
|
|
630
|
-
function renderBundleCard(b, holdMs, held, cols) {
|
|
631
|
-
const keys = describeBundle(b).length;
|
|
632
|
-
const used = b.last_used ? relativeAge(b.last_used) : (b.created_at ? 'never' : '?');
|
|
633
|
-
const tag = b.backend === 'file'
|
|
634
|
-
? chalk.magenta(' [file]')
|
|
635
|
-
: b.backend === 'vault'
|
|
636
|
-
? chalk.blue(' [synced]')
|
|
637
|
-
: '';
|
|
638
|
-
const meta = chalk.gray(`${keys} key${keys === 1 ? '' : 's'} · `) + renderPolicyCol(b, holdMs, held) + chalk.gray(` · used ${used}`);
|
|
639
|
-
const line1 = `${chalk.cyan(b.name)} ${meta}${tag}`;
|
|
640
|
-
if (!b.description)
|
|
641
|
-
return line1;
|
|
642
|
-
return `${line1}\n ${chalk.gray(truncateToWidth(safePrint(b.description), cols - 4))}`;
|
|
643
|
-
}
|
|
644
|
-
/** Colorize a variable source kind (literal, keychain, env, file, exec). */
|
|
645
|
-
function kindLabel(kind) {
|
|
646
|
-
switch (kind) {
|
|
647
|
-
case 'literal': return chalk.gray('literal');
|
|
648
|
-
case 'keychain': return chalk.green('keychain');
|
|
649
|
-
case 'env': return chalk.blue('env');
|
|
650
|
-
case 'file': return chalk.magenta('file');
|
|
651
|
-
case 'exec': return chalk.red('exec');
|
|
652
|
-
default: return kind;
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
/** Mask a value with asterisks unless reveal is true. */
|
|
656
|
-
function redact(value, reveal) {
|
|
657
|
-
if (reveal)
|
|
658
|
-
return value;
|
|
659
|
-
if (!value)
|
|
660
|
-
return '';
|
|
661
|
-
return '*'.repeat(Math.min(value.length, 8));
|
|
662
|
-
}
|
|
663
|
-
/**
|
|
664
|
-
* Strip ASCII / C1 control bytes from a string before printing it to the
|
|
665
|
-
* terminal. Bundle descriptions, notes, and remote-supplied names can carry
|
|
666
|
-
* arbitrary text and a malicious value containing ANSI escape sequences (e.g.
|
|
667
|
-
* OSC 52 clipboard set, screen-clear, cursor moves) would otherwise be
|
|
668
|
-
* interpreted by the user's terminal. Allow tab and newline so multi-line
|
|
669
|
-
* notes still render; strip everything else in the C0/C1 ranges plus DEL.
|
|
670
|
-
*/
|
|
671
|
-
function safePrint(s) {
|
|
672
|
-
return s.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, '');
|
|
673
|
-
}
|
|
674
|
-
/**
|
|
675
|
-
* Build a VarMeta patch from CLI flags. Validates each provided field. Returns
|
|
676
|
-
* undefined if no meta flag was passed (so callers know to skip meta updates).
|
|
677
|
-
*
|
|
678
|
-
* `--note -` reads the note from stdin so users can pass long/multi-line notes
|
|
679
|
-
* without shell-escaping. It's mutually exclusive with `--value-stdin`; both
|
|
680
|
-
* trying to consume stdin would race and silently corrupt one or the other.
|
|
681
|
-
*/
|
|
682
|
-
function buildMetaPatch(raw) {
|
|
683
|
-
if (raw.type === undefined && raw.expires === undefined && raw.note === undefined) {
|
|
684
|
-
return undefined;
|
|
685
|
-
}
|
|
686
|
-
const patch = {};
|
|
687
|
-
if (raw.type !== undefined) {
|
|
688
|
-
validateSecretType(raw.type);
|
|
689
|
-
patch.type = raw.type;
|
|
690
|
-
}
|
|
691
|
-
if (raw.expires !== undefined) {
|
|
692
|
-
validateExpiresFutureDated(raw.expires);
|
|
693
|
-
patch.expires = raw.expires;
|
|
694
|
-
}
|
|
695
|
-
if (raw.note !== undefined) {
|
|
696
|
-
if (raw.note === '-') {
|
|
697
|
-
if (raw.valueStdin) {
|
|
698
|
-
throw new Error('--note - and --value-stdin both want stdin; only one can read it.');
|
|
699
|
-
}
|
|
700
|
-
const fromStdin = readStdinSync();
|
|
701
|
-
if (!fromStdin)
|
|
702
|
-
throw new Error('No note received on stdin.');
|
|
703
|
-
patch.note = fromStdin;
|
|
704
|
-
}
|
|
705
|
-
else {
|
|
706
|
-
patch.note = raw.note;
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
return patch;
|
|
710
|
-
}
|
|
711
|
-
/** Whole days from now until midnight-UTC of the given ISO date. Negative if past. */
|
|
712
|
-
function daysUntil(iso) {
|
|
713
|
-
const target = new Date(iso + 'T23:59:59Z').getTime();
|
|
714
|
-
const now = Date.now();
|
|
715
|
-
return Math.floor((target - now) / (24 * 60 * 60 * 1000));
|
|
716
|
-
}
|
|
717
|
-
/** Render the meta line under a var, indented. Returns empty string if nothing to show. */
|
|
718
|
-
function renderMetaLine(meta, reveal) {
|
|
719
|
-
if (!meta)
|
|
720
|
-
return '';
|
|
721
|
-
const parts = [];
|
|
722
|
-
if (meta.type)
|
|
723
|
-
parts.push(`type: ${meta.type}`);
|
|
724
|
-
if (meta.expires) {
|
|
725
|
-
const days = daysUntil(meta.expires);
|
|
726
|
-
const tail = `(in ${days} days)`;
|
|
727
|
-
let colored;
|
|
728
|
-
if (days < 0) {
|
|
729
|
-
colored = chalk.red(`expires: ${meta.expires} ${tail}`);
|
|
730
|
-
}
|
|
731
|
-
else if (days < 30) {
|
|
732
|
-
colored = chalk.yellow(`expires: ${meta.expires} ${tail}`);
|
|
733
|
-
}
|
|
734
|
-
else {
|
|
735
|
-
colored = chalk.gray(`expires: ${meta.expires} ${tail}`);
|
|
736
|
-
}
|
|
737
|
-
parts.push(colored);
|
|
738
|
-
}
|
|
739
|
-
if (meta.note) {
|
|
740
|
-
let note = safePrint(meta.note);
|
|
741
|
-
if (!reveal && note.length > 80) {
|
|
742
|
-
note = note.slice(0, 79) + '\u2026';
|
|
743
|
-
}
|
|
744
|
-
parts.push(`note: ${note}`);
|
|
745
|
-
}
|
|
746
|
-
if (parts.length === 0)
|
|
747
|
-
return '';
|
|
748
|
-
return ` ${parts.join(' ')}`;
|
|
749
|
-
}
|
|
750
|
-
/** Count entries in `meta` whose `expires` falls in the next 30 days. Excludes
|
|
751
|
-
* keys that have ALREADY expired — those are counted by `bundleExpiry().expired`
|
|
752
|
-
* and rendered separately, since a lapsed key is a different problem from one
|
|
753
|
-
* coming due. */
|
|
754
|
-
function countExpiringSoon(meta) {
|
|
755
|
-
if (!meta)
|
|
756
|
-
return 0;
|
|
757
|
-
let n = 0;
|
|
758
|
-
for (const m of Object.values(meta)) {
|
|
759
|
-
if (!m.expires)
|
|
760
|
-
continue;
|
|
761
|
-
const d = daysUntil(m.expires);
|
|
762
|
-
if (d >= 0 && d < 30)
|
|
763
|
-
n++;
|
|
764
|
-
}
|
|
765
|
-
return n;
|
|
766
|
-
}
|
|
767
|
-
/** Human labels for the usage events, in the order `view` prints them. */
|
|
768
|
-
const USAGE_EVENT_LABELS = {
|
|
769
|
-
access: 'accessed',
|
|
770
|
-
unlock: 'unlocked',
|
|
771
|
-
import: 'imported',
|
|
772
|
-
export: 'exported',
|
|
773
|
-
create: 'created',
|
|
774
|
-
view: 'viewed',
|
|
775
|
-
};
|
|
776
|
-
/**
|
|
777
|
-
* Compact one-line usage summary for `secrets view` — each recorded event with
|
|
778
|
-
* its count and how long ago it last happened ("accessed 42× (last 2h ago) ·
|
|
779
|
-
* exported 3× (last 1d ago)"). Empty string when nothing has been recorded yet.
|
|
780
|
-
* Pure — unit-tested.
|
|
781
|
-
*/
|
|
782
|
-
export function formatUsageLine(summary) {
|
|
783
|
-
if (!summary || summary.total === 0)
|
|
784
|
-
return '';
|
|
785
|
-
const parts = [];
|
|
786
|
-
for (const ev of SECRET_USAGE_EVENTS) {
|
|
787
|
-
const stat = summary.events[ev];
|
|
788
|
-
if (stat.count === 0)
|
|
789
|
-
continue;
|
|
790
|
-
const age = stat.last ? relativeAge(stat.last) : null;
|
|
791
|
-
parts.push(`${USAGE_EVENT_LABELS[ev]} ${stat.count}×${age ? ` (last ${age})` : ''}`);
|
|
792
|
-
}
|
|
793
|
-
return parts.join(' · ');
|
|
794
|
-
}
|
|
795
|
-
/**
|
|
796
|
-
* The held-state line for `secrets view`: whether the secrets-agent currently
|
|
797
|
-
* holds this bundle (so reads are prompt-free) and for how long, or that Touch
|
|
798
|
-
* ID is required on the next read. Only meaningful for keychain bundles on macOS
|
|
799
|
-
* — file/vault backends have no broker to hold, and a `never`-policy bundle is
|
|
800
|
-
* always readable — so both return an empty line and the caller skips it.
|
|
801
|
-
* `heldExpiresAt` is the max expiry across every held scope.
|
|
802
|
-
*/
|
|
803
|
-
export function renderViewStatusLine(b, heldExpiresAt) {
|
|
804
|
-
if (process.platform !== 'darwin')
|
|
805
|
-
return '';
|
|
806
|
-
if ((b.backend ?? 'keychain') !== 'keychain')
|
|
807
|
-
return '';
|
|
808
|
-
if (bundlePolicy(b) === 'never')
|
|
809
|
-
return '';
|
|
810
|
-
if (heldExpiresAt && heldExpiresAt > Date.now()) {
|
|
811
|
-
return chalk.green(`status: unlocked (held ${compactRemaining(heldExpiresAt)}; reads are prompt-free until it locks)`);
|
|
812
|
-
}
|
|
813
|
-
return chalk.gray('status: locked (Touch ID required on the next read; `agents secrets unlock` holds it)');
|
|
814
|
-
}
|
|
815
|
-
/**
|
|
816
|
-
* The EXPIRING cell. Counts keys needing attention — already expired plus due
|
|
817
|
-
* within 30 days — and colours by the worst of the two: red once anything has
|
|
818
|
-
* lapsed, yellow while everything is merely upcoming.
|
|
819
|
-
*
|
|
820
|
-
* Expired keys used to be invisible here. `countExpiringSoon` requires
|
|
821
|
-
* `d >= 0`, so a bundle whose token died last month rendered `-`, identical to
|
|
822
|
-
* one with no expiry at all; the only places it surfaced were `secrets view`
|
|
823
|
-
* and a hard abort at inject time, i.e. after it had already broken something.
|
|
824
|
-
*/
|
|
825
|
-
export function renderExpiringCol(b, now = Date.now()) {
|
|
826
|
-
const { expired, soon } = bundleExpiry(b, now);
|
|
827
|
-
const total = expired + soon;
|
|
828
|
-
if (total === 0)
|
|
829
|
-
return chalk.gray('-');
|
|
830
|
-
return expired > 0 ? chalk.red(String(total)) : chalk.yellow(String(total));
|
|
831
|
-
}
|
|
832
|
-
/**
|
|
833
|
-
* Resolve an existing import target bundle (inheriting its backend) or create a
|
|
834
|
-
* new one with the requested backend. Refuses to silently downgrade a
|
|
835
|
-
* keychain-backed bundle to `file` — shared by every `import` source so the
|
|
836
|
-
* guard can't drift between them.
|
|
837
|
-
*
|
|
838
|
-
* `force` additionally recreates a bundle whose METADATA RECORD is present but
|
|
839
|
-
* undecryptable — a file store whose key was lost or rotated out from under it.
|
|
840
|
-
* That is precisely the state provisioning exists to repair (import is how a box
|
|
841
|
-
* gets its bundles back), and without this the import dies on `readBundle` and
|
|
842
|
-
* the only route left is deleting the record by hand on an already-degraded
|
|
843
|
-
* store. It is gated on `--force` on purpose: recreating unconditionally would
|
|
844
|
-
* destroy a perfectly healthy bundle for someone who merely forgot to set
|
|
845
|
-
* `AGENTS_SECRETS_PASSPHRASE`, which is the hazard `readBundleIfDecryptable`
|
|
846
|
-
* exists to describe. `--force` already means "overwrite what is there".
|
|
847
|
-
*/
|
|
848
|
-
export function resolveImportBundle(name, backendOpt, synced = false, force = false) {
|
|
849
|
-
if (isReservedBundleName(name) && (synced || (backendOpt && backendOpt !== 'file'))) {
|
|
850
|
-
throw new ReservedBundleWrongBackendError(name, synced ? 'vault' : resolveBackendOpt(backendOpt));
|
|
851
|
-
}
|
|
852
|
-
const requestedBackend = isReservedBundleName(name)
|
|
853
|
-
? 'file'
|
|
854
|
-
: (synced ? 'vault' : resolveBackendOpt(backendOpt));
|
|
855
|
-
if (bundleExists(name)) {
|
|
856
|
-
// readBundleIfDecryptable nulls ONLY on BundleUndecryptableError; a locked
|
|
857
|
-
// keychain or logged-out vault still throws, so a recoverable state can
|
|
858
|
-
// never be mistaken for a lost key and silently overwritten.
|
|
859
|
-
const bundle = force ? readBundleIfDecryptable(name) : readBundle(name);
|
|
860
|
-
if (bundle) {
|
|
861
|
-
if (requestedBackend !== 'keychain' && bundle.backend !== requestedBackend) {
|
|
862
|
-
throw new Error(`Bundle '${name}' already exists with a different backend; ` +
|
|
863
|
-
`delete it first to recreate it as ${requestedBackend === 'vault' ? 'synced' : `${requestedBackend}-backed`}.`);
|
|
864
|
-
}
|
|
865
|
-
return bundle;
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
return { name, backend: requestedBackend === 'keychain' ? undefined : requestedBackend, vars: {} };
|
|
869
|
-
}
|
|
870
|
-
/**
|
|
871
|
-
* Apply KEY=VALUE entries into a bundle (keychain item or plaintext literal),
|
|
872
|
-
* honoring `--force`, then persist. Returns the added/skipped tally. Shared by
|
|
873
|
-
* the .env, --from-file, and --from-ssh import paths.
|
|
874
|
-
*/
|
|
875
|
-
function applyEnvToBundle(bundle, env, opts) {
|
|
876
|
-
const storedItems = new Map();
|
|
877
|
-
let added = 0;
|
|
878
|
-
let skipped = 0;
|
|
879
|
-
for (const [key, value] of Object.entries(env)) {
|
|
880
|
-
if (!opts.force && key in bundle.vars) {
|
|
881
|
-
skipped++;
|
|
882
|
-
continue;
|
|
883
|
-
}
|
|
884
|
-
if (opts.allPlaintext) {
|
|
885
|
-
bundle.vars[key] = { value };
|
|
886
|
-
}
|
|
887
|
-
else {
|
|
888
|
-
const item = secretsKeychainItem(bundle.name, key);
|
|
889
|
-
storedItems.set(item, value);
|
|
890
|
-
bundle.vars[key] = keychainRef(key);
|
|
891
|
-
}
|
|
892
|
-
added++;
|
|
893
|
-
}
|
|
894
|
-
if (storedItems.size > 0) {
|
|
895
|
-
writeBundleWithItems(bundle, storedItems);
|
|
896
|
-
}
|
|
897
|
-
else {
|
|
898
|
-
writeBundle(bundle);
|
|
899
|
-
}
|
|
900
|
-
if (added > 0) {
|
|
901
|
-
assertFileBundleDecryptable(bundle.name, Object.keys(env).filter((k) => k in bundle.vars));
|
|
902
|
-
}
|
|
903
|
-
return { added, skipped };
|
|
904
|
-
}
|
|
905
|
-
/** Register the `agents secrets` command tree. */
|
|
906
|
-
export function registerSecretsCommands(program) {
|
|
907
|
-
const cmd = program
|
|
908
|
-
.command('secrets')
|
|
909
|
-
.description('Named bundles of env variables backed by macOS Keychain (device-local, biometry-gated). Inject into agents via `agents run --secrets <name>`.');
|
|
910
|
-
setHelpSections(cmd, {
|
|
911
|
-
examples: `
|
|
912
|
-
# Create a bundle
|
|
913
|
-
agents secrets create prod --description "Production keys for the api stack"
|
|
914
|
-
|
|
915
|
-
# Add a keychain-backed secret (prompts for the value)
|
|
916
|
-
agents secrets add prod STRIPE_API_KEY
|
|
917
|
-
|
|
918
|
-
# Add a non-sensitive literal
|
|
919
|
-
agents secrets add prod LOG_LEVEL --value info
|
|
920
|
-
|
|
921
|
-
# Inject the bundle into an agent run
|
|
922
|
-
agents run claude "deploy the worker" --secrets prod
|
|
923
|
-
|
|
924
|
-
# See what's in the bundle (values masked); shows its policy, held state, and usage
|
|
925
|
-
agents secrets view prod
|
|
926
|
-
|
|
927
|
-
# Order the list by how recently / how often each bundle is used
|
|
928
|
-
agents secrets list --sort used
|
|
929
|
-
agents secrets list --sort uses
|
|
930
|
-
|
|
931
|
-
# Stop a noisy automation bundle from prompting every run: ask once a week
|
|
932
|
-
agents secrets policy prod hold
|
|
933
|
-
|
|
934
|
-
# Run a one-off command with secrets injected (values ride the child env, never stdout)
|
|
935
|
-
agents secrets exec prod -- ./deploy.sh
|
|
936
|
-
|
|
937
|
-
# Need one value inside a script? Read it under injection, not with a printer
|
|
938
|
-
agents secrets exec prod -- printenv STRIPE_API_KEY
|
|
939
|
-
|
|
940
|
-
# Deliberately reveal values at your terminal (interactive only)
|
|
941
|
-
agents secrets view prod --reveal
|
|
942
|
-
|
|
943
|
-
# Push the bundle to remote machine(s) over SSH (lands as a native bundle there)
|
|
944
|
-
agents secrets export prod --device yosemite-s0 --device yosemite-s1 --force
|
|
945
|
-
`,
|
|
946
|
-
notes: `
|
|
947
|
-
Bundles are containers; secrets are the variables inside them. Keychain values
|
|
948
|
-
never touch disk in plaintext. Every item is device-local and gated by Touch ID
|
|
949
|
-
or device passcode; cross-machine sync is handled by 'agents secrets push/pull'.
|
|
950
|
-
|
|
951
|
-
Naming: name a bundle after the thing it holds credentials for, so an agent can
|
|
952
|
-
guess it without listing. For a website, use its domain WITH the real suffix —
|
|
953
|
-
'stripe.com', 'openai.ai', 'github.com'. For a desktop app, use the app's binary
|
|
954
|
-
suffix — 'slack.app' (macOS) or 'photoshop.exe' (Windows). Always pass
|
|
955
|
-
'--description' so 'list' / 'view' explain the bundle without opening it; an
|
|
956
|
-
undescribed bundle prints a "No description found" nudge. 'view' also shows
|
|
957
|
-
whether the bundle is currently unlocked (held by the agent) and how often / how
|
|
958
|
-
recently it has been created, imported, exported, viewed, and accessed;
|
|
959
|
-
'list --sort used|uses' orders by recency / frequency, and
|
|
960
|
-
'agents secrets activity [bundle]' prints the recent value-free event timeline.
|
|
961
|
-
|
|
962
|
-
Touch ID noise: macOS pops a prompt per bundle per process. Each bundle has
|
|
963
|
-
a prompt policy, shown in the POLICY column of 'agents secrets list':
|
|
964
|
-
hold (default) ask once, then serve it silently from the local agent
|
|
965
|
-
for the hold window ('secrets.agent.holdMs', 7d by
|
|
966
|
-
default), until sleep / logout or 'lock' (a bare
|
|
967
|
-
screen-lock does NOT drop it). Accepts the older names
|
|
968
|
-
'daily' and 'session'.
|
|
969
|
-
always ask for Touch ID every time — never auto-held.
|
|
970
|
-
never no biometry ACL at all — reads are silent forever, even
|
|
971
|
-
if the agent is down. Automation credentials only.
|
|
972
|
-
The default is 'hold' (one Touch ID per hold window); change it globally with
|
|
973
|
-
'secrets.policy' in agents.yaml, or per bundle with 'agents secrets policy
|
|
974
|
-
<bundle> always'. 'agents secrets unlock <bundle>' holds any bundle after one
|
|
975
|
-
prompt regardless of policy. Nothing on disk.
|
|
976
|
-
|
|
977
|
-
See also:
|
|
978
|
-
agents secrets policy <bundle> hold ask once per hold window (7d by default), not every run
|
|
979
|
-
agents secrets unlock <bundle> hold a bundle after one Touch ID
|
|
980
|
-
agents secrets lock wipe held bundles (re-prompt next read)
|
|
981
|
-
agents secrets status show held bundles + when they lock
|
|
982
|
-
agents secrets rotate <bundle> <key> rotate value, preserve metadata
|
|
983
|
-
agents secrets import <bundle> --from .env bulk import from .env
|
|
984
|
-
agents secrets import <bundle> --from 1password:<vault>
|
|
985
|
-
agents secrets import --from icloud recover legacy iCloud Keychain bundles
|
|
986
|
-
agents secrets generate [length] generate a random password / PIN / hex
|
|
987
|
-
agents secrets migrate-acl upgrade legacy items to the biometry ACL
|
|
988
|
-
`,
|
|
989
|
-
});
|
|
990
|
-
registerCommandGroups(cmd, [
|
|
991
|
-
{ title: 'Bundle commands', names: ['list', 'view', 'activity', 'create', 'rename', 'describe', 'delete'] },
|
|
992
|
-
{ title: 'Secret commands', names: ['add', 'rotate', 'remove', 'import', 'export'] },
|
|
993
|
-
{ title: 'Agent commands', names: ['start', 'stop', 'unlock', 'lock', 'status', 'policy'] },
|
|
994
|
-
{ title: 'Raw item commands', names: ['get', 'set'] },
|
|
995
|
-
{ title: 'Sync commands', names: ['push', 'pull', 'remote-list'] },
|
|
996
|
-
{ title: 'Utilities', names: ['exec', 'mcp', 'generate', 'migrate-acl'] },
|
|
997
|
-
{ title: 'Synced vault', names: ['vault'] },
|
|
998
|
-
]);
|
|
999
|
-
registerSecretsVaultCommands(cmd);
|
|
1000
|
-
const listCmd = cmd
|
|
1001
|
-
.command('list [query]')
|
|
1002
|
-
.alias('ls')
|
|
1003
|
-
.description('List configured secrets bundles, optionally filtered (use --device/--devices for other machines over SSH)')
|
|
1004
|
-
.option('--device <target>', 'List bundles on a remote device over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
|
|
1005
|
-
.option('--devices <list>', 'Comma-separated devices to list in one shot, e.g. yosemite-s0,yosemite-s1')
|
|
1006
|
-
.option('--json', 'Emit machine-readable JSON (bundle metadata only — never secret values) instead of the table')
|
|
1007
|
-
.option('--policy <list>', "Only these prompt policies (comma-separated): hold, always, never. '--policy never' is the audit for bundles that read with no Touch ID at all")
|
|
1008
|
-
.option('--backend <list>', 'Only these backends (comma-separated): keychain, file, vault')
|
|
1009
|
-
.option('--type <list>', `Only bundles carrying a key of these types (comma-separated): ${SECRET_TYPES.join(', ')}`)
|
|
1010
|
-
.option('--kind <list>', `Only bundles carrying a value of these ref kinds (comma-separated): ${REF_KINDS.join(', ')}. '--kind literal' finds raw values stored inline; '--kind exec' finds bundles that shell out`)
|
|
1011
|
-
.option('--held', 'Only bundles the secrets-agent is holding right now (read silently, no Touch ID). macOS only')
|
|
1012
|
-
.option('--not-held', 'Only bundles the agent is NOT holding — the next read of each will prompt. macOS only')
|
|
1013
|
-
.option('--expired', 'Only bundles with at least one key whose expiry has already passed')
|
|
1014
|
-
.option('--expiring [days]', `Only bundles with a key falling due within N days (default ${DEFAULT_EXPIRING_DAYS})`)
|
|
1015
|
-
.option('--unused <duration>', 'Only bundles not read since this far back (e.g. 30d, 4w, 3mo). Never-used bundles always match')
|
|
1016
|
-
.addOption(new Option('--sort <field>', 'Sort by: name (default), used (most recently used), uses (most frequently accessed), created, updated, expiry').choices([...SORT_FIELDS]))
|
|
1017
|
-
.option('-n, --limit <n>', 'Show at most this many bundles (after filtering and sorting)')
|
|
1018
|
-
.action(async (query, opts) => {
|
|
1019
|
-
const targets = parseHostsOption({ device: opts.device, devices: opts.devices });
|
|
1020
|
-
if (targets.length > 0) {
|
|
1021
|
-
// Forward the filters — browseRemote sends this argv verbatim, so a
|
|
1022
|
-
// dropped flag would make the remote silently list everything.
|
|
1023
|
-
await browseRemote(targets, buildRemoteListArgs(opts, query), false);
|
|
1024
|
-
return;
|
|
1025
|
-
}
|
|
1026
|
-
// Parse before touching the keychain so a typo'd flag fails instantly,
|
|
1027
|
-
// and render the message rather than a stack trace (the `policy` pattern).
|
|
1028
|
-
let filter;
|
|
1029
|
-
let sortField;
|
|
1030
|
-
let limit;
|
|
1031
|
-
try {
|
|
1032
|
-
filter = parseListFilters(opts, query);
|
|
1033
|
-
sortField = parseSortField(opts.sort);
|
|
1034
|
-
limit = opts.limit === undefined ? undefined : Number(opts.limit);
|
|
1035
|
-
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
|
|
1036
|
-
throw new Error(`Invalid --limit '${opts.limit}'. Use a whole number of bundles, e.g. --limit 10.`);
|
|
1037
|
-
}
|
|
1038
|
-
// The broker is macOS-only, so off darwin `held` is always empty and a
|
|
1039
|
-
// hold-state filter would answer from no data — every bundle would look
|
|
1040
|
-
// not-held. Refuse rather than return a confidently wrong list.
|
|
1041
|
-
if (filter.held !== undefined && process.platform !== 'darwin') {
|
|
1042
|
-
throw new Error('--held/--not-held need the secrets-agent, which is macOS-only. Run it on a Mac, or with --device <mac>.');
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
catch (err) {
|
|
1046
|
-
console.error(chalk.red(err.message));
|
|
1047
|
-
process.exit(1);
|
|
1048
|
-
}
|
|
1049
|
-
const all = listBundles();
|
|
1050
|
-
// The configured hold window, read once for the whole listing — it is
|
|
1051
|
-
// global, so every `hold` bundle renders the same span.
|
|
1052
|
-
const holdMs = secretsHoldMs();
|
|
1053
|
-
// Cross-reference the secrets-agent so `hold` bundles that are currently
|
|
1054
|
-
// held can show "· held Nh". Soft-fails to no hint if the broker is down.
|
|
1055
|
-
const held = new Map();
|
|
1056
|
-
if (process.platform === 'darwin') {
|
|
1057
|
-
try {
|
|
1058
|
-
for (const e of await agentStatus())
|
|
1059
|
-
held.set(e.name, e.expiresAt);
|
|
1060
|
-
}
|
|
1061
|
-
catch {
|
|
1062
|
-
/* broker not running — render policy without the countdown */
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
// Value-free usage read-model backs `--sort used|uses` and the --json
|
|
1066
|
-
// `uses`/`usage` fields. Open the DB only when a caller actually needs it
|
|
1067
|
-
// (a machine payload, or a usage-ranked sort) so a plain human `list`
|
|
1068
|
-
// stays a pure keychain read.
|
|
1069
|
-
const needUsage = Boolean(opts.json) || sortField === 'used' || sortField === 'uses';
|
|
1070
|
-
const usage = needUsage ? getAllBundleUsage() : new Map();
|
|
1071
|
-
const usageHints = new Map();
|
|
1072
|
-
for (const [name, s] of usage)
|
|
1073
|
-
usageHints.set(name, { lastUsedAt: s.lastUsedAt, uses: s.events.access.count });
|
|
1074
|
-
// Filter, sort, then cap — before the --json branch, so the machine
|
|
1075
|
-
// payload is the exact twin of the table (the sessions ordering rule).
|
|
1076
|
-
const now = Date.now();
|
|
1077
|
-
const matched = all.filter((b) => bundleMatchesFilter(b, filter, { held, now }));
|
|
1078
|
-
const sorted = sortBundles(matched, sortField, usageHints);
|
|
1079
|
-
const bundles = limit === undefined ? sorted : sorted.slice(0, limit);
|
|
1080
|
-
if (opts.json) {
|
|
1081
|
-
// Discovery payload for agents: metadata only, no secret values. Gated on
|
|
1082
|
-
// the explicit --json flag (not stdout.isTTY) so piping the human table to
|
|
1083
|
-
// a pager never silently swaps formats.
|
|
1084
|
-
const payload = bundles.map((b) => ({
|
|
1085
|
-
name: b.name,
|
|
1086
|
-
keys: describeBundle(b).length,
|
|
1087
|
-
policy: bundlePolicy(b),
|
|
1088
|
-
// The window `policy: "hold"` is shorthand for. Null on always/never,
|
|
1089
|
-
// which have no window at all.
|
|
1090
|
-
holdMs: bundlePolicy(b) === 'hold' ? holdMs : null,
|
|
1091
|
-
backend: b.backend === 'file' ? 'file' : 'keychain',
|
|
1092
|
-
allowExec: Boolean(b.allow_exec),
|
|
1093
|
-
expiringSoon: countExpiringSoon(b.meta),
|
|
1094
|
-
// Already-lapsed keys, which `expiringSoon` deliberately excludes. A
|
|
1095
|
-
// machine caller polling for rotation needs both numbers.
|
|
1096
|
-
expired: bundleExpiry(b, now).expired,
|
|
1097
|
-
description: b.description ?? null,
|
|
1098
|
-
createdAt: b.created_at ?? null,
|
|
1099
|
-
updatedAt: b.updated_at ?? null,
|
|
1100
|
-
lastUsed: b.last_used ?? null,
|
|
1101
|
-
// Same liveness rule the POLICY column and --held use: a broker entry
|
|
1102
|
-
// past its expiry is not held. Without the check this field reported a
|
|
1103
|
-
// stale past timestamp as if the bundle were still warm, disagreeing
|
|
1104
|
-
// with the table and the filter about the very same bundle.
|
|
1105
|
-
heldExpiresAt: liveHold(held.get(b.name), now),
|
|
1106
|
-
// Value-free usage from the read-model: `uses` is the recorded access
|
|
1107
|
-
// (read/inject) count; `usage` is the full per-kind rollup. Null when
|
|
1108
|
-
// nothing has been recorded yet.
|
|
1109
|
-
uses: usage.get(b.name)?.events.access.count ?? 0,
|
|
1110
|
-
usage: usage.get(b.name) ?? null,
|
|
1111
|
-
}));
|
|
1112
|
-
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
1113
|
-
return;
|
|
1114
|
-
}
|
|
1115
|
-
if (bundles.length === 0) {
|
|
1116
|
-
// Distinguish "you have none" from "your filter excluded all of them",
|
|
1117
|
-
// and name the axes that did it — otherwise the only way to find out
|
|
1118
|
-
// which flag emptied the list is to remove them one at a time.
|
|
1119
|
-
if (filterIsActive(filter)) {
|
|
1120
|
-
console.log(chalk.gray(`No bundles ${describeFilter(filter)}. ${all.length} bundle${all.length === 1 ? '' : 's'} total.`));
|
|
1121
|
-
console.log(chalk.gray('Try: agents secrets list (no filters)'));
|
|
1122
|
-
}
|
|
1123
|
-
else {
|
|
1124
|
-
console.log(chalk.gray('No secrets bundles configured.'));
|
|
1125
|
-
console.log(chalk.gray('Try: agents secrets create <name>'));
|
|
1126
|
-
}
|
|
1127
|
-
return;
|
|
1128
|
-
}
|
|
1129
|
-
const cols = terminalWidth();
|
|
1130
|
-
if (cols >= SECRETS_WIDE) {
|
|
1131
|
-
console.log(chalk.bold(`${'NAME'.padEnd(20)} ${'KEYS'.padEnd(5)} ${'POLICY'.padEnd(POLICY_COL_WIDTH)} ${'EXPIRING'.padEnd(9)} ${'CREATED'.padEnd(9)} ${'UPDATED'.padEnd(9)} ${'USED'.padEnd(7)} DESCRIPTION`));
|
|
1132
|
-
for (const b of bundles) {
|
|
1133
|
-
console.log(renderBundleRow(b, holdMs, held, cols));
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
else {
|
|
1137
|
-
for (const b of bundles) {
|
|
1138
|
-
console.log(renderBundleCard(b, holdMs, held, cols));
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
});
|
|
1142
|
-
setHelpSections(listCmd, {
|
|
1143
|
-
examples: `
|
|
1144
|
-
# Everything, newest-used first
|
|
1145
|
-
agents secrets list --sort used
|
|
1146
|
-
|
|
1147
|
-
# Order by how often each bundle is read (most-accessed first)
|
|
1148
|
-
agents secrets list --sort uses
|
|
1149
|
-
|
|
1150
|
-
# Find one by name or description
|
|
1151
|
-
agents secrets list github
|
|
1152
|
-
|
|
1153
|
-
# The security audit: which bundles read with NO Touch ID at all?
|
|
1154
|
-
agents secrets list --policy never
|
|
1155
|
-
|
|
1156
|
-
# Which still store a raw value inline, or can shell out?
|
|
1157
|
-
agents secrets list --kind literal
|
|
1158
|
-
agents secrets list --kind exec
|
|
1159
|
-
|
|
1160
|
-
# What has already lapsed, and what is about to
|
|
1161
|
-
agents secrets list --expired
|
|
1162
|
-
agents secrets list --expiring 7 --sort expiry
|
|
1163
|
-
|
|
1164
|
-
# What can I delete? Untouched in three months
|
|
1165
|
-
agents secrets list --unused 3mo --sort used
|
|
1166
|
-
|
|
1167
|
-
# Which bundles will prompt me on the next read (macOS)
|
|
1168
|
-
agents secrets list --not-held
|
|
1169
|
-
|
|
1170
|
-
# Combine axes — every filter narrows further
|
|
1171
|
-
agents secrets list --policy hold --backend file --expiring
|
|
1172
|
-
|
|
1173
|
-
# Same filters, on another machine, machine-readable
|
|
1174
|
-
agents secrets list --expired --device mac-mini --json
|
|
1175
|
-
`,
|
|
1176
|
-
notes: `
|
|
1177
|
-
- Filters compose: every flag you add narrows the list further.
|
|
1178
|
-
- An unknown value is an error, not an empty list — '--policy hodl' names the valid set.
|
|
1179
|
-
- --held/--not-held read live broker state, so they need macOS. Use --device <mac> from elsewhere.
|
|
1180
|
-
- --expired and --expiring are different questions: already lapsed vs coming due. The EXPIRING column counts both and turns red once anything has lapsed.
|
|
1181
|
-
- --unused matches bundles never read at all, not just old ones.
|
|
1182
|
-
- Filters apply before --json, so the JSON is the exact twin of the table.
|
|
1183
|
-
- Filters are forwarded over --device, so a remote list narrows the same way.
|
|
1184
|
-
- --sort used|uses read the value-free usage read-model (~/.agents/secrets/secrets.db); see 'agents secrets activity'.
|
|
1185
|
-
`,
|
|
1186
|
-
});
|
|
1187
|
-
const activityCmd = cmd
|
|
1188
|
-
.command('activity [name]')
|
|
1189
|
-
.description('Show the recent value-free usage timeline (created / imported / exported / viewed / accessed / unlocked) for one bundle, or across all bundles')
|
|
1190
|
-
.option('-n, --limit <n>', 'Show at most this many events (default 20)')
|
|
1191
|
-
.option('--json', 'Emit machine-readable JSON (metadata only — never secret values)')
|
|
1192
|
-
.action((name, opts) => {
|
|
1193
|
-
let limit = opts.limit === undefined ? 20 : Number(opts.limit);
|
|
1194
|
-
if (!Number.isInteger(limit) || limit < 1) {
|
|
1195
|
-
console.error(chalk.red(`Invalid --limit '${opts.limit}'. Use a whole number of events, e.g. --limit 50.`));
|
|
1196
|
-
process.exit(1);
|
|
1197
|
-
}
|
|
1198
|
-
const events = getUsageHistory(name, limit);
|
|
1199
|
-
if (opts.json) {
|
|
1200
|
-
process.stdout.write(JSON.stringify(events) + '\n');
|
|
1201
|
-
return;
|
|
1202
|
-
}
|
|
1203
|
-
if (events.length === 0) {
|
|
1204
|
-
console.log(chalk.gray(name ? `No recorded activity for '${name}'.` : 'No recorded secrets activity yet.'));
|
|
1205
|
-
return;
|
|
1206
|
-
}
|
|
1207
|
-
for (const e of events) {
|
|
1208
|
-
const who = e.agent ? chalk.gray(` by ${e.agent}`) : '';
|
|
1209
|
-
const where = e.source ? chalk.gray(` (${e.source}${e.host ? ` ${e.host}` : ''})`) : '';
|
|
1210
|
-
const bundleTag = name ? '' : chalk.cyan(` ${e.bundle}`);
|
|
1211
|
-
console.log(`${chalk.gray(relativeAge(e.ts).padEnd(10))} ${chalk.bold(e.event.padEnd(8))}${bundleTag}${who}${where}`);
|
|
1212
|
-
}
|
|
1213
|
-
});
|
|
1214
|
-
setHelpSections(activityCmd, {
|
|
1215
|
-
examples: `
|
|
1216
|
-
# The recent value-free event timeline across every bundle
|
|
1217
|
-
agents secrets activity
|
|
1218
|
-
|
|
1219
|
-
# Just one bundle, more history
|
|
1220
|
-
agents secrets activity anthropic.com --limit 50
|
|
1221
|
-
|
|
1222
|
-
# Machine-readable
|
|
1223
|
-
agents secrets activity anthropic.com --json
|
|
1224
|
-
`,
|
|
1225
|
-
notes: `
|
|
1226
|
-
- Reads the value-free usage read-model (~/.agents/secrets/secrets.db), fed off the
|
|
1227
|
-
same emitSecretAudit chokepoint as 'agents events'. Never a secret value.
|
|
1228
|
-
- History is bounded to the last 90 days; the full audit trail is 'agents events --module secrets'.
|
|
1229
|
-
`,
|
|
1230
|
-
});
|
|
1231
|
-
cmd
|
|
1232
|
-
.command('view [name]')
|
|
1233
|
-
.alias('show')
|
|
1234
|
-
.description('Show a bundle. Keychain values are masked by default — pass --reveal to see them.')
|
|
1235
|
-
.option('--reveal', 'Print keychain-backed values in the clear (interactive terminal only)')
|
|
1236
|
-
.option('--json', 'Emit machine-readable JSON (values masked unless --reveal) instead of the human view')
|
|
1237
|
-
.option('--device <target>', 'Show a bundle on a remote device over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
|
|
1238
|
-
.option('--devices <list>', 'Comma-separated devices to show in one shot, e.g. yosemite-s0,yosemite-s1')
|
|
1239
|
-
.action(async (name, opts) => {
|
|
1240
|
-
try {
|
|
1241
|
-
// `--reveal` is a deliberate HUMAN reveal: interactive terminal only, and
|
|
1242
|
-
// never inside an agent session (an agent under tmux has a TTY, so the
|
|
1243
|
-
// env markers are the load-bearing check). The old `--plaintext` escape
|
|
1244
|
-
// that allowed a piped/non-TTY reveal is removed (RUSH-2774) — printed
|
|
1245
|
-
// values land in an agent's context and session transcript.
|
|
1246
|
-
if (opts.reveal && (isAgentInvocationContext() || !isInteractiveTerminal())) {
|
|
1247
|
-
console.error(chalk.red('--reveal prints values in the clear and needs a real interactive terminal outside an agent session.'));
|
|
1248
|
-
console.error(chalk.dim('Run the consuming command under injection instead: agents secrets exec <bundle> -- <cmd>'));
|
|
1249
|
-
process.exit(1);
|
|
1250
|
-
}
|
|
1251
|
-
const targets = parseHostsOption({ device: opts.device, devices: opts.devices });
|
|
1252
|
-
if (targets.length > 0) {
|
|
1253
|
-
if (!name) {
|
|
1254
|
-
console.error(chalk.red('A bundle name is required when viewing a remote host (interactive pick needs a local terminal).'));
|
|
1255
|
-
process.exit(1);
|
|
1256
|
-
}
|
|
1257
|
-
const args = ['view', name];
|
|
1258
|
-
if (opts.reveal)
|
|
1259
|
-
args.push('--reveal');
|
|
1260
|
-
if (opts.json)
|
|
1261
|
-
args.push('--json');
|
|
1262
|
-
// With --reveal, force a TTY so the remote keychain prompt can surface
|
|
1263
|
-
// and the remote's own interactive-terminal gate is satisfied. The
|
|
1264
|
-
// early gate above already proved this side is an interactive,
|
|
1265
|
-
// non-agent terminal.
|
|
1266
|
-
const tty = Boolean(opts.reveal);
|
|
1267
|
-
// `--reveal` streams the plaintext value back over ssh stdout — a
|
|
1268
|
-
// secret-bearing read that must pin the host key and not multiplex,
|
|
1269
|
-
// whether it runs on the interactive (tty) or `--plaintext` non-tty path.
|
|
1270
|
-
await browseRemote(targets, args, tty, Boolean(opts.reveal));
|
|
1271
|
-
return;
|
|
1272
|
-
}
|
|
1273
|
-
const resolvedName = name ?? (await pickBundleName('view'));
|
|
1274
|
-
let bundle;
|
|
1275
|
-
try {
|
|
1276
|
-
bundle = readBundle(resolvedName);
|
|
1277
|
-
}
|
|
1278
|
-
catch (err) {
|
|
1279
|
-
console.error(chalk.red(err.message));
|
|
1280
|
-
maybePrintSyncedHint(resolvedName, bundleExists(resolvedName));
|
|
1281
|
-
process.exit(1);
|
|
1282
|
-
}
|
|
1283
|
-
const entries = describeBundle(bundle);
|
|
1284
|
-
// Value-free usage read-model (per-kind counts + recency + per-agent) for
|
|
1285
|
-
// both the human and --json views. Read before recording this view so the
|
|
1286
|
-
// just-happened view isn't counted in its own summary.
|
|
1287
|
-
const usage = getBundleUsage(bundle.name);
|
|
1288
|
-
// Is the secrets-agent currently holding this bundle? A held bundle reads
|
|
1289
|
-
// prompt-free. macOS keychain only; take the max expiry across every scope
|
|
1290
|
-
// it's held under. Soft-fails to "not held" if the broker is down.
|
|
1291
|
-
let heldExpiresAt = null;
|
|
1292
|
-
if (process.platform === 'darwin' && (bundle.backend ?? 'keychain') === 'keychain') {
|
|
1293
|
-
try {
|
|
1294
|
-
for (const e of await agentStatus()) {
|
|
1295
|
-
if (e.name === bundle.name && (heldExpiresAt === null || e.expiresAt > heldExpiresAt)) {
|
|
1296
|
-
heldExpiresAt = e.expiresAt;
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
catch {
|
|
1301
|
-
/* broker not running — render as not held */
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
// Record the inspection through the ONE chokepoint (events.jsonl + the
|
|
1305
|
-
// usage read-model). Value-free; a masked `view` is metadata only.
|
|
1306
|
-
emitSecretAudit({ event: 'secrets.view', bundle: bundle.name, operation: opts.json ? 'view --json' : 'view', source: 'view', status: 'success' });
|
|
1307
|
-
if (opts.json) {
|
|
1308
|
-
// Machine-readable discovery for agents. Values are null unless --reveal
|
|
1309
|
-
// (already gated above: interactive terminal, outside an agent session).
|
|
1310
|
-
// Gated on the explicit flag, not stdout.isTTY, so a piped human view
|
|
1311
|
-
// never silently swaps formats.
|
|
1312
|
-
const reveal = Boolean(opts.reveal);
|
|
1313
|
-
const revealed = new Map();
|
|
1314
|
-
if (reveal) {
|
|
1315
|
-
const { env } = readAndResolveBundleEnv(bundle.name, {
|
|
1316
|
-
caller: 'view --reveal --json',
|
|
1317
|
-
keyMode: 'storage',
|
|
1318
|
-
// An explicit human `--reveal` at a terminal is a deliberate value
|
|
1319
|
-
// access → resolve interactively (one Touch ID). Under an agent
|
|
1320
|
-
// (AGENTS_RUNTIME) or headless (no TTY) it stays broker-only.
|
|
1321
|
-
agentOnly: isHeadlessSecretsContext() || !isInteractiveTerminal(),
|
|
1322
|
-
});
|
|
1323
|
-
for (const entry of entries) {
|
|
1324
|
-
if (entry.kind === 'keychain' && env[entry.key] !== undefined) {
|
|
1325
|
-
revealed.set(secretsKeychainItem(bundle.name, entry.detail), env[entry.key]);
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
const exposed = revealed.size + entries.filter((e) => e.kind === 'literal').length;
|
|
1329
|
-
if (exposed > 0) {
|
|
1330
|
-
emitSecretAudit({
|
|
1331
|
-
event: 'secrets.get',
|
|
1332
|
-
bundle: bundle.name,
|
|
1333
|
-
operation: 'view --reveal --json',
|
|
1334
|
-
source: 'reveal',
|
|
1335
|
-
status: 'success',
|
|
1336
|
-
keyCount: exposed,
|
|
1337
|
-
});
|
|
1338
|
-
}
|
|
1339
|
-
}
|
|
1340
|
-
const keys = entries.map((e) => {
|
|
1341
|
-
const row = {
|
|
1342
|
-
key: e.key,
|
|
1343
|
-
kind: e.kind,
|
|
1344
|
-
detail: e.detail || null,
|
|
1345
|
-
value: null,
|
|
1346
|
-
};
|
|
1347
|
-
if (e.kind === 'keychain') {
|
|
1348
|
-
const item = secretsKeychainItem(bundle.name, e.detail);
|
|
1349
|
-
row.stored = hasKeychainToken(item);
|
|
1350
|
-
if (reveal && revealed.has(item))
|
|
1351
|
-
row.value = revealed.get(item);
|
|
1352
|
-
}
|
|
1353
|
-
else if (e.kind === 'literal' && reveal) {
|
|
1354
|
-
const raw = bundle.vars[e.key];
|
|
1355
|
-
row.value =
|
|
1356
|
-
typeof raw === 'string'
|
|
1357
|
-
? raw
|
|
1358
|
-
: (raw && typeof raw === 'object' && 'value' in raw ? raw.value : '');
|
|
1359
|
-
}
|
|
1360
|
-
return row;
|
|
1361
|
-
});
|
|
1362
|
-
process.stdout.write(JSON.stringify({
|
|
1363
|
-
name: bundle.name,
|
|
1364
|
-
description: bundle.description ?? null,
|
|
1365
|
-
policy: bundlePolicy(bundle),
|
|
1366
|
-
// The window `policy: "hold"` is shorthand for. Null on
|
|
1367
|
-
// always/never, which have no window at all.
|
|
1368
|
-
holdMs: bundlePolicy(bundle) === 'hold' ? secretsHoldMs() : null,
|
|
1369
|
-
backend: bundle.backend === 'file' ? 'file' : 'keychain',
|
|
1370
|
-
allowExec: Boolean(bundle.allow_exec),
|
|
1371
|
-
createdAt: bundle.created_at ?? null,
|
|
1372
|
-
updatedAt: bundle.updated_at ?? null,
|
|
1373
|
-
lastUsed: bundle.last_used ?? null,
|
|
1374
|
-
// Held state + value-free usage rollup, same data the human view
|
|
1375
|
-
// shows. `heldExpiresAt` is null when the bundle is not held.
|
|
1376
|
-
heldExpiresAt,
|
|
1377
|
-
usage: usage ?? null,
|
|
1378
|
-
uses: usage?.events.access.count ?? 0,
|
|
1379
|
-
revealed: reveal,
|
|
1380
|
-
keys,
|
|
1381
|
-
}) + '\n');
|
|
1382
|
-
return;
|
|
1383
|
-
}
|
|
1384
|
-
console.log(chalk.bold(bundle.name));
|
|
1385
|
-
if (bundle.description) {
|
|
1386
|
-
console.log(chalk.gray(safePrint(bundle.description)));
|
|
1387
|
-
}
|
|
1388
|
-
else {
|
|
1389
|
-
// A described bundle is self-documenting for the next agent/human that
|
|
1390
|
-
// reads it. Nudge — never block — toward adding one.
|
|
1391
|
-
console.log(chalk.yellow(`No description found. Add one: agents secrets describe ${bundle.name} "what this bundle is for"`));
|
|
1392
|
-
}
|
|
1393
|
-
if (bundle.allow_exec)
|
|
1394
|
-
console.log(chalk.yellow('allow_exec: true'));
|
|
1395
|
-
if (bundle.backend === 'file')
|
|
1396
|
-
console.log(chalk.gray('backend: file (encrypted at rest; headless reads via a machine-local key, or AGENTS_SECRETS_PASSPHRASE if set — no Touch ID)'));
|
|
1397
|
-
if (bundle.backend === 'vault')
|
|
1398
|
-
console.log(chalk.gray('storage: synced (age-encrypted ~/.agents/vault.age; needs `agents secrets vault unlock`)'));
|
|
1399
|
-
if (bundlePolicy(bundle) === 'never') {
|
|
1400
|
-
console.log(chalk.red.bold('policy: never — NO biometry ACL; reads are silent (no Touch ID, no user-presence check). Automation-only.'));
|
|
1401
|
-
}
|
|
1402
|
-
else {
|
|
1403
|
-
console.log(bundlePolicy(bundle) === 'hold'
|
|
1404
|
-
// The window comes from secretsHoldMs(), never a literal — this
|
|
1405
|
-
// line used to hardcode "7d by default" and so misstated the
|
|
1406
|
-
// window for anyone who had configured secrets.agent.holdMs.
|
|
1407
|
-
? chalk.gray(`policy: hold (ask once, then held for ${formatHoldWindow(secretsHoldMs())} — until sleep / logout; screen-lock does not drop it)`)
|
|
1408
|
-
: chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
|
|
1409
|
-
}
|
|
1410
|
-
const statusLine = renderViewStatusLine(bundle, heldExpiresAt);
|
|
1411
|
-
if (statusLine)
|
|
1412
|
-
console.log(statusLine);
|
|
1413
|
-
if (bundle.created_at)
|
|
1414
|
-
console.log(chalk.gray(`created_at: ${bundle.created_at} (${humanAge(bundle.created_at)})`));
|
|
1415
|
-
if (bundle.updated_at)
|
|
1416
|
-
console.log(chalk.gray(`updated_at: ${bundle.updated_at} (${humanAge(bundle.updated_at)})`));
|
|
1417
|
-
if (bundle.last_used)
|
|
1418
|
-
console.log(chalk.gray(`last_used: ${bundle.last_used} (${humanAge(bundle.last_used)})`));
|
|
1419
|
-
const usageLine = formatUsageLine(usage);
|
|
1420
|
-
if (usageLine)
|
|
1421
|
-
console.log(chalk.gray(`usage: ${usageLine}`));
|
|
1422
|
-
if (usage && usage.byAgent.length > 0) {
|
|
1423
|
-
const top = usage.byAgent.slice(0, 3).map((a) => `${a.agent} ${a.count}×`).join(', ');
|
|
1424
|
-
console.log(chalk.gray(`by: ${top}`));
|
|
1425
|
-
}
|
|
1426
|
-
console.log();
|
|
1427
|
-
if (entries.length === 0) {
|
|
1428
|
-
console.log(chalk.gray('(no keys)'));
|
|
1429
|
-
return;
|
|
1430
|
-
}
|
|
1431
|
-
const reveal = Boolean(opts.reveal);
|
|
1432
|
-
// An explicit human `view --reveal` at a terminal is a deliberate value
|
|
1433
|
-
// access: resolve interactively (one Touch ID) so a locked bundle shows
|
|
1434
|
-
// its values. Under an agent (AGENTS_RUNTIME) or headless (no TTY) it
|
|
1435
|
-
// stays broker-only and errors with the unlock hint — never a sheet.
|
|
1436
|
-
const revealedValues = new Map();
|
|
1437
|
-
if (reveal) {
|
|
1438
|
-
const { env } = readAndResolveBundleEnv(bundle.name, {
|
|
1439
|
-
caller: 'view --reveal',
|
|
1440
|
-
keyMode: 'storage',
|
|
1441
|
-
agentOnly: isHeadlessSecretsContext() || !isInteractiveTerminal(),
|
|
1442
|
-
});
|
|
1443
|
-
for (const entry of entries) {
|
|
1444
|
-
if (entry.kind === 'keychain' && env[entry.key] !== undefined) {
|
|
1445
|
-
revealedValues.set(secretsKeychainItem(bundle.name, entry.detail), env[entry.key]);
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
// Revealing plaintext bypasses readAndResolveBundleEnv (the usual
|
|
1449
|
-
// audit chokepoint), so emit here — a `--reveal` exposes real values
|
|
1450
|
-
// and must show up in `agents events --module secrets`. Count both the
|
|
1451
|
-
// keychain values actually decrypted AND the inline literals (which
|
|
1452
|
-
// `--reveal` always prints, even for a literal-only bundle with no
|
|
1453
|
-
// keychain refs — see the entries loop below). Values are never
|
|
1454
|
-
// included, only how many keys were exposed. Emit only when something
|
|
1455
|
-
// was actually shown (a cancelled Touch ID + no literals reveals none).
|
|
1456
|
-
const literalCount = entries.filter((e) => e.kind === 'literal').length;
|
|
1457
|
-
const exposedCount = revealedValues.size + literalCount;
|
|
1458
|
-
if (exposedCount > 0) {
|
|
1459
|
-
emitSecretAudit({
|
|
1460
|
-
event: 'secrets.get',
|
|
1461
|
-
bundle: bundle.name,
|
|
1462
|
-
operation: 'view --reveal',
|
|
1463
|
-
source: 'reveal',
|
|
1464
|
-
status: 'success',
|
|
1465
|
-
keyCount: exposedCount,
|
|
1466
|
-
});
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
for (const e of entries) {
|
|
1470
|
-
if (e.kind === 'keychain') {
|
|
1471
|
-
const item = secretsKeychainItem(bundle.name, e.detail);
|
|
1472
|
-
const stored = bundleItemStore(bundle.backend).has(item);
|
|
1473
|
-
const marker = stored ? chalk.green('stored') : chalk.red('missing');
|
|
1474
|
-
let valueCol = `[keychain:${e.detail}] ${marker}`;
|
|
1475
|
-
if (reveal && revealedValues.has(item)) {
|
|
1476
|
-
valueCol = redact(revealedValues.get(item), true);
|
|
1477
|
-
}
|
|
1478
|
-
console.log(` ${chalk.cyan(e.key.padEnd(28))} ${kindLabel(e.kind).padEnd(18)} ${valueCol}`);
|
|
1479
|
-
}
|
|
1480
|
-
else if (e.kind === 'literal') {
|
|
1481
|
-
const raw = bundle.vars[e.key];
|
|
1482
|
-
const literalValue = typeof raw === 'string'
|
|
1483
|
-
? raw
|
|
1484
|
-
: (raw && typeof raw === 'object' && 'value' in raw ? raw.value : '');
|
|
1485
|
-
console.log(` ${chalk.cyan(e.key.padEnd(28))} ${kindLabel(e.kind).padEnd(18)} ${redact(literalValue, reveal)}`);
|
|
1486
|
-
}
|
|
1487
|
-
else {
|
|
1488
|
-
console.log(` ${chalk.cyan(e.key.padEnd(28))} ${kindLabel(e.kind).padEnd(18)} ${e.detail}`);
|
|
1489
|
-
}
|
|
1490
|
-
const metaLine = renderMetaLine(bundle.meta?.[e.key], reveal);
|
|
1491
|
-
if (metaLine)
|
|
1492
|
-
console.log(metaLine);
|
|
1493
|
-
}
|
|
1494
|
-
}
|
|
1495
|
-
catch (err) {
|
|
1496
|
-
if (isPromptCancelled(err))
|
|
1497
|
-
return;
|
|
1498
|
-
console.error(chalk.red(err.message));
|
|
1499
|
-
process.exit(1);
|
|
1500
|
-
}
|
|
1501
|
-
});
|
|
1502
|
-
cmd
|
|
1503
|
-
.command('get <item> [key]')
|
|
1504
|
-
.description('Print one raw keychain item by name, for shell hooks/automation. Cross-platform. Bundle values are never printed — run commands under `agents secrets exec` instead.')
|
|
1505
|
-
.action((item, key) => {
|
|
1506
|
-
if (key !== undefined) {
|
|
1507
|
-
// The bundle-key form (`get <bundle> <KEY>`) is REMOVED (RUSH-2774): a
|
|
1508
|
-
// one-liner that prints a bundle credential is the exfiltration path
|
|
1509
|
-
// this change closes, for every caller. The injection replacement is
|
|
1510
|
-
// just as short for a human script and never touches this stdout.
|
|
1511
|
-
console.error(chalk.red(`'secrets get <bundle> <KEY>' has been removed — printing a bundle value to stdout is the exfiltration path this blocks.`));
|
|
1512
|
-
console.error(chalk.dim(`Use: agents secrets exec ${item} -- printenv ${key} (or run the consuming command directly under exec)`));
|
|
1513
|
-
process.exit(1);
|
|
1514
|
-
}
|
|
1515
|
-
// Raw keychain item path — a single ad-hoc token, not a credential bundle.
|
|
1516
|
-
// Deliberately NOT agent-gated (unlike the removed bundle-key form): shell
|
|
1517
|
-
// hooks that run inside agent sessions inherit the session env markers and
|
|
1518
|
-
// legitimately capture a raw item into their own variables (e.g. the
|
|
1519
|
-
// posthog analytics hook), where the value never reaches the transcript.
|
|
1520
|
-
// A single ad-hoc token is the accepted narrower residual (RUSH-2774).
|
|
1521
|
-
try {
|
|
1522
|
-
// Routes through the platform keychain layer: macOS reads bare items
|
|
1523
|
-
// via /usr/bin/security (no Touch ID), Linux via secret-tool with the
|
|
1524
|
-
// encrypted-file fallback. The value goes to stdout (newline-terminated
|
|
1525
|
-
// so `$(agents secrets get NAME)` captures it cleanly); diagnostics go
|
|
1526
|
-
// to stderr so they never pollute the captured value.
|
|
1527
|
-
const value = getKeychainToken(item);
|
|
1528
|
-
// Raw item reads bypass readAndResolveBundleEnv, so audit here too.
|
|
1529
|
-
// `item` is the keychain service name, never the value.
|
|
1530
|
-
emitSecretAudit({ event: 'secrets.get', item, source: 'raw-item', status: 'success' });
|
|
1531
|
-
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
|
|
1532
|
-
}
|
|
1533
|
-
catch {
|
|
1534
|
-
// Missing item is a normal, quiet outcome for a hook probe: exit 1,
|
|
1535
|
-
// print nothing to stdout. Callers test the exit code / empty capture.
|
|
1536
|
-
process.exit(1);
|
|
1537
|
-
}
|
|
1538
|
-
});
|
|
1539
|
-
cmd
|
|
1540
|
-
.command('set <item>')
|
|
1541
|
-
.description('Store a raw keychain item by name (for shell hooks/automation). Cross-platform; no bundle required.')
|
|
1542
|
-
.option('--value <v>', 'Value to store (omit to read from stdin or be prompted)')
|
|
1543
|
-
.option('--value-stdin', 'Read the value from stdin')
|
|
1544
|
-
.action(async (item, opts) => {
|
|
1545
|
-
try {
|
|
1546
|
-
let value;
|
|
1547
|
-
if (opts.value !== undefined) {
|
|
1548
|
-
value = opts.value;
|
|
1549
|
-
}
|
|
1550
|
-
else if (opts.valueStdin) {
|
|
1551
|
-
value = readStdinSync();
|
|
1552
|
-
if (!value)
|
|
1553
|
-
throw new Error('No value received on stdin.');
|
|
1554
|
-
}
|
|
1555
|
-
else {
|
|
1556
|
-
value = await promptForSecret(`Enter value for ${item}`);
|
|
1557
|
-
}
|
|
1558
|
-
// setKeychainToken stores bare items WITHOUT the biometry ACL on macOS
|
|
1559
|
-
// so `agents secrets get` can read them back without a password sheet;
|
|
1560
|
-
// on Linux it goes through secret-tool / encrypted-file fallback.
|
|
1561
|
-
setKeychainToken(item, value);
|
|
1562
|
-
// Raw item writes bypass writeBundle (the usual secrets.set chokepoint).
|
|
1563
|
-
emit('secrets.set', { module: 'secrets', item, source: 'raw-item' });
|
|
1564
|
-
console.error(chalk.green(`Stored keychain item '${item}'.`));
|
|
1565
|
-
}
|
|
1566
|
-
catch (err) {
|
|
1567
|
-
if (isPromptCancelled(err))
|
|
1568
|
-
return;
|
|
1569
|
-
console.error(chalk.red(err.message));
|
|
1570
|
-
process.exit(1);
|
|
1571
|
-
}
|
|
1572
|
-
});
|
|
1573
|
-
cmd
|
|
1574
|
-
.command('create [name]')
|
|
1575
|
-
.description('Create an empty bundle. Name it after what it holds — a website by domain (stripe.com, openai.ai), a desktop app by its binary suffix (slack.app, photoshop.exe) — and pass --description.')
|
|
1576
|
-
.option('--description <text>', 'Free-form description (recommended — an undescribed bundle prints a "No description found" nudge)')
|
|
1577
|
-
.option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
|
|
1578
|
-
.option('--policy <policy>', "prompt policy: hold (default, ask once per hold window — secrets.agent.holdMs, 7d by default), always (ask every time), or never (silent, NO biometry ACL — needs --i-understand). 'daily'/'session' are accepted aliases for 'hold'.")
|
|
1579
|
-
.addOption(new Option('--tier <policy>', 'deprecated alias for --policy').hideHelp())
|
|
1580
|
-
.option('--i-understand', 'Confirm creating a "never"-policy bundle (no biometry ACL) without an interactive prompt')
|
|
1581
|
-
.option('--backend <backend>', 'storage backend: keychain or file (defaults to agents.yaml secrets.backend)')
|
|
1582
|
-
.option('--synced', 'Store this bundle in the age-encrypted synced secrets file for user-managed cross-machine file sync')
|
|
1583
|
-
.option('--force', 'Overwrite an existing bundle')
|
|
1584
|
-
.action(async (name, opts) => {
|
|
1585
|
-
try {
|
|
1586
|
-
const resolvedName = name ?? (await promptBundleName());
|
|
1587
|
-
validateBundleName(resolvedName);
|
|
1588
|
-
// Leave policy unset unless the user explicitly chose one, so the bundle
|
|
1589
|
-
// inherits the configured default (`hold`) instead of being pinned.
|
|
1590
|
-
const policyOpt = opts.policy ?? opts.tier;
|
|
1591
|
-
const policy = policyOpt ? parsePolicyOpt(policyOpt) : undefined;
|
|
1592
|
-
let backend = opts.synced ? 'vault' : resolveBackendOpt(opts.backend);
|
|
1593
|
-
if (isReservedBundleName(resolvedName)) {
|
|
1594
|
-
// Reserved `auth` is file-backed by construction. Omitting --backend
|
|
1595
|
-
// still forces file even when agents.yaml defaults to keychain; an
|
|
1596
|
-
// explicit non-file backend fails loud (SEC-GAP-3).
|
|
1597
|
-
if (opts.synced || (opts.backend && opts.backend !== 'file')) {
|
|
1598
|
-
throw new ReservedBundleWrongBackendError(resolvedName, backend);
|
|
1599
|
-
}
|
|
1600
|
-
backend = 'file';
|
|
1601
|
-
}
|
|
1602
|
-
if (bundleExists(resolvedName) && !opts.force) {
|
|
1603
|
-
console.error(chalk.red(`Bundle '${resolvedName}' already exists. Use --force to overwrite.`));
|
|
1604
|
-
process.exit(1);
|
|
1605
|
-
}
|
|
1606
|
-
// The `never` tier is the least-safe option — gate it loudly. Throws in a
|
|
1607
|
-
// headless shell without --i-understand; prompts otherwise.
|
|
1608
|
-
const ack = assertNeverPolicyAcknowledged(policy, { iUnderstand: opts.iUnderstand, interactive: isInteractiveTerminal() });
|
|
1609
|
-
if (ack === 'prompt' && !(await confirmNeverPolicyInteractive(resolvedName))) {
|
|
1610
|
-
console.error(chalk.yellow('Aborted.'));
|
|
1611
|
-
return;
|
|
1612
|
-
}
|
|
1613
|
-
const bundle = {
|
|
1614
|
-
name: resolvedName,
|
|
1615
|
-
description: opts.description,
|
|
1616
|
-
allow_exec: opts.allowExec,
|
|
1617
|
-
backend: backend === 'keychain' ? undefined : backend,
|
|
1618
|
-
policy,
|
|
1619
|
-
vars: {},
|
|
1620
|
-
};
|
|
1621
|
-
writeBundle(bundle);
|
|
1622
|
-
// Record the create through the ONE chokepoint (events.jsonl + the usage
|
|
1623
|
-
// read-model). Value-free — bundle name only.
|
|
1624
|
-
emitSecretAudit({ event: 'secrets.create', bundle: resolvedName, operation: 'create', source: 'create', status: 'success' });
|
|
1625
|
-
const policyTag = bundlePolicy(bundle) === 'hold'
|
|
1626
|
-
? 'policy: hold'
|
|
1627
|
-
: bundlePolicy(bundle) === 'always'
|
|
1628
|
-
? 'policy: always ask'
|
|
1629
|
-
: 'policy: never (NO biometry ACL)';
|
|
1630
|
-
const tags = [
|
|
1631
|
-
policyTag,
|
|
1632
|
-
backend === 'file' ? 'backend: file' : null,
|
|
1633
|
-
backend === 'vault' ? 'synced' : null,
|
|
1634
|
-
].filter(Boolean);
|
|
1635
|
-
console.log(chalk.green(`Bundle '${resolvedName}' created (${tags.join(', ')}).`));
|
|
1636
|
-
if (!bundle.description) {
|
|
1637
|
-
// A described bundle is self-documenting for the next agent that reads
|
|
1638
|
-
// `list` / `view`. Nudge toward one at create time — never blocking.
|
|
1639
|
-
console.log(chalk.yellow(`No description found. Add one: agents secrets describe ${resolvedName} "what this bundle is for"`));
|
|
1640
|
-
}
|
|
1641
|
-
if (bundlePolicy(bundle) === 'never') {
|
|
1642
|
-
console.log(chalk.red('Stored without biometry protection — reads are silent. Automation-only; rotate anything sensitive out of it.'));
|
|
1643
|
-
}
|
|
1644
|
-
if (backend === 'file') {
|
|
1645
|
-
console.log(chalk.gray('File-backed: items are AES-256-GCM encrypted at rest under a machine-local key (or AGENTS_SECRETS_PASSPHRASE if set); headless reads, no Touch ID.'));
|
|
1646
|
-
}
|
|
1647
|
-
if (backend === 'vault') {
|
|
1648
|
-
console.log(chalk.gray('Synced: items are encrypted in ~/.agents/vault.age. Copy that file with your sync tool of choice.'));
|
|
1649
|
-
}
|
|
1650
|
-
console.log(chalk.gray(`Try: agents secrets add ${resolvedName} MY_KEY`));
|
|
1651
|
-
}
|
|
1652
|
-
catch (err) {
|
|
1653
|
-
if (isPromptCancelled(err))
|
|
1654
|
-
return;
|
|
1655
|
-
console.error(chalk.red(err.message));
|
|
1656
|
-
process.exit(1);
|
|
1657
|
-
}
|
|
1658
|
-
});
|
|
1659
|
-
cmd
|
|
1660
|
-
.command('rename <old> <new>')
|
|
1661
|
-
.alias('mv')
|
|
1662
|
-
.description('Rename a bundle. Moves the metadata and every keychain-backed value to the new name.')
|
|
1663
|
-
.option('--force', 'Overwrite the destination bundle if it already exists (purges its keychain items first)')
|
|
1664
|
-
.action((oldName, newName, opts) => {
|
|
1665
|
-
try {
|
|
1666
|
-
renameBundle(oldName, newName, { force: opts.force });
|
|
1667
|
-
console.log(chalk.green(`Bundle '${oldName}' renamed to '${newName}'.`));
|
|
1668
|
-
}
|
|
1669
|
-
catch (err) {
|
|
1670
|
-
console.error(chalk.red(err.message));
|
|
1671
|
-
process.exit(1);
|
|
1672
|
-
}
|
|
1673
|
-
});
|
|
1674
|
-
cmd
|
|
1675
|
-
.command('describe <name> [text...]')
|
|
1676
|
-
.description('Update the description of a bundle. Pass --clear to remove it.')
|
|
1677
|
-
.option('--clear', 'Remove the existing description')
|
|
1678
|
-
.action((name, textParts, opts) => {
|
|
1679
|
-
try {
|
|
1680
|
-
const bundle = readBundle(name);
|
|
1681
|
-
const text = textParts.join(' ').trim();
|
|
1682
|
-
if (opts.clear) {
|
|
1683
|
-
if (text) {
|
|
1684
|
-
console.error(chalk.red('Pass either description text or --clear, not both.'));
|
|
1685
|
-
process.exit(1);
|
|
1686
|
-
}
|
|
1687
|
-
bundle.description = undefined;
|
|
1688
|
-
}
|
|
1689
|
-
else {
|
|
1690
|
-
if (!text) {
|
|
1691
|
-
console.error(chalk.red('Description text is required. Pass it as an argument or use --clear.'));
|
|
1692
|
-
process.exit(1);
|
|
1693
|
-
}
|
|
1694
|
-
bundle.description = text;
|
|
1695
|
-
}
|
|
1696
|
-
writeBundle(bundle);
|
|
1697
|
-
console.log(chalk.green(opts.clear
|
|
1698
|
-
? `Bundle '${name}' description cleared.`
|
|
1699
|
-
: `Bundle '${name}' description updated.`));
|
|
1700
|
-
}
|
|
1701
|
-
catch (err) {
|
|
1702
|
-
console.error(chalk.red(err.message));
|
|
1703
|
-
process.exit(1);
|
|
1704
|
-
}
|
|
1705
|
-
});
|
|
1706
|
-
cmd
|
|
1707
|
-
.command('add [bundle] [key]')
|
|
1708
|
-
.description('Add a variable to a bundle. Defaults to keychain-backed; pass --value for literal, --env/--file/--exec for refs.')
|
|
1709
|
-
.option('--value <v>', 'Store as a plaintext literal in the bundle (non-sensitive values only)')
|
|
1710
|
-
.option('--value-stdin', 'Read the value from stdin (stored in keychain unless combined with --value)')
|
|
1711
|
-
.option('--env <VAR>', 'Store as an env: ref that reads from the parent process.env at run time')
|
|
1712
|
-
.option('--file <path>', 'Store as a file: ref that reads from a file at run time')
|
|
1713
|
-
.option('--exec <cmd>', 'Store as an exec: ref that runs a command at run time (requires allow_exec)')
|
|
1714
|
-
.option('--type <kind>', 'Tag this secret with a type (api-key, token, password, url, database-url, ssh-key, certificate, webhook, note)')
|
|
1715
|
-
.option('--expires <YYYY-MM-DD>', 'Mark when this secret expires (must be future-dated)')
|
|
1716
|
-
.option('--note <text>', 'Attach a freeform note. Pass `-` to read from stdin (mutually exclusive with --value-stdin).')
|
|
1717
|
-
.action(async (bundleName, key, opts) => {
|
|
1718
|
-
try {
|
|
1719
|
-
const resolvedBundleName = bundleName ?? (await pickBundleName('add to'));
|
|
1720
|
-
const bundle = readBundle(resolvedBundleName);
|
|
1721
|
-
const resolvedKey = key ?? (await promptKeyName(resolvedBundleName));
|
|
1722
|
-
validateEnvKey(resolvedKey);
|
|
1723
|
-
if (resolvedKey in bundle.vars) {
|
|
1724
|
-
throw new Error(`Key '${resolvedKey}' already exists in bundle '${resolvedBundleName}'. Use 'agents secrets rotate' to refresh it.`);
|
|
1725
|
-
}
|
|
1726
|
-
const sources = [opts.value !== undefined, Boolean(opts.env), Boolean(opts.file), Boolean(opts.exec)].filter(Boolean).length;
|
|
1727
|
-
if (sources > 1) {
|
|
1728
|
-
throw new Error('Pick one of: --value, --env, --file, --exec.');
|
|
1729
|
-
}
|
|
1730
|
-
const metaPatch = buildMetaPatch(opts);
|
|
1731
|
-
const applyMeta = () => {
|
|
1732
|
-
if (!metaPatch)
|
|
1733
|
-
return;
|
|
1734
|
-
if (!bundle.meta)
|
|
1735
|
-
bundle.meta = {};
|
|
1736
|
-
bundle.meta[resolvedKey] = { ...(bundle.meta[resolvedKey] ?? {}), ...metaPatch };
|
|
1737
|
-
};
|
|
1738
|
-
if (opts.env) {
|
|
1739
|
-
bundle.vars[resolvedKey] = `env:${opts.env}`;
|
|
1740
|
-
applyMeta();
|
|
1741
|
-
writeBundle(bundle);
|
|
1742
|
-
console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} -> env:${opts.env}`));
|
|
1743
|
-
return;
|
|
1744
|
-
}
|
|
1745
|
-
if (opts.file) {
|
|
1746
|
-
bundle.vars[resolvedKey] = `file:${opts.file}`;
|
|
1747
|
-
applyMeta();
|
|
1748
|
-
writeBundle(bundle);
|
|
1749
|
-
console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} -> file:${opts.file}`));
|
|
1750
|
-
return;
|
|
1751
|
-
}
|
|
1752
|
-
if (opts.exec) {
|
|
1753
|
-
if (!bundle.allow_exec) {
|
|
1754
|
-
throw new Error(`Bundle '${resolvedBundleName}' does not allow exec refs. Re-create with --allow-exec.`);
|
|
1755
|
-
}
|
|
1756
|
-
bundle.vars[resolvedKey] = `exec:${opts.exec}`;
|
|
1757
|
-
applyMeta();
|
|
1758
|
-
writeBundle(bundle);
|
|
1759
|
-
console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} -> exec:${opts.exec}`));
|
|
1760
|
-
return;
|
|
1761
|
-
}
|
|
1762
|
-
if (opts.value !== undefined) {
|
|
1763
|
-
bundle.vars[resolvedKey] = { value: opts.value };
|
|
1764
|
-
applyMeta();
|
|
1765
|
-
writeBundle(bundle);
|
|
1766
|
-
console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} = <literal>`));
|
|
1767
|
-
return;
|
|
1768
|
-
}
|
|
1769
|
-
// Default path: stored in the bundle's backend (keychain or file).
|
|
1770
|
-
let secretValue;
|
|
1771
|
-
if (opts.valueStdin) {
|
|
1772
|
-
secretValue = readStdinSync();
|
|
1773
|
-
if (!secretValue)
|
|
1774
|
-
throw new Error('No value received on stdin.');
|
|
1775
|
-
}
|
|
1776
|
-
else {
|
|
1777
|
-
secretValue = await promptForSecret(`Enter value for ${resolvedBundleName}.${resolvedKey}`);
|
|
1778
|
-
}
|
|
1779
|
-
const item = secretsKeychainItem(resolvedBundleName, resolvedKey);
|
|
1780
|
-
bundle.vars[resolvedKey] = keychainRef(resolvedKey);
|
|
1781
|
-
applyMeta();
|
|
1782
|
-
writeBundleWithItems(bundle, new Map([[item, secretValue]]));
|
|
1783
|
-
const where = bundle.backend === 'file'
|
|
1784
|
-
? 'encrypted file store'
|
|
1785
|
-
: bundle.backend === 'vault'
|
|
1786
|
-
? 'synced secrets file'
|
|
1787
|
-
: 'keychain';
|
|
1788
|
-
console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} stored in ${where} (${item}).`));
|
|
1789
|
-
}
|
|
1790
|
-
catch (err) {
|
|
1791
|
-
if (isPromptCancelled(err))
|
|
1792
|
-
return;
|
|
1793
|
-
console.error(chalk.red(err.message));
|
|
1794
|
-
process.exit(1);
|
|
1795
|
-
}
|
|
1796
|
-
});
|
|
1797
|
-
cmd
|
|
1798
|
-
.command('rotate [bundle] [key]')
|
|
1799
|
-
.description('Rotate an existing keychain-backed secret (replaces the value, preserves metadata unless overridden).')
|
|
1800
|
-
.option('--value <v>', 'New value (non-secret cases). Prompts interactively if omitted.')
|
|
1801
|
-
.option('--value-stdin', 'Read the new value from stdin (stored in keychain unless combined with --value)')
|
|
1802
|
-
.option('--type <kind>', 'Update the type metadata (api-key, token, password, url, database-url, ssh-key, certificate, webhook, note)')
|
|
1803
|
-
.option('--expires <YYYY-MM-DD>', 'Update the expiration date (must be future-dated)')
|
|
1804
|
-
.option('--note <text>', 'Update the note. Pass `-` to read from stdin (mutually exclusive with --value-stdin).')
|
|
1805
|
-
.option('--clear-meta', 'Wipe all metadata for this key while rotating')
|
|
1806
|
-
.addHelpText('after', `
|
|
1807
|
-
Examples:
|
|
1808
|
-
# Rotate the value, preserve all metadata
|
|
1809
|
-
agents secrets rotate prod STRIPE_API_KEY
|
|
1810
|
-
|
|
1811
|
-
# Rotate with a metadata refresh
|
|
1812
|
-
agents secrets rotate prod STRIPE_API_KEY --type api-key --expires 2027-01-15 --note "rotated after employee offboarding"
|
|
1813
|
-
`)
|
|
1814
|
-
.action(async (bundleName, key, opts) => {
|
|
1815
|
-
try {
|
|
1816
|
-
const resolvedBundleName = bundleName ?? (await pickBundleName('rotate in'));
|
|
1817
|
-
const bundle = readBundle(resolvedBundleName);
|
|
1818
|
-
const resolvedKey = key ?? (await pickKey(bundle, 'rotate'));
|
|
1819
|
-
if (!(resolvedKey in bundle.vars)) {
|
|
1820
|
-
throw new Error(`Key '${resolvedKey}' not in bundle '${resolvedBundleName}'. Use 'agents secrets add' to add a new key.`);
|
|
1821
|
-
}
|
|
1822
|
-
const raw = bundle.vars[resolvedKey];
|
|
1823
|
-
if (typeof raw !== 'string' || !raw.startsWith('keychain:')) {
|
|
1824
|
-
throw new Error(`Key '${resolvedKey}' in bundle '${resolvedBundleName}' is not keychain-backed; cannot rotate.`);
|
|
1825
|
-
}
|
|
1826
|
-
const metaPatch = buildMetaPatch(opts);
|
|
1827
|
-
if (opts.clearMeta && metaPatch) {
|
|
1828
|
-
throw new Error('--clear-meta and --type/--expires/--note are mutually exclusive.');
|
|
1829
|
-
}
|
|
1830
|
-
// Resolve the new value: --value > --value-stdin > prompt.
|
|
1831
|
-
let newValue;
|
|
1832
|
-
if (opts.value !== undefined) {
|
|
1833
|
-
newValue = opts.value;
|
|
1834
|
-
}
|
|
1835
|
-
else if (opts.valueStdin) {
|
|
1836
|
-
newValue = readStdinSync();
|
|
1837
|
-
if (!newValue)
|
|
1838
|
-
throw new Error('No value received on stdin.');
|
|
1839
|
-
}
|
|
1840
|
-
else {
|
|
1841
|
-
newValue = await promptForSecret(`Enter new value for ${resolvedBundleName}.${resolvedKey}`);
|
|
1842
|
-
}
|
|
1843
|
-
rotateBundleSecret(bundle, resolvedKey, {
|
|
1844
|
-
newValue,
|
|
1845
|
-
clearMeta: opts.clearMeta,
|
|
1846
|
-
meta: metaPatch,
|
|
1847
|
-
});
|
|
1848
|
-
const where = bundle.backend === 'vault' ? 'synced secrets file' : bundle.backend === 'file' ? 'encrypted file store' : 'keychain';
|
|
1849
|
-
console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} rotated in ${where}.`));
|
|
1850
|
-
}
|
|
1851
|
-
catch (err) {
|
|
1852
|
-
if (isPromptCancelled(err))
|
|
1853
|
-
return;
|
|
1854
|
-
console.error(chalk.red(err.message));
|
|
1855
|
-
process.exit(1);
|
|
1856
|
-
}
|
|
1857
|
-
});
|
|
1858
|
-
cmd
|
|
1859
|
-
.command('remove [bundle] [key]')
|
|
1860
|
-
.description('Remove a key from the bundle. Purges the keychain item if the ref was keychain:. Use --keep-secret to retain it.')
|
|
1861
|
-
.option('--keep-secret', 'Leave the keychain item in place after removing the ref from the bundle')
|
|
1862
|
-
.option('-y, --yes', 'Skip the confirmation prompt when purging a keychain item')
|
|
1863
|
-
.action(async (bundleName, key, opts) => {
|
|
1864
|
-
try {
|
|
1865
|
-
const resolvedBundleName = bundleName ?? (await pickBundleName('remove from'));
|
|
1866
|
-
const bundle = readBundle(resolvedBundleName);
|
|
1867
|
-
const resolvedKey = key ?? (await pickKey(bundle, 'remove'));
|
|
1868
|
-
if (!(resolvedKey in bundle.vars)) {
|
|
1869
|
-
console.error(chalk.red(`Key '${resolvedKey}' not found in bundle '${resolvedBundleName}'.`));
|
|
1870
|
-
process.exit(1);
|
|
1871
|
-
}
|
|
1872
|
-
const raw = bundle.vars[resolvedKey];
|
|
1873
|
-
const willPurge = !opts.keepSecret && typeof raw === 'string' && raw.startsWith('keychain:');
|
|
1874
|
-
if (willPurge && !opts.yes) {
|
|
1875
|
-
if (!isInteractiveTerminal()) {
|
|
1876
|
-
console.error(chalk.red(`Refusing to purge keychain item for ${resolvedBundleName}.${resolvedKey} non-interactively. ` +
|
|
1877
|
-
`Pass --yes to confirm or --keep-secret to retain the keychain entry.`));
|
|
1878
|
-
process.exit(1);
|
|
1879
|
-
}
|
|
1880
|
-
const { confirm } = await import('@inquirer/prompts');
|
|
1881
|
-
const ok = await confirm({
|
|
1882
|
-
message: `Purge keychain item for ${resolvedBundleName}.${resolvedKey}? (use --keep-secret to retain)`,
|
|
1883
|
-
default: false,
|
|
1884
|
-
});
|
|
1885
|
-
if (!ok) {
|
|
1886
|
-
console.log(chalk.gray('Aborted. Bundle metadata unchanged.'));
|
|
1887
|
-
return;
|
|
1888
|
-
}
|
|
1889
|
-
}
|
|
1890
|
-
delete bundle.vars[resolvedKey];
|
|
1891
|
-
writeBundle(bundle);
|
|
1892
|
-
if (willPurge) {
|
|
1893
|
-
const shortId = raw.slice('keychain:'.length);
|
|
1894
|
-
// Purge only the removed key's own storage slot. A corrupted ref
|
|
1895
|
-
// (e.g. a stale manifest write-back leaving OPENAI_API_KEY pointing
|
|
1896
|
-
// at keychain:LINEAR_API_KEY) must never destroy another key's item —
|
|
1897
|
-
// the ref is gone from the bundle either way; the stray item is the
|
|
1898
|
-
// recoverable side of that trade.
|
|
1899
|
-
if (shortId !== resolvedKey) {
|
|
1900
|
-
console.log(chalk.yellow(`Removed ${resolvedBundleName}.${resolvedKey}, but its ref pointed at '${shortId}' — ` +
|
|
1901
|
-
`left that keychain item untouched (it belongs to a different key).`));
|
|
1902
|
-
return;
|
|
1903
|
-
}
|
|
1904
|
-
const item = secretsKeychainItem(resolvedBundleName, shortId);
|
|
1905
|
-
const removed = bundleItemStore(bundle.backend).delete(item);
|
|
1906
|
-
if (removed) {
|
|
1907
|
-
const where = bundle.backend === 'file'
|
|
1908
|
-
? 'encrypted file item'
|
|
1909
|
-
: bundle.backend === 'vault'
|
|
1910
|
-
? 'synced secrets item'
|
|
1911
|
-
: 'keychain item';
|
|
1912
|
-
console.log(chalk.green(`Removed ${resolvedBundleName}.${resolvedKey} and purged ${where}.`));
|
|
1913
|
-
return;
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
console.log(chalk.green(`Removed ${resolvedBundleName}.${resolvedKey}.`));
|
|
1917
|
-
}
|
|
1918
|
-
catch (err) {
|
|
1919
|
-
if (isPromptCancelled(err))
|
|
1920
|
-
return;
|
|
1921
|
-
console.error(chalk.red(err.message));
|
|
1922
|
-
process.exit(1);
|
|
1923
|
-
}
|
|
1924
|
-
});
|
|
1925
|
-
cmd
|
|
1926
|
-
.command('delete [name]')
|
|
1927
|
-
.description('Delete a bundle and purge all its keychain items (use --keep-secrets to retain them).')
|
|
1928
|
-
.option('--keep-secrets', 'Leave keychain items in place after deleting the bundle')
|
|
1929
|
-
.option('-y, --yes', 'Skip the confirmation prompt')
|
|
1930
|
-
.action(async (name, opts) => {
|
|
1931
|
-
try {
|
|
1932
|
-
const resolvedName = name ?? (await pickBundleName('delete'));
|
|
1933
|
-
// An undecryptable bundle (lost/rotated passphrase) must still be
|
|
1934
|
-
// deletable: deletion is the ONLY way out of that state, and every other
|
|
1935
|
-
// verb — view, add, import — refuses to touch the name until it's gone.
|
|
1936
|
-
// Its key refs are unreadable, so its keychain items cannot be
|
|
1937
|
-
// enumerated for purging; say so rather than claiming a clean purge.
|
|
1938
|
-
const bundle = readBundleIfDecryptable(resolvedName);
|
|
1939
|
-
if (!opts.yes) {
|
|
1940
|
-
if (!isInteractiveTerminal()) {
|
|
1941
|
-
console.error(chalk.red(`Refusing to delete '${resolvedName}' without --yes in a non-interactive shell.`));
|
|
1942
|
-
process.exit(1);
|
|
1943
|
-
}
|
|
1944
|
-
let suffix;
|
|
1945
|
-
if (!bundle) {
|
|
1946
|
-
suffix = ' (unreadable — its keychain items cannot be enumerated and will be left in place)';
|
|
1947
|
-
}
|
|
1948
|
-
else {
|
|
1949
|
-
const keychainCount = describeBundle(bundle).filter((e) => e.kind === 'keychain').length;
|
|
1950
|
-
suffix = keychainCount && !opts.keepSecrets
|
|
1951
|
-
? ` and purge ${keychainCount} keychain item${keychainCount === 1 ? '' : 's'}`
|
|
1952
|
-
: '';
|
|
1953
|
-
}
|
|
1954
|
-
const { confirm } = await import('@inquirer/prompts');
|
|
1955
|
-
const proceed = await confirm({
|
|
1956
|
-
message: `Delete bundle '${resolvedName}'${suffix}?`,
|
|
1957
|
-
default: false,
|
|
1958
|
-
});
|
|
1959
|
-
if (!proceed) {
|
|
1960
|
-
console.log(chalk.gray('Cancelled.'));
|
|
1961
|
-
return;
|
|
1962
|
-
}
|
|
1963
|
-
}
|
|
1964
|
-
if (bundle && !opts.keepSecrets) {
|
|
1965
|
-
const store = bundleItemStore(bundle.backend);
|
|
1966
|
-
for (const { item } of keychainItemsForBundle(bundle)) {
|
|
1967
|
-
store.delete(item);
|
|
1968
|
-
}
|
|
1969
|
-
}
|
|
1970
|
-
const existed = deleteBundle(resolvedName);
|
|
1971
|
-
if (!existed) {
|
|
1972
|
-
console.error(chalk.red(`Bundle '${resolvedName}' not found.`));
|
|
1973
|
-
process.exit(1);
|
|
1974
|
-
}
|
|
1975
|
-
console.log(chalk.green(`Bundle '${resolvedName}' deleted.`));
|
|
1976
|
-
}
|
|
1977
|
-
catch (err) {
|
|
1978
|
-
if (isPromptCancelled(err))
|
|
1979
|
-
return;
|
|
1980
|
-
console.error(chalk.red(err.message));
|
|
1981
|
-
process.exit(1);
|
|
1982
|
-
}
|
|
1983
|
-
});
|
|
1984
|
-
cmd
|
|
1985
|
-
.command('migrate')
|
|
1986
|
-
.description('Interactively migrate legacy YAML bundles into Keychain')
|
|
1987
|
-
.action(async () => {
|
|
1988
|
-
try {
|
|
1989
|
-
if (!isInteractiveTerminal()) {
|
|
1990
|
-
console.error(chalk.red('Refusing to migrate legacy secrets without an interactive confirmation prompt.'));
|
|
1991
|
-
process.exit(1);
|
|
1992
|
-
}
|
|
1993
|
-
const { confirm } = await import('@inquirer/prompts');
|
|
1994
|
-
const migrated = await migrateLegacyBundles(async (candidate) => {
|
|
1995
|
-
console.log(chalk.bold(`Legacy bundle '${candidate.name}'`));
|
|
1996
|
-
console.log(chalk.gray(candidate.file));
|
|
1997
|
-
for (const key of candidate.keys) {
|
|
1998
|
-
console.log(` ${key}`);
|
|
1999
|
-
}
|
|
2000
|
-
return await confirm({
|
|
2001
|
-
message: `Migrate legacy bundle '${candidate.name}' into Keychain?`,
|
|
2002
|
-
default: false,
|
|
2003
|
-
});
|
|
2004
|
-
});
|
|
2005
|
-
if (migrated === 0) {
|
|
2006
|
-
console.log(chalk.gray('No legacy bundles migrated.'));
|
|
2007
|
-
return;
|
|
2008
|
-
}
|
|
2009
|
-
console.log(chalk.green(`Migrated ${migrated} legacy bundle${migrated === 1 ? '' : 's'} into keychain.`));
|
|
2010
|
-
}
|
|
2011
|
-
catch (err) {
|
|
2012
|
-
if (isPromptCancelled(err))
|
|
2013
|
-
return;
|
|
2014
|
-
console.error(chalk.red(err.message));
|
|
2015
|
-
process.exit(1);
|
|
2016
|
-
}
|
|
2017
|
-
});
|
|
2018
|
-
cmd
|
|
2019
|
-
.command('import [bundle]')
|
|
2020
|
-
.description('Import keys into a bundle from a .env file, a 1Password vault, or legacy iCloud Keychain bundles. The bundle is created if it does not exist. Values are stored in the bundle\'s backend (keychain by default).')
|
|
2021
|
-
.option('--from <source>', "Source: a .env path (- reads stdin), '1password:<vault>', or 'icloud' (legacy iCloud Keychain bundles)")
|
|
2022
|
-
.addOption(new Option('--from-1password', 'deprecated alias for --from 1password:<vault>').hideHelp())
|
|
2023
|
-
.addOption(new Option('--vault <name>', 'deprecated: name the vault in --from 1password:<vault>').hideHelp())
|
|
2024
|
-
.option('--all-plaintext', 'Store every imported value as a literal in the bundle metadata (skip keychain item creation)')
|
|
2025
|
-
.option('--backend <backend>', 'When creating the bundle: keychain or file (defaults to agents.yaml secrets.backend)')
|
|
2026
|
-
.option('--synced', 'When creating the bundle, store it in the age-encrypted synced secrets file')
|
|
2027
|
-
.option('--force', 'Overwrite an existing key in the bundle')
|
|
2028
|
-
.option('--policy <policy>', 'Set the bundle prompt policy as part of this import (always, hold, or never)')
|
|
2029
|
-
.option('--i-understand', 'Confirm an import with policy never (no biometry ACL)')
|
|
2030
|
-
.option('--purge', 'With --from icloud: delete the iCloud copies after a successful import (iCloud propagates the deletion to your other devices)')
|
|
2031
|
-
.option('--from-file <path>', `Import from an AES-256-GCM encrypted offline bundle file (needs ${SYNC_PASSPHRASE_ENV}; symmetric counterpart of export --to-file)`)
|
|
2032
|
-
.option('--from-ssh', 'Pull the bundle from a fleet peer over SSH and import it locally (requires --device)')
|
|
2033
|
-
.option('--device <peer>', 'SSH peer to pull from when using --from-ssh (device alias or user@host)')
|
|
2034
|
-
.action(async (bundleName, opts) => {
|
|
2035
|
-
try {
|
|
2036
|
-
const importPolicy = opts.policy ? parsePolicyOpt(opts.policy) : undefined;
|
|
2037
|
-
const policyAck = assertNeverPolicyAcknowledged(importPolicy, {
|
|
2038
|
-
iUnderstand: opts.iUnderstand,
|
|
2039
|
-
interactive: isInteractiveTerminal(),
|
|
2040
|
-
});
|
|
2041
|
-
if (policyAck === 'prompt' && !(await confirmNeverPolicyInteractive(bundleName ?? 'imported bundle'))) {
|
|
2042
|
-
console.error(chalk.yellow('Aborted.'));
|
|
2043
|
-
return;
|
|
2044
|
-
}
|
|
2045
|
-
const applyImportPolicy = (bundle) => {
|
|
2046
|
-
if (importPolicy)
|
|
2047
|
-
bundle.policy = importPolicy;
|
|
2048
|
-
return bundle;
|
|
2049
|
-
};
|
|
2050
|
-
// A single import can name only one source. --from-file / --from-ssh are
|
|
2051
|
-
// early-return paths, so guard them against each other and the --from /
|
|
2052
|
-
// --from-1password pair (which parseImportSource guards on its own).
|
|
2053
|
-
const namedFileOrSsh = [
|
|
2054
|
-
opts.fromFile ? '--from-file' : null,
|
|
2055
|
-
opts.fromSsh ? '--from-ssh' : null,
|
|
2056
|
-
].filter(Boolean);
|
|
2057
|
-
if (namedFileOrSsh.length > 1 || (namedFileOrSsh.length > 0 && (opts.from || opts.from1password))) {
|
|
2058
|
-
throw new Error('--from-file, --from-ssh, and --from/--from-1password are mutually exclusive; pick one import source.');
|
|
2059
|
-
}
|
|
2060
|
-
if (opts.fromFile) {
|
|
2061
|
-
// Transport, not the local store's master key — see lib/secrets/sync-passphrase.ts.
|
|
2062
|
-
const passphrase = resolveSyncPassphraseFromEnv().value ?? '';
|
|
2063
|
-
if (!passphrase) {
|
|
2064
|
-
throw new Error(`--from-file needs ${SYNC_PASSPHRASE_ENV} set to decrypt the bundle file.`);
|
|
2065
|
-
}
|
|
2066
|
-
const env = importBundleFromFile(opts.fromFile, passphrase);
|
|
2067
|
-
const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
|
|
2068
|
-
const bundle = applyImportPolicy(resolveImportBundle(resolvedBundleName, opts.backend, opts.synced, opts.force));
|
|
2069
|
-
const { added, skipped } = applyEnvToBundle(bundle, env, opts);
|
|
2070
|
-
emitSecretAudit({ event: 'secrets.import', bundle: bundle.name, operation: 'import --from-file', source: 'file', status: 'success', keyCount: added });
|
|
2071
|
-
console.log(chalk.green(`Imported ${added} key(s) from file${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
|
|
2072
|
-
return;
|
|
2073
|
-
}
|
|
2074
|
-
if (opts.fromSsh) {
|
|
2075
|
-
if (!opts.device) {
|
|
2076
|
-
throw new Error('--from-ssh requires --device <peer>.');
|
|
2077
|
-
}
|
|
2078
|
-
assertValidSshTarget(opts.device);
|
|
2079
|
-
const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
|
|
2080
|
-
const target = await resolveHostSshTarget(opts.device);
|
|
2081
|
-
const env = await remoteResolveEnv(target, resolvedBundleName, { osLookupName: opts.device });
|
|
2082
|
-
const bundle = applyImportPolicy(resolveImportBundle(resolvedBundleName, opts.backend, opts.synced, opts.force));
|
|
2083
|
-
const { added, skipped } = applyEnvToBundle(bundle, env, opts);
|
|
2084
|
-
emitSecretAudit({ event: 'secrets.import', bundle: bundle.name, operation: 'import --from-ssh', source: 'ssh', host: opts.device, status: 'success', keyCount: added });
|
|
2085
|
-
console.log(chalk.green(`Imported ${added} key(s) from ${opts.device}${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
|
|
2086
|
-
return;
|
|
2087
|
-
}
|
|
2088
|
-
const source = parseImportSource(opts);
|
|
2089
|
-
if (opts.purge && source.kind !== 'icloud') {
|
|
2090
|
-
throw new Error('--purge only applies to --from icloud.');
|
|
2091
|
-
}
|
|
2092
|
-
if (opts.from1password) {
|
|
2093
|
-
console.log(chalk.yellow('--from-1password is deprecated; use --from 1password:<vault>.'));
|
|
2094
|
-
}
|
|
2095
|
-
const requestedBackend = opts.synced ? 'vault' : resolveBackendOpt(opts.backend);
|
|
2096
|
-
if (source.kind === 'icloud') {
|
|
2097
|
-
await importFromICloud(bundleName, {
|
|
2098
|
-
force: opts.force,
|
|
2099
|
-
allPlaintext: opts.allPlaintext,
|
|
2100
|
-
backend: requestedBackend === 'keychain' ? undefined : requestedBackend,
|
|
2101
|
-
purge: opts.purge,
|
|
2102
|
-
});
|
|
2103
|
-
return;
|
|
2104
|
-
}
|
|
2105
|
-
const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
|
|
2106
|
-
// resolveImportBundle inherits an existing bundle's backend (and refuses
|
|
2107
|
-
// to downgrade keychain -> file) or creates it with the requested backend
|
|
2108
|
-
// so a single `import --backend file` works (what `export --device ...
|
|
2109
|
-
// --remote-backend file` drives on the remote).
|
|
2110
|
-
const bundle = applyImportPolicy(resolveImportBundle(resolvedBundleName, opts.backend, opts.synced, opts.force));
|
|
2111
|
-
if (source.kind === '1password') {
|
|
2112
|
-
assertOpAvailable();
|
|
2113
|
-
const vault = await resolveVault(source.vault);
|
|
2114
|
-
const items = listItems(vault);
|
|
2115
|
-
const { secrets, skipped: opSkipped } = extractSecrets(items, vault);
|
|
2116
|
-
const env = {};
|
|
2117
|
-
for (const { envKey, value } of secrets)
|
|
2118
|
-
env[envKey] = value;
|
|
2119
|
-
const { added, skipped } = applyEnvToBundle(bundle, env, opts);
|
|
2120
|
-
emitSecretAudit({ event: 'secrets.import', bundle: bundle.name, operation: `import --from 1password:${vault}`, source: '1password', status: 'success', keyCount: added });
|
|
2121
|
-
if (opSkipped.length) {
|
|
2122
|
-
console.log(chalk.yellow(`Skipped ${opSkipped.length} item(s) with no importable fields.`));
|
|
2123
|
-
}
|
|
2124
|
-
console.log(chalk.green(`Imported ${added} key(s) from 1Password vault '${vault}'${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
|
|
2125
|
-
}
|
|
2126
|
-
else {
|
|
2127
|
-
const raw = readImportDotenv(source.path);
|
|
2128
|
-
const pairs = parseDotenv(raw);
|
|
2129
|
-
const { added, skipped } = applyEnvToBundle(bundle, pairs, opts);
|
|
2130
|
-
emitSecretAudit({ event: 'secrets.import', bundle: bundle.name, operation: 'import --from env-file', source: 'env-file', status: 'success', keyCount: added });
|
|
2131
|
-
console.log(chalk.green(`Imported ${added} key(s)${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
|
|
2132
|
-
}
|
|
2133
|
-
}
|
|
2134
|
-
catch (err) {
|
|
2135
|
-
if (isPromptCancelled(err))
|
|
2136
|
-
return;
|
|
2137
|
-
console.error(chalk.red(err.message));
|
|
2138
|
-
process.exit(1);
|
|
2139
|
-
}
|
|
2140
|
-
});
|
|
2141
|
-
cmd
|
|
2142
|
-
.command('export [bundle]')
|
|
2143
|
-
.description('Move a bundle without exposing it: push to remote machine(s) over SSH with --device, to a 1Password vault with --to-1password, or to an encrypted file with --to-file.')
|
|
2144
|
-
// Both transport flags are hidden, not documented: the ONLY caller is the
|
|
2145
|
-
// machine-to-machine SSH resolve (remoteResolveEnv / verifyRemoteKeychainPush),
|
|
2146
|
-
// which sets AGENTS_SECRETS_REMOTE_TRANSPORT=1 on the remote invocation. The
|
|
2147
|
-
// old public shell-eval mode (`eval "$(agents secrets export … --plaintext)"`)
|
|
2148
|
-
// was the top agent-exfiltration path and is removed (RUSH-2774).
|
|
2149
|
-
.addOption(new Option('--plaintext', 'internal remote-resolve transport').hideHelp())
|
|
2150
|
-
.addOption(new Option('--format <json>', 'internal remote-resolve transport').hideHelp())
|
|
2151
|
-
.option('--to-1password', 'Push every key in the bundle as a PASSWORD item in a 1Password vault')
|
|
2152
|
-
.option('--vault <name>', '1Password vault name (used with --to-1password)')
|
|
2153
|
-
.option('--device <target...>', 'Push the bundle over SSH to this device (host alias or user@host); repeatable for multiple machines')
|
|
2154
|
-
.option('--remote-backend <backend>', 'Backend for the bundle on the remote device (with --device): keychain (default) or file. file is headless-readable via the remote\'s machine-local key and never forwards AGENTS_SECRETS_PASSPHRASE.', 'keychain')
|
|
2155
|
-
.option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --device)')
|
|
2156
|
-
.option('--to-file <path>', `Write the bundle as an AES-256-GCM encrypted offline file (needs ${SYNC_PASSPHRASE_ENV}; symmetric counterpart of import --from-file)`)
|
|
2157
|
-
.action(async (bundleName, opts) => {
|
|
2158
|
-
try {
|
|
2159
|
-
const { readAndResolveBundleEnv } = await import('../lib/secrets/bundles.js');
|
|
2160
|
-
const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
|
|
2161
|
-
if (opts.toFile) {
|
|
2162
|
-
// Transport, not the local store's master key — see lib/secrets/sync-passphrase.ts.
|
|
2163
|
-
const passphrase = resolveSyncPassphraseFromEnv().value ?? '';
|
|
2164
|
-
if (!passphrase) {
|
|
2165
|
-
throw new Error(`--to-file needs ${SYNC_PASSPHRASE_ENV} set to encrypt the bundle. ` +
|
|
2166
|
-
'Set it for this command, then supply the same value when importing.');
|
|
2167
|
-
}
|
|
2168
|
-
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: 'export --to-file', keyMode: 'storage', agentOnly: true });
|
|
2169
|
-
exportBundleToFile(env, opts.toFile, passphrase);
|
|
2170
|
-
emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: 'export --to-file', source: 'file', status: 'success', keyCount: Object.keys(env).length });
|
|
2171
|
-
console.log(chalk.green(`Exported ${Object.keys(env).length} key(s) to ${opts.toFile}`));
|
|
2172
|
-
return;
|
|
2173
|
-
}
|
|
2174
|
-
// The presence of --device selects SSH push: --device is the destination
|
|
2175
|
-
// and carries the mode (no separate --to-ssh needed — it would be
|
|
2176
|
-
// strictly redundant since SSH always requires at least one device).
|
|
2177
|
-
const hosts = opts.device ?? [];
|
|
2178
|
-
if (hosts.length > 0) {
|
|
2179
|
-
for (const h of hosts)
|
|
2180
|
-
assertValidSshTarget(h);
|
|
2181
|
-
const parsedBackend = parseBackendOpt(opts.remoteBackend);
|
|
2182
|
-
// `--remote-backend` documents keychain|file only, and parseBackendOpt
|
|
2183
|
-
// exits on anything else — but its return type still admits 'vault',
|
|
2184
|
-
// which has no remote-push path. Refuse it here rather than let it fall
|
|
2185
|
-
// through to the keychain branch and half-work.
|
|
2186
|
-
if (parsedBackend === 'vault') {
|
|
2187
|
-
console.error(chalk.red("--remote-backend vault is not supported; use 'keychain' or 'file'."));
|
|
2188
|
-
process.exit(1);
|
|
2189
|
-
}
|
|
2190
|
-
let remoteBackend = parsedBackend;
|
|
2191
|
-
if (isReservedBundleName(resolvedBundleName)) {
|
|
2192
|
-
if (opts.remoteBackend && opts.remoteBackend !== 'file') {
|
|
2193
|
-
throw new ReservedBundleWrongBackendError(resolvedBundleName, parsedBackend);
|
|
2194
|
-
}
|
|
2195
|
-
remoteBackend = 'file';
|
|
2196
|
-
}
|
|
2197
|
-
// File-backend export NEVER forwards AGENTS_SECRETS_PASSPHRASE
|
|
2198
|
-
// (PHNX-2371). Forwarding it keys the remote ciphertext to a secret
|
|
2199
|
-
// the destination daemon does not hold, so later headless reads fail
|
|
2200
|
-
// while import still prints "Imported N key(s)". The remote
|
|
2201
|
-
// auto-provisions its own machine-local key instead.
|
|
2202
|
-
// Resolve ONCE for N hosts — reading a bundle can prompt, and doing it
|
|
2203
|
-
// per host would prompt per host.
|
|
2204
|
-
//
|
|
2205
|
-
// Same predicate as `view --reveal` / `exec`: a human at a TTY gets the
|
|
2206
|
-
// Touch ID sheet, an agent launch or a piped/CI run stays broker-only
|
|
2207
|
-
// and fails fast with the `agents secrets unlock` hint. Requiring a
|
|
2208
|
-
// prior unlock for an interactive push was an inconsistency, not a
|
|
2209
|
-
// boundary — this read is strictly less exposed than `view --reveal`,
|
|
2210
|
-
// which already prompts.
|
|
2211
|
-
const resolvedForPush = resolveBundleForPush(resolvedBundleName, 'ssh export', {
|
|
2212
|
-
agentOnly: isHeadlessSecretsContext() || !isInteractiveTerminal(),
|
|
2213
|
-
});
|
|
2214
|
-
const keyCount = resolvedForPush.keyCount;
|
|
2215
|
-
let failures = 0;
|
|
2216
|
-
for (const host of hosts) {
|
|
2217
|
-
const out = pushResolvedBundleToHost(resolvedForPush, resolvedBundleName, host, {
|
|
2218
|
-
remoteBackend,
|
|
2219
|
-
force: opts.force,
|
|
2220
|
-
operation: 'ssh export',
|
|
2221
|
-
});
|
|
2222
|
-
if (!out.ok) {
|
|
2223
|
-
failures++;
|
|
2224
|
-
console.error(chalk.red(`${host}: ${out.message}`));
|
|
2225
|
-
continue;
|
|
2226
|
-
}
|
|
2227
|
-
console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${out.message}`));
|
|
2228
|
-
}
|
|
2229
|
-
emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: `export --device ${hosts.join(',')}`, source: 'ssh', host: hosts.join(','), status: failures > 0 ? 'error' : 'success', keyCount });
|
|
2230
|
-
if (failures > 0)
|
|
2231
|
-
process.exit(1);
|
|
2232
|
-
return;
|
|
2233
|
-
}
|
|
2234
|
-
if (opts.to1password) {
|
|
2235
|
-
assertOpAvailable();
|
|
2236
|
-
const vault = await resolveVault(opts.vault);
|
|
2237
|
-
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `1Password vault ${vault}`, keyMode: 'storage', agentOnly: true });
|
|
2238
|
-
let created = 0;
|
|
2239
|
-
let overwritten = 0;
|
|
2240
|
-
let skipped = 0;
|
|
2241
|
-
for (const [key, value] of Object.entries(env)) {
|
|
2242
|
-
const exists = itemExistsByTitle(key, vault);
|
|
2243
|
-
if (exists) {
|
|
2244
|
-
if (!opts.force) {
|
|
2245
|
-
skipped++;
|
|
2246
|
-
continue;
|
|
2247
|
-
}
|
|
2248
|
-
deleteItemByTitle(key, vault);
|
|
2249
|
-
createPasswordItem(key, value, vault);
|
|
2250
|
-
overwritten++;
|
|
2251
|
-
}
|
|
2252
|
-
else {
|
|
2253
|
-
createPasswordItem(key, value, vault);
|
|
2254
|
-
created++;
|
|
2255
|
-
}
|
|
2256
|
-
}
|
|
2257
|
-
const parts = [];
|
|
2258
|
-
if (created)
|
|
2259
|
-
parts.push(`${created} created`);
|
|
2260
|
-
if (overwritten)
|
|
2261
|
-
parts.push(`${overwritten} overwritten`);
|
|
2262
|
-
if (skipped)
|
|
2263
|
-
parts.push(`${skipped} skipped (already exist, pass --force)`);
|
|
2264
|
-
emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: `export --to-1password ${vault}`, source: '1password', status: 'success', keyCount: created + overwritten });
|
|
2265
|
-
console.log(chalk.green(`Exported to 1Password vault '${vault}': ${parts.join(', ')}.`));
|
|
2266
|
-
return;
|
|
2267
|
-
}
|
|
2268
|
-
// The shell-eval print mode (`eval "$(agents secrets export … --plaintext)"`)
|
|
2269
|
-
// is REMOVED (RUSH-2774): a whole bundle printed to stdout lands in a
|
|
2270
|
-
// coding agent's context and session transcript, and it was the single
|
|
2271
|
-
// most-copied exfiltration one-liner on the fleet. The sole surviving
|
|
2272
|
-
// stdout emitter is the machine-to-machine SSH resolve transport, which
|
|
2273
|
-
// requires the remote-invocation marker AND a non-agent context — both
|
|
2274
|
-
// set only by remoteResolveEnv / verifyRemoteKeychainPush building the
|
|
2275
|
-
// remote command. Everything else gets the refusal with the paved paths.
|
|
2276
|
-
const isTransportCall = process.env.AGENTS_SECRETS_REMOTE_TRANSPORT === '1' &&
|
|
2277
|
-
Boolean(opts.plaintext) &&
|
|
2278
|
-
opts.format === 'json' &&
|
|
2279
|
-
!isAgentInvocationContext();
|
|
2280
|
-
if (!isTransportCall) {
|
|
2281
|
-
console.error(chalk.red('export no longer prints values. Pick a destination:'));
|
|
2282
|
-
console.error(chalk.red(' --device <host> | --to-1password | --to-file <path>'));
|
|
2283
|
-
console.error(chalk.dim(`Run a command with the values injected instead: agents secrets exec ${resolvedBundleName} -- <cmd>`));
|
|
2284
|
-
console.error(chalk.dim(`Reveal one value at your terminal: agents secrets view ${resolvedBundleName} --reveal`));
|
|
2285
|
-
process.exit(1);
|
|
2286
|
-
}
|
|
2287
|
-
// Machine-readable form consumed by `remoteResolveEnv` over SSH: single
|
|
2288
|
-
// object of injected env KEY -> value; values verbatim. Broker-only, so a
|
|
2289
|
-
// headless remote read fails fast rather than raising Touch ID. The
|
|
2290
|
-
// resolve emits its own `secrets.get` audit on the remote; no export
|
|
2291
|
-
// event here — that would tally every peer pull as an export.
|
|
2292
|
-
const { env } = readAndResolveBundleEnv(resolvedBundleName, {
|
|
2293
|
-
caller: 'remote resolve transport',
|
|
2294
|
-
keyMode: 'process',
|
|
2295
|
-
agentOnly: true,
|
|
2296
|
-
});
|
|
2297
|
-
process.stdout.write(JSON.stringify(env));
|
|
2298
|
-
}
|
|
2299
|
-
catch (err) {
|
|
2300
|
-
if (isPromptCancelled(err))
|
|
2301
|
-
return;
|
|
2302
|
-
console.error(chalk.red(err.message));
|
|
2303
|
-
process.exit(1);
|
|
2304
|
-
}
|
|
2305
|
-
});
|
|
2306
|
-
cmd
|
|
2307
|
-
.command('exec <bundle> [command...]')
|
|
2308
|
-
.description('Run a command with the bundle\'s secrets injected into the environment (use --device to resolve the bundle from a remote machine, ephemerally)')
|
|
2309
|
-
.option('--device <target>', 'Resolve <bundle> on a remote device over SSH and inject it (ephemeral — never stored on this machine)')
|
|
2310
|
-
.option('--keys <keys>', 'Inject only this comma-separated subset of keys (e.g. KEY1,KEY2). Missing keys are an error.')
|
|
2311
|
-
.option('--allow-expired', 'Inject keys even if their expiry date has passed (overrides the pre-run expiry abort).')
|
|
2312
|
-
.allowUnknownOption()
|
|
2313
|
-
.action(async (bundleName, commandParts, execOpts) => {
|
|
2314
|
-
try {
|
|
2315
|
-
if (commandParts.length === 0) {
|
|
2316
|
-
console.error(chalk.red('Usage: agents secrets exec <bundle> -- <command...>'));
|
|
2317
|
-
process.exit(1);
|
|
2318
|
-
}
|
|
2319
|
-
const [cmd, ...args] = commandParts;
|
|
2320
|
-
const keysSubset = execOpts.keys
|
|
2321
|
-
? execOpts.keys.split(',').map((k) => k.trim()).filter(Boolean)
|
|
2322
|
-
: undefined;
|
|
2323
|
-
let secretEnv;
|
|
2324
|
-
if (execOpts.device) {
|
|
2325
|
-
// Least-privilege flags do not yet cross the SSH resolver —
|
|
2326
|
-
// silently applying them would inject the full remote env or an
|
|
2327
|
-
// expired key. Fail loud so the user can drop the flag or run
|
|
2328
|
-
// locally instead.
|
|
2329
|
-
const { assertRemoteBundleFlagsUnsupported } = await import('../lib/secrets/bundles.js');
|
|
2330
|
-
assertRemoteBundleFlagsUnsupported(bundleName, execOpts.device, { keys: keysSubset, allowExpired: execOpts.allowExpired }, { keysFlag: '--keys', allowExpiredFlag: '--allow-expired' });
|
|
2331
|
-
secretEnv = await remoteResolveEnv(await resolveHostSshTarget(execOpts.device), bundleName, { osLookupName: execOpts.device });
|
|
2332
|
-
}
|
|
2333
|
-
else {
|
|
2334
|
-
const { readAndResolveBundleEnv } = await import('../lib/secrets/bundles.js');
|
|
2335
|
-
secretEnv = readAndResolveBundleEnv(bundleName, {
|
|
2336
|
-
caller: `command ${cmd}`,
|
|
2337
|
-
keys: keysSubset,
|
|
2338
|
-
allowExpired: execOpts.allowExpired,
|
|
2339
|
-
// An explicit `secrets exec` at a real terminal is a deliberate use of
|
|
2340
|
-
// the values: an unlocked bundle runs silently, a locked one resolves
|
|
2341
|
-
// with one Touch ID sheet, then the command runs with the secrets
|
|
2342
|
-
// injected. Under an agent (AGENTS_RUNTIME) or headless (no TTY) it
|
|
2343
|
-
// stays broker-only and points at the explicit unlock command instead
|
|
2344
|
-
// of raising a sheet — so release/CI scripts never prompt.
|
|
2345
|
-
agentOnly: isHeadlessSecretsContext() || !isInteractiveTerminal(),
|
|
2346
|
-
}).env;
|
|
2347
|
-
}
|
|
2348
|
-
const { spawn } = await import('child_process');
|
|
2349
|
-
// On Windows, spawn without a shell ENOENTs for `.cmd`/`.bat` launchers
|
|
2350
|
-
// (npm, yarn, most JS CLIs) and shell built-ins, so we set shell:true.
|
|
2351
|
-
// With shell:true Node hands cmd.exe a single command line with NO quoting
|
|
2352
|
-
// of its own. Compose that line ourselves (composeWin32CommandLine quotes
|
|
2353
|
-
// every token) and pass an EMPTY args array so Node never concatenates the
|
|
2354
|
-
// user-supplied args unescaped (DEP0190 + injection). See that helper for
|
|
2355
|
-
// the cmd.exe %VAR%/!VAR! expansion caveat.
|
|
2356
|
-
const useShell = process.platform === 'win32';
|
|
2357
|
-
const spawnCmd = useShell ? composeWin32CommandLine(cmd, args) : cmd;
|
|
2358
|
-
const spawnArgs = useShell ? [] : args;
|
|
2359
|
-
const proc = spawn(spawnCmd, spawnArgs, {
|
|
2360
|
-
stdio: 'inherit',
|
|
2361
|
-
shell: useShell,
|
|
2362
|
-
env: buildSecretsExecEnv(process.env, secretEnv),
|
|
2363
|
-
});
|
|
2364
|
-
proc.on('close', (code) => process.exit(code ?? 0));
|
|
2365
|
-
proc.on('error', (err) => {
|
|
2366
|
-
console.error(chalk.red(`Failed to run '${cmd}': ${err.message}`));
|
|
2367
|
-
process.exit(1);
|
|
2368
|
-
});
|
|
2369
|
-
}
|
|
2370
|
-
catch (err) {
|
|
2371
|
-
if (isPromptCancelled(err))
|
|
2372
|
-
return;
|
|
2373
|
-
console.error(chalk.red(err.message));
|
|
2374
|
-
process.exit(1);
|
|
2375
|
-
}
|
|
2376
|
-
});
|
|
2377
|
-
cmd
|
|
2378
|
-
.command('mcp')
|
|
2379
|
-
.description('Run a stdio MCP server exposing get_secret(bundle, key) — hand credentials to an MCP-speaking agent by name at call time, never through the child process environment')
|
|
2380
|
-
.action(async () => {
|
|
2381
|
-
// JIT credential delivery (#333): unlike `secrets exec`, which bakes every
|
|
2382
|
-
// resolved value into the child's env, this speaks MCP over stdio so a
|
|
2383
|
-
// framework requests one secret at a time and the raw value never enters
|
|
2384
|
-
// process.env. stdout is the JSON-RPC channel — nothing else may print there.
|
|
2385
|
-
const { runSecretsMcpServer } = await import('../lib/secrets/mcp.js');
|
|
2386
|
-
await runSecretsMcpServer({ version: getCliVersion() });
|
|
2387
|
-
});
|
|
2388
|
-
const openclawKeychain = cmd
|
|
2389
|
-
.command('openclaw-keychain')
|
|
2390
|
-
.description('Migrate supported OpenClaw credentials from plaintext config to macOS Keychain-backed SecretRefs');
|
|
2391
|
-
openclawKeychain
|
|
2392
|
-
.command('migrate [config]')
|
|
2393
|
-
.description('Rewrite supported OpenClaw plaintext secrets to exec SecretRefs backed by macOS Keychain')
|
|
2394
|
-
.option('--account <name>', 'Keychain account name', 'openclaw')
|
|
2395
|
-
.option('--dry-run', 'Inspect the migration without writing Keychain or config changes')
|
|
2396
|
-
.action(async (configArg, opts) => {
|
|
2397
|
-
try {
|
|
2398
|
-
const configPath = configArg || path.join(os.homedir(), '.openclaw', 'openclaw.json');
|
|
2399
|
-
const { migrateOpenClawConfigToKeychainRefs, storeOpenClawKeychainServices } = await import('../lib/openclaw-keychain.js');
|
|
2400
|
-
const before = fs.readFileSync(configPath, 'utf-8');
|
|
2401
|
-
const config = JSON.parse(before);
|
|
2402
|
-
const result = migrateOpenClawConfigToKeychainRefs(config, { account: opts.account });
|
|
2403
|
-
if (result.unsupportedEnvKeys.length > 0) {
|
|
2404
|
-
throw new Error(`OpenClaw top-level env keys have no supported SecretRef target: ${result.unsupportedEnvKeys.join(', ')}. ` +
|
|
2405
|
-
`Move them to a supported credential field before removing plaintext.`);
|
|
2406
|
-
}
|
|
2407
|
-
if (!opts.dryRun) {
|
|
2408
|
-
storeOpenClawKeychainServices(result.services, opts.account);
|
|
2409
|
-
if (result.changed)
|
|
2410
|
-
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
|
|
2411
|
-
}
|
|
2412
|
-
console.log(`OpenClaw Keychain migration ${opts.dryRun ? 'plan' : 'complete'}: ${result.services.length} secret${result.services.length === 1 ? '' : 's'}, ${result.replacedPaths.length} ref${result.replacedPaths.length === 1 ? '' : 's'}, ${result.removedEnvPaths.length} env entr${result.removedEnvPaths.length === 1 ? 'y' : 'ies'} removed.`);
|
|
2413
|
-
for (const p of result.replacedPaths)
|
|
2414
|
-
console.log(`ref ${p}`);
|
|
2415
|
-
for (const p of result.removedEnvPaths)
|
|
2416
|
-
console.log(`removed ${p}`);
|
|
2417
|
-
process.exit(0);
|
|
2418
|
-
}
|
|
2419
|
-
catch (err) {
|
|
2420
|
-
console.error(chalk.red(err.message));
|
|
2421
|
-
process.exit(1);
|
|
2422
|
-
}
|
|
2423
|
-
});
|
|
2424
|
-
openclawKeychain
|
|
2425
|
-
.command('resolve')
|
|
2426
|
-
.description('OpenClaw exec SecretRef resolver for Keychain service ids')
|
|
2427
|
-
.option('--account <name>', 'Keychain account name', 'openclaw')
|
|
2428
|
-
.action(async (opts) => {
|
|
2429
|
-
try {
|
|
2430
|
-
const { readOpenClawKeychainService, resolveOpenClawKeychainRequest } = await import('../lib/openclaw-keychain.js');
|
|
2431
|
-
const raw = readStdinSync();
|
|
2432
|
-
const request = raw.trim() ? JSON.parse(raw) : {};
|
|
2433
|
-
const response = resolveOpenClawKeychainRequest(request, (service) => readOpenClawKeychainService(service, opts.account));
|
|
2434
|
-
process.stdout.write(JSON.stringify(response));
|
|
2435
|
-
process.exit(0);
|
|
2436
|
-
}
|
|
2437
|
-
catch {
|
|
2438
|
-
process.stdout.write(JSON.stringify({ protocolVersion: 1, values: {}, errors: { value: { code: 'NOT_FOUND' } } }));
|
|
2439
|
-
process.exit(1);
|
|
2440
|
-
}
|
|
2441
|
-
});
|
|
2442
|
-
cmd
|
|
2443
|
-
.command('generate [length]')
|
|
2444
|
-
.description('Generate a random password')
|
|
2445
|
-
.option('-U, --uppercase', 'Include A-Z (default: on)')
|
|
2446
|
-
.option('-l, --lowercase', 'Include a-z (default: on)')
|
|
2447
|
-
.option('-d, --digits', 'Include 0-9 (default: on)')
|
|
2448
|
-
.option('-s, --symbols', 'Include symbols (default: on)')
|
|
2449
|
-
.option('--no-uppercase', 'Exclude A-Z')
|
|
2450
|
-
.option('--no-lowercase', 'Exclude a-z')
|
|
2451
|
-
.option('--no-digits', 'Exclude 0-9')
|
|
2452
|
-
.option('--no-symbols', 'Exclude symbols')
|
|
2453
|
-
.option('--strong', 'Include all character classes')
|
|
2454
|
-
.option('--pin', 'Digits only (shortcut for -d --no-uppercase --no-lowercase --no-symbols)')
|
|
2455
|
-
.option('--hex', 'Hex characters only (0-9, a-f)')
|
|
2456
|
-
.option('-c, --copy', 'Copy to clipboard (does not print)')
|
|
2457
|
-
.action(async (lengthArg, opts) => {
|
|
2458
|
-
const length = lengthArg ? parseInt(lengthArg, 10) : 32;
|
|
2459
|
-
if (isNaN(length) || length < 1 || length > 1024) {
|
|
2460
|
-
console.error(chalk.red('Length must be a number between 1 and 1024.'));
|
|
2461
|
-
process.exit(1);
|
|
2462
|
-
}
|
|
2463
|
-
const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
2464
|
-
const LOWER = 'abcdefghijklmnopqrstuvwxyz';
|
|
2465
|
-
const DIGITS = '0123456789';
|
|
2466
|
-
const SYMBOLS = '!@#$%^&*()-_=+[]{}|;:,.<>?';
|
|
2467
|
-
const HEX_LOWER = '0123456789abcdef';
|
|
2468
|
-
let charClasses = [];
|
|
2469
|
-
if (opts.hex) {
|
|
2470
|
-
charClasses = [HEX_LOWER];
|
|
2471
|
-
}
|
|
2472
|
-
else if (opts.pin) {
|
|
2473
|
-
charClasses = [DIGITS];
|
|
2474
|
-
}
|
|
2475
|
-
else {
|
|
2476
|
-
const useUpper = opts.strong || opts.uppercase !== false;
|
|
2477
|
-
const useLower = opts.strong || opts.lowercase !== false;
|
|
2478
|
-
const useDigits = opts.strong || opts.digits !== false;
|
|
2479
|
-
const useSymbols = opts.strong || opts.symbols !== false;
|
|
2480
|
-
if (useUpper)
|
|
2481
|
-
charClasses.push(UPPER);
|
|
2482
|
-
if (useLower)
|
|
2483
|
-
charClasses.push(LOWER);
|
|
2484
|
-
if (useDigits)
|
|
2485
|
-
charClasses.push(DIGITS);
|
|
2486
|
-
if (useSymbols)
|
|
2487
|
-
charClasses.push(SYMBOLS);
|
|
2488
|
-
}
|
|
2489
|
-
if (charClasses.length === 0) {
|
|
2490
|
-
console.error(chalk.red('At least one character class must be enabled.'));
|
|
2491
|
-
process.exit(1);
|
|
2492
|
-
}
|
|
2493
|
-
const randomBytes = crypto.getRandomValues(new Uint32Array(length * 2));
|
|
2494
|
-
let password = '';
|
|
2495
|
-
for (let i = 0; i < length; i++) {
|
|
2496
|
-
const classIndex = randomBytes[i * 2] % charClasses.length;
|
|
2497
|
-
const charClass = charClasses[classIndex];
|
|
2498
|
-
const charIndex = randomBytes[i * 2 + 1] % charClass.length;
|
|
2499
|
-
password += charClass[charIndex];
|
|
2500
|
-
}
|
|
2501
|
-
if (opts.copy) {
|
|
2502
|
-
try {
|
|
2503
|
-
await copyToClipboard(password);
|
|
2504
|
-
console.log(chalk.green(`Password copied to clipboard (${length} chars)`));
|
|
2505
|
-
}
|
|
2506
|
-
catch (err) {
|
|
2507
|
-
console.error(chalk.red(`Clipboard copy failed: ${err.message}`));
|
|
2508
|
-
console.error(chalk.gray('Re-run without --copy to print the password instead.'));
|
|
2509
|
-
process.exitCode = 1;
|
|
2510
|
-
}
|
|
2511
|
-
}
|
|
2512
|
-
else {
|
|
2513
|
-
console.log(password);
|
|
2514
|
-
}
|
|
2515
|
-
});
|
|
2516
|
-
cmd
|
|
2517
|
-
.command('unlock [names...]')
|
|
2518
|
-
.description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS). With --device, unlock FILE-backed bundle(s) on a remote (the passphrase prompt surfaces over the SSH TTY); keychain/biometry bundles are GUI-only and can\'t be remote-unlocked.')
|
|
2519
|
-
.option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
|
|
2520
|
-
.option('--until <date>', 'Hold until this absolute date or timestamp (for example 2026-08-06T12:00:00Z). Mutually exclusive with --ttl.')
|
|
2521
|
-
.option('--durable', 'Keep the unlock across sleep + reboot too (default: survives upgrade/restart but re-locks on sleep). Set secrets.agent.durable in agents.yaml to make this the default.')
|
|
2522
|
-
.option('--agent <agent>', 'Narrow the unlock to ONE harness type (for example claude, codex, or kimi). Default: the grant is global — every harness and a plain shell can read it, so one Touch ID covers them all.')
|
|
2523
|
-
.option('--keys <keys>', 'Hold ONLY this comma-separated subset of the bundle\'s keys instead of the whole bundle. Scopes exactly one bundle; fails closed on an unknown key. `secrets status` shows which keys a scoped hold covers.')
|
|
2524
|
-
.option('--all', 'Unlock every configured bundle')
|
|
2525
|
-
.option('--device <target>', 'Unlock the bundle(s) on this remote device over SSH instead of locally (file-backed bundles only — the remote\'s passphrase prompt surfaces on your terminal over a -tt session). Single-valued (NOT variadic) so it never swallows the bundle name: `unlock <name> --device <machine>`.')
|
|
2526
|
-
.action(async (names, opts) => {
|
|
2527
|
-
if (opts.ttl && opts.until) {
|
|
2528
|
-
console.error(chalk.red('--ttl and --until are mutually exclusive.'));
|
|
2529
|
-
process.exit(1);
|
|
2530
|
-
}
|
|
2531
|
-
if (opts.keys !== undefined) {
|
|
2532
|
-
// --keys scopes ONE bundle to an explicit subset. It is local-only (a
|
|
2533
|
-
// remote --device unlock holds the whole file-backed bundle) and cannot
|
|
2534
|
-
// combine with --all, which would hold every bundle whole.
|
|
2535
|
-
if (opts.device) {
|
|
2536
|
-
console.error(chalk.red('--keys is local-only; a remote (--device) unlock holds the whole file-backed bundle.'));
|
|
2537
|
-
process.exit(1);
|
|
2538
|
-
}
|
|
2539
|
-
if (opts.all || !names || names.length !== 1) {
|
|
2540
|
-
console.error(chalk.red('--keys scopes ONE bundle to a subset of its keys: name exactly one bundle (not --all).'));
|
|
2541
|
-
process.exit(1);
|
|
2542
|
-
}
|
|
2543
|
-
}
|
|
2544
|
-
// Single-valued (not variadic): a variadic --device greedily consumes the
|
|
2545
|
-
// positional bundle name (`unlock --device mac wztest` -> device=[mac,wztest],
|
|
2546
|
-
// names=[]). Unlock targets one remote at a time anyway.
|
|
2547
|
-
const hosts = opts.device ? [opts.device] : [];
|
|
2548
|
-
if (hosts.length > 0) {
|
|
2549
|
-
// Remote unlock: the REMOTE enforces its own platform rules, so the
|
|
2550
|
-
// local darwin-only guard below does NOT apply. Only file-backed
|
|
2551
|
-
// bundles are remote-unlockable — their passphrase prompt surfaces over
|
|
2552
|
-
// the -tt SSH TTY; a keychain/biometry bundle would trigger a local GUI
|
|
2553
|
-
// Touch-ID sheet that can't cross SSH.
|
|
2554
|
-
if (!opts.all && (!names || names.length === 0)) {
|
|
2555
|
-
console.error(chalk.red('Specify one or more bundle names, or --all.'));
|
|
2556
|
-
process.exit(1);
|
|
2557
|
-
}
|
|
2558
|
-
const unlockArgs = buildRemoteUnlockArgs(names, opts);
|
|
2559
|
-
let failures = 0;
|
|
2560
|
-
for (const h of hosts) {
|
|
2561
|
-
const target = await resolveHostSshTarget(h);
|
|
2562
|
-
// FOREGROUND stream (stdio inherited), NOT the piped remoteSecretsRaw:
|
|
2563
|
-
// the remote's passphrase prompt only surfaces if the remote process
|
|
2564
|
-
// sees a real TTY, which requires our local terminal to pass straight
|
|
2565
|
-
// through. The remote's prompt + output stream to this terminal; we get
|
|
2566
|
-
// back only the exit code.
|
|
2567
|
-
const code = remoteSecretsStream(target, unlockArgs, { osLookupName: h });
|
|
2568
|
-
if (code === 0) {
|
|
2569
|
-
console.log(chalk.green(`${h}: unlocked`));
|
|
2570
|
-
}
|
|
2571
|
-
else {
|
|
2572
|
-
failures++;
|
|
2573
|
-
console.error(chalk.red(`${h}: unlock failed (exit ${code})`));
|
|
2574
|
-
}
|
|
2575
|
-
}
|
|
2576
|
-
if (failures > 0)
|
|
2577
|
-
process.exit(1);
|
|
2578
|
-
return;
|
|
2579
|
-
}
|
|
2580
|
-
if (opts.until) {
|
|
2581
|
-
try {
|
|
2582
|
-
resolveUnlockTtlMs(undefined, opts.until);
|
|
2583
|
-
}
|
|
2584
|
-
catch (err) {
|
|
2585
|
-
console.error(chalk.red(err.message));
|
|
2586
|
-
process.exit(1);
|
|
2587
|
-
}
|
|
2588
|
-
}
|
|
2589
|
-
if (process.platform !== 'darwin') {
|
|
2590
|
-
// No broker + no biometry prompt off darwin: secrets already resolve
|
|
2591
|
-
// durably from the OS store (libsecret / Credential Manager) on every
|
|
2592
|
-
// read with no prompt, so an unlock is a friendly no-op — not an error.
|
|
2593
|
-
// Accept --durable as a documented no-op so the command is uniform.
|
|
2594
|
-
console.log(chalk.gray('Nothing to unlock: secrets already persist on this OS — reads never re-prompt.'));
|
|
2595
|
-
return;
|
|
2596
|
-
}
|
|
2597
|
-
let targets = opts.all ? listBundles().map((b) => b.name) : names;
|
|
2598
|
-
if (!targets || targets.length === 0) {
|
|
2599
|
-
console.error(chalk.red('Specify one or more bundle names, or --all.'));
|
|
2600
|
-
process.exit(1);
|
|
2601
|
-
}
|
|
2602
|
-
let ttlMs;
|
|
2603
|
-
try {
|
|
2604
|
-
ttlMs = resolveUnlockTtlMs(opts.ttl, opts.until);
|
|
2605
|
-
}
|
|
2606
|
-
catch (err) {
|
|
2607
|
-
console.error(chalk.red(err.message));
|
|
2608
|
-
process.exit(1);
|
|
2609
|
-
}
|
|
2610
|
-
const expiresAt = Date.now() + ttlMs;
|
|
2611
|
-
if (!isSecretsBrokerEnabled()) {
|
|
2612
|
-
console.error(chalk.red('Secrets broker is disabled — re-enable with \'agents daemon services enable secrets-broker\'.'));
|
|
2613
|
-
process.exit(1);
|
|
2614
|
-
}
|
|
2615
|
-
if (!(await ensureAgentRunning())) {
|
|
2616
|
-
console.error(chalk.red('Could not start the secrets broker.'));
|
|
2617
|
-
process.exit(1);
|
|
2618
|
-
}
|
|
2619
|
-
// #415: the daemon should be always-on for any background need, not only
|
|
2620
|
-
// after `routines add`. `ensureAgentRunning` prefers the daemon (it hosts
|
|
2621
|
-
// the broker, #416), but can fall back to a one-off broker spawn when the
|
|
2622
|
-
// daemon can't come up — so ensure the daemon is up regardless. Idempotent
|
|
2623
|
-
// (single-instance start lock, #414) and best-effort — never blocks unlock.
|
|
2624
|
-
ensureDaemonStarted();
|
|
2625
|
-
let loaded = 0;
|
|
2626
|
-
// An unlock is a deliberate act, so it grants GLOBALLY unless `--agent`
|
|
2627
|
-
// narrows it to one harness. It must NOT inherit the ambient
|
|
2628
|
-
// AGENTS_AGENT_NAME: that silently scoped a terminal unlock to whichever
|
|
2629
|
-
// agent happened to launch the shell, leaving the grant unreadable to
|
|
2630
|
-
// every other reader for its whole TTL.
|
|
2631
|
-
const harness = opts.agent || GLOBAL_HARNESS;
|
|
2632
|
-
const durable = opts.durable ?? secretsAgentDurable();
|
|
2633
|
-
for (const name of targets) {
|
|
2634
|
-
try {
|
|
2635
|
-
// noAgent: read the real keychain (one Touch ID) rather than the
|
|
2636
|
-
// agent we're about to populate. Snapshot time is captured BEFORE the
|
|
2637
|
-
// read so a concurrent eviction always beats this load (broker
|
|
2638
|
-
// tombstones reject snapshots that predate an eviction).
|
|
2639
|
-
const snapshotAt = Date.now();
|
|
2640
|
-
const { bundle, env } = readAndResolveBundleEnv(name, {
|
|
2641
|
-
noAgent: true,
|
|
2642
|
-
caller: 'unlock secrets',
|
|
2643
|
-
agent: harness,
|
|
2644
|
-
duration: humanRemaining(expiresAt),
|
|
2645
|
-
keyMode: 'storage',
|
|
2646
|
-
});
|
|
2647
|
-
// Migrations are authorized only by this explicit unlock. The bundle
|
|
2648
|
-
// metadata was included in the successful authenticated batch, so the
|
|
2649
|
-
// ACL heal can rewrite that already-read value without another read.
|
|
2650
|
-
maybeAutoRekey();
|
|
2651
|
-
healKeychainBundleMetadataAclOnce(new Map([[bundle.name, JSON.stringify(bundle)]]));
|
|
2652
|
-
// With --keys, hold ONLY the resolved subset behind a lease that scopes
|
|
2653
|
-
// the broker entry and its own expiry; without it, the whole bundle is
|
|
2654
|
-
// held exactly as before. scopeHeldEnv fails closed on an unknown key.
|
|
2655
|
-
const { heldEnv, lease } = scopeHeldEnv({ bundle: name, env, keys: opts.keys ?? null, ttlMs, harness, sleepPersist: durable });
|
|
2656
|
-
const heldExpiresAt = lease ? lease.expiresAt : expiresAt;
|
|
2657
|
-
if (await agentLoad(name, bundle, heldEnv, ttlMs, harness, lease, snapshotAt)) {
|
|
2658
|
-
loaded++;
|
|
2659
|
-
// Persist a durable session snapshot so the unlock survives a daemon
|
|
2660
|
-
// restart / upgrade (and sleep too, with --durable). session-store.ts.
|
|
2661
|
-
// A scoped hold persists its lease so it rehydrates scoped.
|
|
2662
|
-
saveSession(name, {
|
|
2663
|
-
bundle,
|
|
2664
|
-
env: heldEnv,
|
|
2665
|
-
expiresAt: heldExpiresAt,
|
|
2666
|
-
sleepPersist: durable,
|
|
2667
|
-
harness,
|
|
2668
|
-
lease,
|
|
2669
|
-
});
|
|
2670
|
-
// Audit the GRANT itself — the broker + durable session now serve this
|
|
2671
|
-
// bundle prompt-free for the whole TTL, to every reader in `harness`
|
|
2672
|
-
// scope. The pre-read above emits `secrets.get`; this records the
|
|
2673
|
-
// longer-lived unlock a `secrets.get` does not capture. Key NAMES only.
|
|
2674
|
-
emitSecretAudit({
|
|
2675
|
-
event: 'secrets.unlocked',
|
|
2676
|
-
bundle: name,
|
|
2677
|
-
operation: 'unlock',
|
|
2678
|
-
source: durable ? 'broker+durable' : 'broker',
|
|
2679
|
-
status: 'success',
|
|
2680
|
-
keyCount: Object.keys(heldEnv).length,
|
|
2681
|
-
keys: Object.keys(heldEnv).sort(),
|
|
2682
|
-
agent: harness,
|
|
2683
|
-
ttlMs,
|
|
2684
|
-
});
|
|
2685
|
-
const scoped = lease ? chalk.gray(` scoped to ${lease.keys.join(', ')}`) : '';
|
|
2686
|
-
console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(heldEnv).length} keys, ${humanRemaining(heldExpiresAt)})`)}${scoped}`);
|
|
2687
|
-
}
|
|
2688
|
-
else {
|
|
2689
|
-
console.error(chalk.red(`Failed to load '${name}' into the agent.`));
|
|
2690
|
-
}
|
|
2691
|
-
}
|
|
2692
|
-
catch (err) {
|
|
2693
|
-
if (isPromptCancelled(err)) {
|
|
2694
|
-
console.error(chalk.yellow(`Cancelled unlocking '${name}'.`));
|
|
2695
|
-
continue;
|
|
2696
|
-
}
|
|
2697
|
-
console.error(chalk.red(`${name}: ${err.message}`));
|
|
2698
|
-
}
|
|
2699
|
-
}
|
|
2700
|
-
if (loaded === 0)
|
|
2701
|
-
process.exit(1);
|
|
2702
|
-
});
|
|
2703
|
-
cmd
|
|
2704
|
-
.command('lock [names...]')
|
|
2705
|
-
.description('Wipe bundles from the secrets-agent (forces Touch ID again next read). Default: all.')
|
|
2706
|
-
.option('--all', 'Wipe every unlocked bundle (same as no names)')
|
|
2707
|
-
.action(async (names, opts) => {
|
|
2708
|
-
if (process.platform !== 'darwin')
|
|
2709
|
-
return; // nothing to lock off darwin
|
|
2710
|
-
if (names && names.length > 0 && !opts.all) {
|
|
2711
|
-
let total = 0;
|
|
2712
|
-
for (const name of names) {
|
|
2713
|
-
total += await agentLock(name);
|
|
2714
|
-
deleteBundleSessions(name); // drop every harness scope
|
|
2715
|
-
}
|
|
2716
|
-
console.log(total > 0 ? chalk.green(`Locked ${total} bundle(s).`) : chalk.gray('Nothing to lock.'));
|
|
2717
|
-
}
|
|
2718
|
-
else {
|
|
2719
|
-
const wiped = await agentLock();
|
|
2720
|
-
deleteAllSessions();
|
|
2721
|
-
console.log(wiped > 0 ? chalk.green(`Locked ${wiped} bundle(s).`) : chalk.gray('Nothing to lock.'));
|
|
2722
|
-
}
|
|
2723
|
-
});
|
|
2724
|
-
cmd
|
|
2725
|
-
.command('status')
|
|
2726
|
-
.description('Show which bundles the secrets-agent currently holds and when they lock.')
|
|
2727
|
-
.action(async () => {
|
|
2728
|
-
if (process.platform !== 'darwin') {
|
|
2729
|
-
console.log(chalk.gray('secrets-agent is macOS-only.'));
|
|
2730
|
-
return;
|
|
2731
|
-
}
|
|
2732
|
-
if (!isSecretsBrokerEnabled()) {
|
|
2733
|
-
console.log(chalk.yellow('broker: disabled — re-enable with \'agents daemon services enable secrets-broker\''));
|
|
2734
|
-
return;
|
|
2735
|
-
}
|
|
2736
|
-
const ping = await agentPing();
|
|
2737
|
-
const brokerUp = ping.reachable;
|
|
2738
|
-
console.log(chalk.gray('broker: ') +
|
|
2739
|
-
(brokerUp
|
|
2740
|
-
? chalk.green('running') + chalk.gray(isDaemonRunning() ? ' (hosted by the daemon)' : ' (standalone)')
|
|
2741
|
-
: chalk.yellow('not running — starts on demand, or run `agents secrets start` to bring the daemon up now')));
|
|
2742
|
-
// Diagnostic: version skew is the top reason a `hold` bundle keeps
|
|
2743
|
-
// re-prompting — a broker on an older build gets torn down when the CLI
|
|
2744
|
-
// version changes (e.g. `agents-cli-update`), wiping every held bundle.
|
|
2745
|
-
const onDisk = getCliVersionFresh();
|
|
2746
|
-
if (brokerUp && ping.cliVersion && ping.cliVersion !== onDisk) {
|
|
2747
|
-
console.log(chalk.yellow(` warning: broker is running an older build (${ping.cliVersion} vs ${onDisk} on disk). ` +
|
|
2748
|
-
`A version change can wipe held bundles — reads re-warm on the next access.`));
|
|
2749
|
-
}
|
|
2750
|
-
// Surface the hold window so "why did it prompt again" is answerable.
|
|
2751
|
-
const holdStr = formatHoldWindow(secretsHoldMs());
|
|
2752
|
-
// Only claim "(secrets.agent.holdMs)" when the config is actually honored —
|
|
2753
|
-
// an invalid value (0/NaN/negative) falls back to the default via
|
|
2754
|
-
// clampHoldMs, so it must read "(default)", not misattribute to config.
|
|
2755
|
-
const configured = (() => { try {
|
|
2756
|
-
const v = readMeta().secrets?.agent?.holdMs;
|
|
2757
|
-
return typeof v === 'number' && Number.isFinite(v) && v > 0;
|
|
2758
|
-
}
|
|
2759
|
-
catch {
|
|
2760
|
-
return false;
|
|
2761
|
-
} })();
|
|
2762
|
-
console.log(chalk.gray(renderHoldSummary(holdStr, configured)));
|
|
2763
|
-
const entries = await agentStatus();
|
|
2764
|
-
const held = new Set(entries.map((e) => e.name));
|
|
2765
|
-
if (entries.length === 0) {
|
|
2766
|
-
console.log(chalk.gray(NO_BUNDLES_HELD_LINE));
|
|
2767
|
-
console.log(chalk.gray('Pre-warm now with: agents secrets unlock <bundle> (or --all)'));
|
|
2768
|
-
}
|
|
2769
|
-
else {
|
|
2770
|
-
console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
|
|
2771
|
-
for (const e of entries) {
|
|
2772
|
-
// A scoped hold (unlock --keys) carries the held key names, so name them
|
|
2773
|
-
// rather than leave the bundle looking whole.
|
|
2774
|
-
const scoped = e.keys?.length ? chalk.gray(` scoped: ${e.keys.join(', ')}`) : '';
|
|
2775
|
-
console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}${scoped}`);
|
|
2776
|
-
}
|
|
2777
|
-
console.log(chalk.gray('Reads of held bundles are silent; any bundle not listed prompts once on its next read.'));
|
|
2778
|
-
}
|
|
2779
|
-
// Usage hint: bundles you keep getting a Touch ID prompt for — a keychain
|
|
2780
|
-
// read that fell through to biometry (not served by the broker/session)
|
|
2781
|
-
// >= a few times in the last week, and not currently held. Unlocking each
|
|
2782
|
-
// once silences it for the hold window. This runs even when NO bundle is
|
|
2783
|
-
// held — that user re-prompts on every read and most needs the nudge.
|
|
2784
|
-
// Excludes `never`/no-ACL bundles (never prompt) and file/vault bundles
|
|
2785
|
-
// (resolve by passphrase, not the keychain broker — `unlock` is a no-op).
|
|
2786
|
-
try {
|
|
2787
|
-
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
2788
|
-
const reads = query({ eventTypes: ['secrets.get'], startDate: weekAgo });
|
|
2789
|
-
const hot = frequentlyPromptedBundles(reads, held, { minReads: 3 }).filter((h) => {
|
|
2790
|
-
try {
|
|
2791
|
-
return bundleBackend(h.name) === 'keychain' && bundlePolicy(readBundle(h.name)) !== 'never';
|
|
2792
|
-
}
|
|
2793
|
-
catch {
|
|
2794
|
-
return false; // bundle gone / unreadable metadata — nothing to suggest
|
|
2795
|
-
}
|
|
2796
|
-
});
|
|
2797
|
-
if (hot.length > 0) {
|
|
2798
|
-
console.log();
|
|
2799
|
-
console.log(chalk.bold('Prompted often — unlock once to silence:'));
|
|
2800
|
-
for (const h of hot) {
|
|
2801
|
-
console.log(`${chalk.cyan(h.name.padEnd(24))} ${chalk.gray(`${h.count}× in the last 7d`)} → ${chalk.green(`agents secrets unlock ${h.name}`)}`);
|
|
2802
|
-
}
|
|
2803
|
-
}
|
|
2804
|
-
}
|
|
2805
|
-
catch {
|
|
2806
|
-
// Best-effort hint — never let usage analysis break `secrets status`.
|
|
2807
|
-
}
|
|
2808
|
-
});
|
|
2809
|
-
cmd
|
|
2810
|
-
.command('policy <bundle> [policy]')
|
|
2811
|
-
.alias('tier')
|
|
2812
|
-
.description("Show or set a bundle's prompt policy: hold (default, ask once per hold window — secrets.agent.holdMs, 7d by default), always (ask every time), or never (silent, NO biometry ACL). 'daily'/'session' are accepted aliases for 'hold'.")
|
|
2813
|
-
.option('--i-understand', 'Confirm switching to the "never" policy (no biometry ACL) without an interactive prompt')
|
|
2814
|
-
.action(async (bundleName, policyArg, opts) => {
|
|
2815
|
-
try {
|
|
2816
|
-
const bundle = readBundle(bundleName);
|
|
2817
|
-
if (policyArg === undefined) {
|
|
2818
|
-
console.log(`${chalk.cyan(bundle.name)} policy: ${chalk.bold(bundlePolicy(bundle))}`);
|
|
2819
|
-
return;
|
|
2820
|
-
}
|
|
2821
|
-
const next = parsePolicyOpt(policyArg);
|
|
2822
|
-
// Switching to `never` drops the biometry ACL — gate it exactly like create.
|
|
2823
|
-
const ack = assertNeverPolicyAcknowledged(next, { iUnderstand: opts.iUnderstand, interactive: isInteractiveTerminal() });
|
|
2824
|
-
if (ack === 'prompt' && !(await confirmNeverPolicyInteractive(bundle.name))) {
|
|
2825
|
-
console.error(chalk.yellow('Aborted.'));
|
|
2826
|
-
return;
|
|
2827
|
-
}
|
|
2828
|
-
bundle.policy = next;
|
|
2829
|
-
// Re-store the VALUE items so their keychain ACL matches the new tier —
|
|
2830
|
-
// NOT just the metadata. macOS gates each read on the item's own ACL
|
|
2831
|
-
// (SEC-19); a metadata-only write would leave a hold/always bundle's
|
|
2832
|
-
// biometry ACL in place and keep popping Touch ID forever after a switch
|
|
2833
|
-
// to `never`. reAclBundleItems reads once (the last prompt this bundle
|
|
2834
|
-
// raises when tightening to `never`), rewrites no-ACL, and evicts any
|
|
2835
|
-
// broker-held copy so the change takes effect on the very next read.
|
|
2836
|
-
reAclBundleItems(bundle);
|
|
2837
|
-
console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
|
|
2838
|
-
if (next === 'hold') {
|
|
2839
|
-
console.log(chalk.gray('Held by the secrets-agent for ~7 days after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));
|
|
2840
|
-
}
|
|
2841
|
-
else if (next === 'always') {
|
|
2842
|
-
console.log(chalk.gray('Asks for Touch ID every time — never auto-held.'));
|
|
2843
|
-
}
|
|
2844
|
-
else {
|
|
2845
|
-
console.log(chalk.red('Stored without biometry protection — reads are silent. Automation-only.'));
|
|
2846
|
-
}
|
|
2847
|
-
}
|
|
2848
|
-
catch (err) {
|
|
2849
|
-
console.error(chalk.red(err.message));
|
|
2850
|
-
process.exit(1);
|
|
2851
|
-
}
|
|
2852
|
-
});
|
|
2853
|
-
cmd
|
|
2854
|
-
.command('start')
|
|
2855
|
-
.description('Bring up the always-on daemon that hosts the secrets broker (macOS). Survives heavy load; reads connect instantly.')
|
|
2856
|
-
.action(async () => {
|
|
2857
|
-
if (process.platform !== 'darwin') {
|
|
2858
|
-
console.error(chalk.red('The secrets broker is macOS-only.'));
|
|
2859
|
-
process.exit(1);
|
|
2860
|
-
}
|
|
2861
|
-
if (!isSecretsBrokerEnabled()) {
|
|
2862
|
-
console.error(chalk.yellow('Secrets broker is disabled — the daemon will start but will not host the broker. Re-enable with \'agents daemon services enable secrets-broker\'.'));
|
|
2863
|
-
}
|
|
2864
|
-
process.stdout.write(chalk.gray('Starting the daemon…\n'));
|
|
2865
|
-
ensureDaemonStarted();
|
|
2866
|
-
// The daemon hosts the broker socket-first; wait briefly for it to answer.
|
|
2867
|
-
const deadline = Date.now() + 10000;
|
|
2868
|
-
let reachable = (await agentPing()).reachable;
|
|
2869
|
-
while (!reachable && Date.now() < deadline) {
|
|
2870
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
2871
|
-
reachable = (await agentPing()).reachable;
|
|
2872
|
-
}
|
|
2873
|
-
if (reachable) {
|
|
2874
|
-
console.log(chalk.green('secrets broker running.') + chalk.gray(' Hosted by the always-on daemon; unlock/auto-cache now connect instantly.'));
|
|
2875
|
-
}
|
|
2876
|
-
else {
|
|
2877
|
-
console.error(chalk.red('Daemon started but the broker did not become reachable in time (machine may be heavily loaded — it will keep retrying).'));
|
|
2878
|
-
process.exit(1);
|
|
2879
|
-
}
|
|
2880
|
-
});
|
|
2881
|
-
cmd
|
|
2882
|
-
.command('stop')
|
|
2883
|
-
.description('Lock all bundles and retire any legacy standalone service. The always-on daemon (which hosts the broker) is left running.')
|
|
2884
|
-
.action(async () => {
|
|
2885
|
-
if (process.platform !== 'darwin')
|
|
2886
|
-
return;
|
|
2887
|
-
await uninstallSecretsAgentService();
|
|
2888
|
-
console.log(chalk.green('Locked all bundles.') + chalk.gray(' The broker stays hosted by the always-on daemon; a legacy standalone service, if any, was retired.'));
|
|
2889
|
-
});
|
|
2890
|
-
cmd
|
|
2891
|
-
.command('_agent-run', { hidden: true })
|
|
2892
|
-
.description('Run the secrets-agent broker in the foreground (internal)')
|
|
2893
|
-
.option('--service', 'run as a persistent launchd service (never idle-exit)')
|
|
2894
|
-
.action(async (opts) => {
|
|
2895
|
-
await runSecretsAgent({ service: Boolean(opts.service) });
|
|
2896
|
-
});
|
|
2897
|
-
cmd
|
|
2898
|
-
.command('_agent-load', { hidden: true })
|
|
2899
|
-
.description('Detached auto-cache worker: load a bundle from stdin into the broker (internal)')
|
|
2900
|
-
.action(async () => {
|
|
2901
|
-
await runAgentLoadFromStdin();
|
|
2902
|
-
});
|
|
2903
|
-
registerSecretsSyncCommands(cmd);
|
|
2904
|
-
registerSecretsMigrateAclCommand(cmd);
|
|
2905
|
-
registerSecretsImportKeyringCommand(cmd);
|
|
2906
|
-
registerSecretsRotatePassphraseCommand(cmd);
|
|
2907
|
-
}
|
|
2908
|
-
/** Validate a prompt-policy value, throwing a clear message on a bad one (the
|
|
2909
|
-
* caller's try/catch renders it and exits). Accepts the legacy `biometry` /
|
|
2910
|
-
* `session` / `none` tokens as aliases for `always` / `daily` / `never` so older
|
|
2911
|
-
* flags and scripts keep working. `never`/`none` is the no-biometry-ACL tier —
|
|
2912
|
-
* accepted here, then gated behind a loud confirmation in the command layer. */
|
|
2913
|
-
export function parsePolicyOpt(raw) {
|
|
2914
|
-
const v = (raw ?? 'always').toLowerCase();
|
|
2915
|
-
if (v === 'always' || v === 'biometry')
|
|
2916
|
-
return 'always';
|
|
2917
|
-
if (v === 'hold' || v === 'daily' || v === 'session')
|
|
2918
|
-
return 'hold';
|
|
2919
|
-
if (v === 'never' || v === 'none')
|
|
2920
|
-
return 'never';
|
|
2921
|
-
throw new Error(`Invalid policy '${raw}'. Use 'always', 'hold', or 'never'.`);
|
|
2922
|
-
}
|
|
2923
|
-
/**
|
|
2924
|
-
* Gate a create/switch to the `never` prompt-policy behind an explicit,
|
|
2925
|
-
* deliberate acknowledgement — it is the least-safe tier (no user-presence
|
|
2926
|
-
* check on any read). Returns:
|
|
2927
|
-
* - `'ok'` — not `never`, or already acknowledged via `--i-understand`.
|
|
2928
|
-
* - `'prompt'` — interactive shell: caller must run a loud confirm prompt.
|
|
2929
|
-
* Throws in a non-interactive shell when the flag is absent, so a headless
|
|
2930
|
-
* `create --policy never` can't silently downgrade a bundle's protection.
|
|
2931
|
-
*/
|
|
2932
|
-
export function assertNeverPolicyAcknowledged(policy, opts) {
|
|
2933
|
-
if (policy !== 'never')
|
|
2934
|
-
return 'ok';
|
|
2935
|
-
if (opts.iUnderstand)
|
|
2936
|
-
return 'ok';
|
|
2937
|
-
if (!opts.interactive) {
|
|
2938
|
-
throw new Error("Refusing to set the 'never' prompt-policy without confirmation. This tier stores " +
|
|
2939
|
-
'the bundle WITHOUT the biometry access control: every read is silent, with no Touch ID ' +
|
|
2940
|
-
'and no user-presence check — any code running as you can read it. Re-run with ' +
|
|
2941
|
-
'--i-understand to confirm you want an unprotected, automation-only bundle.');
|
|
2942
|
-
}
|
|
2943
|
-
return 'prompt';
|
|
2944
|
-
}
|
|
2945
|
-
/** Loud confirm prompt shown before creating/switching to `never` in a TTY.
|
|
2946
|
-
* Returns true to proceed. Kept separate from assertNeverPolicyAcknowledged so
|
|
2947
|
-
* the decision logic stays pure and unit-testable. */
|
|
2948
|
-
async function confirmNeverPolicyInteractive(bundleName) {
|
|
2949
|
-
console.error(chalk.red.bold('WARNING: policy "never" stores this bundle with NO biometry ACL.'));
|
|
2950
|
-
console.error(chalk.red(`Reads of '${bundleName}' will be fully silent — no Touch ID, no user-presence check.`));
|
|
2951
|
-
console.error(chalk.yellow('Use it only for low-sensitivity, automation-only credentials.'));
|
|
2952
|
-
const { confirm } = await import('@inquirer/prompts');
|
|
2953
|
-
return confirm({ message: `Store '${bundleName}' WITHOUT biometry protection?`, default: false });
|
|
2954
|
-
}
|
|
2955
|
-
/** Validate a --backend value, exiting with a clear message on a bad one. */
|
|
2956
|
-
export function secretsDefaultBackend() {
|
|
2957
|
-
const raw = readMeta().secrets?.backend;
|
|
2958
|
-
if (raw === undefined)
|
|
2959
|
-
return 'keychain';
|
|
2960
|
-
if (raw === 'keychain' || raw === 'file' || raw === 'vault')
|
|
2961
|
-
return raw;
|
|
2962
|
-
console.error(chalk.red(`Invalid secrets.backend '${raw}'. Use 'keychain', 'file', or 'vault'.`));
|
|
2963
|
-
process.exit(1);
|
|
2964
|
-
}
|
|
2965
|
-
function parseBackendOpt(raw, defaultBackend = 'keychain') {
|
|
2966
|
-
const v = (raw ?? defaultBackend).toLowerCase();
|
|
2967
|
-
if (!raw && v === 'vault')
|
|
2968
|
-
return 'vault';
|
|
2969
|
-
if (v === 'keychain' || v === 'file')
|
|
2970
|
-
return v;
|
|
2971
|
-
console.error(chalk.red(`Invalid --backend '${raw}'. Use 'keychain' or 'file'. For cross-machine file sync, pass --synced.`));
|
|
2972
|
-
process.exit(1);
|
|
2973
|
-
}
|
|
2974
|
-
function resolveBackendOpt(raw) {
|
|
2975
|
-
return parseBackendOpt(raw, raw === undefined ? secretsDefaultBackend() : 'keychain');
|
|
2976
|
-
}
|
|
2977
|
-
/** Human-readable "locks in 3 hours" / "locks in 5 minutes" from an epoch-ms expiry. */
|
|
2978
|
-
function humanRemaining(expiresAt) {
|
|
2979
|
-
const ms = expiresAt - Date.now();
|
|
2980
|
-
if (ms <= 0)
|
|
2981
|
-
return 'expired';
|
|
2982
|
-
const mins = Math.round(ms / 60000);
|
|
2983
|
-
if (mins < 60)
|
|
2984
|
-
return `locks in ${mins} minute${mins === 1 ? '' : 's'}`;
|
|
2985
|
-
const hours = Math.round(mins / 60);
|
|
2986
|
-
if (hours < 24)
|
|
2987
|
-
return `locks in ${hours} hour${hours === 1 ? '' : 's'}`;
|
|
2988
|
-
const days = Math.round(hours / 24);
|
|
2989
|
-
return `locks in ${days} day${days === 1 ? '' : 's'}`;
|
|
2990
|
-
}
|
|
2991
|
-
// `quoteWin32ExecArg` now lives in lib/platform/exec.ts (shared with the agent
|
|
2992
|
-
// run/shim spawn paths); re-exported here for the colocated secrets tests.
|
|
2993
|
-
export { quoteWin32ExecArg };
|
|
2994
|
-
/**
|
|
2995
|
-
* Copy text to the system clipboard, cross-platform.
|
|
2996
|
-
* macOS: `pbcopy`. Windows: `clip`. Linux: tries `wl-copy` (Wayland), then
|
|
2997
|
-
* `xclip`, then `xsel` (X11). Throws with an install hint if none are present.
|
|
2998
|
-
*/
|
|
2999
|
-
async function copyToClipboard(text) {
|
|
3000
|
-
const { spawn } = await import('child_process');
|
|
3001
|
-
const candidates = process.platform === 'darwin'
|
|
3002
|
-
? [['pbcopy', []]]
|
|
3003
|
-
: process.platform === 'win32'
|
|
3004
|
-
? [['clip', []]]
|
|
3005
|
-
: [
|
|
3006
|
-
['wl-copy', []],
|
|
3007
|
-
['xclip', ['-selection', 'clipboard']],
|
|
3008
|
-
['xsel', ['--clipboard', '--input']],
|
|
3009
|
-
];
|
|
3010
|
-
let lastErr = null;
|
|
3011
|
-
for (const [cmd, args] of candidates) {
|
|
3012
|
-
try {
|
|
3013
|
-
await new Promise((resolve, reject) => {
|
|
3014
|
-
const proc = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
3015
|
-
proc.on('error', reject);
|
|
3016
|
-
proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}`))));
|
|
3017
|
-
proc.stdin.write(text);
|
|
3018
|
-
proc.stdin.end();
|
|
3019
|
-
});
|
|
3020
|
-
return;
|
|
3021
|
-
}
|
|
3022
|
-
catch (err) {
|
|
3023
|
-
lastErr = err;
|
|
3024
|
-
}
|
|
3025
|
-
}
|
|
3026
|
-
const hint = process.platform === 'linux'
|
|
3027
|
-
? ' Install one: wl-clipboard (Wayland) or xclip / xsel (X11).'
|
|
3028
|
-
: '';
|
|
3029
|
-
throw new Error(`no clipboard tool available (${lastErr?.message ?? 'none found'}).${hint}`);
|
|
3030
|
-
}
|