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 +19 -0
- package/README.md +44 -15
- package/dist/cli.js +153 -9
- package/dist/core.d.ts +4 -0
- package/dist/core.js +76 -7
- package/dist/hooks.js +2 -2
- package/dist/mcp.js +24 -2
- package/dist/refs.d.ts +14 -0
- package/dist/refs.js +72 -0
- package/package.json +1 -1
- package/skills/keyshroud/SKILL.md +6 -3
- package/src/cli.ts +109 -9
- package/src/core.ts +61 -7
- package/src/hooks.ts +2 -2
- package/src/mcp.ts +19 -2
- package/src/refs.ts +49 -0
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)
|
|
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
|
|
|
@@ -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.
|
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
|
-
|
|
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
|
|
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
|
|
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
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
|
|
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
|
|
26
|
-
: `${BRAND.name}: no secrets are wired yet.
|
|
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.
|
|
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
|
|
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.
|
|
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,
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
26
|
-
: `${BRAND.name}: no secrets are wired yet.
|
|
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.
|
|
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
|
|
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
|
+
}
|