keyshroud 0.5.2 → 0.6.0
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 +42 -0
- package/README.md +47 -7
- package/dist/cli.js +195 -28
- package/dist/core.d.ts +18 -4
- package/dist/core.js +268 -36
- 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 +6 -2
- package/src/cli.ts +140 -22
- package/src/core.ts +228 -34
- package/src/mcp.ts +24 -15
- package/src/redact.ts +1 -1
- package/src/refs.ts +23 -6
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
|
}
|