keyshroud 0.5.1 → 0.5.2

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
@@ -3,6 +3,25 @@
3
3
  All notable changes are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [Unreleased]
7
+
8
+ ## [0.5.2] - 2026-10-15
9
+
10
+ ### Added
11
+ - `keyshroud import`: moves the secrets in a `.env` into the vault and writes
12
+ `.env.refs`. Dry run with names only by default, `--yes` to write; values are
13
+ verified after storing, never printed, and `--replace` rewrites the source
14
+ atomically without a backup.
15
+
16
+ - `add`/`rotate` without a terminal, and the `secrets_request` MCP tool, open a
17
+ native hidden-input dialog (macOS `osascript`, Linux `zenity`/`kdialog`), so
18
+ an agent can start the command and the user only pastes. `--dialog` forces it.
19
+
20
+ ### Changed
21
+ - The `add` and `rotate` prompts echo `*` per character (a count after 24) so
22
+ typing and pastes are visible without showing the value; Backspace, Ctrl-U
23
+ and Ctrl-C work as in a password prompt.
24
+
6
25
  ## [0.5.1] - 2026-10-14
7
26
 
8
27
  ### Added
package/README.md CHANGED
@@ -19,7 +19,7 @@ secrets stay in your own self-hosted [Vaultwarden](https://github.com/dani-garci
19
19
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
20
20
  [![Node](https://img.shields.io/node/v/keyshroud.svg)](package.json)
21
21
 
22
- [🚀 Quickstart](#quickstart) · [🔌 Supported agents](#supported-agents) · [⚙️ How it works](#how-it-works) · [❓ FAQ](#faq) · [🛡️ Security model](#security-model) · [🌐 Website](https://keyshroud.dev)
22
+ [🚀 Quickstart](#quickstart) · [🔐 Vault setup](examples/vaultwarden#readme) · [🔌 Supported agents](#supported-agents) · [⚙️ How it works](#how-it-works) · [❓ FAQ](#faq) · [🛡️ Security model](#security-model) · [🌐 Website](https://keyshroud.dev)
23
23
 
24
24
  [🇻🇳 Tiếng Việt](i18n/README.vi.md) · [🇨🇳 简体中文](i18n/README.zh-CN.md) · [🇯🇵 日本語](i18n/README.ja-JP.md)
25
25
 
@@ -82,9 +82,9 @@ webhooks, databases. The usual options are bad:
82
82
 
83
83
  ## Use cases
84
84
 
85
- **"Claude Code keeps reading my `.env`."** Move the values into the vault, commit a
86
- `.env.refs` file with `GH_TOKEN=bw://GitHub CLI token`, and start the agent with
87
- `keyshroud run -- claude`. The plugin's hooks mask anything that still slips into a tool result.
85
+ **"Claude Code keeps reading my `.env`."** Run `keyshroud import` to move the values into
86
+ the vault and write a `.env.refs` file with lines like `GH_TOKEN=bw://myapp GH_TOKEN`, then start
87
+ the agent with `keyshroud run --env-file .env.refs -- claude`. The plugin's hooks mask anything that still slips into a tool result.
88
88
 
89
89
  **"I already self-host Vaultwarden."** Point `bw` at it, run `keyshroud doctor`, and you
90
90
  are done — keyshroud adds no server of its own.
@@ -92,18 +92,24 @@ are done — keyshroud adds no server of its own.
92
92
  **"I like `op run` but don't want 1Password."** `keyshroud run` is the same idea —
93
93
  references in, masked output out — on a vault you host.
94
94
 
95
- **"My agent needs to add a new key."** It calls `secrets_request` (MCP) or `/secret` (pi);
96
- you type the value into a masked prompt in your own terminal. It never enters the chat.
95
+ **"My agent needs to add a new key."** It runs `keyshroud add` or calls `secrets_request` (MCP),
96
+ and a hidden-input dialog pops up on your desktop; in pi it is `/secret`. You paste the value
97
+ there. It never enters the chat.
97
98
 
98
99
  ## Quickstart
99
100
 
100
- **Requirements:** Node.js ≥ 22.18, the Bitwarden CLI (`npm i -g @bitwarden/cli`),
101
- and a Vaultwarden or Bitwarden account. No server yet?
102
- [examples/vaultwarden](examples/vaultwarden) runs Vaultwarden behind Caddy
103
- (automatic HTTPS) with one `docker compose up -d`.
101
+ **Requirements:** Node.js ≥ 22.18, the Bitwarden CLI, and a vault.
102
+
103
+ **0. Get a vault** — pick one:
104
+
105
+ - **Self-host Vaultwarden** (free, your server): follow
106
+ [examples/vaultwarden](examples/vaultwarden#readme) — Docker Compose + Caddy with automatic HTTPS,
107
+ from an empty VPS to `keyshroud doctor`, plus backups and hardening.
108
+ - **Bitwarden cloud** (free account, no server): sign up at [bitwarden.com](https://bitwarden.com)
109
+ and skip `bw config server` below (EU accounts: `bw config server https://vault.bitwarden.eu`).
104
110
 
105
111
  ```bash
106
- npm install -g keyshroud
112
+ npm install -g keyshroud @bitwarden/cli # or: brew install bitwarden-cli
107
113
 
108
114
  # 1. Point bw at your server and log in once
109
115
  bw config server https://vault.example.com
@@ -126,7 +132,8 @@ keyshroud run -- claude # start an agent with every wired secret availa
126
132
 
127
133
  Optional: put `vaultwarden-api-client-id` / `vaultwarden-api-client-secret` (from
128
134
  *Settings → Security → Keys → API key*) in the keychain too, and keyshroud will
129
- also re-login automatically when the `bw` session expires.
135
+ also re-login automatically when the `bw` session expires. Linux keyring commands
136
+ and a headless-server option are in the [vault setup guide](examples/vaultwarden#4-let-keyshroud-unlock-without-prompting).
130
137
 
131
138
  ## How it works
132
139
 
@@ -250,7 +257,7 @@ OpenCode: `opencode mcp add keyshroud -- keyshroud mcp`.
250
257
  |---|---|---|
251
258
  | `secrets_list` | Lists wired names (+ optional `env_file`) | resolved / unresolved names |
252
259
  | `secrets_run` | Runs a shell command with secrets injected (`only`, `env_file`, `cwd`, `timeout_ms`) | exit code + masked stdout/stderr |
253
- | `secrets_request` | Asks the human to store a new secret | a `keyshroud add …` command for *their* terminal |
260
+ | `secrets_request` | Opens a hidden-input dialog on the user's desktop and stores the value | `created … verified=yes`, or a `keyshroud add …` command for *their* terminal when no dialog can be shown |
254
261
  | `vault_status` | Vault lock state, server, item count | status line |
255
262
 
256
263
  ## CLI reference
@@ -262,8 +269,9 @@ keyshroud inject [-i template] [-o out] [--force]
262
269
  keyshroud redact < log.txt
263
270
  keyshroud scan [paths...]
264
271
  keyshroud env [--shell]
265
- keyshroud add --env NAME --item ITEM [--notes TEXT] [--target pi|omp|both] [--stdin]
266
- keyshroud rotate --item ITEM [--stdin]
272
+ keyshroud add --env NAME --item ITEM [--notes TEXT] [--target pi|omp|both] [--stdin|--dialog]
273
+ keyshroud import [--from F|-] [--out F] [--prefix P] [--only A,B] [--skip A,B] [--yes] [--replace] [--force]
274
+ keyshroud rotate --item ITEM [--stdin|--dialog]
267
275
  keyshroud wire --env NAME --item ITEM [--target pi|omp|both] [--force]
268
276
  keyshroud unwire --env NAME [--target pi|omp|both]
269
277
  keyshroud status | unlock | lock | doctor
@@ -278,6 +286,27 @@ keyshroud link-cli [--dir DIR] [--force]
278
286
  passes the TTY through untouched for interactive use. Force either way with
279
287
  `--mask` / `--no-mask`. `exec` is an alias of `run`.
280
288
 
289
+ `add` and `rotate` read the value from a masked prompt that shows `*` per
290
+ character. Without a terminal (an agent's shell), they open a native hidden-input
291
+ dialog instead (`osascript` on macOS, `zenity` or `kdialog` on Linux), so the agent
292
+ can start the command and you only paste. Over SSH or without a display, pipe the
293
+ value with `--stdin`.
294
+
295
+ `import` reads a `.env` (default `./.env`), decides which lines are secrets (by name, such as
296
+ `*_TOKEN` or `*_KEY`, or by value, such as `sk-…` or a URL with a password), and prints a plan of
297
+ names only. Nothing is written until you add `--yes`: it then creates one vault item per secret,
298
+ named `<prefix> NAME` (prefix defaults to the directory name), reads each value back to verify it,
299
+ and writes `.env.refs` with comments and non-secret lines kept. Re-running reuses items that already
300
+ hold the same value and stops on a conflicting one. `--replace` rewrites the source file in place
301
+ instead (atomically, no backup copy). Multiline values, `${VAR}` expansion and duplicate keys are
302
+ rejected with the line number, never the value.
303
+
304
+ ```bash
305
+ keyshroud import # dry run: which names would move
306
+ keyshroud import --yes # store them and write .env.refs
307
+ keyshroud run --env-file .env.refs -- npm test
308
+ ```
309
+
281
310
  **Exit codes:** `0` ok · `2` usage · `3` vault locked or unavailable · `4` secret
282
311
  missing or item name ambiguous · `5` refusing to overwrite existing state.
283
312
  `run` exits with the child's code.
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
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
7
  import process from "node:process";
8
8
  import { BRAND } from "./brand.js";
@@ -10,7 +10,7 @@ import * as core from "./core.js";
10
10
  import { runClaudeHook } from "./hooks.js";
11
11
  import { serveMcp } from "./mcp.js";
12
12
  import { buildRedactIndex, compileRedactIndex, createRedactor, createRedactStream } from "./redact.js";
13
- import { ENV_NAME_RE, parseRef } from "./refs.js";
13
+ import { ENV_NAME_RE, isRef, looksSecret, parseDotenvStrict, parseRef } from "./refs.js";
14
14
  const usage = `Usage: ${BRAND.bin} <command> [options]
15
15
 
16
16
  Secrets
@@ -21,8 +21,11 @@ Secrets
21
21
  redact Copy stdin to stdout with vault secrets masked
22
22
  scan [paths...] Find vault secret values in files (exit 1 on leaks)
23
23
  env [--shell] List wired names (or print shell exports)
24
- add --env NAME --item ITEM [--notes TEXT] [--target pi|omp|both] [--stdin]
25
- rotate --item ITEM [--stdin]
24
+ add --env NAME --item ITEM [--notes TEXT] [--target pi|omp|both] [--stdin|--dialog]
25
+ import [--from F|-] [--out F] [--prefix P] [--only A,B] [--skip A,B] [--yes] [--replace] [--force]
26
+ Move secrets from a .env into the vault and write .env.refs
27
+ (dry run unless --yes; prints names only)
28
+ rotate --item ITEM [--stdin|--dialog]
26
29
  wire --env NAME --item ITEM [--target pi|omp|both] [--force]
27
30
  unwire --env NAME [--target pi|omp|both]
28
31
 
@@ -60,6 +63,21 @@ function targetList(value) {
60
63
  return [targetPaths.pi];
61
64
  fail("--target must be omp, pi, or both", 2);
62
65
  }
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") });
80
+ }
63
81
  function shellQuote(value) { return `'${value.replaceAll("'", "'\\''")}'`; }
64
82
  function failureCode(error) {
65
83
  const text = error instanceof Error ? error.message : String(error);
@@ -216,6 +234,128 @@ async function scan(paths) {
216
234
  if (leaks)
217
235
  process.exitCode = 1;
218
236
  }
237
+ async function importCommand(args) {
238
+ requireKnownFlags(args, ["--from", "--out", "--prefix", "--only", "--skip"], ["--yes", "--replace", "--force"]);
239
+ const from = valueAfter(args, "--from") ?? ".env";
240
+ const yes = args.includes("--yes");
241
+ const replace = args.includes("--replace");
242
+ if (replace && (from === "-" || valueAfter(args, "--out")))
243
+ fail("--replace rewrites the --from file; it cannot be combined with stdin or --out", 2);
244
+ if (from === "-" && process.stdin.isTTY)
245
+ fail("--from - needs the .env piped on stdin", 2);
246
+ const baseDir = from === "-" ? process.cwd() : dirname(resolve(from));
247
+ const out = replace ? resolve(from) : resolve(valueAfter(args, "--out") ?? join(baseDir, ".env.refs"));
248
+ const prefix = valueAfter(args, "--prefix") ?? basename(baseDir);
249
+ if (!/^[A-Za-z0-9._-]+$/.test(prefix))
250
+ fail("--prefix may contain only letters, digits, '.', '_' and '-' (set it with --prefix)", 2);
251
+ const list = (flag) => (valueAfter(args, flag) ?? "").split(",").map((name) => name.trim()).filter(Boolean);
252
+ const only = list("--only");
253
+ const skip = list("--skip");
254
+ let text;
255
+ try {
256
+ text = from === "-" ? await readStdin() : await readFile(from, "utf8");
257
+ }
258
+ catch {
259
+ fail(`Cannot read ${from}`, 2);
260
+ }
261
+ const { entries, errors } = parseDotenvStrict(text);
262
+ if (errors.length) {
263
+ for (const error of errors)
264
+ console.error(`${from}:${error.line} ${error.reason}`);
265
+ fail(`${errors.length} line(s) cannot be imported safely; fix them or move them out of the file`, 2);
266
+ }
267
+ for (const name of [...only, ...skip])
268
+ if (!entries.some((entry) => entry.name === name))
269
+ fail(`--only/--skip name not in ${from}: ${name}`, 2);
270
+ await core.ensureSession();
271
+ const existing = await core.findItems(prefix);
272
+ const rows = [];
273
+ for (const entry of entries) {
274
+ if (isRef(entry.value)) {
275
+ rows.push({ entry, kind: "ref" });
276
+ continue;
277
+ }
278
+ const secret = (only.length ? only.includes(entry.name) : looksSecret(entry.name, entry.value)) && !skip.includes(entry.name);
279
+ if (!secret) {
280
+ rows.push({ entry, kind: "literal" });
281
+ continue;
282
+ }
283
+ const item = `${prefix} ${entry.name}`;
284
+ const matches = existing.filter((candidate) => candidate.name.toLowerCase() === item.toLowerCase());
285
+ if (matches.length > 1) {
286
+ rows.push({ entry, item, kind: "conflict", note: "more than one vault item has this name" });
287
+ continue;
288
+ }
289
+ if (matches.length === 0) {
290
+ rows.push({ entry, item, kind: "new" });
291
+ continue;
292
+ }
293
+ const current = await core.resolveRef({ item: matches[0].id, field: "password" });
294
+ rows.push(current === entry.value
295
+ ? { entry, item, kind: "reuse", note: "same value already in the vault" }
296
+ : { entry, item, kind: "conflict", note: "vault item exists with a different value" });
297
+ }
298
+ const width = Math.max(4, ...rows.map((row) => row.entry.name.length));
299
+ console.log(`import plan for ${from} (item prefix "${prefix}")`);
300
+ for (const row of rows) {
301
+ const label = row.kind === "literal" ? "literal kept" : row.kind === "ref" ? "reference kept"
302
+ : row.kind === "conflict" ? `secret conflict: ${row.note} ("${row.item}")`
303
+ : `secret ${row.kind.padEnd(8)} -> bw://${row.item}${row.note ? ` (${row.note})` : ""}`;
304
+ const short = row.item && row.entry.value.length < 6 ? " [under 6 characters: stored, but too short to be masked]" : "";
305
+ console.log(` ${row.entry.name.padEnd(width)} ${label}${short}`);
306
+ }
307
+ const count = (kind) => rows.filter((row) => row.kind === kind).length;
308
+ const toStore = rows.filter((row) => row.kind === "new" || row.kind === "reuse");
309
+ 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)`);
310
+ if (count("conflict"))
311
+ fail("Resolve the conflicts (choose another --prefix, or rename the vault items) before importing", 5);
312
+ if (!toStore.length) {
313
+ console.log("Nothing to import.");
314
+ return;
315
+ }
316
+ const outExists = await stat(out).then(() => true, () => false);
317
+ if (outExists && !replace && !args.includes("--force"))
318
+ fail(`${out} already exists; use --force to overwrite it`, 5);
319
+ if (!yes) {
320
+ console.log(`Dry run: nothing written. Re-run with --yes to create ${count("new")} item(s) and write ${out}`);
321
+ return;
322
+ }
323
+ const created = [];
324
+ for (const row of rows) {
325
+ if (row.kind !== "new")
326
+ continue;
327
+ try {
328
+ await core.createSecretItem({ itemName: row.item, secret: row.entry.value, envName: row.entry.name, notes: `Imported by ${BRAND.bin} from ${basename(from === "-" ? "stdin" : from)}` });
329
+ }
330
+ catch {
331
+ if (created.length)
332
+ console.error(`Created before the failure: ${created.join(", ")}`);
333
+ fail(`Failed to create the vault item for ${row.entry.name}; nothing was written to ${out}`, 3);
334
+ }
335
+ created.push(row.entry.name);
336
+ }
337
+ for (const row of toStore) {
338
+ let value = null;
339
+ try {
340
+ value = await core.resolveRef({ item: row.item, field: "password" });
341
+ }
342
+ catch { }
343
+ if (value !== row.entry.value)
344
+ fail(`Verification failed for ${row.entry.name}: the vault does not return the imported value; nothing was written to ${out}`, 4);
345
+ }
346
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
347
+ const lines = text.split(/\r?\n/);
348
+ for (const row of toStore)
349
+ 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 });
353
+ await rename(tmp, out);
354
+ await core.audit("import", toStore.map((row) => row.entry.name), "cli");
355
+ console.log(`imported ${toStore.length} secret(s) (${created.length} new); wrote ${out}`);
356
+ if (!replace && from !== "-")
357
+ 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.`);
358
+ }
219
359
  async function main() {
220
360
  const args = process.argv.slice(2);
221
361
  const command = args.shift();
@@ -320,7 +460,7 @@ async function main() {
320
460
  return;
321
461
  }
322
462
  if (command === "add") {
323
- requireKnownFlags(args, ["--env", "--item", "--notes", "--target"], ["--stdin"]);
463
+ requireKnownFlags(args, ["--env", "--item", "--notes", "--target"], ["--stdin", "--dialog"]);
324
464
  const envName = valueAfter(args, "--env");
325
465
  const itemName = valueAfter(args, "--item");
326
466
  const notes = valueAfter(args, "--notes");
@@ -329,7 +469,7 @@ async function main() {
329
469
  const reference = core.referenceFor(itemName);
330
470
  const targets = targetList(valueAfter(args, "--target"));
331
471
  await core.ensureSession();
332
- const secret = await core.readSecretFromInput({ prompt: `Paste secret for ${envName}: `, stdin: args.includes("--stdin") });
472
+ const secret = await readSecret(args, `Paste secret for ${envName}: `, envName, itemName);
333
473
  await core.createSecretItem({ itemName, secret, envName, notes });
334
474
  let conflict = false;
335
475
  for (const path of targets) {
@@ -345,13 +485,17 @@ async function main() {
345
485
  }
346
486
  return;
347
487
  }
488
+ if (command === "import") {
489
+ await importCommand(args);
490
+ return;
491
+ }
348
492
  if (command === "rotate") {
349
- requireKnownFlags(args, ["--item"], ["--stdin"]);
493
+ requireKnownFlags(args, ["--item"], ["--stdin", "--dialog"]);
350
494
  const itemName = valueAfter(args, "--item");
351
495
  if (!itemName)
352
496
  fail(usage, 2);
353
497
  await core.ensureSession();
354
- const secret = await core.readSecretFromInput({ prompt: "Paste replacement secret: ", stdin: args.includes("--stdin") });
498
+ const secret = await readSecret(args, "Paste replacement secret: ", `the new value of ${itemName}`, itemName);
355
499
  await core.rotateSecretItem({ itemName, secret });
356
500
  console.log(`rotated item=${itemName}`);
357
501
  return;
package/dist/core.d.ts CHANGED
@@ -103,3 +103,7 @@ export declare function readSecretFromInput(opts: {
103
103
  prompt: string;
104
104
  stdin: boolean;
105
105
  }): Promise<string>;
106
+ export declare function readSecretFromDialog(opts: {
107
+ label: string;
108
+ itemName: string;
109
+ }): Promise<string | null>;
package/dist/core.js CHANGED
@@ -5,7 +5,6 @@ import { appendFile, chmod, mkdir, readFile, realpath, rename, rm, stat, unlink,
5
5
  import { homedir } from "node:os";
6
6
  import { join, dirname } from "node:path";
7
7
  import process from "node:process";
8
- import * as readline from "node:readline";
9
8
  import { BRAND } from "./brand.js";
10
9
  import { buildRedactIndex } from "./redact.js";
11
10
  import { ENV_NAME_RE, legacyRefFor, parseEnvFile, parseRef } from "./refs.js";
@@ -630,13 +629,83 @@ export async function readSecretFromInput(opts) {
630
629
  throw new Error("Empty secret");
631
630
  return input;
632
631
  }
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");
632
+ const value = await readMasked(opts.prompt);
639
633
  if (!value)
640
634
  throw new Error("Empty secret");
641
635
  return value;
642
636
  }
637
+ // 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).
641
+ export async function readSecretFromDialog(opts) {
642
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
643
+ return null;
644
+ const text = `Paste the secret for ${opts.label}\nVault item: ${opts.itemName}`;
645
+ 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")]]]
651
+ : process.env.DISPLAY || process.env.WAYLAND_DISPLAY
652
+ ? [["zenity", ["--entry", "--hide-text", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text]]]
653
+ : [];
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");
664
+ return null;
665
+ }
666
+ return null;
667
+ }
668
+ // Raw-mode prompt that echoes one "*" per character (capped, then a count), so
669
+ // the user sees typing and pastes land without the value ever reaching the screen.
670
+ function readMasked(prompt) {
671
+ const stdin = process.stdin;
672
+ const chars = [];
673
+ const draw = () => {
674
+ const shown = "*".repeat(Math.min(chars.length, 24)) + (chars.length > 24 ? ` (${chars.length} chars)` : "");
675
+ process.stderr.write(`\r\x1b[2K${prompt}${shown}`);
676
+ };
677
+ return new Promise((resolve, reject) => {
678
+ const finish = (error) => {
679
+ stdin.off("data", onData);
680
+ stdin.setRawMode(false);
681
+ stdin.pause();
682
+ process.stderr.write("\n");
683
+ if (error)
684
+ reject(error);
685
+ else
686
+ resolve(chars.join(""));
687
+ };
688
+ const onData = (chunk) => {
689
+ for (const ch of chunk.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b./g, "")) {
690
+ if (ch === "\r" || ch === "\n")
691
+ return finish();
692
+ if (ch === "\x03")
693
+ return finish(new Error("Cancelled"));
694
+ if (ch === "\x04")
695
+ return chars.length ? finish() : finish(new Error("Cancelled"));
696
+ if (ch === "\x7f" || ch === "\b")
697
+ chars.pop();
698
+ else if (ch === "\x15")
699
+ chars.length = 0;
700
+ else if (ch >= " ")
701
+ chars.push(ch);
702
+ }
703
+ draw();
704
+ };
705
+ stdin.setRawMode(true);
706
+ stdin.setEncoding("utf8");
707
+ stdin.resume();
708
+ stdin.on("data", onData);
709
+ draw();
710
+ });
711
+ }
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
@@ -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"],
@@ -141,7 +141,29 @@ async function callTool(name, args) {
141
141
  const itemName = String(args.item_name ?? "");
142
142
  if (!ENV_NAME_RE.test(envName) || !itemName)
143
143
  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)}` : "";
144
+ const notes = typeof args.purpose === "string" && args.purpose ? args.purpose : undefined;
145
+ try {
146
+ await core.ensureSession();
147
+ const secret = await core.readSecretFromDialog({ label: envName, itemName });
148
+ if (secret === "")
149
+ return text("The user saved an empty value; nothing was stored.", true);
150
+ if (secret !== null) {
151
+ await core.createSecretItem({ itemName, secret, envName, notes });
152
+ const reference = core.referenceFor(itemName);
153
+ for (const path of Object.values(core.getAuthTargets()))
154
+ await core.writeAuthEntry(path, envName, reference);
155
+ const verified = (await core.resolveShellValue(reference)) !== null;
156
+ 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
+ }
158
+ }
159
+ catch (error) {
160
+ 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);
165
+ }
166
+ const purpose = notes ? ` --notes ${shellQuote(notes)}` : "";
145
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}.`);
146
168
  }
147
169
  if (name === "vault_status") {
package/dist/refs.d.ts CHANGED
@@ -14,3 +14,17 @@ export declare function parseEnvFile(text: string): Array<{
14
14
  name: string;
15
15
  value: string;
16
16
  }>;
17
+ export interface DotenvEntry {
18
+ line: number;
19
+ name: string;
20
+ value: string;
21
+ exported: boolean;
22
+ }
23
+ export declare function parseDotenvStrict(text: string): {
24
+ entries: DotenvEntry[];
25
+ errors: Array<{
26
+ line: number;
27
+ reason: string;
28
+ }>;
29
+ };
30
+ export declare function looksSecret(name: string, value: string): boolean;
package/dist/refs.js CHANGED
@@ -80,3 +80,75 @@ export function parseEnvFile(text) {
80
80
  });
81
81
  return out;
82
82
  }
83
+ // Stricter than parseEnvFile, for `import`: a misread value would be stored in
84
+ // the vault, so anything ambiguous is reported by line number (never by value)
85
+ // instead of guessed. Values must also read back unchanged through parseEnvFile.
86
+ export function parseDotenvStrict(text) {
87
+ const entries = [];
88
+ const errors = [];
89
+ const seen = new Set();
90
+ text.split(/\r?\n/).forEach((raw, index) => {
91
+ const line = index + 1;
92
+ const trimmed = raw.trim();
93
+ if (!trimmed || trimmed.startsWith("#"))
94
+ return;
95
+ const match = /^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(trimmed);
96
+ if (!match) {
97
+ errors.push({ line, reason: "not a NAME=value line" });
98
+ return;
99
+ }
100
+ const rest = match[3].trim();
101
+ let value;
102
+ if (rest.startsWith("'") || rest.startsWith('"')) {
103
+ const end = rest.indexOf(rest[0], 1);
104
+ if (end < 0) {
105
+ errors.push({ line, reason: "unterminated quote (multiline values are not supported)" });
106
+ return;
107
+ }
108
+ const after = rest.slice(end + 1).trim();
109
+ if (after && !/^#/.test(after)) {
110
+ errors.push({ line, reason: "text after the closing quote" });
111
+ return;
112
+ }
113
+ if (after && end + 1 === rest.indexOf("#", end)) {
114
+ errors.push({ line, reason: "comment must be separated from the closing quote by a space" });
115
+ return;
116
+ }
117
+ value = rest.slice(1, end);
118
+ if (rest[0] === '"' && /[\\$]/.test(value)) {
119
+ errors.push({ line, reason: "escape sequence or $ expansion inside double quotes" });
120
+ return;
121
+ }
122
+ }
123
+ else {
124
+ value = rest.replace(/\s+#.*$/, "");
125
+ if (value.includes("${")) {
126
+ errors.push({ line, reason: "${} expansion" });
127
+ return;
128
+ }
129
+ }
130
+ if (value.startsWith("!")) {
131
+ errors.push({ line, reason: "value starts with '!' (env files cannot run commands)" });
132
+ return;
133
+ }
134
+ if (seen.has(match[2])) {
135
+ errors.push({ line, reason: `duplicate key ${match[2]}` });
136
+ return;
137
+ }
138
+ seen.add(match[2]);
139
+ entries.push({ line, name: match[2], value, exported: Boolean(match[1]) });
140
+ });
141
+ return { entries, errors };
142
+ }
143
+ const SECRET_NAME_RE = /(SECRET|TOKEN|PASSW(?:OR)?D|PASSPHRASE|PWD|(?:^|_)KEY(?:$|_)|APIKEY|PRIVATE|CREDENTIAL|AUTH|DSN|WEBHOOK|COOKIE|SIGNING|ENCRYPT|SALT)/i;
144
+ 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.
148
+ export function looksSecret(name, value) {
149
+ if (!value || isRef(value) || /PUBLIC/i.test(name))
150
+ return false;
151
+ if (SECRET_VALUE_RE.test(value) || URL_PASSWORD_RE.test(value) || SECRET_NAME_RE.test(name))
152
+ return true;
153
+ return value.length >= 24 && !/\s/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value) && /\d/.test(value);
154
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keyshroud",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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,15 @@ 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. With the MCP server, call
21
+ `secrets_request` instead. Over SSH or without a display there is no dialog:
22
+ then give the user the command to run in their own terminal.
20
23
  2. Never print, echo, `cat`, base64, or otherwise transform a secret value.
21
24
  `<redacted:NAME>` in output means the secret was used correctly. Do not try
22
25
  to recover it.
package/src/cli.ts CHANGED
@@ -1,8 +1,8 @@
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
7
  import process from "node:process";
8
8
  import { BRAND } from "./brand.ts";
@@ -10,7 +10,7 @@ import * as core from "./core.ts";
10
10
  import { runClaudeHook } from "./hooks.ts";
11
11
  import { serveMcp } from "./mcp.ts";
12
12
  import { buildRedactIndex, compileRedactIndex, createRedactor, createRedactStream, type RedactIndex } from "./redact.ts";
13
- import { ENV_NAME_RE, parseRef } from "./refs.ts";
13
+ import { ENV_NAME_RE, isRef, looksSecret, parseDotenvStrict, parseRef, type DotenvEntry } from "./refs.ts";
14
14
 
15
15
  const usage = `Usage: ${BRAND.bin} <command> [options]
16
16
 
@@ -22,8 +22,11 @@ Secrets
22
22
  redact Copy stdin to stdout with vault secrets masked
23
23
  scan [paths...] Find vault secret values in files (exit 1 on leaks)
24
24
  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]
25
+ add --env NAME --item ITEM [--notes TEXT] [--target pi|omp|both] [--stdin|--dialog]
26
+ import [--from F|-] [--out F] [--prefix P] [--only A,B] [--skip A,B] [--yes] [--replace] [--force]
27
+ Move secrets from a .env into the vault and write .env.refs
28
+ (dry run unless --yes; prints names only)
29
+ rotate --item ITEM [--stdin|--dialog]
27
30
  wire --env NAME --item ITEM [--target pi|omp|both] [--force]
28
31
  unwire --env NAME [--target pi|omp|both]
29
32
 
@@ -53,6 +56,18 @@ function targetList(value: string | undefined): string[] {
53
56
  if (value === "pi") return [targetPaths.pi];
54
57
  fail("--target must be omp, pi, or both", 2);
55
58
  }
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") });
70
+ }
56
71
  function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; }
57
72
  function failureCode(error: unknown): number {
58
73
  const text = error instanceof Error ? error.message : String(error);
@@ -171,6 +186,90 @@ async function scan(paths: string[]): Promise<void> {
171
186
  if (leaks) process.exitCode = 1;
172
187
  }
173
188
 
189
+ interface ImportRow { entry: DotenvEntry; kind: "new" | "reuse" | "conflict" | "literal" | "ref"; item?: string; note?: string }
190
+
191
+ async function importCommand(args: string[]): Promise<void> {
192
+ requireKnownFlags(args, ["--from", "--out", "--prefix", "--only", "--skip"], ["--yes", "--replace", "--force"]);
193
+ const from = valueAfter(args, "--from") ?? ".env";
194
+ const yes = args.includes("--yes"); const replace = args.includes("--replace");
195
+ if (replace && (from === "-" || valueAfter(args, "--out"))) fail("--replace rewrites the --from file; it cannot be combined with stdin or --out", 2);
196
+ if (from === "-" && process.stdin.isTTY) fail("--from - needs the .env piped on stdin", 2);
197
+ const baseDir = from === "-" ? process.cwd() : dirname(resolve(from));
198
+ const out = replace ? resolve(from) : resolve(valueAfter(args, "--out") ?? join(baseDir, ".env.refs"));
199
+ const prefix = valueAfter(args, "--prefix") ?? basename(baseDir);
200
+ if (!/^[A-Za-z0-9._-]+$/.test(prefix)) fail("--prefix may contain only letters, digits, '.', '_' and '-' (set it with --prefix)", 2);
201
+ const list = (flag: string) => (valueAfter(args, flag) ?? "").split(",").map((name) => name.trim()).filter(Boolean);
202
+ const only = list("--only"); const skip = list("--skip");
203
+
204
+ let text: string;
205
+ try { text = from === "-" ? await readStdin() : await readFile(from, "utf8"); } catch { fail(`Cannot read ${from}`, 2); }
206
+ const { entries, errors } = parseDotenvStrict(text);
207
+ if (errors.length) {
208
+ for (const error of errors) console.error(`${from}:${error.line} ${error.reason}`);
209
+ fail(`${errors.length} line(s) cannot be imported safely; fix them or move them out of the file`, 2);
210
+ }
211
+ for (const name of [...only, ...skip]) if (!entries.some((entry) => entry.name === name)) fail(`--only/--skip name not in ${from}: ${name}`, 2);
212
+
213
+ await core.ensureSession();
214
+ const existing = await core.findItems(prefix);
215
+ const rows: ImportRow[] = [];
216
+ for (const entry of entries) {
217
+ if (isRef(entry.value)) { rows.push({ entry, kind: "ref" }); continue; }
218
+ const secret = (only.length ? only.includes(entry.name) : looksSecret(entry.name, entry.value)) && !skip.includes(entry.name);
219
+ if (!secret) { rows.push({ entry, kind: "literal" }); continue; }
220
+ const item = `${prefix} ${entry.name}`;
221
+ const matches = existing.filter((candidate) => candidate.name.toLowerCase() === item.toLowerCase());
222
+ if (matches.length > 1) { rows.push({ entry, item, kind: "conflict", note: "more than one vault item has this name" }); continue; }
223
+ if (matches.length === 0) { rows.push({ entry, item, kind: "new" }); continue; }
224
+ const current = await core.resolveRef({ item: matches[0].id, field: "password" });
225
+ rows.push(current === entry.value
226
+ ? { entry, item, kind: "reuse", note: "same value already in the vault" }
227
+ : { entry, item, kind: "conflict", note: "vault item exists with a different value" });
228
+ }
229
+
230
+ const width = Math.max(4, ...rows.map((row) => row.entry.name.length));
231
+ console.log(`import plan for ${from} (item prefix "${prefix}")`);
232
+ for (const row of rows) {
233
+ const label = row.kind === "literal" ? "literal kept" : row.kind === "ref" ? "reference kept"
234
+ : row.kind === "conflict" ? `secret conflict: ${row.note} ("${row.item}")`
235
+ : `secret ${row.kind.padEnd(8)} -> bw://${row.item}${row.note ? ` (${row.note})` : ""}`;
236
+ const short = row.item && row.entry.value.length < 6 ? " [under 6 characters: stored, but too short to be masked]" : "";
237
+ console.log(` ${row.entry.name.padEnd(width)} ${label}${short}`);
238
+ }
239
+ const count = (kind: ImportRow["kind"]) => rows.filter((row) => row.kind === kind).length;
240
+ const toStore = rows.filter((row) => row.kind === "new" || row.kind === "reuse");
241
+ 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)`);
242
+ if (count("conflict")) fail("Resolve the conflicts (choose another --prefix, or rename the vault items) before importing", 5);
243
+ if (!toStore.length) { console.log("Nothing to import."); return; }
244
+ const outExists = await stat(out).then(() => true, () => false);
245
+ if (outExists && !replace && !args.includes("--force")) fail(`${out} already exists; use --force to overwrite it`, 5);
246
+ if (!yes) { console.log(`Dry run: nothing written. Re-run with --yes to create ${count("new")} item(s) and write ${out}`); return; }
247
+
248
+ const created: string[] = [];
249
+ for (const row of rows) {
250
+ if (row.kind !== "new") continue;
251
+ 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)}` }); }
252
+ 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); }
253
+ created.push(row.entry.name);
254
+ }
255
+ for (const row of toStore) {
256
+ let value: string | null = null;
257
+ try { value = await core.resolveRef({ item: row.item!, field: "password" }); } catch {}
258
+ 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);
259
+ }
260
+
261
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
262
+ const lines = text.split(/\r?\n/);
263
+ 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 });
267
+ await rename(tmp, out);
268
+ await core.audit("import", toStore.map((row) => row.entry.name), "cli");
269
+ console.log(`imported ${toStore.length} secret(s) (${created.length} new); wrote ${out}`);
270
+ 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.`);
271
+ }
272
+
174
273
  async function main(): Promise<void> {
175
274
  const args = process.argv.slice(2); const command = args.shift();
176
275
  if (!command || command === "--help" || command === "-h" || command === "help") { console.log(usage); if (!command) process.exitCode = 2; return; }
@@ -226,13 +325,13 @@ async function main(): Promise<void> {
226
325
  await core.ensureSession(); process.stdout.write(await core.getSecret(item)); return;
227
326
  }
228
327
  if (command === "add") {
229
- requireKnownFlags(args, ["--env", "--item", "--notes", "--target"], ["--stdin"]);
328
+ requireKnownFlags(args, ["--env", "--item", "--notes", "--target"], ["--stdin", "--dialog"]);
230
329
  const envName = valueAfter(args, "--env"); const itemName = valueAfter(args, "--item"); const notes = valueAfter(args, "--notes");
231
330
  if (!envName || !itemName || !ENV_NAME_RE.test(envName)) fail(usage, 2);
232
331
  const reference = core.referenceFor(itemName);
233
332
  const targets = targetList(valueAfter(args, "--target"));
234
333
  await core.ensureSession();
235
- const secret = await core.readSecretFromInput({ prompt: `Paste secret for ${envName}: `, stdin: args.includes("--stdin") });
334
+ const secret = await readSecret(args, `Paste secret for ${envName}: `, envName, itemName);
236
335
  await core.createSecretItem({ itemName, secret, envName, notes });
237
336
  let conflict = false;
238
337
  for (const path of targets) { const result = await core.writeAuthEntry(path, envName, reference); if (!result.ok) conflict = true; }
@@ -241,10 +340,11 @@ async function main(): Promise<void> {
241
340
  if (conflict) { console.error("Item created, but one or more auth targets already contained the environment name."); process.exitCode = 5; }
242
341
  return;
243
342
  }
343
+ if (command === "import") { await importCommand(args); return; }
244
344
  if (command === "rotate") {
245
- requireKnownFlags(args, ["--item"], ["--stdin"]);
345
+ requireKnownFlags(args, ["--item"], ["--stdin", "--dialog"]);
246
346
  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") });
347
+ await core.ensureSession(); const secret = await readSecret(args, "Paste replacement secret: ", `the new value of ${itemName}`, itemName);
248
348
  await core.rotateSecretItem({ itemName, secret }); console.log(`rotated item=${itemName}`); return;
249
349
  }
250
350
  if (command === "wire") {
package/src/core.ts CHANGED
@@ -5,7 +5,6 @@ import { appendFile, chmod, mkdir, readFile, realpath, rename, rm, stat, unlink,
5
5
  import { homedir } from "node:os";
6
6
  import { join, dirname } from "node:path";
7
7
  import process from "node:process";
8
- import * as readline from "node:readline";
9
8
  import { BRAND } from "./brand.ts";
10
9
  import { buildRedactIndex, type RedactIndex } from "./redact.ts";
11
10
  import { ENV_NAME_RE, legacyRefFor, parseEnvFile, parseRef, type SecretRef } from "./refs.ts";
@@ -573,12 +572,67 @@ export async function readSecretFromInput(opts: { prompt: string; stdin: boolean
573
572
  if (!input) throw new Error("Empty secret");
574
573
  return input;
575
574
  }
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");
575
+ const value = await readMasked(opts.prompt);
582
576
  if (!value) throw new Error("Empty secret");
583
577
  return value;
584
578
  }
579
+
580
+ // 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> {
585
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return null;
586
+ const text = `Paste the secret for ${opts.label}\nVault item: ${opts.itemName}`;
587
+ 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")]]]
593
+ : process.env.DISPLAY || process.env.WAYLAND_DISPLAY
594
+ ? [["zenity", ["--entry", "--hide-text", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text]]]
595
+ : [];
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");
604
+ return null;
605
+ }
606
+ return null;
607
+ }
608
+
609
+ // Raw-mode prompt that echoes one "*" per character (capped, then a count), so
610
+ // the user sees typing and pastes land without the value ever reaching the screen.
611
+ function readMasked(prompt: string): Promise<string> {
612
+ const stdin = process.stdin;
613
+ const chars: string[] = [];
614
+ const draw = () => {
615
+ const shown = "*".repeat(Math.min(chars.length, 24)) + (chars.length > 24 ? ` (${chars.length} chars)` : "");
616
+ process.stderr.write(`\r\x1b[2K${prompt}${shown}`);
617
+ };
618
+ return new Promise((resolve, reject) => {
619
+ const finish = (error?: Error) => {
620
+ stdin.off("data", onData); stdin.setRawMode(false); stdin.pause();
621
+ process.stderr.write("\n");
622
+ if (error) reject(error); else resolve(chars.join(""));
623
+ };
624
+ const onData = (chunk: string) => {
625
+ for (const ch of chunk.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b./g, "")) {
626
+ if (ch === "\r" || ch === "\n") return finish();
627
+ if (ch === "\x03") return finish(new Error("Cancelled"));
628
+ if (ch === "\x04") return chars.length ? finish() : finish(new Error("Cancelled"));
629
+ if (ch === "\x7f" || ch === "\b") chars.pop();
630
+ else if (ch === "\x15") chars.length = 0;
631
+ else if (ch >= " ") chars.push(ch);
632
+ }
633
+ draw();
634
+ };
635
+ stdin.setRawMode(true); stdin.setEncoding("utf8"); stdin.resume(); stdin.on("data", onData);
636
+ draw();
637
+ });
638
+ }
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
@@ -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"],
@@ -132,7 +132,24 @@ async function callTool(name: string, args: Json): Promise<Json> {
132
132
  const envName = String(args.env_name ?? "");
133
133
  const itemName = String(args.item_name ?? "");
134
134
  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)}` : "";
135
+ const notes = typeof args.purpose === "string" && args.purpose ? args.purpose : undefined;
136
+ try {
137
+ await core.ensureSession();
138
+ const secret = await core.readSecretFromDialog({ label: envName, itemName });
139
+ if (secret === "") return text("The user saved an empty value; nothing was stored.", true);
140
+ if (secret !== null) {
141
+ 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);
144
+ const verified = (await core.resolveShellValue(reference)) !== null;
145
+ 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
+ }
147
+ } catch (error) {
148
+ 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);
151
+ }
152
+ const purpose = notes ? ` --notes ${shellQuote(notes)}` : "";
136
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}.`);
137
154
  }
138
155
  if (name === "vault_status") {
package/src/refs.ts CHANGED
@@ -74,3 +74,52 @@ export function parseEnvFile(text: string): Array<{ name: string; value: string
74
74
  });
75
75
  return out;
76
76
  }
77
+
78
+ export interface DotenvEntry { line: number; name: string; value: string; exported: boolean }
79
+
80
+ // Stricter than parseEnvFile, for `import`: a misread value would be stored in
81
+ // the vault, so anything ambiguous is reported by line number (never by value)
82
+ // instead of guessed. Values must also read back unchanged through parseEnvFile.
83
+ export function parseDotenvStrict(text: string): { entries: DotenvEntry[]; errors: Array<{ line: number; reason: string }> } {
84
+ const entries: DotenvEntry[] = [];
85
+ const errors: Array<{ line: number; reason: string }> = [];
86
+ const seen = new Set<string>();
87
+ text.split(/\r?\n/).forEach((raw, index) => {
88
+ const line = index + 1;
89
+ const trimmed = raw.trim();
90
+ if (!trimmed || trimmed.startsWith("#")) return;
91
+ const match = /^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(trimmed);
92
+ if (!match) { errors.push({ line, reason: "not a NAME=value line" }); return; }
93
+ const rest = match[3].trim();
94
+ let value: string;
95
+ if (rest.startsWith("'") || rest.startsWith('"')) {
96
+ const end = rest.indexOf(rest[0], 1);
97
+ if (end < 0) { errors.push({ line, reason: "unterminated quote (multiline values are not supported)" }); return; }
98
+ const after = rest.slice(end + 1).trim();
99
+ if (after && !/^#/.test(after)) { errors.push({ line, reason: "text after the closing quote" }); return; }
100
+ if (after && end + 1 === rest.indexOf("#", end)) { errors.push({ line, reason: "comment must be separated from the closing quote by a space" }); return; }
101
+ value = rest.slice(1, end);
102
+ if (rest[0] === '"' && /[\\$]/.test(value)) { errors.push({ line, reason: "escape sequence or $ expansion inside double quotes" }); return; }
103
+ } else {
104
+ value = rest.replace(/\s+#.*$/, "");
105
+ if (value.includes("${")) { errors.push({ line, reason: "${} expansion" }); return; }
106
+ }
107
+ if (value.startsWith("!")) { errors.push({ line, reason: "value starts with '!' (env files cannot run commands)" }); return; }
108
+ if (seen.has(match[2])) { errors.push({ line, reason: `duplicate key ${match[2]}` }); return; }
109
+ seen.add(match[2]);
110
+ entries.push({ line, name: match[2], value, exported: Boolean(match[1]) });
111
+ });
112
+ return { entries, errors };
113
+ }
114
+
115
+ const SECRET_NAME_RE = /(SECRET|TOKEN|PASSW(?:OR)?D|PASSPHRASE|PWD|(?:^|_)KEY(?:$|_)|APIKEY|PRIVATE|CREDENTIAL|AUTH|DSN|WEBHOOK|COOKIE|SIGNING|ENCRYPT|SALT)/i;
116
+ 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;
118
+
119
+ // Local heuristic for `import`: never sends anything anywhere. Names that say
120
+ // PUBLIC (NEXT_PUBLIC_*, VITE_PUBLIC_*) are meant to ship to browsers.
121
+ 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;
124
+ return value.length >= 24 && !/\s/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value) && /\d/.test(value);
125
+ }