@cantinasecurity/apex-cli 0.1.0 → 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,20 +19,28 @@ 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
 
25
26
  - Prefer scripted commands over the interactive shell.
26
27
  - Use `--non-interactive` and `--no-open` for automation-friendly CLI calls.
27
28
  - Use `--json` whenever structured output is helpful.
29
+ - `apex credits` reports standard credits and audit scan entitlements when Bedrock returns both balances.
28
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.
29
32
  - `apex-doctor` reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
30
33
  - Plain local directories and dirty git worktrees can scan through local snapshot uploads without provider access.
34
+ - Audit scans use `--mode audit` in user-facing CLI calls. The legacy `ultra` mode remains accepted as an alias, but audit scans still require provider-backed GitHub or GitLab sources.
31
35
  - `apex-workspace-use` accepts a workspace name, prefix, or ID.
32
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.
33
40
 
34
41
  ## Examples
35
42
 
36
43
  - Start a scan for the current repository or directory: run `apex-doctor`, bind a workspace if needed, then call `apex-scan`.
37
44
  - Check an active scan: call `apex-status`, then `apex-findings` when the scan completes.
38
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
@@ -98,9 +98,12 @@ If Apex asks for a workspace name, that is the Apex workspace name for the curre
98
98
  Supported shell commands:
99
99
 
100
100
  - `/credits`
101
- - `/scan [standard|ultra]`
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`
@@ -147,9 +152,33 @@ Helpful workspace flags:
147
152
  - `--company <id-or-handle>` to choose the Apex company when more than one is available
148
153
  - `--workspace-name <name>` to set the Apex workspace name for this directory
149
154
 
155
+ `apex credits` shows standard scan credits plus audit scan entitlements when the server returns them.
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
+
150
179
  ## Local Source Scans
151
180
 
152
- `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:
153
182
 
154
183
  - clean GitHub or GitLab checkouts can stay on the remote-materialization path
155
184
  - dirty git worktrees fall back to a local snapshot upload by default
@@ -157,12 +186,13 @@ Helpful workspace flags:
157
186
 
158
187
  Useful flags:
159
188
 
160
- - `--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
161
190
  - `--source-mode auto|remote|local` to control remote-first fallback behavior
191
+ - `--mode standard|audit` to choose the scan mode
162
192
 
163
193
  `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.
164
194
 
165
- Ultra scans still require provider-backed GitHub or GitLab repositories that Apex can materialize remotely without a local snapshot fallback.
195
+ Audit scans still require provider-backed GitHub or GitLab repositories that Apex can materialize remotely without a local snapshot fallback. `ultra` remains accepted as a backwards-compatible alias for the audit scan mode.
166
196
 
167
197
  ## LLM / MCP Usage
168
198
 
@@ -234,6 +264,7 @@ The MCP server exposes Apex-specific tools for:
234
264
  - doctor, credits, and provider connection URLs
235
265
  - workspace inspection and workspace binding
236
266
  - scan start, status, cancellation, findings, and findings export
267
+ - finding comments and valid/invalid feedback with `apex-finding-comment` and `apex-finding-feedback`
237
268
 
238
269
  For repository-scoped operations, pass `cwd` explicitly so the server can resolve the right `.apex/workspace.json` binding and repository roots.
239
270
 
@@ -243,7 +274,7 @@ For Claude Code, the packaged project skill can be installed into the current re
243
274
 
244
275
  ## Development Notes
245
276
 
246
- The CLI uses the Apex `/api/cli/v2/**` local-source routes for scan planning and snapshot uploads, with legacy `/api/cli/v1/**` routes still used for provider-backed flows such as ultra scans. Local state is stored under:
277
+ The CLI uses the Apex `/api/cli/v2/**` local-source routes for scan planning and snapshot uploads, with legacy `/api/cli/v1/**` routes still used for provider-backed flows such as audit scans. Local state is stored under:
247
278
 
248
279
  - `~/.config/apex/config.json`
