@cantinasecurity/apex-cli 0.1.36 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commands.js CHANGED
@@ -4,7 +4,8 @@ import { ApiError, formatApiError, getApiErrorCode, } from "./api-client.js";
4
4
  import { openInBrowser } from "./browser.js";
5
5
  import { getClaimProofState, isClaimProofAvailable, parseClaimDeadlineMs, } from "./claim-proof.js";
6
6
  import { loadConfig, updateConfig } from "./config.js";
7
- import { createFindingComment, fetchFindingTickets, linkFindingTicket, sendFindingToIssueTracker, fetchScanExport, fetchScanFindings, fetchWorkspaceFindings, isFindingUuid, normalizeFindingRef, resolveFindingSelection, resolveScanSelection, startFindingFixReviewScan, submitFindingFeedback, } from "./findings.js";
7
+ import { createFindingComment, fetchFindingTickets, linkFindingTicket, sendFindingToIssueTracker, fetchScanExport, fetchScanFindings, fetchWorkspaceFindings, isFindingUuid, normalizeFindingRef, resolveFindingSelection, resolveScanSelection, startFindingFixReviewScan, submitFindingFeedback, setFindingStatus, } from "./findings.js";
8
+ import { FINDING_LIFECYCLE_STATUSES } from "./types.js";
8
9
  import { prepareLocalSourceArchives, prepareExplicitScanSources, supportsLegacyRemoteFlow, } from "./local-source-scan.js";
9
10
  import { logLine, printJson, withLoadingIndicator } from "./output.js";
10
11
  import { claimOnboardingSession, isProofOnlyOnboardingCredentials, redeemOnboardingCode, revokeGuestOnboardingSession, } from "./onboarding.js";
@@ -332,6 +333,8 @@ function formatLiteScanBalance(scanBalance) {
332
333
  return `Lite scan credits: ${detailParts.join(", ")}`;
333
334
  }
