@cantinasecurity/apex-cli 0.1.28 → 0.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/help.js CHANGED
@@ -5,6 +5,11 @@ export const CLI_HELP_TEXT = `Usage:
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
8
+ apex findings send <finding-ref> <linear|jira>
9
+ Create a ticket using the connected integration and saved destination
10
+ apex findings tickets <finding-ref> List linked Linear and Jira tickets
11
+ apex findings link-ticket <finding-ref> <linear|jira> <issue-key|url>
12
+ Link an existing ticket using a connected integration
8
13
  apex findings comment <finding-id|finding-identifier>
9
14
  Add a comment or note to a finding
10
15
  apex findings feedback <finding-id|finding-identifier> <valid|invalid>
@@ -20,7 +25,7 @@ export const CLI_HELP_TEXT = `Usage:
20
25
  apex status Show progress for the latest or selected scan
21
26
  apex doctor Validate auth, repos, connections, and workspace binding
22
27
  apex login Sign in to Apex
23
- apex redeem <trial-code> Start a temporary guest session without device login
28
+ apex redeem <code> Redeem a Standard-scan code without device login
24
29
  apex claim Link a guest workspace and findings to a Cantina account
25
30
  apex logout Sign out locally
26
31
  apex service-key create Create an organization service key (shown once)
@@ -38,12 +43,12 @@ export const CLI_HELP_TEXT = `Usage:
38
43
  Flags:
39
44
  --company <id-or-handle> Override the automatically selected Apex company
40
45
  --name <name> Name a new service key
41
- --scope <scope[,scope]> Limit a service key to scans:create, scans:read, and/or findings:read
46
+ --scope <scope[,scope]> Limit a service key to scans:create, scans:read, findings:read, and/or findings:write
42
47
  --expires-at <ISO-8601> Set an optional service-key expiration time
43
48
  --workspace-name <name> Set the Apex workspace name for this directory
44
49
  --scan <scan-id> Select a specific scan for status, findings, or export
45
50
  --workspace Use the cross-scan workspace findings view
46
- --workspace-ref <name|prefix|id> Select a workspace for apex findings --workspace only
51
+ --workspace-ref <name|prefix|id> Select a workspace for cross-scan findings
47
52
  --finding-state all|open Select all findings or only findings needing review
48
53
  --filter <[!]facet:value> Filter workspace findings; repeat for multiple filters
49
54
  --status valid|invalid Set the finding feedback status explicitly
@@ -81,6 +86,7 @@ Tips:
81
86
  audit is the current scan mode for audit scans; ultra remains accepted as a legacy alias.
82
87
  Lite scans use --mode lite and require beta access plus provider-backed GitHub or GitLab sources.
83
88
  apex workspace use accepts a workspace name, prefix, or ID.
89
+ Scan selectors use the public Bedrock scan ID shown by Apex; Kernel execution IDs are private and are not accepted as aliases.
84
90
  Finding comments, feedback, and fix review scans use the same Apex login credentials as other CLI commands.
85
91
  Trial codes go only to apex redeem. Never echo, log, save, telemeter, or send them through MCP.
86
92
  Trial redemption, claim, and revocation require https://ai.cantina.xyz and reject other APEX_BASE_URL origins before sending secrets.
@@ -92,10 +98,15 @@ Tips:
92
98
  Service-key commands require an existing login; run apex login first when needed.
93
99
  APEX_SERVICE_KEY authenticates https://ai.cantina.xyz/mcp and hosted REST; it does not replace apex login for local CLI or stdio MCP commands.
94
100
  Apex automatically selects your company when only one is available. With multiple companies, choose one interactively or pass --company.
95
- Service-key creation also requires --name and reveals the secret only once; omit --scope to grant all hosted API scopes.
101
+ Service-key creation also requires --name and reveals the secret only once; omit --scope to grant scans:create, scans:read, and findings:read only.
96
102
  Service-key management is available only in the scripted CLI, not the interactive shell or MCP server.
97
103
  Invalid finding feedback requires --dismissal-reason.
98
104
  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.
105
+ Ticket linking verifies the company's connected integration, never creates a ticket, and never replaces a link.
106
+ Sending creates a ticket with the same title, details, and Apex link as the frontend, using its saved destination.
107
+ Configure Linear routing or the Jira workspace destination in Apex first. Already sent or linked findings return the existing ticket.
108
+ After an ambiguous send failure, check the provider and apex findings tickets before retrying.
109
+ Hosted MCP ticket creation and linking require an explicitly granted findings:write service-key scope; default scopes stay unchanged.
99
110
  Finding identifiers such as KERN2-25 resolve against the selected scan; pass --scan or use the finding UUID directly when needed.
100
111
  Use apex findings --workspace --company <handle> --workspace-ref <ref> --finding-state open to retrieve findings needing review without changing the local binding.
101
112
  To run scans or exports against a different workspace, bind it first with apex workspace use <ref>.
@@ -105,6 +116,26 @@ Tips:
105
116
  Quote workspace names that contain spaces:
106
117
  apex workspace use "Core Platform"
107
118
  `;
