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/dist/core.js 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.js";
10
10
  import { buildRedactIndex } from "./redact.js";
11
- import { ENV_NAME_RE, legacyRefFor, parseEnvFile, parseRef } from "./refs.js";
11
+ import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem, legacyRefFor, parseEnvFile, parseRef } from "./refs.js";
12
12
  const P = BRAND.envPrefix;
13
13
  function setting(name) {
14
14
  const value = process.env[`${P}_${name}`]?.trim();
@@ -61,7 +61,10 @@ export function childEnv(extra = {}) {
61
61
  for (const [name, value] of Object.entries(process.env))
62
62
  if (value !== undefined && !VAULT_ENV_KEYS.includes(name))
63
63
  env[name] = value;
64
- return { ...env, ...extra };
64
+ for (const [name, value] of Object.entries(extra))
65
+ if (!isLoaderEnvName(name))
66
+ env[name] = value;
67
+ return env;
65
68
  }
66
69
  export async function bw(args, opts = {}) {
67
70
  const session = opts.session ?? process.env.BW_SESSION;
@@ -239,8 +242,8 @@ export function ensureSession() {
239
242
  }
240
243
  async function writePrivateFile(path, content) {
241
244
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
242
- const tmp = `${path}.tmp.${process.pid}`;
243
- 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" });
244
247
  await chmod(tmp, 0o600);
245
248
  await rename(tmp, path);
246
249
  await chmod(path, 0o600);
@@ -486,6 +489,8 @@ export async function envFileEntries(path) {
486
489
  return parseEnvFile(await readFile(path, "utf8")).map(({ name, value }) => {
487
490
  if (value.trim().startsWith("!"))
488
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`);
489
494
  return { name, raw: value };
490
495
  });
491
496
  }
@@ -540,6 +545,11 @@ export async function writeRedactIndex(values) {
540
545
  await writePrivateFile(REDACT_INDEX_FILE, `${JSON.stringify(index)}\n`);
541
546
  return index;
542
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
+ }
543
553
  export async function readRedactIndex() {
544
554
  try {
545
555
  const parsed = JSON.parse(await readFile(REDACT_INDEX_FILE, "utf8"));
@@ -574,7 +584,17 @@ export async function getSecret(itemName) {
574
584
  throw mapItemError(error, itemName);
575
585
  }
576
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
+ }
577
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);
578
598
  const existing = await findItems(opts.itemName);
579
599
  if (existing.some((item) => item.name.toLowerCase() === opts.itemName.toLowerCase()))
580
600
  throw new Error(`Vault item already exists: ${opts.itemName}`);
@@ -599,6 +619,7 @@ export async function createSecretItem(opts) {
599
619
  return { id: String(created.id) };
600
620
  }
601
621
  export async function rotateSecretItem(opts) {
622
+ await refuseMasterPassword(opts.secret);
602
623
  let parsed;
603
624
  try {
604
625
  parsed = JSON.parse(await bw(["get", "item", opts.itemName]));
@@ -621,7 +642,7 @@ export async function deleteItem(id, permanent = false) {
621
642
  invalidateSecretCache();
622
643
  }
623
644
  export async function readSecretFromInput(opts) {
624
- if (opts.stdin || !process.stdin.isTTY) {
645
+ if (opts.stdin) {
625
646
  let input = "";
626
647
  for await (const chunk of process.stdin)
627
648
  input += String(chunk);
@@ -630,13 +651,152 @@ export async function readSecretFromInput(opts) {
630
651
  throw new Error("Empty secret");
631
652
  return input;
632
653
  }
633
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
634
- rl._writeToOutput = () => { };
635
- process.stderr.write(opts.prompt);
636
- const value = await new Promise((resolve) => rl.question("", resolve));
637
- rl.close();
638
- process.stderr.write("\n");
654
+ if (!process.stdin.isTTY)
655
+ throw new Error("No terminal to prompt on; pipe the value with --stdin");
656
+ const value = await readMasked(opts.prompt);
639
657
  if (!value)
640
658
  throw new Error("Empty secret");
641
659
  return value;
642
660
  }
661
+ // Native hidden-input dialog for callers without a terminal (agent shells, MCP).
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;
669
+ export async function readSecretFromDialog(opts) {
670
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
671
+ return null;
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");
685
+ const candidates = process.platform === "darwin"
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]]]
689
+ : process.env.DISPLAY || process.env.WAYLAND_DISPLAY
690
+ ? [["zenity", ["--entry", "--hide-text", "--no-markup", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text.replace(/[<>]/g, "")]]]
691
+ : [];
692
+ if (!candidates.length)
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;
739
+ }
740
+ }
741
+ // Raw-mode prompt that echoes one "*" per character (capped, then a count), so
742
+ // the user sees typing and pastes land without the value ever reaching the screen.
743
+ function readMasked(prompt) {
744
+ const stdin = process.stdin;
745
+ const chars = [];
746
+ const draw = () => {
747
+ const shown = "*".repeat(Math.min(chars.length, 24)) + (chars.length > 24 ? ` (${chars.length} chars)` : "");
748
+ process.stderr.write(`\r\x1b[2K${prompt}${shown}`);
749
+ };
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
+ };
759
+ const finish = (error) => {
760
+ clearTimeout(ending);
761
+ stdin.off("data", onData);
762
+ stdin.setRawMode(false);
763
+ stdin.pause();
764
+ process.stderr.write("\n");
765
+ if (error)
766
+ reject(error);
767
+ else
768
+ resolve(chars.join(""));
769
+ };
770
+ const onData = (chunk) => {
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
+ }
783
+ if (ch === "\x03")
784
+ return finish(new Error("Cancelled"));
785
+ if (ch === "\x04")
786
+ return chars.length ? finish() : finish(new Error("Cancelled"));
787
+ if (ch === "\x7f" || ch === "\b")
788
+ chars.pop();
789
+ else if (ch === "\x15")
790
+ chars.length = 0;
791
+ else if (ch >= " ")
792
+ chars.push(ch);
793
+ }
794
+ draw();
795
+ };
796
+ stdin.setRawMode(true);
797
+ stdin.setEncoding("utf8");
798
+ stdin.resume();
799
+ stdin.on("data", onData);
800
+ draw();
801
+ });
802
+ }
package/dist/hooks.js CHANGED
@@ -22,8 +22,8 @@ async function sessionStart() {
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
  }
28
28
  catch (error) {
29
29
  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\`.`;
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 = [
@@ -33,7 +33,7 @@ const TOOLS = [
33
33
  },
34
34
  {
35
35
  name: "secrets_request",
36
- 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.",
36
+ 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.",
37
37
  inputSchema: {
38
38
  type: "object",
39
39
  required: ["env_name", "item_name"],
@@ -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,8 +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 purpose = typeof args.purpose === "string" && args.purpose ? ` --notes ${shellQuote(args.purpose)}` : "";
145
- 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}.`);
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;
155
+ try {
156
+ const reference = core.referenceFor(itemName);
157
+ await core.ensureSession();
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 });
161
+ if (secret === "")
162
+ return text("The user saved an empty value; nothing was stored.", true);
163
+ if (secret !== null) {
164
+ await core.createSecretItem({ itemName, secret, envName, notes });
165
+ await core.audit("request", [envName], "mcp");
166
+ const conflicts = [];
167
+ for (const path of Object.values(core.getAuthTargets()))
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);
173
+ const verified = (await core.resolveShellValue(reference)) !== null;
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}.`);
175
+ }
176
+ }
177
+ catch (error) {
178
+ const message = error instanceof Error ? error.message : String(error);
179
+ return text(message === "Cancelled" ? "The user cancelled the dialog; nothing was stored." : message, true);
180
+ }
181
+ const purpose = notes ? ` --notes ${shellQuote(notes)}` : "";
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}.`);
146
183
  }
147
184
  if (name === "vault_status") {
148
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,7 +10,23 @@ 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;
16
18
  }>;
19
+ export interface DotenvEntry {
20
+ line: number;
21
+ name: string;
22
+ value: string;
23
+ exported: boolean;
24
+ }
25
+ export declare function parseDotenvStrict(text: string): {
26
+ entries: DotenvEntry[];
27
+ errors: Array<{
28
+ line: number;
29
+ reason: string;
30
+ }>;
31
+ };
32
+ export declare function looksSecret(name: string, value: string): boolean;
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"
@@ -80,3 +95,80 @@ export function parseEnvFile(text) {
80
95
  });
81
96
  return out;
82
97
  }
98
+ // Stricter than parseEnvFile, for `import`: a misread value would be stored in
99
+ // the vault, so anything ambiguous is reported by line number (never by value)
100
+ // instead of guessed. Values must also read back unchanged through parseEnvFile.
101
+ export function parseDotenvStrict(text) {
102
+ const entries = [];
103
+ const errors = [];
104
+ const seen = new Set();
105
+ text.split(/\r?\n/).forEach((raw, index) => {
106
+ const line = index + 1;
107
+ const trimmed = raw.trim();
108
+ if (!trimmed || trimmed.startsWith("#"))
109
+ return;
110
+ const match = /^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(trimmed);
111
+ if (!match) {
112
+ errors.push({ line, reason: "not a NAME=value line" });
113
+ return;
114
+ }
115
+ const rest = match[3].trim();
116
+ let value;
117
+ if (rest.startsWith("'") || rest.startsWith('"')) {
118
+ const end = rest.indexOf(rest[0], 1);
119
+ if (end < 0) {
120
+ errors.push({ line, reason: "unterminated quote (multiline values are not supported)" });
121
+ return;
122
+ }
123
+ const after = rest.slice(end + 1).trim();
124
+ if (after && !/^#/.test(after)) {
125
+ errors.push({ line, reason: "text after the closing quote" });
126
+ return;
127
+ }
128
+ if (after && end + 1 === rest.indexOf("#", end)) {
129
+ errors.push({ line, reason: "comment must be separated from the closing quote by a space" });
130
+ return;
131
+ }
132
+ value = rest.slice(1, end);
133
+ if (rest[0] === '"' && /[\\$]/.test(value)) {
134
+ errors.push({ line, reason: "escape sequence or $ expansion inside double quotes" });
135
+ return;
136
+ }
137
+ }
138
+ else {
139
+ value = rest.replace(/\s+#.*$/, "");
140
+ if (value.includes("${")) {
141
+ errors.push({ line, reason: "${} expansion" });
142
+ return;
143
+ }
144
+ }
145
+ if (value.startsWith("!")) {
146
+ errors.push({ line, reason: "value starts with '!' (env files cannot run commands)" });
147
+ return;
148
+ }
149
+ if (seen.has(match[2])) {
150
+ errors.push({ line, reason: `duplicate key ${match[2]}` });
151
+ return;
152
+ }
153
+ seen.add(match[2]);
154
+ entries.push({ line, name: match[2], value, exported: Boolean(match[1]) });
155
+ });
156
+ return { entries, errors };
157
+ }
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;
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)/;
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.
164
+ export function looksSecret(name, value) {
165
+ if (!value || isRef(value))
166
+ return false;
167
+ if (SECRET_VALUE_RE.test(value) || URL_PASSWORD_RE.test(value))
168
+ return true;
169
+ if (/(?:^|_)PUBLIC(?:$|_)/i.test(name))
170
+ return false;
171
+ if (SECRET_NAME_RE.test(name) || /^[0-9a-f]{32,}$/i.test(value))
172
+ return true;
173
+ return value.length >= 24 && !/\s/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value) && /\d/.test(value);
174
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keyshroud",
3
- "version": "0.5.1",
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": [
@@ -11,12 +11,16 @@ in output with `<redacted:NAME>`.
11
11
 
12
12
  ## Rules
13
13
 
14
- 1. Never ask the user to paste a secret into the chat. To store one, give them
15
- this command to run in **their own terminal** (masked prompt):
14
+ 1. Never ask the user to paste a secret into the chat. To store one, run this
15
+ yourself; it opens a hidden-input dialog on the user's desktop, and you only
16
+ see `created … verified=yes`:
16
17
  ```bash
17
18
  keyshroud add --env STRIPE_API_KEY --item "Stripe API key" --notes "billing service"
18
19
  ```
19
- With the MCP server, call `secrets_request` instead; it returns the same command.
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:
23
+ then give the user the command to run in their own terminal.
20
24
  2. Never print, echo, `cat`, base64, or otherwise transform a secret value.
21
25
  `<redacted:NAME>` in output means the secret was used correctly. Do not try
22
26
  to recover it.