@fentz26/envcp 1.1.0 → 1.2.0-exp.8d5b91b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +65 -421
  2. package/VERSION +1 -0
  3. package/dist/adapters/base.d.ts +31 -3
  4. package/dist/adapters/base.d.ts.map +1 -1
  5. package/dist/adapters/base.js +235 -26
  6. package/dist/adapters/base.js.map +1 -1
  7. package/dist/adapters/gemini.d.ts +1 -1
  8. package/dist/adapters/gemini.d.ts.map +1 -1
  9. package/dist/adapters/gemini.js +12 -7
  10. package/dist/adapters/gemini.js.map +1 -1
  11. package/dist/adapters/openai.d.ts +1 -1
  12. package/dist/adapters/openai.d.ts.map +1 -1
  13. package/dist/adapters/openai.js +14 -9
  14. package/dist/adapters/openai.js.map +1 -1
  15. package/dist/adapters/rest.d.ts +3 -1
  16. package/dist/adapters/rest.d.ts.map +1 -1
  17. package/dist/adapters/rest.js +99 -15
  18. package/dist/adapters/rest.js.map +1 -1
  19. package/dist/cli/index.js +678 -138
  20. package/dist/cli/index.js.map +1 -1
  21. package/dist/config/config-guard.d.ts.map +1 -1
  22. package/dist/config/config-guard.js +3 -2
  23. package/dist/config/config-guard.js.map +1 -1
  24. package/dist/config/config-hmac.d.ts +5 -0
  25. package/dist/config/config-hmac.d.ts.map +1 -0
  26. package/dist/config/config-hmac.js +29 -0
  27. package/dist/config/config-hmac.js.map +1 -0
  28. package/dist/config/manager.d.ts +20 -2
  29. package/dist/config/manager.d.ts.map +1 -1
  30. package/dist/config/manager.js +103 -26
  31. package/dist/config/manager.js.map +1 -1
  32. package/dist/mcp/server.d.ts +1 -1
  33. package/dist/mcp/server.d.ts.map +1 -1
  34. package/dist/mcp/server.js +8 -4
  35. package/dist/mcp/server.js.map +1 -1
  36. package/dist/server/unified.d.ts +4 -1
  37. package/dist/server/unified.d.ts.map +1 -1
  38. package/dist/server/unified.js +108 -39
  39. package/dist/server/unified.js.map +1 -1
  40. package/dist/service/config.d.ts +21 -0
  41. package/dist/service/config.d.ts.map +1 -0
  42. package/dist/service/config.js +94 -0
  43. package/dist/service/config.js.map +1 -0
  44. package/dist/service/generators.d.ts +14 -0
  45. package/dist/service/generators.d.ts.map +1 -0
  46. package/dist/service/generators.js +92 -0
  47. package/dist/service/generators.js.map +1 -0
  48. package/dist/service/index.d.ts +23 -0
  49. package/dist/service/index.d.ts.map +1 -0
  50. package/dist/service/index.js +215 -0
  51. package/dist/service/index.js.map +1 -0
  52. package/dist/service/platform.d.ts +5 -0
  53. package/dist/service/platform.d.ts.map +1 -0
  54. package/dist/service/platform.js +25 -0
  55. package/dist/service/platform.js.map +1 -0
  56. package/dist/storage/index.d.ts +52 -10
  57. package/dist/storage/index.d.ts.map +1 -1
  58. package/dist/storage/index.js +350 -31
  59. package/dist/storage/index.js.map +1 -1
  60. package/dist/types.d.ts +574 -14
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/types.js +79 -2
  63. package/dist/types.js.map +1 -1
  64. package/dist/utils/crypto.d.ts +9 -0
  65. package/dist/utils/crypto.d.ts.map +1 -1
  66. package/dist/utils/crypto.js +88 -19
  67. package/dist/utils/crypto.js.map +1 -1
  68. package/dist/utils/fs.d.ts +1 -0
  69. package/dist/utils/fs.d.ts.map +1 -1
  70. package/dist/utils/fs.js +12 -0
  71. package/dist/utils/fs.js.map +1 -1
  72. package/dist/utils/hsm.d.ts +82 -0
  73. package/dist/utils/hsm.d.ts.map +1 -0
  74. package/dist/utils/hsm.js +344 -0
  75. package/dist/utils/hsm.js.map +1 -0
  76. package/dist/utils/http.d.ts.map +1 -1
  77. package/dist/utils/http.js +14 -4
  78. package/dist/utils/http.js.map +1 -1
  79. package/dist/utils/keychain.d.ts.map +1 -1
  80. package/dist/utils/keychain.js +0 -8
  81. package/dist/utils/keychain.js.map +1 -1
  82. package/dist/utils/lock.d.ts +1 -1
  83. package/dist/utils/lock.d.ts.map +1 -1
  84. package/dist/utils/lock.js +4 -2
  85. package/dist/utils/lock.js.map +1 -1
  86. package/dist/utils/lockout.d.ts +56 -0
  87. package/dist/utils/lockout.d.ts.map +1 -0
  88. package/dist/utils/lockout.js +209 -0
  89. package/dist/utils/lockout.js.map +1 -0
  90. package/dist/utils/prompt.d.ts +13 -0
  91. package/dist/utils/prompt.d.ts.map +1 -0
  92. package/dist/utils/prompt.js +124 -0
  93. package/dist/utils/prompt.js.map +1 -0
  94. package/dist/utils/secure-memory.d.ts +20 -0
  95. package/dist/utils/secure-memory.d.ts.map +1 -0
  96. package/dist/utils/secure-memory.js +122 -0
  97. package/dist/utils/secure-memory.js.map +1 -0
  98. package/dist/utils/session.d.ts.map +1 -1
  99. package/dist/utils/session.js +14 -4
  100. package/dist/utils/session.js.map +1 -1
  101. package/dist/utils/update-checker.d.ts +1 -1
  102. package/dist/utils/update-checker.d.ts.map +1 -1
  103. package/dist/utils/update-checker.js +7 -5
  104. package/dist/utils/update-checker.js.map +1 -1
  105. package/dist/vault/index.d.ts +2 -0
  106. package/dist/vault/index.d.ts.map +1 -1
  107. package/dist/vault/index.js +12 -2
  108. package/dist/vault/index.js.map +1 -1
  109. package/dist/version.d.ts +2 -0
  110. package/dist/version.d.ts.map +1 -0
  111. package/dist/version.js +8 -0
  112. package/dist/version.js.map +1 -0
  113. package/package.json +18 -9
  114. package/scripts/sync-version.js +88 -0
  115. package/scripts/install.sh +0 -54
package/dist/cli/index.js CHANGED
@@ -1,21 +1,40 @@
1
1
  import { Command } from 'commander';
2
- import inquirer from 'inquirer';
3
2
  import chalk from 'chalk';
4
3
  import * as path from 'path';
5
4
  import * as os from 'os';
6
5
  import * as fs from 'fs/promises';
6
+ import { promptPassword, promptInput, promptConfirm, promptList } from '../utils/prompt.js';
7
7
  import { ensureDir, pathExists } from '../utils/fs.js';
8
8
  import { loadConfig, initConfig, saveConfig, parseEnvFile, registerMcpConfig, isBlacklisted, canAccess } from '../config/manager.js';
9
9
  import { ConfigGuard } from '../config/config-guard.js';
10
- import { StorageManager } from '../storage/index.js';
10
+ import { StorageManager, LogManager, resolveLogPath } from '../storage/index.js';
11
+ import { VERSION } from '../version.js';
11
12
  import { SessionManager } from '../utils/session.js';
12
13
  import { maskValue, validatePassword, encrypt, decrypt, generateRecoveryKey, createRecoveryData, recoverPassword } from '../utils/crypto.js';
13
14
  import { KeychainManager } from '../utils/keychain.js';
15
+ import { HsmManager } from '../utils/hsm.js';
14
16
  import { checkForUpdate, formatUpdateMessage, logUpdateCheck } from '../utils/update-checker.js';
