infernoflow 0.33.0 → 0.34.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/README.md +208 -120
- package/dist/bin/infernoflow.mjs +271 -85
- package/dist/lib/adopters/angular.mjs +128 -1
- package/dist/lib/adopters/css.mjs +111 -1
- package/dist/lib/adopters/react.mjs +104 -1
- package/dist/lib/ai/ideDetection.mjs +31 -1
- package/dist/lib/ai/localProvider.mjs +88 -1
- package/dist/lib/ai/providerRouter.mjs +295 -2
- package/dist/lib/commands/adopt.mjs +869 -20
- package/dist/lib/commands/adoptWizard.mjs +320 -9
- package/dist/lib/commands/agent.mjs +191 -5
- package/dist/lib/commands/ai.mjs +407 -2
- package/dist/lib/commands/ask.mjs +299 -0
- package/dist/lib/commands/audit.mjs +300 -13
- package/dist/lib/commands/changelog.mjs +594 -26
- package/dist/lib/commands/check.mjs +184 -3
- package/dist/lib/commands/ci.mjs +208 -3
- package/dist/lib/commands/claudeMd.mjs +139 -28
- package/dist/lib/commands/cloud.mjs +521 -5
- package/dist/lib/commands/context.mjs +346 -34
- package/dist/lib/commands/coverage.mjs +282 -2
- package/dist/lib/commands/dashboard.mjs +635 -123
- package/dist/lib/commands/demo.mjs +465 -8
- package/dist/lib/commands/diff.mjs +274 -5
- package/dist/lib/commands/docGate.mjs +81 -2
- package/dist/lib/commands/doctor.mjs +321 -3
- package/dist/lib/commands/explain.mjs +438 -8
- package/dist/lib/commands/export.mjs +239 -10
- package/dist/lib/commands/generateSkills.mjs +163 -38
- package/dist/lib/commands/graph.mjs +378 -11
- package/dist/lib/commands/health.mjs +309 -2
- package/dist/lib/commands/impact.mjs +325 -2
- package/dist/lib/commands/implement.mjs +103 -7
- package/dist/lib/commands/init.mjs +545 -23
- package/dist/lib/commands/installCursorHooks.mjs +36 -1
- package/dist/lib/commands/installVsCodeCopilotHooks.mjs +37 -1
- package/dist/lib/commands/link.mjs +342 -2
- package/dist/lib/commands/log.mjs +164 -16
- package/dist/lib/commands/monorepo.mjs +428 -4
- package/dist/lib/commands/notify.mjs +258 -4
- package/dist/lib/commands/onboard.mjs +296 -4
- package/dist/lib/commands/prComment.mjs +361 -2
- package/dist/lib/commands/prImpact.mjs +157 -2
- package/dist/lib/commands/publish.mjs +316 -15
- package/dist/lib/commands/recap.mjs +359 -0
- package/dist/lib/commands/report.mjs +272 -28
- package/dist/lib/commands/review.mjs +223 -9
- package/dist/lib/commands/run.mjs +336 -8
- package/dist/lib/commands/scaffold.mjs +419 -54
- package/dist/lib/commands/scan.mjs +1118 -5
- package/dist/lib/commands/scout.mjs +291 -2
- package/dist/lib/commands/setup.mjs +310 -5
- package/dist/lib/commands/share.mjs +196 -13
- package/dist/lib/commands/snapshot.mjs +383 -3
- package/dist/lib/commands/stability.mjs +293 -2
- package/dist/lib/commands/stats.mjs +402 -0
- package/dist/lib/commands/status.mjs +172 -4
- package/dist/lib/commands/suggest.mjs +563 -21
- package/dist/lib/commands/switch.mjs +310 -0
- package/dist/lib/commands/syncAuto.mjs +96 -1
- package/dist/lib/commands/synthesize.mjs +228 -10
- package/dist/lib/commands/teamSync.mjs +388 -2
- package/dist/lib/commands/test.mjs +363 -6
- package/dist/lib/commands/theme.mjs +195 -18
- package/dist/lib/commands/upgrade.mjs +153 -0
- package/dist/lib/commands/version.mjs +282 -2
- package/dist/lib/commands/vibe.mjs +357 -7
- package/dist/lib/commands/watch.mjs +203 -4
- package/dist/lib/commands/why.mjs +358 -4
- package/dist/lib/cursorHooksInstall.mjs +60 -1
- package/dist/lib/draftToolingInstall.mjs +68 -7
- package/dist/lib/git/detect-drift.mjs +208 -4
- package/dist/lib/learning/adapt.mjs +101 -6
- package/dist/lib/learning/observe.mjs +119 -1
- package/dist/lib/learning/patternDetector.mjs +298 -1
- package/dist/lib/learning/profile.mjs +279 -2
- package/dist/lib/learning/skillSynthesizer.mjs +145 -24
- package/dist/lib/templates/index.mjs +131 -1
- package/dist/lib/theme/scanner.mjs +343 -4
- package/dist/lib/ui/errors.mjs +142 -1
- package/dist/lib/ui/output.mjs +72 -6
- package/dist/lib/ui/prompts.mjs +147 -6
- package/dist/lib/vsCodeCopilotHooksInstall.mjs +42 -1
- package/package.json +1 -1
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow ask
|
|
3
|
+
*
|
|
4
|
+
* Query session memory by keyword, topic, or type.
|
|
5
|
+
* The killer use case: before an AI agent tries something, it asks
|
|
6
|
+
* "what have we already tried for this?" — avoiding repeated mistakes.
|
|
7
|
+
*
|
|
8
|
+
* Ranking:
|
|
9
|
+
* 1. gotcha (highest — these are landmines, must surface first)
|
|
10
|
+
* 2. decision (architectural choices)
|
|
11
|
+
* 3. attempt (things tried, especially failed ones)
|
|
12
|
+
* 4. preference
|
|
13
|
+
* 5. theme
|
|
14
|
+
* 6. note / error / handoff
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* infernoflow ask "auth" Search all entries containing "auth"
|
|
18
|
+
* infernoflow ask "upload flow" Multi-word fuzzy search
|
|
19
|
+
* infernoflow ask --type gotcha All gotchas, no filter
|
|
20
|
+
* infernoflow ask "s3" --type attempt Attempts mentioning S3
|
|
21
|
+
* infernoflow ask --recent Last 10 entries regardless of type
|
|
22
|
+
* infernoflow ask "stripe" --json Machine-readable results
|
|
23
|
+
*
|
|
24
|
+
* MCP use (agent-facing):
|
|
25
|
+
* infernoflow_ask({ query: "what did we try for payments?" })
|
|
26
|
+
* → Returns relevant entries so the agent avoids repeating failed work
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
32
|
+
|
|
33
|
+
const INFERNO_DIR = "inferno";
|
|
34
|
+
const SESSIONS_FILE = path.join(INFERNO_DIR, "sessions.jsonl");
|
|
35
|
+
|
|
36
|
+
// ── Type priority (lower = shown first) ──────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const TYPE_PRIORITY = {
|
|
39
|
+
gotcha: 0,
|
|
40
|
+
decision: 1,
|
|
41
|
+
attempt: 2,
|
|
42
|
+
preference: 3,
|
|
43
|
+
theme: 4,
|
|
44
|
+
note: 5,
|
|
45
|
+
error: 5,
|
|
46
|
+
handoff: 6,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const TYPE_ICONS = {
|
|
50
|
+
gotcha: "⚠",
|
|
51
|
+
decision: "✓",
|
|
52
|
+
attempt: "↺",
|
|
53
|
+
preference: "♦",
|
|
54
|
+
theme: "🎨",
|
|
55
|
+
note: "·",
|
|
56
|
+
error: "✗",
|
|
57
|
+
handoff: "→",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const TYPE_COLORS = {
|
|
61
|
+
gotcha: yellow,
|
|
62
|
+
decision: green,
|
|
63
|
+
attempt: cyan,
|
|
64
|
+
preference: cyan,
|
|
65
|
+
theme: cyan,
|
|
66
|
+
note: gray,
|
|
67
|
+
error: red,
|
|
68
|
+
handoff: gray,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ── Tokeniser for fuzzy matching ──────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function tokenise(str) {
|
|
74
|
+
return str.toLowerCase()
|
|
75
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
76
|
+
.split(/\s+/)
|
|
77
|
+
.filter(t => t.length > 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Score an entry against a query.
|
|
82
|
+
* Returns 0 if no match, >0 otherwise.
|
|
83
|
+
* Higher score = more relevant.
|
|
84
|
+
*/
|
|
85
|
+
function scoreEntry(entry, queryTokens) {
|
|
86
|
+
const text = [entry.summary || "", entry.type || ""].join(" ").toLowerCase();
|
|
87
|
+
const entryTokens = tokenise(text);
|
|
88
|
+
|
|
89
|
+
let score = 0;
|
|
90
|
+
for (const qt of queryTokens) {
|
|
91
|
+
// Exact substring match in summary (strongest signal)
|
|
92
|
+
if ((entry.summary || "").toLowerCase().includes(qt)) {
|
|
93
|
+
score += 3;
|
|
94
|
+
}
|
|
95
|
+
// Token in entry text
|
|
96
|
+
if (entryTokens.includes(qt)) {
|
|
97
|
+
score += 1;
|
|
98
|
+
}
|
|
99
|
+
// Partial prefix match (e.g. "auth" matches "authentication")
|
|
100
|
+
if (entryTokens.some(et => et.startsWith(qt) || qt.startsWith(et))) {
|
|
101
|
+
score += 0.5;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return score;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── formatters ────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function fmtRelDate(iso) {
|
|
111
|
+
if (!iso) return "";
|
|
112
|
+
const d = new Date(iso);
|
|
113
|
+
const diff = Date.now() - d.getTime();
|
|
114
|
+
const days = Math.floor(diff / 86400000);
|
|
115
|
+
if (days === 0) return "today";
|
|
116
|
+
if (days === 1) return "yesterday";
|
|
117
|
+
if (days < 7) return `${days}d ago`;
|
|
118
|
+
if (days < 30) return `${Math.floor(days / 7)}w ago`;
|
|
119
|
+
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short" });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function printEntry(entry, highlight) {
|
|
123
|
+
const type = entry.type || "note";
|
|
124
|
+
const icon = TYPE_ICONS[type] || "·";
|
|
125
|
+
const colorFn = TYPE_COLORS[type] || gray;
|
|
126
|
+
const result = entry.result ? gray(` [${entry.result}]`) : "";
|
|
127
|
+
const agent = entry.agent ? gray(` — ${entry.agent}`) : "";
|
|
128
|
+
const date = gray(` (${fmtRelDate(entry.ts)})`);
|
|
129
|
+
|
|
130
|
+
let summary = entry.summary || "";
|
|
131
|
+
|
|
132
|
+
// Bold the matched keyword in the summary
|
|
133
|
+
if (highlight) {
|
|
134
|
+
for (const word of highlight) {
|
|
135
|
+
const re = new RegExp(`(${word})`, "gi");
|
|
136
|
+
summary = summary.replace(re, (m) => bold(m));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log(` ${colorFn(icon + " " + type.padEnd(11))}${result}${agent}${date}`);
|
|
141
|
+
console.log(` ${summary}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── load + search ─────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
function loadSessions(cwd) {
|
|
147
|
+
const p = path.join(cwd, SESSIONS_FILE);
|
|
148
|
+
if (!fs.existsSync(p)) return [];
|
|
149
|
+
return fs.readFileSync(p, "utf8")
|
|
150
|
+
.split("\n").filter(Boolean)
|
|
151
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
152
|
+
.filter(Boolean);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function search(entries, queryTokens, typeFilter, limit) {
|
|
156
|
+
let candidates = entries;
|
|
157
|
+
|
|
158
|
+
// Filter by type if specified
|
|
159
|
+
if (typeFilter) {
|
|
160
|
+
candidates = candidates.filter(e => (e.type || "note") === typeFilter);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Score and filter
|
|
164
|
+
let scored;
|
|
165
|
+
if (queryTokens.length > 0) {
|
|
166
|
+
scored = candidates
|
|
167
|
+
.map(e => ({ entry: e, score: scoreEntry(e, queryTokens) }))
|
|
168
|
+
.filter(({ score }) => score > 0)
|
|
169
|
+
.sort((a, b) => {
|
|
170
|
+
// Primary: score descending
|
|
171
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
172
|
+
// Secondary: type priority
|
|
173
|
+
const pa = TYPE_PRIORITY[a.entry.type] ?? 9;
|
|
174
|
+
const pb = TYPE_PRIORITY[b.entry.type] ?? 9;
|
|
175
|
+
if (pa !== pb) return pa - pb;
|
|
176
|
+
// Tertiary: newest first
|
|
177
|
+
return new Date(b.entry.ts || 0) - new Date(a.entry.ts || 0);
|
|
178
|
+
});
|
|
179
|
+
} else {
|
|
180
|
+
// No query — sort by type priority then newest
|
|
181
|
+
scored = candidates
|
|
182
|
+
.map(e => ({ entry: e, score: 1 }))
|
|
183
|
+
.sort((a, b) => {
|
|
184
|
+
const pa = TYPE_PRIORITY[a.entry.type] ?? 9;
|
|
185
|
+
const pb = TYPE_PRIORITY[b.entry.type] ?? 9;
|
|
186
|
+
if (pa !== pb) return pa - pb;
|
|
187
|
+
return new Date(b.entry.ts || 0) - new Date(a.entry.ts || 0);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return scored.slice(0, limit || 20);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── entry point ───────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
export async function askCommand(rawArgs = []) {
|
|
197
|
+
const args = rawArgs;
|
|
198
|
+
|
|
199
|
+
// Parse flags
|
|
200
|
+
const typeIdx = args.indexOf("--type");
|
|
201
|
+
const typeFilter = typeIdx !== -1 ? args[typeIdx + 1] : null;
|
|
202
|
+
const limitIdx = args.indexOf("--limit") !== -1 ? args.indexOf("--limit") : args.indexOf("-n");
|
|
203
|
+
const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1] || "20", 10) : 15;
|
|
204
|
+
const jsonMode = args.includes("--json");
|
|
205
|
+
const recentMode = args.includes("--recent") || args.includes("-r");
|
|
206
|
+
|
|
207
|
+
// Query = all non-flag args joined
|
|
208
|
+
const queryWords = args.filter((a, i) => {
|
|
209
|
+
if (a.startsWith("--")) return false;
|
|
210
|
+
if (i > 0 && args[i - 1].startsWith("--")) return false; // flag value
|
|
211
|
+
return true;
|
|
212
|
+
});
|
|
213
|
+
const queryStr = queryWords.join(" ").trim();
|
|
214
|
+
const queryTokens = recentMode ? [] : tokenise(queryStr);
|
|
215
|
+
|
|
216
|
+
const cwd = process.cwd();
|
|
217
|
+
const sessions = loadSessions(cwd);
|
|
218
|
+
|
|
219
|
+
if (sessions.length === 0) {
|
|
220
|
+
if (jsonMode) { console.log(JSON.stringify({ results: [], total: 0 })); return; }
|
|
221
|
+
console.log(gray("\n No session memory yet."));
|
|
222
|
+
console.log(gray(" Run: infernoflow log \"<what happened>\" --type gotcha\n"));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// For --recent: just return last N entries without scoring
|
|
227
|
+
const results = recentMode
|
|
228
|
+
? sessions.slice(-limit).reverse().map(e => ({ entry: e, score: 1 }))
|
|
229
|
+
: search(sessions, queryTokens, typeFilter, limit);
|
|
230
|
+
|
|
231
|
+
if (jsonMode) {
|
|
232
|
+
console.log(JSON.stringify({
|
|
233
|
+
query: queryStr,
|
|
234
|
+
type: typeFilter,
|
|
235
|
+
total: sessions.length,
|
|
236
|
+
matched: results.length,
|
|
237
|
+
results: results.map(({ entry, score }) => ({ ...entry, relevanceScore: score })),
|
|
238
|
+
}, null, 2));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Header ──────────────────────────────────────────────────────────────
|
|
243
|
+
console.log();
|
|
244
|
+
if (queryStr) {
|
|
245
|
+
console.log(` ${bold("🔥 infernoflow ask")} ${cyan(`"${queryStr}"`)}${typeFilter ? gray(` [${typeFilter}]`) : ""}`);
|
|
246
|
+
} else if (recentMode) {
|
|
247
|
+
console.log(` ${bold("🔥 infernoflow ask")} ${gray("— recent entries")}`);
|
|
248
|
+
} else {
|
|
249
|
+
console.log(` ${bold("🔥 infernoflow ask")} ${gray("— all entries")}${typeFilter ? gray(` [${typeFilter}]`) : ""}`);
|
|
250
|
+
}
|
|
251
|
+
console.log(gray(` ${"─".repeat(52)}`));
|
|
252
|
+
|
|
253
|
+
if (results.length === 0) {
|
|
254
|
+
console.log();
|
|
255
|
+
if (queryStr) {
|
|
256
|
+
console.log(gray(` No entries found for "${queryStr}"`));
|
|
257
|
+
if (typeFilter) console.log(gray(` Try removing --type ${typeFilter} to widen the search`));
|
|
258
|
+
} else {
|
|
259
|
+
console.log(gray(" No entries found."));
|
|
260
|
+
}
|
|
261
|
+
console.log();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Group by type for display ────────────────────────────────────────────
|
|
266
|
+
const byType = new Map();
|
|
267
|
+
for (const { entry, score } of results) {
|
|
268
|
+
const t = entry.type || "note";
|
|
269
|
+
if (!byType.has(t)) byType.set(t, []);
|
|
270
|
+
byType.get(t).push({ entry, score });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Print in priority order
|
|
274
|
+
const typeOrder = Object.keys(TYPE_PRIORITY).sort((a, b) => TYPE_PRIORITY[a] - TYPE_PRIORITY[b]);
|
|
275
|
+
|
|
276
|
+
let printed = 0;
|
|
277
|
+
for (const type of typeOrder) {
|
|
278
|
+
const group = byType.get(type);
|
|
279
|
+
if (!group?.length) continue;
|
|
280
|
+
|
|
281
|
+
console.log();
|
|
282
|
+
const colorFn = TYPE_COLORS[type] || gray;
|
|
283
|
+
console.log(colorFn(` ${TYPE_ICONS[type]} ${type.toUpperCase()}S (${group.length})`));
|
|
284
|
+
console.log(gray(" " + "─".repeat(50)));
|
|
285
|
+
|
|
286
|
+
for (const { entry } of group) {
|
|
287
|
+
console.log();
|
|
288
|
+
printEntry(entry, queryTokens);
|
|
289
|
+
printed++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
console.log();
|
|
294
|
+
console.log(gray(` ${printed} result${printed !== 1 ? "s" : ""} from ${sessions.length} total entries`));
|
|
295
|
+
if (results.length === limit && sessions.length > limit) {
|
|
296
|
+
console.log(gray(` Use --limit N to see more`));
|
|
297
|
+
}
|
|
298
|
+
console.log();
|
|
299
|
+
}
|
|
@@ -1,6 +1,204 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow audit
|
|
3
|
+
*
|
|
4
|
+
* Classify capabilities by security sensitivity and generate a surface map.
|
|
5
|
+
* Tags each capability with one or more sensitivity labels:
|
|
6
|
+
* auth — authentication / authorization / sessions / tokens
|
|
7
|
+
* payment — billing, subscriptions, pricing, invoices
|
|
8
|
+
* pii — personal data, email, address, phone, GDPR scope
|
|
9
|
+
* admin — privileged operations, configuration, user management
|
|
10
|
+
* public — read-only, no auth required
|
|
11
|
+
*
|
|
12
|
+
* Stores results in inferno/audit.json (git-trackable).
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* infernoflow audit Run audit, print summary
|
|
16
|
+
* infernoflow audit --format json Machine-readable JSON to stdout
|
|
17
|
+
* infernoflow audit --format html Write HTML report
|
|
18
|
+
* infernoflow audit --out audit.html Custom output path
|
|
19
|
+
* infernoflow audit --fail-on high Exit 1 if any HIGH caps are unreviewed
|
|
20
|
+
* infernoflow audit --json Alias for --format json
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
26
|
+
|
|
27
|
+
const AUDIT_FILE = "audit.json";
|
|
28
|
+
|
|
29
|
+
// ── Sensitivity keyword maps ──────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const SENSITIVITY_RULES = [
|
|
32
|
+
{
|
|
33
|
+
tag: "auth",
|
|
34
|
+
severity: "high",
|
|
35
|
+
label: "Authentication / Authorization",
|
|
36
|
+
keywords: [
|
|
37
|
+
"auth", "login", "logout", "signin", "signout", "signup",
|
|
38
|
+
"password", "credential", "token", "session", "oauth",
|
|
39
|
+
"jwt", "permission", "role", "access", "privilege", "2fa",
|
|
40
|
+
"mfa", "sso", "saml", "openid", "verify", "authenticate",
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
tag: "payment",
|
|
45
|
+
severity: "high",
|
|
46
|
+
label: "Payment / Billing",
|
|
47
|
+
keywords: [
|
|
48
|
+
"payment", "billing", "invoice", "charge", "subscription",
|
|
49
|
+
"checkout", "stripe", "card", "credit", "debit", "price",
|
|
50
|
+
"plan", "tier", "coupon", "refund", "transaction", "purchase",
|
|
51
|
+
"order", "cart", "paypal", "wallet",
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
tag: "pii",
|
|
56
|
+
severity: "high",
|
|
57
|
+
label: "Personal / PII Data",
|
|
58
|
+
keywords: [
|
|
59
|
+
"pii", "personal", "profile", "email", "phone", "address",
|
|
60
|
+
"name", "user", "account", "gdpr", "privacy", "data",
|
|
61
|
+
"export", "download", "delete account", "identity", "dob",
|
|
62
|
+
"birthday", "ssn", "passport", "tax",
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
tag: "admin",
|
|
67
|
+
severity: "medium",
|
|
68
|
+
label: "Admin / Privileged",
|
|
69
|
+
keywords: [
|
|
70
|
+
"admin", "manage", "config", "setting", "system", "deploy",
|
|
71
|
+
"migration", "seed", "reset", "purge", "archive", "batch",
|
|
72
|
+
"bulk", "impersonate", "override", "feature flag",
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
tag: "public",
|
|
77
|
+
severity: "low",
|
|
78
|
+
label: "Public / Read-only",
|
|
79
|
+
keywords: [
|
|
80
|
+
"list", "search", "view", "read", "fetch", "get", "show",
|
|
81
|
+
"display", "browse", "filter", "sort", "paginate",
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const SEVERITY_ORDER = { high: 3, medium: 2, low: 1, unknown: 0 };
|
|
87
|
+
|
|
88
|
+
// ── Classification ────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function classifyCapability(cap) {
|
|
91
|
+
const text = [
|
|
92
|
+
typeof cap === "string" ? cap : "",
|
|
93
|
+
cap?.id || "",
|
|
94
|
+
cap?.name || "",
|
|
95
|
+
cap?.description || "",
|
|
96
|
+
(cap?.tags || []).join(" "),
|
|
97
|
+
].join(" ").toLowerCase();
|
|
98
|
+
|
|
99
|
+
const matched = [];
|
|
100
|
+
for (const rule of SENSITIVITY_RULES) {
|
|
101
|
+
if (rule.keywords.some(kw => text.includes(kw))) {
|
|
102
|
+
matched.push(rule);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Remove "public" if any higher-severity tag also matched
|
|
107
|
+
const hasHigher = matched.some(r => r.tag !== "public" && r.severity !== "low");
|
|
108
|
+
const filtered = hasHigher ? matched.filter(r => r.tag !== "public") : matched;
|
|
109
|
+
|
|
110
|
+
if (!filtered.length) {
|
|
111
|
+
return { tags: ["unknown"], severity: "unknown", labels: ["Unclassified"] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const severity = filtered.reduce((best, r) => {
|
|
115
|
+
return SEVERITY_ORDER[r.severity] > SEVERITY_ORDER[best] ? r.severity : best;
|
|
116
|
+
}, "low");
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
tags: filtered.map(r => r.tag),
|
|
120
|
+
severity,
|
|
121
|
+
labels: filtered.map(r => r.label),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Storage ───────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function readAudit(infernoDir) {
|
|
128
|
+
const p = path.join(infernoDir, AUDIT_FILE);
|
|
129
|
+
if (!fs.existsSync(p)) return {};
|
|
130
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function writeAudit(infernoDir, data) {
|
|
134
|
+
fs.writeFileSync(path.join(infernoDir, AUDIT_FILE), JSON.stringify(data, null, 2) + "\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readContract(infernoDir) {
|
|
138
|
+
for (const f of ["contract.json", "capabilities.json"]) {
|
|
139
|
+
const p = path.join(infernoDir, f);
|
|
140
|
+
if (fs.existsSync(p)) {
|
|
141
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Formatters ────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function severityColor(sev) {
|
|
150
|
+
if (sev === "high") return red;
|
|
151
|
+
if (sev === "medium") return yellow;
|
|
152
|
+
if (sev === "low") return green;
|
|
153
|
+
return gray;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function severityIcon(sev) {
|
|
157
|
+
if (sev === "high") return "🔴";
|
|
158
|
+
if (sev === "medium") return "🟡";
|
|
159
|
+
if (sev === "low") return "🟢";
|
|
160
|
+
return "⚪";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function printTextReport(results, stats) {
|
|
164
|
+
const bySeverity = { high: [], medium: [], low: [], unknown: [] };
|
|
165
|
+
for (const r of results) (bySeverity[r.severity] || bySeverity.unknown).push(r);
|
|
166
|
+
|
|
167
|
+
console.log();
|
|
168
|
+
console.log(` ${bold("🔥 infernoflow audit — Security Surface Map")}`);
|
|
169
|
+
console.log();
|
|
170
|
+
console.log(` ${bold(String(results.length))} capabilities scanned`);
|
|
171
|
+
console.log(` ${red(String(stats.high))} high · ${yellow(String(stats.medium))} medium · ${green(String(stats.low))} low · ${gray(String(stats.unknown))} unclassified`);
|
|
172
|
+
console.log();
|
|
173
|
+
|
|
174
|
+
for (const [sev, items] of Object.entries(bySeverity)) {
|
|
175
|
+
if (!items.length) continue;
|
|
176
|
+
const col = severityColor(sev);
|
|
177
|
+
console.log(` ${col(bold(`${sev.toUpperCase()} (${items.length})`))}`);
|
|
178
|
+
for (const item of items) {
|
|
179
|
+
const tagStr = item.tags.join(", ");
|
|
180
|
+
console.log(` ${col("▸")} ${bold(item.id.padEnd(30))} ${gray(tagStr)}`);
|
|
181
|
+
if (item.description) console.log(` ${gray(item.description.slice(0, 72))}`);
|
|
182
|
+
}
|
|
183
|
+
console.log();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (stats.unknown > 0) {
|
|
187
|
+
console.log(` ${gray("Tip: Add descriptions to unclassified capabilities for better detection.")}`);
|
|
188
|
+
console.log();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildHtmlReport(results, stats, runAt) {
|
|
193
|
+
const rows = results.map(r => {
|
|
194
|
+
const sev = r.severity;
|
|
195
|
+
const cls = sev === "high" ? "high" : sev === "medium" ? "med" : sev === "low" ? "low" : "unk";
|
|
196
|
+
const tags = r.tags.join(", ");
|
|
197
|
+
const icon = severityIcon(sev);
|
|
198
|
+
return `<tr class="${cls}"><td>${icon} ${sev}</td><td><strong>${escHtml(r.id)}</strong></td><td>${escHtml(tags)}</td><td>${escHtml(r.description || "")}</td></tr>`;
|
|
199
|
+
}).join("\n");
|
|
200
|
+
|
|
201
|
+
return `<!DOCTYPE html>
|
|
4
202
|
<html lang="en">
|
|
5
203
|
<head>
|
|
6
204
|
<meta charset="UTF-8">
|
|
@@ -28,21 +226,110 @@ import*as d from"node:fs";import*as u from"node:path";import{done as I,warn as N
|
|
|
28
226
|
</style>
|
|
29
227
|
</head>
|
|
30
228
|
<body>
|
|
31
|
-
<h1
|
|
32
|
-
<p class="meta">Generated ${
|
|
229
|
+
<h1>🔥 infernoflow audit</h1>
|
|
230
|
+
<p class="meta">Generated ${runAt}</p>
|
|
33
231
|
<div class="stats">
|
|
34
|
-
<div class="stat high"><div class="n">${
|
|
35
|
-
<div class="stat med"><div class="n">${
|
|
36
|
-
<div class="stat low"><div class="n">${
|
|
37
|
-
<div class="stat unk"><div class="n">${
|
|
232
|
+
<div class="stat high"><div class="n">${stats.high}</div><div class="l">HIGH</div></div>
|
|
233
|
+
<div class="stat med"><div class="n">${stats.medium}</div><div class="l">MEDIUM</div></div>
|
|
234
|
+
<div class="stat low"><div class="n">${stats.low}</div><div class="l">LOW</div></div>
|
|
235
|
+
<div class="stat unk"><div class="n">${stats.unknown}</div><div class="l">UNKNOWN</div></div>
|
|
38
236
|
</div>
|
|
39
237
|
<table>
|
|
40
238
|
<thead><tr><th>Severity</th><th>Capability</th><th>Tags</th><th>Description</th></tr></thead>
|
|
41
239
|
<tbody>
|
|
42
|
-
${
|
|
240
|
+
${rows}
|
|
43
241
|
</tbody>
|
|
44
242
|
</table>
|
|
45
243
|
</body>
|
|
46
|
-
</html
|
|
47
|
-
|
|
48
|
-
|
|
244
|
+
</html>`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function escHtml(str) {
|
|
248
|
+
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
export async function auditCommand(rawArgs) {
|
|
254
|
+
const args = rawArgs.slice(1);
|
|
255
|
+
const jsonMode = args.includes("--json") || args.includes("--format") && args[args.indexOf("--format") + 1] === "json";
|
|
256
|
+
const cwd = process.cwd();
|
|
257
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
258
|
+
|
|
259
|
+
if (!fs.existsSync(infernoDir)) {
|
|
260
|
+
const msg = "inferno/ not found. Run: infernoflow init";
|
|
261
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); }
|
|
262
|
+
else { warn(msg); }
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Parse flags
|
|
267
|
+
const fmtIdx = args.indexOf("--format");
|
|
268
|
+
const format = fmtIdx !== -1 ? args[fmtIdx + 1] : (jsonMode ? "json" : "text");
|
|
269
|
+
const outIdx = args.indexOf("--out");
|
|
270
|
+
const outPath = outIdx !== -1 ? args[outIdx + 1] : null;
|
|
271
|
+
const failOnIdx = args.indexOf("--fail-on");
|
|
272
|
+
const failOn = failOnIdx !== -1 ? args[failOnIdx + 1] : null;
|
|
273
|
+
|
|
274
|
+
const contract = readContract(infernoDir);
|
|
275
|
+
if (!contract) {
|
|
276
|
+
const msg = "No contract.json or capabilities.json found.";
|
|
277
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); }
|
|
278
|
+
else { warn(msg); }
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const rawCaps = contract.capabilities || [];
|
|
283
|
+
const runAt = new Date().toISOString();
|
|
284
|
+
|
|
285
|
+
// Classify every capability
|
|
286
|
+
const results = rawCaps.map(cap => {
|
|
287
|
+
const id = typeof cap === "string" ? cap : (cap.id || cap.name || "unknown");
|
|
288
|
+
const description = typeof cap === "string" ? "" : (cap.description || "");
|
|
289
|
+
const cls = classifyCapability(cap);
|
|
290
|
+
return { id, description, ...cls };
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Sort by severity descending
|
|
294
|
+
results.sort((a, b) => (SEVERITY_ORDER[b.severity] || 0) - (SEVERITY_ORDER[a.severity] || 0));
|
|
295
|
+
|
|
296
|
+
// Stats
|
|
297
|
+
const stats = { high: 0, medium: 0, low: 0, unknown: 0 };
|
|
298
|
+
for (const r of results) {
|
|
299
|
+
const key = r.severity in stats ? r.severity : "unknown";
|
|
300
|
+
stats[key]++;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Persist to audit.json
|
|
304
|
+
const auditData = {
|
|
305
|
+
runAt,
|
|
306
|
+
stats,
|
|
307
|
+
capabilities: results,
|
|
308
|
+
};
|
|
309
|
+
writeAudit(infernoDir, auditData);
|
|
310
|
+
|
|
311
|
+
// Output
|
|
312
|
+
if (format === "json" || jsonMode) {
|
|
313
|
+
console.log(JSON.stringify({ ok: true, ...auditData }));
|
|
314
|
+
} else if (format === "html") {
|
|
315
|
+
const html = buildHtmlReport(results, stats, runAt);
|
|
316
|
+
const dest = outPath || path.join(infernoDir, "audit.html");
|
|
317
|
+
fs.writeFileSync(dest, html);
|
|
318
|
+
if (!jsonMode) done(`HTML audit report written to ${bold(dest)}`);
|
|
319
|
+
} else {
|
|
320
|
+
printTextReport(results, stats);
|
|
321
|
+
if (!jsonMode) done(`Saved to ${bold(path.join(infernoDir, AUDIT_FILE))}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Exit code enforcement
|
|
325
|
+
if (failOn) {
|
|
326
|
+
const threshold = SEVERITY_ORDER[failOn] || 0;
|
|
327
|
+
const violations = results.filter(r => (SEVERITY_ORDER[r.severity] || 0) >= threshold);
|
|
328
|
+
if (violations.length > 0) {
|
|
329
|
+
if (!jsonMode) {
|
|
330
|
+
console.error(red(`\n ✗ ${violations.length} capability/capabilities at or above "${failOn}" severity — review required.\n`));
|
|
331
|
+
}
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|