Repository: SFDO-Tooling/CumulusCI
Pull request #3345 (2022-08-26 01:26:29)

CCI projects can now run tasks from their "sources" even when they are custom tasks coded in Python
If a flow uses tasks that are implemented in custom Python code
then you may instruct CumulusCI to allow that code to run:

```yaml
sources:
    eda:
        github: https://github.com/SalesforceFoundation/EDA
        allow_remote_code: True
```



davidmreed: Two thoughts:

1. We need to carefully evaluate the changes to security posture we are introducing here. We might want to introduce new markup on `source` to indicate whether or not the remote repo is allowed to execute custom Python.
2. We need to do E2E tests with MetaDeploy and Metecho to ensure that the web apps are fully compatible.
prescod: @davidmreed . Yes, you're right. At this point this is considered a "spike" and your idea about having an opt-in or opt-out is good.
yippie: Is there an ETA on this? This is a super important feature to be able to manage our projects in any kind of reasonable way.
prescod: > Is there an ETA on this? This is a super important feature to be able to manage our projects in any kind of reasonable way.

I will check with the team doing the review.

Patch:
diff --git a/cumulusci.yml b/cumulusci.yml
index 0d8a653b9..b6371b8f6 100644
--- a/cumulusci.yml
+++ b/cumulusci.yml
@@ -8,6 +8,12 @@ sources:
     ccitest:
         github: https://github.com/SFDO-Tooling/CumulusCI-Test
         branch: main
+        allow_remote_code: True
+    local_fake:
+        path: cumulusci/core/tests/fake_remote_repo
+        allow_remote_code: True
+    disallowed_repo:
+        path: cumulusci/core/tests/untrusted_repo_parent
 
     omni:
         github: https://github.com/SalesforceFoundation/omni-cci
@@ -164,6 +170,20 @@ flows:
             1:
                 task: None
 
+    test_cross_project_custom_tasks:
+        steps:
+            1:
+                task: ccitest:example_task
+
+            2:
+                task: local_fake:example_task
+
+            3:
+                task: local_fake:example_task_from_subdirectory
+
+            4:
+                flow: local_fake:run_child_tasks
+
     test_upserts_snowfakery:
         steps:
             1:
diff --git a/cumulusci/cli/task.py b/cumulusci/cli/task.py
index 1c06906e1..cfbe749b9 100644
--- a/cumulusci/cli/task.py
+++ b/cumulusci/cli/task.py
@@ -7,7 +7,6 @@ from rst2ansi import rst2ansi
 
 from cumulusci.core.config import TaskConfig
 from cumulusci.core.exceptions import CumulusCIUsageError
-from cumulusci.core.utils import import_global
 from cumulusci.utils import doc_task
 
 from .runtime import pass_runtime
@@ -144,7 +143,7 @@ class RunTaskCommand(click.MultiCommand):
         if "options" not in task_config.config:
             task_config.config["options"] = {}
 
-        task_class = import_global(task_config.class_path)
+        task_class = task_config.get_class()
         task_options = task_class.task_options
 
         params = self._get_default_command_options(task_class.salesforce_task)
diff --git a/cumulusci/core/config/__init__.py b/cumulusci/core/config/__init__.py
index 6146269ec..9060608ba 100644
--- a/cumulusci/core/config/__init__.py
+++ b/cumulusci/core/config/__init__.py
@@ -4,6 +4,8 @@
 FAILED_TO_CREATE_SCRATCH_ORG = "Failed to create scratch org"
 
 from cumulusci.core.config.base_config import BaseConfig
+from cumulusci.core.exceptions import TaskImportError
+from cumulusci.core.utils import import_global
 
 
 class ConnectedAppOAuthConfig(BaseConfig):
@@ -66,7 +68,30 @@ class TaskConfig(BaseConfig):
     checks: list
     project_config: "BaseProjectConfig"
 
-    pass
+    # TODO: What if an intermediate repo "allows" a downstream repo?
+    #       Only the top repo should be allowed to do so.
+
+    def get_class(self):
+        try:
+            return import_global(self.class_path)
+        except ModuleNotFoundError as e:
+            message = "Cannot load Python class for task:\n" + str(e)
+            if not self.source.allow_remote_code:
+                message += "\n".join(
+                    (
+                        "",
+                        str(self.source),
+                        "is not an approved source for running third party Python code.",
+                        "If this task is custom Python, that would explain the problem.",
+                        "Otherwise, it might just be a mistyped `class_name`.",
+                        "More info: https://cumulusci.readthedocs.io/en/stable/config.html?highlight=sources#tasks-and-flows-from-a-different-project",
+                    )
+                )
+            raise TaskImportError(message) from e
+
+    @property
+    def source(self):
+        return self.project_config.source
 
 
 from cumulusci.core.config.base_task_flow_config import BaseTaskFlowConfig