15
- import { getGlobalVaultPath, getProjectVaultPath, resolveVaultPath, setActiveVault, listVaults, initNamedVault, } from '../vault/index.js';
16
- async function withSession(fn, vaultOverride) {
17
+ import { LockoutManager } from '../utils/lockout.js';
18
+ import { initMemoryProtection } from '../utils/secure-memory.js';
19
+ import { getGlobalVaultPath, getProjectVaultPath, resolveVaultPath, resolveSessionPath, setActiveVault, listVaults, initNamedVault, } from '../vault/index.js';
20
+ import { findProjectRoot } from '../utils/fs.js';
21
+ initMemoryProtection();
22
+ async function resolveCliContext(vaultOverride) {
23
+ if (vaultOverride === 'global') {
24
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
25
+ const config = await loadConfig(home);
26
+ config.vault = { ...config.vault, mode: 'global' };
27
+ return { projectPath: home, config };
28
+ }
17
29
  const projectPath = process.cwd();
18
30
  const config = await loadConfig(projectPath);
31
+ if (vaultOverride === 'project') {
32
+ config.vault = { ...config.vault, mode: 'project' };
33
+ }
34
+ return { projectPath, config };
35
+ }
36
+ async function withSession(fn, vaultOverride) {
37
+ const { projectPath, config } = await resolveCliContext(vaultOverride);
19
38
  const vaultPath = vaultOverride
20
39
  ? vaultOverride === 'global'
21
40
  ? getGlobalVaultPath(config)
@@ -23,27 +42,81 @@ async function withSession(fn, vaultOverride) {
23
42
  : await resolveVaultPath(projectPath, config);
24
43
  if (config.encryption?.enabled === false) {
25
44
  const storage = new StorageManager(vaultPath, false);
26
- await fn(storage, '', config, projectPath);
45
+ const logManager = new LogManager(resolveLogPath(config.audit, projectPath), config.audit);
46
+ await logManager.init();
47
+ await fn(storage, '', config, projectPath, logManager);
27
48
  return;
28
49
  }
29
- const sessionManager = new SessionManager(path.join(projectPath, config.session?.path || '.envcp/.session'), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
50
+ const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
30
51
  await sessionManager.init();
52
+ // Initialize audit logging
53
+ const logManager = new LogManager(resolveLogPath(config.audit, projectPath), config.audit);
54
+ await logManager.init();
31
55
  let session = await sessionManager.load();
32
56
  let password = '';
33
57
  if (!session) {
34
- if (config.keychain?.enabled) {
35
- const keychain = new KeychainManager(config.keychain.service || 'envcp');
58
+ const authMethod = config.auth?.method ?? 'password';
59
+ // --- HSM-only or multi-factor auth ---
60
+ if (authMethod === 'hsm' || authMethod === 'multi') {
61
+ const hsm = HsmManager.fromConfig(config, projectPath);
62
+ const hsmAvailable = await hsm.isAvailable();
63
+ if (hsmAvailable) {
64
+ try {
65
+ const hsmSecret = await hsm.retrieveVaultPassword();
66
+ if (authMethod === 'multi') {
67
+ const factors = config.auth?.multi_factors ?? ['password', 'hsm'];
68
+ let userPassword = '';
69
+ if (factors.includes('password')) {
70
+ userPassword = await promptPassword('Enter password (multi-factor):');
71
+ }
72
+ else if (factors.includes('keychain')) {
73
+ const keychain = new KeychainManager(config.keychain?.service || 'envcp');
74
+ const stored = await keychain.retrievePassword(projectPath);
75
+ if (stored) {
76
+ userPassword = stored;
77
+ console.log(chalk.gray('Password retrieved from OS keychain (multi-factor)'));
78
+ }
79
+ }
80
+ password = HsmManager.combineSecrets(hsmSecret, userPassword);
81
+ console.log(chalk.gray(`Authenticated via ${hsm.backendName} + password`));
82
+ }
83
+ else {
84
+ password = hsmSecret;
85
+ console.log(chalk.gray(`Authenticated via ${hsm.backendName}`));
86
+ }
87
+ }
88
+ catch (err) {
89
+ const msg = err instanceof Error ? err.message : String(err);
90
+ const fallback = config.auth?.fallback ?? 'password';
91
+ if (fallback === 'none') {
92
+ console.log(chalk.red(`HSM authentication failed: ${msg}`));
93
+ return;
94
+ }
95
+ console.log(chalk.yellow(`HSM unavailable: ${msg}`));
96
+ console.log(chalk.gray('Falling back to password...'));
97
+ }
98
+ }
99
+ else {
100
+ const fallback = config.auth?.fallback ?? 'password';
101
+ if (fallback === 'none') {
102
+ console.log(chalk.red(`HSM device (${hsm.backendName}) not found. No fallback configured.`));
103
+ return;
104
+ }
105
+ console.log(chalk.yellow(`HSM device (${hsm.backendName}) not available. Falling back to password...`));
106
+ }
107
+ }
108
+ // --- OS keychain ---
109
+ if (!password && (authMethod === 'keychain' || config.keychain?.enabled)) {
110
+ const keychain = new KeychainManager(config.keychain?.service || 'envcp');
36
111
  const stored = await keychain.retrievePassword(projectPath);
37
112
  if (stored) {
38
113
  password = stored;
39
114
  console.log(chalk.gray('Password retrieved from OS keychain'));
40
115
  }
41
116
  }
117
+ // --- Password prompt fallback ---
42
118
  if (!password) {
43
- const answer = await inquirer.prompt([
44
- { type: 'password', name: 'password', message: 'Enter password:', mask: '*' }
45
- ]);
46
- password = answer.password;
119
+ password = await promptPassword('Enter password:');
47
120
  const { valid: passwordValid, warning: passwordWarning } = validatePassword(password, config.password || {});
48
121
  if (!passwordValid) {
49
122
  console.log(chalk.red("Invalid password"));
@@ -59,13 +132,13 @@ async function withSession(fn, vaultOverride) {
59
132
  const storage = new StorageManager(vaultPath, config.storage.encrypted);
60
133
  if (password)
61
134
  storage.setPassword(password);
62
- await fn(storage, password, config, projectPath);
135
+ await fn(storage, password, config, projectPath, logManager);
63
136
  }
64
137
  const program = new Command();
65
138
  program
66
139
  .name('envcp')
67
140
  .description('Secure environment variable management for AI-assisted coding')
68
- .version('1.0.0');
141
+ .version(VERSION);
69
142
  program
70
143
  .command('init')
71
144
  .description('Initialize EnvCP in the current project')
@@ -73,60 +146,98 @@ program
73
146
  .option('--no-encrypt', 'Skip encryption (passwordless mode)')
74
147
  .option('--skip-env', 'Skip .env auto-import')
75
148
  .option('--skip-mcp', 'Skip MCP auto-registration')
149
+ .option('--auth-method <method>', 'Authentication method: password | keychain | hsm | multi (default: password)')
150
+ .option('--hsm-type <type>', 'HSM type for --auth-method hsm|multi: yubikey | gpg | pkcs11')
151
+ .option('--key-id <id>', 'GPG key ID or PKCS#11 key label for HSM auth')
152
+ .option('--pkcs11-lib <path>', 'Path to PKCS#11 shared library for --hsm-type pkcs11')
153
+ .option('--global', 'Initialize a global vault at ~/.envcp/ (config + store live in $HOME)')
154
+ .option('--force', 'Overwrite existing envcp.yaml (DESTRUCTIVE: existing vault may become inaccessible)')
76
155
  .action(async (options) => {
77
- const projectPath = process.cwd();
78
- const projectName = options.project || path.basename(projectPath);
79
- console.log(chalk.blue('Initializing EnvCP...'));
156
+ const useGlobal = !!options.global;
157
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
158
+ const projectPath = useGlobal ? home : process.cwd();
159
+ const projectName = options.project || (useGlobal ? 'global' : path.basename(projectPath));
160
+ const configPath = path.join(projectPath, 'envcp.yaml');
161
+ if (await pathExists(configPath) && !options.force) {
162
+ const vaultRelPath = path.join('.envcp', 'store.enc');
163
+ console.log(chalk.yellow('EnvCP is already initialized here.'));
164
+ console.log(chalk.gray(` Config: ${configPath}`));
165
+ if (await pathExists(path.join(projectPath, vaultRelPath))) {
166
+ console.log(chalk.gray(` Vault: ${path.join(projectPath, vaultRelPath)}`));
167
+ }
168
+ console.log('');
169
+ console.log(chalk.gray('Running init again would overwrite your config and may make'));
170
+ console.log(chalk.gray('existing encrypted variables permanently inaccessible.'));
171
+ console.log('');
172
+ console.log(chalk.gray('To reconfigure: edit envcp.yaml directly'));
173
+ console.log(chalk.gray('To force re-init (DESTRUCTIVE): envcp init --force'));
174
+ process.exit(1);
175
+ }
176
+ console.log(chalk.blue(useGlobal ? 'Initializing EnvCP (global vault)...' : 'Initializing EnvCP...'));
80
177
  console.log('');
81
- const config = await initConfig(projectPath, projectName);
178
+ const config = await initConfig(projectPath, projectName, { global: useGlobal });
82
179
  // Single security question (or skip if --no-encrypt)
83
180
  let securityChoice;
84
181
  if (options.encrypt === false) {
85
182
  securityChoice = 'none';
86
183
  }
87
184
  else {
88
- const { mode } = await inquirer.prompt([
89
- {
90
- type: 'list',
91
- name: 'mode',
92
- message: 'How would you like to secure your variables?',
93
- choices: [
94
- { name: 'No encryption (fastest setup, for local dev)', value: 'none' },
95
- { name: 'Encrypted with recovery key (recommended)', value: 'recoverable' },
96
- { name: 'Encrypted hard-lock (max security, no recovery)', value: 'hard-lock' },
97
- ],
98
- default: 'recoverable',
99
- }
100
- ]);
101
- securityChoice = mode;
185
+ securityChoice = await promptList('How would you like to secure your variables?', [
186
+ { name: 'No encryption (fastest setup, for local dev)', value: 'none' },
187
+ { name: 'Encrypted with recovery key (recommended)', value: 'recoverable' },
188
+ { name: 'Encrypted hard-lock (max security, no recovery)', value: 'hard-lock' },
189
+ ], 'recoverable');
102
190
  }
103
191
  // Apply security choice to config
104
192
  if (securityChoice === 'none') {
105
193
  config.encryption = { enabled: false };
106
194
  config.storage.encrypted = false;
107
- config.security = { mode: 'recoverable', recovery_file: '.envcp/.recovery' };
195
+ config.security = {
196
+ mode: 'recoverable',
197
+ recovery_file: '.envcp/.recovery',
198
+ brute_force_protection: {
199
+ enabled: true,
200
+ max_attempts: 5,
201
+ lockout_duration: 300,
202
+ progressive_delay: true,
203
+ max_delay: 60,
204
+ permanent_lockout_threshold: 50,
205
+ permanent_lockout_action: 'require_recovery_key',
206
+ notifications: {}
207
+ }
208
+ };
108
209
  }
109
210
  else {
110
211
  config.encryption = { enabled: true };
111
212
  config.storage.encrypted = true;
112
- config.security = { mode: securityChoice, recovery_file: '.envcp/.recovery' };
213
+ config.security = {
214
+ mode: securityChoice,
215
+ recovery_file: '.envcp/.recovery',
216
+ brute_force_protection: {
217
+ enabled: true,
218
+ max_attempts: 5,
219
+ lockout_duration: 300,
220
+ progressive_delay: true,
221
+ max_delay: 60,
222
+ permanent_lockout_threshold: 50,
223
+ permanent_lockout_action: 'require_recovery_key',
224
+ notifications: {},
225
+ }
226
+ };
113
227
  }
114
228
  // For encrypted modes: get password now
115
229
  let pwd = '';
116
230
  if (securityChoice !== 'none') {
117
- const { password } = await inquirer.prompt([
118
- { type: 'password', name: 'password', message: 'Set encryption password:', mask: '*' }
119
- ]);
120
- const { confirm } = await inquirer.prompt([
121
- { type: 'password', name: 'confirm', message: 'Confirm password:', mask: '*' }
122
- ]);
123
- if (password !== confirm) {
231
+ const password = await promptPassword('Set encryption password:');
232
+ const confirmPwd = await promptPassword('Confirm password:');
233
+ // eslint-disable-next-line security/detect-possible-timing-attacks -- comparing two user-typed confirm fields, not a secret-vs-known value
234
+ if (password !== confirmPwd) {
124
235
  console.log(chalk.red('Passwords do not match. Aborting.'));
125
236
  return;
126
237
  }
127
238
  pwd = password;
128
239
  }
129
- await saveConfig(config, projectPath);
240
+ await saveConfig(config, projectPath, { global: useGlobal });
130
241
  const modeLabel = securityChoice === 'none' ? 'no encryption' : securityChoice;
131
242
  console.log(chalk.green('EnvCP initialized!'));
132
243
  console.log(chalk.gray(` Project: ${config.project}`));
@@ -146,18 +257,20 @@ program
146
257
  if (pwd)
147
258
  storage.setPassword(pwd);
148
259
  const now = new Date().toISOString();
260
+ const existing = await storage.load();
149
261
  for (const [name, value] of Object.entries(vars)) {
150
- await storage.set(name, {
262
+ existing[name] = {
151
263
  name, value,
152
264
  encrypted: config.storage.encrypted,
153
265
  created: now, updated: now,
154
266
  sync_to_env: true,
155
267
  protected: false,
156
- });
268
+ };
157
269
  }
270
+ await storage.save(existing);
158
271
  // Create session for encrypted mode
159
272
  if (pwd) {
160
- const sessionManager = new SessionManager(path.join(projectPath, config.session?.path || '.envcp/.session'), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
273
+ const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
161
274
  await sessionManager.init();
162
275
  await sessionManager.create(pwd);
163
276
  }
@@ -202,6 +315,45 @@ program
202
315
  console.log(chalk.gray(' No AI tools detected for auto-registration'));
203
316
  }
204
317
  }
318
+ // Setup HSM authentication if --auth-method hsm|multi provided
319
+ const authMethod = options.authMethod;
320
+ if (pwd && (authMethod === 'hsm' || authMethod === 'multi')) {
321
+ const hsmType = options.hsmType || 'yubikey';
322
+ config.hsm = {
323
+ ...config.hsm,
324
+ enabled: true,
325
+ type: hsmType,
326
+ key_id: options.keyId ?? config.hsm?.key_id,
327
+ pkcs11_lib: options.pkcs11Lib ?? config.hsm?.pkcs11_lib,
328
+ require_touch: config.hsm?.require_touch ?? true,
329
+ protected_key_path: config.hsm?.protected_key_path ?? '.envcp/.hsm-key',
330
+ };
331
+ config.auth = {
332
+ method: authMethod,
333
+ multi_factors: authMethod === 'multi' ? ['password', 'hsm'] : ['hsm'],
334
+ fallback: 'password',
335
+ };
336
+ const hsm = HsmManager.fromConfig(config, projectPath);
337
+ if (await hsm.isAvailable()) {
338
+ try {
339
+ await hsm.protectVaultPassword(pwd);
340
+ await saveConfig(config, projectPath, { global: useGlobal });
341
+ console.log('');
342
+ console.log(chalk.green(` Vault password protected by ${hsm.backendName}`));
343
+ console.log(chalk.gray(` Future sessions will authenticate via hardware`));
344
+ }
345
+ catch (err) {
346
+ console.log(chalk.yellow(` HSM setup warning: ${err instanceof Error ? err.message : String(err)}`));
347
+ console.log(chalk.gray(' Falling back to password authentication'));
348
+ }
349
+ }
350
+ else {
351
+ console.log(chalk.yellow(` HSM device (${hsm.backendName}) not available at init time.`));
352
+ console.log(chalk.gray(' Run "envcp unlock --setup-hsm" later to enable hardware authentication.'));
353
+ config.auth = { method: authMethod, multi_factors: ['password', 'hsm'], fallback: 'password' };
354
+ await saveConfig(config, projectPath, { global: useGlobal });
355
+ }
356
+ }
205
357
  console.log('');
206
358
  console.log(chalk.green('Done! Your AI tools can now use EnvCP.'));
207
359
  });
@@ -209,21 +361,18 @@ program
209
361
  .command('unlock')
210
362
  .description('Unlock EnvCP session with password')
211
363
  .option('-p, --password <password>', 'Password (will prompt if not provided)')
364
+ .option('--recovery-key <key>', 'Recovery key to clear permanent lockout')
212
365
  .option('--save-to-keychain', 'Save password to OS keychain for auto-unlock')
366
+ .option('--setup-hsm', 'Protect vault password with hardware security module')
367
+ .option('--hsm-type <type>', 'HSM type: yubikey | gpg | pkcs11 (default: yubikey)')
368
+ .option('--key-id <id>', 'GPG key ID or PKCS#11 key label')
369
+ .option('--pkcs11-lib <path>', 'Path to PKCS#11 shared library (.so / .dll)')
370
+ .option('--global', 'Unlock the global vault at ~/.envcp')
213
371
  .action(async (options) => {
214
- const projectPath = process.cwd();
215
- const config = await loadConfig(projectPath);
372
+ const { projectPath, config } = await resolveCliContext(options.global ? 'global' : undefined);
216
373
  let password = options.password;
217
374
  if (!password) {
218
- const answer = await inquirer.prompt([
219
- {
220
- type: 'password',
221
- name: 'password',
222
- message: 'Enter password:',
223
- mask: '*'
224
- }
225
- ]);
226
- password = answer.password;
375
+ password = await promptPassword('Enter password:');
227
376
  }
228
377
  const { valid: passwordValid, warning: passwordWarning } = validatePassword(password, config.password || {});
229
378
  if (!passwordValid) {
@@ -234,16 +383,116 @@ program
234
383
  if (passwordValid && passwordWarning) {
235
384
  console.log(chalk.yellow('⚠ Weak password detected'));
236
385
  }
237
- const sessionManager = new SessionManager(path.join(projectPath, config.session?.path || '.envcp/.session'), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
386
+ const sessionDir = path.dirname(resolveSessionPath(projectPath, config));
387
+ let lockoutManager = new LockoutManager(path.join(sessionDir, '.lockout'));
388
+ // Handle recovery key if provided
389
+ if (options.recoveryKey) {
390
+ const recoveryPath = path.join(projectPath, config.security?.recovery_file || '.envcp/.recovery');
391
+ if (!await pathExists(recoveryPath)) {
392
+ console.log(chalk.red('No recovery file found.'));
393
+ return;
394
+ }
395
+ const recoveryData = await fs.readFile(recoveryPath, 'utf8');
396
+ try {
397
+ await recoverPassword(recoveryData, options.recoveryKey);
398
+ // Recovery key is valid - clear permanent lockout
399
+ await lockoutManager.clearPermanentLockout();
400
+ console.log(chalk.green('Permanent lockout cleared.'));
401
+ // Log recovery event
402
+ const logManager = new LogManager(resolveLogPath(config.audit, projectPath), config.audit);
403
+ await logManager.init();
404
+ await logManager.log({
405
+ timestamp: new Date().toISOString(),
406
+ operation: 'unlock',
407
+ variable: '',
408
+ source: 'cli',
409
+ success: true,
410
+ message: 'Permanent lockout cleared with recovery key (via --recovery-key flag)',
411
+ session_id: '',
412
+ client_id: 'cli',
413
+ client_type: 'terminal',
414
+ ip: '127.0.0.1'
415
+ });
416
+ console.log(chalk.yellow('Note: You still need to enter the correct password.'));
417
+ // Continue with password prompt
418
+ }
419
+ catch {
420
+ console.log(chalk.red('Invalid recovery key.'));
421
+ return;
422
+ }
423
+ }
424
+ // Use new brute_force_protection config if available, fall back to session config
425
+ const bfpConfig = config.security?.brute_force_protection;
426
+ const lockoutThreshold = bfpConfig?.max_attempts ?? config.session?.lockout_threshold ?? 5;
427
+ const lockoutBaseSeconds = bfpConfig?.lockout_duration ?? config.session?.lockout_base_seconds ?? 60;
428
+ const progressiveDelay = bfpConfig?.progressive_delay ?? true;
429
+ const maxDelay = bfpConfig?.max_delay ?? 60;
430
+ const permanentThreshold = bfpConfig?.permanent_lockout_threshold ?? 0;
431
+ const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
238
432
  await sessionManager.init();
239
- const storage = new StorageManager(path.join(projectPath, config.storage.path), config.storage.encrypted);
433
+ // Initialize audit logging
434
+ const logManager = new LogManager(resolveLogPath(config.audit, projectPath), config.audit);
435
+ await logManager.init();
436
+ const vaultPathForUnlock = await resolveVaultPath(projectPath, config);
437
+ const storage = new StorageManager(vaultPathForUnlock, config.storage.encrypted);
240
438
  storage.setPassword(password);
241
439
  const storeExists = await storage.exists();
440
+ // Check lockout before attempting password verification on existing stores
441
+ if (storeExists) {
442
+ const lockoutStatus = await lockoutManager.check();
443
+ if (lockoutStatus.locked) {
444
+ if (lockoutStatus.permanent_locked) {
445
+ console.log(chalk.red.bold('PERMANENT LOCKOUT: Too many failed attempts.'));
446
+ console.log(chalk.red('Recovery key or administrator intervention required.'));
447
+ const useRecovery = await promptConfirm('Use recovery key to clear lockout?', true);
448
+ if (useRecovery) {
449
+ const recoveryKey = await promptPassword('Enter recovery key:');
450
+ const recoveryPath = path.join(projectPath, config.security?.recovery_file || '.envcp/.recovery');
451
+ if (!await pathExists(recoveryPath)) {
452
+ console.log(chalk.red('No recovery file found.'));
453
+ return;
454
+ }
455
+ const recoveryData = await fs.readFile(recoveryPath, 'utf8');
456
+ try {
457
+ await recoverPassword(recoveryData, recoveryKey);
458
+ // Recovery key is valid - clear permanent lockout
459
+ await lockoutManager.clearPermanentLockout();
460
+ console.log(chalk.green('Permanent lockout cleared. You can now attempt to unlock.'));
461
+ console.log(chalk.yellow('Note: You still need to enter the correct password.'));
462
+ // Log recovery event
463
+ await logManager.log({
464
+ timestamp: new Date().toISOString(),
465
+ operation: 'unlock',
466
+ variable: '',
467
+ source: 'cli',
468
+ success: true,
469
+ message: 'Permanent lockout cleared with recovery key',
470
+ session_id: '',
471
+ client_id: 'cli',
472
+ client_type: 'terminal',
473
+ ip: '127.0.0.1'
474
+ });
475
+ // Continue with password prompt
476
+ }
477
+ catch {
478
+ console.log(chalk.red('Invalid recovery key.'));
479
+ return;
480
+ }
481
+ }
482
+ else {
483
+ return;
484
+ }
485
+ }
486
+ else {
487
+ console.log(chalk.red(`Too many failed attempts. Try again in ${lockoutStatus.remaining_seconds} second(s).`));
488
+ return;
489
+ }
490
+ }
491
+ }
242
492
  if (!storeExists) {
243
- const confirm = await inquirer.prompt([
244
- { type: 'password', name: 'password', message: 'Confirm password:', mask: '*' }
245
- ]);
246
- if (confirm.password !== password) {
493
+ const confirmPasswordValue = await promptPassword('Confirm password:');
494
+ // eslint-disable-next-line security/detect-possible-timing-attacks -- comparing two user-typed confirm fields, not a secret-vs-known value
495
+ if (confirmPasswordValue !== password) {
247
496
  console.log(chalk.red('Passwords do not match'));
248
497
  return;
249
498
  }
@@ -267,10 +516,85 @@ program
267
516
  await storage.load();
268
517
  }
269
518
  catch (error) {
270
- console.log(chalk.red('Invalid password'));
519
+ if (storeExists) {
520
+ const status = await lockoutManager.recordFailure(lockoutThreshold, lockoutBaseSeconds, progressiveDelay, maxDelay, permanentThreshold);
521
+ // Log the failed attempt
522
+ await logManager.log({
523
+ timestamp: new Date().toISOString(),
524
+ operation: 'auth_failure',
525
+ variable: '',
526
+ source: 'cli',
527
+ success: false,
528
+ message: `Failed unlock attempt (attempt ${status.attempts})`,
529
+ session_id: '',
530
+ client_id: 'cli',
531
+ client_type: 'terminal',
532
+ ip: '127.0.0.1'
533
+ });
534
+ if (status.permanent_locked) {
535
+ console.log(chalk.red.bold('PERMANENT LOCKOUT TRIGGERED: Too many failed attempts.'));
536
+ console.log(chalk.red('Recovery key or administrator intervention required.'));
537
+ // Log permanent lockout event
538
+ await logManager.log({
539
+ timestamp: new Date().toISOString(),
540
+ operation: 'permanent_lockout',
541
+ variable: '',
542
+ source: 'cli',
543
+ success: false,
544
+ message: `Permanent lockout triggered after ${status.permanent_lockout_count} lockouts`,
545
+ session_id: '',
546
+ client_id: 'cli',
547
+ client_type: 'terminal',
548
+ ip: '127.0.0.1'
549
+ });
550
+ }
551
+ else if (status.locked) {
552
+ console.log(chalk.red(`Invalid password. Too many failed attempts — locked out for ${status.remaining_seconds} second(s).`));
553
+ // Log lockout event
554
+ await logManager.log({
555
+ timestamp: new Date().toISOString(),
556
+ operation: 'lockout_triggered',
557
+ variable: '',
558
+ source: 'cli',
559
+ success: false,
560
+ message: `Lockout triggered for ${status.remaining_seconds}s (lockout #${status.lockout_count})`,
561
+ session_id: '',
562
+ client_id: 'cli',
563
+ client_type: 'terminal',
564
+ ip: '127.0.0.1'
565
+ });
566
+ }
567
+ else {
568
+ const remaining = lockoutThreshold - status.attempts;
569
+ let message = `Invalid password. ${remaining} attempt(s) remaining before lockout.`;
570
+ // Show progressive delay if applied
571
+ if (status.delay_seconds && status.delay_seconds > 0) {
572
+ message += ` (Delayed ${status.delay_seconds}s)`;
573
+ }
574
+ console.log(chalk.red(message));
575
+ }
576
+ }
577
+ else {
578
+ console.log(chalk.red('Invalid password'));
579
+ }
271
580
  return;
272
581
  }
582
+ // Successful unlock — clear any lockout state
583
+ await lockoutManager.reset();
273
584
  const session = await sessionManager.create(password);
585
+ // Log successful unlock
586
+ await logManager.log({
587
+ timestamp: new Date().toISOString(),
588
+ operation: 'unlock',
589
+ variable: '',
590
+ source: 'cli',
591
+ success: true,
592
+ message: 'Session unlocked successfully',
593
+ session_id: session.id,
594
+ client_id: 'cli',
595
+ client_type: 'terminal',
596
+ ip: '127.0.0.1'
597
+ });
274
598
  console.log(chalk.green('Session unlocked!'));
275
599
  console.log(chalk.gray(` Session ID: ${session.id}`));
276
600
  console.log(chalk.gray(` Expires in: ${config.session?.timeout_minutes || 30} minutes`));
@@ -296,14 +620,43 @@ program
296
620
  console.log(chalk.red(`OS keychain not available (${keychain.backendName})`));
297
621
  }
298
622
  }
623
+ // Setup HSM if requested
624
+ if (options.setupHsm) {
625
+ const hsmType = options.hsmType || 'yubikey';
626
+ config.hsm = {
627
+ ...config.hsm,
628
+ enabled: true,
629
+ type: hsmType,
630
+ key_id: options.keyId ?? config.hsm?.key_id,
631
+ pkcs11_lib: options.pkcs11Lib ?? config.hsm?.pkcs11_lib,
632
+ require_touch: config.hsm?.require_touch ?? true,
633
+ protected_key_path: config.hsm?.protected_key_path ?? '.envcp/.hsm-key',
634
+ };
635
+ config.auth = { ...config.auth, method: 'hsm', fallback: config.auth?.fallback ?? 'password' };
636
+ const hsm = HsmManager.fromConfig(config, projectPath);
637
+ if (!await hsm.isAvailable()) {
638
+ console.log(chalk.red(`HSM device (${hsm.backendName}) not available. Aborting HSM setup.`));
639
+ return;
640
+ }
641
+ try {
642
+ await hsm.protectVaultPassword(password);
643
+ await saveConfig(config, projectPath);
644
+ console.log(chalk.green(`Vault password protected by ${hsm.backendName}`));
645
+ console.log(chalk.gray(` Key file: ${config.hsm.protected_key_path}`));
646
+ console.log(chalk.gray(' Future sessions will authenticate via hardware'));
647
+ }
648
+ catch (err) {
649
+ console.log(chalk.red(`HSM setup failed: ${err instanceof Error ? err.message : String(err)}`));
650
+ }
651
+ }
299
652
  });
300
653
  program
301
654
  .command('lock')
302
655
  .description('Lock EnvCP session')
303
- .action(async () => {
304
- const projectPath = process.cwd();
305
- const config = await loadConfig(projectPath);
306
- const sessionManager = new SessionManager(path.join(projectPath, config.session?.path || '.envcp/.session'), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
656
+ .option('--global', 'Lock the global vault session at ~/.envcp/.session')
657
+ .action(async (options) => {
658
+ const { projectPath, config } = await resolveCliContext(options.global ? 'global' : undefined);
659
+ const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
307
660
  await sessionManager.init();
308
661
  await sessionManager.destroy();
309
662
  console.log(chalk.green('Session locked'));
@@ -311,15 +664,13 @@ program
311
664
  program
312
665
  .command('status')
313
666
  .description('Check session status')
314
- .action(async () => {
315
- const projectPath = process.cwd();
316
- const config = await loadConfig(projectPath);
317
- const sessionManager = new SessionManager(path.join(projectPath, config.session?.path || '.envcp/.session'), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
667
+ .option('--global', 'Check status of the global vault session at ~/.envcp/.session')
668
+ .action(async (options) => {
669
+ const { projectPath, config } = await resolveCliContext(options.global ? 'global' : undefined);
670
+ const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
318
671
  await sessionManager.init();
319
- const answer = await inquirer.prompt([
320
- { type: 'password', name: 'password', message: 'Enter password:', mask: '*' }
321
- ]);
322
- const session = await sessionManager.load(answer.password);
672
+ const password = await promptPassword('Enter password:');
673
+ const session = await sessionManager.load(password);
323
674
  if (!session) {
324
675
  console.log(chalk.yellow('No active session (expired, invalid password, or not unlocked)'));
325
676
  console.log(chalk.gray('Run: envcp unlock'));
@@ -340,9 +691,7 @@ program
340
691
  .action(async () => {
341
692
  const projectPath = process.cwd();
342
693
  const configGuard = new ConfigGuard(projectPath);
343
- const { password } = await inquirer.prompt([
344
- { type: 'password', name: 'password', message: 'Enter password to reload config:', mask: '*' }
345
- ]);
694
+ const password = await promptPassword('Enter password to reload config:');
346
695
  const result = await configGuard.reload(password);
347
696
  if (result.success) {
348
697
  console.log(chalk.green('Config reloaded successfully'));
@@ -355,15 +704,13 @@ program
355
704
  program
356
705
  .command('extend')
357
706
  .description('Extend session timeout')
358
- .action(async () => {
359
- const projectPath = process.cwd();
360
- const config = await loadConfig(projectPath);
361
- const sessionManager = new SessionManager(path.join(projectPath, config.session?.path || '.envcp/.session'), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
707
+ .option('--global', 'Extend the global vault session at ~/.envcp/.session')
708
+ .action(async (options) => {
709
+ const { projectPath, config } = await resolveCliContext(options.global ? 'global' : undefined);
710
+ const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
362
711
  await sessionManager.init();
363
- const answer = await inquirer.prompt([
364
- { type: 'password', name: 'password', message: 'Enter password:', mask: '*' }
365
- ]);
366
- const loaded = await sessionManager.load(answer.password);
712
+ const password = await promptPassword('Enter password:');
713
+ const loaded = await sessionManager.load(password);
367
714
  if (!loaded) {
368
715
  console.log(chalk.red('Cannot extend session. No active session or invalid password.'));
369
716
  return;
@@ -394,9 +741,7 @@ program
394
741
  console.log(chalk.red('No recovery file found. Recovery is not available.'));
395
742
  return;
396
743
  }
397
- const { recoveryKey } = await inquirer.prompt([
398
- { type: 'password', name: 'recoveryKey', message: 'Enter your recovery key:', mask: '*' }
399
- ]);
744
+ const recoveryKey = await promptPassword('Enter your recovery key:');
400
745
  const recoveryData = await fs.readFile(recoveryPath, 'utf8');
401
746
  let oldPassword;
402
747
  try {
@@ -419,12 +764,8 @@ program
419
764
  }
420
765
  console.log(chalk.green('Recovery key verified. Store contains ' + Object.keys(variables).length + ' variables.'));
421
766
  // Set new password
422
- const { newPassword } = await inquirer.prompt([
423
- { type: 'password', name: 'newPassword', message: 'Set new password:', mask: '*' }
424
- ]);
425
- const { confirmPassword } = await inquirer.prompt([
426
- { type: 'password', name: 'confirmPassword', message: 'Confirm new password:', mask: '*' }
427
- ]);
767
+ const newPassword = await promptPassword('Set new password:');
768
+ const confirmPassword = await promptPassword('Confirm new password:');
428
769
  if (newPassword !== confirmPassword) {
429
770
  console.log(chalk.red('Passwords do not match'));
430
771
  return;
@@ -443,7 +784,7 @@ program
443
784
  console.log(chalk.yellow.bold(` ${newRecoveryKey}`));
444
785
  console.log(chalk.gray('Your old recovery key no longer works.'));
445
786
  // Create a session with new password
446
- const sessionManager = new SessionManager(path.join(projectPath, config.session?.path || '.envcp/.session'), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
787
+ const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
447
788
  await sessionManager.init();
448
789
  await sessionManager.create(newPassword);
449
790
  console.log(chalk.green('Session unlocked with new password.'));
@@ -480,27 +821,82 @@ program
480
821
  program
481
822
  .command('add <name>')
482
823
  .description('Add a new environment variable')
483
- .option('-v, --value <value>', 'Variable value')
824
+ .option('-v, --value <value>', 'Variable value (WARNING: leaks in shell history; prefer --from-env/--from-file/--stdin)')
825
+ .option('--from-env <envVar>', 'Read value from the named environment variable')
826
+ .option('--from-file <path>', 'Read value from a file (trailing newline trimmed)')
827
+ .option('--stdin', 'Read value from piped stdin')
484
828
  .option('-t, --tags <tags>', 'Tags (comma-separated)')
485
829
  .option('-d, --description <desc>', 'Description')
486
830
  .action(async (name, options) => {
831
+ const sourceFlags = [
832
+ options.value !== undefined ? '--value' : null,
833
+ options.fromEnv !== undefined ? '--from-env' : null,
834
+ options.fromFile !== undefined ? '--from-file' : null,
835
+ options.stdin ? '--stdin' : null,
836
+ ].filter(Boolean);
837
+ if (sourceFlags.length > 1) {
838
+ console.error(chalk.red(`Error: ${sourceFlags.join(', ')} are mutually exclusive — pick one`));
839
+ process.exit(1);
840
+ }
841
+ let value;
842
+ let sourced = false;
843
+ if (options.value !== undefined) {
844
+ value = options.value;
845
+ sourced = true;
846
+ if (process.stdout.isTTY) {
847
+ console.warn(chalk.yellow('⚠ --value is visible in shell history and process list. Use --from-env, --from-file, or --stdin for secrets.'));
848
+ }
849
+ }
850
+ else if (options.fromEnv) {
851
+ const envValue = process.env[options.fromEnv];
852
+ if (envValue === undefined) {
853
+ console.error(chalk.red(`Error: environment variable '${options.fromEnv}' is not set`));
854
+ process.exit(1);
855
+ }
856
+ value = envValue;
857
+ sourced = true;
858
+ }
859
+ else if (options.fromFile) {
860
+ try {
861
+ const raw = await fs.readFile(options.fromFile, 'utf-8');
862
+ value = raw.replace(/\r?\n$/, '');
863
+ sourced = true;
864
+ }
865
+ catch (err) {
866
+ const msg = err instanceof Error ? err.message : String(err);
867
+ console.error(chalk.red(`Error: cannot read '${options.fromFile}': ${msg}`));
868
+ process.exit(1);
869
+ }
870
+ }
871
+ else if (options.stdin) {
872
+ if (process.stdin.isTTY) {
873
+ console.error(chalk.red('Error: --stdin requires piped input (e.g., `echo "$SECRET" | envcp add NAME --stdin`)'));
874
+ process.exit(1);
875
+ }
876
+ value = await new Promise((resolve, reject) => {
877
+ const chunks = [];
878
+ process.stdin.on('data', (c) => chunks.push(typeof c === 'string' ? Buffer.from(c) : c));
879
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8').replace(/\r?\n$/, '')));
880
+ process.stdin.on('error', reject);
881
+ });
882
+ sourced = true;
883
+ }
487
884
  await withSession(async (storage, _password, config) => {
488
- let value = options.value;
489
885
  let tags = [];
490
886
  let description = options.description;
491
- if (!value) {
492
- const answers = await inquirer.prompt([
493
- { type: 'password', name: 'value', message: 'Enter value:', mask: '*' },
494
- { type: 'input', name: 'tags', message: 'Tags (comma-separated):' },
495
- { type: 'input', name: 'description', message: 'Description:' },
496
- ]);
497
- value = answers.value;
498
- tags = answers.tags.split(',').map((t) => t.trim()).filter(Boolean);
499
- description = answers.description;
887
+ if (!sourced) {
888
+ value = await promptPassword('Enter value:');
889
+ const tagsInput = await promptInput('Tags (comma-separated):');
890
+ tags = tagsInput.split(',').map((t) => t.trim()).filter(Boolean);
891
+ description = await promptInput('Description:');
500
892
  }
501
893
  else if (options.tags) {
502
894
  tags = options.tags.split(',').map((t) => t.trim()).filter(Boolean);
503
895
  }
896
+ if (value === undefined) {
897
+ console.error(chalk.red('Error: no value provided'));
898
+ process.exit(1);
899
+ }
504
900
  const now = new Date().toISOString();
505
901
  const variable = {
506
902
  name,
@@ -606,7 +1002,8 @@ program
606
1002
  if (!variable.sync_to_env)
607
1003
  continue;
608
1004
  const excluded = config.sync.exclude?.some((pattern) => {
609
- const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
1005
+ // eslint-disable-next-line security/detect-non-literal-regexp -- glob pattern from config; metacharacters escaped above
1006
+ const regex = new RegExp('^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
610
1007
  return regex.test(name);
611
1008
  });
612
1009
  if (excluded)
@@ -631,7 +1028,8 @@ program
631
1028
  if (!variable.sync_to_env)
632
1029
  continue;
633
1030
  const excluded = config.sync.exclude?.some((pattern) => {
634
- const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
1031
+ // eslint-disable-next-line security/detect-non-literal-regexp -- glob pattern from config; metacharacters escaped above
1032
+ const regex = new RegExp('^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
635
1033
  return regex.test(name);
636
1034
  });
637
1035
  if (excluded)
@@ -680,10 +1078,40 @@ program
680
1078
  .option('--port <port>', 'HTTP port (for non-MCP modes)', '3456')
681
1079
  .option('--host <host>', 'HTTP host', '127.0.0.1')
682
1080
  .option('-k, --api-key <key>', 'API key for HTTP authentication')
1081
+ .option('--global', 'Force the global vault at ~/.envcp (skip project lookup)')
683
1082
  .action(async (options) => {
684
- const projectPath = process.cwd();
1083
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
1084
+ const globalConfigPath = path.join(home, '.envcp', 'config.yaml');
1085
+ // Resolve where the vault lives: --global forces home; otherwise walk up
1086
+ // from cwd looking for envcp.yaml; otherwise fall back to a global install.
1087
+ let projectPath;
1088
+ let forceGlobalMode = false;
1089
+ if (options.global) {
1090
+ projectPath = home;
1091
+ forceGlobalMode = true;
1092
+ }
1093
+ else {
1094
+ const found = await findProjectRoot(process.cwd());
1095
+ if (found) {
1096
+ projectPath = found;
1097
+ }
1098
+ else if (await pathExists(globalConfigPath)) {
1099
+ projectPath = home;
1100
+ forceGlobalMode = true;
1101
+ }
1102
+ else {
1103
+ process.stderr.write('Error: No envcp.yaml found in cwd or any ancestor, and no global config at ~/.envcp/config.yaml.\n' +
1104
+ 'Run `envcp init` (project) or `envcp init --global` first.\n');
1105
+ process.exit(1);
1106
+ }
1107
+ }
685
1108
  const configGuard = new ConfigGuard(projectPath);
686
1109
  const config = await configGuard.loadAndLock();
1110
+ if (forceGlobalMode) {
1111
+ config.vault = { ...config.vault, mode: 'global' };
1112
+ }
1113
+ const vaultPath = await resolveVaultPath(projectPath, config);
1114
+ const sessionPath = resolveSessionPath(projectPath, config);
687
1115
  const mode = options.mode;
688
1116
  const port = parseInt(options.port, 10);
689
1117
  const host = options.host;
@@ -693,26 +1121,23 @@ program
693
1121
  if (config.encryption?.enabled === false) {
694
1122
  if (mode === 'mcp') {
695
1123
  const { EnvCPServer } = await import('../mcp/server.js');
696
- const server = new EnvCPServer(config, projectPath);
1124
+ const server = new EnvCPServer(config, projectPath, undefined, vaultPath, sessionPath);
697
1125
  await server.start();
698
1126
  return;
699
1127
  }
700
1128
  }
701
1129
  else {
702
1130
  // Encrypted mode: need password
703
- const sessionManager = new SessionManager(path.join(projectPath, config.session?.path || '.envcp/.session'), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
1131
+ const sessionManager = new SessionManager(sessionPath, config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
704
1132
  await sessionManager.init();
705
1133
  let session = await sessionManager.load();
706
1134
  if (!session && !password) {
707
1135
  // MCP mode uses stdio — can't prompt interactively
708
1136
  if (mode === 'mcp') {
709
- process.stderr.write('Error: No active session. Run `envcp unlock` first, or use --password flag.\n');
1137
+ process.stderr.write(`Error: No active session at ${sessionPath}. Run \`envcp unlock${forceGlobalMode ? ' --global' : ''}\` first, or use --password flag.\n`);
710
1138
  process.exit(1);
711
1139
  }
712
- const answer = await inquirer.prompt([
713
- { type: 'password', name: 'password', message: 'Enter password:', mask: '*' }
714
- ]);
715
- password = answer.password;
1140
+ password = await promptPassword('Enter password:');
716
1141
  const { valid: passwordValid, warning: passwordWarning } = validatePassword(password, config.password || {});
717
1142
  if (!passwordValid) {
718
1143
  // nosem: no tainted data flows to log
@@ -728,7 +1153,7 @@ program
728
1153
  // MCP mode uses stdio
729
1154
  if (mode === 'mcp') {
730
1155
  const { EnvCPServer } = await import('../mcp/server.js');
731
- const server = new EnvCPServer(config, projectPath, password);
1156
+ const server = new EnvCPServer(config, projectPath, password, vaultPath, sessionPath);
732
1157
  await server.start();
733
1158
  return;
734
1159
  }
@@ -804,12 +1229,8 @@ program
804
1229
  console.log(chalk.red('--output <path> is required with --encrypted'));
805
1230
  return;
806
1231
  }
807
- const { exportPassword } = await inquirer.prompt([
808
- { type: 'password', name: 'exportPassword', message: 'Set export password:', mask: '*' }
809
- ]);
810
- const { confirmExport } = await inquirer.prompt([
811
- { type: 'password', name: 'confirmExport', message: 'Confirm export password:', mask: '*' }
812
- ]);
1232
+ const exportPassword = await promptPassword('Set export password:');
1233
+ const confirmExport = await promptPassword('Confirm export password:');
813
1234
  if (exportPassword !== confirmExport) {
814
1235
  console.log(chalk.red('Passwords do not match'));
815
1236
  return;
@@ -857,9 +1278,7 @@ program
857
1278
  console.log(chalk.red(`File not found: ${file}`));
858
1279
  return;
859
1280
  }
860
- const { importPassword } = await inquirer.prompt([
861
- { type: 'password', name: 'importPassword', message: 'Enter export file password:', mask: '*' }
862
- ]);
1281
+ const importPassword = await promptPassword('Enter export file password:');
863
1282
  const fileContent = await fs.readFile(file, 'utf8');
864
1283
  let importData;
865
1284
  try {
@@ -920,9 +1339,7 @@ program
920
1339
  console.log(chalk.gray('\nNo files were modified.'));
921
1340
  return;
922
1341
  }
923
- const { confirm } = await inquirer.prompt([
924
- { type: 'confirm', name: 'confirm', message: options.merge ? 'Merge into current store?' : 'Replace current store?', default: false }
925
- ]);
1342
+ const confirm = await promptConfirm(options.merge ? 'Merge into current store?' : 'Replace current store?', false);
926
1343
  if (!confirm) {
927
1344
  console.log(chalk.yellow('Import cancelled'));
928
1345
  return;
@@ -1004,9 +1421,7 @@ program
1004
1421
  console.log(chalk.gray(` Created: ${meta.timestamp}`));
1005
1422
  console.log(chalk.gray(` Variables: ${meta.count || Object.keys(variables).length}`));
1006
1423
  }
1007
- const { confirm } = await inquirer.prompt([
1008
- { type: 'confirm', name: 'confirm', message: options.merge ? 'Merge backup into current store?' : 'Replace current store with backup?', default: false }
1009
- ]);
1424
+ const confirm = await promptConfirm(options.merge ? 'Merge backup into current store?' : 'Replace current store with backup?', false);
1010
1425
  if (!confirm) {
1011
1426
  console.log(chalk.yellow('Restore cancelled'));
1012
1427
  return;
@@ -1049,7 +1464,7 @@ program
1049
1464
  }
1050
1465
  // 5. Session status
1051
1466
  if (encrypted) {
1052
- const sessionManager = new SessionManager(path.join(projectPath, config.session?.path || '.envcp/.session'), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
1467
+ const sessionManager = new SessionManager(resolveSessionPath(projectPath, config), config.session?.timeout_minutes || 30, config.session?.max_extensions || 5);
1053
1468
  await sessionManager.init();
1054
1469
  const session = await sessionManager.load();
1055
1470
  if (session) {
@@ -1168,7 +1583,7 @@ program
1168
1583
  .option('--name <name>', 'Operate on a named vault')
1169
1584
  .addCommand(new Command('init')
1170
1585
  .description('Initialize a vault')
1171
- .action(async (options, cmd) => {
1586
+ .action(async (_options, cmd) => {
1172
1587
  const parentOpts = cmd.parent.opts();
1173
1588
  const projectPath = process.cwd();
1174
1589
  const config = await loadConfig(projectPath);
@@ -1199,7 +1614,7 @@ program
1199
1614
  const parentOpts = cmd.parent.opts();
1200
1615
  const vaultOverride = parentOpts.global ? 'global' : parentOpts.project ? 'project' : undefined;
1201
1616
  await withSession(async (storage) => {
1202
- const value = options.value || (await inquirer.prompt([{ type: 'input', name: 'value', message: 'Value:' }])).value;
1617
+ const value = options.value || await promptInput('Value:');
1203
1618
  const tags = options.tags ? options.tags.split(',').map((t) => t.trim()) : [];
1204
1619
  const now = new Date().toISOString();
1205
1620
  await storage.set(name, {
@@ -1275,7 +1690,6 @@ program
1275
1690
  .argument('<name>', 'Vault name (global, project, or named vault)')
1276
1691
  .action(async (name) => {
1277
1692
  const projectPath = process.cwd();
1278
- const config = await loadConfig(projectPath);
1279
1693
  if (name !== 'global' && name !== 'project') {
1280
1694
  const vaultDir = path.join(projectPath, '.envcp/vaults', name);
1281
1695
  if (!await pathExists(vaultDir)) {
@@ -1334,7 +1748,7 @@ program
1334
1748
  .addCommand(new Command('save')
1335
1749
  .description('Save current password to OS keychain')
1336
1750
  .action(async () => {
1337
- await withSession(async (storage, password, config, projectPath) => {
1751
+ await withSession(async (_storage, password, config, projectPath) => {
1338
1752
  if (!password) {
1339
1753
  console.log(chalk.red('No password available (encryption disabled?)'));
1340
1754
  return;
@@ -1422,5 +1836,131 @@ if (!await pathExists(firstRunMarker)) {
1422
1836
  Docs: https://github.com/fentz26/EnvCP
1423
1837
  `);
1424
1838
  }
1839
+ program
1840
+ .command('logs')
1841
+ .description('View audit logs')
1842
+ .option('--date <date>', 'Log date (YYYY-MM-DD, default: today)')
1843
+ .option('--operation <op>', 'Filter by operation (add, get, update, delete, list, sync, export, unlock, lock, check_access, run, auth_failure)')
1844
+ .option('--variable <name>', 'Filter by variable name')
1845
+ .option('--source <source>', 'Filter by source (cli, mcp, api)')
1846
+ .option('--success', 'Show only successful operations')
1847
+ .option('--failure', 'Show only failed operations')
1848
+ .option('--tail <n>', 'Show last N entries', parseInt)
1849
+ .option('--dates', 'List all available log dates')
1850
+ .option('--verify', 'Verify HMAC integrity of log entries')
1851
+ .action(async (options) => {
1852
+ const projectPath = process.cwd();
1853
+ const config = await loadConfig(projectPath);
1854
+ const logDir = resolveLogPath(config.audit, projectPath);
1855
+ const logs = new LogManager(logDir, config.audit);
1856
+ await logs.init();
1857
+ if (options.dates) {
1858
+ const dates = await logs.getLogDates();
1859
+ if (dates.length === 0) {
1860
+ console.log(chalk.gray('No log files found.'));
1861
+ }
1862
+ else {
1863
+ console.log(chalk.bold('Available log dates:'));
1864
+ dates.forEach(d => console.log(chalk.gray(` ${d}`)));
1865
+ }
1866
+ return;
1867
+ }
1868
+ const filter = {};
1869
+ if (options.date)
1870
+ filter.date = options.date;
1871
+ if (options.operation)
1872
+ filter.operation = options.operation;
1873
+ if (options.variable)
1874
+ filter.variable = options.variable;
1875
+ if (options.source)
1876
+ filter.source = options.source;
1877
+ if (options.success)
1878
+ filter.success = true;
1879
+ if (options.failure)
1880
+ filter.success = false;
1881
+ if (options.tail)
1882
+ filter.tail = options.tail;
1883
+ const entries = await logs.getLogs(filter);
1884
+ if (entries.length === 0) {
1885
+ console.log(chalk.gray('No log entries found.'));
1886
+ return;
1887
+ }
1888
+ let failed = 0;
1889
+ entries.forEach(entry => {
1890
+ const ts = chalk.gray(entry.timestamp);
1891
+ const op = entry.success ? chalk.green(entry.operation) : chalk.red(entry.operation);
1892
+ const src = chalk.blue(entry.source);
1893
+ const varName = entry.variable ? chalk.yellow(` [${entry.variable}]`) : '';
1894
+ const msg = entry.message ? chalk.gray(` — ${entry.message}`) : '';
1895
+ if (options.verify && entry.hmac !== undefined) {
1896
+ const valid = logs.verifyEntry(entry);
1897
+ if (!valid) {
1898
+ failed++;
1899
+ console.log(`${ts} ${chalk.red('[TAMPERED]')} ${op} ${src}${varName}${msg}`);
1900
+ return;
1901
+ }
1902
+ }
1903
+ console.log(`${ts} ${op} ${src}${varName}${msg}`);
1904
+ });
1905
+ if (options.verify) {
1906
+ if (failed === 0) {
1907
+ console.log(chalk.green(`\nAll ${entries.length} entries verified OK.`));
1908
+ }
1909
+ else {
1910
+ console.log(chalk.red(`\n${failed}/${entries.length} entries failed HMAC verification.`));
1911
+ }
1912
+ }
1913
+ });
1914
+ program
1915
+ .command('verify-logs')
1916
+ .description('Verify HMAC chain integrity of audit logs')
1917
+ .option('--date <date>', 'Verify specific date (YYYY-MM-DD, default: all dates)')
1918
+ .action(async (options) => {
1919
+ const projectPath = process.cwd();
1920
+ const config = await loadConfig(projectPath);
1921
+ const logDir = resolveLogPath(config.audit, projectPath);
1922
+ const logs = new LogManager(logDir, config.audit);
1923
+ await logs.init();
1924
+ if (!config.audit.hmac_chain) {
1925
+ console.log(chalk.yellow('HMAC chain is not enabled in audit config.'));
1926
+ return;
1927
+ }
1928
+ console.log(chalk.bold('Verifying log chain integrity...'));
1929
+ const result = await logs.verifyLogChain(options.date);
1930
+ if (result.valid) {
1931
+ console.log(chalk.green(`✓ Chain integrity verified: ${result.entries} entries OK`));
1932
+ }
1933
+ else {
1934
+ console.log(chalk.red(`✗ Chain integrity FAILED: ${result.tampered.length}/${result.entries} entries tampered`));
1935
+ console.log(chalk.red(` Tampered indices: ${result.tampered.join(', ')}`));
1936
+ }
1937
+ });
1938
+ program
1939
+ .command('protect-logs')
1940
+ .description('Apply OS-level protection to audit log files (Linux only)')
1941
+ .action(async () => {
1942
+ const projectPath = process.cwd();
1943
+ const config = await loadConfig(projectPath);
1944
+ const logDir = resolveLogPath(config.audit, projectPath);
1945
+ const logs = new LogManager(logDir, config.audit);
1946
+ await logs.init();
1947
+ if (config.audit.protection === 'none') {
1948
+ console.log(chalk.yellow('Log protection is disabled in config (protection: none).'));
1949
+ return;
1950
+ }
1951
+ console.log(chalk.bold(`Applying ${config.audit.protection} protection to log files...`));
1952
+ const result = await logs.protectLogFiles();
1953
+ if (result.protected.length > 0) {
1954
+ console.log(chalk.green(`✓ Protected ${result.protected.length} files:`));
1955
+ result.protected.forEach((f) => console.log(chalk.gray(` ${f}`)));
1956
+ }
1957
+ if (result.failed.length > 0) {
1958
+ console.log(chalk.red(`✗ Failed to protect ${result.failed.length} files:`));
1959
+ result.failed.forEach((f) => console.log(chalk.gray(` ${f}`)));
1960
+ }
1961
+ if (result.protected.length === 0 && result.failed.length === 0) {
1962
+ console.log(chalk.gray('No log files to protect.'));
1963
+ }
1964
+ });
1425
1965
  program.parse();
1426
1966
  //# sourceMappingURL=index.js.map