keyshroud 0.5.1 → 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/src/cli.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { chmod, copyFile, mkdir, lstat, readdir, readFile, readlink, symlink, unlink, writeFile } from "node:fs/promises";
2
+ import { chmod, copyFile, mkdir, lstat, readdir, readFile, readlink, rename, stat, symlink, unlink, writeFile } from "node:fs/promises";
3
3
  import { constants, homedir } from "node:os";
4
4
  import { fileURLToPath } from "node:url";
5
- import { join, resolve } from "node:path";
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, parseRef } 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
 
@@ -22,8 +23,11 @@ Secrets
22
23
  redact Copy stdin to stdout with vault secrets masked
23
24
  scan [paths...] Find vault secret values in files (exit 1 on leaks)
24
25
  env [--shell] List wired names (or print shell exports)
25
- add --env NAME --item ITEM [--notes TEXT] [--target pi|omp|both] [--stdin]
26
- rotate --item ITEM [--stdin]
26
+ add --env NAME --item ITEM [--notes TEXT] [--target pi|omp|both] [--stdin|--dialog]
27
+ import [--from F|-] [--out F] [--prefix P] [--only A,B] [--skip A,B] [--yes] [--replace] [--force]
28
+ Move secrets from a .env into the vault and write .env.refs
29
+ (dry run unless --yes; prints names only)
30
+ rotate --item ITEM [--stdin|--dialog]
27
31
  wire --env NAME --item ITEM [--target pi|omp|both] [--force]
28
32
  unwire --env NAME [--target pi|omp|both]
29
33
 
@@ -53,6 +57,21 @@ function targetList(value: string | undefined): string[] {
53
57
  if (value === "pi") return [targetPaths.pi];
54
58
  fail("--target must be omp, pi, or both", 2);
55
59
  }
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);
74
+ }
56
75
  function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; }
57
76
  function failureCode(error: unknown): number {
58
77
  const text = error instanceof Error ? error.message : String(error);
@@ -128,9 +147,13 @@ async function inject(args: string[]): Promise<void> {
128
147
  await core.audit("inject", refs, output ?? "stdout");
129
148
  const rendered = template.replace(pattern, (_match, raw: string) => values.get(raw) ?? "");
130
149
  if (!output) { process.stdout.write(rendered); return; }
131
- try { await readFile(output); if (!args.includes("--force")) fail(`Refusing to overwrite ${output}; use --force`, 5); } catch {}
132
- await writeFile(output, rendered, { mode: 0o600 });
133
- 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);
134
157
  console.error(`rendered ${refs.length} reference(s) into ${output} (mode 0600)`);
135
158
  }
136
159
 
@@ -171,6 +194,90 @@ async function scan(paths: string[]): Promise<void> {
171
194
  if (leaks) process.exitCode = 1;
172
195
  }
173
196
 
