@cantinasecurity/apex-cli 0.1.1 → 0.1.2

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,6 +19,7 @@ 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
23
 
23
24
  If the Apex MCP server is not configured, fall back to the local CLI:
24
25
 
@@ -27,14 +28,19 @@ If the Apex MCP server is not configured, fall back to the local CLI:
27
28
  - Use `--json` whenever structured output is helpful.
28
29
  - `apex credits` reports standard credits and audit scan entitlements when Bedrock returns both balances.
29
30
  - Work from the target repository directory so Apex can resolve `.apex/workspace.json`.
31
+ - `apex scan` scans the current working directory by default; pass `--repo` only when the user asks to scan explicit alternate roots.
30
32
  - `apex-doctor` reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
31
33
  - Plain local directories and dirty git worktrees can scan through local snapshot uploads without provider access.
32
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.
33
35
  - `apex-workspace-use` accepts a workspace name, prefix, or ID.
34
36
  - 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.
38
+ - Invalid finding feedback requires `dismissalReason`; valid feedback can include `suggestedSeverity`, including `extreme`.
39
+ - 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.
35
40
 
36
41
  ## Examples
37
42
 
38
43
  - Start a scan for the current repository or directory: run `apex-doctor`, bind a workspace if needed, then call `apex-scan`.
39
44
  - Check an active scan: call `apex-status`, then `apex-findings` when the scan completes.
40
45
  - Export findings for review: call `apex-export-findings` with `format` set to `markdown`, `json`, or `gitlab-sast`.
46
+ - Leave review feedback: call `apex-finding-comment` to add a note, or `apex-finding-feedback` with `status` set to `valid` or `invalid`.
package/README.md CHANGED
@@ -101,6 +101,9 @@ Supported shell commands:
101
101
  - `/scan [standard|audit]`
102
102
  - `/scans`
103
103
  - `/findings [scan-id]`
104
+ - `/findings comment <finding-id|finding-identifier> <comment>`
105
+ - `/findings feedback <finding-id|finding-identifier> valid [comment]`
106
+ - `/findings feedback <finding-id|finding-identifier> invalid <false-positive|by-design|not-relevant> [comment]`
104
107
  - `/export [scan-id]`
105
108
  - `/workspaces`
106
109
  - `/cancel-scan [scan-id]`
@@ -128,6 +131,8 @@ Supported shell commands:
128
131
  - `apex scan`
129
132
  - `apex scans`
130
133
  - `apex findings [--scan <scan-id>]`
134
+ - `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]`
131
136
  - `apex export findings [--scan <scan-id>] [--format markdown|json|gitlab-sast] [--output <path>]`
132
137
  - `apex workspaces`
133
138
  - `apex workspace`
@@ -149,9 +154,31 @@ Helpful workspace flags:
149
154
 
150
155
  `apex credits` shows standard scan credits plus audit scan entitlements when the server returns them.
151
156
 
157
+ ## Finding Review Feedback
158
+
159
+ Finding review collaboration now has explicit write commands:
160
+
161
+ - `apex findings comment <finding-id|finding-identifier> --content "Needs auth check"`
162
+ - `apex findings feedback <finding-id|finding-identifier> valid --comment "Reproduced on latest build"`
163
+ - `apex findings feedback <finding-id|finding-identifier> invalid --dismissal-reason false-positive --comment "This path is unreachable"`
164
+ - `/findings comment <finding-ref> <comment>`
165
+ - `/findings feedback <finding-ref> valid [comment]`
166
+ - `/findings feedback <finding-ref> invalid <false-positive|by-design|not-relevant> [comment]`
167
+
168
+ 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
+
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.
171
+
172
+ Invalid feedback requires a dismissal reason. Valid feedback can include `--suggested-severity extreme|critical|high|medium|low|informational`.
173
+
174
+ 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
+
176
+ - read operations such as `apex findings`, `apex export findings`, and `apex-findings` use the Apex CLI device-login bearer token
177
+ - finding comments and feedback currently go through the Cantina web-app routes and require `CANTINA_AUTH_TOKEN`
178
+
152
179
  ## Local Source Scans
153
180
 
154
- `apex scan` now works against any local source root you point it at:
181
+ `apex scan` now works against any local source root you point it at. By default, that source root is the current working directory:
155
182
 
156
183
  - clean GitHub or GitLab checkouts can stay on the remote-materialization path
157
184
  - dirty git worktrees fall back to a local snapshot upload by default
@@ -159,7 +186,7 @@ Helpful workspace flags:
159
186
 
160
187
  Useful flags:
161
188
 
162
- - `--repo <path>` to scan one or more explicit local roots
189
+ - `--repo <path>` to scan one or more explicit local roots instead of the current directory
163
190
  - `--source-mode auto|remote|local` to control remote-first fallback behavior
164
191
  - `--mode standard|audit` to choose the scan mode
165
192
 
@@ -237,6 +264,7 @@ The MCP server exposes Apex-specific tools for:
237
264
  - doctor, credits, and provider connection URLs
238
265
  - workspace inspection and workspace binding
239
266
  - scan start, status, cancellation, findings, and findings export
