keyshroud 0.5.2 → 0.5.3

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 CHANGED
@@ -5,6 +5,36 @@ All notable changes are documented here. This project follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.5.3] - 2026-10-15
9
+
10
+ ### Security
11
+ Fixes from an independent audit and a multi-model review of 0.5.2.
12
+ - The native dialog could be used by a prompt-injected agent to phish new
13
+ secrets, including the vault master password. It now says a program asked for
14
+ it and warns against the master password, refuses multi-line, control or bidi
15
+ characters and names over 120 characters, refuses a value equal to the master
16
+ password, opens one at a time with a 30 s pause after a cancel, and is audited.
17
+ zenity runs with `--no-markup`.
18
+ - Env files could set `PATH`, `NODE_OPTIONS`, `LD_PRELOAD` and other loader
19
+ variables, so a cloned repo's `.env.refs` could run code. They are rejected
20
+ in env files, refused by `add`/`wire`/`secrets_request`, and never injected.
21
+ - Streaming redaction leaked most of a secret longer than 4096 characters.
22
+ - `secrets_run` could return a partial secret when output hit the collection
23
+ limit; the cut tail is now dropped before masking, with per-stream budgets.
24
+ - `import` missed `*_PASS`, URL passwords with no username, hex keys, and known
25
+ key formats in names containing `PUBLIC`; `.env.refs` is now written `0600`.
26
+ - `inject -o` replaces a planted symlink instead of writing through it; temp
27
+ files use random names and exclusive creation.
28
+
29
+ ### Changed
30
+ - `add`/`rotate` read stdin only with `--stdin`; a multi-line paste into the
31
+ `*` prompt is drained and refused instead of reaching the shell.
32
+ - `secrets_request` checks names and existing items before opening the dialog,
33
+ reports auth.json conflicts instead of claiming success, and returns errors.
34
+ - Item names with leading or trailing spaces are refused (they shadowed
35
+ existing items). `add`, `rotate` and `secrets_request` refresh the redact
36
+ index and write to the audit log.
37
+
8
38
  ## [0.5.2] - 2026-10-15
9
39
 
10
40
  ### Added
package/README.md CHANGED
@@ -61,7 +61,7 @@ webhooks, databases. The usual options are bad:
61
61
  | 🙈 **Masked output** | `keyshroud run` replaces every secret value in stdout/stderr with `<redacted:NAME>` — streaming-safe across chunk boundaries. |
62
62
  | 🪝 **Agent hooks** | The pi/omp `tool_result` hook redacts every tool result, failed ones included. Claude Code's `PostToolUse` hook redacts every *successful* tool result (Bash, Read, Grep, MCP…) — see [limits](#security-model). |
63
63
  | 🔌 **MCP server** | `keyshroud mcp` gives any MCP client `secrets_run`, `secrets_list`, `secrets_request`, `vault_status`. No tool ever returns a value. |
64
- | 🔗 **Commit-safe references** | `bw://item`, `bw://item/username`, `bw://item/fields/name`, `bw://item/totp` in `.env.refs` files and templates. |
64
+ | 🔗 **Committable references** | `bw://item`, `bw://item/username`, `bw://item/fields/name`, `bw://item/totp` in `.env.refs` files and templates: names, not values. |
65
65
  | ⌨️ **Paste-to-create** | New secrets go through a masked prompt in the user's terminal or a masked popup in pi — never through the chat. |
66
66
  | 📄 **`inject`** | Render `{{ bw://… }}` templates into `0600` config files for tools that can't read env vars (`.npmrc`, kubeconfig). |
67
67
  | 🔎 **`scan`** | Find vault values that leaked into your working tree (pre-commit friendly). |
@@ -373,18 +373,30 @@ Guarantees we test for:
373
373
 
374
374
  - secret values never appear in argv, logs, diagnostics, MCP results, hook
375
375
  output, or `audit.jsonl`;
376
- - new secrets are read from a masked prompt or stdin, never flags;
376
+ - new secrets are read from a masked prompt, `--stdin`, or a native dialog, never flags;
377
+ - the dialog an agent can open says it was requested by a program, warns against the
378
+ master password, refuses names that are not one printable line, refuses the master
379
+ password itself, and opens one at a time (30 s pause after a cancel);
377
380
  - the session file, redact index, rendered templates and auth files are `0600`;
378
381
  - auth.json writes are locked and atomic and never overwrite without `--force`;
379
382
  - commands started by `run` and `secrets_run` never receive `BW_SESSION`,
380
383
  `BW_PASSWORD`, `BW_CLIENTID` or `BW_CLIENTSECRET`, so `--only` really limits
381
384
  what a command can read (inside pi/omp the agent process keeps the session for
382
385
  legacy `!bw` auth entries; its value is redacted from tool output);
383
- - env files may contain only references and literals — a `!command` value is
384
- rejected, so a cloned repo's `.env.refs` cannot run code;
386
+ - env files may contain only references and literals: a `!command` value is
387
+ rejected, and so are variables that change what programs load or run (`PATH`,
388
+ `NODE_OPTIONS`, `LD_*`, `DYLD_*`, `BASH_ENV`, `GIT_SSH_COMMAND`, …), which are
389
+ also never wired or injected;
385
390
  - an unparseable `auth.json` is never overwritten.
386
391
 
387
- Known limits: an agent with shell access as your user can read
392
+ Known limits: a `.env.refs` can reference **any** item in your vault, not only the
393
+ ones you wired, so read a cloned repo's `.env.refs` before running `--env-file`
394
+ with it. `import` decides what is secret by heuristics and keeps everything else
395
+ as a literal; check its plan before committing `.env.refs`. The Claude Code
396
+ `PostToolUse` hook does not see failed commands (`PostToolUseFailure` cannot
397
+ rewrite output) or anything a client sends as telemetry, and it only knows wired
398
+ values, so `keyshroud get bw://…` or `run --no-mask` print in the clear; run
399
+ secret-using commands through `keyshroud run` or `secrets_run`. An agent with shell access as your user can read
388
400
  `~/.config/keyshroud/session` and use `bw` directly — run agents in a sandbox or
389
401
  container if that matters. Redaction is exact-match, so a *partial* or transformed value
390
402
  (`${TOKEN:0:10}`, base64, a line wrapped by a tool) is not masked; values
package/dist/cli.js CHANGED
@@ -4,13 +4,14 @@ import { constants, homedir } from "node:os";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { basename, dirname, join, relative, resolve } from "node:path";
6
6
  import { spawn } from "node:child_process";
7
+ import { randomBytes } from "node:crypto";
7
8
  import process from "node:process";
8
9
  import { BRAND } from "./brand.js";
9
10
  import * as core from "./core.js";
10
11
  import { runClaudeHook } from "./hooks.js";
11
12
  import { serveMcp } from "./mcp.js";
12
13
  import { buildRedactIndex, compileRedactIndex, createRedactor, createRedactStream } from "./redact.js";