197
+ interface ImportRow { entry: DotenvEntry; kind: "new" | "reuse" | "conflict" | "literal" | "ref"; item?: string; note?: string }
198
+
199
+ async function importCommand(args: string[]): Promise<void> {
200
+ requireKnownFlags(args, ["--from", "--out", "--prefix", "--only", "--skip"], ["--yes", "--replace", "--force"]);
201
+ const from = valueAfter(args, "--from") ?? ".env";
202
+ const yes = args.includes("--yes"); const replace = args.includes("--replace");
203
+ if (replace && (from === "-" || valueAfter(args, "--out"))) fail("--replace rewrites the --from file; it cannot be combined with stdin or --out", 2);
204
+ if (from === "-" && process.stdin.isTTY) fail("--from - needs the .env piped on stdin", 2);
205
+ const baseDir = from === "-" ? process.cwd() : dirname(resolve(from));
206
+ const out = replace ? resolve(from) : resolve(valueAfter(args, "--out") ?? join(baseDir, ".env.refs"));
207
+ const prefix = valueAfter(args, "--prefix") ?? basename(baseDir);
208
+ if (!/^[A-Za-z0-9._-]+$/.test(prefix)) fail("--prefix may contain only letters, digits, '.', '_' and '-' (set it with --prefix)", 2);
209
+ const list = (flag: string) => (valueAfter(args, flag) ?? "").split(",").map((name) => name.trim()).filter(Boolean);
210
+ const only = list("--only"); const skip = list("--skip");
211
+
212
+ let text: string;
213
+ try { text = from === "-" ? await readStdin() : await readFile(from, "utf8"); } catch { fail(`Cannot read ${from}`, 2); }
214
+ const { entries, errors } = parseDotenvStrict(text);
215
+ if (errors.length) {
216
+ for (const error of errors) console.error(`${from}:${error.line} ${error.reason}`);
217
+ fail(`${errors.length} line(s) cannot be imported safely; fix them or move them out of the file`, 2);
218
+ }
219
+ for (const name of [...only, ...skip]) if (!entries.some((entry) => entry.name === name)) fail(`--only/--skip name not in ${from}: ${name}`, 2);
220
+
221
+ await core.ensureSession();
222
+ const existing = await core.findItems(prefix);
223
+ const rows: ImportRow[] = [];
224
+ for (const entry of entries) {
225
+ if (isRef(entry.value)) { rows.push({ entry, kind: "ref" }); continue; }
226
+ const secret = (only.length ? only.includes(entry.name) : looksSecret(entry.name, entry.value)) && !skip.includes(entry.name);
227
+ if (!secret) { rows.push({ entry, kind: "literal" }); continue; }
228
+ const item = `${prefix} ${entry.name}`;
229
+ const matches = existing.filter((candidate) => candidate.name.toLowerCase() === item.toLowerCase());
230
+ if (matches.length > 1) { rows.push({ entry, item, kind: "conflict", note: "more than one vault item has this name" }); continue; }
231
+ if (matches.length === 0) { rows.push({ entry, item, kind: "new" }); continue; }
232
+ const current = await core.resolveRef({ item: matches[0].id, field: "password" });
233
+ rows.push(current === entry.value
234
+ ? { entry, item, kind: "reuse", note: "same value already in the vault" }
235
+ : { entry, item, kind: "conflict", note: "vault item exists with a different value" });
236
+ }
237
+
238
+ const width = Math.max(4, ...rows.map((row) => row.entry.name.length));
239
+ console.log(`import plan for ${from} (item prefix "${prefix}")`);
240
+ for (const row of rows) {
241
+ const label = row.kind === "literal" ? "literal kept" : row.kind === "ref" ? "reference kept"
242
+ : row.kind === "conflict" ? `secret conflict: ${row.note} ("${row.item}")`
243
+ : `secret ${row.kind.padEnd(8)} -> bw://${row.item}${row.note ? ` (${row.note})` : ""}`;
244
+ const short = row.item && row.entry.value.length < 6 ? " [under 6 characters: stored, but too short to be masked]" : "";
245
+ console.log(` ${row.entry.name.padEnd(width)} ${label}${short}`);
246
+ }
247
+ const count = (kind: ImportRow["kind"]) => rows.filter((row) => row.kind === kind).length;
248
+ const toStore = rows.filter((row) => row.kind === "new" || row.kind === "reuse");
249
+ console.log(`${toStore.length + count("conflict")} secret(s): ${count("new")} new, ${count("reuse")} already in the vault, ${count("conflict")} conflict(s); ${count("literal")} literal(s), ${count("ref")} reference(s)`);
250
+ if (count("conflict")) fail("Resolve the conflicts (choose another --prefix, or rename the vault items) before importing", 5);
251
+ if (!toStore.length) { console.log("Nothing to import."); return; }
252
+ const outExists = await stat(out).then(() => true, () => false);
253
+ if (outExists && !replace && !args.includes("--force")) fail(`${out} already exists; use --force to overwrite it`, 5);
254
+ if (!yes) { console.log(`Dry run: nothing written. Re-run with --yes to create ${count("new")} item(s) and write ${out}`); return; }
255
+
256
+ const created: string[] = [];
257
+ for (const row of rows) {
258
+ if (row.kind !== "new") continue;
259
+ try { await core.createSecretItem({ itemName: row.item!, secret: row.entry.value, envName: row.entry.name, notes: `Imported by ${BRAND.bin} from ${basename(from === "-" ? "stdin" : from)}` }); }
260
+ catch { if (created.length) console.error(`Created before the failure: ${created.join(", ")}`); fail(`Failed to create the vault item for ${row.entry.name}; nothing was written to ${out}`, 3); }
261
+ created.push(row.entry.name);
262
+ }
263
+ for (const row of toStore) {
264
+ let value: string | null = null;
265
+ try { value = await core.resolveRef({ item: row.item!, field: "password" }); } catch {}
266
+ if (value !== row.entry.value) fail(`Verification failed for ${row.entry.name}: the vault does not return the imported value; nothing was written to ${out}`, 4);
267
+ }
268
+
269
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
270
+ const lines = text.split(/\r?\n/);
271
+ for (const row of toStore) lines[row.entry.line - 1] = `${row.entry.exported ? "export " : ""}${row.entry.name}=bw://${row.item}`;
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" });
275
+ await rename(tmp, out);
276
+ await core.audit("import", toStore.map((row) => row.entry.name), "cli");
277
+ console.log(`imported ${toStore.length} secret(s) (${created.length} new); wrote ${out}`);
278
+ if (!replace && from !== "-") console.log(`${from} still holds the plaintext values. Check with \`${BRAND.bin} run --env-file ${relative(process.cwd(), out) || out} -- <cmd>\`, then delete it or re-run with --replace.`);
279
+ }
280
+
174
281
  async function main(): Promise<void> {
175
282
  const args = process.argv.slice(2); const command = args.shift();
176
283
  if (!command || command === "--help" || command === "-h" || command === "help") { console.log(usage); if (!command) process.exitCode = 2; return; }
@@ -226,31 +333,38 @@ async function main(): Promise<void> {
226
333
  await core.ensureSession(); process.stdout.write(await core.getSecret(item)); return;
227
334
  }
228
335
  if (command === "add") {
229
- requireKnownFlags(args, ["--env", "--item", "--notes", "--target"], ["--stdin"]);
336
+ requireKnownFlags(args, ["--env", "--item", "--notes", "--target"], ["--stdin", "--dialog"]);
230
337
  const envName = valueAfter(args, "--env"); const itemName = valueAfter(args, "--item"); const notes = valueAfter(args, "--notes");
231
338
  if (!envName || !itemName || !ENV_NAME_RE.test(envName)) fail(usage, 2);
339
+ checkNames(envName, itemName);
232
340
  const reference = core.referenceFor(itemName);
233
341
  const targets = targetList(valueAfter(args, "--target"));
234
342
  await core.ensureSession();
235
- const secret = await core.readSecretFromInput({ prompt: `Paste secret for ${envName}: `, stdin: args.includes("--stdin") });
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);
236
345
  await core.createSecretItem({ itemName, secret, envName, notes });
346
+ await core.audit("add", [envName], "cli");
237
347
  let conflict = false;
238
348
  for (const path of targets) { const result = await core.writeAuthEntry(path, envName, reference); if (!result.ok) conflict = true; }
239
349
  const verified = (await core.resolveShellValue(reference)) !== null;
350
+ await core.refreshRedactIndex();
240
351
  console.log(`created item=${itemName} env=${envName} targets=${targets.join(",")} verified=${verified ? "yes" : "no"}`);
241
352
  if (conflict) { console.error("Item created, but one or more auth targets already contained the environment name."); process.exitCode = 5; }
242
353
  return;
243
354
  }