diff --git a/cumulusci/core/config/base_task_flow_config.py b/cumulusci/core/config/base_task_flow_config.py
index 0facdc8c1..be2ac6b34 100644
--- a/cumulusci/core/config/base_task_flow_config.py
+++ b/cumulusci/core/config/base_task_flow_config.py
@@ -42,6 +42,10 @@ class BaseTaskFlowConfig(BaseConfig):
             # task does not exist
             error_msg = f"Task not found: {name}"
             suggestion = self.get_suggested_name(name, self.tasks)
+            if self.source and self.source.location:
+                suggestion += "\n"
+                suggestion += "Source of the problem:"
+                suggestion += str(self.source.location)
             raise TaskNotFoundError(error_msg + suggestion)
         elif not config:
             # task exists but there is no config at all
diff --git a/cumulusci/core/config/project_config.py b/cumulusci/core/config/project_config.py
index 2b4cf2bc2..76b88ba64 100644
--- a/cumulusci/core/config/project_config.py
+++ b/cumulusci/core/config/project_config.py
@@ -2,6 +2,8 @@ import json
 import os
 import pathlib
 import re
+import sys
+import types
 from configparser import ConfigParser
 from contextlib import contextmanager
 from io import StringIO
@@ -13,6 +15,7 @@ from github3 import GitHub
 from github3.repos.repo import Repository
 
 from cumulusci.core.config.base_config import BaseConfig
+from cumulusci.core.debug import get_debug_mode
 from cumulusci.core.versions import PackageVersionNumber
 from cumulusci.utils.version_strings import LooseVersion
 
@@ -46,6 +49,12 @@ from cumulusci.utils.yaml.cumulusci_yml import (
     cci_safe_load,
 )
 
+sys.modules.setdefault(
+    "tasks", types.ModuleType("tasks", "Synthetic package for all repo tasks")
+)
+import tasks
+
+tasks.__path__ = []
 if TYPE_CHECKING:
     from cumulusci.core.config.universal_config import UniversalConfig
     from cumulusci.core.keychain.base_project_keychain import BaseProjectKeychain
@@ -523,6 +532,10 @@ class BaseProjectConfig(BaseTaskFlowConfig, ProjectConfigPropertiesMixin):
             config = json.load(f)
         return config
 
+    @property
+    def allow_remote_code(self) -> bool:
+        return self.source.allow_remote_code
+
     def get_tag_for_version(self, prefix: str, version: str) -> str:
         """Given a prefix and version, returns the appropriate tag name to use."""
         try:
@@ -641,8 +654,29 @@ class BaseProjectConfig(BaseTaskFlowConfig, ProjectConfigPropertiesMixin):
             project_config.source = source
             self.included_sources[spec] = project_config
 
+            # If I can't load remote code, make sure that my
+            # included repos can't either.
+            if not self.allow_remote_code:
+                spec.allow_remote_code = False
+            else:
+                project_config._add_tasks_directory_to_python_path()
+
         return project_config
 
