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/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
- export const KEYCHAIN_MASTER_SERVICE = setting("KEYCHAIN_MASTER") ?? "vaultwarden-master-password";
22
- export const KEYCHAIN_CLIENT_ID_SERVICE = setting("KEYCHAIN_CLIENT_ID") ?? "vaultwarden-api-client-id";
23
- export const KEYCHAIN_CLIENT_SECRET_SERVICE = setting("KEYCHAIN_CLIENT_SECRET") ?? "vaultwarden-api-client-secret";
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
- return { ...env, ...extra };
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
- async function readKeychain(service) {
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: 10000, maxBuffer: 1024 * 1024 });
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.${process.pid}`;
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 || !process.stdin.isTTY) {
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
- // Names reach the dialog through env vars, so an item name cannot inject
639
- // AppleScript; the value comes back only on the dialog's stdout. Resolves null
640
- // when no dialog can be shown (SSH, headless Linux, missing tools).
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 text = `Paste the secret for ${opts.label}\nVault item: ${opts.itemName}`;
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
- 'set t to "Paste the secret for " & (system attribute "KEYSHROUD_DIALOG_ENV") & return & "Vault item: " & (system attribute "KEYSHROUD_DIALOG_ITEM")',
648
- 'activate',
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
- for (const [command, args] of candidates) {
655
- const result = await new Promise((resolve) => {
656
- execFile(command, args, { env: { ...process.env, KEYSHROUD_DIALOG_ENV: opts.label, KEYSHROUD_DIALOG_ITEM: opts.itemName }, timeout: 300_000 }, (error, stdout, stderr) => resolve({ code: error ? (error.code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) }));
657
- });
658
- if (result.code === "ENOENT")
659
- continue;
660
- if (result.code === 0)
661
- return result.stdout.replace(/\r?\n$/, "");
662
- if (command !== "osascript" || /-128/.test(result.stderr))
663
- throw new Error("Cancelled");
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
- for (const ch of chunk.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b./g, "")) {
690
- if (ch === "\r" || ch === "\n")
691
- return finish();
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, so a cut never splits a secret
89
- // before redaction.
90
- let size = 0;
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 (size >= OUTPUT_LIMIT * 4)
93
+ if (sizes[stream] >= OUTPUT_LIMIT * 2)
94
94
  return;
95
- size += chunk.length;
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 = size >= OUTPUT_LIMIT * 4;
108
+ let truncated = false;
109
109
  const out = (stream) => {
110
- const masked = redactor.redact(Buffer.concat(chunks[stream]).toString("utf8"));
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
- const notes = typeof args.purpose === "string" && args.purpose ? args.purpose : undefined;
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
- const secret = await core.readSecretFromDialog({ label: envName, itemName });
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
- const reference = core.referenceFor(itemName);
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
- if (message === "Cancelled")
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:@/]+:[^\s@/]+@/i;
146
- // Local heuristic for `import`: never sends anything anywhere. Names that say
147
- // PUBLIC (NEXT_PUBLIC_*, VITE_PUBLIC_*) are meant to ship to browsers.
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) || /PUBLIC/i.test(name))
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 (SECRET_VALUE_RE.test(value) || URL_PASSWORD_RE.test(value) || SECRET_NAME_RE.test(name))
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.5.2",
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. With the MCP server, call
21
- `secrets_request` instead. Over SSH or without a display there is no dialog:
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"`.