Skip to content
Open
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
17 changes: 17 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,23 @@ Scans are report-only by default. Set `--fail-on-severity high` to exit with
`1` if a completed scan finds high or critical issues. Incomplete scans exit
with `2`, writing available results to stdout and a coverage warning to stderr.

For machine-readable scan output (`--format json` or `--format jsonl`), a scan
execution failure writes one structured object to stdout:

```json
{
"status": "failed",
"code": "SCAN_FAILED",
"message": "..."
}
```

The command still exits with `2` for runtime, export, invalid-input, or
incomplete-scan failures, and human-readable diagnostics remain on stderr.
Use `scan --schema --format json` to discover this failure variant alongside
the successful scan output. Cancellation and termination retain their `130`
and `143` exit codes.

### Generate mock scan results

Use `--mock` to populate a Standard scan with synthetic test data in seconds,
Expand Down
20 changes: 19 additions & 1 deletion sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,17 @@ interface ScanOutcome {
error?: string;
}

const scanOutputSchema = z
.union([
z.record(z.string(), z.unknown()),
z.object({
status: z.literal("failed"),
code: z.literal("SCAN_FAILED"),
message: z.string(),
}),
])
.optional();

interface ExportArguments {
scanDir: string;
format: keyof typeof EXPORT_DEFAULT_OUTPUTS;
Expand Down Expand Up @@ -3007,7 +3018,7 @@ export async function main(
},
},
],
output: z.record(z.string(), z.unknown()).optional(),
output: scanOutputSchema,
async run({ args, error: incurError, format, options }) {
if (format === "md") {
errorOutput.write(
Expand Down Expand Up @@ -3061,6 +3072,13 @@ export async function main(
);
exitCode = outcome.exitCode;
if (outcome.error !== undefined) {
if (format === "json" || format === "jsonl") {
return {
status: "failed",
code: "SCAN_FAILED",
message: safeErrorMessage(outcome.error),
Comment thread
Hughhhhcoder marked this conversation as resolved.
};
}
return incurError({
code: "SCAN_FAILED",
message: outcome.error,
Expand Down
11 changes: 9 additions & 2 deletions sdk/typescript/tests-ts/cli-authentication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,7 +928,10 @@ describe("CLI authentication", () => {
deps,
),
).toBe(2);
expect(stdout.text()).toBe("");
expect(JSON.parse(stdout.text())).toMatchObject({
status: "failed",
code: "SCAN_FAILED",
});
expect(stderr.text()).toContain("workspace-managed policies");
expect(stderr.text()).toContain(
"API key is selected for model authentication",
Expand Down Expand Up @@ -963,7 +966,11 @@ describe("CLI authentication", () => {
expect(
await main(["scan", "--json"], stdout.stream, stderr.stream, deps),
).toBe(2);
expect(stdout.text()).toBe("");
expect(JSON.parse(stdout.text())).toMatchObject({
status: "failed",
code: "SCAN_FAILED",
message: message.includes("access token") ? "[redacted]" : message,
});
expect(stderr.text()).toContain(`${message}\n`);
expect(stderr.text()).not.toContain("PRIVATE_UPSTREAM_DETAIL");
expect(stderr.text()).not.toContain("npx @openai/codex-security logout");
Expand Down
116 changes: 106 additions & 10 deletions sdk/typescript/tests-ts/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,18 @@ describe("CLI", () => {
dependencies(),
),
).toBe(0);
expect(JSON.parse(schema.text())).toMatchObject({
const scanSchema = JSON.parse(schema.text()) as {
args: Record<string, unknown>;
options: Record<string, unknown>;
output?: {
anyOf?: Array<{
properties?: Record<string, { const?: string; type?: string }>;
required?: string[];
additionalProperties?: boolean;
}>;
};
};
expect(scanSchema).toMatchObject({
args: { properties: { repository: { type: "string" } } },
options: {
properties: {
Expand All @@ -163,6 +174,18 @@ describe("CLI", () => {
},
},
});
const failureSchema = scanSchema.output?.anyOf?.find(
(variant) => variant.properties?.["code"]?.const === "SCAN_FAILED",
);
expect(failureSchema).toMatchObject({
properties: {
status: { const: "failed" },
code: { const: "SCAN_FAILED" },
message: { type: "string" },
},
required: ["status", "code", "message"],
additionalProperties: false,
});

const rerunSchema = capture();
expect(
Expand Down Expand Up @@ -3617,7 +3640,10 @@ describe("CLI", () => {
expect(stderr.text()).toContain("Provider failed for");
expect(stderr.text()).toContain("tenant-private");
expect(stderr.text()).toContain("req-internal");
expect(stdout.text()).toBe("");
expect(JSON.parse(stdout.text())).toMatchObject({
status: "failed",
code: "SCAN_FAILED",
});
});

test("preserves provider identifier variants in scan failures", async () => {
Expand Down Expand Up @@ -3710,7 +3736,10 @@ describe("CLI", () => {
deps,
),
).toBe(2);
expect(stdout.text()).toBe("");
expect(JSON.parse(stdout.text())).toMatchObject({
status: "failed",
code: "SCAN_FAILED",
});
expect(stderr.text()).toContain("Provider failed for");
for (const identifier of identifiers) {
expect(stderr.text()).toContain(identifier);
Expand Down Expand Up @@ -3930,7 +3959,10 @@ describe("CLI", () => {
expect(stderr.text()).toContain("Cleanup failed for");
expect(stderr.text()).toContain("tenant-private");
expect(stderr.text()).toContain("req-internal");
expect(stdout.text()).toBe("");
expect(JSON.parse(stdout.text())).toMatchObject({
status: "failed",
code: "SCAN_FAILED",
});
});

test("reports reconnect progress on stderr and keeps JSON output clean", async () => {
Expand Down Expand Up @@ -4036,6 +4068,41 @@ describe("CLI", () => {
}
});

test("emits structured failures for machine-readable scan output", async () => {
const message = "This content was flagged for possible cybersecurity risk.";
for (const formatArgs of [
["--json"],
["--format", "json"],
["--format", "jsonl"],
] as const) {
const stdout = capture();
const stderr = capture();
const deps = dependencies();
deps.createSecurity = () => ({
run: async () => {
throw new CodexSecurityError(message);
},
preflight: async () => fakePreflight(),
close: async () => {},
});

expect(
await main(
["scan", ".", ...formatArgs],
stdout.stream,
stderr.stream,
deps,
),
).toBe(2);
expect(JSON.parse(stdout.text().trim())).toEqual({
status: "failed",
code: "SCAN_FAILED",
message,
});
expect(stderr.text()).toContain(`${message}\n`);
}
});

test("surfaces underlying scanner errors instead of inventing a model outage", async () => {
for (const message of [
"Could not save the Codex Security scan: UNIQUE constraint failed: scans.scan_dir",
Expand All @@ -4058,7 +4125,11 @@ describe("CLI", () => {
expect(
await main(["scan", ".", "--json"], stdout.stream, stderr.stream, deps),
).toBe(2);
expect(stdout.text()).toBe("");
expect(JSON.parse(stdout.text())).toEqual({
status: "failed",
code: "SCAN_FAILED",
message,
});
expect(stderr.text()).toContain(`${message}\n`);
expect(stderr.text()).not.toContain("codex-security:");
expect(stderr.text()).not.toContain("model service could not be reached");
Expand Down Expand Up @@ -4182,7 +4253,11 @@ describe("CLI", () => {
expect(
await main(["scan", ".", "--json"], stdout.stream, stderr.stream, deps),
).toBe(2);
expect(stdout.text()).toBe("");
expect(JSON.parse(stdout.text())).toEqual({
status: "failed",
code: "SCAN_FAILED",
message: "[redacted]",
});
expect(stderr.text()).toContain(
`network failure ECONNRESET ${SYNTHETIC_CREDENTIALS}`,
);
Expand Down Expand Up @@ -4889,7 +4964,11 @@ describe("CLI", () => {
}),
),
).toBe(2);
expect(stdout.text()).toBe("");
expect(JSON.parse(stdout.text())).toMatchObject({
status: "failed",
code: "SCAN_FAILED",
message: expect.stringContaining("Scan stopped: estimated cost"),
});
expect(stderr.text()).toContain(
"Scan stopped: estimated cost $0.00625 exceeded the $0.005 limit; partial output remains at /tmp/scan.",
);
Expand Down Expand Up @@ -5308,7 +5387,13 @@ describe("CLI", () => {
failing,
),
).toBe(2);
expect(stdout.text()).toBe("");
expect(JSON.parse(stdout.text())).toMatchObject({
status: "failed",
code: "SCAN_FAILED",
message: expect.stringContaining(
"Scan output directory must be outside",
),
});
expect(stderr.text()).toContain(
"Scan output directory must be outside the scanned directory and any enclosing Git worktree.",
);
Expand Down Expand Up @@ -5415,7 +5500,10 @@ describe("CLI", () => {
failing,
),
).toBe(2);
expect(stdout.text()).toBe("");
expect(JSON.parse(stdout.text())).toMatchObject({
status: "failed",
code: "SCAN_FAILED",
});
expect(stderr.text()).toContain(`Resolved path: ${output}`);
expect(stderr.text()).toContain(`Protected root: ${protectedRoot}`);
});
Expand Down Expand Up @@ -5496,7 +5584,15 @@ describe("CLI", () => {
}),
),
).toBe(2);
expect(stdout.text()).toBe("");
if (json) {
expect(JSON.parse(stdout.text())).toEqual({
status: "failed",
code: "SCAN_FAILED",
message: "SYNTHETIC_AUTH_HOME_CLEANUP_FAILED",
});
} else {
expect(stdout.text()).toBe("");
}
expect(stderr.text()).toContain("SYNTHETIC_AUTH_HOME_CLEANUP_FAILED");
expect(stderr.text()).toContain("Partial output was kept at /tmp/scan.");
}
Expand Down