diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index d3be46141..9c66ee608 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -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, diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index d803ed425..e0dfbaef5 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -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; @@ -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( @@ -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), + }; + } return incurError({ code: "SCAN_FAILED", message: outcome.error, diff --git a/sdk/typescript/tests-ts/cli-authentication.test.ts b/sdk/typescript/tests-ts/cli-authentication.test.ts index 56e1ef8d7..9ea874bfd 100644 --- a/sdk/typescript/tests-ts/cli-authentication.test.ts +++ b/sdk/typescript/tests-ts/cli-authentication.test.ts @@ -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", @@ -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"); diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index 897ca5041..71bd7c62c 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -136,7 +136,18 @@ describe("CLI", () => { dependencies(), ), ).toBe(0); - expect(JSON.parse(schema.text())).toMatchObject({ + const scanSchema = JSON.parse(schema.text()) as { + args: Record; + options: Record; + output?: { + anyOf?: Array<{ + properties?: Record; + required?: string[]; + additionalProperties?: boolean; + }>; + }; + }; + expect(scanSchema).toMatchObject({ args: { properties: { repository: { type: "string" } } }, options: { properties: { @@ -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( @@ -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 () => { @@ -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); @@ -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 () => { @@ -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", @@ -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"); @@ -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}`, ); @@ -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.", ); @@ -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.", ); @@ -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}`); }); @@ -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."); }