@josfox/jos 4.0.8 → 5.0.0

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 (59) hide show
  1. package/LICENSE +208 -21
  2. package/PROMISES.md +52 -0
  3. package/README.md +294 -146
  4. package/SECURITY-MCP.md +117 -0
  5. package/SECURITY-SECRETS.md +108 -0
  6. package/STYLE.md +23 -0
  7. package/bin/jos +139 -67
  8. package/bin/jos.cmd +5 -0
  9. package/examples/01-hello.jos +44 -0
  10. package/examples/02-criteria.jos +54 -0
  11. package/examples/03-flow-gate.jos +83 -0
  12. package/examples/04-use-template/README.md +22 -0
  13. package/examples/04-use-template/lib/greeting.jos +56 -0
  14. package/examples/04-use-template/use.jos +64 -0
  15. package/examples/05-sealed/.jos/manifest.jos +53 -0
  16. package/examples/05-sealed/README.md +25 -0
  17. package/examples/06-requires.jos +42 -0
  18. package/examples/07-sandbox.jos +39 -0
  19. package/examples/08-ids-ledger-meter.md +26 -0
  20. package/examples/10-ollama.jos +47 -0
  21. package/examples/11-openai.jos +47 -0
  22. package/examples/12-gemini.jos +47 -0
  23. package/examples/13-anthropic.jos +47 -0
  24. package/examples/14-serve.jos +88 -0
  25. package/examples/README.md +105 -0
  26. package/examples/hello.jos +47 -0
  27. package/package.json +31 -9
  28. package/promises.json +894 -0
  29. package/src/args.js +47 -0
  30. package/src/commands/_frozen/add.README.md +1 -0
  31. package/src/commands/_frozen/get.README.md +1 -0
  32. package/src/commands/_frozen/llm.README.md +1 -0
  33. package/src/commands/_frozen/prompts.README.md +1 -0
  34. package/src/commands/_frozen/repo.README.md +1 -0
  35. package/src/commands/_frozen/serve-explorer.README.md +1 -0
  36. package/src/commands/ids.js +101 -0
  37. package/src/commands/init.js +113 -147
  38. package/src/commands/ledger.js +94 -0
  39. package/src/commands/meter.js +96 -0
  40. package/src/commands/migrate.js +214 -0
  41. package/src/commands/overview.js +118 -0
  42. package/src/commands/run.js +164 -214
  43. package/src/commands/runs.js +102 -0
  44. package/src/commands/secrets.js +695 -115
  45. package/src/commands/start.js +210 -0
  46. package/src/commands/validate.js +153 -165
  47. package/src/commands/verify.js +56 -0
  48. package/src/commands/version.js +23 -0
  49. package/src/intention.js +114 -0
  50. package/src/kernel.js +69 -0
  51. package/src/ledgers.js +57 -0
  52. package/src/mcp-guide.js +138 -0
  53. package/src/mcp.js +734 -0
  54. package/src/serve.js +38 -770
  55. package/src/ui.js +98 -0
  56. package/src/commands/add.js +0 -145
  57. package/src/commands/get.js +0 -245
  58. package/src/commands/repo.js +0 -139
  59. package/src/index.js +0 -0
@@ -1,137 +1,717 @@
1
1
  /**
2
- * JOS SECRETS Command - Secure credential storage with AES-256-CBC
2
+ * JOS SECRETS - local credential storage in ~/.jos/secrets/ (threat model and format: SECURITY-SECRETS.md).
3
+ *
4
+ * Format v2: AES-256-GCM per entry, 12-byte random nonce per write, the entry name as AAD, 16-byte tag; the
5
+ * GCM key is derived from the master key with HKDF-SHA256, so the legacy CBC key and the GCM key are never the
6
+ * same bytes. Entries written by 4.x and 5.0.0-rc.1/rc.2 (AES-256-CBC, "<iv>:<ct>") are still read, and are
7
+ * rewritten only by `jos secrets migrate` (backup 0600 first, every value verified after the write).
8
+ *
9
+ * Values never reach argv, stdout, files other than the vault, or error messages:
10
+ * set NAME --stdin value from stdin (hidden prompt on a terminal); a value argument is refused
11
+ * get NAME existence + keyed fingerprint (file backend: hmac:, 8 hex); --reveal only to a terminal, confirmed
12
+ * exec --only A,B -- cmd the values go only into the child's environment
13
+ * The directory must be 0700 and everything in it 0600 (0700 for directories), owned by the user, no symlinks;
14
+ * otherwise nothing is read until `jos secrets fix-perms`. JOS_SECRETS_BACKEND=keychain uses the macOS keychain.
15
+ *
16
+ * Every process-dependent input (streams, environment, platform, uid, clock, the security(1) path, the stdio of
17
+ * an exec child, read-back after a write) goes through `io`, so the tests reach every branch in-process.
18
+ *
19
+ * Exit codes: 0 ok, 1 error, not found or refused, 64 usage; exec returns the child's code (126/127 if it
20
+ * cannot be started, 128+n if it dies by signal n).
3
21
  */
4
-
22
+ 'use strict';
5
23
  const fs = require('fs');
24
+ const os = require('os');
6
25
  const path = require('path');
7
26
  const crypto = require('crypto');
27
+ const { spawn } = require('child_process');
28
+ const { StringDecoder } = require('string_decoder');
29
+ const ui = require('../ui');
30
+ const { RULE } = ui;
8
31
 
9
- // AURORA colors
10
- const C = {
11
- reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
12
- purple: '\x1b[38;5;135m', magenta: '\x1b[38;5;198m', cyan: '\x1b[38;5;51m',
13
- green: '\x1b[38;5;78m', red: '\x1b[38;5;196m', gray: '\x1b[38;5;245m',
14
- white: '\x1b[38;5;255m', yellow: '\x1b[38;5;220m'
15
- };
32
+ const HEX = /^[0-9a-f]*$/i;
33
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
34
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
35
+ const FS_CODE = /^E[A-Z]+$/;
36
+ const ALG = 'aes-256-gcm';
37
+ const HKDF_INFO = 'jos-secrets/v2 aes-256-gcm';
38
+ const HKDF_FP_INFO = 'jos-secrets/v2 fingerprint hmac-sha256'; // its own label: the fingerprint key is never the GCM key
39
+ const MAX_VALUE = 1024 * 1024; // bytes (stdin) or characters (terminal) at most
40
+ const MAX_NAME = 256;
41
+ const KEYCHAIN_SERVICE = 'jos';
42
+ const KEYCHAIN_MAX = 128; // security(1) reads a prompted password with getpass(3): 128 characters at most
43
+ const ACTIONS = ['set', 'get', 'list', 'delete', 'exec', 'migrate', 'fix-perms'];
16
44
 
