keyshroud 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/README.md +61 -20
- package/dist/cli.js +177 -17
- package/dist/core.d.ts +5 -0
- package/dist/core.js +172 -12
- package/dist/hooks.js +2 -2
- package/dist/mcp.js +48 -11
- package/dist/redact.js +1 -1
- package/dist/refs.d.ts +16 -0
- package/dist/refs.js +92 -0
- package/package.json +1 -1
- package/skills/keyshroud/SKILL.md +7 -3
- package/src/cli.ts +127 -13
- package/src/core.ts +133 -12
- package/src/hooks.ts +2 -2
- package/src/mcp.ts +37 -11
- package/src/redact.ts +1 -1
- package/src/refs.ts +66 -0
package/dist/core.js
CHANGED
|
@@ -1,14 +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
|
-
import * as readline from "node:readline";
|
|
9
9
|
import { BRAND } from "./brand.js";
|
|
10
10
|
import { buildRedactIndex } from "./redact.js";
|
|
11
|
-
import { ENV_NAME_RE, legacyRefFor, parseEnvFile, parseRef } from "./refs.js";
|
|
11
|
+
import { ENV_NAME_RE, isLoaderEnvName, itemNameProblem, legacyRefFor, parseEnvFile, parseRef } from "./refs.js";
|
|
12
12
|
const P = BRAND.envPrefix;
|
|
13
13
|
function setting(name) {
|
|
14
14
|
const value = process.env[`${P}_${name}`]?.trim();
|
|
@@ -61,7 +61,10 @@ export function childEnv(extra = {}) {
|
|
|
61
61
|
for (const [name, value] of Object.entries(process.env))
|
|
62
62
|
if (value !== undefined && !VAULT_ENV_KEYS.includes(name))
|
|
63
63
|
env[name] = value;
|
|
64
|
-
|
|
64
|
+
for (const [name, value] of Object.entries(extra))
|
|
65
|
+
if (!isLoaderEnvName(name))
|
|
66
|
+
env[name] = value;
|
|
67
|
+
return env;
|
|
65
68
|
}
|
|
66
69
|
export async function bw(args, opts = {}) {
|
|
67
70
|
const session = opts.session ?? process.env.BW_SESSION;
|
|
@@ -239,8 +242,8 @@ export function ensureSession() {
|
|
|
239
242
|
}
|
|
240
243
|
async function writePrivateFile(path, content) {
|
|
241
244
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
242
|
-
const tmp = `${path}.tmp.${
|
|
243
|
-
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" });
|
|
244
247
|
await chmod(tmp, 0o600);
|
|
245
248
|
await rename(tmp, path);
|
|
246
249
|
await chmod(path, 0o600);
|
|
@@ -486,6 +489,8 @@ export async function envFileEntries(path) {
|
|
|
486
489
|
return parseEnvFile(await readFile(path, "utf8")).map(({ name, value }) => {
|
|
487
490
|
if (value.trim().startsWith("!"))
|
|
488
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`);
|
|
489
494
|
return { name, raw: value };
|
|
490
495
|
});
|
|
491
496
|
}
|
|
@@ -540,6 +545,11 @@ export async function writeRedactIndex(values) {
|
|
|
540
545
|
await writePrivateFile(REDACT_INDEX_FILE, `${JSON.stringify(index)}\n`);
|
|
541
546
|
return index;
|
|
542
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
|
+
}
|
|
543
553
|
export async function readRedactIndex() {
|
|
544
554
|
try {
|
|
545
555
|
const parsed = JSON.parse(await readFile(REDACT_INDEX_FILE, "utf8"));
|
|
@@ -574,7 +584,17 @@ export async function getSecret(itemName) {
|
|
|
574
584
|
throw mapItemError(error, itemName);
|
|
575
585
|
}
|
|
576
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
|
+
}
|
|
577
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);
|
|
578
598
|
const existing = await findItems(opts.itemName);
|
|
579
599
|
if (existing.some((item) => item.name.toLowerCase() === opts.itemName.toLowerCase()))
|
|
580
600
|
throw new Error(`Vault item already exists: ${opts.itemName}`);
|
|
@@ -599,6 +619,7 @@ export async function createSecretItem(opts) {
|
|
|
599
619
|
return { id: String(created.id) };
|
|
600
620
|
}
|
|
601
621
|
export async function rotateSecretItem(opts) {
|
|
622
|
+
await refuseMasterPassword(opts.secret);
|
|
602
623
|
let parsed;
|
|
603
624
|
try {
|
|
604
625
|
parsed = JSON.parse(await bw(["get", "item", opts.itemName]));
|
|
@@ -621,7 +642,7 @@ export async function deleteItem(id, permanent = false) {
|
|
|
621
642
|
invalidateSecretCache();
|
|
622
643
|
}
|
|
623
644
|
export async function readSecretFromInput(opts) {
|
|
624
|
-
if (opts.stdin
|
|
645
|
+
if (opts.stdin) {
|
|
625
646
|
let input = "";
|
|
626
647
|
for await (const chunk of process.stdin)
|
|
627
648
|
input += String(chunk);
|
|
@@ -630,13 +651,152 @@ export async function readSecretFromInput(opts) {
|
|
|
630
651
|
throw new Error("Empty secret");
|
|
631
652
|
return input;
|
|
632
653
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const value = await new Promise((resolve) => rl.question("", resolve));
|
|
637
|
-
rl.close();
|
|
638
|
-
process.stderr.write("\n");
|
|
654
|
+
if (!process.stdin.isTTY)
|
|
655
|
+
throw new Error("No terminal to prompt on; pipe the value with --stdin");
|
|
656
|
+
const value = await readMasked(opts.prompt);
|
|
639
657
|
if (!value)
|
|
640
658
|
throw new Error("Empty secret");
|
|
641
659
|
return value;
|
|
642
660
|
}
|
|
661
|
+
// Native hidden-input dialog for callers without a terminal (agent shells, MCP).
|
|
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;
|
|
669
|
+
export async function readSecretFromDialog(opts) {
|
|
670
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
|
|
671
|
+
return null;
|
|
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");
|
|
685
|
+
const candidates = process.platform === "darwin"
|
|
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]]]
|
|
689
|
+
: process.env.DISPLAY || process.env.WAYLAND_DISPLAY
|
|
690
|
+
? [["zenity", ["--entry", "--hide-text", "--no-markup", "--title=keyshroud", `--text=${text}`]], ["kdialog", ["--title", "keyshroud", "--password", text.replace(/[<>]/g, "")]]]
|
|
691
|
+
: [];
|
|
692
|
+
if (!candidates.length)
|
|
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;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// Raw-mode prompt that echoes one "*" per character (capped, then a count), so
|
|
742
|
+
// the user sees typing and pastes land without the value ever reaching the screen.
|
|
743
|
+
function readMasked(prompt) {
|
|
744
|
+
const stdin = process.stdin;
|
|
745
|
+
const chars = [];
|
|
746
|
+
const draw = () => {
|
|
747
|
+
const shown = "*".repeat(Math.min(chars.length, 24)) + (chars.length > 24 ? ` (${chars.length} chars)` : "");
|
|
748
|
+
process.stderr.write(`\r\x1b[2K${prompt}${shown}`);
|
|
749
|
+
};
|
|
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
|
+
};
|
|
759
|
+
const finish = (error) => {
|
|
760
|
+
clearTimeout(ending);
|
|
761
|
+
stdin.off("data", onData);
|
|
762
|
+
stdin.setRawMode(false);
|
|
763
|
+
stdin.pause();
|
|
764
|
+
process.stderr.write("\n");
|
|
765
|
+
if (error)
|
|
766
|
+
reject(error);
|
|
767
|
+
else
|
|
768
|
+
resolve(chars.join(""));
|
|
769
|
+
};
|
|
770
|
+
const onData = (chunk) => {
|
|
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
|
+
}
|
|
783
|
+
if (ch === "\x03")
|
|
784
|
+
return finish(new Error("Cancelled"));
|
|
785
|
+
if (ch === "\x04")
|
|
786
|
+
return chars.length ? finish() : finish(new Error("Cancelled"));
|
|
787
|
+
if (ch === "\x7f" || ch === "\b")
|
|
788
|
+
chars.pop();
|
|
789
|
+
else if (ch === "\x15")
|
|
790
|
+
chars.length = 0;
|
|
791
|
+
else if (ch >= " ")
|
|
792
|
+
chars.push(ch);
|
|
793
|
+
}
|
|
794
|
+
draw();
|
|
795
|
+
};
|
|
796
|
+
stdin.setRawMode(true);
|
|
797
|
+
stdin.setEncoding("utf8");
|
|
798
|
+
stdin.resume();
|
|
799
|
+
stdin.on("data", onData);
|
|
800
|
+
draw();
|
|
801
|
+
});
|
|
802
|
+
}
|
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
|
@@ -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 = [
|
|
@@ -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"],
|
|
@@ -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,8 +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
|
-
|
|
145
|
-
|
|
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;
|
|
155
|
+
try {
|
|
156
|
+
const reference = core.referenceFor(itemName);
|
|
157
|
+
await core.ensureSession();
|
|
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 });
|
|
161
|
+
if (secret === "")
|
|
162
|
+
return text("The user saved an empty value; nothing was stored.", true);
|
|
163
|
+
if (secret !== null) {
|
|
164
|
+
await core.createSecretItem({ itemName, secret, envName, notes });
|
|
165
|
+
await core.audit("request", [envName], "mcp");
|
|
166
|
+
const conflicts = [];
|
|
167
|
+
for (const path of Object.values(core.getAuthTargets()))
|
|
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);
|
|
173
|
+
const verified = (await core.resolveShellValue(reference)) !== null;
|
|
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}.`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
179
|
+
return text(message === "Cancelled" ? "The user cancelled the dialog; nothing was stored." : message, true);
|
|
180
|
+
}
|
|
181
|
+
const purpose = notes ? ` --notes ${shellQuote(notes)}` : "";
|
|
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}.`);
|
|
146
183
|
}
|
|
147
184
|
if (name === "vault_status") {
|
|
148
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,7 +10,23 @@ 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;
|
|
16
18
|
}>;
|
|
19
|
+
export interface DotenvEntry {
|
|
20
|
+
line: number;
|
|
21
|
+
name: string;
|
|
22
|
+
value: string;
|
|
23
|
+
exported: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare function parseDotenvStrict(text: string): {
|
|
26
|
+
entries: DotenvEntry[];
|
|
27
|
+
errors: Array<{
|
|
28
|
+
line: number;
|
|
29
|
+
reason: string;
|
|
30
|
+
}>;
|
|
31
|
+
};
|
|
32
|
+
export declare function looksSecret(name: string, value: string): boolean;
|
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"
|
|
@@ -80,3 +95,80 @@ export function parseEnvFile(text) {
|
|
|
80
95
|
});
|
|
81
96
|
return out;
|
|
82
97
|
}
|
|
98
|
+
// Stricter than parseEnvFile, for `import`: a misread value would be stored in
|
|
99
|
+
// the vault, so anything ambiguous is reported by line number (never by value)
|
|
100
|
+
// instead of guessed. Values must also read back unchanged through parseEnvFile.
|
|
101
|
+
export function parseDotenvStrict(text) {
|
|
102
|
+
const entries = [];
|
|
103
|
+
const errors = [];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
text.split(/\r?\n/).forEach((raw, index) => {
|
|
106
|
+
const line = index + 1;
|
|
107
|
+
const trimmed = raw.trim();
|
|
108
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
109
|
+
return;
|
|
110
|
+
const match = /^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(trimmed);
|
|
111
|
+
if (!match) {
|
|
112
|
+
errors.push({ line, reason: "not a NAME=value line" });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const rest = match[3].trim();
|
|
116
|
+
let value;
|
|
117
|
+
if (rest.startsWith("'") || rest.startsWith('"')) {
|
|
118
|
+
const end = rest.indexOf(rest[0], 1);
|
|
119
|
+
if (end < 0) {
|
|
120
|
+
errors.push({ line, reason: "unterminated quote (multiline values are not supported)" });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const after = rest.slice(end + 1).trim();
|
|
124
|
+
if (after && !/^#/.test(after)) {
|
|
125
|
+
errors.push({ line, reason: "text after the closing quote" });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (after && end + 1 === rest.indexOf("#", end)) {
|
|
129
|
+
errors.push({ line, reason: "comment must be separated from the closing quote by a space" });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
value = rest.slice(1, end);
|
|
133
|
+
if (rest[0] === '"' && /[\\$]/.test(value)) {
|
|
134
|
+
errors.push({ line, reason: "escape sequence or $ expansion inside double quotes" });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
value = rest.replace(/\s+#.*$/, "");
|
|
140
|
+
if (value.includes("${")) {
|
|
141
|
+
errors.push({ line, reason: "${} expansion" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (value.startsWith("!")) {
|
|
146
|
+
errors.push({ line, reason: "value starts with '!' (env files cannot run commands)" });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (seen.has(match[2])) {
|
|
150
|
+
errors.push({ line, reason: `duplicate key ${match[2]}` });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
seen.add(match[2]);
|
|
154
|
+
entries.push({ line, name: match[2], value, exported: Boolean(match[1]) });
|
|
155
|
+
});
|
|
156
|
+
return { entries, errors };
|
|
157
|
+
}
|
|
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;
|
|
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)/;
|
|
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.
|
|
164
|
+
export function looksSecret(name, 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))
|
|
170
|
+
return false;
|
|
171
|
+
if (SECRET_NAME_RE.test(name) || /^[0-9a-f]{32,}$/i.test(value))
|
|
172
|
+
return true;
|
|
173
|
+
return value.length >= 24 && !/\s/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value) && /\d/.test(value);
|
|
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": [
|
|
@@ -11,12 +11,16 @@ 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, 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:
|
|
23
|
+
then give the user the command to run in their own terminal.
|
|
20
24
|
2. Never print, echo, `cat`, base64, or otherwise transform a secret value.
|
|
21
25
|
`<redacted:NAME>` in output means the secret was used correctly. Do not try
|
|
22
26
|
to recover it.
|