From fdd313a9043da97bcfafde03b0e679059ce5dc75 Mon Sep 17 00:00:00 2001 From: HughChaw <146055770+Hughhhhcoder@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:08:51 +0800 Subject: [PATCH 1/4] fix(cli): emit structured JSON errors for failed scans --- sdk/typescript/src/cli.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 7848ccb07..d2151355d 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -3032,6 +3032,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, From 2915cf0bab412770ace8a68694741bff06505d97 Mon Sep 17 00:00:00 2001 From: HughChaw <146055770+Hughhhhcoder@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:09:55 +0800 Subject: [PATCH 2/4] test(cli): cover structured scan failures --- sdk/typescript/tests-ts/cli.test.ts | 91 ++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 9 deletions(-) diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index 398724d3d..581237c02 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -3613,7 +3613,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 () => { @@ -3706,7 +3709,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); @@ -3926,7 +3932,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 () => { @@ -4032,6 +4041,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", @@ -4054,7 +4098,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"); @@ -4178,7 +4226,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}`, ); @@ -4848,7 +4900,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.", ); @@ -5267,7 +5323,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.", ); @@ -5374,7 +5436,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}`); }); @@ -5455,7 +5520,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."); } From 0ce5e097723d52c2612ae9fe5f30e219329caba2 Mon Sep 17 00:00:00 2001 From: HughChaw <146055770+Hughhhhcoder@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:09:58 +0800 Subject: [PATCH 3/4] test(cli): cover structured scan failures --- sdk/typescript/tests-ts/cli-authentication.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/tests-ts/cli-authentication.test.ts b/sdk/typescript/tests-ts/cli-authentication.test.ts index 0d55872bd..27ce95b39 100644 --- a/sdk/typescript/tests-ts/cli-authentication.test.ts +++ b/sdk/typescript/tests-ts/cli-authentication.test.ts @@ -912,7 +912,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", @@ -947,7 +950,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"); From 7f681ac21750b96bfe5b04f5377eb286289de40c Mon Sep 17 00:00:00 2001 From: HughhhhCoder Date: Sat, 29 Aug 2026 14:06:08 +0800 Subject: [PATCH 4/4] docs(cli): describe structured scan failures --- sdk/typescript/README.md | 17 +++++++++++++++++ sdk/typescript/src/cli.ts | 13 ++++++++++++- sdk/typescript/tests-ts/cli.test.ts | 25 ++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 7b71e940d..514cc17f6 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -286,6 +286,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. + ### Attribute scans to end users When scanning on behalf of users, pass each user's stable hashed ID: diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index d2151355d..345caf41d 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -1011,6 +1011,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; @@ -2979,7 +2990,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( diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index 581237c02..54c4ed644 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -132,7 +132,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: { @@ -159,6 +170,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(