securevibe 0.1.12 → 0.1.13

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
@@ -54,6 +54,13 @@ before (`scan`, `fix --apply`, `help`, etc. — the `/` prefix is optional, not
54
54
  Up/down arrow cycles through your command history. `scan`/`fix`/`explain` show a single
55
55
  live progress spinner instead of scrolling status lines.
56
56
 
57
+ Inside the REPL, anything you type that is not a command is a question for the AI about
58
+ your last scan: "why is finding 3 critical?", "which of these should I fix first?".
59
+ Answers are grounded in the scan results only; the assistant never sees your files and
60
+ never claims your code is secure. The first question each session asks before anything
61
+ is sent to your provider. Needs the same key as `fix` and `explain`
62
+ (`securevibe config set-key groq`).
63
+
57
64
  ## Always-on (doc 12)
58
65
 
59
66
  `securevibe init` makes the tool continuous so insecure code can't slip through:
@@ -0,0 +1,76 @@
1
+ import { SEVERITY_ORDER } from "./types.js";
2
+ import { llmChatMessages } from "./fix/llm.js";
3
+ /** Findings included in the grounding table, top N by severity then confidence. */
4
+ export const CHAT_MAX_FINDINGS = 30;
5
+ /** Prior exchanges (user + assistant pairs) kept and re-sent per question. */
6
+ export const CHAT_MAX_EXCHANGES = 10;
7
+ const CHAT_SYSTEM = [
8
+ "You are the assistant inside SecureVibe, a security scanner CLI. The user just scanned their repository; the scan results are below. Answer their questions about those results.",
9
+ "Rules you must never break:",
10
+ "- Answer ONLY from the scan results provided. You cannot see the repository's files, only the findings listed. If asked about anything not in them, say you can only see the scan results.",
11
+ "- Never call the code \"secure\", \"safe\", or \"unhackable\". A clean scan means our detectors found nothing, not that the code is secure.",
12
+ "- When you give remediation advice, end with: review the diff and run your tests.",
13
+ "- The user can run these commands: scan, fix (--apply), explain, ai-audit, protect, attack-map, score, deps, ready. Recommend those when relevant. You cannot fix, read files, or run anything yourself; never claim otherwise.",
14
+ "- Findings are numbered; \"finding 3\" means number 3 in the list below.",
15
+ "- Keep answers short and concrete: plain prose, no markdown headings, no bullet spam.",
16
+ ].join("\n");
17
+ /** The compact, numbered findings table the assistant is grounded in. */
18
+ export function buildGrounding(result) {
19
+ const sorted = [...result.findings].sort((a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity] || b.confidence - a.confidence);
20
+ const shown = sorted.slice(0, CHAT_MAX_FINDINGS);
21
+ const lines = [
22
+ `Target: ${result.root}`,
23
+ `Score: grade ${result.score.grade} (composite ${result.score.composite}), readiness: ${result.score.readiness}`,
24
+ ];
25
+ if (result.launchReadiness) {
26
+ lines.push(`Launch verdict: ${result.launchReadiness.verdict} (${result.launchReadiness.blockers} blockers, ${result.launchReadiness.warnings} warnings)`);
27
+ }
28
+ if (shown.length === 0) {
29
+ lines.push("Findings: none (no findings — our detectors found nothing; that does not prove the code is secure).");
30
+ return lines.join("\n");
31
+ }
32
+ lines.push(shown.length < sorted.length
33
+ ? `Findings (top ${shown.length} of ${sorted.length}, by severity):`
34
+ : `Findings (${shown.length}):`);
35
+ shown.forEach((f, i) => {
36
+ lines.push(`${i + 1}. [${f.severity}] ${f.detector} ${f.file}:${f.line} - ${f.title}`);
37
+ if (f.evidence)
38
+ lines.push(` evidence: ${f.evidence.trim()}`);
39
+ });
40
+ return lines.join("\n");
41
+ }
42
+ export class ChatSession {
43
+ callLlm;
44
+ history = [];
45
+ grounding = null;
46
+ constructor(callLlm = llmChatMessages) {
47
+ this.callLlm = callLlm;
48
+ }
49
+ /** New grounding means a fresh conversation: the old answers described old results. */
50
+ setScanResult(result) {
51
+ this.grounding = buildGrounding(result);
52
+ this.history = [];
53
+ }
54
+ reset() {
55
+ this.grounding = null;
56
+ this.history = [];
57
+ }
58
+ /**
59
+ * Ask one question. Returns null (and leaves history untouched) when there
60
+ * is no grounding or the call fails — the caller owns the user-facing
61
+ * degradation message, same contract as llmChat.
62
+ */
63
+ async ask(question) {
64
+ if (this.grounding === null)
65
+ return null;
66
+ const messages = [...this.history, { role: "user", content: question }];
67
+ const answer = await this.callLlm(`${CHAT_SYSTEM}\n\nScan results:\n${this.grounding}`, messages);
68
+ if (answer == null)
69
+ return null;
70
+ this.history.push({ role: "user", content: question }, { role: "assistant", content: answer });
71
+ const max = CHAT_MAX_EXCHANGES * 2;
72
+ if (this.history.length > max)
73
+ this.history = this.history.slice(-max);
74
+ return answer;
75
+ }
76
+ }
@@ -102,22 +102,29 @@ export async function llmFix(input) {
102
102
  /**
103
103
  * Generic single-turn chat call against whichever provider is configured.
104
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.
105
+ * finding explanations). Returns null on no-provider / network / auth / quota
106
+ * failure callers degrade gracefully and must never throw because of this.
108
107
  */
109
108
  export async function llmChat(system, user) {
109
+ return llmChatMessages(system, [{ role: "user", content: user }]);
110
+ }
111
+ /**
112
+ * Multi turn variant: same provider selection and same null-on-failure
113
+ * contract as llmChat, but takes the full message history. Used by
114
+ * engine/chat.ts (the REPL's free text chat).
115
+ */
116
+ export async function llmChatMessages(system, messages) {
110
117
  const provider = selectProvider();
111
118
  if (!provider)
112
119
  return null;
113
120
  try {
114
- return provider === "groq" ? await callGroq(system, user) : await callAnthropic(system, user);
121
+ return provider === "groq" ? await callGroq(system, messages) : await callAnthropic(system, messages);
115
122
  }
116
123
  catch {
117
124
  return null;
118
125
  }
119
126
  }
120
- async function callAnthropic(system, user) {
127
+ async function callAnthropic(system, messages) {
121
128
  let Anthropic;
122
129
  try {
123
130
  ({ default: Anthropic } = await import("@anthropic-ai/sdk"));
@@ -130,12 +137,12 @@ async function callAnthropic(system, user) {
130
137
  model: modelFor("anthropic"),
131
138
  max_tokens: 8192,
132
139
  system,
133
- messages: [{ role: "user", content: user }],
140
+ messages,
134
141
  });
135
142
  return extractAnthropicText(msg);
136
143
  }
137
144
  /** Groq's chat-completions endpoint is OpenAI-compatible — plain fetch, no SDK. */
138
- async function callGroq(system, user) {
145
+ async function callGroq(system, messages) {
139
146
  const res = await fetch("https://api.groq.com/openai/v1/chat/completions", {
140
147
  method: "POST",
141
148
  headers: {
@@ -145,10 +152,7 @@ async function callGroq(system, user) {
145
152
  body: JSON.stringify({
146
153
  model: modelFor("groq"),
147
154
  max_tokens: 8192,
148
- messages: [
149
- { role: "system", content: system },
150
- { role: "user", content: user },
151
- ],
155
+ messages: [{ role: "system", content: system }, ...messages],
152
156
  }),
153
157
  });
154
158
  if (!res.ok)
package/dist/repl.js CHANGED
@@ -70,27 +70,29 @@ export async function processLine(line, state, io) {
70
70
  const cmd = tokens[0];
71
71
  const rest = tokens.slice(1);
72
72
  try {
73
- if (cmd === "exit" || cmd === "quit" || cmd === "q")
73
+ if ((cmd === "exit" || cmd === "quit" || cmd === "q") && rest.length === 0)
74
74
  return true;
75
- if (cmd === "help" || cmd === "?") {
75
+ if ((cmd === "help" || cmd === "?") && rest.length === 0) {
76
76
  const { renderHelp } = await import("./ui/repl-tui/commandRegistry.js");
77
77
  write(io, renderHelp() + "\n\n");
78
78
  return false;
79
79
  }
80
- if (cmd === "pwd") {
80
+ if (cmd === "pwd" && rest.length === 0) {
81
81
  write(io, ` ${state.root}\n\n`);
82
82
  return false;
83
83
  }
84
- if (cmd === "cd") {
84
+ if (cmd === "cd" && rest.length <= 1) {
85
85
  if (!rest[0]) {
86
86
  write(io, " usage: cd <path>\n\n");
87
87
  return false;
88
88
  }
89
89
  state.root = path.resolve(state.root, rest[0]);
90
+ state.lastResult = undefined; // stale results would ground chat in the wrong repo
91
+ state.chat?.reset();
90
92
  write(io, ` target: ${state.root}\n\n`);
91
93
  return false;
92
94
  }
93
- await dispatch(cmd, rest, state, io);
95
+ await dispatch(cmd, rest, state, io, trimmed);
94
96
  }
95
97
  catch (err) {
96
98
  write(io, ` error: ${err?.message ?? err}\n\n`);
@@ -118,9 +120,42 @@ function progressCallback(io, asJson) {
118
120
  return undefined;
119
121
  return io.onProgress ?? ((m) => write(io, ` · ${m}\n`));
120
122
  }
121
- async function dispatch(cmd, tokens, state, io) {
123
+ /**
124
+ * True when `rest` (the tokens after the command word) is a plausible
125
+ * argument list for `cmd`'s actual grammar -- known flags/subcommands only.
126
+ * A command WORD matching a real command but followed by tokens outside its
127
+ * grammar is natural language that happens to start with that word (e.g.
128
+ * "score seems too low, why?"), not an invocation -- dispatch() falls
129
+ * through to chat instead of running the command. Every dispatch-level name
130
+ * here must also appear in ui/repl-tui/commandRegistry.ts's COMMANDS list;
131
+ * repl-command-collision.test.ts cross-checks the two so they can't drift.
132
+ */
133
+ export function matchesCommandGrammar(cmd, rest) {
134
+ const jsonOnly = new Set(["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready", "explain"]);
135
+ if (jsonOnly.has(cmd))
136
+ return rest.every((t) => t === "--json");
137
+ if (cmd === "fix") {
138
+ const known = new Set(["--apply", "--yes", "--no-llm", "--json"]);
139
+ return rest.every((t) => known.has(t));
140
+ }
141
+ if (cmd === "init")
142
+ return rest.every((t) => t === "--force");
143
+ if (cmd === "config") {
144
+ if (rest.length === 0)
145
+ return true;
146
+ if (rest[0] === "show")
147
+ return rest.length === 1;
148
+ if (rest[0] === "set-key" || rest[0] === "unset-key")
149
+ return rest.length <= 2;
150
+ return false;
151
+ }
152
+ if (cmd === "db")
153
+ return rest.length === 0 || (rest.length === 1 && (rest[0] === "update" || rest[0] === "status"));
154
+ return false;
155
+ }
156
+ async function dispatch(cmd, tokens, state, io, rawLine) {
122
157
  const asJson = hasFlag(tokens, "--json");
123
- if (["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready"].includes(cmd)) {
158
+ if (["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready"].includes(cmd) && matchesCommandGrammar(cmd, tokens)) {
124
159
  const { scan } = await import("./engine/scan.js");
125
160
  const usage = await meterOrPrint(io);
126
161
  const result = await scan(state.root, {
@@ -128,6 +163,7 @@ async function dispatch(cmd, tokens, state, io) {
128
163
  onProgress: progressCallback(io, asJson),
129
164
  });
130
165
  state.lastResult = result;
166
+ state.chat?.setScanResult(result);
131
167
  if (asJson) {
132
168
  const { toJson } = await import("./ui/emit.js");
133
169
  write(io, toJson(result) + "\n\n");
@@ -136,7 +172,7 @@ async function dispatch(cmd, tokens, state, io) {
136
172
  write(io, (await renderFor(cmd, result, usage)) + "\n");
137
173
  return;
138
174
  }
139
- if (cmd === "fix") {
175
+ if (cmd === "fix" && matchesCommandGrammar(cmd, tokens)) {
140
176
  const { runFix } = await import("./engine/fix/session.js");
141
177
  const { renderFixSession, fixToJson, renderApprovalRequest } = await import("./ui/fix.js");
142
178
  const opts = { apply: hasFlag(tokens, "--apply"), yes: hasFlag(tokens, "--yes"), json: asJson };
@@ -175,7 +211,7 @@ async function dispatch(cmd, tokens, state, io) {
175
211
  }
176
212
  return;
177
213
  }
178
- if (cmd === "explain") {
214
+ if (cmd === "explain" && matchesCommandGrammar(cmd, tokens)) {
179
215
  if (!asJson) {
180
216
  if (io.inkMode) {
181
217
  const { noKeyNudgeMessage } = await import("./index-support.js");
@@ -205,7 +241,7 @@ async function dispatch(cmd, tokens, state, io) {
205
241
  write(io, renderExplain(report, usage) + "\n");
206
242
  return;
207
243
  }
208
- if (cmd === "init") {
244
+ if (cmd === "init" && matchesCommandGrammar(cmd, tokens)) {
209
245
  const { runInit } = await import("./engine/init.js");
210
246
  const { renderInit } = await import("./ui/init.js");
211
247
  const result = await runInit(state.root, { force: hasFlag(tokens, "--force") });
@@ -223,15 +259,70 @@ async function dispatch(cmd, tokens, state, io) {
223
259
  : undefined);
224
260
  return;
225
261
  }
226
- if (cmd === "config") {
262
+ if (cmd === "config" && matchesCommandGrammar(cmd, tokens)) {
227
263
  await handleConfig(tokens, io);
228
264
  return;
229
265
  }
230
- if (cmd === "db") {
266
+ if (cmd === "db" && matchesCommandGrammar(cmd, tokens)) {
231
267
  await handleDb(tokens, io);
232
268
  return;
233
269
  }
234
- write(io, ` unknown command: ${cmd} (type \`help\`)\n\n`);
270
+ await handleChat(rawLine, state, io);
271
+ }
272
+ /** Two-space indent so answers sit inside the REPL's output gutter. */
273
+ function indentAnswer(text) {
274
+ return text
275
+ .split("\n")
276
+ .map((l) => " " + l)
277
+ .join("\n");
278
+ }
279
+ /**
280
+ * Free text = a question about the last scan (spec 2026-07-12). Order of
281
+ * gates: scan first (nothing to talk about), then key (consent would be
282
+ * pointless without one), then once-per-session consent, then the ask.
283
+ * NOT metered: no scan runs here and the call spends the user's own key,
284
+ * the same reasoning that exempts --staged/--diff.
285
+ */
286
+ async function handleChat(question, state, io) {
287
+ if (!state.lastResult) {
288
+ write(io, " Run `scan` first so I have findings to talk about. (Free text asks the AI about your last scan; type `help` for commands.)\n\n");
289
+ return;
290
+ }
291
+ const { llmAvailability } = await import("./engine/fix/llm.js");
292
+ const avail = llmAvailability(false);
293
+ if (!avail.available) {
294
+ write(io, " Chat needs an AI key. Run `securevibe config set-key groq` (outside the REPL) to enable it; `explain` and `fix` use the same key.\n\n");
295
+ return;
296
+ }
297
+ if (state.chatConsent === false) {
298
+ write(io, " Chat is off for this session (you declined earlier). Commands still work; type `help`.\n\n");
299
+ return;
300
+ }
301
+ if (state.chatConsent === undefined) {
302
+ const render = (req) => req.kind === "chat-consent"
303
+ ? ` First question this session: send your questions and the scan findings to ${req.provider}?`
304
+ : "";
305
+ const confirm = io.makeConfirm ? io.makeConfirm(render) : makeFixConfirm(render, io.input, io.output);
306
+ const answer = await confirm({ kind: "chat-consent", provider: avail.provider });
307
+ state.chatConsent = answer === "yes";
308
+ if (!state.chatConsent) {
309
+ write(io, " Understood, chat stays off for this session. Commands still work; type `help`.\n\n");
310
+ return;
311
+ }
312
+ }
313
+ if (!state.chat) {
314
+ const { ChatSession } = await import("./engine/chat.js");
315
+ state.chat = new ChatSession();
316
+ state.chat.setScanResult(state.lastResult);
317
+ }
318
+ const progress = progressCallback(io, false);
319
+ progress?.(`asking ${avail.provider}…`);
320
+ const answer = await state.chat.ask(question);
321
+ if (answer == null) {
322
+ write(io, " The AI call failed; nothing was answered. Try again, or use `explain` for per finding context.\n\n");
323
+ return;
324
+ }
325
+ write(io, indentAnswer(answer) + "\n\n");
235
326
  }
236
327
  async function renderFor(cmd, result, usage) {
237
328
  if (cmd === "scan" || cmd === "ai-audit") {
package/dist/ui/fix.js CHANGED
@@ -131,7 +131,7 @@ export function renderApprovalRequest(req) {
131
131
  L.push(renderDiff(p.relPath, p.before, p.after));
132
132
  L.push(" " + pc.dim("verified: finding clears · no new issue · file still parses"));
133
133
  }
134
- else {
134
+ else if (req.kind === "side-effects") {
135
135
  L.push("");
136
136
  L.push(" " + pc.bold("Supporting changes") + pc.dim(" (additive, no break risk)"));
137
137
  for (const fx of req.effects) {
package/dist/ui/prompt.js CHANGED
@@ -26,10 +26,11 @@ export function decideApply(opts, interactive) {
26
26
  return { apply: false, prompt: false, notice: true };
27
27
  return { apply: true, prompt: true, notice: false };
28
28
  }
29
- /** Map a raw keypress/line to an answer. Empty / unknown → the safe default "no". */
29
+ /** Map a raw keypress/line to an answer. Empty / unknown → the safe default "no".
30
+ * Only "patch" offers all/quit; side-effects and chat-consent are yes/no. */
30
31
  export function mapAnswer(raw, kind) {
31
32
  const c = raw.trim().toLowerCase().charAt(0);
32
- if (kind === "side-effects")
33
+ if (kind !== "patch")
33
34
  return c === "y" ? "yes" : "no";
34
35
  if (c === "y")
35
36
  return "yes";
@@ -55,7 +56,9 @@ export function makeFixConfirm(render, input = process.stdin, output = process.s
55
56
  output.write(render(req) + "\n");
56
57
  const prompt = req.kind === "patch"
57
58
  ? " Apply this change? [y]es / [n]o / [a]ll / [q]uit: "
58
- : " Apply these supporting changes? [y]es / [n]o: ";
59
+ : req.kind === "side-effects"
60
+ ? " Apply these supporting changes? [y]es / [n]o: "
61
+ : " Send to your AI provider? [y]es / [n]o: ";
59
62
  const raw = await askLine(prompt, input, output);
60
63
  return mapAnswer(raw, req.kind);
61
64
  };
@@ -118,7 +118,9 @@ export function App({ initialPath }) {
118
118
  }), h(Box, { flexDirection: "column" }, h(StatusLine, { targetRoot: stateRef.current.root }), busy && progressMessage ? h(Spinner, { message: progressMessage }) : null, confirmKind
119
119
  ? h(Text, { dimColor: true }, confirmKind === "patch"
120
120
  ? " Apply this change? [y]es / [n]o / [a]ll / [q]uit"
121
- : " Apply these supporting changes? [y]es / [n]o")
121
+ : confirmKind === "side-effects"
122
+ ? " Apply these supporting changes? [y]es / [n]o"
123
+ : " Send to your AI provider? [y]es / [n]o")
122
124
  : null, menuOpen ? h(SlashMenu, { items: filteredCommands, selectedIndex: clampedIndex }) : null, h(InputBox, {
123
125
  value,
124
126
  onChange: handleChange,
@@ -32,5 +32,7 @@ export function renderHelp() {
32
32
  const left = ` ${c.name}${c.args ? " " + c.args : ""}`;
33
33
  lines.push(left.length < 44 ? left.padEnd(44) + c.description : left + " " + c.description);
34
34
  }
35
+ lines.push("");
36
+ lines.push(" Anything else you type is a question for the AI about your last scan (needs a key: `config set-key groq`).");
35
37
  return lines.join("\n");
36
38
  }
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.12";
5
+ export const VERSION = "0.1.13";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securevibe",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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",