keyshroud 0.5.2 → 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 +30 -0
- package/README.md +17 -5
- package/dist/cli.js +44 -28
- package/dist/core.d.ts +2 -1
- package/dist/core.js +120 -29
- package/dist/mcp.js +32 -17
- package/dist/redact.js +1 -1
- package/dist/refs.d.ts +2 -0
- package/dist/refs.js +26 -6
- package/package.json +1 -1
- package/skills/keyshroud/SKILL.md +3 -2
- package/src/cli.ts +35 -21
- package/src/core.ts +94 -27
- package/src/mcp.ts +24 -15
- package/src/redact.ts +1 -1
- package/src/refs.ts +23 -6
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ All notable changes are documented here. This project follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
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
|
+
|
|
8
38
|
## [0.5.2] - 2026-10-15
|
|
9
39
|
|
|
10
40
|
### Added
|
package/README.md
CHANGED
|
@@ -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). |
|
|
@@ -373,18 +373,30 @@ Guarantees we test for:
|
|
|
373
373
|
|
|
374
374
|
- secret values never appear in argv, logs, diagnostics, MCP results, hook
|
|
375
375
|
output, or `audit.jsonl`;
|
|
376
|
-
- 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);
|
|
377
380
|
- the session file, redact index, rendered templates and auth files are `0600`;
|
|
378
381
|
- auth.json writes are locked and atomic and never overwrite without `--force`;
|
|
379
382
|
- commands started by `run` and `secrets_run` never receive `BW_SESSION`,
|
|
380
383
|
`BW_PASSWORD`, `BW_CLIENTID` or `BW_CLIENTSECRET`, so `--only` really limits
|
|
381
384
|
what a command can read (inside pi/omp the agent process keeps the session for
|
|
382
385
|
legacy `!bw` auth entries; its value is redacted from tool output);
|
|
383
|
-
- env files may contain only references and literals
|
|
384
|
-
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;
|
|
385
390
|
- an unparseable `auth.json` is never overwritten.
|
|
386
391
|
|
|
387
|
-
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
|
|
388
400
|
`~/.config/keyshroud/session` and use `bw` directly — run agents in a sandbox or
|
|
389
401
|
container if that matters. Redaction is exact-match, so a *partial* or transformed value
|
|
390
402
|
(`${TOKEN:0:10}`, base64, a line wrapped by a tool) is not masked; values
|
package/dist/cli.js
CHANGED
|
@@ -4,13 +4,14 @@ import { constants, homedir } from "node:os";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
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, isRef, looksSecret, parseDotenvStrict, 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
|
|
@@ -63,20 +64,26 @@ function targetList(value) {
|
|
|
63
64
|
return [targetPaths.pi];
|
|
64
65
|
fail("--target must be omp, pi, or both", 2);
|
|
65
66
|
}
|
|
66
|
-
// Terminal: masked prompt.
|
|
67
|
-
// dialog, so the agent can start the command and the user only pastes.
|
|
68
|
-
async function readSecret(args, prompt,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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);
|
|
80
87
|
}
|
|
81
88
|
function shellQuote(value) { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
82
89
|
function failureCode(error) {
|
|
@@ -172,14 +179,14 @@ async function inject(args) {
|
|
|
172
179
|
process.stdout.write(rendered);
|
|
173
180
|
return;
|
|
174
181
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
await writeFile(
|
|
182
|
-
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);
|
|
183
190
|
console.error(`rendered ${refs.length} reference(s) into ${output} (mode 0600)`);
|
|
184
191
|
}
|
|
185
192
|
const SKIP_DIRS = new Set([".git", "node_modules", "dist", ".next", ".venv"]);
|
|
@@ -347,9 +354,9 @@ async function importCommand(args) {
|
|
|
347
354
|
const lines = text.split(/\r?\n/);
|
|
348
355
|
for (const row of toStore)
|
|
349
356
|
lines[row.entry.line - 1] = `${row.entry.exported ? "export " : ""}${row.entry.name}=bw://${row.item}`;
|
|
350
|
-
const mode = replace ? (await stat(out)).mode & 0o777 :
|
|
351
|
-
const tmp = `${out}.tmp.${
|
|
352
|
-
await writeFile(tmp, lines.join(eol), { mode });
|
|
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" });
|
|
353
360
|
await rename(tmp, out);
|
|
354
361
|
await core.audit("import", toStore.map((row) => row.entry.name), "cli");
|
|
355
362
|
console.log(`imported ${toStore.length} secret(s) (${created.length} new); wrote ${out}`);
|
|
@@ -466,11 +473,15 @@ async function main() {
|
|
|
466
473
|
const notes = valueAfter(args, "--notes");
|
|
467
474
|
if (!envName || !itemName || !ENV_NAME_RE.test(envName))
|
|
468
475
|
fail(usage, 2);
|
|
476
|
+
checkNames(envName, itemName);
|
|
469
477
|
const reference = core.referenceFor(itemName);
|
|
470
478
|
const targets = targetList(valueAfter(args, "--target"));
|
|
471
479
|
await core.ensureSession();
|
|
472
|
-
|
|
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);
|
|
473
483
|
await core.createSecretItem({ itemName, secret, envName, notes });
|
|
484
|
+
await core.audit("add", [envName], "cli");
|
|
474
485
|
let conflict = false;
|
|
475
486
|
for (const path of targets) {
|
|
476
487
|
const result = await core.writeAuthEntry(path, envName, reference);
|
|
@@ -478,6 +489,7 @@ async function main() {
|
|
|
478
489
|
conflict = true;
|
|
479
490
|
}
|
|
480
491
|
const verified = (await core.resolveShellValue(reference)) !== null;
|
|
492
|
+
await core.refreshRedactIndex();
|
|
481
493
|
console.log(`created item=${itemName} env=${envName} targets=${targets.join(",")} verified=${verified ? "yes" : "no"}`);
|
|
482
494
|
if (conflict) {
|
|
483
495
|
console.error("Item created, but one or more auth targets already contained the environment name.");
|
|
@@ -495,8 +507,10 @@ async function main() {
|
|
|
495
507
|
if (!itemName)
|
|
496
508
|
fail(usage, 2);
|
|
497
509
|
await core.ensureSession();
|
|
498
|
-
const secret = await readSecret(args, "Paste replacement secret: ",
|
|
510
|
+
const secret = await readSecret(args, "Paste replacement secret: ", itemName);
|
|
499
511
|
await core.rotateSecretItem({ itemName, secret });
|
|
512
|
+
await core.audit("rotate", [itemName], "cli");
|
|
513
|
+
await core.refreshRedactIndex();
|
|
500
514
|
console.log(`rotated item=${itemName}`);
|
|
501
515
|
return;
|
|
502
516
|
}
|
|
@@ -506,6 +520,8 @@ async function main() {
|
|
|
506
520
|
const itemName = valueAfter(args, "--item");
|
|
507
521
|
if (!envName || !itemName || !ENV_NAME_RE.test(envName))
|
|
508
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);
|
|
509
525
|
const reference = core.referenceFor(itemName);
|
|
510
526
|
const targets = targetList(valueAfter(args, "--target"));
|
|
511
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;
|
|
@@ -104,6 +105,6 @@ export declare function readSecretFromInput(opts: {
|
|
|
104
105
|
stdin: boolean;
|
|
105
106
|
}): Promise<string>;
|
|
106
107
|
export declare function readSecretFromDialog(opts: {
|
|
107
|
-
label: string;
|
|
108
108
|
itemName: string;
|
|
109
|
+
envName?: string;
|
|
109
110
|
}): Promise<string | null>;
|
package/dist/core.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Runtime-neutral core: no pi/omp/Claude Code imports. Every caller (agent
|
|
2
2
|
// extensions, CLI, MCP server, hooks) goes through these functions.
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
4
5
|
import { appendFile, chmod, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { join, dirname } from "node:path";
|
|
7
8
|
import process from "node:process";
|
|
8
9
|
import { BRAND } from "./brand.js";
|
|
9
10
|
import { buildRedactIndex } from "./redact.js";
|
|
10
|
-
import { ENV_NAME_RE, legacyRefFor, parseEnvFile, parseRef } from "./refs.js";
|
|
11
|
+
import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem, legacyRefFor, parseEnvFile, parseRef } from "./refs.js";
|
|
11
12
|
const P = BRAND.envPrefix;
|
|
12
13
|
function setting(name) {
|
|
13
14
|
const value = process.env[`${P}_${name}`]?.trim();
|
|
@@ -60,7 +61,10 @@ export function childEnv(extra = {}) {
|
|
|
60
61
|
for (const [name, value] of Object.entries(process.env))
|
|
61
62
|
if (value !== undefined && !VAULT_ENV_KEYS.includes(name))
|
|
62
63
|
env[name] = value;
|
|
63
|
-
|
|
64
|
+
for (const [name, value] of Object.entries(extra))
|
|
65
|
+
if (!isLoaderEnvName(name))
|
|
66
|
+
env[name] = value;
|
|
67
|
+
return env;
|
|
64
68
|
}
|
|
65
69
|
export async function bw(args, opts = {}) {
|
|
66
70
|
const session = opts.session ?? process.env.BW_SESSION;
|
|
@@ -238,8 +242,8 @@ export function ensureSession() {
|
|
|
238
242
|
}
|
|
239
243
|
async function writePrivateFile(path, content) {
|
|
240
244
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
241
|
-
const tmp = `${path}.tmp.${
|
|
242
|
-
await writeFile(tmp, content, { encoding: "utf8", mode: 0o600 });
|
|
245
|
+
const tmp = `${path}.tmp.${randomBytes(6).toString("hex")}`;
|
|
246
|
+
await writeFile(tmp, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
243
247
|
await chmod(tmp, 0o600);
|
|
244
248
|
await rename(tmp, path);
|
|
245
249
|
await chmod(path, 0o600);
|
|
@@ -485,6 +489,8 @@ export async function envFileEntries(path) {
|
|
|
485
489
|
return parseEnvFile(await readFile(path, "utf8")).map(({ name, value }) => {
|
|
486
490
|
if (value.trim().startsWith("!"))
|
|
487
491
|
throw new Error(`${path}: ${name} uses a shell command; env files accept only bw:// references and literals`);
|
|
492
|
+
if (isLoaderEnvName(name))
|
|
493
|
+
throw new Error(`${path}: ${name} changes what programs load or run; env files cannot set it`);
|
|
488
494
|
return { name, raw: value };
|
|
489
495
|
});
|
|
490
496
|
}
|
|
@@ -539,6 +545,11 @@ export async function writeRedactIndex(values) {
|
|
|
539
545
|
await writePrivateFile(REDACT_INDEX_FILE, `${JSON.stringify(index)}\n`);
|
|
540
546
|
return index;
|
|
541
547
|
}
|
|
548
|
+
// Re-index after the wired set changes so hooks mask a new value right away.
|
|
549
|
+
export async function refreshRedactIndex() {
|
|
550
|
+
const env = await resolveEntries(await authEnvEntries());
|
|
551
|
+
await writeRedactIndex(indexValues(env.secrets));
|
|
552
|
+
}
|
|
542
553
|
export async function readRedactIndex() {
|
|
543
554
|
try {
|
|
544
555
|
const parsed = JSON.parse(await readFile(REDACT_INDEX_FILE, "utf8"));
|
|
@@ -573,7 +584,17 @@ export async function getSecret(itemName) {
|
|
|
573
584
|
throw mapItemError(error, itemName);
|
|
574
585
|
}
|
|
575
586
|
}
|
|
587
|
+
// An agent can start add/rotate, so a dialog could be used to phish the master
|
|
588
|
+
// password into an item it can then read; refuse it outright.
|
|
589
|
+
async function refuseMasterPassword(secret) {
|
|
590
|
+
if (secret && secret === await readMasterPassword())
|
|
591
|
+
throw new Error("Refusing to store the vault master password as a secret");
|
|
592
|
+
}
|
|
576
593
|
export async function createSecretItem(opts) {
|
|
594
|
+
const problem = itemNameProblem(opts.itemName);
|
|
595
|
+
if (problem)
|
|
596
|
+
throw new Error(`Vault item name ${problem}`);
|
|
597
|
+
await refuseMasterPassword(opts.secret);
|
|
577
598
|
const existing = await findItems(opts.itemName);
|
|
578
599
|
if (existing.some((item) => item.name.toLowerCase() === opts.itemName.toLowerCase()))
|
|
579
600
|
throw new Error(`Vault item already exists: ${opts.itemName}`);
|
|
@@ -598,6 +619,7 @@ export async function createSecretItem(opts) {
|
|
|
598
619
|
return { id: String(created.id) };
|
|
599
620
|
}
|
|
600
621
|
export async function rotateSecretItem(opts) {
|
|
622
|
+
await refuseMasterPassword(opts.secret);
|
|
601
623
|
let parsed;
|
|
602
624
|
try {
|
|
603
625
|
parsed = JSON.parse(await bw(["get", "item", opts.itemName]));
|
|
@@ -620,7 +642,7 @@ export async function deleteItem(id, permanent = false) {
|
|
|
620
642
|
invalidateSecretCache();
|
|
621
643
|
}
|
|
622
644
|
export async function readSecretFromInput(opts) {
|
|
623
|
-
if (opts.stdin
|
|
645
|
+
if (opts.stdin) {
|
|
624
646
|
let input = "";
|
|
625
647
|
for await (const chunk of process.stdin)
|
|
626
648
|
input += String(chunk);
|
|
@@ -629,41 +651,92 @@ export async function readSecretFromInput(opts) {
|
|
|
629
651
|
throw new Error("Empty secret");
|
|
630
652
|
return input;
|
|
631
653
|
}
|
|
654
|
+
if (!process.stdin.isTTY)
|
|
655
|
+
throw new Error("No terminal to prompt on; pipe the value with --stdin");
|
|
632
656
|
const value = await readMasked(opts.prompt);
|
|
633
657
|
if (!value)
|
|
634
658
|
throw new Error("Empty secret");
|
|
635
659
|
return value;
|
|
636
660
|
}
|
|
637
661
|
// Native hidden-input dialog for callers without a terminal (agent shells, MCP).
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
662
|
+
// An agent can open it, so the wording is fixed, says who asked, and warns
|
|
663
|
+
// against the master password; names are validated and passed as data (argv),
|
|
664
|
+
// never as script. One dialog at a time, with a pause after a cancel. Resolves
|
|
665
|
+
// null when no dialog can be shown (SSH, headless Linux, missing tools).
|
|
666
|
+
const DIALOG_LOCK = join(CONFIG_DIR, "dialog.lock");
|
|
667
|
+
const DIALOG_COOLDOWN_MS = 30_000;
|
|
668
|
+
const DIALOG_TIMEOUT_MS = 300_000;
|
|
641
669
|
export async function readSecretFromDialog(opts) {
|
|
642
670
|
if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
|
|
643
671
|
return null;
|
|
644
|
-
const
|
|
672
|
+
const problem = itemNameProblem(opts.itemName);
|
|
673
|
+
if (problem)
|
|
674
|
+
throw new Error(`Vault item name ${problem}`);
|
|
675
|
+
if (opts.envName !== undefined && !ENV_NAME_RE.test(opts.envName))
|
|
676
|
+
throw new Error("Variable names must be UPPER_SNAKE_CASE");
|
|
677
|
+
const text = [
|
|
678
|
+
opts.envName ? "A program on this computer, usually an AI agent, asked to store a NEW secret." : "A program on this computer, usually an AI agent, asked to REPLACE the value of a secret.",
|
|
679
|
+
"",
|
|
680
|
+
...(opts.envName ? [`Variable: ${opts.envName}`] : []),
|
|
681
|
+
`Vault item: ${opts.itemName}`,
|
|
682
|
+
"",
|
|
683
|
+
"This is not a login prompt. Never enter your vault master password here.",
|
|
684
|
+
].join("\n");
|
|
645
685
|
const candidates = process.platform === "darwin"
|
|
646
|
-
? [["osascript", ["-e",
|
|
647
|
-
|
|
648
|
-
|
|
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")]]]
|
|
686
|
+
? [["osascript", ["-e", "on run argv", "-e", 'activate', "-e",
|
|
687
|
+
'text returned of (display dialog (item 1 of argv) default answer "" with hidden answer with title "keyshroud" buttons {"Cancel", "Save"} default button "Save" cancel button "Cancel" with icon caution)',
|
|
688
|
+
"-e", "end run", "--", text]]]
|
|
651
689
|
: process.env.DISPLAY || process.env.WAYLAND_DISPLAY
|
|
652
|
-
? [["zenity", ["--entry", "--hide-text", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text]]]
|
|
690
|
+
? [["zenity", ["--entry", "--hide-text", "--no-markup", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text.replace(/[<>]/g, "")]]]
|
|
653
691
|
: [];
|
|
654
|
-
|
|
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");
|
|
692
|
+
if (!candidates.length)
|
|
664
693
|
return null;
|
|
694
|
+
return withDialogLock(async () => {
|
|
695
|
+
for (const [command, args] of candidates) {
|
|
696
|
+
const result = await new Promise((resolve) => {
|
|
697
|
+
execFile(command, args, { timeout: DIALOG_TIMEOUT_MS }, (error, stdout, stderr) => resolve({ code: error ? (error.code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) }));
|
|
698
|
+
});
|
|
699
|
+
if (result.code === "ENOENT")
|
|
700
|
+
continue;
|
|
701
|
+
if (result.code === 0)
|
|
702
|
+
return result.stdout.replace(/\r?\n$/, "");
|
|
703
|
+
if (command !== "osascript" || /-128/.test(result.stderr))
|
|
704
|
+
throw new Error("Cancelled");
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
return null;
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
async function withDialogLock(fn) {
|
|
711
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
712
|
+
let held = "";
|
|
713
|
+
try {
|
|
714
|
+
held = await readFile(DIALOG_LOCK, "utf8");
|
|
715
|
+
}
|
|
716
|
+
catch { }
|
|
717
|
+
const [kind, at] = held.trim().split(":");
|
|
718
|
+
const age = Date.now() - Number(at);
|
|
719
|
+
const live = (kind === "open" || kind === "cancelled") && age >= 0 && age < (kind === "cancelled" ? DIALOG_COOLDOWN_MS : DIALOG_TIMEOUT_MS + 30_000);
|
|
720
|
+
if (!live)
|
|
721
|
+
await rm(DIALOG_LOCK, { force: true });
|
|
722
|
+
try {
|
|
723
|
+
await writeFile(DIALOG_LOCK, `open:${Date.now()}\n`, { flag: "wx", mode: 0o600 });
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
throw new Error(kind === "cancelled" ? "The last dialog was cancelled; wait 30 seconds before asking again" : "Another keyshroud dialog is already open");
|
|
727
|
+
}
|
|
728
|
+
try {
|
|
729
|
+
const value = await fn();
|
|
730
|
+
await rm(DIALOG_LOCK, { force: true });
|
|
731
|
+
return value;
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
if (error instanceof Error && error.message === "Cancelled")
|
|
735
|
+
await writeFile(DIALOG_LOCK, `cancelled:${Date.now()}\n`, { mode: 0o600 });
|
|
736
|
+
else
|
|
737
|
+
await rm(DIALOG_LOCK, { force: true });
|
|
738
|
+
throw error;
|
|
665
739
|
}
|
|
666
|
-
return null;
|
|
667
740
|
}
|
|
668
741
|
// Raw-mode prompt that echoes one "*" per character (capped, then a count), so
|
|
669
742
|
// the user sees typing and pastes land without the value ever reaching the screen.
|
|
@@ -675,7 +748,16 @@ function readMasked(prompt) {
|
|
|
675
748
|
process.stderr.write(`\r\x1b[2K${prompt}${shown}`);
|
|
676
749
|
};
|
|
677
750
|
return new Promise((resolve, reject) => {
|
|
751
|
+
// After Enter, keep reading briefly: the rest of a multi-line paste must be
|
|
752
|
+
// consumed here (or it would reach the parent shell) and is then refused.
|
|
753
|
+
let ending;
|
|
754
|
+
let multiline = false;
|
|
755
|
+
const settle = () => {
|
|
756
|
+
clearTimeout(ending);
|
|
757
|
+
ending = setTimeout(() => finish(multiline ? new Error("Multi-line values are not supported in the prompt; pipe them with --stdin") : undefined), 100);
|
|
758
|
+
};
|
|
678
759
|
const finish = (error) => {
|
|
760
|
+
clearTimeout(ending);
|
|
679
761
|
stdin.off("data", onData);
|
|
680
762
|
stdin.setRawMode(false);
|
|
681
763
|
stdin.pause();
|
|
@@ -686,9 +768,18 @@ function readMasked(prompt) {
|
|
|
686
768
|
resolve(chars.join(""));
|
|
687
769
|
};
|
|
688
770
|
const onData = (chunk) => {
|
|
689
|
-
|
|
690
|
-
if (
|
|
691
|
-
|
|
771
|
+
if (ending) {
|
|
772
|
+
if (/[^\r\n]/.test(chunk))
|
|
773
|
+
multiline = true;
|
|
774
|
+
return settle();
|
|
775
|
+
}
|
|
776
|
+
const clean = [...chunk.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b./g, "")];
|
|
777
|
+
for (const [index, ch] of clean.entries()) {
|
|
778
|
+
if (ch === "\r" || ch === "\n") {
|
|
779
|
+
if (/[^\r\n]/.test(clean.slice(index + 1).join("")))
|
|
780
|
+
multiline = true;
|
|
781
|
+
return settle();
|
|
782
|
+
}
|
|
692
783
|
if (ch === "\x03")
|
|
693
784
|
return finish(new Error("Cancelled"));
|
|
694
785
|
if (ch === "\x04")
|
package/dist/mcp.js
CHANGED
|
@@ -7,7 +7,7 @@ import process from "node:process";
|
|
|
7
7
|
import { BRAND } from "./brand.js";
|
|
8
8
|
import * as core from "./core.js";
|
|
9
9
|
import { createRedactor } from "./redact.js";
|
|
10
|
-
import { ENV_NAME_RE } from "./refs.js";
|
|
10
|
+
import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem } from "./refs.js";
|
|
11
11
|
const SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
12
12
|
const OUTPUT_LIMIT = 256 * 1024;
|
|
13
13
|
const TOOLS = [
|
|
@@ -85,14 +85,14 @@ async function runTool(args) {
|
|
|
85
85
|
detached: true,
|
|
86
86
|
});
|
|
87
87
|
const chunks = { stdout: [], stderr: [] };
|
|
88
|
-
// Collect generously, mask, then truncate
|
|
89
|
-
// before
|
|
90
|
-
|
|
88
|
+
// Collect generously per stream, mask, then truncate. If collection stopped
|
|
89
|
+
// early, the tail may end inside a secret, so it is dropped before masking.
|
|
90
|
+
const sizes = { stdout: 0, stderr: 0 };
|
|
91
91
|
for (const stream of ["stdout", "stderr"]) {
|
|
92
92
|
child[stream].on("data", (chunk) => {
|
|
93
|
-
if (
|
|
93
|
+
if (sizes[stream] >= OUTPUT_LIMIT * 2)
|
|
94
94
|
return;
|
|
95
|
-
|
|
95
|
+
sizes[stream] += chunk.length;
|
|
96
96
|
chunks[stream].push(chunk);
|
|
97
97
|
});
|
|
98
98
|
}
|
|
@@ -105,9 +105,14 @@ async function runTool(args) {
|
|
|
105
105
|
child.on("error", (error) => done(`spawn error: ${error.message}`));
|
|
106
106
|
});
|
|
107
107
|
clearTimeout(timer);
|
|
108
|
-
let truncated =
|
|
108
|
+
let truncated = false;
|
|
109
109
|
const out = (stream) => {
|
|
110
|
-
|
|
110
|
+
let raw = Buffer.concat(chunks[stream]).toString("utf8");
|
|
111
|
+
if (sizes[stream] >= OUTPUT_LIMIT * 2) {
|
|
112
|
+
truncated = true;
|
|
113
|
+
raw = raw.slice(0, redactor.safeCut(raw));
|
|
114
|
+
}
|
|
115
|
+
const masked = redactor.redact(raw);
|
|
111
116
|
if (masked.length <= OUTPUT_LIMIT / 2)
|
|
112
117
|
return masked;
|
|
113
118
|
truncated = true;
|
|
@@ -141,30 +146,40 @@ async function callTool(name, args) {
|
|
|
141
146
|
const itemName = String(args.item_name ?? "");
|
|
142
147
|
if (!ENV_NAME_RE.test(envName) || !itemName)
|
|
143
148
|
return text("env_name must be UPPER_SNAKE_CASE and item_name is required", true);
|
|
144
|
-
|
|
149
|
+
if (isLoaderEnvName(envName))
|
|
150
|
+
return text(`${envName} changes what programs load or run; it cannot be wired to a secret`, true);
|
|
151
|
+
const problem = itemNameProblem(itemName);
|
|
152
|
+
if (problem)
|
|
153
|
+
return text(`item_name ${problem}`, true);
|
|
154
|
+
const notes = typeof args.purpose === "string" && args.purpose ? args.purpose.slice(0, 500) : undefined;
|
|
145
155
|
try {
|
|
156
|
+
const reference = core.referenceFor(itemName);
|
|
146
157
|
await core.ensureSession();
|
|
147
|
-
|
|
158
|
+
if ((await core.findItems(itemName)).some((item) => item.name.toLowerCase() === itemName.toLowerCase()))
|
|
159
|
+
return text(`Vault item already exists: ${itemName}`, true);
|
|
160
|
+
const secret = await core.readSecretFromDialog({ itemName, envName });
|
|
148
161
|
if (secret === "")
|
|
149
162
|
return text("The user saved an empty value; nothing was stored.", true);
|
|
150
163
|
if (secret !== null) {
|
|
151
164
|
await core.createSecretItem({ itemName, secret, envName, notes });
|
|
152
|
-
|
|
165
|
+
await core.audit("request", [envName], "mcp");
|
|
166
|
+
const conflicts = [];
|
|
153
167
|
for (const path of Object.values(core.getAuthTargets()))
|
|
154
|
-
await core.writeAuthEntry(path, envName, reference)
|
|
168
|
+
if (!(await core.writeAuthEntry(path, envName, reference)).ok)
|
|
169
|
+
conflicts.push(path);
|
|
170
|
+
await core.refreshRedactIndex();
|
|
171
|
+
if (conflicts.length)
|
|
172
|
+
return text(`created item=${itemName}, but ${envName} is already wired to another secret in ${conflicts.join(", ")} and was left unchanged. Ask the user before running \`${BRAND.bin} wire --env ${envName} --item ${shellQuote(itemName)} --force\`.`, true);
|
|
155
173
|
const verified = (await core.resolveShellValue(reference)) !== null;
|
|
156
174
|
return text(`The user entered the value in a ${BRAND.name} dialog. created item=${itemName} env=${envName} verified=${verified ? "yes" : "no"}. Use secrets_run with $${envName}.`);
|
|
157
175
|
}
|
|
158
176
|
}
|
|
159
177
|
catch (error) {
|
|
160
178
|
const message = error instanceof Error ? error.message : String(error);
|
|
161
|
-
|
|
162
|
-
return text("The user cancelled the dialog; nothing was stored.", true);
|
|
163
|
-
if (/already exists/.test(message))
|
|
164
|
-
return text(message, true);
|
|
179
|
+
return text(message === "Cancelled" ? "The user cancelled the dialog; nothing was stored." : message, true);
|
|
165
180
|
}
|
|
166
181
|
const purpose = notes ? ` --notes ${shellQuote(notes)}` : "";
|
|
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}.`);
|
|
182
|
+
return text(`No dialog can be shown here. Ask the user to run this in their own terminal (the value is entered in a masked prompt; it must never be pasted into the chat):\n\n ${BRAND.bin} add --env ${envName} --item ${shellQuote(itemName)}${purpose}\n\nAfter it reports verified=yes, use secrets_run with $${envName}.`);
|
|
168
183
|
}
|
|
169
184
|
if (name === "vault_status") {
|
|
170
185
|
try {
|
package/dist/redact.js
CHANGED
|
@@ -87,7 +87,7 @@ export function createRedactor(values) {
|
|
|
87
87
|
return cut;
|
|
88
88
|
// Bounded fallback: cut near the end, moving forward past any complete
|
|
89
89
|
// secret that straddles it, so whole matches are still replaced.
|
|
90
|
-
cut = text.length - maxLength;
|
|
90
|
+
cut = Math.max(0, text.length - maxLength);
|
|
91
91
|
while (cut < text.length && straddling(cut))
|
|
92
92
|
cut++;
|
|
93
93
|
return cut;
|
package/dist/refs.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export declare function parseRef(raw: string): SecretRef | null;
|
|
|
10
10
|
export declare function formatRef(ref: SecretRef): string;
|
|
11
11
|
export declare function legacyRefFor(itemName: string): string;
|
|
12
12
|
export declare const ENV_NAME_RE: RegExp;
|
|
13
|
+
export declare function isLoaderEnvName(name: string): boolean;
|
|
14
|
+
export declare function itemNameProblem(name: string): string | null;
|
|
13
15
|
export declare function parseEnvFile(text: string): Array<{
|
|
14
16
|
name: string;
|
|
15
17
|
value: string;
|
package/dist/refs.js
CHANGED
|
@@ -57,6 +57,21 @@ export function legacyRefFor(itemName) {
|
|
|
57
57
|
return `!bw get password '${itemName}'`;
|
|
58
58
|
}
|
|
59
59
|
export const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
|
|
60
|
+
// Variables that change what a child process loads or runs. Env files can come
|
|
61
|
+
// from a cloned repo and agents can wire names, so neither may set these.
|
|
62
|
+
const LOADER_ENV_RE = /^(?:PATH|HOME|ZDOTDIR|XDG_CONFIG_HOME|IFS|ENV|BASH_ENV|SHELLOPTS|BASHOPTS|PS4|PROMPT_COMMAND|NODE_OPTIONS|NODE_PATH|NODE_EXTRA_CA_CERTS|PYTHONPATH|PYTHONHOME|PYTHONSTARTUP|PERL5OPT|PERL5LIB|PERLLIB|RUBYOPT|RUBYLIB|JAVA_TOOL_OPTIONS|_JAVA_OPTIONS|JDK_JAVA_OPTIONS|EDITOR|VISUAL|PAGER|LESSOPEN|LESSCLOSE|BROWSER|SSH_ASKPASS|LD_\w*|DYLD_\w*|NPM_CONFIG_\w*|GIT_(?:SSH|SSH_COMMAND|EXEC_PATH|ASKPASS|EDITOR|PAGER|EXTERNAL_DIFF|PROXY_COMMAND|DIR|WORK_TREE|TEMPLATE_DIR|CONFIG\w*))$/i;
|
|
63
|
+
export function isLoaderEnvName(name) { return LOADER_ENV_RE.test(name); }
|
|
64
|
+
// Item names appear in native dialogs and lookups: one line of printable text,
|
|
65
|
+
// no padding (lookups trim, so a padded name would shadow an existing item).
|
|
66
|
+
export function itemNameProblem(name) {
|
|
67
|
+
if (!name || name !== name.trim())
|
|
68
|
+
return "must not be empty or start or end with spaces";
|
|
69
|
+
if (name.length > 120)
|
|
70
|
+
return "must be at most 120 characters";
|
|
71
|
+
if (/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(name))
|
|
72
|
+
return "must be one line of printable text";
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
60
75
|
// Parses dotenv-style files whose values may be references:
|
|
61
76
|
// GH_TOKEN=bw://GitHub CLI token
|
|
62
77
|
// export SENTRY_DSN="bw://Sentry/fields/dsn"
|
|
@@ -140,15 +155,20 @@ export function parseDotenvStrict(text) {
|
|
|
140
155
|
});
|
|
141
156
|
return { entries, errors };
|
|
142
157
|
}
|
|
143
|
-
const SECRET_NAME_RE = /(SECRET|TOKEN|PASSW(?:OR)?D|PASSPHRASE|PWD|(?:^|_)KEY(?:$|_)|APIKEY|PRIVATE|CREDENTIAL|AUTH|DSN|WEBHOOK|COOKIE|SIGNING|ENCRYPT|SALT)/i;
|
|
158
|
+
const SECRET_NAME_RE = /(SECRET|TOKEN|PASSW(?:OR)?D|PASSPHRASE|(?:^|_)PASS(?:$|_)|PWD|(?:^|_)KEY(?:$|_)|APIKEY|PRIVATE|CREDENTIAL|(?:^|_)CREDS?(?:$|_)|AUTH|JWT|DSN|WEBHOOK|COOKIE|SIGNING|ENCRYPT|SALT)/i;
|
|
144
159
|
const SECRET_VALUE_RE = /^(?:gh[pousr]_|github_pat_|glpat-|xox[abprs]-|sk-|sk_(?:live|test)_|rk_(?:live|test)_|whsec_|AKIA|ASIA|AIza|npm_|hf_|ya29\.|eyJ)/;
|
|
145
|
-
const URL_PASSWORD_RE = /^[a-z][a-z0-9+.-]*:\/\/[^\s:@/]
|
|
146
|
-
// Local heuristic for `import`: never sends anything anywhere.
|
|
147
|
-
//
|
|
160
|
+
const URL_PASSWORD_RE = /^[a-z][a-z0-9+.-]*:\/\/[^\s:@/]*:[^\s@/]+@/i;
|
|
161
|
+
// Local heuristic for `import`: never sends anything anywhere. A known secret
|
|
162
|
+
// format wins over the name; otherwise names with a PUBLIC segment
|
|
163
|
+
// (NEXT_PUBLIC_*, VITE_PUBLIC_*) are meant to ship to browsers.
|
|
148
164
|
export function looksSecret(name, value) {
|
|
149
|
-
if (!value || isRef(value)
|
|
165
|
+
if (!value || isRef(value))
|
|
166
|
+
return false;
|
|
167
|
+
if (SECRET_VALUE_RE.test(value) || URL_PASSWORD_RE.test(value))
|
|
168
|
+
return true;
|
|
169
|
+
if (/(?:^|_)PUBLIC(?:$|_)/i.test(name))
|
|
150
170
|
return false;
|
|
151
|
-
if (
|
|
171
|
+
if (SECRET_NAME_RE.test(name) || /^[0-9a-f]{32,}$/i.test(value))
|
|
152
172
|
return true;
|
|
153
173
|
return value.length >= 24 && !/\s/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value) && /\d/.test(value);
|
|
154
174
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keyshroud",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"mcpName": "io.github.dinhkarate/keyshroud",
|
|
5
5
|
"description": "Let AI coding agents use your secrets without ever seeing them. Self-hosted Vaultwarden/Bitwarden, masked output, MCP server, Claude Code + pi plugins.",
|
|
6
6
|
"keywords": [
|
|
@@ -17,8 +17,9 @@ in output with `<redacted:NAME>`.
|
|
|
17
17
|
```bash
|
|
18
18
|
keyshroud add --env STRIPE_API_KEY --item "Stripe API key" --notes "billing service"
|
|
19
19
|
```
|
|
20
|
-
Tell the user a dialog is waiting for them
|
|
21
|
-
|
|
20
|
+
Tell the user a dialog is waiting for them, and ask for one secret at a time;
|
|
21
|
+
if they cancel, do not ask again unprompted. Never ask for the vault master
|
|
22
|
+
password. With the MCP server, call `secrets_request` instead. Over SSH or without a display there is no dialog:
|
|
22
23
|
then give the user the command to run in their own terminal.
|
|
23
24
|
2. Never print, echo, `cat`, base64, or otherwise transform a secret value.
|
|
24
25
|
`<redacted:NAME>` in output means the secret was used correctly. Do not try
|
package/src/cli.ts
CHANGED
|
@@ -4,13 +4,14 @@ import { constants, homedir } from "node:os";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
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.ts";
|
|
9
10
|
import * as core from "./core.ts";
|
|
10
11
|
import { runClaudeHook } from "./hooks.ts";
|
|
11
12
|
import { serveMcp } from "./mcp.ts";
|
|
12
13
|
import { buildRedactIndex, compileRedactIndex, createRedactor, createRedactStream, type RedactIndex } from "./redact.ts";
|
|
13
|
-
import { ENV_NAME_RE, isRef, looksSecret, parseDotenvStrict, parseRef, type DotenvEntry } from "./refs.ts";
|
|
14
|
+
import { ENV_NAME_RE, isLoaderEnvName, isRef, itemNameProblem, looksSecret, parseDotenvStrict, parseRef, type DotenvEntry } from "./refs.ts";
|
|
14
15
|
|
|
15
16
|
const usage = `Usage: ${BRAND.bin} <command> [options]
|
|
16
17
|
|
|
@@ -56,17 +57,20 @@ function targetList(value: string | undefined): string[] {
|
|
|
56
57
|
if (value === "pi") return [targetPaths.pi];
|
|
57
58
|
fail("--target must be omp, pi, or both", 2);
|
|
58
59
|
}
|
|
59
|
-
// Terminal: masked prompt.
|
|
60
|
-
// dialog, so the agent can start the command and the user only pastes.
|
|
61
|
-
async function readSecret(args: string[], prompt: string,
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
60
|
+
// --stdin: piped value. Terminal: masked prompt. Otherwise (an agent's shell):
|
|
61
|
+
// a native dialog, so the agent can start the command and the user only pastes.
|
|
62
|
+
async function readSecret(args: string[], prompt: string, itemName: string, envName?: string): Promise<string> {
|
|
63
|
+
if (args.includes("--stdin")) return core.readSecretFromInput({ prompt, stdin: true });
|
|
64
|
+
if (process.stdin.isTTY && !args.includes("--dialog")) return core.readSecretFromInput({ prompt, stdin: false });
|
|
65
|
+
const value = await core.readSecretFromDialog({ itemName, envName });
|
|
66
|
+
if (value) return value;
|
|
67
|
+
if (value === "") throw new Error("Empty secret");
|
|
68
|
+
fail("No terminal and no dialog here (SSH or no display). Run the command in a terminal, or pipe the value with --stdin.", 2);
|
|
69
|
+
}
|
|
70
|
+
function checkNames(envName: string, itemName: string): void {
|
|
71
|
+
if (isLoaderEnvName(envName)) fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
|
|
72
|
+
const problem = itemNameProblem(itemName);
|
|
73
|
+
if (problem) fail(`Vault item name ${problem}`, 2);
|
|
70
74
|
}
|
|
71
75
|
function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
72
76
|
function failureCode(error: unknown): number {
|
|
@@ -143,9 +147,13 @@ async function inject(args: string[]): Promise<void> {
|
|
|
143
147
|
await core.audit("inject", refs, output ?? "stdout");
|
|
144
148
|
const rendered = template.replace(pattern, (_match, raw: string) => values.get(raw) ?? "");
|
|
145
149
|
if (!output) { process.stdout.write(rendered); return; }
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
await
|
|
150
|
+
// lstat, and a private temp file renamed over the target, so a planted
|
|
151
|
+
// symlink (even a dangling one) is replaced instead of followed.
|
|
152
|
+
const exists = await lstat(output).then(() => true, () => false);
|
|
153
|
+
if (exists && !args.includes("--force")) fail(`Refusing to overwrite ${output}; use --force`, 5);
|
|
154
|
+
const tmp = join(dirname(resolve(output)), `.${basename(output)}.tmp.${randomBytes(6).toString("hex")}`);
|
|
155
|
+
await writeFile(tmp, rendered, { mode: 0o600, flag: "wx" });
|
|
156
|
+
await rename(tmp, output);
|
|
149
157
|
console.error(`rendered ${refs.length} reference(s) into ${output} (mode 0600)`);
|
|
150
158
|
}
|
|
151
159
|
|
|
@@ -261,9 +269,9 @@ async function importCommand(args: string[]): Promise<void> {
|
|
|
261
269
|
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
262
270
|
const lines = text.split(/\r?\n/);
|
|
263
271
|
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 :
|
|
265
|
-
const tmp = `${out}.tmp.${
|
|
266
|
-
await writeFile(tmp, lines.join(eol), { mode });
|
|
272
|
+
const mode = replace ? (await stat(out)).mode & 0o777 : 0o600;
|
|
273
|
+
const tmp = `${out}.tmp.${randomBytes(6).toString("hex")}`;
|
|
274
|
+
await writeFile(tmp, lines.join(eol), { mode, flag: "wx" });
|
|
267
275
|
await rename(tmp, out);
|
|
268
276
|
await core.audit("import", toStore.map((row) => row.entry.name), "cli");
|
|
269
277
|
console.log(`imported ${toStore.length} secret(s) (${created.length} new); wrote ${out}`);
|
|
@@ -328,14 +336,18 @@ async function main(): Promise<void> {
|
|
|
328
336
|
requireKnownFlags(args, ["--env", "--item", "--notes", "--target"], ["--stdin", "--dialog"]);
|
|
329
337
|
const envName = valueAfter(args, "--env"); const itemName = valueAfter(args, "--item"); const notes = valueAfter(args, "--notes");
|
|
330
338
|
if (!envName || !itemName || !ENV_NAME_RE.test(envName)) fail(usage, 2);
|
|
339
|
+
checkNames(envName, itemName);
|
|
331
340
|
const reference = core.referenceFor(itemName);
|
|
332
341
|
const targets = targetList(valueAfter(args, "--target"));
|
|
333
342
|
await core.ensureSession();
|
|
334
|
-
|
|
343
|
+
if ((await core.findItems(itemName)).some((item) => item.name.toLowerCase() === itemName.toLowerCase())) fail(`Vault item already exists: ${itemName}`, 5);
|
|
344
|
+
const secret = await readSecret(args, `Paste secret for ${envName}: `, itemName, envName);
|
|
335
345
|
await core.createSecretItem({ itemName, secret, envName, notes });
|
|
346
|
+
await core.audit("add", [envName], "cli");
|
|
336
347
|
let conflict = false;
|
|
337
348
|
for (const path of targets) { const result = await core.writeAuthEntry(path, envName, reference); if (!result.ok) conflict = true; }
|
|
338
349
|
const verified = (await core.resolveShellValue(reference)) !== null;
|
|
350
|
+
await core.refreshRedactIndex();
|
|
339
351
|
console.log(`created item=${itemName} env=${envName} targets=${targets.join(",")} verified=${verified ? "yes" : "no"}`);
|
|
340
352
|
if (conflict) { console.error("Item created, but one or more auth targets already contained the environment name."); process.exitCode = 5; }
|
|
341
353
|
return;
|
|
@@ -344,13 +356,15 @@ async function main(): Promise<void> {
|
|
|
344
356
|
if (command === "rotate") {
|
|
345
357
|
requireKnownFlags(args, ["--item"], ["--stdin", "--dialog"]);
|
|
346
358
|
const itemName = valueAfter(args, "--item"); if (!itemName) fail(usage, 2);
|
|
347
|
-
await core.ensureSession(); const secret = await readSecret(args, "Paste replacement secret: ",
|
|
348
|
-
await core.rotateSecretItem({ itemName, secret });
|
|
359
|
+
await core.ensureSession(); const secret = await readSecret(args, "Paste replacement secret: ", itemName);
|
|
360
|
+
await core.rotateSecretItem({ itemName, secret }); await core.audit("rotate", [itemName], "cli"); await core.refreshRedactIndex();
|
|
361
|
+
console.log(`rotated item=${itemName}`); return;
|
|
349
362
|
}
|
|
350
363
|
if (command === "wire") {
|
|
351
364
|
requireKnownFlags(args, ["--env", "--item", "--target"], ["--force"]);
|
|
352
365
|
const envName = valueAfter(args, "--env"); const itemName = valueAfter(args, "--item");
|
|
353
366
|
if (!envName || !itemName || !ENV_NAME_RE.test(envName)) fail(usage, 2);
|
|
367
|
+
if (isLoaderEnvName(envName)) fail(`${envName} changes what programs load or run; it cannot be wired to a secret`, 2);
|
|
354
368
|
const reference = core.referenceFor(itemName);
|
|
355
369
|
const targets = targetList(valueAfter(args, "--target"));
|
|
356
370
|
for (const path of targets) { const result = await core.writeAuthEntry(path, envName, reference, { overwrite: args.includes("--force") }); if (!result.ok) fail(result.message, 5); }
|
package/src/core.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Runtime-neutral core: no pi/omp/Claude Code imports. Every caller (agent
|
|
2
2
|
// extensions, CLI, MCP server, hooks) goes through these functions.
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
4
5
|
import { appendFile, chmod, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { join, dirname } from "node:path";
|
|
7
8
|
import process from "node:process";
|
|
8
9
|
import { BRAND } from "./brand.ts";
|
|
9
10
|
import { buildRedactIndex, type RedactIndex } from "./redact.ts";
|
|
10
|
-
import { ENV_NAME_RE, legacyRefFor, parseEnvFile, parseRef, type SecretRef } from "./refs.ts";
|
|
11
|
+
import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem, legacyRefFor, parseEnvFile, parseRef, type SecretRef } from "./refs.ts";
|
|
11
12
|
|
|
12
13
|
const P = BRAND.envPrefix;
|
|
13
14
|
function setting(name: string): string | undefined {
|
|
@@ -71,7 +72,8 @@ export const VAULT_ENV_KEYS: readonly string[] = ["BW_SESSION", "BW_PASSWORD", "
|
|
|
71
72
|
export function childEnv(extra: Record<string, string> = {}): Record<string, string> {
|
|
72
73
|
const env: Record<string, string> = {};
|
|
73
74
|
for (const [name, value] of Object.entries(process.env)) if (value !== undefined && !VAULT_ENV_KEYS.includes(name)) env[name] = value;
|
|
74
|
-
|
|
75
|
+
for (const [name, value] of Object.entries(extra)) if (!isLoaderEnvName(name)) env[name] = value;
|
|
76
|
+
return env;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
export async function bw(args: string[], opts: { input?: string; session?: string } = {}): Promise<string> {
|
|
@@ -251,8 +253,8 @@ export function ensureSession(): Promise<string> {
|
|
|
251
253
|
|
|
252
254
|
async function writePrivateFile(path: string, content: string): Promise<void> {
|
|
253
255
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
254
|
-
const tmp = `${path}.tmp.${
|
|
255
|
-
await writeFile(tmp, content, { encoding: "utf8", mode: 0o600 });
|
|
256
|
+
const tmp = `${path}.tmp.${randomBytes(6).toString("hex")}`;
|
|
257
|
+
await writeFile(tmp, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
256
258
|
await chmod(tmp, 0o600);
|
|
257
259
|
await rename(tmp, path);
|
|
258
260
|
await chmod(path, 0o600);
|
|
@@ -455,6 +457,7 @@ export async function resolveEntries(entries: Array<{ name: string; raw: string
|
|
|
455
457
|
export async function envFileEntries(path: string): Promise<Array<{ name: string; raw: string }>> {
|
|
456
458
|
return parseEnvFile(await readFile(path, "utf8")).map(({ name, value }) => {
|
|
457
459
|
if (value.trim().startsWith("!")) throw new Error(`${path}: ${name} uses a shell command; env files accept only bw:// references and literals`);
|
|
460
|
+
if (isLoaderEnvName(name)) throw new Error(`${path}: ${name} changes what programs load or run; env files cannot set it`);
|
|
458
461
|
return { name, raw: value };
|
|
459
462
|
});
|
|
460
463
|
}
|
|
@@ -504,6 +507,12 @@ export async function writeRedactIndex(values: Record<string, string>): Promise<
|
|
|
504
507
|
return index;
|
|
505
508
|
}
|
|
506
509
|
|
|
510
|
+
// Re-index after the wired set changes so hooks mask a new value right away.
|
|
511
|
+
export async function refreshRedactIndex(): Promise<void> {
|
|
512
|
+
const env = await resolveEntries(await authEnvEntries());
|
|
513
|
+
await writeRedactIndex(indexValues(env.secrets));
|
|
514
|
+
}
|
|
515
|
+
|
|
507
516
|
export async function readRedactIndex(): Promise<RedactIndex | null> {
|
|
508
517
|
try {
|
|
509
518
|
const parsed = JSON.parse(await readFile(REDACT_INDEX_FILE, "utf8")) as RedactIndex;
|
|
@@ -531,7 +540,16 @@ export async function getSecret(itemName: string): Promise<string> {
|
|
|
531
540
|
try { return await bw(["get", "password", itemName]); } catch (error) { throw mapItemError(error, itemName); }
|
|
532
541
|
}
|
|
533
542
|
|
|
543
|
+
// An agent can start add/rotate, so a dialog could be used to phish the master
|
|
544
|
+
// password into an item it can then read; refuse it outright.
|
|
545
|
+
async function refuseMasterPassword(secret: string): Promise<void> {
|
|
546
|
+
if (secret && secret === await readMasterPassword()) throw new Error("Refusing to store the vault master password as a secret");
|
|
547
|
+
}
|
|
548
|
+
|
|
534
549
|
export async function createSecretItem(opts: { itemName: string; secret: string; envName?: string; notes?: string }): Promise<{ id: string }> {
|
|
550
|
+
const problem = itemNameProblem(opts.itemName);
|
|
551
|
+
if (problem) throw new Error(`Vault item name ${problem}`);
|
|
552
|
+
await refuseMasterPassword(opts.secret);
|
|
535
553
|
const existing = await findItems(opts.itemName);
|
|
536
554
|
if (existing.some((item) => item.name.toLowerCase() === opts.itemName.toLowerCase())) throw new Error(`Vault item already exists: ${opts.itemName}`);
|
|
537
555
|
const item: Record<string, unknown> = {
|
|
@@ -548,6 +566,7 @@ export async function createSecretItem(opts: { itemName: string; secret: string;
|
|
|
548
566
|
}
|
|
549
567
|
|
|
550
568
|
export async function rotateSecretItem(opts: { itemName: string; secret: string }): Promise<{ id: string }> {
|
|
569
|
+
await refuseMasterPassword(opts.secret);
|
|
551
570
|
let parsed: Record<string, unknown>;
|
|
552
571
|
try { parsed = JSON.parse(await bw(["get", "item", opts.itemName])) as Record<string, unknown>; } catch (error) { throw mapItemError(error, opts.itemName); }
|
|
553
572
|
const login = parsed.login && typeof parsed.login === "object" ? parsed.login as Record<string, unknown> : {};
|
|
@@ -565,45 +584,82 @@ export async function deleteItem(id: string, permanent = false): Promise<void> {
|
|
|
565
584
|
}
|
|
566
585
|
|
|
567
586
|
export async function readSecretFromInput(opts: { prompt: string; stdin: boolean }): Promise<string> {
|
|
568
|
-
if (opts.stdin
|
|
587
|
+
if (opts.stdin) {
|
|
569
588
|
let input = "";
|
|
570
589
|
for await (const chunk of process.stdin) input += String(chunk);
|
|
571
590
|
input = input.replace(/\r?\n$/, "");
|
|
572
591
|
if (!input) throw new Error("Empty secret");
|
|
573
592
|
return input;
|
|
574
593
|
}
|
|
594
|
+
if (!process.stdin.isTTY) throw new Error("No terminal to prompt on; pipe the value with --stdin");
|
|
575
595
|
const value = await readMasked(opts.prompt);
|
|
576
596
|
if (!value) throw new Error("Empty secret");
|
|
577
597
|
return value;
|
|
578
598
|
}
|
|
579
599
|
|
|
580
600
|
// Native hidden-input dialog for callers without a terminal (agent shells, MCP).
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
|
|
601
|
+
// An agent can open it, so the wording is fixed, says who asked, and warns
|
|
602
|
+
// against the master password; names are validated and passed as data (argv),
|
|
603
|
+
// never as script. One dialog at a time, with a pause after a cancel. Resolves
|
|
604
|
+
// null when no dialog can be shown (SSH, headless Linux, missing tools).
|
|
605
|
+
const DIALOG_LOCK = join(CONFIG_DIR, "dialog.lock");
|
|
606
|
+
const DIALOG_COOLDOWN_MS = 30_000;
|
|
607
|
+
const DIALOG_TIMEOUT_MS = 300_000;
|
|
608
|
+
|
|
609
|
+
export async function readSecretFromDialog(opts: { itemName: string; envName?: string }): Promise<string | null> {
|
|
585
610
|
if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return null;
|
|
586
|
-
const
|
|
611
|
+
const problem = itemNameProblem(opts.itemName);
|
|
612
|
+
if (problem) throw new Error(`Vault item name ${problem}`);
|
|
613
|
+
if (opts.envName !== undefined && !ENV_NAME_RE.test(opts.envName)) throw new Error("Variable names must be UPPER_SNAKE_CASE");
|
|
614
|
+
const text = [
|
|
615
|
+
opts.envName ? "A program on this computer, usually an AI agent, asked to store a NEW secret." : "A program on this computer, usually an AI agent, asked to REPLACE the value of a secret.",
|
|
616
|
+
"",
|
|
617
|
+
...(opts.envName ? [`Variable: ${opts.envName}`] : []),
|
|
618
|
+
`Vault item: ${opts.itemName}`,
|
|
619
|
+
"",
|
|
620
|
+
"This is not a login prompt. Never enter your vault master password here.",
|
|
621
|
+
].join("\n");
|
|
587
622
|
const candidates: Array<[string, string[]]> = process.platform === "darwin"
|
|
588
|
-
? [["osascript", ["-e",
|
|
589
|
-
'
|
|
590
|
-
|
|
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")]]]
|
|
623
|
+
? [["osascript", ["-e", "on run argv", "-e", 'activate', "-e",
|
|
624
|
+
'text returned of (display dialog (item 1 of argv) default answer "" with hidden answer with title "keyshroud" buttons {"Cancel", "Save"} default button "Save" cancel button "Cancel" with icon caution)',
|
|
625
|
+
"-e", "end run", "--", text]]]
|
|
593
626
|
: process.env.DISPLAY || process.env.WAYLAND_DISPLAY
|
|
594
|
-
? [["zenity", ["--entry", "--hide-text", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text]]]
|
|
627
|
+
? [["zenity", ["--entry", "--hide-text", "--no-markup", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text.replace(/[<>]/g, "")]]]
|
|
595
628
|
: [];
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
629
|
+
if (!candidates.length) return null;
|
|
630
|
+
return withDialogLock(async () => {
|
|
631
|
+
for (const [command, args] of candidates) {
|
|
632
|
+
const result = await new Promise<{ code: number | string | null; stdout: string; stderr: string }>((resolve) => {
|
|
633
|
+
execFile(command, args, { timeout: DIALOG_TIMEOUT_MS }, (error, stdout, stderr) => resolve({ code: error ? ((error as NodeJS.ErrnoException).code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) }));
|
|
634
|
+
});
|
|
635
|
+
if (result.code === "ENOENT") continue;
|
|
636
|
+
if (result.code === 0) return result.stdout.replace(/\r?\n$/, "");
|
|
637
|
+
if (command !== "osascript" || /-128/.test(result.stderr)) throw new Error("Cancelled");
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
604
640
|
return null;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function withDialogLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
645
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
646
|
+
let held = "";
|
|
647
|
+
try { held = await readFile(DIALOG_LOCK, "utf8"); } catch {}
|
|
648
|
+
const [kind, at] = held.trim().split(":");
|
|
649
|
+
const age = Date.now() - Number(at);
|
|
650
|
+
const live = (kind === "open" || kind === "cancelled") && age >= 0 && age < (kind === "cancelled" ? DIALOG_COOLDOWN_MS : DIALOG_TIMEOUT_MS + 30_000);
|
|
651
|
+
if (!live) await rm(DIALOG_LOCK, { force: true });
|
|
652
|
+
try { await writeFile(DIALOG_LOCK, `open:${Date.now()}\n`, { flag: "wx", mode: 0o600 }); }
|
|
653
|
+
catch { throw new Error(kind === "cancelled" ? "The last dialog was cancelled; wait 30 seconds before asking again" : "Another keyshroud dialog is already open"); }
|
|
654
|
+
try {
|
|
655
|
+
const value = await fn();
|
|
656
|
+
await rm(DIALOG_LOCK, { force: true });
|
|
657
|
+
return value;
|
|
658
|
+
} catch (error) {
|
|
659
|
+
if (error instanceof Error && error.message === "Cancelled") await writeFile(DIALOG_LOCK, `cancelled:${Date.now()}\n`, { mode: 0o600 });
|
|
660
|
+
else await rm(DIALOG_LOCK, { force: true });
|
|
661
|
+
throw error;
|
|
605
662
|
}
|
|
606
|
-
return null;
|
|
607
663
|
}
|
|
608
664
|
|
|
609
665
|
// Raw-mode prompt that echoes one "*" per character (capped, then a count), so
|
|
@@ -616,14 +672,25 @@ function readMasked(prompt: string): Promise<string> {
|
|
|
616
672
|
process.stderr.write(`\r\x1b[2K${prompt}${shown}`);
|
|
617
673
|
};
|
|
618
674
|
return new Promise((resolve, reject) => {
|
|
675
|
+
// After Enter, keep reading briefly: the rest of a multi-line paste must be
|
|
676
|
+
// consumed here (or it would reach the parent shell) and is then refused.
|
|
677
|
+
let ending: ReturnType<typeof setTimeout> | undefined;
|
|
678
|
+
let multiline = false;
|
|
679
|
+
const settle = () => {
|
|
680
|
+
clearTimeout(ending);
|
|
681
|
+
ending = setTimeout(() => finish(multiline ? new Error("Multi-line values are not supported in the prompt; pipe them with --stdin") : undefined), 100);
|
|
682
|
+
};
|
|
619
683
|
const finish = (error?: Error) => {
|
|
684
|
+
clearTimeout(ending);
|
|
620
685
|
stdin.off("data", onData); stdin.setRawMode(false); stdin.pause();
|
|
621
686
|
process.stderr.write("\n");
|
|
622
687
|
if (error) reject(error); else resolve(chars.join(""));
|
|
623
688
|
};
|
|
624
689
|
const onData = (chunk: string) => {
|
|
625
|
-
|
|
626
|
-
|
|
690
|
+
if (ending) { if (/[^\r\n]/.test(chunk)) multiline = true; return settle(); }
|
|
691
|
+
const clean = [...chunk.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b./g, "")];
|
|
692
|
+
for (const [index, ch] of clean.entries()) {
|
|
693
|
+
if (ch === "\r" || ch === "\n") { if (/[^\r\n]/.test(clean.slice(index + 1).join(""))) multiline = true; return settle(); }
|
|
627
694
|
if (ch === "\x03") return finish(new Error("Cancelled"));
|
|
628
695
|
if (ch === "\x04") return chars.length ? finish() : finish(new Error("Cancelled"));
|
|
629
696
|
if (ch === "\x7f" || ch === "\b") chars.pop();
|
package/src/mcp.ts
CHANGED
|
@@ -7,7 +7,7 @@ import process from "node:process";
|
|
|
7
7
|
import { BRAND } from "./brand.ts";
|
|
8
8
|
import * as core from "./core.ts";
|
|
9
9
|
import { createRedactor } from "./redact.ts";
|
|
10
|
-
import { ENV_NAME_RE } from "./refs.ts";
|
|
10
|
+
import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem } from "./refs.ts";
|
|
11
11
|
|
|
12
12
|
const SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
13
13
|
const OUTPUT_LIMIT = 256 * 1024;
|
|
@@ -85,13 +85,13 @@ async function runTool(args: Json): Promise<Json> {
|
|
|
85
85
|
detached: true,
|
|
86
86
|
});
|
|
87
87
|
const chunks = { stdout: [] as Buffer[], stderr: [] as Buffer[] };
|
|
88
|
-
// Collect generously, mask, then truncate
|
|
89
|
-
// before
|
|
90
|
-
|
|
88
|
+
// Collect generously per stream, mask, then truncate. If collection stopped
|
|
89
|
+
// early, the tail may end inside a secret, so it is dropped before masking.
|
|
90
|
+
const sizes = { stdout: 0, stderr: 0 };
|
|
91
91
|
for (const stream of ["stdout", "stderr"] as const) {
|
|
92
92
|
child[stream].on("data", (chunk: Buffer) => {
|
|
93
|
-
if (
|
|
94
|
-
|
|
93
|
+
if (sizes[stream] >= OUTPUT_LIMIT * 2) return;
|
|
94
|
+
sizes[stream] += chunk.length;
|
|
95
95
|
chunks[stream].push(chunk);
|
|
96
96
|
});
|
|
97
97
|
}
|
|
@@ -101,9 +101,11 @@ async function runTool(args: Json): Promise<Json> {
|
|
|
101
101
|
child.on("error", (error) => done(`spawn error: ${error.message}`));
|
|
102
102
|
});
|
|
103
103
|
clearTimeout(timer);
|
|
104
|
-
let truncated =
|
|
104
|
+
let truncated = false;
|
|
105
105
|
const out = (stream: "stdout" | "stderr") => {
|
|
106
|
-
|
|
106
|
+
let raw = Buffer.concat(chunks[stream]).toString("utf8");
|
|
107
|
+
if (sizes[stream] >= OUTPUT_LIMIT * 2) { truncated = true; raw = raw.slice(0, redactor.safeCut(raw)); }
|
|
108
|
+
const masked = redactor.redact(raw);
|
|
107
109
|
if (masked.length <= OUTPUT_LIMIT / 2) return masked;
|
|
108
110
|
truncated = true;
|
|
109
111
|
return masked.slice(0, OUTPUT_LIMIT / 2);
|
|
@@ -132,25 +134,32 @@ async function callTool(name: string, args: Json): Promise<Json> {
|
|
|
132
134
|
const envName = String(args.env_name ?? "");
|
|
133
135
|
const itemName = String(args.item_name ?? "");
|
|
134
136
|
if (!ENV_NAME_RE.test(envName) || !itemName) return text("env_name must be UPPER_SNAKE_CASE and item_name is required", true);
|
|
135
|
-
|
|
137
|
+
if (isLoaderEnvName(envName)) return text(`${envName} changes what programs load or run; it cannot be wired to a secret`, true);
|
|
138
|
+
const problem = itemNameProblem(itemName);
|
|
139
|
+
if (problem) return text(`item_name ${problem}`, true);
|
|
140
|
+
const notes = typeof args.purpose === "string" && args.purpose ? args.purpose.slice(0, 500) : undefined;
|
|
136
141
|
try {
|
|
142
|
+
const reference = core.referenceFor(itemName);
|
|
137
143
|
await core.ensureSession();
|
|
138
|
-
|
|
144
|
+
if ((await core.findItems(itemName)).some((item) => item.name.toLowerCase() === itemName.toLowerCase())) return text(`Vault item already exists: ${itemName}`, true);
|
|
145
|
+
const secret = await core.readSecretFromDialog({ itemName, envName });
|
|
139
146
|
if (secret === "") return text("The user saved an empty value; nothing was stored.", true);
|
|
140
147
|
if (secret !== null) {
|
|
141
148
|
await core.createSecretItem({ itemName, secret, envName, notes });
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
await core.audit("request", [envName], "mcp");
|
|
150
|
+
const conflicts: string[] = [];
|
|
151
|
+
for (const path of Object.values(core.getAuthTargets())) if (!(await core.writeAuthEntry(path, envName, reference)).ok) conflicts.push(path);
|
|
152
|
+
await core.refreshRedactIndex();
|
|
153
|
+
if (conflicts.length) return text(`created item=${itemName}, but ${envName} is already wired to another secret in ${conflicts.join(", ")} and was left unchanged. Ask the user before running \`${BRAND.bin} wire --env ${envName} --item ${shellQuote(itemName)} --force\`.`, true);
|
|
144
154
|
const verified = (await core.resolveShellValue(reference)) !== null;
|
|
145
155
|
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
156
|
}
|
|
147
157
|
} catch (error) {
|
|
148
158
|
const message = error instanceof Error ? error.message : String(error);
|
|
149
|
-
|
|
150
|
-
if (/already exists/.test(message)) return text(message, true);
|
|
159
|
+
return text(message === "Cancelled" ? "The user cancelled the dialog; nothing was stored." : message, true);
|
|
151
160
|
}
|
|
152
161
|
const purpose = notes ? ` --notes ${shellQuote(notes)}` : "";
|
|
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}.`);
|
|
162
|
+
return text(`No dialog can be shown here. Ask the user to run this in their own terminal (the value is entered in a masked prompt; it must never be pasted into the chat):\n\n ${BRAND.bin} add --env ${envName} --item ${shellQuote(itemName)}${purpose}\n\nAfter it reports verified=yes, use secrets_run with $${envName}.`);
|
|
154
163
|
}
|
|
155
164
|
if (name === "vault_status") {
|
|
156
165
|
try { await core.ensureSession(); } catch {}
|
package/src/redact.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function createRedactor(values: Record<string, string>): Redactor {
|
|
|
81
81
|
if (text.length - cut <= MAX_HOLD) return cut;
|
|
82
82
|
// Bounded fallback: cut near the end, moving forward past any complete
|
|
83
83
|
// secret that straddles it, so whole matches are still replaced.
|
|
84
|
-
cut = text.length - maxLength;
|
|
84
|
+
cut = Math.max(0, text.length - maxLength);
|
|
85
85
|
while (cut < text.length && straddling(cut)) cut++;
|
|
86
86
|
return cut;
|
|
87
87
|
},
|
package/src/refs.ts
CHANGED
|
@@ -55,6 +55,20 @@ export function legacyRefFor(itemName: string): string {
|
|
|
55
55
|
|
|
56
56
|
export const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
|
|
57
57
|
|
|
58
|
+
// Variables that change what a child process loads or runs. Env files can come
|
|
59
|
+
// from a cloned repo and agents can wire names, so neither may set these.
|
|
60
|
+
const LOADER_ENV_RE = /^(?:PATH|HOME|ZDOTDIR|XDG_CONFIG_HOME|IFS|ENV|BASH_ENV|SHELLOPTS|BASHOPTS|PS4|PROMPT_COMMAND|NODE_OPTIONS|NODE_PATH|NODE_EXTRA_CA_CERTS|PYTHONPATH|PYTHONHOME|PYTHONSTARTUP|PERL5OPT|PERL5LIB|PERLLIB|RUBYOPT|RUBYLIB|JAVA_TOOL_OPTIONS|_JAVA_OPTIONS|JDK_JAVA_OPTIONS|EDITOR|VISUAL|PAGER|LESSOPEN|LESSCLOSE|BROWSER|SSH_ASKPASS|LD_\w*|DYLD_\w*|NPM_CONFIG_\w*|GIT_(?:SSH|SSH_COMMAND|EXEC_PATH|ASKPASS|EDITOR|PAGER|EXTERNAL_DIFF|PROXY_COMMAND|DIR|WORK_TREE|TEMPLATE_DIR|CONFIG\w*))$/i;
|
|
61
|
+
export function isLoaderEnvName(name: string): boolean { return LOADER_ENV_RE.test(name); }
|
|
62
|
+
|
|
63
|
+
// Item names appear in native dialogs and lookups: one line of printable text,
|
|
64
|
+
// no padding (lookups trim, so a padded name would shadow an existing item).
|
|
65
|
+
export function itemNameProblem(name: string): string | null {
|
|
66
|
+
if (!name || name !== name.trim()) return "must not be empty or start or end with spaces";
|
|
67
|
+
if (name.length > 120) return "must be at most 120 characters";
|
|
68
|
+
if (/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(name)) return "must be one line of printable text";
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
// Parses dotenv-style files whose values may be references:
|
|
59
73
|
// GH_TOKEN=bw://GitHub CLI token
|
|
60
74
|
// export SENTRY_DSN="bw://Sentry/fields/dsn"
|
|
@@ -112,14 +126,17 @@ export function parseDotenvStrict(text: string): { entries: DotenvEntry[]; error
|
|
|
112
126
|
return { entries, errors };
|
|
113
127
|
}
|
|
114
128
|
|
|
115
|
-
const SECRET_NAME_RE = /(SECRET|TOKEN|PASSW(?:OR)?D|PASSPHRASE|PWD|(?:^|_)KEY(?:$|_)|APIKEY|PRIVATE|CREDENTIAL|AUTH|DSN|WEBHOOK|COOKIE|SIGNING|ENCRYPT|SALT)/i;
|
|
129
|
+
const SECRET_NAME_RE = /(SECRET|TOKEN|PASSW(?:OR)?D|PASSPHRASE|(?:^|_)PASS(?:$|_)|PWD|(?:^|_)KEY(?:$|_)|APIKEY|PRIVATE|CREDENTIAL|(?:^|_)CREDS?(?:$|_)|AUTH|JWT|DSN|WEBHOOK|COOKIE|SIGNING|ENCRYPT|SALT)/i;
|
|
116
130
|
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:@/]
|
|
131
|
+
const URL_PASSWORD_RE = /^[a-z][a-z0-9+.-]*:\/\/[^\s:@/]*:[^\s@/]+@/i;
|
|
118
132
|
|
|
119
|
-
// Local heuristic for `import`: never sends anything anywhere.
|
|
120
|
-
//
|
|
133
|
+
// Local heuristic for `import`: never sends anything anywhere. A known secret
|
|
134
|
+
// format wins over the name; otherwise names with a PUBLIC segment
|
|
135
|
+
// (NEXT_PUBLIC_*, VITE_PUBLIC_*) are meant to ship to browsers.
|
|
121
136
|
export function looksSecret(name: string, value: string): boolean {
|
|
122
|
-
if (!value || isRef(value)
|
|
123
|
-
if (SECRET_VALUE_RE.test(value) || URL_PASSWORD_RE.test(value)
|
|
137
|
+
if (!value || isRef(value)) return false;
|
|
138
|
+
if (SECRET_VALUE_RE.test(value) || URL_PASSWORD_RE.test(value)) return true;
|
|
139
|
+
if (/(?:^|_)PUBLIC(?:$|_)/i.test(name)) return false;
|
|
140
|
+
if (SECRET_NAME_RE.test(name) || /^[0-9a-f]{32,}$/i.test(value)) return true;
|
|
124
141
|
return value.length >= 24 && !/\s/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value) && /\d/.test(value);
|
|
125
142
|
}
|