From 5de0011508c473d98198c92c2ba39c132170e0a2 Mon Sep 17 00:00:00 2001 From: faizan-oai Date: Mon, 17 Aug 2026 13:23:33 -0700 Subject: [PATCH 1/5] fix(windows): reject non-portable path aliases --- .../scripts/finalize_scan_contract.py | 35 +++- .../scripts/generate_in_scope_files.py | 20 +++ .../scripts/generate_rank_input.py | 18 ++ sdk/typescript/scripts/check-package.mjs | 1 + sdk/typescript/src/cli.ts | 18 +- sdk/typescript/src/contract.ts | 8 +- sdk/typescript/src/multiscan.ts | 5 +- sdk/typescript/src/runtime.ts | 50 +++++- sdk/typescript/src/targets.ts | 21 +++ sdk/typescript/src/windows-path.ts | 21 +++ .../tests-ts/cli-history-paths.test.ts | 42 +++++ sdk/typescript/tests-ts/contract.test.ts | 116 ++++++++++++- sdk/typescript/tests-ts/multiscan.test.ts | 33 ++++ sdk/typescript/tests-ts/runtime.test.ts | 158 +++++++++++++++++- sdk/typescript/tests-ts/targets.test.ts | 52 ++++++ 15 files changed, 574 insertions(+), 24 deletions(-) create mode 100644 sdk/typescript/src/windows-path.ts create mode 100644 sdk/typescript/tests-ts/cli-history-paths.test.ts diff --git a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py index 5a7806c7e..c5bf8ba4d 100644 --- a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py +++ b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py @@ -66,6 +66,19 @@ "json": "exports/findings.json", "sarif": "exports/results.sarif", } +WINDOWS_INVALID_PATH_CHARACTERS = frozenset('<>:"|?*') +WINDOWS_RESERVED_DEVICE_NAMES = { + "AUX", + "CON", + "CONIN$", + "CONOUT$", + "NUL", + "PRN", + *(f"COM{number}" for number in range(1, 10)), + *(f"LPT{number}" for number in range(1, 10)), + *(f"COM{number}" for number in "¹²³"), + *(f"LPT{number}" for number in "¹²³"), +} class ContractError(ValueError): @@ -224,11 +237,23 @@ def _require_safe_relative_path(value: str, context: str, *, allow_dot: bool = F or "\0" in value or path.is_absolute() or ".." in path.parts + or any(_windows_unsafe_path_component(part) for part in value.split("/")) ): raise ContractError(f"{context}: expected a safe repository-relative POSIX path") return normalized +def _windows_unsafe_path_component(value: str) -> bool: + if not value or value == ".": + return False + return ( + any(character in WINDOWS_INVALID_PATH_CHARACTERS for character in value) + or any(ord(character) < 32 for character in value) + or value.endswith((" ", ".")) + or value.split(".", 1)[0].upper() in WINDOWS_RESERVED_DEVICE_NAMES + ) + + def _require_scan_directory(scan_dir: Path) -> Path: scan_dir = scan_dir.absolute() try: @@ -1336,6 +1361,7 @@ def _validate_manifest(manifest: dict[str, Any]) -> None: if not artifacts: raise ContractError("manifest.scan.artifacts: expected generated artifact records") artifact_paths: set[str] = set() + artifact_collision_keys: set[str] = set() for index, artifact in enumerate(artifacts): context = f"manifest.scan.artifacts[{index}]" if not isinstance(artifact, dict): @@ -1343,9 +1369,11 @@ def _validate_manifest(manifest: dict[str, Any]) -> None: path = _require_safe_relative_path( _require_str(artifact, "path", context), f"{context}.path" ) - if path in artifact_paths: + collision_key = path.lower() + if collision_key in artifact_collision_keys: raise ContractError(f"{context}.path: duplicate artifact path") artifact_paths.add(path) + artifact_collision_keys.add(collision_key) _require_str(artifact, "sha256", context) _require_str(artifact, "mediaType", context) for required_path in ("findings.json", "coverage.json"): @@ -1848,6 +1876,7 @@ def _validate_existing_seal( if not isinstance(artifacts, list) or not artifacts: raise ContractError("manifest.scan.artifacts: sealed manifest requires artifact records") artifact_paths: set[str] = set() + artifact_collision_keys: set[str] = set() for index, artifact in enumerate(artifacts): context = f"manifest.scan.artifacts[{index}]" if not isinstance(artifact, dict): @@ -1855,9 +1884,11 @@ def _validate_existing_seal( path = _require_safe_relative_path( _require_str(artifact, "path", context), f"{context}.path" ) - if path in artifact_paths: + collision_key = path.lower() + if collision_key in artifact_collision_keys: raise ContractError(f"{context}.path: duplicate artifact path") artifact_paths.add(path) + artifact_collision_keys.add(collision_key) expected_sha256 = _require_str(artifact, "sha256", context) contents = (artifact_contents or {}).get(path) actual_sha256 = ( diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py index 6449bf747..42bb48dc6 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -15,6 +15,21 @@ class InventoryError(ValueError): """Raised when the repository, scope, or inventory cannot be used safely.""" +def windows_stream_component(path: Path) -> str | None: + """Return the first NTFS alternate-data-stream component.""" + + if os.name != "nt": + return None + return next( + ( + component + for component in path.parts + if component != path.anchor and ":" in component + ), + None, + ) + + def resolve_repository(value: str) -> Path: """Resolve the repository once so every scope is bound to its real root.""" try: @@ -32,6 +47,11 @@ def resolve_scope(repository: Path, value: str) -> str: raise InventoryError("--scope: expected a non-empty file or directory") requested = Path(value).expanduser() + stream = windows_stream_component(requested) + if stream is not None: + raise InventoryError( + f"--scope: NTFS alternate data streams are not supported: {stream}" + ) scope = requested if requested.is_absolute() else repository / requested try: resolved = scope.resolve(strict=True) diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 92018d74b..0f8e6f87d 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -293,6 +293,21 @@ def path_is_excluded(path: Path) -> bool: return path.name.endswith((".min.js", ".map")) +def windows_stream_component(path: Path) -> str | None: + """Return the first NTFS alternate-data-stream component.""" + + if os.name != "nt": + return None + return next( + ( + component + for component in path.parts + if component != path.anchor and ":" in component + ), + None, + ) + + def resolve_scope( repo: Path, scope: str, @@ -301,6 +316,9 @@ def resolve_scope( reject_symlinks: bool = False, ) -> Path: scope_path = Path(scope).expanduser() if expand_user else Path(scope) + stream = windows_stream_component(scope_path) + if stream is not None: + raise SystemExit(f"Scope must not use an NTFS alternate data stream: {stream}") if not scope_path.is_absolute(): scope_path = repo / scope_path if reject_symlinks: diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 9cd6feb8c..2bd6ec060 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -187,6 +187,7 @@ const distFiles = new Set( "targets", "trusted-executable", "version", + "windows-path", "worker-progress", ].flatMap((module) => ["js", "js.map", "d.ts", "d.ts.map"].map( diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index addcfb807..541790077 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -97,6 +97,7 @@ import { import type { ScanResult } from "./result.js"; import { bundledPluginRoot, + canonicalizeModelSafePath, codexSecurityCredentialHome, codexSecurityStateDirectory, expandHome, @@ -1320,6 +1321,14 @@ export async function main( output: z.record(z.string(), z.unknown()).optional(), async run({ args, format, options }) { const directory = dependencies.currentDirectory(); + const scanRoot = + options.scanRoot === undefined + ? undefined + : process.platform === "win32" + ? await canonicalizeModelSafePath( + resolve(directory, options.scanRoot), + ) + : resolve(directory, options.scanRoot); const repository = options.scanRoot !== undefined && args.repository === undefined ? undefined @@ -1328,18 +1337,13 @@ export async function main( await history([ "list-scans", ...(repository === undefined ? [] : ["--repository", repository]), - ...(options.scanRoot === undefined - ? [] - : ["--scan-root", resolve(directory, options.scanRoot)]), + ...(scanRoot === undefined ? [] : ["--scan-root", scanRoot]), ]), "list", format, { repository, - scanRoot: - options.scanRoot === undefined - ? undefined - : resolve(directory, options.scanRoot), + scanRoot, }, ); }, diff --git a/sdk/typescript/src/contract.ts b/sdk/typescript/src/contract.ts index 559f17a1e..b177937b4 100644 --- a/sdk/typescript/src/contract.ts +++ b/sdk/typescript/src/contract.ts @@ -20,6 +20,7 @@ import { requireSecureOutputAncestry, } from "./runtime.js"; import type { NormalizedTarget, ScanMode } from "./targets.js"; +import { isWindowsUnsafePathComponent } from "./windows-path.js"; const DOCUMENTS = { "scan-manifest.json": "scan-manifest.schema.json", @@ -376,16 +377,19 @@ async function validateSeal( } const artifactPaths = new Set(); + const artifactCollisionKeys = new Set(); for (const [index, artifact] of scan.artifacts.entries()) { throwIfAborted(signal); const context = `manifest.scan.artifacts[${index}]`; const normalized = safeRelativePath(artifact.path, `${context}.path`); - if (artifactPaths.has(normalized)) { + const collisionKey = normalized.toLowerCase(); + if (artifactCollisionKeys.has(collisionKey)) { throw new ContractValidationError( `${context}.path: duplicate artifact path.`, ); } artifactPaths.add(normalized); + artifactCollisionKeys.add(collisionKey); const digest = documentDigests.get(normalized) ?? (await sha256ScanFile( @@ -617,7 +621,7 @@ function safeRelativePath(value: string, context: string): string { parts.includes("..") || value.includes("\\") || value.includes("\0") || - parts.some((part) => part.includes(":")) + parts.some(isWindowsUnsafePathComponent) ) { throw new ContractValidationError( `${context}: expected a safe scan-relative POSIX path.`, diff --git a/sdk/typescript/src/multiscan.ts b/sdk/typescript/src/multiscan.ts index 63f3a7909..6eaf4db64 100644 --- a/sdk/typescript/src/multiscan.ts +++ b/sdk/typescript/src/multiscan.ts @@ -35,6 +35,8 @@ const REQUIRED_ARTIFACTS = [ ]; const LOCK_LEASE_MS = 30_000; const LOCK_HEARTBEAT_MS = 5_000; +const WINDOWS_DEVICE_PATH_NAME = + /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu; interface MultiscanTask { id: string; @@ -718,7 +720,7 @@ function parseInventory( if ( !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(id) || id.endsWith(".") || - /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(id) + WINDOWS_DEVICE_PATH_NAME.test(id) ) { throw new Error("Multiscan task IDs must be safe, unique path names."); } @@ -740,6 +742,7 @@ function parseInventory( (isAbsolute(scope) || scope.includes("\\") || scope.split("/").includes("..") || + (process.platform === "win32" && scope.includes(":")) || scope.includes("\0")) ) { throw new Error("Multiscan scope must stay inside its repository."); diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 13c41f2c2..c7336f549 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -39,6 +39,10 @@ import { } from "./errors.js"; import type { JsonObject } from "./config.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; +import { + isWindowsUnsafePathComponent, + windowsUnsafePathComponent, +} from "./windows-path.js"; const execFile = promisify(execFileCallback); @@ -112,10 +116,20 @@ export function codexSecurityStateDirectory( environment: ProcessEnvironment = process.env, ): string { const configured = environmentValue(environment, "CODEX_SECURITY_STATE_DIR"); - if (configured !== undefined) return resolve(expandHome(configured)); - const codexHome = - environmentValue(environment, "CODEX_HOME") ?? join(homedir(), ".codex"); - return resolve(expandHome(codexHome), "state", "plugins", "codex-security"); + const path = + configured !== undefined + ? resolve(expandHome(configured)) + : resolve( + expandHome( + environmentValue(environment, "CODEX_HOME") ?? + join(homedir(), ".codex"), + ), + "state", + "plugins", + "codex-security", + ); + requireModelSafeOutputDir(path); + return path; } export function codexSecurityCredentialHome( @@ -1272,6 +1286,7 @@ export async function preparePersistentScanRoot( stateDirectory: string, repositoryName: string, ): Promise { + requireModelSafeOutputDir(stateDirectory); await mkdir(stateDirectory, { recursive: true, mode: 0o700 }); let root = await realpath(stateDirectory); for (const directory of ["scans", safePrefix(repositoryName)]) { @@ -1463,6 +1478,31 @@ export function requireModelSafeOutputDir(path: string): void { "Scan output directory must not contain control or line-separator characters.", ); } + const ambiguous = + process.platform === "win32" ? windowsUnsafePathComponent(path) : undefined; + if (ambiguous !== undefined) { + throw new OutputDirectoryError( + `Codex Security paths must not contain Windows-ambiguous components: ${ambiguous}`, + ); + } +} + +export async function canonicalizeModelSafePath( + input: string, +): Promise { + const path = resolve(expandHome(input)); + requireModelSafeOutputDir(path); + for (let ancestor = path; ; ancestor = dirname(ancestor)) { + try { + const canonicalAncestor = resolve(ancestor, await realpath(ancestor)); + const canonical = resolve(canonicalAncestor, relative(ancestor, path)); + requireModelSafeOutputDir(canonical); + return canonical; + } catch (error) { + if (nodeErrorCode(error) !== "ENOENT") throw error; + if (dirname(ancestor) === ancestor) throw error; + } + } } export async function prepareOutputDir( @@ -2400,7 +2440,7 @@ function safeArchivePath(value: string): string { parts.includes("..") || value.includes("\\") || value.includes("\0") || - parts.some((part) => part.includes(":")) || + parts.some(isWindowsUnsafePathComponent) || normalized.length === 0 ) { throw new PluginBootstrapError( diff --git a/sdk/typescript/src/targets.ts b/sdk/typescript/src/targets.ts index f13858af2..529dc8a7b 100644 --- a/sdk/typescript/src/targets.ts +++ b/sdk/typescript/src/targets.ts @@ -6,6 +6,7 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; import { InvalidTargetError } from "./errors.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; +import { windowsUnsafePathComponent } from "./windows-path.js"; const execFile = promisify(execFileCallback); const UNSUPPORTED_GIT_ENVIRONMENT = new Set([ @@ -111,6 +112,7 @@ export async function normalizeRepository( signal?: AbortSignal, ): Promise { const candidate = resolveRepositoryPath(repository); + requirePortableWindowsRepositoryPath(candidate); let canonical: string; try { canonical = await abortable(() => realpath(candidate), signal); @@ -126,6 +128,7 @@ export async function normalizeRepository( }, ); } + requirePortableWindowsRepositoryPath(canonical); return canonical; } @@ -133,6 +136,16 @@ export function resolveRepositoryPath(repository: string): string { return resolve(expandHome(repository)); } +function requirePortableWindowsRepositoryPath(path: string): void { + if (process.platform !== "win32") return; + const ambiguous = windowsUnsafePathComponent(path); + if (ambiguous !== undefined) { + throw new InvalidTargetError( + `Repository paths must not contain Windows-ambiguous components: ${ambiguous}`, + ); + } +} + export async function enclosingGitWorktreeRoot( repository: string, signal?: AbortSignal, @@ -276,6 +289,14 @@ export async function normalizeTarget( `Path target is outside the repository: ${value}`, ); } + if ( + process.platform === "win32" && + relativePath.split(sep).some((part) => part.includes(":")) + ) { + throw new InvalidTargetError( + `Path target contains an unsupported colon component: ${value}`, + ); + } const normalized = relativePath.split(sep).join("/") || "."; if (!paths.includes(normalized)) { paths.push(normalized); diff --git a/sdk/typescript/src/windows-path.ts b/sdk/typescript/src/windows-path.ts new file mode 100644 index 000000000..05b3911fb --- /dev/null +++ b/sdk/typescript/src/windows-path.ts @@ -0,0 +1,21 @@ +import { parse } from "node:path"; + +const UNSAFE_COMPONENT = /[<>:"|?*\u0000-\u001f]|[ .]$/u; +const RESERVED_COMPONENT = + /^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\..*)?$/iu; + +export function isWindowsUnsafePathComponent(value: string): boolean { + return ( + value !== "" && + value !== "." && + (UNSAFE_COMPONENT.test(value) || RESERVED_COMPONENT.test(value)) + ); +} + +export function windowsUnsafePathComponent(path: string): string | undefined { + const root = parse(path).root; + return path + .slice(root.length) + .split(/[\\/]/u) + .find(isWindowsUnsafePathComponent); +} diff --git a/sdk/typescript/tests-ts/cli-history-paths.test.ts b/sdk/typescript/tests-ts/cli-history-paths.test.ts new file mode 100644 index 000000000..8a8c9372c --- /dev/null +++ b/sdk/typescript/tests-ts/cli-history-paths.test.ts @@ -0,0 +1,42 @@ +import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "bun:test"; +import { main } from "../src/cli.js"; +import { capture, dependencies } from "./cli-fixtures.js"; + +test.skipIf(process.platform !== "win32")( + "rejects an aliased Windows scan root before querying history", + async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-history-root-alias-")), + ); + try { + const scanRoot = join(root, "history"); + const ambiguous = join(root, "history."); + await Promise.all([mkdir(scanRoot), mkdir(ambiguous)]); + expect(await realpath(scanRoot)).not.toBe(await realpath(ambiguous)); + let workbenchCalls = 0; + const stderr = capture(); + + expect( + await main( + ["scans", "list", "--scan-root", ambiguous], + capture().stream, + stderr.stream, + dependencies({ + currentDirectory: root, + onWorkbench: () => { + workbenchCalls += 1; + return { scans: [] }; + }, + }), + ), + ).toBe(2); + expect(workbenchCalls).toBe(0); + expect(stderr.text()).toContain("Windows-ambiguous components"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/sdk/typescript/tests-ts/contract.test.ts b/sdk/typescript/tests-ts/contract.test.ts index 4fdfcd05f..71efb8246 100644 --- a/sdk/typescript/tests-ts/contract.test.ts +++ b/sdk/typescript/tests-ts/contract.test.ts @@ -7,6 +7,7 @@ import { mkdir, mkdtemp, readFile, + realpath, rm, symlink, type FileHandle, @@ -415,7 +416,12 @@ describe("canonical scan contract", () => { }); test("rejects unsafe Windows and traversal artifact paths", async () => { - for (const unsafe of ["D:/escape", "../escape", "artifacts\\escape"]) { + for (const unsafe of [ + "D:/escape", + "../escape", + "artifacts\\escape", + "artifacts/report?.json", + ]) { const scanDir = await copyExample(); const path = join(scanDir, "scan-manifest.json"); const manifest = await readJson(path); @@ -429,6 +435,114 @@ describe("canonical scan contract", () => { loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }), ).rejects.toThrow(ContractValidationError); } + + for (const unsafe of [ + "artifacts/report.json.", + "artifacts/report.json ", + "artifacts/CON.txt", + ]) { + const scanDir = await copyExample(); + const path = join(scanDir, "scan-manifest.json"); + const manifest = await readJson(path); + manifest["scan"]["artifacts"].push({ + path: unsafe, + sha256: "0".repeat(64), + mediaType: "text/plain", + }); + await writeJson(path, manifest); + await expect( + loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }), + ).rejects.toThrow("safe scan-relative POSIX path"); + } + }); + + test("keeps bundled finalizer paths portable to Windows", () => { + const python = + process.env["PYTHON"] ?? Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + if (python === null) return; + const program = [ + "import json, sys", + "sys.path.insert(0, sys.argv[1])", + "import finalize_scan_contract as finalizer", + "def accepted(value):", + " try:", + " finalizer._require_safe_relative_path(value, 'artifact path')", + " except finalizer.ContractError:", + " return False", + " return True", + "print(json.dumps([accepted(value) for value in ['artifacts/report.json.', 'artifacts/report.json ', 'artifacts/CON.txt', 'artifacts/report?.json', 'artifacts/report:stream']]))", + ].join("\n"); + const result = Bun.spawnSync( + [python, "-I", "-B", "-c", program, join(PLUGIN_ROOT, "scripts")], + { stdout: "pipe", stderr: "pipe" }, + ); + + expect(result.exitCode, new TextDecoder().decode(result.stderr)).toBe(0); + expect(JSON.parse(new TextDecoder().decode(result.stdout))).toEqual([ + false, + false, + false, + false, + false, + ]); + }); + + test("rejects trailing-dot aliases for sealed artifacts", async () => { + const scanDir = await copyExample(); + const manifestPath = join(scanDir, "scan-manifest.json"); + const manifest = await readJson(manifestPath); + await writeFile( + join(scanDir, "findings.json."), + await readFile(join(scanDir, "findings.json")), + ); + manifest["scan"]["artifacts"].push({ + ...manifest["scan"]["artifacts"][0], + path: "findings.json.", + }); + await writeJson(manifestPath, manifest); + + await expect( + loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }), + ).rejects.toThrow("safe scan-relative POSIX path"); + }); + + test("rejects case-insensitive aliases for sealed artifacts", async () => { + const scanDir = await copyExample(); + const manifestPath = join(scanDir, "scan-manifest.json"); + const manifest = await readJson(manifestPath); + await writeFile( + join(scanDir, "FINDINGS.json"), + await readFile(join(scanDir, "findings.json")), + ); + manifest["scan"]["artifacts"].push({ + ...manifest["scan"]["artifacts"][0], + path: "FINDINGS.json", + }); + await writeJson(manifestPath, manifest); + + await expect( + loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }), + ).rejects.toThrow("duplicate artifact path"); + + const python = + process.env["PYTHON"] ?? Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + if (python === null) return; + const result = Bun.spawnSync( + [ + python, + "-I", + "-B", + join(PLUGIN_ROOT, "scripts", "finalize_scan_contract.py"), + "--scan-dir", + await realpath(scanDir), + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const stderr = new TextDecoder().decode(result.stderr); + expect(result.exitCode, stderr).not.toBe(0); + expect(stderr).toContain("duplicate artifact path"); }); test("rejects calendar-invalid RFC 3339 timestamps", async () => { diff --git a/sdk/typescript/tests-ts/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index 33676192a..0a02ff88c 100644 --- a/sdk/typescript/tests-ts/multiscan.test.ts +++ b/sdk/typescript/tests-ts/multiscan.test.ts @@ -833,6 +833,31 @@ describe("multiscan", () => { expect(scans).toBe(0); }); + test("rejects task IDs that collide with Windows path names", async () => { + const paths = await fixture(); + let scans = 0; + for (const id of ["task.", "CON", "nul.txt", "COM1", "LPT9.log"]) { + await writeFile( + paths.input, + `id,repository,revision\n${id},./repository,${"0".repeat(40)}\n`, + ); + + await expect( + runMultiscan( + options( + paths, + client(async (_repository, scanOptions = {}) => { + scans += 1; + return await completedScan(scanOptions.outputDir!); + }), + ), + ), + ).rejects.toThrow("safe, unique path names"); + } + + expect(scans).toBe(0); + }); + test("materializes the pinned commit, applies row options, and removes its checkout", async () => { const paths = await fixture(); const source = await repository(paths.root, "payments"); @@ -1984,6 +2009,14 @@ describe("multiscan", () => { name: "scope", row: `safe,${source.path},${source.revision},../outside`, }, + ...(process.platform === "win32" + ? [ + { + name: "windows-qualified-scope", + row: `safe,${source.path},${source.revision},src:stream`, + }, + ] + : []), { name: "revision", row: `safe,${source.path},HEAD,.`, diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 16280e70e..c970ba942 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -52,6 +52,7 @@ import { import { acquireCodexSecurityCredentialHomeLock, bundledPluginCandidates, + canonicalizeModelSafePath, codexSecurityCredentialAllowsAmbientImport, codexSecurityCredentialHome, codexSecurityHasStoredFileCredentials, @@ -75,6 +76,7 @@ import { } from "../src/runtime.js"; import { loadBundledRuntime, PLUGIN_ROOT } from "./plugin-root.js"; import { runMockInSubprocess } from "./support/isolated-mock.js"; +import { isWindowsUnsafePathComponent } from "../src/windows-path.js"; const temporaryDirectories: string[] = []; const testPosix = process.platform === "win32" ? test.skip : test; @@ -407,6 +409,63 @@ describe("plugin runtime preparation", () => { } }); + test.skipIf(process.platform !== "win32")( + "rejects alternate data streams in bundled scan scopes", + async () => { + const root = await temporaryDirectory("codex-security-scope-ads-"); + const repository = join(root, "repository"); + await mkdir(repository); + await writeFile( + join(repository, "source.ts"), + "export const safe = true;\n", + ); + await writeFile( + join(repository, "source.ts:synthetic-stream"), + "export const hidden = true;\n", + ); + const python = + process.env["PYTHON"] ?? Bun.which("python3") ?? Bun.which("python"); + expect(python).not.toBeNull(); + + const inventory = spawnSync( + python!, + [ + "-I", + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + "source.ts:synthetic-stream", + "--out", + join(root, "inventory.txt"), + ], + { encoding: "utf8" }, + ); + expect(inventory.status).toBe(2); + expect(inventory.stderr).toContain("NTFS alternate data streams"); + + const rankInput = spawnSync( + python!, + [ + "-I", + "-B", + join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), + "make-repo-rank-input", + "--repo", + repository, + "--scope", + "source.ts:synthetic-stream", + "--out", + join(root, "rank-input.jsonl"), + ], + { encoding: "utf8" }, + ); + expect(rankInput.status).toBe(1); + expect(rankInput.stderr).toContain("NTFS alternate data stream"); + }, + ); + test("preserves remediation when the filesystem device changes", async () => { const python = Bun.which("python3") ?? Bun.which("python"); expect(python).not.toBeNull(); @@ -798,7 +857,7 @@ describe("plugin runtime preparation", () => { contractValid: item.path.trim().length > 0 && !item.path.includes("\\") && - !item.path.includes(":"), + !item.path.split("/").some(isWindowsUnsafePathComponent), })), ); }, @@ -1201,7 +1260,7 @@ describe("plugin runtime preparation", () => { ).toBe(false); }); - test("rejects traversal, Windows-qualified, duplicate, and symlink ZIP paths", async () => { + test("rejects unsafe, Windows-ambiguous, duplicate, and symlink ZIP paths", async () => { const unsafeArchives: Array<[string, Uint8Array]> = [ ["traversal", zipSync({ "../escape": strToU8("bad") })], ["drive", zipSync({ "D:/escape": strToU8("bad") })], @@ -1220,6 +1279,53 @@ describe("plugin runtime preparation", () => { "release/scripts/file.py": strToU8("overwrite"), }), ], + [ + "trailing-dot-collision", + zipSync({ + "release/.codex-plugin/plugin.json": strToU8( + JSON.stringify({ name: "codex-security", version: "1.2.3" }), + ), + "release/helper.py": strToU8("same"), + "release/helper.py.": strToU8("same"), + }), + ], + [ + "trailing-space-collision", + zipSync({ + "release/.codex-plugin/plugin.json": strToU8( + JSON.stringify({ name: "codex-security", version: "1.2.3" }), + ), + "release/helper.py": strToU8("same"), + "release/helper.py ": strToU8("same"), + }), + ], + [ + "reserved-device-name", + zipSync({ + "release/.codex-plugin/plugin.json": strToU8( + JSON.stringify({ name: "codex-security", version: "1.2.3" }), + ), + "release/CON.txt": strToU8("bad"), + }), + ], + [ + "reserved-console-device-name", + zipSync({ + "release/.codex-plugin/plugin.json": strToU8( + JSON.stringify({ name: "codex-security", version: "1.2.3" }), + ), + "release/CONIN$.txt": strToU8("bad"), + }), + ], + [ + "invalid-windows-character", + zipSync({ + "release/.codex-plugin/plugin.json": strToU8( + JSON.stringify({ name: "codex-security", version: "1.2.3" }), + ), + "release/helper?.py": strToU8("bad"), + }), + ], [ "symlink", zipSync({ @@ -3560,6 +3666,16 @@ describe("runtime directories and plugin Python boundary", () => { CODEX_SECURITY_STATE_DIR: join(root, "explicit-state"), }), ).toBe(join(root, "explicit-state")); + if (process.platform === "win32") { + expect(() => + codexSecurityStateDirectory({ + CODEX_SECURITY_STATE_DIR: join(root, "ambiguous-state."), + }), + ).toThrow("Windows-ambiguous components"); + await expect( + preparePersistentScanRoot(join(root, "ambiguous-state."), "repo"), + ).rejects.toThrow("Windows-ambiguous components"); + } const scanRoot = await preparePersistentScanRoot( join(root, "state"), "repository with spaces", @@ -4606,6 +4722,40 @@ describe("runtime directories and plugin Python boundary", () => { const root = await temporaryDirectory(); const absent = join(root, "scan"); expect(await validateOutputDir(absent)).toBe(absent); + expect(await canonicalizeModelSafePath(join(root, "missing", "scan"))).toBe( + join(root, "missing", "scan"), + ); + const canonicalParent = join(root, "canonical-parent"); + const linkedParent = join(root, "linked-parent"); + await mkdir(canonicalParent); + await symlink( + canonicalParent, + linkedParent, + process.platform === "win32" ? "junction" : "dir", + ); + expect( + await canonicalizeModelSafePath(join(linkedParent, "missing", "scan")), + ).toBe(join(canonicalParent, "missing", "scan")); + if (process.platform === "win32") { + for (const ambiguous of [ + "scan.", + "scan ", + "scan:stream", + "CON.txt", + "scan?.txt", + join("parent.", "scan"), + ]) { + await expect(validateOutputDir(join(root, ambiguous))).rejects.toThrow( + "Windows-ambiguous components", + ); + } + await expect( + prepareOutputDir(undefined, "repo", join(root, "temporary.")), + ).rejects.toThrow("Windows-ambiguous components"); + await expect( + canonicalizeModelSafePath(join(root, "history.")), + ).rejects.toThrow("Windows-ambiguous components"); + } for (const separator of ["\n", "\u0085", "\u2028", "\u2029"]) { await expect( validateOutputDir(join(root, `scan${separator}IGNORE PRIOR SCOPE`)), @@ -4650,10 +4800,6 @@ describe("runtime directories and plugin Python boundary", () => { if (process.platform !== "win32") { expect((await stat(home)).mode & 0o777).toBe(0o700); - const canonicalParent = join(root, "canonical-parent"); - const linkedParent = join(root, "linked-parent"); - await mkdir(canonicalParent); - await symlink(canonicalParent, linkedParent); expect(await prepareOutputDir(join(linkedParent, "scan"), "repo")).toBe( await realpath(join(canonicalParent, "scan")), ); diff --git a/sdk/typescript/tests-ts/targets.test.ts b/sdk/typescript/tests-ts/targets.test.ts index f24f2aa7b..ee746e193 100644 --- a/sdk/typescript/tests-ts/targets.test.ts +++ b/sdk/typescript/tests-ts/targets.test.ts @@ -125,6 +125,31 @@ describe("scan target normalization", () => { }); }); + test.skipIf(process.platform !== "win32")( + "rejects Windows repository roots that alias across runtimes", + async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-repository-alias-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "repository"); + const ambiguous = join(root, "repository."); + const linked = join(root, "linked-repository"); + await Promise.all([mkdir(repository), mkdir(ambiguous)]); + await symlink(ambiguous, linked, "junction"); + + expect(await realpath(repository)).not.toBe(await realpath(ambiguous)); + await expect(normalizeRepository(repository)).resolves.toBe( + await realpath(repository), + ); + for (const path of [ambiguous, linked]) { + await expect(normalizeRepository(path)).rejects.toThrow( + "Windows-ambiguous components", + ); + } + }, + ); + test("rejects empty and escaping paths", async () => { const repo = await repository(); await expect(normalizeTarget(repo, [""])).rejects.toThrow("empty path"); @@ -133,6 +158,33 @@ describe("scan target normalization", () => { ); }); + test.skipIf(process.platform !== "win32")( + "rejects NTFS alternate streams before runtime initialization", + async () => { + const repo = await repository(); + const stream = join(repo, "src", "app.ts:synthetic-stream"); + await writeFile(stream, "export const hidden = true;\n"); + + await expect(normalizeTarget(repo, [stream])).rejects.toThrow( + "unsupported colon component", + ); + }, + ); + + test.skipIf(process.platform === "win32")( + "allows colons in POSIX path components", + async () => { + const repo = await repository(); + const path = join(repo, "src", "app.ts:fixture"); + await writeFile(path, "export const fixture = true;\n"); + + await expect(normalizeTarget(repo, [path])).resolves.toEqual({ + kind: "paths", + paths: ["src/app.ts:fixture"], + }); + }, + ); + test("reports a path that disappears during normalization as invalid", async () => { const repo = await repository(); const script = ` From 71cdc0fef79fbb9c7cef4382ff0afe4b4ce0a0c6 Mon Sep 17 00:00:00 2001 From: faizan-oai Date: Mon, 17 Aug 2026 16:07:15 -0700 Subject: [PATCH 2/5] fix: distinguish portable and source paths --- .../scripts/finalize_scan_contract.py | 32 ++++++++++++------- sdk/typescript/src/contract.ts | 19 ++++++++--- sdk/typescript/tests-ts/contract.test.ts | 23 ++++++++++++- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py index c5bf8ba4d..02381d58e 100644 --- a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py +++ b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py @@ -237,12 +237,20 @@ def _require_safe_relative_path(value: str, context: str, *, allow_dot: bool = F or "\0" in value or path.is_absolute() or ".." in path.parts - or any(_windows_unsafe_path_component(part) for part in value.split("/")) ): raise ContractError(f"{context}: expected a safe repository-relative POSIX path") return normalized +def _require_portable_relative_path( + value: str, context: str, *, allow_dot: bool = False +) -> str: + normalized = _require_safe_relative_path(value, context, allow_dot=allow_dot) + if any(_windows_unsafe_path_component(part) for part in value.split("/")): + raise ContractError(f"{context}: expected a safe scan-relative POSIX path") + return normalized + + def _windows_unsafe_path_component(value: str) -> bool: if not value or value == ".": return False @@ -365,7 +373,7 @@ def _open_scan_local_directory(root_fd: int, parts: tuple[str, ...], *, create: def open_scan_local_file_descriptor(scan_dir: Path, relative_path: str, context: str) -> int: scan_dir = _require_scan_directory(scan_dir) - relative_path = _require_safe_relative_path(relative_path, context) + relative_path = _require_portable_relative_path(relative_path, context) if not _descriptor_relative_reads_available(): if not _is_windows(): raise ContractError("scan-local input requires descriptor-relative file operations") @@ -496,7 +504,7 @@ def write_scan_local_bytes( if relative_path in {"", ".", ".."} or "/" in relative_path or "\0" in relative_path: raise ContractError("external output path: expected a safe file name") else: - relative_path = _require_safe_relative_path(relative_path, "scan-local output path") + relative_path = _require_portable_relative_path(relative_path, "scan-local output path") path = scan_dir / relative_path if not _descriptor_relative_writes_available(): if not _is_windows(): @@ -547,7 +555,7 @@ def write_scan_local_bytes( def _remove_scan_local_file_if_exists(scan_dir: Path, relative_path: str) -> None: scan_dir = _require_scan_directory(scan_dir) - relative_path = _require_safe_relative_path(relative_path, "scan-local cleanup path") + relative_path = _require_portable_relative_path(relative_path, "scan-local cleanup path") if not _descriptor_relative_writes_available(): if not _is_windows(): raise ContractError("scan-local cleanup requires descriptor-relative file operations") @@ -946,7 +954,7 @@ def _recover_unsealed_coverage( try: if not isinstance(ref, str): raise ContractError(f"{ref_context}: expected a string") - normalized_ref = _require_safe_relative_path(ref, ref_context) + normalized_ref = _require_portable_relative_path(ref, ref_context) if not normalized_ref.startswith("artifacts/"): raise ContractError( f"{ref_context}: expected a file under artifacts/" @@ -1316,7 +1324,9 @@ def _validate_coverage(manifest: dict[str, Any], coverage: dict[str, Any], scan_ for ref_index, ref in enumerate(receipt_refs): if not isinstance(ref, str): raise ContractError(f"{context}.receiptRefs[{ref_index}]: expected a string") - normalized_ref = _require_safe_relative_path(ref, f"{context}.receiptRefs[{ref_index}]") + normalized_ref = _require_portable_relative_path( + ref, f"{context}.receiptRefs[{ref_index}]" + ) if not normalized_ref.startswith("artifacts/"): raise ContractError( f"{context}.receiptRefs[{ref_index}]: expected a file under artifacts/" @@ -1366,7 +1376,7 @@ def _validate_manifest(manifest: dict[str, Any]) -> None: context = f"manifest.scan.artifacts[{index}]" if not isinstance(artifact, dict): raise ContractError(f"{context}: expected an object") - path = _require_safe_relative_path( + path = _require_portable_relative_path( _require_str(artifact, "path", context), f"{context}.path" ) collision_key = path.lower() @@ -1832,7 +1842,7 @@ def _validate_sarif(sarif: dict[str, Any]) -> None: def _artifact_record( scan_dir: Path, relative_path: str, media_type: str, contents: bytes | None = None ) -> dict[str, str]: - relative_path = _require_safe_relative_path(relative_path, "artifact path") + relative_path = _require_portable_relative_path(relative_path, "artifact path") if contents is not None: _require_scan_local_file(scan_dir, relative_path, relative_path) return { @@ -1853,7 +1863,7 @@ def _coverage_receipt_refs(coverage: dict[str, Any]) -> list[str]: def _validate_sealed_coverage_receipts(scan: dict[str, Any], coverage: dict[str, Any]) -> None: artifact_paths = { - _require_safe_relative_path(artifact["path"], "sealed artifact path") + _require_portable_relative_path(artifact["path"], "sealed artifact path") for artifact in scan["artifacts"] } for ref in _coverage_receipt_refs(coverage): @@ -1881,7 +1891,7 @@ def _validate_existing_seal( context = f"manifest.scan.artifacts[{index}]" if not isinstance(artifact, dict): raise ContractError(f"{context}: expected an object") - path = _require_safe_relative_path( + path = _require_portable_relative_path( _require_str(artifact, "path", context), f"{context}.path" ) collision_key = path.lower() @@ -2112,7 +2122,7 @@ def write_export_output(scan_dir: Path, output: Path, export_format: str, conten scan = _require_dict(manifest, "scan", "manifest") artifacts = _require_list(scan, "artifacts", "manifest.scan") artifact_paths = [ - _require_safe_relative_path( + _require_portable_relative_path( _require_str(artifact, "path", f"manifest.scan.artifacts[{index}]"), f"manifest.scan.artifacts[{index}].path", ) diff --git a/sdk/typescript/src/contract.ts b/sdk/typescript/src/contract.ts index b177937b4..850666cc0 100644 --- a/sdk/typescript/src/contract.ts +++ b/sdk/typescript/src/contract.ts @@ -302,7 +302,7 @@ async function requireCheckedScanFile( const checkedRoot = await requireScanRoot(scanDirectory, signal); const scanDir = checkedRoot.path; throwIfAborted(signal); - const safePath = safeRelativePath(relativePath, context); + const safePath = portableRelativePath(relativePath, context); const parts = safePath.split("/"); let current = scanDir; try { @@ -381,7 +381,7 @@ async function validateSeal( for (const [index, artifact] of scan.artifacts.entries()) { throwIfAborted(signal); const context = `manifest.scan.artifacts[${index}]`; - const normalized = safeRelativePath(artifact.path, `${context}.path`); + const normalized = portableRelativePath(artifact.path, `${context}.path`); const collisionKey = normalized.toLowerCase(); if (artifactCollisionKeys.has(collisionKey)) { throw new ContractValidationError( @@ -409,7 +409,7 @@ async function validateSeal( for (const surface of coverage.surfaces) { for (const receipt of surface.receiptRefs) { throwIfAborted(signal); - const normalized = safeRelativePath(receipt, "coverage receipt"); + const normalized = portableRelativePath(receipt, "coverage receipt"); if (!normalized.startsWith("artifacts/")) { throw new ContractValidationError( `Coverage receipt must be under artifacts/: ${receipt}`, @@ -620,8 +620,7 @@ function safeRelativePath(value: string, context: string): string { /^[A-Za-z]:/.test(value) || parts.includes("..") || value.includes("\\") || - value.includes("\0") || - parts.some(isWindowsUnsafePathComponent) + value.includes("\0") ) { throw new ContractValidationError( `${context}: expected a safe scan-relative POSIX path.`, @@ -640,6 +639,16 @@ function safeRelativePath(value: string, context: string): string { return normalized; } +function portableRelativePath(value: string, context: string): string { + const normalized = safeRelativePath(value, context); + if (value.split("/").some(isWindowsUnsafePathComponent)) { + throw new ContractValidationError( + `${context}: expected a safe scan-relative POSIX path.`, + ); + } + return normalized; +} + function safeScopePath(value: string): string { return value === "." ? value diff --git a/sdk/typescript/tests-ts/contract.test.ts b/sdk/typescript/tests-ts/contract.test.ts index 71efb8246..fd3fed463 100644 --- a/sdk/typescript/tests-ts/contract.test.ts +++ b/sdk/typescript/tests-ts/contract.test.ts @@ -467,7 +467,7 @@ describe("canonical scan contract", () => { "import finalize_scan_contract as finalizer", "def accepted(value):", " try:", - " finalizer._require_safe_relative_path(value, 'artifact path')", + " finalizer._require_portable_relative_path(value, 'artifact path')", " except finalizer.ContractError:", " return False", " return True", @@ -488,6 +488,27 @@ describe("canonical scan contract", () => { ]); }); + test("accepts Unix-valid source and scope path components", async () => { + const scanDir = await copyExample(); + const manifestPath = join(scanDir, "scan-manifest.json"); + const findingsPath = join(scanDir, "findings.json"); + const coveragePath = join(scanDir, "coverage.json"); + const manifest = await readJson(manifestPath); + const findings = await readJson(findingsPath); + const coverage = await readJson(coveragePath); + findings["findings"][0]["locations"][0]["path"] = "src/app.ts:fixture"; + manifest["scan"]["scope"]["includePaths"] = ["src/CON.py"]; + coverage["includePaths"] = ["src/CON.py"]; + await writeJson(findingsPath, findings); + await writeJson(coveragePath, coverage); + await writeJson(manifestPath, manifest); + await reseal(scanDir); + + await expect( + loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }), + ).resolves.toBeDefined(); + }); + test("rejects trailing-dot aliases for sealed artifacts", async () => { const scanDir = await copyExample(); const manifestPath = join(scanDir, "scan-manifest.json"); From 48c009093e5993d4ed7d4884d5a58bdb52293ce8 Mon Sep 17 00:00:00 2001 From: faizan-oai Date: Mon, 17 Aug 2026 16:16:58 -0700 Subject: [PATCH 3/5] test: align POSIX path expectations --- sdk/typescript/src/contract.ts | 1 - sdk/typescript/tests-ts/runtime.test.ts | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/sdk/typescript/src/contract.ts b/sdk/typescript/src/contract.ts index 850666cc0..82ee90d55 100644 --- a/sdk/typescript/src/contract.ts +++ b/sdk/typescript/src/contract.ts @@ -617,7 +617,6 @@ function safeRelativePath(value: string, context: string): string { !isWellFormedUnicode(value) || value === "." || value.startsWith("/") || - /^[A-Za-z]:/.test(value) || parts.includes("..") || value.includes("\\") || value.includes("\0") diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index c970ba942..0a867f360 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -76,7 +76,6 @@ import { } from "../src/runtime.js"; import { loadBundledRuntime, PLUGIN_ROOT } from "./plugin-root.js"; import { runMockInSubprocess } from "./support/isolated-mock.js"; -import { isWindowsUnsafePathComponent } from "../src/windows-path.js"; const temporaryDirectories: string[] = []; const testPosix = process.platform === "win32" ? test.skip : test; @@ -854,10 +853,7 @@ describe("plugin runtime preparation", () => { cases.map((item) => ({ ...item, inScope: true, - contractValid: - item.path.trim().length > 0 && - !item.path.includes("\\") && - !item.path.split("/").some(isWindowsUnsafePathComponent), + contractValid: item.path.length > 0 && !item.path.includes("\\"), })), ); }, From 97f63c35d612497a295c9836a98826f364fe8573 Mon Sep 17 00:00:00 2001 From: faizan-oai Date: Mon, 17 Aug 2026 16:24:42 -0700 Subject: [PATCH 4/5] fix: preserve model-safe source paths --- .../_bundled_plugin/scripts/finalize_scan_contract.py | 1 + sdk/typescript/src/contract.ts | 2 +- sdk/typescript/tests-ts/runtime.test.ts | 5 ++++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py index 02381d58e..3b3706ee6 100644 --- a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py +++ b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py @@ -235,6 +235,7 @@ def _require_safe_relative_path(value: str, context: str, *, allow_dot: bool = F or (normalized == "." and not allow_dot) or "\\" in value or "\0" in value + or any(ord(character) < 32 for character in value) or path.is_absolute() or ".." in path.parts ): diff --git a/sdk/typescript/src/contract.ts b/sdk/typescript/src/contract.ts index 82ee90d55..fd2da432a 100644 --- a/sdk/typescript/src/contract.ts +++ b/sdk/typescript/src/contract.ts @@ -619,7 +619,7 @@ function safeRelativePath(value: string, context: string): string { value.startsWith("/") || parts.includes("..") || value.includes("\\") || - value.includes("\0") + /[\u0000-\u001f]/u.test(value) ) { throw new ContractValidationError( `${context}: expected a safe scan-relative POSIX path.`, diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 0a867f360..367c3cfb5 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -853,7 +853,10 @@ describe("plugin runtime preparation", () => { cases.map((item) => ({ ...item, inScope: true, - contractValid: item.path.length > 0 && !item.path.includes("\\"), + contractValid: + item.path.length > 0 && + !item.path.includes("\\") && + !/[\u0000-\u001f]/u.test(item.path), })), ); }, From 1a4d6f7842ad691fe577735a5ecc2f23586fa4c2 Mon Sep 17 00:00:00 2001 From: faizan-oai Date: Mon, 17 Aug 2026 16:32:49 -0700 Subject: [PATCH 5/5] fix: align source path validation --- .../_bundled_plugin/scripts/finalize_scan_contract.py | 3 ++- sdk/typescript/src/contract.ts | 3 ++- sdk/typescript/tests-ts/runtime.test.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py index 3b3706ee6..96ebd6fce 100644 --- a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py +++ b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py @@ -231,8 +231,9 @@ def _require_safe_relative_path(value: str, context: str, *, allow_dot: bool = F path = PurePosixPath(value) normalized = path.as_posix() if ( - not value + not value.strip() or (normalized == "." and not allow_dot) + or (len(value) >= 2 and value[0].isalpha() and value[1] == ":") or "\\" in value or "\0" in value or any(ord(character) < 32 for character in value) diff --git a/sdk/typescript/src/contract.ts b/sdk/typescript/src/contract.ts index fd2da432a..84b3bd4e2 100644 --- a/sdk/typescript/src/contract.ts +++ b/sdk/typescript/src/contract.ts @@ -613,10 +613,11 @@ async function verifyScanRoot( function safeRelativePath(value: string, context: string): string { const parts = value.split("/"); if ( - value.length === 0 || + value.trim().length === 0 || !isWellFormedUnicode(value) || value === "." || value.startsWith("/") || + /^[A-Za-z]:/.test(value) || parts.includes("..") || value.includes("\\") || /[\u0000-\u001f]/u.test(value) diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 367c3cfb5..1dc6d0f8e 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -854,7 +854,8 @@ describe("plugin runtime preparation", () => { ...item, inScope: true, contractValid: - item.path.length > 0 && + item.path.trim().length > 0 && + !/^[A-Za-z]:/.test(item.path) && !item.path.includes("\\") && !/[\u0000-\u001f]/u.test(item.path), })),