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/CHANGELOG.md CHANGED
@@ -3,6 +3,55 @@
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.3] - 2026-10-15
9
+
10
+ ### Security
11
+ Fixes from an independent audit and a multi-model review of 0.5.2.
12
+ - The native dialog could be used by a prompt-injected agent to phish new
13
+ secrets, including the vault master password. It now says a program asked for
14
+ it and warns against the master password, refuses multi-line, control or bidi
15
+ characters and names over 120 characters, refuses a value equal to the master
16
+ password, opens one at a time with a 30 s pause after a cancel, and is audited.
17
+ zenity runs with `--no-markup`.
18
+ - Env files could set `PATH`, `NODE_OPTIONS`, `LD_PRELOAD` and other loader
19
+ variables, so a cloned repo's `.env.refs` could run code. They are rejected
20
+ in env files, refused by `add`/`wire`/`secrets_request`, and never injected.
21
+ - Streaming redaction leaked most of a secret longer than 4096 characters.
22
+ - `secrets_run` could return a partial secret when output hit the collection
23
+ limit; the cut tail is now dropped before masking, with per-stream budgets.
24
+ - `import` missed `*_PASS`, URL passwords with no username, hex keys, and known
25
+ key formats in names containing `PUBLIC`; `.env.refs` is now written `0600`.
26
+ - `inject -o` replaces a planted symlink instead of writing through it; temp
27
+ files use random names and exclusive creation.
28
+
29
+ ### Changed
30
+ - `add`/`rotate` read stdin only with `--stdin`; a multi-line paste into the
31
+ `*` prompt is drained and refused instead of reaching the shell.
32
+ - `secrets_request` checks names and existing items before opening the dialog,
33
+ reports auth.json conflicts instead of claiming success, and returns errors.
34
+ - Item names with leading or trailing spaces are refused (they shadowed
35
+ existing items). `add`, `rotate` and `secrets_request` refresh the redact
36
+ index and write to the audit log.
37
+
38
+ ## [0.5.2] - 2026-10-15
39
+
40
+ ### Added
41
+ - `keyshroud import`: moves the secrets in a `.env` into the vault and writes
42
+ `.env.refs`. Dry run with names only by default, `--yes` to write; values are
43
+ verified after storing, never printed, and `--replace` rewrites the source
44
+ atomically without a backup.
45
+
46
+ - `add`/`rotate` without a terminal, and the `secrets_request` MCP tool, open a
47
+ native hidden-input dialog (macOS `osascript`, Linux `zenity`/`kdialog`), so
48
+ an agent can start the command and the user only pastes. `--dialog` forces it.
49
+
50
+ ### Changed
51
+ - The `add` and `rotate` prompts echo `*` per character (a count after 24) so
52
+ typing and pastes are visible without showing the value; Backspace, Ctrl-U
53
+ and Ctrl-C work as in a password prompt.
54
+
6
55
  ## [0.5.1] - 2026-10-14
7
56
 
8
57
  ### 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
 
@@ -61,7 +61,7 @@ webhooks, databases. The usual options are bad:
61
61
  | ๐Ÿ™ˆ **Masked output** | `keyshroud run` replaces every secret value in stdout/stderr with `<redacted:NAME>` โ€” streaming-safe across chunk boundaries. |
62
62
  | ๐Ÿช **Agent hooks** | The pi/omp `tool_result` hook redacts every tool result, failed ones included. Claude Code's `PostToolUse` hook redacts every *successful* tool result (Bash, Read, Grep, MCPโ€ฆ) โ€” see [limits](#security-model). |
63
63
  | ๐Ÿ”Œ **MCP server** | `keyshroud mcp` gives any MCP client `secrets_run`, `secrets_list`, `secrets_request`, `vault_status`. No tool ever returns a value. |
64
- | ๐Ÿ”— **Commit-safe references** | `bw://item`, `bw://item/username`, `bw://item/fields/name`, `bw://item/totp` in `.env.refs` files and templates. |
64
+ | ๐Ÿ”— **Committable references** | `bw://item`, `bw://item/username`, `bw://item/fields/name`, `bw://item/totp` in `.env.refs` files and templates: names, not values. |
65
65
  | โŒจ๏ธ **Paste-to-create** | New secrets go through a masked prompt in the user's terminal or a masked popup in pi โ€” never through the chat. |
66
66
  | ๐Ÿ“„ **`inject`** | Render `{{ bw://โ€ฆ }}` templates into `0600` config files for tools that can't read env vars (`.npmrc`, kubeconfig). |
67
67
  | ๐Ÿ”Ž **`scan`** | Find vault values that leaked into your working tree (pre-commit friendly). |
