Skip to content

Interpreter lookup can report Python 3.15 as 3.11 #186

Closed

Description

@gaborbernat

Resolving a 3.11 virtual environment can prime the discover_exe cache so a later lookup reports a Python 3.15 executable as 3.11. The returned path runs 3.15, while its cached metadata says 3.11.

Affected code

PythonInfo.discover_exe keys the cache by prefix and exactness.

Mechanism

When a virtual environment cannot name its base executable, resolve_to_system() searches its base prefix. The first lookup stores interpreter metadata under (prefix, exact). A second lookup for another version under that prefix retrieves the earlier metadata before comparing versions. The cache key omits the requested version.

Reproduction

The following files reproduce the result on 004294957ed227fe7e52b2864952ba09fd28b220 using Python 3.15. Run repro as root in an isolated container with the companion directory mounted at /tmp/repro.d and python-discovery installed in /opt/venv.

repro

#!/usr/bin/env python3
import json
import pathlib
import subprocess
import tempfile

PYTHON = "/opt/venv/bin/python"  # the image installs python-discovery from /src here

# set up: <root>/prefix holds python3.11 and python3.15; venv311 and venv315 are venvs of each
root = pathlib.Path(tempfile.mkdtemp())
subprocess.run([PYTHON, "/tmp/repro.d/make_two_venvs_on_one_prefix.py", root], check=True)
prefix = root / "prefix"

# the one artificial step, standing in for a CPython <= 3.10 venv: `home` without an interpreter in it.
# CPython still finds base_prefix = prefix by walking up from `home`, but sys._base_executable names
# nothing, so python-discovery gets system_executable=None and searches the prefix for the base.
for venv in ("venv311", "venv315"):
    pyvenv_cfg = root / venv / "pyvenv.cfg"
    recorded_home = f"home = {prefix}/bin\n"
    assert recorded_home in pyvenv_cfg.read_text()
    pyvenv_cfg.write_text(pyvenv_cfg.read_text().replace(recorded_home, f"home = {prefix}/no-python-here\n"))

resolve_with_only_venv315_on_path = [PYTHON, "/tmp/repro.d/resolve_specs.py", str(root / "venv315" / "bin")]

# control: a fresh process asks for "3.11" with only the 3.15 venv on PATH
control = subprocess.run([*resolve_with_only_venv315_on_path, "3.11"], check=True, capture_output=True, text=True)
print("process 1:")
print(control.stdout)
(alone,) = json.loads(control.stdout.splitlines()[-1])

# trigger: the same request, in a process that resolved the 3.11 venv first
trigger = subprocess.run(
    [*resolve_with_only_venv315_on_path, str(root / "venv311" / "bin" / "python"), "3.11"],
    check=True,
    capture_output=True,
    text=True,
)
print("process 2:")
print(trigger.stdout)
_, after_venv311 = json.loads(trigger.stdout.splitlines()[-1])

# check: "3.11" must not be answered with the 3.15 venv; run what was returned and ask its version
print('"3.11" alone                  ->', alone)
print('"3.11" after resolving venv311 ->', after_venv311)
really_runs = None
if after_venv311 is not None:
    ask_version = [after_venv311["executable"], "-c", "import platform; print(platform.python_version())"]
    really_runs = subprocess.check_output(ask_version, text=True).strip()
    print(after_venv311["executable"], "is reported as", after_venv311["version"], "and really runs", really_runs)
print("BUG" if alone is None and after_venv311 is not None and after_venv311["version"] != really_runs else "OK")

repro.d/make_two_venvs_on_one_prefix.py

#!/usr/bin/env python3
# usage: make_two_venvs_on_one_prefix.py <root>
# Creates <root>/prefix (bin/python3.11 and bin/python3.15, like a distribution's /usr)
# and a plain `python -m venv` of each: <root>/venv311, <root>/venv315.
import pathlib
import shutil
import subprocess
import sys

root = pathlib.Path(sys.argv[1])
prefix = root / "prefix"
(prefix / "bin").mkdir(parents=True)
(prefix / "lib").mkdir()

# copies, not symlinks: CPython derives sys.prefix from the real path of its binary
shutil.copy2("/usr/bin/python3.11", prefix / "bin" / "python3.11")
shutil.copy2("/usr/local/bin/python3.15", prefix / "bin" / "python3.15")
(prefix / "lib" / "python3.11").symlink_to("/usr/lib/python3.11")
(prefix / "lib" / "python3.15").symlink_to("/usr/local/lib/python3.15")

# --copies: a symlinked venv python would find its base executable through realpath()
subprocess.run([prefix / "bin" / "python3.11", "-m", "venv", "--copies", "--without-pip", root / "venv311"], check=True)
subprocess.run([prefix / "bin" / "python3.15", "-m", "venv", "--copies", "--without-pip", root / "venv315"], check=True)

repro.d/resolve_specs.py

#!/usr/bin/env python3
# A tool that resolves several interpreter requests in one process, as tox or virtualenv do.
# usage: resolve_specs.py <PATH to search> <spec> [<spec> ...]
# Last line of stdout: a JSON list with one entry per spec (null when nothing was found).
import json
import pathlib
import sys
import tempfile

from python_discovery import DiskCache, get_interpreter


def print_spawned_interpreter(event, args):
    if event == "subprocess.Popen":
        print("    spawned:", *args[1][:2])


sys.addaudithook(print_spawned_interpreter)

search_path, specs = sys.argv[1], sys.argv[2:]
cache = DiskCache(pathlib.Path(tempfile.mkdtemp()))

results = []
for spec in specs:
    print(f"  get_interpreter({spec!r}, env={{'PATH': {search_path!r}}})")
    found = get_interpreter(spec, cache=cache, env={"PATH": search_path})
    if found is None:
        results.append(None)
    else:
        results.append({
            "executable": found.executable,
            "version": found.version_str,
            "system_executable": found.system_executable,
        })
print(json.dumps(results))

The final lines on current main were:

"3.11" alone                  -> None
"3.11" after resolving venv311 -> {'executable': '/tmp/tmpok87iy0p/venv315/bin/python3', 'version': '3.11.2', 'system_executable': '/tmp/tmpok87iy0p/prefix/bin/python3.11'}
/tmp/tmpok87iy0p/venv315/bin/python3 is reported as 3.11.2 and really runs 3.15.2
BUG

The second lookup should reject venv315/bin/python3 for a 3.11 request.

Impact

A caller that first resolves one virtual environment and then searches another environment under the same prefix can select the wrong interpreter. Reproducibility is moderate because the cache needs that lookup order. Exposure is unlikely: both environments must lack a usable system_executable, share a base prefix, and be resolved in the same process. The reproducer edits pyvenv.cfg to reach that state; ordinary CPython 3.11+ virtual environments name their base executable.

Credits

Reported by Anthropic's OSS Scanner: ANT-2026-AIC1BQKO.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions