@cantinasecurity/apex-cli 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,7 +19,9 @@ Recommended workflow:
19
19
  4. Call `apex-doctor` before starting a scan if workspace binding or source planning might be unclear.
20
20
  5. If the directory is not bound to the right workspace, call `apex-workspaces` and `apex-workspace-use`.
21
21
  6. Use `apex-scan`, `apex-status`, `apex-scans`, `apex-findings`, and `apex-export-findings` for the scan lifecycle.
22
- 7. Use `apex-finding-comment` and `apex-finding-feedback` when the user wants to leave review notes or valid/invalid feedback on a finding.
22
+ 7. For PR scans, call `apex-scan` with `mode: "pr"` and `pullRequests`.
23
+ 8. Use `apex-finding-comment` and `apex-finding-feedback` when the user wants to leave review notes or valid/invalid feedback on a finding.
24
+ 9. When an agent creates a fix PR externally, call `apex-finding-feedback` with `status: "valid"`, `labels: ["fixed"]`, and `fixPrUrls`, then call `apex-finding-fix-review` to start the fix review scan.
23
25
 
24
26
  If the Apex MCP server is not configured, fall back to the local CLI:
25
27
 
@@ -31,16 +33,20 @@ If the Apex MCP server is not configured, fall back to the local CLI:
31
33
  - `apex scan` scans the current working directory by default; pass `--repo` only when the user asks to scan explicit alternate roots.
32
34
  - `apex-doctor` reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
33
35
  - Plain local directories and dirty git worktrees can scan through local snapshot uploads without provider access.
34
- - Audit scans use `--mode audit` in user-facing CLI calls. The legacy `ultra` mode remains accepted as an alias, but audit scans still require provider-backed GitHub or GitLab sources.
36
+ - Audit scans use `--mode audit` in user-facing CLI calls and request payloads. The legacy `ultra` mode remains accepted as an alias, but audit scans still require provider-backed GitHub or GitLab sources.
37
+ - PR scans use `--mode pr --pr <number>` in CLI calls, or `mode: "pr"` with `pullRequests` in MCP. They require one provider-backed GitHub repository.
35
38
  - `apex-workspace-use` accepts a workspace name, prefix, or ID.
36
39
  - Use `sourceMode: "remote"` only when the user explicitly wants to forbid local snapshot fallbacks.
37
- - Finding comments and feedback currently require `CANTINA_AUTH_TOKEN` in the MCP server environment because those writes go through the Cantina web-app routes instead of the Apex CLI bearer-token routes.
40
+ - Finding comments, feedback, and fix review scan starts currently require `CANTINA_AUTH_TOKEN` in the MCP server environment because those writes go through the Cantina web-app routes instead of the Apex CLI bearer-token routes.
38
41
  - Invalid finding feedback requires `dismissalReason`; valid feedback can include `suggestedSeverity`, including `extreme`.
42
+ - Fix PR callback feedback requires valid feedback with `labels: ["fixed"]` and `fixPrUrls`; start the fix review scan with `apex-finding-fix-review` after saving that feedback.
39
43
  - Finding identifiers such as `KERN2-25` resolve against the selected or latest scan for the current workspace binding. Pass an explicit `scanId` when needed, or use the finding UUID directly.
40
44
 
41
45
  ## Examples
42
46
 
43
47
  - Start a scan for the current repository or directory: run `apex-doctor`, bind a workspace if needed, then call `apex-scan`.
48
+ - Start a PR scan: call `apex-scan` with `mode: "pr"` and `pullRequests`.
44
49
  - Check an active scan: call `apex-status`, then `apex-findings` when the scan completes.
45
50
  - Export findings for review: call `apex-export-findings` with `format` set to `markdown`, `json`, or `gitlab-sast`.
46
51
  - Leave review feedback: call `apex-finding-comment` to add a note, or `apex-finding-feedback` with `status` set to `valid` or `invalid`.
52
+ - Add a Fix PR and run fix review: call `apex-finding-feedback` with `status: "valid"`, `labels: ["fixed"]`, and `fixPrUrls`, then call `apex-finding-fix-review`.
package/README.md CHANGED
@@ -99,11 +99,13 @@ Supported shell commands:
99
99
 
100
100
  - `/credits`
101
101
  - `/scan [standard|audit]`
102
+ - `/scan pr <pr-number>`
102
103
  - `/scans`
103
104
  - `/findings [scan-id]`
104
105
  - `/findings comment <finding-id|finding-identifier> <comment>`
105
106
  - `/findings feedback <finding-id|finding-identifier> valid [comment]`
106
107
  - `/findings feedback <finding-id|finding-identifier> invalid <false-positive|by-design|not-relevant> [comment]`
108
+ - `/findings fix-review <finding-id|finding-identifier>`
107
109
  - `/export [scan-id]`
108
110
  - `/workspaces`
109
111
  - `/cancel-scan [scan-id]`
@@ -129,10 +131,12 @@ Supported shell commands:
129
131
 
130
132
  - `apex credits`
131
133
  - `apex scan`
134
+ - `apex scan --mode pr --pr <number> [--pr <number>] [--pr-path <path>]`
132
135
  - `apex scans`
133
136
  - `apex findings [--scan <scan-id>]`
134
137
  - `apex findings comment <finding-id|finding-identifier> --content <markdown> [--parent-comment <comment-id>] [--scan <scan-id>]`
135
- - `apex findings feedback <finding-id|finding-identifier> <valid|invalid> [comment] [--comment <markdown>] [--scan <scan-id>] [--suggested-severity extreme|critical|high|medium|low|informational] [--dismissal-reason false-positive|by-design|not-relevant]`
138
+ - `apex findings feedback <finding-id|finding-identifier> <valid|invalid> [comment] [--comment <markdown>] [--scan <scan-id>] [--suggested-severity extreme|critical|high|medium|low|informational] [--dismissal-reason false-positive|by-design|not-relevant] [--label acknowledged|fixed] [--fix-pr-url <github-pr-url>]`
139
+ - `apex findings fix-review <finding-id|finding-identifier> [--scan <scan-id>]`
136
140
  - `apex export findings [--scan <scan-id>] [--format markdown|json|gitlab-sast] [--output <path>]`
137
141
  - `apex workspaces`
138
142
  - `apex workspace`
@@ -161,16 +165,26 @@ Finding review collaboration now has explicit write commands:
161
165
  - `apex findings comment <finding-id|finding-identifier> --content "Needs auth check"`
162
166
  - `apex findings feedback <finding-id|finding-identifier> valid --comment "Reproduced on latest build"`
163
167
  - `apex findings feedback <finding-id|finding-identifier> invalid --dismissal-reason false-positive --comment "This path is unreachable"`
168
+ - `apex findings feedback <finding-id|finding-identifier> valid --label fixed --fix-pr-url https://github.com/acme/app/pull/123 --comment "Fixed in PR #123"`
169
+ - `apex findings fix-review <finding-id|finding-identifier>`
164
170
  - `/findings comment <finding-ref> <comment>`
165
171
  - `/findings feedback <finding-ref> valid [comment]`
166
172
  - `/findings feedback <finding-ref> invalid <false-positive|by-design|not-relevant> [comment]`
173
+ - `/findings fix-review <finding-ref>`
167
174
 
168
175
  Identifiers such as `KERN2-25` are resolved against the selected or latest scan for the current workspace binding. Pass `--scan <scan-id>` when you need a specific scan, or pass the finding UUID directly to skip workspace-based resolution.
169
176
 
170
- Finding comments and valid/invalid feedback currently require a Cantina web session token in `CANTINA_AUTH_TOKEN`. Set it to the value of your logged-in `auth_token` cookie before using the write commands or the corresponding MCP tools.
177
+ Finding comments, valid/invalid feedback, and fix review scan starts currently require a Cantina web session token in `CANTINA_AUTH_TOKEN`. Set it to the value of your logged-in `auth_token` cookie before using the write commands or the corresponding MCP tools.
171
178
 
172
179
  Invalid feedback requires a dismissal reason. Valid feedback can include `--suggested-severity extreme|critical|high|medium|low|informational`.
173
180
 
