From ab2156bd16d0e1d637b2bacfefea751d16e2ea38 Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 17 Aug 2026 23:01:03 +0000 Subject: [PATCH 1/2] fix: do not mount a cluster credential into the GKE code sandbox (v1) Ports upstream commit 82078801 to the v1 branch. The pod that GkeCodeExecutor creates to run model-generated code did not set automount_service_account_token, and Kubernetes defaults it to true, so the pod received a token for the namespace's default ServiceAccount. Code executed in the sandbox could use it to call the cluster's API server. The pod spec now sets automount_service_account_token=False, so no token is projected into the pod. Behaviour change: executed code that today calls the Kubernetes API from inside the cluster loses that access. Nothing in ADK does this. --- src/google/adk/code_executors/gke_code_executor.py | 3 +++ tests/unittests/code_executors/test_gke_code_executor.py | 1 + 2 files changed, 4 insertions(+) diff --git a/src/google/adk/code_executors/gke_code_executor.py b/src/google/adk/code_executors/gke_code_executor.py index 3336eed6d94..67f7c904d31 100644 --- a/src/google/adk/code_executors/gke_code_executor.py +++ b/src/google/adk/code_executors/gke_code_executor.py @@ -295,6 +295,9 @@ def _create_job_manifest( # Use tolerations to request a gVisor node. pod_spec = k8s.client.V1PodSpec( restart_policy="Never", + # The pod runs model-generated code, so it must not receive a + # credential for the cluster it is running in. + automount_service_account_token=False, containers=[container], volumes=[ k8s.client.V1Volume( diff --git a/tests/unittests/code_executors/test_gke_code_executor.py b/tests/unittests/code_executors/test_gke_code_executor.py index 300780ca40f..36586843a31 100644 --- a/tests/unittests/code_executors/test_gke_code_executor.py +++ b/tests/unittests/code_executors/test_gke_code_executor.py @@ -260,6 +260,7 @@ def test_create_job_manifest_structure(self, mock_invocation_context): # Check pod template properties pod_spec = job.spec.template.spec assert pod_spec.restart_policy == "Never" + assert pod_spec.automount_service_account_token is False assert pod_spec.runtime_class_name == "gvisor" assert len(pod_spec.tolerations) == 1 assert pod_spec.tolerations[0].value == "gvisor" From 656462f019f520b85344ffb2c6e2d2844f0839cb Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 17 Aug 2026 23:03:44 +0000 Subject: [PATCH 2/2] fix: harden ContainerCodeExecutor sandbox by default (v1) Ports upstream commit 0a9ce0f6 to the v1 branch. ContainerCodeExecutor started its container with default Docker networking and no capability restrictions, so the model-generated code it runs could reach the network, acquire Linux capabilities, and gain privileges through setuid binaries. Every other ADK code executor is isolated: GkeCodeExecutor runs under gVisor with all capabilities dropped, and the Vertex AI and Agent Engine executors run in managed server-side sandboxes. The container now starts with networking disabled, all Linux capabilities dropped, and no-new-privileges set. A new network_enabled field re-enables networking when the executed code is trusted. Breaking change: executed code no longer has network access by default, so anything that makes an HTTP request, resolves DNS, or installs a package fails until the executor is constructed as ContainerCodeExecutor(..., network_enabled=True). Dropping capabilities and setting no-new-privileges do not affect ordinary Python execution. Also adds docker>=7 to the test extra so that extra installs on its own. --- pyproject.toml | 1 + .../code_executors/container_code_executor.py | 30 ++++++++++ .../test_container_code_executor.py | 58 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 tests/unittests/code_executors/test_container_code_executor.py diff --git a/pyproject.toml b/pyproject.toml index 951f86a64f6..e8f5adf264c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,6 +139,7 @@ optional-dependencies.test = [ "a2a-sdk>=0.3,<0.4", "anthropic>=0.78", # For anthropic model tests; 0.78 introduced ThinkingConfigAdaptiveParam (required for Claude Opus 4.7). "crewai[tools]; python_version>='3.11' and python_version<'3.12'", # For CrewaiTool tests; chromadb/pypika fail on 3.12+ + "docker>=7", # For ContainerCodeExecutor tests "google-cloud-firestore>=2.11,<3", "google-cloud-iamconnectorcredentials>=0.1,<0.2", "google-cloud-parametermanager>=0.4,<1", diff --git a/src/google/adk/code_executors/container_code_executor.py b/src/google/adk/code_executors/container_code_executor.py index d6a78d4d263..671c33ac34e 100644 --- a/src/google/adk/code_executors/container_code_executor.py +++ b/src/google/adk/code_executors/container_code_executor.py @@ -37,6 +37,15 @@ class ContainerCodeExecutor(BaseCodeExecutor): """A code executor that uses a custom container to execute code. + Security note: this executor runs model-generated code, which may be + influenced by untrusted input (e.g. via prompt injection). By default the + container is started with networking disabled and all Linux capabilities + dropped so that the executed code cannot reach the network (including the + cloud metadata endpoint at ``169.254.169.254``) or escalate privileges. For + stronger, kernel-level isolation of untrusted code prefer + ``GkeCodeExecutor`` (gVisor) or a managed executor + (``VertexAiCodeExecutor`` / ``AgentEngineSandboxCodeExecutor``). + Attributes: base_url: Optional. The base url of the user hosted Docker client. image: The tag of the predefined image or custom image to run on the @@ -44,6 +53,9 @@ class ContainerCodeExecutor(BaseCodeExecutor): docker_path: The path to the directory containing the Dockerfile. If set, build the image from the dockerfile path instead of using the predefined image. Either docker_path or image must be set. + network_enabled: Whether to start the container with networking enabled. + Defaults to False. Set to True only if the executed code must make network + requests and you trust it. """ base_url: Optional[str] = None @@ -64,6 +76,17 @@ class ContainerCodeExecutor(BaseCodeExecutor): predefined image. Either docker_path or image must be set. """ + network_enabled: bool = False + """ + Whether to start the code execution container with networking enabled. + + Defaults to False so that untrusted, model-generated code cannot reach the + network -- in particular the cloud metadata endpoint at 169.254.169.254 + (which can yield the host's service-account credentials), internal services, + or arbitrary exfiltration destinations. Set to True only if the executed + code must make network requests and you trust it. + """ + # Overrides the BaseCodeExecutor attribute: this executor cannot be stateful. stateful: bool = Field(default=False, frozen=True, exclude=True) @@ -183,6 +206,13 @@ def __init_container(self): image=self.image, detach=True, tty=True, + # Harden the sandbox for untrusted, model-generated code: no network + # (blocks metadata/SSRF/exfil), drop all Linux capabilities, and + # forbid privilege escalation. Networking can be re-enabled via + # `network_enabled=True` when the executed code is trusted. + network_disabled=not self.network_enabled, + cap_drop=['ALL'], + security_opt=['no-new-privileges'], ) logger.info('Container %s started.', self._container.id) diff --git a/tests/unittests/code_executors/test_container_code_executor.py b/tests/unittests/code_executors/test_container_code_executor.py new file mode 100644 index 00000000000..5574135b52d --- /dev/null +++ b/tests/unittests/code_executors/test_container_code_executor.py @@ -0,0 +1,58 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the ContainerCodeExecutor container hardening defaults.""" + +from unittest import mock + +from google.adk.code_executors.container_code_executor import ContainerCodeExecutor + + +def _mock_docker_client(): + """Returns a mock Docker client whose container passes python verification.""" + client = mock.MagicMock() + container = mock.MagicMock() + # `_verify_python_installation` runs `exec_run(['which', 'python3'])` and + # checks `exit_code == 0`. + container.exec_run.return_value = mock.MagicMock(exit_code=0) + client.containers.run.return_value = container + return client + + +@mock.patch('google.adk.code_executors.container_code_executor.docker') +def test_container_is_hardened_by_default(mock_docker): + """Networking is disabled and privileges are dropped by default.""" + client = _mock_docker_client() + mock_docker.from_env.return_value = client + + ContainerCodeExecutor(image='test-image') + + _, kwargs = client.containers.run.call_args + # Untrusted model-generated code must not be able to reach the network + # (e.g. the cloud metadata endpoint) or escalate privileges by default. + assert kwargs['network_disabled'] + assert kwargs['cap_drop'] == ['ALL'] + assert kwargs['security_opt'] == ['no-new-privileges'] + + +@mock.patch('google.adk.code_executors.container_code_executor.docker') +def test_container_network_can_be_explicitly_enabled(mock_docker): + """Networking is left enabled when the caller opts in.""" + client = _mock_docker_client() + mock_docker.from_env.return_value = client + + ContainerCodeExecutor(image='test-image', network_enabled=True) + + _, kwargs = client.containers.run.call_args + assert not kwargs['network_disabled']