334
335
  function normalizeRequestedScanMode(value) {
336
+ if (value === null || value === "standard")
337
+ return "standard";
335
338
  if (value === "ultra" || value === "audit") {
336
339
  return "audit";
337
340
  }
@@ -341,7 +344,7 @@ function normalizeRequestedScanMode(value) {
341
344
  if (value === "lite") {
342
345
  return "lite";
343
346
  }
344
- return "standard";
347
+ throw new Error("--mode must be standard, audit, ultra, lite, or pr.");
345
348
  }
346
349
  function getFlagValues(flags, key) {
347
350
  const values = getFlagList(flags, key);
@@ -1074,7 +1077,7 @@ export async function commandConnect(client, cwd, flags, provider) {
1074
1077
  if (!isJsonMode(flags)) {
1075
1078
  logLine(`Connection URL for ${provider}: ${response.url}`, flags);
1076
1079
  }
1077
- if (flags["no-open"] !== true) {
1080
+ if (flags["no-open"] !== true && canPromptInteractively(flags)) {
1078
1081
  await openInBrowser(response.url);
1079
1082
  }
1080
1083
  if (isJsonMode(flags)) {
@@ -1097,7 +1100,23 @@ export async function commandScan(client, cwd, flags) {
1097
1100
  const me = await ensureAuthenticated(client, flags);
1098
1101
  const authState = await getCommandAuthState(client, me);
1099
1102
  setTelemetryAuthKind(authState.authKind);
1103
+ if (authState.authKind === "guest" && requestedMode !== "standard") {
1104
+ throw new Error("Guest trials support standard scans only. Use --mode standard, or run `apex claim` before choosing another mode.");
1105
+ }
1106
+ const guestLocalSourceGuidance = "Guest trials require local snapshot uploads. Clone remote repositories locally, then scan a local path without --source-mode remote.";
1107
+ if (authState.authKind === "guest" && getFlagString(flags, "source-mode") === "remote") {
1108
+ throw new Error(guestLocalSourceGuidance);
1109
+ }
1100
1110
  let selection = await selectWorkspaceTarget(cwd, me, flags);
1111
+ if (authState.authKind === "guest" && selection.sources.some((source) => source.absolutePath === null)) {
1112
+ throw new Error(guestLocalSourceGuidance);
1113
+ }
1114
+ const requiresLocalSnapshot = selection.sourceMode === "local" ||
1115
+ selection.sources.some((source) => source.kind !== "git_candidate" ||
1116
+ (selection.sourceMode !== "remote" && source.dirty));
1117
+ if ((requestedMode === "audit" || requestedMode === "lite") && requiresLocalSnapshot) {
1118
+ throw new Error(`${requestedMode === "audit" ? "Audit" : "Lite"} scans require provider-backed repositories without local changes. Use --mode standard for files, directories, or local changes.`);
1119
+ }
1101
1120
  let preparedArchives = null;
1102
1121
  if (authState.authKind === "guest" && requestedMode === "standard") {
1103
1122
  preparedArchives = await withLoadingIndicator("Preparing local source archive...", flags, () => prepareLocalSourceArchives({
@@ -1139,6 +1158,21 @@ export async function commandScan(client, cwd, flags) {
1139
1158
  let workspaceId = resolvedWorkspaceId;
1140
1159
  let scan;
1141
1160
  let prScanSource = null;
1161
+ const requestScanStart = async (endpoint, json) => {
1162
+ try {
1163
+ return normalizeScanStartResponse(await client.request(endpoint, { method: "POST", json }));
1164
+ }
1165
+ catch (error) {
1166
+ if (error instanceof ApiError && getApiErrorCode(error) === "AUDIT_SETUP_REQUIRED") {
1167
+ const quote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
1168
+ const mode = requestedMode === "audit" ? "audit" : "standard";
1169
+ const command = `apex scan --company ${quote(result.company.id)} --workspace-ref ${quote(workspaceId)} --mode ${mode} --setup guided`;
1170
+ const message = `This backend requires Audit Setup. For this workspace's saved repositories, run: ${command}. Setup uses saved workspace sources and does not preserve local target selections. An explicit Standard source scan requires an updated client and backend; contact support if it still returns AUDIT_SETUP_REQUIRED.`;
1171
+ throw new ApiError(message, error.status, { code: "AUDIT_SETUP_REQUIRED", error: message });
1172
+ }
1173
+ throw error;
1174
+ }
1175
+ };
1142
1176
  if (requestedMode === "pr") {
1143
1177
  let legacyResolve = await withLoadingIndicator("Preparing PR scan...", flags, () => resolveWorkspaceSelectionLegacy(client, selection, resolvedWorkspaceId));
1144
1178
  if (!legacyResolve.workspaceId) {
@@ -1161,40 +1195,30 @@ export async function commandScan(client, cwd, flags) {
1161
1195
  const prSource = resolvePrScanSource(legacyResolve.repositories);
1162
1196
  ensurePrSelectionsMatchSource(prSelections, prSource);
1163
1197
  prScanSource = prSource;
1164
- scan = await withLoadingIndicator("Starting PR scan...", flags, () => client
1165
- .request(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
1166
- method: "POST",
1167
- json: {
1168
- mode: "pr",
1169
- scanType: "pr",
1170
- force: forceRestart || flags.force === true,
1171
- repositories: buildPrScanRepositoriesPayload({
1172
- source: prSource,
1173
- pullRequests: prSelections,
1174
- }),
1175
- },
1176
- })
1177
- .then(normalizeScanStartResponse));
1198
+ scan = await withLoadingIndicator("Starting PR scan...", flags, () => requestScanStart(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
1199
+ mode: "pr",
1200
+ scanType: "pr",
1201
+ force: forceRestart || flags.force === true,
1202
+ repositories: buildPrScanRepositoriesPayload({
1203
+ source: prSource,
1204
+ pullRequests: prSelections,
1205
+ }),
1206
+ }));
1178
1207
  }
1179
1208
  else if (requestedMode === "audit" || requestedMode === "lite") {
1180
1209
  const modeLabel = requestedMode === "audit" ? "Audit" : "Lite";
1181
1210
  if (!supportsLegacyRemoteFlow(result.resolve.plannedSources)) {
1182
- throw new Error(`${modeLabel} scans currently require provider-backed GitHub or GitLab repositories without local snapshot fallbacks.`);
1211
+ throw new Error(`${modeLabel} scans currently require provider-backed GitHub or GitLab repositories without local snapshot fallbacks. Use --mode standard for local snapshots or public repositories without a provider connection.`);
1183
1212
  }
1184
1213
  const legacyResolve = await withLoadingIndicator(`Preparing ${modeLabel.toLowerCase()} scan...`, flags, () => resolveWorkspaceSelectionLegacy(client, selection, resolvedWorkspaceId));
1185
1214
  if (!legacyResolve.workspaceId) {
1186
1215
  throw new Error("Workspace resolution did not return a workspaceId.");
1187
1216
  }
1188
1217
  workspaceId = legacyResolve.workspaceId;
1189
- scan = await withLoadingIndicator(`Starting ${modeLabel.toLowerCase()} scan...`, flags, () => client
1190
- .request(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
1191
- method: "POST",
1192
- json: {
1193
- mode: requestedMode,
1194
- force: forceRestart || flags.force === true,
1195
- },
1196
- })
1197
- .then(normalizeScanStartResponse));
1218
+ scan = await withLoadingIndicator(`Starting ${modeLabel.toLowerCase()} scan...`, flags, () => requestScanStart(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
1219
+ mode: requestedMode,
1220
+ force: forceRestart || flags.force === true,
1221
+ }));
1198
1222
  }
1199
1223
  else {
1200
1224
  try {
@@ -1206,15 +1230,11 @@ export async function commandScan(client, cwd, flags) {
1206
1230
  detectedSources: result.sources,
1207
1231
  preparedArchives,
1208
1232
  });
1209
- const response = await client.request(`/api/cli/v2/local-workspaces/${encodeURIComponent(resolvedWorkspaceId)}/scan`, {
1210
- method: "POST",
1211
- json: {
1212
- mode: requestedMode,
1213
- force: forceRestart || flags.force === true,
1214
- scanSources,
1215
- },
1233
+ return requestScanStart(`/api/cli/v2/local-workspaces/${encodeURIComponent(resolvedWorkspaceId)}/scan`, {
1234
+ mode: requestedMode,
1235
+ force: forceRestart || flags.force === true,
1236
+ scanSources,
1216
1237
  });
1217
- return normalizeScanStartResponse(response);
1218
1238
  });
1219
1239
  }
1220
1240
  finally {
@@ -1877,7 +1897,7 @@ export async function openCurrentApexView(client, cwd, flags) {
1877
1897
  }
1878
1898
  return url;
1879
1899
  }
1880
- async function resolveTicketFinding(client, cwd, flags, requestedFindingRef) {
1900
+ async function resolveCommandFinding(client, cwd, flags, requestedFindingRef) {
1881
1901
  const findingRef = normalizeFindingRefInput(requestedFindingRef);
1882
1902
  const currentBinding = await loadWorkspaceBinding(cwd);
1883
1903
  const binding = isFindingUuid(findingRef)
@@ -1888,7 +1908,7 @@ async function resolveTicketFinding(client, cwd, flags, requestedFindingRef) {
1888
1908
  requestedScanId: getFlagString(flags, "scan") });
1889
1909
  }
1890
1910
  export async function commandFindingTickets(client, cwd, flags, findingRef) {
1891
- const selection = await resolveTicketFinding(client, cwd, flags, findingRef);
1911
+ const selection = await resolveCommandFinding(client, cwd, flags, findingRef);
1892
1912
  const result = await fetchFindingTickets(client, selection.findingId);
1893
1913
  const payload = { findingId: selection.findingId, findingRef: selection.findingRef, ...result };
1894
1914
  if (isJsonMode(flags))
@@ -1904,7 +1924,7 @@ export async function commandFindingLinkTicket(client, cwd, flags, findingRef, p
1904
1924
  if ((provider !== "linear" && provider !== "jira") || !issueRef.trim() || issueRef.trim().length > 2048) {
1905
1925
  throw new Error("Usage: apex findings link-ticket <finding-ref> <linear|jira> <issue-key|url>");
1906
1926
  }
1907
- const selection = await resolveTicketFinding(client, cwd, flags, findingRef);
1927
+ const selection = await resolveCommandFinding(client, cwd, flags, findingRef);
1908
1928
  const result = await linkFindingTicket(client, selection.findingId, { provider, issueRef: issueRef.trim() });
1909
1929
  const payload = { findingId: selection.findingId, findingRef: selection.findingRef, ...result };
1910
1930
  if (isJsonMode(flags))
@@ -1917,7 +1937,7 @@ export async function commandFindingSend(client, cwd, flags, findingRef, provide
1917
1937
  if (provider !== "linear" && provider !== "jira") {
1918
1938
  throw new Error("Usage: apex findings send <finding-ref> <linear|jira>");
1919
1939
  }
1920
- const selection = await resolveTicketFinding(client, cwd, flags, findingRef);
1940
+ const selection = await resolveCommandFinding(client, cwd, flags, findingRef);
1921
1941
  const result = await sendFindingToIssueTracker(client, selection.findingId, provider);
1922
1942
  const payload = { findingId: selection.findingId, findingRef: selection.findingRef, ...result };
1923
1943
  if (isJsonMode(flags))
@@ -1926,3 +1946,29 @@ export async function commandFindingSend(client, cwd, flags, findingRef, provide
1926
1946
  logLine(`${result.created ? "Created" : "Already sent"}: ${result.export.issueKey ?? result.export.issueId} ${result.export.issueUrl}`, flags);
1927
1947
  return payload;
1928
1948
  }
1949
+ export async function commandFindingStatus(client, cwd, flags, findingRef, status, note, expectedUpdatedAt) {
1950
+ if (!FINDING_LIFECYCLE_STATUSES.includes(status)) {
1951
+ throw new Error(`Usage: apex findings status <finding-ref> <${FINDING_LIFECYCLE_STATUSES.join("|")}> [--note <rationale>]`);
1952
+ }
1953
+ const trimmedNote = note?.trim();
1954
+ if (trimmedNote && trimmedNote.length > 1000)
1955
+ throw new Error("Status notes must be 1000 characters or less.");
1956
+ if (expectedUpdatedAt && (!/^\d{4}-\d{2}-\d{2}T/.test(expectedUpdatedAt) || !Number.isFinite(Date.parse(expectedUpdatedAt)))) {
1957
+ throw new Error("--expected-updated-at must be an ISO timestamp from the finding's updatedAt.");
1958
+ }
1959
+ const selection = await resolveCommandFinding(client, cwd, flags, findingRef);
1960
+ const result = await setFindingStatus(client, selection.findingId, {
1961
+ status: status,
1962
+ ...(trimmedNote ? { note: trimmedNote } : {}),
1963
+ ...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
1964
+ });
1965
+ const payload = { ...result, findingRef: selection.findingRef };
1966
+ if (isJsonMode(flags))
1967
+ printJson(payload);
1968
+ else {
1969
+ logLine(`Finding: ${selection.findingRef}`, flags);
1970
+ logLine(`Status: ${result.status} (${result.reviewState})${result.changed ? "" : " — already set"}`, flags);
1971
+ logLine("Scanner validity and review feedback are unchanged.", flags);
1972
+ }
1973
+ return payload;
1974
+ }
package/dist/findings.js CHANGED
@@ -5,7 +5,7 @@ const WORKSPACE_FINDING_FILTER_PATTERN = /^!?(severity|scan|repo|review|impact|l
5
5
  const MAX_WORKSPACE_FINDING_FILTERS = 50;
6
6
  const MAX_WORKSPACE_FINDING_FILTER_LENGTH = 128;
7
7
  const CANTINA_AUTH_TOKEN_ENV = "CANTINA_AUTH_TOKEN";
8
- const FINDING_WRITE_AUTH_ERROR = "Finding comments, feedback, ticket links, ticket creation, and fix review scans require Apex authentication. Run `apex login` or use MCP `apex-auth-start` followed by `apex-auth-wait`, then retry.";
8
+ const FINDING_WRITE_AUTH_ERROR = "Finding comments, feedback, status changes, ticket links, ticket creation, and fix review scans require Apex authentication. Run `apex login` or use MCP `apex-auth-start` followed by `apex-auth-wait`, then retry.";
9
9
  function parsePositiveInt(value) {
10
10
  if (!value)
11
11
  return null;
@@ -321,6 +321,11 @@ export async function startFindingFixReviewScan(client, findingId, input = {}) {
321
321
  export async function fetchFindingTickets(client, findingId) {
322
322
  return requestFindingAppRoute(client, `/api/findings/${encodeURIComponent(findingId)}/tickets`);
323
323
  }
324
+ export async function setFindingStatus(client, findingId, input) {
325
+ return requestFindingAppRoute(client, `/api/findings/${encodeURIComponent(findingId)}/status`, {
326
+ method: "PATCH", json: input,
327
+ });
328
+ }
324
329
  export async function linkFindingTicket(client, findingId, input) {
325
330
  return requestFindingAppRoute(client, `/api/findings/${encodeURIComponent(findingId)}/tickets`, {
326
331
  method: "POST", json: input,
package/dist/help.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export const CLI_HELP_TEXT = `Usage:
2
2
  apex Open the interactive Apex shell
3
3
  apex credits Show standard, audit, fix review, and Lite scan entitlements for the active company
4
- apex scan Create or resolve a workspace for this directory and start a scan
4
+ apex scan [target...] 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
7
  apex findings --workspace List findings across the workspace browser scan set
@@ -14,6 +14,8 @@ export const CLI_HELP_TEXT = `Usage:
14
14
  Add a comment or note to a finding
15
15
  apex findings feedback <finding-id|finding-identifier> <valid|invalid>
16
16
  Leave feedback; invalid requires --dismissal-reason
17
+ apex findings status <finding-ref> <open|fixed|acknowledged|in_review|pending_merge|false_positive|wont_fix|skipped>
18
+ Close, reopen, or update client remediation status
17
19
  apex findings fix-review <finding-id|finding-identifier>
18
20
  Start a fix review scan for a Fix PR or current repository
19
21
  apex export findings Export findings for the latest or selected scan
@@ -56,6 +58,8 @@ Flags:
56
58
  --status valid|invalid Set the finding feedback status explicitly
57
59
  --content <markdown> Supply comment content for apex findings comment
58
60
  --comment <markdown> Supply rationale for apex findings feedback
61
+ --note <rationale> Audit note for a finding status change (max 1000 characters)
62
+ --expected-updated-at <timestamp> Reject a status change if the finding has changed since this updatedAt
59
63
  --parent-comment <comment-id> Reply to an existing finding comment
60
64
  --suggested-severity extreme|critical|high|medium|low|informational
61
65
  Attach a suggested severity to valid feedback
@@ -75,7 +79,7 @@ Flags:
75
79
  --source-mode auto|remote|local Control remote-vs-local source materialization
76
80
  --force Start an overlapping non-PR scan, or confirm destructive guest logout
77
81
  --replace Revoke and replace an existing guest trial session
78
- --repo <path> Include one or more explicit local source roots
82
+ --repo <path-or-url> Select a file, directory, or HTTPS repository (repeatable)
79
83
  --json Print machine-readable JSON output
80
84
  --no-open Do not open browser flows automatically
81
85
  --non-interactive Disable prompts and browser-dependent UX
@@ -83,7 +87,9 @@ Flags:
83
87
 
84
88
  Tips:
85
89
  apex scan uses the current directory name as the default workspace name unless you pass --workspace-name.
86
- apex scan uses the current directory as the default source root unless you pass --repo.
90
+ apex scan defaults to the current directory; positional targets or --repo select files, directories, or HTTPS GitHub/GitLab repositories.
91
+ A file target scans only that file. Use --mode standard for local changes and public repositories without a provider connection.
92
+ Headless and --json commands require an existing session; run apex login explicitly first.
87
93
  PR scans require --mode pr and at least one --pr selection for a GitHub repository.
88
94
  audit is the current scan mode for audit scans; ultra remains accepted as a legacy alias.
89
95
  Lite scans use --mode lite and require beta access plus provider-backed GitHub or GitLab sources.
@@ -132,6 +138,9 @@ Audit Setup beta:
132
138
  --no-feedback-context Exclude prior feedback from this setup
133
139
  --wait Wait for the next actionable setup state
134
140
  --timeout <seconds> Bound next --wait (default 60, maximum 3600)
141
+ Setup uses saved workspace repositories and context, not local target selections.
142
+ Use the draft ID returned by setup: apex threat-model status --draft <uuid>.
143
+ Lite and PR scans do not use --setup.
135
144
  `;
136
145
  export function cliHelpText(auditSetupEnabled) {
137
146
  return auditSetupEnabled
@@ -142,7 +151,7 @@ export const SHELL_HELP_TEXT = `Press Tab to autocomplete commands and common ar
142
151
 
143
152
  Commands:
144
153
  /credits Show standard, audit, fix review, and Lite scan entitlements for the active company
145
- /scan [standard|audit|lite]
154
+ /scan [standard|audit|lite] [target...]
146
155
  Start a new Apex scan for this workspace
147
156
  /scan pr <pr-number> Start a PR scan for this workspace
148
157
  /scans List scans for this workspace
@@ -158,6 +167,8 @@ Commands:
158
167
  Add a comment or note to a finding
159
168
  /findings feedback <finding-ref> valid [comment]
160
169
  Leave valid feedback on a finding
170
+ /findings status <finding-ref> <open|fixed|acknowledged|in_review|pending_merge|false_positive|wont_fix|skipped> [note]
171
+ Close, reopen, or update client remediation status
161
172
  /findings feedback <finding-ref> invalid <reason> [comment]
162
173
  Leave invalid feedback; reason is false-positive, by-design, or not-relevant
163
174
  /findings fix-review <finding-ref>
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { execFile } from "node:child_process";
3
3
  import { createReadStream } from "node:fs";
4
- import { lstat, mkdtemp, readFile, readdir, readlink, rm, stat, } from "node:fs/promises";
4
+ import { lstat, mkdtemp, readFile, readdir, readlink, realpath, rm, stat, } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import path from "node:path";
7
7
  import { promisify } from "node:util";
@@ -11,6 +11,8 @@ const execFileAsync = promisify(execFile);
11
11
  const MAX_ARCHIVE_ENTRIES = 100_000;
12
12
  const MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
13
13
  const BUILTIN_IGNORE_PATTERNS = [
14
+ ".apex",
15
+ ".apex/**",
14
16
  ".git",
15
17
  ".git/**",
16
18
  "node_modules",
@@ -89,6 +91,9 @@ async function buildIgnoreMatcher(rootPath) {
89
91
  throw new Error(".apexignore is not valid UTF-8.");
90
92
  }
91
93
  matcher.add(decoded);
94
+ // Local CLI state is never scan input, even when a broad negation in the
95
+ // user's ignore file re-includes ordinary generated files.
96
+ matcher.add([".apex", ".apex/**"]);
92
97
  }
93
98
  catch (error) {
94
99
  if (error.code !== "ENOENT") {
@@ -207,10 +212,19 @@ async function collectDirectoryEntries(rootPath, matcher) {
207
212
  return entries;
208
213
  }
209
214
  async function collectArchiveEntries(source) {
215
+ if (!source.absolutePath) {
216
+ throw new Error("A remote repository URL cannot use a local snapshot. Connect its provider or clone it locally, then retry.");
217
+ }
218
+ const resolvedRoot = await realpath(source.absolutePath);
219
+ if (resolvedRoot.split(path.sep).some((segment) => segment.toLowerCase() === ".apex")) {
220
+ throw new Error("Apex local state (.apex) cannot be selected as scan input. Select your source files or repository instead.");
221
+ }
210
222
  const matcher = await buildIgnoreMatcher(source.absolutePath);
211
223
  const gitEntries = await collectGitEntries(source.absolutePath);
212
- const rawEntries = gitEntries ??
213
- await collectDirectoryEntries(source.absolutePath, matcher);
224
+ const selectedFiles = source.kind === "directory_candidate" ? source.selectedFiles : undefined;
225
+ const rawEntries = selectedFiles
226
+ ? selectedFiles.filter((entry) => !gitEntries || gitEntries.includes(entry))
227
+ : gitEntries ?? await collectDirectoryEntries(source.absolutePath, matcher);
214
228
  const fileEntries = new Set();
215
229
  let uncompressedBytes = 0;
216
230
  for (const entry of rawEntries.sort(compareArchivePaths)) {
@@ -305,7 +319,13 @@ async function inspectCreatedArchive(archivePath, sourceRoot, expectedFiles, dis
305
319
  return { entryCount, uncompressedBytes };
306
320
  }
307
321
  async function createDeterministicArchive(params) {
322
+ const sourceRoot = params.source.absolutePath;
323
+ if (!sourceRoot)
324
+ throw new Error("Remote repository targets require remote materialization.");
308
325
  const { files, uncompressedBytes } = await collectArchiveEntries(params.source);
326
+ if (files.length === 0) {
327
+ throw new Error(`No scannable files found in ${params.source.displayName}. Check the target and ignore rules.`);
328
+ }
309
329
  if (files.length > MAX_ARCHIVE_ENTRIES) {
310
330
  throw new Error(`Apex local snapshots support at most ${MAX_ARCHIVE_ENTRIES.toLocaleString()} entries per source.`);
311
331
  }
@@ -313,7 +333,7 @@ async function createDeterministicArchive(params) {
313
333
  throw new Error(`Archive for ${params.source.displayName} exceeds the uncompressed upload limit.`);
314
334
  }
315
335
  await tar.create({
316
- cwd: params.source.absolutePath,
336
+ cwd: sourceRoot,
317
337
  file: params.archivePath,
318
338
  gzip: true,
319
339
  portable: true,
@@ -334,7 +354,7 @@ async function createDeterministicArchive(params) {
334
354
  entry.gname = "root";
335
355
  },
336
356
  }, files);
337
- const observedArchive = await inspectCreatedArchive(params.archivePath, params.source.absolutePath, files, params.source.displayName, params.maxUncompressedBytes);
357
+ const observedArchive = await inspectCreatedArchive(params.archivePath, sourceRoot, files, params.source.displayName, params.maxUncompressedBytes);
338
358
  const manifest = await createArchiveManifest(params.archivePath);
339
359
  return {
340
360
  ...manifest,
package/dist/mcp.js CHANGED
@@ -6,11 +6,12 @@ import { z } from "zod";
6
6
  import { getMe, logout, readStableDeviceLoginState, startDeviceLogin, waitForDeviceLoginApproval, } from "./auth.js";
7
7
  import { ApexApiClient, formatApiError } from "./api-client.js";
8
8
  import { getClaimProofState, isClaimProofAvailable, } from "./claim-proof.js";
9
- import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingTickets, commandFindingLinkTicket, commandFindingSend, commandFindingFeedback, commandFindingFixReview, commandFindings, commandWorkspaceFindings, commandScan, commandScans, commandStatus, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
9
+ import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingStatus, commandFindingTickets, commandFindingLinkTicket, commandFindingSend, commandFindingFeedback, commandFindingFixReview, commandFindings, commandWorkspaceFindings, commandScan, commandScans, commandStatus, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
10
10
  import { cliHelpText } from "./help.js";
11
11
  import { getLocalTrialStatus, isProofOnlyOnboardingCredentials, revokeGuestOnboardingSession, } from "./onboarding.js";
12
12
  import { createMcpServerTelemetryInvocation, createMcpTelemetryInvocation, emitInvocationCompleted, emitInvocationStarted, emitMcpServerStarted, withTelemetryContext, } from "./telemetry.js";
13
13
  import { APEX_CLI_VERSION } from "./version.js";
14
+ import { FINDING_LIFECYCLE_STATUSES } from "./types.js";
14
15
  const MCP_WORKFLOW_GUIDE = `Use Apex through these tools instead of shelling out to the CLI.
15
16
 
16
17
  Suggested workflow:
@@ -23,11 +24,11 @@ Suggested workflow:
23
24
  7. Start and monitor scans with apex-scan, apex-status, apex-scans, apex-findings, and apex-export-findings. For cross-scan review queues, call apex-workspace-findings with company, workspaceRef, and findingState "open"; this direct selection does not require or change a local binding.
24
25
  8. For PR scans, call apex-scan with mode "pr" and one or more pullRequests.
25
26
  9. Send findings to Linear/Jira with apex-finding-send to create tickets using the frontend's saved destination. Link existing tickets with apex-finding-link-ticket and inspect links with apex-finding-tickets. The company integration must already be connected.
26
- 10. Leave finding comments or valid/invalid feedback with apex-finding-comment and apex-finding-feedback when review collaboration is needed.
27
+ 10. Close or reopen findings with apex-finding-status using status fixed or open. This changes remediation, not scanner validity or feedback. Leave comments or valid/invalid feedback with apex-finding-comment and apex-finding-feedback when review collaboration is needed.
27
28
  11. To review a fix, call apex-finding-fix-review. Pass fixPrUrls for PR-based fix reviews, or omit them to review the current repository state when Bedrock has original scan repository context.
28
29
 
29
30
  Notes:
30
- - Pass cwd explicitly for repository-specific operations.
31
+ - Pass cwd explicitly for repository-specific operations. repoPaths accepts exact files, directories, or HTTPS repository URLs; remote URLs never fall back to uploading cwd. Use Standard for local changes and unconnected public repositories.
31
32
  - Tool calls are always non-interactive and never auto-open a browser.
32
33
  - Finding write tools use the same Apex device-login credentials as read tools. Never ask for browser cookies or CANTINA_AUTH_TOKEN. An error requiring CANTINA_AUTH_TOKEN proves the MCP server is outdated; update Apex CLI, re-run apex setup for this client, restart it, and retry.
33
34
  - Organization service-key creation, listing, and revocation are intentionally scripted-CLI-only and are not exposed as MCP tools. Never ask a user to paste a service key into a prompt or tool argument. A hosted MCP client supplies it only as the HTTP Authorization header.
@@ -42,6 +43,8 @@ const AUDIT_SETUP_MCP_GUIDE = `
42
43
 
43
44
  Audit Setup beta:
44
45
  - Use apex-scan-setup for a configured workspace, then apex-threat-model.
46
+ - Setup uses saved workspace repositories and context, not repoPaths or local file selections. Never substitute setup for an explicit source scan after AUDIT_SETUP_REQUIRED.
47
+ - Use the draft ID returned by setup for apex-threat-model; setup supports Standard and Audit, not Lite or PR.
45
48
  - Read the complete model and get explicit approval before approve with the reviewed sha256 and startScan true.
46
49
  - These tools use the local device account. Hosted service-key MCP does not expose Audit Setup.`;
47
50
  function mcpWorkflowGuide(auditSetupEnabled) {
@@ -540,7 +543,7 @@ function registerTools(server, auditSetupEnabled = false) {
540
543
  }, value => `Threat-model draft ${String(value.draftId)}: ${String(value.state)}.`, input));
541
544
  server.registerTool("apex-scan", {
542
545
  title: "Start Apex Scan",
543
- 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, mode lite for beta-gated provider-backed Lite scans, and mode pr for GitHub pull request scans.",
546
+ description: "Start a new Apex scan for the provided cwd by default. Pass repoPaths to select exact files, directories, or HTTPS GitHub/GitLab repository URLs. Remote URLs use the default branch; files never include siblings. Use mode audit for audit scans, mode lite for beta-gated provider-backed Lite scans, and mode pr for GitHub pull request scans.",
544
547
  inputSchema: {
545
548
  cwd: z.string().optional(),
546
549
  company: z.string().optional(),
@@ -753,6 +756,22 @@ function registerTools(server, auditSetupEnabled = false) {
753
756
  parentCommentId,
754
757
  scanId,
755
758
  }));
759
+ server.registerTool("apex-finding-status", {
760
+ title: "Change Apex Finding Status",
761
+ description: "Close a finding with fixed or reopen it with open. Changes client remediation only, preserving scanner validity, severity, feedback, and linked ticket status. Invalid or duplicate findings cannot be reopened until their classification is reviewed. Uses device login. Hosted MCP exposes the same tool with findingId and explicit findings:write. Optional expectedUpdatedAt prevents overwriting a newer edit.",
762
+ inputSchema: {
763
+ cwd: z.string().optional(),
764
+ findingRef: z.string(),
765
+ status: z.enum(FINDING_LIFECYCLE_STATUSES),
766
+ note: z.string().trim().max(1000).optional(),
767
+ expectedUpdatedAt: z.iso.datetime({ offset: true }).optional(),
768
+ scanId: z.string().optional(),
769
+ },
770
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
771
+ }, async ({ cwd, findingRef, status, note, expectedUpdatedAt, scanId }) => runTool("apex-finding-status", async () => {
772
+ const targetCwd = resolveCwd(cwd);
773
+ return { cwd: targetCwd, ...await commandFindingStatus(new ApexApiClient(), targetCwd, buildFlags({ scanId }), findingRef, status, note, expectedUpdatedAt) };
774
+ }, (value) => `Set finding ${String(value.findingRef)} to ${String(value.status)}.`, { cwd, findingRef, status, note, expectedUpdatedAt, scanId }));
756
775
  server.registerTool("apex-finding-feedback", {
757
776
  title: "Leave Apex Finding Feedback",
758
777
  description: "Leave valid or invalid feedback on an Apex finding using the current Apex login. To attach a fix PR, send status valid with label fixed and fixPrUrls.",
@@ -7,6 +7,8 @@ const execFileAsync = promisify(execFile);
7
7
  async function runGit(cwd, args) {
8
8
  const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
9
9
  encoding: "utf8",
10
+ maxBuffer: 64 * 1024 * 1024,
11
+ timeout: 30_000,
10
12
  });
11
13
  return stdout.trim();
12
14
  }
@@ -49,18 +51,39 @@ async function chooseRemote(directory) {
49
51
  return firstFetch.url;
50
52
  return remotes[0]?.url ?? null;
51
53
  }
52
- async function ensureDirectoryCandidate(cwd, directory) {
53
- const stats = await lstat(directory).catch(() => null);
54
- if (!stats) {
55
- throw new Error(`Path does not exist: ${path.relative(cwd, directory) || "."}`);
54
+ function inspectRemoteTarget(value) {
55
+ if (!/^(?:[a-z][a-z0-9+.-]*:\/\/|git@|(?:github|gitlab)\.com\/)/i.test(value))
56
+ return null;
57
+ const target = value.replace(/^(github|gitlab)\.com\//i, "https://$1.com/");
58
+ let url;
59
+ try {
60
+ url = new URL(target);
56
61
  }
57
- if (stats.isDirectory()) {
58
- return directory;
62
+ catch {
63
+ throw new Error("Remote scan targets must be HTTPS GitHub or GitLab repository URLs.");
59
64
  }
60
- if (stats.isFile()) {
61
- return path.dirname(directory);
65
+ const segments = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
66
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.port ||
67
+ !["github.com", "gitlab.com"].includes(url.hostname) || segments.length < 2 ||
68
+ (url.hostname === "github.com" && segments.length !== 2) || segments.includes("-")) {
69
+ throw new Error("Remote scan targets must be HTTPS GitHub or GitLab repository URLs without credentials, query strings, or file/tree paths.");
62
70
  }
63
- throw new Error(`Path is not a directory: ${path.relative(cwd, directory) || "."}`);
71
+ const metadata = extractCanonicalRepoMetadata(url.href);
72
+ if (!metadata)
73
+ throw new Error("Unsupported remote repository target.");
74
+ return {
75
+ kind: "git_candidate",
76
+ path: `remote/${metadata.provider}/${segments.join("/").replace(/\.git$/i, "")}`,
77
+ absolutePath: null,
78
+ displayName: metadata.repoName,
79
+ repoUrl: metadata.repoUrl,
80
+ provider: metadata.provider,
81
+ branch: null,
82
+ commitSha: null,
83
+ dirty: false,
84
+ isRoot: false,
85
+ sourceType: "branch",
86
+ };
64
87
  }
65
88
  function normalizeSelectedRoots(cwd, selectedPaths) {
66
89
  const resolved = selectedPaths
@@ -86,7 +109,7 @@ async function inspectGitRepository(rootCwd, directory) {
86
109
  const metadata = remoteUrl ? extractCanonicalRepoMetadata(remoteUrl) : null;
87
110
  const branchName = await runGit(directory, ["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => "HEAD");
88
111
  const commitSha = await runGit(directory, ["rev-parse", "HEAD"]).catch(() => null);
89
- const dirtyOutput = await runGit(directory, ["status", "--porcelain"]).catch(() => "");
112
+ const dirtyOutput = await runGit(directory, ["status", "--porcelain", "--", ".", ":(exclude).apex"]).catch(() => null);
90
113
  const relativePath = path.relative(rootCwd, directory) || ".";
91
114
  const detached = !branchName || branchName === "HEAD";
92
115
  return {
@@ -98,13 +121,31 @@ async function inspectGitRepository(rootCwd, directory) {
98
121
  provider: metadata?.provider ?? null,
99
122
  branch: detached ? null : branchName,
100
123
  commitSha: commitSha && commitSha.length > 0 ? commitSha : null,
101
- dirty: dirtyOutput.length > 0,
124
+ // Unknown working-tree state must use a snapshot, never assume the remote
125
+ // commit contains the user's current files.
126
+ dirty: dirtyOutput === null || dirtyOutput.length > 0,
102
127
  isRoot: relativePath === ".",
103
128
  sourceType: detached ? "commit" : "branch",
104
129
  };
105
130
  }
106
131
  async function inspectSource(rootCwd, requestedDirectory) {
107
- const directory = await ensureDirectoryCandidate(rootCwd, requestedDirectory);
132
+ const stats = await lstat(requestedDirectory).catch(() => null);
133
+ const requestedPath = path.relative(rootCwd, requestedDirectory) || ".";
134
+ if (!stats)
135
+ throw new Error(`Path does not exist: ${requestedPath}`);
136
+ if (stats.isFile()) {
137
+ return {
138
+ kind: "directory_candidate",
139
+ path: requestedPath,
140
+ absolutePath: path.dirname(requestedDirectory),
141
+ displayName: path.basename(requestedDirectory),
142
+ isRoot: false,
143
+ selectedFiles: [path.basename(requestedDirectory)],
144
+ };
145
+ }
146
+ if (!stats.isDirectory())
147
+ throw new Error(`Path is not a regular file or directory: ${requestedPath}`);
148
+ const directory = requestedDirectory;
108
149
  const relativePath = path.relative(rootCwd, directory) || ".";
109
150
  const gitTopLevel = await getGitTopLevel(directory);
110
151
  const comparableDirectory = await resolveComparablePath(directory);
@@ -120,10 +161,22 @@ async function inspectSource(rootCwd, requestedDirectory) {
120
161
  };
121
162
  }
122
163
  export async function discoverSources(cwd, selectedPaths = []) {
123
- const discoveredRoots = selectedPaths.length > 0
124
- ? normalizeSelectedRoots(cwd, selectedPaths)
125
- : [cwd];
126
- const sources = await Promise.all(discoveredRoots.map((root) => inspectSource(cwd, root)));
164
+ const remoteSources = [];
165
+ const localPaths = [];
166
+ for (const target of selectedPaths) {
167
+ const remote = inspectRemoteTarget(target);
168
+ if (remote) {
169
+ if (!remoteSources.some((source) => source.path === remote.path))
170
+ remoteSources.push(remote);
171
+ }
172
+ else
173
+ localPaths.push(target);
174
+ }
175
+ const discoveredRoots = selectedPaths.length === 0 ? [cwd] : normalizeSelectedRoots(cwd, localPaths);
176
+ const sources = [...remoteSources, ...await Promise.all(discoveredRoots.map((root) => inspectSource(cwd, root)))];
177
+ if (new Set(sources.map((source) => source.path)).size !== sources.length) {
178
+ throw new Error("Scan targets have conflicting source paths. Select them in separate scans.");
179
+ }
127
180
  sources.sort((left, right) => left.path.localeCompare(right.path));
128
181
  return { sources, scanned: discoveredRoots };
129
182
  }