Skip to content
Draft
144 changes: 108 additions & 36 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,12 @@ import {
} from "./config.js";
import { formatUsd, type ScanCost } from "./cost.js";
import {
AuthenticationRequiredError,
CodexSecurityError,
ConfigurationError,
ContractValidationError,
InvalidTargetError,
LocalPluginBootstrapError,
OutputDirectoryError,
OutputInsideProtectedRootError,
PluginPythonUnavailableError,
Expand Down Expand Up @@ -1101,8 +1104,33 @@ export async function main(
let exitCode = 0;
let frameworkExit: number | undefined;
let frameworkOutput = "";
let renderedScanFailure: string | undefined;
let renderedHistory: string | undefined;
let renderedPublication: string | undefined;
const startedAt = performance.now();
const scanFailure = (
command: "scan" | "scans rerun",
format: string,
code: "SCAN_FAILED" | "SCAN_REPLAY_UNAVAILABLE",
message: string,
) => {
const error = { code, message };
if (format === "json" || format === "jsonl") {
// Keep failures complete and exclude framework invocation metadata.
const payload = argv.includes("--full-output")
? {
ok: false,
error,
meta: {
command,
duration: `${Math.round(performance.now() - startedAt)}ms`,
},
}
: error;
renderedScanFailure = `${JSON.stringify(payload, null, format === "json" ? 2 : undefined)}\n`;
}
return { ...error, exitCode };
};
const history = async (
args: readonly string[],
select: (value: JsonObject) => JsonObject | Promise<JsonObject> = (value) =>
Expand Down Expand Up @@ -1427,36 +1455,39 @@ export async function main(
.describe("Print scan diagnostics to stderr."),
}),
output: z.record(z.string(), z.unknown()).optional(),
async run({ args, error: incurError, options }) {
const scanId = args.scanId ?? (await latestScans())?.[0]?.scanId;
if (scanId === undefined) return;
let scanArguments: ScanArguments;
async run({ args, error: incurError, format, options }) {
let scanArguments: ScanArguments | undefined;
try {
const { recipe } = await dependencies.runWorkbench([
"get-scan-recipe",
"--scan-id",
scanId,
]);
scanArguments = scanArgumentsFromRecipe(recipe, scanId);
scanArguments.verbose = options.verbose;
const scanId = args.scanId ?? (await latestScans())?.[0]?.scanId;
if (scanId !== undefined) {
const { recipe } = await dependencies.runWorkbench([
"get-scan-recipe",
"--scan-id",
scanId,
]);
scanArguments = scanArgumentsFromRecipe(recipe, scanId);
scanArguments.verbose = options.verbose;
}
} catch (error) {
const message = errorMessage(error);
errorOutput.write(`codex-security: ${message}\n`);
errorOutput.write(`codex-security: ${errorMessage(error)}\n`);
}
if (scanArguments === undefined) {
exitCode = 2;
return incurError({
code: "SCAN_REPLAY_UNAVAILABLE",
message,
exitCode,
});
return incurError(
scanFailure(
"scans rerun",
format,
"SCAN_REPLAY_UNAVAILABLE",
"The saved scan could not be replayed.",
),
);
}
const outcome = await runScan(scanArguments, errorOutput, dependencies);
exitCode = outcome.exitCode;
if (outcome.error !== undefined) {
return incurError({
code: "SCAN_FAILED",
message: outcome.error,
exitCode,
});
return incurError(
scanFailure("scans rerun", format, "SCAN_FAILED", outcome.error),
);
}
return outcome.data;
},
Expand Down Expand Up @@ -2095,11 +2126,9 @@ export async function main(
);
exitCode = outcome.exitCode;
if (outcome.error !== undefined) {
return incurError({
code: "SCAN_FAILED",
message: outcome.error,
exitCode,
});
return incurError(
scanFailure("scan", format, "SCAN_FAILED", outcome.error),
);
}
if (
!options.dryRun &&
Expand Down Expand Up @@ -2752,18 +2781,23 @@ export async function main(
updateController.abort();
}
if (notice !== undefined) errorOutput.write(formatUpdateNotice(notice));
if (frameworkExit !== undefined) {
if (frameworkExit !== undefined && renderedScanFailure === undefined) {
if (exitCode !== 0) return exitCode;
errorOutput.write(
`codex-security: ${errorMessage(incurErrorMessage(frameworkOutput))}\n`,
);
return 2;
}
if (frameworkOutput.length === 0) return exitCode;
if (renderedScanFailure === undefined && frameworkOutput.length === 0) {
return exitCode;
}
try {
await writeCliOutput(
output,
renderedPublication ?? renderedHistory ?? frameworkOutput,
renderedScanFailure ??
renderedPublication ??
renderedHistory ??
frameworkOutput,
);
return exitCode;
} catch (error) {
Expand Down Expand Up @@ -4123,15 +4157,12 @@ async function executeScan(
estimated_usd: costLimitFailure?.cost.estimatedUsd,
});
errorOutput.write(`${message}\n`);
if (failure instanceof ScanInterruptedError) {
return { exitCode: 2, error: message };
}
if (scanDir !== null) {
if (!(failure instanceof ScanInterruptedError) && scanDir !== null) {
errorOutput.write(
`Partial output was kept at ${errorMessage(scanDir)}.\n`,
);
}
return { exitCode: 2, error: message };
return { exitCode: 2, error: structuredScanFailureMessage(failure) };
}
if (preflight !== null) {
const effectivePreflight: ScanPreflight = {
Expand Down Expand Up @@ -4244,6 +4275,7 @@ function isLocalScanFailure(error: unknown): boolean {
error instanceof InvalidTargetError ||
error instanceof OutputDirectoryError ||
error instanceof ConfigurationError ||
error instanceof LocalPluginBootstrapError ||
error instanceof PluginPythonUnavailableError
) {
return true;
Expand Down Expand Up @@ -4305,6 +4337,42 @@ function scanFailureMessage(
}
}

function structuredScanFailureMessage(error: unknown): string {
if (error instanceof OutputInsideProtectedRootError) {
return protectedRootErrorMessage(error, false);
}
if (error instanceof ScanCostLimitExceededError) {
return "The scan exceeded its configured cost limit.";
}
if (error instanceof ContractValidationError || isLocalScanFailure(error)) {
return "The scan could not complete because a local input or filesystem operation failed.";
}
if (error instanceof AuthenticationRequiredError) {
return "Authentication failed. Check the selected credentials.";
}
if (
/flagged for possible cybersecurity risk|trusted access for cyber|cybersecurity policy/iu.test(
errorMessage(error),
)
) {
return "The scan was blocked by a cybersecurity policy. Trusted Access for Cyber may be required.";
}
switch (classifyConnectionFailure(error)) {
case "unauthorized":
return "Authentication failed. Check the selected credentials.";
case "forbidden":
return "The selected credentials cannot access the configured model.";
case "rate_limited":
return "The configured account reached its rate limit. Wait and retry.";
case "network_error":
return "The scan encountered a network or connection failure.";
case "timeout":
return "The scan timed out.";
case "unknown":
return "The scan failed. See stderr for details.";
}
}

function scanScope(arguments_: ScanArguments): string | null {
if (arguments_.paths.length > 0) {
const displayed = arguments_.paths.slice(0, 3).map((path) => {
Expand Down Expand Up @@ -4435,6 +4503,7 @@ function formatTokenUsage(usage: unknown): string | null {

function protectedRootErrorMessage(
error: OutputInsideProtectedRootError,
includePaths = true,
): string {
const description =
error.pathKind === "output"
Expand All @@ -4446,6 +4515,9 @@ function protectedRootErrorMessage(
error.pathKind === "output"
? "Scan artifacts cannot be written inside the protected scan root."
: "Temporary and runtime files cannot be created inside the protected scan root.";
if (!includePaths) {
return `${description} must be outside the scanned directory and any enclosing Git worktree. ${reason}`;
}
const suggestion = suggestedOutputDirectory(error.protectedRoot);
const recovery =
error.pathKind === "output"
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export class CodexSecurityError extends Error {
export class ConfigurationError extends CodexSecurityError {}
export class AuthenticationRequiredError extends CodexSecurityError {}
export class PluginBootstrapError extends CodexSecurityError {}
export class LocalPluginBootstrapError extends PluginBootstrapError {}
export class PluginPythonUnavailableError extends PluginBootstrapError {}
export class InvalidTargetError extends CodexSecurityError {}
export class OutputDirectoryError extends CodexSecurityError {}
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export {
ContractValidationError,
IncompleteScanError,
InvalidTargetError,
LocalPluginBootstrapError,
OutputDirectoryError,
OutputInsideProtectedRootError,
PluginBootstrapError,
Expand Down
13 changes: 13 additions & 0 deletions sdk/typescript/src/knowledge-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { tmpdir } from "node:os";
import { basename, extname, join, resolve } from "node:path";
import { unzipSync } from "fflate";
import { ConfigurationError, errorMessage } from "./errors.js";
import { expandHome } from "./runtime.js";

const SUPPORTED_EXTENSIONS = new Set([
Expand All @@ -30,6 +31,18 @@ export interface PreparedKnowledgeBase {
export async function prepareKnowledgeBase(
paths: readonly string[],
signal?: AbortSignal,
): Promise<PreparedKnowledgeBase> {
try {
return await stageKnowledgeBase(paths, signal);
} catch (error) {
if (signal?.aborted) throw error;
throw new ConfigurationError(errorMessage(error), { cause: error });
}
}

async function stageKnowledgeBase(
paths: readonly string[],
signal?: AbortSignal,
): Promise<PreparedKnowledgeBase> {
const sources = new Set<string>();
const documents = new Set<string>();
Expand Down
44 changes: 38 additions & 6 deletions sdk/typescript/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import extractZip from "extract-zip";
import { parse } from "smol-toml";
import {
CodexSecurityError,
LocalPluginBootstrapError,
OutputDirectoryError,
PluginBootstrapError,
PluginPythonUnavailableError,
Expand Down Expand Up @@ -1911,6 +1912,21 @@ export async function resolvePluginPath(
pluginPath: string | undefined,
workspace: string,
signal?: AbortSignal,
): Promise<string> {
try {
return await resolveLocalPluginPath(pluginPath, workspace, signal);
} catch (error) {
if (signal?.aborted || error instanceof LocalPluginBootstrapError) {
throw error;
}
throw new LocalPluginBootstrapError(errorMessage(error), { cause: error });
}
}

async function resolveLocalPluginPath(
pluginPath: string | undefined,
workspace: string,
signal?: AbortSignal,
): Promise<string> {
if (pluginPath === undefined) {
return await bundledPluginRoot();
Expand Down Expand Up @@ -1938,6 +1954,21 @@ export async function createMarketplace(
codexHome: string,
pluginRoot: string,
signal?: AbortSignal,
): Promise<string> {
try {
return await stageMarketplace(codexHome, pluginRoot, signal);
} catch (error) {
if (signal?.aborted || error instanceof LocalPluginBootstrapError) {
throw error;
}
throw new LocalPluginBootstrapError(errorMessage(error), { cause: error });
}
}

async function stageMarketplace(
codexHome: string,
pluginRoot: string,
signal?: AbortSignal,
): Promise<string> {
throwIfSignalAborted(signal);
const root = await realpath(pluginRoot);
Expand Down Expand Up @@ -2049,7 +2080,7 @@ export async function bootstrapPlugin(
throw error;
});
if (existing !== null && !existing.isDirectory()) {
throw new PluginBootstrapError(
throw new LocalPluginBootstrapError(
`Codex Security plugin marketplace path must be a directory: ${marketplace}`,
);
}
Expand Down Expand Up @@ -2135,18 +2166,19 @@ export async function pluginMetadata(
}
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} catch (error) {
throw new PluginBootstrapError(`Invalid Codex plugin directory: ${root}`, {
cause: error,
});
throw new LocalPluginBootstrapError(
`Invalid Codex plugin directory: ${root}`,
{ cause: error },
);
}
if (!isRecord(manifest) || manifest["name"] !== PLUGIN_NAME) {
throw new PluginBootstrapError(
throw new LocalPluginBootstrapError(
"Plugin manifest must have name 'codex-security'.",
);
}
const version = manifest["version"];
if (typeof version !== "string" || version.trim().length === 0) {
throw new PluginBootstrapError(
throw new LocalPluginBootstrapError(
"Plugin manifest must have a non-empty version.",
);
}
Expand Down
Loading
Loading