@cantinasecurity/apex-cli 0.1.35 → 0.1.37

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.
@@ -120,3 +120,43 @@ If the Apex MCP server is not configured, fall back to the local CLI:
120
120
  - Use `apex-finding-tickets` to read existing links. CLI fallback: `apex findings link-ticket <finding-ref> <linear|jira> <issue-key|url> --json`, then `apex findings tickets <finding-ref> --json`.
121
121
  - Hosted MCP uses `findingId` (UUID) instead of `findingRef`, with no `cwd` or `scanId`. Linking requires explicit `findings:write`; listing requires `findings:read`. Default service-key scopes remain scans:create, scans:read, findings:read. Do not infer write permission from read access.
122
122
  - Apex verifies the ticket through the connected integration and records the frontend link; it does not create or modify a provider ticket. Repeating the same association is safe. A different existing link or in-progress export is a conflict: do not replace it or create another ticket as a fallback. For ambiguous Jira sites, use the full issue URL.
123
+
124
+ ### Workspace CVSS configuration
125
+
126
+ Use `apex workspace use <workspace>` to bind the intended existing workspace, then
127
+ `apex cvss get` to read its configuration and CVSS 3.1 policy template. To update it,
128
+ write a JSON file containing exactly `enabled` (boolean) and `policy` (the edited
129
+ template), then run `apex cvss set --file <configuration.json>`. Both commands emit
130
+ JSON and require an existing device-login account; they never initiate login,
131
+ create a workspace, or start a scan. Guest sessions are denied. `enabled: false`
132
+ disables CVSS scoring while preserving the supplied policy. This configures future
133
+ synthesis; it does not rescore historical findings.
134
+
135
+ CVSS administration is intentionally scripted CLI only for this release. No shell,
136
+ local MCP, hosted MCP, or organization service-key tool is added. Agents can invoke
137
+ these explicit terminal commands. Server deployment requires Bedrock CVSS API support for CLI bearer authentication.
138
+
139
+ Example (jq is used only to prepare the JSON file):
140
+
141
+ ```sh
142
+ apex login
143
+ apex workspace use "<workspace name or prefix>"
144
+ apex cvss get > cvss-current.json
145
+ jq '.configuration // {enabled: true, policy: .template}' cvss-current.json > cvss.json
146
+ # Edit cvss.json, then save and verify:
147
+ apex cvss set --file cvss.json
148
+ apex cvss get
149
+ ```
150
+
151
+ Keep `policy.version` at `3.1`. Supply a name, instructions, and all eight metrics
152
+ (AV, AC, PR, UI, S, C, I, A). Each metric has an `options` map with at least one
153
+ allowed value and nonempty guidance for each allowed value. Omit disallowed
154
+ values. If Audit Setup or another workspace update is active, wait for it to
155
+ finish before saving again.
156
+
157
+ Scan target and recovery rules:
158
+ - CLI `apex scan [target...]` and repeatable `--repo` accept exact files, directories, or HTTPS GitHub/GitLab repository URLs; local MCP uses `repoPaths` for the same selections. No target means `cwd`.
159
+ - A file selection scans only that file, respecting ignore rules. Local snapshots exclude `.apex/` state. Remote URLs use the default branch and cannot fall back to uploading `cwd`; clone locally to choose a branch or commit.
160
+ - Use Standard for local changes, files, plain directories, and public repositories without provider access. Lite and Audit still require connected provider sources; do not silently substitute another mode.
161
+ - Unauthenticated headless CLI commands fail with explicit login guidance; they never start device login. Use the MCP auth tools for explicit MCP login. Retry transient refresh failures without clearing credentials.
162
+ - Audit Setup is an explicit Standard/Audit beta flow using saved workspace repositories and context. It does not preserve local targets. Never automatically substitute setup after `AUDIT_SETUP_REQUIRED`; use the error's selected-workspace command only when that saved scope is intended. Draft operations require the ID returned by preparation; eligible accounts receive setup commands in CLI help. Lite and PR do not use `--setup`.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Cantina agent plugins for security review workflows.",
9
- "version": "0.1.35"
9
+ "version": "0.1.37"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -14,10 +14,10 @@
14
14
  "source": {
15
15
  "source": "npm",
16
16
  "package": "@cantinasecurity/apex-cli",
17
- "version": "0.1.35"
17
+ "version": "0.1.37"
18
18
  },
19
19
  "description": "Run Apex security scans and review findings from Claude Code.",