@@ -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.
@@ -344,18 +373,30 @@ Guarantees we test for:
344
373
 
345
374
  - secret values never appear in argv, logs, diagnostics, MCP results, hook
346
375
  output, or `audit.jsonl`;
347
- - new secrets are read from a masked prompt or stdin, never flags;
376
+ - new secrets are read from a masked prompt, `--stdin`, or a native dialog, never flags;
377
+ - the dialog an agent can open says it was requested by a program, warns against the
378
+ master password, refuses names that are not one printable line, refuses the master
379
+ password itself, and opens one at a time (30 s pause after a cancel);
348
380
  - the session file, redact index, rendered templates and auth files are `0600`;
349
381
  - auth.json writes are locked and atomic and never overwrite without `--force`;
350
382
  - commands started by `run` and `secrets_run` never receive `BW_SESSION`,
351
383
  `BW_PASSWORD`, `BW_CLIENTID` or `BW_CLIENTSECRET`, so `--only` really limits
352
384
  what a command can read (inside pi/omp the agent process keeps the session for
353
385
  legacy `!bw` auth entries; its value is redacted from tool output);
354
- - env files may contain only references and literals โ€” a `!command` value is
355
- rejected, so a cloned repo's `.env.refs` cannot run code;
386
+ - env files may contain only references and literals: a `!command` value is
387
+ rejected, and so are variables that change what programs load or run (`PATH`,
388
+ `NODE_OPTIONS`, `LD_*`, `DYLD_*`, `BASH_ENV`, `GIT_SSH_COMMAND`, โ€ฆ), which are
389
+ also never wired or injected;
356
390
  - an unparseable `auth.json` is never overwritten.
357
391
 
358
- Known limits: an agent with shell access as your user can read
392
+ Known limits: a `.env.refs` can reference **any** item in your vault, not only the
393
+ ones you wired, so read a cloned repo's `.env.refs` before running `--env-file`
394
+ with it. `import` decides what is secret by heuristics and keeps everything else
395
+ as a literal; check its plan before committing `.env.refs`. The Claude Code
396
+ `PostToolUse` hook does not see failed commands (`PostToolUseFailure` cannot
397
+ rewrite output) or anything a client sends as telemetry, and it only knows wired
398
+ values, so `keyshroud get bw://โ€ฆ` or `run --no-mask` print in the clear; run
399
+ secret-using commands through `keyshroud run` or `secrets_run`. An agent with shell access as your user can read
359
400
  `~/.config/keyshroud/session` and use `bw` directly โ€” run agents in a sandbox or
360
401
  container if that matters. Redaction is exact-match, so a *partial* or transformed value
361
402
  (`${TOKEN:0:10}`, base64, a line wrapped by a tool) is not masked; values
package/dist/cli.js 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.js";
9
10
  import * as core from "./core.js";
10
11
  import { runClaudeHook } from "./hooks.js";
11
12
  import { serveMcp } from "./mcp.js";
12
13
  import { buildRedactIndex, compileRedactIndex, createRedactor, createRedactStream } from "./redact.js";