119
+ const AUDIT_SETUP_CLI_HELP = `
120
+ Audit Setup beta:
121
+ apex scan --workspace-ref <ref> --mode standard|audit --setup guided|auto
122
+ Prepare a threat model for an enabled workspace
123
+ apex threat-model <action> Read, answer, review, and approve a prepared model
124
+ --draft <uuid> Select an Audit Setup draft
125
+ --file <path|-> Read answer JSON, message, or revision text (stdin: -)
126
+ --attachment <path> Attach a file to an answer or message (repeatable)
127
+ --sha256 <hash> Select the exact reviewed model
128
+ --start-scan Confirm scan launch when approving a model
129
+ --request-id <uuid> Reuse an operation ID when retrying
130
+ --no-feedback-context Exclude prior feedback from this setup
131
+ --wait Wait for the next actionable setup state
132
+ --timeout <seconds> Bound next --wait (default 60, maximum 3600)
133
+ `;
134
+ export function cliHelpText(auditSetupEnabled) {
135
+ return auditSetupEnabled
136
+ ? `${CLI_HELP_TEXT.trimEnd()}\n${AUDIT_SETUP_CLI_HELP}`
137
+ : CLI_HELP_TEXT;
138
+ }
108
139
  export const SHELL_HELP_TEXT = `Press Tab to autocomplete commands and common arguments.
109
140
 
110
141
  Commands:
@@ -116,6 +147,11 @@ Commands:
116
147
  /findings [scan-id] List findings for the latest or selected scan
117
148
  /findings workspace [filter...]
118
149
  List workspace findings; for example review:unreviewed
150
+ /findings send <finding-ref> <linear|jira>
151
+ Create a ticket using the connected integration and saved destination
152
+ /findings tickets <finding-ref> List linked Linear and Jira tickets
153
+ /findings link-ticket <finding-ref> <linear|jira> <issue-key|url>
154
+ Link an existing ticket using a connected integration
119
155
  /findings comment <finding-ref> <comment>
120
156
  Add a comment or note to a finding
121
157
  /findings feedback <finding-ref> valid [comment]
@@ -147,8 +183,8 @@ Commands:
147
183
  /exit Exit Apex
148
184
 
149
185
  Scripted-only commands (run outside the interactive shell):
150
- apex redeem <trial-code> [--json] [--replace]
151
- Start a guest session without device login or browser access
186
+ apex redeem <code> [--json] [--replace]
187
+ Redeem a Standard-scan code without device login or browser access
152
188
  apex service-key create --name <name> [--company <id-or-handle>] [--scope <scope[,scope]>]
153
189
  Create an organization service key (shown once)
154
190
  apex service-key list [--company <id-or-handle>]
@@ -161,6 +197,7 @@ Tips:
161
197
  audit is the current scan mode for audit scans; ultra remains accepted as a legacy alias.
162
198
  Lite scans require beta access plus provider-backed GitHub or GitLab sources.
163
199
  /workspace use accepts a workspace name, prefix, or ID.
200
+ Scan selectors use the public Bedrock scan ID shown by Apex; Kernel execution IDs are private and are not accepted as aliases.
164
201
  /findings comment, /findings feedback, and /findings fix-review use the current Apex login.
165
202
  For first-class open state or a workspace that is not locally bound, use scripted
166
203
  apex findings --workspace --company <handle> --workspace-ref <ref> --finding-state open.
@@ -174,3 +211,13 @@ Tips:
174
211
  Anonymous usage telemetry can be disabled with /telemetry disable or APEX_TELEMETRY_DISABLED=1.
175
212
  Quote workspace names that contain spaces: /workspace use "Core Platform"
176
213
  `;
214
+ const AUDIT_SETUP_SHELL_HELP = `
215
+ Audit Setup beta:
216
+ Start the shell with --setup guided|auto and --mode standard|audit to prepare
217
+ the active workspace's threat model before explicitly approving its scan.
218
+ `;
219
+ export function shellHelpText(auditSetupEnabled) {
220
+ return auditSetupEnabled
221
+ ? `${SHELL_HELP_TEXT.trimEnd()}\n${AUDIT_SETUP_SHELL_HELP}`
222
+ : SHELL_HELP_TEXT;
223
+ }
package/dist/mcp.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { commandStartThreatModel, commandThreatModel, hasAuditSetupBeta } from "./threat-model.js";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
4
  import path from "node:path";
@@ -5,8 +6,8 @@ import { z } from "zod";
5
6
  import { getMe, logout, readStableDeviceLoginState, startDeviceLogin, waitForDeviceLoginApproval, } from "./auth.js";
6
7
  import { ApexApiClient, formatApiError } from "./api-client.js";
7
8
  import { getClaimProofState, isClaimProofAvailable, } from "./claim-proof.js";
8
- import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindingFixReview, commandFindings, commandWorkspaceFindings, commandScan, commandScans, commandStatus, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
9
- import { CLI_HELP_TEXT } from "./help.js";
9
+ import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingTickets, commandFindingLinkTicket, commandFindingSend, commandFindingFeedback, commandFindingFixReview, commandFindings, commandWorkspaceFindings, commandScan, commandScans, commandStatus, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
10
+ import { cliHelpText } from "./help.js";
10
11
  import { getLocalTrialStatus, isProofOnlyOnboardingCredentials, revokeGuestOnboardingSession, } from "./onboarding.js";
11
12
  import { createMcpServerTelemetryInvocation, createMcpTelemetryInvocation, emitInvocationCompleted, emitInvocationStarted, emitMcpServerStarted, withTelemetryContext, } from "./telemetry.js";
12
13
  import { APEX_CLI_VERSION } from "./version.js";
@@ -21,8 +22,9 @@ Suggested workflow:
21
22
  6. If a repository operation needs a different local binding, call apex-workspaces and apex-workspace-use.
