keymaxxer 0.1.2 → 0.2.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.
- package/README.md +73 -54
- package/dist/cli.mjs +530 -484
- package/package.json +2 -2
package/dist/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// packages/cli/src/index.ts
|
|
5
|
-
import { existsSync as
|
|
5
|
+
import { existsSync as existsSync5, readFileSync as readFileSync3 } from "fs";
|
|
6
6
|
|
|
7
7
|
// packages/sdk/src/scrubber.ts
|
|
8
8
|
var MIN_SCRUB_LEN = 4;
|
|
@@ -59,7 +59,7 @@ function runWithSecrets(opts) {
|
|
|
59
59
|
}
|
|
60
60
|
// packages/sdk/src/store.ts
|
|
61
61
|
import { connect } from "@tursodatabase/database";
|
|
62
|
-
import { mkdirSync } from "node:fs";
|
|
62
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
63
63
|
import { homedir } from "node:os";
|
|
64
64
|
import { dirname, join } from "node:path";
|
|
65
65
|
var DEFAULT_CIPHER = "aes256gcm";
|
|
@@ -82,6 +82,9 @@ class SecretStore {
|
|
|
82
82
|
static async open(opts) {
|
|
83
83
|
const path = opts.path ?? defaultVaultPath();
|
|
84
84
|
mkdirSync(dirname(path), { recursive: true });
|
|
85
|
+
try {
|
|
86
|
+
chmodSync(dirname(path), 448);
|
|
87
|
+
} catch {}
|
|
85
88
|
try {
|
|
86
89
|
const db = await connect(path, {
|
|
87
90
|
encryption: { cipher: opts.cipher ?? DEFAULT_CIPHER, hexkey: opts.hexkey },
|
|
@@ -291,9 +294,8 @@ function newPassphraseMeta(cipher, salt) {
|
|
|
291
294
|
function newExternalKeyMeta(cipher) {
|
|
292
295
|
return { version: 1, cipher, kdf: "none" };
|
|
293
296
|
}
|
|
294
|
-
// packages/cli/src/
|
|
295
|
-
import {
|
|
296
|
-
import { createServer } from "node:net";
|
|
297
|
+
// packages/cli/src/client.ts
|
|
298
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
297
299
|
// packages/cli/src/approver.ts
|
|
298
300
|
import { spawn as spawn2 } from "node:child_process";
|
|
299
301
|
function isSensitive(m) {
|
|
@@ -310,32 +312,43 @@ function describe(req) {
|
|
|
310
312
|
const names = req.secrets.map((s) => `${s.name} (${[s.provider, s.environment, s.access].filter(Boolean).join(" / ")})`).join(", ");
|
|
311
313
|
const cmd = req.command.length > 200 ? req.command.slice(0, 197) + "..." : req.command;
|
|
312
314
|
return [
|
|
313
|
-
"An agent wants to USE a sensitive secret:",
|
|
314
315
|
`Secret: ${names}`,
|
|
315
316
|
`Command: ${cmd}`,
|
|
316
|
-
`Dir: ${req.cwd}
|
|
317
|
+
`Dir: ${req.cwd}`,
|
|
318
|
+
"",
|
|
319
|
+
"Allow once = this command only. Allow session = until the vault locks."
|
|
317
320
|
];
|
|
318
321
|
}
|
|
319
322
|
function approveViaOsascript(req) {
|
|
320
323
|
return new Promise((resolve) => {
|
|
321
324
|
const body = describe(req).map(asQuote).join(" & return & ");
|
|
322
|
-
const script = `display
|
|
325
|
+
const script = `display alert "An agent wants to use a sensitive secret" message (${body}) as critical ` + `buttons {"Deny", "Allow once", "Allow session"} default button "Allow once" ` + `cancel button "Deny" giving up after 60`;
|
|
323
326
|
const proc = spawn2("osascript", ["-e", script]);
|
|
324
327
|
let out = "";
|
|
325
328
|
proc.stdout.on("data", (c) => out += c.toString());
|
|
326
|
-
proc.on("error", () => resolve(
|
|
327
|
-
proc.on("close", (code) =>
|
|
329
|
+
proc.on("error", () => resolve("deny"));
|
|
330
|
+
proc.on("close", (code) => {
|
|
331
|
+
if (code !== 0)
|
|
332
|
+
return resolve("deny");
|
|
333
|
+
if (/button returned:Allow session/.test(out))
|
|
334
|
+
return resolve("session");
|
|
335
|
+
if (/button returned:Allow once/.test(out))
|
|
336
|
+
return resolve("once");
|
|
337
|
+
return resolve("deny");
|
|
338
|
+
});
|
|
328
339
|
});
|
|
329
340
|
}
|
|
330
341
|
async function requestApproval(req) {
|
|
331
342
|
const override = (process.env.KEYMAXXER_APPROVE ?? "").toLowerCase();
|
|
332
|
-
if (override === "allow")
|
|
333
|
-
return true;
|
|
334
343
|
if (override === "deny")
|
|
335
|
-
return
|
|
344
|
+
return "deny";
|
|
345
|
+
if (override === "session")
|
|
346
|
+
return "session";
|
|
347
|
+
if (override === "allow" || override === "once")
|
|
348
|
+
return "once";
|
|
336
349
|
if (process.platform === "darwin")
|
|
337
350
|
return approveViaOsascript(req);
|
|
338
|
-
return
|
|
351
|
+
return "deny";
|
|
339
352
|
}
|
|
340
353
|
|
|
341
354
|
// packages/cli/src/paths.ts
|
|
@@ -347,320 +360,178 @@ function keymaxxerDir() {
|
|
|
347
360
|
function vaultPath() {
|
|
348
361
|
return join3(keymaxxerDir(), "vault.db");
|
|
349
362
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
|
|
363
|
+
|
|
364
|
+
// packages/cli/src/prompt.ts
|
|
365
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
366
|
+
var stdinAttached = false;
|
|
367
|
+
var buffer = "";
|
|
368
|
+
var ended = false;
|
|
369
|
+
var queued = [];
|
|
370
|
+
var waiters = [];
|
|
371
|
+
function attachStdinLines() {
|
|
372
|
+
if (stdinAttached)
|
|
373
|
+
return;
|
|
374
|
+
stdinAttached = true;
|
|
375
|
+
process.stdin.on("data", (d) => {
|
|
376
|
+
buffer += d.toString();
|
|
377
|
+
let nl;
|
|
378
|
+
while ((nl = buffer.indexOf(`
|
|
379
|
+
`)) >= 0) {
|
|
380
|
+
const line = buffer.slice(0, nl).replace(/\r$/, "");
|
|
381
|
+
buffer = buffer.slice(nl + 1);
|
|
382
|
+
const w = waiters.shift();
|
|
383
|
+
if (w)
|
|
384
|
+
w(line);
|
|
385
|
+
else
|
|
386
|
+
queued.push(line);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
process.stdin.on("end", () => {
|
|
390
|
+
ended = true;
|
|
391
|
+
if (buffer.length) {
|
|
392
|
+
queued.push(buffer);
|
|
393
|
+
buffer = "";
|
|
394
|
+
}
|
|
395
|
+
while (waiters.length)
|
|
396
|
+
waiters.shift()(queued.shift() ?? null);
|
|
397
|
+
});
|
|
398
|
+
process.stdin.resume();
|
|
355
399
|
}
|
|
356
|
-
function
|
|
357
|
-
|
|
400
|
+
function nextLine() {
|
|
401
|
+
attachStdinLines();
|
|
402
|
+
const q = queued.shift();
|
|
403
|
+
if (q !== undefined)
|
|
404
|
+
return Promise.resolve(q);
|
|
405
|
+
if (ended)
|
|
406
|
+
return Promise.resolve(null);
|
|
407
|
+
return new Promise((res) => waiters.push(res));
|
|
358
408
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
409
|
+
function readHiddenLine(promptText) {
|
|
410
|
+
return new Promise((resolve) => {
|
|
411
|
+
const stdin = process.stdin;
|
|
412
|
+
process.stderr.write(promptText);
|
|
413
|
+
stdin.setRawMode(true);
|
|
414
|
+
stdin.resume();
|
|
415
|
+
let buf = "";
|
|
416
|
+
const cleanup = () => {
|
|
417
|
+
stdin.setRawMode(false);
|
|
418
|
+
stdin.pause();
|
|
419
|
+
stdin.off("data", onData);
|
|
420
|
+
};
|
|
421
|
+
const onData = (d) => {
|
|
422
|
+
for (const ch of d.toString("utf8")) {
|
|
423
|
+
const code = ch.charCodeAt(0);
|
|
424
|
+
if (code === 13 || code === 10) {
|
|
425
|
+
cleanup();
|
|
426
|
+
process.stderr.write(`
|
|
364
427
|
`);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const onData = (chunk) => {
|
|
371
|
-
data += chunk.toString();
|
|
372
|
-
const nl = data.indexOf(`
|
|
428
|
+
resolve(buf);
|
|
429
|
+
return;
|
|
430
|
+
} else if (code === 3) {
|
|
431
|
+
cleanup();
|
|
432
|
+
process.stderr.write(`
|
|
373
433
|
`);
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
434
|
+
process.exit(130);
|
|
435
|
+
} else if (code === 127 || code === 8) {
|
|
436
|
+
buf = buf.slice(0, -1);
|
|
437
|
+
} else if (code >= 32) {
|
|
438
|
+
buf += ch;
|
|
439
|
+
}
|
|
378
440
|
}
|
|
379
441
|
};
|
|
380
|
-
|
|
381
|
-
process.stdin.on("end", () => reject(new Error("stdin closed before key was received")));
|
|
382
|
-
process.stdin.on("error", reject);
|
|
442
|
+
stdin.on("data", onData);
|
|
383
443
|
});
|
|
384
444
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const idleMs = Math.max(1, Number(process.env.KEYMAXXER_IDLE_MINUTES) || 15) * 60000;
|
|
388
|
-
const hexkey = await readKeyFromStdin();
|
|
389
|
-
let store;
|
|
390
|
-
try {
|
|
391
|
-
store = await SecretStore.open({ path: vaultPath(), hexkey });
|
|
392
|
-
} catch (err) {
|
|
393
|
-
log(`failed to open vault: ${err instanceof Error ? err.message : String(err)}`);
|
|
394
|
-
process.exit(1);
|
|
395
|
-
}
|
|
396
|
-
let lastActivity = Date.now();
|
|
397
|
-
const unlockedAt = Date.now();
|
|
398
|
-
const shutdown = (reason) => {
|
|
399
|
-
log(`locking (${reason})`);
|
|
400
|
-
if (existsSync2(sock))
|
|
401
|
-
unlinkSync(sock);
|
|
402
|
-
if (existsSync2(pidPath()))
|
|
403
|
-
unlinkSync(pidPath());
|
|
404
|
-
process.exit(0);
|
|
405
|
-
};
|
|
406
|
-
const idleTimer = setInterval(() => {
|
|
407
|
-
if (Date.now() - lastActivity > idleMs)
|
|
408
|
-
shutdown("idle timeout");
|
|
409
|
-
}, 5000);
|
|
410
|
-
idleTimer.unref();
|
|
411
|
-
async function handle(req) {
|
|
412
|
-
lastActivity = Date.now();
|
|
413
|
-
try {
|
|
414
|
-
switch (req.op) {
|
|
415
|
-
case "status": {
|
|
416
|
-
const result = {
|
|
417
|
-
unlocked: true,
|
|
418
|
-
vault: vaultPath(),
|
|
419
|
-
idleSeconds: Math.floor((Date.now() - lastActivity) / 1000),
|
|
420
|
-
idleTimeoutSeconds: Math.floor(idleMs / 1000)
|
|
421
|
-
};
|
|
422
|
-
return { ok: true, result };
|
|
423
|
-
}
|
|
424
|
-
case "list":
|
|
425
|
-
return { ok: true, result: await store.list() };
|
|
426
|
-
case "run": {
|
|
427
|
-
const metas = await store.list();
|
|
428
|
-
const sensitive = req.req.secrets.map((n) => metas.find((m) => m.name === n)).filter((m) => !!m && isSensitive(m));
|
|
429
|
-
if (sensitive.length > 0) {
|
|
430
|
-
const names = sensitive.map((s) => s.name).join(", ");
|
|
431
|
-
const ok = await requestApproval({
|
|
432
|
-
secrets: sensitive,
|
|
433
|
-
command: req.req.command,
|
|
434
|
-
cwd: req.req.cwd ?? process.cwd()
|
|
435
|
-
});
|
|
436
|
-
if (!ok) {
|
|
437
|
-
await store.auditDenied(req.req.secrets, req.req.command, req.req.cwd ?? process.cwd());
|
|
438
|
-
log(`DENIED [${names}]: ${req.req.command}`);
|
|
439
|
-
return { ok: false, error: `Denied: use of ${names} was not approved by the user.` };
|
|
440
|
-
}
|
|
441
|
-
log(`approved [${names}]`);
|
|
442
|
-
}
|
|
443
|
-
return { ok: true, result: await store.run(req.req) };
|
|
444
|
-
}
|
|
445
|
-
case "set":
|
|
446
|
-
await store.set(req.name, req.value, req.fields);
|
|
447
|
-
return { ok: true, result: null };
|
|
448
|
-
case "remove":
|
|
449
|
-
return { ok: true, result: await store.remove(req.name) };
|
|
450
|
-
case "audit":
|
|
451
|
-
return { ok: true, result: await store.recentAudit(req.limit) };
|
|
452
|
-
case "lock":
|
|
453
|
-
setTimeout(() => shutdown("explicit lock"), 10);
|
|
454
|
-
return { ok: true, result: null };
|
|
455
|
-
default:
|
|
456
|
-
return { ok: false, error: `unknown op` };
|
|
457
|
-
}
|
|
458
|
-
} catch (err) {
|
|
459
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
const server = createServer((conn) => {
|
|
463
|
-
let data = "";
|
|
464
|
-
conn.on("data", async (chunk) => {
|
|
465
|
-
data += chunk.toString();
|
|
466
|
-
const nl = data.indexOf(`
|
|
467
|
-
`);
|
|
468
|
-
if (nl < 0)
|
|
469
|
-
return;
|
|
470
|
-
let req;
|
|
471
|
-
try {
|
|
472
|
-
req = JSON.parse(data.slice(0, nl));
|
|
473
|
-
} catch {
|
|
474
|
-
conn.end(JSON.stringify({ ok: false, error: "malformed request" }) + `
|
|
475
|
-
`);
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
const res = await handle(req);
|
|
479
|
-
conn.end(JSON.stringify(res) + `
|
|
480
|
-
`);
|
|
481
|
-
});
|
|
482
|
-
conn.on("error", () => conn.destroy());
|
|
483
|
-
});
|
|
484
|
-
mkdirSync3(keymaxxerDir(), { recursive: true });
|
|
485
|
-
try {
|
|
486
|
-
chmodSync(keymaxxerDir(), 448);
|
|
487
|
-
} catch {}
|
|
488
|
-
if (existsSync2(sock))
|
|
489
|
-
unlinkSync(sock);
|
|
490
|
-
server.listen(sock, () => {
|
|
491
|
-
try {
|
|
492
|
-
chmodSync(sock, 384);
|
|
493
|
-
} catch {}
|
|
494
|
-
writeFileSync2(pidPath(), String(process.pid));
|
|
495
|
-
log(`unlocked, listening on ${sock} (idle ${idleMs / 60000}m)`);
|
|
496
|
-
});
|
|
497
|
-
server.on("error", (err) => {
|
|
498
|
-
log(`server error: ${err.message}`);
|
|
499
|
-
process.exit(1);
|
|
500
|
-
});
|
|
501
|
-
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
502
|
-
process.on(sig, () => shutdown(sig));
|
|
503
|
-
}
|
|
445
|
+
function asAppleScript(s) {
|
|
446
|
+
return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/[\r\n]+/g, " ") + '"';
|
|
504
447
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
data += chunk.toString();
|
|
522
|
-
const nl = data.indexOf(`
|
|
523
|
-
`);
|
|
524
|
-
if (nl >= 0) {
|
|
525
|
-
conn.end();
|
|
526
|
-
try {
|
|
527
|
-
resolve(JSON.parse(data.slice(0, nl)));
|
|
528
|
-
} catch (err) {
|
|
529
|
-
reject(new Error("malformed response from agent"));
|
|
530
|
-
}
|
|
531
|
-
}
|
|
448
|
+
function promptPassphraseGui(message) {
|
|
449
|
+
if (process.platform !== "darwin")
|
|
450
|
+
return Promise.resolve(null);
|
|
451
|
+
return new Promise((resolve) => {
|
|
452
|
+
const script = `display dialog ${asAppleScript(message)} default answer "" with hidden answer ` + `with icon note with title "keymaxxer — unlock vault" ` + `buttons {"Cancel", "Unlock"} default button "Unlock" cancel button "Cancel" ` + `giving up after 120`;
|
|
453
|
+
const proc = spawn3("osascript", ["-e", script]);
|
|
454
|
+
let out = "";
|
|
455
|
+
proc.stdout.on("data", (c) => out += c.toString());
|
|
456
|
+
proc.on("error", () => resolve(null));
|
|
457
|
+
proc.on("close", (code) => {
|
|
458
|
+
if (code !== 0)
|
|
459
|
+
return resolve(null);
|
|
460
|
+
if (/gave up:true/.test(out))
|
|
461
|
+
return resolve(null);
|
|
462
|
+
const m = out.match(/text returned:([\s\S]*?)(?:, gave up:[^,]*)?$/);
|
|
463
|
+
resolve(m ? m[1].replace(/\n$/, "") : "");
|
|
532
464
|
});
|
|
533
|
-
conn.on("error", reject);
|
|
534
465
|
});
|
|
535
466
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
if (
|
|
541
|
-
return
|
|
542
|
-
|
|
467
|
+
async function readPassphrase(promptText) {
|
|
468
|
+
const env = process.env.KEYMAXXER_PASSPHRASE;
|
|
469
|
+
if (env)
|
|
470
|
+
return env;
|
|
471
|
+
if (process.stdin.isTTY)
|
|
472
|
+
return readHiddenLine(promptText);
|
|
473
|
+
const piped = await nextLine();
|
|
474
|
+
if (piped)
|
|
475
|
+
return piped;
|
|
476
|
+
const gui = await promptPassphraseGui(promptText.replace(/:\s*$/, ""));
|
|
477
|
+
if (gui)
|
|
478
|
+
return gui;
|
|
479
|
+
throw new Error("no passphrase provided (no TTY, no piped input, no GUI available).");
|
|
543
480
|
}
|
|
544
481
|
|
|
545
482
|
// packages/cli/src/client.ts
|
|
546
483
|
var HEX64 = /^[0-9a-fA-F]{64}$/;
|
|
547
|
-
function
|
|
548
|
-
if (!res.ok)
|
|
549
|
-
throw new Error(res.error);
|
|
550
|
-
return res.result;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
class DaemonClient {
|
|
554
|
-
call(req) {
|
|
555
|
-
return sendRequest(socketPath(), req).then(unwrap);
|
|
556
|
-
}
|
|
557
|
-
list() {
|
|
558
|
-
return this.call({ op: "list" });
|
|
559
|
-
}
|
|
560
|
-
run(req) {
|
|
561
|
-
return this.call({ op: "run", req });
|
|
562
|
-
}
|
|
563
|
-
async set(name, value, fields) {
|
|
564
|
-
await this.call({ op: "set", name, value, fields });
|
|
565
|
-
}
|
|
566
|
-
remove(name) {
|
|
567
|
-
return this.call({ op: "remove", name });
|
|
568
|
-
}
|
|
569
|
-
audit(limit) {
|
|
570
|
-
return this.call({ op: "audit", limit });
|
|
571
|
-
}
|
|
572
|
-
async close() {}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
class DirectStore {
|
|
576
|
-
store;
|
|
577
|
-
constructor(store) {
|
|
578
|
-
this.store = store;
|
|
579
|
-
}
|
|
580
|
-
static async open(hexkey) {
|
|
581
|
-
return new DirectStore(await SecretStore.open({ path: vaultPath(), hexkey }));
|
|
582
|
-
}
|
|
583
|
-
list() {
|
|
584
|
-
return this.store.list();
|
|
585
|
-
}
|
|
586
|
-
run(req) {
|
|
587
|
-
return this.store.run(req);
|
|
588
|
-
}
|
|
589
|
-
set(name, value, fields) {
|
|
590
|
-
return this.store.set(name, value, fields);
|
|
591
|
-
}
|
|
592
|
-
remove(name) {
|
|
593
|
-
return this.store.remove(name);
|
|
594
|
-
}
|
|
595
|
-
audit(limit) {
|
|
596
|
-
return this.store.recentAudit(limit);
|
|
597
|
-
}
|
|
598
|
-
close() {
|
|
599
|
-
return this.store.close();
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
async function isAgentAlive() {
|
|
603
|
-
if (!existsSync3(socketPath()))
|
|
604
|
-
return false;
|
|
605
|
-
try {
|
|
606
|
-
const res = await sendRequest(socketPath(), { op: "status" }, 2000);
|
|
607
|
-
return res.ok;
|
|
608
|
-
} catch {
|
|
609
|
-
for (const p of [socketPath(), pidPath()])
|
|
610
|
-
if (existsSync3(p))
|
|
611
|
-
unlinkSync2(p);
|
|
612
|
-
return false;
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
async function agentStatus() {
|
|
616
|
-
if (!await isAgentAlive())
|
|
617
|
-
return null;
|
|
618
|
-
const res = await sendRequest(socketPath(), { op: "status" });
|
|
619
|
-
return res.ok ? res.result : null;
|
|
620
|
-
}
|
|
621
|
-
async function getClient() {
|
|
484
|
+
async function openVault(getPassphrase) {
|
|
622
485
|
const envKey = process.env.KEYMAXXER_MASTER_KEY;
|
|
623
486
|
if (envKey) {
|
|
624
487
|
if (!HEX64.test(envKey))
|
|
625
488
|
throw new Error("KEYMAXXER_MASTER_KEY must be 64 hex characters.");
|
|
626
|
-
return
|
|
489
|
+
return SecretStore.open({ path: vaultPath(), hexkey: envKey.toLowerCase() });
|
|
627
490
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
491
|
+
const meta = loadMeta(vaultPath());
|
|
492
|
+
if (!meta || !existsSync2(vaultPath()))
|
|
493
|
+
throw new Error("no vault found. Run `keymaxxer init` first.");
|
|
494
|
+
if (meta.kdf !== "scrypt" || !meta.salt) {
|
|
495
|
+
throw new Error("this vault uses an external key — set KEYMAXXER_MASTER_KEY.");
|
|
496
|
+
}
|
|
497
|
+
const passphrase = await getPassphrase();
|
|
498
|
+
if (!passphrase)
|
|
499
|
+
throw new Error("no passphrase provided — vault stays locked.");
|
|
500
|
+
const hexkey = deriveKey(passphrase, meta.salt, meta.scrypt);
|
|
635
501
|
try {
|
|
636
|
-
await
|
|
637
|
-
} catch {
|
|
638
|
-
|
|
502
|
+
return await SecretStore.open({ path: vaultPath(), hexkey });
|
|
503
|
+
} catch (err) {
|
|
504
|
+
if (err instanceof WrongKeyError)
|
|
505
|
+
throw new Error("wrong passphrase.");
|
|
506
|
+
throw err;
|
|
507
|
+
}
|
|
639
508
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
509
|
+
function openVaultCli() {
|
|
510
|
+
return openVault(() => readPassphrase("Vault passphrase: "));
|
|
511
|
+
}
|
|
512
|
+
async function runGated(store, req, approved) {
|
|
513
|
+
const metas = await store.list();
|
|
514
|
+
const sensitive = req.secrets.map((n) => metas.find((m) => m.name === n)).filter((m) => !!m && isSensitive(m));
|
|
515
|
+
const needPrompt = sensitive.filter((s) => !approved.has(s.name));
|
|
516
|
+
if (needPrompt.length > 0) {
|
|
517
|
+
const names = needPrompt.map((s) => s.name).join(", ");
|
|
518
|
+
const decision = await requestApproval({
|
|
519
|
+
secrets: needPrompt,
|
|
520
|
+
command: req.command,
|
|
521
|
+
cwd: req.cwd ?? process.cwd()
|
|
522
|
+
});
|
|
523
|
+
if (decision === "deny") {
|
|
524
|
+
await store.auditDenied(req.secrets, req.command, req.cwd ?? process.cwd());
|
|
525
|
+
throw new Error(`Denied: use of ${names} was not approved by the user.`);
|
|
526
|
+
}
|
|
527
|
+
if (decision === "session")
|
|
528
|
+
needPrompt.forEach((s) => approved.add(s.name));
|
|
658
529
|
}
|
|
659
|
-
|
|
530
|
+
return store.run(req);
|
|
660
531
|
}
|
|
661
532
|
|
|
662
533
|
// packages/cli/src/init.ts
|
|
663
|
-
import { existsSync as
|
|
534
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
664
535
|
import { join as join4 } from "node:path";
|
|
665
536
|
function keymaxxerServerEntry() {
|
|
666
537
|
const entry = process.argv[1] ?? "";
|
|
@@ -672,7 +543,7 @@ function keymaxxerServerEntry() {
|
|
|
672
543
|
function wireMcpJson(dir) {
|
|
673
544
|
const file = join4(dir, ".mcp.json");
|
|
674
545
|
let config = {};
|
|
675
|
-
if (
|
|
546
|
+
if (existsSync3(file)) {
|
|
676
547
|
try {
|
|
677
548
|
config = JSON.parse(readFileSync2(file, "utf8"));
|
|
678
549
|
} catch {
|
|
@@ -682,7 +553,7 @@ function wireMcpJson(dir) {
|
|
|
682
553
|
config.mcpServers ??= {};
|
|
683
554
|
const existed = "keymaxxer" in config.mcpServers;
|
|
684
555
|
config.mcpServers.keymaxxer = keymaxxerServerEntry();
|
|
685
|
-
|
|
556
|
+
writeFileSync2(file, JSON.stringify(config, null, 2) + `
|
|
686
557
|
`);
|
|
687
558
|
return `${existed ? "Updated" : "Added"} 'keymaxxer' server in ${file}`;
|
|
688
559
|
}
|
|
@@ -695,118 +566,262 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
695
566
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
696
567
|
import { z } from "zod";
|
|
697
568
|
|
|
698
|
-
// packages/cli/src/
|
|
699
|
-
import {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
if (!res.ok)
|
|
703
|
-
throw new Error(res.error);
|
|
704
|
-
return res.result;
|
|
569
|
+
// packages/cli/src/addsecret.ts
|
|
570
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
571
|
+
function asAppleScript2(s) {
|
|
572
|
+
return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/[\r\n]+/g, " ") + '"';
|
|
705
573
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
return this.call({ op: "audit", limit });
|
|
725
|
-
}
|
|
726
|
-
async close() {}
|
|
574
|
+
function dialog(message, prefill, saveLabel) {
|
|
575
|
+
if (process.platform !== "darwin")
|
|
576
|
+
return Promise.resolve(null);
|
|
577
|
+
return new Promise((resolve) => {
|
|
578
|
+
const script = `display dialog ${asAppleScript2(message)} default answer ${asAppleScript2(prefill)} ` + `with title "keymaxxer — add secret" with icon note ` + `buttons {"Cancel", ${asAppleScript2(saveLabel)}} default button ${asAppleScript2(saveLabel)} ` + `cancel button "Cancel" giving up after 180`;
|
|
579
|
+
const proc = spawn4("osascript", ["-e", script]);
|
|
580
|
+
let out = "";
|
|
581
|
+
proc.stdout.on("data", (c) => out += c.toString());
|
|
582
|
+
proc.on("error", () => resolve(null));
|
|
583
|
+
proc.on("close", (code) => {
|
|
584
|
+
if (code !== 0)
|
|
585
|
+
return resolve(null);
|
|
586
|
+
if (/gave up:true/.test(out))
|
|
587
|
+
return resolve(null);
|
|
588
|
+
const m = out.match(/text returned:([\s\S]*?)(?:, gave up:[^,]*)?$/);
|
|
589
|
+
resolve(m ? m[1].replace(/\n$/, "") : "");
|
|
590
|
+
});
|
|
591
|
+
});
|
|
727
592
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
593
|
+
function confirm(message) {
|
|
594
|
+
if (process.platform !== "darwin")
|
|
595
|
+
return Promise.resolve(null);
|
|
596
|
+
return new Promise((resolve) => {
|
|
597
|
+
const lines = message.split(`
|
|
598
|
+
`);
|
|
599
|
+
const heading = asAppleScript2(lines[0] ?? "");
|
|
600
|
+
const rest = lines.slice(1).map(asAppleScript2).join(" & return & ") || '""';
|
|
601
|
+
const script = `display alert ${heading} message (${rest}) as informational ` + `buttons {"Edit", "Save"} default button "Save" giving up after 180`;
|
|
602
|
+
const proc = spawn4("osascript", ["-e", script]);
|
|
603
|
+
let out = "";
|
|
604
|
+
proc.stdout.on("data", (c) => out += c.toString());
|
|
605
|
+
proc.on("error", () => resolve(null));
|
|
606
|
+
proc.on("close", (code) => {
|
|
607
|
+
if (code !== 0)
|
|
608
|
+
return resolve(null);
|
|
609
|
+
if (/gave up:true/.test(out))
|
|
610
|
+
return resolve(null);
|
|
611
|
+
if (/button returned:Save/.test(out))
|
|
612
|
+
return resolve("save");
|
|
613
|
+
if (/button returned:Edit/.test(out))
|
|
614
|
+
return resolve("edit");
|
|
615
|
+
return resolve(null);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
function tokenize(s) {
|
|
620
|
+
const out = [];
|
|
621
|
+
let cur = "";
|
|
622
|
+
let inQuote = false;
|
|
623
|
+
let started = false;
|
|
624
|
+
for (const ch of s) {
|
|
625
|
+
if (ch === '"') {
|
|
626
|
+
inQuote = !inQuote;
|
|
627
|
+
started = true;
|
|
628
|
+
} else if (ch === " " && !inQuote) {
|
|
629
|
+
if (started || cur)
|
|
630
|
+
out.push(cur);
|
|
631
|
+
cur = "";
|
|
632
|
+
started = false;
|
|
633
|
+
} else {
|
|
634
|
+
cur += ch;
|
|
635
|
+
started = true;
|
|
636
|
+
}
|
|
751
637
|
}
|
|
752
|
-
|
|
753
|
-
|
|
638
|
+
if (started || cur)
|
|
639
|
+
out.push(cur);
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
function tokensToFields(tokens) {
|
|
643
|
+
const flags = {};
|
|
644
|
+
for (let i = 0;i < tokens.length; i++) {
|
|
645
|
+
const t = tokens[i];
|
|
646
|
+
if (!t.startsWith("--"))
|
|
647
|
+
continue;
|
|
648
|
+
const key = t.slice(2);
|
|
649
|
+
const next = tokens[i + 1];
|
|
650
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
651
|
+
flags[key] = next;
|
|
652
|
+
i++;
|
|
653
|
+
} else {
|
|
654
|
+
flags[key] = "";
|
|
655
|
+
}
|
|
754
656
|
}
|
|
657
|
+
const fields = {};
|
|
658
|
+
const pick = (...keys) => keys.map((k) => flags[k]).find((v) => v);
|
|
659
|
+
const tag = pick("tag", "tags");
|
|
660
|
+
if (tag)
|
|
661
|
+
fields.tags = tag.split(",");
|
|
662
|
+
const provider = pick("provider");
|
|
663
|
+
if (provider)
|
|
664
|
+
fields.provider = provider;
|
|
665
|
+
const account = pick("account");
|
|
666
|
+
if (account)
|
|
667
|
+
fields.account = account;
|
|
668
|
+
const environment = pick("env", "environment");
|
|
669
|
+
if (environment)
|
|
670
|
+
fields.environment = environment;
|
|
671
|
+
const access = pick("access");
|
|
672
|
+
if (access)
|
|
673
|
+
fields.access = access;
|
|
674
|
+
const description = pick("description", "desc");
|
|
675
|
+
if (description)
|
|
676
|
+
fields.description = description;
|
|
677
|
+
return fields;
|
|
755
678
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
679
|
+
function suggestionLine(s) {
|
|
680
|
+
const q = (v) => `"${v.replace(/"/g, "'")}"`;
|
|
681
|
+
const parts = [s.name];
|
|
682
|
+
if (s.provider)
|
|
683
|
+
parts.push(`--provider ${q(s.provider)}`);
|
|
684
|
+
if (s.account)
|
|
685
|
+
parts.push(`--account ${q(s.account)}`);
|
|
686
|
+
if (s.environment)
|
|
687
|
+
parts.push(`--env ${q(s.environment)}`);
|
|
688
|
+
if (s.access)
|
|
689
|
+
parts.push(`--access ${q(s.access)}`);
|
|
690
|
+
if (s.tags)
|
|
691
|
+
parts.push(`--tag ${q(s.tags)}`);
|
|
692
|
+
if (s.description)
|
|
693
|
+
parts.push(`--description ${q(s.description)}`);
|
|
694
|
+
return parts.join(" ");
|
|
695
|
+
}
|
|
696
|
+
function parseSuggestionLine(line) {
|
|
697
|
+
const tokens = tokenize(line.trim());
|
|
698
|
+
const name = tokens[0];
|
|
699
|
+
if (!name || name.startsWith("--"))
|
|
700
|
+
throw new Error("a secret name is required.");
|
|
701
|
+
return { name, fields: tokensToFields(tokens.slice(1)) };
|
|
702
|
+
}
|
|
703
|
+
async function promptAddSecret(s) {
|
|
704
|
+
const edited = await dialog("Edit the new secret's name and attributes. Available flags: --provider --account --env --access --tag --description", suggestionLine(s), "Next");
|
|
705
|
+
if (edited === null)
|
|
706
|
+
return null;
|
|
707
|
+
const { name, fields } = parseSuggestionLine(edited);
|
|
708
|
+
let value = "";
|
|
709
|
+
for (;; ) {
|
|
710
|
+
const entered = await dialog(`Value for ${name} — saved to the vault, never shared with the agent:`, value, "Review");
|
|
711
|
+
if (entered === null)
|
|
712
|
+
return null;
|
|
713
|
+
if (!entered)
|
|
714
|
+
throw new Error("no value provided.");
|
|
715
|
+
value = entered;
|
|
716
|
+
const choice = await confirm(`Save this value for ${name}?
|
|
717
|
+
|
|
718
|
+
${value}`);
|
|
719
|
+
if (choice === "save")
|
|
720
|
+
break;
|
|
721
|
+
if (choice === null)
|
|
722
|
+
return null;
|
|
767
723
|
}
|
|
724
|
+
return { name, value, fields };
|
|
768
725
|
}
|
|
769
|
-
|
|
726
|
+
|
|
727
|
+
// packages/cli/src/client.ts
|
|
728
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
729
|
+
var HEX642 = /^[0-9a-fA-F]{64}$/;
|
|
730
|
+
async function openVault2(getPassphrase) {
|
|
770
731
|
const envKey = process.env.KEYMAXXER_MASTER_KEY;
|
|
771
732
|
if (envKey) {
|
|
772
733
|
if (!HEX642.test(envKey))
|
|
773
734
|
throw new Error("KEYMAXXER_MASTER_KEY must be 64 hex characters.");
|
|
774
|
-
return
|
|
735
|
+
return SecretStore.open({ path: vaultPath(), hexkey: envKey.toLowerCase() });
|
|
736
|
+
}
|
|
737
|
+
const meta = loadMeta(vaultPath());
|
|
738
|
+
if (!meta || !existsSync4(vaultPath()))
|
|
739
|
+
throw new Error("no vault found. Run `keymaxxer init` first.");
|
|
740
|
+
if (meta.kdf !== "scrypt" || !meta.salt) {
|
|
741
|
+
throw new Error("this vault uses an external key — set KEYMAXXER_MASTER_KEY.");
|
|
742
|
+
}
|
|
743
|
+
const passphrase = await getPassphrase();
|
|
744
|
+
if (!passphrase)
|
|
745
|
+
throw new Error("no passphrase provided — vault stays locked.");
|
|
746
|
+
const hexkey = deriveKey(passphrase, meta.salt, meta.scrypt);
|
|
747
|
+
try {
|
|
748
|
+
return await SecretStore.open({ path: vaultPath(), hexkey });
|
|
749
|
+
} catch (err) {
|
|
750
|
+
if (err instanceof WrongKeyError)
|
|
751
|
+
throw new Error("wrong passphrase.");
|
|
752
|
+
throw err;
|
|
775
753
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
754
|
+
}
|
|
755
|
+
function openVaultServe(message) {
|
|
756
|
+
return openVault2(() => process.env.KEYMAXXER_PASSPHRASE ? Promise.resolve(process.env.KEYMAXXER_PASSPHRASE) : promptPassphraseGui(message));
|
|
757
|
+
}
|
|
758
|
+
async function runGated2(store, req, approved) {
|
|
759
|
+
const metas = await store.list();
|
|
760
|
+
const sensitive = req.secrets.map((n) => metas.find((m) => m.name === n)).filter((m) => !!m && isSensitive(m));
|
|
761
|
+
const needPrompt = sensitive.filter((s) => !approved.has(s.name));
|
|
762
|
+
if (needPrompt.length > 0) {
|
|
763
|
+
const names = needPrompt.map((s) => s.name).join(", ");
|
|
764
|
+
const decision = await requestApproval({
|
|
765
|
+
secrets: needPrompt,
|
|
766
|
+
command: req.command,
|
|
767
|
+
cwd: req.cwd ?? process.cwd()
|
|
768
|
+
});
|
|
769
|
+
if (decision === "deny") {
|
|
770
|
+
await store.auditDenied(req.secrets, req.command, req.cwd ?? process.cwd());
|
|
771
|
+
throw new Error(`Denied: use of ${names} was not approved by the user.`);
|
|
772
|
+
}
|
|
773
|
+
if (decision === "session")
|
|
774
|
+
needPrompt.forEach((s) => approved.add(s.name));
|
|
775
|
+
}
|
|
776
|
+
return store.run(req);
|
|
779
777
|
}
|
|
780
778
|
|
|
781
779
|
// packages/cli/src/mcp.ts
|
|
782
780
|
async function serve() {
|
|
783
|
-
|
|
781
|
+
let store = null;
|
|
782
|
+
const approved = new Set;
|
|
783
|
+
let lastActivity = Date.now();
|
|
784
|
+
const idleMs = Math.max(0, Number(process.env.KEYMAXXER_IDLE_MINUTES) || 0) * 60000;
|
|
785
|
+
if (idleMs > 0) {
|
|
786
|
+
const timer = setInterval(() => {
|
|
787
|
+
if (store && Date.now() - lastActivity > idleMs) {
|
|
788
|
+
store.close().catch(() => {});
|
|
789
|
+
store = null;
|
|
790
|
+
approved.clear();
|
|
791
|
+
}
|
|
792
|
+
}, 5000);
|
|
793
|
+
timer.unref();
|
|
794
|
+
}
|
|
795
|
+
async function vault() {
|
|
796
|
+
lastActivity = Date.now();
|
|
797
|
+
if (!store) {
|
|
798
|
+
store = await openVaultServe("An agent wants to use a secret. Enter your keymaxxer passphrase to unlock the vault:");
|
|
799
|
+
}
|
|
800
|
+
return store;
|
|
801
|
+
}
|
|
802
|
+
const server = new McpServer({ name: "keymaxxer", version: "0.2.0" });
|
|
784
803
|
server.registerTool("keymaxxer_list", {
|
|
785
804
|
description: "List the secrets in the vault with their attributes — name, provider (e.g. github/orb/stripe), account, environment (prod/dev/staging), access level (read-only/read-write/admin), tags, and description. Returns NO secret values. Call this first to discover which secrets exist AND to choose the correct one: match the provider/account the task targets, prefer the right environment, and prefer the least-privileged credential that can do the job (e.g. a read-only key when you're only reading).",
|
|
786
805
|
inputSchema: {}
|
|
787
806
|
}, async () => {
|
|
788
807
|
try {
|
|
789
|
-
const
|
|
790
|
-
const metas = await client.list();
|
|
791
|
-
await client.close();
|
|
808
|
+
const metas = await (await vault()).list();
|
|
792
809
|
return { content: [{ type: "text", text: JSON.stringify(metas, null, 2) }] };
|
|
793
810
|
} catch (err) {
|
|
794
811
|
return { content: [{ type: "text", text: errText(err) }], isError: true };
|
|
795
812
|
}
|
|
796
813
|
});
|
|
797
814
|
server.registerTool("keymaxxer_run", {
|
|
798
|
-
description:
|
|
815
|
+
description: `Run a shell command with secrets injected as environment variables. Reference each secret as $NAME (e.g. "gh api /user" with GITHUB_TOKEN, or curl -H "Authorization: Bearer $TOKEN"). Secret values are injected into the child process only; they are scrubbed from the returned output and never exposed to you. Use keymaxxer_list to find available names. Note: if the vault is locked the human is prompted to unlock it (the call may pause). Read-write or production secrets are gated — the human approves the use and may allow it just once or for the whole session; the call may pause and can be denied. If denied, pick a less-privileged secret or ask the user. Don't tell the user to unlock manually — just make the call and it will prompt them.`,
|
|
799
816
|
inputSchema: {
|
|
800
817
|
command: z.string().describe("Shell command to run. Reference secrets as $NAME."),
|
|
801
818
|
secrets: z.array(z.string()).describe("Names of secrets to inject as environment variables."),
|
|
802
|
-
cwd: z.string().optional().describe("Working directory. Defaults to the
|
|
819
|
+
cwd: z.string().optional().describe("Working directory. Defaults to the server's cwd."),
|
|
803
820
|
timeoutMs: z.number().optional().describe("Kill the command after this many milliseconds.")
|
|
804
821
|
}
|
|
805
822
|
}, async (args) => {
|
|
806
823
|
try {
|
|
807
|
-
const
|
|
808
|
-
const res = await client.run(args);
|
|
809
|
-
await client.close();
|
|
824
|
+
const res = await runGated2(await vault(), args, approved);
|
|
810
825
|
const lines = [
|
|
811
826
|
`exit_code: ${res.exitCode}`,
|
|
812
827
|
res.redactions > 0 ? `[keymaxxer] redacted ${res.redactions} secret occurrence(s)` : null,
|
|
@@ -821,6 +836,37 @@ async function serve() {
|
|
|
821
836
|
return { content: [{ type: "text", text: errText(err) }], isError: true };
|
|
822
837
|
}
|
|
823
838
|
});
|
|
839
|
+
server.registerTool("keymaxxer_add", {
|
|
840
|
+
description: "Ask the human to add a new secret to the vault. Suggest the name and any attributes you can infer (provider, account, environment, access, description, tags); the human reviews/edits them in a dialog and types the secret VALUE. The value is saved straight to the encrypted vault and is NEVER returned to you. Use this when a task needs a credential that isn't in keymaxxer_list yet — never ask the user to paste a secret into the chat.",
|
|
841
|
+
inputSchema: {
|
|
842
|
+
name: z.string().describe("Suggested secret name, e.g. GITHUB_TOKEN or ORB_PROD_TOKEN."),
|
|
843
|
+
provider: z.string().optional().describe("e.g. github, orb, stripe"),
|
|
844
|
+
account: z.string().optional().describe("which account/org the credential belongs to"),
|
|
845
|
+
environment: z.string().optional().describe("prod / dev / staging / test"),
|
|
846
|
+
access: z.string().optional().describe("read-only / read-write / admin"),
|
|
847
|
+
description: z.string().optional(),
|
|
848
|
+
tags: z.string().optional().describe("comma-separated")
|
|
849
|
+
}
|
|
850
|
+
}, async (args) => {
|
|
851
|
+
try {
|
|
852
|
+
const store2 = await vault();
|
|
853
|
+
const result = await promptAddSecret(args);
|
|
854
|
+
if (!result) {
|
|
855
|
+
return { content: [{ type: "text", text: "The user cancelled — no secret was added." }] };
|
|
856
|
+
}
|
|
857
|
+
await store2.set(result.name, result.value, result.fields);
|
|
858
|
+
return {
|
|
859
|
+
content: [
|
|
860
|
+
{
|
|
861
|
+
type: "text",
|
|
862
|
+
text: `Stored '${result.name}'. The value was entered by the user and is not shown to you — use it with keymaxxer_run as $${result.name}.`
|
|
863
|
+
}
|
|
864
|
+
]
|
|
865
|
+
};
|
|
866
|
+
} catch (err) {
|
|
867
|
+
return { content: [{ type: "text", text: errText(err) }], isError: true };
|
|
868
|
+
}
|
|
869
|
+
});
|
|
824
870
|
await server.connect(new StdioServerTransport);
|
|
825
871
|
}
|
|
826
872
|
function errText(err) {
|
|
@@ -838,37 +884,51 @@ function vaultPath2() {
|
|
|
838
884
|
}
|
|
839
885
|
|
|
840
886
|
// packages/cli/src/prompt.ts
|
|
841
|
-
|
|
842
|
-
var
|
|
843
|
-
var
|
|
844
|
-
var
|
|
845
|
-
|
|
846
|
-
|
|
887
|
+
import { spawn as spawn5 } from "node:child_process";
|
|
888
|
+
var stdinAttached2 = false;
|
|
889
|
+
var buffer2 = "";
|
|
890
|
+
var ended2 = false;
|
|
891
|
+
var queued2 = [];
|
|
892
|
+
var waiters2 = [];
|
|
893
|
+
function attachStdinLines2() {
|
|
894
|
+
if (stdinAttached2)
|
|
847
895
|
return;
|
|
848
|
-
|
|
896
|
+
stdinAttached2 = true;
|
|
849
897
|
process.stdin.on("data", (d) => {
|
|
850
|
-
|
|
898
|
+
buffer2 += d.toString();
|
|
851
899
|
let nl;
|
|
852
|
-
while ((nl =
|
|
900
|
+
while ((nl = buffer2.indexOf(`
|
|
853
901
|
`)) >= 0) {
|
|
854
|
-
const line =
|
|
855
|
-
|
|
856
|
-
const w =
|
|
902
|
+
const line = buffer2.slice(0, nl).replace(/\r$/, "");
|
|
903
|
+
buffer2 = buffer2.slice(nl + 1);
|
|
904
|
+
const w = waiters2.shift();
|
|
857
905
|
if (w)
|
|
858
906
|
w(line);
|
|
859
907
|
else
|
|
860
|
-
|
|
908
|
+
queued2.push(line);
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
process.stdin.on("end", () => {
|
|
912
|
+
ended2 = true;
|
|
913
|
+
if (buffer2.length) {
|
|
914
|
+
queued2.push(buffer2);
|
|
915
|
+
buffer2 = "";
|
|
861
916
|
}
|
|
917
|
+
while (waiters2.length)
|
|
918
|
+
waiters2.shift()(queued2.shift() ?? null);
|
|
862
919
|
});
|
|
920
|
+
process.stdin.resume();
|
|
863
921
|
}
|
|
864
|
-
function
|
|
865
|
-
|
|
866
|
-
const q =
|
|
922
|
+
function nextLine2() {
|
|
923
|
+
attachStdinLines2();
|
|
924
|
+
const q = queued2.shift();
|
|
867
925
|
if (q !== undefined)
|
|
868
926
|
return Promise.resolve(q);
|
|
869
|
-
|
|
927
|
+
if (ended2)
|
|
928
|
+
return Promise.resolve(null);
|
|
929
|
+
return new Promise((res) => waiters2.push(res));
|
|
870
930
|
}
|
|
871
|
-
function
|
|
931
|
+
function readHiddenLine2(promptText) {
|
|
872
932
|
return new Promise((resolve) => {
|
|
873
933
|
const stdin = process.stdin;
|
|
874
934
|
process.stderr.write(promptText);
|
|
@@ -904,16 +964,47 @@ function readHiddenLine(promptText) {
|
|
|
904
964
|
stdin.on("data", onData);
|
|
905
965
|
});
|
|
906
966
|
}
|
|
907
|
-
function
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
967
|
+
function asAppleScript3(s) {
|
|
968
|
+
return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/[\r\n]+/g, " ") + '"';
|
|
969
|
+
}
|
|
970
|
+
function promptPassphraseGui2(message) {
|
|
971
|
+
if (process.platform !== "darwin")
|
|
972
|
+
return Promise.resolve(null);
|
|
973
|
+
return new Promise((resolve) => {
|
|
974
|
+
const script = `display dialog ${asAppleScript3(message)} default answer "" with hidden answer ` + `with icon note with title "keymaxxer — unlock vault" ` + `buttons {"Cancel", "Unlock"} default button "Unlock" cancel button "Cancel" ` + `giving up after 120`;
|
|
975
|
+
const proc = spawn5("osascript", ["-e", script]);
|
|
976
|
+
let out = "";
|
|
977
|
+
proc.stdout.on("data", (c) => out += c.toString());
|
|
978
|
+
proc.on("error", () => resolve(null));
|
|
979
|
+
proc.on("close", (code) => {
|
|
980
|
+
if (code !== 0)
|
|
981
|
+
return resolve(null);
|
|
982
|
+
if (/gave up:true/.test(out))
|
|
983
|
+
return resolve(null);
|
|
984
|
+
const m = out.match(/text returned:([\s\S]*?)(?:, gave up:[^,]*)?$/);
|
|
985
|
+
resolve(m ? m[1].replace(/\n$/, "") : "");
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
async function readPassphrase2(promptText) {
|
|
990
|
+
const env = process.env.KEYMAXXER_PASSPHRASE;
|
|
991
|
+
if (env)
|
|
992
|
+
return env;
|
|
993
|
+
if (process.stdin.isTTY)
|
|
994
|
+
return readHiddenLine2(promptText);
|
|
995
|
+
const piped = await nextLine2();
|
|
996
|
+
if (piped)
|
|
997
|
+
return piped;
|
|
998
|
+
const gui = await promptPassphraseGui2(promptText.replace(/:\s*$/, ""));
|
|
999
|
+
if (gui)
|
|
1000
|
+
return gui;
|
|
1001
|
+
throw new Error("no passphrase provided (no TTY, no piped input, no GUI available).");
|
|
911
1002
|
}
|
|
912
1003
|
async function readNewPassphrase() {
|
|
913
|
-
const a = await
|
|
1004
|
+
const a = await readPassphrase2("Create a vault passphrase: ");
|
|
914
1005
|
if (a.length < 8)
|
|
915
1006
|
throw new Error("passphrase must be at least 8 characters.");
|
|
916
|
-
const b = await
|
|
1007
|
+
const b = await readPassphrase2("Confirm passphrase: ");
|
|
917
1008
|
if (a !== b)
|
|
918
1009
|
throw new Error("passphrases did not match.");
|
|
919
1010
|
return a;
|
|
@@ -923,12 +1014,9 @@ async function readNewPassphrase() {
|
|
|
923
1014
|
var HELP = `keymaxxer \u2014 a secret manager for coding agents
|
|
924
1015
|
|
|
925
1016
|
Setup:
|
|
926
|
-
keymaxxer init
|
|
927
|
-
keymaxxer unlock [--timeout m] Unlock the vault into the background agent (default 15m idle)
|
|
928
|
-
keymaxxer lock Lock the vault and stop the agent
|
|
929
|
-
keymaxxer status Show whether the vault is unlocked
|
|
1017
|
+
keymaxxer init Create the encrypted vault (prompts for a passphrase)
|
|
930
1018
|
|
|
931
|
-
Secrets
|
|
1019
|
+
Secrets:
|
|
932
1020
|
keymaxxer set <NAME> [attrs] Store a secret; prompts to paste the value (hidden),
|
|
933
1021
|
or pipe it in. attrs: --provider --account --env --access --tag --description
|
|
934
1022
|
keymaxxer import <file> Import KEY=VALUE lines from a .env-style file
|
|
@@ -938,10 +1026,10 @@ Secrets (require an unlocked vault, or KEYMAXXER_MASTER_KEY):
|
|
|
938
1026
|
keymaxxer audit [--limit N] Show recent secret-access log
|
|
939
1027
|
|
|
940
1028
|
Agent integration:
|
|
941
|
-
keymaxxer serve Start the MCP server on stdio (
|
|
1029
|
+
keymaxxer serve Start the MCP server on stdio (holds the key for the session)
|
|
942
1030
|
|
|
943
|
-
The
|
|
944
|
-
|
|
1031
|
+
The key is derived from your passphrase and never stored. Each command unlocks on
|
|
1032
|
+
demand; set KEYMAXXER_PASSPHRASE (or KEYMAXXER_MASTER_KEY, 64-hex) for non-interactive use.`;
|
|
945
1033
|
async function readStdin() {
|
|
946
1034
|
const chunks = [];
|
|
947
1035
|
for await (const chunk of process.stdin)
|
|
@@ -1014,28 +1102,23 @@ async function main() {
|
|
|
1014
1102
|
case "--help":
|
|
1015
1103
|
console.log(HELP);
|
|
1016
1104
|
return;
|
|
1017
|
-
case "__agent":
|
|
1018
|
-
await runAgent();
|
|
1019
|
-
return;
|
|
1020
1105
|
case "init": {
|
|
1021
|
-
if (loadMeta(vaultPath2()) &&
|
|
1106
|
+
if (loadMeta(vaultPath2()) && existsSync5(vaultPath2())) {
|
|
1022
1107
|
die("a vault already exists at " + vaultPath2());
|
|
1023
1108
|
}
|
|
1024
1109
|
const envKey = process.env.KEYMAXXER_MASTER_KEY;
|
|
1025
1110
|
if (envKey) {
|
|
1026
|
-
await SecretStore.open({ path: vaultPath2(), hexkey: envKey.toLowerCase() });
|
|
1111
|
+
const store = await SecretStore.open({ path: vaultPath2(), hexkey: envKey.toLowerCase() });
|
|
1112
|
+
await store.close();
|
|
1027
1113
|
saveMeta(vaultPath2(), newExternalKeyMeta(DEFAULT_CIPHER));
|
|
1028
1114
|
console.log(`\u2713 Vault created at ${vaultPath2()} using KEYMAXXER_MASTER_KEY (AES-256-GCM).`);
|
|
1029
1115
|
} else {
|
|
1030
1116
|
const passphrase = await readNewPassphrase();
|
|
1031
1117
|
const salt = generateSalt();
|
|
1032
|
-
const
|
|
1033
|
-
const store = await SecretStore.open({ path: vaultPath2(), hexkey });
|
|
1118
|
+
const store = await SecretStore.open({ path: vaultPath2(), hexkey: deriveKey(passphrase, salt) });
|
|
1034
1119
|
await store.close();
|
|
1035
1120
|
saveMeta(vaultPath2(), newPassphraseMeta(DEFAULT_CIPHER, salt));
|
|
1036
1121
|
console.log(`\u2713 Vault created at ${vaultPath2()} (key derived from your passphrase, AES-256-GCM).`);
|
|
1037
|
-
await spawnAgent(hexkey);
|
|
1038
|
-
console.log("\u2713 Vault unlocked into the background agent.");
|
|
1039
1122
|
}
|
|
1040
1123
|
console.log(wireMcpJson(process.cwd()));
|
|
1041
1124
|
console.log(`
|
|
@@ -1043,64 +1126,23 @@ For editors not auto-configured, add this MCP server:`);
|
|
|
1043
1126
|
console.log(manualSnippet());
|
|
1044
1127
|
return;
|
|
1045
1128
|
}
|
|
1046
|
-
case "unlock": {
|
|
1047
|
-
const meta = loadMeta(vaultPath2());
|
|
1048
|
-
if (!meta || !existsSync6(vaultPath2()))
|
|
1049
|
-
die("no vault found. Run `keymaxxer init` first.");
|
|
1050
|
-
if (meta.kdf === "none") {
|
|
1051
|
-
die("this vault uses an external key \u2014 set KEYMAXXER_MASTER_KEY; no unlock needed.");
|
|
1052
|
-
}
|
|
1053
|
-
if (await isAgentAlive()) {
|
|
1054
|
-
console.log("Vault is already unlocked.");
|
|
1055
|
-
return;
|
|
1056
|
-
}
|
|
1057
|
-
const passphrase = await readPassphrase("Vault passphrase: ");
|
|
1058
|
-
const hexkey = deriveKey(passphrase, meta.salt, meta.scrypt);
|
|
1059
|
-
try {
|
|
1060
|
-
const probe = await SecretStore.open({ path: vaultPath2(), hexkey });
|
|
1061
|
-
await probe.close();
|
|
1062
|
-
} catch (err) {
|
|
1063
|
-
if (err instanceof WrongKeyError)
|
|
1064
|
-
die("wrong passphrase.");
|
|
1065
|
-
throw err;
|
|
1066
|
-
}
|
|
1067
|
-
const timeout = typeof flags.timeout === "string" ? Number(flags.timeout) : undefined;
|
|
1068
|
-
await spawnAgent(hexkey, timeout);
|
|
1069
|
-
const st = await agentStatus();
|
|
1070
|
-
console.log(`\u2713 Vault unlocked (auto-locks after ${st ? st.idleTimeoutSeconds / 60 : 15}m idle).`);
|
|
1071
|
-
return;
|
|
1072
|
-
}
|
|
1073
|
-
case "lock": {
|
|
1074
|
-
const stopped = await lockAgent();
|
|
1075
|
-
console.log(stopped ? "\u2713 Vault locked." : "Vault was not unlocked.");
|
|
1076
|
-
return;
|
|
1077
|
-
}
|
|
1078
|
-
case "status": {
|
|
1079
|
-
const st = await agentStatus();
|
|
1080
|
-
if (!st) {
|
|
1081
|
-
console.log(process.env.KEYMAXXER_MASTER_KEY ? "Unlocked via KEYMAXXER_MASTER_KEY." : "Locked.");
|
|
1082
|
-
return;
|
|
1083
|
-
}
|
|
1084
|
-
console.log(`Unlocked \u2014 vault ${st.vault}, idle ${st.idleSeconds}s / ${st.idleTimeoutSeconds}s timeout.`);
|
|
1085
|
-
return;
|
|
1086
|
-
}
|
|
1087
1129
|
case "set": {
|
|
1088
1130
|
const name = positionals[0] ?? die("usage: keymaxxer set <NAME> [--provider p] [--account a] [--env e] [--access a] [--tag t] [--description d]");
|
|
1089
|
-
const value = process.stdin.isTTY ? await
|
|
1131
|
+
const value = process.stdin.isTTY ? await readHiddenLine2(`Value for ${name} (paste \u2014 input is hidden): `) : await readStdin();
|
|
1090
1132
|
if (!value)
|
|
1091
1133
|
die("no value provided.");
|
|
1092
|
-
const
|
|
1093
|
-
await
|
|
1094
|
-
await
|
|
1134
|
+
const store = await openVaultCli();
|
|
1135
|
+
await store.set(name, value, fieldsFromFlags(flags));
|
|
1136
|
+
await store.close();
|
|
1095
1137
|
console.log(`\u2713 Stored '${name}'.`);
|
|
1096
1138
|
return;
|
|
1097
1139
|
}
|
|
1098
1140
|
case "import": {
|
|
1099
1141
|
const file = positionals[0] ?? die("usage: keymaxxer import <file>");
|
|
1100
|
-
if (!
|
|
1142
|
+
if (!existsSync5(file))
|
|
1101
1143
|
die(`file not found: ${file}`);
|
|
1102
1144
|
const text = readFileSync3(file, "utf8");
|
|
1103
|
-
const
|
|
1145
|
+
const store = await openVaultCli();
|
|
1104
1146
|
let count = 0;
|
|
1105
1147
|
for (const raw of text.split(`
|
|
1106
1148
|
`)) {
|
|
@@ -1115,19 +1157,19 @@ For editors not auto-configured, add this MCP server:`);
|
|
|
1115
1157
|
if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
|
|
1116
1158
|
val = val.slice(1, -1);
|
|
1117
1159
|
}
|
|
1118
|
-
await
|
|
1160
|
+
await store.set(key, val, {});
|
|
1119
1161
|
count++;
|
|
1120
1162
|
}
|
|
1121
|
-
await
|
|
1163
|
+
await store.close();
|
|
1122
1164
|
console.log(`\u2713 Imported ${count} secret(s) from ${file}.`);
|
|
1123
1165
|
return;
|
|
1124
1166
|
}
|
|
1125
1167
|
case "list": {
|
|
1126
|
-
const
|
|
1127
|
-
const metas = await
|
|
1128
|
-
await
|
|
1168
|
+
const store = await openVaultCli();
|
|
1169
|
+
const metas = await store.list();
|
|
1170
|
+
await store.close();
|
|
1129
1171
|
if (metas.length === 0) {
|
|
1130
|
-
console.log("No secrets yet. Add one:
|
|
1172
|
+
console.log("No secrets yet. Add one: keymaxxer set NAME");
|
|
1131
1173
|
return;
|
|
1132
1174
|
}
|
|
1133
1175
|
for (const m of metas) {
|
|
@@ -1143,9 +1185,9 @@ For editors not auto-configured, add this MCP server:`);
|
|
|
1143
1185
|
}
|
|
1144
1186
|
case "rm": {
|
|
1145
1187
|
const name = positionals[0] ?? die("usage: keymaxxer rm <NAME>");
|
|
1146
|
-
const
|
|
1147
|
-
const removed = await
|
|
1148
|
-
await
|
|
1188
|
+
const store = await openVaultCli();
|
|
1189
|
+
const removed = await store.remove(name);
|
|
1190
|
+
await store.close();
|
|
1149
1191
|
console.log(removed ? `\u2713 Removed '${name}'.` : `'${name}' not found.`);
|
|
1150
1192
|
return;
|
|
1151
1193
|
}
|
|
@@ -1155,9 +1197,13 @@ For editors not auto-configured, add this MCP server:`);
|
|
|
1155
1197
|
die("usage: keymaxxer run --secrets a,b -- <command...>");
|
|
1156
1198
|
const secrets = typeof flags.secrets === "string" && flags.secrets.length ? flags.secrets.split(",") : [];
|
|
1157
1199
|
const timeoutMs = typeof flags.timeout === "string" ? Number(flags.timeout) : undefined;
|
|
1158
|
-
const
|
|
1159
|
-
|
|
1160
|
-
|
|
1200
|
+
const store = await openVaultCli();
|
|
1201
|
+
let res;
|
|
1202
|
+
try {
|
|
1203
|
+
res = await runGated(store, { command, secrets, timeoutMs }, new Set);
|
|
1204
|
+
} finally {
|
|
1205
|
+
await store.close();
|
|
1206
|
+
}
|
|
1161
1207
|
if (res.stdout)
|
|
1162
1208
|
process.stdout.write(res.stdout.endsWith(`
|
|
1163
1209
|
`) ? res.stdout : res.stdout + `
|
|
@@ -1172,9 +1218,9 @@ For editors not auto-configured, add this MCP server:`);
|
|
|
1172
1218
|
}
|
|
1173
1219
|
case "audit": {
|
|
1174
1220
|
const limit = typeof flags.limit === "string" ? Number(flags.limit) : 20;
|
|
1175
|
-
const
|
|
1176
|
-
const entries = await
|
|
1177
|
-
await
|
|
1221
|
+
const store = await openVaultCli();
|
|
1222
|
+
const entries = await store.recentAudit(limit);
|
|
1223
|
+
await store.close();
|
|
1178
1224
|
if (entries.length === 0) {
|
|
1179
1225
|
console.log("No audit entries yet.");
|
|
1180
1226
|
return;
|