13
- import { ENV_NAME_RE, parseRef } from "./refs.js";
14
+ import { ENV_NAME_RE, isLoaderEnvName, isRef, itemNameProblem, looksSecret, parseDotenvStrict, parseRef } from "./refs.js";
14
15
  const usage = `Usage: ${BRAND.bin} <command> [options]
15
16
 
16
17
  Secrets
@@ -21,8 +22,11 @@ Secrets
21
22
  redact Copy stdin to stdout with vault secrets masked
22
23
  scan [paths...] Find vault secret values in files (exit 1 on leaks)
23
24
  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]
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]
26
30
  wire --env NAME --item ITEM [--target pi|omp|both] [--force]
27
31
  unwire --env NAME [--target pi|omp|both]
28
32
 
@@ -60,6 +64,27 @@ function targetList(value) {
60
64
  return [targetPaths.pi];
61
65
  fail("--target must be omp, pi, or both", 2);
62
66
  }
67
+ // --stdin: piped value. Terminal: masked prompt. Otherwise (an agent's shell):
68
+ // a native dialog, so the agent can start the command and the user only pastes.
69
+ async function readSecret(args, prompt, itemName, envName) {
70
+ if (args.includes("--stdin"))
71
+ return core.readSecretFromInput({ prompt, stdin: true });
72
+ if (process.stdin.isTTY && !args.includes("--dialog"))
73
+ return core.readSecretFromInput({ prompt, stdin: false });
74
+ const value = await core.readSecretFromDialog({ itemName, envName });
75
+ if (value)
76
+ return value;
77
+ if (value === "")
78
+ throw new Error("Empty secret");
79
+ fail("No terminal and no dialog here (SSH or no display). Run the command in a terminal, or pipe the value with --stdin.", 2);
80
+ }
81
+ function checkNames(envName, itemName) {
82
+ if (isLoaderEnvName(envName))
83
+ fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
84
+ const problem = itemNameProblem(itemName);
85
+ if (problem)
86
+ fail(`Vault item name ${problem}`, 2);
87
+ }
63
88
  function shellQuote(value) { return `'${value.replaceAll("'", "'\\''")}'`; }
64
89
  function failureCode(error) {
65
90
  const text = error instanceof Error ? error.message : String(error);
@@ -154,14 +179,14 @@ async function inject(args) {
154
179
  process.stdout.write(rendered);
155
180
  return;
156
181
  }
157
- try {
158
- await readFile(output);
159
- if (!args.includes("--force"))
160
- fail(`Refusing to overwrite ${output}; use --force`, 5);
161
- }
162
- catch { }
163
- await writeFile(output, rendered, { mode: 0o600 });
164
- await chmod(output, 0o600);
182
+ // lstat, and a private temp file renamed over the target, so a planted
183
+ // symlink (even a dangling one) is replaced instead of followed.
184
+ const exists = await lstat(output).then(() => true, () => false);
185
+ if (exists && !args.includes("--force"))
186
+ fail(`Refusing to overwrite ${output}; use --force`, 5);
187
+ const tmp = join(dirname(resolve(output)), `.${basename(output)}.tmp.${randomBytes(6).toString("hex")}`);
188
+ await writeFile(tmp, rendered, { mode: 0o600, flag: "wx" });
189
+ await rename(tmp, output);
165
190
  console.error(`rendered ${refs.length} reference(s) into ${output} (mode 0600)`);
166
191
  }
167
192
  const SKIP_DIRS = new Set([".git", "node_modules", "dist", ".next", ".venv"]);
@@ -216,6 +241,128 @@ async function scan(paths) {
216
241
  if (leaks)
217
242
  process.exitCode = 1;
218
243
  }
244
+ async function importCommand(args) {
245
+ requireKnownFlags(args, ["--from", "--out", "--prefix", "--only", "--skip"], ["--yes", "--replace", "--force"]);
246
+ const from = valueAfter(args, "--from") ?? ".env";
247
+ const yes = args.includes("--yes");
248
+ const replace = args.includes("--replace");
249
+ if (replace && (from === "-" || valueAfter(args, "--out")))
250
+ fail("--replace rewrites the --from file; it cannot be combined with stdin or --out", 2);
251
+ if (from === "-" && process.stdin.isTTY)
252
+ fail("--from - needs the .env piped on stdin", 2);
253
+ const baseDir = from === "-" ? process.cwd() : dirname(resolve(from));
254
+ const out = replace ? resolve(from) : resolve(valueAfter(args, "--out") ?? join(baseDir, ".env.refs"));
255
+ const prefix = valueAfter(args, "--prefix") ?? basename(baseDir);
256
+ if (!/^[A-Za-z0-9._-]+$/.test(prefix))
257
+ fail("--prefix may contain only letters, digits, '.', '_' and '-' (set it with --prefix)", 2);
258
+ const list = (flag) => (valueAfter(args, flag) ?? "").split(",").map((name) => name.trim()).filter(Boolean);
259
+ const only = list("--only");
260
+ const skip = list("--skip");
261
+ let text;
262
+ try {
263
+ text = from === "-" ? await readStdin() : await readFile(from, "utf8");
264
+ }
265
+ catch {
266
+ fail(`Cannot read ${from}`, 2);
267
+ }
268
+ const { entries, errors } = parseDotenvStrict(text);
269
+ if (errors.length) {
270
+ for (const error of errors)
271
+ console.error(`${from}:${error.line} ${error.reason}`);
272
+ fail(`${errors.length} line(s) cannot be imported safely; fix them or move them out of the file`, 2);
273
+ }
274
+ for (const name of [...only, ...skip])
275
+ if (!entries.some((entry) => entry.name === name))
276
+ fail(`--only/--skip name not in ${from}: ${name}`, 2);
277
+ await core.ensureSession();
278
+ const existing = await core.findItems(prefix);
279
+ const rows = [];
280
+ for (const entry of entries) {
281
+ if (isRef(entry.value)) {
282
+ rows.push({ entry, kind: "ref" });
283
+ continue;
284
+ }
285
+ const secret = (only.length ? only.includes(entry.name) : looksSecret(entry.name, entry.value)) && !skip.includes(entry.name);
286
+ if (!secret) {
287
+ rows.push({ entry, kind: "literal" });
288
+ continue;
289
+ }
290
+ const item = `${prefix} ${entry.name}`;
291
+ const matches = existing.filter((candidate) => candidate.name.toLowerCase() === item.toLowerCase());
292
+ if (matches.length > 1) {
293
+ rows.push({ entry, item, kind: "conflict", note: "more than one vault item has this name" });
294
+ continue;
295
+ }
296
+ if (matches.length === 0) {
297
+ rows.push({ entry, item, kind: "new" });
298
+ continue;
299
+ }
300
+ const current = await core.resolveRef({ item: matches[0].id, field: "password" });
301
+ rows.push(current === entry.value
302
+ ? { entry, item, kind: "reuse", note: "same value already in the vault" }
303
+ : { entry, item, kind: "conflict", note: "vault item exists with a different value" });
304
+ }
305
+ const width = Math.max(4, ...rows.map((row) => row.entry.name.length));
306
+ console.log(`import plan for ${from} (item prefix "${prefix}")`);
307
+ for (const row of rows) {
308
+ const label = row.kind === "literal" ? "literal kept" : row.kind === "ref" ? "reference kept"
309
+ : row.kind === "conflict" ? `secret conflict: ${row.note} ("${row.item}")`
310
+ : `secret ${row.kind.padEnd(8)} -> bw://${row.item}${row.note ? ` (${row.note})` : ""}`;
311
+ const short = row.item && row.entry.value.length < 6 ? " [under 6 characters: stored, but too short to be masked]" : "";
312
+ console.log(` ${row.entry.name.padEnd(width)} ${label}${short}`);
313
+ }
314
+ const count = (kind) => rows.filter((row) => row.kind === kind).length;
315
+ const toStore = rows.filter((row) => row.kind === "new" || row.kind === "reuse");
316
+ 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)`);
317
+ if (count("conflict"))
318
+ fail("Resolve the conflicts (choose another --prefix, or rename the vault items) before importing", 5);
319
+ if (!toStore.length) {
320
+ console.log("Nothing to import.");
321
+ return;
322
+ }
323
+ const outExists = await stat(out).then(() => true, () => false);
324
+ if (outExists && !replace && !args.includes("--force"))
325
+ fail(`${out} already exists; use --force to overwrite it`, 5);
326
+ if (!yes) {
327
+ console.log(`Dry run: nothing written. Re-run with --yes to create ${count("new")} item(s) and write ${out}`);
328
+ return;
329
+ }
330
+ const created = [];
331
+ for (const row of rows) {
332
+ if (row.kind !== "new")
333
+ continue;
334
+ try {
335
+ await core.createSecretItem({ itemName: row.item, secret: row.entry.value, envName: row.entry.name, notes: `Imported by ${BRAND.bin} from ${basename(from === "-" ? "stdin" : from)}` });
336
+ }
337
+ catch {
338
+ if (created.length)
339
+ console.error(`Created before the failure: ${created.join(", ")}`);
340
+ fail(`Failed to create the vault item for ${row.entry.name}; nothing was written to ${out}`, 3);
341
+ }
342
+ created.push(row.entry.name);
343
+ }
344
+ for (const row of toStore) {
345
+ let value = null;
346
+ try {
347
+ value = await core.resolveRef({ item: row.item, field: "password" });
348
+ }
349
+ catch { }
350
+ if (value !== row.entry.value)
351
+ fail(`Verification failed for ${row.entry.name}: the vault does not return the imported value; nothing was written to ${out}`, 4);
352
+ }
353
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
354
+ const lines = text.split(/\r?\n/);
355
+ for (const row of toStore)
356
+ lines[row.entry.line - 1] = `${row.entry.exported ? "export " : ""}${row.entry.name}=bw://${row.item}`;
357
+ const mode = replace ? (await stat(out)).mode & 0o777 : 0o600;
358
+ const tmp = `${out}.tmp.${randomBytes(6).toString("hex")}`;
359
+ await writeFile(tmp, lines.join(eol), { mode, flag: "wx" });
360
+ await rename(tmp, out);
361
+ await core.audit("import", toStore.map((row) => row.entry.name), "cli");
362
+ console.log(`imported ${toStore.length} secret(s) (${created.length} new); wrote ${out}`);
363
+ if (!replace && from !== "-")
364
+ 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.`);
365
+ }
219
366
  async function main() {
220
367
  const args = process.argv.slice(2);
221
368
  const command = args.shift();
@@ -320,17 +467,21 @@ async function main() {
320
467
  return;
321
468
  }
322
469
  if (command === "add") {
323
- requireKnownFlags(args, ["--env", "--item", "--notes", "--target"], ["--stdin"]);
470
+ requireKnownFlags(args, ["--env", "--item", "--notes", "--target"], ["--stdin", "--dialog"]);
324
471
  const envName = valueAfter(args, "--env");
325
472
  const itemName = valueAfter(args, "--item");
326
473
  const notes = valueAfter(args, "--notes");
327
474
  if (!envName || !itemName || !ENV_NAME_RE.test(envName))
328
475
  fail(usage, 2);
476
+ checkNames(envName, itemName);
329
477
  const reference = core.referenceFor(itemName);
330
478
  const targets = targetList(valueAfter(args, "--target"));
331
479
  await core.ensureSession();
332
- const secret = await core.readSecretFromInput({ prompt: `Paste secret for ${envName}: `, stdin: args.includes("--stdin") });
480
+ if ((await core.findItems(itemName)).some((item) => item.name.toLowerCase() === itemName.toLowerCase()))
481
+ fail(`Vault item already exists: ${itemName}`, 5);
482
+ const secret = await readSecret(args, `Paste secret for ${envName}: `, itemName, envName);
333
483
  await core.createSecretItem({ itemName, secret, envName, notes });
484
+ await core.audit("add", [envName], "cli");
334
485
  let conflict = false;
335
486
  for (const path of targets) {
336
487
  const result = await core.writeAuthEntry(path, envName, reference);
@@ -338,6 +489,7 @@ async function main() {
338
489
  conflict = true;
339
490
  }
340
491
  const verified = (await core.resolveShellValue(reference)) !== null;
492
+ await core.refreshRedactIndex();
341
493
  console.log(`created item=${itemName} env=${envName} targets=${targets.join(",")} verified=${verified ? "yes" : "no"}`);
342
494
  if (conflict) {
343
495
  console.error("Item created, but one or more auth targets already contained the environment name.");
@@ -345,14 +497,20 @@ async function main() {
345
497
  }
346
498
  return;
347
499
  }
500
+ if (command === "import") {
501
+ await importCommand(args);
502
+ return;
503
+ }
348
504
  if (command === "rotate") {
349
- requireKnownFlags(args, ["--item"], ["--stdin"]);
505
+ requireKnownFlags(args, ["--item"], ["--stdin", "--dialog"]);
350
506
  const itemName = valueAfter(args, "--item");
351
507
  if (!itemName)
352
508
  fail(usage, 2);
353
509
  await core.ensureSession();
354
- const secret = await core.readSecretFromInput({ prompt: "Paste replacement secret: ", stdin: args.includes("--stdin") });
510
+ const secret = await readSecret(args, "Paste replacement secret: ", itemName);
355
511
  await core.rotateSecretItem({ itemName, secret });
512
+ await core.audit("rotate", [itemName], "cli");
513
+ await core.refreshRedactIndex();
356
514
  console.log(`rotated item=${itemName}`);
357
515
  return;
358
516
  }
@@ -362,6 +520,8 @@ async function main() {
362
520
  const itemName = valueAfter(args, "--item");
363
521
  if (!envName || !itemName || !ENV_NAME_RE.test(envName))
364
522
  fail(usage, 2);
523
+ if (isLoaderEnvName(envName))
524
+ fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
365
525
  const reference = core.referenceFor(itemName);
366
526
  const targets = targetList(valueAfter(args, "--target"));
367
527
  for (const path of targets) {
package/dist/core.d.ts CHANGED
@@ -78,6 +78,7 @@ export declare function buildEnvMap(): Promise<Record<string, string>>;
78
78
  export declare function redactionValues(wired?: Record<string, string>): Promise<Record<string, string>>;
79
79
  export declare function indexValues(wired?: Record<string, string>): Record<string, string>;
80
80
  export declare function writeRedactIndex(values: Record<string, string>): Promise<RedactIndex>;
81
+ export declare function refreshRedactIndex(): Promise<void>;
81
82
  export declare function readRedactIndex(): Promise<RedactIndex | null>;
82
83
  export declare function findItems(search: string): Promise<Array<{
83
84
  id: string;
@@ -103,3 +104,7 @@ export declare function readSecretFromInput(opts: {
103
104
  prompt: string;
104
105
  stdin: boolean;
105
106
  }): Promise<string>;
107
+ export declare function readSecretFromDialog(opts: {
108
+ itemName: string;
109
+ envName?: string;
110
+ }): Promise<string | null>;