22
23
  7. Start and monitor scans with apex-scan, apex-status, apex-scans, apex-findings, and apex-export-findings. For cross-scan review queues, call apex-workspace-findings with company, workspaceRef, and findingState "open"; this direct selection does not require or change a local binding.
23
24
  8. For PR scans, call apex-scan with mode "pr" and one or more pullRequests.
24
- 9. Leave finding comments or valid/invalid feedback with apex-finding-comment and apex-finding-feedback when review collaboration is needed.
25
- 10. 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.
25
+ 9. Send findings to Linear/Jira with apex-finding-send to create tickets using the frontend's saved destination. Link existing tickets with apex-finding-link-ticket and inspect links with apex-finding-tickets. The company integration must already be connected.
26
+ 10. Leave finding comments or valid/invalid feedback with apex-finding-comment and apex-finding-feedback when review collaboration is needed.
27
+ 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.
26
28
 
27
29
  Notes:
28
30
  - Pass cwd explicitly for repository-specific operations.
@@ -31,10 +33,20 @@ Notes:
31
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.
32
34
  - Production agents that do not need local filesystem access can connect to https://ai.cantina.xyz/mcp over Streamable HTTP with Authorization: Bearer <service-key>. Serverless and cron applications can instead use the REST API documented at https://ai.cantina.xyz/docs/apex-api. This local stdio server continues to use device login.
33
35
  - Use mode "audit" for audit scans; "ultra" remains accepted as a legacy alias. Use mode "lite" only for beta-enabled provider-backed Lite scans, and mode "pr" for GitHub pull request scans.
36
+ - Use only the public Bedrock scanId returned by Apex for scan inputs. Kernel execution IDs are private and are neither returned nor accepted as aliases.
34
37
  - Starting a PR scan may run alongside active workspace scans and does not require force. Starting any non-PR scan still treats every active scan, including a PR scan, as a blocker; use force only when the user explicitly wants that overlap.
35
38
  - Doctor is read-only for guest sessions and reports the local snapshot plan without reserving a workspace.
36
39
  - Guest standard scans validate the complete local archive before resolving or reserving a workspace. For an unbound renamed checkout, Apex may recover only the sole empty guest workspace when no explicit workspace name was supplied.
