@cantinasecurity/apex-cli 0.1.28 → 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.
- package/.claude/skills/apex-cli/SKILL.md +17 -1
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.mcp.claude.json +1 -1
- package/.mcp.codex.json +1 -1
- package/MARKETPLACE.md +1 -1
- package/README.md +44 -11
- package/dist/apex.js +41 -8
- package/dist/args.js +17 -1
- package/dist/auth.js +2 -2
- package/dist/commands.js +64 -11
- package/dist/findings.js +15 -3
- package/dist/help.js +50 -3
- package/dist/mcp.js +92 -10
- package/dist/scan.js +49 -7
- package/dist/service-keys.js +2 -1
- package/dist/shell.js +29 -6
- package/dist/telemetry.js +4 -2
- package/dist/threat-model.js +435 -0
- package/package.json +1 -1
- package/skills/apex-cli/SKILL.md +17 -1
|
@@ -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
package/skills/apex-cli/SKILL.md
CHANGED
|
@@ -9,7 +9,7 @@ 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. The tools installed by `apex setup` use the local stdio server and device login.
|
|
11
11
|
|
|
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`, and `apex-
|
|
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
13
|
|
|
14
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.
|
|
15
15
|
|
|
@@ -80,6 +80,7 @@ Guidelines:
|
|
|
80
80
|
- `apex-workspace-use` accepts a workspace name, prefix, or ID.
|
|
81
81
|
- Use `sourceMode: "remote"` only when the user explicitly wants to forbid local snapshot fallbacks.
|
|
82
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.
|
|
83
84
|
- Prefer `apex-findings` for quick inspection and `apex-export-findings` when the user needs a file artifact.
|
|
84
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.
|
|
85
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.
|
|
@@ -99,3 +100,18 @@ redeem response's `claimDeadline` instead.
|
|
|
99
100
|
Example hosted MCP flow: configure `https://ai.cantina.xyz/mcp` as a
|
|
100
101
|
Streamable HTTP server, source its Bearer token from `APEX_SERVICE_KEY`, and
|
|
101
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.
|