From 3efd0712406621a271916a9693940432a3817366 Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 11 Aug 2026 09:37:28 -0700 Subject: [PATCH 1/3] feat(scan): record available validation tools --- sdk/typescript/README.md | 5 +- .../_bundled_plugin/scripts/workbench_db.py | 29 +++++ .../scripts/workbench_validation.py | 42 ++++++++ sdk/typescript/src/scan-history-renderer.ts | 18 ++++ .../tests-ts/scan-history-renderer.test.ts | 25 +++++ sdk/typescript/tests-ts/scan-recovery.test.ts | 101 +++++++++++++++++- 6 files changed, 218 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 1fe6764e..757c3fe9 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -505,7 +505,10 @@ Results remain under `--output-dir`; rerun the same command to resume. `npx @openai/codex-security scans list` lists scans for the current repository. Pass a repository path to inspect another checkout, `--scan-root DIR` to list scans whose artifacts are under a particular root. `scans show SCAN_ID` includes the -scan configuration, results, coverage, and artifact locations. Add +scan configuration, results, coverage, and artifact locations. It also shows +the Python interpreter and validation tools available on the scan's `PATH`, plus +recorded scan limitations and deferred work. Tool availability does not +guarantee access to services or sandboxed resources. Add `--show-linked-findings` to include finding links from previous scans. Every scan history command accepts a full scan ID or a unique prefix of at diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index b7c7395f..9f48b77e 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -128,6 +128,7 @@ require_uuid, sqlite_busy, user_text, + validation_environment, ) FINDING_ARTIFACT_DIRECTORIES_LIMIT = 80 @@ -1326,6 +1327,7 @@ def register_cli_scan(connection: sqlite3.Connection, args: argparse.Namespace) raise SystemExit("The scan artifact directory must be empty before the scan starts.") recipe = parse_scan_recipe(args.recipe_json, repository) + recipe["validationEnvironment"] = validation_environment() requested_target = recipe["target"] paths = requested_target["paths"] scope = paths[0] if len(paths) == 1 else "." @@ -2692,6 +2694,7 @@ def scan_result( ) if sarif_path is not None: artifacts["sarifReport"] = str(sarif_path) + validation = scan_validation_environment(scan, artifacts) occurrence_rows = scan_history.finding_occurrence_rows( connection, scan["id"], offset=0, limit=FINDINGS_RESULT_LIMIT ) @@ -2777,6 +2780,7 @@ def scan_result( "targetPath": scan["target_path"], "targetRevision": scan["target_revision"], "targetSummary": scan["target_summary"], + **({"validationEnvironment": validation} if validation is not None else {}), "updatedAt": max( scan["updated_at"], progress["updated_at"], @@ -2788,6 +2792,31 @@ def scan_result( } +def scan_validation_environment( + scan: sqlite3.Row, artifacts: dict[str, str] +) -> dict[str, Any] | None: + if scan["recipe_json"] is None: + return None + environment = json.loads(scan["recipe_json"]).get("validationEnvironment") + if environment is None: + return None + manifest_path = artifacts.get("manifest") + coverage_path = artifacts.get("coverage") + if manifest_path is None or coverage_path is None: + return environment + try: + scope = read_json_object(Path(manifest_path))["scan"]["scope"] + coverage = read_json_object(Path(coverage_path)) + except (KeyError, SystemExit): + return environment + blockers = list( + dict.fromkeys( + [*scope.get("limitations", []), *(item["reason"] for item in coverage["deferred"])] + ) + ) + return {**environment, **({"blockers": blockers} if blockers else {})} + + def remediation_availability(scan: sqlite3.Row) -> tuple[bool, str | None]: try: current_revision = git_revision(require_scan_target_identity(scan)) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_validation.py b/sdk/typescript/_bundled_plugin/scripts/workbench_validation.py index a01d979e..fa7c7b8d 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_validation.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_validation.py @@ -6,6 +6,7 @@ import json import math import re +import shutil import sqlite3 import sys import uuid @@ -23,6 +24,47 @@ def require_uuid(value: str, label: str) -> str: raise SystemExit(f"{label} must be a UUID.") from exc +def validation_environment() -> dict[str, Any]: + tools = ( + "python", + "python3", + "pip", + "uv", + "poetry", + "pytest", + "node", + "npm", + "npx", + "pnpm", + "yarn", + "bun", + "java", + "javac", + "mvn", + "gradle", + "go", + "cargo", + "rustc", + "ruby", + "bundle", + "php", + "composer", + "dotnet", + "gcc", + "clang", + "make", + "cmake", + "gdb", + "lldb", + "valgrind", + "docker", + ) + return { + "python": sys.executable, + "availableTools": [tool for tool in tools if shutil.which(tool) is not None], + } + + def optional_text(value: str | None, *, maximum: int | None = None) -> str | None: if value is None: return None diff --git a/sdk/typescript/src/scan-history-renderer.ts b/sdk/typescript/src/scan-history-renderer.ts index f8d2fe6d..04bfc1c2 100644 --- a/sdk/typescript/src/scan-history-renderer.ts +++ b/sdk/typescript/src/scan-history-renderer.ts @@ -254,6 +254,24 @@ export function renderScanHistory( .join(` ${accent("ยท")} `)}`, ); } + const validation = result["validationEnvironment"] as + | JsonObject + | undefined; + if (validation) { + lines.push( + ` ${strong("VALIDATION PYTHON")} ${clean(validation["python"])}`, + ); + const tools = validation["availableTools"] as string[]; + wrap( + tools.length > 0 ? tools.map(clean).join(", ") : "none", + 19, + ` ${strong("AVAILABLE TOOLS")} `, + ); + for (const blocker of (validation["blockers"] as string[] | undefined) ?? + []) { + wrap(blocker, 15, ` ${paint("LIMITATION", 33)} `); + } + } const coverage = (result["progress"] as JsonObject)["coverage"] as | JsonObject | undefined; diff --git a/sdk/typescript/tests-ts/scan-history-renderer.test.ts b/sdk/typescript/tests-ts/scan-history-renderer.test.ts index fe0a3e3b..cf0331be 100644 --- a/sdk/typescript/tests-ts/scan-history-renderer.test.ts +++ b/sdk/typescript/tests-ts/scan-history-renderer.test.ts @@ -273,6 +273,31 @@ describe("scan history renderer", () => { expect(output).not.toContain("ERROR"); }); + test("shows observed validation tools and recorded blockers", () => { + const output = stripVTControlCharacters( + renderScanHistory( + { + scanId: "12345678-abcd-4567-abcd-1234567890ab", + targetPath: "/demo/juice-shop", + mode: "standard", + progress: { status: "complete" }, + findings: [], + validationEnvironment: { + python: "/managed/python", + availableTools: ["node", "npm", "pytest"], + blockers: ["Docker daemon is unavailable."], + }, + }, + "show", + ), + ); + + expect(output).toContain("VALIDATION PYTHON /managed/python"); + expect(output).toContain("AVAILABLE TOOLS node, npm, pytest"); + expect(output).toContain("LIMITATION Docker daemon is unavailable."); + expect(output).not.toContain("USED TOOLS"); + }); + test("renders match-all results from the original workbench data", () => { const output = stripVTControlCharacters( renderScanHistory( diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index 1dfb7cba..7f2f02f4 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -9,7 +9,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { runWorkbench } from "../src/runtime.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; @@ -305,6 +305,105 @@ describe("malformed scan artifact recovery", () => { }); }); + test("keeps the Python and tools observed when each scan was registered", async () => { + const fixture = await startDraftScan(); + const running = await workbench(fixture, [ + "get-scan", + "--scan-id", + fixture.scanId, + ]); + const available = { + python: fixture.python, + availableTools: expect.arrayContaining(["node"]), + }; + expect(running["scan"]).toMatchObject({ + validationEnvironment: available, + }); + expect(running["recipe"]).toMatchObject({ + validationEnvironment: available, + }); + + const emptyPath = join(dirname(fixture.scanDir), "empty-path"); + const restrictedScanDir = join(dirname(fixture.scanDir), "restricted-scan"); + await mkdir(emptyPath); + await mkdir(restrictedScanDir, { mode: 0o700 }); + const restricted = await runWorkbench( + { + python: fixture.python, + pluginRoot: PLUGIN_ROOT, + environment: { + PATH: emptyPath, + CODEX_SECURITY_STATE_DIR: fixture.stateDir, + }, + }, + [ + "register-cli-scan", + "--repository", + fixture.repository, + "--scan-dir", + restrictedScanDir, + "--recipe-json", + JSON.stringify({ + config: {}, + mode: "standard", + repository: fixture.repository, + target: { kind: "repository", paths: [] }, + }), + ], + ); + const historical = await workbench(fixture, [ + "get-scan", + "--scan-id", + String(restricted["scanId"]), + ]); + + expect(historical["scan"]).toMatchObject({ + validationEnvironment: { + python: fixture.python, + availableTools: [], + }, + }); + }); + + test("shows recorded limitations and deferred work without changing the saved recipe", async () => { + const fixture = await startDraftScan(); + const manifestPath = join(fixture.scanDir, "scan-manifest.json"); + const manifest = await readJson<{ + scan: { scope: { limitations?: string[] } }; + }>(manifestPath); + manifest.scan.scope.limitations = ["Docker daemon is unavailable."]; + await writeJson(manifestPath, manifest); + + const coveragePath = join(fixture.scanDir, "coverage.json"); + const coverage = await readJson(coveragePath); + coverage.completeness = "partial"; + coverage.deferred = [ + { id: "docker", reason: "Docker daemon is unavailable." }, + { id: "database", reason: "The PostgreSQL test service is not running." }, + ]; + await writeJson(coveragePath, coverage); + await completeScan(fixture); + + const context = await workbench(fixture, [ + "get-scan", + "--scan-id", + fixture.scanId, + ]); + expect(context["scan"]).toMatchObject({ + validationEnvironment: { + python: fixture.python, + availableTools: expect.arrayContaining(["node"]), + blockers: [ + "Docker daemon is unavailable.", + "The PostgreSQL test service is not running.", + ], + }, + }); + expect( + (context["recipe"] as Record)["validationEnvironment"], + ).not.toHaveProperty("blockers"); + }); + test("returns authoritative clean, dirty, and nested Git target contracts", async () => { for (const kind of ["clean", "dirty", "nested"] as const) { const fixture = await startDraftScan(kind); From 5cec5a108a1579cf71caf8fda7209dab93faa6cb Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 11 Aug 2026 09:42:20 -0700 Subject: [PATCH 2/3] test(scan): accept resolved Python interpreter paths --- sdk/typescript/tests-ts/scan-recovery.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index 7f2f02f4..a5ea8cd8 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -312,8 +312,12 @@ describe("malformed scan artifact recovery", () => { "--scan-id", fixture.scanId, ]); + const python = ( + running["scan"] as { validationEnvironment: { python: string } } + ).validationEnvironment.python; + expect(await realpath(python)).toBe(await realpath(fixture.python)); const available = { - python: fixture.python, + python, availableTools: expect.arrayContaining(["node"]), }; expect(running["scan"]).toMatchObject({ @@ -359,7 +363,7 @@ describe("malformed scan artifact recovery", () => { expect(historical["scan"]).toMatchObject({ validationEnvironment: { - python: fixture.python, + python, availableTools: [], }, }); @@ -389,9 +393,12 @@ describe("malformed scan artifact recovery", () => { "--scan-id", fixture.scanId, ]); + const recipe = context["recipe"] as { + validationEnvironment: { python: string }; + }; expect(context["scan"]).toMatchObject({ validationEnvironment: { - python: fixture.python, + python: recipe.validationEnvironment.python, availableTools: expect.arrayContaining(["node"]), blockers: [ "Docker daemon is unavailable.", @@ -399,9 +406,7 @@ describe("malformed scan artifact recovery", () => { ], }, }); - expect( - (context["recipe"] as Record)["validationEnvironment"], - ).not.toHaveProperty("blockers"); + expect(recipe.validationEnvironment).not.toHaveProperty("blockers"); }); test("returns authoritative clean, dirty, and nested Git target contracts", async () => { From 5c7bde5f05ac38bd006ca315763b82794b9ccd5a Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 11 Aug 2026 10:10:39 -0700 Subject: [PATCH 3/3] refactor: simplify scan validation tool reporting --- sdk/typescript/README.md | 8 ++-- .../_bundled_plugin/scripts/workbench_db.py | 27 +++++++----- .../scripts/workbench_validation.py | 42 ------------------- sdk/typescript/src/scan-history-renderer.ts | 2 +- .../tests-ts/scan-history-renderer.test.ts | 1 - sdk/typescript/tests-ts/scan-recovery.test.ts | 4 +- 6 files changed, 22 insertions(+), 62 deletions(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 757c3fe9..d2f6cbc4 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -504,11 +504,9 @@ Results remain under `--output-dir`; rerun the same command to resume. `npx @openai/codex-security scans list` lists scans for the current repository. Pass a repository path to inspect another checkout, `--scan-root DIR` to list scans -whose artifacts are under a particular root. `scans show SCAN_ID` includes the -scan configuration, results, coverage, and artifact locations. It also shows -the Python interpreter and validation tools available on the scan's `PATH`, plus -recorded scan limitations and deferred work. Tool availability does not -guarantee access to services or sandboxed resources. Add +whose artifacts are under a particular root. `scans show SCAN_ID` includes scan +configuration, results, coverage, artifact locations, the Python interpreter, +available validation tools, and recorded limitations or deferred work. Add `--show-linked-findings` to include finding links from previous scans. Every scan history command accepts a full scan ID or a unique prefix of at diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index 9f48b77e..0d2a16f2 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -11,6 +11,7 @@ import json import os import re +import shutil import sqlite3 import stat import sys @@ -128,7 +129,6 @@ require_uuid, sqlite_busy, user_text, - validation_environment, ) FINDING_ARTIFACT_DIRECTORIES_LIMIT = 80 @@ -1327,7 +1327,18 @@ def register_cli_scan(connection: sqlite3.Connection, args: argparse.Namespace) raise SystemExit("The scan artifact directory must be empty before the scan starts.") recipe = parse_scan_recipe(args.recipe_json, repository) - recipe["validationEnvironment"] = validation_environment() + recipe["validationEnvironment"] = { + "python": sys.executable, + "availableTools": [ + tool + for tool in ( + "python python3 pip uv poetry pytest node npm npx pnpm yarn bun " + "java javac mvn gradle go cargo rustc ruby bundle php composer " + "dotnet gcc clang make cmake gdb lldb valgrind docker" + ).split() + if shutil.which(tool) + ], + } requested_target = recipe["target"] paths = requested_target["paths"] scope = paths[0] if len(paths) == 1 else "." @@ -2798,15 +2809,11 @@ def scan_validation_environment( if scan["recipe_json"] is None: return None environment = json.loads(scan["recipe_json"]).get("validationEnvironment") - if environment is None: - return None - manifest_path = artifacts.get("manifest") - coverage_path = artifacts.get("coverage") - if manifest_path is None or coverage_path is None: + if environment is None or not {"manifest", "coverage"}.issubset(artifacts): return environment try: - scope = read_json_object(Path(manifest_path))["scan"]["scope"] - coverage = read_json_object(Path(coverage_path)) + scope = read_json_object(Path(artifacts["manifest"]))["scan"]["scope"] + coverage = read_json_object(Path(artifacts["coverage"])) except (KeyError, SystemExit): return environment blockers = list( @@ -2814,7 +2821,7 @@ def scan_validation_environment( [*scope.get("limitations", []), *(item["reason"] for item in coverage["deferred"])] ) ) - return {**environment, **({"blockers": blockers} if blockers else {})} + return {**environment, "blockers": blockers} if blockers else environment def remediation_availability(scan: sqlite3.Row) -> tuple[bool, str | None]: diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_validation.py b/sdk/typescript/_bundled_plugin/scripts/workbench_validation.py index fa7c7b8d..a01d979e 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_validation.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_validation.py @@ -6,7 +6,6 @@ import json import math import re -import shutil import sqlite3 import sys import uuid @@ -24,47 +23,6 @@ def require_uuid(value: str, label: str) -> str: raise SystemExit(f"{label} must be a UUID.") from exc -def validation_environment() -> dict[str, Any]: - tools = ( - "python", - "python3", - "pip", - "uv", - "poetry", - "pytest", - "node", - "npm", - "npx", - "pnpm", - "yarn", - "bun", - "java", - "javac", - "mvn", - "gradle", - "go", - "cargo", - "rustc", - "ruby", - "bundle", - "php", - "composer", - "dotnet", - "gcc", - "clang", - "make", - "cmake", - "gdb", - "lldb", - "valgrind", - "docker", - ) - return { - "python": sys.executable, - "availableTools": [tool for tool in tools if shutil.which(tool) is not None], - } - - def optional_text(value: str | None, *, maximum: int | None = None) -> str | None: if value is None: return None diff --git a/sdk/typescript/src/scan-history-renderer.ts b/sdk/typescript/src/scan-history-renderer.ts index 04bfc1c2..8704f3e8 100644 --- a/sdk/typescript/src/scan-history-renderer.ts +++ b/sdk/typescript/src/scan-history-renderer.ts @@ -263,7 +263,7 @@ export function renderScanHistory( ); const tools = validation["availableTools"] as string[]; wrap( - tools.length > 0 ? tools.map(clean).join(", ") : "none", + tools.map(clean).join(", ") || "none", 19, ` ${strong("AVAILABLE TOOLS")} `, ); diff --git a/sdk/typescript/tests-ts/scan-history-renderer.test.ts b/sdk/typescript/tests-ts/scan-history-renderer.test.ts index cf0331be..a8ddac71 100644 --- a/sdk/typescript/tests-ts/scan-history-renderer.test.ts +++ b/sdk/typescript/tests-ts/scan-history-renderer.test.ts @@ -295,7 +295,6 @@ describe("scan history renderer", () => { expect(output).toContain("VALIDATION PYTHON /managed/python"); expect(output).toContain("AVAILABLE TOOLS node, npm, pytest"); expect(output).toContain("LIMITATION Docker daemon is unavailable."); - expect(output).not.toContain("USED TOOLS"); }); test("renders match-all results from the original workbench data", () => { diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index a5ea8cd8..f48416fa 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -327,16 +327,14 @@ describe("malformed scan artifact recovery", () => { validationEnvironment: available, }); - const emptyPath = join(dirname(fixture.scanDir), "empty-path"); const restrictedScanDir = join(dirname(fixture.scanDir), "restricted-scan"); - await mkdir(emptyPath); await mkdir(restrictedScanDir, { mode: 0o700 }); const restricted = await runWorkbench( { python: fixture.python, pluginRoot: PLUGIN_ROOT, environment: { - PATH: emptyPath, + PATH: "", CODEX_SECURITY_STATE_DIR: fixture.stateDir, }, },