37
40
  - When terminal redemption returns JSON, parse stdout only and keep stderr warnings separate. An idempotent outcome resumes the existing guest session, which may already own a workspace.`;
41
+ const AUDIT_SETUP_MCP_GUIDE = `
42
+
43
+ Audit Setup beta:
44
+ - Use apex-scan-setup for a configured workspace, then apex-threat-model.
45
+ - Read the complete model and get explicit approval before approve with the reviewed sha256 and startScan true.
46
+ - These tools use the local device account. Hosted service-key MCP does not expose Audit Setup.`;
47
+ function mcpWorkflowGuide(auditSetupEnabled) {
48
+ return auditSetupEnabled ? `${MCP_WORKFLOW_GUIDE}${AUDIT_SETUP_MCP_GUIDE}` : MCP_WORKFLOW_GUIDE;
49
+ }
38
50
  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.";
39
51
  const MISSING_PROVIDER_CONNECTIONS_ERROR = "Missing provider connections. Re-run interactively or pre-connect providers.";
40
52
  const PR_MISSING_PROVIDER_CONNECTIONS_ERROR_PREFIX = "PR scans require connected provider access for ";
@@ -190,7 +202,7 @@ async function requireAuthenticated(client) {
190
202
  }
191
203
  return me;
192
204
  }
193
- function registerResources(server) {
205
+ function registerResources(server, auditSetupEnabled = false) {
194
206
  server.registerResource("apex-cli-help", "apex://help/cli", {
195
207
  title: "Apex CLI Help",
196
208
  description: "Command reference for the Apex CLI.",
@@ -199,7 +211,7 @@ function registerResources(server) {
199
211
  contents: [
200
212
  {
201
213
  uri: uri.href,
202
- text: CLI_HELP_TEXT,
214
+ text: cliHelpText(auditSetupEnabled),
203
215
  },
204
216
  ],
205
217
  }));
@@ -211,12 +223,12 @@ function registerResources(server) {
211
223
  contents: [
212
224
  {
213
225
  uri: uri.href,
214
- text: MCP_WORKFLOW_GUIDE,
226
+ text: mcpWorkflowGuide(auditSetupEnabled),
215
227
  },
216
228
  ],
217
229
  }));
218
230
  }
219
- function registerTools(server) {
231
+ function registerTools(server, auditSetupEnabled = false) {
220
232
  server.registerTool("apex-auth-status", {
221
233
  title: "Apex Auth Status",
222
234
  description: "Check whether Apex is already authenticated on this machine.",
@@ -494,6 +506,38 @@ function registerTools(server) {
494
506
  company,
495
507
  workspaceRef,
496
508
  }));
509
+ if (auditSetupEnabled)
510
+ server.registerTool("apex-scan-setup", {
511
+ title: "Prepare a scan threat model",
512
+ description: "Use an existing configured workspace to prepare a Standard or Audit threat model. Preparation can use paid setup compute. Always pause for explicit user model approval before starting the analysis scan.",
513
+ inputSchema: { cwd: z.string().optional(), company: z.string().optional(), workspaceRef: z.string(), mode: z.enum(["standard", "audit"]), setup: z.enum(["guided", "auto"]), includeFeedbackContext: z.boolean().default(true), requestId: z.string().uuid().optional() },
514
+ }, async (input) => runTool("apex-scan-setup", async () => {
515
+ const flags = { json: true, "non-interactive": true, "workspace-ref": input.workspaceRef, mode: input.mode, setup: input.setup };
516
+ if (input.company)
517
+ flags.company = input.company;
518
+ if (input.requestId)
519
+ flags["request-id"] = input.requestId;
520
+ if (!input.includeFeedbackContext)
521
+ flags["no-feedback-context"] = true;
522
+ return commandStartThreatModel(new ApexApiClient(), resolveCwd(input.cwd), flags, false);
523
+ }, value => `Threat-model draft ${String(value.draftId)}: ${String(value.state)}.`, input));
524
+ if (auditSetupEnabled)
525
+ server.registerTool("apex-threat-model", {
526
+ title: "Read or respond to an Audit Setup draft",
527
+ description: "Read status or the full model, answer an exact pending question, submit context, request changes, or explicitly approve the reviewed SHA-256 and start its scan. Auto preparation is never approval. For answer, input is JSON containing questionEventId, questionAnswers and optional text/clientMessageId. For message and request-changes, input is plain text.",
528
+ inputSchema: { cwd: z.string().optional(), draftId: z.string().uuid(), action: z.enum(["status", "next", "show", "answer", "message", "request-changes", "approve"]), input: z.string().optional(), sha256: z.string().regex(/^[0-9a-f]{64}$/).optional(), startScan: z.boolean().optional(), requestId: z.string().uuid().optional(), wait: z.boolean().optional() },
529
+ }, async (input) => runTool("apex-threat-model", async () => {
530
+ const flags = { json: true, "non-interactive": true, draft: input.draftId };
531
+ if (input.sha256)
532
+ flags.sha256 = input.sha256;
533
+ if (input.startScan)
534
+ flags["start-scan"] = true;
535
+ if (input.requestId)
536
+ flags["request-id"] = input.requestId;
537
+ if (input.wait)
538
+ flags.wait = true;
539
+ return commandThreatModel(new ApexApiClient(), resolveCwd(input.cwd), flags, input.action, input.input, false);
540
+ }, value => `Threat-model draft ${String(value.draftId)}: ${String(value.state)}.`, input));
497
541
  server.registerTool("apex-scan", {
498
542
  title: "Start Apex Scan",
499
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.",
@@ -649,6 +693,39 @@ function registerTools(server) {
649
693
  ...payload,
650
694
  };
651
695
  }, (value) => `Fetched Apex workspace findings for ${String(value.cwd)}.`, { cwd, company, workspaceRef, findingState, filters }));
696
+ server.registerTool("apex-finding-tickets", {
697
+ title: "List Apex Finding Tickets",
698
+ description: "List the Linear and Jira tickets linked to a finding. Uses the current Apex device login.",
699
+ inputSchema: { cwd: z.string().optional(), findingRef: z.string(), scanId: z.string().optional() },
700
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
701
+ }, async ({ cwd, findingRef, scanId }) => runTool("apex-finding-tickets", async () => {
702
+ const targetCwd = resolveCwd(cwd);
703
+ return { cwd: targetCwd, ...await commandFindingTickets(new ApexApiClient(), targetCwd, buildFlags({ scanId }), findingRef) };
704
+ }, () => "Loaded finding ticket links.", { cwd, findingRef, scanId }));
705
+ server.registerTool("apex-finding-link-ticket", {
706
+ title: "Link an Existing Ticket to an Apex Finding",
707
+ description: "Verify and link an existing Linear or Jira ticket using the company's connected integration. Accepts an issue key or HTTPS issue URL; does not create a ticket or replace a link. Uses the current Apex device login.",
708
+ inputSchema: {
709
+ cwd: z.string().optional(), findingRef: z.string(), scanId: z.string().optional(),
710
+ provider: z.enum(["linear", "jira"]), issueRef: z.string().trim().min(1).max(2048),
711
+ },
712
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
713
+ }, async ({ cwd, findingRef, scanId, provider, issueRef }) => runTool("apex-finding-link-ticket", async () => {
714
+ const targetCwd = resolveCwd(cwd);
715
+ return { cwd: targetCwd, ...await commandFindingLinkTicket(new ApexApiClient(), targetCwd, buildFlags({ scanId }), findingRef, provider, issueRef) };
716
+ }, () => "Linked existing ticket to finding.", { cwd, findingRef, scanId, provider, issueRef }));
717
+ server.registerTool("apex-finding-send", {
718
+ title: "Send an Apex Finding to Linear or Jira",
719
+ description: "Create a Linear or Jira ticket from a finding, using the connected company integration and the same saved destination, title, details, and Apex link as the frontend. Returns an existing ticket if already sent or linked. Configure the destination in Apex first. After a timeout, inspect ticket links and the provider before retrying. Uses device login.",
720
+ inputSchema: {
721
+ cwd: z.string().optional(), findingRef: z.string(), scanId: z.string().optional(),
722
+ provider: z.enum(["linear", "jira"]),
723
+ },
724
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
725
+ }, async ({ cwd, findingRef, scanId, provider }) => runTool("apex-finding-send", async () => {
726
+ const targetCwd = resolveCwd(cwd);
727
+ return { cwd: targetCwd, ...await commandFindingSend(new ApexApiClient(), targetCwd, buildFlags({ scanId }), findingRef, provider) };
728
+ }, () => "Sent finding to issue tracker.", { cwd, findingRef, scanId, provider }));
652
729
  server.registerTool("apex-finding-comment", {
653
730
  title: "Add Apex Finding Comment",
654
731
  description: "Add a comment or note to an Apex finding using the current Apex login.",
@@ -818,8 +895,12 @@ export async function runMcpServer() {
818
895
  version: APEX_CLI_VERSION,
819
896
  });
820
897
  const invocation = createMcpServerTelemetryInvocation();
821
- registerResources(server);
822
- registerTools(server);
898
+ const me = await getMe(new ApexApiClient(), {
899
+ signal: AbortSignal.timeout(1_500),
900
+ }).catch(() => null);
901
+ const auditSetupEnabled = hasAuditSetupBeta(me);
902
+ registerResources(server, auditSetupEnabled);
903
+ registerTools(server, auditSetupEnabled);
823
904
  const transport = new StdioServerTransport();
824
905
  emitMcpServerStarted(invocation);
825
906
  await server.connect(transport);
@@ -831,5 +912,6 @@ export const testing = {
831
912
  requireAuthenticated,
832
913
  resolveCwd,
833
914
  runTool,
915
+ mcpWorkflowGuide,
834
916
  workflowGuide: MCP_WORKFLOW_GUIDE,
835
917
  };
@@ -0,0 +1,27 @@
1
+ import { redactSensitiveText, redactSensitiveValue } from "./redaction.js";
2
+ // These top-level names describe the API error envelope, not user input.
3
+ const ONBOARDING_ERROR_FIELDS = new Set(["code", "error", "detail", "message"]);
4
+ // Public protocol enums are classification, never a free-form echo of the
5
+ // submitted code. Keep this allowlist aligned with Bedrock's onboarding errors.
6
+ const ONBOARDING_ERROR_CODES = new Set([
7
+ "INVALID_CODE_FORMAT", "INVALID_FINGERPRINT", "CODE_NOT_FOUND",
8
+ "CODE_EXPIRED", "CODE_REVOKED", "CODE_PAUSED", "CODE_EXHAUSTED",
9
+ "ACTIVE_GUEST_SESSION", "TRIAL_ALREADY_REDEEMED", "RATE_LIMITED",
10
+ "TRIAL_CAPACITY", "GUEST_SESSION_INVALID", "GUEST_SESSION_EXPIRED",
11
+ "GUEST_SESSION_CLAIMED", "GUEST_SESSION_REVOKED", "CLAIM_INVALID",
12
+ "CLAIM_EXPIRED", "ALREADY_CLAIMED", "FINGERPRINT_MISMATCH",
13
+ "CLAIM_PERSONAL_TENANT_EXISTS", "CLAIM_TARGET_UNAUTHORIZED",
14
+ "CLAIM_TARGET_PREFIX_EXHAUSTED", "CLAIM_SCAN_START_INDETERMINATE",
15
+ "CLAIM_RECONCILIATION_PENDING",
16
+ ]);
17
+ export function redactOnboardingErrorBody(body, requestCode) {
18
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
19
+ return redactSensitiveValue(body, [requestCode]);
20
+ }
21
+ return Object.fromEntries(Object.entries(body).map(([key, value]) => [
22
+ ONBOARDING_ERROR_FIELDS.has(key) ? key : redactSensitiveText(key, [requestCode]),
23
+ key === "code" && typeof value === "string" && ONBOARDING_ERROR_CODES.has(value)
24
+ ? value
25
+ : redactSensitiveValue(value, [requestCode]),
26
+ ]));
27
+ }
@@ -3,9 +3,10 @@ import { loginWithoutPersist } from "./auth.js";
3
3
  import { isClaimProofAvailable } from "./claim-proof.js";
4
4
  import { ApiError, getApiErrorCode, redactSensitiveText, } from "./api-client.js";
5
5
  import { clearCredentialsIfCurrent, clearGuestFieldsIfClaimTokenMatches, getOrCreateConfigString, loadConfig, loadCredentials, replaceCredentialsIfCurrent, updateConfig, withCredentialLifecycleLock, } from "./config.js";
6
+ import { redactOnboardingErrorBody } from "./onboarding-error-redaction.js";
6
7
  import { APEX_CLI_VERSION } from "./version.js";
7
8
  import { loadWorkspaceBinding, saveWorkspaceBinding } from "./workspace-binding.js";
8
- const ONBOARDING_CODE_PATTERN = /^SPBT-[A-Z0-9]{2,24}-[A-Z0-9]{8}$/;
9
+ const ONBOARDING_CODE_PATTERN = /^[A-Z0-9_-]{1,64}$/;
9
10
  const ONBOARDING_FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
10
11
  const DEFAULT_CLIENT_NAME = "apex";
11
12
  const HOSTED_APEX_ORIGIN = "https://ai.cantina.xyz";
@@ -541,8 +542,22 @@ async function redeemOnboardingCodeUnlocked(client, options = {}) {
541
542
  : {}),
542
543
  };
543
544
  }
544
- export function redeemOnboardingCode(client, options = {}) {
545
- return withCredentialLifecycleLock(() => redeemOnboardingCodeUnlocked(client, options));
545
+ export async function redeemOnboardingCode(client, options = {}) {
546
+ const code = resolveOnboardingCode(options.code);
547
+ return withCredentialLifecycleLock(() => redeemOnboardingCodeUnlocked(client, { ...options, code })).catch((error) => {
548
+ // Custom codes have no recognizable prefix. Redact the exact request value
549
+ // at this boundary without retaining it in credentials or global state.
550
+ const message = redactSensitiveText(error instanceof Error ? error.message : String(error), [code]);
551
+ if (error instanceof ApiError) {
552
+ throw new ApiError(message, error.status, redactOnboardingErrorBody(error.body, code));
553
+ }
554
+ if (error instanceof AggregateError) {
555
+ throw new AggregateError(error.errors.map(e => new Error(redactSensitiveText(e instanceof Error ? e.message : String(e), [code]))), message);
556
+ }
557
+ // Rebuild ordinary errors so causes, stacks, and attached request fields
558
+ // cannot retain the custom code after its message has been sanitized.
559
+ throw new Error(message);
560
+ });
546
561
  }
547
562
  async function claimOnboardingSessionUnlocked(client, cwd, options = {}) {
548
563
  let expectedCredentials = await loadCredentials();
package/dist/redaction.js CHANGED
@@ -1,23 +1,30 @@
1
1
  const APEX_SECRET_PATTERN = /(apex_(?:ct|at|rt|dc)_)[A-Za-z0-9._~+/=-]+/gi;
2
- const ONBOARDING_CODE_PATTERN = /SPBT-[A-Z0-9]{2,24}-[A-Z0-9]{8}/gi;
2
+ const ONBOARDING_CODE_PATTERN = /(APEX|SPBT)-[A-Z0-9]{2,24}-[A-Z0-9]{8}/gi;
3
3
  // Hex-only boundaries avoid redacting a 64-character window from a longer hex
4
4
  // value while still catching fingerprints next to punctuation or prose.
5
5
  const ONBOARDING_FINGERPRINT_PATTERN = /(?<![A-Fa-f0-9])[A-Fa-f0-9]{64}(?![A-Fa-f0-9])/g;
6
- export function redactSensitiveText(value) {
7
- return value
6
+ export function redactSensitiveText(value, requestSecrets = []) {
7
+ let redacted = value;
8
+ for (const secret of requestSecrets) {
9
+ if (!secret)
10
+ continue;
11
+ const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12
+ redacted = redacted.replace(new RegExp(`(?<![A-Z0-9_-])${escaped}(?![A-Z0-9_-])`, "gi"), "[REDACTED]");
13
+ }
14
+ return redacted
8
15
  .replace(APEX_SECRET_PATTERN, (_match, prefix) => `${prefix}[REDACTED]`)
9
- .replace(ONBOARDING_CODE_PATTERN, "SPBT-[REDACTED]")
16
+ .replace(ONBOARDING_CODE_PATTERN, (_match, prefix) => `${prefix.toUpperCase()}-[REDACTED]`)
10
17
  .replace(ONBOARDING_FINGERPRINT_PATTERN, "[REDACTED]");
11
18
  }
12
19
  export function containsSensitiveText(value) {
13
20
  return redactSensitiveText(value) !== value;
14
21
  }
15
22
  /** Redacts every string key and value in an API error body without mutating it. */
16
- export function redactSensitiveValue(value) {
23
+ export function redactSensitiveValue(value, requestSecrets = []) {
17
24
  const seen = new WeakSet();
18
25
  const visit = (current) => {
19
26
  if (typeof current === "string")
20
- return redactSensitiveText(current);
27
+ return redactSensitiveText(current, requestSecrets);
21
28
  if (!current || typeof current !== "object")
22
29
  return current;
23
30
  if (seen.has(current))
@@ -26,7 +33,7 @@ export function redactSensitiveValue(value) {
26
33
  if (Array.isArray(current))
27
34
  return current.map(visit);
28
35
  return Object.fromEntries(Object.entries(current).map(([key, nested]) => [
29
- redactSensitiveText(key),
36
+ redactSensitiveText(key, requestSecrets),
30
37
  visit(nested),
31
38
  ]));
32
39
  };
package/dist/scan.js CHANGED
@@ -11,6 +11,19 @@ const ACTIVE_SCAN_STATUSES = new Set([
11
11
  function asRecord(value) {
12
12
  return value && typeof value === "object" ? value : null;
13
13
  }
14
+ const KERNEL_SCAN_IDENTIFIER_KEY = /^kernel_?scan_?id$/i;
15
+ export function stripKernelScanIdentifiers(value) {
16
+ if (Array.isArray(value)) {
17
+ return value.map(stripKernelScanIdentifiers);
18
+ }
19
+ const record = asRecord(value);
20
+ if (!record) {
21
+ return value;
22
+ }
23
+ return Object.fromEntries(Object.entries(record)
24
+ .filter(([key]) => !KERNEL_SCAN_IDENTIFIER_KEY.test(key))
25
+ .map(([key, entry]) => [key, stripKernelScanIdentifiers(entry)]));
26
+ }
14
27
  function readString(...values) {
15
28
  for (const value of values) {
16
29
  if (typeof value === "string" && value.trim().length > 0) {
@@ -30,6 +43,14 @@ function readNumber(...values) {
30
43
  }
31
44
  return null;
32
45
  }
46
+ function readBoolean(...values) {
47
+ for (const value of values) {
48
+ if (typeof value === "boolean") {
49
+ return value;
50
+ }
51
+ }
52
+ return null;
53
+ }
33
54
  function readScanMode(...values) {
34
55
  const mode = readString(...values);
35
56
  return mode === "ultra" ? "audit" : mode;
@@ -55,13 +76,12 @@ function normalizeScanRecord(payload) {
55
76
  if (!record) {
56
77
  return null;
57
78
  }
58
- const scanId = readString(record.scanId, record.id, record.apexScanId, record.bedrockScanId, record.kernelScanId);
79
+ const scanId = readString(record.scanId, record.id, record.apexScanId, record.bedrockScanId);
59
80
  if (!scanId) {
60
81
  return null;
61
82
  }
62
83
  return {
63
84
  scanId,
64
- kernelScanId: readString(record.kernelScanId, record.kernel_scan_id),
65
85
  apexScanId: readString(record.apexScanId, record.apex_scan_id),
66
86
  bedrockScanId: readString(record.bedrockScanId, record.bedrock_scan_id),
67
87
  displayName: readString(record.displayName, record.display_name, record.name),
@@ -75,6 +95,29 @@ function normalizeScanRecord(payload) {
75
95
  finishedAt: readTimestamp(record, "finishedAt", "finished_at", "completedAt", "completed_at", "cancelledAt", "cancelled_at"),
76
96
  };
77
97
  }
98
+ export function normalizeScanStartResponse(payload) {
99
+ const record = asRecord(stripKernelScanIdentifiers(payload));
100
+ if (!record) {
101
+ throw new Error("Scan response was not a JSON object.");
102
+ }
103
+ const scanId = readString(record.apexScanId, record.apex_scan_id, record.bedrockScanId, record.bedrock_scan_id, record.scanId, record.scan_id);
104
+ const workspaceId = readString(record.workspaceId, record.workspace_id);
105
+ const apexScanId = readString(record.apexScanId, record.apex_scan_id);
106
+ const bedrockScanId = readString(record.bedrockScanId, record.bedrock_scan_id);
107
+ const scanUrl = readString(record.scanUrl, record.scan_url);
108
+ const fullScanRecommended = readBoolean(record.fullScanRecommended);
109
+ const fullScanRecommendation = readString(record.fullScanRecommendation);
110
+ return {
111
+ ...(workspaceId ? { workspaceId } : {}),
112
+ ...(scanId ? { scanId } : {}),
113
+ ...(apexScanId ? { apexScanId } : {}),
114
+ ...(bedrockScanId ? { bedrockScanId } : {}),
115
+ status: readString(record.status) ?? "unknown",
116
+ ...(scanUrl ? { scanUrl } : {}),
117
+ ...(fullScanRecommended !== null ? { fullScanRecommended } : {}),
118
+ ...(fullScanRecommendation ? { fullScanRecommendation } : {}),
119
+ };
120
+ }
78
121
  async function requestWithFallbacks(client, attempts) {
79
122
  let lastApiError = null;
80
123
  for (const attempt of attempts) {
@@ -95,7 +138,7 @@ async function requestWithFallbacks(client, attempts) {
95
138
  throw new Error("No scan routes were available.");
96
139
  }
97
140
  export function getTrackedScanId(scan) {
98
- const trackedScanId = readString(scan.apexScanId, scan.bedrockScanId, scan.scanId, scan.kernelScanId);
141
+ const trackedScanId = readString(scan.apexScanId, scan.bedrockScanId, scan.scanId);
99
142
  if (!trackedScanId) {
100
143
  throw new Error("Scan response did not include an Apex scan id.");
101
144
  }
@@ -129,7 +172,6 @@ function mergeScanProgress(scan, progress) {
129
172
  : scan.finishedAt;
130
173
  return {
131
174
  ...scan,
132
- kernelScanId: progress.kernelScanId ?? scan.kernelScanId,
133
175
  status,
134
176
  startedAt,
135
177
  finishedAt,
@@ -152,7 +194,7 @@ export function findMostRelevantActiveScan(scans) {
152
194
  return scans.find((scan) => isActiveScanStatus(scan.status)) ?? null;
153
195
  }
154
196
  export function matchesScanId(scan, scanId) {
155
- return [scan.scanId, scan.apexScanId, scan.bedrockScanId, scan.kernelScanId].includes(scanId);
197
+ return [scan.scanId, scan.apexScanId, scan.bedrockScanId].includes(scanId);
156
198
  }
157
199
  export function selectDefaultScanToCancel(scans, lastScanId) {
158
200
  const activeScans = scans.filter((scan) => isActiveScanStatus(scan.status));
@@ -179,7 +221,8 @@ export async function fetchWorkspaceScans(client, workspaceId) {
179
221
  return Promise.all(scans.map((scan) => reconcileActiveScanStatus(client, scan)));
180
222
  }
181
223
  export async function fetchScanProgress(client, scanId) {
182
- return client.request(`/api/cli/v1/scans/${encodeURIComponent(scanId)}/progress`);
224
+ const response = await client.request(`/api/cli/v1/scans/${encodeURIComponent(scanId)}/progress`);
225
+ return stripKernelScanIdentifiers(response);
183
226
  }
184
227
  export async function cancelScan(client, scanId) {
185
228
  const encodedScanId = encodeURIComponent(scanId);
@@ -205,7 +248,6 @@ export async function cancelScan(client, scanId) {
205
248
  }
206
249
  return {
207
250
  scanId,
208
- kernelScanId: null,
209
251
  apexScanId: null,
210
252
  bedrockScanId: null,
211
253
  displayName: null,
@@ -2,6 +2,7 @@ export const SERVICE_API_SCOPES = [
2
2
  "scans:create",
3
3
  "scans:read",
4
4
  "findings:read",
5
+ "findings:write",
5
6
  ];
6
7
  export function isServiceApiScope(value) {
7
8
  return (typeof value === "string" &&
@@ -13,7 +14,7 @@ function serviceApiKeysPath(companyId) {
13
14
  function serviceApiKeyMetadata(key) {
14
15
  const scopes = Array.isArray(key.scopes)
15
16
  ? [...new Set(key.scopes.filter(isServiceApiScope))]
16
- : [...SERVICE_API_SCOPES];
17
+ : ["scans:create", "scans:read", "findings:read"];
17
18
  return {
18
19
  id: key.id,
19
20
  name: key.name,
package/dist/shell.js CHANGED
@@ -1,5 +1,6 @@
1
- import { SHELL_HELP_TEXT } from "./help.js";
2
- import { commandCancelScan, commandClaim, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingFeedback, commandFindingFixReview, commandFindings, commandWorkspaceFindings, commandLogout, commandScan, commandScans, commandStatus, commandTelemetry, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, initializeInteractiveSession, openCurrentApexView, } from "./commands.js";
1
+ import { commandStartThreatModel, hasAuditSetupBeta } from "./threat-model.js";
2
+ import { shellHelpText } from "./help.js";
3
+ import { commandCancelScan, commandClaim, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindingComment, commandFindingTickets, commandFindingLinkTicket, commandFindingSend, commandFindingFeedback, commandFindingFixReview, commandFindings, commandWorkspaceFindings, commandLogout, commandScan, commandScans, commandStatus, commandTelemetry, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, initializeInteractiveSession, openCurrentApexView, } from "./commands.js";
3
4
  import { withFlag } from "./args.js";
4
5
  import { printCompanyDetails, printInteractiveSessionSummary, printSourceList, printWorkspaceDetails, } from "./output.js";
5
6
  import { readLine } from "./prompt.js";
@@ -12,7 +13,7 @@ const SHELL_COMPLETIONS = [
12
13
  { command: "scans" },
13
14
  {
14
15
  command: "findings",
15
- args: ["workspace", "comment", "feedback", "fix-review"],
16
+ args: ["workspace", "comment", "feedback", "fix-review", "tickets", "link-ticket", "send"],
16
17
  },
17
18
  { command: "export" },
18
19
  { command: "workspaces" },
@@ -133,8 +134,8 @@ export function getShellCompletionCandidates(input) {
133
134
  function clearTerminal() {
134
135
  process.stdout.write("\x1Bc");
135
136
  }
136
- function printShellHelp() {
137
- process.stdout.write(`${SHELL_HELP_TEXT}\n`);
137
+ function printShellHelp(session) {
138
+ process.stdout.write(`${shellHelpText(hasAuditSetupBeta(session.me, session.company))}\n`);
138
139
  }
139
140
  export async function runInteractiveShell(client, cwd, initialFlags) {
140
141
  let shellFlags = { ...initialFlags };
@@ -192,7 +193,7 @@ async function runShellCommandWithTelemetry(client, cwd, parsed, shellFlags, ses
192
193
  async function runShellCommand(client, cwd, parsed, shellFlags, session) {
193
194
  switch (parsed.command) {
194
195
  case "help":
195
- printShellHelp();
196
+ printShellHelp(session);
196
197
  return {};
197
198
  case "credits":
198
199
  await commandCredits(client, cwd, shellFlags);
@@ -224,6 +225,10 @@ async function runShellCommand(client, cwd, parsed, shellFlags, session) {
224
225
  : mode
225
226
  ? withFlag(shellFlags, "mode", mode)
226
227
  : shellFlags;
228
+ if (commandFlags.setup !== undefined) {
229
+ await commandStartThreatModel(client, cwd, commandFlags);
230
+ return {};
231
+ }
227
232
  const result = await commandScan(client, cwd, commandFlags);
228
233
  return {
229
234
  session: {
@@ -248,6 +253,24 @@ async function runShellCommand(client, cwd, parsed, shellFlags, session) {
248
253
  await commandScans(client, cwd, shellFlags);
249
254
  return {};
250
255
  case "findings": {
256
+ if (parsed.args[0] === "send") {
257
+ if (parsed.args.length !== 3)
258
+ throw new Error("Usage: /findings send <finding-ref> <linear|jira>");
259
+ await commandFindingSend(client, cwd, shellFlags, parsed.args[1], parsed.args[2]);
260
+ return {};
261
+ }
262
+ if (parsed.args[0] === "tickets") {
263
+ if (parsed.args.length !== 2)
264
+ throw new Error("Usage: /findings tickets <finding-ref>");
265
+ await commandFindingTickets(client, cwd, shellFlags, parsed.args[1]);
266
+ return {};
267
+ }
268
+ if (parsed.args[0] === "link-ticket") {
269
+ if (parsed.args.length !== 4)
270
+ throw new Error("Usage: /findings link-ticket <finding-ref> <linear|jira> <issue-key|url>");
271
+ await commandFindingLinkTicket(client, cwd, shellFlags, parsed.args[1], parsed.args[2], parsed.args[3]);
272
+ return {};
273
+ }
251
274
  if (parsed.args[0] === "workspace") {
252
275
  await commandWorkspaceFindings(client, cwd, parsed.args.length > 1
253
276
  ? withFlag(shellFlags, "filter", parsed.args.slice(1))