355
+ if (command === "import") { await importCommand(args); return; }
244
356
  if (command === "rotate") {
245
- requireKnownFlags(args, ["--item"], ["--stdin"]);
357
+ requireKnownFlags(args, ["--item"], ["--stdin", "--dialog"]);
246
358
  const itemName = valueAfter(args, "--item"); if (!itemName) fail(usage, 2);
247
- await core.ensureSession(); const secret = await core.readSecretFromInput({ prompt: "Paste replacement secret: ", stdin: args.includes("--stdin") });
248
- 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;
249
362
  }
250
363
  if (command === "wire") {
251
364
  requireKnownFlags(args, ["--env", "--item", "--target"], ["--force"]);
252
365
  const envName = valueAfter(args, "--env"); const itemName = valueAfter(args, "--item");
253
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);
254
368
  const reference = core.referenceFor(itemName);
255
369
  const targets = targetList(valueAfter(args, "--target"));
256
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,14 +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
- import * as readline from "node:readline";
9
9
  import { BRAND } from "./brand.ts";
10
10
  import { buildRedactIndex, type RedactIndex } from "./redact.ts";
11
- 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";
12
12
 
13
13
  const P = BRAND.envPrefix;
14
14
  function setting(name: string): string | undefined {
@@ -72,7 +72,8 @@ export const VAULT_ENV_KEYS: readonly string[] = ["BW_SESSION", "BW_PASSWORD", "
72
72
  export function childEnv(extra: Record<string, string> = {}): Record<string, string> {
73
73
  const env: Record<string, string> = {};
74
74
  for (const [name, value] of Object.entries(process.env)) if (value !== undefined && !VAULT_ENV_KEYS.includes(name)) env[name] = value;
75
- return { ...env, ...extra };
75
+ for (const [name, value] of Object.entries(extra)) if (!isLoaderEnvName(name)) env[name] = value;
76
+ return env;
76
77
  }
77
78
 
78
79
  export async function bw(args: string[], opts: { input?: string; session?: string } = {}): Promise<string> {
@@ -252,8 +253,8 @@ export function ensureSession(): Promise<string> {
252
253
 
253
254
  async function writePrivateFile(path: string, content: string): Promise<void> {
254
255
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
255
- const tmp = `${path}.tmp.${process.pid}`;
256
- 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" });
257
258
  await chmod(tmp, 0o600);
258
259
  await rename(tmp, path);
259
260
  await chmod(path, 0o600);
@@ -456,6 +457,7 @@ export async function resolveEntries(entries: Array<{ name: string; raw: string
456
457
  export async function envFileEntries(path: string): Promise<Array<{ name: string; raw: string }>> {
457
458
  return parseEnvFile(await readFile(path, "utf8")).map(({ name, value }) => {
458
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`);
459
461
  return { name, raw: value };
460
462
  });
461
463
  }
@@ -505,6 +507,12 @@ export async function writeRedactIndex(values: Record<string, string>): Promise<
505
507
  return index;
506
508
  }
507
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
+
508
516
  export async function readRedactIndex(): Promise<RedactIndex | null> {
509
517
  try {
510
518
  const parsed = JSON.parse(await readFile(REDACT_INDEX_FILE, "utf8")) as RedactIndex;
@@ -532,7 +540,16 @@ export async function getSecret(itemName: string): Promise<string> {
532
540
  try { return await bw(["get", "password", itemName]); } catch (error) { throw mapItemError(error, itemName); }
533
541
  }
534
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
+
535
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);
536
553
  const existing = await findItems(opts.itemName);
537
554
  if (existing.some((item) => item.name.toLowerCase() === opts.itemName.toLowerCase())) throw new Error(`Vault item already exists: ${opts.itemName}`);
538
555
  const item: Record<string, unknown> = {
@@ -549,6 +566,7 @@ export async function createSecretItem(opts: { itemName: string; secret: string;
549
566
  }
550
567
 
551
568
  export async function rotateSecretItem(opts: { itemName: string; secret: string }): Promise<{ id: string }> {
569
+ await refuseMasterPassword(opts.secret);
552
570
  let parsed: Record<string, unknown>;
553
571
  try { parsed = JSON.parse(await bw(["get", "item", opts.itemName])) as Record<string, unknown>; } catch (error) { throw mapItemError(error, opts.itemName); }
554
572
  const login = parsed.login && typeof parsed.login === "object" ? parsed.login as Record<string, unknown> : {};
@@ -566,19 +584,122 @@ export async function deleteItem(id: string, permanent = false): Promise<void> {
566
584
  }
567
585
 
568
586
  export async function readSecretFromInput(opts: { prompt: string; stdin: boolean }): Promise<string> {
569
- if (opts.stdin || !process.stdin.isTTY) {
587
+ if (opts.stdin) {
570
588
  let input = "";
571
589
  for await (const chunk of process.stdin) input += String(chunk);
572
590
  input = input.replace(/\r?\n$/, "");
573
591
  if (!input) throw new Error("Empty secret");
574
592
  return input;
575
593
  }
576
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
577
- (rl as unknown as { _writeToOutput: (text: string) => void })._writeToOutput = () => {};
578
- process.stderr.write(opts.prompt);
579
- const value = await new Promise<string>((resolve) => rl.question("", resolve));
580
- rl.close();
581
- process.stderr.write("\n");
594
+ if (!process.stdin.isTTY) throw new Error("No terminal to prompt on; pipe the value with --stdin");
595
+ const value = await readMasked(opts.prompt);
582
596
  if (!value) throw new Error("Empty secret");
583
597
  return value;
584
598
  }
599
+
600
+ // Native hidden-input dialog for callers without a terminal (agent shells, MCP).
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> {
610
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return null;
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");
622
+ const candidates: Array<[string, string[]]> = process.platform === "darwin"
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]]]
626
+ : process.env.DISPLAY || process.env.WAYLAND_DISPLAY
627
+ ? [["zenity", ["--entry", "--hide-text", "--no-markup", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text.replace(/[<>]/g, "")]]]
628
+ : [];
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
+ }
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;
662
+ }
663
+ }
664
+
665
+ // Raw-mode prompt that echoes one "*" per character (capped, then a count), so
666
+ // the user sees typing and pastes land without the value ever reaching the screen.
667
+ function readMasked(prompt: string): Promise<string> {
668
+ const stdin = process.stdin;
669
+ const chars: string[] = [];
670
+ const draw = () => {
671
+ const shown = "*".repeat(Math.min(chars.length, 24)) + (chars.length > 24 ? ` (${chars.length} chars)` : "");
672
+ process.stderr.write(`\r\x1b[2K${prompt}${shown}`);
673
+ };
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
+ };
683
+ const finish = (error?: Error) => {
684
+ clearTimeout(ending);
685
+ stdin.off("data", onData); stdin.setRawMode(false); stdin.pause();
686
+ process.stderr.write("\n");
687
+ if (error) reject(error); else resolve(chars.join(""));
688
+ };
689
+ const onData = (chunk: string) => {
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(); }
694
+ if (ch === "\x03") return finish(new Error("Cancelled"));
695
+ if (ch === "\x04") return chars.length ? finish() : finish(new Error("Cancelled"));
696
+ if (ch === "\x7f" || ch === "\b") chars.pop();
697
+ else if (ch === "\x15") chars.length = 0;
698
+ else if (ch >= " ") chars.push(ch);
699
+ }
700
+ draw();
701
+ };
702
+ stdin.setRawMode(true); stdin.setEncoding("utf8"); stdin.resume(); stdin.on("data", onData);
703
+ draw();
704
+ });
705
+ }
package/src/hooks.ts CHANGED
@@ -22,8 +22,8 @@ async function sessionStart(): Promise<string> {
22
22
  await core.writeRedactIndex(core.indexValues(env.secrets));
23
23
  const names = Object.keys(env.values);
24
24
  context = names.length
25
- ? `${BRAND.name}: vault secrets are available by name only: ${names.join(", ")}. Run every command that uses one through \`${BRAND.bin} run -- <command>\` (or the secrets_run MCP tool): its output is masked as <redacted:NAME> even when the command fails. Never print a secret. Never ask the user to paste a secret into chat; use \`${BRAND.bin} add\` in their terminal instead.`
26
- : `${BRAND.name}: no secrets are wired yet. Ask the user to run \`${BRAND.bin} add --env NAME --item "Item"\` in their own terminal.`;
25
+ ? `${BRAND.name}: vault secrets are available by name only: ${names.join(", ")}. Run every command that uses one through \`${BRAND.bin} run -- <command>\` (or the secrets_run MCP tool): its output is masked as <redacted:NAME> even when the command fails. Never print a secret. Never ask the user to paste a secret into chat. To store a new one, run \`${BRAND.bin} add --env NAME --item "Item"\` (or call secrets_request): it opens a dialog on the user's desktop for the value.`
26
+ : `${BRAND.name}: no secrets are wired yet. To store one, run \`${BRAND.bin} add --env NAME --item "Item"\` (or call secrets_request): it opens a dialog on the user's desktop for the value.`;
27
27
  } catch (error) {
28
28
  context = `${BRAND.name}: the vault could not be unlocked (${error instanceof Error ? error.message : String(error)}), so tool-output redaction may be incomplete this session. Do not print or inspect secrets; ask the user to run \`${BRAND.bin} unlock\`.`;
29
29
  }
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;
@@ -35,7 +35,7 @@ const TOOLS = [
35
35
  },
36
36
  {
37
37
  name: "secrets_request",
38
- description: "Ask the human to store a new secret. Returns a terminal command for the user to run; the value is typed into a masked prompt and never passes through the conversation.",
38
+ description: "Ask the human to store a new secret. Opens a native hidden-input dialog on the user's desktop and stores the value in the vault; where no dialog can be shown, returns a terminal command for the user to run instead. The value never passes through the conversation.",
39
39
  inputSchema: {
40
40
  type: "object",
41
41
  required: ["env_name", "item_name"],
@@ -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,8 +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 purpose = typeof args.purpose === "string" && args.purpose ? ` --notes ${shellQuote(args.purpose)}` : "";
136
- 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}.`);
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;
141
+ try {
142
+ const reference = core.referenceFor(itemName);
143
+ await core.ensureSession();
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 });
146
+ if (secret === "") return text("The user saved an empty value; nothing was stored.", true);
147
+ if (secret !== null) {
148
+ await core.createSecretItem({ itemName, secret, envName, notes });
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);
154
+ const verified = (await core.resolveShellValue(reference)) !== null;
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}.`);
156
+ }
157
+ } catch (error) {
158
+ const message = error instanceof Error ? error.message : String(error);
159
+ return text(message === "Cancelled" ? "The user cancelled the dialog; nothing was stored." : message, true);
160
+ }
161
+ const purpose = notes ? ` --notes ${shellQuote(notes)}` : "";
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}.`);
137
163
  }
138
164
  if (name === "vault_status") {
139
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"
@@ -74,3 +88,55 @@ export function parseEnvFile(text: string): Array<{ name: string; value: string
74
88
  });
75
89
  return out;
76
90
  }
91
+
92
+ export interface DotenvEntry { line: number; name: string; value: string; exported: boolean }
93
+
94
+ // Stricter than parseEnvFile, for `import`: a misread value would be stored in
95
+ // the vault, so anything ambiguous is reported by line number (never by value)
96
+ // instead of guessed. Values must also read back unchanged through parseEnvFile.
97
+ export function parseDotenvStrict(text: string): { entries: DotenvEntry[]; errors: Array<{ line: number; reason: string }> } {
98
+ const entries: DotenvEntry[] = [];
99
+ const errors: Array<{ line: number; reason: string }> = [];
100
+ const seen = new Set<string>();
101
+ text.split(/\r?\n/).forEach((raw, index) => {
102
+ const line = index + 1;
103
+ const trimmed = raw.trim();
104
+ if (!trimmed || trimmed.startsWith("#")) return;
105
+ const match = /^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(trimmed);
106
+ if (!match) { errors.push({ line, reason: "not a NAME=value line" }); return; }
107
+ const rest = match[3].trim();
108
+ let value: string;
109
+ if (rest.startsWith("'") || rest.startsWith('"')) {
110
+ const end = rest.indexOf(rest[0], 1);
111
+ if (end < 0) { errors.push({ line, reason: "unterminated quote (multiline values are not supported)" }); return; }
112
+ const after = rest.slice(end + 1).trim();
113
+ if (after && !/^#/.test(after)) { errors.push({ line, reason: "text after the closing quote" }); return; }
114
+ if (after && end + 1 === rest.indexOf("#", end)) { errors.push({ line, reason: "comment must be separated from the closing quote by a space" }); return; }
115
+ value = rest.slice(1, end);
116
+ if (rest[0] === '"' && /[\\$]/.test(value)) { errors.push({ line, reason: "escape sequence or $ expansion inside double quotes" }); return; }
117
+ } else {
118
+ value = rest.replace(/\s+#.*$/, "");
119
+ if (value.includes("${")) { errors.push({ line, reason: "${} expansion" }); return; }
120
+ }
121
+ if (value.startsWith("!")) { errors.push({ line, reason: "value starts with '!' (env files cannot run commands)" }); return; }
122
+ if (seen.has(match[2])) { errors.push({ line, reason: `duplicate key ${match[2]}` }); return; }
123
+ seen.add(match[2]);
124
+ entries.push({ line, name: match[2], value, exported: Boolean(match[1]) });
125
+ });
126
+ return { entries, errors };
127
+ }
128
+
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;
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)/;
131
+ const URL_PASSWORD_RE = /^[a-z][a-z0-9+.-]*:\/\/[^\s:@/]*:[^\s@/]+@/i;
132
+
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.
136
+ export function looksSecret(name: string, value: string): boolean {
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;
141
+ return value.length >= 24 && !/\s/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value) && /\d/.test(value);
142
+ }