@cantinasecurity/apex-cli 0.1.13 → 0.1.15

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.
@@ -13,6 +13,10 @@ This skill is bundled with Apex CLI and can be installed into the current reposi
13
13
 
14
14
  Use Apex through the MCP server when the `apex-*` tools are available.
15
15
 
16
+ For serverless, cron, or other HTTP-only integrations, use the hosted Apex REST API instead of CLI or stdio MCP. Its scan trigger, status, and findings contract is documented at https://ai.cantina.xyz/docs/apex-api.
17
+
18
+ For on-prem or other non-default Apex hosts, make sure the CLI or MCP server runs with `APEX_BASE_URL` set to the external HTTPS Bedrock origin before auth or provider connection flows.
19
+
16
20
  Recommended workflow:
17
21
 
18
22
  1. Start with `apex-auth-status`.
@@ -28,6 +32,11 @@ Recommended workflow:
28
32
  If the Apex MCP server is not configured, fall back to the local CLI:
29
33
 
30
34
  - Prefer scripted commands over the interactive shell.
35
+ - Service-key management has no MCP tools. Never ask a user to paste a service key into a prompt, and never place one in an MCP call, tool result, source file, or log.
36
+ - If a user explicitly needs a new service key, tell them to run `apex login` if needed, then run `apex service-key create --company <company> --name <name> --scope <scope[,scope]>` directly in their own terminal. Use only the required scopes: `scans:create` to trigger, `scans:read` to poll status, and `findings:read` to retrieve findings. A P1-only adapter can use `--scope scans:read,findings:read`; omitting `--scope` grants all three. Service-key commands never initiate login; the secret is shown once. `list` and `revoke` also require an explicit `--company`.
37
+ - For a headless HTTP adapter, read the service key from a secret-backed environment variable, send it as a bearer token to `/api/apex/v1`, and redact it from errors and logs. Do not shell out to `apex` or launch MCP from the adapter.
38
+ - Hosted API v1 requires the GitHub repository to be linked to an active Apex workspace. It never creates or mutates workspaces; use optional `workspaceId` to disambiguate matches and treat zero or ambiguous matches as HTTP `409` setup errors.
39
+ - Hosted scan triggers require `Idempotency-Key`. Reuse one stable key only for an identical transport/timeout/5xx retry. After correcting a definitive 4xx response, or for any new intended scan, use a new key.
31
40
  - Use `--non-interactive` and `--no-open` for automation-friendly CLI calls.
32
41
  - Use `--json` whenever structured output is helpful.
33
42
  - `apex credits` reports standard credits plus audit and fix review scan entitlements when Bedrock returns those balances.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Cantina agent plugins for security review workflows.",
9
- "version": "0.1.13"
9
+ "version": "0.1.15"
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.13"
17
+ "version": "0.1.15"
18
18
  },
19
19
  "description": "Run Apex security scans and review findings from Claude Code.",
20
- "version": "0.1.13",
20
+ "version": "0.1.15",
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.13",
3
+ "version": "0.1.15",
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.13",
3
+ "version": "0.1.15",
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.13",
8
+ "@cantinasecurity/apex-cli@0.1.15",
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.13",
7
+ "@cantinasecurity/apex-cli@0.1.15",
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.13 apex-mcp
16
+ npx -y -p @cantinasecurity/apex-cli@0.1.15 apex-mcp
17
17
  ```
18
18
 
19
19
  That keeps marketplace installs independent of a user's global `apex` install.
package/README.md CHANGED
@@ -74,10 +74,13 @@ pnpm apex
74
74
 
75
75
  By default, the CLI targets `https://ai.cantina.xyz/`.
76
76
 
77
- Use `APEX_BASE_URL` only when testing against a non-production Apex host:
77
+ Use `APEX_BASE_URL` only when targeting a non-default Apex host. For on-prem deployments,
78
+ set it to the same external HTTPS Bedrock origin configured as `ONPREM_PUBLIC_BASE_URL`;
79
+ browser handoffs such as `apex connect github` and `apex-connect-provider` need that
80
+ public origin for GitHub callback and success redirects.
78
81
 
79
82
  ```bash
80
- APEX_BASE_URL=https://preview.cantina.xyz pnpm apex
83
+ APEX_BASE_URL=https://bedrock.customer.example pnpm apex
81
84
  ```
82
85
 
83
86
  ## Interactive Shell
@@ -148,6 +151,9 @@ Supported shell commands:
148
151
  - `apex doctor`
149
152
  - `apex login`
150
153
  - `apex logout`
154
+ - `apex service-key create --company <id-or-handle> --name <name> [--scope <scope[,scope]>] [--expires-at <ISO-8601>] [--json]`
155
+ - `apex service-key list --company <id-or-handle> [--json]`
156
+ - `apex service-key revoke <key-id> --company <id-or-handle> [--json]`
151
157
  - `apex setup [all|codex|claude|copilot]`
152
158
  - `apex telemetry [status|enable|disable]`
