open-memex 0.2.0-alpha → 0.3.0-alpha

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/index.ts CHANGED
@@ -63,19 +63,26 @@ const plugin: Plugin = async ({ worktree, directory }) => {
63
63
 
64
64
  const hits = detectKeywords(text, cfg);
65
65
  for (const h of hits) {
66
- const { content, hadSecret } = redact(h.content, cfg.redactPatterns);
67
- if (hadSecret || content.length === 0) continue;
66
+ const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
67
+ // Secrets are masked (first 4 chars kept) and the capture proceeds;
68
+ // skip only when nothing usable remains.
69
+ if (content.length === 0) continue;
70
+ if (hadSecret && cfg.logLevel === "debug") {
71
+ console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
72
+ }
73
+ // Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
74
+ const target = h.personal ? PERSONAL_SCOPE : scope;
68
75
  // Dedup (§3.4): skip exact duplicates captured before.
69
- if (findDuplicates(scope.key, content).exact) continue;
76
+ if (findDuplicates(target.key, content).exact) continue;
70
77
  const now = Date.now();
71
78
  const rfc = msToRfc3339(now);
72
79
  const fm: Frontmatter = {
73
80
  id: ulid(),
74
81
  schema_version: 2,
75
- scope_key: scope.key,
76
- scope: scope.kind === "project" ? "project" : "personal",
77
- visibility: scope.kind === "project" ? "internal" : "private",
78
- project_name: scope.projectName,
82
+ scope_key: target.key,
83
+ scope: target.kind === "project" ? "project" : "personal",
84
+ visibility: target.kind === "project" ? "internal" : "private",
85
+ project_name: target.projectName,
79
86
  type: "fact",
80
87
  role: "knowledge",
81
88
  importance: "normal",
@@ -91,9 +98,11 @@ const plugin: Plugin = async ({ worktree, directory }) => {
91
98
  const { filePath } = writeMemoryFile(fm, content);
92
99
  const mf = readMemoryFile(filePath);
93
100
  if (mf) upsertFromFile(mf);
94
- if (cfg.logLevel === "debug") {
95
- console.log(`[open-memex] captured keyword memory ${fm.id}`);
96
- }
101
+ // Capture feedback: always visible (not debug-only) — the user said
102
+ // "记住…", they should see that it landed. The opencode plugin API
103
+ // offers no toast channel, so the plugin log is the feedback surface.
104
+ const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
105
+ console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
97
106
  } catch (err) {
98
107
  console.error("[open-memex] keyword capture failed:", err);
99
108
  }
package/src/init.ts ADDED
@@ -0,0 +1,304 @@
1
+ // `open-memex init` — one-command project setup (§17 adoption path).
2
+ // Pure file operation: no DB, no network. Safe to run in any directory.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { createInterface } from "node:readline/promises";
7
+ import { DEFAULT_CONFIG, saveConfig } from "./config.ts";
8
+
9
+ const MARKER = "<!-- open-memex -->";
10
+
11
+ /** npm dist-tag carrying the 0.3.x preview line. */
12
+ const ALPHA_TAG = "open-memex@alpha";
13
+
14
+ export interface McpCommand {
15
+ command: string;
16
+ args: string[];
17
+ /** false when no durable bin exists (e.g. one-shot npx) and we fell back to npx. */
18
+ durable: boolean;
19
+ }
20
+
21
+ /**
22
+ * Resolve the MCP server command to write into client configs.
23
+ *
24
+ * A one-shot `npx open-memex@alpha init` runs from npm's ephemeral `_npx` cache,
25
+ * so `open-memex` resolving on PATH *inside that process* does not mean it will be
26
+ * there tomorrow. Only a bin found on PATH outside `_npx` cache dirs counts as
27
+ * durable; otherwise fall back to an npx-based command (slower startup, zero install).
28
+ */
29
+ export function resolveMcpCommand(): McpCommand {
30
+ const pathEnv = process.env.PATH ?? "";
31
+ const dirs = pathEnv.split(path.delimiter).filter((d) => d && !d.includes("_npx"));
32
+ const names = process.platform === "win32" ? ["open-memex.cmd", "open-memex"] : ["open-memex"];
33
+ const durable = dirs.some((d) =>
34
+ names.some((n) => {
35
+ try {
36
+ return fs.existsSync(path.join(d, n));
37
+ } catch {
38
+ return false;
39
+ }
40
+ }),
41
+ );
42
+ if (durable) return { command: "open-memex", args: ["mcp"], durable: true };
43
+ return { command: "npx", args: ["-y", ALPHA_TAG, "mcp"], durable: false };
44
+ }
45
+
46
+ const INSTRUCTIONS = `${MARKER}
47
+ # OpenMemex memory
48
+
49
+ You have a local memory MCP server (\`open-memex\`) with five tools:
50
+ \`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget\`.
51
+
52
+ - BE PROACTIVE. When the user shares something worth remembering across sessions
53
+ (a decision, a preference, a project convention, a fix and its cause), call
54
+ \`memory_add\` without being asked. Keep each memory to one self-contained statement.
55
+ - Before asking the user about past decisions, conventions, or preferences they may
56
+ have told you before, call \`memory_search\` first — try a few keyword variants
57
+ (including the user's own language) when the first search comes up empty.
58
+ - Memories default to this project's scope; use the \`personal\` scope for facts about
59
+ the user that hold across all projects. When a saved fact becomes outdated, call
60
+ \`memory_supersede\` instead of adding a duplicate.
61
+ `;
62
+
63
+ /** Project root: git top-level, falling back to cwd. */
64
+ function projectRoot(): string {
65
+ try {
66
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
67
+ encoding: "utf8",
68
+ stdio: ["ignore", "pipe", "ignore"],
69
+ }).trim();
70
+ if (top) return top;
71
+ } catch {
72
+ /* not a git repo — use cwd */
73
+ }
74
+ return process.cwd();
75
+ }
76
+
77
+ function writeMcpJson(root: string, client: string, force: boolean): string | null {
78
+ if (client === "opencode") return writeOpencodeMcpJson(root, force);
79
+ if (client === "visualstudio") return writeVisualStudioMcpJson(root, force);
80
+ const dir = client === "cursor" ? path.join(root, ".cursor") : path.join(root, ".vscode");
81
+ const file = path.join(dir, "mcp.json");
82
+ const sectionKey = client === "cursor" ? "mcpServers" : "servers";
83
+
84
+ let doc: Record<string, unknown> = {};
85
+ if (fs.existsSync(file)) {
86
+ try {
87
+ doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
88
+ } catch {
89
+ console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
90
+ return null;
91
+ }
92
+ }
93
+
94
+ const section = ((doc[sectionKey] ??= {}) as Record<string, unknown>);
95
+ if (section["open-memex"] && !force) {
96
+ console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
97
+ return file;
98
+ }
99
+ // D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
100
+ const mc = resolveMcpCommand();
101
+ section["open-memex"] =
102
+ client === "cursor"
103
+ ? { command: mc.command, args: mc.args }
104
+ : {
105
+ type: "stdio",
106
+ command: mc.command,
107
+ args: mc.args,
108
+ cwd: "${workspaceFolder}",
109
+ };
110
+
111
+ fs.mkdirSync(dir, { recursive: true });
112
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
113
+ console.log(` + ${file}`);
114
+ if (!mc.durable) {
115
+ console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
116
+ console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
117
+ }
118
+ return file;
119
+ }
120
+
121
+ /** opencode MCP config: project-level opencode.jsonc, `type: "local"` + command array (v1 format). */
122
+ function writeOpencodeMcpJson(root: string, force: boolean): string | null {
123
+ const file = path.join(root, "opencode.jsonc");
124
+ let doc: Record<string, unknown> = {};
125
+ if (fs.existsSync(file)) {
126
+ try {
127
+ doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
128
+ } catch {
129
+ console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
130
+ return null;
131
+ }
132
+ }
133
+ const section = ((doc["mcp"] ??= {}) as Record<string, unknown>);
134
+ if (section["open-memex"] && !force) {
135
+ console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
136
+ return file;
137
+ }
138
+ // D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
139
+ const mc = resolveMcpCommand();
140
+ section["open-memex"] = {
141
+ type: "local",
142
+ command: [mc.command, ...mc.args],
143
+ enabled: true,
144
+ };
145
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
146
+ console.log(` + ${file}`);
147
+ if (!mc.durable) {
148
+ console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
149
+ console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
150
+ }
151
+ return file;
152
+ }
153
+
154
+ /** Visual Studio (Windows-only, 2022 17.14+ / 2026): solution-level `.mcp.json`
155
+ * with the `"servers"` section, per Microsoft Learn. Source-controllable.
156
+ * (VS also auto-discovers `.vscode/mcp.json` and `.cursor/mcp.json`.) */
157
+ function writeVisualStudioMcpJson(root: string, force: boolean): string | null {
158
+ const file = path.join(root, ".mcp.json");
159
+ let doc: Record<string, unknown> = {};
160
+ if (fs.existsSync(file)) {
161
+ try {
162
+ doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
163
+ } catch {
164
+ console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
165
+ return null;
166
+ }
167
+ }
168
+ const section = ((doc["servers"] ??= {}) as Record<string, unknown>);
169
+ if (section["open-memex"] && !force) {
170
+ console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
171
+ return file;
172
+ }
173
+ // D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
174
+ const mc = resolveMcpCommand();
175
+ section["open-memex"] = {
176
+ type: "stdio",
177
+ command: mc.command,
178
+ args: mc.args,
179
+ };
180
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
181
+ console.log(` + ${file}`);
182
+ if (!mc.durable) {
183
+ console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
184
+ console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
185
+ }
186
+ return file;
187
+ }
188
+
189
+ function writeInstructions(root: string): string {
190
+ const dir = path.join(root, ".github");
191
+ const file = path.join(dir, "copilot-instructions.md");
192
+ if (fs.existsSync(file)) {
193
+ const cur = fs.readFileSync(file, "utf8");
194
+ if (cur.includes(MARKER)) {
195
+ console.log(` = ${file} already has open-memex instructions — left as is`);
196
+ return file;
197
+ }
198
+ fs.writeFileSync(file, cur.replace(/\s+$/, "") + "\n\n" + INSTRUCTIONS);
199
+ } else {
200
+ fs.mkdirSync(dir, { recursive: true });
201
+ fs.writeFileSync(file, INSTRUCTIONS);
202
+ }
203
+ console.log(` + ${file}`);
204
+ return file;
205
+ }
206
+
207
+ export const INIT_CLIENTS = ["vscode", "cursor", "opencode", "visualstudio"] as const;
208
+
209
+ /** Normalize --client values; accepts "visual-studio" as an alias. */
210
+ export function normalizeClient(c: string): string {
211
+ const lower = c.toLowerCase();
212
+ return lower === "visual-studio" ? "visualstudio" : lower;
213
+ }
214
+
215
+ /** Ask a yes/no question. Only called on a TTY when --yes was not passed. */
216
+ async function askBool(q: string, def: boolean): Promise<boolean> {
217
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
218
+ try {
219
+ const hint = def ? "Y/n" : "y/N";
220
+ const ans = (await rl.question(`${q} [${hint}]: `)).trim().toLowerCase();
221
+ if (!ans) return def;
222
+ return ans === "y" || ans === "yes";
223
+ } finally {
224
+ rl.close();
225
+ }
226
+ }
227
+
228
+ async function promptClient(): Promise<string | null> {
229
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
230
+ try {
231
+ const ans = (
232
+ await rl.question(
233
+ "Which editor? (1) VS Code (2) Cursor (3) opencode (4) Visual Studio (5) skip [1]: ",
234
+ )
235
+ ).trim();
236
+ switch (ans) {
237
+ case "":
238
+ case "1":
239
+ return "vscode";
240
+ case "2":
241
+ return "cursor";
242
+ case "3":
243
+ return "opencode";
244
+ case "4":
245
+ return "visualstudio";
246
+ case "5":
247
+ return null;
248
+ default:
249
+ console.log(` ? unknown choice "${ans}" — editor setup skipped`);
250
+ return null;
251
+ }
252
+ } finally {
253
+ rl.close();
254
+ }
255
+ }
256
+
257
+ export async function initProject(opts: {
258
+ client?: string;
259
+ force: boolean;
260
+ yes: boolean;
261
+ }): Promise<void> {
262
+ const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
263
+ let client = normalizeClient(opts.client ?? "");
264
+ if (client && !(INIT_CLIENTS as readonly string[]).includes(client)) {
265
+ console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
266
+ process.exit(1);
267
+ }
268
+ if (!client && interactive) client = (await promptClient()) ?? "";
269
+ if (!client && !interactive) client = "vscode"; // historical default for scripts / one-shot npx
270
+ if (interactive) {
271
+ // Install-time settings (D19). Non-default answers persist to the JSONC
272
+ // config file; `open-memex config set` changes them later.
273
+ const patch: Record<string, unknown> = {};
274
+ const keywordCaptureEnabled = await askBool(
275
+ "Auto-capture keywords like 记住… / remember… into memory?",
276
+ DEFAULT_CONFIG.keywordCaptureEnabled,
277
+ );
278
+ if (keywordCaptureEnabled !== DEFAULT_CONFIG.keywordCaptureEnabled)
279
+ patch.keywordCaptureEnabled = keywordCaptureEnabled;
280
+ const injectOnFirstTurn = await askBool(
281
+ "Inject relevant memories when a session starts?",
282
+ DEFAULT_CONFIG.injectOnFirstTurn,
283
+ );
284
+ if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
285
+ patch.injectOnFirstTurn = injectOnFirstTurn;
286
+ if (Object.keys(patch).length > 0) {
287
+ const file = saveConfig(patch);
288
+ console.log(
289
+ ` + settings saved to ${file} (change later with \`open-memex config set <key> <value>\`)`,
290
+ );
291
+ }
292
+ }
293
+ const root = projectRoot();
294
+ console.log(`open-memex init — project root: ${root}`);
295
+ if (client) {
296
+ writeMcpJson(root, client, opts.force);
297
+ // copilot-instructions.md is VS Code/Cursor-shaped; opencode as a plain MCP
298
+ // consumer already gets the guidance from the tool descriptions (D16).
299
+ if (client !== "opencode") writeInstructions(root);
300
+ } else {
301
+ console.log(" - editor setup skipped");
302
+ }
303
+ console.log(`\nDone. Reload your editor window to start the open-memex MCP server.`);
304
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * open-memex generic MCP server (stdio transport).
3
+ *
4
+ * Exposes the same five memory tools as the opencode plugin
5
+ * (memory_add / memory_search / memory_list / memory_supersede /
6
+ * memory_forget) over the Model Context Protocol, so any MCP client —
7
+ * VS Code Copilot Chat, Cursor, Claude Code, etc. — can use open-memex
8
+ * without a host-specific plugin.
9
+ *
10
+ * Run: node --experimental-strip-types src/mcp.ts
11
+ * (or: npm run mcp)
12
+ *
13
+ * The project scope is resolved from the process working directory, so
14
+ * launch the server with cwd set to the project root (VS Code, Cursor and
15
+ * Claude Code all do this for workspace-configured MCP servers).
16
+ *
17
+ * IMPORTANT: stdout is the MCP protocol channel. Never log to stdout here;
18
+ * diagnostics go to stderr.
19
+ */
20
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
+ import { z } from "zod";
23
+ import { loadConfig } from "./config.ts";
24
+ import { resolveProjectScope, PERSONAL_SCOPE, type Scope } from "./scope.ts";
25
+ import { db } from "./store/db.ts";
26
+ import { syncScope } from "./store/sync.ts";
27
+ import {
28
+ addMemory,
29
+ searchMemories,
30
+ listMemories,
31
+ supersedeMemory,
32
+ forgetMemory,
33
+ memoryAddArgs,
34
+ memorySearchArgs,
35
+ memoryListArgs,
36
+ memorySupersedeArgs,
37
+ memoryForgetArgs,
38
+ TOOL_DESCRIPTIONS,
39
+ type ToolResult,
40
+ } from "./tools/ops.ts";
41
+
42
+ const SERVER_VERSION = "0.2.0-alpha";
43
+
44
+ /** Adapt a framework-agnostic op result to an MCP tool response. */
45
+ function toMcp(p: Promise<ToolResult>) {
46
+ return p.then(
47
+ (r) => ({ content: [{ type: "text" as const, text: r.output }] }),
48
+ (e: unknown) => ({
49
+ content: [
50
+ {
51
+ type: "text" as const,
52
+ text: `open-memex error: ${(e as Error)?.message ?? String(e)}`,
53
+ },
54
+ ],
55
+ isError: true as const,
56
+ }),
57
+ );
58
+ }
59
+
60
+ export async function runMcpServer() {
61
+ const cfg = loadConfig();
62
+ const scope: Scope = resolveProjectScope(process.cwd());
63
+ const getScope = () => scope;
64
+
65
+ // Init DB and one-shot sync of markdown -> index, mirroring the plugin.
66
+ db();
67
+ syncScope(scope.key);
68
+ syncScope(PERSONAL_SCOPE.key);
69
+ console.error(`[open-memex] MCP server up. scope=${scope.key}`);
70
+
71
+ const server = new McpServer({ name: "open-memex", version: SERVER_VERSION });
72
+
73
+ server.registerTool(
74
+ "memory_add",
75
+ {
76
+ description: TOOL_DESCRIPTIONS.memory_add,
77
+ inputSchema: z.object(memoryAddArgs),
78
+ },
79
+ (args) => toMcp(addMemory(getScope, cfg, args)),
80
+ );
81
+
82
+ server.registerTool(
83
+ "memory_search",
84
+ {
85
+ description: TOOL_DESCRIPTIONS.memory_search,
86
+ inputSchema: z.object(memorySearchArgs),
87
+ annotations: { readOnlyHint: true },
88
+ },
89
+ (args) => toMcp(searchMemories(getScope, args)),
90
+ );
91
+
92
+ server.registerTool(
93
+ "memory_list",
94
+ {
95
+ description: TOOL_DESCRIPTIONS.memory_list,
96
+ inputSchema: z.object(memoryListArgs),
97
+ annotations: { readOnlyHint: true },
98
+ },
99
+ (args) => toMcp(listMemories(getScope, args)),
100
+ );
101
+
102
+ server.registerTool(
103
+ "memory_supersede",
104
+ {
105
+ description: TOOL_DESCRIPTIONS.memory_supersede,
106
+ inputSchema: z.object(memorySupersedeArgs),
107
+ },
108
+ (args) => toMcp(supersedeMemory(cfg, args)),
109
+ );
110
+
111
+ server.registerTool(
112
+ "memory_forget",
113
+ {
114
+ description: TOOL_DESCRIPTIONS.memory_forget,
115
+ inputSchema: z.object(memoryForgetArgs),
116
+ annotations: { destructiveHint: true },
117
+ },
118
+ (args) => toMcp(forgetMemory(args)),
119
+ );
120
+
121
+ const transport = new StdioServerTransport();
122
+ await server.connect(transport);
123
+ }
124
+
125
+ // Standalone entry: `node --experimental-strip-types src/mcp.ts`.
126
+ // The CLI (`open-memex mcp`) imports runMcpServer() instead.
127
+ import { pathToFileURL } from "node:url";
128
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
129
+ runMcpServer().catch((err) => {
130
+ console.error("[open-memex] MCP server failed:", err);
131
+ process.exit(1);
132
+ });
133
+ }
package/src/redact.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Redaction: secrets must never land in memory files or the index.
2
+ * Redaction: secrets must never land in memory files or the index in
3
+ * readable form.
3
4
  *
4
- * Three layers, checked in order by redact():
5
+ * Layers, in order:
5
6
  * 1. <private>...</private> regions are stripped first (explicit opt-out —
6
7
  * the author marked this span as sensitive, so it is replaced with
7
8
  * [REDACTED] before any detection runs).
@@ -10,8 +11,11 @@
10
11
  * 4. High-entropy assignment heuristic (catches secrets whose provider we
11
12
  * don't have a pattern for, e.g. `deploy_key = "aB3d..."`).
12
13
  *
13
- * Any hit refuses the whole write — a memory with a hole in it is worse
14
- * than no memory, because the hole invites reconstruction.
14
+ * A detected secret does NOT refuse the write: the matched string is masked
15
+ * in place — first 4 characters kept, the rest replaced with 'x' — and the
16
+ * write proceeds with the masked content. hadSecret reports that masking
17
+ * happened so callers can surface a notice. A partially-masked memory still
18
+ * identifies which credential it referred to without storing the secret.
15
19
  */
16
20
 
17
21
  export interface SecretPattern {
@@ -21,6 +25,13 @@ export interface SecretPattern {
21
25
  source: string;
22
26
  /** Optional RegExp flags, e.g. "i". */
23
27
  flags?: string;
28
+ /**
29
+ * Capture-group index holding the secret value. When set, masking replaces
30
+ * only that group — the credential name stays readable (D14: a masked
31
+ * memory should still identify which key it referred to). Default: mask
32
+ * the whole match.
33
+ */
34
+ valueGroup?: number;
24
35
  }
25
36
 
26
37
  /**
@@ -56,8 +67,9 @@ export const BUILTIN_SECRET_PATTERNS: SecretPattern[] = [
56
67
  {
57
68
  id: "aws-secret-access-key",
58
69
  source:
59
- "aws[_-]?secret[_-]?access[_-]?key[\"']?\\s*[:=]\\s*[\"']?[A-Za-z0-9/+=]{40}",
70
+ "(aws[_-]?secret[_-]?access[_-]?key)([\"']?\\s*[:=]\\s*[\"']?)([A-Za-z0-9/+=]{40})",
60
71
  flags: "i",
72
+ valueGroup: 3,
61
73
  },
62
74
  { id: "slack-token", source: `${TOKEN_BOUNDARY}xox[baprs]-[A-Za-z0-9-]{10,}` },
63
75
  { id: "google-api-key", source: `${TOKEN_BOUNDARY}AIza[0-9A-Za-z_-]{30,}` },
@@ -71,7 +83,19 @@ export const BUILTIN_SECRET_PATTERNS: SecretPattern[] = [
71
83
  id: "stripe-webhook-secret",
72
84
  source: `${TOKEN_BOUNDARY}whsec_[A-Za-z0-9]{20,}`,
73
85
  },
74
- { id: "private-key-block", source: "-----BEGIN [A-Z ]*PRIVATE KEY-----" },
86
+ {
87
+ id: "private-key-block",
88
+ // Whole block: masking only the BEGIN header would leave the base64 body
89
+ // readable in the memory file. Non-greedy so two blocks mask separately.
90
+ source:
91
+ "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----",
92
+ },
93
+ {
94
+ id: "private-key-truncated",
95
+ // No END marker: mask from the header to end of text. A header without a
96
+ // body is still key material; over-masking is the safe direction.
97
+ source: "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*$",
98
+ },
75
99
  {
76
100
  id: "jwt",
77
101
  source: `${TOKEN_BOUNDARY}eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}`,
@@ -79,8 +103,9 @@ export const BUILTIN_SECRET_PATTERNS: SecretPattern[] = [
79
103
  {
80
104
  id: "generic-secret-assignment",
81
105
  source:
82
- "(api[_-]?key|secret|passwd|password|auth[_-]?token|access[_-]?token)[\"']?\\s*[:=]\\s*[\"']?[A-Za-z0-9_\\-./+=]{16,}[\"']?",
106
+ "(api[_-]?key|secret|passwd|password|auth[_-]?token|access[_-]?token)([\"']?\\s*[:=]\\s*[\"']?)([A-Za-z0-9_\\-./+=]{16,})([\"']?)",
83
107
  flags: "i",
108
+ valueGroup: 3,
84
109
  },
85
110
  ];
86
111
 
@@ -139,12 +164,31 @@ export function findHighEntropySecret(text: string): string | null {
139
164
  for (const m of text.matchAll(ASSIGNMENT_RE)) {
140
165
  const name = m[1];
141
166
  const value = m[2];
142
- if (value.includes("://")) continue; // URL, not a secret
143
- if (shannonEntropy(value) >= 4.5) return `high-entropy-secret:${name}`;
167
+ if (!isSuspectAssignmentValue(value, m[0], m.index ?? 0, text)) continue;
168
+ return `high-entropy-secret:${name}`;
144
169
  }
145
170
  return null;
146
171
  }
147
172
 
173
+ /**
174
+ * Shared benign-value rule for detection AND masking: a URL (e.g. the value
175
+ * after "https:") or a low-entropy value must never be touched. Note the
176
+ * "://" check: ASSIGNMENT_RE consumes the colon of "https:" as the separator,
177
+ * so the captured value starts with "//..." — check for "://" in the
178
+ * original text around the match, not just inside the value.
179
+ */
180
+ function isSuspectAssignmentValue(value: string, fullMatch: string, offset: number, text: string): boolean {
181
+ // Bare URL: ASSIGNMENT_RE consumed the scheme colon ("https:") as the
182
+ // separator, so the match itself starts with "scheme://".
183
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(fullMatch)) return false;
184
+ if (value.includes("://")) return false;
185
+ // Reconstruct what preceded the value inside the match (name + separator);
186
+ // if the text right before the value looks like scheme://, it is a URL.
187
+ const before = text.slice(Math.max(0, offset - 12), offset);
188
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/)?$/.test(before.trim()) || before.includes("://")) return false;
189
+ return shannonEntropy(value) >= 4.5;
190
+ }
191
+
148
192
  export function stripPrivate(text: string): string {
149
193
  // Closed pairs first...
150
194
  let out = text.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]");