20
- "version": "0.1.35",
20
+ "version": "0.1.37",
21
21
  "author": {
22
22
  "name": "Cantina",
23
23
  "email": "support@cantina.xyz"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-cli",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "description": "Run Apex security scans and review findings from Claude Code.",
5
5
  "author": {
6
6
  "name": "Cantina",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-cli",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "description": "Run Apex security scans and review findings from Codex.",
5
5
  "author": {
6
6
  "name": "Cantina",
package/.mcp.claude.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "args": [
6
6
  "-y",
7
7
  "-p",
8
- "@cantinasecurity/apex-cli@0.1.35",
8
+ "@cantinasecurity/apex-cli@0.1.37",
9
9
  "apex-mcp"
10
10
  ],
11
11
  "env": {
package/.mcp.codex.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "args": [
5
5
  "-y",
6
6
  "-p",
7
- "@cantinasecurity/apex-cli@0.1.35",
7
+ "@cantinasecurity/apex-cli@0.1.37",
8
8
  "apex-mcp"
9
9
  ],
10
10
  "env": {
package/MARKETPLACE.md CHANGED
@@ -13,7 +13,7 @@ This package is prepared as both a Claude Code plugin and a Codex plugin. The pu
13
13
  The plugin MCP configs launch the pinned npm CLI package with:
14
14
 
15
15
  ```bash
16
- npx -y -p @cantinasecurity/apex-cli@0.1.35 apex-mcp
16
+ npx -y -p @cantinasecurity/apex-cli@0.1.37 apex-mcp
17
17
  ```
18
18
 
19
19
  That keeps marketplace installs independent of a user's global `apex` install.
package/README.md CHANGED
@@ -158,30 +158,30 @@ unclaimed local trial proof so `apex claim` can still use it.
158
158
  Agent-safe copy/paste prompt (substitute the issued code):
159
159
 
160
160
  ```text
161
- Install and run Cantina Apex CLI 0.1.35 to start one free standard security scan of this repository immediately.
161
+ Install and run Cantina Apex CLI 0.1.37 to start one free standard security scan of this repository immediately.
162
162
 
163
163
  Use this trial code only where <TRIAL_CODE> appears below: SPBT-DEVREL-XXXXXXXX
164
164
 
165
165
  Run the commands yourself from the repository root; do not stop after setup or only explain the steps. Use terminal CLI commands only. Do not use an Apex MCP tool or run `apex setup`, `apex mcp`, `apex login`, `apex-auth-start`, or `apex-auth-wait`. Never use `--replace` or `--force`.
166
166
 
167
167
  Use this exact command prefix for every Apex command:
168
- npx -y -p @cantinasecurity/apex-cli@0.1.35 apex
168
+ npx -y -p @cantinasecurity/apex-cli@0.1.37 apex
169
169
 
170
170
  1. Before redeeming, confirm every command will use the same persistent, user-accessible machine or container, HOME, filesystem, and repository root. Confirm Node.js 20+ and npx are available. If `APEX_BASE_URL` is set, continue only when its origin is `https://ai.cantina.xyz`. Run the exact prefix with `--help` and continue only if help lists both `redeem` and `claim`. If any check fails, STOP before sending the code; do not install system packages.
171
171
  2. Redeem, with at most one response-loss recovery retry:
172
- npx -y -p @cantinasecurity/apex-cli@0.1.35 apex redeem <TRIAL_CODE> --json
172
+ npx -y -p @cantinasecurity/apex-cli@0.1.37 apex redeem <TRIAL_CODE> --json
173
173
  This terminal command is the only allowed tool call containing the code. Never echo or restate the code, store it in a file or environment variable, put it in a URL or MCP call, or include it in your response. Parse the JSON from stdout only; do not merge stderr into stdout, because npm may write warnings to stderr. Continue when the command exits 0 and stdout is JSON with `redeemed: true` and `outcome` equal to `redeemed` or `idempotent`. `idempotent` means the existing guest session resumed and it may already own a workspace. If and only if the first attempt ends with an ambiguous transport/connection failure or an interrupted/missing response and no typed server error code, retry this exact same command once on the same machine, HOME, and repository root. Never retry a typed server rejection and never make more than one recovery retry. If the allowed retry does not return a successful JSON outcome, STOP.
174
174
  3. Immediately start exactly one scan; do not wait for another instruction:
175
- npx -y -p @cantinasecurity/apex-cli@0.1.35 apex scan --mode standard --non-interactive --no-open --json
175
+ npx -y -p @cantinasecurity/apex-cli@0.1.37 apex scan --mode standard --non-interactive --no-open --json
176
176
  The CLI validates the complete guest local archive before it reserves a workspace. An unbound renamed checkout may recover only the sole empty guest workspace when no explicit workspace name was supplied. On any error or nonzero exit, STOP. After a successful scan JSON response, inspect `localPersistence.workspaceBindingSaved`. If it is `false`, the server scan already started: capture and report `binding.lastScanId`, then STOP; do not run status or findings until local repository permissions are repaired. Otherwise capture `binding.lastScanId` from the JSON as <SCAN_ID>; if it is absent, STOP.
177
177
  4. About every 60 seconds, poll that exact scan:
178
- npx -y -p @cantinasecurity/apex-cli@0.1.35 apex status --scan <SCAN_ID> --json
178
+ npx -y -p @cantinasecurity/apex-cli@0.1.37 apex status --scan <SCAN_ID> --json
179
179
  Read `progress.progress.status`. Wait while it is `created`, `pending`, `queued`, `starting`, `running`, `in_progress`, or `processing`. Continue on `completed`. STOP on `failed`, `cancelled`, a missing or unknown status, or a command failure.
180
180
  5. Fetch that exact scan's findings:
181
- npx -y -p @cantinasecurity/apex-cli@0.1.35 apex findings --scan <SCAN_ID> --json
181
+ npx -y -p @cantinasecurity/apex-cli@0.1.37 apex findings --scan <SCAN_ID> --json
182
182
  On success, summarize findings by severity, most severe first, including identifier, title, status, and confidence. This command does not return file locations, so do not invent them. On failure, STOP.
183
183
  6. Do not claim automatically. Tell me the claim deadline from step 2 and remind me to run this exact command myself from the same repository root, machine, and HOME:
184
- npx -y -p @cantinasecurity/apex-cli@0.1.35 apex claim
184
+ npx -y -p @cantinasecurity/apex-cli@0.1.37 apex claim
185
185
  It may open a browser so I can create or sign in to a Cantina account and keep the results. Claim moves the trial into the currently selected Cantina workspace when I can run scans there, or into my existing personal workspace. If the selected workspace cannot run scans, tell me to switch to one that can and retry before the claim deadline.
186
186
 
187
187
  Never paste raw command transcripts. For any failure, report only the step, exit code, and sanitized error. Redact the trial code and every `apex_at_`, `apex_rt_`, or `apex_ct_` value from all reports.
@@ -266,6 +266,8 @@ Supported shell commands:
266
266
  - `apex workspaces`
267
267
  - `apex workspace`
268
268
  - `apex workspace use <workspace-name|workspace-prefix|workspace-id>`
269
+ - `apex cvss get`
270
+ - `apex cvss set --file <configuration.json>`
269
271
  - `apex cancel-scan [scan-id]`
270
272
  - `apex status [--scan <scan-id>]`
271
273
  - `apex doctor`
@@ -464,7 +466,18 @@ Local MCP exposes `apex-finding-link-ticket` with `findingRef`, `provider`, `iss
464
466
 
465
467
  ## Local Source Scans
466
468
 
467
- `apex scan` now works against any local source root you point it at. By default, that source root is the current working directory:
469
+ `apex scan [target...]` accepts files, directories, and HTTPS GitHub/GitLab repository URLs. With no targets, it scans the current working directory:
470
+
471
+ ```bash
472
+ apex scan . --mode standard
473
+ apex scan ./contracts --mode standard
474
+ apex scan ./contracts/UniswapV2ERC20.sol --mode standard
475
+ apex scan https://github.com/Uniswap/v2-core --mode standard
476
+ ```
477
+
478
+ A file target uploads only that file, never its siblings. Local snapshots include current local changes and honor Git ignore rules and `.apexignore`; Apex state under `.apex/` is excluded. Explicit remote URLs use the default branch and never fall back to uploading the current checkout. Use a local clone to scan a particular remote branch or commit.
479
+
480
+ Source planning:
468
481
 
469
482
  - clean GitHub or GitLab checkouts can stay on the remote-materialization path
470
483
  - dirty git worktrees fall back to a local snapshot upload by default
@@ -472,18 +485,22 @@ Local MCP exposes `apex-finding-link-ticket` with `findingRef`, `provider`, `iss
472
485
 
473
486
  Useful flags:
474
487
 
475
- - `--repo <path>` to scan one or more explicit local roots instead of the current directory
488
+ - `--repo <path-or-url>` as a repeatable alternative to positional targets
476
489
  - `--source-mode auto|remote|local` to control remote-first fallback behavior
477
490
  - `--mode standard|audit|lite|pr` to choose the scan mode
478
491
  - `--pr <number>` to select one or more GitHub pull requests for `--mode pr`
479
492
  - `--pr <number:path,path>` or `--pr-path <path>` to limit a PR scan to changed paths
480
493
 
494
+ Unauthenticated `--json`, `--non-interactive`, and non-TTY commands fail immediately with `apex login` guidance. Run login explicitly before automation. Temporary refresh/network failures preserve credentials; retry after the service recovers. Provider connection commands return URLs without opening browsers in automation.
495
+
481
496
  `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.
482
497
 
483
498
  Audit scans use `audit` as the scan mode and still require provider-backed GitHub or GitLab repositories that Apex can materialize remotely without a local snapshot fallback. `ultra` remains accepted as a backwards-compatible alias.
484
499
 
485
500
  Lite scans use `lite` as the scan mode and require the Lite Scan beta feature plus provider-backed GitHub or GitLab repositories that Apex can materialize remotely. Local snapshot uploads are not supported for Lite scans.
486
501
 
502
+ If a backend returns `AUDIT_SETUP_REQUIRED`, the error includes a setup command for the selected workspace. This explicit beta workflow uses the workspace's saved repositories and context; it does not preserve positional file or directory selections. Do not automatically substitute it for a local target scan. An explicit Standard source scan requires current CLI and backend versions. Eligible accounts receive setup commands in CLI help; draft operations use the ID returned by preparation. Lite and PR scans do not use `--setup`.
503
+
487
504
  PR scans require exactly one provider-backed GitHub repository. If the current directory resolves to multiple sources, pass `--repo <path>` to select the one that contains the pull request.
488
505
 
489
506
  CLI and MCP scan results use the public Bedrock `scanId`. Kernel execution IDs are internal implementation details and are neither returned nor accepted as scan aliases.
@@ -667,7 +684,7 @@ Telemetry event posts do not include bearer tokens. The telemetry endpoint is `P
667
684
  {
668
685
  "schemaVersion": 1,
669
686
  "event": "apex.invocation.completed",
670
- "cliVersion": "0.1.35",
687
+ "cliVersion": "0.1.37",
671
688
  "invocation": {
672
689
  "surface": "mcp",
673
690
  "command": "apex-scan",
@@ -698,7 +715,7 @@ The npm package also includes marketplace-ready plugin artifacts:
698
715
  - `.claude-plugin/plugin.json` and `.mcp.claude.json` for Claude Code plugin installs
699
716
  - `.claude-plugin/marketplace.json` for a Claude marketplace entry backed by the public npm package
700
717
 
701
- These plugin installs launch the pinned npm package with `npx -y -p @cantinasecurity/apex-cli@0.1.35 apex-mcp`, so users do not need to install `apex` globally before enabling the plugin.
718
+ These plugin installs launch the pinned npm package with `npx -y -p @cantinasecurity/apex-cli@0.1.37 apex-mcp`, so users do not need to install `apex` globally before enabling the plugin.
702
719
 
703
720
  The repository also includes `.agents/plugins/marketplace.json` for local Codex marketplace testing from a checkout.
704
721
 
@@ -735,3 +752,36 @@ To move between existing Apex workspaces from the CLI:
735
752
  3. If the workspace name contains spaces, quote it, for example `apex workspace use "Core Platform"`.
736
753
  4. Use `apex scans`, `apex status --scan <scan-id>`, `apex findings`, and `apex export findings` against that binding.
737
754
  5. Use `apex findings --workspace --company <handle> --workspace-ref <ref> --finding-state open` for the workspace browser's cross-scan findings that still need review, without changing the local binding.
755
+
756
+ ### Workspace CVSS configuration
757
+
758
+ Use `apex workspace use <workspace>` to bind the intended existing workspace, then
759
+ `apex cvss get` to read its configuration and CVSS 3.1 policy template. To update it,
760
+ write a JSON file containing exactly `enabled` (boolean) and `policy` (the edited
761
+ template), then run `apex cvss set --file <configuration.json>`. Both commands emit
762
+ JSON and require an existing device-login account; they never initiate login,
763
+ create a workspace, or start a scan. Guest sessions are denied. `enabled: false`
764
+ disables CVSS scoring while preserving the supplied policy. This configures future
765
+ synthesis; it does not rescore historical findings.
766
+
767
+ CVSS administration is intentionally scripted CLI only for this release. No shell,
768
+ local MCP, hosted MCP, or organization service-key tool is added. Agents can invoke
769
+ these explicit terminal commands. Server deployment requires Bedrock CVSS API support for CLI bearer authentication.
770
+
771
+ Example (jq is used only to prepare the JSON file):
772
+
773
+ ```sh
774
+ apex login
775
+ apex workspace use "<workspace name or prefix>"
776
+ apex cvss get > cvss-current.json
777
+ jq '.configuration // {enabled: true, policy: .template}' cvss-current.json > cvss.json
778
+ # Edit cvss.json, then save and verify:
779
+ apex cvss set --file cvss.json
780
+ apex cvss get
781
+ ```
782
+
783
+ Keep `policy.version` at `3.1`. Supply a name, instructions, and all eight metrics
784
+ (AV, AC, PR, UI, S, C, I, A). Each metric has an `options` map with at least one
785
+ allowed value and nonempty guidance for each allowed value. Omit disallowed
786
+ values. If Audit Setup or another workspace update is active, wait for it to
787
+ finish before saving again.
package/dist/apex.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { commandCvss } from "./cvss.js";
1
2
  import { commandStartThreatModel, commandThreatModel, hasAuditSetupBeta, threatModelExitCode, terminalText } from "./threat-model.js";
2
3
  import { getMe } from "./auth.js";
3
4
  import { ApexApiClient, formatApiError } from "./api-client.js";
@@ -94,6 +95,11 @@ async function dispatch(parsed) {
94
95
  case "workspaces":
95
96
  await commandWorkspaces(client, cwd, parsed.flags);
96
97
  return;
98
+ case "cvss":
99
+ if (parsed.args.length)
100
+ throw new Error("Unexpected CVSS arguments");
101
+ await commandCvss(client, cwd, parsed.flags, parsed.subcommand);
102
+ return;
97
103
  case "workspace":
98
104
  if (parsed.subcommand === "use") {
99
105
  const workspaceRef = parsed.args.join(" ");
@@ -1,9 +1,16 @@
1
+ import { z } from "zod";
1
2
  import { bindCredentialIssuerOriginIfCurrent, clearCredentials, clearCredentialsIfRefreshTokenMatches, loadConfig, loadCredentials, saveRefreshedCredentialsIfCurrent, } from "./config.js";
2
3
  import { getTelemetryRequestHeaders } from "./telemetry.js";
3
4
  import { redactSensitiveText, redactSensitiveValue, } from "./redaction.js";
4
5
  export { redactSensitiveText } from "./redaction.js";
5
6
  const CREDENTIAL_GENERATION_CHANGED_MESSAGE = "Apex credentials changed while this request was refreshing. The newer credentials were kept; retry the command.";
6
7
  const HOSTED_APEX_ORIGIN = "https://ai.cantina.xyz";
8
+ const REFRESH_CREDENTIALS_SCHEMA = z.object({
9
+ accessToken: z.string().trim().min(1),
10
+ refreshToken: z.string().trim().min(1),
11
+ accessTokenExpiresAt: z.string().datetime({ offset: true }),
12
+ refreshTokenExpiresAt: z.string().datetime({ offset: true }),
13
+ });
7
14
  const CREDENTIAL_ORIGIN_MISMATCH_MESSAGE = "Refusing to send stored Apex credentials because their issuer origin does not match the configured APEX_BASE_URL. Restore the origin used to sign in, or run `apex logout` before signing in to this origin.";
8
15
  export class CredentialGenerationChangedError extends Error {
9
16
  constructor() {
@@ -211,7 +218,11 @@ export class ApexApiClient {
211
218
  });
212
219
  }
213
220
  async clearCredentialsAfterUnauthorized(expectedRefreshToken, error) {
214
- if (!expectedRefreshToken) {
221
+ // Transport errors, rate limits and server failures do not invalidate a
222
+ // token pair. Keep it recoverable for the next command.
223
+ const credentialsInvalid = (error instanceof ApiError && error.status === 401) ||
224
+ isTerminalGuestSessionError(error);
225
+ if (!expectedRefreshToken || !credentialsInvalid) {
215
226
  return;
216
227
  }
217
228
  await this.clearCredentialsForGeneration(expectedRefreshToken, error);
@@ -244,20 +255,19 @@ export class ApexApiClient {
244
255
  const effectiveRefreshToken = refreshToken ?? existing?.refreshToken ?? null;
245
256
  if (!effectiveRefreshToken)
246
257
  return null;
247
- const payload = (await this.request("/api/cli/v1/auth/refresh", {
258
+ const payload = await this.request("/api/cli/v1/auth/refresh", {
248
259
  method: "POST",
249
260
  auth: false,
250
261
  json: { refreshToken: effectiveRefreshToken },
251
262
  retryOnUnauthorized: false,
252
263
  telemetryAttribution: false,
253
264
  requestBaseUrl: baseUrl,
254
- }));
255
- const nextCredentials = {
256
- accessToken: payload.accessToken,
257
- refreshToken: payload.refreshToken,
258
- accessTokenExpiresAt: payload.accessTokenExpiresAt,
259
- refreshTokenExpiresAt: payload.refreshTokenExpiresAt,
260
- };
265
+ });
266
+ const parsed = REFRESH_CREDENTIALS_SCHEMA.safeParse(payload);
267
+ if (!parsed.success) {
268
+ throw new Error("Apex returned an invalid credential refresh response. Your credentials were kept; retry the command.");
269
+ }
270
+ const nextCredentials = parsed.data;
261
271
  const persisted = await saveRefreshedCredentialsIfCurrent(effectiveRefreshToken, nextCredentials);
262
272
  if (!persisted.saved) {
263
273
  throw new CredentialGenerationChangedError();
package/dist/args.js CHANGED
@@ -94,6 +94,15 @@ export function parseArgs(argv) {
94
94
  }
95
95
  index += 1;
96
96
  }
97
+ // Scan operands are source targets, never subcommands. Keep them on the
98
+ // same typed path as --repo so the CLI and MCP share source selection.
99
+ if (positional[0] === "scan" && positional.length > 1) {
100
+ if (flags.setup !== undefined) {
101
+ throw new Error("Audit Setup does not accept scan targets. Use --workspace-ref <workspace>.");
102
+ }
103
+ flags.repo = [...(flags.repo ?? []), ...positional.slice(1)];
104
+ positional.splice(1);
105
+ }
97
106
  return {
98
107
  command: positional[0] ?? null,
99
108
  subcommand: positional[1] ?? null,
@@ -102,6 +111,9 @@ export function parseArgs(argv) {
102
111
  };
103
112
  }
104
113
  export function validateCommandFlags(parsed) {
114
+ if (parsed.flags["source-mode"] !== undefined && !["auto", "remote", "local"].includes(String(parsed.flags["source-mode"]))) {
115
+ throw new Error("--source-mode must be auto, remote, or local.");
116
+ }
105
117
  if (parsed.flags["workspace-ref"] === undefined) {
106
118
  return;
107
119
  }
package/dist/commands.js CHANGED
@@ -332,6 +332,8 @@ function formatLiteScanBalance(scanBalance) {
332
332
  return `Lite scan credits: ${detailParts.join(", ")}`;
333
333
  }
334
334
  function normalizeRequestedScanMode(value) {
335
+ if (value === null || value === "standard")
336
+ return "standard";
335
337
  if (value === "ultra" || value === "audit") {
336
338
  return "audit";
337
339
  }
@@ -341,7 +343,7 @@ function normalizeRequestedScanMode(value) {
341
343
  if (value === "lite") {
342
344
  return "lite";
343
345
  }
344
- return "standard";
346
+ throw new Error("--mode must be standard, audit, ultra, lite, or pr.");
345
347
  }
346
348
  function getFlagValues(flags, key) {
347
349
  const values = getFlagList(flags, key);
@@ -1074,7 +1076,7 @@ export async function commandConnect(client, cwd, flags, provider) {
1074
1076
  if (!isJsonMode(flags)) {
1075
1077
  logLine(`Connection URL for ${provider}: ${response.url}`, flags);
1076
1078
  }
1077
- if (flags["no-open"] !== true) {
1079
+ if (flags["no-open"] !== true && canPromptInteractively(flags)) {
1078
1080
  await openInBrowser(response.url);
1079
1081
  }
1080
1082
  if (isJsonMode(flags)) {
@@ -1097,7 +1099,23 @@ export async function commandScan(client, cwd, flags) {
1097
1099
  const me = await ensureAuthenticated(client, flags);
1098
1100
  const authState = await getCommandAuthState(client, me);
1099
1101
  setTelemetryAuthKind(authState.authKind);
1102
+ if (authState.authKind === "guest" && requestedMode !== "standard") {
1103
+ throw new Error("Guest trials support standard scans only. Use --mode standard, or run `apex claim` before choosing another mode.");
1104
+ }
1105
+ const guestLocalSourceGuidance = "Guest trials require local snapshot uploads. Clone remote repositories locally, then scan a local path without --source-mode remote.";
1106
+ if (authState.authKind === "guest" && getFlagString(flags, "source-mode") === "remote") {
1107
+ throw new Error(guestLocalSourceGuidance);
1108
+ }
1100
1109
  let selection = await selectWorkspaceTarget(cwd, me, flags);
1110
+ if (authState.authKind === "guest" && selection.sources.some((source) => source.absolutePath === null)) {
1111
+ throw new Error(guestLocalSourceGuidance);
1112
+ }
1113
+ const requiresLocalSnapshot = selection.sourceMode === "local" ||
1114
+ selection.sources.some((source) => source.kind !== "git_candidate" ||
1115
+ (selection.sourceMode !== "remote" && source.dirty));
1116
+ if ((requestedMode === "audit" || requestedMode === "lite") && requiresLocalSnapshot) {
1117
+ throw new Error(`${requestedMode === "audit" ? "Audit" : "Lite"} scans require provider-backed repositories without local changes. Use --mode standard for files, directories, or local changes.`);
1118
+ }
1101
1119
  let preparedArchives = null;
1102
1120
  if (authState.authKind === "guest" && requestedMode === "standard") {
1103
1121
  preparedArchives = await withLoadingIndicator("Preparing local source archive...", flags, () => prepareLocalSourceArchives({
@@ -1139,6 +1157,21 @@ export async function commandScan(client, cwd, flags) {
1139
1157
  let workspaceId = resolvedWorkspaceId;
1140
1158
  let scan;
1141
1159
  let prScanSource = null;
1160
+ const requestScanStart = async (endpoint, json) => {
1161
+ try {
1162
+ return normalizeScanStartResponse(await client.request(endpoint, { method: "POST", json }));
1163
+ }
1164
+ catch (error) {
1165
+ if (error instanceof ApiError && getApiErrorCode(error) === "AUDIT_SETUP_REQUIRED") {
1166
+ const quote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
1167
+ const mode = requestedMode === "audit" ? "audit" : "standard";
1168
+ const command = `apex scan --company ${quote(result.company.id)} --workspace-ref ${quote(workspaceId)} --mode ${mode} --setup guided`;
1169
+ 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.`;
1170
+ throw new ApiError(message, error.status, { code: "AUDIT_SETUP_REQUIRED", error: message });
1171
+ }
1172
+ throw error;
1173
+ }
1174
+ };
1142
1175
  if (requestedMode === "pr") {
1143
1176
  let legacyResolve = await withLoadingIndicator("Preparing PR scan...", flags, () => resolveWorkspaceSelectionLegacy(client, selection, resolvedWorkspaceId));
1144
1177
  if (!legacyResolve.workspaceId) {
@@ -1161,40 +1194,30 @@ export async function commandScan(client, cwd, flags) {
1161
1194
  const prSource = resolvePrScanSource(legacyResolve.repositories);
1162
1195
  ensurePrSelectionsMatchSource(prSelections, prSource);
1163
1196
  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));
1197
+ scan = await withLoadingIndicator("Starting PR scan...", flags, () => requestScanStart(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
1198
+ mode: "pr",
1199
+ scanType: "pr",
1200
+ force: forceRestart || flags.force === true,
1201
+ repositories: buildPrScanRepositoriesPayload({
1202
+ source: prSource,
1203
+ pullRequests: prSelections,
1204
+ }),
1205
+ }));
1178
1206
  }
1179
1207
  else if (requestedMode === "audit" || requestedMode === "lite") {
1180
1208
  const modeLabel = requestedMode === "audit" ? "Audit" : "Lite";
1181
1209
  if (!supportsLegacyRemoteFlow(result.resolve.plannedSources)) {
1182
- throw new Error(`${modeLabel} scans currently require provider-backed GitHub or GitLab repositories without local snapshot fallbacks.`);
1210
+ 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
1211
  }
1184
1212
  const legacyResolve = await withLoadingIndicator(`Preparing ${modeLabel.toLowerCase()} scan...`, flags, () => resolveWorkspaceSelectionLegacy(client, selection, resolvedWorkspaceId));
1185
1213
  if (!legacyResolve.workspaceId) {
1186
1214
  throw new Error("Workspace resolution did not return a workspaceId.");
1187
1215
  }
1188
1216
  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));
1217
+ scan = await withLoadingIndicator(`Starting ${modeLabel.toLowerCase()} scan...`, flags, () => requestScanStart(`/api/cli/v1/local-workspaces/${encodeURIComponent(workspaceId)}/scan`, {
1218
+ mode: requestedMode,
1219
+ force: forceRestart || flags.force === true,
1220
+ }));
1198
1221
  }
1199
1222
  else {
1200
1223
  try {
@@ -1206,15 +1229,11 @@ export async function commandScan(client, cwd, flags) {
1206
1229
  detectedSources: result.sources,
1207
1230
  preparedArchives,
1208
1231
  });
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
- },
1232
+ return requestScanStart(`/api/cli/v2/local-workspaces/${encodeURIComponent(resolvedWorkspaceId)}/scan`, {
1233
+ mode: requestedMode,
1234
+ force: forceRestart || flags.force === true,
1235
+ scanSources,
1216
1236
  });
1217
- return normalizeScanStartResponse(response);
1218
1237
  });
1219
1238
  }
1220
1239
  finally {
package/dist/cvss.js ADDED
@@ -0,0 +1,49 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { getMe } from "./auth.js";
5
+ import { getFlagString } from "./args.js";
6
+ import { printJson } from "./output.js";
7
+ import { loadWorkspaceBinding } from "./workspace-binding.js";
8
+ const inputSchema = z
9
+ .object({
10
+ enabled: z.boolean(),
11
+ policy: z.record(z.string(), z.unknown()),
12
+ })
13
+ .strict();
14
+ export async function commandCvss(client, cwd, flags, action) {
15
+ const file = getFlagString(flags, "file");
16
+ if (!["get", "set"].includes(action ?? "") ||
17
+ (action === "set" ? !file : file))
18
+ throw new Error("Usage: apex cvss get | apex cvss set --file <configuration.json>");
19
+ if (flags.company || flags["workspace-name"] || flags["workspace-ref"])
20
+ throw new Error("CVSS uses the bound workspace. Run `apex workspace use <workspace>` first.");
21
+ const binding = await loadWorkspaceBinding(cwd);
22
+ if (!binding)
23
+ throw new Error("Run `apex workspace use <workspace>` before configuring CVSS.");
24
+ let input;
25
+ if (action === "set") {
26
+ const raw = await readFile(path.resolve(cwd, file), "utf8");
27
+ if (Buffer.byteLength(raw) > 128 * 1024)
28
+ throw new Error("CVSS configuration is too large.");
29
+ input = inputSchema.parse(JSON.parse(raw));
30
+ }
31
+ const me = await getMe(client);
32
+ if (!me)
33
+ throw new Error("Run `apex login` before configuring CVSS.");
34
+ if ((await client.getCredentials())?.guest ||
35
+ me.user.role?.trim().toLowerCase() === "guest")
36
+ throw new Error("CVSS configuration requires a signed-in account.");
37
+ const response = await client.request(`/api/workspaces/${encodeURIComponent(binding.workspaceId)}/cvss`, action === "set" ? { method: "PUT", json: input } : {});
38
+ const payload = {
39
+ configuration: response.configuration
40
+ ? {
41
+ enabled: response.configuration.enabled,
42
+ policy: response.configuration.policy,
43
+ }
44
+ : null,
45
+ template: response.template,
46
+ };
47
+ printJson(payload);
48
+ return payload;
49
+ }
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
@@ -18,6 +18,8 @@ export const CLI_HELP_TEXT = `Usage:
18
18
  Start a fix review scan for a Fix PR or current repository
19
19
  apex export findings Export findings for the latest or selected scan
20
20
  apex workspaces List accessible workspaces for the active company
21
+ apex cvss get Read workspace CVSS configuration and template (JSON)
22
+ apex cvss set --file <json> Update the bound workspace CVSS 3.1 rubric
21
23
  apex workspace Show the workspace currently bound to this directory
22
24
  apex workspace use <workspace-name|workspace-prefix|workspace-id>
23
25
  Bind this directory to an existing Apex workspace
@@ -73,7 +75,7 @@ Flags:
73
75
  --source-mode auto|remote|local Control remote-vs-local source materialization
74
76
  --force Start an overlapping non-PR scan, or confirm destructive guest logout
75
77
  --replace Revoke and replace an existing guest trial session
76
- --repo <path> Include one or more explicit local source roots
78
+ --repo <path-or-url> Select a file, directory, or HTTPS repository (repeatable)
77
79
  --json Print machine-readable JSON output
78
80
  --no-open Do not open browser flows automatically
79
81
  --non-interactive Disable prompts and browser-dependent UX
@@ -81,7 +83,9 @@ Flags:
81
83
 
82
84
  Tips:
83
85
  apex scan uses the current directory name as the default workspace name unless you pass --workspace-name.
84
- apex scan uses the current directory as the default source root unless you pass --repo.
86
+ apex scan defaults to the current directory; positional targets or --repo select files, directories, or HTTPS GitHub/GitLab repositories.
87
+ A file target scans only that file. Use --mode standard for local changes and public repositories without a provider connection.
88
+ Headless and --json commands require an existing session; run apex login explicitly first.
85
89
  PR scans require --mode pr and at least one --pr selection for a GitHub repository.
86
90
  audit is the current scan mode for audit scans; ultra remains accepted as a legacy alias.
87
91
  Lite scans use --mode lite and require beta access plus provider-backed GitHub or GitLab sources.
@@ -130,6 +134,9 @@ Audit Setup beta:
130
134
  --no-feedback-context Exclude prior feedback from this setup
131
135
  --wait Wait for the next actionable setup state
132
136
  --timeout <seconds> Bound next --wait (default 60, maximum 3600)
137
+ Setup uses saved workspace repositories and context, not local target selections.
138
+ Use the draft ID returned by setup: apex threat-model status --draft <uuid>.
139
+ Lite and PR scans do not use --setup.
133
140
  `;
134
141
  export function cliHelpText(auditSetupEnabled) {
135
142
  return auditSetupEnabled
@@ -140,7 +147,7 @@ export const SHELL_HELP_TEXT = `Press Tab to autocomplete commands and common ar
140
147
 
141
148
  Commands:
142
149
  /credits Show standard, audit, fix review, and Lite scan entitlements for the active company
143
- /scan [standard|audit|lite]
150
+ /scan [standard|audit|lite] [target...]
144
151
  Start a new Apex scan for this workspace
145
152
  /scan pr <pr-number> Start a PR scan for this workspace
146
153
  /scans List scans for this workspace
@@ -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
@@ -27,7 +27,7 @@ Suggested workflow:
27
27
  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
28
 
29
29
  Notes:
30
- - Pass cwd explicitly for repository-specific operations.
30
+ - 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
31
  - Tool calls are always non-interactive and never auto-open a browser.
32
32
  - 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
33
  - 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 +42,8 @@ const AUDIT_SETUP_MCP_GUIDE = `
42
42
 
43
43
  Audit Setup beta:
44
44
  - Use apex-scan-setup for a configured workspace, then apex-threat-model.
45
+ - 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.
46
+ - Use the draft ID returned by setup for apex-threat-model; setup supports Standard and Audit, not Lite or PR.
45
47
  - Read the complete model and get explicit approval before approve with the reviewed sha256 and startScan true.
46
48
  - These tools use the local device account. Hosted service-key MCP does not expose Audit Setup.`;
47
49
  function mcpWorkflowGuide(auditSetupEnabled) {
@@ -540,7 +542,7 @@ function registerTools(server, auditSetupEnabled = false) {
540
542
  }, value => `Threat-model draft ${String(value.draftId)}: ${String(value.state)}.`, input));
541
543
  server.registerTool("apex-scan", {
542
544
  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.",
545
+ 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
546
  inputSchema: {
545
547
  cwd: z.string().optional(),
546
548
  company: z.string().optional(),
@@ -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
  }
package/dist/session.js CHANGED
@@ -98,9 +98,16 @@ function selectBindingForWorkspaceName(flags, binding, workspaceName) {
98
98
  }
99
99
  function getSourceMode(flags) {
100
100
  const value = getFlagString(flags, "source-mode");
101
- return value === "remote" || value === "local" ? value : "auto";
101
+ if (value === null || value === "auto")
102
+ return "auto";
103
+ if (value === "remote" || value === "local")
104
+ return value;
105
+ throw new Error("--source-mode must be auto, remote, or local.");
102
106
  }
103
107
  function buildFallbackLocalArchivePlan(source, sourceMode) {
108
+ if (!source.absolutePath) {
109
+ throw new Error("A remote repository URL cannot use a local snapshot. Connect its provider or clone it locally, then retry.");
110
+ }
104
111
  const reason = sourceMode === "local"
105
112
  ? "user_requested_local_snapshot"
106
113
  : source.kind === "directory_candidate"
@@ -136,6 +143,11 @@ function buildFallbackLocalArchivePlan(source, sourceMode) {
136
143
  }
137
144
  function ensurePlannedSources(selection, resolve) {
138
145
  if (resolve.plannedSources.length > 0) {
146
+ for (const source of selection.sources) {
147
+ if (!source.absolutePath && !resolve.plannedSources.some((plan) => plan.path === source.path && plan.sourceKind === "remote_repo")) {
148
+ throw new Error("The remote repository is not accessible. Connect its provider or clone it locally, then retry.");
149
+ }
150
+ }
139
151
  return resolve;
140
152
  }
141
153
  if (selection.sourceMode === "remote") {
@@ -172,6 +184,9 @@ export async function ensureAuthenticated(client, flags) {
172
184
  if (isClaimProofAvailable(credentials)) {
173
185
  throw new Error("Your guest trial session is no longer available, but its claim proof is stored on this machine. Run `apex claim` to keep the trial results.");
174
186
  }
187
+ if (!canPromptInteractively(flags)) {
188
+ throw new Error("Apex is not authenticated. Run `apex login` explicitly, then retry this command.");
189
+ }
175
190
  return login(client, {
176
191
  noOpen: flags["no-open"] === true,
177
192
  quiet: isJsonMode(flags),
@@ -182,16 +197,21 @@ export async function resolveWorkspace(client, cwd, me, flags) {
182
197
  return resolveWorkspaceSelection(client, selection);
183
198
  }
184
199
  export async function selectWorkspaceTarget(cwd, me, flags) {
200
+ const sourceMode = getSourceMode(flags);
185
201
  const loadedBinding = await loadWorkspaceBinding(cwd);
186
202
  const company = await chooseCompany(me, flags, loadedBinding);
187
- const workspaceNameSelection = await chooseWorkspaceName(cwd, flags, loadedBinding);
203
+ const companyBinding = loadedBinding?.companyId === company.id ? loadedBinding : null;
204
+ const workspaceNameSelection = await chooseWorkspaceName(cwd, flags, companyBinding);
188
205
  const workspaceName = workspaceNameSelection.workspaceName;
189
- const binding = selectBindingForWorkspaceName(flags, loadedBinding, workspaceName);
206
+ const binding = selectBindingForWorkspaceName(flags, companyBinding, workspaceName);
190
207
  const sourceSelection = getFlagList(flags, "repo");
191
208
  const discovered = await discoverSources(cwd, sourceSelection);
192
209
  if (discovered.sources.length === 0) {
193
210
  throw new Error("No local sources were found.");
194
211
  }
212
+ if (sourceMode === "local" && discovered.sources.some((source) => source.absolutePath === null)) {
213
+ throw new Error("Remote repository URLs cannot use --source-mode local. Clone the repository locally first.");
214
+ }
195
215
  return {
196
216
  company,
197
217
  workspaceName,
@@ -199,7 +219,7 @@ export async function selectWorkspaceTarget(cwd, me, flags) {
199
219
  binding,
200
220
  sources: discovered.sources,
201
221
  legacyRepositories: extractLegacyRepositories(discovered.sources),
202
- sourceMode: getSourceMode(flags),
222
+ sourceMode,
203
223
  };
204
224
  }
205
225
  export async function resolveWorkspaceSelection(client, selection) {
package/dist/shell.js CHANGED
@@ -206,11 +206,8 @@ async function runShellCommand(client, cwd, parsed, shellFlags, session) {
206
206
  await commandClaim(client, cwd, shellFlags);
207
207
  return false;
208
208
  case "scan": {
209
- const mode = parsed.args[0];
210
- if (mode && !["standard", "audit", "ultra", "lite", "pr"].includes(mode)) {
211
- process.stderr.write("Usage: /scan [standard|audit|ultra|lite|pr] [pr-number]\n");
212
- return {};
213
- }
209
+ const first = parsed.args[0];
210
+ const mode = first && ["standard", "audit", "ultra", "lite", "pr"].includes(first) ? first : null;
214
211
  const prNumber = mode === "pr" ? parsed.args[1] : null;
215
212
  if (mode === "pr" && !prNumber) {
216
213
  process.stderr.write("Usage: /scan pr <pr-number>\n");
@@ -220,11 +217,15 @@ async function runShellCommand(client, cwd, parsed, shellFlags, session) {
220
217
  process.stderr.write("Usage: /scan pr <pr-number>\n");
221
218
  return {};
222
219
  }
223
- const commandFlags = mode === "pr" && prNumber
220
+ let commandFlags = mode === "pr" && prNumber
224
221
  ? withFlag(withFlag(shellFlags, "mode", mode), "pr", [prNumber])
225
222
  : mode
226
223
  ? withFlag(shellFlags, "mode", mode)
227
224
  : shellFlags;
225
+ const targets = parsed.args.slice(mode ? 1 : 0);
226
+ if (mode !== "pr" && targets.length > 0) {
227
+ commandFlags = withFlag(commandFlags, "repo", targets);
228
+ }
228
229
  if (commandFlags.setup !== undefined) {
229
230
  await commandStartThreatModel(client, cwd, commandFlags);
230
231
  return {};
@@ -314,7 +314,18 @@ async function interactiveAnswers(state) {
314
314
  };
315
315
  }
316
316
  export async function commandThreatModel(client, cwd, flags, action, supplied, emit = true) {
317
- const draftId = uuid.parse(required(flags, "draft"));
317
+ const selectedDraft = getFlagString(flags, "draft");
318
+ if (!selectedDraft) {
319
+ const binding = await loadWorkspaceBinding(cwd);
320
+ const setupCommand = binding
321
+ ? "apex scan --mode standard --setup guided"
322
+ : "apex workspaces";
323
+ throw new ThreatModelUsageError(`Select --draft <id> from the setup response. To create a draft, run ${setupCommand}${binding ? "" : ", bind the intended workspace with apex workspace use <ref>, then run apex scan --mode standard --setup guided"}. Setup uses the workspace's saved repositories and context, not local file selections.`);
324
+ }
325
+ const parsedDraft = uuid.safeParse(selectedDraft);
326
+ if (!parsedDraft.success)
327
+ throw new ThreatModelUsageError("--draft must be a valid draft ID from the setup response.");
328
+ const draftId = parsedDraft.data;
318
329
  requireAuditSetupBeta(await requireAccount(client));
319
330
  let state;
320
331
  if (action === "status" || action === "next") {
@@ -368,8 +379,15 @@ export async function commandThreatModel(client, cwd, flags, action, supplied, e
368
379
  return state;
369
380
  }
370
381
  export async function commandStartThreatModel(client, cwd, flags, emit = true) {
371
- const setup = z.enum(["guided", "auto"]).parse(required(flags, "setup"));
372
- const mode = z.enum(["standard", "audit"]).parse(required(flags, "mode"));
382
+ const setup = getFlagString(flags, "setup");
383
+ if (setup !== "guided" && setup !== "auto") {
384
+ throw new ThreatModelUsageError("--setup must be guided or auto.");
385
+ }
386
+ const requestedMode = getFlagString(flags, "mode");
387
+ const mode = requestedMode === "ultra" ? "audit" : requestedMode;
388
+ if (mode !== "standard" && mode !== "audit") {
389
+ throw new ThreatModelUsageError("Audit Setup supports --mode standard or --mode audit. Lite and PR scans do not use --setup; remove it and retry the intended scan mode.");
390
+ }
373
391
  if (["repo", "pr", "pr-path", "source-mode", "force", "workspace-name"].some((key) => flags[key] !== undefined))
374
392
  throw new ThreatModelUsageError("Audit Setup uses the configured workspace repositories and context; local source and force flags are not supported");
375
393
  const me = await requireAccount(client);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cantinasecurity/apex-cli",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "description": "Standalone CLI and MCP server for Apex.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -27,18 +27,19 @@
27
27
  "node": ">=20"
28
28
  },
29
29
  "dependencies": {
30
- "@modelcontextprotocol/sdk": "^1.29.0",
31
- "ignore": "^7.0.5",
30
+ "@modelcontextprotocol/sdk": "^1.32.1",
31
+ "ignore": "^7.0.12",
32
32
  "proper-lockfile": "^4.1.2",
33
- "tar": "^7.4.3",
34
- "zod": "^4.3.6"
33
+ "tar": "^7.5.22",
34
+ "zod": "^4.6.5"
35
35
  },
36
36
  "devDependencies": {
37
- "@types/node": "^22.18.6",
37
+ "@types/node": "^22.20.5",
38
38
  "@types/proper-lockfile": "^4.1.4",
39
- "tsx": "^4.21.0",
39
+ "tsx": "^4.23.15",
40
40
  "typescript": "^5.9.3",
41
- "vitest": "^2.1.9"
41
+ "vite": "^6.4.4",
42
+ "vitest": "^4.1.11"
42
43
  },
43
44
  "scripts": {
44
45
  "apex": "tsx src/apex.ts",
@@ -120,3 +120,43 @@ verify `apex-credits` without printing the credential.
120
120
  - Use `apex-finding-tickets` to read existing links. CLI fallback: `apex findings link-ticket <finding-ref> <linear|jira> <issue-key|url> --json`, then `apex findings tickets <finding-ref> --json`.
121
121
  - Hosted MCP uses `findingId` (UUID) instead of `findingRef`, with no `cwd` or `scanId`. Linking requires explicit `findings:write`; listing requires `findings:read`. Default service-key scopes remain scans:create, scans:read, findings:read. Do not infer write permission from read access.
122
122
  - Apex verifies the ticket through the connected integration and records the frontend link; it does not create or modify a provider ticket. Repeating the same association is safe. A different existing link or in-progress export is a conflict: do not replace it or create another ticket as a fallback. For ambiguous Jira sites, use the full issue URL.
123
+
124
+ ### Workspace CVSS configuration
125
+
126
+ Use `apex workspace use <workspace>` to bind the intended existing workspace, then
127
+ `apex cvss get` to read its configuration and CVSS 3.1 policy template. To update it,
128
+ write a JSON file containing exactly `enabled` (boolean) and `policy` (the edited
129
+ template), then run `apex cvss set --file <configuration.json>`. Both commands emit
130
+ JSON and require an existing device-login account; they never initiate login,
131
+ create a workspace, or start a scan. Guest sessions are denied. `enabled: false`
132
+ disables CVSS scoring while preserving the supplied policy. This configures future
133
+ synthesis; it does not rescore historical findings.
134
+
135
+ CVSS administration is intentionally scripted CLI only for this release. No shell,
136
+ local MCP, hosted MCP, or organization service-key tool is added. Agents can invoke
137
+ these explicit terminal commands. Server deployment requires Bedrock CVSS API support for CLI bearer authentication.
138
+
139
+ Example (jq is used only to prepare the JSON file):
140
+
141
+ ```sh
142
+ apex login
143
+ apex workspace use "<workspace name or prefix>"
144
+ apex cvss get > cvss-current.json
145
+ jq '.configuration // {enabled: true, policy: .template}' cvss-current.json > cvss.json
146
+ # Edit cvss.json, then save and verify:
147
+ apex cvss set --file cvss.json
148
+ apex cvss get
149
+ ```
150
+
151
+ Keep `policy.version` at `3.1`. Supply a name, instructions, and all eight metrics
152
+ (AV, AC, PR, UI, S, C, I, A). Each metric has an `options` map with at least one
153
+ allowed value and nonempty guidance for each allowed value. Omit disallowed
154
+ values. If Audit Setup or another workspace update is active, wait for it to
155
+ finish before saving again.
156
+
157
+ Scan target and recovery rules:
158
+ - CLI `apex scan [target...]` and repeatable `--repo` accept exact files, directories, or HTTPS GitHub/GitLab repository URLs; local MCP uses `repoPaths` for the same selections. No target means `cwd`.
159
+ - A file selection scans only that file, respecting ignore rules. Local snapshots exclude `.apex/` state. Remote URLs use the default branch and cannot fall back to uploading `cwd`; clone locally to choose a branch or commit.
160
+ - Use Standard for local changes, files, plain directories, and public repositories without provider access. Lite and Audit still require connected provider sources; do not silently substitute another mode.
161
+ - Unauthenticated headless CLI commands fail with explicit login guidance; they never start device login. Use the MCP auth tools for explicit MCP login. Retry transient refresh failures without clearing credentials.
162
+ - Audit Setup is an explicit Standard/Audit beta flow using saved workspace repositories and context. It does not preserve local targets. Never automatically substitute setup after `AUDIT_SETUP_REQUIRED`; use the error's selected-workspace command only when that saved scope is intended. Draft operations require the ID returned by preparation; eligible accounts receive setup commands in CLI help. Lite and PR do not use `--setup`.