whale-igniter 1.5.3 → 1.5.4

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 CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to Whale Igniter are documented here.
4
4
 
5
+ ## 1.5.4 - 2026-07-08
6
+
7
+ ### Security
8
+
9
+ - Added automatic secret redaction before Selene prompts are copied, written, cached, or sent to Anthropic/OpenAI.
10
+ - Added `npm run security:scan` to detect likely committed secrets in source and compiled output.
11
+ - Added the security scan to `prepublishOnly`, after build and before tests, so npm releases fail before publishing if high-confidence secrets are found.
12
+ - Replaced the `gray-matter`/`js-yaml` dependency path with a local frontmatter parser, restoring a zero-vulnerability production audit.
13
+
5
14
  ## 1.5.3 - 2026-07-06
6
15
 
7
16
  ### Added
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import matter from "gray-matter";
3
2
  import { resolveTarget } from "../utils/paths.js";
4
3
  import { loadComponents } from "../utils/components.js";
4
+ import { parseFrontmatter } from "../utils/frontmatter.js";
5
5
  import { ui } from "../ui/index.js";
6
6
  const DECISIONS_DIR = "intelligence/decisions";
7
7
  const REFINEMENTS_DIR = "intelligence/refinements";
@@ -42,10 +42,10 @@ function mdLabelAtRef(target, ref, file, label) {
42
42
  }
43
43
  }
44
44
  const decisionLabel = (raw) => {
45
- const title = matter(raw).data?.title;
45
+ const title = parseFrontmatter(raw).data.title;
46
46
  return title ? String(title) : "";
47
47
  };