249
280
  - `~/.config/apex/credentials.json`
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) {
@@ -36,6 +49,64 @@ function getSuggestedWorkspaceRef(workspace) {
36
49
  function formatWorkspaceUseCommand(workspace) {
37
50
  return `apex workspace use ${formatCommandArgument(getSuggestedWorkspaceRef(workspace))}`;
38
51
  }
52
+ function getOptionalCount(value) {
53
+ return typeof value === "number" && Number.isFinite(value)
54
+ ? Math.max(0, Math.floor(value))
55
+ : null;
56
+ }
57
+ function formatAuditScanBalance(scanBalance) {
58
+ const purchased = getOptionalCount(scanBalance.auditPurchased);
59
+ const used = getOptionalCount(scanBalance.auditUsed);
60
+ const remaining = getOptionalCount(scanBalance.auditRemaining);
61
+ const available = getOptionalCount(scanBalance.auditAvailable) ??
62
+ remaining ??
63
+ (purchased !== null && used !== null ? Math.max(0, purchased - used) : null);
64
+ if (purchased === null && used === null && available === null) {
65
+ return null;
66
+ }
67
+ const detailParts = [];
68
+ if (purchased !== null) {
69
+ detailParts.push(`${purchased} purchased`);
70
+ }
71
+ if (used !== null) {
72
+ detailParts.push(`${used} used`);
73
+ }
74
+ if (available !== null) {
75
+ const detailSuffix = detailParts.length ? ` (${detailParts.join(", ")})` : "";
76
+ return `Audit scans: ${available} available${detailSuffix}`;
77
+ }
78
+ return `Audit scans: ${detailParts.join(", ")}`;
79
+ }
80
+ function normalizeRequestedScanMode(value) {
81
+ return value === "ultra" || value === "audit" ? "ultra" : "standard";
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
+ }
39
110
  async function requireWorkspaceBinding(cwd) {
40
111
  const binding = await loadWorkspaceBinding(cwd);
41
112
  if (binding) {
@@ -43,6 +114,27 @@ async function requireWorkspaceBinding(cwd) {
43
114
  }
44
115
  throw new Error(`This directory is not bound to an Apex workspace yet. ${WORKSPACE_BINDING_GUIDANCE}`);
45
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
+ }
46
138
  function canPromptForConfirmation(flags) {
47
139
  return !isNonInteractive(flags) && process.stdin.isTTY && process.stdout.isTTY;
48
140
  }
@@ -144,7 +236,15 @@ export async function commandCredits(client, cwd, flags) {
144
236
  return payload;
145
237
  }
146
238
  logLine(`Company: ${payload.company.handle ?? payload.company.id}`, flags);
147
- logLine(`Credits: ${payload.scanBalance.remaining} remaining (${payload.scanBalance.purchased} purchased, ${payload.scanBalance.used} used)`, flags);
239
+ logLine(`Standard credits: ${payload.scanBalance.remaining} remaining (${payload.scanBalance.purchased} purchased, ${payload.scanBalance.used} used)`, flags);
240
+ const auditBalance = formatAuditScanBalance(payload.scanBalance);
241
+ if (auditBalance) {
242
+ logLine(auditBalance, flags);
243
+ }
244
+ const redeemableAuditScans = getOptionalCount(payload.scanBalance.auditRedeemableFromCredits);
245
+ if (redeemableAuditScans && redeemableAuditScans > 0) {
246
+ logLine(`Redeemable audit scans from standard credits: ${redeemableAuditScans}`, flags);
247
+ }
148
248
  logLine(`Scans enabled: ${payload.scansEnabled ? "yes" : "no"}`, flags);
149
249
  return payload;
150
250
  }
@@ -244,7 +344,7 @@ export async function commandScan(client, cwd, flags) {
244
344
  const me = await ensureAuthenticated(client, flags);
245
345
  const selection = await selectWorkspaceTarget(cwd, me, flags);
246
346
  const result = await withLoadingIndicator("Resolving workspace and scan plan...", flags, () => resolveWorkspaceSelection(client, selection));
247
- const requestedMode = getFlagString(flags, "mode") === "ultra" ? "ultra" : "standard";
347
+ const requestedMode = normalizeRequestedScanMode(getFlagString(flags, "mode"));
248
348
  if (!result.resolve.workspaceId) {
249
349
  throw new Error("Workspace resolution did not return a workspaceId.");
250
350
  }
@@ -255,14 +355,14 @@ export async function commandScan(client, cwd, flags) {
255
355
  let scan;
256
356
  if (requestedMode === "ultra") {
257
357
  if (!supportsLegacyRemoteFlow(result.resolve.plannedSources)) {
258
- throw new Error("Ultra scans currently require provider-backed GitHub or GitLab repositories without local snapshot fallbacks.");
358
+ throw new Error("Audit scans currently require provider-backed GitHub or GitLab repositories without local snapshot fallbacks.");
259
359
  }
260
- const legacyResolve = await withLoadingIndicator("Preparing ultra scan...", flags, () => resolveWorkspaceSelectionLegacy(client, selection, resolvedWorkspaceId));
360
+ const legacyResolve = await withLoadingIndicator("Preparing audit scan...", flags, () => resolveWorkspaceSelectionLegacy(client, selection, resolvedWorkspaceId));
261
361
  if (!legacyResolve.workspaceId) {
262
362
  throw new Error("Workspace resolution did not return a workspaceId.");
263
363
  }
264
364
  workspaceId = legacyResolve.workspaceId;
265
- scan = await withLoadingIndicator("Starting ultra scan...", flags, () => client.request(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
365
+ scan = await withLoadingIndicator("Starting audit scan...", flags, () => client.request(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
266
366
  method: "POST",
267
367
  json: {
268
368
  mode: requestedMode,
@@ -560,6 +660,122 @@ export async function commandFindings(client, cwd, flags) {
560
660
  }
561
661
  return payload;
562
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
+ }
563
779
  export async function commandExportFindings(client, cwd, flags) {
564
780
  await ensureAuthenticated(client, flags);
565
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
@@ -1,9 +1,13 @@
1
1
  export const CLI_HELP_TEXT = `Usage:
2
2
  apex Open the interactive Apex shell
3
- apex credits Show scan credits for the active company
3
+ apex credits Show scan credits and audit scan entitlements for the active company
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,11 +28,19 @@ 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
30
42
  --limit <count> Limit the number of findings returned
31
- --mode standard|ultra Choose the scan mode
43
+ --mode standard|audit Choose the scan mode
32
44
  --source-mode auto|remote|local Control remote-vs-local source materialization
33
45
  --force Start a new scan even if another scan is active
34
46
  --repo <path> Include one or more explicit local source roots
@@ -39,17 +51,28 @@ 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.
55
+ audit is the user-facing name for the legacy ultra scan mode; ultra remains accepted as an alias.
42
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.
43
60
  Quote workspace names that contain spaces:
44
61
  apex workspace use "Core Platform"
45
62
  `;
46
63
  export const SHELL_HELP_TEXT = `Press Tab to autocomplete commands and common arguments.
47
64
 
48
65
  Commands:
49
- /credits Show scan credits for the active company
50
- /scan [standard|ultra] Start a new Apex scan for this workspace
66
+ /credits Show scan credits and audit scan entitlements for the active company
67
+ /scan [standard|audit] Start a new Apex scan for this workspace
51
68
  /scans List scans for this workspace
52
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
53
76
  /export [scan-id] Export findings for the latest or selected scan
54
77
  /workspaces List accessible workspaces for the active company
55
78
  /cancel-scan [scan-id] Cancel a running or most recent scan
@@ -70,6 +93,10 @@ Commands:
70
93
  /exit Exit Apex
71
94
 
72
95
  Tips:
96
+ audit is the user-facing name for the legacy ultra scan mode; ultra remains accepted as an alias.
73
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.
74
101
  Quote workspace names that contain spaces: /workspace use "Core Platform"
75
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,10 +15,12 @@ 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.
21
22
  - 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.
22
24
  - Doctor reports whether Apex will use remote materialization or a local snapshot upload for each source.`;
23
25
  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.";
24
26
  const MISSING_PROVIDER_CONNECTIONS_ERROR = "Missing provider connections. Re-run interactively or pre-connect providers.";
@@ -248,7 +250,7 @@ function registerTools(server) {
248
250
  }, (value) => `Apex doctor completed for ${String(value.cwd)}.`));
249
251
  server.registerTool("apex-credits", {
250
252
  title: "Get Apex Credits",
251
- description: "Show scan credits for the active or selected company.",
253
+ description: "Show scan credits and audit scan entitlements for the active or selected company.",
252
254
  inputSchema: {
253
255
  cwd: z.string().optional(),
254
256
  company: z.string().optional(),
@@ -320,13 +322,13 @@ function registerTools(server) {
320
322
  }, (value) => `Bound ${String(value.cwd)} to an Apex workspace.`));
321
323
  server.registerTool("apex-scan", {
322
324
  title: "Start Apex Scan",
323
- description: "Start a new Apex scan for any local repository or directory. Pass cwd explicitly for reliable workspace resolution.",
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.",
324
326
  inputSchema: {
325
327
  cwd: z.string().optional(),
326
328
  company: z.string().optional(),
327
329
  workspaceName: z.string().optional(),
328
330
  repoPaths: z.array(z.string()).optional(),
329
- mode: z.enum(["standard", "ultra"]).optional(),
331
+ mode: z.enum(["standard", "audit", "ultra"]).optional(),
330
332
  sourceMode: z.enum(["auto", "remote", "local"]).optional(),
331
333
  force: z.boolean().optional(),
332
334
  },
@@ -417,6 +419,61 @@ function registerTools(server) {
417
419
  ...payload,
418
420
  };
419
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)}.`));
420
477
  server.registerTool("apex-export-findings", {
421
478
  title: "Export Apex Findings",
422
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/scan.js CHANGED
@@ -30,6 +30,10 @@ function readNumber(...values) {
30
30
  }
31
31
  return null;
32
32
  }
33
+ function readScanMode(...values) {
34
+ const mode = readString(...values);
35
+ return mode === "ultra" ? "audit" : mode;
36
+ }
33
37
  function getScanListItems(payload) {
34
38
  if (Array.isArray(payload)) {
35
39
  return payload;
@@ -63,7 +67,7 @@ function normalizeScanRecord(payload) {
63
67
  displayName: readString(record.displayName, record.display_name, record.name),
64
68
  sequenceNumber: readNumber(record.sequenceNumber, record.sequence_number),
65
69
  status: readString(record.status) ?? "unknown",
66
- mode: readString(record.mode, record.scanType, record.scan_type),
70
+ mode: readScanMode(record.mode, record.scanType, record.scan_type),
67
71
  scanUrl: readString(record.scanUrl, record.url, record.scan_url),
68
72
  createdAt: readTimestamp(record, "createdAt", "created_at"),
69
73
  startedAt: readTimestamp(record, "startedAt", "started_at"),
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,14 +1,14 @@
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";
6
6
  import { formatApiError } from "./api-client.js";
7
7
  const SHELL_COMPLETIONS = [
8
8
  { command: "credits" },
9
- { command: "scan", args: ["standard", "ultra"] },
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" },
@@ -178,8 +178,8 @@ 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", "ultra"].includes(mode)) {
182
- process.stderr.write("Usage: /scan [standard|ultra]\n");
181
+ if (mode && !["standard", "audit", "ultra"].includes(mode)) {
182
+ process.stderr.write("Usage: /scan [standard|audit|ultra]\n");
183
183
  return {};
184
184
  }
185
185
  const result = await commandScan(client, cwd, mode ? withFlag(shellFlags, "mode", mode) : shellFlags);
@@ -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.0",
3
+ "version": "0.1.2",
4
4
  "description": "Standalone CLI and MCP server for Apex.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -17,13 +17,20 @@ 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.
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.
24
27
  - `apex-doctor` reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
25
28
  - Apex can scan plain local directories and dirty git worktrees without provider connections by using local snapshot uploads.
29
+ - Audit scans use `mode: "audit"` in user-facing instructions. The legacy `ultra` mode remains accepted as an alias, but audit scans still require provider-backed GitHub or GitLab sources.
26
30
  - `apex-workspace-use` accepts a workspace name, prefix, or ID.
27
31
  - Use `sourceMode: "remote"` only when the user explicitly wants to forbid local snapshot fallbacks.
28
32
  - Use `force: true` on `apex-scan` only when the user explicitly wants to replace or overlap an active scan.
29
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.