267
+ - finding comments and valid/invalid feedback with `apex-finding-comment` and `apex-finding-feedback`
240
268
 
241
269
  For repository-scoped operations, pass `cwd` explicitly so the server can resolve the right `.apex/workspace.json` binding and repository roots.
242
270
 
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, commandFindings, commandLogin, commandLogout, commandScan, commandScans, commandSetup, commandStatus, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
3
+ import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, 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";
@@ -63,6 +63,32 @@ async function main() {
63
63
  await commandWorkspace(cwd, parsed.flags);
64
64
  return;
65
65
  case "findings":
66
+ if (parsed.subcommand === "comment") {
67
+ const findingRef = parsed.args[0] ?? "";
68
+ const content = parsed.args.slice(1).join(" ").trim() || String(parsed.flags.content ?? "");
69
+ await commandFindingComment(client, cwd, parsed.flags, findingRef, content, typeof parsed.flags["parent-comment"] === "string"
70
+ ? parsed.flags["parent-comment"]
71
+ : null);
72
+ return;
73
+ }
74
+ if (parsed.subcommand === "feedback") {
75
+ const findingRef = parsed.args[0] ?? "";
76
+ const feedbackType = parsed.args[1] ??
77
+ (typeof parsed.flags.status === "string"
78
+ ? parsed.flags.status
79
+ : undefined);
80
+ if (feedbackType !== "valid" && feedbackType !== "invalid") {
81
+ throw new Error("Usage: apex findings feedback <finding-id|finding-identifier> <valid|invalid> [--dismissal-reason false-positive|by-design|not-relevant]");
82
+ }
83
+ await commandFindingFeedback(client, cwd, parsed.flags, {
84
+ requestedFindingRef: findingRef,
85
+ feedbackType,
86
+ comment: parsed.args.slice(2).join(" ").trim() || getFlagValue(parsed.flags.comment),
87
+ suggestedSeverity: getFlagValue(parsed.flags["suggested-severity"]),
88
+ dismissalReason: getFlagValue(parsed.flags["dismissal-reason"]),
89
+ });
90
+ return;
91
+ }
66
92
  await commandFindings(client, cwd, parsed.flags);
67
93
  return;
68
94
  case "export":
@@ -84,6 +110,9 @@ async function main() {
84
110
  throw new Error(`Unknown command: ${parsed.command}`);
85
111
  }
86
112
  }