17
- function ensureMasterKey(secretsDir) {
18
- const masterPath = path.join(secretsDir, '.master');
19
- if (!fs.existsSync(secretsDir)) {
20
- fs.mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
45
+ class VaultError extends Error {}
46
+ class UsageError extends Error {}
47
+
48
+ function defaultIO() {
49
+ return {
50
+ stdin: process.stdin,
51
+ stdout: process.stdout,
52
+ stderr: process.stderr,
53
+ env: process.env,
54
+ platform: process.platform,
55
+ /* node:coverage ignore next */
56
+ uid: typeof process.getuid === 'function' ? process.getuid() : -1, // Windows has no uid (and no mode check)
57
+ now: () => new Date(),
58
+ security: '/usr/bin/security',
59
+ childStdio: 'inherit',
60
+ readBack: (file) => fs.readFileSync(file, 'utf8'),
61
+ };
62
+ }
63
+
64
+ // ------------------------------------------------------------------------------------------------ files
65
+
66
+ function writeAtomic(file, data) {
67
+ // Unique temp name created exclusively (never follows a planted file), mode 0600, then renamed over.
68
+ const tmp = file + '.' + process.pid + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
69
+ const buf = Buffer.from(data, 'utf8');
70
+ const fd = fs.openSync(tmp, 'wx', 0o600);
71
+ try {
72
+ for (let off = 0; off < buf.length;) off += fs.writeSync(fd, buf, off, buf.length - off);
73
+ fs.fchmodSync(fd, 0o600);
74
+ fs.fsyncSync(fd);
75
+ } finally { fs.closeSync(fd); }
76
+ fs.renameSync(tmp, file);
77
+ }
78
+
79
+ function modeText(m) { return '0' + (m & 0o777).toString(8).padStart(3, '0'); }
80
+
81
+ // What is wrong with the permissions of the secrets directory and of every entry in it. A missing directory
82
+ // is not a problem (nothing is stored yet); a non-directory is an fs error (ENOTDIR).
83
+ function permissionProblems(dir, io) {
84
+ const problems = [];
85
+ let st;
86
+ try { st = fs.lstatSync(dir); } catch (e) { if (e.code === 'ENOENT') return problems; throw e; }
87
+ if (st.isSymbolicLink()) return [{ path: dir, why: 'is a symbolic link', fixable: false }];
88
+ if (!st.isDirectory()) throw Object.assign(new Error('not a directory'), { code: 'ENOTDIR' });
89
+ if (io.platform === 'win32') return problems; // no POSIX modes: the directory ACL is the user's
90
+ const look = (p, s, want) => {
91
+ if (s.uid !== io.uid) problems.push({ path: p, why: 'is owned by uid ' + s.uid + ', not by you (' + io.uid + ')', fixable: false });
92
+ if (s.mode & 0o077) problems.push({ path: p, why: 'has mode ' + modeText(s.mode) + ', expected ' + modeText(want), fixable: true, want });
93
+ };
94
+ look(dir, st, 0o700);
95
+ for (const name of fs.readdirSync(dir)) {
96
+ const p = path.join(dir, name);
97
+ const s = fs.lstatSync(p);
98
+ if (s.isSymbolicLink()) problems.push({ path: p, why: 'is a symbolic link', fixable: false });
99
+ else look(p, s, s.isDirectory() ? 0o700 : 0o600);
21
100
  }
101
+ return problems;
102
+ }
103
+
104
+ function readMasterKey(dir, create, io) {
105
+ const masterPath = path.join(dir, '.master');
22
106
  if (!fs.existsSync(masterPath)) {
23
- // Generate secure master key
24
- const key = crypto.randomBytes(32).toString('hex');
25
- fs.writeFileSync(masterPath, key, { mode: 0o600 });
26
- console.log(`${C.green}✓ Master key generated${C.reset}`);
27
- }
28
- return Buffer.from(fs.readFileSync(masterPath, 'utf8').trim(), 'hex');
29
- }
30
-
31
- exports.execute = async (args, home) => {
32
- const action = args[0];
33
- const key = args[1];
34
- const val = args[2];
35
- const showHelp = args.includes('--help') || args.includes('-h');
36
-
37
- if (showHelp || !action) {
38
- console.log(`
39
- ${C.cyan}${C.bold}JOS SECRETS${C.reset} - Secure credential storage (AES-256-CBC)
40
-
41
- ${C.white}Usage:${C.reset} jos secrets <action> [key] [value]
42
-
43
- ${C.white}Actions:${C.reset}
44
- set <key> <value> Encrypt and store a secret
45
- get <key> Decrypt and retrieve a secret
46
- list List all secret keys (not values)
47
- delete <key> Remove a secret
48
-
49
- ${C.white}Examples:${C.reset}
50
- jos secrets set API_KEY sk-abc123
51
- jos secrets get API_KEY
52
- jos secrets list
53
- jos secrets delete API_KEY
54
-
55
- ${C.white}Security:${C.reset}
56
- • Master key: ~/.jos/secrets/.master (0600)
57
- • Vault: ~/.jos/secrets/vault.json
58
- • Encryption: AES-256-CBC with random IV per secret
59
- `);
107
+ if (!create) return null;
108
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
109
+ writeAtomic(masterPath, crypto.randomBytes(32).toString('hex'));
110
+ const e = ui.printer(io.stderr);
111
+ e.ok('New master key generated at ' + masterPath);
112
+ e.hint('0600; keep it: without it the entries of the vault cannot be decrypted');
113
+ }
114
+ const text = fs.readFileSync(masterPath, 'utf8').trim();
115
+ if (text.length !== 64 || !HEX.test(text)) {
116
+ throw new VaultError('master key is corrupt: ' + masterPath + ' must hold 64 hex characters');
117
+ }
118
+ return Buffer.from(text, 'hex');
119
+ }
120
+
121
+ // ------------------------------------------------------------------------------------------------ vault
122
+
123
+ function entryFormat(v) { return typeof v === 'string' ? 1 : 2; }
124
+
125
+ function isObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
126
+
127
+ function checkEntry(k, v, vaultPath) {
128
+ if (typeof v === 'string') {
129
+ const parts = v.split(':');
130
+ if (parts.length !== 2 || parts[0].length !== 32 || !HEX.test(parts[0]) || parts[1].length === 0 || parts[1].length % 32 !== 0 || !HEX.test(parts[1])) {
131
+ throw new VaultError('vault is corrupt: entry ' + JSON.stringify(k) + ' in ' + vaultPath + ' is not "<iv hex>:<ciphertext hex>"');
132
+ }
60
133
  return;
61
134
  }
135
+ if (isObject(v) && typeof v.v === 'number' && v.v > 2) {
136
+ throw new VaultError('vault entry ' + JSON.stringify(k) + ' uses format v' + v.v + ', which this version of jos does not read');
137
+ }
138
+ const ok = isObject(v) && v.v === 2 && v.alg === ALG &&
139
+ typeof v.iv === 'string' && v.iv.length === 24 && HEX.test(v.iv) &&
140
+ typeof v.tag === 'string' && v.tag.length === 32 && HEX.test(v.tag) &&
141
+ typeof v.ct === 'string' && v.ct.length % 2 === 0 && HEX.test(v.ct) &&
142
+ Object.keys(v).every((f) => ['v', 'alg', 'iv', 'tag', 'ct'].includes(f));
143
+ if (!ok) {
144
+ throw new VaultError('vault is corrupt: entry ' + JSON.stringify(k) + ' in ' + vaultPath + ' is neither "<iv hex>:<ciphertext hex>" (v1) nor {"v":2,"alg":"aes-256-gcm","iv","tag","ct"}');
145
+ }
146
+ }
147
+
148
+ function parseVault(text, vaultPath) {
149
+ let parsed;
150
+ try { parsed = JSON.parse(text); } catch (e) {
151
+ throw new VaultError('vault is corrupt: ' + vaultPath + ' is not valid JSON');
152
+ }
153
+ if (!isObject(parsed)) throw new VaultError('vault is corrupt: ' + vaultPath + ' must hold a JSON object');
154
+ const vault = Object.create(null); // "__proto__" is an ordinary name
155
+ for (const k of Object.keys(parsed)) { checkEntry(k, parsed[k], vaultPath); vault[k] = parsed[k]; }
156
+ return vault;
157
+ }
158
+
159
+ function serializeVault(vault) {
160
+ const out = Object.create(null); // a plain {} would treat "__proto__" as the prototype and drop the entry
161
+ for (const k of Object.keys(vault)) out[k] = vault[k];
162
+ return JSON.stringify(out, null, 2) + '\n';
163
+ }
164
+
165
+ function gcmKey(master) { return Buffer.from(crypto.hkdfSync('sha256', master, Buffer.alloc(0), HKDF_INFO, 32)); }
62
166
 
63
- const secretsDir = path.join(home, 'secrets');
64
- const vaultPath = path.join(secretsDir, 'vault.json');
167
+ function encryptV2(master, name, value) {
168
+ const key = gcmKey(master);
169
+ const iv = crypto.randomBytes(12);
170
+ const c = crypto.createCipheriv(ALG, key, iv, { authTagLength: 16 });
171
+ c.setAAD(Buffer.from(name, 'utf8'));
172
+ const ct = Buffer.concat([c.update(value), c.final()]);
173
+ key.fill(0);
174
+ return { v: 2, alg: ALG, iv: iv.toString('hex'), tag: c.getAuthTag().toString('hex'), ct: ct.toString('hex') };
175
+ }
65
176
 
177
+ function decryptEntry(master, name, entry) {
178
+ const v1 = typeof entry === 'string';
179
+ const key = v1 ? Buffer.from(master) : gcmKey(master);
180
+ let value;
66
181
  try {
67
- const masterKey = ensureMasterKey(secretsDir);
68
- let vault = {};
69
- if (fs.existsSync(vaultPath)) {
70
- vault = JSON.parse(fs.readFileSync(vaultPath, 'utf8'));
182
+ let d;
183
+ if (v1) {
184
+ d = crypto.createDecipheriv('aes-256-cbc', key, Buffer.from(entry.slice(0, 32), 'hex'));
185
+ value = Buffer.concat([d.update(Buffer.from(entry.slice(33), 'hex')), d.final()]);
186
+ } else {
187
+ d = crypto.createDecipheriv(ALG, key, Buffer.from(entry.iv, 'hex'), { authTagLength: 16 });
188
+ d.setAAD(Buffer.from(name, 'utf8'));
189
+ d.setAuthTag(Buffer.from(entry.tag, 'hex'));
190
+ value = Buffer.concat([d.update(Buffer.from(entry.ct, 'hex')), d.final()]);
71
191
  }
192
+ } catch (e) {
193
+ throw new VaultError(v1
194
+ ? 'secret ' + JSON.stringify(name) + ' cannot be decrypted with the current master key'
195
+ : 'secret ' + JSON.stringify(name) + ' failed authentication: the vault entry was altered, renamed, or the master key is not the one that wrote it');
196
+ } finally { key.fill(0); }
197
+ return value;
198
+ }
72
199
 
73
- switch (action) {
74
- case 'set':
75
- if (!key || !val) {
76
- console.log(`${C.red}✖ Usage: jos secrets set <key> <value>${C.reset}`);
77
- process.exit(1);
78
- }
79
- const iv = crypto.randomBytes(16);
80
- const cipher = crypto.createCipheriv('aes-256-cbc', masterKey, iv);
81
- let encrypted = cipher.update(val, 'utf8', 'hex');
82
- encrypted += cipher.final('hex');
83
- vault[key] = iv.toString('hex') + ':' + encrypted;
84
- fs.writeFileSync(vaultPath, JSON.stringify(vault, null, 2), { mode: 0o600 });
85
- console.log(`${C.green}🔒 Secret '${key}' saved securely.${C.reset}`);
86
- break;
87
-
88
- case 'get':
89
- if (!key) {
90
- console.log(`${C.red}✖ Usage: jos secrets get <key>${C.reset}`);
91
- process.exit(1);
92
- }
93
- if (!vault[key]) {
94
- console.log(`${C.yellow}⚠ Secret '${key}' not found${C.reset}`);
95
- process.exit(1);
96
- }
97
- const [ivHex, encData] = vault[key].split(':');
98
- const decipher = crypto.createDecipheriv('aes-256-cbc', masterKey, Buffer.from(ivHex, 'hex'));
99
- let decrypted = decipher.update(encData, 'hex', 'utf8');
100
- decrypted += decipher.final('utf8');
101
- console.log(decrypted);
102
- break;
103
-
104
- case 'list':
105
- const keys = Object.keys(vault);
106
- if (keys.length === 0) {
107
- console.log(`${C.dim}No secrets stored${C.reset}`);
108
- } else {
109
- console.log(`${C.cyan}🔐 Stored secrets (${keys.length}):${C.reset}`);
110
- keys.forEach(k => console.log(` ${C.white}•${C.reset} ${k}`));
111
- }
112
- break;
200
+ // Keyed fingerprint: HMAC-SHA256 under a key derived (HKDF) from the master key with its own label, first 8 hex.
201
+ // Stable for one value in one vault, different between vaults, and not computable (so a guess not testable)
202
+ // without the master key. A bare sha256 of the value would let anyone who saw the output test guesses.
203
+ function fingerprint(master, value) {
204
+ const key = Buffer.from(crypto.hkdfSync('sha256', master, Buffer.alloc(0), HKDF_FP_INFO, 32));
205
+ const mac = crypto.createHmac('sha256', key).update(value).digest('hex').slice(0, 8);
206
+ key.fill(0);
207
+ return 'hmac:' + mac;
208
+ }
113
209
 
114
- case 'delete':
115
- if (!key) {
116
- console.log(`${C.red}✖ Usage: jos secrets delete <key>${C.reset}`);
117
- process.exit(1);
118
- }
119
- if (vault[key]) {
120
- delete vault[key];
121
- fs.writeFileSync(vaultPath, JSON.stringify(vault, null, 2), { mode: 0o600 });
122
- console.log(`${C.green}✓ Secret '${key}' deleted${C.reset}`);
123
- } else {
124
- console.log(`${C.yellow}⚠ Secret '${key}' not found${C.reset}`);
125
- }
126
- break;
210
+ function sameBytes(a, b) { return a.length === b.length && crypto.timingSafeEqual(a, b); }
211
+
212
+ // ------------------------------------------------------------------------------------------------ keychain (macOS)
213
+
214
+ function run(io, argv, input, detached) {
215
+ return new Promise((resolve) => {
216
+ // detached: a new session without a controlling terminal, so security(1) reads the prompted password
217
+ // from this stdin pipe instead of /dev/tty.
218
+ const ch = spawn(io.security, argv, { stdio: ['pipe', 'pipe', 'ignore'], detached, env: io.env });
219
+ const out = [];
220
+ ch.stdout.on('data', (d) => out.push(d));
221
+ ch.on('error', () => resolve({ code: -1, out: Buffer.alloc(0) }));
222
+ ch.on('close', (code) => resolve({ code, out: Buffer.concat(out) }));
223
+ // EPIPE if the child exits before reading (its exit code tells); not reproducible in a test: the input is
224
+ // at most 2 x 129 bytes and fits the pipe buffer before the child can exit.
225
+ /* node:coverage ignore next */
226
+ ch.stdin.on('error', () => {});
227
+ ch.stdin.end(input);
228
+ });
229
+ }
230
+
231
+ function keychain(io) {
232
+ const kc = {
233
+ async get(name) {
234
+ const r = await run(io, ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', name, '-w'], '', false);
235
+ if (r.code === 44) return null;
236
+ if (r.code !== 0) throw new VaultError('keychain read of ' + JSON.stringify(name) + ' failed (security exit ' + r.code + ')');
237
+ const b = r.out;
238
+ return b.length && b[b.length - 1] === 0x0a ? b.subarray(0, b.length - 1) : b;
239
+ },
240
+ async set(name, value) {
241
+ const line = Buffer.concat([value, Buffer.from('\n')]);
242
+ const input = Buffer.concat([line, line]); // the password, then its confirmation
243
+ line.fill(0);
244
+ const r = await run(io, ['add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', name, '-w'], input, true);
245
+ input.fill(0);
246
+ if (r.code !== 0) throw new VaultError('keychain write of ' + JSON.stringify(name) + ' failed (security exit ' + r.code + ')');
247
+ const back = await kc.get(name);
248
+ const same = back !== null && sameBytes(back, value);
249
+ if (back) back.fill(0);
250
+ if (!same) {
251
+ await kc.del(name);
252
+ throw new VaultError('keychain write of ' + JSON.stringify(name) + ' could not be verified; the item was removed');
253
+ }
254
+ },
255
+ async del(name) {
256
+ const r = await run(io, ['delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', name], '', false);
257
+ if (r.code === 44) return false;
258
+ if (r.code !== 0) throw new VaultError('keychain delete of ' + JSON.stringify(name) + ' failed (security exit ' + r.code + ')');
259
+ return true;
260
+ },
261
+ };
262
+ return kc;
263
+ }
264
+
265
+ // The keychain cannot be listed without dumping it, so the names (never the values) are kept in keychain.json.
266
+ function readIndex(indexPath) {
267
+ if (!fs.existsSync(indexPath)) return [];
268
+ let p = null;
269
+ try { p = JSON.parse(fs.readFileSync(indexPath, 'utf8')); } catch (e) { /* reported below */ }
270
+ if (!isObject(p) || p.v !== 1 || !Array.isArray(p.names) || !p.names.every((n) => typeof n === 'string')) {
271
+ throw new VaultError('keychain index is corrupt: ' + indexPath + ' must hold {"v":1,"names":[...]}');
272
+ }
273
+ return p.names;
274
+ }
275
+
276
+ function writeIndex(dir, indexPath, names) {
277
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
278
+ writeAtomic(indexPath, JSON.stringify({ v: 1, names: Array.from(new Set(names)).sort() }, null, 2) + '\n');
279
+ }
280
+
281
+ // ------------------------------------------------------------------------------------------------ input
282
+
283
+ function readPiped(stdin, max) {
284
+ return new Promise((resolve, reject) => {
285
+ const chunks = [];
286
+ let n = 0;
287
+ const finish = (e) => {
288
+ stdin.removeListener('data', onData);
289
+ stdin.removeListener('end', onEnd);
290
+ stdin.removeListener('error', finish);
291
+ stdin.pause();
292
+ const buf = Buffer.concat(chunks);
293
+ for (const c of chunks) c.fill(0);
294
+ if (e) { buf.fill(0); reject(e); } else resolve(buf);
295
+ };
296
+ const onData = (d) => { chunks.push(d); n += d.length; if (n > max) finish(new UsageError('the value is longer than ' + max + ' bytes')); };
297
+ const onEnd = () => finish();
298
+ stdin.on('data', onData);
299
+ stdin.on('end', onEnd);
300
+ stdin.on('error', finish);
301
+ stdin.resume();
302
+ });
303
+ }
304
+
305
+ // One line from a terminal. hidden: raw mode (no echo), backspace edits, Enter or Ctrl-D ends, Ctrl-C cancels.
306
+ function readTerminalLine(io, prompt, hidden) {
307
+ return new Promise((resolve, reject) => {
308
+ const stdin = io.stdin;
309
+ const dec = new StringDecoder('utf8');
310
+ let chars = [];
311
+ const finish = (e) => {
312
+ stdin.removeListener('data', onData);
313
+ if (hidden) { stdin.setRawMode(false); ui.printer(io.stderr).line(); }
314
+ stdin.pause();
315
+ const text = chars.join('');
316
+ chars = [];
317
+ if (e) reject(e); else resolve(text);
318
+ };
319
+ const onData = (d) => {
320
+ for (const ch of dec.write(d)) {
321
+ if (ch === '\r' || ch === '\n' || ch === '\u0004') return finish();
322
+ if (ch === '\u0003') return finish(Object.assign(new UsageError('cancelled'), { exitCode: 130 }));
323
+ if (hidden && (ch === '\u007f' || ch === '\b')) { chars.pop(); continue; }
324
+ chars.push(ch);
325
+ if (chars.length > MAX_VALUE) return finish(new UsageError('the value is longer than ' + MAX_VALUE + ' characters'));
326
+ }
327
+ };
328
+ ui.printer(io.stderr).text(prompt);
329
+ if (hidden) stdin.setRawMode(true);
330
+ stdin.on('data', onData);
331
+ stdin.resume();
332
+ });
333
+ }
334
+
335
+ async function readSecretValue(io, name) {
336
+ let buf;
337
+ if (io.stdin.isTTY) {
338
+ buf = Buffer.from(await readTerminalLine(io, 'Value for ' + JSON.stringify(name) + ' (input hidden, Enter to finish): ', true), 'utf8');
339
+ } else {
340
+ const raw = await readPiped(io.stdin, MAX_VALUE);
341
+ let end = raw.length; // one final line break comes from `echo` or a heredoc; it is not part of the value
342
+ if (end && raw[end - 1] === 0x0a) { end--; if (end && raw[end - 1] === 0x0d) end--; }
343
+ buf = Buffer.from(raw.subarray(0, end));
344
+ raw.fill(0);
345
+ }
346
+ if (buf.length === 0) throw new UsageError('empty value: nothing was read from stdin');
347
+ return buf;
348
+ }
349
+
350
+ // ------------------------------------------------------------------------------------------------ command line
351
+
352
+ const HELP = (C) => `
353
+ ${C.purple}${RULE}${C.reset}
354
+ ${C.bold}JOS SECRETS${C.reset} // local credential storage (AES-256-GCM, values never on the command line)
355
+ ${C.purple}${RULE}${C.reset}
127
356
 
128
- default:
129
- console.log(`${C.red}✖ Unknown action: ${action}${C.reset}`);
130
- console.log(`${C.dim}Run 'jos secrets --help' for usage${C.reset}`);
131
- process.exit(1);
357
+ ${C.bold}Usage:${C.reset} jos secrets <action> [name] [options]
358
+
359
+ ${C.bold}Actions:${C.reset}
360
+ set <name> --stdin Store a secret read from stdin (hidden prompt on a terminal); the name is
361
+ an environment variable name: letters, digits, "_", not starting with a digit
362
+ get <name> Say whether it exists and print its keyed fingerprint (hmac, 8 hex; file backend)
363
+ get <name> --reveal Print the value, only to a terminal and after typing the name
364
+ exec --only A,B -- <cmd> ... Run <cmd> with the secrets A and B added to its environment only
365
+ list List the names (not the values); marks entries in the old format
366
+ delete <name> Remove a secret
367
+ migrate Rewrite old-format entries (AES-256-CBC) as v2, with a 0600 backup
368
+ fix-perms Set the secrets directory to 0700 and its files to 0600
369
+
370
+ ${C.bold}Examples:${C.reset}
371
+ printf %s "$TOKEN" | jos secrets set OPENAI_API_KEY --stdin
372
+ jos secrets exec --only OPENAI_API_KEY -- node agent.js
373
+
374
+ ${C.bold}Files:${C.reset}
375
+ ~/.jos/secrets/.master master key, 64 hex characters (0600)
376
+ ~/.jos/secrets/vault.json {"<name>": {"v":2,"alg":"aes-256-gcm","iv","tag","ct"}} (0600)
377
+
378
+ ${C.bold}Environment:${C.reset} JOS_SECRETS_BACKEND=keychain stores values in the macOS login keychain (service "jos")
379
+
380
+ ${C.bold}Exit codes:${C.reset} 0 ok, 1 error, not found or refused, 64 usage; exec returns the code of <cmd>
381
+ `;
382
+
383
+ function parseArgs(args) {
384
+ const dd = args.indexOf('--');
385
+ const head = dd === -1 ? args : args.slice(0, dd);
386
+ const o = { action: head[0], positional: [], stdin: false, reveal: false, only: null, unknown: [], command: dd === -1 ? null : args.slice(dd + 1) };
387
+ o.help = head.includes('--help') || head.includes('-h');
388
+ for (let i = 1; i < head.length; i++) {
389
+ const a = head[i];
390
+ if (a === '--stdin') o.stdin = true;
391
+ else if (a === '--reveal') o.reveal = true;
392
+ else if (a === '--only') o.only = i + 1 < head.length ? head[++i] : '';
393
+ else if (a.startsWith('--only=')) o.only = a.slice(7);
394
+ else if (a.startsWith('-') && a !== '--help' && a !== '-h') o.unknown.push(a);
395
+ else if (!a.startsWith('-')) o.positional.push(a);
396
+ }
397
+ return o;
398
+ }
399
+
400
+ function checkName(name) {
401
+ if (name === undefined || name.length === 0) throw new UsageError('missing secret name');
402
+ if (name.length > MAX_NAME) throw new UsageError('secret names are at most ' + MAX_NAME + ' characters');
403
+ if (CONTROL.test(name)) throw new UsageError('secret names cannot contain control characters');
404
+ }
405
+
406
+ // One rule for the names `set` accepts and `exec --only` accepts: they are environment variable names.
407
+ // `get`, `delete` and `list` keep the looser checkName, so a vault written by an older version (names such as
408
+ // "my-key") can still be listed, read and cleaned up.
409
+ const NAME_RULE = 'letters, digits and "_", not starting with a digit ([A-Za-z_][A-Za-z0-9_]*), because exec hands the name to the command as an environment variable';
410
+ function checkEnvName(name, where) {
411
+ if (!ENV_NAME.test(name)) throw new UsageError(where + ': ' + JSON.stringify(name) + ' is not a valid secret name: use ' + NAME_RULE);
412
+ }
413
+
414
+ const SET_ARG_REFUSED =
415
+ 'a value on the command line is visible in the process list and in your shell history, so it is not accepted.\n' +
416
+ ' Pipe it: printf %s "$VALUE" | jos secrets set NAME --stdin\n' +
417
+ ' or type it at the hidden prompt: jos secrets set NAME --stdin';
418
+
419
+ // Usage errors, found before anything is read.
420
+ function checkUsage(o, io) {
421
+ const action = o.action;
422
+ if (!ACTIONS.includes(action)) throw new UsageError('unknown action ' + JSON.stringify(action) + ' (' + ACTIONS.join(', ') + ')');
423
+ if (o.unknown.length) throw new UsageError('unknown option ' + JSON.stringify(o.unknown[0]) + ' for ' + action);
424
+ const backend = io.env.JOS_SECRETS_BACKEND || 'file';
425
+ if (backend !== 'file' && backend !== 'keychain') throw new UsageError('JOS_SECRETS_BACKEND must be "file" or "keychain"');
426
+ if (backend === 'keychain' && io.platform !== 'darwin') throw new UsageError('JOS_SECRETS_BACKEND=keychain is available on macOS only');
427
+ if (o.command !== null && action !== 'exec') throw new UsageError('"--" is only used by exec');
428
+ if (o.stdin && action !== 'set') throw new UsageError('--stdin is only used by set');
429
+ if (o.reveal && action !== 'get') throw new UsageError('--reveal is only used by get');
430
+ if (o.only !== null && action !== 'exec') throw new UsageError('--only is only used by exec');
431
+ if (action === 'set') {
432
+ if (o.positional.length > 1) throw new UsageError(SET_ARG_REFUSED);
433
+ checkName(o.positional[0]);
434
+ checkEnvName(o.positional[0], 'set');
435
+ if (!o.stdin) throw new UsageError('set needs --stdin; ' + SET_ARG_REFUSED);
436
+ } else if (action === 'get' || action === 'delete') {
437
+ if (o.positional.length !== 1) throw new UsageError('usage: jos secrets ' + action + ' <name>');
438
+ checkName(o.positional[0]);
439
+ } else if (action === 'exec') {
440
+ if (o.positional.length) throw new UsageError('usage: jos secrets exec --only A,B -- <command> [args]');
441
+ if (!o.only) throw new UsageError('exec needs --only A,B: the secrets the command receives are always named');
442
+ if (!o.command || o.command.length === 0) throw new UsageError('exec needs "-- <command> [args]"');
443
+ for (const n of o.only.split(',')) checkEnvName(n, 'exec --only');
444
+ } else if (o.positional.length) {
445
+ throw new UsageError('usage: jos secrets ' + action);
446
+ }
447
+ return backend;
448
+ }
449
+
450
+ exports.execute = async (args, home, ioOverride) => {
451
+ const io = Object.assign(defaultIO(), ioOverride);
452
+ const out = ui.printer(io.stdout);
453
+ const err = ui.printer(io.stderr);
454
+ const ctx = {
455
+ io, C: out.C, out, err,
456
+ secretsDir: path.join(home, 'secrets'),
457
+ };
458
+ ctx.vaultPath = path.join(ctx.secretsDir, 'vault.json');
459
+ ctx.indexPath = path.join(ctx.secretsDir, 'keychain.json');
460
+ const o = parseArgs(args);
461
+ if (!o.action || o.help) { out.text(HELP(out.C)); return 0; }
462
+
463
+ try {
464
+ const backend = checkUsage(o, io);
465
+ if (o.action === 'fix-perms') return fixPerms(ctx);
466
+ const problems = permissionProblems(ctx.secretsDir, io);
467
+ if (problems.length) {
468
+ for (const p of problems) err.warn(`${p.path} ${p.why}`);
469
+ err.fail('jos secrets: refusing to read the secrets while their permissions are open: run `jos secrets fix-perms`');
470
+ return 1;
132
471
  }
472
+ return backend === 'keychain' ? await withKeychain(o, ctx) : await withFile(o, ctx);
133
473
  } catch (e) {
134
- console.log(`${C.red}✖ Error: ${e.message}${C.reset}`);
135
- process.exit(1);
474
+ if (e instanceof UsageError || e instanceof VaultError) {
475
+ err.fail('jos secrets: ' + e.message);
476
+ return e instanceof VaultError ? 1 : (e.exitCode || 64);
477
+ }
478
+ if (typeof e.code === 'string' && FS_CODE.test(e.code)) {
479
+ // file system errors (ENOTDIR, EACCES, ...): report the path, not a stack
480
+ err.fail(`jos secrets: cannot use ${ctx.secretsDir}: ${e.code}`);
481
+ return 1;
482
+ }
483
+ // A host bug. No message of a crypto or fs error carries a value, but the message is replaced anyway
484
+ // (unless JOS_DEBUG is set) so that no future error path can leak one through bin/jos.
485
+ if (io.env.JOS_DEBUG) throw e;
486
+ throw new Error('secrets: unexpected error (set JOS_DEBUG=1 for details)');
136
487
  }
137
488
  };
489
+
490
+ async function withFile(o, ctx) {
491
+ const { io, C, out, err, secretsDir, vaultPath } = ctx;
492
+ const text = fs.existsSync(vaultPath) ? fs.readFileSync(vaultPath, 'utf8') : null;
493
+ const vault = text === null ? Object.create(null) : parseVault(text, vaultPath);
494
+ const names = Object.keys(vault);
495
+ const masterPath = path.join(secretsDir, '.master');
496
+ // A master key is created only by the first `set` of an empty vault: never next to existing entries.
497
+ const master = readMasterKey(secretsDir, o.action === 'set' && names.length === 0, io);
498
+ const needMaster = () => {
499
+ if (!master) throw new VaultError('master key is missing: ' + masterPath + ' (the entries of ' + vaultPath + ' cannot be decrypted without it)');
500
+ return master;
501
+ };
502
+ const name = o.positional[0];
503
+ const notFound = () => { err.warn(`Secret '${name}' not found`); return 1; };
504
+
505
+ switch (o.action) {
506
+ case 'set': {
507
+ const m = needMaster();
508
+ const value = await readSecretValue(io, name);
509
+ vault[name] = encryptV2(m, name, value);
510
+ const fp = fingerprint(m, value);
511
+ value.fill(0);
512
+ writeAtomic(vaultPath, serializeVault(vault));
513
+ err.ok(`Secret '${name}' saved`, `(aes-256-gcm, ${fp})`);
514
+ return 0;
515
+ }
516
+ case 'get': {
517
+ if (!(name in vault)) return notFound();
518
+ if (o.reveal) return await reveal(ctx, name, () => decryptEntry(needMaster(), name, vault[name]));
519
+ const m = needMaster();
520
+ const value = decryptEntry(m, name, vault[name]);
521
+ const fp = fingerprint(m, value);
522
+ value.fill(0);
523
+ out.line(`${name} exists · ${fp} · ` + (entryFormat(vault[name]) === 1 ? 'v1 aes-256-cbc (old format: run jos secrets migrate)' : 'v2 aes-256-gcm'));
524
+ return 0;
525
+ }
526
+ case 'list': {
527
+ if (names.length === 0) { out.line(`${C.dim}No secrets stored${C.reset}`); return 0; }
528
+ const old = names.filter((k) => entryFormat(vault[k]) === 1);
529
+ out.line(`${C.cyan}Stored secrets (${names.length}):${C.reset}`);
530
+ for (const k of names) out.item(k + (entryFormat(vault[k]) === 1 ? ` ${C.yellow}[v1 aes-256-cbc, old format]${C.reset}` : ''));
531
+ if (old.length) out.warn(`${old.length} of ${names.length} in the old format (unauthenticated AES-256-CBC): run \`jos secrets migrate\``);
532
+ return 0;
533
+ }
534
+ case 'delete': {
535
+ if (!(name in vault)) return notFound();
536
+ delete vault[name];
537
+ writeAtomic(vaultPath, serializeVault(vault));
538
+ err.ok(`Secret '${name}' deleted`);
539
+ return 0;
540
+ }
541
+ case 'exec': {
542
+ const want = Array.from(new Set(o.only.split(',')));
543
+ const missing = want.find((n) => !(n in vault));
544
+ if (missing !== undefined) { err.warn(`Secret '${missing}' not found; the command was not started`); return 1; }
545
+ const add = Object.create(null);
546
+ for (const n of want) add[n] = envValue(n, decryptEntry(needMaster(), n, vault[n]));
547
+ return await execChild(ctx, o.command, add);
548
+ }
549
+ default: // migrate (checkUsage admits nothing else here)
550
+ return migrate(ctx, vault, text, names.length ? needMaster() : null);
551
+ }
552
+ }
553
+
554
+ function envValue(name, buf) {
555
+ const nul = buf.includes(0);
556
+ const s = buf.toString('utf8');
557
+ buf.fill(0);
558
+ if (nul) throw new VaultError('secret ' + JSON.stringify(name) + ' contains a NUL byte and cannot be passed in an environment variable');
559
+ return s;
560
+ }
561
+
562
+ function execChild(ctx, command, add) {
563
+ const { io, err } = ctx;
564
+ return new Promise((resolve) => {
565
+ const env = Object.assign(Object.create(null), io.env, add); // null prototype: "__proto__" is a name too
566
+ const cannot = (code) => err.fail(`jos secrets exec: cannot start ${JSON.stringify(command[0])}: ${code}`);
567
+ let child;
568
+ try {
569
+ child = spawn(command[0], command.slice(1), { stdio: io.childStdio, env, shell: false });
570
+ } catch (e) { // invalid arguments (a NUL byte in the command, ...)
571
+ cannot(e.code);
572
+ resolve(126);
573
+ return;
574
+ }
575
+ const forward = ['SIGINT', 'SIGTERM', 'SIGHUP'];
576
+ const relay = (s) => { child.kill(s); };
577
+ for (const s of forward) process.on(s, relay);
578
+ const done = (code) => { for (const s of forward) process.removeListener(s, relay); resolve(code); };
579
+ child.on('error', (e) => { cannot(e.code); done(e.code === 'ENOENT' ? 127 : 126); });
580
+ child.on('exit', (code, signal) => done(signal ? 128 + os.constants.signals[signal] : code));
581
+ });
582
+ }
583
+
584
+ async function reveal(ctx, name, decrypt) {
585
+ const { io, err } = ctx;
586
+ if (!io.stdout.isTTY || !io.stdin.isTTY) {
587
+ err.fail('jos secrets: --reveal prints only to a terminal (stdin and stdout must both be one).');
588
+ err.hint('To hand the value to a program: jos secrets exec --only ' + (ENV_NAME.test(name) ? name : 'NAME') + ' -- <command>');
589
+ return 1;
590
+ }
591
+ const typed = await readTerminalLine(io, 'Type the name ' + JSON.stringify(name) + ' to print its value on this terminal: ', false);
592
+ if (typed !== name) { err.warn('Not confirmed; nothing printed'); return 1; }
593
+ const value = await decrypt();
594
+ ui.data(io.stdout, Buffer.concat([value, Buffer.from('\n')])); // the value itself: data, not a message
595
+ value.fill(0);
596
+ return 0;
597
+ }
598
+
599
+ function fixPerms(ctx) {
600
+ const { io, out, err, secretsDir } = ctx;
601
+ const problems = permissionProblems(secretsDir, io);
602
+ if (problems.length === 0) { out.ok('jos secrets: permissions are already 0700/0600; nothing to fix'); return 0; }
603
+ let bad = 0;
604
+ for (const p of problems) {
605
+ if (!p.fixable) { bad++; err.fail(`${p.path} ${p.why}; fix it by hand`); continue; }
606
+ fs.chmodSync(p.path, p.want);
607
+ out.ok(`${p.path}: ${modeText(p.want)}`);
608
+ }
609
+ return bad ? 1 : 0;
610
+ }
611
+
612
+ function migrate(ctx, vault, text, master) {
613
+ const { io, out, vaultPath } = ctx;
614
+ const old = Object.keys(vault).filter((k) => entryFormat(vault[k]) === 1);
615
+ if (old.length === 0) { out.ok('jos secrets: nothing to migrate (every entry is already v2)'); return 0; }
616
+
617
+ const plain = Object.create(null);
618
+ try {
619
+ // 1. decrypt every old entry first: if one fails, nothing is written
620
+ for (const k of old) plain[k] = decryptEntry(master, k, vault[k]);
621
+
622
+ // 2. a copy of the exact bytes, created exclusively with mode 0600, read back and compared
623
+ const stamp = io.now().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
624
+ let backup = vaultPath + '.bak-' + stamp + '-v1';
625
+ for (let i = 1; fs.existsSync(backup); i++) backup = vaultPath + '.bak-' + stamp + '-v1-' + i;
626
+ const fd = fs.openSync(backup, 'wx', 0o600);
627
+ try { fs.writeSync(fd, Buffer.from(text, 'utf8')); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
628
+ if (io.readBack(backup) !== text) throw new VaultError('backup ' + backup + ' could not be verified; the vault was not changed');
629
+
630
+ // 3. write the v2 vault, read it back from disk, decrypt every migrated value and compare; any
631
+ // difference restores the original bytes
632
+ const next = Object.create(null);
633
+ for (const k of Object.keys(vault)) next[k] = entryFormat(vault[k]) === 1 ? encryptV2(master, k, plain[k]) : vault[k];
634
+ writeAtomic(vaultPath, serializeVault(next));
635
+ let ok;
636
+ try {
637
+ const disk = parseVault(io.readBack(vaultPath), vaultPath);
638
+ ok = Object.keys(disk).length === Object.keys(vault).length && old.every((k) => {
639
+ if (entryFormat(disk[k]) !== 2) return false;
640
+ const got = decryptEntry(master, k, disk[k]);
641
+ const same = sameBytes(got, plain[k]);
642
+ got.fill(0);
643
+ return same;
644
+ });
645
+ } catch (e) { ok = false; }
646
+ if (!ok) {
647
+ writeAtomic(vaultPath, text);
648
+ throw new VaultError('migration could not be verified on disk; the original vault was restored (backup: ' + backup + ')');
649
+ }
650
+ out.ok(`migrated ${old.length} entr${old.length === 1 ? 'y' : 'ies'} to v2 (aes-256-gcm); every value verified`);
651
+ out.hint('backup of the old vault: ' + backup + ' (0600)');
652
+ out.hint('it holds the old unauthenticated ciphertexts under the same master key: delete it once you have checked the vault');
653
+ return 0;
654
+ } finally {
655
+ for (const k of Object.keys(plain)) plain[k].fill(0);
656
+ }
657
+ }
658
+
659
+ async function withKeychain(o, ctx) {
660
+ const { io, C, out, err, secretsDir, indexPath } = ctx;
661
+ const kc = keychain(io);
662
+ const names = readIndex(indexPath);
663
+ const name = o.positional[0];
664
+ const notFound = (n, tail) => { err.warn(`Secret '${n}' not found${tail}`); return 1; };
665
+ const fetch = async (n) => {
666
+ const v = await kc.get(n);
667
+ if (v === null) throw new VaultError('secret ' + JSON.stringify(n) + ' is in ' + indexPath + ' but not in the keychain');
668
+ return v;
669
+ };
670
+ switch (o.action) {
671
+ case 'set': {
672
+ const value = await readSecretValue(io, name);
673
+ try {
674
+ if (value.length > KEYCHAIN_MAX || !value.every((b) => b >= 0x20 && b <= 0x7e)) {
675
+ throw new UsageError('the keychain backend stores printable ASCII values of at most ' + KEYCHAIN_MAX + ' characters; use the file backend for this one');
676
+ }
677
+ await kc.set(name, value);
678
+ writeIndex(secretsDir, indexPath, names.concat([name]));
679
+ err.ok(`Secret '${name}' saved`, `(macOS keychain, service "${KEYCHAIN_SERVICE}")`);
680
+ return 0;
681
+ } finally { value.fill(0); }
682
+ }
683
+ case 'get': {
684
+ if (!names.includes(name)) return notFound(name, '');
685
+ const v = await fetch(name);
686
+ if (o.reveal) {
687
+ try { return await reveal(ctx, name, () => Buffer.from(v)); } finally { v.fill(0); }
688
+ }
689
+ v.fill(0); // read to prove the item exists; no fingerprint here (no master key to key it: SECURITY-SECRETS.md)
690
+ out.line(`${name} exists · macOS keychain`);
691
+ return 0;
692
+ }
693
+ case 'list': {
694
+ if (names.length === 0) { out.line(`${C.dim}No secrets stored${C.reset}`); return 0; }
695
+ out.line(`${C.cyan}Stored secrets (${names.length}, macOS keychain):${C.reset}`);
696
+ for (const k of names) out.item(k);
697
+ return 0;
698
+ }
699
+ case 'delete': {
700
+ const was = await kc.del(name);
701
+ if (!was && !names.includes(name)) return notFound(name, '');
702
+ writeIndex(secretsDir, indexPath, names.filter((n) => n !== name));
703
+ err.ok(`Secret '${name}' deleted`);
704
+ return 0;
705
+ }
706
+ case 'exec': {
707
+ const want = Array.from(new Set(o.only.split(',')));
708
+ const missing = want.find((n) => !names.includes(n));
709
+ if (missing !== undefined) return notFound(missing, '; the command was not started');
710
+ const add = Object.create(null);
711
+ for (const n of want) add[n] = envValue(n, await fetch(n));
712
+ return await execChild(ctx, o.command, add);
713
+ }
714
+ default: // migrate
715
+ throw new UsageError('migrate rewrites the file vault; run it without JOS_SECRETS_BACKEND=keychain');
716
+ }
717
+ }