@cantinasecurity/apex-cli 0.1.39 → 0.1.41
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 +3 -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 +12 -10
- package/dist/apex.js +10 -1
- package/dist/args.js +26 -5
- package/dist/code-access.js +446 -0
- package/dist/local-source-scan.js +4 -7
- package/package.json +1 -1
- package/skills/apex-cli/SKILL.md +3 -1
|
@@ -156,7 +156,7 @@ finish before saving again.
|
|
|
156
156
|
|
|
157
157
|
Scan target and recovery rules:
|
|
158
158
|
- CLI `apex scan [target...]` and repeatable `--repo` accept exact files, directories, or HTTPS GitHub/GitLab or company-enabled Forgejo repository URLs; local MCP uses `repoPaths` for the same selections. No target means `cwd`.
|
|
159
|
-
- A file selection scans only that file, respecting ignore rules. Local snapshots exclude `.apex/` state. Remote URLs use the default branch and cannot fall back to uploading `cwd`; clone locally to choose a branch or commit.
|
|
159
|
+
- A file selection scans only that file, respecting ignore rules. Local snapshots exclude `.apex/` state. Hard-linked files are uploaded independently, while safe relative symbolic links and long filenames are preserved. Remote URLs use the default branch and cannot fall back to uploading `cwd`; clone locally to choose a branch or commit.
|
|
160
160
|
- Use Standard for local changes, files, plain directories, and public repositories without provider access. Lite and Audit still require connected provider sources; do not silently substitute another mode.
|
|
161
161
|
- Unauthenticated headless CLI commands fail with explicit login guidance; they never start device login. Use the MCP auth tools for explicit MCP login. Retry transient refresh failures without clearing credentials.
|
|
162
162
|
- Audit Setup is an explicit Standard/Audit beta flow using saved workspace repositories and context. It does not preserve local targets. Never automatically substitute setup after `AUDIT_SETUP_REQUIRED`; use the error's selected-workspace command only when that saved scope is intended. Draft operations require the ID returned by preparation; eligible accounts receive setup commands in CLI help. Lite and PR do not use `--setup`.
|
|
@@ -175,3 +175,5 @@ Finding lifecycle:
|
|
|
175
175
|
Forgejo connections, remote repository scans, PR scans and Fix Review require the Forgejo company feature enabled by an administrator in Bedrock Settings. Run `apex connect forgejo` (or `apex-connect-provider` with `provider: "forgejo"`) to obtain the browser connection URL. A dedicated bot, HTTPS instance URL and repository-scoped read token are required; PR statuses, comments and webhook installation also require a write token.
|
|
176
176
|
|
|
177
177
|
After connecting, run `apex doctor --repo . --source-mode remote`, then `apex scan --mode standard --repo . --source-mode remote` or `apex scan --mode pr --pr 42 --repo .`. Same-repository Forgejo PR URLs use `/owner/repository/pulls/42`. Pass a Forgejo Fix PR URL through finding feedback and start Fix Review using the existing finding commands or MCP tools. Disabling the company flag stops native Forgejo access. Existing local Standard snapshots remain provider-independent.
|
|
178
|
+
|
|
179
|
+
Internal employee beta capabilities are advertised only after live role and workspace eligibility checks. SSH credential delivery is intentionally scripted CLI only; it is not exposed through MCP tools or public help. Server authorization remains mandatory for every action.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Cantina agent plugins for security review workflows.",
|
|
9
|
-
"version": "0.1.
|
|
9
|
+
"version": "0.1.41"
|
|
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.
|
|
17
|
+
"version": "0.1.41"
|
|
18
18
|
},
|
|
19
19
|
"description": "Run Apex security scans and review findings from Claude Code.",
|
|
20
|
-
"version": "0.1.
|
|
20
|
+
"version": "0.1.41",
|
|
21
21
|
"author": {
|
|
22
22
|
"name": "Cantina",
|
|
23
23
|
"email": "support@cantina.xyz"
|
package/.mcp.claude.json
CHANGED
package/.mcp.codex.json
CHANGED
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.
|
|
16
|
+
npx -y -p @cantinasecurity/apex-cli@0.1.41 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.
|
|
161
|
+
Install and run Cantina Apex CLI 0.1.41 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.
|
|
168
|
+
npx -y -p @cantinasecurity/apex-cli@0.1.41 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.
|
|
172
|
+
npx -y -p @cantinasecurity/apex-cli@0.1.41 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.
|
|
175
|
+
npx -y -p @cantinasecurity/apex-cli@0.1.41 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.
|
|
178
|
+
npx -y -p @cantinasecurity/apex-cli@0.1.41 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.
|
|
181
|
+
npx -y -p @cantinasecurity/apex-cli@0.1.41 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.
|
|
184
|
+
npx -y -p @cantinasecurity/apex-cli@0.1.41 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.
|
|
@@ -477,7 +477,7 @@ apex scan ./contracts/UniswapV2ERC20.sol --mode standard
|
|
|
477
477
|
apex scan https://github.com/Uniswap/v2-core --mode standard
|
|
478
478
|
```
|
|
479
479
|
|
|
480
|
-
A file target uploads only that file, never its siblings. Local snapshots include current local changes and honor Git ignore rules and `.apexignore`; Apex state under `.apex/` is excluded. Explicit remote URLs use the default branch and never fall back to uploading the current checkout. Use a local clone to scan a particular remote branch or commit.
|
|
480
|
+
A file target uploads only that file, never its siblings. Local snapshots include current local changes and honor Git ignore rules and `.apexignore`; Apex state under `.apex/` is excluded. Hard-linked paths are uploaded as independent file contents; safe relative symbolic links and long filenames are preserved. Explicit remote URLs use the default branch and never fall back to uploading the current checkout. Use a local clone to scan a particular remote branch or commit.
|
|
481
481
|
|
|
482
482
|
Source planning:
|
|
483
483
|
|
|
@@ -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.
|
|
690
|
+
"cliVersion": "0.1.41",
|
|
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.
|
|
721
|
+
These plugin installs launch the pinned npm package with `npx -y -p @cantinasecurity/apex-cli@0.1.41 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
|
|
|
@@ -826,3 +826,5 @@ or Jira tickets. Use the direct status operation to manage issues from MCP or RE
|
|
|
826
826
|
Forgejo connections, remote repository scans, PR scans and Fix Review require the Forgejo company feature enabled by an administrator in Bedrock Settings. Run `apex connect forgejo` (or `apex-connect-provider` with `provider: "forgejo"`) to obtain the browser connection URL. A dedicated bot, HTTPS instance URL and repository-scoped read token are required; PR statuses, comments and webhook installation also require a write token.
|
|
827
827
|
|
|
828
828
|
After connecting, run `apex doctor --repo . --source-mode remote`, then `apex scan --mode standard --repo . --source-mode remote` or `apex scan --mode pr --pr 42 --repo .`. Same-repository Forgejo PR URLs use `/owner/repository/pulls/42`. Pass a Forgejo Fix PR URL through finding feedback and start Fix Review using the existing finding commands or MCP tools. Disabling the company flag stops native Forgejo access. Existing local Standard snapshots remain provider-independent.
|
|
829
|
+
|
|
830
|
+
Internal employee beta capabilities are advertised only after live role and workspace eligibility checks. SSH credential delivery is intentionally scripted CLI only; it is not exposed through MCP tools or public help. Server authorization remains mandatory for every action.
|
package/dist/apex.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { commandCodeAccess, commandInternalTriage, hasCodeAccess, CODE_ACCESS_HELP } from "./code-access.js";
|
|
1
2
|
import { commandCvss } from "./cvss.js";
|
|
2
3
|
import { commandStartThreatModel, commandThreatModel, hasAuditSetupBeta, threatModelExitCode, terminalText } from "./threat-model.js";
|
|
3
4
|
import { getMe } from "./auth.js";
|
|
@@ -13,13 +14,21 @@ async function dispatch(parsed) {
|
|
|
13
14
|
const client = new ApexApiClient();
|
|
14
15
|
if (parsed.flags.help === true || parsed.command === "help") {
|
|
15
16
|
const me = await getMe(client, { signal: AbortSignal.timeout(1_500) }).catch(() => null);
|
|
16
|
-
process.stdout.write(cliHelpText(hasAuditSetupBeta(me)));
|
|
17
|
+
process.stdout.write(cliHelpText(hasAuditSetupBeta(me)) + (hasCodeAccess(me) ? CODE_ACCESS_HELP : ""));
|
|
17
18
|
return;
|
|
18
19
|
}
|
|
19
20
|
if (parsed.command === "mcp") {
|
|
20
21
|
await runMcpServer();
|
|
21
22
|
return;
|
|
22
23
|
}
|
|
24
|
+
if (parsed.command === "code-access") {
|
|
25
|
+
await commandCodeAccess(client, parsed.flags, parsed.subcommand ?? "list", parsed.args[0]);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (parsed.command === "triage") {
|
|
29
|
+
await commandInternalTriage(client, parsed.flags, parsed.subcommand ?? "findings", parsed.args[0]);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
23
32
|
const cwd = process.cwd();
|
|
24
33
|
if (await maybePromptForUpdate(parsed.flags, parsed.command)) {
|
|
25
34
|
return;
|
package/dist/args.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export const KNOWN_CLI_FLAG_NAMES = [
|
|
2
|
+
"instance-type",
|
|
3
|
+
"disk",
|
|
4
|
+
"minutes",
|
|
5
|
+
"key-file",
|
|
2
6
|
"setup",
|
|
3
7
|
"draft",
|
|
4
8
|
"file",
|
|
@@ -40,6 +44,8 @@ export const KNOWN_CLI_FLAG_NAMES = [
|
|
|
40
44
|
"scope",
|
|
41
45
|
"source-mode",
|
|
42
46
|
"status",
|
|
47
|
+
"validity",
|
|
48
|
+
"severity",
|
|
43
49
|
"suggested-severity",
|
|
44
50
|
"workspace-name",
|
|
45
51
|
"workspace-ref",
|
|
@@ -47,7 +53,9 @@ export const KNOWN_CLI_FLAG_NAMES = [
|
|
|
47
53
|
];
|
|
48
54
|
const KNOWN_CLI_FLAGS = new Set(KNOWN_CLI_FLAG_NAMES);
|
|
49
55
|
const BOOLEAN_FLAGS = new Set([
|
|
50
|
-
"wait",
|
|
56
|
+
"wait",
|
|
57
|
+
"start-scan",
|
|
58
|
+
"no-feedback-context",
|
|
51
59
|
"force",
|
|
52
60
|
"non-interactive",
|
|
53
61
|
"json",
|
|
@@ -102,7 +110,10 @@ export function parseArgs(argv) {
|
|
|
102
110
|
if (flags.setup !== undefined) {
|
|
103
111
|
throw new Error("Audit Setup does not accept scan targets. Use --workspace-ref <workspace>.");
|
|
104
112
|
}
|
|
105
|
-
flags.repo = [
|
|
113
|
+
flags.repo = [
|
|
114
|
+
...(flags.repo ?? []),
|
|
115
|
+
...positional.slice(1),
|
|
116
|
+
];
|
|
106
117
|
positional.splice(1);
|
|
107
118
|
}
|
|
108
119
|
return {
|
|
@@ -113,9 +124,15 @@ export function parseArgs(argv) {
|
|
|
113
124
|
};
|
|
114
125
|
}
|
|
115
126
|
export function validateCommandFlags(parsed) {
|
|
116
|
-
if (parsed.flags["source-mode"] !== undefined &&
|
|
127
|
+
if (parsed.flags["source-mode"] !== undefined &&
|
|
128
|
+
!["auto", "remote", "local"].includes(String(parsed.flags["source-mode"]))) {
|
|
117
129
|
throw new Error("--source-mode must be auto, remote, or local.");
|
|
118
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
|
+
}
|
|
119
136
|
if (parsed.flags["workspace-ref"] === undefined) {
|
|
120
137
|
return;
|
|
121
138
|
}
|
|
@@ -123,7 +140,9 @@ export function validateCommandFlags(parsed) {
|
|
|
123
140
|
parsed.subcommand === null &&
|
|
124
141
|
parsed.flags.workspace === true;
|
|
125
142
|
const isSetupScan = parsed.command === "scan" && parsed.flags.setup !== undefined;
|
|
126
|
-
if (!isWorkspaceFindings &&
|
|
143
|
+
if (!isWorkspaceFindings &&
|
|
144
|
+
!isSetupScan &&
|
|
145
|
+
!["code-access", "triage"].includes(parsed.command ?? "")) {
|
|
127
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.");
|
|
128
147
|
}
|
|
129
148
|
}
|
|
@@ -144,7 +163,9 @@ export function canPromptInteractively(flags) {
|
|
|
144
163
|
}
|
|
145
164
|
export function getFlagString(flags, key) {
|
|
146
165
|
const value = flags[key];
|
|
147
|
-
return typeof value === "string" && value.trim().length > 0
|
|
166
|
+
return typeof value === "string" && value.trim().length > 0
|
|
167
|
+
? value.trim()
|
|
168
|
+
: null;
|
|
148
169
|
}
|
|
149
170
|
export function getFlagList(flags, key) {
|
|
150
171
|
const value = flags[key];
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { createPrivateKey, randomUUID } from "node:crypto";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { accessSync, chmodSync, constants, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, } from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { Transform } from "node:stream";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { getMe } from "./auth.js";
|
|
11
|
+
import { canPromptInteractively, getFlagString, } from "./args.js";
|
|
12
|
+
import { withLoadingIndicator } from "./output.js";
|
|
13
|
+
import { chooseOne } from "./prompt.js";
|
|
14
|
+
export const CODE_ACCESS_HELP = `
|
|
15
|
+
Internal employee beta:
|
|
16
|
+
apex code-access create --workspace-ref <id> [--instance-type m7i.xlarge] [--disk 100]
|
|
17
|
+
apex code-access list --workspace-ref <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.
|
|
20
|
+
apex triage findings --workspace-ref <id>
|
|
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: {"action":"comment","content":"..."}, {"action":"severity","severity":"high"},
|
|
24
|
+
{"action":"review","validity":"valid","severity":"high","note":"..."}, or {"action":"cvss","vector":"CVSS:3.1/...","note":"..."}.
|
|
25
|
+
`;
|
|
26
|
+
export function hasCodeAccess(me) {
|
|
27
|
+
return Boolean(me?.user.triageWorkspaces?.length);
|
|
28
|
+
}
|
|
29
|
+
const uuid = z.string().uuid();
|
|
30
|
+
const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
|
|
31
|
+
export function createSsmPluginOutputFilter() {
|
|
32
|
+
const prefix = Buffer.from("\nStarting session with SessionId:");
|
|
33
|
+
let buffered = Buffer.alloc(0);
|
|
34
|
+
let decided = false;
|
|
35
|
+
return new Transform({
|
|
36
|
+
transform(chunk, _encoding, callback) {
|
|
37
|
+
if (decided)
|
|
38
|
+
return callback(null, chunk);
|
|
39
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
40
|
+
const comparable = Math.min(buffered.length, prefix.length);
|
|
41
|
+
if (!buffered.subarray(0, comparable).equals(prefix.subarray(0, comparable))) {
|
|
42
|
+
decided = true;
|
|
43
|
+
callback(null, buffered);
|
|
44
|
+
buffered = Buffer.alloc(0);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const end = buffered.indexOf(0x0a, prefix.length);
|
|
48
|
+
if (end < 0)
|
|
49
|
+
return callback();
|
|
50
|
+
decided = true;
|
|
51
|
+
const remainder = buffered.subarray(end + 1);
|
|
52
|
+
buffered = Buffer.alloc(0);
|
|
53
|
+
callback(null, remainder);
|
|
54
|
+
},
|
|
55
|
+
flush(callback) {
|
|
56
|
+
callback(null, decided ? undefined : buffered);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const machineOptions = [
|
|
61
|
+
{ value: "m7i.xlarge", label: "4 vCPU / 16 GiB RAM (default)" },
|
|
62
|
+
{ value: "m7i.2xlarge", label: "8 vCPU / 32 GiB RAM" },
|
|
63
|
+
{ value: "m7i.4xlarge", label: "16 vCPU / 64 GiB RAM" },
|
|
64
|
+
];
|
|
65
|
+
const diskOptions = [
|
|
66
|
+
{ value: 100, label: "100 GiB SSD (default)" },
|
|
67
|
+
{ value: 200, label: "200 GiB SSD" },
|
|
68
|
+
{ value: 500, label: "500 GiB SSD" },
|
|
69
|
+
];
|
|
70
|
+
function prepareCredentialPath(requestedPath) {
|
|
71
|
+
let keyFile;
|
|
72
|
+
if (requestedPath) {
|
|
73
|
+
keyFile = path.resolve(requestedPath);
|
|
74
|
+
try {
|
|
75
|
+
accessSync(path.dirname(keyFile), constants.W_OK);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new Error("Credential directory does not exist or is not writable");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const directory = path.join(os.homedir(), ".config", "apex", "ssh");
|
|
83
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
84
|
+
chmodSync(directory, 0o700);
|
|
85
|
+
keyFile = path.join(directory, `triage-${randomUUID()}.pem`);
|
|
86
|
+
}
|
|
87
|
+
const hostsFile = keyFile + ".known_hosts";
|
|
88
|
+
if (existsSync(keyFile) || existsSync(hostsFile))
|
|
89
|
+
throw new Error("Choose new credential filenames; existing files are never overwritten");
|
|
90
|
+
return keyFile;
|
|
91
|
+
}
|
|
92
|
+
export async function codeAccessScope(client, flags) {
|
|
93
|
+
const me = await getMe(client);
|
|
94
|
+
const scopes = me?.user.triageWorkspaces ?? [];
|
|
95
|
+
if (!scopes.length)
|
|
96
|
+
throw new Error("Unknown command");
|
|
97
|
+
const workspaceId = uuid.parse(getFlagString(flags, "workspace-ref") ??
|
|
98
|
+
(scopes.length === 1 ? scopes[0].workspaceId : undefined));
|
|
99
|
+
if (!scopes.some((s) => s.workspaceId === workspaceId))
|
|
100
|
+
throw new Error("Workspace not found");
|
|
101
|
+
return workspaceId;
|
|
102
|
+
}
|
|
103
|
+
function numberFlag(flags, key, fallback) {
|
|
104
|
+
const value = getFlagString(flags, key);
|
|
105
|
+
return value == null
|
|
106
|
+
? fallback
|
|
107
|
+
: z.coerce.number().int().positive().parse(value);
|
|
108
|
+
}
|
|
109
|
+
async function createConfiguration(flags) {
|
|
110
|
+
const interactive = canPromptInteractively(flags);
|
|
111
|
+
const machineFlag = getFlagString(flags, "instance-type");
|
|
112
|
+
const diskFlag = getFlagString(flags, "disk");
|
|
113
|
+
const machine = machineFlag
|
|
114
|
+
? machineOptions.find((option) => option.value === machineFlag)
|
|
115
|
+
: interactive
|
|
116
|
+
? await chooseOne("Select sandbox compute:", [...machineOptions])
|
|
117
|
+
: machineOptions[0];
|
|
118
|
+
if (!machine)
|
|
119
|
+
throw new Error("--instance-type must be m7i.xlarge, m7i.2xlarge, or m7i.4xlarge");
|
|
120
|
+
const requestedDisk = diskFlag
|
|
121
|
+
? z.coerce.number().int().parse(diskFlag)
|
|
122
|
+
: null;
|
|
123
|
+
const disk = requestedDisk !== null
|
|
124
|
+
? diskOptions.find((option) => option.value === requestedDisk)
|
|
125
|
+
: interactive
|
|
126
|
+
? await chooseOne("Select encrypted SSD size:", [...diskOptions])
|
|
127
|
+
: diskOptions[0];
|
|
128
|
+
if (!disk)
|
|
129
|
+
throw new Error("--disk must be 100, 200, or 500");
|
|
130
|
+
return { instanceType: machine.value, storageGiB: disk.value };
|
|
131
|
+
}
|
|
132
|
+
export async function commandCodeAccess(client, flags, action, id) {
|
|
133
|
+
const workspaceId = await codeAccessScope(client, flags);
|
|
134
|
+
const endpoint = `/api/workspaces/${workspaceId}/code-access`;
|
|
135
|
+
const request = (body) => client.request(endpoint, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
json: body,
|
|
138
|
+
signal: AbortSignal.timeout(790_000),
|
|
139
|
+
});
|
|
140
|
+
const print = (body) => process.stdout.write(JSON.stringify(body, null, 2) + "\n");
|
|
141
|
+
const issueConnection = async (executionId, keyFile) => {
|
|
142
|
+
const act = (input) => request({ operation: "act", id: executionId, input });
|
|
143
|
+
const hostsFile = keyFile + ".known_hosts";
|
|
144
|
+
if (existsSync(keyFile) || existsSync(hostsFile))
|
|
145
|
+
throw new Error("Choose new credential filenames; existing files are never overwritten");
|
|
146
|
+
const issued = await act({ action: "connect" });
|
|
147
|
+
const parsed = z
|
|
148
|
+
.object({
|
|
149
|
+
connection: z.object({
|
|
150
|
+
privateKey: z.string().min(1),
|
|
151
|
+
hostPublicKey: z.string(),
|
|
152
|
+
hostAlias: z.literal(`triage-${executionId}`),
|
|
153
|
+
username: z.literal("triage"),
|
|
154
|
+
expiresAt: z.string().datetime({ offset: true }),
|
|
155
|
+
}),
|
|
156
|
+
})
|
|
157
|
+
.safeParse(issued);
|
|
158
|
+
if (!parsed.success)
|
|
159
|
+
throw new Error("Invalid SSH identity returned by Apex");
|
|
160
|
+
const { connection } = parsed.data;
|
|
161
|
+
const hostKey = connection.hostPublicKey
|
|
162
|
+
.trim()
|
|
163
|
+
.split(/\s+/)
|
|
164
|
+
.slice(0, 2)
|
|
165
|
+
.join(" ");
|
|
166
|
+
if (!/^ssh-ed25519 [A-Za-z0-9+/]+=*$/.test(hostKey) ||
|
|
167
|
+
Date.parse(connection.expiresAt) <= Date.now())
|
|
168
|
+
throw new Error("Invalid SSH identity");
|
|
169
|
+
try {
|
|
170
|
+
if (createPrivateKey(connection.privateKey).asymmetricKeyType !== "rsa")
|
|
171
|
+
throw new Error("unexpected key type");
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
throw new Error("Invalid SSH private key returned by Apex");
|
|
175
|
+
}
|
|
176
|
+
let wroteKey = false;
|
|
177
|
+
try {
|
|
178
|
+
writeFileSync(keyFile, connection.privateKey, {
|
|
179
|
+
flag: "wx",
|
|
180
|
+
mode: 0o600,
|
|
181
|
+
});
|
|
182
|
+
wroteKey = true;
|
|
183
|
+
writeFileSync(hostsFile, `${connection.hostAlias} ${hostKey}\n`, {
|
|
184
|
+
flag: "wx",
|
|
185
|
+
mode: 0o600,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
if (wroteKey)
|
|
190
|
+
rmSync(keyFile);
|
|
191
|
+
throw new Error("Could not save credentials");
|
|
192
|
+
}
|
|
193
|
+
const source = import.meta.url.endsWith(".ts");
|
|
194
|
+
const entry = fileURLToPath(new URL(source ? "./apex.ts" : "./apex.js", import.meta.url));
|
|
195
|
+
const invocation = [
|
|
196
|
+
quote(process.execPath),
|
|
197
|
+
...(source
|
|
198
|
+
? ["--import", quote(createRequire(import.meta.url).resolve("tsx"))]
|
|
199
|
+
: []),
|
|
200
|
+
quote(entry),
|
|
201
|
+
"code-access",
|
|
202
|
+
"proxy",
|
|
203
|
+
executionId,
|
|
204
|
+
"--workspace-ref",
|
|
205
|
+
workspaceId,
|
|
206
|
+
].join(" ");
|
|
207
|
+
const sshArgs = [
|
|
208
|
+
"-i",
|
|
209
|
+
keyFile.replaceAll("%", "%%"),
|
|
210
|
+
"-o",
|
|
211
|
+
"IdentitiesOnly=yes",
|
|
212
|
+
"-o",
|
|
213
|
+
"StrictHostKeyChecking=yes",
|
|
214
|
+
"-o",
|
|
215
|
+
"UpdateHostKeys=no",
|
|
216
|
+
"-o",
|
|
217
|
+
"ForwardAgent=no",
|
|
218
|
+
"-o",
|
|
219
|
+
"ClearAllForwardings=yes",
|
|
220
|
+
"-o",
|
|
221
|
+
`UserKnownHostsFile="${hostsFile.replaceAll("%", "%%").replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`,
|
|
222
|
+
"-o",
|
|
223
|
+
`HostKeyAlias=${connection.hostAlias}`,
|
|
224
|
+
"-o",
|
|
225
|
+
`ProxyCommand=${invocation.replaceAll("%", "%%")}`,
|
|
226
|
+
`triage@${connection.hostAlias}`,
|
|
227
|
+
];
|
|
228
|
+
const sshCommand = ["ssh", ...sshArgs.map(quote)].join(" ");
|
|
229
|
+
return {
|
|
230
|
+
keyFile,
|
|
231
|
+
hostsFile,
|
|
232
|
+
expiresAt: connection.expiresAt,
|
|
233
|
+
sshCommand,
|
|
234
|
+
sshArgs,
|
|
235
|
+
};
|
|
236
|
+
};
|
|
237
|
+
if (action === "create") {
|
|
238
|
+
const config = await createConfiguration(flags);
|
|
239
|
+
const created = await withLoadingIndicator("Starting sandbox and cloning workspace repositories...", flags, () => request({
|
|
240
|
+
operation: "create",
|
|
241
|
+
input: {
|
|
242
|
+
...config,
|
|
243
|
+
leaseMinutes: numberFlag(flags, "minutes", 1440),
|
|
244
|
+
},
|
|
245
|
+
}));
|
|
246
|
+
const execution = z
|
|
247
|
+
.object({ execution: z.object({ id: uuid }).passthrough() })
|
|
248
|
+
.passthrough()
|
|
249
|
+
.parse(created);
|
|
250
|
+
print({
|
|
251
|
+
...execution,
|
|
252
|
+
sshCommand: `apex code-access ssh ${execution.execution.id} --workspace-ref ${workspaceId}`,
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (action === "list") {
|
|
257
|
+
print(await request({ operation: "list" }));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const executionId = uuid.parse(id);
|
|
261
|
+
if (action === "get") {
|
|
262
|
+
print(await request({ operation: "get", id: executionId }));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const act = (input) => request({ operation: "act", id: executionId, input });
|
|
266
|
+
if (action === "proxy") {
|
|
267
|
+
const session = z
|
|
268
|
+
.object({
|
|
269
|
+
session: z.object({
|
|
270
|
+
SessionId: z.string(),
|
|
271
|
+
StreamUrl: z.string().url(),
|
|
272
|
+
TokenValue: z.string(),
|
|
273
|
+
}),
|
|
274
|
+
target: z.string().regex(/^i-[a-f0-9]+$/),
|
|
275
|
+
region: z.string().regex(/^[a-z0-9-]+$/),
|
|
276
|
+
port: z.literal(2222),
|
|
277
|
+
})
|
|
278
|
+
.parse(await act({ action: "connect-session" }));
|
|
279
|
+
const child = spawn("session-manager-plugin", [
|
|
280
|
+
JSON.stringify(session.session),
|
|
281
|
+
session.region,
|
|
282
|
+
"StartSession",
|
|
283
|
+
"",
|
|
284
|
+
JSON.stringify({
|
|
285
|
+
Target: session.target,
|
|
286
|
+
DocumentName: "AWS-StartSSHSession",
|
|
287
|
+
Parameters: { portNumber: ["2222"] },
|
|
288
|
+
}),
|
|
289
|
+
`https://ssm.${session.region}.amazonaws.com`,
|
|
290
|
+
], {
|
|
291
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
292
|
+
env: {
|
|
293
|
+
PATH: process.env.PATH,
|
|
294
|
+
HOME: process.env.HOME,
|
|
295
|
+
LANG: process.env.LANG,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
child.stdin.on("error", () => undefined);
|
|
299
|
+
process.stdin.pipe(child.stdin);
|
|
300
|
+
child.stdout.pipe(createSsmPluginOutputFilter()).pipe(process.stdout, {
|
|
301
|
+
end: false,
|
|
302
|
+
});
|
|
303
|
+
try {
|
|
304
|
+
await new Promise((resolve, reject) => {
|
|
305
|
+
child.once("error", () => reject(new Error("Install the AWS session-manager-plugin to connect")));
|
|
306
|
+
child.once("close", (code) => code === 0 ? resolve() : reject(new Error("SSH transport closed")));
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
process.stdin.unpipe(child.stdin);
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (action === "ssh") {
|
|
315
|
+
const directory = mkdtempSync(path.join(os.tmpdir(), "apex-triage-ssh-"));
|
|
316
|
+
let child = null;
|
|
317
|
+
let interruptedBy = null;
|
|
318
|
+
const cleanup = () => rmSync(directory, { recursive: true, force: true });
|
|
319
|
+
const onSignal = (signal) => {
|
|
320
|
+
interruptedBy = signal;
|
|
321
|
+
cleanup();
|
|
322
|
+
child?.kill(signal);
|
|
323
|
+
};
|
|
324
|
+
const signals = ["SIGHUP", "SIGINT", "SIGTERM"];
|
|
325
|
+
const signalHandlers = signals.map((signal) => [signal, () => onSignal(signal)]);
|
|
326
|
+
for (const [signal, handler] of signalHandlers)
|
|
327
|
+
process.once(signal, handler);
|
|
328
|
+
try {
|
|
329
|
+
const connection = await issueConnection(executionId, path.join(directory, "identity"));
|
|
330
|
+
if (interruptedBy)
|
|
331
|
+
throw new Error(`SSH interrupted by ${interruptedBy}`);
|
|
332
|
+
const ssh = spawn("ssh", connection.sshArgs, { stdio: "inherit" });
|
|
333
|
+
child = ssh;
|
|
334
|
+
await new Promise((resolve, reject) => {
|
|
335
|
+
ssh.once("error", () => reject(new Error("Unable to start SSH")));
|
|
336
|
+
ssh.once("close", (code) => {
|
|
337
|
+
if (interruptedBy)
|
|
338
|
+
reject(new Error(`SSH interrupted by ${interruptedBy}`));
|
|
339
|
+
else if (code === 255)
|
|
340
|
+
reject(new Error("SSH connection failed"));
|
|
341
|
+
else {
|
|
342
|
+
if (code)
|
|
343
|
+
process.exitCode = code;
|
|
344
|
+
resolve();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
for (const [signal, handler] of signalHandlers)
|
|
351
|
+
process.removeListener(signal, handler);
|
|
352
|
+
cleanup();
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (action === "connect") {
|
|
357
|
+
const keyPath = getFlagString(flags, "key-file");
|
|
358
|
+
if (!keyPath)
|
|
359
|
+
throw new Error("--key-file is required");
|
|
360
|
+
const { sshArgs: _sshArgs, ...connection } = await issueConnection(executionId, prepareCredentialPath(keyPath));
|
|
361
|
+
print(connection);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const input = action === "extend"
|
|
365
|
+
? { action, minutes: numberFlag(flags, "minutes", 1440) }
|
|
366
|
+
: ["resume", "revoke-ssh", "stop", "kill", "delete"].includes(action)
|
|
367
|
+
? { action }
|
|
368
|
+
: null;
|
|
369
|
+
if (!input)
|
|
370
|
+
throw new Error("Unknown command");
|
|
371
|
+
print(await act(input));
|
|
372
|
+
}
|
|
373
|
+
export async function commandInternalTriage(client, flags, action, id) {
|
|
374
|
+
const workspaceId = await codeAccessScope(client, flags);
|
|
375
|
+
const endpoint = `/api/workspaces/${workspaceId}/triage`;
|
|
376
|
+
if (action === "findings") {
|
|
377
|
+
process.stdout.write(JSON.stringify(await client.request(endpoint), null, 2) + "\n");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (action === "review") {
|
|
381
|
+
const findingId = uuid.parse(id).toLowerCase();
|
|
382
|
+
const validity = z
|
|
383
|
+
.enum(["valid", "partially_valid", "invalid"])
|
|
384
|
+
.parse(getFlagString(flags, "validity"));
|
|
385
|
+
const severityValue = getFlagString(flags, "severity");
|
|
386
|
+
const severity = severityValue
|
|
387
|
+
? z
|
|
388
|
+
.enum([
|
|
389
|
+
"extreme",
|
|
390
|
+
"critical",
|
|
391
|
+
"high",
|
|
392
|
+
"medium",
|
|
393
|
+
"low",
|
|
394
|
+
"informational",
|
|
395
|
+
])
|
|
396
|
+
.parse(severityValue)
|
|
397
|
+
: null;
|
|
398
|
+
if (validity === "invalid" && severity)
|
|
399
|
+
throw new Error("Invalid reviews must omit --severity");
|
|
400
|
+
if (validity !== "invalid" && !severity)
|
|
401
|
+
throw new Error("Valid and partially valid reviews require --severity");
|
|
402
|
+
const expectedUpdatedAt = z
|
|
403
|
+
.string()
|
|
404
|
+
.datetime({ offset: true })
|
|
405
|
+
.parse(getFlagString(flags, "expected-updated-at"));
|
|
406
|
+
const requestedId = getFlagString(flags, "request-id");
|
|
407
|
+
const requestId = uuid.parse(requestedId ?? randomUUID()).toLowerCase();
|
|
408
|
+
if (!requestedId)
|
|
409
|
+
process.stderr.write(`Request ID: ${requestId}\n`);
|
|
410
|
+
const result = await client.request(endpoint, {
|
|
411
|
+
method: "POST",
|
|
412
|
+
json: {
|
|
413
|
+
findingId,
|
|
414
|
+
requestId,
|
|
415
|
+
expectedUpdatedAt,
|
|
416
|
+
edit: {
|
|
417
|
+
action: "review",
|
|
418
|
+
validity,
|
|
419
|
+
severity,
|
|
420
|
+
note: getFlagString(flags, "note") ?? "",
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (action !== "edit")
|
|
428
|
+
throw new Error("Unknown command");
|
|
429
|
+
const findingId = uuid.parse(id).toLowerCase(), file = getFlagString(flags, "file");
|
|
430
|
+
if (!file)
|
|
431
|
+
throw new Error("--file is required");
|
|
432
|
+
const edit = JSON.parse(readFileSync(file, "utf8"));
|
|
433
|
+
const expectedUpdatedAt = z
|
|
434
|
+
.string()
|
|
435
|
+
.datetime({ offset: true })
|
|
436
|
+
.parse(getFlagString(flags, "expected-updated-at"));
|
|
437
|
+
const requestedId = getFlagString(flags, "request-id");
|
|
438
|
+
const requestId = uuid.parse(requestedId ?? randomUUID()).toLowerCase();
|
|
439
|
+
if (!requestedId)
|
|
440
|
+
process.stderr.write(`Request ID: ${requestId}\n`);
|
|
441
|
+
const result = await client.request(endpoint, {
|
|
442
|
+
method: "POST",
|
|
443
|
+
json: { findingId, requestId, expectedUpdatedAt, edit },
|
|
444
|
+
});
|
|
445
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
446
|
+
}
|
|
@@ -285,7 +285,7 @@ async function inspectCreatedArchive(archivePath, sourceRoot, expectedFiles, dis
|
|
|
285
285
|
if (!entryPath ||
|
|
286
286
|
!expectedEntries.has(entryPath) ||
|
|
287
287
|
observedEntries.has(entryPath) ||
|
|
288
|
-
!["File", "OldFile", "ContiguousFile", "SymbolicLink"
|
|
288
|
+
!["File", "OldFile", "ContiguousFile", "SymbolicLink"].includes(entry.type)) {
|
|
289
289
|
throw new Error(`Archive for ${displayName} changed while it was being prepared.`);
|
|
290
290
|
}
|
|
291
291
|
if (entry.type === "SymbolicLink") {
|
|
@@ -294,12 +294,6 @@ async function inspectCreatedArchive(archivePath, sourceRoot, expectedFiles, dis
|
|
|
294
294
|
}
|
|
295
295
|
ensureEntryWithinRoot(sourceRoot, entryPath, entry.linkpath);
|
|
296
296
|
}
|
|
297
|
-
else if (entry.type === "Link") {
|
|
298
|
-
const linkedEntry = normalizeRelativeEntry(entry.linkpath ?? "");
|
|
299
|
-
if (!linkedEntry || !expectedEntries.has(linkedEntry)) {
|
|
300
|
-
throw new Error(`Archive for ${displayName} changed while it was being prepared.`);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
297
|
observedEntries.add(entryPath);
|
|
304
298
|
entryCount += 1;
|
|
305
299
|
const entrySize = Number(entry.size ?? 0);
|
|
@@ -347,6 +341,9 @@ async function createDeterministicArchive(params) {
|
|
|
347
341
|
// that was not reviewed against the ignore rules and upload limits.
|
|
348
342
|
noDirRecurse: true,
|
|
349
343
|
onWriteEntry(entry) {
|
|
344
|
+
// Kernel rejects hard-link records. With jobs: 1, clearing the inode
|
|
345
|
+
// cache here makes each subsequent path carry its own file payload.
|
|
346
|
+
entry.linkCache.clear();
|
|
350
347
|
entry.mtime = FIXED_MTIME;
|
|
351
348
|
entry.uid = 0;
|
|
352
349
|
entry.gid = 0;
|
package/package.json
CHANGED
package/skills/apex-cli/SKILL.md
CHANGED
|
@@ -156,7 +156,7 @@ finish before saving again.
|
|
|
156
156
|
|
|
157
157
|
Scan target and recovery rules:
|
|
158
158
|
- CLI `apex scan [target...]` and repeatable `--repo` accept exact files, directories, or HTTPS GitHub/GitLab or company-enabled Forgejo repository URLs; local MCP uses `repoPaths` for the same selections. No target means `cwd`.
|
|
159
|
-
- A file selection scans only that file, respecting ignore rules. Local snapshots exclude `.apex/` state. Remote URLs use the default branch and cannot fall back to uploading `cwd`; clone locally to choose a branch or commit.
|
|
159
|
+
- A file selection scans only that file, respecting ignore rules. Local snapshots exclude `.apex/` state. Hard-linked files are uploaded independently, while safe relative symbolic links and long filenames are preserved. Remote URLs use the default branch and cannot fall back to uploading `cwd`; clone locally to choose a branch or commit.
|
|
160
160
|
- Use Standard for local changes, files, plain directories, and public repositories without provider access. Lite and Audit still require connected provider sources; do not silently substitute another mode.
|
|
161
161
|
- Unauthenticated headless CLI commands fail with explicit login guidance; they never start device login. Use the MCP auth tools for explicit MCP login. Retry transient refresh failures without clearing credentials.
|
|
162
162
|
- Audit Setup is an explicit Standard/Audit beta flow using saved workspace repositories and context. It does not preserve local targets. Never automatically substitute setup after `AUDIT_SETUP_REQUIRED`; use the error's selected-workspace command only when that saved scope is intended. Draft operations require the ID returned by preparation; eligible accounts receive setup commands in CLI help. Lite and PR do not use `--setup`.
|
|
@@ -175,3 +175,5 @@ Finding lifecycle:
|
|
|
175
175
|
Forgejo connections, remote repository scans, PR scans and Fix Review require the Forgejo company feature enabled by an administrator in Bedrock Settings. Run `apex connect forgejo` (or `apex-connect-provider` with `provider: "forgejo"`) to obtain the browser connection URL. A dedicated bot, HTTPS instance URL and repository-scoped read token are required; PR statuses, comments and webhook installation also require a write token.
|
|
176
176
|
|
|
177
177
|
After connecting, run `apex doctor --repo . --source-mode remote`, then `apex scan --mode standard --repo . --source-mode remote` or `apex scan --mode pr --pr 42 --repo .`. Same-repository Forgejo PR URLs use `/owner/repository/pulls/42`. Pass a Forgejo Fix PR URL through finding feedback and start Fix Review using the existing finding commands or MCP tools. Disabling the company flag stops native Forgejo access. Existing local Standard snapshots remain provider-independent.
|
|
178
|
+
|
|
179
|
+
Internal employee beta capabilities are advertised only after live role and workspace eligibility checks. SSH credential delivery is intentionally scripted CLI only; it is not exposed through MCP tools or public help. Server authorization remains mandatory for every action.
|