113
+ function getFlagValue(value) {
114
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
115
+ }
87
116
  main().catch((error) => {
88
117
  if (error instanceof Error && error.reported) {
89
118
  process.exitCode = 1;
package/dist/commands.js CHANGED
@@ -3,7 +3,7 @@ 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 { fetchScanExport, fetchScanFindings, resolveScanSelection } from "./findings.js";
6
+ import { createFindingComment, fetchScanExport, fetchScanFindings, isFindingUuid, normalizeFindingRef, requireCantinaAuthToken, resolveFindingSelection, resolveScanSelection, 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";
@@ -18,6 +18,19 @@ import path from "node:path";
18
18
  const WORKSPACE_USE_PATTERN = "<workspace-name|workspace-prefix|workspace-id>";
19
19
  const WORKSPACE_BINDING_GUIDANCE = "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.";
20
20
  const WORKSPACE_SELECTION_GUIDANCE = "Run `apex workspaces` to list available workspace names, prefixes, and IDs, then retry `apex workspace use \"<workspace name>\"`.";
21
+ const FINDING_FEEDBACK_SEVERITIES = [
22
+ "extreme",
23
+ "critical",
24
+ "high",
25
+ "medium",
26
+ "low",
27
+ "informational",
28
+ ];
29
+ const FINDING_DISMISSAL_REASONS = [
30
+ "false-positive",
31
+ "by-design",
32
+ "not-relevant",
33
+ ];
21
34
  async function saveCompanyDefault(companyId) {
22
35
  const config = await loadConfig();
23
36
  if (config.defaultCompanyId !== companyId) {
@@ -67,6 +80,33 @@ function formatAuditScanBalance(scanBalance) {
67
80
  function normalizeRequestedScanMode(value) {
68
81
  return value === "ultra" || value === "audit" ? "ultra" : "standard";
69
82
  }
83
+ function normalizeFindingRefInput(value) {
84
+ const trimmed = normalizeFindingRef(value);
85
+ if (!trimmed) {
86
+ throw new Error("A finding ID or finding identifier is required.");
87
+ }
88
+ return trimmed;
89
+ }
90
+ function normalizeFeedbackSeverity(value) {
91
+ const normalized = value?.trim() ?? "";
92
+ if (!normalized) {
93
+ return null;
94
+ }
95
+ if (FINDING_FEEDBACK_SEVERITIES.includes(normalized)) {
96
+ return normalized;
97
+ }
98
+ throw new Error(`Suggested severity must be one of: ${FINDING_FEEDBACK_SEVERITIES.join(", ")}.`);
99
+ }
100
+ function normalizeDismissalReason(value) {
101
+ const normalized = value?.trim() ?? "";
102
+ if (!normalized) {
103
+ return null;
104
+ }
105
+ if (FINDING_DISMISSAL_REASONS.includes(normalized)) {
106
+ return normalized;
107
+ }
108
+ throw new Error(`Dismissal reason must be one of: ${FINDING_DISMISSAL_REASONS.join(", ")}.`);
109
+ }
70
110
  async function requireWorkspaceBinding(cwd) {
71
111
  const binding = await loadWorkspaceBinding(cwd);
72
112
  if (binding) {
@@ -74,6 +114,27 @@ async function requireWorkspaceBinding(cwd) {
74
114
  }
75
115
  throw new Error(`This directory is not bound to an Apex workspace yet. ${WORKSPACE_BINDING_GUIDANCE}`);
76
116
  }
117
+ function requireLoadedWorkspaceBinding(binding) {
118
+ if (binding) {
119
+ return binding;
120
+ }
121
+ throw new Error(`This directory is not bound to an Apex workspace yet. ${WORKSPACE_BINDING_GUIDANCE}`);
122
+ }
123
+ async function maybePersistFindingSelectionBinding(params) {
124
+ if (!params.binding || !params.scan) {
125
+ return params.binding;
126
+ }
127
+ const nextBinding = {
128
+ ...params.binding,
129
+ lastScanId: getScanDisplayId(params.scan),
130
+ lastScanUrl: params.response?.scan.scanUrl ??
131
+ params.scan.scanUrl ??
132
+ params.binding.lastScanUrl ??
133
+ null,
134
+ };
135
+ await saveWorkspaceBinding(params.cwd, nextBinding);
136
+ return nextBinding;
137
+ }
77
138
  function canPromptForConfirmation(flags) {
78
139
  return !isNonInteractive(flags) && process.stdin.isTTY && process.stdout.isTTY;
79
140
  }
@@ -599,6 +660,122 @@ export async function commandFindings(client, cwd, flags) {
599
660
  }
600
661
  return payload;
601
662
  }
663
+ export async function commandFindingComment(client, cwd, flags, requestedFindingRef, content, parentCommentId) {
664
+ const findingRef = normalizeFindingRefInput(requestedFindingRef);
665
+ const trimmedContent = content.trim();
666
+ if (!trimmedContent) {
667
+ throw new Error("Usage: apex findings comment <finding-id|finding-identifier> --content \"<markdown>\"");
668
+ }
669
+ requireCantinaAuthToken();
670
+ const currentBinding = await loadWorkspaceBinding(cwd);
671
+ const needsWorkspaceResolution = !isFindingUuid(findingRef);
672
+ const binding = needsWorkspaceResolution
673
+ ? requireLoadedWorkspaceBinding(currentBinding)
674
+ : currentBinding;
675
+ if (needsWorkspaceResolution) {
676
+ await ensureAuthenticated(client, flags);
677
+ }
678
+ const selection = await withLoadingIndicator("Resolving finding...", flags, () => resolveFindingSelection({
679
+ client,
680
+ binding,
681
+ requestedFindingRef: findingRef,
682
+ requestedScanId: getFlagString(flags, "scan"),
683
+ }));
684
+ const response = await withLoadingIndicator("Adding finding comment...", flags, () => createFindingComment(client, selection.findingId, {
685
+ content: trimmedContent,
686
+ parentCommentId: parentCommentId?.trim() || null,
687
+ }));
688
+ const nextBinding = await maybePersistFindingSelectionBinding({
689
+ cwd,
690
+ binding,
691
+ scan: selection.scan,
692
+ response: selection.response,
693
+ });
694
+ const payload = {
695
+ binding: nextBinding,
696
+ scan: selection.scan,
697
+ findingId: selection.findingId,
698
+ findingRef: selection.findingRef,
699
+ finding: selection.finding,
700
+ comment: response.comment,
701
+ };
702
+ if (isJsonMode(flags)) {
703
+ printJson(payload);
704
+ return payload;
705
+ }
706
+ logLine(`Finding: ${selection.finding?.findingIdentifier ?? selection.findingId}`, flags);
707
+ logLine(`Comment: added (${response.comment.id})`, flags);
708
+ logLine(`Author: ${response.comment.user.name}`, flags);
709
+ logLine(`Created: ${response.comment.createdAt}`, flags);
710
+ return payload;
711
+ }
712
+ export async function commandFindingFeedback(client, cwd, flags, params) {
713
+ const findingRef = normalizeFindingRefInput(params.requestedFindingRef);
714
+ const suggestedSeverity = normalizeFeedbackSeverity(params.suggestedSeverity);
715
+ const dismissalReason = normalizeDismissalReason(params.dismissalReason);
716
+ if (params.feedbackType === "valid" &&
717
+ dismissalReason) {
718
+ throw new Error("Dismissal reasons are only valid for invalid feedback.");
719
+ }
720
+ if (params.feedbackType === "invalid" &&
721
+ suggestedSeverity) {
722
+ throw new Error("Suggested severity is only valid for valid feedback.");
723
+ }
724
+ if (params.feedbackType === "invalid" && !dismissalReason) {
725
+ throw new Error("Invalid feedback requires a dismissal reason.");
726
+ }
727
+ requireCantinaAuthToken();
728
+ const currentBinding = await loadWorkspaceBinding(cwd);
729
+ const needsWorkspaceResolution = !isFindingUuid(findingRef);
730
+ const binding = needsWorkspaceResolution
731
+ ? requireLoadedWorkspaceBinding(currentBinding)
732
+ : currentBinding;
733
+ if (needsWorkspaceResolution) {
734
+ await ensureAuthenticated(client, flags);
735
+ }
736
+ const selection = await withLoadingIndicator("Resolving finding...", flags, () => resolveFindingSelection({
737
+ client,
738
+ binding,
739
+ requestedFindingRef: findingRef,
740
+ requestedScanId: getFlagString(flags, "scan"),
741
+ }));
742
+ const response = await withLoadingIndicator("Submitting finding feedback...", flags, () => submitFindingFeedback(client, selection.findingId, {
743
+ feedbackType: params.feedbackType,
744
+ comment: params.comment?.trim() || null,
745
+ suggestedSeverity,
746
+ dismissalReason,
747
+ }));
748
+ const nextBinding = await maybePersistFindingSelectionBinding({
749
+ cwd,
750
+ binding,
751
+ scan: selection.scan,
752
+ response: selection.response,
753
+ });
754
+ const payload = {
755
+ binding: nextBinding,
756
+ scan: selection.scan,
757
+ findingId: selection.findingId,
758
+ findingRef: selection.findingRef,
759
+ finding: selection.finding,
760
+ feedback: response.feedback,
761
+ };
762
+ if (isJsonMode(flags)) {
763
+ printJson(payload);
764
+ return payload;
765
+ }
766
+ logLine(`Finding: ${selection.finding?.findingIdentifier ?? selection.findingId}`, flags);
767
+ logLine(`Feedback: ${response.feedback.feedbackType ?? params.feedbackType}`, flags);
768
+ if (response.feedback.suggestedSeverity) {
769
+ logLine(`Suggested severity: ${response.feedback.suggestedSeverity}`, flags);
770
+ }
771
+ if (response.feedback.dismissalReason) {
772
+ logLine(`Dismissal reason: ${response.feedback.dismissalReason}`, flags);
773
+ }
774
+ if (response.feedback.comment) {
775
+ logLine("Comment: saved", flags);
776
+ }
777
+ return payload;
778
+ }
602
779
  export async function commandExportFindings(client, cwd, flags) {
603
780
  await ensureAuthenticated(client, flags);
604
781
  const binding = await requireWorkspaceBinding(cwd);
package/dist/findings.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { fetchWorkspaceScans, getScanDisplayId, matchesScanId, } from "./scan.js";
2
+ import { ApiError } from "./api-client.js";
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
+ 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.";
2
6
  function parsePositiveInt(value) {
3
7
  if (!value)
4
8
  return null;
@@ -8,6 +12,77 @@ function parsePositiveInt(value) {
8
12
  }
9
13
  return Math.floor(parsed);
10
14
  }
15
+ async function parseWebRouteResponse(response) {
16
+ const contentType = response.headers.get("content-type") ?? "";
17
+ if (contentType.includes("application/json")) {
18
+ return response.json().catch(() => null);
19
+ }
20
+ return response.text().catch(() => "");
21
+ }
22
+ function normalizeCantinaAuthToken(value) {
23
+ if (!value) {
24
+ return null;
25
+ }
26
+ let token = value.trim();
27
+ if (!token) {
28
+ return null;
29
+ }
30
+ if (token.toLowerCase().startsWith("auth_token=")) {
31
+ token = token.slice("auth_token=".length).trim();
32
+ }
33
+ return token.length > 0 ? token : null;
34
+ }
35
+ export function requireCantinaAuthToken() {
36
+ const token = normalizeCantinaAuthToken(process.env[CANTINA_AUTH_TOKEN_ENV]);
37
+ if (!token) {
38
+ throw new Error(CANTINA_AUTH_TOKEN_ERROR);
39
+ }
40
+ return token;
41
+ }
42
+ export function isFindingUuid(value) {
43
+ return FINDING_UUID_PATTERN.test(value.trim());
44
+ }
45
+ export function normalizeFindingRef(value) {
46
+ const trimmed = value.trim();
47
+ if (!trimmed) {
48
+ return trimmed;
49
+ }
50
+ try {
51
+ const url = new URL(trimmed);
52
+ const segments = url.pathname.split("/").filter(Boolean);
53
+ return segments[segments.length - 1] ?? trimmed;
54
+ }
55
+ catch {
56
+ return trimmed;
57
+ }
58
+ }
59
+ function matchesFindingRef(finding, findingRef) {
60
+ const normalizedRef = findingRef.trim().toLowerCase();
61
+ return [finding.id, finding.findingIdentifier]
62
+ .filter((value) => typeof value === "string")
63
+ .some((value) => value.trim().toLowerCase() === normalizedRef);
64
+ }
65
+ async function requestFindingAppRoute(client, path, options = {}) {
66
+ const token = requireCantinaAuthToken();
67
+ const baseUrl = await client.getBaseUrl();
68
+ const headers = new Headers(options.headers ?? {});
69
+ // These routes still authenticate through the web app cookie, not Apex CLI bearer auth.
70
+ headers.set("Cookie", `auth_token=${token}`);
71
+ headers.set("Accept", "application/json");
72
+ if (options.json !== undefined) {
73
+ headers.set("Content-Type", "application/json");
74
+ }
75
+ const response = await fetch(new URL(path, baseUrl), {
76
+ ...options,
77
+ headers,
78
+ body: options.json !== undefined ? JSON.stringify(options.json) : options.body,
79
+ });
80
+ const body = await parseWebRouteResponse(response);
81
+ if (!response.ok) {
82
+ throw new ApiError(`Request failed with ${response.status}`, response.status, body);
83
+ }
84
+ return body;
85
+ }
11
86
  export async function fetchScanFindings(client, scanId, limit) {
12
87
  const params = new URLSearchParams();
13
88
  const parsedLimit = parsePositiveInt(limit);
@@ -48,3 +123,60 @@ export async function resolveScanSelection(params) {
48
123
  export function describeScanSelection(scan) {
49
124
  return scan.scanUrl ?? getScanDisplayId(scan);
50
125
  }
126
+ export async function resolveFindingSelection(params) {
127
+ const findingRef = normalizeFindingRef(params.requestedFindingRef);
128
+ if (!findingRef) {
129
+ throw new Error("A finding ID or finding identifier is required.");
130
+ }
131
+ if (isFindingUuid(findingRef)) {
132
+ return {
133
+ findingId: findingRef,
134
+ findingRef,
135
+ finding: null,
136
+ scan: null,
137
+ response: null,
138
+ };
139
+ }
140
+ if (!params.binding) {
141
+ throw new Error("A workspace binding is required to resolve finding identifiers. Bind this directory first or use the finding UUID directly.");
142
+ }
143
+ const scan = await resolveScanSelection({
144
+ client: params.client,
145
+ binding: params.binding,
146
+ requestedScanId: params.requestedScanId,
147
+ });
148
+ const response = await fetchScanFindings(params.client, scan.scanId, "1000");
149
+ const finding = response.findings.find((item) => matchesFindingRef(item, findingRef)) ?? null;
150
+ if (!finding) {
151
+ throw new Error(`Could not find finding "${findingRef}" in ${describeScanSelection(scan)}. Re-run with --scan to target a different scan, or use the finding UUID directly.`);
152
+ }
153
+ return {
154
+ findingId: finding.id,
155
+ findingRef,
156
+ finding,
157
+ scan,
158
+ response,
159
+ };
160
+ }
161
+ export async function createFindingComment(client, findingId, input) {
162
+ return requestFindingAppRoute(client, `/api/findings/${encodeURIComponent(findingId)}/comments`, {
163
+ method: "POST",
164
+ json: {
165
+ content: input.content,
166
+ ...(input.parentCommentId ? { parentId: input.parentCommentId } : {}),
167
+ },
168
+ });
169
+ }
170
+ export async function submitFindingFeedback(client, findingId, input) {
171
+ return requestFindingAppRoute(client, `/api/findings/${encodeURIComponent(findingId)}/feedback`, {
172
+ method: "POST",
173
+ json: {
174
+ status: input.feedbackType,
175
+ ...(input.comment ? { comment: input.comment } : {}),
176
+ ...(input.suggestedSeverity
177
+ ? { suggestedSeverity: input.suggestedSeverity }
178
+ : {}),
179
+ ...(input.dismissalReason ? { dismissalReason: input.dismissalReason } : {}),
180
+ },
181
+ });
182
+ }
package/dist/help.js CHANGED
@@ -4,6 +4,10 @@ export const CLI_HELP_TEXT = `Usage:
4
4
  apex scan Create or resolve a workspace for this directory and start a scan
5
5
  apex scans List scans for the current workspace binding
6
6
  apex findings List findings for the latest or selected scan
7
+ apex findings comment <finding-id|finding-identifier>
8
+ Add a comment or note to a finding
9
+ apex findings feedback <finding-id|finding-identifier> <valid|invalid>
10
+ Leave feedback; invalid requires --dismissal-reason
7
11
  apex export findings Export findings for the latest or selected scan
8
12
  apex workspaces List accessible workspaces for the active company
9
13
  apex workspace Show the workspace currently bound to this directory
@@ -24,6 +28,14 @@ Flags:
24
28
  --company <id-or-handle> Choose the Apex company to use
25
29
  --workspace-name <name> Set the Apex workspace name for this directory
26
30
  --scan <scan-id> Select a specific scan for findings or export
31
+ --status valid|invalid Set the finding feedback status explicitly
32
+ --content <markdown> Supply comment content for apex findings comment
33
+ --comment <markdown> Supply rationale for apex findings feedback
34
+ --parent-comment <comment-id> Reply to an existing finding comment
35
+ --suggested-severity extreme|critical|high|medium|low|informational
36
+ Attach a suggested severity to valid feedback
37
+ --dismissal-reason false-positive|by-design|not-relevant
38
+ Explain why invalid feedback is being left
27
39
  --format markdown|json|gitlab-sast
28
40
  Choose the export format for findings
29
41
  --output <path> Write exported findings to this file path
@@ -39,8 +51,12 @@ Flags:
39
51
 
40
52
  Tips:
41
53
  apex scan uses the current directory name as the default workspace name unless you pass --workspace-name.
54
+ apex scan uses the current directory as the default source root unless you pass --repo.
42
55
  audit is the user-facing name for the legacy ultra scan mode; ultra remains accepted as an alias.
43
56
  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.
58
+ Invalid finding feedback requires --dismissal-reason.
59
+ Finding identifiers such as KERN2-25 resolve against the selected scan; pass --scan or use the finding UUID directly when needed.
44
60
  Quote workspace names that contain spaces:
45
61
  apex workspace use "Core Platform"
46
62
  `;
@@ -51,6 +67,12 @@ Commands:
51
67
  /scan [standard|audit] Start a new Apex scan for this workspace
52
68
  /scans List scans for this workspace
53
69
  /findings [scan-id] List findings for the latest or selected scan
70
+ /findings comment <finding-ref> <comment>
71
+ Add a comment or note to a finding
72
+ /findings feedback <finding-ref> valid [comment]
73
+ Leave valid feedback on a finding
74
+ /findings feedback <finding-ref> invalid <reason> [comment]
75
+ Leave invalid feedback; reason is false-positive, by-design, or not-relevant
54
76
  /export [scan-id] Export findings for the latest or selected scan
55
77
  /workspaces List accessible workspaces for the active company
56
78
  /cancel-scan [scan-id] Cancel a running or most recent scan
@@ -73,5 +95,8 @@ Commands:
73
95
  Tips:
74
96
  audit is the user-facing name for the legacy ultra scan mode; ultra remains accepted as an alias.
75
97
  /workspace use accepts a workspace name, prefix, or ID.
98
+ /findings comment and /findings feedback require CANTINA_AUTH_TOKEN in the shell environment.
99
+ Invalid finding feedback requires a dismissal reason.
100
+ Use scripted CLI flags for advanced feedback options such as suggested severity or dismissal reason.
76
101
  Quote workspace names that contain spaces: /workspace use "Core Platform"
77
102
  `;
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, commandFindings, commandScan, commandScans, commandStatus, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
7
+ import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, 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,6 +15,7 @@ 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
19
 
19
20
  Notes:
20
21
  - Pass cwd explicitly for repository-specific operations.
@@ -321,7 +322,7 @@ function registerTools(server) {
321
322
  }, (value) => `Bound ${String(value.cwd)} to an Apex workspace.`));
322
323
  server.registerTool("apex-scan", {
323
324
  title: "Start Apex Scan",
324
- description: "Start a new Apex scan for any local repository or directory. Pass cwd explicitly for reliable workspace resolution. Use mode audit for audit scans.",
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.",
325
326
  inputSchema: {
326
327
  cwd: z.string().optional(),
327
328
  company: z.string().optional(),
@@ -418,6 +419,61 @@ function registerTools(server) {
418
419
  ...payload,
419
420
  };
420
421
  }, (value) => `Fetched Apex findings for ${String(value.cwd)}.`));
422
+ server.registerTool("apex-finding-comment", {
423
+ title: "Add Apex Finding Comment",
424
+ description: "Add a comment or note to an Apex finding. Requires CANTINA_AUTH_TOKEN in the MCP server environment.",
425
+ inputSchema: {
426
+ cwd: z.string().optional(),
427
+ findingRef: z.string(),
428
+ content: z.string().min(1),
429
+ parentCommentId: z.string().optional(),
430
+ scanId: z.string().optional(),
431
+ },
432
+ }, async ({ cwd, findingRef, content, parentCommentId, scanId }) => runTool("apex-finding-comment", async () => {
433
+ const client = new ApexApiClient();
434
+ const targetCwd = resolveCwd(cwd);
435
+ const payload = await commandFindingComment(client, targetCwd, buildFlags({
436
+ scanId,
437
+ }), findingRef, content, parentCommentId);
438
+ return {
439
+ cwd: targetCwd,
440
+ ...payload,
441
+ };
442
+ }, (value) => `Added a finding comment for ${String(value.findingRef)}.`));
443
+ server.registerTool("apex-finding-feedback", {
444
+ 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.",
446
+ inputSchema: {
447
+ cwd: z.string().optional(),
448
+ findingRef: z.string(),
449
+ status: z.enum(["valid", "invalid"]),
450
+ comment: z.string().optional(),
451
+ suggestedSeverity: z
452
+ .enum(["extreme", "critical", "high", "medium", "low", "informational"])
453
+ .optional(),
454
+ dismissalReason: z
455
+ .enum(["false-positive", "by-design", "not-relevant"])
456
+ .optional(),
457
+ scanId: z.string().optional(),
458
+ },
459
+ }, async ({ cwd, findingRef, status, comment, suggestedSeverity, dismissalReason, scanId, }) => runTool("apex-finding-feedback", async () => {
460
+ const client = new ApexApiClient();
461
+ const targetCwd = resolveCwd(cwd);
462
+ const payload = await commandFindingFeedback(client, targetCwd, buildFlags({
463
+ scanId,
464
+ }), {
465
+ requestedFindingRef: findingRef,
466
+ feedbackType: status,
467
+ comment,
468
+ suggestedSeverity,
469
+ dismissalReason,
470
+ });
471
+ return {
472
+ cwd: targetCwd,
473
+ ...payload,
474
+ };
475
+ }, (value) => `Submitted ${String((value.feedback?.feedbackType ??
476
+ "finding"))} feedback for ${String(value.findingRef)}.`));
421
477
  server.registerTool("apex-export-findings", {
422
478
  title: "Export Apex Findings",
423
479
  description: "Export findings for the latest or selected Apex scan to a file on disk.",
@@ -1,37 +1,15 @@
1
1
  import { execFile } from "node:child_process";
2
- import { lstat, readdir, realpath } from "node:fs/promises";
2
+ import { lstat, realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { extractCanonicalRepoMetadata } from "./repo-url.js";
6
6
  const execFileAsync = promisify(execFile);
7
- const IGNORED_DIRECTORIES = new Set([
8
- ".git",
9
- ".next",
10
- "node_modules",
11
- "dist",
12
- "build",
13
- "coverage",
14
- ".turbo",
15
- ".cache",
16
- ".pnpm-store",
17
- "tmp",
18
- "vendor",
19
- ]);
20
7
  async function runGit(cwd, args) {
21
8
  const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
22
9
  encoding: "utf8",
23
10
  });
24
11
  return stdout.trim();
25
12
  }
26
- async function isGitRepository(directory) {
27
- try {
28
- await runGit(directory, ["rev-parse", "--is-inside-work-tree"]);
29
- return true;
30
- }
31
- catch {
32
- return false;
33
- }
34
- }
35
13
  async function getGitTopLevel(directory) {
36
14
  try {
37
15
  const topLevel = await runGit(directory, ["rev-parse", "--show-toplevel"]);
@@ -103,25 +81,6 @@ function normalizeSelectedRoots(cwd, selectedPaths) {
103
81
  }
104
82
  return disjoint;
105
83
  }
106
- export async function findGitRepositoryRoots(cwd) {
107
- const discovered = new Set();
108
- async function walk(directory) {
109
- const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
110
- const hasGitMarker = entries.some((entry) => entry.name === ".git");
111
- if (hasGitMarker && (await isGitRepository(directory))) {
112
- discovered.add(directory);
113
- }
114
- for (const entry of entries) {
115
- if (!entry.isDirectory())
116
- continue;
117
- if (IGNORED_DIRECTORIES.has(entry.name))
118
- continue;
119
- await walk(path.join(directory, entry.name));
120
- }
121
- }
122
- await walk(cwd);
123
- return Array.from(discovered).sort((left, right) => left.length - right.length);
124
- }
125
84
  async function inspectGitRepository(rootCwd, directory) {
126
85
  const remoteUrl = await chooseRemote(directory);
127
86
  const metadata = remoteUrl ? extractCanonicalRepoMetadata(remoteUrl) : null;
@@ -163,7 +122,7 @@ async function inspectSource(rootCwd, requestedDirectory) {
163
122
  export async function discoverSources(cwd, selectedPaths = []) {
164
123
  const discoveredRoots = selectedPaths.length > 0
165
124
  ? normalizeSelectedRoots(cwd, selectedPaths)
166
- : await findGitRepositoryRoots(cwd).then((gitRoots) => gitRoots.length > 0 ? gitRoots : [cwd]);
125
+ : [cwd];
167
126
  const sources = await Promise.all(discoveredRoots.map((root) => inspectSource(cwd, root)));
168
127
  sources.sort((left, right) => left.path.localeCompare(right.path));
169
128
  return { sources, scanned: discoveredRoots };
package/dist/session.js CHANGED
@@ -70,6 +70,52 @@ function getSourceMode(flags) {
70
70
  const value = getFlagString(flags, "source-mode");
71
71
  return value === "remote" || value === "local" ? value : "auto";
72
72
  }
73
+ function buildFallbackLocalArchivePlan(source, sourceMode) {
74
+ const reason = sourceMode === "local"
75
+ ? "user_requested_local_snapshot"
76
+ : source.kind === "directory_candidate"
77
+ ? "non_git_directory"
78
+ : source.dirty
79
+ ? "dirty_worktree"
80
+ : source.repoUrl
81
+ ? "remote_access_unavailable"
82
+ : "missing_remote";
83
+ return {
84
+ path: source.path,
85
+ displayName: source.displayName,
86
+ relativePath: source.path,
87
+ sourceKind: "local_archive",
88
+ archiveRequired: true,
89
+ reason,
90
+ git: source.kind === "git_candidate"
91
+ ? {
92
+ repoUrl: source.repoUrl,
93
+ provider: source.provider,
94
+ branch: source.branch,
95
+ commitSha: source.commitSha,
96
+ dirty: source.dirty,
97
+ }
98
+ : {
99
+ repoUrl: null,
100
+ provider: null,
101
+ branch: null,
102
+ commitSha: null,
103
+ dirty: false,
104
+ },
105
+ };
106
+ }
107
+ function ensurePlannedSources(selection, resolve) {
108
+ if (resolve.plannedSources.length > 0) {
109
+ return resolve;
110
+ }
111
+ if (selection.sourceMode === "remote") {
112
+ throw new Error("Workspace resolution did not return any remote scan sources.");
113
+ }
114
+ return {
115
+ ...resolve,
116
+ plannedSources: selection.sources.map((source) => buildFallbackLocalArchivePlan(source, selection.sourceMode)),
117
+ };
118
+ }
73
119
  export async function ensureAuthenticated(client, flags) {
74
120
  return login(client, {
75
121
  noOpen: flags["no-open"] === true,
@@ -99,7 +145,7 @@ export async function selectWorkspaceTarget(cwd, me, flags) {
99
145
  };
100
146
  }
101
147
  export async function resolveWorkspaceSelection(client, selection) {
102
- const resolve = await client.request("/api/cli/v2/local-workspaces/resolve", {
148
+ const rawResolve = await client.request("/api/cli/v2/local-workspaces/resolve", {
103
149
  method: "POST",
104
150
  json: {
105
151
  companyId: selection.company.id,
@@ -124,6 +170,7 @@ export async function resolveWorkspaceSelection(client, selection) {
124
170
  }),
125
171
  },
126
172
  });
173
+ const resolve = ensurePlannedSources(selection, rawResolve);
127
174
  return {
128
175
  company: selection.company,
129
176
  workspaceName: selection.workspaceName,
package/dist/shell.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SHELL_HELP_TEXT } from "./help.js";
2
- import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindings, commandLogout, commandScan, commandScans, commandStatus, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, initializeInteractiveSession, openCurrentApexView, } from "./commands.js";
2
+ import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, 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";
@@ -8,7 +8,7 @@ const SHELL_COMPLETIONS = [
8
8
  { command: "credits" },
9
9
  { command: "scan", args: ["standard", "audit", "ultra"] },
10
10
  { command: "scans" },
11
- { command: "findings" },
11
+ { command: "findings", args: ["comment", "feedback"] },
12
12
  { command: "export" },
13
13
  { command: "workspaces" },
14
14
  { command: "cancel-scan" },
@@ -200,6 +200,43 @@ async function runShellCommand(client, cwd, parsed, shellFlags, session) {
200
200
  await commandScans(client, cwd, shellFlags);
201
201
  return {};
202
202
  case "findings": {
203
+ if (parsed.args[0] === "comment") {
204
+ if (parsed.args.length < 3) {
205
+ process.stderr.write("Usage: /findings comment <finding-id|finding-identifier> <comment>\n");
206
+ return {};
207
+ }
208
+ await commandFindingComment(client, cwd, shellFlags, parsed.args[1], parsed.args.slice(2).join(" "));
209
+ return {};
210
+ }
211
+ if (parsed.args[0] === "feedback") {
212
+ const findingRef = parsed.args[1];
213
+ const feedbackType = parsed.args[2];
214
+ if (!findingRef ||
215
+ (feedbackType !== "valid" && feedbackType !== "invalid")) {
216
+ process.stderr.write("Usage: /findings feedback <finding-id|finding-identifier> <valid|invalid> [comment]\n");
217
+ return {};
218
+ }
219
+ const dismissalReason = feedbackType === "invalid" ? parsed.args[3] : null;
220
+ if (feedbackType === "invalid" &&
221
+ !["false-positive", "by-design", "not-relevant"].includes(dismissalReason ?? "")) {
222
+ process.stderr.write("Usage: /findings feedback <finding-id|finding-identifier> invalid <false-positive|by-design|not-relevant> [comment]\n");
223
+ return {};
224
+ }
225
+ await commandFindingFeedback(client, cwd, shellFlags, {
226
+ requestedFindingRef: findingRef,
227
+ feedbackType,
228
+ comment: parsed.args
229
+ .slice(feedbackType === "invalid" ? 4 : 3)
230
+ .join(" ")
231
+ .trim() || null,
232
+ ...(dismissalReason
233
+ ? {
234
+ dismissalReason: dismissalReason,
235
+ }
236
+ : {}),
237
+ });
238
+ return {};
239
+ }
203
240
  if (parsed.args.length > 1) {
204
241
  process.stderr.write("Usage: /findings [scan-id]\n");
205
242
  return {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cantinasecurity/apex-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Standalone CLI and MCP server for Apex.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -17,11 +17,13 @@ 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
21
 
21
22
  Guidelines:
22
23
 
23
24
  - Do not rely on interactive CLI prompts. The MCP tools are intentionally non-interactive.
24
25
  - `apex-credits` reports standard credits and audit scan entitlements when Bedrock returns both balances.
26
+ - `apex-scan` scans the provided `cwd` by default; pass `repoPaths` only when the user asks to scan explicit alternate roots.
25
27
  - `apex-doctor` reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
26
28
  - Apex can scan plain local directories and dirty git worktrees without provider connections by using local snapshot uploads.
27
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.
@@ -29,3 +31,6 @@ Guidelines:
29
31
  - Use `sourceMode: "remote"` only when the user explicitly wants to forbid local snapshot fallbacks.
30
32
  - Use `force: true` on `apex-scan` only when the user explicitly wants to replace or overlap an active scan.
31
33
  - 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.
35
+ - Invalid finding feedback requires `dismissalReason`; valid feedback can include `suggestedSeverity`, including `extreme`.
36
+ - 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.