@cantinasecurity/apex-cli 0.1.40 → 0.1.42

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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Cantina agent plugins for security review workflows.",
9
- "version": "0.1.40"
9
+ "version": "0.1.42"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -14,10 +14,10 @@
14
14
  "source": {
15
15
  "source": "npm",
16
16
  "package": "@cantinasecurity/apex-cli",
17
- "version": "0.1.40"
17
+ "version": "0.1.42"
18
18
  },
19
19
  "description": "Run Apex security scans and review findings from Claude Code.",
20
- "version": "0.1.40",
20
+ "version": "0.1.42",
21
21
  "author": {
22
22
  "name": "Cantina",
23
23
  "email": "support@cantina.xyz"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-cli",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
4
4
  "description": "Run Apex security scans and review findings from Claude Code.",
5
5
  "author": {
6
6
  "name": "Cantina",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-cli",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
4
4
  "description": "Run Apex security scans and review findings from Codex.",
5
5
  "author": {
6
6
  "name": "Cantina",
package/.mcp.claude.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "args": [
6
6
  "-y",
7
7
  "-p",
8
- "@cantinasecurity/apex-cli@0.1.40",
8
+ "@cantinasecurity/apex-cli@0.1.42",
9
9
  "apex-mcp"
10
10
  ],
11
11
  "env": {
package/.mcp.codex.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "args": [
5
5
  "-y",
6
6
  "-p",
7
- "@cantinasecurity/apex-cli@0.1.40",
7
+ "@cantinasecurity/apex-cli@0.1.42",
8
8
  "apex-mcp"
9
9
  ],
10
10
  "env": {
package/MARKETPLACE.md CHANGED
@@ -13,7 +13,7 @@ This package is prepared as both a Claude Code plugin and a Codex plugin. The pu
13
13
  The plugin MCP configs launch the pinned npm CLI package with:
14
14
 
15
15
  ```bash
16
- npx -y -p @cantinasecurity/apex-cli@0.1.40 apex-mcp
16
+ npx -y -p @cantinasecurity/apex-cli@0.1.42 apex-mcp
17
17
  ```
18
18
 
19
19
  That keeps marketplace installs independent of a user's global `apex` install.
package/README.md CHANGED
@@ -158,30 +158,30 @@ unclaimed local trial proof so `apex claim` can still use it.
158
158
  Agent-safe copy/paste prompt (substitute the issued code):
159
159
 
160
160
  ```text
161
- Install and run Cantina Apex CLI 0.1.40 to start one free standard security scan of this repository immediately.
161
+ Install and run Cantina Apex CLI 0.1.42 to start one free standard security scan of this repository immediately.
162
162
 
163
163
  Use this trial code only where <TRIAL_CODE> appears below: SPBT-DEVREL-XXXXXXXX
164
164
 
165
165
  Run the commands yourself from the repository root; do not stop after setup or only explain the steps. Use terminal CLI commands only. Do not use an Apex MCP tool or run `apex setup`, `apex mcp`, `apex login`, `apex-auth-start`, or `apex-auth-wait`. Never use `--replace` or `--force`.
166
166
 
167
167
  Use this exact command prefix for every Apex command:
168
- npx -y -p @cantinasecurity/apex-cli@0.1.40 apex
168
+ npx -y -p @cantinasecurity/apex-cli@0.1.42 apex
169
169
 
170
170
  1. Before redeeming, confirm every command will use the same persistent, user-accessible machine or container, HOME, filesystem, and repository root. Confirm Node.js 20+ and npx are available. If `APEX_BASE_URL` is set, continue only when its origin is `https://ai.cantina.xyz`. Run the exact prefix with `--help` and continue only if help lists both `redeem` and `claim`. If any check fails, STOP before sending the code; do not install system packages.
171
171
  2. Redeem, with at most one response-loss recovery retry:
172
- npx -y -p @cantinasecurity/apex-cli@0.1.40 apex redeem <TRIAL_CODE> --json
172
+ npx -y -p @cantinasecurity/apex-cli@0.1.42 apex redeem <TRIAL_CODE> --json
173
173
  This terminal command is the only allowed tool call containing the code. Never echo or restate the code, store it in a file or environment variable, put it in a URL or MCP call, or include it in your response. Parse the JSON from stdout only; do not merge stderr into stdout, because npm may write warnings to stderr. Continue when the command exits 0 and stdout is JSON with `redeemed: true` and `outcome` equal to `redeemed` or `idempotent`. `idempotent` means the existing guest session resumed and it may already own a workspace. If and only if the first attempt ends with an ambiguous transport/connection failure or an interrupted/missing response and no typed server error code, retry this exact same command once on the same machine, HOME, and repository root. Never retry a typed server rejection and never make more than one recovery retry. If the allowed retry does not return a successful JSON outcome, STOP.
174
174
  3. Immediately start exactly one scan; do not wait for another instruction:
175
- npx -y -p @cantinasecurity/apex-cli@0.1.40 apex scan --mode standard --non-interactive --no-open --json
175
+ npx -y -p @cantinasecurity/apex-cli@0.1.42 apex scan --mode standard --non-interactive --no-open --json
176
176
  The CLI validates the complete guest local archive before it reserves a workspace. An unbound renamed checkout may recover only the sole empty guest workspace when no explicit workspace name was supplied. On any error or nonzero exit, STOP. After a successful scan JSON response, inspect `localPersistence.workspaceBindingSaved`. If it is `false`, the server scan already started: capture and report `binding.lastScanId`, then STOP; do not run status or findings until local repository permissions are repaired. Otherwise capture `binding.lastScanId` from the JSON as <SCAN_ID>; if it is absent, STOP.
177
177
  4. About every 60 seconds, poll that exact scan:
178
- npx -y -p @cantinasecurity/apex-cli@0.1.40 apex status --scan <SCAN_ID> --json
178
+ npx -y -p @cantinasecurity/apex-cli@0.1.42 apex status --scan <SCAN_ID> --json
179
179
  Read `progress.progress.status`. Wait while it is `created`, `pending`, `queued`, `starting`, `running`, `in_progress`, or `processing`. Continue on `completed`. STOP on `failed`, `cancelled`, a missing or unknown status, or a command failure.
180
180
  5. Fetch that exact scan's findings:
181
- npx -y -p @cantinasecurity/apex-cli@0.1.40 apex findings --scan <SCAN_ID> --json
181
+ npx -y -p @cantinasecurity/apex-cli@0.1.42 apex findings --scan <SCAN_ID> --json
182
182
  On success, summarize findings by severity, most severe first, including identifier, title, status, and confidence. This command does not return file locations, so do not invent them. On failure, STOP.
183
183
  6. Do not claim automatically. Tell me the claim deadline from step 2 and remind me to run this exact command myself from the same repository root, machine, and HOME:
184
- npx -y -p @cantinasecurity/apex-cli@0.1.40 apex claim
184
+ npx -y -p @cantinasecurity/apex-cli@0.1.42 apex claim
185
185
  It may open a browser so I can create or sign in to a Cantina account and keep the results. Claim moves the trial into the currently selected Cantina workspace when I can run scans there, or into my existing personal workspace. If the selected workspace cannot run scans, tell me to switch to one that can and retry before the claim deadline.
186
186
 
187
187
  Never paste raw command transcripts. For any failure, report only the step, exit code, and sanitized error. Redact the trial code and every `apex_at_`, `apex_rt_`, or `apex_ct_` value from all reports.
@@ -687,7 +687,7 @@ Telemetry event posts do not include bearer tokens. The telemetry endpoint is `P
687
687
  {
688
688
  "schemaVersion": 1,
689
689
  "event": "apex.invocation.completed",
690
- "cliVersion": "0.1.40",
690
+ "cliVersion": "0.1.42",
691
691
  "invocation": {
692
692
  "surface": "mcp",
693
693
  "command": "apex-scan",
@@ -718,7 +718,7 @@ The npm package also includes marketplace-ready plugin artifacts:
718
718
  - `.claude-plugin/plugin.json` and `.mcp.claude.json` for Claude Code plugin installs
719
719
  - `.claude-plugin/marketplace.json` for a Claude marketplace entry backed by the public npm package
720
720
 
721
- These plugin installs launch the pinned npm package with `npx -y -p @cantinasecurity/apex-cli@0.1.40 apex-mcp`, so users do not need to install `apex` globally before enabling the plugin.
721
+ These plugin installs launch the pinned npm package with `npx -y -p @cantinasecurity/apex-cli@0.1.42 apex-mcp`, so users do not need to install `apex` globally before enabling the plugin.
722
722
 
723
723
  The repository also includes `.agents/plugins/marketplace.json` for local Codex marketplace testing from a checkout.
724
724
 
package/dist/args.js CHANGED
@@ -1,5 +1,8 @@
1
1
  export const KNOWN_CLI_FLAG_NAMES = [
2
- "instance-type", "disk", "minutes", "key-file", "retention-days", "snapshot",
2
+ "instance-type",
3
+ "disk",
4
+ "minutes",
5
+ "key-file",
3
6
  "setup",
4
7
  "draft",
5
8
  "file",
@@ -41,6 +44,8 @@ export const KNOWN_CLI_FLAG_NAMES = [
41
44
  "scope",
42
45
  "source-mode",
43
46
  "status",
47
+ "validity",
48
+ "severity",
44
49
  "suggested-severity",
45
50
  "workspace-name",
46
51
  "workspace-ref",
@@ -48,7 +53,9 @@ export const KNOWN_CLI_FLAG_NAMES = [
48
53
  ];
49
54
  const KNOWN_CLI_FLAGS = new Set(KNOWN_CLI_FLAG_NAMES);
50
55
  const BOOLEAN_FLAGS = new Set([
51
- "wait", "start-scan", "no-feedback-context",
56
+ "wait",
57
+ "start-scan",
58
+ "no-feedback-context",
52
59
  "force",
53
60
  "non-interactive",
54
61
  "json",
@@ -103,7 +110,10 @@ export function parseArgs(argv) {
103
110
  if (flags.setup !== undefined) {
104
111
  throw new Error("Audit Setup does not accept scan targets. Use --workspace-ref <workspace>.");
105
112
  }
106
- flags.repo = [...(flags.repo ?? []), ...positional.slice(1)];
113
+ flags.repo = [
114
+ ...(flags.repo ?? []),
115
+ ...positional.slice(1),
116
+ ];
107
117
  positional.splice(1);
108
118
  }
109
119
  return {
@@ -114,9 +124,15 @@ export function parseArgs(argv) {
114
124
  };
115
125
  }
116
126
  export function validateCommandFlags(parsed) {
117
- if (parsed.flags["source-mode"] !== undefined && !["auto", "remote", "local"].includes(String(parsed.flags["source-mode"]))) {
127
+ if (parsed.flags["source-mode"] !== undefined &&
128
+ !["auto", "remote", "local"].includes(String(parsed.flags["source-mode"]))) {
118
129
  throw new Error("--source-mode must be auto, remote, or local.");
119
130
  }
131
+ if ((parsed.flags.validity !== undefined ||
132
+ parsed.flags.severity !== undefined) &&
133
+ parsed.command !== "triage") {
134
+ throw new Error("--validity and --severity are only supported with triage.");
135
+ }
120
136
  if (parsed.flags["workspace-ref"] === undefined) {
121
137
  return;
122
138
  }
@@ -124,7 +140,9 @@ export function validateCommandFlags(parsed) {
124
140
  parsed.subcommand === null &&
125
141
  parsed.flags.workspace === true;
126
142
  const isSetupScan = parsed.command === "scan" && parsed.flags.setup !== undefined;
127
- if (!isWorkspaceFindings && !isSetupScan && !["code-access", "triage"].includes(parsed.command ?? "")) {
143
+ if (!isWorkspaceFindings &&
144
+ !isSetupScan &&
145
+ !["code-access", "triage"].includes(parsed.command ?? "")) {
128
146
  throw new Error("--workspace-ref is only supported with `apex findings --workspace`. To change the local workspace used by scans or exports, run `apex workspace use <workspace-ref>` first.");
129
147
  }
130
148
  }
@@ -145,7 +163,9 @@ export function canPromptInteractively(flags) {
145
163
  }
146
164
  export function getFlagString(flags, key) {
147
165
  const value = flags[key];
148
- return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
166
+ return typeof value === "string" && value.trim().length > 0
167
+ ? value.trim()
168
+ : null;
149
169
  }
150
170
  export function getFlagList(flags, key) {
151
171
  const value = flags[key];
@@ -1,27 +1,99 @@
1
+ import { createPrivateKey, randomUUID } from "node:crypto";
1
2
  import { createRequire } from "node:module";
2
3
  import { spawn } from "node:child_process";
3
- import { existsSync, writeFileSync, rmSync, readFileSync } from "node:fs";
4
+ import { accessSync, chmodSync, constants, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, } from "node:fs";
5
+ import os from "node:os";
4
6
  import path from "node:path";
7
+ import { Transform } from "node:stream";
5
8
  import { fileURLToPath } from "node:url";
6
9
  import { z } from "zod";
7
10
  import { getMe } from "./auth.js";
8
- import { getFlagString } from "./args.js";
11
+ import { canPromptInteractively, getFlagString, } from "./args.js";
12
+ import { withLoadingIndicator } from "./output.js";
13
+ import { chooseOne } from "./prompt.js";
9
14
  export const CODE_ACCESS_HELP = `
10
15
  Internal employee beta:
11
- apex code-access create --workspace-ref <id> [--instance-type m7i.2xlarge] [--disk 200]
16
+ apex code-access create --workspace-ref <id> [--instance-type m7i.xlarge] [--disk 100]
12
17
  apex code-access list --workspace-ref <id>
13
- apex code-access get|connect|extend|snapshot|resume|respawn|revoke-ssh|stop|delete <id> --workspace-ref <id>
14
- connect requires --key-file <new-path>; snapshot accepts --retention-days 1..30; respawn requires --snapshot <id>.
18
+ apex code-access ssh|get|connect|extend|resume|revoke-ssh|stop|kill <id> --workspace-ref <id>
19
+ ssh opens the sandbox immediately with a temporary key that is deleted on exit. create prompts for compute and disk, then prints the one-line ssh command. connect is an advanced flow and requires --key-file <new-path>. kill permanently removes sandbox resources while retaining execution history.
15
20
  apex triage findings --workspace-ref <id>
16
- apex triage edit <finding-id> --workspace-ref <id> --file <edit.json> --expected-updated-at <timestamp from findings>
17
- Edit JSON: {"action":"comment","content":"..."}, {"action":"severity","severity":"high"},
18
- {"action":"review","validity":"valid","severity":"high","note":"..."}, or {"action":"cvss","vector":"CVSS:3.1/...","note":"..."}.
21
+ apex triage review <finding-id> --workspace-ref <id> --expected-updated-at <timestamp from findings> --validity valid|partially_valid|invalid [--severity high] [--note <rationale>] [--request-id <uuid>]
22
+ apex triage edit <finding-id> --workspace-ref <id> --file <edit.json> --expected-updated-at <timestamp from findings> [--request-id <uuid>]
23
+ Edit JSON supports comments only: {"action":"comment","content":"..."}.
19
24
  `;
20
25
  export function hasCodeAccess(me) {
21
26
  return Boolean(me?.user.triageWorkspaces?.length);
22
27
  }
23
28
  const uuid = z.string().uuid();
29
+ const commentEdit = z
30
+ .object({
31
+ action: z.literal("comment"),
32
+ content: z.string().trim().min(1).max(10_000),
33
+ })
34
+ .strict();
24
35
  const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
36
+ export function createSsmPluginOutputFilter() {
37
+ const prefix = Buffer.from("\nStarting session with SessionId:");
38
+ let buffered = Buffer.alloc(0);
39
+ let decided = false;
40
+ return new Transform({
41
+ transform(chunk, _encoding, callback) {
42
+ if (decided)
43
+ return callback(null, chunk);
44
+ buffered = Buffer.concat([buffered, chunk]);
45
+ const comparable = Math.min(buffered.length, prefix.length);
46
+ if (!buffered.subarray(0, comparable).equals(prefix.subarray(0, comparable))) {
47
+ decided = true;
48
+ callback(null, buffered);
49
+ buffered = Buffer.alloc(0);
50
+ return;
51
+ }
52
+ const end = buffered.indexOf(0x0a, prefix.length);
53
+ if (end < 0)
54
+ return callback();
55
+ decided = true;
56
+ const remainder = buffered.subarray(end + 1);
57
+ buffered = Buffer.alloc(0);
58
+ callback(null, remainder);
59
+ },
60
+ flush(callback) {
61
+ callback(null, decided ? undefined : buffered);
62
+ },
63
+ });
64
+ }
65
+ const machineOptions = [
66
+ { value: "m7i.xlarge", label: "4 vCPU / 16 GiB RAM (default)" },
67
+ { value: "m7i.2xlarge", label: "8 vCPU / 32 GiB RAM" },
68
+ { value: "m7i.4xlarge", label: "16 vCPU / 64 GiB RAM" },
69
+ ];
70
+ const diskOptions = [
71
+ { value: 100, label: "100 GiB SSD (default)" },
72
+ { value: 200, label: "200 GiB SSD" },
73
+ { value: 500, label: "500 GiB SSD" },
74
+ ];
75
+ function prepareCredentialPath(requestedPath) {
76
+ let keyFile;
77
+ if (requestedPath) {
78
+ keyFile = path.resolve(requestedPath);
79
+ try {
80
+ accessSync(path.dirname(keyFile), constants.W_OK);
81
+ }
82
+ catch {
83
+ throw new Error("Credential directory does not exist or is not writable");
84
+ }
85
+ }
86
+ else {
87
+ const directory = path.join(os.homedir(), ".config", "apex", "ssh");
88
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
89
+ chmodSync(directory, 0o700);
90
+ keyFile = path.join(directory, `triage-${randomUUID()}.pem`);
91
+ }
92
+ const hostsFile = keyFile + ".known_hosts";
93
+ if (existsSync(keyFile) || existsSync(hostsFile))
94
+ throw new Error("Choose new credential filenames; existing files are never overwritten");
95
+ return keyFile;
96
+ }
25
97
  export async function codeAccessScope(client, flags) {
26
98
  const me = await getMe(client);
27
99
  const scopes = me?.user.triageWorkspaces ?? [];
@@ -39,6 +111,29 @@ function numberFlag(flags, key, fallback) {
39
111
  ? fallback
40
112
  : z.coerce.number().int().positive().parse(value);
41
113
  }
114
+ async function createConfiguration(flags) {
115
+ const interactive = canPromptInteractively(flags);
116
+ const machineFlag = getFlagString(flags, "instance-type");
117
+ const diskFlag = getFlagString(flags, "disk");
118
+ const machine = machineFlag
119
+ ? machineOptions.find((option) => option.value === machineFlag)
120
+ : interactive
121
+ ? await chooseOne("Select sandbox compute:", [...machineOptions])
122
+ : machineOptions[0];
123
+ if (!machine)
124
+ throw new Error("--instance-type must be m7i.xlarge, m7i.2xlarge, or m7i.4xlarge");
125
+ const requestedDisk = diskFlag
126
+ ? z.coerce.number().int().parse(diskFlag)
127
+ : null;
128
+ const disk = requestedDisk !== null
129
+ ? diskOptions.find((option) => option.value === requestedDisk)
130
+ : interactive
131
+ ? await chooseOne("Select encrypted SSD size:", [...diskOptions])
132
+ : diskOptions[0];
133
+ if (!disk)
134
+ throw new Error("--disk must be 100, 200, or 500");
135
+ return { instanceType: machine.value, storageGiB: disk.value };
136
+ }
42
137
  export async function commandCodeAccess(client, flags, action, id) {
43
138
  const workspaceId = await codeAccessScope(client, flags);
44
139
  const endpoint = `/api/workspaces/${workspaceId}/code-access`;
@@ -48,15 +143,119 @@ export async function commandCodeAccess(client, flags, action, id) {
48
143
  signal: AbortSignal.timeout(790_000),
49
144
  });
50
145
  const print = (body) => process.stdout.write(JSON.stringify(body, null, 2) + "\n");
146
+ const issueConnection = async (executionId, keyFile) => {
147
+ const act = (input) => request({ operation: "act", id: executionId, input });
148
+ const hostsFile = keyFile + ".known_hosts";
149
+ if (existsSync(keyFile) || existsSync(hostsFile))
150
+ throw new Error("Choose new credential filenames; existing files are never overwritten");
151
+ const issued = await act({ action: "connect" });
152
+ const parsed = z
153
+ .object({
154
+ connection: z.object({
155
+ privateKey: z.string().min(1),
156
+ hostPublicKey: z.string(),
157
+ hostAlias: z.literal(`triage-${executionId}`),
158
+ username: z.literal("triage"),
159
+ expiresAt: z.string().datetime({ offset: true }),
160
+ }),
161
+ })
162
+ .safeParse(issued);
163
+ if (!parsed.success)
164
+ throw new Error("Invalid SSH identity returned by Apex");
165
+ const { connection } = parsed.data;
166
+ const hostKey = connection.hostPublicKey
167
+ .trim()
168
+ .split(/\s+/)
169
+ .slice(0, 2)
170
+ .join(" ");
171
+ if (!/^ssh-ed25519 [A-Za-z0-9+/]+=*$/.test(hostKey) ||
172
+ Date.parse(connection.expiresAt) <= Date.now())
173
+ throw new Error("Invalid SSH identity");
174
+ try {
175
+ if (createPrivateKey(connection.privateKey).asymmetricKeyType !== "rsa")
176
+ throw new Error("unexpected key type");
177
+ }
178
+ catch {
179
+ throw new Error("Invalid SSH private key returned by Apex");
180
+ }
181
+ let wroteKey = false;
182
+ try {
183
+ writeFileSync(keyFile, connection.privateKey, {
184
+ flag: "wx",
185
+ mode: 0o600,
186
+ });
187
+ wroteKey = true;
188
+ writeFileSync(hostsFile, `${connection.hostAlias} ${hostKey}\n`, {
189
+ flag: "wx",
190
+ mode: 0o600,
191
+ });
192
+ }
193
+ catch {
194
+ if (wroteKey)
195
+ rmSync(keyFile);
196
+ throw new Error("Could not save credentials");
197
+ }
198
+ const source = import.meta.url.endsWith(".ts");
199
+ const entry = fileURLToPath(new URL(source ? "./apex.ts" : "./apex.js", import.meta.url));
200
+ const invocation = [
201
+ quote(process.execPath),
202
+ ...(source
203
+ ? ["--import", quote(createRequire(import.meta.url).resolve("tsx"))]
204
+ : []),
205
+ quote(entry),
206
+ "code-access",
207
+ "proxy",
208
+ executionId,
209
+ "--workspace-ref",
210
+ workspaceId,
211
+ ].join(" ");
212
+ const sshArgs = [
213
+ "-i",
214
+ keyFile.replaceAll("%", "%%"),
215
+ "-o",
216
+ "IdentitiesOnly=yes",
217
+ "-o",
218
+ "StrictHostKeyChecking=yes",
219
+ "-o",
220
+ "UpdateHostKeys=no",
221
+ "-o",
222
+ "ForwardAgent=no",
223
+ "-o",
224
+ "ClearAllForwardings=yes",
225
+ "-o",
226
+ `UserKnownHostsFile="${hostsFile.replaceAll("%", "%%").replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`,
227
+ "-o",
228
+ `HostKeyAlias=${connection.hostAlias}`,
229
+ "-o",
230
+ `ProxyCommand=${invocation.replaceAll("%", "%%")}`,
231
+ `triage@${connection.hostAlias}`,
232
+ ];
233
+ const sshCommand = ["ssh", ...sshArgs.map(quote)].join(" ");
234
+ return {
235
+ keyFile,
236
+ hostsFile,
237
+ expiresAt: connection.expiresAt,
238
+ sshCommand,
239
+ sshArgs,
240
+ };
241
+ };
51
242
  if (action === "create") {
52
- print(await request({
243
+ const config = await createConfiguration(flags);
244
+ const created = await withLoadingIndicator("Starting sandbox and cloning workspace repositories...", flags, () => request({
53
245
  operation: "create",
54
246
  input: {
55
- instanceType: getFlagString(flags, "instance-type") ?? "m7i.2xlarge",
56
- storageGiB: numberFlag(flags, "disk", 200),
247
+ ...config,
57
248
  leaseMinutes: numberFlag(flags, "minutes", 1440),
58
249
  },
59
250
  }));
251
+ const execution = z
252
+ .object({ execution: z.object({ id: uuid }).passthrough() })
253
+ .passthrough()
254
+ .parse(created);
255
+ print({
256
+ ...execution,
257
+ sshCommand: `apex code-access ssh ${execution.execution.id} --workspace-ref ${workspaceId}`,
258
+ });
60
259
  return;
61
260
  }
62
261
  if (action === "list") {
@@ -94,102 +293,84 @@ export async function commandCodeAccess(client, flags, action, id) {
94
293
  }),
95
294
  `https://ssm.${session.region}.amazonaws.com`,
96
295
  ], {
97
- stdio: "inherit",
296
+ stdio: ["pipe", "pipe", "inherit"],
98
297
  env: {
99
298
  PATH: process.env.PATH,
100
299
  HOME: process.env.HOME,
101
300
  LANG: process.env.LANG,
102
301
  },
103
302
  });
104
- await new Promise((resolve, reject) => {
105
- child.once("error", () => reject(new Error("Install the AWS session-manager-plugin to connect")));
106
- child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("SSH transport closed")));
303
+ child.stdin.on("error", () => undefined);
304
+ process.stdin.pipe(child.stdin);
305
+ child.stdout.pipe(createSsmPluginOutputFilter()).pipe(process.stdout, {
306
+ end: false,
107
307
  });
108
- return;
109
- }
110
- if (action === "connect") {
111
- const keyPath = getFlagString(flags, "key-file");
112
- if (!keyPath)
113
- throw new Error("--key-file is required");
114
- const keyFile = path.resolve(keyPath), hostsFile = keyFile + ".known_hosts";
115
- if (existsSync(keyFile) || existsSync(hostsFile))
116
- throw new Error("Choose new credential filenames; existing files are never overwritten");
117
- const issued = await act({ action: "connect" });
118
- const parsed = z
119
- .object({
120
- connection: z.object({
121
- privateKey: z.string().min(1),
122
- hostPublicKey: z.string(),
123
- hostAlias: z.literal(`triage-${executionId}`),
124
- username: z.literal("triage"),
125
- expiresAt: z.string().datetime({ offset: true }),
126
- }),
127
- })
128
- .safeParse(issued);
129
- if (!parsed.success) {
130
- await act({ action: "revoke-ssh" });
131
- throw new Error("Invalid SSH identity; access was revoked");
308
+ try {
309
+ await new Promise((resolve, reject) => {
310
+ child.once("error", () => reject(new Error("Install the AWS session-manager-plugin to connect")));
311
+ child.once("close", (code) => code === 0 ? resolve() : reject(new Error("SSH transport closed")));
312
+ });
132
313
  }
133
- const { connection } = parsed.data;
134
- const hostKey = connection.hostPublicKey
135
- .trim()
136
- .split(/\s+/)
137
- .slice(0, 2)
138
- .join(" ");
139
- if (!/^ssh-ed25519 [A-Za-z0-9+/]+=*$/.test(hostKey) ||
140
- Date.parse(connection.expiresAt) <= Date.now()) {
141
- await act({ action: "revoke-ssh" });
142
- throw new Error("Invalid SSH identity");
314
+ finally {
315
+ process.stdin.unpipe(child.stdin);
143
316
  }
144
- let wroteKey = false;
317
+ return;
318
+ }
319
+ if (action === "ssh") {
320
+ const directory = mkdtempSync(path.join(os.tmpdir(), "apex-triage-ssh-"));
321
+ let child = null;
322
+ let interruptedBy = null;
323
+ const cleanup = () => rmSync(directory, { recursive: true, force: true });
324
+ const onSignal = (signal) => {
325
+ interruptedBy = signal;
326
+ cleanup();
327
+ child?.kill(signal);
328
+ };
329
+ const signals = ["SIGHUP", "SIGINT", "SIGTERM"];
330
+ const signalHandlers = signals.map((signal) => [signal, () => onSignal(signal)]);
331
+ for (const [signal, handler] of signalHandlers)
332
+ process.once(signal, handler);
145
333
  try {
146
- writeFileSync(keyFile, connection.privateKey, {
147
- flag: "wx",
148
- mode: 0o600,
149
- });
150
- wroteKey = true;
151
- writeFileSync(hostsFile, `${connection.hostAlias} ${hostKey}\n`, {
152
- flag: "wx",
153
- mode: 0o600,
334
+ const connection = await issueConnection(executionId, path.join(directory, "identity"));
335
+ if (interruptedBy)
336
+ throw new Error(`SSH interrupted by ${interruptedBy}`);
337
+ const ssh = spawn("ssh", connection.sshArgs, { stdio: "inherit" });
338
+ child = ssh;
339
+ await new Promise((resolve, reject) => {
340
+ ssh.once("error", () => reject(new Error("Unable to start SSH")));
341
+ ssh.once("close", (code) => {
342
+ if (interruptedBy)
343
+ reject(new Error(`SSH interrupted by ${interruptedBy}`));
344
+ else if (code === 255)
345
+ reject(new Error("SSH connection failed"));
346
+ else {
347
+ if (code)
348
+ process.exitCode = code;
349
+ resolve();
350
+ }
351
+ });
154
352
  });
155
353
  }
156
- catch {
157
- if (wroteKey)
158
- rmSync(keyFile);
159
- await act({ action: "revoke-ssh" });
160
- throw new Error("Could not save credentials; SSH access was revoked");
354
+ finally {
355
+ for (const [signal, handler] of signalHandlers)
356
+ process.removeListener(signal, handler);
357
+ cleanup();
161
358
  }
162
- const source = import.meta.url.endsWith(".ts");
163
- const entry = fileURLToPath(new URL(source ? "./apex.ts" : "./apex.js", import.meta.url));
164
- const invocation = [
165
- quote(process.execPath),
166
- ...(source
167
- ? ["--import", quote(createRequire(import.meta.url).resolve("tsx"))]
168
- : []),
169
- quote(entry),
170
- "code-access",
171
- "proxy",
172
- executionId,
173
- "--workspace-ref",
174
- workspaceId,
175
- ].join(" ");
176
- const sshCommand = `ssh -i ${quote(keyFile.replaceAll("%", "%%"))} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ${quote(`UserKnownHostsFile="${hostsFile.replaceAll("%", "%%").replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`)} -o ${quote("ProxyCommand=" + invocation.replaceAll("%", "%%"))} triage@${connection.hostAlias}`;
177
- print({ keyFile, hostsFile, expiresAt: connection.expiresAt, sshCommand });
359
+ return;
360
+ }
361
+ if (action === "connect") {
362
+ const keyPath = getFlagString(flags, "key-file");
363
+ if (!keyPath)
364
+ throw new Error("--key-file is required");
365
+ const { sshArgs: _sshArgs, ...connection } = await issueConnection(executionId, prepareCredentialPath(keyPath));
366
+ print(connection);
178
367
  return;
179
368
  }
180
369
  const input = action === "extend"
181
370
  ? { action, minutes: numberFlag(flags, "minutes", 1440) }
182
- : action === "snapshot"
183
- ? { action, retentionDays: numberFlag(flags, "retention-days", 7) }
184
- : action === "respawn"
185
- ? {
186
- action,
187
- snapshotId: uuid.parse(getFlagString(flags, "snapshot")),
188
- leaseMinutes: numberFlag(flags, "minutes", 1440),
189
- }
190
- : ["resume", "revoke-ssh", "stop", "delete"].includes(action)
191
- ? { action }
192
- : null;
371
+ : ["resume", "revoke-ssh", "stop", "kill", "delete"].includes(action)
372
+ ? { action }
373
+ : null;
193
374
  if (!input)
194
375
  throw new Error("Unknown command");
195
376
  print(await act(input));
@@ -201,19 +382,70 @@ export async function commandInternalTriage(client, flags, action, id) {
201
382
  process.stdout.write(JSON.stringify(await client.request(endpoint), null, 2) + "\n");
202
383
  return;
203
384
  }
385
+ if (action === "review") {
386
+ const findingId = uuid.parse(id).toLowerCase();
387
+ const validity = z
388
+ .enum(["valid", "partially_valid", "invalid"])
389
+ .parse(getFlagString(flags, "validity"));
390
+ const severityValue = getFlagString(flags, "severity");
391
+ const severity = severityValue
392
+ ? z
393
+ .enum([
394
+ "extreme",
395
+ "critical",
396
+ "high",
397
+ "medium",
398
+ "low",
399
+ "informational",
400
+ ])
401
+ .parse(severityValue)
402
+ : null;
403
+ if (validity === "invalid" && severity)
404
+ throw new Error("Invalid reviews must omit --severity");
405
+ if (validity !== "invalid" && !severity)
406
+ throw new Error("Valid and partially valid reviews require --severity");
407
+ const expectedUpdatedAt = z
408
+ .string()
409
+ .datetime({ offset: true })
410
+ .parse(getFlagString(flags, "expected-updated-at"));
411
+ const requestedId = getFlagString(flags, "request-id");
412
+ const requestId = uuid.parse(requestedId ?? randomUUID()).toLowerCase();
413
+ if (!requestedId)
414
+ process.stderr.write(`Request ID: ${requestId}\n`);
415
+ const result = await client.request(endpoint, {
416
+ method: "POST",
417
+ json: {
418
+ findingId,
419
+ requestId,
420
+ expectedUpdatedAt,
421
+ edit: {
422
+ action: "review",
423
+ validity,
424
+ severity,
425
+ note: getFlagString(flags, "note") ?? "",
426
+ },
427
+ },
428
+ });
429
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
430
+ return;
431
+ }
204
432
  if (action !== "edit")
205
433
  throw new Error("Unknown command");
206
- const findingId = uuid.parse(id), file = getFlagString(flags, "file");
434
+ const findingId = uuid.parse(id).toLowerCase(), file = getFlagString(flags, "file");
207
435
  if (!file)
208
436
  throw new Error("--file is required");
209
- const edit = JSON.parse(readFileSync(file, "utf8"));
437
+ const edit = commentEdit.parse(JSON.parse(readFileSync(file, "utf8")));
210
438
  const expectedUpdatedAt = z
211
439
  .string()
212
440
  .datetime({ offset: true })
213
441
  .parse(getFlagString(flags, "expected-updated-at"));
442
+ const requestedId = getFlagString(flags, "request-id");
443
+ const requestId = uuid.parse(requestedId ?? randomUUID()).toLowerCase();
444
+ if (!requestedId)
445
+ process.stderr.write(`Request ID: ${requestId}\n`);
214
446
  const result = await client.request(endpoint, {
215
447
  method: "POST",
216
- json: { findingId, expectedUpdatedAt, edit },
448
+ json: { findingId, requestId, expectedUpdatedAt, edit },
217
449
  });
218
450
  process.stdout.write(JSON.stringify(result, null, 2) + "\n");
219
451
  }
package/dist/commands.js CHANGED
@@ -23,6 +23,45 @@ import { commandUpdate as runCliUpdate } from "./update.js";
23
23
  import { mkdir, writeFile } from "node:fs/promises";
24
24
  import path from "node:path";
25
25
  import { z } from "zod";
26
+ function getRequestedWorkspaceSrDoctorPayload(me, flags) {
27
+ const requested = getFlagString(flags, "company")?.trim().toLowerCase();
28
+ if (!requested)
29
+ return null;
30
+ if (me.companies.some((company) => company.id.toLowerCase() === requested ||
31
+ company.handle?.toLowerCase() === requested)) {
32
+ return null;
33
+ }
34
+ const triageWorkspaces = me.user.triageWorkspaces ?? [];
35
+ const scopes = triageWorkspaces.filter((workspace) => workspace.companyId.toLowerCase() === requested ||
36
+ workspace.companyHandle?.toLowerCase() === requested);
37
+ if (scopes.length === 0) {
38
+ const companyIdsMissingHandles = [
39
+ ...new Set(triageWorkspaces
40
+ .filter((workspace) => !workspace.companyHandle)
41
+ .map((workspace) => workspace.companyId)),
42
+ ];
43
+ if (companyIdsMissingHandles.length > 0) {
44
+ throw new Error(`Workspace SR access metadata is missing a company handle. Retry with --company ${companyIdsMissingHandles.join(" or --company ")}.`);
45
+ }
46
+ return null;
47
+ }
48
+ const company = scopes[0];
49
+ const workspaceIds = [...new Set(scopes.map((scope) => scope.workspaceId))];
50
+ return {
51
+ authenticatedAs: me.user.username ?? me.user.id,
52
+ accessMode: "workspace_sr",
53
+ company: {
54
+ id: company.companyId,
55
+ name: company.companyName ?? null,
56
+ handle: company.companyHandle ?? null,
57
+ },
58
+ workspaces: workspaceIds.map((workspaceId) => ({
59
+ workspaceId,
60
+ findingsCommand: `apex triage findings --workspace-ref ${workspaceId}`,
61
+ createSandboxCommand: `apex code-access create --workspace-ref ${workspaceId}`,
62
+ })),
63
+ };
64
+ }
26
65
  const WORKSPACE_USE_PATTERN = "<workspace-name|workspace-prefix|workspace-id>";
27
66
  const WORKSPACE_BINDING_GUIDANCE = "Run `apex workspaces` to list workspace names, prefixes, and IDs, then bind one with `apex workspace use \"<workspace name>\"`, or run `apex scan` to create or resolve one automatically.";
28
67
  const WORKSPACE_SELECTION_GUIDANCE = "Run `apex workspaces` to list available workspace names, prefixes, and IDs, then retry `apex workspace use \"<workspace name>\"`.";
@@ -1005,6 +1044,22 @@ export async function commandDoctor(client, cwd, flags) {
1005
1044
  const me = existingMe ?? (await ensureAuthenticated(client, flags));
1006
1045
  const authState = await getCommandAuthState(client, me);
1007
1046
  setTelemetryAuthKind(authState.authKind);
1047
+ const workspaceSrPayload = getRequestedWorkspaceSrDoctorPayload(me, flags);
1048
+ if (workspaceSrPayload) {
1049
+ if (isJsonMode(flags)) {
1050
+ printJson(workspaceSrPayload);
1051
+ return workspaceSrPayload;
1052
+ }
1053
+ logLine(`Authenticated as ${workspaceSrPayload.authenticatedAs}`, flags);
1054
+ logLine(`Company: ${workspaceSrPayload.company.handle ?? workspaceSrPayload.company.id}`, flags);
1055
+ logLine("Access: workspace-scoped SR", flags);
1056
+ for (const workspace of workspaceSrPayload.workspaces) {
1057
+ logLine(`Workspace: ${workspace.workspaceId}`, flags);
1058
+ logLine(`Findings: ${workspace.findingsCommand}`, flags);
1059
+ logLine(`Create sandbox: ${workspace.createSandboxCommand}`, flags);
1060
+ }
1061
+ return workspaceSrPayload;
1062
+ }
1008
1063
  const result = await withLoadingIndicator("Inspecting this directory and checking Apex setup...", flags, async () => {
1009
1064
  if (authState.authKind === "guest") {
1010
1065
  const selection = await selectWorkspaceTarget(cwd, me, flags);
@@ -1015,6 +1070,7 @@ export async function commandDoctor(client, cwd, flags) {
1015
1070
  const localSnapshotCount = result.resolve.plannedSources.filter((source) => source.sourceKind === "local_archive").length;
1016
1071
  const payload = {
1017
1072
  authenticatedAs: me.user.username ?? me.user.id,
1073
+ accessMode: "company",
1018
1074
  company: result.company,
1019
1075
  workspaceName: result.workspaceName,
1020
1076
  binding: result.binding,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cantinasecurity/apex-cli",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
4
4
  "description": "Standalone CLI and MCP server for Apex.",
5
5
  "private": false,
6
6
  "type": "module",