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/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, so a cut never splits a secret
89
- // before redaction.
90
- let size = 0;
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 (size >= OUTPUT_LIMIT * 4) return;
94
- size += chunk.length;
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 = size >= OUTPUT_LIMIT * 4;
104
+ let truncated = false;
105
105
  const out = (stream: "stdout" | "stderr") => {
106
- const masked = redactor.redact(Buffer.concat(chunks[stream]).toString("utf8"));
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
- const notes = typeof args.purpose === "string" && args.purpose ? args.purpose : undefined;
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
- const secret = await core.readSecretFromDialog({ label: envName, itemName });
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
- const reference = core.referenceFor(itemName);
143
- for (const path of Object.values(core.getAuthTargets())) await core.writeAuthEntry(path, envName, reference);
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
- if (message === "Cancelled") return text("The user cancelled the dialog; nothing was stored.", true);
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:@/]+:[^\s@/]+@/i;
131
+ const URL_PASSWORD_RE = /^[a-z][a-z0-9+.-]*:\/\/[^\s:@/]*:[^\s@/]+@/i;
118
132
 
119
- // Local heuristic for `import`: never sends anything anywhere. Names that say
120
- // PUBLIC (NEXT_PUBLIC_*, VITE_PUBLIC_*) are meant to ship to browsers.
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) || /PUBLIC/i.test(name)) return false;
123
- if (SECRET_VALUE_RE.test(value) || URL_PASSWORD_RE.test(value) || SECRET_NAME_RE.test(name)) return true;
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
  }