keyshroud 0.5.2 → 0.6.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/CHANGELOG.md +42 -0
- package/README.md +47 -7
- package/dist/cli.js +195 -28
- package/dist/core.d.ts +18 -4
- package/dist/core.js +268 -36
- package/dist/mcp.js +32 -17
- package/dist/redact.js +1 -1
- package/dist/refs.d.ts +2 -0
- package/dist/refs.js +26 -6
- package/package.json +1 -1
- package/skills/keyshroud/SKILL.md +6 -2
- package/src/cli.ts +140 -22
- package/src/core.ts +228 -34
- package/src/mcp.ts +24 -15
- package/src/redact.ts +1 -1
- package/src/refs.ts +23 -6
package/dist/core.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// Runtime-neutral core: no pi/omp/Claude Code imports. Every caller (agent
|
|
2
2
|
// extensions, CLI, MCP server, hooks) goes through these functions.
|
|
3
|
-
import { execFile } from "node:child_process";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
4
6
|
import { appendFile, chmod, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
7
|
import { homedir } from "node:os";
|
|
6
8
|
import { join, dirname } from "node:path";
|
|
7
9
|
import process from "node:process";
|
|
8
10
|
import { BRAND } from "./brand.js";
|
|
9
11
|
import { buildRedactIndex } from "./redact.js";
|
|
10
|
-
import { ENV_NAME_RE, legacyRefFor, parseEnvFile, parseRef } from "./refs.js";
|
|
12
|
+
import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem, legacyRefFor, parseEnvFile, parseRef } from "./refs.js";
|
|
11
13
|
const P = BRAND.envPrefix;
|
|
12
14
|
function setting(name) {
|
|
13
15
|
const value = process.env[`${P}_${name}`]?.trim();
|
|
@@ -18,9 +20,24 @@ export const SESSION_FILE = join(CONFIG_DIR, "session");
|
|
|
18
20
|
export const MASTER_PASSWORD_FILE = join(CONFIG_DIR, "master-password");
|
|
19
21
|
export const REDACT_INDEX_FILE = join(CONFIG_DIR, "redact-index.json");
|
|
20
22
|
export const AUDIT_FILE = join(CONFIG_DIR, "audit.jsonl");
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
// After `setup`, keyshroud keeps its own bw login (a dedicated agent account) in
|
|
24
|
+
// CONFIG_DIR/bw, with its own keychain entries, so the user's personal bw login
|
|
25
|
+
// and vault are never unlocked. Without it, keyshroud shares the default bw login.
|
|
26
|
+
export const OWN_BW_DIR = join(CONFIG_DIR, "bw");
|
|
27
|
+
export const PERSONAL_KEYCHAIN_MASTER_SERVICE = setting("PERSONAL_KEYCHAIN_MASTER") ?? "vaultwarden-master-password";
|
|
28
|
+
export let OWN_BW_LOGIN = false;
|
|
29
|
+
export let KEYCHAIN_MASTER_SERVICE = setting("KEYCHAIN_MASTER") ?? PERSONAL_KEYCHAIN_MASTER_SERVICE;
|
|
30
|
+
export let KEYCHAIN_CLIENT_ID_SERVICE = setting("KEYCHAIN_CLIENT_ID") ?? "vaultwarden-api-client-id";
|
|
31
|
+
export let KEYCHAIN_CLIENT_SECRET_SERVICE = setting("KEYCHAIN_CLIENT_SECRET") ?? "vaultwarden-api-client-secret";
|
|
32
|
+
export function useOwnBwLogin() {
|
|
33
|
+
OWN_BW_LOGIN = true;
|
|
34
|
+
process.env.BITWARDENCLI_APPDATA_DIR = OWN_BW_DIR;
|
|
35
|
+
KEYCHAIN_MASTER_SERVICE = setting("KEYCHAIN_MASTER") ?? `${BRAND.bin}-master-password`;
|
|
36
|
+
KEYCHAIN_CLIENT_ID_SERVICE = setting("KEYCHAIN_CLIENT_ID") ?? `${BRAND.bin}-api-client-id`;
|
|
37
|
+
KEYCHAIN_CLIENT_SECRET_SERVICE = setting("KEYCHAIN_CLIENT_SECRET") ?? `${BRAND.bin}-api-client-secret`;
|
|
38
|
+
}
|
|
39
|
+
if (!process.env.BITWARDENCLI_APPDATA_DIR && existsSync(join(OWN_BW_DIR, "data.json")))
|
|
40
|
+
useOwnBwLogin();
|
|
24
41
|
export const PROVIDER_KEYS = new Set([
|
|
25
42
|
"anthropic", "openai", "azure-openai-responses", "deepseek", "google", "mistral",
|
|
26
43
|
"groq", "cerebras", "cloudflare-ai-gateway", "cloudflare-workers-ai", "xai",
|
|
@@ -54,13 +71,16 @@ export function getAuthTargets() {
|
|
|
54
71
|
export const referenceFor = legacyRefFor;
|
|
55
72
|
// Vault credentials never reach commands run on the agent's behalf: with the
|
|
56
73
|
// session token a child could dump the whole vault, defeating `--only`.
|
|
57
|
-
export const VAULT_ENV_KEYS = ["BW_SESSION", "BW_PASSWORD", "BW_CLIENTID", "BW_CLIENTSECRET"];
|
|
74
|
+
export const VAULT_ENV_KEYS = ["BW_SESSION", "BW_PASSWORD", "BW_CLIENTID", "BW_CLIENTSECRET", "BITWARDENCLI_APPDATA_DIR"];
|
|
58
75
|
export function childEnv(extra = {}) {
|
|
59
76
|
const env = {};
|
|
60
77
|
for (const [name, value] of Object.entries(process.env))
|
|
61
78
|
if (value !== undefined && !VAULT_ENV_KEYS.includes(name))
|
|
62
79
|
env[name] = value;
|
|
63
|
-
|
|
80
|
+
for (const [name, value] of Object.entries(extra))
|
|
81
|
+
if (!isLoaderEnvName(name))
|
|
82
|
+
env[name] = value;
|
|
83
|
+
return env;
|
|
64
84
|
}
|
|
65
85
|
export async function bw(args, opts = {}) {
|
|
66
86
|
const session = opts.session ?? process.env.BW_SESSION;
|
|
@@ -185,10 +205,12 @@ export async function resolveRef(ref) {
|
|
|
185
205
|
case "totp": return (await bw(["get", "totp", item.id])).trim() || null;
|
|
186
206
|
}
|
|
187
207
|
}
|
|
188
|
-
|
|
208
|
+
// The timeout leaves room for a macOS "allow access" prompt when the entry was
|
|
209
|
+
// stored with `setup --confirm-unlock`; a missing entry still fails at once.
|
|
210
|
+
export async function readKeychain(service) {
|
|
189
211
|
const user = process.env.USER || process.env.LOGNAME || "";
|
|
190
212
|
try {
|
|
191
|
-
const result = await runFile("security", ["find-generic-password", "-a", user, "-s", service, "-w"], { timeout:
|
|
213
|
+
const result = await runFile("security", ["find-generic-password", "-a", user, "-s", service, "-w"], { timeout: 120000, maxBuffer: 1024 * 1024 });
|
|
192
214
|
return result.stdout.trim() || null;
|
|
193
215
|
}
|
|
194
216
|
catch { }
|
|
@@ -238,8 +260,8 @@ export function ensureSession() {
|
|
|
238
260
|
}
|
|
239
261
|
async function writePrivateFile(path, content) {
|
|
240
262
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
241
|
-
const tmp = `${path}.tmp.${
|
|
242
|
-
await writeFile(tmp, content, { encoding: "utf8", mode: 0o600 });
|
|
263
|
+
const tmp = `${path}.tmp.${randomBytes(6).toString("hex")}`;
|
|
264
|
+
await writeFile(tmp, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
243
265
|
await chmod(tmp, 0o600);
|
|
244
266
|
await rename(tmp, path);
|
|
245
267
|
await chmod(path, 0o600);
|
|
@@ -281,6 +303,129 @@ async function ensureSessionInner(existing) {
|
|
|
281
303
|
invalidateSecretCache();
|
|
282
304
|
return token;
|
|
283
305
|
}
|
|
306
|
+
// Store a keychain entry by letting the OS tool prompt for the value on the
|
|
307
|
+
// user's terminal, so keyshroud never holds it in argv. `confirm` (macOS) trusts
|
|
308
|
+
// no app, so every later read asks the user first.
|
|
309
|
+
export async function storeKeychainInteractive(service, label, confirm) {
|
|
310
|
+
const user = process.env.USER || process.env.LOGNAME || "";
|
|
311
|
+
const attempts = process.platform === "darwin"
|
|
312
|
+
? [["security", ["add-generic-password", "-U", "-a", user, "-s", service, "-l", label, ...(confirm ? ["-T", ""] : []), "-w"]]]
|
|
313
|
+
: [["secret-tool", ["store", `--label=${label}`, "service", service, "account", user]]];
|
|
314
|
+
// -U keeps an existing entry's access list, so an ask-every-time entry is recreated.
|
|
315
|
+
if (process.platform === "darwin" && confirm)
|
|
316
|
+
await runFile("security", ["delete-generic-password", "-a", user, "-s", service], { timeout: 10000, maxBuffer: 1024 * 1024 }).catch(() => { });
|
|
317
|
+
for (const [command, args] of attempts) {
|
|
318
|
+
const code = await new Promise((resolve) => {
|
|
319
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
320
|
+
child.on("error", () => resolve(null));
|
|
321
|
+
child.on("exit", (exit) => resolve(exit));
|
|
322
|
+
});
|
|
323
|
+
if (code === 0)
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
// `setup` from an agent's shell (no terminal): a random master password for the
|
|
329
|
+
// new agent account, written to the keychain over stdin. Nobody types it and it
|
|
330
|
+
// never reaches argv or output; the user gets it via the clipboard for signup.
|
|
331
|
+
export async function generateKeychainSecret(service, label, confirm) {
|
|
332
|
+
const secret = randomBytes(24).toString("base64url");
|
|
333
|
+
const user = process.env.USER || process.env.LOGNAME || "";
|
|
334
|
+
const opts = { timeout: 30000, maxBuffer: 1024 * 1024 };
|
|
335
|
+
try {
|
|
336
|
+
if (process.platform === "darwin") {
|
|
337
|
+
// `security -i` reads the command from stdin, so the value is not in argv.
|
|
338
|
+
await runFile("security", ["-i"], opts, `add-generic-password -U -a "${user}" -s "${service}" -l "${label}"${confirm ? ' -T ""' : ""} -w "${secret}"\n`);
|
|
339
|
+
await runFile("security", ["find-generic-password", "-a", user, "-s", service], opts);
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
await runFile("secret-tool", ["store", `--label=${label}`, "service", service, "account", user], opts, secret);
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const CLIPBOARD_TOOLS = process.platform === "darwin"
|
|
351
|
+
? [["pbcopy", [], "pbpaste", []]]
|
|
352
|
+
: [["wl-copy", [], "wl-paste", ["--no-newline"]], ["xclip", ["-selection", "clipboard"], "xclip", ["-selection", "clipboard", "-o"]]];
|
|
353
|
+
export async function copyToClipboard(text) {
|
|
354
|
+
for (const [copy, args] of CLIPBOARD_TOOLS) {
|
|
355
|
+
try {
|
|
356
|
+
await runFile(copy, args, { timeout: 10000, maxBuffer: 1024 * 1024 }, text);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
catch { }
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
// Empty the clipboard only if it still holds `text`.
|
|
364
|
+
export async function clearClipboardIf(text) {
|
|
365
|
+
for (const [copy, copyArgs, paste, pasteArgs] of CLIPBOARD_TOOLS) {
|
|
366
|
+
try {
|
|
367
|
+
if ((await runFile(paste, pasteArgs, { timeout: 10000, maxBuffer: 1024 * 1024 })).stdout.trim() === text)
|
|
368
|
+
await runFile(copy, copyArgs, { timeout: 10000, maxBuffer: 1024 * 1024 }, "");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
catch { }
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// `setup`: log keyshroud's own bw login in to the agent account. stdin and
|
|
375
|
+
// stderr stay on the terminal so bw can ask for a two-step login code.
|
|
376
|
+
export async function loginOwnAccount(email, master) {
|
|
377
|
+
const token = await new Promise((resolve, reject) => {
|
|
378
|
+
const child = spawn("bw", ["login", email, "--passwordenv", "BW_PASSWORD", "--raw"], { env: { ...process.env, BW_PASSWORD: master }, stdio: ["inherit", "pipe", "inherit"] });
|
|
379
|
+
let out = "";
|
|
380
|
+
child.stdout.on("data", (chunk) => { out += chunk; });
|
|
381
|
+
child.on("error", reject);
|
|
382
|
+
child.on("exit", (exit) => (exit === 0 && out.trim() ? resolve(out.trim()) : reject(new Error("bw login failed"))));
|
|
383
|
+
});
|
|
384
|
+
await writePrivateFile(SESSION_FILE, `${token}\n`);
|
|
385
|
+
process.env.BW_SESSION = token;
|
|
386
|
+
try {
|
|
387
|
+
await bw(["sync", "--quiet"], { session: token });
|
|
388
|
+
}
|
|
389
|
+
catch { }
|
|
390
|
+
invalidateSecretCache();
|
|
391
|
+
return token;
|
|
392
|
+
}
|
|
393
|
+
// `migrate`: read the items of the bw login the user had before setup (the
|
|
394
|
+
// default bw data dir), then lock it again. The values stay in memory.
|
|
395
|
+
export async function readPersonalItems() {
|
|
396
|
+
const master = await readKeychain(PERSONAL_KEYCHAIN_MASTER_SERVICE);
|
|
397
|
+
if (!master)
|
|
398
|
+
throw new Error(`Personal master password not in the keychain (${PERSONAL_KEYCHAIN_MASTER_SERVICE})`);
|
|
399
|
+
const env = (extra) => {
|
|
400
|
+
const out = {};
|
|
401
|
+
for (const [name, value] of Object.entries(process.env))
|
|
402
|
+
if (value !== undefined && !VAULT_ENV_KEYS.includes(name))
|
|
403
|
+
out[name] = value;
|
|
404
|
+
return { ...out, ...extra };
|
|
405
|
+
};
|
|
406
|
+
const token = (await runFile("bw", ["unlock", "--passwordenv", "BW_PASSWORD", "--raw"], { env: env({ BW_PASSWORD: master }), timeout: 30000, maxBuffer: 4 * 1024 * 1024 })).stdout.trim();
|
|
407
|
+
if (!token)
|
|
408
|
+
throw new Error("Personal vault unlock returned no session");
|
|
409
|
+
try {
|
|
410
|
+
const parsed = JSON.parse((await runFile("bw", ["list", "items"], { env: env({ BW_SESSION: token }), timeout: 120000, maxBuffer: 256 * 1024 * 1024 })).stdout);
|
|
411
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
try {
|
|
415
|
+
await runFile("bw", ["lock"], { env: env({}), timeout: 20000, maxBuffer: 1024 * 1024 });
|
|
416
|
+
}
|
|
417
|
+
catch { }
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// A personal item as a new item for the agent account: no ids, folders,
|
|
421
|
+
// organizations, history, attachments or per-item key (encrypted to the personal account).
|
|
422
|
+
export async function copyItemToOwnVault(item) {
|
|
423
|
+
const copy = { ...item, favorite: false };
|
|
424
|
+
for (const key of ["id", "key", "object", "organizationId", "folderId", "collectionIds", "revisionDate", "creationDate", "deletedDate", "passwordHistory", "attachments"])
|
|
425
|
+
delete copy[key];
|
|
426
|
+
await bw(["create", "item"], { input: Buffer.from(JSON.stringify(copy), "utf8").toString("base64") });
|
|
427
|
+
}
|
|
428
|
+
export async function syncOwnVault() { await bw(["sync", "--quiet"]); invalidateSecretCache(); }
|
|
284
429
|
// Forget the cached session and lock the bw CLI. The next resolve unlocks
|
|
285
430
|
// again from the keychain.
|
|
286
431
|
export async function lock() {
|
|
@@ -485,6 +630,8 @@ export async function envFileEntries(path) {
|
|
|
485
630
|
return parseEnvFile(await readFile(path, "utf8")).map(({ name, value }) => {
|
|
486
631
|
if (value.trim().startsWith("!"))
|
|
487
632
|
throw new Error(`${path}: ${name} uses a shell command; env files accept only bw:// references and literals`);
|
|
633
|
+
if (isLoaderEnvName(name))
|
|
634
|
+
throw new Error(`${path}: ${name} changes what programs load or run; env files cannot set it`);
|
|
488
635
|
return { name, raw: value };
|
|
489
636
|
});
|
|
490
637
|
}
|
|
@@ -539,6 +686,11 @@ export async function writeRedactIndex(values) {
|
|
|
539
686
|
await writePrivateFile(REDACT_INDEX_FILE, `${JSON.stringify(index)}\n`);
|
|
540
687
|
return index;
|
|
541
688
|
}
|
|
689
|
+
// Re-index after the wired set changes so hooks mask a new value right away.
|
|
690
|
+
export async function refreshRedactIndex() {
|
|
691
|
+
const env = await resolveEntries(await authEnvEntries());
|
|
692
|
+
await writeRedactIndex(indexValues(env.secrets));
|
|
693
|
+
}
|
|
542
694
|
export async function readRedactIndex() {
|
|
543
695
|
try {
|
|
544
696
|
const parsed = JSON.parse(await readFile(REDACT_INDEX_FILE, "utf8"));
|
|
@@ -573,7 +725,17 @@ export async function getSecret(itemName) {
|
|
|
573
725
|
throw mapItemError(error, itemName);
|
|
574
726
|
}
|
|
575
727
|
}
|
|
728
|
+
// An agent can start add/rotate, so a dialog could be used to phish the master
|
|
729
|
+
// password into an item it can then read; refuse it outright.
|
|
730
|
+
async function refuseMasterPassword(secret) {
|
|
731
|
+
if (secret && secret === await readMasterPassword())
|
|
732
|
+
throw new Error("Refusing to store the vault master password as a secret");
|
|
733
|
+
}
|
|
576
734
|
export async function createSecretItem(opts) {
|
|
735
|
+
const problem = itemNameProblem(opts.itemName);
|
|
736
|
+
if (problem)
|
|
737
|
+
throw new Error(`Vault item name ${problem}`);
|
|
738
|
+
await refuseMasterPassword(opts.secret);
|
|
577
739
|
const existing = await findItems(opts.itemName);
|
|
578
740
|
if (existing.some((item) => item.name.toLowerCase() === opts.itemName.toLowerCase()))
|
|
579
741
|
throw new Error(`Vault item already exists: ${opts.itemName}`);
|
|
@@ -598,6 +760,7 @@ export async function createSecretItem(opts) {
|
|
|
598
760
|
return { id: String(created.id) };
|
|
599
761
|
}
|
|
600
762
|
export async function rotateSecretItem(opts) {
|
|
763
|
+
await refuseMasterPassword(opts.secret);
|
|
601
764
|
let parsed;
|
|
602
765
|
try {
|
|
603
766
|
parsed = JSON.parse(await bw(["get", "item", opts.itemName]));
|
|
@@ -620,7 +783,7 @@ export async function deleteItem(id, permanent = false) {
|
|
|
620
783
|
invalidateSecretCache();
|
|
621
784
|
}
|
|
622
785
|
export async function readSecretFromInput(opts) {
|
|
623
|
-
if (opts.stdin
|
|
786
|
+
if (opts.stdin) {
|
|
624
787
|
let input = "";
|
|
625
788
|
for await (const chunk of process.stdin)
|
|
626
789
|
input += String(chunk);
|
|
@@ -629,41 +792,92 @@ export async function readSecretFromInput(opts) {
|
|
|
629
792
|
throw new Error("Empty secret");
|
|
630
793
|
return input;
|
|
631
794
|
}
|
|
795
|
+
if (!process.stdin.isTTY)
|
|
796
|
+
throw new Error("No terminal to prompt on; pipe the value with --stdin");
|
|
632
797
|
const value = await readMasked(opts.prompt);
|
|
633
798
|
if (!value)
|
|
634
799
|
throw new Error("Empty secret");
|
|
635
800
|
return value;
|
|
636
801
|
}
|
|
637
802
|
// Native hidden-input dialog for callers without a terminal (agent shells, MCP).
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
803
|
+
// An agent can open it, so the wording is fixed, says who asked, and warns
|
|
804
|
+
// against the master password; names are validated and passed as data (argv),
|
|
805
|
+
// never as script. One dialog at a time, with a pause after a cancel. Resolves
|
|
806
|
+
// null when no dialog can be shown (SSH, headless Linux, missing tools).
|
|
807
|
+
const DIALOG_LOCK = join(CONFIG_DIR, "dialog.lock");
|
|
808
|
+
const DIALOG_COOLDOWN_MS = 30_000;
|
|
809
|
+
const DIALOG_TIMEOUT_MS = 300_000;
|
|
641
810
|
export async function readSecretFromDialog(opts) {
|
|
642
811
|
if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
|
|
643
812
|
return null;
|
|
644
|
-
const
|
|
813
|
+
const problem = itemNameProblem(opts.itemName);
|
|
814
|
+
if (problem)
|
|
815
|
+
throw new Error(`Vault item name ${problem}`);
|
|
816
|
+
if (opts.envName !== undefined && !ENV_NAME_RE.test(opts.envName))
|
|
817
|
+
throw new Error("Variable names must be UPPER_SNAKE_CASE");
|
|
818
|
+
const text = [
|
|
819
|
+
opts.envName ? "A program on this computer, usually an AI agent, asked to store a NEW secret." : "A program on this computer, usually an AI agent, asked to REPLACE the value of a secret.",
|
|
820
|
+
"",
|
|
821
|
+
...(opts.envName ? [`Variable: ${opts.envName}`] : []),
|
|
822
|
+
`Vault item: ${opts.itemName}`,
|
|
823
|
+
"",
|
|
824
|
+
"This is not a login prompt. Never enter your vault master password here.",
|
|
825
|
+
].join("\n");
|
|
645
826
|
const candidates = process.platform === "darwin"
|
|
646
|
-
? [["osascript", ["-e",
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
'text returned of (display dialog t default answer "" with hidden answer with title "keyshroud" buttons {"Cancel", "Save"} default button "Save" cancel button "Cancel")',
|
|
650
|
-
].join("\n")]]]
|
|
827
|
+
? [["osascript", ["-e", "on run argv", "-e", 'activate', "-e",
|
|
828
|
+
'text returned of (display dialog (item 1 of argv) default answer "" with hidden answer with title "keyshroud" buttons {"Cancel", "Save"} default button "Save" cancel button "Cancel" with icon caution)',
|
|
829
|
+
"-e", "end run", "--", text]]]
|
|
651
830
|
: process.env.DISPLAY || process.env.WAYLAND_DISPLAY
|
|
652
|
-
? [["zenity", ["--entry", "--hide-text", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text]]]
|
|
831
|
+
? [["zenity", ["--entry", "--hide-text", "--no-markup", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text.replace(/[<>]/g, "")]]]
|
|
653
832
|
: [];
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
833
|
+
if (!candidates.length)
|
|
834
|
+
return null;
|
|
835
|
+
return withDialogLock(async () => {
|
|
836
|
+
for (const [command, args] of candidates) {
|
|
837
|
+
const result = await new Promise((resolve) => {
|
|
838
|
+
execFile(command, args, { timeout: DIALOG_TIMEOUT_MS }, (error, stdout, stderr) => resolve({ code: error ? (error.code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) }));
|
|
839
|
+
});
|
|
840
|
+
if (result.code === "ENOENT")
|
|
841
|
+
continue;
|
|
842
|
+
if (result.code === 0)
|
|
843
|
+
return result.stdout.replace(/\r?\n$/, "");
|
|
844
|
+
if (command !== "osascript" || /-128/.test(result.stderr))
|
|
845
|
+
throw new Error("Cancelled");
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
664
848
|
return null;
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
async function withDialogLock(fn) {
|
|
852
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
853
|
+
let held = "";
|
|
854
|
+
try {
|
|
855
|
+
held = await readFile(DIALOG_LOCK, "utf8");
|
|
856
|
+
}
|
|
857
|
+
catch { }
|
|
858
|
+
const [kind, at] = held.trim().split(":");
|
|
859
|
+
const age = Date.now() - Number(at);
|
|
860
|
+
const live = (kind === "open" || kind === "cancelled") && age >= 0 && age < (kind === "cancelled" ? DIALOG_COOLDOWN_MS : DIALOG_TIMEOUT_MS + 30_000);
|
|
861
|
+
if (!live)
|
|
862
|
+
await rm(DIALOG_LOCK, { force: true });
|
|
863
|
+
try {
|
|
864
|
+
await writeFile(DIALOG_LOCK, `open:${Date.now()}\n`, { flag: "wx", mode: 0o600 });
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
throw new Error(kind === "cancelled" ? "The last dialog was cancelled; wait 30 seconds before asking again" : "Another keyshroud dialog is already open");
|
|
868
|
+
}
|
|
869
|
+
try {
|
|
870
|
+
const value = await fn();
|
|
871
|
+
await rm(DIALOG_LOCK, { force: true });
|
|
872
|
+
return value;
|
|
873
|
+
}
|
|
874
|
+
catch (error) {
|
|
875
|
+
if (error instanceof Error && error.message === "Cancelled")
|
|
876
|
+
await writeFile(DIALOG_LOCK, `cancelled:${Date.now()}\n`, { mode: 0o600 });
|
|
877
|
+
else
|
|
878
|
+
await rm(DIALOG_LOCK, { force: true });
|
|
879
|
+
throw error;
|
|
665
880
|
}
|
|
666
|
-
return null;
|
|
667
881
|
}
|
|
668
882
|
// Raw-mode prompt that echoes one "*" per character (capped, then a count), so
|
|
669
883
|
// the user sees typing and pastes land without the value ever reaching the screen.
|
|
@@ -675,7 +889,16 @@ function readMasked(prompt) {
|
|
|
675
889
|
process.stderr.write(`\r\x1b[2K${prompt}${shown}`);
|
|
676
890
|
};
|
|
677
891
|
return new Promise((resolve, reject) => {
|
|
892
|
+
// After Enter, keep reading briefly: the rest of a multi-line paste must be
|
|
893
|
+
// consumed here (or it would reach the parent shell) and is then refused.
|
|
894
|
+
let ending;
|
|
895
|
+
let multiline = false;
|
|
896
|
+
const settle = () => {
|
|
897
|
+
clearTimeout(ending);
|
|
898
|
+
ending = setTimeout(() => finish(multiline ? new Error("Multi-line values are not supported in the prompt; pipe them with --stdin") : undefined), 100);
|
|
899
|
+
};
|
|
678
900
|
const finish = (error) => {
|
|
901
|
+
clearTimeout(ending);
|
|
679
902
|
stdin.off("data", onData);
|
|
680
903
|
stdin.setRawMode(false);
|
|
681
904
|
stdin.pause();
|
|
@@ -686,9 +909,18 @@ function readMasked(prompt) {
|
|
|
686
909
|
resolve(chars.join(""));
|
|
687
910
|
};
|
|
688
911
|
const onData = (chunk) => {
|
|
689
|
-
|
|
690
|
-
if (
|
|
691
|
-
|
|
912
|
+
if (ending) {
|
|
913
|
+
if (/[^\r\n]/.test(chunk))
|
|
914
|
+
multiline = true;
|
|
915
|
+
return settle();
|
|
916
|
+
}
|
|
917
|
+
const clean = [...chunk.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b./g, "")];
|
|
918
|
+
for (const [index, ch] of clean.entries()) {
|
|
919
|
+
if (ch === "\r" || ch === "\n") {
|
|
920
|
+
if (/[^\r\n]/.test(clean.slice(index + 1).join("")))
|
|
921
|
+
multiline = true;
|
|
922
|
+
return settle();
|
|
923
|
+
}
|
|
692
924
|
if (ch === "\x03")
|
|
693
925
|
return finish(new Error("Cancelled"));
|
|
694
926
|
if (ch === "\x04")
|
package/dist/mcp.js
CHANGED
|
@@ -7,7 +7,7 @@ import process from "node:process";
|
|
|
7
7
|
import { BRAND } from "./brand.js";
|
|
8
8
|
import * as core from "./core.js";
|
|
9
9
|
import { createRedactor } from "./redact.js";
|
|
10
|
-
import { ENV_NAME_RE } from "./refs.js";
|
|
10
|
+
import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem } from "./refs.js";
|
|
11
11
|
const SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
12
12
|
const OUTPUT_LIMIT = 256 * 1024;
|
|
13
13
|
const TOOLS = [
|
|
@@ -85,14 +85,14 @@ async function runTool(args) {
|
|
|
85
85
|
detached: true,
|
|
86
86
|
});
|
|
87
87
|
const chunks = { stdout: [], stderr: [] };
|
|
88
|
-
// Collect generously, mask, then truncate
|
|
89
|
-
// before
|
|
90
|
-
|
|
88
|
+
// Collect generously per stream, mask, then truncate. If collection stopped
|
|
89
|
+
// early, the tail may end inside a secret, so it is dropped before masking.
|
|
90
|
+
const sizes = { stdout: 0, stderr: 0 };
|
|
91
91
|
for (const stream of ["stdout", "stderr"]) {
|
|
92
92
|
child[stream].on("data", (chunk) => {
|
|
93
|
-
if (
|
|
93
|
+
if (sizes[stream] >= OUTPUT_LIMIT * 2)
|
|
94
94
|
return;
|
|
95
|
-
|
|
95
|
+
sizes[stream] += chunk.length;
|
|
96
96
|
chunks[stream].push(chunk);
|
|
97
97
|
});
|
|
98
98
|
}
|
|
@@ -105,9 +105,14 @@ async function runTool(args) {
|
|
|
105
105
|
child.on("error", (error) => done(`spawn error: ${error.message}`));
|
|
106
106
|
});
|
|
107
107
|
clearTimeout(timer);
|
|
108
|
-
let truncated =
|
|
108
|
+
let truncated = false;
|
|
109
109
|
const out = (stream) => {
|
|
110
|
-
|
|
110
|
+
let raw = Buffer.concat(chunks[stream]).toString("utf8");
|
|
111
|
+
if (sizes[stream] >= OUTPUT_LIMIT * 2) {
|
|
112
|
+
truncated = true;
|
|
113
|
+
raw = raw.slice(0, redactor.safeCut(raw));
|
|
114
|
+
}
|
|
115
|
+
const masked = redactor.redact(raw);
|
|
111
116
|
if (masked.length <= OUTPUT_LIMIT / 2)
|
|
112
117
|
return masked;
|
|
113
118
|
truncated = true;
|
|
@@ -141,30 +146,40 @@ async function callTool(name, args) {
|
|
|
141
146
|
const itemName = String(args.item_name ?? "");
|
|
142
147
|
if (!ENV_NAME_RE.test(envName) || !itemName)
|
|
143
148
|
return text("env_name must be UPPER_SNAKE_CASE and item_name is required", true);
|
|
144
|
-
|
|
149
|
+
if (isLoaderEnvName(envName))
|
|
150
|
+
return text(`${envName} changes what programs load or run; it cannot be wired to a secret`, true);
|
|
151
|
+
const problem = itemNameProblem(itemName);
|
|
152
|
+
if (problem)
|
|
153
|
+
return text(`item_name ${problem}`, true);
|
|
154
|
+
const notes = typeof args.purpose === "string" && args.purpose ? args.purpose.slice(0, 500) : undefined;
|
|
145
155
|
try {
|
|
156
|
+
const reference = core.referenceFor(itemName);
|
|
146
157
|
await core.ensureSession();
|
|
147
|
-
|
|
158
|
+
if ((await core.findItems(itemName)).some((item) => item.name.toLowerCase() === itemName.toLowerCase()))
|
|
159
|
+
return text(`Vault item already exists: ${itemName}`, true);
|
|
160
|
+
const secret = await core.readSecretFromDialog({ itemName, envName });
|
|
148
161
|
if (secret === "")
|
|
149
162
|
return text("The user saved an empty value; nothing was stored.", true);
|
|
150
163
|
if (secret !== null) {
|
|
151
164
|
await core.createSecretItem({ itemName, secret, envName, notes });
|
|
152
|
-
|
|
165
|
+
await core.audit("request", [envName], "mcp");
|
|
166
|
+
const conflicts = [];
|
|
153
167
|
for (const path of Object.values(core.getAuthTargets()))
|
|
154
|
-
await core.writeAuthEntry(path, envName, reference)
|
|
168
|
+
if (!(await core.writeAuthEntry(path, envName, reference)).ok)
|
|
169
|
+
conflicts.push(path);
|
|
170
|
+
await core.refreshRedactIndex();
|
|
171
|
+
if (conflicts.length)
|
|
172
|
+
return text(`created item=${itemName}, but ${envName} is already wired to another secret in ${conflicts.join(", ")} and was left unchanged. Ask the user before running \`${BRAND.bin} wire --env ${envName} --item ${shellQuote(itemName)} --force\`.`, true);
|
|
155
173
|
const verified = (await core.resolveShellValue(reference)) !== null;
|
|
156
174
|
return text(`The user entered the value in a ${BRAND.name} dialog. created item=${itemName} env=${envName} verified=${verified ? "yes" : "no"}. Use secrets_run with $${envName}.`);
|
|
157
175
|
}
|
|
158
176
|
}
|
|
159
177
|
catch (error) {
|
|
160
178
|
const message = error instanceof Error ? error.message : String(error);
|
|
161
|
-
|
|
162
|
-
return text("The user cancelled the dialog; nothing was stored.", true);
|
|
163
|
-
if (/already exists/.test(message))
|
|
164
|
-
return text(message, true);
|
|
179
|
+
return text(message === "Cancelled" ? "The user cancelled the dialog; nothing was stored." : message, true);
|
|
165
180
|
}
|
|
166
181
|
const purpose = notes ? ` --notes ${shellQuote(notes)}` : "";
|
|
167
|
-
return text(`Ask the user to run this in their own terminal (the value is entered in a masked prompt; it must never be pasted into the chat):\n\n ${BRAND.bin} add --env ${envName} --item ${shellQuote(itemName)}${purpose}\n\nAfter it reports verified=yes, use secrets_run with $${envName}.`);
|
|
182
|
+
return text(`No dialog can be shown here. Ask the user to run this in their own terminal (the value is entered in a masked prompt; it must never be pasted into the chat):\n\n ${BRAND.bin} add --env ${envName} --item ${shellQuote(itemName)}${purpose}\n\nAfter it reports verified=yes, use secrets_run with $${envName}.`);
|
|
168
183
|
}
|
|
169
184
|
if (name === "vault_status") {
|
|
170
185
|
try {
|
package/dist/redact.js
CHANGED
|
@@ -87,7 +87,7 @@ export function createRedactor(values) {
|
|
|
87
87
|
return cut;
|
|
88
88
|
// Bounded fallback: cut near the end, moving forward past any complete
|
|
89
89
|
// secret that straddles it, so whole matches are still replaced.
|
|
90
|
-
cut = text.length - maxLength;
|
|
90
|
+
cut = Math.max(0, text.length - maxLength);
|
|
91
91
|
while (cut < text.length && straddling(cut))
|
|
92
92
|
cut++;
|
|
93
93
|
return cut;
|
package/dist/refs.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export declare function parseRef(raw: string): SecretRef | null;
|
|
|
10
10
|
export declare function formatRef(ref: SecretRef): string;
|
|
11
11
|
export declare function legacyRefFor(itemName: string): string;
|
|
12
12
|
export declare const ENV_NAME_RE: RegExp;
|
|
13
|
+
export declare function isLoaderEnvName(name: string): boolean;
|
|
14
|
+
export declare function itemNameProblem(name: string): string | null;
|
|
13
15
|
export declare function parseEnvFile(text: string): Array<{
|
|
14
16
|
name: string;
|
|
15
17
|
value: string;
|
package/dist/refs.js
CHANGED
|
@@ -57,6 +57,21 @@ export function legacyRefFor(itemName) {
|
|
|
57
57
|
return `!bw get password '${itemName}'`;
|
|
58
58
|
}
|
|
59
59
|
export const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
|
|
60
|
+
// Variables that change what a child process loads or runs. Env files can come
|
|
61
|
+
// from a cloned repo and agents can wire names, so neither may set these.
|
|
62
|
+
const LOADER_ENV_RE = /^(?:PATH|HOME|ZDOTDIR|XDG_CONFIG_HOME|IFS|ENV|BASH_ENV|SHELLOPTS|BASHOPTS|PS4|PROMPT_COMMAND|NODE_OPTIONS|NODE_PATH|NODE_EXTRA_CA_CERTS|PYTHONPATH|PYTHONHOME|PYTHONSTARTUP|PERL5OPT|PERL5LIB|PERLLIB|RUBYOPT|RUBYLIB|JAVA_TOOL_OPTIONS|_JAVA_OPTIONS|JDK_JAVA_OPTIONS|EDITOR|VISUAL|PAGER|LESSOPEN|LESSCLOSE|BROWSER|SSH_ASKPASS|LD_\w*|DYLD_\w*|NPM_CONFIG_\w*|GIT_(?:SSH|SSH_COMMAND|EXEC_PATH|ASKPASS|EDITOR|PAGER|EXTERNAL_DIFF|PROXY_COMMAND|DIR|WORK_TREE|TEMPLATE_DIR|CONFIG\w*))$/i;
|
|
63
|
+
export function isLoaderEnvName(name) { return LOADER_ENV_RE.test(name); }
|
|
64
|
+
// Item names appear in native dialogs and lookups: one line of printable text,
|
|
65
|
+
// no padding (lookups trim, so a padded name would shadow an existing item).
|
|
66
|
+
export function itemNameProblem(name) {
|
|
67
|
+
if (!name || name !== name.trim())
|
|
68
|
+
return "must not be empty or start or end with spaces";
|
|
69
|
+
if (name.length > 120)
|
|
70
|
+
return "must be at most 120 characters";
|
|
71
|
+
if (/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(name))
|
|
72
|
+
return "must be one line of printable text";
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
60
75
|
// Parses dotenv-style files whose values may be references:
|
|
61
76
|
// GH_TOKEN=bw://GitHub CLI token
|
|
62
77
|
// export SENTRY_DSN="bw://Sentry/fields/dsn"
|
|
@@ -140,15 +155,20 @@ export function parseDotenvStrict(text) {
|
|
|
140
155
|
});
|
|
141
156
|
return { entries, errors };
|
|
142
157
|
}
|
|
143
|
-
const SECRET_NAME_RE = /(SECRET|TOKEN|PASSW(?:OR)?D|PASSPHRASE|PWD|(?:^|_)KEY(?:$|_)|APIKEY|PRIVATE|CREDENTIAL|AUTH|DSN|WEBHOOK|COOKIE|SIGNING|ENCRYPT|SALT)/i;
|
|
158
|
+
const SECRET_NAME_RE = /(SECRET|TOKEN|PASSW(?:OR)?D|PASSPHRASE|(?:^|_)PASS(?:$|_)|PWD|(?:^|_)KEY(?:$|_)|APIKEY|PRIVATE|CREDENTIAL|(?:^|_)CREDS?(?:$|_)|AUTH|JWT|DSN|WEBHOOK|COOKIE|SIGNING|ENCRYPT|SALT)/i;
|
|
144
159
|
const SECRET_VALUE_RE = /^(?:gh[pousr]_|github_pat_|glpat-|xox[abprs]-|sk-|sk_(?:live|test)_|rk_(?:live|test)_|whsec_|AKIA|ASIA|AIza|npm_|hf_|ya29\.|eyJ)/;
|
|
145
|
-
const URL_PASSWORD_RE = /^[a-z][a-z0-9+.-]*:\/\/[^\s:@/]
|
|
146
|
-
// Local heuristic for `import`: never sends anything anywhere.
|
|
147
|
-
//
|
|
160
|
+
const URL_PASSWORD_RE = /^[a-z][a-z0-9+.-]*:\/\/[^\s:@/]*:[^\s@/]+@/i;
|
|
161
|
+
// Local heuristic for `import`: never sends anything anywhere. A known secret
|
|
162
|
+
// format wins over the name; otherwise names with a PUBLIC segment
|
|
163
|
+
// (NEXT_PUBLIC_*, VITE_PUBLIC_*) are meant to ship to browsers.
|
|
148
164
|
export function looksSecret(name, value) {
|
|
149
|
-
if (!value || isRef(value)
|
|
165
|
+
if (!value || isRef(value))
|
|
166
|
+
return false;
|
|
167
|
+
if (SECRET_VALUE_RE.test(value) || URL_PASSWORD_RE.test(value))
|
|
168
|
+
return true;
|
|
169
|
+
if (/(?:^|_)PUBLIC(?:$|_)/i.test(name))
|
|
150
170
|
return false;
|
|
151
|
-
if (
|
|
171
|
+
if (SECRET_NAME_RE.test(name) || /^[0-9a-f]{32,}$/i.test(value))
|
|
152
172
|
return true;
|
|
153
173
|
return value.length >= 24 && !/\s/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value) && /\d/.test(value);
|
|
154
174
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keyshroud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"mcpName": "io.github.dinhkarate/keyshroud",
|
|
5
5
|
"description": "Let AI coding agents use your secrets without ever seeing them. Self-hosted Vaultwarden/Bitwarden, masked output, MCP server, Claude Code + pi plugins.",
|
|
6
6
|
"keywords": [
|
|
@@ -17,8 +17,9 @@ in output with `<redacted:NAME>`.
|
|
|
17
17
|
```bash
|
|
18
18
|
keyshroud add --env STRIPE_API_KEY --item "Stripe API key" --notes "billing service"
|
|
19
19
|
```
|
|
20
|
-
Tell the user a dialog is waiting for them
|
|
21
|
-
|
|
20
|
+
Tell the user a dialog is waiting for them, and ask for one secret at a time;
|
|
21
|
+
if they cancel, do not ask again unprompted. Never ask for the vault master
|
|
22
|
+
password. With the MCP server, call `secrets_request` instead. Over SSH or without a display there is no dialog:
|
|
22
23
|
then give the user the command to run in their own terminal.
|
|
23
24
|
2. Never print, echo, `cat`, base64, or otherwise transform a secret value.
|
|
24
25
|
`<redacted:NAME>` in output means the secret was used correctly. Do not try
|
|
@@ -77,3 +78,6 @@ keyshroud scan . # exits 1 and lists file:line NAME for any leaked valu
|
|
|
77
78
|
`0` ok · `2` usage · `3` vault locked/unavailable (ask the user to run
|
|
78
79
|
`keyshroud unlock`) · `4` secret missing or item name ambiguous · `5` would
|
|
79
80
|
overwrite existing state.
|
|
81
|
+
|
|
82
|
+
`setup` and `migrate` are for the user: they touch the personal vault. If an item
|
|
83
|
+
is missing after `setup`, ask the user to run `keyshroud migrate --item "NAME"`.
|