13
- import { ENV_NAME_RE, isRef, looksSecret, parseDotenvStrict, parseRef } from "./refs.js";
14
+ import { ENV_NAME_RE, isLoaderEnvName, isRef, itemNameProblem, looksSecret, parseDotenvStrict, parseRef } from "./refs.js";
14
15
  const usage = `Usage: ${BRAND.bin} <command> [options]
15
16
 
16
17
  Secrets
@@ -63,20 +64,26 @@ function targetList(value) {
63
64
  return [targetPaths.pi];
64
65
  fail("--target must be omp, pi, or both", 2);
65
66
  }
66
- // Terminal: masked prompt. No terminal (an agent's shell) and no --stdin: a native
67
- // dialog, so the agent can start the command and the user only pastes.
68
- async function readSecret(args, prompt, label, itemName) {
69
- const forced = args.includes("--dialog");
70
- if (forced || (!args.includes("--stdin") && !process.stdin.isTTY)) {
71
- const value = await core.readSecretFromDialog({ label, itemName });
72
- if (value)
73
- return value;
74
- if (value === "")
75
- throw new Error("Empty secret");
76
- if (forced)
77
- fail("No dialog available here (SSH or no display). Run the command in a terminal, or pipe the value with --stdin.", 2);
78
- }
79
- return core.readSecretFromInput({ prompt, stdin: args.includes("--stdin") });
67
+ // --stdin: piped value. Terminal: masked prompt. Otherwise (an agent's shell):
68
+ // a native dialog, so the agent can start the command and the user only pastes.
69
+ async function readSecret(args, prompt, itemName, envName) {
70
+ if (args.includes("--stdin"))
71
+ return core.readSecretFromInput({ prompt, stdin: true });
72
+ if (process.stdin.isTTY && !args.includes("--dialog"))
73
+ return core.readSecretFromInput({ prompt, stdin: false });
74
+ const value = await core.readSecretFromDialog({ itemName, envName });
75
+ if (value)
76
+ return value;
77
+ if (value === "")
78
+ throw new Error("Empty secret");
79
+ fail("No terminal and no dialog here (SSH or no display). Run the command in a terminal, or pipe the value with --stdin.", 2);
80
+ }
81
+ function checkNames(envName, itemName) {
82
+ if (isLoaderEnvName(envName))
83
+ fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
84
+ const problem = itemNameProblem(itemName);
85
+ if (problem)
86
+ fail(`Vault item name ${problem}`, 2);
80
87
  }
81
88
  function shellQuote(value) { return `'${value.replaceAll("'", "'\\''")}'`; }
82
89
  function failureCode(error) {
@@ -172,14 +179,14 @@ async function inject(args) {
172
179
  process.stdout.write(rendered);
173
180
  return;
174
181
  }
175
- try {
176
- await readFile(output);
177
- if (!args.includes("--force"))
178
- fail(`Refusing to overwrite ${output}; use --force`, 5);
179
- }
180
- catch { }
181
- await writeFile(output, rendered, { mode: 0o600 });
182
- await chmod(output, 0o600);
182
+ // lstat, and a private temp file renamed over the target, so a planted
183
+ // symlink (even a dangling one) is replaced instead of followed.
184
+ const exists = await lstat(output).then(() => true, () => false);
185
+ if (exists && !args.includes("--force"))
186
+ fail(`Refusing to overwrite ${output}; use --force`, 5);
187
+ const tmp = join(dirname(resolve(output)), `.${basename(output)}.tmp.${randomBytes(6).toString("hex")}`);
188
+ await writeFile(tmp, rendered, { mode: 0o600, flag: "wx" });
189
+ await rename(tmp, output);
183
190
  console.error(`rendered ${refs.length} reference(s) into ${output} (mode 0600)`);
184
191
  }
185
192
  const SKIP_DIRS = new Set([".git", "node_modules", "dist", ".next", ".venv"]);
@@ -347,9 +354,9 @@ async function importCommand(args) {
347
354
  const lines = text.split(/\r?\n/);
348
355
  for (const row of toStore)
349
356
  lines[row.entry.line - 1] = `${row.entry.exported ? "export " : ""}${row.entry.name}=bw://${row.item}`;
350
- const mode = replace ? (await stat(out)).mode & 0o777 : 0o644;
351
- const tmp = `${out}.tmp.${process.pid}`;
352
- await writeFile(tmp, lines.join(eol), { mode });
357
+ const mode = replace ? (await stat(out)).mode & 0o777 : 0o600;
358
+ const tmp = `${out}.tmp.${randomBytes(6).toString("hex")}`;
359
+ await writeFile(tmp, lines.join(eol), { mode, flag: "wx" });
353
360
  await rename(tmp, out);
354
361
  await core.audit("import", toStore.map((row) => row.entry.name), "cli");
355
362
  console.log(`imported ${toStore.length} secret(s) (${created.length} new); wrote ${out}`);
