securevibe 0.1.7 → 0.1.10

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 CHANGED
@@ -27,6 +27,7 @@ midnight). The `--staged` commit guard is never limited. See [`LICENSE`](LICENSE
27
27
  | `securevibe scan [path]` | Full scan → findings + security score (default command) |
28
28
  | `securevibe init [path]` | **Wire SecureVibe in: pre-commit guard + GitHub Action + gitignore** (doc 12) |
29
29
  | `securevibe fix [path]` | **Autonomously fix findings, then re-scan to verify** (doc 05) |
30
+ | `securevibe explain [path]` | AI-powered plain-language explanations for critical/high findings |
30
31
  | `securevibe deps [path]` | Audit dependencies for known CVEs against the local OSV database (SCA) |
31
32
  | `securevibe db <update\|status>` | Manage the local OSV database — `update` is the only networked command |
32
33
  | `securevibe ready [path]` | Launch readiness scorecard: pass/fail gates + go/no-go verdict |
@@ -34,6 +35,8 @@ midnight). The `--staged` commit guard is never limited. See [`LICENSE`](LICENSE
34
35
  | `securevibe protect [path]` | Remediation-first view: the fix for every finding (doc 05) |
35
36
  | `securevibe attack-map [path]` | Exploit paths from reachable findings + choke point (doc 06) |
36
37
  | `securevibe score [path]` | Just the score + deployment-readiness verdict (doc 08) |
38
+ | `securevibe config <show\|set-key\|unset-key>` | Manage local Groq/Anthropic keys for the AI-powered fixer |
39
+ | `securevibe repl [path]` | Interactive session — run `scan`/`fix`/etc without re-invoking the CLI each time |
37
40
 
38
41
  Flags: `--json`, `--sarif` (GitHub code-scanning / CI interop), `--no-color`,
39
42
  `--ci` (exit `2` on BLOCK, `1` on WARN, `0` on SHIP — for build gates),
@@ -76,11 +79,20 @@ Anything that fails the gate is rolled back and surfaced as a manual instruction
76
79
  is kept **only if it passes the verification gate**, otherwise it falls back to a precise manual
77
80
  instruction. Groq is used by default when both keys are present (lower bar to try it at all);
78
81
  set `SECUREVIBE_LLM_PROVIDER=anthropic` to force Claude instead.
79
- - **Setting a key:** either export it yourself (`GROQ_API_KEY=...`), or run
80
- `securevibe config set-key groq` it prompts interactively (never as a command argument, so it
81
- never lands in shell history) and saves it to `~/.securevibe/config.json`, used automatically by
82
- `fix` whenever the env var isn't already set. Run `fix --apply` with no key configured and it will
83
- offer to set one up on the spot. `securevibe config show` lists what's stored (masked).
82
+ - **Setting a key:** `securevibe init` is the primary path — if it detects an LLM SDK dependency
83
+ (openai, `@anthropic-ai/sdk`, langchain, ...) and no key is already set, it offers to set one up
84
+ on the spot, once per project (declining is remembered so it won't ask again). You can also
85
+ export a key yourself (`GROQ_API_KEY=...`), or run `securevibe config set-key groq` any time it
86
+ prompts interactively (never as a command argument, so it never lands in shell history) and saves
87
+ it to `~/.securevibe/config.json`, used automatically whenever the env var isn't already set.
88
+ - **A second AI feature, `securevibe explain`:** for critical/high findings, asks
89
+ the same configured provider for a plain-language explanation grounded in the
90
+ actual flagged code (not just the generic why/fix text every finding of that
91
+ detector shares). Capped at the top 10 qualifying findings per run. Falls back to
92
+ the static why/fix text with no key configured, or if a call fails — an
93
+ explanation is always additional context, never required to use the command.
94
+ `fix --apply` with no key configured still offers to set one up as a fallback.
95
+ `securevibe config show` lists what's stored (masked).
84
96
 
85
97
  **Honesty (read this):**
86
98
  - A "fixed" finding means *our detector no longer flags it* — not that the app is proven secure.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `securevibe explain` (AI feature #2, 2026-07-10). Turns a critical/high
3
+ * finding's generic why/fix text into a plain-language explanation grounded
4
+ * in the actual flagged code. Read-only — no files are modified, so there is
5
+ * no verification/rollback gate the way `fix` needs one.
6
+ *
7
+ * Capped at EXPLAIN_MAX_FINDINGS per run to bound worst-case AI cost/latency:
8
+ * a scan with dozens of criticals should never turn into dozens of API calls
9
+ * without the caller asking for it. Degrades gracefully with no provider
10
+ * configured (or a failed call) — every finding still gets a result, with
11
+ * explanation: null, so the UI layer falls back to the static why/fix text.
12
+ */
13
+ import { promises as fs } from "node:fs";
14
+ import path from "node:path";
15
+ import { SEVERITY_ORDER } from "./types.js";
16
+ import { llmChat, llmAvailability } from "./fix/llm.js";
17
+ export const EXPLAIN_MAX_FINDINGS = 10;
18
+ const EXPLAIN_SEVERITIES = new Set(["critical", "high"]);
19
+ const SNIPPET_CONTEXT_LINES = 4;
20
+ const EXPLAIN_SYSTEM = [
21
+ "You are a senior application-security engineer explaining one specific vulnerability finding to the developer who wrote the code.",
22
+ "You will receive the finding's detector, severity, generic why/fix guidance, and a snippet of the actual flagged code (the vulnerable line is marked with >>).",
23
+ "Write a short plain-language explanation (3-5 sentences) of the concrete risk IN THIS CODE specifically — reference the actual variable, function, or endpoint names visible in the snippet, not generic advice.",
24
+ "Do not repeat the generic why/fix text verbatim. Do not add a heading, bullet list, or markdown formatting. Return prose only.",
25
+ ].join("\n");
26
+ function explainUserPrompt(finding, snippet) {
27
+ return [
28
+ `Finding: ${finding.title}`,
29
+ `Detector: ${finding.detector} Severity: ${finding.severity}${finding.cwe ? ` ${finding.cwe}` : ""}`,
30
+ `File: ${finding.file}:${finding.line}`,
31
+ "",
32
+ "Generic why:",
33
+ finding.why,
34
+ "",
35
+ "Generic fix:",
36
+ finding.fix,
37
+ "",
38
+ "Code snippet (flagged line marked with >>):",
39
+ snippet,
40
+ ].join("\n");
41
+ }
42
+ /** A fixed window of lines around `line` (1-indexed), clamped to file bounds. */
43
+ export function extractSnippet(code, line) {
44
+ const lines = code.split(/\r?\n/);
45
+ const idx = Math.min(Math.max(line - 1, 0), lines.length - 1);
46
+ const start = Math.max(0, idx - SNIPPET_CONTEXT_LINES);
47
+ const end = Math.min(lines.length, idx + SNIPPET_CONTEXT_LINES + 1);
48
+ const out = [];
49
+ for (let i = start; i < end; i++) {
50
+ out.push(`${i === idx ? ">>" : " "} ${i + 1}: ${lines[i]}`);
51
+ }
52
+ return out.join("\n");
53
+ }
54
+ export async function explainFindings(scanResult, opts = {}) {
55
+ const qualifying = scanResult.findings
56
+ .filter((f) => EXPLAIN_SEVERITIES.has(f.severity))
57
+ .sort((a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity] || b.confidence - a.confidence);
58
+ const capped = qualifying.slice(0, EXPLAIN_MAX_FINDINGS);
59
+ const truncatedCount = qualifying.length - capped.length;
60
+ const avail = llmAvailability(false);
61
+ const results = [];
62
+ for (const finding of capped) {
63
+ opts.onProgress?.(`Explaining ${finding.file}:${finding.line}…`);
64
+ let explanation = null;
65
+ if (avail.available) {
66
+ let snippet;
67
+ try {
68
+ const code = await fs.readFile(path.join(scanResult.root, finding.file), "utf8");
69
+ snippet = extractSnippet(code, finding.line);
70
+ }
71
+ catch {
72
+ snippet = "(source unavailable)";
73
+ }
74
+ explanation = await llmChat(EXPLAIN_SYSTEM, explainUserPrompt(finding, snippet));
75
+ }
76
+ results.push({ finding, explanation });
77
+ }
78
+ return { root: scanResult.root, results, truncatedCount, llmAvailable: avail.available };
79
+ }
@@ -94,18 +94,30 @@ function userPrompt(input) {
94
94
  export async function llmFix(input) {
95
95
  if (input.code.length > MAX_FILE_CHARS)
96
96
  return null;
97
+ const text = await llmChat(SYSTEM, userPrompt(input));
98
+ if (text == null)
99
+ return null;
100
+ return parseFile(text, input.code);
101
+ }
102
+ /**
103
+ * Generic single-turn chat call against whichever provider is configured.
104
+ * Shared by llmFix (full-file rewrites) and engine/explain.ts (plain-language
105
+ * finding explanations) so the Groq/Anthropic HTTP plumbing lives in one place.
106
+ * Returns null on no-provider / network / auth / quota failure — callers degrade
107
+ * gracefully and must never throw because of this.
108
+ */
109
+ export async function llmChat(system, user) {
97
110
  const provider = selectProvider();
98
111
  if (!provider)
99
112
  return null;
100
113
  try {
101
- const text = provider === "groq" ? await callGroq(input) : await callAnthropic(input);
102
- return parseFile(text, input.code);
114
+ return provider === "groq" ? await callGroq(system, user) : await callAnthropic(system, user);
103
115
  }
104
116
  catch {
105
- return null; // network / auth / quota failure → fall back to manual
117
+ return null;
106
118
  }
107
119
  }
108
- async function callAnthropic(input) {
120
+ async function callAnthropic(system, user) {
109
121
  let Anthropic;
110
122
  try {
111
123
  ({ default: Anthropic } = await import("@anthropic-ai/sdk"));
@@ -117,13 +129,13 @@ async function callAnthropic(input) {
117
129
  const msg = await client.messages.create({
118
130
  model: modelFor("anthropic"),
119
131
  max_tokens: 8192,
120
- system: SYSTEM,
121
- messages: [{ role: "user", content: userPrompt(input) }],
132
+ system,
133
+ messages: [{ role: "user", content: user }],
122
134
  });
123
135
  return extractAnthropicText(msg);
124
136
  }
125
137
  /** Groq's chat-completions endpoint is OpenAI-compatible — plain fetch, no SDK. */
126
- async function callGroq(input) {
138
+ async function callGroq(system, user) {
127
139
  const res = await fetch("https://api.groq.com/openai/v1/chat/completions", {
128
140
  method: "POST",
129
141
  headers: {
@@ -134,8 +146,8 @@ async function callGroq(input) {
134
146
  model: modelFor("groq"),
135
147
  max_tokens: 8192,
136
148
  messages: [
137
- { role: "system", content: SYSTEM },
138
- { role: "user", content: userPrompt(input) },
149
+ { role: "system", content: system },
150
+ { role: "user", content: user },
139
151
  ],
140
152
  }),
141
153
  });
@@ -9,6 +9,43 @@
9
9
  import { promises as fs } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { isGitRepo, gitDir } from "./git.js";
12
+ export function projectConfigPath(root) {
13
+ return path.join(path.resolve(root), "securevibe.config.json");
14
+ }
15
+ /** Tolerant read: a missing or corrupt config is treated as empty. */
16
+ export async function readProjectConfig(root) {
17
+ try {
18
+ const raw = await fs.readFile(projectConfigPath(root), "utf8");
19
+ const parsed = JSON.parse(raw);
20
+ return typeof parsed === "object" && parsed ? parsed : {};
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ export async function patchProjectConfig(root, patch) {
27
+ const current = await readProjectConfig(root);
28
+ const next = { ...current, ...patch };
29
+ await fs.writeFile(projectConfigPath(root), JSON.stringify(next, null, 2) + "\n", "utf8");
30
+ }
31
+ // Package names/imports that mark a repo as "uses an LLM SDK" — matched against
32
+ // package.json (dependency keys) and requirements.txt (package names) so the
33
+ // init-time AI-fixer offer only fires where it's actually relevant.
34
+ const AI_PACKAGE_RE = /"(openai|@anthropic-ai\/sdk|anthropic|langchain|@langchain\/[\w.-]+|cohere-ai|@google\/generative-ai|llamaindex|@modelcontextprotocol\/sdk)"\s*:|^\s*(openai|anthropic|langchain|cohere|google-generativeai|llama-index)\b/im;
35
+ export async function looksAiRelated(root) {
36
+ const absRoot = path.resolve(root);
37
+ for (const rel of ["package.json", "requirements.txt"]) {
38
+ try {
39
+ const content = await fs.readFile(path.join(absRoot, rel), "utf8");
40
+ if (AI_PACKAGE_RE.test(content))
41
+ return true;
42
+ }
43
+ catch {
44
+ /* file absent — try the next */
45
+ }
46
+ }
47
+ return false;
48
+ }
12
49
  const GITIGNORE_LINES = [".env", ".env.local", ".env.*.local", ".securevibe-backup/"];
13
50
  const CONFIG = `{
14
51
  "failOn": "block",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared helpers used by both the one-shot CLI commands (index.ts) and the
3
+ * interactive REPL (repl.ts), so the two entry points never duplicate logic.
4
+ */
5
+ /**
6
+ * If `fix` is about to run with no AI-fixer provider key available (and we're
7
+ * in an interactive terminal), offer to set one up on the spot — a free Groq
8
+ * key takes under a minute. Never prompts under --json/non-TTY/--no-llm, and
9
+ * never touches process.env if the user declines or a key is already set.
10
+ */
11
+ export async function maybeOfferAiFixerSetup() {
12
+ if (process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)
13
+ return;
14
+ const { isInteractive, askLine } = await import("./ui/prompt.js");
15
+ if (!isInteractive())
16
+ return;
17
+ process.stderr.write("\n AI auto-fix (RCE, AI tool-hijack, IDOR, complex SQLi) needs a provider key — you don't have one set.\n");
18
+ const ans = (await askLine(" Enable it now with a free Groq key? [y/N]: ")).trim().toLowerCase();
19
+ if (ans !== "y" && ans !== "yes") {
20
+ process.stderr.write(" Skipping — those findings will show as manual review. Run `securevibe config set-key groq` anytime.\n\n");
21
+ return;
22
+ }
23
+ const key = (await askLine(" Paste your Groq key (get one free at console.groq.com/keys): ")).trim();
24
+ if (!key) {
25
+ process.stderr.write(" No key entered — skipping.\n\n");
26
+ return;
27
+ }
28
+ const { setKey, configFilePath } = await import("./config.js");
29
+ await setKey("groq", key);
30
+ process.env.GROQ_API_KEY = key;
31
+ process.stderr.write(` Saved to ${configFilePath()}. This run will use it.\n\n`);
32
+ }
33
+ /**
34
+ * Runs once at the end of `init`, interactive-only. Users were learning a
35
+ * provider key was needed only mid-`fix` — too late, past the point of
36
+ * expecting the fixer to already work. `init` already runs once per project
37
+ * before any `fix`, so it's the right place to surface this early — but only
38
+ * for repos that actually use an LLM SDK, and only once (a decline is
39
+ * remembered per-project so we don't nag).
40
+ */
41
+ export async function maybeOfferAiFixerSetupDuringInit(root, deps) {
42
+ const { applyStoredKeysToEnv } = await import("./config.js");
43
+ await applyStoredKeysToEnv(deps?.configFile);
44
+ if (process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)
45
+ return;
46
+ const { readProjectConfig, patchProjectConfig, looksAiRelated } = await import("./engine/init.js");
47
+ const cfg = await readProjectConfig(root);
48
+ if (cfg.aiFixerPromptDismissed)
49
+ return;
50
+ if (!(await looksAiRelated(root)))
51
+ return;
52
+ const isInteractive = deps?.isInteractive ?? (await import("./ui/prompt.js")).isInteractive;
53
+ if (!isInteractive())
54
+ return;
55
+ process.stderr.write("\n This project uses an LLM SDK — SecureVibe can auto-fix AI-specific findings\n (tool-hijack, prompt-injection sinks) with a free key.\n");
56
+ const offer = deps?.offer ?? maybeOfferAiFixerSetup;
57
+ await offer();
58
+ if (!(process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)) {
59
+ await patchProjectConfig(root, { aiFixerPromptDismissed: true });
60
+ }
61
+ }
package/dist/index.js CHANGED
@@ -92,34 +92,6 @@ async function maybeNotifyUpdate(opts) {
92
92
  if (latest)
93
93
  process.stderr.write("\n" + updateNotice(VERSION, latest) + "\n");
94
94
  }
95
- /**
96
- * If `fix` is about to run with no AI-fixer provider key available (and we're
97
- * in an interactive terminal), offer to set one up on the spot — a free Groq
98
- * key takes under a minute. Never prompts under --json/non-TTY/--no-llm, and
99
- * never touches process.env if the user declines or a key is already set.
100
- */
101
- async function maybeOfferAiFixerSetup() {
102
- if (process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)
103
- return;
104
- const { isInteractive, askLine } = await import("./ui/prompt.js");
105
- if (!isInteractive())
106
- return;
107
- process.stderr.write("\n AI auto-fix (RCE, AI tool-hijack, IDOR, complex SQLi) needs a provider key — you don't have one set.\n");
108
- const ans = (await askLine(" Enable it now with a free Groq key? [y/N]: ")).trim().toLowerCase();
109
- if (ans !== "y" && ans !== "yes") {
110
- process.stderr.write(" Skipping — those findings will show as manual review. Run `securevibe config set-key groq` anytime.\n\n");
111
- return;
112
- }
113
- const key = (await askLine(" Paste your Groq key (get one free at console.groq.com/keys): ")).trim();
114
- if (!key) {
115
- process.stderr.write(" No key entered — skipping.\n\n");
116
- return;
117
- }
118
- const { setKey, configFilePath } = await import("./config.js");
119
- await setKey("groq", key);
120
- process.env.GROQ_API_KEY = key;
121
- process.stderr.write(` Saved to ${configFilePath()}. This run will use it.\n\n`);
122
- }
123
95
  /** Exit code policy for CI gating (doc 08/12). */
124
96
  function ciExit(result, opts) {
125
97
  if (!opts.ci)
@@ -156,6 +128,8 @@ program
156
128
  const { renderInit } = await import("./ui/init.js");
157
129
  const result = await runInit(pathArg ?? ".", { force: opts.force });
158
130
  process.stdout.write(renderInit(result) + "\n");
131
+ const { maybeOfferAiFixerSetupDuringInit } = await import("./index-support.js");
132
+ await maybeOfferAiFixerSetupDuringInit(pathArg ?? ".");
159
133
  });
160
134
  program
161
135
  .command("fix")
@@ -175,8 +149,10 @@ program
175
149
  const decision = decideApply(opts, isInteractive());
176
150
  // Only worth asking on a run that will actually write something — a dry-run
177
151
  // preview has no use for a key yet, and asking anyway reads as nagging.
178
- if (opts.llm !== false && !opts.json && decision.apply)
152
+ if (opts.llm !== false && !opts.json && decision.apply) {
153
+ const { maybeOfferAiFixerSetup } = await import("./index-support.js");
179
154
  await maybeOfferAiFixerSetup();
155
+ }
180
156
  const usage = await meter();
181
157
  const { runFix } = await import("./engine/fix/session.js");
182
158
  const { renderFixSession, fixToJson, renderApprovalRequest } = await import("./ui/fix.js");
@@ -195,6 +171,36 @@ program
195
171
  }
196
172
  await maybeNotifyUpdate({ json: opts.json });
197
173
  });
174
+ program
175
+ .command("explain")
176
+ .description("AI-powered plain-language explanations for critical/high findings")
177
+ .argument("[path]", "path to the repository", ".")
178
+ .option("--json", "output machine-readable JSON")
179
+ .option("--no-color", "disable coloured output")
180
+ .action(async (pathArg, opts) => {
181
+ if (opts.color === false)
182
+ process.env.NO_COLOR = "1";
183
+ const { applyStoredKeysToEnv } = await import("./config.js");
184
+ await applyStoredKeysToEnv();
185
+ if (!opts.json) {
186
+ const { maybeOfferAiFixerSetup } = await import("./index-support.js");
187
+ await maybeOfferAiFixerSetup();
188
+ }
189
+ const { result, usage } = await runScan(pathArg, { json: opts.json, color: opts.color });
190
+ const { explainFindings } = await import("./engine/explain.js");
191
+ const report = await explainFindings(result, {
192
+ onProgress: opts.json ? undefined : (m) => process.stderr.write(` · ${m}\n`),
193
+ });
194
+ if (opts.json) {
195
+ const { explainToJson } = await import("./ui/explain.js");
196
+ process.stdout.write(explainToJson(report) + "\n");
197
+ }
198
+ else {
199
+ const { renderExplain } = await import("./ui/explain.js");
200
+ process.stdout.write(renderExplain(report, usage) + "\n");
201
+ }
202
+ await maybeNotifyUpdate({ json: opts.json });
203
+ });
198
204
  addCommon(program
199
205
  .command("ai-audit")
200
206
  .description("Focus on the AI-application / agent attack surface (doc 04)")
@@ -352,6 +358,14 @@ program
352
358
  process.stderr.write(` unknown config action: ${action} (use: show | set-key | unset-key)\n`);
353
359
  process.exit(64);
354
360
  });
361
+ program
362
+ .command("repl")
363
+ .description("Interactive session: run scan/fix/etc without re-invoking the CLI each time")
364
+ .argument("[path]", "path to the repository", ".")
365
+ .action(async (pathArg) => {
366
+ const { runRepl } = await import("./repl.js");
367
+ await runRepl(pathArg ?? ".");
368
+ });
355
369
  program.parseAsync(process.argv).catch((err) => {
356
370
  process.stderr.write(`securevibe: ${err?.message ?? err}\n`);
357
371
  process.exit(70);
package/dist/repl.js ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Interactive REPL (`securevibe repl`). A persistent session that holds the
3
+ * last scan result in memory and reuses the exact same underlying functions
4
+ * the one-shot commands use (scan(), runFix(), the ui/* renderers) — never
5
+ * the commander actions, since several of those call process.exit() on
6
+ * error/limit paths, which would tear down the whole session, not just the
7
+ * failed command.
8
+ *
9
+ * Only ever one readline interface alive at a time (via askLine, which opens
10
+ * and closes its own per call): the REPL's own prompt and any nested
11
+ * interactive flow (fix --apply confirmations, config set-key) are always
12
+ * sequential, never concurrent, so there's no stdin contention.
13
+ *
14
+ * Streams are injectable (runReplSession) so the whole loop — including
15
+ * fix --apply's confirm prompts — is unit-testable without a real TTY; the
16
+ * public runRepl() just wraps it with the real stdio + the TTY gate.
17
+ */
18
+ import path from "node:path";
19
+ import { askLine, isInteractive, decideApply, makeFixConfirm } from "./ui/prompt.js";
20
+ function tokenize(line) {
21
+ const out = [];
22
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
23
+ let m;
24
+ while ((m = re.exec(line)))
25
+ out.push(m[1] ?? m[2] ?? m[3]);
26
+ return out;
27
+ }
28
+ function hasFlag(tokens, ...names) {
29
+ return tokens.some((t) => names.includes(t));
30
+ }
31
+ function write(io, text) {
32
+ io.output.write(text);
33
+ }
34
+ async function meterOrPrint(io) {
35
+ const { recordUse, limitMessage } = await import("./usage.js");
36
+ const r = await recordUse();
37
+ if (!r.allowed) {
38
+ write(io, limitMessage() + "\n");
39
+ return undefined;
40
+ }
41
+ return r;
42
+ }
43
+ const HELP = [
44
+ " Commands:",
45
+ " scan [--json] full scan",
46
+ " fix [--apply] [--yes] [--no-llm] [--json] fix findings",
47
+ " explain [--json] AI explanations for critical/high findings",
48
+ " ai-audit | protect | attack-map | score | deps | ready [--json]",
49
+ " init [--force] wire up the pre-commit guard + CI",
50
+ " config show | set-key <groq|anthropic> | unset-key <groq|anthropic>",
51
+ " db update | status manage the local OSV database",
52
+ " cd <path> change the scan target",
53
+ " pwd show the current scan target",
54
+ " help this message",
55
+ " exit | quit leave the session",
56
+ ].join("\n");
57
+ /** Public entry point: real stdio, refuses to start without a TTY. */
58
+ export async function runRepl(initialPath) {
59
+ if (!isInteractive()) {
60
+ process.stderr.write(" repl needs an interactive terminal.\n");
61
+ process.exitCode = 64;
62
+ return;
63
+ }
64
+ await runReplSession(initialPath, { input: process.stdin, output: process.stdout });
65
+ }
66
+ /** The actual session loop, over injectable streams — testable without a TTY. */
67
+ export async function runReplSession(initialPath, io) {
68
+ const { applyStoredKeysToEnv } = await import("./config.js");
69
+ await applyStoredKeysToEnv();
70
+ const state = { root: path.resolve(initialPath) };
71
+ const { renderBanner } = await import("./ui/banner.js");
72
+ write(io, renderBanner({ subtitle: "interactive session — type `help`" }) + "\n\n");
73
+ write(io, ` target: ${state.root}\n\n`);
74
+ while (true) {
75
+ const raw = await askLine(`securevibe (${path.basename(state.root)})> `, io.input, io.output);
76
+ const line = raw.trim();
77
+ if (!line)
78
+ continue;
79
+ const tokens = tokenize(line);
80
+ const cmd = tokens[0];
81
+ const rest = tokens.slice(1);
82
+ try {
83
+ if (cmd === "exit" || cmd === "quit" || cmd === "q")
84
+ break;
85
+ if (cmd === "help" || cmd === "?") {
86
+ write(io, HELP + "\n\n");
87
+ continue;
88
+ }
89
+ if (cmd === "pwd") {
90
+ write(io, ` ${state.root}\n\n`);
91
+ continue;
92
+ }
93
+ if (cmd === "cd") {
94
+ if (!rest[0]) {
95
+ write(io, " usage: cd <path>\n\n");
96
+ continue;
97
+ }
98
+ state.root = path.resolve(state.root, rest[0]);
99
+ write(io, ` target: ${state.root}\n\n`);
100
+ continue;
101
+ }
102
+ await dispatch(cmd, rest, state, io);
103
+ }
104
+ catch (err) {
105
+ write(io, ` error: ${err?.message ?? err}\n\n`);
106
+ }
107
+ }
108
+ }
109
+ async function dispatch(cmd, tokens, state, io) {
110
+ const asJson = hasFlag(tokens, "--json");
111
+ if (["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready"].includes(cmd)) {
112
+ const { scan } = await import("./engine/scan.js");
113
+ const usage = await meterOrPrint(io);
114
+ const result = await scan(state.root, {
115
+ aiOnly: cmd === "ai-audit",
116
+ onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`),
117
+ });
118
+ state.lastResult = result;
119
+ if (asJson) {
120
+ const { toJson } = await import("./ui/emit.js");
121
+ write(io, toJson(result) + "\n\n");
122
+ return;
123
+ }
124
+ write(io, (await renderFor(cmd, result, usage)) + "\n");
125
+ return;
126
+ }
127
+ if (cmd === "fix") {
128
+ const { runFix } = await import("./engine/fix/session.js");
129
+ const { renderFixSession, fixToJson, renderApprovalRequest } = await import("./ui/fix.js");
130
+ const opts = { apply: hasFlag(tokens, "--apply"), yes: hasFlag(tokens, "--yes"), json: asJson };
131
+ const decision = decideApply(opts, true);
132
+ if (decision.apply && !hasFlag(tokens, "--no-llm")) {
133
+ const { maybeOfferAiFixerSetup } = await import("./index-support.js");
134
+ await maybeOfferAiFixerSetup();
135
+ }
136
+ const usage = await meterOrPrint(io);
137
+ const result = await runFix(state.root, {
138
+ apply: decision.apply,
139
+ noLlm: hasFlag(tokens, "--no-llm"),
140
+ confirm: decision.prompt ? makeFixConfirm(renderApprovalRequest, io.input, io.output) : undefined,
141
+ onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`),
142
+ });
143
+ if (asJson) {
144
+ write(io, fixToJson(result) + "\n\n");
145
+ return;
146
+ }
147
+ write(io, renderFixSession(result, usage) + "\n");
148
+ if (decision.notice) {
149
+ write(io, " Not applied: re-run with `fix --apply --yes` to apply non-interactively.\n\n");
150
+ }
151
+ return;
152
+ }
153
+ if (cmd === "explain") {
154
+ if (!asJson) {
155
+ const { maybeOfferAiFixerSetup } = await import("./index-support.js");
156
+ await maybeOfferAiFixerSetup();
157
+ }
158
+ const { explainFindings } = await import("./engine/explain.js");
159
+ const usage = await meterOrPrint(io);
160
+ const { scan } = await import("./engine/scan.js");
161
+ const scanResult = await scan(state.root, { onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`) });
162
+ state.lastResult = scanResult;
163
+ const report = await explainFindings(scanResult, {
164
+ onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`),
165
+ });
166
+ if (asJson) {
167
+ const { explainToJson } = await import("./ui/explain.js");
168
+ write(io, explainToJson(report) + "\n\n");
169
+ return;
170
+ }
171
+ const { renderExplain } = await import("./ui/explain.js");
172
+ write(io, renderExplain(report, usage) + "\n");
173
+ return;
174
+ }
175
+ if (cmd === "init") {
176
+ const { runInit } = await import("./engine/init.js");
177
+ const { renderInit } = await import("./ui/init.js");
178
+ const result = await runInit(state.root, { force: hasFlag(tokens, "--force") });
179
+ write(io, renderInit(result) + "\n");
180
+ const { maybeOfferAiFixerSetupDuringInit } = await import("./index-support.js");
181
+ await maybeOfferAiFixerSetupDuringInit(state.root);
182
+ return;
183
+ }
184
+ if (cmd === "config") {
185
+ await handleConfig(tokens, io);
186
+ return;
187
+ }
188
+ if (cmd === "db") {
189
+ await handleDb(tokens, io);
190
+ return;
191
+ }
192
+ write(io, ` unknown command: ${cmd} (type \`help\`)\n\n`);
193
+ }
194
+ async function renderFor(cmd, result, usage) {
195
+ if (cmd === "scan" || cmd === "ai-audit") {
196
+ const { renderReport } = await import("./ui/report.js");
197
+ return renderReport(result, { aiFocus: cmd === "ai-audit", usage });
198
+ }
199
+ if (cmd === "protect") {
200
+ const { renderRemediations } = await import("./ui/protect.js");
201
+ return renderRemediations(result, usage);
202
+ }
203
+ if (cmd === "attack-map") {
204
+ const { renderAttackMap } = await import("./ui/attackmap.js");
205
+ return renderAttackMap(result, usage);
206
+ }
207
+ if (cmd === "score") {
208
+ const { renderScoreOnly } = await import("./ui/protect.js");
209
+ return renderScoreOnly(result, usage);
210
+ }
211
+ if (cmd === "deps") {
212
+ const { renderDeps } = await import("./ui/deps.js");
213
+ return renderDeps(result, usage);
214
+ }
215
+ const { renderScorecard } = await import("./ui/readiness.js");
216
+ return renderScorecard(result, usage);
217
+ }
218
+ async function handleConfig(tokens, io) {
219
+ const [action, provider] = tokens;
220
+ const { readConfig, setKey, unsetKey, maskKey, configFilePath, ENV_VAR } = await import("./config.js");
221
+ if (action === "show" || !action) {
222
+ const cfg = await readConfig();
223
+ const line = (label, stored, envSet) => ` ${label}:`.padEnd(13) + (stored ? maskKey(stored) : "not set") + (envSet ? " (env var active)" : "");
224
+ write(io, " SecureVibe AI-fixer keys\n");
225
+ write(io, ` file: ${configFilePath()}\n`);
226
+ write(io, line("groq", cfg.groqApiKey, !!process.env.GROQ_API_KEY) + "\n");
227
+ write(io, line("anthropic", cfg.anthropicApiKey, !!process.env.ANTHROPIC_API_KEY) + "\n\n");
228
+ return;
229
+ }
230
+ if ((action === "set-key" || action === "unset-key") && provider !== "groq" && provider !== "anthropic") {
231
+ write(io, ` usage: config ${action} <groq|anthropic>\n\n`);
232
+ return;
233
+ }
234
+ if (action === "set-key") {
235
+ const hint = provider === "groq" ? " (free — console.groq.com/keys)" : " (console.anthropic.com)";
236
+ const key = (await askLine(` Paste your ${provider} API key${hint}: `, io.input, io.output)).trim();
237
+ if (!key) {
238
+ write(io, " No key entered — nothing saved.\n\n");
239
+ return;
240
+ }
241
+ await setKey(provider, key);
242
+ process.env[ENV_VAR[provider]] = key;
243
+ write(io, ` Saved to ${configFilePath()}. This session will use it.\n\n`);
244
+ return;
245
+ }
246
+ if (action === "unset-key") {
247
+ await unsetKey(provider);
248
+ write(io, ` Removed the stored ${provider} key.\n\n`);
249
+ return;
250
+ }
251
+ write(io, " usage: config show | set-key <groq|anthropic> | unset-key <groq|anthropic>\n\n");
252
+ }
253
+ async function handleDb(tokens, io) {
254
+ const [action] = tokens;
255
+ if (action === "status") {
256
+ const { loadDepDb, userDbDir } = await import("./engine/deps/db.js");
257
+ const db = await loadDepDb();
258
+ const ecos = Object.entries(db.ecosystems)
259
+ .map(([e, pkgs]) => `${e}: ${Object.keys(pkgs).length} packages`)
260
+ .join(", ");
261
+ write(io, ` SecureVibe OSV database\n date: ${db.date ?? "(bundled seed only)"}\n cache: ${userDbDir()}\n loaded: ${ecos || "(empty)"}\n\n`);
262
+ return;
263
+ }
264
+ if (action === "update") {
265
+ const { updateDb } = await import("./engine/deps/osv.js");
266
+ write(io, " · fetching OSV dumps (npm, PyPI)…\n");
267
+ try {
268
+ const r = await updateDb();
269
+ write(io, ` Updated local OSV db (${r.date}): ${r.count} packages → ${r.destDir}\n\n`);
270
+ }
271
+ catch (err) {
272
+ write(io, ` db update failed: ${err?.message ?? err}\n\n`);
273
+ }
274
+ return;
275
+ }
276
+ write(io, " usage: db update | status\n\n");
277
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `explain` view. Shows the AI-generated explanation per finding when
3
+ * available, falling back to the existing static why/fix text otherwise —
4
+ * an AI explanation is additional context, never a replacement, so the
5
+ * static text stays a guaranteed field in both the human and JSON output.
6
+ */
7
+ import pc from "picocolors";
8
+ import { renderBanner } from "./banner.js";
9
+ import { renderUsageLine } from "./usage.js";
10
+ const SEV_TAG = {
11
+ critical: (s) => pc.bgRed(pc.white(pc.bold(s))),
12
+ high: (s) => pc.red(pc.bold(s)),
13
+ medium: (s) => pc.yellow(s),
14
+ low: (s) => pc.blue(s),
15
+ info: (s) => pc.dim(s),
16
+ };
17
+ export function renderExplain(report, usage) {
18
+ const L = [];
19
+ L.push(renderBanner({ subtitle: "AI-powered finding explanations" }));
20
+ L.push(pc.dim(` ${report.root}`));
21
+ L.push("");
22
+ if (usage) {
23
+ L.push(renderUsageLine(usage));
24
+ L.push("");
25
+ }
26
+ if (report.results.length === 0) {
27
+ L.push(pc.green(" No critical/high findings to explain."));
28
+ L.push("");
29
+ return L.join("\n");
30
+ }
31
+ for (const r of report.results) {
32
+ const f = r.finding;
33
+ const sev = SEV_TAG[f.severity](` ${f.severity.toUpperCase()} `);
34
+ L.push(` ${sev} ${pc.bold(f.title)} ${pc.dim(`(${f.file}:${f.line})`)}`);
35
+ if (r.explanation) {
36
+ L.push(` ${pc.cyan("AI:")} ${r.explanation.trim()}`);
37
+ }
38
+ else {
39
+ L.push(` ${pc.dim("why:")} ${f.why}`);
40
+ L.push(` ${pc.dim("fix:")} ${f.fix}`);
41
+ }
42
+ L.push("");
43
+ }
44
+ if (report.truncatedCount > 0) {
45
+ const total = report.results.length + report.truncatedCount;
46
+ L.push(pc.dim(` Showing the top ${report.results.length} of ${total} critical/high findings — re-run \`scan\` to see the rest.`));
47
+ L.push("");
48
+ }
49
+ if (!report.llmAvailable) {
50
+ L.push(pc.dim(" Set GROQ_API_KEY (free tier) or ANTHROPIC_API_KEY for AI-generated explanations — showing static guidance above. Run `securevibe config set-key groq`."));
51
+ L.push("");
52
+ }
53
+ return L.join("\n");
54
+ }
55
+ export function explainToJson(report) {
56
+ return JSON.stringify({
57
+ root: report.root,
58
+ llmAvailable: report.llmAvailable,
59
+ truncatedCount: report.truncatedCount,
60
+ results: report.results.map((r) => ({
61
+ id: r.finding.id,
62
+ detector: r.finding.detector,
63
+ severity: r.finding.severity,
64
+ file: r.finding.file,
65
+ line: r.finding.line,
66
+ title: r.finding.title,
67
+ why: r.finding.why,
68
+ fix: r.finding.fix,
69
+ explanation: r.explanation,
70
+ })),
71
+ }, null, 2);
72
+ }
package/dist/version.js CHANGED
@@ -2,4 +2,4 @@
2
2
  // Single source of truth for the CLI's own version, so it can't drift from
3
3
  // what commander reports and what the update-check compares against.
4
4
  // Kept in sync with the "version" field in package.json by hand at release time.
5
- export const VERSION = "0.1.7";
5
+ export const VERSION = "0.1.10";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securevibe",
3
- "version": "0.1.7",
3
+ "version": "0.1.10",
4
4
  "description": "Autonomous AI security engineer for AI generated apps: scan, fix, verify, and gate every commit before you launch",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",