@@ -154,18 +198,78 @@ export function stripPrivate(text: string): string {
154
198
  return out;
155
199
  }
156
200
 
201
+ /**
202
+ * Mask a matched secret: keep the first 4 characters, replace the rest
203
+ * with 'x' (length-preserving). "sk-1234567890abcdefghij" → "sk-1xxxxxxxxxxxxx".
204
+ */
205
+ function maskMatch(m: string): string {
206
+ return m.slice(0, 4) + "x".repeat(Math.max(0, m.length - 4));
207
+ }
208
+
209
+ /** Apply the builtin patterns as masking (global, all matches). */
210
+ function maskBuiltin(content: string): string {
211
+ let out = content;
212
+ for (const p of BUILTIN_SECRET_PATTERNS) {
213
+ const re = compile(p);
214
+ if (!re) continue;
215
+ const g = new RegExp(re.source, re.flags + "g");
216
+ if (p.valueGroup == null) {
217
+ out = out.replace(g, (m) => maskMatch(m));
218
+ } else {
219
+ // Mask only the secret-value group; the credential name stays readable.
220
+ out = out.replace(g, (...args: unknown[]) => {
221
+ const m = args[0] as string;
222
+ const val = args[p.valueGroup as number] as string | undefined;
223
+ if (!val) return m;
224
+ const idx = m.lastIndexOf(val);
225
+ if (idx < 0) return m;
226
+ return m.slice(0, idx) + maskMatch(val) + m.slice(idx + val.length);
227
+ });
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /** Apply user patterns as masking. Invalid regexes are ignored. */
234
+ function maskUser(content: string, patterns: string[]): string {
235
+ let out = content;
236
+ for (const p of patterns) {
237
+ try {
238
+ out = out.replace(new RegExp(p, "g"), (m) => maskMatch(m));
239
+ } catch {
240
+ // ignore malformed regex
241
+ }
242
+ }
243
+ return out;
244
+ }
245
+
246
+ /** Mask the value side of high-entropy assignments (name stays readable).
247
+ * Uses the exact same benign-value rule as detection, so URLs and
248
+ * low-entropy values are never touched even when another secret triggers. */
249
+ function maskEntropy(content: string): string {
250
+ return content.replace(
251
+ ASSIGNMENT_RE,
252
+ (m, name: string, value: string, offset: number) => {
253
+ if (!isSuspectAssignmentValue(value, m, offset, content)) return m;
254
+ return m.split(value).join(maskMatch(value));
255
+ },
256
+ );
257
+ }
258
+
157
259
  export function redact(
158
260
  text: string,
159
261
  patterns: string[],
160
262
  ): { content: string; hadSecret: boolean; matchedPattern: string | null } {
161
263
  const stripped = stripPrivate(text);
264
+ // Detection (for reporting) runs in priority order: builtin → user → entropy.
162
265
  const builtin = findBuiltinSecret(stripped);
163
- if (builtin)
164
- return { content: stripped, hadSecret: true, matchedPattern: builtin };
165
- const user = findSecret(stripped, patterns);
166
- if (user) return { content: stripped, hadSecret: true, matchedPattern: user };
167
- const entropic = findHighEntropySecret(stripped);
168
- if (entropic)
169
- return { content: stripped, hadSecret: true, matchedPattern: entropic };
170
- return { content: stripped, hadSecret: false, matchedPattern: null };
266
+ const user = builtin ? null : findSecret(stripped, patterns);
267
+ const entropic =
268
+ builtin || user ? null : findHighEntropySecret(stripped);
269
+ const matched = builtin ?? user ?? entropic;
270
+ if (!matched) return { content: stripped, hadSecret: false, matchedPattern: null };
271
+ // Masking runs every family over the text (not just the reported one) so
272
+ // multiple credentials in one memory are all masked.
273
+ const masked = maskEntropy(maskUser(maskBuiltin(stripped), patterns));
274
+ return { content: masked, hadSecret: true, matchedPattern: matched };
171
275
  }