@@ -466,11 +473,15 @@ async function main() {
466
473
  const notes = valueAfter(args, "--notes");
467
474
  if (!envName || !itemName || !ENV_NAME_RE.test(envName))
468
475
  fail(usage, 2);
476
+ checkNames(envName, itemName);
469
477
  const reference = core.referenceFor(itemName);
470
478
  const targets = targetList(valueAfter(args, "--target"));
471
479
  await core.ensureSession();
472
- const secret = await readSecret(args, `Paste secret for ${envName}: `, envName, itemName);
480
+ if ((await core.findItems(itemName)).some((item) => item.name.toLowerCase() === itemName.toLowerCase()))
481
+ fail(`Vault item already exists: ${itemName}`, 5);
482
+ const secret = await readSecret(args, `Paste secret for ${envName}: `, itemName, envName);
473
483
  await core.createSecretItem({ itemName, secret, envName, notes });
484
+ await core.audit("add", [envName], "cli");
474
485
  let conflict = false;
475
486
  for (const path of targets) {
476
487
  const result = await core.writeAuthEntry(path, envName, reference);
@@ -478,6 +489,7 @@ async function main() {
478
489
  conflict = true;
479
490
  }
480
491
  const verified = (await core.resolveShellValue(reference)) !== null;
492
+ await core.refreshRedactIndex();
481
493
  console.log(`created item=${itemName} env=${envName} targets=${targets.join(",")} verified=${verified ? "yes" : "no"}`);
482
494
  if (conflict) {
483
495
  console.error("Item created, but one or more auth targets already contained the environment name.");
@@ -495,8 +507,10 @@ async function main() {
495
507
  if (!itemName)
496
508
  fail(usage, 2);
497
509
  await core.ensureSession();
498
- const secret = await readSecret(args, "Paste replacement secret: ", `the new value of ${itemName}`, itemName);
510
+ const secret = await readSecret(args, "Paste replacement secret: ", itemName);
499
511
  await core.rotateSecretItem({ itemName, secret });
512
+ await core.audit("rotate", [itemName], "cli");
513
+ await core.refreshRedactIndex();
500
514
  console.log(`rotated item=${itemName}`);
501
515
  return;
502
516
  }
@@ -506,6 +520,8 @@ async function main() {
506
520
  const itemName = valueAfter(args, "--item");
507
521
  if (!envName || !itemName || !ENV_NAME_RE.test(envName))
508
522
  fail(usage, 2);
523
+ if (isLoaderEnvName(envName))
524
+ fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
509
525
  const reference = core.referenceFor(itemName);
510
526
  const targets = targetList(valueAfter(args, "--target"));
511
527
  for (const path of targets) {
package/dist/core.d.ts CHANGED
@@ -78,6 +78,7 @@ export declare function buildEnvMap(): Promise<Record<string, string>>;
78
78
  export declare function redactionValues(wired?: Record<string, string>): Promise<Record<string, string>>;
79
79
  export declare function indexValues(wired?: Record<string, string>): Record<string, string>;
80
80
  export declare function writeRedactIndex(values: Record<string, string>): Promise<RedactIndex>;
81
+ export declare function refreshRedactIndex(): Promise<void>;
81
82
  export declare function readRedactIndex(): Promise<RedactIndex | null>;
82
83
  export declare function findItems(search: string): Promise<Array<{
83
84
  id: string;
@@ -104,6 +105,6 @@ export declare function readSecretFromInput(opts: {
104
105
  stdin: boolean;
105
106
  }): Promise<string>;
106
107
  export declare function readSecretFromDialog(opts: {
107
- label: string;
108
108
  itemName: string;
109
+ envName?: string;
109
110
  }): Promise<string | null>;
package/dist/core.js CHANGED
@@ -1,13 +1,14 @@
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
3
  import { execFile } from "node:child_process";
4
+ import { randomBytes } from "node:crypto";
4
5
  import { appendFile, chmod, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
5
6
  import { homedir } from "node:os";
6
7
  import { join, dirname } from "node:path";
7
8
  import process from "node:process";
8
9
  import { BRAND } from "./brand.js";
9
10
  import { buildRedactIndex } from "./redact.js";
10
- import { ENV_NAME_RE, legacyRefFor, parseEnvFile, parseRef } from "./refs.js";
11
+ import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem, legacyRefFor, parseEnvFile, parseRef } from "./refs.js";
11
12
  const P = BRAND.envPrefix;
12
13
  function setting(name) {
13
14
  const value = process.env[`${P}_${name}`]?.trim();
@@ -60,7 +61,10 @@ export function childEnv(extra = {}) {
60
61
  for (const [name, value] of Object.entries(process.env))
61
62
  if (value !== undefined && !VAULT_ENV_KEYS.includes(name))
62
63
  env[name] = value;
63
- return { ...env, ...extra };
64
+ for (const [name, value] of Object.entries(extra))
65
+ if (!isLoaderEnvName(name))
66
+ env[name] = value;
67
+ return env;
64
68
  }
65
69
  export async function bw(args, opts = {}) {
66
70
  const session = opts.session ?? process.env.BW_SESSION;
@@ -238,8 +242,8 @@ export function ensureSession() {
238
242
  }
239
243
  async function writePrivateFile(path, content) {
240
244
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
241
- const tmp = `${path}.tmp.${process.pid}`;
242
- await writeFile(tmp, content, { encoding: "utf8", mode: 0o600 });
245
+ const tmp = `${path}.tmp.${randomBytes(6).toString("hex")}`;
246
+ await writeFile(tmp, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
243
247
  await chmod(tmp, 0o600);
244
248
  await rename(tmp, path);
245
249
  await chmod(path, 0o600);
@@ -485,6 +489,8 @@ export async function envFileEntries(path) {
485
489
  return parseEnvFile(await readFile(path, "utf8")).map(({ name, value }) => {
486
490
  if (value.trim().startsWith("!"))
487
491
  throw new Error(`${path}: ${name} uses a shell command; env files accept only bw:// references and literals`);
492
+ if (isLoaderEnvName(name))
493
+ throw new Error(`${path}: ${name} changes what programs load or run; env files cannot set it`);
488
494
  return { name, raw: value };
489
495
  });
490
496
  }
@@ -539,6 +545,11 @@ export async function writeRedactIndex(values) {
539
545
  await writePrivateFile(REDACT_INDEX_FILE, `${JSON.stringify(index)}\n`);
540
546
  return index;
541
547
  }
548
+ // Re-index after the wired set changes so hooks mask a new value right away.
549
+ export async function refreshRedactIndex() {
550
+ const env = await resolveEntries(await authEnvEntries());
551
+ await writeRedactIndex(indexValues(env.secrets));
552
+ }
542
553
  export async function readRedactIndex() {
543
554
  try {
544
555
  const parsed = JSON.parse(await readFile(REDACT_INDEX_FILE, "utf8"));
@@ -573,7 +584,17 @@ export async function getSecret(itemName) {
573
584
  throw mapItemError(error, itemName);
574
585
  }
575
586
  }
587
+ // An agent can start add/rotate, so a dialog could be used to phish the master
588
+ // password into an item it can then read; refuse it outright.
589
+ async function refuseMasterPassword(secret) {
590
+ if (secret && secret === await readMasterPassword())
591
+ throw new Error("Refusing to store the vault master password as a secret");
592
+ }
576
593
  export async function createSecretItem(opts) {
594
+ const problem = itemNameProblem(opts.itemName);
595
+ if (problem)
596
+ throw new Error(`Vault item name ${problem}`);
597
+ await refuseMasterPassword(opts.secret);
577
598
  const existing = await findItems(opts.itemName);
578
599
  if (existing.some((item) => item.name.toLowerCase() === opts.itemName.toLowerCase()))
579
600
  throw new Error(`Vault item already exists: ${opts.itemName}`);
@@ -598,6 +619,7 @@ export async function createSecretItem(opts) {
598
619
  return { id: String(created.id) };
599
620
  }
600
621
  export async function rotateSecretItem(opts) {
622
+ await refuseMasterPassword(opts.secret);
601
623
  let parsed;
602
624
  try {
603
625
  parsed = JSON.parse(await bw(["get", "item", opts.itemName]));
@@ -620,7 +642,7 @@ export async function deleteItem(id, permanent = false) {
620
642
  invalidateSecretCache();
621
643
  }
622
644
  export async function readSecretFromInput(opts) {
623
- if (opts.stdin || !process.stdin.isTTY) {
645
+ if (opts.stdin) {
624
646
  let input = "";
625
647
  for await (const chunk of process.stdin)
626
648
  input += String(chunk);
@@ -629,41 +651,92 @@ export async function readSecretFromInput(opts) {
629
651
  throw new Error("Empty secret");
630
652
  return input;
631
653
  }
654
+ if (!process.stdin.isTTY)
655
+ throw new Error("No terminal to prompt on; pipe the value with --stdin");
632
656
  const value = await readMasked(opts.prompt);
633
657
  if (!value)
634
658
  throw new Error("Empty secret");
635
659
  return value;
636
660
  }
637
661
  // 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).
662
+ // An agent can open it, so the wording is fixed, says who asked, and warns
663
+ // against the master password; names are validated and passed as data (argv),
664
+ // never as script. One dialog at a time, with a pause after a cancel. Resolves
665
+ // null when no dialog can be shown (SSH, headless Linux, missing tools).
666
+ const DIALOG_LOCK = join(CONFIG_DIR, "dialog.lock");
667
+ const DIALOG_COOLDOWN_MS = 30_000;
668
+ const DIALOG_TIMEOUT_MS = 300_000;
641
669
  export async function readSecretFromDialog(opts) {
642
670
  if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
643
671
  return null;
644
- const text = `Paste the secret for ${opts.label}\nVault item: ${opts.itemName}`;
672
+ const problem = itemNameProblem(opts.itemName);
673
+ if (problem)
674
+ throw new Error(`Vault item name ${problem}`);
675
+ if (opts.envName !== undefined && !ENV_NAME_RE.test(opts.envName))
676
+ throw new Error("Variable names must be UPPER_SNAKE_CASE");
677
+ const text = [
678
+ 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.",
679
+ "",
680
+ ...(opts.envName ? [`Variable: ${opts.envName}`] : []),
681
+ `Vault item: ${opts.itemName}`,
682
+ "",
683
+ "This is not a login prompt. Never enter your vault master password here.",
684
+ ].join("\n");
645
685
  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")]]]
686
+ ? [["osascript", ["-e", "on run argv", "-e", 'activate', "-e",
687
+ '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)',
688
+ "-e", "end run", "--", text]]]
651
689
  : process.env.DISPLAY || process.env.WAYLAND_DISPLAY
652
- ? [["zenity", ["--entry", "--hide-text", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text]]]
690
+ ? [["zenity", ["--entry", "--hide-text", "--no-markup", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text.replace(/[<>]/g, "")]]]
653
691
  : [];
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");
692
+ if (!candidates.length)
664
693
  return null;
694
+ return withDialogLock(async () => {
695
+ for (const [command, args] of candidates) {
696
+ const result = await new Promise((resolve) => {
697
+ execFile(command, args, { timeout: DIALOG_TIMEOUT_MS }, (error, stdout, stderr) => resolve({ code: error ? (error.code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) }));
698
+ });
699
+ if (result.code === "ENOENT")
700
+ continue;
701
+ if (result.code === 0)
702
+ return result.stdout.replace(/\r?\n$/, "");
703
+ if (command !== "osascript" || /-128/.test(result.stderr))
704
+ throw new Error("Cancelled");
705
+ return null;
706
+ }
707
+ return null;
708
+ });
709
+ }
710
+ async function withDialogLock(fn) {
711
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
712
+ let held = "";
713
+ try {
714
+ held = await readFile(DIALOG_LOCK, "utf8");
715
+ }
716
+ catch { }
717
+ const [kind, at] = held.trim().split(":");
718
+ const age = Date.now() - Number(at);
719
+ const live = (kind === "open" || kind === "cancelled") && age >= 0 && age < (kind === "cancelled" ? DIALOG_COOLDOWN_MS : DIALOG_TIMEOUT_MS + 30_000);
720
+ if (!live)
721
+ await rm(DIALOG_LOCK, { force: true });
722
+ try {
723
+ await writeFile(DIALOG_LOCK, `open:${Date.now()}\n`, { flag: "wx", mode: 0o600 });
724
+ }
725
+ catch {
726
+ throw new Error(kind === "cancelled" ? "The last dialog was cancelled; wait 30 seconds before asking again" : "Another keyshroud dialog is already open");
727
+ }
728
+ try {
729
+ const value = await fn();
730
+ await rm(DIALOG_LOCK, { force: true });
731
+ return value;
732
+ }
733
+ catch (error) {
734
+ if (error instanceof Error && error.message === "Cancelled")
735
+ await writeFile(DIALOG_LOCK, `cancelled:${Date.now()}\n`, { mode: 0o600 });
736
+ else
737
+ await rm(DIALOG_LOCK, { force: true });
738
+ throw error;
665
739
  }
666
- return null;
667
740
  }
668
741
  // Raw-mode prompt that echoes one "*" per character (capped, then a count), so
669
742
  // the user sees typing and pastes land without the value ever reaching the screen.
@@ -675,7 +748,16 @@ function readMasked(prompt) {
675
748
  process.stderr.write(`\r\x1b[2K${prompt}${shown}`);
676
749
  };
677
750
  return new Promise((resolve, reject) => {
751
+ // After Enter, keep reading briefly: the rest of a multi-line paste must be
752
+ // consumed here (or it would reach the parent shell) and is then refused.
753
+ let ending;
754
+ let multiline = false;
755
+ const settle = () => {
756
+ clearTimeout(ending);
757
+ ending = setTimeout(() => finish(multiline ? new Error("Multi-line values are not supported in the prompt; pipe them with --stdin") : undefined), 100);
758
+ };
678
759
  const finish = (error) => {
760
+ clearTimeout(ending);
679
761
  stdin.off("data", onData);
680
762
  stdin.setRawMode(false);
681
763
  stdin.pause();
@@ -686,9 +768,18 @@ function readMasked(prompt) {
686
768
  resolve(chars.join(""));
687
769
  };
688
770
  const onData = (chunk) => {
689
- for (const ch of chunk.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b./g, "")) {
690
- if (ch === "\r" || ch === "\n")
691
- return finish();
771
+ if (ending) {
772
+ if (/[^\r\n]/.test(chunk))
773
+ multiline = true;
774
+ return settle();
775
+ }
776
+ const clean = [...chunk.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b./g, "")];
777
+ for (const [index, ch] of clean.entries()) {
778
+ if (ch === "\r" || ch === "\n") {
779
+ if (/[^\r\n]/.test(clean.slice(index + 1).join("")))
780
+ multiline = true;
781
+ return settle();
782
+ }
692
783
  if (ch === "\x03")
693
784
  return finish(new Error("Cancelled"));
694
785
  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.5.3",
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
package/src/cli.ts CHANGED
@@ -4,13 +4,14 @@ import { constants, homedir } from "node:os";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { basename, dirname, join, relative, resolve } from "node:path";
6
6
  import { spawn } from "node:child_process";
7
+ import { randomBytes } from "node:crypto";
7
8
  import process from "node:process";
8
9
  import { BRAND } from "./brand.ts";
9
10
  import * as core from "./core.ts";
10
11
  import { runClaudeHook } from "./hooks.ts";
11
12
  import { serveMcp } from "./mcp.ts";
12
13
  import { buildRedactIndex, compileRedactIndex, createRedactor, createRedactStream, type RedactIndex } from "./redact.ts";
13
- import { ENV_NAME_RE, isRef, looksSecret, parseDotenvStrict, parseRef, type DotenvEntry } from "./refs.ts";
14
+ import { ENV_NAME_RE, isLoaderEnvName, isRef, itemNameProblem, looksSecret, parseDotenvStrict, parseRef, type DotenvEntry } from "./refs.ts";
14
15
 
15
16
  const usage = `Usage: ${BRAND.bin} <command> [options]
16
17
 
@@ -56,17 +57,20 @@ function targetList(value: string | undefined): string[] {
56
57
  if (value === "pi") return [targetPaths.pi];
57
58
  fail("--target must be omp, pi, or both", 2);
58
59
  }
59
- // Terminal: masked prompt. No terminal (an agent's shell) and no --stdin: a native
60
- // dialog, so the agent can start the command and the user only pastes.
61
- async function readSecret(args: string[], prompt: string, label: string, itemName: string): Promise<string> {
62
- const forced = args.includes("--dialog");
63
- if (forced || (!args.includes("--stdin") && !process.stdin.isTTY)) {
64
- const value = await core.readSecretFromDialog({ label, itemName });
65
- if (value) return value;
66
- if (value === "") throw new Error("Empty secret");
67
- if (forced) fail("No dialog available here (SSH or no display). Run the command in a terminal, or pipe the value with --stdin.", 2);
68
- }
69
- return core.readSecretFromInput({ prompt, stdin: args.includes("--stdin") });
60
+ // --stdin: piped value. Terminal: masked prompt. Otherwise (an agent's shell):
61
+ // a native dialog, so the agent can start the command and the user only pastes.
62
+ async function readSecret(args: string[], prompt: string, itemName: string, envName?: string): Promise<string> {
63
+ if (args.includes("--stdin")) return core.readSecretFromInput({ prompt, stdin: true });
64
+ if (process.stdin.isTTY && !args.includes("--dialog")) return core.readSecretFromInput({ prompt, stdin: false });
65
+ const value = await core.readSecretFromDialog({ itemName, envName });
66
+ if (value) return value;
67
+ if (value === "") throw new Error("Empty secret");
68
+ fail("No terminal and no dialog here (SSH or no display). Run the command in a terminal, or pipe the value with --stdin.", 2);
69
+ }
70
+ function checkNames(envName: string, itemName: string): void {
71
+ if (isLoaderEnvName(envName)) fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
72
+ const problem = itemNameProblem(itemName);
73
+ if (problem) fail(`Vault item name ${problem}`, 2);
70
74
  }
71
75
  function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; }
72
76
  function failureCode(error: unknown): number {
@@ -143,9 +147,13 @@ async function inject(args: string[]): Promise<void> {
143
147
  await core.audit("inject", refs, output ?? "stdout");
144
148
  const rendered = template.replace(pattern, (_match, raw: string) => values.get(raw) ?? "");
145
149
  if (!output) { process.stdout.write(rendered); return; }
146
- try { await readFile(output); if (!args.includes("--force")) fail(`Refusing to overwrite ${output}; use --force`, 5); } catch {}
147
- await writeFile(output, rendered, { mode: 0o600 });
148
- await chmod(output, 0o600);
150
+ // lstat, and a private temp file renamed over the target, so a planted
151
+ // symlink (even a dangling one) is replaced instead of followed.
152
+ const exists = await lstat(output).then(() => true, () => false);
153
+ if (exists && !args.includes("--force")) fail(`Refusing to overwrite ${output}; use --force`, 5);
154
+ const tmp = join(dirname(resolve(output)), `.${basename(output)}.tmp.${randomBytes(6).toString("hex")}`);
155
+ await writeFile(tmp, rendered, { mode: 0o600, flag: "wx" });
156
+ await rename(tmp, output);
149
157
  console.error(`rendered ${refs.length} reference(s) into ${output} (mode 0600)`);
150
158
  }
151
159
 
@@ -261,9 +269,9 @@ async function importCommand(args: string[]): Promise<void> {
261
269
  const eol = text.includes("\r\n") ? "\r\n" : "\n";
262
270
  const lines = text.split(/\r?\n/);
263
271
  for (const row of toStore) lines[row.entry.line - 1] = `${row.entry.exported ? "export " : ""}${row.entry.name}=bw://${row.item}`;
264
- const mode = replace ? (await stat(out)).mode & 0o777 : 0o644;
265
- const tmp = `${out}.tmp.${process.pid}`;
266
- await writeFile(tmp, lines.join(eol), { mode });
272
+ const mode = replace ? (await stat(out)).mode & 0o777 : 0o600;
273
+ const tmp = `${out}.tmp.${randomBytes(6).toString("hex")}`;
274
+ await writeFile(tmp, lines.join(eol), { mode, flag: "wx" });
267
275
  await rename(tmp, out);
268
276
  await core.audit("import", toStore.map((row) => row.entry.name), "cli");
269
277
  console.log(`imported ${toStore.length} secret(s) (${created.length} new); wrote ${out}`);
@@ -328,14 +336,18 @@ async function main(): Promise<void> {
328
336
  requireKnownFlags(args, ["--env", "--item", "--notes", "--target"], ["--stdin", "--dialog"]);
329
337
  const envName = valueAfter(args, "--env"); const itemName = valueAfter(args, "--item"); const notes = valueAfter(args, "--notes");
330
338
  if (!envName || !itemName || !ENV_NAME_RE.test(envName)) fail(usage, 2);
339
+ checkNames(envName, itemName);
331
340
  const reference = core.referenceFor(itemName);
332
341
  const targets = targetList(valueAfter(args, "--target"));
333
342
  await core.ensureSession();
334
- const secret = await readSecret(args, `Paste secret for ${envName}: `, envName, itemName);
343
+ if ((await core.findItems(itemName)).some((item) => item.name.toLowerCase() === itemName.toLowerCase())) fail(`Vault item already exists: ${itemName}`, 5);
344
+ const secret = await readSecret(args, `Paste secret for ${envName}: `, itemName, envName);
335
345
  await core.createSecretItem({ itemName, secret, envName, notes });
346
+ await core.audit("add", [envName], "cli");
336
347
  let conflict = false;
337
348
  for (const path of targets) { const result = await core.writeAuthEntry(path, envName, reference); if (!result.ok) conflict = true; }
338
349
  const verified = (await core.resolveShellValue(reference)) !== null;
350
+ await core.refreshRedactIndex();
339
351
  console.log(`created item=${itemName} env=${envName} targets=${targets.join(",")} verified=${verified ? "yes" : "no"}`);
340
352
  if (conflict) { console.error("Item created, but one or more auth targets already contained the environment name."); process.exitCode = 5; }
341
353
  return;
@@ -344,13 +356,15 @@ async function main(): Promise<void> {
344
356
  if (command === "rotate") {
345
357
  requireKnownFlags(args, ["--item"], ["--stdin", "--dialog"]);
346
358
  const itemName = valueAfter(args, "--item"); if (!itemName) fail(usage, 2);
347
- await core.ensureSession(); const secret = await readSecret(args, "Paste replacement secret: ", `the new value of ${itemName}`, itemName);
348
- await core.rotateSecretItem({ itemName, secret }); console.log(`rotated item=${itemName}`); return;
359
+ await core.ensureSession(); const secret = await readSecret(args, "Paste replacement secret: ", itemName);
360
+ await core.rotateSecretItem({ itemName, secret }); await core.audit("rotate", [itemName], "cli"); await core.refreshRedactIndex();
361
+ console.log(`rotated item=${itemName}`); return;
349
362
  }
350
363
  if (command === "wire") {
351
364
  requireKnownFlags(args, ["--env", "--item", "--target"], ["--force"]);
352
365
  const envName = valueAfter(args, "--env"); const itemName = valueAfter(args, "--item");
353
366
  if (!envName || !itemName || !ENV_NAME_RE.test(envName)) fail(usage, 2);
367
+ if (isLoaderEnvName(envName)) fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
354
368
  const reference = core.referenceFor(itemName);
355
369
  const targets = targetList(valueAfter(args, "--target"));
356
370
  for (const path of targets) { const result = await core.writeAuthEntry(path, envName, reference, { overwrite: args.includes("--force") }); if (!result.ok) fail(result.message, 5); }
package/src/core.ts CHANGED
@@ -1,13 +1,14 @@
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
3
  import { execFile } from "node:child_process";
4
+ import { randomBytes } from "node:crypto";
4
5
  import { appendFile, chmod, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
5
6
  import { homedir } from "node:os";
6
7
  import { join, dirname } from "node:path";
7
8
  import process from "node:process";
8
9
  import { BRAND } from "./brand.ts";
9
10
  import { buildRedactIndex, type RedactIndex } from "./redact.ts";
10
- import { ENV_NAME_RE, legacyRefFor, parseEnvFile, parseRef, type SecretRef } from "./refs.ts";
11
+ import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem, legacyRefFor, parseEnvFile, parseRef, type SecretRef } from "./refs.ts";
11
12
 
12
13
  const P = BRAND.envPrefix;
13
14
  function setting(name: string): string | undefined {
@@ -71,7 +72,8 @@ export const VAULT_ENV_KEYS: readonly string[] = ["BW_SESSION", "BW_PASSWORD", "
71
72
  export function childEnv(extra: Record<string, string> = {}): Record<string, string> {
72
73
  const env: Record<string, string> = {};
73
74
  for (const [name, value] of Object.entries(process.env)) if (value !== undefined && !VAULT_ENV_KEYS.includes(name)) env[name] = value;
74
- return { ...env, ...extra };
75
+ for (const [name, value] of Object.entries(extra)) if (!isLoaderEnvName(name)) env[name] = value;
76
+ return env;
75
77
  }
76
78
 
77
79
  export async function bw(args: string[], opts: { input?: string; session?: string } = {}): Promise<string> {
@@ -251,8 +253,8 @@ export function ensureSession(): Promise<string> {
251
253
 
252
254
  async function writePrivateFile(path: string, content: string): Promise<void> {
253
255
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
254
- const tmp = `${path}.tmp.${process.pid}`;
255
- await writeFile(tmp, content, { encoding: "utf8", mode: 0o600 });
256
+ const tmp = `${path}.tmp.${randomBytes(6).toString("hex")}`;
257
+ await writeFile(tmp, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
256
258
  await chmod(tmp, 0o600);
257
259
  await rename(tmp, path);
258
260
  await chmod(path, 0o600);
@@ -455,6 +457,7 @@ export async function resolveEntries(entries: Array<{ name: string; raw: string
455
457
  export async function envFileEntries(path: string): Promise<Array<{ name: string; raw: string }>> {
456
458
  return parseEnvFile(await readFile(path, "utf8")).map(({ name, value }) => {
457
459
  if (value.trim().startsWith("!")) throw new Error(`${path}: ${name} uses a shell command; env files accept only bw:// references and literals`);
460
+ if (isLoaderEnvName(name)) throw new Error(`${path}: ${name} changes what programs load or run; env files cannot set it`);
458
461
  return { name, raw: value };
459
462
  });
460
463
  }
@@ -504,6 +507,12 @@ export async function writeRedactIndex(values: Record<string, string>): Promise<
504
507
  return index;
505
508
  }
506
509
 
510
+ // Re-index after the wired set changes so hooks mask a new value right away.
511
+ export async function refreshRedactIndex(): Promise<void> {
512
+ const env = await resolveEntries(await authEnvEntries());
513
+ await writeRedactIndex(indexValues(env.secrets));
514
+ }
515
+
507
516
  export async function readRedactIndex(): Promise<RedactIndex | null> {
508
517
  try {
509
518
  const parsed = JSON.parse(await readFile(REDACT_INDEX_FILE, "utf8")) as RedactIndex;
@@ -531,7 +540,16 @@ export async function getSecret(itemName: string): Promise<string> {
531
540
  try { return await bw(["get", "password", itemName]); } catch (error) { throw mapItemError(error, itemName); }
532
541
  }
533
542
 
543
+ // An agent can start add/rotate, so a dialog could be used to phish the master
544
+ // password into an item it can then read; refuse it outright.
545
+ async function refuseMasterPassword(secret: string): Promise<void> {
546
+ if (secret && secret === await readMasterPassword()) throw new Error("Refusing to store the vault master password as a secret");
547
+ }
548
+
534
549
  export async function createSecretItem(opts: { itemName: string; secret: string; envName?: string; notes?: string }): Promise<{ id: string }> {
550
+ const problem = itemNameProblem(opts.itemName);
551
+ if (problem) throw new Error(`Vault item name ${problem}`);
552
+ await refuseMasterPassword(opts.secret);
535
553
  const existing = await findItems(opts.itemName);
536
554
  if (existing.some((item) => item.name.toLowerCase() === opts.itemName.toLowerCase())) throw new Error(`Vault item already exists: ${opts.itemName}`);
537
555
  const item: Record<string, unknown> = {
@@ -548,6 +566,7 @@ export async function createSecretItem(opts: { itemName: string; secret: string;
548
566
  }
549
567
 
550
568
  export async function rotateSecretItem(opts: { itemName: string; secret: string }): Promise<{ id: string }> {
569
+ await refuseMasterPassword(opts.secret);
551
570
  let parsed: Record<string, unknown>;
552
571
  try { parsed = JSON.parse(await bw(["get", "item", opts.itemName])) as Record<string, unknown>; } catch (error) { throw mapItemError(error, opts.itemName); }
553
572
  const login = parsed.login && typeof parsed.login === "object" ? parsed.login as Record<string, unknown> : {};
@@ -565,45 +584,82 @@ export async function deleteItem(id: string, permanent = false): Promise<void> {
565
584
  }
566
585
 
567
586
  export async function readSecretFromInput(opts: { prompt: string; stdin: boolean }): Promise<string> {
568
- if (opts.stdin || !process.stdin.isTTY) {
587
+ if (opts.stdin) {
569
588
  let input = "";
570
589
  for await (const chunk of process.stdin) input += String(chunk);
571
590
  input = input.replace(/\r?\n$/, "");
572
591
  if (!input) throw new Error("Empty secret");
573
592
  return input;
574
593
  }
594
+ if (!process.stdin.isTTY) throw new Error("No terminal to prompt on; pipe the value with --stdin");
575
595
  const value = await readMasked(opts.prompt);
576
596
  if (!value) throw new Error("Empty secret");
577
597
  return value;
578
598
  }
579
599
 
580
600
  // Native hidden-input dialog for callers without a terminal (agent shells, MCP).
581
- // Names reach the dialog through env vars, so an item name cannot inject
582
- // AppleScript; the value comes back only on the dialog's stdout. Resolves null
583
- // when no dialog can be shown (SSH, headless Linux, missing tools).
584
- export async function readSecretFromDialog(opts: { label: string; itemName: string }): Promise<string | null> {
601
+ // An agent can open it, so the wording is fixed, says who asked, and warns
602
+ // against the master password; names are validated and passed as data (argv),
603
+ // never as script. One dialog at a time, with a pause after a cancel. Resolves
604
+ // null when no dialog can be shown (SSH, headless Linux, missing tools).
605
+ const DIALOG_LOCK = join(CONFIG_DIR, "dialog.lock");
606
+ const DIALOG_COOLDOWN_MS = 30_000;
607
+ const DIALOG_TIMEOUT_MS = 300_000;
608
+
609
+ export async function readSecretFromDialog(opts: { itemName: string; envName?: string }): Promise<string | null> {
585
610
  if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return null;
586
- const text = `Paste the secret for ${opts.label}\nVault item: ${opts.itemName}`;
611
+ const problem = itemNameProblem(opts.itemName);
612
+ if (problem) throw new Error(`Vault item name ${problem}`);
613
+ if (opts.envName !== undefined && !ENV_NAME_RE.test(opts.envName)) throw new Error("Variable names must be UPPER_SNAKE_CASE");
614
+ const text = [
615
+ 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.",
616
+ "",
617
+ ...(opts.envName ? [`Variable: ${opts.envName}`] : []),
618
+ `Vault item: ${opts.itemName}`,
619
+ "",
620
+ "This is not a login prompt. Never enter your vault master password here.",
621
+ ].join("\n");
587
622
  const candidates: Array<[string, string[]]> = process.platform === "darwin"
588
- ? [["osascript", ["-e", [
589
- 'set t to "Paste the secret for " & (system attribute "KEYSHROUD_DIALOG_ENV") & return & "Vault item: " & (system attribute "KEYSHROUD_DIALOG_ITEM")',
590
- 'activate',
591
- 'text returned of (display dialog t default answer "" with hidden answer with title "keyshroud" buttons {"Cancel", "Save"} default button "Save" cancel button "Cancel")',
592
- ].join("\n")]]]
623
+ ? [["osascript", ["-e", "on run argv", "-e", 'activate', "-e",
624
+ '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)',
625
+ "-e", "end run", "--", text]]]
593
626
  : process.env.DISPLAY || process.env.WAYLAND_DISPLAY
594
- ? [["zenity", ["--entry", "--hide-text", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text]]]
627
+ ? [["zenity", ["--entry", "--hide-text", "--no-markup", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text.replace(/[<>]/g, "")]]]
595
628
  : [];
596
- for (const [command, args] of candidates) {
597
- const result = await new Promise<{ code: number | string | null; stdout: string; stderr: string }>((resolve) => {
598
- execFile(command, args, { env: { ...process.env, KEYSHROUD_DIALOG_ENV: opts.label, KEYSHROUD_DIALOG_ITEM: opts.itemName }, timeout: 300_000 },
599
- (error, stdout, stderr) => resolve({ code: error ? ((error as NodeJS.ErrnoException).code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) }));
600
- });
601
- if (result.code === "ENOENT") continue;
602
- if (result.code === 0) return result.stdout.replace(/\r?\n$/, "");
603
- if (command !== "osascript" || /-128/.test(result.stderr)) throw new Error("Cancelled");
629
+ if (!candidates.length) return null;
630
+ return withDialogLock(async () => {
631
+ for (const [command, args] of candidates) {
632
+ const result = await new Promise<{ code: number | string | null; stdout: string; stderr: string }>((resolve) => {
633
+ execFile(command, args, { timeout: DIALOG_TIMEOUT_MS }, (error, stdout, stderr) => resolve({ code: error ? ((error as NodeJS.ErrnoException).code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) }));
634
+ });
635
+ if (result.code === "ENOENT") continue;
636
+ if (result.code === 0) return result.stdout.replace(/\r?\n$/, "");
637
+ if (command !== "osascript" || /-128/.test(result.stderr)) throw new Error("Cancelled");
638
+ return null;
639
+ }
604
640
  return null;
641
+ });
642
+ }
643
+
644
+ async function withDialogLock<T>(fn: () => Promise<T>): Promise<T> {
645
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
646
+ let held = "";
647
+ try { held = await readFile(DIALOG_LOCK, "utf8"); } catch {}
648
+ const [kind, at] = held.trim().split(":");
649
+ const age = Date.now() - Number(at);
650
+ const live = (kind === "open" || kind === "cancelled") && age >= 0 && age < (kind === "cancelled" ? DIALOG_COOLDOWN_MS : DIALOG_TIMEOUT_MS + 30_000);
651
+ if (!live) await rm(DIALOG_LOCK, { force: true });
652
+ try { await writeFile(DIALOG_LOCK, `open:${Date.now()}\n`, { flag: "wx", mode: 0o600 }); }
653
+ catch { throw new Error(kind === "cancelled" ? "The last dialog was cancelled; wait 30 seconds before asking again" : "Another keyshroud dialog is already open"); }
654
+ try {
655
+ const value = await fn();
656
+ await rm(DIALOG_LOCK, { force: true });
657
+ return value;
658
+ } catch (error) {
659
+ if (error instanceof Error && error.message === "Cancelled") await writeFile(DIALOG_LOCK, `cancelled:${Date.now()}\n`, { mode: 0o600 });
660
+ else await rm(DIALOG_LOCK, { force: true });
661
+ throw error;
605
662
  }
606
- return null;
607
663
  }
608
664
 
609
665
  // Raw-mode prompt that echoes one "*" per character (capped, then a count), so
@@ -616,14 +672,25 @@ function readMasked(prompt: string): Promise<string> {
616
672
  process.stderr.write(`\r\x1b[2K${prompt}${shown}`);
617
673
  };
618
674
  return new Promise((resolve, reject) => {
675
+ // After Enter, keep reading briefly: the rest of a multi-line paste must be
676
+ // consumed here (or it would reach the parent shell) and is then refused.
677
+ let ending: ReturnType<typeof setTimeout> | undefined;
678
+ let multiline = false;
679
+ const settle = () => {
680
+ clearTimeout(ending);
681
+ ending = setTimeout(() => finish(multiline ? new Error("Multi-line values are not supported in the prompt; pipe them with --stdin") : undefined), 100);
682
+ };
619
683
  const finish = (error?: Error) => {
684
+ clearTimeout(ending);
620
685
  stdin.off("data", onData); stdin.setRawMode(false); stdin.pause();
621
686
  process.stderr.write("\n");
622
687
  if (error) reject(error); else resolve(chars.join(""));
623
688
  };
624
689
  const onData = (chunk: string) => {
625
- for (const ch of chunk.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b./g, "")) {
626
- if (ch === "\r" || ch === "\n") return finish();
690
+ if (ending) { if (/[^\r\n]/.test(chunk)) multiline = true; return settle(); }
691
+ const clean = [...chunk.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b./g, "")];
692
+ for (const [index, ch] of clean.entries()) {
693
+ if (ch === "\r" || ch === "\n") { if (/[^\r\n]/.test(clean.slice(index + 1).join(""))) multiline = true; return settle(); }
627
694
  if (ch === "\x03") return finish(new Error("Cancelled"));
628
695
  if (ch === "\x04") return chars.length ? finish() : finish(new Error("Cancelled"));
629
696
  if (ch === "\x7f" || ch === "\b") chars.pop();
package/src/mcp.ts CHANGED
@@ -7,7 +7,7 @@ import process from "node:process";
7
7
  import { BRAND } from "./brand.ts";
8
8
  import * as core from "./core.ts";
9
9
  import { createRedactor } from "./redact.ts";
10
- import { ENV_NAME_RE } from "./refs.ts";
10
+ import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem } from "./refs.ts";
11
11
 
12
12
  const SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
13
13
  const OUTPUT_LIMIT = 256 * 1024;
@@ -85,13 +85,13 @@ async function runTool(args: Json): Promise<Json> {
85
85
  detached: true,
86
86
  });
87
87
  const chunks = { stdout: [] as Buffer[], stderr: [] as Buffer[] };
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"] as const) {
92
92
  child[stream].on("data", (chunk: Buffer) => {
93
- if (size >= OUTPUT_LIMIT * 4) return;
94
- size += chunk.length;
93
+ if (sizes[stream] >= OUTPUT_LIMIT * 2) return;
94
+ sizes[stream] += chunk.length;
95
95
  chunks[stream].push(chunk);
96
96
  });
97
97
  }
@@ -101,9 +101,11 @@ async function runTool(args: Json): Promise<Json> {
101
101
  child.on("error", (error) => done(`spawn error: ${error.message}`));
102
102
  });
103
103
  clearTimeout(timer);
104
- let truncated = size >= OUTPUT_LIMIT * 4;
104
+ let truncated = false;
105
105
  const out = (stream: "stdout" | "stderr") => {
106
- const masked = redactor.redact(Buffer.concat(chunks[stream]).toString("utf8"));
106
+ let raw = Buffer.concat(chunks[stream]).toString("utf8");
107
+ if (sizes[stream] >= OUTPUT_LIMIT * 2) { truncated = true; raw = raw.slice(0, redactor.safeCut(raw)); }
108
+ const masked = redactor.redact(raw);
107
109
  if (masked.length <= OUTPUT_LIMIT / 2) return masked;
108
110
  truncated = true;
109
111
  return masked.slice(0, OUTPUT_LIMIT / 2);
@@ -132,25 +134,32 @@ async function callTool(name: string, args: Json): Promise<Json> {
132
134
  const envName = String(args.env_name ?? "");
133
135
  const itemName = String(args.item_name ?? "");
134
136
  if (!ENV_NAME_RE.test(envName) || !itemName) return text("env_name must be UPPER_SNAKE_CASE and item_name is required", true);
135
- const notes = typeof args.purpose === "string" && args.purpose ? args.purpose : undefined;
137
+ if (isLoaderEnvName(envName)) return text(`${envName} changes what programs load or run; it cannot be wired to a secret`, true);
138
+ const problem = itemNameProblem(itemName);
139
+ if (problem) return text(`item_name ${problem}`, true);
140
+ const notes = typeof args.purpose === "string" && args.purpose ? args.purpose.slice(0, 500) : undefined;
136
141
  try {
142
+ const reference = core.referenceFor(itemName);
137
143
  await core.ensureSession();
138
- const secret = await core.readSecretFromDialog({ label: envName, itemName });
144
+ if ((await core.findItems(itemName)).some((item) => item.name.toLowerCase() === itemName.toLowerCase())) return text(`Vault item already exists: ${itemName}`, true);
145
+ const secret = await core.readSecretFromDialog({ itemName, envName });
139
146
  if (secret === "") return text("The user saved an empty value; nothing was stored.", true);
140
147
  if (secret !== null) {
141
148
  await core.createSecretItem({ itemName, secret, envName, notes });
142
- const reference = core.referenceFor(itemName);
143
- for (const path of Object.values(core.getAuthTargets())) await core.writeAuthEntry(path, envName, reference);
149
+ await core.audit("request", [envName], "mcp");
150
+ const conflicts: string[] = [];
151
+ for (const path of Object.values(core.getAuthTargets())) if (!(await core.writeAuthEntry(path, envName, reference)).ok) conflicts.push(path);
152
+ await core.refreshRedactIndex();
153
+ if (conflicts.length) 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);
144
154
  const verified = (await core.resolveShellValue(reference)) !== null;
145
155
  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}.`);
146
156
  }
147
157
  } catch (error) {
148
158
  const message = error instanceof Error ? error.message : String(error);
149
- if (message === "Cancelled") return text("The user cancelled the dialog; nothing was stored.", true);
150
- if (/already exists/.test(message)) return text(message, true);
159
+ return text(message === "Cancelled" ? "The user cancelled the dialog; nothing was stored." : message, true);
151
160
  }
152
161
  const purpose = notes ? ` --notes ${shellQuote(notes)}` : "";
153
- 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}.`);
162
+ 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}.`);
154
163
  }
155
164
  if (name === "vault_status") {
156
165
  try { await core.ensureSession(); } catch {}
package/src/redact.ts CHANGED
@@ -81,7 +81,7 @@ export function createRedactor(values: Record<string, string>): Redactor {
81
81
  if (text.length - cut <= MAX_HOLD) return cut;
82
82
  // Bounded fallback: cut near the end, moving forward past any complete
83
83
  // secret that straddles it, so whole matches are still replaced.
84
- cut = text.length - maxLength;
84
+ cut = Math.max(0, text.length - maxLength);
85
85
  while (cut < text.length && straddling(cut)) cut++;
86
86
  return cut;
87
87
  },
package/src/refs.ts CHANGED
@@ -55,6 +55,20 @@ export function legacyRefFor(itemName: string): string {
55
55
 
56
56
  export const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
57
57
 
58
+ // Variables that change what a child process loads or runs. Env files can come
59
+ // from a cloned repo and agents can wire names, so neither may set these.
60
+ 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;
61
+ export function isLoaderEnvName(name: string): boolean { return LOADER_ENV_RE.test(name); }
62
+
63
+ // Item names appear in native dialogs and lookups: one line of printable text,
64
+ // no padding (lookups trim, so a padded name would shadow an existing item).
65
+ export function itemNameProblem(name: string): string | null {
66
+ if (!name || name !== name.trim()) return "must not be empty or start or end with spaces";
67
+ if (name.length > 120) return "must be at most 120 characters";
68
+ if (/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(name)) return "must be one line of printable text";
69
+ return null;
70
+ }
71
+
58
72
  // Parses dotenv-style files whose values may be references:
59
73
  // GH_TOKEN=bw://GitHub CLI token
60
74
  // export SENTRY_DSN="bw://Sentry/fields/dsn"
@@ -112,14 +126,17 @@ export function parseDotenvStrict(text: string): { entries: DotenvEntry[]; error
112
126
  return { entries, errors };
113
127
  }
114
128
 
115
- const SECRET_NAME_RE = /(SECRET|TOKEN|PASSW(?:OR)?D|PASSPHRASE|PWD|(?:^|_)KEY(?:$|_)|APIKEY|PRIVATE|CREDENTIAL|AUTH|DSN|WEBHOOK|COOKIE|SIGNING|ENCRYPT|SALT)/i;
129
+ 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;
116
130
  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)/;
117
- const URL_PASSWORD_RE = /^[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:[^\s@/]+@/i;
131
+ const URL_PASSWORD_RE = /^[a-z][a-z0-9+.-]*:\/\/[^\s:@/]*:[^\s@/]+@/i;
118
132
 
119
- // Local heuristic for `import`: never sends anything anywhere. Names that say
120
- // PUBLIC (NEXT_PUBLIC_*, VITE_PUBLIC_*) are meant to ship to browsers.
133
+ // Local heuristic for `import`: never sends anything anywhere. A known secret
134
+ // format wins over the name; otherwise names with a PUBLIC segment
135
+ // (NEXT_PUBLIC_*, VITE_PUBLIC_*) are meant to ship to browsers.
121
136
  export function looksSecret(name: string, value: string): boolean {
122
- if (!value || isRef(value) || /PUBLIC/i.test(name)) return false;
123
- if (SECRET_VALUE_RE.test(value) || URL_PASSWORD_RE.test(value) || SECRET_NAME_RE.test(name)) return true;
137
+ if (!value || isRef(value)) return false;
138
+ if (SECRET_VALUE_RE.test(value) || URL_PASSWORD_RE.test(value)) return true;
139
+ if (/(?:^|_)PUBLIC(?:$|_)/i.test(name)) return false;
140
+ if (SECRET_NAME_RE.test(name) || /^[0-9a-f]{32,}$/i.test(value)) return true;
124
141
  return value.length >= 24 && !/\s/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value) && /\d/.test(value);
125
142
  }