181
+ Fix review scans use a two-step callback flow for agents that create a PR outside Apex:
182
+
183
+ 1. Save fixed feedback on the finding with `--label fixed` and one or more `--fix-pr-url` values.
184
+ 2. Start the scan with `apex findings fix-review <finding-id|finding-identifier>`.
185
+
186
+ The matching MCP flow is `apex-finding-feedback` with `status: "valid"`, `labels: ["fixed"]`, and `fixPrUrls`, followed by `apex-finding-fix-review`.
187
+
174
188
  This is intentionally documented as a separate auth requirement because the current Apex read APIs and finding review write APIs do not accept the same credentials:
175
189
 
176
190
  - read operations such as `apex findings`, `apex export findings`, and `apex-findings` use the Apex CLI device-login bearer token
@@ -188,11 +202,15 @@ Useful flags:
188
202
 
189
203
  - `--repo <path>` to scan one or more explicit local roots instead of the current directory
190
204
  - `--source-mode auto|remote|local` to control remote-first fallback behavior
191
- - `--mode standard|audit` to choose the scan mode
205
+ - `--mode standard|audit|pr` to choose the scan mode
206
+ - `--pr <number>` to select one or more GitHub pull requests for `--mode pr`
207
+ - `--pr <number:path,path>` or `--pr-path <path>` to limit a PR scan to changed paths
192
208
 
193
209
  `auto` is the default. `remote` requires Apex to materialize from a remote repository. `local` forces a local snapshot upload even when a clean remote path is available.
194
210
 
195
- Audit scans still require provider-backed GitHub or GitLab repositories that Apex can materialize remotely without a local snapshot fallback. `ultra` remains accepted as a backwards-compatible alias for the audit scan mode.
211
+ Audit scans use `audit` as the scan mode and still require provider-backed GitHub or GitLab repositories that Apex can materialize remotely without a local snapshot fallback. `ultra` remains accepted as a backwards-compatible alias.
212
+
213
+ PR scans require exactly one provider-backed GitHub repository. If the current directory resolves to multiple sources, pass `--repo <path>` to select the one that contains the pull request.
196
214
 
197
215
  ## LLM / MCP Usage
198
216
 
@@ -264,7 +282,9 @@ The MCP server exposes Apex-specific tools for:
264
282
  - doctor, credits, and provider connection URLs
265
283
  - workspace inspection and workspace binding
266
284
  - scan start, status, cancellation, findings, and findings export
285
+ - PR scan start by calling `apex-scan` with `mode: "pr"` and `pullRequests`
267
286
  - finding comments and valid/invalid feedback with `apex-finding-comment` and `apex-finding-feedback`
287
+ - Fix PR callback and fix review scans with `apex-finding-feedback` plus `apex-finding-fix-review`
268
288
 
269
289
  For repository-scoped operations, pass `cwd` explicitly so the server can resolve the right `.apex/workspace.json` binding and repository roots.
270
290
 
package/dist/apex.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ApexApiClient, formatApiError } from "./api-client.js";
2
2
  import { parseArgs } from "./args.js";
3
- import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindings, commandLogin, commandLogout, commandScan, commandScans, commandSetup, commandStatus, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
3
+ import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindingFixReview, commandFindings, commandLogin, commandLogout, commandScan, commandScans, commandSetup, commandStatus, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
4
4
  import { CLI_HELP_TEXT } from "./help.js";
5
5
  import { runMcpServer } from "./mcp.js";
6
6
  import { runInteractiveShell } from "./shell.js";
@@ -86,9 +86,16 @@ async function main() {
86
86
  comment: parsed.args.slice(2).join(" ").trim() || getFlagValue(parsed.flags.comment),
87
87
  suggestedSeverity: getFlagValue(parsed.flags["suggested-severity"]),
88
88
  dismissalReason: getFlagValue(parsed.flags["dismissal-reason"]),
89
+ labels: getFlagValues(parsed.flags.label),
90
+ fixPrUrls: getFlagValues(parsed.flags["fix-pr-url"]),
89
91
  });
90
92
  return;
91
93
  }
94
+ if (parsed.subcommand === "fix-review") {
95
+ const findingRef = parsed.args[0] ?? "";
96
+ await commandFindingFixReview(client, cwd, parsed.flags, findingRef);
97
+ return;
98
+ }
92
99
  await commandFindings(client, cwd, parsed.flags);
93
100
  return;
94
101
  case "export":
