@cantinasecurity/apex-cli 0.1.27 → 0.1.33

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.
@@ -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))
package/dist/telemetry.js CHANGED
@@ -11,6 +11,7 @@ const TELEMETRY_SESSION_ID = randomUUID();
11
11
  const TELEMETRY_TIMEOUT_MS = 750;
12
12
  const telemetryContext = new AsyncLocalStorage();
13
13
  const KNOWN_CLI_COMMANDS = [
14
+ "threat-model",
14
15
  "cancel-scan",
15
16
  "connect",
16
17
  "claim",
@@ -282,7 +283,7 @@ function sanitizeShellArgs(parsed) {
282
283
  commentLengthBucket: bucketLength(parsed.args.slice(feedbackStatus === "invalid" ? 4 : 3).join(" ")),
283
284
  };
284
285
  }
285
- if (action === "fix-review") {
286
+ if (["fix-review", "tickets", "link-ticket", "send"].includes(action)) {
286
287
  return {
287
288
  findingRefProvided: Boolean(parsed.args[1]),
288
289
  };
@@ -330,7 +331,7 @@ function safeCliSubcommand(parsed) {
330
331
  case "export":
331
332
  return safeEnum(subcommand, ["findings"]);
332
333
  case "findings":
333
- return safeEnum(subcommand, ["comment", "feedback", "fix-review"]);
334
+ return safeEnum(subcommand, ["comment", "feedback", "fix-review", "tickets", "link-ticket", "send"]);
334
335
  case "setup":
335
336
  return safeEnum(subcommand, ["all", "codex", "claude", "copilot"]);
336
337
  case "service-key":
@@ -404,6 +405,7 @@ export function sanitizeMcpInput(input = {}) {
404
405
  "github",
405
406
  "gitlab",
406
407
  ]),
408
+ ticketProvider: safeEnum(typeof input.provider === "string" ? input.provider : null, ["linear", "jira"]),
407
409
  feedbackStatus: safeEnum(typeof input.status === "string" ? input.status : null, [
408
410
  "valid",
409
411
  "invalid",
@@ -0,0 +1,435 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { stripVTControlCharacters } from "node:util";
6
+ import { z } from "zod";
7
+ import { ApiError } from "./api-client.js";
8
+ import { getMe } from "./auth.js";
9
+ import { canPromptInteractively, getFlagList, getFlagString, } from "./args.js";
10
+ import { loadConfig } from "./config.js";
11
+ import { chooseCompany } from "./session.js";
12
+ import { fetchCompanyWorkspaces, findWorkspaceByRef } from "./workspaces.js";
13
+ import { loadWorkspaceBinding } from "./workspace-binding.js";
14
+ import { confirm, promptText } from "./prompt.js";
15
+ const uuid = z.string().uuid();
16
+ export function hasAuditSetupBeta(me, company) {
17
+ return company
18
+ ? company.betaFeatures?.auditSetup === true
19
+ : me?.companies.some((candidate) => candidate.betaFeatures?.auditSetup === true) === true;
20
+ }
21
+ function requireAuditSetupBeta(me, company) {
22
+ if (!hasAuditSetupBeta(me, company)) {
23
+ throw new ThreatModelUsageError("Unknown command or option");
24
+ }
25
+ }
26
+ const field = z.object({
27
+ id: z.string(),
28
+ label: z.string(),
29
+ description: z.string().optional(),
30
+ kind: z.enum(["single_select", "multi_select", "short_text", "long_text"]),
31
+ required: z.boolean(),
32
+ options: z.array(z.object({
33
+ value: z.string(),
34
+ label: z.string(),
35
+ description: z.string().optional(),
36
+ })),
37
+ defaultValue: z.union([z.string(), z.array(z.string())]).optional(),
38
+ });
39
+ const stateSchema = z
40
+ .object({
41
+ contractVersion: z.literal(1),
42
+ draftId: uuid,
43
+ company: z.object({ id: z.string() }).passthrough(),
44
+ workspace: z
45
+ .object({ id: z.string(), ref: z.string().nullable(), name: z.string() })
46
+ .passthrough(),
47
+ state: z.enum([
48
+ "preparing",
49
+ "working",
50
+ "needs_input",
51
+ "needs_review",
52
+ "launch_pending",
53
+ "launch_retryable",
54
+ "launched",
55
+ "failed",
56
+ "cancelled",
57
+ "expired",
58
+ ]),
59
+ nextAction: z.string(),
60
+ question: z
61
+ .object({
62
+ questionEventId: z.number().int().positive(),
63
+ title: z.string(),
64
+ context: z.string().optional(),
65
+ contextSections: z
66
+ .array(z.object({
67
+ title: z.string(),
68
+ description: z.string().optional(),
69
+ bullets: z.array(z.string()),
70
+ }))
71
+ .optional(),
72
+ fields: z.array(field),
73
+ allowAttachments: z.boolean(),
74
+ })
75
+ .passthrough()
76
+ .nullable()
77
+ .optional(),
78
+ model: z
79
+ .object({
80
+ id: uuid,
81
+ sha256: z.string().regex(/^[0-9a-f]{64}$/),
82
+ content: z.string().nullable(),
83
+ })
84
+ .passthrough()
85
+ .nullable()
86
+ .optional(),
87
+ pollAfterMs: z.number().nullable().optional(),
88
+ scanId: uuid.optional(),
89
+ lastActivityAt: z.string().optional(),
90
+ })
91
+ .passthrough();
92
+ export class ThreatModelUsageError extends Error {
93
+ }
94
+ export function threatModelExitCode(error) {
95
+ if (error instanceof ThreatModelUsageError || error instanceof z.ZodError)
96
+ return 2;
97
+ if (error instanceof ApiError)
98
+ return [401, 403].includes(error.status)
99
+ ? 3
100
+ : error.status === 409
101
+ ? 4
102
+ : error.status === 400 || error.status === 413
103
+ ? 2
104
+ : 1;
105
+ return 1;
106
+ }
107
+ export function terminalText(text) {
108
+ return stripVTControlCharacters(text).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
109
+ }
110
+ function required(flags, key) {
111
+ const value = getFlagString(flags, key);
112
+ if (!value)
113
+ throw new ThreatModelUsageError(`--${key} is required`);
114
+ return value;
115
+ }
116
+ async function requireAccount(client) {
117
+ const me = await getMe(client);
118
+ if (!me)
119
+ throw new ApiError("Sign in with apex login first", 401, {});
120
+ return me;
121
+ }
122
+ function output(value, flags) {
123
+ if (flags.json)
124
+ process.stdout.write(`${JSON.stringify(value)}\n`);
125
+ else {
126
+ process.stdout.write(terminalText(`Workspace: ${value.workspace.ref ?? value.workspace.name}\nDraft: ${value.draftId}\nState: ${value.state}\n`));
127
+ if (value.question)
128
+ process.stdout.write(terminalText(`${value.question.title}\n${value.question.context ?? ""}\n${JSON.stringify(value.question, null, 2)}\n`));
129
+ if (value.lastActivityAt)
130
+ process.stdout.write(`Last activity: ${terminalText(value.lastActivityAt)}\n`);
131
+ const next = value.state === "launched"
132
+ ? `apex status --scan ${value.scanId} --json`
133
+ : value.state === "needs_review"
134
+ ? `apex threat-model show --draft ${value.draftId}`
135
+ : value.state === "needs_input"
136
+ ? `apex threat-model answer --draft ${value.draftId}`
137
+ : value.state === "launch_retryable"
138
+ ? `apex threat-model approve --draft ${value.draftId} --sha256 <reviewed-hash> --start-scan`
139
+ : `apex threat-model next --draft ${value.draftId} --wait`;
140
+ process.stdout.write(`Next: ${next}\n`);
141
+ }
142
+ }
143
+ async function readInput(flags, supplied) {
144
+ if (supplied !== undefined)
145
+ return supplied;
146
+ const file = required(flags, "file");
147
+ const limit = 600_000;
148
+ if (file !== "-") {
149
+ const content = await readFile(file, "utf8");
150
+ if (Buffer.byteLength(content) > limit)
151
+ throw new ThreatModelUsageError("Input exceeds 600 KB");
152
+ return content;
153
+ }
154
+ const chunks = [];
155
+ let size = 0;
156
+ for await (const chunk of process.stdin) {
157
+ const buffer = Buffer.from(chunk);
158
+ size += buffer.length;
159
+ if (size > limit)
160
+ throw new ThreatModelUsageError("Input exceeds 600 KB");
161
+ chunks.push(buffer);
162
+ }
163
+ return Buffer.concat(chunks).toString("utf8");
164
+ }
165
+ async function mutate(client, flags, endpoint, payload, files = [], idField = "requestId") {
166
+ const me = await requireAccount(client);
167
+ const mimeTypes = {
168
+ ".png": "image/png",
169
+ ".jpg": "image/jpeg",
170
+ ".jpeg": "image/jpeg",
171
+ ".gif": "image/gif",
172
+ ".webp": "image/webp",
173
+ ".pdf": "application/pdf",
174
+ ".txt": "text/plain",
175
+ ".md": "text/markdown",
176
+ ".json": "application/json",
177
+ };
178
+ const uploads = await Promise.all(files.map(async (file) => {
179
+ const info = await stat(file);
180
+ if (!info.isFile() || info.size > 10 * 1024 * 1024)
181
+ throw new ThreatModelUsageError("Attachments must be regular files of at most 10 MB");
182
+ return {
183
+ name: path.basename(file),
184
+ bytes: await readFile(file),
185
+ type: mimeTypes[path.extname(file).toLowerCase()] ??
186
+ "application/octet-stream",
187
+ };
188
+ }));
189
+ if (uploads.length > 8 ||
190
+ uploads.some((f) => f.bytes.length > 10 * 1024 * 1024) ||
191
+ uploads.reduce((n, f) => n + f.bytes.length, 0) > 25 * 1024 * 1024)
192
+ throw new ThreatModelUsageError("Audit Setup accepts up to 8 attachments, 10 MB each and 25 MB total");
193
+ const config = await loadConfig();
194
+ const identity = createHash("sha256")
195
+ .update(JSON.stringify({
196
+ origin: config.baseUrl,
197
+ user: me.user.id,
198
+ endpoint,
199
+ payload,
200
+ uploads: uploads.map((f) => ({
201
+ name: f.name,
202
+ sha256: createHash("sha256").update(f.bytes).digest("hex"),
203
+ })),
204
+ }))
205
+ .digest("hex");
206
+ const directory = path.join(os.homedir(), ".config", "apex", "pending-threat-model-requests");
207
+ const receiptFile = path.join(directory, `${identity}.json`);
208
+ let requestId = getFlagString(flags, "request-id") ??
209
+ (typeof payload[idField] === "string"
210
+ ? payload[idField]
211
+ : null);
212
+ const persist = !requestId;
213
+ if (persist) {
214
+ await mkdir(directory, { recursive: true, mode: 0o700 });
215
+ requestId = randomUUID();
216
+ try {
217
+ await writeFile(receiptFile, JSON.stringify({ requestId }), {
218
+ flag: "wx",
219
+ mode: 0o600,
220
+ });
221
+ }
222
+ catch (error) {
223
+ if (error.code !== "EEXIST")
224
+ throw error;
225
+ requestId = JSON.parse(await readFile(receiptFile, "utf8")).requestId;
226
+ }
227
+ }
228
+ uuid.parse(requestId);
229
+ const body = { ...payload, [idField]: requestId };
230
+ const form = uploads.length ? new FormData() : null;
231
+ if (form) {
232
+ form.set("message", JSON.stringify(body));
233
+ for (const upload of uploads)
234
+ form.append("files", new Blob([new Uint8Array(upload.bytes)], { type: upload.type }), upload.name);
235
+ }
236
+ if (!flags.json)
237
+ process.stderr.write(`Sending request ${requestId}…\n`);
238
+ const response = stateSchema.parse(await client.request(endpoint, {
239
+ method: "POST",
240
+ ...(form ? { body: form } : { json: body }),
241
+ signal: AbortSignal.timeout(300_000),
242
+ }));
243
+ if (persist &&
244
+ !["launch_pending", "launch_retryable"].includes(response.state))
245
+ await rm(receiptFile, { force: true });
246
+ return response;
247
+ }
248
+ export async function readThreatModel(client, draftId, model = false, timeoutMs = 60_000) {
249
+ uuid.parse(draftId);
250
+ return stateSchema.parse(await client.request(`/api/cli/v1/threat-models/${draftId}${model ? "/model" : ""}`, { signal: AbortSignal.timeout(Math.max(1, timeoutMs)) }));
251
+ }
252
+ export async function waitForThreatModel(client, draftId, timeoutSeconds = 60) {
253
+ if (!Number.isFinite(timeoutSeconds) ||
254
+ timeoutSeconds < 1 ||
255
+ timeoutSeconds > 3600)
256
+ throw new ThreatModelUsageError("--timeout must be between 1 and 3600 seconds");
257
+ const deadline = Date.now() + timeoutSeconds * 1000;
258
+ let state = await readThreatModel(client, draftId, false, deadline - Date.now());
259
+ while (["preparing", "working", "launch_pending"].includes(state.state)) {
260
+ const delay = Math.min(Math.max(state.pollAfterMs ?? 2000, 500), 10_000, Math.max(0, deadline - Date.now()));
261
+ if (delay <= 0)
262
+ return { ...state, timedOut: true };
263
+ await new Promise((resolve) => setTimeout(resolve, delay));
264
+ if (Date.now() >= deadline)
265
+ return { ...state, timedOut: true };
266
+ try {
267
+ state = await readThreatModel(client, draftId, false, deadline - Date.now());
268
+ }
269
+ catch (error) {
270
+ if (Date.now() >= deadline &&
271
+ error instanceof Error &&
272
+ ["TimeoutError", "AbortError"].includes(error.name))
273
+ return { ...state, timedOut: true };
274
+ throw error;
275
+ }
276
+ }
277
+ return state;
278
+ }
279
+ async function interactiveAnswers(state) {
280
+ if (!state.question)
281
+ throw new ThreatModelUsageError("There is no pending question. Read the current draft state.");
282
+ output(state, {});
283
+ const answers = {};
284
+ for (const field of state.question.fields) {
285
+ process.stdout.write(terminalText(`${field.label}\n${field.description ?? ""}\n`));
286
+ for (const [index, option] of field.options.entries())
287
+ process.stdout.write(terminalText(`${index + 1}. ${option.label} (${option.value}) ${option.description ?? ""}\n`));
288
+ if (field.defaultValue !== undefined)
289
+ process.stdout.write(terminalText(`Suggestion: ${JSON.stringify(field.defaultValue)}. Type = to explicitly use it.\n`));
290
+ const value = await promptText("Answer (multi-select: comma-separated option numbers or values):");
291
+ if (value === "=" && field.defaultValue !== undefined) {
292
+ answers[field.id] = field.defaultValue;
293
+ continue;
294
+ }
295
+ if (!value && !field.required)
296
+ continue;
297
+ if (!value)
298
+ throw new ThreatModelUsageError(`${field.label} requires an answer; nothing was submitted`);
299
+ const optionValue = (v) => /^\d+$/.test(v) ? (field.options[Number(v) - 1]?.value ?? v) : v;
300
+ answers[field.id] =
301
+ field.kind === "multi_select"
302
+ ? value.split(",").map((v) => optionValue(v.trim()))
303
+ : field.kind === "single_select"
304
+ ? optionValue(value)
305
+ : value;
306
+ }
307
+ process.stdout.write(terminalText(`${JSON.stringify(answers, null, 2)}\n`));
308
+ if (!(await confirm("Submit these answers?", false)))
309
+ throw new ThreatModelUsageError("Answers were not submitted");
310
+ return {
311
+ questionEventId: state.question.questionEventId,
312
+ questionAnswers: answers,
313
+ text: "",
314
+ };
315
+ }
316
+ export async function commandThreatModel(client, cwd, flags, action, supplied, emit = true) {
317
+ const draftId = uuid.parse(required(flags, "draft"));
318
+ requireAuditSetupBeta(await requireAccount(client));
319
+ let state;
320
+ if (action === "status" || action === "next") {
321
+ state =
322
+ action === "next" && flags.wait
323
+ ? await waitForThreatModel(client, draftId, Number(getFlagString(flags, "timeout") ?? 60))
324
+ : await readThreatModel(client, draftId);
325
+ }
326
+ else if (action === "show") {
327
+ state = await readThreatModel(client, draftId, true);
328
+ if (!flags.json) {
329
+ const content = state.model?.content;
330
+ if (typeof content !== "string")
331
+ throw new ThreatModelUsageError("The complete model is not ready");
332
+ process.stderr.write(`SHA-256: ${state.model.sha256}\n`);
333
+ process.stdout.write(process.stdout.isTTY ? terminalText(content) : content);
334
+ return state;
335
+ }
336
+ }
337
+ else if (action === "answer" || action === "message") {
338
+ const payload = action === "answer"
339
+ ? !getFlagString(flags, "file") &&
340
+ supplied === undefined &&
341
+ canPromptInteractively(flags)
342
+ ? await interactiveAnswers(await readThreatModel(client, draftId))
343
+ : JSON.parse(await readInput(flags, supplied))
344
+ : { text: await readInput(flags, supplied) };
345
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
346
+ throw new ThreatModelUsageError("Answer file must contain a JSON object");
347
+ state = await mutate(client, flags, `/api/cli/v1/threat-models/${draftId}/${action === "answer" ? "answers" : "messages"}`, payload, getFlagList(flags, "attachment"), "clientMessageId");
348
+ }
349
+ else if (action === "approve" || action === "request-changes") {
350
+ const sha256 = z
351
+ .string()
352
+ .regex(/^[0-9a-f]{64}$/)
353
+ .parse(required(flags, "sha256"));
354
+ if (action === "approve" && flags["start-scan"] !== true)
355
+ throw new ThreatModelUsageError("Approval requires --start-scan. Read the complete model first.");
356
+ state = await mutate(client, flags, `/api/cli/v1/threat-models/${draftId}/review`, {
357
+ decision: action === "approve" ? "approve" : "request_changes",
358
+ sha256,
359
+ ...(action === "approve"
360
+ ? { startScan: true }
361
+ : { feedback: await readInput(flags, supplied) }),
362
+ });
363
+ }
364
+ else
365
+ throw new ThreatModelUsageError("Usage: apex threat-model <status|next|answer|message|show|request-changes|approve> --draft <id>");
366
+ if (emit)
367
+ output(state, flags);
368
+ return state;
369
+ }
370
+ 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"));
373
+ if (["repo", "pr", "pr-path", "source-mode", "force", "workspace-name"].some((key) => flags[key] !== undefined))
374
+ throw new ThreatModelUsageError("Audit Setup uses the configured workspace repositories and context; local source and force flags are not supported");
375
+ const me = await requireAccount(client);
376
+ const binding = await loadWorkspaceBinding(cwd);
377
+ const company = await chooseCompany(me, flags, binding);
378
+ requireAuditSetupBeta(me, company);
379
+ const ref = getFlagString(flags, "workspace-ref") ??
380
+ (binding?.companyId === company.id ? binding.workspaceId : null);
381
+ if (!ref)
382
+ throw new ThreatModelUsageError("Select --workspace-ref or bind a workspace with apex workspace use");
383
+ const response = await fetchCompanyWorkspaces(client, company.id);
384
+ const workspace = findWorkspaceByRef(response.workspaces, ref);
385
+ if (!workspace)
386
+ throw new ThreatModelUsageError("Workspace selection is missing or ambiguous. Use an exact workspace reference.");
387
+ if (canPromptInteractively(flags)) {
388
+ process.stdout.write(terminalText(`Prepare ${mode} scan in ${company.handle ?? company.id}/${workspace.prefix ?? workspace.name}, ${setup} mode. Feedback context: ${flags["no-feedback-context"] ? "disabled" : "included when available"}.\nUses the workspace's saved repositories, scope, and context. Preparation uses setup compute; the analysis scan starts only after explicit model approval.\n`));
389
+ if (!(await confirm("Start threat-model preparation?", false)))
390
+ throw new ThreatModelUsageError("Setup was not started");
391
+ }
392
+ let state = await mutate(client, flags, `/api/cli/v1/local-workspaces/${encodeURIComponent(workspace.workspaceId)}/scan-requests`, { mode, setup, includeFeedbackMemory: !flags["no-feedback-context"] });
393
+ if (emit)
394
+ output(state, flags);
395
+ if (!canPromptInteractively(flags))
396
+ return state;
397
+ const decisionFlags = { ...flags };
398
+ delete decisionFlags["request-id"];
399
+ // Each decision remains explicit. Closing the terminal leaves the server-owned draft intact.
400
+ while (true) {
401
+ if (["preparing", "working", "launch_pending"].includes(state.state)) {
402
+ process.stderr.write("Waiting for Audit Setup. You can exit and resume with the draft ID.\n");
403
+ state = await waitForThreatModel(client, state.draftId);
404
+ output(state, flags);
405
+ if (state.timedOut)
406
+ return state;
407
+ }
408
+ else if (state.state === "needs_input") {
409
+ state = await commandThreatModel(client, cwd, { ...decisionFlags, draft: state.draftId }, "answer");
410
+ }
411
+ else if (state.state === "needs_review") {
412
+ const review = await commandThreatModel(client, cwd, { ...decisionFlags, draft: state.draftId }, "show");
413
+ if (await confirm("Approve this exact threat model and start the scan?", false)) {
414
+ state = await commandThreatModel(client, cwd, {
415
+ ...decisionFlags,
416
+ draft: state.draftId,
417
+ sha256: review.model.sha256,
418
+ "start-scan": true,
419
+ }, "approve");
420
+ }
421
+ else {
422
+ const changes = await promptText("Describe requested changes, or leave blank to stop here:");
423
+ if (!changes)
424
+ return review;
425
+ state = await commandThreatModel(client, cwd, {
426
+ ...decisionFlags,
427
+ draft: state.draftId,
428
+ sha256: review.model.sha256,
429
+ }, "request-changes", changes);
430
+ }
431
+ }
432
+ else
433
+ return state;
434
+ }
435
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cantinasecurity/apex-cli",
3
- "version": "0.1.27",
3
+ "version": "0.1.33",
4
4
  "description": "Standalone CLI and MCP server for Apex.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -7,9 +7,11 @@ description: Use when a user wants to start Apex scans, inspect findings, bind w
7
7
 
8
8
  This skill is bundled with Apex CLI and can be installed into Codex with `apex setup codex` or GitHub Copilot CLI with `apex setup copilot`.
9
9
 
10
- Prefer the Apex MCP tools over running `apex` in the shell when the server is available.
10
+ Prefer the Apex MCP tools over running `apex` in the shell when the server is available. The tools installed by `apex setup` use the local stdio server and device login.
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.
12
+ For a production MCP client that does not need local filesystem access, use the hosted Streamable HTTP endpoint at `https://ai.cantina.xyz/mcp`. Read the organization service key from `APEX_SERVICE_KEY` and supply it only as `Authorization: Bearer <TOKEN>`; never put the raw key in a tool argument, result, prompt, source file, or log. The hosted tools are `apex-credits`, `apex-workspaces`, `apex-scan`, `apex-status`, `apex-cancel-scan`, `apex-findings`, `apex-workspace-findings`, `apex-finding-tickets`, `apex-finding-link-ticket`, and `apex-finding-send`.
13
+
14
+ For serverless, cron, or other explicit HTTP/JSON integrations, use the hosted Apex REST API instead of CLI or local stdio MCP. The hosted MCP and REST contract is documented at https://ai.cantina.xyz/docs/apex-api.
13
15
 
14
16
  `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`.
15
17
 
@@ -60,11 +62,12 @@ Guidelines:
60
62
  - Do not rely on interactive CLI prompts. The MCP tools are intentionally non-interactive.
61
63
  - The MCP-only `apex-trial-status` tool may report local guest and claim deadlines, but it must never return a trial code or claim token. `claimAvailable` becomes false at a known passed deadline; missing or invalid local deadline metadata remains conservative and server-authoritative. There is intentionally no MCP redemption or claim tool.
62
64
  - When MCP remains available after a guest scan, remind the user to run `apex claim` on the same machine while `claimAvailable` is true; after a known passed deadline, continue with normal account login.
63
- - 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.
65
+ - Service-key creation, listing, and revocation have no MCP tools. Never ask a user to paste a service key into a prompt, and never place one in a tool argument, tool result, source file, or log. Hosted MCP clients may read it from a secret-backed environment variable for the HTTP Authorization header.
64
66
  - If a user explicitly needs a new service key, tell them to run `apex login` if needed, then run `apex service-key create --name <name> --scope <scope[,scope]>` directly in their own terminal. Use only the required scopes: `scans:create` to query credit availability or 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. The CLI automatically selects the company when only one is available and prompts when there are several; use `--company <id-or-handle>` to skip that prompt or resolve a non-interactive command. Service-key commands never initiate login, and the secret is shown once.
65
- - For a headless HTTP adapter, read the service key from the secret-backed `APEX_SERVICE_KEY` environment variable, send it as a bearer token to `/api/apex/v1`, and redact it from errors and logs. A service key does not authenticate CLI or MCP commands; those use device login. Do not shell out to `apex` or launch MCP from the adapter.
67
+ - For a hosted MCP client, connect to `https://ai.cantina.xyz/mcp` and send `APEX_SERVICE_KEY` as a Bearer header. The service key authenticates hosted MCP, but not the local CLI or stdio MCP; those use device login.
68
+ - For a headless REST adapter, read the service key from the secret-backed `APEX_SERVICE_KEY` 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 local MCP from the adapter.
66
69
  - Hosted API integrations can call `GET /api/apex/v1/credits` with `scans:create` scope to inspect the organization's current standard, audit, and Lite balances and effective scan-type availability. Treat the response as a point-in-time snapshot; scan creation remains authoritative.
67
- - Hosted API v1 remote-repository scans require the GitHub repository to be linked to an active Apex workspace and do not create or mutate workspaces; use optional `workspaceId` to disambiguate matches and treat zero or ambiguous matches as HTTP `409` setup errors. Local or non-Git scans use the documented source-upload flow, which can create or reuse a workspace; send `generateThreatModel: true` for the first scan of a newly created local workspace.
70
+ - Hosted remote-repository scans require the GitHub repository to be connected to the service key's Apex organization. They reuse a linked active workspace or create and link one when no match exists; use optional `workspaceId` to disambiguate multiple matches and treat explicit workspace/repository mismatches as HTTP `409` setup errors. Local or non-Git REST scans use the documented source-upload flow; send `generateThreatModel: true` for the first scan of a newly created local workspace.
68
71
  - 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.
69
72
  - `apex-credits` reports standard credits plus audit, fix review, and Lite scan entitlements when Bedrock returns those balances.
70
73
  - `apex-scan` scans the provided `cwd` by default; pass `repoPaths` only when the user asks to scan explicit alternate roots.
@@ -77,6 +80,7 @@ Guidelines:
77
80
  - `apex-workspace-use` accepts a workspace name, prefix, or ID.
78
81
  - Use `sourceMode: "remote"` only when the user explicitly wants to forbid local snapshot fallbacks.
79
82
  - When checking a scan that is not the workspace binding's latest scan, pass `scanId` to `apex-status`; use `apex-scans` first if you need to discover scan IDs.
83
+ - Use only the Bedrock `scanId` returned by Apex. Kernel execution IDs are private and are not returned or accepted as aliases by CLI or MCP tools.
80
84
  - Prefer `apex-findings` for quick inspection and `apex-export-findings` when the user needs a file artifact.
81
85
  - Use `apex-workspace-findings` when the user asks for findings across a workspace instead of one scan. Set `findingState: "open"` for findings needing review; open excludes fixed, invalid, dismissed, and duplicate findings and is not `status:open`. Pass `company` plus a workspace name, prefix, or ID as `workspaceRef` to avoid relying on or mutating a local binding.
82
86
  - Workspace finding responses expose the web UI lifecycle fields `status`, `validity`, `reviewState`, `isDuplicate`, `duplicateOfId`, `repository`, and `sourceFile`. Supported filter facets are `severity`, `scan`, `repo`, `review`, `impact`, `likelihood`, `status`, `validation`, `visibility`, `duplicate`, and `source`; a leading `!` negates a filter.
@@ -92,3 +96,22 @@ in a terminal, use MCP for scan/status/findings after redemption, then check the
92
96
  MCP-only `apex-trial-status` tool and remind the user to run `apex claim`
93
97
  locally while `claimAvailable` remains true. In a CLI-only flow, use the
94
98
  redeem response's `claimDeadline` instead.
99
+
100
+ Example hosted MCP flow: configure `https://ai.cantina.xyz/mcp` as a
101
+ Streamable HTTP server, source its Bearer token from `APEX_SERVICE_KEY`, and
102
+ verify `apex-credits` without printing the credential.
103
+
104
+ ## Send findings to Linear and Jira
105
+
106
+ - Use `apex-finding-send` to create a ticket from a finding, using the connected integration and saved frontend routing. Local input: `findingRef`, `provider: "linear" | "jira"`, optional `cwd`/`scanId`. Hosted input: `findingId` (UUID) and `provider`, with explicit `findings:write` scope.
107
+ - CLI fallback: `apex findings send <finding-ref> <linear|jira> --json`. This uses device login and stays non-interactive in automation.
108
+ - Connect the integration and configure its destination in Apex first: Linear workspace project/labels or company default; Jira workspace site/project/issue type. Missing destinations return a typed error; do not guess routing or create a different integration.
109
+ - The result has `created` and `export` with issue key/URL. An existing sent or linked ticket returns `created: false`; an active export is a conflict. After ambiguous transport/provider failure, inspect `apex-finding-tickets` and provider state before retrying. Do not automatically repeat ticket creation.
110
+ - Ticket content uses the same title, severity, finding details, and Apex link as the frontend.
111
+
112
+ ## Existing Linear and Jira tickets
113
+
114
+ - After the company connects its integration, use `apex-finding-link-ticket` with `findingRef`, `provider: "linear" | "jira"`, and `issueRef` (ticket key or HTTPS issue URL). Local MCP accepts optional `cwd` and `scanId` and uses device login. A finding UUID works without a local binding; an identifier requires the bound workspace.
115
+ - 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`.
116
+ - 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.
117
+ - 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.