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 +49 -0
- package/README.md +61 -20
- package/dist/cli.js +177 -17
- package/dist/core.d.ts +5 -0
- package/dist/core.js +172 -12
- package/dist/hooks.js +2 -2
- package/dist/mcp.js +48 -11
- package/dist/redact.js +1 -1
- package/dist/refs.d.ts +16 -0
- package/dist/refs.js +92 -0
- package/package.json +1 -1
- package/skills/keyshroud/SKILL.md +7 -3
- package/src/cli.ts +127 -13
- package/src/core.ts +133 -12
- package/src/hooks.ts +2 -2
- package/src/mcp.ts +37 -11
- package/src/redact.ts +1 -1
- package/src/refs.ts +66 -0
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)
|
|
20
20
|
[](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
|
-
| ๐ **
|
|
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`."**
|
|
86
|
-
`.env.refs` file with `GH_TOKEN=bw://
|
|
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
|
|
96
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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` |
|
|
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
|
|
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
|
|
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
|
|
355
|
-
rejected, so
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
await writeFile(
|
|
164
|
-
await
|
|
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
|
-
|
|
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
|
|
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>;
|