@cantinasecurity/apex-cli 0.1.3 → 0.1.5
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/.claude/skills/apex-cli/SKILL.md +8 -2
- package/README.md +23 -3
- package/dist/apex.js +16 -1
- package/dist/args.js +8 -1
- package/dist/commands.js +355 -12
- package/dist/findings.js +16 -1
- package/dist/help.js +16 -3
- package/dist/mcp.js +61 -9
- package/dist/session.js +20 -7
- package/dist/shell.js +29 -6
- package/package.json +1 -1
- package/skills/apex-cli/SKILL.md +6 -2
|
@@ -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.
|
|
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
|
|
|
@@ -32,15 +34,19 @@ If the Apex MCP server is not configured, fall back to the local CLI:
|
|
|
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
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
|
|
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
|
|
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,12 +202,16 @@ 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
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.
|
|
196
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.
|
|
214
|
+
|
|
197
215
|
## LLM / MCP Usage
|
|
198
216
|
|
|
199
217
|
The CLI now ships an MCP server so LLM clients can drive Apex directly over stdio.
|
|
@@ -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
|
|
21
|
+
if (REPEATABLE_VALUE_FLAGS.has(key)) {
|
|
21
22
|
const existing = flags[key] ?? [];
|
|
22
23
|
existing.push(next);
|
|
23
24
|
flags[key] = existing;
|
|
@@ -40,6 +41,12 @@ export function isJsonMode(flags) {
|
|
|
40
41
|
export function isNonInteractive(flags) {
|
|
41
42
|
return flags["non-interactive"] === true;
|
|
42
43
|
}
|
|
44
|
+
export function canPromptInteractively(flags) {
|
|
45
|
+
return (!isJsonMode(flags) &&
|
|
46
|
+
!isNonInteractive(flags) &&
|
|
47
|
+
process.stdin.isTTY === true &&
|
|
48
|
+
process.stdout.isTTY === true);
|
|
49
|
+
}
|
|
43
50
|
export function getFlagString(flags, key) {
|
|
44
51
|
const value = flags[key];
|
|
45
52
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
package/dist/commands.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { getFlagString, isJsonMode,
|
|
1
|
+
import { canPromptInteractively, getFlagList, getFlagString, isJsonMode, } 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
|
-
|
|
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) {
|
|
@@ -136,7 +341,7 @@ async function maybePersistFindingSelectionBinding(params) {
|
|
|
136
341
|
return nextBinding;
|
|
137
342
|
}
|
|
138
343
|
function canPromptForConfirmation(flags) {
|
|
139
|
-
return
|
|
344
|
+
return canPromptInteractively(flags);
|
|
140
345
|
}
|
|
141
346
|
async function findFallbackActiveScan(client, binding) {
|
|
142
347
|
if (!binding?.lastScanId) {
|
|
@@ -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
|
-
|
|
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 (!canPromptInteractively(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
|
|
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:
|
|
405
|
-
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: ${
|
|
420
|
-
logLine(`Plan: ${
|
|
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
|
|
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
|
|
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.
|
|
61
|
+
PR scans require --mode pr and at least one --pr selection for a GitHub repository.
|
|
55
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
|
|
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:
|
|
107
|
+
PR scans require a GitHub PR number and provider-backed repository access.
|
|
96
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
|
|
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.
|
|
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/session.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { getFlagList, getFlagString, isJsonMode,
|
|
2
|
+
import { canPromptInteractively, getFlagList, getFlagString, isJsonMode, } from "./args.js";
|
|
3
3
|
import { login } from "./auth.js";
|
|
4
4
|
import { ApiError } from "./api-client.js";
|
|
5
5
|
import { openInBrowser } from "./browser.js";
|
|
@@ -37,10 +37,13 @@ export async function chooseCompany(me, flags, binding) {
|
|
|
37
37
|
throw new Error(`Unknown company: ${requested}`);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
+
if (me.companies.length === 0) {
|
|
41
|
+
throw new Error("No Apex companies are available for this account.");
|
|
42
|
+
}
|
|
40
43
|
if (me.companies.length === 1) {
|
|
41
44
|
return me.companies[0];
|
|
42
45
|
}
|
|
43
|
-
if (
|
|
46
|
+
if (!canPromptInteractively(flags)) {
|
|
44
47
|
throw new Error("Multiple companies available. Re-run with --company <id-or-handle>.");
|
|
45
48
|
}
|
|
46
49
|
const selected = await chooseOne("Select the Apex company to use for this directory:", me.companies.map((company) => ({
|
|
@@ -60,12 +63,21 @@ async function chooseWorkspaceName(cwd, flags, binding) {
|
|
|
60
63
|
return binding.workspaceName;
|
|
61
64
|
}
|
|
62
65
|
const suggested = path.basename(cwd);
|
|
63
|
-
if (
|
|
66
|
+
if (!canPromptInteractively(flags)) {
|
|
64
67
|
return suggested;
|
|
65
68
|
}
|
|
66
69
|
const chosen = await promptText("Workspace name to use in Apex for this directory (press Enter to use the current folder name)", suggested);
|
|
67
70
|
return chosen.trim().length > 0 ? chosen.trim() : suggested;
|
|
68
71
|
}
|
|
72
|
+
function selectBindingForWorkspaceName(flags, binding, workspaceName) {
|
|
73
|
+
const explicit = getFlagString(flags, "workspace-name");
|
|
74
|
+
if (!explicit || !binding) {
|
|
75
|
+
return binding;
|
|
76
|
+
}
|
|
77
|
+
return binding.workspaceName.trim().toLowerCase() === workspaceName.trim().toLowerCase()
|
|
78
|
+
? binding
|
|
79
|
+
: null;
|
|
80
|
+
}
|
|
69
81
|
function getSourceMode(flags) {
|
|
70
82
|
const value = getFlagString(flags, "source-mode");
|
|
71
83
|
return value === "remote" || value === "local" ? value : "auto";
|
|
@@ -127,9 +139,10 @@ export async function resolveWorkspace(client, cwd, me, flags) {
|
|
|
127
139
|
return resolveWorkspaceSelection(client, selection);
|
|
128
140
|
}
|
|
129
141
|
export async function selectWorkspaceTarget(cwd, me, flags) {
|
|
130
|
-
const
|
|
131
|
-
const company = await chooseCompany(me, flags,
|
|
132
|
-
const workspaceName = await chooseWorkspaceName(cwd, flags,
|
|
142
|
+
const loadedBinding = await loadWorkspaceBinding(cwd);
|
|
143
|
+
const company = await chooseCompany(me, flags, loadedBinding);
|
|
144
|
+
const workspaceName = await chooseWorkspaceName(cwd, flags, loadedBinding);
|
|
145
|
+
const binding = selectBindingForWorkspaceName(flags, loadedBinding, workspaceName);
|
|
133
146
|
const sourceSelection = getFlagList(flags, "repo");
|
|
134
147
|
const discovered = await discoverSources(cwd, sourceSelection);
|
|
135
148
|
if (discovered.sources.length === 0) {
|
|
@@ -218,7 +231,7 @@ export async function remediateMissingConnections(missing, flags) {
|
|
|
218
231
|
if (missing.length === 0) {
|
|
219
232
|
return;
|
|
220
233
|
}
|
|
221
|
-
if (
|
|
234
|
+
if (!canPromptInteractively(flags)) {
|
|
222
235
|
throw new Error("Missing provider connections. Re-run interactively or pre-connect providers.");
|
|
223
236
|
}
|
|
224
237
|
for (const item of missing) {
|
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
|
|
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
package/skills/apex-cli/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
|
|
@@ -27,10 +29,12 @@ Guidelines:
|
|
|
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
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
|
|
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.
|