Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 101 additions & 36 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1080,8 +1080,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 @@ -1406,36 +1431,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 @@ -2065,11 +2093,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 @@ -2665,18 +2691,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 @@ -4028,15 +4059,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 @@ -4210,6 +4238,39 @@ 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 (isLocalScanFailure(error)) {
return "The scan could not complete because a local input or filesystem operation failed.";
}
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 @@ -4340,6 +4401,7 @@ function formatTokenUsage(usage: unknown): string | null {

function protectedRootErrorMessage(
error: OutputInsideProtectedRootError,
includePaths = true,
): string {
const description =
error.pathKind === "output"
Expand All @@ -4351,6 +4413,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
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
42 changes: 37 additions & 5 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,
ConfigurationError,
OutputDirectoryError,
PluginBootstrapError,
PluginPythonUnavailableError,
Expand Down Expand Up @@ -1813,7 +1814,12 @@ export async function extractPluginZip(
} catch (error) {
await rm(staging, { recursive: true, force: true }).catch(() => undefined);
throwIfSignalAborted(signal);
if (error instanceof PluginBootstrapError) throw error;
if (
error instanceof PluginBootstrapError ||
error instanceof ConfigurationError
) {
throw error;
}
throw new PluginBootstrapError(`Invalid plugin ZIP: ${archivePath}`, {
cause: error,
});
Expand Down Expand Up @@ -1911,6 +1917,19 @@ 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 ConfigurationError) throw error;
throw new ConfigurationError(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 +1957,19 @@ 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 ConfigurationError) throw error;
throw new ConfigurationError(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 +2081,7 @@ export async function bootstrapPlugin(
throw error;
});
if (existing !== null && !existing.isDirectory()) {
throw new PluginBootstrapError(
throw new ConfigurationError(
`Codex Security plugin marketplace path must be a directory: ${marketplace}`,
);
}
Expand Down Expand Up @@ -2135,18 +2167,18 @@ export async function pluginMetadata(
}
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} catch (error) {
throw new PluginBootstrapError(`Invalid Codex plugin directory: ${root}`, {
throw new ConfigurationError(`Invalid Codex plugin directory: ${root}`, {
cause: error,
});
}
if (!isRecord(manifest) || manifest["name"] !== PLUGIN_NAME) {
throw new PluginBootstrapError(
throw new ConfigurationError(
"Plugin manifest must have name 'codex-security'.",
);
}
const version = manifest["version"];
if (typeof version !== "string" || version.trim().length === 0) {
throw new PluginBootstrapError(
throw new ConfigurationError(
"Plugin manifest must have a non-empty version.",
);
}
Expand Down
Loading
Loading