+    def _add_tasks_directory_to_python_path(self):
+        # https://stackoverflow.com/a/2700924/113477
+        if not self.allow_remote_code:
+            return False
+
+        directory = str(Path(self.repo_root) / "tasks")
+        if directory not in tasks.__path__:
+            self.logger.debug(f"Adding {directory} to tasks.__path__")
+            tasks.__path__.append(directory)
+        if get_debug_mode():
+            self.logger.debug(
+                f"After importing {self.source.spec}:  tasks.__path__ {tasks.__path__}"
+            )
+
     def construct_subproject_config(self, **kwargs) -> "BaseProjectConfig":
         """Construct another project config for an external source"""
         return self.__class__(
diff --git a/cumulusci/core/exceptions.py b/cumulusci/core/exceptions.py
index 442f83fa8..4d8d66f61 100644
--- a/cumulusci/core/exceptions.py
+++ b/cumulusci/core/exceptions.py
@@ -134,6 +134,12 @@ class TaskOptionsError(CumulusCIUsageError):
     pass
 
 
+class TaskImportError(CumulusCIException):
+    """Raise when a task's class can't be loaded"""
+
+    pass
+
+
 class NamespaceNotFoundError(CumulusCIUsageError):
     """Raise when namespace is not found in project includes"""
 
diff --git a/cumulusci/core/flowrunner.py b/cumulusci/core/flowrunner.py
index a8c5b9751..098e84ba4 100644
--- a/cumulusci/core/flowrunner.py
+++ b/cumulusci/core/flowrunner.py
@@ -73,8 +73,11 @@ from jinja2.sandbox import ImmutableSandboxedEnvironment
 from cumulusci.core.config import FlowConfig, TaskConfig
 from cumulusci.core.config.org_config import OrgConfig
 from cumulusci.core.config.project_config import BaseProjectConfig
-from cumulusci.core.exceptions import FlowConfigError, FlowInfiniteLoopError
-from cumulusci.core.utils import import_global
+from cumulusci.core.exceptions import (
+    FlowConfigError,
+    FlowInfiniteLoopError,
+    TaskImportError,
+)
 from cumulusci.utils.version_strings import LooseVersion
 
 if TYPE_CHECKING:
@@ -263,10 +266,13 @@ class TaskRunner:
         # Resolve ^^task_name.return_value style option syntax
         task_config = self.step.task_config.copy()
         task_config["options"] = task_config.get("options", {}).copy()
+        assert self.flow
         self.flow.resolve_return_value_options(task_config["options"])
 
         task_config["options"].update(options)
 
+        assert self.step.task_class
+
         task = self.step.task_class(
             self.step.project_config,
             TaskConfig(task_config),
@@ -647,9 +653,9 @@ class FlowCoordinator:
 
             # get implementation class. raise/fail if it doesn't exist, because why continue
             try:
-                task_class = import_global(task_config_dict["class_path"])
-            except (ImportError, AttributeError):
-                raise FlowConfigError(f"Task named {name} has bad classpath")
+                task_class = task_config.get_class()
+            except (ImportError, AttributeError, TaskImportError) as e:
+                raise FlowConfigError(f"Task named {name} has bad classpath, {e}")
 
             visited_steps.append(
                 StepSpec(
@@ -859,7 +865,9 @@ class CachedTaskRunner:
             return self.cache.results[cache_key].return_values
 
         task_config = self.cache.project_config.tasks[self.task_name]
-        task_class = import_global(task_config["class_path"])
+        task_class = TaskConfig(
+            {**task_config, "project_config": self.cache.project_config}
+        ).get_class()
         step = StepSpec(
             step_num=StepVersion("1"),
             task_name=self.task_name,
diff --git a/cumulusci/core/source/__init__.py b/cumulusci/core/source/__init__.py
index 50f26d526..292ced547 100644
--- a/cumulusci/core/source/__init__.py
+++ b/cumulusci/core/source/__init__.py
@@ -3,7 +3,11 @@ from .local_folder import LocalFolderSource
 
 
 class NullSource:
+    """Source for the root project."""
+
     frozenspec = None
+    allow_remote_code = True
+    location = None
 
     def __str__(self):
         return "current folder"
diff --git a/cumulusci/core/source/github.py b/cumulusci/core/source/github.py
index 0b41623a7..f2f7cab2a 100644
--- a/cumulusci/core/source/github.py
+++ b/cumulusci/core/source/github.py
@@ -21,6 +21,7 @@ class GitHubSource:
         self.project_config = project_config
         self.spec = spec
         self.url = spec.github
+        self.location = self.url
         if self.url.endswith(".git"):
             self.url = self.url[:-4]
 
@@ -166,3 +167,7 @@ class GitHubSource:
             "commit": self.commit,
             "description": self.description,
         }
+
+    @property
+    def allow_remote_code(self) -> bool:
+        return self.spec.allow_remote_code
diff --git a/cumulusci/core/source/local_folder.py b/cumulusci/core/source/local_folder.py
index bed36e57e..530cfa665 100644
--- a/cumulusci/core/source/local_folder.py
+++ b/cumulusci/core/source/local_folder.py
@@ -8,6 +8,7 @@ class LocalFolderSource:
         self.project_config = project_config
         self.spec = spec
         self.path = spec.path
+        self.location = self.path
 
     def __repr__(self):
         return f"<LocalFolderSource {str(self)}>"
@@ -28,3 +29,7 @@ class LocalFolderSource:
     @property
     def frozenspec(self):
         raise NotImplementedError("Cannot construct frozenspec for local folder")
+
+    @property
+    def allow_remote_code(self) -> bool:
+        return bool(self.spec.allow_remote_code)
diff --git a/cumulusci/core/utils.py b/cumulusci/core/utils.py
index 896a48171..4a5eb3aa5 100644
--- a/cumulusci/core/utils.py
+++ b/cumulusci/core/utils.py
@@ -12,6 +12,7 @@ from pathlib import Path
 
 import pytz
 
+from cumulusci.core.debug import get_debug_mode
 from cumulusci.core.exceptions import (
     ConfigMergeError,
     CumulusCIException,
@@ -25,6 +26,15 @@ def import_global(path: str):
     module = components[:-1]
     module = ".".join(module)
     mod = __import__(module, fromlist=[str(components[-1])])
+
+    if get_debug_mode():
+        import sys
+
+        logger = getLogger(__file__)
+        logger.info(f"Importing {path}")
+        logger.info(f"Looking in sys.path {sys.path}")
+        if components[0] == "tasks":
+            logger.info(f"With tasks.__path__ {sys.modules['tasks'].__path__}")
     return getattr(mod, str(components[-1]))
 
 
diff --git a/cumulusci/schema/cumulusci.jsonschema.json b/cumulusci/schema/cumulusci.jsonschema.json
index e11e34f8f..7d53f2c25 100644
--- a/cumulusci/schema/cumulusci.jsonschema.json
+++ b/cumulusci/schema/cumulusci.jsonschema.json
@@ -588,6 +588,11 @@
                     "title": "Path",
                     "format": "directory-path",
                     "type": "string"
+                },
+                "allow_remote_code": {
+                    "title": "Allow Remote Code",
+                    "default": false,
+                    "type": "boolean"
                 }
             },
             "required": ["path"],
@@ -633,6 +638,11 @@
                 "description": {
                     "title": "Description",
                     "type": "string"
+                },
+                "allow_remote_code": {
+                    "title": "Allow Remote Code",
+                    "default": false,
+                    "type": "boolean"
                 }
             },
             "required": ["github"],
diff --git a/cumulusci/utils/yaml/cumulusci_yml.py b/cumulusci/utils/yaml/cumulusci_yml.py
index dd8739c05..a85cee77b 100644
--- a/cumulusci/utils/yaml/cumulusci_yml.py
+++ b/cumulusci/utils/yaml/cumulusci_yml.py
@@ -190,6 +190,7 @@ class GitHubSourceModel(HashableBaseModel):
     tag: Optional[str]
     release: Optional[GitHubSourceRelease]
     description: Optional[str]
+    allow_remote_code: Optional[bool] = False
 
     @root_validator
     def validate(cls, values):
@@ -214,6 +215,7 @@ class GitHubSourceModel(HashableBaseModel):
 
 class LocalFolderSourceModel(HashableBaseModel):
     path: DirectoryPath
+    allow_remote_code: Optional[bool] = False
 
 
 class CumulusCLIConfig(CCIDictModel):
diff --git a/docs/config.md b/docs/config.md
index 88145d343..119ed9482 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -120,7 +120,8 @@ tasks specific to your project when running `cci task list`.
 Congratulations! You created a new custom task in CumulusCI.
 
 If you've built a custom task in Python, you can make it available to
-the project by adding the task under the `tasks` section of the
+the project by adding the task to a `tasks` subdirectory in the repo
+and under the `tasks` section of the
 `cumulusci.yml` file. (Let's assume that your task's class is named
 `MyNewTaskClassName` and exists in the file `tasks/task_file.py`.)
 
@@ -132,6 +133,11 @@ tasks:
         group: projectName
 ```
 
+This task will be accessible directly to this project. It will also
+be accessible to any other project that adds this as a source
+(see [Tasks and Flows from a Different Project](tasks-and-flows-from-a-different-project)) with
+`allow_remote_code: True`.
+
 (use-variables-for-task-options)=
 
 ### Use Variables for Task Options
@@ -474,6 +480,17 @@ flows:
 This flow uses NPSP's `install_prod` flow to install NPSP as a managed
 package, and then run this project's own `dev_org` flow.
 
+If the flow uses tasks that are implemented in custom Python code
+(see [Add a Custom Task](#add-a-custom-task))
+then you must instruct CumulusCI to allow that code to run:
+
+```yaml
+sources:
+    eda:
+        github: https://github.com/SalesforceFoundation/EDA
+        allow_remote_code: True
+```
+
 (scratch-org-configurations)=
 
 ## Scratch Org Configurations
diff --git a/pyproject.toml b/pyproject.toml
index 8670cd71e..516075253 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -259,6 +259,7 @@ include = [
     'cumulusci/utils/iterators.py',
     'cumulusci/utils/parallel/task_worker_queues/parallel_worker.py',
     'cumulusci/utils/salesforce/count_sobjects.py',
+    'cumulusci/tests/util.py',
     'cumulusci/utils/waiting.py',
     'cumulusci/utils/xml/robot_xml.py',
     'cumulusci/utils/xml/salesforce_encoding.py',


Test patch:
diff --git a/.github/workflows/slow_integration_tests.yml b/.github/workflows/slow_integration_tests.yml
index 24808d92d..913befe1b 100644
--- a/.github/workflows/slow_integration_tests.yml
+++ b/.github/workflows/slow_integration_tests.yml
@@ -4,10 +4,20 @@ on:
     pull_request_review:
         types: [submitted]
 
+    workflow_call:
+        secrets:
+            CUMULUSCI_SERVICE_github:
+                required: true
+            CCITEST_APP_KEY:
+                required: true
+
     workflow_dispatch:
 
 env:
     CUMULUSCI_KEY: ${{ secrets.CUMULUSCI_KEY }}
+    CUMULUSCI_SERVICE_github: ${{ secrets.CUMULUSCI_SERVICE_github }}
+    GITHUB_APP_ID: 129383
+    GITHUB_APP_KEY: ${{ secrets.CCITEST_APP_KEY }}
 
 jobs:
     org_backed_tests:
@@ -58,9 +68,9 @@ jobs:
                     # - browser: "BROWSER:headlessfirefox"
                     #   job-name: "Firefox"
                     #   org-shape: "dev"
-                    - browser: "BROWSER:headlesschrome"
-                      job-name: "Pre-release"
-                      org-shape: "prerelease"
+                    # - browser: "BROWSER:headlesschrome"
+                    #   job-name: "Pre-release"
+                    #   org-shape: "prerelease"
         steps:
             - uses: actions/checkout@v2
             - name: Set up Python 3.8
diff --git a/cumulusci/conftest.py b/cumulusci/conftest.py
index cce385cde..708813052 100644
--- a/cumulusci/conftest.py
+++ b/cumulusci/conftest.py
@@ -1,6 +1,6 @@
 import io
 import os
-from contextlib import contextmanager
+from contextlib import contextmanager, nullcontext
 from http.client import HTTPMessage
 from logging import getLogger
 from pathlib import Path
@@ -96,10 +96,19 @@ create_task_fixture = fixture(create_task_fixture, scope="function")
 @pytest.fixture(autouse=True)
 def patch_home_and_env(request):
     "Patch the default home directory and $HOME environment for all tests at once."
-    with TemporaryDirectory(prefix="fake_home_") as home, mock_env(home):
-        Path(home, ".cumulusci").mkdir()
-        Path(home, ".cumulusci/cumulusci.yml").touch()
-        yield
+
+    use_real_env = request.node.get_closest_marker("use_real_env")
+
+    with TemporaryDirectory(prefix="fake_home_") as home:
+        if use_real_env:
+            mock_env_cm = nullcontext()
+        else:
+            mock_env_cm = mock_env(home)
+
+        with mock_env_cm:
+            Path(home, ".cumulusci").mkdir()
+            Path(home, ".cumulusci/cumulusci.yml").touch()
+            yield
 
 
 @pytest.fixture()
diff --git a/cumulusci/core/config/tests/test_config.py b/cumulusci/core/config/tests/test_config.py
index 74b2104bb..bd3e2bed1 100644
--- a/cumulusci/core/config/tests/test_config.py
+++ b/cumulusci/core/config/tests/test_config.py
@@ -682,6 +682,7 @@ class TestBaseProjectConfig:
     @mock.patch("cumulusci.core.config.project_config.GitHubSource")
     def test_include_source__github(self, source):
         source.return_value = expected_result = mock.Mock()
+        expected_result.fetch.return_value.repo_root = "/whatever"
         universal_config = UniversalConfig()
         project_config = BaseProjectConfig(universal_config)
         other_config = project_config.include_source(
diff --git a/cumulusci/core/tests/fake_remote_repo/cumulusci.yml b/cumulusci/core/tests/fake_remote_repo/cumulusci.yml
new file mode 100644
index 000000000..ea8d78651
--- /dev/null
+++ b/cumulusci/core/tests/fake_remote_repo/cumulusci.yml
@@ -0,0 +1,32 @@
+project:
+    name: CumulusCI-Test-Local-Fake
+
+sources:
+    child:
+        path: cumulusci/core/tests/fake_remote_repo_2
+        allow_remote_code: True
+
+tasks:
+    example_task:
+        description: Normal tasks meant to be used by parent tasks
+        class_path: tasks.example.ExampleTask
+
+    example_task_from_subdirectory:
+        description: A task from a sub-directory of this repo's tasks/ directory
+        class_path: tasks.directory.example_2.ExampleTask2
+
+    task_from_child_project:
+        description: A task from a nested "grand-child" (my direct child, root's grand-child)
+        class_path: tasks.example_3.ExampleTask3
+
+flows:
+    run_child_tasks:
+        steps:
+            1:
+                task: example_task_from_subdirectory
+
+            2:
+                task: child:example_task
+
+            3:
+                task: task_from_child_project
diff --git a/cumulusci/core/tests/fake_remote_repo/tasks/directory/example_2.py b/cumulusci/core/tests/fake_remote_repo/tasks/directory/example_2.py
new file mode 100644
index 000000000..d8d36ceac
--- /dev/null
+++ b/cumulusci/core/tests/fake_remote_repo/tasks/directory/example_2.py
@@ -0,0 +1,6 @@
+from cumulusci.core.tasks import BaseTask
+
+
+class ExampleTask2(BaseTask):
+    def _run_task(self):
+        self.logger.info("Called _run_task 2.")
diff --git a/cumulusci/core/tests/fake_remote_repo/tasks/example.py b/cumulusci/core/tests/fake_remote_repo/tasks/example.py
new file mode 100644
index 000000000..48b0bdc68
--- /dev/null
+++ b/cumulusci/core/tests/fake_remote_repo/tasks/example.py
@@ -0,0 +1,43 @@
+from cumulusci.core.tasks import BaseTask
+from cumulusci.tasks.util import Sleep
+
+
+class ExampleTask(BaseTask):
+    def _run_task(self):
+        self.logger.info("Called _run_task")
+
+
+class StaticPreflightTask(BaseTask):
+
+    task_options = {
+        "task_name": {
+            "description": "Task that this preflight is for",
+            "required": True,
+        },
+        "status_code": {
+            "description": 'Status code to return, default to "ok".',
+            "required": False,
+        },
+        "msg": {"description": "Message to be included", "required": False},
+    }
+
+    def _run_task(self):
+        self.return_values["status_code"] = self.options.get("status_code", "ok")
+        self.return_values["task_name"] = self.options.get("task_name")
+        self.return_values["msg"] = self.options.get("msg", "")
+
+
+class StaticSleep(Sleep):
+    task_options = dict(
+        **Sleep.task_options,
+        **{
+            "task_name": {
+                "description": "Task that this preflight is for",
+                "required": True,
+            },
+        }
+    )
+
+    def _run_task(self):
+        super()._run_task()
+        self.return_values["task_name"] = self.options.get("task_name")
diff --git a/cumulusci/core/tests/fake_remote_repo_2/cumulusci.yml b/cumulusci/core/tests/fake_remote_repo_2/cumulusci.yml
new file mode 100644
index 000000000..b06d2e06e
--- /dev/null
+++ b/cumulusci/core/tests/fake_remote_repo_2/cumulusci.yml
@@ -0,0 +1,11 @@
+project:
+    name: CumulusCI-Test-Local-Fake-2
+
+tasks:
+    example_task:
+        description: An example project-level task that does nothing
+        class_path: tasks.example_3.ExampleTask3
+
+    bad_classpath:
+        description: Task with a bad class_path
+        class_path: tasks.module.does.not.exist
diff --git a/cumulusci/core/tests/fake_remote_repo_2/tasks/example_3.py b/cumulusci/core/tests/fake_remote_repo_2/tasks/example_3.py
new file mode 100644
index 000000000..2d9d0e186
--- /dev/null
+++ b/cumulusci/core/tests/fake_remote_repo_2/tasks/example_3.py
@@ -0,0 +1,6 @@
+from cumulusci.core.tasks import BaseTask
+
+
+class ExampleTask3(BaseTask):
+    def _run_task(self):
+        self.logger.info("Called _run_task 3")
diff --git a/cumulusci/core/tests/test_flowrunner.py b/cumulusci/core/tests/test_flowrunner.py
index 65e3bb66c..40c9257ff 100644
--- a/cumulusci/core/tests/test_flowrunner.py
+++ b/cumulusci/core/tests/test_flowrunner.py
@@ -1,13 +1,18 @@
 import logging
+from pathlib import Path
 from unittest import mock
 
 import pytest
 
 import cumulusci
+from cumulusci.cli import cci
+from cumulusci.cli.tests.test_cci import MagicMock
 from cumulusci.core.config import FlowConfig, OrgConfig
+from cumulusci.core.config.project_config import BaseProjectConfig
 from cumulusci.core.exceptions import (
     FlowConfigError,
     FlowInfiniteLoopError,
+    TaskImportError,
     TaskNotFoundError,
 )
 from cumulusci.core.flowrunner import (
@@ -16,9 +21,11 @@ from cumulusci.core.flowrunner import (
     StepSpec,
     TaskRunner,
 )
+from cumulusci.core.source.local_folder import LocalFolderSource
 from cumulusci.core.tasks import BaseTask
 from cumulusci.core.tests.utils import MockLoggingHandler
 from cumulusci.tests.util import create_project_config
+from cumulusci.utils.yaml.cumulusci_yml import LocalFolderSourceModel
 
 ORG_ID = "00D000000000001"
 
@@ -747,3 +754,83 @@ def test_log_options__options_is_list__sensitive(task_runner, task_options_sensi
 
     task.logger.info.assert_any_call("  color:")
     task.logger.info.assert_any_call("    - ********")
+
+
+def include_fake_project(self: BaseProjectConfig, _spec) -> BaseProjectConfig:
+    # cumulusci/core/source/local_folder.py
+    source = LocalFolderSource(
+        self,
+        LocalFolderSourceModel(path=Path("cumulusci/core/tests/fake_remote_repo/")),
+    )
+    project = source.fetch()
+    return project
+
+
+# This grossness is inherited from `test_cci.py`...needs to be
+# fixed centrally!
+@mock.patch(
+    "cumulusci.cli.runtime.CliRuntime.get_org",
+    lambda *args, **kwargs: (MagicMock(), MagicMock()),
+)
+@mock.patch("cumulusci.core.runtime.BaseCumulusCI._load_keychain", MagicMock())
+@mock.patch("pdb.post_mortem", MagicMock())
+@mock.patch("cumulusci.cli.cci.tee_stdout_stderr", MagicMock())
+@mock.patch("cumulusci.cli.cci.init_logger", MagicMock())
+@mock.patch("cumulusci.cli.cci.get_tempfile_logger")
+def test_cross_project_tasks(get_tempfile_logger):
+    # get_tempfile_logger doesn't clean up after itself which breaks other tests
+    get_tempfile_logger.return_value = mock.Mock(), ""
+    with mock.patch("cumulusci.core.debug._DEBUG_MODE", get=lambda: True), mock.patch(
+        "logging.Logger.info", wraps=lambda data: print(data)
+    ) as out:
+        cci.main(
+            [
+                "cci",
+                "task",
+                "run",
+                "local_fake:example_task",
+            ]
+        )
+    assert "Called _run_task" in str(out.mock_calls)
+
+
+# TODO: Get these running in CI and remove opt-in label
+class TestCrossRepoFlow:
+    @pytest.mark.slow()
+    @pytest.mark.use_real_env()
+    def test_cross_project_tasks_2_repos_same_flow(self, capsys, org_config, runtime):
+        coordinator = runtime.get_flow("test_cross_project_custom_tasks", options=())
+        with mock.patch.object(coordinator, "logger"):
+            coordinator.run(org_config)
+            out = str(coordinator.logger.mock_calls)
+        assert "Called _run_task" in out, out
+        assert "Called _run_task 2" in out, out
+
+    @pytest.mark.slow()
+    @pytest.mark.use_real_env()
+    def test_cross_project_other_task(self, runtime):
+        def assert_task(task_name, class_name):
+            task_config = runtime.project_config.get_task(task_name)
+            task_class = task_config.get_class()
+            assert task_class.__name__ == class_name, (task_class.__name__, class_name)
+
+        assert_task("local_fake:example_task", "ExampleTask")
+
+        assert_task("local_fake:example_task_from_subdirectory", "ExampleTask2")
+
+        assert_task("local_fake:task_from_child_project", "ExampleTask3")
+
+        with pytest.raises(TaskNotFoundError, match="bad_classpath"):
+            assert_task("local_fake:bad_classpath", "FAILED")
+
+        with pytest.raises(TaskImportError, match="tasks.untrusted_parent"):
+            task_config = runtime.project_config.get_task(
+                "disallowed_repo:example_task"
+            )
+            task_config.get_class()
+
+        with pytest.raises(TaskImportError, match="tasks.untrusted_child"):
+            task_config = runtime.project_config.get_task(
+                "disallowed_repo:untrusted_child_task"
+            )
+            task_config.get_class()
diff --git a/cumulusci/core/tests/untrusted_repo_child/cumulusci.yml b/cumulusci/core/tests/untrusted_repo_child/cumulusci.yml
new file mode 100644
index 000000000..0b538345c
--- /dev/null
+++ b/cumulusci/core/tests/untrusted_repo_child/cumulusci.yml
@@ -0,0 +1,7 @@
+project:
+    name: Untrusted-Repo-Child
+
+tasks:
+    example_task:
+        description: A task that should be inaccessible to parent tasks
+        class_path: tasks.untrusted_child.ExampleTask
diff --git a/cumulusci/core/tests/untrusted_repo_child/tasks/untrusted_child.py b/cumulusci/core/tests/untrusted_repo_child/tasks/untrusted_child.py
new file mode 100644
index 000000000..922128f2a
--- /dev/null
+++ b/cumulusci/core/tests/untrusted_repo_child/tasks/untrusted_child.py
@@ -0,0 +1,6 @@
+from cumulusci.core.tasks import BaseTask
+
+
+class UntrustedChildExampleTask(BaseTask):
+    def _run_task(self):
+        raise AssertionError("This should not be callable (child)")
diff --git a/cumulusci/core/tests/untrusted_repo_parent/cumulusci.yml b/cumulusci/core/tests/untrusted_repo_parent/cumulusci.yml
new file mode 100644
index 000000000..84c7f6a4d
--- /dev/null
+++ b/cumulusci/core/tests/untrusted_repo_parent/cumulusci.yml
@@ -0,0 +1,26 @@
+project:
+    name: Untrusted-Repo-Parent
+
+sources:
+    # trusted by the parent but not the root cumulusci
+    untrusted_child:
+        path: cumulusci/core/tests/untrusted_repo_child
+        allow_remote_code: True
+
+tasks:
+    example_task:
+        description: An example project-level task that does nothing
+        class_path: tasks.untrusted_parent.ExampleTask
+
+    untrusted_child_task:
+        description: An example project-level task that does nothing
+        class_path: tasks.untrusted_child.ExampleTask
+
+flows:
+    untrusted:
+        steps:
+            1:
+                task: example_task
+
+            2:
+                task: untrusted_child:example_task
diff --git a/cumulusci/core/tests/untrusted_repo_parent/tasks/untrusted_parent.py b/cumulusci/core/tests/untrusted_repo_parent/tasks/untrusted_parent.py
new file mode 100644
index 000000000..1fd0e6d81
--- /dev/null
+++ b/cumulusci/core/tests/untrusted_repo_parent/tasks/untrusted_parent.py
@@ -0,0 +1,6 @@
+from cumulusci.core.tasks import BaseTask
+
+
+class UntrustedParentExampleTask(BaseTask):
+    def _run_task(self):
+        raise AssertionError("This should not be callable! (parent)")
diff --git a/cumulusci/tests/util.py b/cumulusci/tests/util.py
index a16294be7..11abfe6e2 100644
--- a/cumulusci/tests/util.py
+++ b/cumulusci/tests/util.py
@@ -8,6 +8,7 @@ import tracemalloc
 from contextlib import contextmanager, nullcontext
 from functools import lru_cache
 from pathlib import Path
+from typing import Optional
 from unittest import mock
 
 import responses
@@ -260,26 +261,35 @@ def mock_salesforce_client(task, *, is_person_accounts_enabled=False):
 
 
 @contextmanager
-def mock_env(home, cumulusci_key="0123456789ABCDEF"):
+def mock_env(
+    home,
+    cumulusci_key: Optional[str] = None,
+):
+    cumulusci_key = cumulusci_key if cumulusci_key else "0123456789ABCDEF"
     real_homedir = str(Path.home())
-    patches = {
+    new_environment = {
         "HOME": home,
         "USERPROFILE": home,
         "REAL_HOME": real_homedir,
         "CUMULUSCI_SYSTEM_CERTS": "True",
+        "PATH": os.environ["PATH"],
     }
+    new_environment.update(os.environ)
+    if pythonpath := os.environ.get("PYTHONPATH"):
+        new_environment["PYTHONPATH"] = pythonpath
+
+    # among other things, this will hide CUMULUSCI_KEY, GITHUB_APP_ID
+    # and CUMULUSCI_SERVICE_github
+    #
+    # Everything else is left as-is to leave stuff like PATH, PYTHONPATH
+    # and at least one more that seems to cause Windows to fail.
+    for key in tuple(new_environment.keys()):
+        if "CUMULUSCI_" in key or "GITHUB_" in key:
+            del key
 
     with mock.patch("pathlib.Path.home", lambda: Path(home)), mock.patch.dict(
-        os.environ, patches
+        os.environ, new_environment, clear=True
     ):
-        # don't use the real CUMULUSCI_KEY for tests
-        if "CUMULUSCI_KEY" in os.environ:
-            del os.environ["CUMULUSCI_KEY"]
-        if cumulusci_key is not None:
-            # do use a fake one, if it was supplied
-            os.environ["REAL_CUMULUSCI_KEY"] = os.environ.get("CUMULUSCI_KEY", "")
-            os.environ["CUMULUSCI_KEY"] = cumulusci_key
-
         yield