Repository: pantsbuild/pants

Issue:
Fix filesystem sandbox leaks in Python bootstrapping
_(blocked by #16754)_

`python_bootstrap.py` and `asdf.py` are the two (known?) filesystem sandbox leaks due to using direct filesystem access (`PATH` checks, essentially) to discover Python interpreters: https://github.com/pantsbuild/pants/blob/1fa5975a23bcc3fb587cfdeafce0a4d476cb4046/src/python/pants/core/util_rules/asdf.py#L166-L168

----

To resolve this, we might need to port the discovery of those paths to a `Process` (which could run inside of an image). And unfortunately, because they execute before we are sure that we have a python-interpreter available, possibly to either bash or native code (see some discussion here: https://github.com/pantsbuild/pants/issues/10769#issuecomment-692372096).

Alternatively, we might be able to implement and use #10842: the implication would be that unlike `Snapshots`/`PathGlobs` in general, an "absolute path" based implementation would be environment specific, and could be implemented such that it did lookups inside a docker container.


Discussion:
@tdyas raised the point that the latter approach (a docker-aware intrinsic), adds the requirement that there is a docker image which corresponds to any remote execution environment, and some environments might not use docker.

Additionally the `Process` approach is very general and composable: it would be easy to create an (uncacheable) `Process` which captured a `Snapshot` from an absolute path, for example.
I'm working on this. `python_boostrap` is a bit of a tangled web, and it's highly likely that I'll be attempting this one in multiple stages, as the current module layout is not really amenable to converting things into rules.
Having got to the point where I'm actually looking at the code in `python_bootstrap` that actually hits the filesystem, it looks like the scenarios that actually hit the filesystem directly are only directly related to pyenv and asdf, both of which are more relevant to managing runtimes on a local machine. Do we envisage a use case where we have pyenv installed in remote/docker environments, or is this something that we can mark off as only working in local environments?
(to be clear: I think pyenv and asdf should be local-only, and we should work on making the rules cacheable based on the contents of the relevant config files if possible.)
Related: @chrisjrn and I decided that `<PEXRC>` should not work with docker environments and remote enviroments: that would be very weird to have added `~/.pexrc` inside either of those environments/containers...Instead, users can set up the `docker_environment` and `remote_environment` targets with the explicit paths.

@chrisjrn, I think it is reasonable to not support `<ASDF>` and `<PYENV>` when the environment is Docker or Remote. At least for now, until we get feedback from users that they need it. 

But, we still would want code to detect when `EnvironmentTarget` is not local, and not attempt to search those special strings. It is wrong for us to evaluate them using localhost, and then to try using those results inside Docker and RE. We need to skip them, and possibly warn or error when they are set for those environments.

> possibly warn or error when they are set for those environments.

I don't know the right behavior here. Note that we include `<PYENV>` by default for `[python-bootstrap].search_path`, so it would be really annoying to error or loudly warn that `<PYENV>` is being ignored with Docker and remote environments because you cannot use Pants's default then with those targets. Instead, we may want to do something like see if `options.is_default("search_path")` to determine whether we should warn/error.
@stuhood thoughts?
> @chrisjrn, I think it is reasonable to not support `<ASDF>` and `<PYENV>` when the environment is Docker or Remote. At least for now, until we get feedback from users that they need it.
> 
> But, we still would want code to detect when `EnvironmentTarget` is not local, and not attempt to search those special strings. It is wrong for us to evaluate them using localhost, and then to try using those results inside Docker and RE. We need to skip them, and possibly warn or error when they are set for those environments.

Yea, fine with this.

Resolving patch:
diff --git a/src/python/pants/backend/go/util_rules/go_bootstrap.py b/src/python/pants/backend/go/util_rules/go_bootstrap.py
--- a/src/python/pants/backend/go/util_rules/go_bootstrap.py
+++ b/src/python/pants/backend/go/util_rules/go_bootstrap.py
@@ -10,9 +10,11 @@
 from pants.backend.go.subsystems.golang import GolangSubsystem
 from pants.core.util_rules import asdf
 from pants.core.util_rules.asdf import AsdfToolPathsRequest, AsdfToolPathsResult
+from pants.core.util_rules.environments import EnvironmentTarget, LocalEnvironmentTarget
 from pants.engine.env_vars import EnvironmentVars, EnvironmentVarsRequest
 from pants.engine.internals.selectors import Get
 from pants.engine.rules import collect_rules, rule, rule_helper
+from pants.util.strutil import softwrap
 
 logger = logging.getLogger(__name__)
 
@@ -24,7 +26,7 @@ class GoBootstrap:
 
 @rule_helper
 async def _go_search_paths(
-    golang_subsystem: GolangSubsystem, paths: Iterable[str]
+    env_tgt: EnvironmentTarget, golang_subsystem: GolangSubsystem, paths: Iterable[str]
 ) -> tuple[str, ...]:
 
     resolve_standard, resolve_local = "<ASDF>" in paths, "<ASDF_LOCAL>" in paths
@@ -34,12 +36,12 @@ async def _go_search_paths(
         asdf_result = await Get(
             AsdfToolPathsResult,
             AsdfToolPathsRequest(
+                env_tgt=env_tgt,
                 tool_name=golang_subsystem.asdf_tool_name,
                 tool_description="Go distribution",
                 resolve_standard=resolve_standard,
                 resolve_local=resolve_local,
                 paths_option_name="[golang].go_search_paths",
-                extra_env_var_names=(),
                 bin_relpath=golang_subsystem.asdf_bin_relpath,
             ),
         )
@@ -76,11 +78,50 @@ async def _environment_paths() -> list[str]:
     return []
 
 
+def _error_if_not_compatible_with_asdf(
+    env_tgt: EnvironmentTarget,
+    _search_paths: Iterable[str],
+) -> None:
+    """Raises an exception if any special search path strings any are invalid for the environment.
+
+    If the environment is non-local and there are invalid tokens for those environments, raise
+    `ValueError`.
+    """
+
+    env = env_tgt.val
+
+    if env is None or isinstance(env, LocalEnvironmentTarget):
+        return
+
+    not_allowed = {"<ASDF>", "<ASDF_LOCAL>"}
+
+    any_not_allowed = set(_search_paths) & not_allowed
+    if any_not_allowed:
+        env_type = type(env)
+        raise ValueError(
+            softwrap(
+                f"`[python-bootstrap].search_paths` is configured to use local Go discovery "
+                f"tools, which do not work in {env_type.__name__} runtime environments. To fix "
+                f"this, set the value of `golang_go_search_paths` in the `{env.alias}` "
+                f"defined at `{env.address}` to contain only hardcoded paths or the `<PATH>` "
+                "special string."
+            )
+        )
+
+    return
+
+
 @rule
 async def resolve_go_bootstrap(
     golang_subsystem: GolangSubsystem, golang_env_aware: GolangSubsystem.EnvironmentAware
 ) -> GoBootstrap:
-    paths = await _go_search_paths(golang_subsystem, golang_env_aware.raw_go_search_paths)
+
+    _error_if_not_compatible_with_asdf(
+        golang_env_aware.env_tgt, golang_env_aware.raw_go_search_paths
+    )
+    paths = await _go_search_paths(
+        golang_env_aware.env_tgt, golang_subsystem, golang_env_aware.raw_go_search_paths
+    )
 
     return GoBootstrap(go_search_paths=paths)
 
diff --git a/src/python/pants/core/subsystems/python_bootstrap.py b/src/python/pants/core/subsystems/python_bootstrap.py
--- a/src/python/pants/core/subsystems/python_bootstrap.py
+++ b/src/python/pants/core/subsystems/python_bootstrap.py
@@ -15,8 +15,8 @@
 from pants.core.util_rules import asdf
 from pants.core.util_rules.asdf import AsdfToolPathsRequest, AsdfToolPathsResult
 from pants.core.util_rules.environments import EnvironmentTarget, LocalEnvironmentTarget
-from pants.engine.env_vars import EnvironmentVars
-from pants.engine.rules import Get, collect_rules, rule
+from pants.engine.env_vars import EnvironmentVars, EnvironmentVarsRequest
+from pants.engine.rules import Get, _uncacheable_rule, collect_rules, rule
 from pants.option.option_types import StrListOption
 from pants.option.subsystem import Subsystem
 from pants.util.strutil import softwrap
@@ -88,27 +88,68 @@ class EnvironmentAware(Subsystem.EnvironmentAware):
 
 @dataclass(frozen=True)
 class PythonBootstrap:
-    EXTRA_ENV_VAR_NAMES = ("PATH", "PYENV_ROOT")
-
     interpreter_names: tuple[str, ...]
     interpreter_search_paths: tuple[str, ...]
 
 
-def _expand_interpreter_search_paths(
-    interpreter_search_paths: Sequence[str],
-    env: EnvironmentVars,
-    asdf_standard_tool_paths: tuple[str, ...],
-    asdf_local_tool_paths: tuple[str, ...],
-) -> tuple[str, ...]:
+@dataclass(frozen=True)
+class _ExpandInterpreterSearchPathsRequest:
+    interpreter_search_paths: Sequence[str]
+    env_tgt: EnvironmentTarget
+
+
+@dataclass(frozen=True)
+class _PyEnvPathsRequest:
+    env_tgt: EnvironmentTarget
+    pyenv_local: bool = False
+
+
+@dataclass(frozen=False)
+class _SearchPaths:
+    paths: tuple[str, ...]
+
+
+@rule
+async def _expand_interpreter_search_paths(
+    request: _ExpandInterpreterSearchPathsRequest,
+) -> _SearchPaths:
+
+    interpreter_search_paths, env_tgt = (request.interpreter_search_paths, request.env_tgt)
+
+    env = await Get(EnvironmentVars, EnvironmentVarsRequest(("PATH",)))
+
+    has_asdf_standard_path_token, has_asdf_local_path_token = _contains_asdf_path_tokens(
+        interpreter_search_paths
+    )
+
+    if has_asdf_standard_path_token or has_asdf_local_path_token:
+        # `AsdfToolPathsResult` is uncacheable, so don't request it unless we actually need it.
+        asdf_paths = await Get(
+            AsdfToolPathsResult,
+            AsdfToolPathsRequest(
+                env_tgt=env_tgt,
+                tool_name="python",
+                tool_description="Python interpreters",
+                resolve_standard=has_asdf_standard_path_token,
+                resolve_local=has_asdf_local_path_token,
+                paths_option_name="[python-bootstrap].search_path",
+            ),
+        )
+
+        asdf_standard_tool_paths, asdf_local_tool_paths = (
+            asdf_paths.standard_tool_paths,
+            asdf_paths.local_tool_paths,
+        )
+    else:
+        asdf_local_tool_paths, asdf_standard_tool_paths = (), ()
 
     special_strings = {
         "<PEXRC>": _get_pex_python_paths,
         "<PATH>": lambda: _get_environment_paths(env),
         "<ASDF>": lambda: asdf_standard_tool_paths,
         "<ASDF_LOCAL>": lambda: asdf_local_tool_paths,
-        "<PYENV>": lambda: _get_pyenv_paths(env),
-        "<PYENV_LOCAL>": lambda: _get_pyenv_paths(env, pyenv_local=True),
     }
+
     expanded: list[str] = []
     from_pexrc = None
     for s in interpreter_search_paths:
@@ -117,6 +158,9 @@ def _expand_interpreter_search_paths(
             if s == "<PEXRC>":
                 from_pexrc = special_paths
             expanded.extend(special_paths)
+        elif s == "<PYENV>" or s == "<PYENV_LOCAL>":
+            paths = await Get(_SearchPaths, _PyEnvPathsRequest(env_tgt, s == "<PYENV_LOCAL>"))
+            expanded.extend(paths.paths)
         else:
             expanded.append(s)
     # Some special-case logging to avoid misunderstandings.
@@ -131,7 +175,7 @@ def _expand_interpreter_search_paths(
                 """
             )
         )
-    return tuple(expanded)
+    return _SearchPaths(tuple(expanded))
 
 
 def _get_environment_paths(env: EnvironmentVars):
@@ -167,20 +211,29 @@ def _contains_asdf_path_tokens(interpreter_search_paths: Iterable[str]) -> tuple
     return standard_path_token, local_path_token
 
 
-def _get_pyenv_paths(env: EnvironmentVars, *, pyenv_local: bool = False) -> list[str]:
-    """Returns a list of paths to Python interpreters managed by pyenv.
+@_uncacheable_rule
+async def _get_pyenv_paths(request: _PyEnvPathsRequest) -> _SearchPaths:
+    """Returns a tuple of paths to Python interpreters managed by pyenv.
 
-    :param env: The environment to use to look up pyenv.
+    :param `request.env_tgt`: The environment target -- if not referring to a local/no environment,
+                                this will return an empty path.
     :param bool pyenv_local: If True, only use the interpreter specified by
                                 '.python-version' file under `build_root`.
     """
-    pyenv_root = get_pyenv_root(env)
+
+    if not (request.env_tgt.val is None or isinstance(request.env_tgt.val, LocalEnvironmentTarget)):
+        return _SearchPaths(())
+
+    pyenv_local = request.pyenv_local
+    env = await Get(EnvironmentVars, EnvironmentVarsRequest(("PYENV_ROOT", "HOME")))
+
+    pyenv_root = _get_pyenv_root(env)
     if not pyenv_root:
-        return []
+        return _SearchPaths(())
 
     versions_dir = Path(pyenv_root, "versions")
     if not versions_dir.is_dir():
-        return []
+        return _SearchPaths(())
 
     if pyenv_local:
         local_version_file = Path(get_buildroot(), ".python-version")
@@ -193,23 +246,23 @@ def _get_pyenv_paths(env: EnvironmentVars, *, pyenv_local: bool = False) -> list
                     """
                 )
             )
-            return []
+            return _SearchPaths(())
 
         local_version = local_version_file.read_text().strip()
         path = Path(versions_dir, local_version, "bin")
         if path.is_dir():
-            return [str(path)]
-        return []
+            return _SearchPaths((str(path),))
+        return _SearchPaths(())
 
     paths = []
     for version in sorted(versions_dir.iterdir()):
         path = Path(versions_dir, version, "bin")
         if path.is_dir():
             paths.append(str(path))
-    return paths
+    return _SearchPaths(tuple(paths))
 
 
-def get_pyenv_root(env: EnvironmentVars) -> str | None:
+def _get_pyenv_root(env: EnvironmentVars) -> str | None:
     """See https://github.com/pyenv/pyenv#environment-variables."""
     from_env = env.get("PYENV_ROOT")
     if from_env:
@@ -280,31 +333,17 @@ async def python_bootstrap(
     )
     interpreter_names = python_bootstrap_subsystem.names
 
-    has_standard_path_token, has_local_path_token = _contains_asdf_path_tokens(
-        interpreter_search_paths
-    )
-    result = await Get(
-        AsdfToolPathsResult,
-        AsdfToolPathsRequest(
-            tool_name="python",
-            tool_description="Python interpreters",
-            resolve_standard=has_standard_path_token,
-            resolve_local=has_local_path_token,
-            extra_env_var_names=PythonBootstrap.EXTRA_ENV_VAR_NAMES,
-            paths_option_name="[python-bootstrap].search_path",
+    expanded_paths = await Get(
+        _SearchPaths,
+        _ExpandInterpreterSearchPathsRequest(
+            interpreter_search_paths,
+            python_bootstrap_subsystem.env_tgt,
         ),
     )
 
-    expanded_paths = _expand_interpreter_search_paths(
-        interpreter_search_paths,
-        result.env,
-        result.standard_tool_paths,
-        result.local_tool_paths,
-    )
-
     return PythonBootstrap(
         interpreter_names=interpreter_names,
-        interpreter_search_paths=expanded_paths,
+        interpreter_search_paths=expanded_paths.paths,
     )
 
 
diff --git a/src/python/pants/core/util_rules/asdf.py b/src/python/pants/core/util_rules/asdf.py
--- a/src/python/pants/core/util_rules/asdf.py
+++ b/src/python/pants/core/util_rules/asdf.py
@@ -10,6 +10,7 @@
 
 from pants.base.build_environment import get_buildroot
 from pants.base.build_root import BuildRoot
+from pants.core.util_rules.environments import EnvironmentTarget, LocalEnvironmentTarget
 from pants.engine.env_vars import EnvironmentVars, EnvironmentVarsRequest
 from pants.engine.internals.selectors import Get
 from pants.engine.rules import _uncacheable_rule, collect_rules, rule_helper
@@ -20,11 +21,11 @@
 
 @dataclass(frozen=True)
 class AsdfToolPathsRequest:
+    env_tgt: EnvironmentTarget
     tool_name: str
     tool_description: str
     resolve_standard: bool
     resolve_local: bool
-    extra_env_var_names: tuple[str, ...]
     paths_option_name: str
     bin_relpath: str = "bin"
 
@@ -32,13 +33,13 @@ class AsdfToolPathsRequest:
 @dataclass(frozen=True)
 class AsdfToolPathsResult:
     tool_name: str
-    env: EnvironmentVars
     standard_tool_paths: tuple[str, ...] = ()
     local_tool_paths: tuple[str, ...] = ()
 
 
 @rule_helper
 async def _resolve_asdf_tool_paths(
+    env_tgt: EnvironmentTarget,
     tool_name: str,
     paths_option_name: str,
     tool_description: str,
@@ -47,6 +48,10 @@ async def _resolve_asdf_tool_paths(
     env: EnvironmentVars,
     local: bool,
 ) -> tuple[str, ...]:
+
+    if not (isinstance(env_tgt.val, LocalEnvironmentTarget) or env_tgt.val is None):
+        return ()
+
     asdf_dir = get_asdf_data_dir(env)
     if not asdf_dir:
         return ()
@@ -193,13 +198,13 @@ async def resolve_asdf_tool_paths(
         "ASDF_DATA_DIR",
         tool_env_name,
         "HOME",
-        *request.extra_env_var_names,
     ]
     env = await Get(EnvironmentVars, EnvironmentVarsRequest(env_vars_to_request))
 
     standard_tool_paths: tuple[str, ...] = ()
     if request.resolve_standard:
         standard_tool_paths = await _resolve_asdf_tool_paths(
+            env_tgt=request.env_tgt,
             tool_name=request.tool_name,
             paths_option_name=request.paths_option_name,
             tool_description=request.tool_description,
@@ -212,6 +217,7 @@ async def resolve_asdf_tool_paths(
     local_tool_paths: tuple[str, ...] = ()
     if request.resolve_local:
         local_tool_paths = await _resolve_asdf_tool_paths(
+            env_tgt=request.env_tgt,
             tool_name=request.tool_name,
             paths_option_name=request.paths_option_name,
             tool_description=request.tool_description,
@@ -223,7 +229,6 @@ async def resolve_asdf_tool_paths(
 
     return AsdfToolPathsResult(
         tool_name=request.tool_name,
-        env=env,
         standard_tool_paths=standard_tool_paths,
         local_tool_paths=local_tool_paths,
     )


Test patch:
diff --git a/src/python/pants/core/subsystems/python_bootstrap_test.py b/src/python/pants/core/subsystems/python_bootstrap_test.py
--- a/src/python/pants/core/subsystems/python_bootstrap_test.py
+++ b/src/python/pants/core/subsystems/python_bootstrap_test.py
@@ -11,17 +11,18 @@
 
 from pants.base.build_environment import get_pants_cachedir
 from pants.core.subsystems.python_bootstrap import (
-    PythonBootstrap,
-    _expand_interpreter_search_paths,
+    _ExpandInterpreterSearchPathsRequest,
     _get_environment_paths,
     _get_pex_python_paths,
-    _get_pyenv_paths,
+    _get_pyenv_root,
     _preprocessed_interpreter_search_paths,
-    get_pyenv_root,
+    _PyEnvPathsRequest,
+    _SearchPaths,
 )
+from pants.core.subsystems.python_bootstrap import rules as python_bootstrap_rules
 from pants.core.util_rules import asdf
 from pants.core.util_rules.asdf import AsdfToolPathsRequest, AsdfToolPathsResult
-from pants.core.util_rules.asdf_test import fake_asdf_root, get_asdf_paths
+from pants.core.util_rules.asdf_test import fake_asdf_root
 from pants.core.util_rules.environments import (
     DockerEnvironmentTarget,
     DockerImageField,
@@ -30,7 +31,7 @@
     RemoteEnvironmentTarget,
 )
 from pants.engine.addresses import Address
-from pants.engine.env_vars import EnvironmentVars
+from pants.engine.env_vars import CompleteEnvironmentVars, EnvironmentVars
 from pants.engine.rules import QueryRule
 from pants.testutil.rule_runner import RuleRunner
 from pants.util.contextutil import environment_as, temporary_dir
@@ -39,6 +40,20 @@
 _T = TypeVar("_T")
 
 
+@pytest.fixture
+def rule_runner() -> RuleRunner:
+    return RuleRunner(
+        rules=[
+            *python_bootstrap_rules(),
+            *asdf.rules(),
+            QueryRule(AsdfToolPathsResult, (AsdfToolPathsRequest,)),
+            QueryRule(_SearchPaths, [_PyEnvPathsRequest]),
+            QueryRule(_SearchPaths, [_ExpandInterpreterSearchPathsRequest]),
+        ],
+        target_types=[],
+    )
+
+
 @contextmanager
 def setup_pexrc_with_pex_python_path(interpreter_paths):
     """A helper function for writing interpreter paths to a PEX_PYTHON_PATH variable in a .pexrc
@@ -67,10 +82,12 @@ def setup_pexrc_with_pex_python_path(interpreter_paths):
 @contextmanager
 def fake_pyenv_root(fake_versions, fake_local_version):
     with temporary_dir() as pyenv_root:
-        fake_version_dirs = [os.path.join(pyenv_root, "versions", v, "bin") for v in fake_versions]
+        fake_version_dirs = tuple(
+            os.path.join(pyenv_root, "versions", v, "bin") for v in fake_versions
+        )
         for d in fake_version_dirs:
             os.makedirs(d)
-        fake_local_version_dirs = [os.path.join(pyenv_root, "versions", fake_local_version, "bin")]
+        fake_local_version_dirs = (os.path.join(pyenv_root, "versions", fake_local_version, "bin"),)
         yield pyenv_root, fake_version_dirs, fake_local_version_dirs
 
 
@@ -94,29 +111,37 @@ def test_get_pyenv_root() -> None:
     default_root = f"{home}/.pyenv"
     explicit_root = f"{home}/explicit"
 
-    assert explicit_root == get_pyenv_root(EnvironmentVars({"PYENV_ROOT": explicit_root}))
-    assert default_root == get_pyenv_root(EnvironmentVars({"HOME": home}))
-    assert get_pyenv_root(EnvironmentVars({})) is None
+    assert explicit_root == _get_pyenv_root(EnvironmentVars({"PYENV_ROOT": explicit_root}))
+    assert default_root == _get_pyenv_root(EnvironmentVars({"HOME": home}))
+    assert _get_pyenv_root(EnvironmentVars({})) is None
 
 
-def test_get_pyenv_paths() -> None:
+def test_get_pyenv_paths(rule_runner: RuleRunner) -> None:
     local_pyenv_version = "3.5.5"
     all_pyenv_versions = ["2.7.14", local_pyenv_version]
-    RuleRunner().write_files({".python-version": f"{local_pyenv_version}\n"})
+    rule_runner.write_files({".python-version": f"{local_pyenv_version}\n"})
     with fake_pyenv_root(all_pyenv_versions, local_pyenv_version) as (
         pyenv_root,
         expected_paths,
         expected_local_paths,
     ):
-        paths = _get_pyenv_paths(EnvironmentVars({"PYENV_ROOT": pyenv_root}))
-        local_paths = _get_pyenv_paths(
-            EnvironmentVars({"PYENV_ROOT": pyenv_root}), pyenv_local=True
+        rule_runner.set_session_values(
+            {CompleteEnvironmentVars: CompleteEnvironmentVars({"PYENV_ROOT": pyenv_root})}
+        )
+        tgt = EnvironmentTarget(LocalEnvironmentTarget({}, Address("flem")))
+        paths = rule_runner.request(
+            _SearchPaths,
+            [_PyEnvPathsRequest(tgt, False)],
         )
-    assert expected_paths == paths
-    assert expected_local_paths == local_paths
+        local_paths = rule_runner.request(
+            _SearchPaths,
+            [_PyEnvPathsRequest(tgt, True)],
+        )
+    assert expected_paths == paths.paths
+    assert expected_local_paths == local_paths.paths
 
 
-def test_expand_interpreter_search_paths() -> None:
+def test_expand_interpreter_search_paths(rule_runner: RuleRunner) -> None:
     local_pyenv_version = "3.5.5"
     all_python_versions = ["2.7.14", local_pyenv_version, "3.7.10", "3.9.4", "3.9.5"]
     asdf_home_versions = [0, 1, 2]
@@ -124,12 +149,6 @@ def test_expand_interpreter_search_paths() -> None:
     asdf_local_versions_str = " ".join(
         materialize_indices(all_python_versions, asdf_local_versions)
     )
-    rule_runner = RuleRunner(
-        rules=[
-            *asdf.rules(),
-            QueryRule(AsdfToolPathsResult, (AsdfToolPathsRequest,)),
-        ]
-    )
     rule_runner.write_files(
         {
             ".python-version": f"{local_pyenv_version}\n",
@@ -159,7 +178,21 @@ def test_expand_interpreter_search_paths() -> None:
             expected_pyenv_paths,
             expected_pyenv_local_paths,
         ):
-            paths = [
+
+            rule_runner.set_session_values(
+                {
+                    CompleteEnvironmentVars: CompleteEnvironmentVars(
+                        {
+                            "HOME": home_dir,
+                            "PATH": "/env/path1:/env/path2",
+                            "PYENV_ROOT": pyenv_root,
+                            "ASDF_DATA_DIR": asdf_dir,
+                        },
+                    )
+                }
+            )
+
+            paths = (
                 "/foo",
                 "<PATH>",
                 "/bar",
@@ -170,24 +203,15 @@ def test_expand_interpreter_search_paths() -> None:
                 "<PYENV>",
                 "<PYENV_LOCAL>",
                 "/qux",
-            ]
-            asdf_paths_result = get_asdf_paths(
-                rule_runner,
-                {
-                    "HOME": home_dir,
-                    "PATH": "/env/path1:/env/path2",
-                    "PYENV_ROOT": pyenv_root,
-                    "ASDF_DATA_DIR": asdf_dir,
-                },
-                standard=True,
-                local=True,
-                extra_env_var_names=PythonBootstrap.EXTRA_ENV_VAR_NAMES,
             )
-            expanded_paths = _expand_interpreter_search_paths(
-                paths,
-                env=asdf_paths_result.env,
-                asdf_standard_tool_paths=asdf_paths_result.standard_tool_paths,
-                asdf_local_tool_paths=asdf_paths_result.local_tool_paths,
+            expanded_paths = rule_runner.request(
+                _SearchPaths,
+                [
+                    _ExpandInterpreterSearchPathsRequest(
+                        paths,
+                        EnvironmentTarget(LocalEnvironmentTarget({}, Address("flem"))),
+                    )
+                ],
             )
 
     expected = (
@@ -204,7 +228,7 @@ def test_expand_interpreter_search_paths() -> None:
         *expected_pyenv_local_paths,
         "/qux",
     )
-    assert expected == expanded_paths
+    assert expected == expanded_paths.paths
 
 
 @pytest.mark.parametrize(
diff --git a/src/python/pants/core/util_rules/asdf_test.py b/src/python/pants/core/util_rules/asdf_test.py
--- a/src/python/pants/core/util_rules/asdf_test.py
+++ b/src/python/pants/core/util_rules/asdf_test.py
@@ -6,8 +6,18 @@
 from pathlib import Path, PurePath
 from typing import Iterable, Mapping, Sequence, TypeVar
 
+import pytest
+
 from pants.core.util_rules import asdf
 from pants.core.util_rules.asdf import AsdfToolPathsRequest, AsdfToolPathsResult, get_asdf_data_dir
+from pants.core.util_rules.environments import (
+    DockerEnvironmentTarget,
+    DockerImageField,
+    EnvironmentTarget,
+    LocalEnvironmentTarget,
+    RemoteEnvironmentTarget,
+)
+from pants.engine.addresses import Address
 from pants.engine.env_vars import CompleteEnvironmentVars, EnvironmentVars
 from pants.engine.rules import QueryRule
 from pants.testutil.rule_runner import RuleRunner
@@ -79,11 +89,11 @@ def test_get_asdf_dir() -> None:
 
 def get_asdf_paths(
     rule_runner: RuleRunner,
+    env_tgt: EnvironmentTarget,
     env: Mapping[str, str],
     *,
     standard: bool,
     local: bool,
-    extra_env_var_names: Iterable[str] = (),
 ) -> AsdfToolPathsResult:
     rule_runner.set_session_values(
         {
@@ -94,18 +104,33 @@ def get_asdf_paths(
         AsdfToolPathsResult,
         [
             AsdfToolPathsRequest(
+                env_tgt=env_tgt,
                 tool_name="python",
                 tool_description="<test>",
                 resolve_standard=standard,
                 resolve_local=local,
-                extra_env_var_names=tuple(extra_env_var_names),
                 paths_option_name="<test>",
             )
         ],
     )
 
 
-def test_get_asdf_paths() -> None:
+@pytest.mark.parametrize(
+    ("env_tgt_type", "should_have_values"),
+    (
+        (LocalEnvironmentTarget, True),
+        (None, True),
+        (DockerEnvironmentTarget, False),
+        (RemoteEnvironmentTarget, False),
+    ),
+)
+def test_get_asdf_paths(
+    env_tgt_type: type[LocalEnvironmentTarget]
+    | type[DockerEnvironmentTarget]
+    | type[RemoteEnvironmentTarget]
+    | None,
+    should_have_values: bool,
+) -> None:
     # 3.9.4 is intentionally "left out" so that it's only found if the "all installs" fallback is
     # used
     all_python_versions = ["2.7.14", "3.5.5", "3.7.10", "3.9.4", "3.9.5"]
@@ -142,21 +167,41 @@ def test_get_asdf_paths() -> None:
         expected_asdf_home_paths,
         expected_asdf_local_paths,
     ):
+
+        extra_kwargs: dict = {}
+        if env_tgt_type is DockerEnvironmentTarget:
+            extra_kwargs = {
+                DockerImageField.alias: "my_img",
+            }
+        env_tgt = EnvironmentTarget(
+            env_tgt_type(extra_kwargs, Address("flem")) if env_tgt_type is not None else None
+        )
+
         # Check the "all installed" fallback
         result = get_asdf_paths(
-            rule_runner, {"ASDF_DATA_DIR": asdf_dir}, standard=True, local=False
+            rule_runner, env_tgt, {"ASDF_DATA_DIR": asdf_dir}, standard=True, local=False
         )
         all_paths = result.standard_tool_paths
 
         result = get_asdf_paths(
-            rule_runner, {"HOME": home_dir, "ASDF_DATA_DIR": asdf_dir}, standard=True, local=True
+            rule_runner,
+            env_tgt,
+            {"HOME": home_dir, "ASDF_DATA_DIR": asdf_dir},
+            standard=True,
+            local=True,
         )
         home_paths = result.standard_tool_paths
         local_paths = result.local_tool_paths
 
-        # The order the filesystem returns the "installed" folders is arbitrary
-        assert set(expected_asdf_paths) == set(all_paths)
-
-        # These have a fixed order defined by the `.tool-versions` file
-        assert expected_asdf_home_paths == home_paths
-        assert expected_asdf_local_paths == local_paths
+        if should_have_values:
+            # The order the filesystem returns the "installed" folders is arbitrary
+            assert set(expected_asdf_paths) == set(all_paths)
+
+            # These have a fixed order defined by the `.tool-versions` file
+            assert expected_asdf_home_paths == home_paths
+            assert expected_asdf_local_paths == local_paths
+        else:
+            # asdf bails quickly on non-local environments
+            assert () == all_paths
+            assert () == home_paths
+            assert () == local_paths