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 +9 -0
- package/dist/commands/changes.js +3 -3
- package/dist/commands/selene.js +12 -7
- package/dist/selene/redaction.js +45 -0
- package/dist/utils/decisions.js +3 -4
- package/dist/utils/frontmatter.js +62 -0
- package/dist/utils/refinements.js +3 -3
- package/dist/version.js +1 -1
- package/package.json +3 -3
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
|
package/dist/commands/changes.js
CHANGED
|
@@ -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 =
|
|
45
|
+
const title = parseFrontmatter(raw).data.title;
|
|
46
46
|
return title ? String(title) : "";
|
|
47
47
|
};
|
|
48
|
-
const refinementLabel = (raw) =>
|
|
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 = "";
|
package/dist/commands/selene.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
+
}
|
package/dist/utils/decisions.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
|
-
import
|
|
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 } =
|
|
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
|
-
|
|
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
|
|
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 } =
|
|
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
|
|
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.
|
|
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
|
+
"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",
|