diff --git a/sdk/typescript/src/trusted-executable.ts b/sdk/typescript/src/trusted-executable.ts index e45b1bd4..9c6b115d 100644 --- a/sdk/typescript/src/trusted-executable.ts +++ b/sdk/typescript/src/trusted-executable.ts @@ -1,6 +1,15 @@ import { constants } from "node:fs"; import { access, realpath, stat } from "node:fs/promises"; -import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + basename, + delimiter, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; export interface TrustedExecutable { executable: string; @@ -65,7 +74,18 @@ export async function resolveTrustedExecutable( process.platform === "win32" ? constants.F_OK : constants.X_OK, ); if (!(await stat(canonical)).isFile()) continue; - executable ??= pathLike ? canonical : current.path; + const canonicalParent = await realpath(dirname(current.path)).catch( + () => null, + ); + const invocationPath = + canonicalParent === null + ? current.path + : join(canonicalParent, basename(current.path)); + // Explicit launchers outside the protected root retain invocation semantics + // such as Python virtualenv selection. Repository-local links still execute + // only the canonical target that passed the trust check. + executable ??= + pathLike && isWithin(root, invocationPath) ? canonical : current.path; } catch { continue; } diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 00f6e96f..b0cd2f40 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -4071,6 +4071,34 @@ describe("runtime directories and plugin Python boundary", () => { ).rejects.toThrow(PluginPythonUnavailableError); }); + testPosix("preserves an explicit virtualenv Python launcher", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const systemBin = join(root, "system", "bin"); + const virtualenvBin = join(root, "venv", "bin"); + const systemPython = join(systemBin, "python3"); + const virtualenvPython = join(virtualenvBin, "python"); + await Promise.all([ + mkdir(repository), + mkdir(systemBin, { recursive: true }), + mkdir(virtualenvBin, { recursive: true }), + ]); + await writeFile( + systemPython, + '#!/bin/sh\ncase "$0" in */venv/bin/python) ;; *) exit 1 ;; esac\nprintf "codex-security-python-ok\\n"\n', + ); + await chmod(systemPython, 0o700); + await symlink(systemPython, virtualenvPython); + + await expect( + resolvePluginPython({ + configuredPath: virtualenvPython, + environment: { PATH: "" }, + protectedRoot: repository, + }), + ).resolves.toBe(virtualenvPython); + }); + testPosix( "does not load repository-controlled Python startup code", async () => { @@ -4100,7 +4128,7 @@ describe("runtime directories and plugin Python boundary", () => { environment, protectedRoot: repository, }), - ).toBe(await realpath(interpreter)); + ).toBe(interpreter); expect(existsSync(marker)).toBe(false); }, ); diff --git a/sdk/typescript/tests-ts/trusted-executable.test.ts b/sdk/typescript/tests-ts/trusted-executable.test.ts index 34db4501..c6933139 100644 --- a/sdk/typescript/tests-ts/trusted-executable.test.ts +++ b/sdk/typescript/tests-ts/trusted-executable.test.ts @@ -110,6 +110,35 @@ describe("trusted executable resolution", () => { }, ); + test.skipIf(process.platform === "win32")( + "canonicalizes aliased parents before preserving explicit launchers", + async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const repositoryBin = join(repository, "bin"); + const aliasedBin = join(root, "repository-bin-alias"); + const trusted = join(root, "trusted"); + const wrapper = join(trusted, "python3"); + const launcher = join(repositoryBin, "python"); + await Promise.all([ + mkdir(repositoryBin, { recursive: true }), + mkdir(trusted), + ]); + await writeFile(wrapper, "#!/bin/sh\nexit 0\n"); + await chmod(wrapper, 0o700); + await symlink(wrapper, launcher); + await symlink(repositoryBin, aliasedBin, "dir"); + + await expect( + resolveTrustedExecutable( + join(aliasedBin, "python"), + { PATH: "" }, + repository, + ), + ).resolves.toEqual({ executable: wrapper, environment: { PATH: "" } }); + }, + ); + test("selects runnable Windows executables ahead of extensionless and batch files", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository");