securevibe 0.1.10 → 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
@@ -28,8 +28,9 @@ midnight). The `--staged` commit guard is never limited. See [`LICENSE`](LICENSE
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
30
  | `securevibe explain [path]` | AI-powered plain-language explanations for critical/high findings |
31
+ | `securevibe pr-comment [path]` | Post/update a PR findings summary (used by the GitHub Actions workflow) |
31
32
  | `securevibe deps [path]` | Audit dependencies for known CVEs against the local OSV database (SCA) |
32
- | `securevibe db <update\|status>` | Manage the local OSV database — `update` is the only networked command |
33
+ | `securevibe db <update\|status>` | Manage the local OSV database — `update` is networked |
33
34
  | `securevibe ready [path]` | Launch readiness scorecard: pass/fail gates + go/no-go verdict |
34
35
  | `securevibe ai-audit [path]` | Focus on the AI-agent attack surface (doc 04) |
35
36
  | `securevibe protect [path]` | Remediation-first view: the fix for every finding (doc 05) |
@@ -40,11 +41,26 @@ midnight). The `--staged` commit guard is never limited. See [`LICENSE`](LICENSE
40
41
 
41
42
  Flags: `--json`, `--sarif` (GitHub code-scanning / CI interop), `--no-color`,
42
43
  `--ci` (exit `2` on BLOCK, `1` on WARN, `0` on SHIP — for build gates),
43
- `--staged` (scan only git-staged files — what the pre-commit guard uses).
44
+ `--staged` (scan only git-staged files — what the pre-commit guard uses),
45
+ `--diff <base>` (scan only files changed since `<base>` — what the PR-comment step
46
+ uses; also exempt from the daily usage limit, same as `--staged`).
44
47
  `fix` flags: `--apply` (write to disk; asks before each change), `--yes` (apply every
45
48
  verified fix without prompting — for CI/scripts), `--no-llm` (deterministic-only), `--json`.
46
49
  `init` flags: `--force` (replace an existing hook/workflow/config).
47
50
 
51
+ The REPL renders a bordered input box with a live status line (target + active AI-fixer
52
+ provider). Type `/` to open a command-discovery menu, or type commands directly as
53
+ before (`scan`, `fix --apply`, `help`, etc. — the `/` prefix is optional, not required).
54
+ Up/down arrow cycles through your command history. `scan`/`fix`/`explain` show a single
55
+ live progress spinner instead of scrolling status lines.
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
+
48
64
  ## Always-on (doc 12)
49
65
 
50
66
  `securevibe init` makes the tool continuous so insecure code can't slip through:
@@ -53,11 +69,16 @@ verified fix without prompting — for CI/scripts), `--no-llm` (deterministic-on
53
69
  a blocking issue** — including a committed secret (verified: it aborts the commit, then lets the
54
70
  clean version through);
55
71
  - a **GitHub Actions workflow** (`.github/workflows/securevibe.yml`) that scans every PR and uploads
56
- SARIF to GitHub code scanning, gating the build on deployment-readiness;
72
+ SARIF to GitHub code scanning, gates the build on deployment-readiness, and posts a PR comment
73
+ summarizing findings in the *changed* files only (updated in place on new commits, not reposted —
74
+ and the only other command besides `db update` that talks to the network, and only to post a
75
+ findings summary already computed locally, never your source code);
57
76
  - gitignores `.env` / backups and drops a minimal `securevibe.config.json`.
58
77
 
59
- It is idempotent (existing files are left alone unless `--force`) and runs entirely locally —
60
- **no account, no upload, and your secrets / API keys are never stored or transmitted.**
78
+ It is idempotent (existing files are left alone unless `--force`). Everything it installs runs
79
+ locally — **no account, and your secrets / API keys are never stored or transmitted.** The one
80
+ exception is the PR-comment step above, which uploads a findings summary (never your source code)
81
+ already computed by the local scan.
61
82
 
62
83
  ## The fix loop (doc 05)
63
84
 
@@ -121,8 +142,9 @@ against a local copy of the [OSV](https://osv.dev) advisory database, and report
121
142
  CVE still blocks deployment, and `securevibe fix` bumps the package to the lowest non vulnerable
122
143
  version while preserving your range prefix (`^`, `~`, `==`, ...).
123
144
  - **Offline first.** A bundled seed (`data/osv-seed.json`) ships with the CLI, so `deps` works with
124
- no network. `db update` is the **only networked command**: it streams the public OSV npm and PyPI
125
- dumps into `~/.securevibe/db`. Nothing about your code or package list leaves the machine.
145
+ no network. `db update` is the only command that **downloads** anything (the public OSV npm and
146
+ PyPI dumps, into `~/.securevibe/db`). `pr-comment` is the only command that **uploads** anything,
147
+ and only a findings summary already computed locally — never your code or package list.
126
148
  - **Honest framing.** A clean result reads "no known advisories as of `<db date>`", not "secure" —
127
149
  it means nothing matched the local database on that date, which is not a proof of safety.
128
150
 
@@ -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)
@@ -34,11 +34,15 @@ export function gitDir(root) {
34
34
  /**
35
35
  * Staged files (added/copied/modified) as absolute paths, filtered to those
36
36
  * that exist on disk. Returns null if not a git repo / git unavailable.
37
+ * `--relative` matters when `root` is a subdirectory of the repo: git diff
38
+ * paths are toplevel-relative by default, which would silently mis-resolve
39
+ * against `root` otherwise (and --relative also scopes the diff to `root`,
40
+ * matching "scan this path" semantics for a subdirectory target).
37
41
  */
38
42
  export async function listStagedFiles(root) {
39
43
  if (!isGitRepo(root))
40
44
  return null;
41
- const out = git(root, ["diff", "--cached", "--name-only", "--diff-filter=ACM", "-z"]);
45
+ const out = git(root, ["diff", "--cached", "--relative", "--name-only", "--diff-filter=ACM", "-z"]);
42
46
  if (out == null)
43
47
  return null;
44
48
  const rels = out.split("\0").filter((s) => s.length > 0);
@@ -56,3 +60,32 @@ export async function listStagedFiles(root) {
56
60
  }
57
61
  return abs;
58
62
  }
63
+ /**
64
+ * Files changed (added/copied/modified) between `baseRef` and the working
65
+ * tree, as absolute paths, filtered to those that still exist on disk. Used
66
+ * by `--diff` (PR-scoped scans) — same shape as listStagedFiles, but diffing
67
+ * against an arbitrary ref instead of the index. Returns null if not a git
68
+ * repo / git unavailable, or if the diff itself fails (e.g. `baseRef` doesn't
69
+ * exist in this checkout).
70
+ */
71
+ export async function listChangedFiles(root, baseRef) {
72
+ if (!isGitRepo(root))
73
+ return null;
74
+ const out = git(root, ["diff", "--relative", "--name-only", "--diff-filter=ACM", "-z", baseRef]);
75
+ if (out == null)
76
+ return null;
77
+ const rels = out.split("\0").filter((s) => s.length > 0);
78
+ const abs = [];
79
+ for (const rel of rels) {
80
+ const p = path.join(root, rel);
81
+ try {
82
+ const st = await fs.stat(p);
83
+ if (st.isFile())
84
+ abs.push(p);
85
+ }
86
+ catch {
87
+ /* changed-but-removed or unreadable — skip */
88
+ }
89
+ }
90
+ return abs;
91
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Minimal GitHub REST client for the pr-comment command (doc 12). Posts or
3
+ * updates a single PR comment, found via a hidden marker so re-runs replace
4
+ * it in place instead of piling up new comments. Every failure (auth,
5
+ * network, rate limit) is caught and returned as a result — this must never
6
+ * throw, since posting the comment is best-effort visibility, not a gate
7
+ * (the existing `scan --ci` step is what's allowed to fail the build).
8
+ */
9
+ export const PR_COMMENT_MARKER = "<!-- securevibe-pr-comment -->";
10
+ const API_BASE = "https://api.github.com";
11
+ function authHeaders(token) {
12
+ return {
13
+ Authorization: `Bearer ${token}`,
14
+ Accept: "application/vnd.github+json",
15
+ "Content-Type": "application/json",
16
+ "X-GitHub-Api-Version": "2022-11-28",
17
+ };
18
+ }
19
+ /** Finds an existing SecureVibe comment on the PR by its hidden marker, or null if none/on any failure. */
20
+ async function findExistingComment(target) {
21
+ try {
22
+ const res = await fetch(`${API_BASE}/repos/${target.owner}/${target.repo}/issues/${target.prNumber}/comments?per_page=100`, { headers: authHeaders(target.token) });
23
+ if (!res.ok)
24
+ return null;
25
+ const comments = await res.json();
26
+ if (!Array.isArray(comments))
27
+ return null;
28
+ const existing = comments.find((c) => typeof c?.body === "string" && c.body.includes(PR_COMMENT_MARKER));
29
+ return existing?.id ?? null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /**
36
+ * Posts a new PR comment, or updates the existing SecureVibe comment (found
37
+ * via the marker) in place. Never throws — any failure comes back as
38
+ * `{ posted: false, error }` so the caller can warn and continue.
39
+ */
40
+ export async function postOrUpdateComment(target, body) {
41
+ try {
42
+ const existingId = await findExistingComment(target);
43
+ const url = existingId
44
+ ? `${API_BASE}/repos/${target.owner}/${target.repo}/issues/comments/${existingId}`
45
+ : `${API_BASE}/repos/${target.owner}/${target.repo}/issues/${target.prNumber}/comments`;
46
+ const res = await fetch(url, {
47
+ method: existingId ? "PATCH" : "POST",
48
+ headers: authHeaders(target.token),
49
+ body: JSON.stringify({ body }),
50
+ });
51
+ if (!res.ok) {
52
+ return { posted: false, updated: false, error: `GitHub API error: ${res.status}` };
53
+ }
54
+ return { posted: true, updated: existingId !== null };
55
+ }
56
+ catch (err) {
57
+ return { posted: false, updated: false, error: err?.message ?? String(err) };
58
+ }
59
+ }
@@ -2,7 +2,7 @@
2
2
  * `securevibe init` (doc 12) — make SecureVibe always-on in a project with one
3
3
  * command. Installs a git pre-commit guard (blocks commits that introduce a
4
4
  * BLOCK-level issue, including committed secrets), a GitHub Actions workflow
5
- * (PR scan + SARIF upload to code scanning), gitignores `.env` and backups, and
5
+ * (PR scan + SARIF upload + PR summary comment), gitignores `.env` and backups, and
6
6
  * drops a minimal config. Idempotent: existing files are left alone unless
7
7
  * `--force`. Everything runs locally — no account, no upload, no API key.
8
8
  */
@@ -72,8 +72,8 @@ if [ "$?" -eq 2 ]; then
72
72
  fi
73
73
  exit 0
74
74
  `;
75
- const WORKFLOW = `# SecureVibe — scan every PR and upload findings to GitHub code scanning.
76
- # Managed by \`securevibe init\`.
75
+ const WORKFLOW = `# SecureVibe — scan every PR, upload findings to GitHub code scanning, and
76
+ # post a PR summary comment. Managed by \`securevibe init\`.
77
77
  name: SecureVibe
78
78
  on:
79
79
  pull_request:
@@ -82,11 +82,14 @@ on:
82
82
  permissions:
83
83
  contents: read
84
84
  security-events: write
85
+ issues: write
85
86
  jobs:
86
87
  securevibe:
87
88
  runs-on: ubuntu-latest
88
89
  steps:
89
90
  - uses: actions/checkout@v4
91
+ with:
92
+ fetch-depth: 0
90
93
  - uses: actions/setup-node@v4
91
94
  with:
92
95
  node-version: 20
@@ -101,6 +104,12 @@ jobs:
101
104
  sarif_file: securevibe.sarif
102
105
  - name: Gate the build on deployment readiness
103
106
  run: securevibe scan . --ci
107
+ - name: PR comment
108
+ if: always() && github.event_name == 'pull_request'
109
+ env:
110
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
111
+ PR_NUMBER: \${{ github.event.pull_request.number }}
112
+ run: securevibe pr-comment . --diff \${{ github.event.pull_request.base.sha }}
104
113
  `;
105
114
  export async function runInit(root, opts = {}) {
106
115
  const absRoot = path.resolve(root);
@@ -122,7 +131,7 @@ export async function runInit(root, opts = {}) {
122
131
  });
123
132
  }
124
133
  // 4) GitHub Actions workflow.
125
- actions.push(await writeIfAbsent(absRoot, ".github/workflows/securevibe.yml", WORKFLOW, opts.force, "PR scan + SARIF upload"));
134
+ actions.push(await writeIfAbsent(absRoot, ".github/workflows/securevibe.yml", WORKFLOW, opts.force, "PR scan + SARIF upload + PR summary comment"));
126
135
  return { root: absRoot, isGitRepo: repo, actions };
127
136
  }
128
137
  async function ensureGitignore(root) {
@@ -1,6 +1,13 @@
1
1
  import { walk } from "./ast.js";
2
- /** Untrusted-input sources. Matching text is considered tainted at its origin. */
3
- const SOURCE_RE = /\b(req|request)\.(query|body|params|headers|cookies)\b|\bsearchParams\.get\b|\bnextUrl\.searchParams\b|\bparams\.[a-zA-Z_$]|\breq(uest)?\.json\(\)|\bawait\s+\w+\.json\(\)|\bprocess\.argv\b|\bflask\.request\.|\brequest\.args\b|\brequest\.form\b|\brequest\.json\b/;
2
+ /**
3
+ * Untrusted-input sources. Matching text is considered tainted at its origin.
4
+ * `req(uest)?.json()` (with or without a preceding `await`) is intentionally
5
+ * scoped to those two names, not `\w+.json()` generically -- an unrestricted
6
+ * `\w+` also matched `await res.json()` on a response *we* fetched (e.g. an
7
+ * outbound GitHub API call), wrongly treating our own already-fetched data
8
+ * as untrusted input.
9
+ */
10
+ const SOURCE_RE = /\b(req|request)\.(query|body|params|headers|cookies)\b|\bsearchParams\.get\b|\bnextUrl\.searchParams\b|\bparams\.[a-zA-Z_$]|\breq(uest)?\.json\(\)|\bprocess\.argv\b|\bflask\.request\.|\brequest\.args\b|\brequest\.form\b|\brequest\.json\b/;
4
11
  function collectAssignments(root) {
5
12
  const out = [];
6
13
  walk(root, (n) => {
@@ -32,7 +39,12 @@ function referencesTainted(text, tainted) {
32
39
  if (SOURCE_RE.test(text))
33
40
  return true;
34
41
  for (const v of tainted) {
35
- // word-boundary match so `id` doesn't match `idx`
42
+ // word-boundary match so `id` doesn't match `idx` -- but this also can't
43
+ // tell a real reference to `v` from `v`'s name coincidentally appearing
44
+ // inside a string/template literal (e.g. a URL path segment: a tainted
45
+ // var named `comments` false-matched a fetch URL literally containing
46
+ // "/comments"). Known false-positive class, not fixed here -- narrowing
47
+ // SOURCE_RE only closes the one source that produced this instance.
36
48
  const re = new RegExp(`\\b${escapeRe(v)}\\b`);
37
49
  if (re.test(text))
38
50
  return true;
@@ -2,6 +2,19 @@
2
2
  * Shared helpers used by both the one-shot CLI commands (index.ts) and the
3
3
  * interactive REPL (repl.ts), so the two entry points never duplicate logic.
4
4
  */
5
+ /**
6
+ * Pure (no I/O) check: is there no AI-fixer provider key configured right
7
+ * now? Returns the static nudge text to print when true, undefined when a
8
+ * key is already set. Shared by the interactive `askLine`-based offer below
9
+ * and by the Ink REPL path (ui/repl-tui/App.ts), which can't safely open a
10
+ * second readline on process.stdin while Ink holds it in raw mode — so it
11
+ * prints this instead of calling maybeOfferAiFixerSetup().
12
+ */
13
+ export function noKeyNudgeMessage() {
14
+ if (process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)
15
+ return undefined;
16
+ return " no AI-fixer key set — those findings show as manual review. Run `securevibe config set-key groq` (outside the REPL) to enable AI-powered fixes.\n\n";
17
+ }
5
18
  /**
6
19
  * If `fix` is about to run with no AI-fixer provider key available (and we're
7
20
  * in an interactive terminal), offer to set one up on the spot — a free Groq
@@ -9,7 +22,7 @@
9
22
  * never touches process.env if the user declines or a key is already set.
10
23
  */
11
24
  export async function maybeOfferAiFixerSetup() {
12
- if (process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)
25
+ if (!noKeyNudgeMessage())
13
26
  return;
14
27
  const { isInteractive, askLine } = await import("./ui/prompt.js");
15
28
  if (!isInteractive())
@@ -52,7 +65,8 @@ export async function maybeOfferAiFixerSetupDuringInit(root, deps) {
52
65
  const isInteractive = deps?.isInteractive ?? (await import("./ui/prompt.js")).isInteractive;
53
66
  if (!isInteractive())
54
67
  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");
68
+ const writePreamble = deps?.writePreamble ?? ((text) => process.stderr.write(text));
69
+ writePreamble("\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
70
  const offer = deps?.offer ?? maybeOfferAiFixerSetup;
57
71
  await offer();
58
72
  if (!(process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)) {
package/dist/index.js CHANGED
@@ -22,16 +22,18 @@ function addCommon(cmd) {
22
22
  .option("--sarif", "output SARIF 2.1.0 (for CI / GitHub code scanning)")
23
23
  .option("--no-color", "disable coloured output")
24
24
  .option("--staged", "scan only git-staged files (for pre-commit guards)")
25
+ .option("--diff <base>", "scan only files changed since <base> (for PR checks)")
25
26
  .option("--ci", "CI mode: exit non-zero based on deployment readiness");
26
27
  }
27
28
  /**
28
29
  * Free beta metering: one use per analysis command against the daily quota.
29
- * The --staged commit guard is exempt a quota must never block a commit.
30
- * Returns the usage state so callers can show it, or undefined when this run
31
- * wasn't metered (--staged).
30
+ * The --staged commit guard and --diff (PR-scoped CI scans) are both exempt
31
+ * an always-on automated guard must never be the thing that breaks. Returns
32
+ * the usage state so callers can show it, or undefined when this run wasn't
33
+ * metered.
32
34
  */
33
35
  async function meter(opts = {}) {
34
- if (opts.staged)
36
+ if (opts.staged || opts.diff)
35
37
  return undefined;
36
38
  const { recordUse, limitMessage } = await import("./usage.js");
37
39
  const r = await recordUse();
@@ -62,6 +64,20 @@ async function runScan(pathArg, opts, scanOpts = {}) {
62
64
  scanOpts = { ...scanOpts, files: staged };
63
65
  }
64
66
  }
67
+ // --diff <base>: analyze only files changed since <base> (PR-scoped CI checks).
68
+ if (opts.diff) {
69
+ const path = await import("node:path");
70
+ const { listChangedFiles } = await import("./engine/git.js");
71
+ const absRoot = path.resolve(target);
72
+ const changed = await listChangedFiles(absRoot, opts.diff);
73
+ if (changed === null) {
74
+ if (!quiet)
75
+ process.stderr.write(` · could not diff against ${opts.diff} — scanning the whole tree\n`);
76
+ }
77
+ else {
78
+ scanOpts = { ...scanOpts, files: changed };
79
+ }
80
+ }
65
81
  const result = await scan(target, {
66
82
  ...scanOpts,
67
83
  onProgress: quiet ? undefined : (m) => process.stderr.write(` · ${m}\n`),
@@ -201,6 +217,36 @@ program
201
217
  }
202
218
  await maybeNotifyUpdate({ json: opts.json });
203
219
  });
220
+ program
221
+ .command("pr-comment")
222
+ .description("Post/update a PR summary of changed-file findings (used by the GitHub Actions workflow)")
223
+ .argument("[path]", "path to the repository", ".")
224
+ .option("--diff <base>", "base ref to diff against (the workflow passes the PR's base SHA)")
225
+ .action(async (pathArg, opts) => {
226
+ if (!opts.diff) {
227
+ process.stderr.write(" pr-comment: --diff <base> is required (the workflow passes the PR's base SHA).\n");
228
+ process.exit(64);
229
+ }
230
+ const { result } = await runScan(pathArg, { diff: opts.diff });
231
+ const { renderPrComment } = await import("./ui/prComment.js");
232
+ const body = renderPrComment(result);
233
+ const token = process.env.GITHUB_TOKEN;
234
+ const repoSlug = process.env.GITHUB_REPOSITORY;
235
+ const prNumber = process.env.PR_NUMBER ? Number(process.env.PR_NUMBER) : undefined;
236
+ if (!token || !repoSlug || !prNumber) {
237
+ process.stdout.write(body + "\n");
238
+ return;
239
+ }
240
+ const [owner, repo] = repoSlug.split("/");
241
+ const { postOrUpdateComment } = await import("./engine/github.js");
242
+ const posted = await postOrUpdateComment({ owner, repo, prNumber, token }, body);
243
+ if (!posted.posted) {
244
+ process.stderr.write(` pr-comment: could not post to GitHub (${posted.error ?? "unknown error"}) — printing instead.\n`);
245
+ process.stdout.write(body + "\n");
246
+ return;
247
+ }
248
+ process.stderr.write(` pr-comment: ${posted.updated ? "updated" : "posted"} the summary on PR #${prNumber}.\n`);
249
+ });
204
250
  addCommon(program
205
251
  .command("ai-audit")
206
252
  .description("Focus on the AI-application / agent attack surface (doc 04)")
@@ -275,7 +321,7 @@ addCommon(program
275
321
  });
276
322
  program
277
323
  .command("db")
278
- .description("Manage the local OSV vulnerability database (the only networked command)")
324
+ .description("Manage the local OSV vulnerability database (update is networked)")
279
325
  .argument("<action>", "update | status")
280
326
  .option("--no-color", "disable coloured output")
281
327
  .action(async (action, opts) => {