@@ -113,6 +120,14 @@ async function main() {
113
120
  function getFlagValue(value) {
114
121
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
115
122
  }
123
+ function getFlagValues(value) {
124
+ if (Array.isArray(value)) {
125
+ return value.map((item) => item.trim()).filter((item) => item.length > 0);
126
+ }
127
+ return typeof value === "string" && value.trim().length > 0
128
+ ? [value.trim()]
129
+ : [];
130
+ }
116
131
  main().catch((error) => {
117
132
  if (error instanceof Error && error.reported) {
118
133
  process.exitCode = 1;
package/dist/args.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const BOOLEAN_FLAGS = new Set(["force", "non-interactive", "json", "no-open", "help"]);
2
+ const REPEATABLE_VALUE_FLAGS = new Set(["repo", "pr", "pr-path", "fix-pr-url", "label"]);
2
3
  export function parseArgs(argv) {
3
4
  const flags = {};
4
5
  const positional = [];
@@ -17,7 +18,7 @@ export function parseArgs(argv) {
17
18
  if (!next || next.startsWith("--")) {
18
19
  throw new Error(`Missing value for --${key}`);
19
20
  }
20
- if (key === "repo") {
21
+ if (REPEATABLE_VALUE_FLAGS.has(key)) {
21
22
  const existing = flags[key] ?? [];
22
23
  existing.push(next);
23
24
  flags[key] = existing;
package/dist/commands.js CHANGED
@@ -1,14 +1,14 @@
1
- import { getFlagString, isJsonMode, isNonInteractive } from "./args.js";
1
+ import { getFlagList, getFlagString, isJsonMode, isNonInteractive } from "./args.js";
2
2
  import { logout } from "./auth.js";
3
3
  import { ApiError } from "./api-client.js";
4
4
  import { openInBrowser } from "./browser.js";
5
5
  import { loadConfig, saveConfig } from "./config.js";
6
- import { createFindingComment, fetchScanExport, fetchScanFindings, isFindingUuid, normalizeFindingRef, requireCantinaAuthToken, resolveFindingSelection, resolveScanSelection, submitFindingFeedback, } from "./findings.js";
6
+ import { createFindingComment, fetchScanExport, fetchScanFindings, isFindingUuid, normalizeFindingRef, requireCantinaAuthToken, resolveFindingSelection, resolveScanSelection, startFindingFixReviewScan, submitFindingFeedback, } from "./findings.js";
7
7
  import { prepareExplicitScanSources, supportsLegacyRemoteFlow, } from "./local-source-scan.js";
8
8
  import { logLine, printJson, withLoadingIndicator } from "./output.js";
9
9
  import { confirm } from "./prompt.js";
10
10
  import { cancelScan, fetchWorkspaceScans, findMostRelevantActiveScan, getScanDisplayLabel, getScanDisplayId, getTrackedScanId, isActiveScanStatus, matchesScanId, selectDefaultScanToCancel, } from "./scan.js";
11
- import { chooseCompany, createWorkspaceBinding, ensureAuthenticated, resolveWorkspaceSelection, resolveWorkspaceSelectionLegacy, resolveWorkspaceAllowingMissingConnections, selectWorkspaceTarget, } from "./session.js";
11
+ import { chooseCompany, createWorkspaceBinding, ensureAuthenticated, remediateMissingConnections, resolveWorkspaceSelection, resolveWorkspaceSelectionLegacy, resolveWorkspaceAllowingMissingConnections, selectWorkspaceTarget, } from "./session.js";
12
12
  import { createWorkspaceBindingFromSummary, fetchCompanyCredits, fetchCompanyWorkspaces, findWorkspaceByRef, } from "./workspaces.js";
13
13
  import { loadWorkspaceBinding, saveWorkspaceBinding } from "./workspace-binding.js";
14
14
  import { commandSetup as runCliSetup } from "./setup.js";
@@ -31,6 +31,10 @@ const FINDING_DISMISSAL_REASONS = [
31
31
  "by-design",
32
32
  "not-relevant",
33
33
  ];
34
+ const FINDING_FEEDBACK_LABELS = [
35
+ "acknowledged",
36
+ "fixed",
37
+ ];
34
38
  async function saveCompanyDefault(companyId) {
35
39
  const config = await loadConfig();
36
40
  if (config.defaultCompanyId !== companyId) {
@@ -78,7 +82,192 @@ function formatAuditScanBalance(scanBalance) {
78
82
  return `Audit scans: ${detailParts.join(", ")}`;
79
83
  }
80
84
  function normalizeRequestedScanMode(value) {
81
- return value === "ultra" || value === "audit" ? "ultra" : "standard";
85
+ if (value === "ultra" || value === "audit") {
86
+ return "audit";
87
+ }
88
+ if (value === "pr") {
89
+ return "pr";
90
+ }
91
+ return "standard";
92
+ }
93
+ function getFlagValues(flags, key) {
94
+ const values = getFlagList(flags, key);
95
+ const single = getFlagString(flags, key);
96
+ if (single && values.length === 0) {
97
+ return [single];
98
+ }
99
+ return values;
100
+ }
101
+ function parsePullRequestNumber(value) {
102
+ const trimmed = value.trim();
103
+ const direct = Number(trimmed);
104
+ if (Number.isInteger(direct) && direct > 0) {
105
+ return direct;
106
+ }
107
+ const urlMatch = trimmed.match(/\/pull\/(\d+)(?:\D|$)/i);
108
+ if (urlMatch?.[1]) {
109
+ const parsed = Number(urlMatch[1]);
110
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
111
+ }
112
+ return null;
113
+ }
114
+ function normalizeRepositoryUrl(value) {
115
+ return value.replace(/\.git$/i, "").replace(/\/+$/, "").toLowerCase();
116
+ }
117
+ function parseGithubPullRequestUrl(value) {
118
+ const withPathFilter = value.match(/^(https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+):(.+)$/i);
119
+ const urlValue = withPathFilter?.[1] ?? value;
120
+ const match = urlValue.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/i);
121
+ if (!match?.[1] || !match[2] || !match[3]) {
122
+ return null;
123
+ }
124
+ const number = parsePullRequestNumber(match[3]);
125
+ if (!number) {
126
+ return null;
127
+ }
128
+ return {
129
+ number,
130
+ repoUrl: `https://github.com/${match[1]}/${match[2]}.git`,
131
+ pathFilterValue: withPathFilter?.[2] ?? null,
132
+ };
133
+ }
134
+ function parsePrScanPullRequestSelection(value) {
135
+ const trimmed = value.trim();
136
+ const numericWithPaths = trimmed.match(/^(\d+)(?::(.+))?$/);
137
+ const githubUrl = numericWithPaths ? null : parseGithubPullRequestUrl(trimmed);
138
+ const pathFilterValue = numericWithPaths?.[2] ?? githubUrl?.pathFilterValue ?? null;
139
+ const number = parsePullRequestNumber(numericWithPaths?.[1] ?? (githubUrl ? String(githubUrl.number) : trimmed));
140
+ if (!number) {
141
+ throw new Error(`Invalid pull request selection "${value}". Use --pr <number> or --pr <number:path,path>.`);
142
+ }
143
+ const paths = pathFilterValue
144
+ ?.split(",")
145
+ .map((pathValue) => pathValue.trim().replace(/^\/+/, ""))
146
+ .filter((pathValue) => pathValue.length > 0) ?? [];
147
+ return { number, paths, repoUrl: githubUrl?.repoUrl ?? null };
148
+ }
149
+ function parsePrScanPullRequestSelections(flags) {
150
+ const prValues = getFlagValues(flags, "pr").flatMap((value) => {
151
+ const trimmed = value.trim();
152
+ if (/^\d+(,\d+)+$/.test(trimmed)) {
153
+ return trimmed.split(",");
154
+ }
155
+ return [trimmed];
156
+ });
157
+ const pathValues = getFlagValues(flags, "pr-path")
158
+ .map((value) => value.trim().replace(/^\/+/, ""))
159
+ .filter((value) => value.length > 0);
160
+ if (prValues.length === 0) {
161
+ if (pathValues.length > 0) {
162
+ throw new Error("--pr-path requires a --pr selection.");
163
+ }
164
+ return [];
165
+ }
166
+ const selections = prValues.map(parsePrScanPullRequestSelection);
167
+ if (pathValues.length > 0) {
168
+ if (selections.length !== 1) {
169
+ throw new Error("--pr-path can only be used with a single --pr selection. Use --pr <number:path,path> for per-PR path selection.");
170
+ }
171
+ selections[0] = {
172
+ ...selections[0],
173
+ paths: Array.from(new Set([...selections[0].paths, ...pathValues])),
174
+ };
175
+ }
176
+ const seen = new Map();
177
+ for (const selection of selections) {
178
+ const existing = seen.get(selection.number);
179
+ if (existing?.repoUrl &&
180
+ selection.repoUrl &&
181
+ normalizeRepositoryUrl(existing.repoUrl) !==
182
+ normalizeRepositoryUrl(selection.repoUrl)) {
183
+ throw new Error(`PR #${selection.number} was selected from multiple repositories. Use PR selections from one repository at a time.`);
184
+ }
185
+ const paths = existing && (existing.paths.length === 0 || selection.paths.length === 0)
186
+ ? []
187
+ : Array.from(new Set([...(existing?.paths ?? []), ...selection.paths])).sort((left, right) => left.localeCompare(right));
188
+ seen.set(selection.number, {
189
+ number: selection.number,
190
+ paths,
191
+ repoUrl: existing?.repoUrl ?? selection.repoUrl,
192
+ });
193
+ }
194
+ return [...seen.values()];
195
+ }
196
+ function resolvePrScanSource(repositories) {
197
+ if (repositories.length === 0) {
198
+ throw new Error("PR scans require a provider-backed GitHub repository.");
199
+ }
200
+ if (repositories.length > 1) {
201
+ throw new Error("PR scans require exactly one GitHub repository. Re-run with --repo to select a single source root.");
202
+ }
203
+ const source = repositories[0];
204
+ if (source.provider !== "github") {
205
+ throw new Error("PR scans require a provider-backed GitHub repository.");
206
+ }
207
+ if (typeof source.installationId !== "number") {
208
+ throw new Error("PR scans require GitHub App installation access for the selected repository.");
209
+ }
210
+ return source;
211
+ }
212
+ function ensurePrSelectionsMatchSource(selections, source) {
213
+ const expectedRepoUrl = normalizeRepositoryUrl(source.repoUrl);
214
+ const mismatched = selections.find((selection) => selection.repoUrl &&
215
+ normalizeRepositoryUrl(selection.repoUrl) !== expectedRepoUrl);
216
+ if (mismatched?.repoUrl) {
217
+ throw new Error(`PR #${mismatched.number} references ${mismatched.repoUrl}, but the selected repository is ${source.repoUrl}. Re-run with --repo for that repository or pass a PR number for the selected repository.`);
218
+ }
219
+ }
220
+ function formatMissingProviderConnections(missing) {
221
+ return missing
222
+ .map((item) => `${item.provider} ${item.repoUrl}`)
223
+ .join(", ");
224
+ }
225
+ function buildPrScanRepositoriesPayload(params) {
226
+ return [
227
+ {
228
+ provider: "github",
229
+ repoUrl: params.source.repoUrl,
230
+ installationId: params.source.installationId,
231
+ sourceType: "pr",
232
+ pullRequests: params.pullRequests.map((pullRequest) => ({
233
+ number: pullRequest.number,
234
+ ...(pullRequest.paths.length > 0 ? { paths: pullRequest.paths } : {}),
235
+ })),
236
+ },
237
+ ];
238
+ }
239
+ function getPrScanSourceDisplayName(source) {
240
+ const normalizedUrl = source.repoUrl.replace(/\.git$/i, "");
241
+ const repoMatch = normalizedUrl.match(/github\.com[:/]([^/]+\/[^/]+)$/i);
242
+ if (repoMatch?.[1]) {
243
+ return repoMatch[1];
244
+ }
245
+ return source.path && source.path !== "."
246
+ ? source.path
247
+ : path.posix.basename(normalizedUrl);
248
+ }
249
+ function buildPrScanPlannedSource(source) {
250
+ return {
251
+ path: source.path || ".",
252
+ displayName: getPrScanSourceDisplayName(source),
253
+ relativePath: source.path || ".",
254
+ sourceKind: "remote_repo",
255
+ provider: "github",
256
+ repoUrl: source.repoUrl,
257
+ ref: {
258
+ type: "default",
259
+ value: "default",
260
+ },
261
+ installationId: source.installationId,
262
+ connectionId: source.connectionId ?? null,
263
+ projectId: source.projectId ?? null,
264
+ baseUrl: source.baseUrl ?? null,
265
+ materializationAuth: "installation",
266
+ };
267
+ }
268
+ function getPrScanSelectedSources(sources, source) {
269
+ const matchedSources = sources.filter((candidate) => candidate.kind === "git_candidate" && candidate.repoUrl === source.repoUrl);
270
+ return matchedSources.length > 0 ? matchedSources : sources;
82
271
  }
83
272
  function normalizeFindingRefInput(value) {
84
273
  const trimmed = normalizeFindingRef(value);
@@ -97,6 +286,22 @@ function normalizeFeedbackSeverity(value) {
97
286
  }
98
287
  throw new Error(`Suggested severity must be one of: ${FINDING_FEEDBACK_SEVERITIES.join(", ")}.`);
99
288
  }
289
+ function normalizeFeedbackLabels(values) {
290
+ const normalized = Array.from(new Set((values ?? [])
291
+ .map((value) => value.trim())
292
+ .filter((value) => value.length > 0)));
293
+ if (normalized.length === 0) {
294
+ return null;
295
+ }
296
+ const invalid = normalized.find((value) => !FINDING_FEEDBACK_LABELS.includes(value));
297
+ if (invalid) {
298
+ throw new Error(`Feedback label must be one of: ${FINDING_FEEDBACK_LABELS.join(", ")}.`);
299
+ }
300
+ if (normalized.length > 1) {
301
+ throw new Error("Select at most one feedback label.");
302
+ }
303
+ return normalized;
304
+ }
100
305
  function normalizeDismissalReason(value) {
101
306
  const normalized = value?.trim() ?? "";
102
307
  if (!normalized) {
@@ -341,10 +546,17 @@ export async function commandConnect(client, cwd, flags, provider) {
341
546
  return { provider, company, url: response.url };
342
547
  }
343
548
  export async function commandScan(client, cwd, flags) {
549
+ const requestedMode = normalizeRequestedScanMode(getFlagString(flags, "mode"));
550
+ const prSelections = parsePrScanPullRequestSelections(flags);
551
+ if (requestedMode !== "pr" && prSelections.length > 0) {
552
+ throw new Error("Pull request selections require --mode pr.");
553
+ }
554
+ if (requestedMode === "pr" && prSelections.length === 0) {
555
+ throw new Error("PR scans require at least one --pr <number> selection.");
556
+ }
344
557
  const me = await ensureAuthenticated(client, flags);
345
558
  const selection = await selectWorkspaceTarget(cwd, me, flags);
346
559
  const result = await withLoadingIndicator("Resolving workspace and scan plan...", flags, () => resolveWorkspaceSelection(client, selection));
347
- const requestedMode = normalizeRequestedScanMode(getFlagString(flags, "mode"));
348
560
  if (!result.resolve.workspaceId) {
349
561
  throw new Error("Workspace resolution did not return a workspaceId.");
350
562
  }
@@ -353,7 +565,43 @@ export async function commandScan(client, cwd, flags) {
353
565
  const forceRestart = await ensureScanRestartConfirmed(flags, activeScan);
354
566
  let workspaceId = resolvedWorkspaceId;
355
567
  let scan;
356
- if (requestedMode === "ultra") {
568
+ let prScanSource = null;
569
+ if (requestedMode === "pr") {
570
+ let legacyResolve = await withLoadingIndicator("Preparing PR scan...", flags, () => resolveWorkspaceSelectionLegacy(client, selection, resolvedWorkspaceId));
571
+ if (!legacyResolve.workspaceId) {
572
+ throw new Error("Workspace resolution did not return a workspaceId.");
573
+ }
574
+ if (legacyResolve.missing.length > 0) {
575
+ if (isNonInteractive(flags)) {
576
+ throw new Error(`PR scans require connected provider access for ${formatMissingProviderConnections(legacyResolve.missing)}. Re-run interactively or pre-connect providers.`);
577
+ }
578
+ await remediateMissingConnections(legacyResolve.missing, flags);
579
+ legacyResolve = await withLoadingIndicator("Rechecking PR scan sources...", flags, () => resolveWorkspaceSelectionLegacy(client, selection, legacyResolve.workspaceId));
580
+ if (!legacyResolve.workspaceId) {
581
+ throw new Error("Workspace resolution did not return a workspaceId.");
582
+ }
583
+ if (legacyResolve.missing.length > 0) {
584
+ throw new Error(`PR scans require connected provider access for ${formatMissingProviderConnections(legacyResolve.missing)}.`);
585
+ }
586
+ }
587
+ workspaceId = legacyResolve.workspaceId;
588
+ const prSource = resolvePrScanSource(legacyResolve.repositories);
589
+ ensurePrSelectionsMatchSource(prSelections, prSource);
590
+ prScanSource = prSource;
591
+ scan = await withLoadingIndicator("Starting PR scan...", flags, () => client.request(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
592
+ method: "POST",
593
+ json: {
594
+ mode: "pr",
595
+ scanType: "pr",
596
+ force: forceRestart || flags.force === true,
597
+ repositories: buildPrScanRepositoriesPayload({
598
+ source: prSource,
599
+ pullRequests: prSelections,
600
+ }),
601
+ },
602
+ }));
603
+ }
604
+ else if (requestedMode === "audit") {
357
605
  if (!supportsLegacyRemoteFlow(result.resolve.plannedSources)) {
358
606
  throw new Error("Audit scans currently require provider-backed GitHub or GitLab repositories without local snapshot fallbacks.");
359
607
  }
@@ -396,13 +644,19 @@ export async function commandScan(client, cwd, flags) {
396
644
  };
397
645
  await saveWorkspaceBinding(cwd, binding);
398
646
  await saveCompanyDefault(result.company.id);
399
- const localSnapshotCount = result.resolve.plannedSources.filter((source) => source.sourceKind === "local_archive").length;
647
+ const summarySources = prScanSource
648
+ ? getPrScanSelectedSources(result.sources, prScanSource)
649
+ : result.sources;
650
+ const summaryPlannedSources = prScanSource
651
+ ? [buildPrScanPlannedSource(prScanSource)]
652
+ : result.resolve.plannedSources;
653
+ const localSnapshotCount = summaryPlannedSources.filter((source) => source.sourceKind === "local_archive").length;
400
654
  const payload = {
401
655
  company: result.company,
402
656
  workspaceId,
403
657
  workspaceName: result.workspaceName,
404
- sources: result.sources,
405
- plannedSources: result.resolve.plannedSources,
658
+ sources: summarySources,
659
+ plannedSources: summaryPlannedSources,
406
660
  scan: {
407
661
  ...scan,
408
662
  apexScanId: scan.apexScanId ?? scan.bedrockScanId ?? null,
@@ -416,12 +670,15 @@ export async function commandScan(client, cwd, flags) {
416
670
  logLine(`Authenticated as ${me.user.username ?? me.user.id}`, flags);
417
671
  logLine(`Company: ${result.company.handle ?? result.company.id}`, flags);
418
672
  logLine(`Workspace: ${result.workspaceName}`, flags);
419
- logLine(`Sources selected: ${result.sources.length}`, flags);
420
- logLine(`Plan: ${result.resolve.plannedSources.length - localSnapshotCount} remote, ${localSnapshotCount} local snapshot`, flags);
673
+ logLine(`Sources selected: ${summarySources.length}`, flags);
674
+ logLine(`Plan: ${summaryPlannedSources.length - localSnapshotCount} remote, ${localSnapshotCount} local snapshot`, flags);
421
675
  logLine(`Scan: ${scan.status}${getTrackedScanId(scan) ? ` (${getTrackedScanId(scan)})` : ""}`, flags);
422
676
  if (scan.scanUrl) {
423
677
  logLine(`View: ${scan.scanUrl}`, flags);
424
678
  }
679
+ if (scan.fullScanRecommendation) {
680
+ logLine(`Recommendation: ${scan.fullScanRecommendation}`, flags);
681
+ }
425
682
  logLine("Next: run `apex status` to watch progress, then `apex findings` when the scan finishes.", flags);
426
683
  return payload;
427
684
  }
@@ -713,6 +970,14 @@ export async function commandFindingFeedback(client, cwd, flags, params) {
713
970
  const findingRef = normalizeFindingRefInput(params.requestedFindingRef);
714
971
  const suggestedSeverity = normalizeFeedbackSeverity(params.suggestedSeverity);
715
972
  const dismissalReason = normalizeDismissalReason(params.dismissalReason);
973
+ const fixPrUrls = params.fixPrUrls
974
+ ?.map((url) => url.trim())
975
+ .filter((url) => url.length > 0) ?? [];
976
+ const labels = params.feedbackType === "valid" &&
977
+ fixPrUrls.length > 0 &&
978
+ (!params.labels || params.labels.length === 0)
979
+ ? ["fixed"]
980
+ : normalizeFeedbackLabels(params.labels);
716
981
  if (params.feedbackType === "valid" &&
717
982
  dismissalReason) {
718
983
  throw new Error("Dismissal reasons are only valid for invalid feedback.");
@@ -724,6 +989,15 @@ export async function commandFindingFeedback(client, cwd, flags, params) {
724
989
  if (params.feedbackType === "invalid" && !dismissalReason) {
725
990
  throw new Error("Invalid feedback requires a dismissal reason.");
726
991
  }
992
+ if (params.feedbackType !== "valid" && labels?.length) {
993
+ throw new Error("Feedback labels require valid feedback.");
994
+ }
995
+ if (params.feedbackType !== "valid" && fixPrUrls.length > 0) {
996
+ throw new Error("Fix PR URLs require valid feedback.");
997
+ }
998
+ if (fixPrUrls.length > 0 && labels?.[0] !== "fixed") {
999
+ throw new Error("Fix PR URLs require the fixed feedback label.");
1000
+ }
727
1001
  requireCantinaAuthToken();
728
1002
  const currentBinding = await loadWorkspaceBinding(cwd);
729
1003
  const needsWorkspaceResolution = !isFindingUuid(findingRef);
@@ -744,6 +1018,8 @@ export async function commandFindingFeedback(client, cwd, flags, params) {
744
1018
  comment: params.comment?.trim() || null,
745
1019
  suggestedSeverity,
746
1020
  dismissalReason,
1021
+ labels,
1022
+ fixPrUrls,
747
1023
  }));
748
1024
  const nextBinding = await maybePersistFindingSelectionBinding({
749
1025
  cwd,
@@ -771,11 +1047,78 @@ export async function commandFindingFeedback(client, cwd, flags, params) {
771
1047
  if (response.feedback.dismissalReason) {
772
1048
  logLine(`Dismissal reason: ${response.feedback.dismissalReason}`, flags);
773
1049
  }
1050
+ if (response.feedback.labels?.length) {
1051
+ logLine(`Labels: ${response.feedback.labels.join(", ")}`, flags);
1052
+ }
1053
+ if (response.feedback.fixPrUrls?.length) {
1054
+ logLine(`Fix PRs: ${response.feedback.fixPrUrls.join(", ")}`, flags);
1055
+ }
1056
+ else if (response.feedback.fixPrUrl) {
1057
+ logLine(`Fix PR: ${response.feedback.fixPrUrl}`, flags);
1058
+ }
774
1059
  if (response.feedback.comment) {
775
1060
  logLine("Comment: saved", flags);
776
1061
  }
777
1062
  return payload;
778
1063
  }
1064
+ export async function commandFindingFixReview(client, cwd, flags, requestedFindingRef) {
1065
+ const findingRef = normalizeFindingRefInput(requestedFindingRef);
1066
+ requireCantinaAuthToken();
1067
+ const currentBinding = await loadWorkspaceBinding(cwd);
1068
+ const needsWorkspaceResolution = !isFindingUuid(findingRef);
1069
+ const binding = needsWorkspaceResolution
1070
+ ? requireLoadedWorkspaceBinding(currentBinding)
1071
+ : currentBinding;
1072
+ if (needsWorkspaceResolution) {
1073
+ await ensureAuthenticated(client, flags);
1074
+ }
1075
+ const selection = await withLoadingIndicator("Resolving finding...", flags, () => resolveFindingSelection({
1076
+ client,
1077
+ binding,
1078
+ requestedFindingRef: findingRef,
1079
+ requestedScanId: getFlagString(flags, "scan"),
1080
+ }));
1081
+ const response = await withLoadingIndicator("Starting fix review scan...", flags, () => startFindingFixReviewScan(client, selection.findingId));
1082
+ const nextBinding = binding && selection.scan
1083
+ ? {
1084
+ ...binding,
1085
+ lastScanId: response.fixReview.scanId,
1086
+ lastScanUrl: null,
1087
+ lastSyncedAt: new Date().toISOString(),
1088
+ }
1089
+ : binding;
1090
+ if (binding && selection.scan && nextBinding) {
1091
+ await saveWorkspaceBinding(cwd, nextBinding);
1092
+ }
1093
+ const payload = {
1094
+ binding: nextBinding,
1095
+ scan: selection.scan,
1096
+ findingId: selection.findingId,
1097
+ findingRef: selection.findingRef,
1098
+ finding: selection.finding,
1099
+ fixReview: response.fixReview,
1100
+ reused: response.reused,
1101
+ };
1102
+ if (isJsonMode(flags)) {
1103
+ printJson(payload);
1104
+ return payload;
1105
+ }
1106
+ logLine(`Finding: ${selection.finding?.findingIdentifier ?? selection.findingId}`, flags);
1107
+ logLine(`Fix review scan: ${response.fixReview.status} (${response.fixReview.scanId})`, flags);
1108
+ if (response.fixReview.kernelScanId) {
1109
+ logLine(`Kernel scan: ${response.fixReview.kernelScanId}`, flags);
1110
+ }
1111
+ if (response.fixReview.fixPrUrls?.length) {
1112
+ logLine(`Fix PRs: ${response.fixReview.fixPrUrls.join(", ")}`, flags);
1113
+ }
1114
+ else {
1115
+ logLine(`Fix PR: ${response.fixReview.fixPrUrl}`, flags);
1116
+ }
1117
+ if (response.reused) {
1118
+ logLine("Reused existing active fix review scan.", flags);
1119
+ }
1120
+ return payload;
1121
+ }
779
1122
  export async function commandExportFindings(client, cwd, flags) {
780
1123
  await ensureAuthenticated(client, flags);
781
1124
  const binding = await requireWorkspaceBinding(cwd);
package/dist/findings.js CHANGED
@@ -2,7 +2,7 @@ import { fetchWorkspaceScans, getScanDisplayId, matchesScanId, } from "./scan.js
2
2
  import { ApiError } from "./api-client.js";
3
3
  const FINDING_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
4
4
  const CANTINA_AUTH_TOKEN_ENV = "CANTINA_AUTH_TOKEN";
5
- const CANTINA_AUTH_TOKEN_ERROR = "Finding comments and feedback currently require `CANTINA_AUTH_TOKEN` from a logged-in Cantina/Apex browser session. Export `CANTINA_AUTH_TOKEN` and retry.";
5
+ const CANTINA_AUTH_TOKEN_ERROR = "Finding comments, feedback, and fix review scans currently require `CANTINA_AUTH_TOKEN` from a logged-in Cantina/Apex browser session. Export `CANTINA_AUTH_TOKEN` and retry.";
6
6
  function parsePositiveInt(value) {
7
7
  if (!value)
8
8
  return null;
@@ -168,10 +168,19 @@ export async function createFindingComment(client, findingId, input) {
168
168
  });
169
169
  }
170
170
  export async function submitFindingFeedback(client, findingId, input) {
171
+ const fixPrUrls = input.fixPrUrls
172
+ ?.map((url) => url.trim())
173
+ .filter((url) => url.length > 0) ?? [];
171
174
  return requestFindingAppRoute(client, `/api/findings/${encodeURIComponent(findingId)}/feedback`, {
172
175
  method: "POST",
173
176
  json: {
174
177
  status: input.feedbackType,
178
+ ...(input.labels && input.labels.length > 0
179
+ ? { labels: input.labels }
180
+ : {}),
181
+ ...(fixPrUrls.length > 0
182
+ ? { fixPrUrl: fixPrUrls[0], fixPrUrls }
183
+ : {}),
175
184
  ...(input.comment ? { comment: input.comment } : {}),
176
185
  ...(input.suggestedSeverity
177
186
  ? { suggestedSeverity: input.suggestedSeverity }
@@ -180,3 +189,9 @@ export async function submitFindingFeedback(client, findingId, input) {
180
189
  },
181
190
  });
182
191
  }
192
+ export async function startFindingFixReviewScan(client, findingId) {
193
+ return requestFindingAppRoute(client, `/api/findings/${encodeURIComponent(findingId)}/fix-review`, {
194
+ method: "POST",
195
+ json: {},
196
+ });
197
+ }
package/dist/help.js CHANGED
@@ -8,6 +8,8 @@ export const CLI_HELP_TEXT = `Usage:
8
8
  Add a comment or note to a finding
9
9
  apex findings feedback <finding-id|finding-identifier> <valid|invalid>
10
10
  Leave feedback; invalid requires --dismissal-reason
11
+ apex findings fix-review <finding-id|finding-identifier>
12
+ Start a fix review scan after fixed feedback has a Fix PR URL
11
13
  apex export findings Export findings for the latest or selected scan
12
14
  apex workspaces List accessible workspaces for the active company
13
15
  apex workspace Show the workspace currently bound to this directory
@@ -36,11 +38,15 @@ Flags:
36
38
  Attach a suggested severity to valid feedback
37
39
  --dismissal-reason false-positive|by-design|not-relevant
38
40
  Explain why invalid feedback is being left
41
+ --label acknowledged|fixed Attach a valid-feedback label
42
+ --fix-pr-url <github-pr-url> Attach one or more Fix PR URLs to fixed feedback
39
43
  --format markdown|json|gitlab-sast
40
44
  Choose the export format for findings
41
45
  --output <path> Write exported findings to this file path
42
46
  --limit <count> Limit the number of findings returned
43
- --mode standard|audit Choose the scan mode
47
+ --mode standard|audit|pr Choose the scan mode
48
+ --pr <number[:path,path]> Select a GitHub pull request for --mode pr
49
+ --pr-path <path> Limit a single-PR scan to a changed file path
44
50
  --source-mode auto|remote|local Control remote-vs-local source materialization
45
51
  --force Start a new scan even if another scan is active
46
52
  --repo <path> Include one or more explicit local source roots
@@ -52,10 +58,12 @@ Flags:
52
58
  Tips:
53
59
  apex scan uses the current directory name as the default workspace name unless you pass --workspace-name.
54
60
  apex scan uses the current directory as the default source root unless you pass --repo.
55
- audit is the user-facing name for the legacy ultra scan mode; ultra remains accepted as an alias.
61
+ PR scans require --mode pr and at least one --pr selection for a GitHub repository.
62
+ audit is the current scan mode for audit scans; ultra remains accepted as a legacy alias.
56
63
  apex workspace use accepts a workspace name, prefix, or ID.
57
- Finding comments and feedback currently require CANTINA_AUTH_TOKEN from a logged-in Cantina/Apex browser session.
64
+ Finding comments, feedback, and fix review scans currently require CANTINA_AUTH_TOKEN from a logged-in Cantina/Apex browser session.
58
65
  Invalid finding feedback requires --dismissal-reason.
66
+ Fix review scans require valid feedback with --label fixed and at least one --fix-pr-url, then apex findings fix-review.
59
67
  Finding identifiers such as KERN2-25 resolve against the selected scan; pass --scan or use the finding UUID directly when needed.
60
68
  Quote workspace names that contain spaces:
61
69
  apex workspace use "Core Platform"
@@ -65,6 +73,7 @@ export const SHELL_HELP_TEXT = `Press Tab to autocomplete commands and common ar
65
73
  Commands:
66
74
  /credits Show scan credits and audit scan entitlements for the active company
67
75
  /scan [standard|audit] Start a new Apex scan for this workspace
76
+ /scan pr <pr-number> Start a PR scan for this workspace
68
77
  /scans List scans for this workspace
69
78
  /findings [scan-id] List findings for the latest or selected scan
70
79
  /findings comment <finding-ref> <comment>
@@ -73,6 +82,8 @@ Commands:
73
82
  Leave valid feedback on a finding
74
83
  /findings feedback <finding-ref> invalid <reason> [comment]
75
84
  Leave invalid feedback; reason is false-positive, by-design, or not-relevant
85
+ /findings fix-review <finding-ref>
86
+ Start a fix review scan after fixed feedback has a Fix PR URL
76
87
  /export [scan-id] Export findings for the latest or selected scan
77
88
  /workspaces List accessible workspaces for the active company
78
89
  /cancel-scan [scan-id] Cancel a running or most recent scan
@@ -93,10 +104,12 @@ Commands:
93
104
  /exit Exit Apex
94
105
 
95
106
  Tips:
96
- audit is the user-facing name for the legacy ultra scan mode; ultra remains accepted as an alias.
107
+ PR scans require a GitHub PR number and provider-backed repository access.
108
+ audit is the current scan mode for audit scans; ultra remains accepted as a legacy alias.
97
109
  /workspace use accepts a workspace name, prefix, or ID.
98
- /findings comment and /findings feedback require CANTINA_AUTH_TOKEN in the shell environment.
110
+ /findings comment, /findings feedback, and /findings fix-review require CANTINA_AUTH_TOKEN in the shell environment.
99
111
  Invalid finding feedback requires a dismissal reason.
112
+ Fix review scans require fixed valid feedback with a Fix PR URL. Use scripted CLI or MCP for attaching Fix PR URLs.
100
113
  Use scripted CLI flags for advanced feedback options such as suggested severity or dismissal reason.
101
114
  Quote workspace names that contain spaces: /workspace use "Core Platform"
102
115
  `;
package/dist/mcp.js CHANGED
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { getMe, logout, startDeviceLogin, waitForDeviceLoginApproval } from "./auth.js";
6
6
  import { ApexApiClient, formatApiError } from "./api-client.js";
7
- import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindings, commandScan, commandScans, commandStatus, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
7
+ import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindingFixReview, commandFindings, commandScan, commandScans, commandStatus, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
8
8
  import { CLI_HELP_TEXT } from "./help.js";
9
9
  import { APEX_CLI_VERSION } from "./version.js";
10
10
  const MCP_WORKFLOW_GUIDE = `Use Apex through these tools instead of shelling out to the CLI.
@@ -15,15 +15,18 @@ Suggested workflow:
15
15
  3. Call apex-doctor for the target repository cwd.
16
16
  4. If the directory is not bound to the right workspace, call apex-workspaces and apex-workspace-use.
17
17
  5. Start and monitor scans with apex-scan, apex-status, apex-scans, apex-findings, and apex-export-findings.
18
- 6. Leave finding comments or valid/invalid feedback with apex-finding-comment and apex-finding-feedback when review collaboration is needed.
18
+ 6. For PR scans, call apex-scan with mode "pr" and one or more pullRequests.
19
+ 7. Leave finding comments or valid/invalid feedback with apex-finding-comment and apex-finding-feedback when review collaboration is needed.
20
+ 8. To attach a fix PR and trigger a fix review scan, call apex-finding-feedback with status "valid", label "fixed", and fixPrUrls, then call apex-finding-fix-review.
19
21
 
20
22
  Notes:
21
23
  - Pass cwd explicitly for repository-specific operations.
22
24
  - Tool calls are always non-interactive and never auto-open a browser.
23
- - Use mode "audit" for audit scans; "ultra" remains accepted as a legacy alias.
25
+ - Use mode "audit" for audit scans; "ultra" remains accepted as a legacy alias. Use mode "pr" for GitHub pull request scans.
24
26
  - Doctor reports whether Apex will use remote materialization or a local snapshot upload for each source.`;
25
27
  const WORKSPACE_BINDING_ERROR = "This directory is not bound to an Apex workspace yet. Run `apex workspaces` to list workspace names, prefixes, and IDs, then bind one with `apex workspace use \"<workspace name>\"`, or run `apex scan` to create or resolve one automatically.";
26
28
  const MISSING_PROVIDER_CONNECTIONS_ERROR = "Missing provider connections. Re-run interactively or pre-connect providers.";
29
+ const PR_MISSING_PROVIDER_CONNECTIONS_ERROR_PREFIX = "PR scans require connected provider access for ";
27
30
  const MULTIPLE_ACTIVE_SCANS_ERROR = "Multiple active scans are running. Run `apex scans` to list scan IDs, then re-run `apex cancel-scan <scan-id>`.";
28
31
  const DUPLICATE_SCAN_FORCE_GUIDANCE = "Re-run with --force to start another scan.";
29
32
  const MULTIPLE_COMPANIES_ERROR = "Multiple companies available. Re-run with --company <id-or-handle>.";
@@ -50,9 +53,15 @@ function buildFlags(options) {
50
53
  if (options.output) {
51
54
  flags.output = options.output;
52
55
  }
56
+ if (options.pullRequests && options.pullRequests.length === 0) {
57
+ throw new Error("PR scans require at least one pull request.");
58
+ }
53
59
  if (options.mode) {
54
60
  flags.mode = options.mode;
55
61
  }
62
+ else if (options.pullRequests) {
63
+ flags.mode = "pr";
64
+ }
56
65
  if (options.sourceMode) {
57
66
  flags["source-mode"] = options.sourceMode;
58
67
  }
@@ -62,6 +71,16 @@ function buildFlags(options) {
62
71
  if (options.repoPaths && options.repoPaths.length > 0) {
63
72
  flags.repo = options.repoPaths;
64
73
  }
74
+ if (options.pullRequests && options.pullRequests.length > 0) {
75
+ flags.pr = options.pullRequests.map((pullRequest) => {
76
+ const paths = pullRequest.paths
77
+ ?.map((pathValue) => pathValue.trim())
78
+ .filter((pathValue) => pathValue.length > 0) ?? [];
79
+ return paths.length > 0
80
+ ? `${pullRequest.number}:${paths.join(",")}`
81
+ : String(pullRequest.number);
82
+ });
83
+ }
65
84
  if (typeof options.limit === "number") {
66
85
  flags.limit = String(options.limit);
67
86
  }
@@ -86,7 +105,8 @@ function formatMcpErrorMessage(toolName, error) {
86
105
  if (message === WORKSPACE_BINDING_ERROR) {
87
106
  return "No workspace is bound to this directory. Call `apex-workspace-use` to bind an existing workspace, or call `apex-scan` to create or resolve one first.";
88
107
  }
89
- if (message === MISSING_PROVIDER_CONNECTIONS_ERROR) {
108
+ if (message === MISSING_PROVIDER_CONNECTIONS_ERROR ||
109
+ message.startsWith(PR_MISSING_PROVIDER_CONNECTIONS_ERROR_PREFIX)) {
90
110
  return "Missing provider connections. Call `apex-connect-provider` for each missing provider, complete the browser flow, then retry `apex-scan`.";
91
111
  }
92
112
  if (message === MULTIPLE_COMPANIES_ERROR) {
@@ -322,17 +342,25 @@ function registerTools(server) {
322
342
  }, (value) => `Bound ${String(value.cwd)} to an Apex workspace.`));
323
343
  server.registerTool("apex-scan", {
324
344
  title: "Start Apex Scan",
325
- description: "Start a new Apex scan for the provided cwd by default. Pass repoPaths only to scan explicit alternate local roots. Use mode audit for audit scans.",
345
+ description: "Start a new Apex scan for the provided cwd by default. Pass repoPaths only to scan explicit alternate local roots. Use mode audit for audit scans and mode pr for GitHub pull request scans.",
326
346
  inputSchema: {
327
347
  cwd: z.string().optional(),
328
348
  company: z.string().optional(),
329
349
  workspaceName: z.string().optional(),
330
350
  repoPaths: z.array(z.string()).optional(),
331
- mode: z.enum(["standard", "audit", "ultra"]).optional(),
351
+ mode: z.enum(["standard", "audit", "ultra", "pr"]).optional(),
352
+ pullRequests: z
353
+ .array(z.object({
354
+ number: z.number().int().positive(),
355
+ paths: z.array(z.string().min(1)).optional(),
356
+ }))
357
+ .min(1)
358
+ .max(20)
359
+ .optional(),
332
360
  sourceMode: z.enum(["auto", "remote", "local"]).optional(),
333
361
  force: z.boolean().optional(),
334
362
  },
335
- }, async ({ cwd, company, workspaceName, repoPaths, mode, sourceMode, force }) => runTool("apex-scan", async () => {
363
+ }, async ({ cwd, company, workspaceName, repoPaths, mode, pullRequests, sourceMode, force, }) => runTool("apex-scan", async () => {
336
364
  const client = new ApexApiClient();
337
365
  await requireAuthenticated(client);
338
366
  const targetCwd = resolveCwd(cwd);
@@ -341,6 +369,7 @@ function registerTools(server) {
341
369
  workspaceName,
342
370
  repoPaths,
343
371
  mode,
372
+ pullRequests,
344
373
  sourceMode,
345
374
  force,
346
375
  }));
@@ -442,12 +471,14 @@ function registerTools(server) {
442
471
  }, (value) => `Added a finding comment for ${String(value.findingRef)}.`));
443
472
  server.registerTool("apex-finding-feedback", {
444
473
  title: "Leave Apex Finding Feedback",
445
- description: "Leave valid or invalid feedback on an Apex finding. Requires CANTINA_AUTH_TOKEN in the MCP server environment.",
474
+ description: "Leave valid or invalid feedback on an Apex finding. To attach a fix PR, send status valid with label fixed and fixPrUrls. Requires CANTINA_AUTH_TOKEN in the MCP server environment.",
446
475
  inputSchema: {
447
476
  cwd: z.string().optional(),
448
477
  findingRef: z.string(),
449
478
  status: z.enum(["valid", "invalid"]),
450
479
  comment: z.string().optional(),
480
+ labels: z.enum(["acknowledged", "fixed"]).array().max(1).optional(),
481
+ fixPrUrls: z.array(z.string().url()).max(20).optional(),
451
482
  suggestedSeverity: z
452
483
  .enum(["extreme", "critical", "high", "medium", "low", "informational"])
453
484
  .optional(),
@@ -456,7 +487,7 @@ function registerTools(server) {
456
487
  .optional(),
457
488
  scanId: z.string().optional(),
458
489
  },
459
- }, async ({ cwd, findingRef, status, comment, suggestedSeverity, dismissalReason, scanId, }) => runTool("apex-finding-feedback", async () => {
490
+ }, async ({ cwd, findingRef, status, comment, labels, fixPrUrls, suggestedSeverity, dismissalReason, scanId, }) => runTool("apex-finding-feedback", async () => {
460
491
  const client = new ApexApiClient();
461
492
  const targetCwd = resolveCwd(cwd);
462
493
  const payload = await commandFindingFeedback(client, targetCwd, buildFlags({
@@ -465,6 +496,8 @@ function registerTools(server) {
465
496
  requestedFindingRef: findingRef,
466
497
  feedbackType: status,
467
498
  comment,
499
+ labels,
500
+ fixPrUrls,
468
501
  suggestedSeverity,
469
502
  dismissalReason,
470
503
  });
@@ -474,6 +507,25 @@ function registerTools(server) {
474
507
  };
475
508
  }, (value) => `Submitted ${String((value.feedback?.feedbackType ??
476
509
  "finding"))} feedback for ${String(value.findingRef)}.`));
510
+ server.registerTool("apex-finding-fix-review", {
511
+ title: "Start Apex Finding Fix Review Scan",
512
+ description: "Start a fix review scan for a finding after fixed feedback with one or more Fix PR URLs has been saved. Requires CANTINA_AUTH_TOKEN in the MCP server environment.",
513
+ inputSchema: {
514
+ cwd: z.string().optional(),
515
+ findingRef: z.string(),
516
+ scanId: z.string().optional(),
517
+ },
518
+ }, async ({ cwd, findingRef, scanId }) => runTool("apex-finding-fix-review", async () => {
519
+ const client = new ApexApiClient();
520
+ const targetCwd = resolveCwd(cwd);
521
+ const payload = await commandFindingFixReview(client, targetCwd, buildFlags({
522
+ scanId,
523
+ }), findingRef);
524
+ return {
525
+ cwd: targetCwd,
526
+ ...payload,
527
+ };
528
+ }, (value) => `Started fix review scan for ${String(value.findingRef)}.`));
477
529
  server.registerTool("apex-export-findings", {
478
530
  title: "Export Apex Findings",
479
531
  description: "Export findings for the latest or selected Apex scan to a file on disk.",
package/dist/shell.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { SHELL_HELP_TEXT } from "./help.js";
2
- import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindings, commandLogout, commandScan, commandScans, commandStatus, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, initializeInteractiveSession, openCurrentApexView, } from "./commands.js";
2
+ import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindingFixReview, commandFindings, commandLogout, commandScan, commandScans, commandStatus, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, initializeInteractiveSession, openCurrentApexView, } from "./commands.js";
3
3
  import { withFlag } from "./args.js";
4
4
  import { printCompanyDetails, printInteractiveSessionSummary, printSourceList, printWorkspaceDetails, } from "./output.js";
5
5
  import { readLine } from "./prompt.js";
6
6
  import { formatApiError } from "./api-client.js";
7
7
  const SHELL_COMPLETIONS = [
8
8
  { command: "credits" },
9
- { command: "scan", args: ["standard", "audit", "ultra"] },
9
+ { command: "scan", args: ["standard", "audit", "ultra", "pr"] },
10
10
  { command: "scans" },
11
- { command: "findings", args: ["comment", "feedback"] },
11
+ { command: "findings", args: ["comment", "feedback", "fix-review"] },
12
12
  { command: "export" },
13
13
  { command: "workspaces" },
14
14
  { command: "cancel-scan" },
@@ -178,11 +178,25 @@ async function runShellCommand(client, cwd, parsed, shellFlags, session) {
178
178
  return {};
179
179
  case "scan": {
180
180
  const mode = parsed.args[0];
181
- if (mode && !["standard", "audit", "ultra"].includes(mode)) {
182
- process.stderr.write("Usage: /scan [standard|audit|ultra]\n");
181
+ if (mode && !["standard", "audit", "ultra", "pr"].includes(mode)) {
182
+ process.stderr.write("Usage: /scan [standard|audit|ultra|pr] [pr-number]\n");
183
183
  return {};
184
184
  }
185
- const result = await commandScan(client, cwd, mode ? withFlag(shellFlags, "mode", mode) : shellFlags);
185
+ const prNumber = mode === "pr" ? parsed.args[1] : null;
186
+ if (mode === "pr" && !prNumber) {
187
+ process.stderr.write("Usage: /scan pr <pr-number>\n");
188
+ return {};
189
+ }
190
+ if (mode === "pr" && parsed.args.length > 2) {
191
+ process.stderr.write("Usage: /scan pr <pr-number>\n");
192
+ return {};
193
+ }
194
+ const commandFlags = mode === "pr" && prNumber
195
+ ? withFlag(withFlag(shellFlags, "mode", mode), "pr", [prNumber])
196
+ : mode
197
+ ? withFlag(shellFlags, "mode", mode)
198
+ : shellFlags;
199
+ const result = await commandScan(client, cwd, commandFlags);
186
200
  return {
187
201
  session: {
188
202
  ...session,
@@ -237,6 +251,15 @@ async function runShellCommand(client, cwd, parsed, shellFlags, session) {
237
251
  });
238
252
  return {};
239
253
  }
254
+ if (parsed.args[0] === "fix-review") {
255
+ const findingRef = parsed.args[1];
256
+ if (!findingRef || parsed.args.length > 2) {
257
+ process.stderr.write("Usage: /findings fix-review <finding-id|finding-identifier>\n");
258
+ return {};
259
+ }
260
+ await commandFindingFixReview(client, cwd, shellFlags, findingRef);
261
+ return {};
262
+ }
240
263
  if (parsed.args.length > 1) {
241
264
  process.stderr.write("Usage: /findings [scan-id]\n");
242
265
  return {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cantinasecurity/apex-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Standalone CLI and MCP server for Apex.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -17,7 +17,9 @@ Workflow:
17
17
  4. Call `apex-doctor` before starting a scan if workspace binding or source planning might be unclear.
18
18
  5. If the directory is not bound to the right workspace, call `apex-workspaces` and `apex-workspace-use`.
19
19
  6. Use `apex-scan`, `apex-status`, `apex-scans`, `apex-findings`, and `apex-export-findings` for the scan lifecycle.
20
- 7. Use `apex-finding-comment` and `apex-finding-feedback` when the user wants to leave review notes or valid/invalid feedback on a finding.
20
+ 7. For PR scans, call `apex-scan` with `mode: "pr"` and `pullRequests`.
21
+ 8. Use `apex-finding-comment` and `apex-finding-feedback` when the user wants to leave review notes or valid/invalid feedback on a finding.
22
+ 9. When an agent creates a fix PR externally, call `apex-finding-feedback` with `status: "valid"`, `labels: ["fixed"]`, and `fixPrUrls`, then call `apex-finding-fix-review` to start the fix review scan.
21
23
 
22
24
  Guidelines:
23
25
 
@@ -26,11 +28,13 @@ Guidelines:
26
28
  - `apex-scan` scans the provided `cwd` by default; pass `repoPaths` only when the user asks to scan explicit alternate roots.
27
29
  - `apex-doctor` reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
28
30
  - Apex can scan plain local directories and dirty git worktrees without provider connections by using local snapshot uploads.
29
- - Audit scans use `mode: "audit"` in user-facing instructions. The legacy `ultra` mode remains accepted as an alias, but audit scans still require provider-backed GitHub or GitLab sources.
31
+ - Audit scans use `mode: "audit"` in user-facing instructions and request payloads. The legacy `ultra` mode remains accepted as an alias, but audit scans still require provider-backed GitHub or GitLab sources.
32
+ - PR scans use `mode: "pr"` and require one provider-backed GitHub repository plus one or more `pullRequests`.
30
33
  - `apex-workspace-use` accepts a workspace name, prefix, or ID.
31
34
  - Use `sourceMode: "remote"` only when the user explicitly wants to forbid local snapshot fallbacks.
32
35
  - Use `force: true` on `apex-scan` only when the user explicitly wants to replace or overlap an active scan.
33
36
  - Prefer `apex-findings` for quick inspection and `apex-export-findings` when the user needs a file artifact.
34
- - Finding comments and feedback currently require `CANTINA_AUTH_TOKEN` in the MCP server environment because those writes go through the Cantina web-app routes instead of the Apex CLI bearer-token routes.
37
+ - Finding comments, feedback, and fix review scan starts currently require `CANTINA_AUTH_TOKEN` in the MCP server environment because those writes go through the Cantina web-app routes instead of the Apex CLI bearer-token routes.
35
38
  - Invalid finding feedback requires `dismissalReason`; valid feedback can include `suggestedSeverity`, including `extreme`.
39
+ - Fix PR callback feedback requires valid feedback with `labels: ["fixed"]` and `fixPrUrls`; start the fix review scan with `apex-finding-fix-review` after saving that feedback.
36
40
  - Finding identifiers such as `KERN2-25` resolve against the selected or latest scan for the current workspace binding. Pass an explicit scan when needed, or use the finding UUID directly.