48
- const refinementLabel = (raw) => matter(raw).content.trim().split("\n")[0] ?? "";
48
+ const refinementLabel = (raw) => parseFrontmatter(raw).content.trim().split("\n")[0] ?? "";
49
49
  function gitDirChanges(target, since, dir, label) {
50
50
  const result = { added: [], removed: [], modified: [] };
51
51
  let raw = "";
@@ -14,6 +14,7 @@ import { copyToClipboard } from "../selene/clipboard.js";
14
14
  import { resolveProvider, describeProviderState } from "../selene/providers.js";
15
15
  import { callProvider, estimateTokens, estimateCostUsd } from "../selene/apiClient.js";
16
16
  import { readCache, writeCache, clearCache } from "../selene/cache.js";
17
+ import { redactSecrets } from "../selene/redaction.js";
17
18
  import { ui } from "../ui/index.js";
18
19
  const PENDING_DIR = ".whale/selene";
19
20
  // ---------------------------------------------------------------------------
@@ -83,6 +84,10 @@ async function emitPrompt(target, prompt, kind, options) {
83
84
  * prompt mode, etc. Centralising keeps the policy consistent.
84
85
  */
85
86
  async function runSelene(target, kind, prompt, options) {
87
+ const safePrompt = redactSecrets(prompt);
88
+ if (safePrompt !== prompt) {
89
+ console.log(ui.warning("Potential secrets were redacted before Selene output/API use."));
90
+ }
86
91
  const config = await loadConfig(target);
87
92
  const sel = config.selene ?? {};
88
93
  // Step 1: figure out the mode.
@@ -103,7 +108,7 @@ async function runSelene(target, kind, prompt, options) {
103
108
  process.exitCode = 1;
104
109
  return { rawResponse: null };
105
110
  }
106
- await emitPrompt(target, prompt, kind, options);
111
+ await emitPrompt(target, safePrompt, kind, options);
107
112
  return { rawResponse: null };
108
113
  }
109
114
  // ---- API mode --------------------------------------------------------------
@@ -111,7 +116,7 @@ async function runSelene(target, kind, prompt, options) {
111
116
  // Cache lookup, unless the user opted out.
112
117
  const cacheEnabled = !options.noCache && !sel.noCache;
113
118
  if (cacheEnabled) {
114
- const cached = await readCache(target, resolved.model, prompt);
119
+ const cached = await readCache(target, resolved.model, safePrompt);
115
120
  if (cached) {
116
121
  console.log(ui.muted(" cache hit — no API call needed"));
117
122
  // Still stash the response so `apply` from disk works if the user wants.
@@ -122,7 +127,7 @@ async function runSelene(target, kind, prompt, options) {
122
127
  // Cost confirmation.
123
128
  const confirm = options.confirmCost || sel.confirmCost;
124
129
  if (confirm) {
125
- const inputTokens = estimateTokens(prompt);
130
+ const inputTokens = estimateTokens(safePrompt);
126
131
  const expectedOutput = sel.maxTokens ?? 1500;
127
132
  const cost = estimateCostUsd(resolved.model, inputTokens, expectedOutput);
128
133
  const costStr = cost === null ? "unknown" : `≈ $${cost.toFixed(4)}`;
@@ -135,13 +140,13 @@ async function runSelene(target, kind, prompt, options) {
135
140
  });
136
141
  if (!ok) {
137
142
  console.log(ui.warning("Aborted — falling back to prompt mode."));
138
- await emitPrompt(target, prompt, kind, options);
143
+ await emitPrompt(target, safePrompt, kind, options);
139
144
  return { rawResponse: null };
140
145
  }
141
146
  }
142
147
  const spin = ora({ text: "Calling provider", stream: process.stderr }).start();
143
148
  const result = await callProvider(resolved, {
144
- prompt,
149
+ prompt: safePrompt,
145
150
  maxTokens: sel.maxTokens ?? 1500,
146
151
  temperature: sel.temperature ?? 0.2
147
152
  });
@@ -152,7 +157,7 @@ async function runSelene(target, kind, prompt, options) {
152
157
  }
153
158
  // Graceful fallback: still emit the prompt so the user isn't stuck.
154
159
  console.log(ui.warning("\nFalling back to prompt mode so you can run this manually:"));
155
- await emitPrompt(target, prompt, kind, options);
160
+ await emitPrompt(target, safePrompt, kind, options);
156
161
  return { rawResponse: null };
157
162
  }
158
163
  const usage = result.inputTokens !== null && result.outputTokens !== null
@@ -160,7 +165,7 @@ async function runSelene(target, kind, prompt, options) {
160
165
  : "";
161
166
  spin.succeed(`Response received${usage}`);
162
167
  if (cacheEnabled) {
163
- await writeCache(target, resolved.model, prompt, result.text);
168
+ await writeCache(target, resolved.model, safePrompt, result.text);
164
169
  }
165
170
  await stashResponse(target, kind, result.text);
166
171
  return { rawResponse: result.text };
@@ -0,0 +1,45 @@
1
+ const REDACTED = "[REDACTED_SECRET]";
2
+ const SECRET_PATTERNS = [
3
+ {
4
+ name: "private-key",
5
+ pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g
6
+ },
7
+ { name: "openai-key", pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
8
+ { name: "github-token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g },
9
+ { name: "slack-token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
10
+ { name: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
11
+ { name: "google-api-key", pattern: /\bAIza[0-9A-Za-z_-]{30,}\b/g },
12
+ { name: "anthropic-key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g }
13
+ ];
14
+ const ASSIGNMENT_PATTERN = /\b(api[_-]?key|token|secret|password|passwd|client[_-]?secret|private[_-]?key)\b(\s*[:=]\s*)(["']?)([^"'\s,;)}\]]{16,})(\3)/gi;
15
+ function redactAssignment(match, key, separator, quote, value) {
16
+ if (isLikelyPlaceholder(value))
17
+ return match;
18
+ return `${key}${separator}${quote}${REDACTED}${quote}`;
19
+ }
20
+ function isLikelyPlaceholder(value) {
21
+ const normalized = value.toLowerCase();
22
+ return [
23
+ "example",
24
+ "placeholder",
25
+ "changeme",
26
+ "dummy",
27
+ "fake",
28
+ "mock",
29
+ "sample",
30
+ "test",
31
+ "xxxx",
32
+ "..."
33
+ ].some((needle) => normalized.includes(needle));
34
+ }
35
+ export function redactSecrets(input) {
36
+ let output = input;
37
+ for (const { pattern } of SECRET_PATTERNS) {
38
+ output = output.replace(pattern, REDACTED);
39
+ }
40
+ output = output.replace(ASSIGNMENT_PATTERN, redactAssignment);
41
+ return output;
42
+ }
43
+ export function containsRedactedSecret(input) {
44
+ return redactSecrets(input) !== input;
45
+ }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
- import matter from "gray-matter";
3
+ import { parseFrontmatter, stringifyFrontmatter } from "./frontmatter.js";
4
4
  const DIR = "intelligence/decisions";
5
5
  const LEGACY_STORE = "intelligence/decisions.json";
6
6
  function slugify(title) {
@@ -17,7 +17,7 @@ function section(body, heading) {
17
17
  return text && text.length > 0 ? text : undefined;
18
18
  }
19
19
  function parseFile(raw) {
20
- const { data, content } = matter(raw);
20
+ const { data, content } = parseFrontmatter(raw);
21
21
  const decision = section(content, "Decision");
22
22
  if (!data.id || !data.title || !decision)
23
23
  return null;
@@ -34,7 +34,7 @@ function parseFile(raw) {
34
34
  };
35
35
  }
36
36
  function serialize(d) {
37
- const fm = matter.stringify([
37
+ return stringifyFrontmatter([
38
38
  "## Context",
39
39
  d.context ?? "",
40
40
  "",
@@ -51,7 +51,6 @@ function serialize(d) {
51
51
  createdAt: d.timestamp,
52
52
  supersedes: d.supersedes ?? null
53
53
  });
54
- return fm;
55
54
  }
56
55
  async function loadLegacy(target) {
57
56
  const file = path.join(target, LEGACY_STORE);
@@ -0,0 +1,62 @@
1
+ function parseValue(raw) {
2
+ const value = raw.trim();
3
+ if (value === "null")
4
+ return null;
5
+ if (value === "true")
6
+ return true;
7
+ if (value === "false")
8
+ return false;
9
+ if (/^-?\d+(?:\.\d+)?$/.test(value))
10
+ return Number(value);
11
+ if ((value.startsWith('"') && value.endsWith('"')) ||
12
+ (value.startsWith("'") && value.endsWith("'"))) {
13
+ try {
14
+ return JSON.parse(value);
15
+ }
16
+ catch {
17
+ return value.slice(1, -1);
18
+ }
19
+ }
20
+ return value;
21
+ }
22
+ function stringifyValue(value) {
23
+ if (value === null || value === undefined)
24
+ return "null";
25
+ if (typeof value === "boolean" || typeof value === "number")
26
+ return String(value);
27
+ const text = String(value);
28
+ if (text === "" || /[:#\n\r]|^\s|\s$/.test(text))
29
+ return JSON.stringify(text);
30
+ return text;
31
+ }
32
+ export function parseFrontmatter(raw) {
33
+ if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) {
34
+ return { data: {}, content: raw };
35
+ }
36
+ const lineEnding = raw.startsWith("---\r\n") ? "\r\n" : "\n";
37
+ const startLength = 3 + lineEnding.length;
38
+ const endMarker = `${lineEnding}---${lineEnding}`;
39
+ const end = raw.indexOf(endMarker, startLength);
40
+ if (end === -1)
41
+ return { data: {}, content: raw };
42
+ const block = raw.slice(startLength, end);
43
+ const content = raw.slice(end + endMarker.length);
44
+ const data = {};
45
+ for (const line of block.split(/\r?\n/)) {
46
+ if (!line.trim())
47
+ continue;
48
+ const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
49
+ if (!match)
50
+ continue;
51
+ data[match[1]] = parseValue(match[2]);
52
+ }
53
+ return { data, content };
54
+ }
55
+ export function stringifyFrontmatter(content, data) {
56
+ const lines = ["---"];
57
+ for (const [key, value] of Object.entries(data)) {
58
+ lines.push(`${key}: ${stringifyValue(value)}`);
59
+ }
60
+ lines.push("---", content);
61
+ return lines.join("\n");
62
+ }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
- import matter from "gray-matter";
3
+ import { parseFrontmatter, stringifyFrontmatter } from "./frontmatter.js";
4
4
  const DIR = "intelligence/refinements";
5
5
  const LEGACY_STORE = "intelligence/refinements.json";
6
6
  function slugify(text) {
@@ -11,7 +11,7 @@ function slugify(text) {
11
11
  .slice(0, 60);
12
12
  }
13
13
  function parseFile(raw) {
14
- const { data, content } = matter(raw);
14
+ const { data, content } = parseFrontmatter(raw);
15
15
  const note = content.trim();
16
16
  if (!data.id || !note)
17
17
  return null;
@@ -40,7 +40,7 @@ function serialize(r) {
40
40
  fm.selector = r.scope.selector;
41
41
  if (r.scope?.file)
42
42
  fm.file = r.scope.file;
43
- return matter.stringify(r.note, fm);
43
+ return stringifyFrontmatter(r.note, fm);
44
44
  }
45
45
  async function loadLegacy(target) {
46
46
  const file = path.join(target, LEGACY_STORE);
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = "1.5.3";
1
+ export const PACKAGE_VERSION = "1.5.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whale-igniter",
3
- "version": "1.5.3",
3
+ "version": "1.5.4",
4
4
  "description": "CLI-first operational intelligence. Bootstraps and adopts AI-readable project context so agents like Claude Code, Codex and Cursor understand your design system from the first commit. Includes an MCP server, a file watcher, deterministic insights, and an opt-in AI bridge (Selene).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -23,8 +23,9 @@
23
23
  "build": "tsc",
24
24
  "postbuild": "node -e \"require('node:fs').chmodSync('dist/index.js', 0o755)\"",
25
25
  "start": "node dist/index.js",
26
+ "security:scan": "tsx scripts/security-scan.ts",
26
27
  "test": "tsx tests/run.ts && tsx tests/mcp-smoke.ts && tsx tests/extractors.ts && tsx tests/driftAnalyzers.ts",
27
- "prepublishOnly": "npm run build && npm test"
28
+ "prepublishOnly": "npm run build && npm run security:scan && npm test"
28
29
  },
29
30
  "engines": {
30
31
  "node": ">=20.0.0"
@@ -66,7 +67,6 @@
66
67
  "commander": "^12.1.0",
67
68
  "fs-extra": "^11.2.0",
68
69
  "glob": "^13.0.6",
69
- "gray-matter": "^4.0.3",
70
70
  "ora": "^8.1.0",
71
71
  "postcss": "^8.4.47",
72
72
  "prompts": "^2.4.2",