153
159
  - `apex update`
@@ -161,6 +167,75 @@ Helpful workspace flags:
161
167
 
162
168
  `apex credits` shows standard scan credits plus audit and fix review scan entitlements when the server returns them.
163
169
 
170
+ ## Service Keys And The Hosted API
171
+
172
+ Use the hosted Apex API for HTTP-only, headless integrations such as serverless functions, cron jobs, and internal audit hubs. Use the CLI or stdio MCP server for local and interactive agent workflows. The full HTTP contract is in the [Apex API documentation](https://ai.cantina.xyz/docs/apex-api).
173
+
174
+ An organization manager can create a service key with an existing Apex device-login session. Service-key commands never start device login implicitly, so run `apex login` first if needed:
175
+
176
+ ```bash
177
+ apex service-key create \
178
+ --company acme \
179
+ --name internal-audit-hub \
180
+ --scope scans:create,scans:read,findings:read \
181
+ --expires-at 2027-01-01T00:00:00Z
182
+ ```
183
+
184
+ The secret is returned only by `create` and cannot be retrieved later. Store it directly in a secret manager and do not put it in source control, prompts, chat messages, or logs. `--json` also includes the shown-once secret so it can be captured programmatically; handle that output as a credential.
185
+
186
+ Service keys are organization-level credentials. Grant only the scopes the integration needs: `scans:create` triggers scans, `scans:read` polls status, and `findings:read` retrieves findings. Omit `--scope` to grant all three scopes. For a P1-only adapter that reads manually started scans, create a read-only key with `--scope scans:read,findings:read`.
187
+
188
+ A key with `findings:read` can retrieve unpublished draft findings for scans in its organization so an external hub can perform triage. Give the key only to systems that are allowed to handle those drafts.
189
+
190
+ List metadata or revoke a key without exposing its secret:
191
+
192
+ ```bash
193
+ apex service-key list --company acme
194
+ apex service-key revoke <key-id> --company acme
195
+ ```
196
+
197
+ Service-key management is intentionally available only through the scripted CLI. It is not exposed as an MCP tool or interactive-shell command, which keeps raw secrets out of model tool results and transcripts. The CLI requires an existing device-login bearer session, never initiates login from these commands, and never stores a created service key locally.
198
+
199
+ Use the service key as a bearer token with the hosted scan lifecycle:
200
+
201
+ ```bash
202
+ curl -X POST https://ai.cantina.xyz/api/apex/v1/scans \
203
+ -H "Authorization: Bearer $APEX_SERVICE_KEY" \
204
+ -H "Idempotency-Key: $APEX_IDEMPOTENCY_KEY" \
205
+ -H "Content-Type: application/json" \
206
+ -d '{
207
+ "repoUrl": "https://github.com/acme/example.git",
208
+ "commit": "0123456789abcdef0123456789abcdef01234567",
209
+ "paths": ["services/payments"]
210
+ }'
211
+
212
+ curl \
213
+ -H "Authorization: Bearer $APEX_SERVICE_KEY" \
214
+ https://ai.cantina.xyz/api/apex/v1/scans/$SCAN_ID
215
+
216
+ curl \
217
+ -H "Authorization: Bearer $APEX_SERVICE_KEY" \
218
+ https://ai.cantina.xyz/api/apex/v1/scans/$SCAN_ID/findings
219
+ ```
220
+
221
+ In v1, the GitHub repository must already be linked to an active Apex workspace. The hosted API never creates, binds, or mutates workspaces. Apex selects the workspace automatically when exactly one active workspace matches the repository; pass the optional `workspaceId` to disambiguate multiple matches. Zero matches, or multiple matches without a valid `workspaceId`, return HTTP `409`.
222
+
223
+ The optional `paths` array is a set of best-effort focus hints, not a hard repository boundary. Apex may inspect or report code outside those paths. Version one does not yet provide strict path isolation.
224
+
225
+ Every trigger request requires an `Idempotency-Key`. Generate one stable key for each intended scan and reuse that same key with the identical request body when retrying after a transport error, timeout, or `5xx`. Idempotency is organization-scoped, so a retry remains safe if the service key is rotated between attempts. A definitive `4xx` response is retained; after correcting its precondition, submit the request with a new key. Use a new key for every genuinely new scan. The trigger returns a `scanId`; poll the scan resource until it reaches a terminal status, then fetch findings. The findings response uses the GitLab SAST JSON shape, including unpublished drafts, finding IDs that remain stable when the same scan is re-fetched, titles, descriptions, severities, and source locations when available.
226
+
227
+ Example prompt for Claude Code:
228
+
229
+ > Build an Apex adapter for our serverless audit hub using the hosted HTTP API, not the Apex CLI or MCP server. Assume the GitHub repository is already linked to an active Apex workspace; this API must not create or mutate workspaces. Read `APEX_SERVICE_KEY` from the environment, generate one stable `Idempotency-Key` per intended scan, and reuse it with the identical request body only after a transport error, timeout, or `5xx`; after correcting a definitive `4xx`, use a new key. POST the repository URL and exact commit to `/api/apex/v1/scans`, include `workspaceId` when needed to disambiguate matches, and handle a `409` workspace-resolution response. Treat optional `paths` as non-binding focus hints, not a security boundary. Poll `/api/apex/v1/scans/{scanId}` until terminal, then fetch `/api/apex/v1/scans/{scanId}/findings`. Preserve the raw GitLab SAST payload, including drafts, and deduplicate findings by stable ID. Never print or log the service key.
230
+
231
+ Example prompt for Codex:
232
+
233
+ > Add Apex to this headless auditor integration using the hosted REST API. Use bearer auth from `APEX_SERVICE_KEY`; do not shell out to `apex` or start MCP. The GitHub repository must already belong to an active Apex workspace, and the adapter must not create or mutate workspaces. Implement scan trigger, `409` workspace-resolution handling, status polling with bounded backoff, terminal failure handling, and findings retrieval. Send `repoUrl`, `commit`, optional `paths`, and optional `workspaceId`; treat `paths` as non-binding focus hints rather than a hard security boundary. Generate a stable `Idempotency-Key` for each intended scan. Preserve it only for an identical transport/timeout/`5xx` retry; after correcting a definitive `4xx`, submit with a new key. Keep the returned `scanId`, and store the unmodified findings payload, including drafts, alongside normalized findings. Add tests with mocked HTTP responses and ensure credentials are redacted from errors and logs.
234
+
235
+ For a P1-only integration where scans are started in the Apex UI:
236
+
237
+ > Given an Apex `scanId`, fetch `/api/apex/v1/scans/{scanId}/findings` with `APEX_SERVICE_KEY`, save the GitLab SAST JSON unchanged, and import each finding once using its stable ID. Do not trigger a new scan and never log the bearer token.
238
+
164
239
  ## Finding Review Feedback
165
240
 
166
241
  Finding review collaboration now has explicit write commands:
@@ -310,6 +385,8 @@ The MCP server exposes Apex-specific tools for:
310
385
  - finding comments and valid/invalid feedback with `apex-finding-comment` and `apex-finding-feedback`
311
386
  - Fix PR and current-repository fix review scans with `apex-finding-fix-review`, optionally after `apex-finding-feedback`
312
387
 
388
+ The MCP server does not expose service-key creation, listing, or revocation. For HTTP-only serverless and cron integrations, use the [hosted Apex API](https://ai.cantina.xyz/docs/apex-api) directly instead of launching the stdio MCP server.
389
+
313
390
  For repository-scoped operations, pass `cwd` explicitly so the server can resolve the right `.apex/workspace.json` binding and repository roots.
314
391
 
315
392
  For Codex-style clients, the packaged skill can be installed with `apex setup codex`. For GitHub Copilot CLI, the same skill is installed into `~/.copilot/skills/apex-cli` with `apex setup copilot`. The repo-local source lives at `skills/apex-cli/SKILL.md`.
@@ -347,7 +424,7 @@ Telemetry event posts do not include bearer tokens. The telemetry endpoint is `P
347
424
  {
348
425
  "schemaVersion": 1,
349
426
  "event": "apex.invocation.completed",
350
- "cliVersion": "0.1.13",
427
+ "cliVersion": "0.1.15",
351
428
  "invocation": {
352
429
  "surface": "mcp",
353
430
  "command": "apex-scan",
@@ -378,7 +455,7 @@ The npm package also includes marketplace-ready plugin artifacts:
378
455
  - `.claude-plugin/plugin.json` and `.mcp.claude.json` for Claude Code plugin installs
379
456
  - `.claude-plugin/marketplace.json` for a Claude marketplace entry backed by the public npm package
380
457
 
381
- These plugin installs launch the pinned npm package with `npx -y -p @cantinasecurity/apex-cli@0.1.13 apex-mcp`, so users do not need to install `apex` globally before enabling the plugin.
458
+ These plugin installs launch the pinned npm package with `npx -y -p @cantinasecurity/apex-cli@0.1.15 apex-mcp`, so users do not need to install `apex` globally before enabling the plugin.
382
459
 
383
460
  The repository also includes `.agents/plugins/marketplace.json` for local Codex marketplace testing from a checkout.
384
461
 
package/dist/apex.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ApexApiClient, formatApiError } from "./api-client.js";
2
2
  import { parseArgs } from "./args.js";
3
- import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindingFixReview, commandFindings, commandLogin, commandLogout, commandScan, commandScans, commandSetup, commandStatus, commandTelemetry, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
3
+ import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindingFixReview, commandFindings, commandLogin, commandLogout, commandScan, commandScans, commandServiceKeyCreate, commandServiceKeyList, commandServiceKeyRevoke, commandSetup, commandStatus, commandTelemetry, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
4
4
  import { CLI_HELP_TEXT } from "./help.js";
5
5
  import { runMcpServer } from "./mcp.js";
6
6
  import { runInteractiveShell } from "./shell.js";
@@ -39,6 +39,20 @@ async function dispatch(parsed) {
39
39
  case "logout":
40
40
  await commandLogout(client, parsed.flags);
41
41
  return;
42
+ case "service-key":
43
+ if (parsed.subcommand === "create" && parsed.args.length === 0) {
44
+ await commandServiceKeyCreate(client, parsed.flags);
45
+ return;
46
+ }
47
+ if (parsed.subcommand === "list" && parsed.args.length === 0) {
48
+ await commandServiceKeyList(client, parsed.flags);
49
+ return;
50
+ }
51
+ if (parsed.subcommand === "revoke" && parsed.args.length === 1) {
52
+ await commandServiceKeyRevoke(client, parsed.flags, parsed.args[0] ?? "");
53
+ return;
54
+ }
55
+ throw new Error("Usage: apex service-key <create|list|revoke> [key-id] --company <id-or-handle>");
42
56
  case "update":
43
57
  await commandUpdate(parsed.flags);
44
58
  return;
package/dist/commands.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { canPromptInteractively, getFlagList, getFlagString, isJsonMode, } from "./args.js";
2
- import { logout } from "./auth.js";
2
+ import { getMe, logout } from "./auth.js";
3
3
  import { ApiError } from "./api-client.js";
4
4
  import { openInBrowser } from "./browser.js";
5
5
  import { loadConfig, saveConfig } from "./config.js";
@@ -8,6 +8,7 @@ import { prepareExplicitScanSources, supportsLegacyRemoteFlow, } from "./local-s
8
8
  import { logLine, printJson, withLoadingIndicator } from "./output.js";
9
9
  import { confirm } from "./prompt.js";
10
10
  import { cancelScan, fetchScanProgress, fetchWorkspaceScans, findMostRelevantActiveScan, getScanDisplayLabel, getScanDisplayId, getTrackedScanId, isActiveScanStatus, matchesScanId, selectDefaultScanToCancel, } from "./scan.js";
11
+ import { createServiceApiKey, isServiceApiScope, listServiceApiKeys, revokeServiceApiKey, SERVICE_API_SCOPES, } from "./service-keys.js";
11
12
  import { chooseCompany, createWorkspaceBinding, ensureAuthenticated, remediateMissingConnections, resolveWorkspaceSelection, resolveWorkspaceSelectionLegacy, resolveWorkspaceAllowingMissingConnections, selectWorkspaceTarget, } from "./session.js";
12
13
  import { createWorkspaceBindingFromSummary, fetchCompanyCredits, fetchCompanyWorkspaces, findWorkspaceByRef, } from "./workspaces.js";
13
14
  import { loadWorkspaceBinding, saveWorkspaceBinding } from "./workspace-binding.js";
@@ -16,6 +17,7 @@ import { getTelemetryStatus, setTelemetryEnabled, } from "./telemetry.js";
16
17
  import { commandUpdate as runCliUpdate } from "./update.js";
17
18
  import { mkdir, writeFile } from "node:fs/promises";
18
19
  import path from "node:path";
20
+ import { z } from "zod";
19
21
  const WORKSPACE_USE_PATTERN = "<workspace-name|workspace-prefix|workspace-id>";
20
22
  const WORKSPACE_BINDING_GUIDANCE = "Run `apex workspaces` to list workspace names, prefixes, and IDs, then bind one with `apex workspace use \"<workspace name>\"`, or run `apex scan` to create or resolve one automatically.";
21
23
  const WORKSPACE_SELECTION_GUIDANCE = "Run `apex workspaces` to list available workspace names, prefixes, and IDs, then retry `apex workspace use \"<workspace name>\"`.";
@@ -40,6 +42,17 @@ const FIX_REVIEW_TARGETS = [
40
42
  "pull_request",
41
43
  "repository_current",
42
44
  ];
45
+ const SERVICE_KEY_CREATE_USAGE = "Usage: apex service-key create --company <id-or-handle> --name <name> [--scope <scope[,scope]>] [--expires-at <ISO-8601>] [--json]";
46
+ const SERVICE_KEY_LIST_USAGE = "Usage: apex service-key list --company <id-or-handle> [--json]";
47
+ const SERVICE_KEY_REVOKE_USAGE = "Usage: apex service-key revoke <key-id> --company <id-or-handle> [--json]";
48
+ const SERVICE_KEY_EXPIRATION_SCHEMA = z.string().datetime({ offset: true });
49
+ const SERVICE_KEY_NAME_CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/;
50
+ const SERVICE_KEY_NAME_SCHEMA = z
51
+ .string()
52
+ .trim()
53
+ .min(1)
54
+ .max(100)
55
+ .refine((value) => !SERVICE_KEY_NAME_CONTROL_CHARACTER_PATTERN.test(value));
43
56
  async function saveCompanyDefault(companyId) {
44
57
  const config = await loadConfig();
45
58
  if (config.defaultCompanyId !== companyId) {
@@ -52,6 +65,72 @@ async function saveCompanyDefault(companyId) {
52
65
  function formatCommandArgument(value) {
53
66
  return /\s/.test(value) ? `"${value}"` : value;
54
67
  }
68
+ function requireExplicitServiceKeyCompany(flags, usage) {
69
+ if (!getFlagString(flags, "company")) {
70
+ throw new Error(`${usage}\nService-key commands require an explicit --company.`);
71
+ }
72
+ }
73
+ async function requireExistingServiceKeySession(client) {
74
+ const me = await getMe(client);
75
+ if (!me) {
76
+ throw new Error("Service-key commands require an existing Apex session. Run `apex login` first.");
77
+ }
78
+ return me;
79
+ }
80
+ function normalizeServiceKeyExpiration(value) {
81
+ if (!value) {
82
+ return null;
83
+ }
84
+ const parsed = SERVICE_KEY_EXPIRATION_SCHEMA.safeParse(value);
85
+ if (!parsed.success) {
86
+ throw new Error(`${SERVICE_KEY_CREATE_USAGE}\n--expires-at must be a valid ISO-8601 timestamp with a timezone offset.`);
87
+ }
88
+ const timestamp = new Date(parsed.data);
89
+ if (timestamp.getTime() <= Date.now()) {
90
+ throw new Error(`${SERVICE_KEY_CREATE_USAGE}\n--expires-at must be in the future.`);
91
+ }
92
+ return timestamp.toISOString();
93
+ }
94
+ function normalizeServiceKeyName(value) {
95
+ const parsed = SERVICE_KEY_NAME_SCHEMA.safeParse(value);
96
+ if (!parsed.success) {
97
+ throw new Error(`${SERVICE_KEY_CREATE_USAGE}\n--name must be 1-100 characters and cannot contain control characters.`);
98
+ }
99
+ return parsed.data;
100
+ }
101
+ function normalizeServiceKeyScopes(flags) {
102
+ if (!Object.prototype.hasOwnProperty.call(flags, "scope")) {
103
+ return undefined;
104
+ }
105
+ const rawScopes = flags.scope;
106
+ if (typeof rawScopes !== "string" || rawScopes.trim().length === 0) {
107
+ throw new Error(`${SERVICE_KEY_CREATE_USAGE}\n--scope must contain one or more comma-separated scopes.`);
108
+ }
109
+ const requestedScopes = rawScopes.split(",").map((scope) => scope.trim());
110
+ if (requestedScopes.some((scope) => scope.length === 0) ||
111
+ requestedScopes.some((scope) => !isServiceApiScope(scope))) {
112
+ throw new Error(`${SERVICE_KEY_CREATE_USAGE}\n--scope must contain only: ${SERVICE_API_SCOPES.join(", ")}.`);
113
+ }
114
+ return [...new Set(requestedScopes)];
115
+ }
116
+ function escapeTerminalControlCharacters(value) {
117
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
118
+ }
119
+ function formatServiceKeyScopes(key) {
120
+ const scopes = [...new Set(key.scopes.filter(isServiceApiScope))];
121
+ return scopes.length > 0 ? scopes.join(", ") : "none";
122
+ }
123
+ function formatServiceKeyState(key) {
124
+ if (key.revokedAt) {
125
+ return `revoked ${escapeTerminalControlCharacters(key.revokedAt)}`;
126
+ }
127
+ if (key.expiresAt && new Date(key.expiresAt).getTime() <= Date.now()) {
128
+ return `expired ${escapeTerminalControlCharacters(key.expiresAt)}`;
129
+ }
130
+ return key.expiresAt
131
+ ? `expires ${escapeTerminalControlCharacters(key.expiresAt)}`
132
+ : "does not expire";
133
+ }
55
134
  function getSuggestedWorkspaceRef(workspace) {
56
135
  return workspace.prefix?.trim().length ? workspace.prefix : workspace.name;
57
136
  }
@@ -508,6 +587,93 @@ export async function commandLogout(client, flags) {
508
587
  logLine("Logged out", flags);
509
588
  }
510
589
  }
590
+ export async function commandServiceKeyCreate(client, flags) {
591
+ requireExplicitServiceKeyCompany(flags, SERVICE_KEY_CREATE_USAGE);
592
+ const name = normalizeServiceKeyName(getFlagString(flags, "name"));
593
+ const scopes = normalizeServiceKeyScopes(flags);
594
+ const expiresAt = normalizeServiceKeyExpiration(getFlagString(flags, "expires-at"));
595
+ const me = await requireExistingServiceKeySession(client);
596
+ const company = await chooseCompany(me, flags, null);
597
+ const response = await withLoadingIndicator("Creating service key...", flags, () => createServiceApiKey(client, company.id, {
598
+ name,
599
+ expiresAt,
600
+ ...(scopes ? { scopes } : {}),
601
+ }));
602
+ const payload = {
603
+ company,
604
+ key: response.key,
605
+ secret: response.secret,
606
+ secretShownOnce: true,
607
+ };
608
+ if (isJsonMode(flags)) {
609
+ printJson(payload);
610
+ return payload;
611
+ }
612
+ logLine(`Company: ${escapeTerminalControlCharacters(company.handle ?? company.id)}`, flags);
613
+ logLine(`Service key: ${escapeTerminalControlCharacters(response.key.name)} (${escapeTerminalControlCharacters(response.key.id)})`, flags);
614
+ if (response.key.prefix) {
615
+ logLine(`Prefix: ${escapeTerminalControlCharacters(response.key.prefix)}`, flags);
616
+ }
617
+ logLine(`Scopes: ${formatServiceKeyScopes(response.key)}`, flags);
618
+ if (response.key.expiresAt) {
619
+ logLine(`Expires: ${escapeTerminalControlCharacters(response.key.expiresAt)}`, flags);
620
+ }
621
+ process.stdout.write(`Service key (shown once): ${response.secret}\n`);
622
+ logLine("Store this service key securely now. Apex cannot show it again.", flags);
623
+ return payload;
624
+ }
625
+ export async function commandServiceKeyList(client, flags) {
626
+ requireExplicitServiceKeyCompany(flags, SERVICE_KEY_LIST_USAGE);
627
+ const me = await requireExistingServiceKeySession(client);
628
+ const company = await chooseCompany(me, flags, null);
629
+ const response = await withLoadingIndicator("Loading service keys...", flags, () => listServiceApiKeys(client, company.id));
630
+ const payload = {
631
+ company,
632
+ keys: response.keys,
633
+ };
634
+ if (isJsonMode(flags)) {
635
+ printJson(payload);
636
+ return payload;
637
+ }
638
+ logLine(`Company: ${escapeTerminalControlCharacters(company.handle ?? company.id)}`, flags);
639
+ if (response.keys.length === 0) {
640
+ logLine("Service keys: none.", flags);
641
+ return payload;
642
+ }
643
+ for (const key of response.keys) {
644
+ const prefix = key.prefix
645
+ ? `, ${escapeTerminalControlCharacters(key.prefix)}`
646
+ : "";
647
+ logLine(`${escapeTerminalControlCharacters(key.name)} (${escapeTerminalControlCharacters(key.id)}${prefix}, ${formatServiceKeyState(key)})`, flags);
648
+ logLine(`Scopes: ${formatServiceKeyScopes(key)}`, flags);
649
+ if (key.lastUsedAt) {
650
+ logLine(`Last used: ${escapeTerminalControlCharacters(key.lastUsedAt)}`, flags);
651
+ }
652
+ }
653
+ return payload;
654
+ }
655
+ export async function commandServiceKeyRevoke(client, flags, requestedKeyId) {
656
+ requireExplicitServiceKeyCompany(flags, SERVICE_KEY_REVOKE_USAGE);
657
+ const keyId = requestedKeyId.trim();
658
+ if (!keyId) {
659
+ throw new Error(SERVICE_KEY_REVOKE_USAGE);
660
+ }
661
+ const me = await requireExistingServiceKeySession(client);
662
+ const company = await chooseCompany(me, flags, null);
663
+ await withLoadingIndicator("Revoking service key...", flags, () => revokeServiceApiKey(client, company.id, keyId));
664
+ const payload = {
665
+ company,
666
+ keyId,
667
+ revoked: true,
668
+ };
669
+ if (isJsonMode(flags)) {
670
+ printJson(payload);
671
+ return payload;
672
+ }
673
+ logLine(`Company: ${escapeTerminalControlCharacters(company.handle ?? company.id)}`, flags);
674
+ logLine(`Revoked service key: ${escapeTerminalControlCharacters(keyId)}`, flags);
675
+ return payload;
676
+ }
511
677
  export async function commandUpdate(flags) {
512
678
  return runCliUpdate(flags);
513
679
  }
package/dist/help.js CHANGED
@@ -20,6 +20,9 @@ export const CLI_HELP_TEXT = `Usage:
20
20
  apex doctor Validate auth, repos, connections, and workspace binding
21
21
  apex login Sign in to Apex
22
22
  apex logout Sign out locally
23
+ apex service-key create Create an organization service key (shown once)
24
+ apex service-key list List service-key metadata for an organization
25
+ apex service-key revoke <key-id> Revoke an organization service key
23
26
  apex mcp Start the Apex MCP server over stdio
24
27
  apex setup [all|codex|claude|copilot]
25
28
  Configure Apex for Codex, Claude Code, and GitHub Copilot CLI
@@ -31,6 +34,9 @@ export const CLI_HELP_TEXT = `Usage:
31
34
 
32
35
  Flags:
33
36
  --company <id-or-handle> Choose the Apex company to use
37
+ --name <name> Name a new service key
38
+ --scope <scope[,scope]> Limit a service key to scans:create, scans:read, and/or findings:read
39
+ --expires-at <ISO-8601> Set an optional service-key expiration time
34
40
  --workspace-name <name> Set the Apex workspace name for this directory
35
41
  --scan <scan-id> Select a specific scan for status, findings, or export
36
42
  --status valid|invalid Set the finding feedback status explicitly
@@ -67,6 +73,9 @@ Tips:
67
73
  audit is the current scan mode for audit scans; ultra remains accepted as a legacy alias.
68
74
  apex workspace use accepts a workspace name, prefix, or ID.
69
75
  Finding comments, feedback, and fix review scans use the same Apex login credentials as other CLI commands.
76
+ Service-key commands require an existing login and --company; run apex login first when needed.
77
+ Service-key creation also requires --name and reveals the secret only once; omit --scope to grant all hosted API scopes.
78
+ Service-key management is available only in the scripted CLI, not the interactive shell or MCP server.
70
79
  Invalid finding feedback requires --dismissal-reason.
71
80
  Fix review scans use saved or supplied Fix PR URLs when present; otherwise they review the current repository when Bedrock has original scan repository context.
72
81
  Finding identifiers such as KERN2-25 resolve against the selected scan; pass --scan or use the finding UUID directly when needed.
package/dist/mcp.js CHANGED
@@ -24,6 +24,8 @@ Notes:
24
24
  - Pass cwd explicitly for repository-specific operations.
25
25
  - Tool calls are always non-interactive and never auto-open a browser.
26
26
  - Finding write tools use the same Apex device-login credentials as read tools.
27
+ - 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 an MCP prompt or tool call.
28
+ - HTTP-only serverless and cron integrations should use the hosted API documented at https://ai.cantina.xyz/docs/apex-api instead of launching this stdio server.
27
29
  - Use mode "audit" for audit scans; "ultra" remains accepted as a legacy alias. Use mode "pr" for GitHub pull request scans.
28
30
  - Doctor reports whether Apex will use remote materialization or a local snapshot upload for each source.`;
29
31
  const WORKSPACE_BINDING_ERROR = "This directory is not bound to an Apex workspace yet. Run `apex workspaces` to list workspace names, prefixes, and IDs, then bind one with `apex workspace use \"<workspace name>\"`, or run `apex scan` to create or resolve one automatically.";
@@ -0,0 +1,52 @@
1
+ export const SERVICE_API_SCOPES = [
2
+ "scans:create",
3
+ "scans:read",
4
+ "findings:read",
5
+ ];
6
+ export function isServiceApiScope(value) {
7
+ return (typeof value === "string" &&
8
+ SERVICE_API_SCOPES.includes(value));
9
+ }
10
+ function serviceApiKeysPath(companyId) {
11
+ return `/api/companies/${encodeURIComponent(companyId)}/service-api-keys`;
12
+ }
13
+ function serviceApiKeyMetadata(key) {
14
+ const scopes = Array.isArray(key.scopes)
15
+ ? [...new Set(key.scopes.filter(isServiceApiScope))]
16
+ : [...SERVICE_API_SCOPES];
17
+ return {
18
+ id: key.id,
19
+ name: key.name,
20
+ ...(key.prefix !== undefined ? { prefix: key.prefix } : {}),
21
+ scopes,
22
+ createdAt: key.createdAt,
23
+ expiresAt: key.expiresAt,
24
+ ...(key.lastUsedAt !== undefined ? { lastUsedAt: key.lastUsedAt } : {}),
25
+ ...(key.revokedAt !== undefined ? { revokedAt: key.revokedAt } : {}),
26
+ };
27
+ }
28
+ export async function createServiceApiKey(client, companyId, input) {
29
+ const response = await client.request(serviceApiKeysPath(companyId), {
30
+ method: "POST",
31
+ json: {
32
+ name: input.name,
33
+ ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
34
+ ...(input.scopes ? { scopes: input.scopes } : {}),
35
+ },
36
+ });
37
+ return {
38
+ key: serviceApiKeyMetadata(response.key),
39
+ secret: response.secret,
40
+ };
41
+ }
42
+ export async function listServiceApiKeys(client, companyId) {
43
+ const response = await client.request(serviceApiKeysPath(companyId));
44
+ return {
45
+ keys: response.keys.map(serviceApiKeyMetadata),
46
+ };
47
+ }
48
+ export async function revokeServiceApiKey(client, companyId, keyId) {
49
+ await client.request(`${serviceApiKeysPath(companyId)}/${encodeURIComponent(keyId)}`, {
50
+ method: "DELETE",
51
+ });
52
+ }
package/dist/telemetry.js CHANGED
@@ -13,6 +13,7 @@ const KNOWN_CLI_FLAG_NAMES = [
13
13
  "company",
14
14
  "content",
15
15
  "dismissal-reason",
16
+ "expires-at",
16
17
  "fix-pr-url",
17
18
  "force",
18
19
  "format",
@@ -21,6 +22,7 @@ const KNOWN_CLI_FLAG_NAMES = [
21
22
  "label",
22
23
  "limit",
23
24
  "mode",
25
+ "name",
24
26
  "no-open",
25
27
  "non-interactive",
26
28
  "output",
@@ -30,6 +32,7 @@ const KNOWN_CLI_FLAG_NAMES = [
30
32
  "repo",
31
33
  "review-target",
32
34
  "scan",
35
+ "scope",
33
36
  "source-mode",
34
37
  "status",
35
38
  "suggested-severity",
@@ -49,6 +52,7 @@ const KNOWN_CLI_COMMANDS = [
49
52
  "mcp",
50
53
  "scan",
51
54
  "scans",
55
+ "service-key",
52
56
  "setup",
53
57
  "status",
54
58
  "telemetry",
@@ -173,6 +177,8 @@ function sanitizeCliFlags(flags) {
173
177
  noOpen: boolFlag(flags, "no-open"),
174
178
  companyProvided: stringFlag(flags, "company") !== null,
175
179
  workspaceNameProvided: stringFlag(flags, "workspace-name") !== null,
180
+ nameProvided: stringFlag(flags, "name") !== null,
181
+ expiresAtProvided: stringFlag(flags, "expires-at") !== null,
176
182
  scanIdProvided: stringFlag(flags, "scan") !== null,
177
183
  outputProvided: stringFlag(flags, "output") !== null,
178
184
  contentProvided: stringFlag(flags, "content") !== null,
@@ -297,6 +303,8 @@ function safeCliSubcommand(parsed) {
297
303
  return safeEnum(subcommand, ["comment", "feedback", "fix-review"]);
298
304
  case "setup":
299
305
  return safeEnum(subcommand, ["all", "codex", "claude", "copilot"]);
306
+ case "service-key":
307
+ return safeEnum(subcommand, ["create", "list", "revoke"]);
300
308
  case "telemetry":
301
309
  return safeEnum(subcommand, ["status", "enable", "disable"]);
302
310
  case "workspace":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cantinasecurity/apex-cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Standalone CLI and MCP server for Apex.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -9,8 +9,12 @@ This skill is bundled with Apex CLI and can be installed into Codex with `apex s
9
9
 
10
10
  Prefer the Apex MCP tools over running `apex` in the shell when the server is available.
11
11
 
12
+ For serverless, cron, or other HTTP-only integrations, use the hosted Apex REST API instead of CLI or stdio MCP. Its scan trigger, status, and findings contract is documented at https://ai.cantina.xyz/docs/apex-api.
13
+
12
14
  `apex setup codex` configures the MCP server with client attribution so Apex can distinguish Codex MCP usage from direct CLI usage. Users can inspect or disable anonymous local usage telemetry with `apex telemetry status` and `apex telemetry disable`.
13
15
 
16
+ For on-prem or other non-default Apex hosts, make sure the MCP server runs with `APEX_BASE_URL` set to the external HTTPS Bedrock origin before auth or provider connection flows.
17
+
14
18
  Workflow:
15
19
 
16
20
  1. Start with `apex-auth-status`.
@@ -26,6 +30,11 @@ Workflow:
26
30
  Guidelines:
27
31
 
28
32
  - Do not rely on interactive CLI prompts. The MCP tools are intentionally non-interactive.
33
+ - Service-key management has no MCP tools. Never ask a user to paste a service key into a prompt, and never place one in an MCP call, tool result, source file, or log.
34
+ - If a user explicitly needs a new service key, tell them to run `apex login` if needed, then run `apex service-key create --company <company> --name <name> --scope <scope[,scope]>` directly in their own terminal. Use only the required scopes: `scans:create` to trigger, `scans:read` to poll status, and `findings:read` to retrieve findings. A P1-only adapter can use `--scope scans:read,findings:read`; omitting `--scope` grants all three. Service-key commands never initiate login; the secret is shown once. `list` and `revoke` also require an explicit `--company`.
35
+ - For a headless HTTP adapter, read the service key from a secret-backed environment variable, send it as a bearer token to `/api/apex/v1`, and redact it from errors and logs. Do not shell out to `apex` or launch MCP from the adapter.
36
+ - Hosted API v1 requires the GitHub repository to be linked to an active Apex workspace. It never creates or mutates workspaces; use optional `workspaceId` to disambiguate matches and treat zero or ambiguous matches as HTTP `409` setup errors.
37
+ - Hosted scan triggers require `Idempotency-Key`. Reuse one stable key only for an identical transport/timeout/5xx retry. After correcting a definitive 4xx response, or for any new intended scan, use a new key.
29
38
  - `apex-credits` reports standard credits plus audit and fix review scan entitlements when Bedrock returns those balances.
30
39
  - `apex-scan` scans the provided `cwd` by default; pass `repoPaths` only when the user asks to scan explicit alternate roots.
31
40
  - `apex-doctor` reports whether Apex will use remote materialization or a local snapshot upload for each selected source.