diff --git a/core/src/main/java/com/google/adk/codeexecutors/ContainerCodeExecutor.java b/core/src/main/java/com/google/adk/codeexecutors/ContainerCodeExecutor.java index 4e75dab75..8c4d6f553 100644 --- a/core/src/main/java/com/google/adk/codeexecutors/ContainerCodeExecutor.java +++ b/core/src/main/java/com/google/adk/codeexecutors/ContainerCodeExecutor.java @@ -1,9 +1,8 @@ /* - * Copyright 2025 Google LLC + * 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 not in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 @@ -20,33 +19,69 @@ import static java.util.Objects.requireNonNullElse; import com.github.dockerjava.api.DockerClient; -import com.github.dockerjava.api.command.ExecCreateCmdResponse; -import com.github.dockerjava.api.model.Container; +import com.github.dockerjava.api.command.CreateContainerResponse; +import com.github.dockerjava.api.model.Capability; +import com.github.dockerjava.api.model.Frame; +import com.github.dockerjava.api.model.HostConfig; +import com.github.dockerjava.api.model.StreamType; import com.github.dockerjava.core.DefaultDockerClientConfig; import com.github.dockerjava.core.DockerClientBuilder; -import com.github.dockerjava.core.command.ExecStartResultCallback; +import com.github.dockerjava.core.command.AttachContainerResultCallback; import com.google.adk.agents.InvocationContext; import com.google.adk.codeexecutors.CodeExecutionUtils.CodeExecutionInput; import com.google.adk.codeexecutors.CodeExecutionUtils.CodeExecutionResult; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.nio.file.Paths; +import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** A code executor that uses a custom container to execute code. */ -public class ContainerCodeExecutor extends BaseCodeExecutor { +/** + * A code executor that runs code in a sandboxed Docker container. + * + *

A fresh container is created for every {@link #executeCode} call so code from one session + * cannot observe or affect another session's execution environment. Each container is hardened: no + * network (so the code cannot reach the cloud metadata endpoint), all Linux capabilities dropped, + * no privilege escalation, a read-only root filesystem with a small writable {@code /tmp} tmpfs, + * and memory/PID limits. The code runs as the container's main process wrapped in an in-container + * {@code timeout}, and the container is set to auto-remove on exit; so even if this JVM dies, the + * OS kills the code and Docker removes the container rather than leaving a resource-draining + * "zombie". + * + *

This executor holds a {@link DockerClient}; call {@link #close()} (or rely on the registered + * JVM shutdown hook) to release its connections and threads. + */ +public class ContainerCodeExecutor extends BaseCodeExecutor implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(ContainerCodeExecutor.class); private static final String DEFAULT_IMAGE_TAG = "adk-code-executor:latest"; + /** Memory limit for each execution container (512 MiB). */ + private static final long MEMORY_LIMIT_BYTES = 512L * 1024 * 1024; + + /** Maximum number of processes/threads allowed inside an execution container. */ + private static final long PIDS_LIMIT = 128L; + + /** Maximum wall-clock time a single execution may run before its container is killed. */ + private static final long EXECUTION_TIMEOUT_SECONDS = 60L; + + /** + * Extra time to wait for the container to exit after its in-container timeout should have fired. + */ + private static final long COMPLETION_GRACE_SECONDS = 10L; + private final String baseUrl; private final String image; private final String dockerPath; private final DockerClient dockerClient; - private Container container; + private boolean networkEnabled = false; + private long executionTimeoutSeconds = EXECUTION_TIMEOUT_SECONDS; /** * Creates a ContainerCodeExecutor from an image. @@ -100,17 +135,33 @@ public ContainerCodeExecutor(String baseUrl, String image, String dockerPath) { this.baseUrl = baseUrl; this.image = requireNonNullElse(image, DEFAULT_IMAGE_TAG); this.dockerPath = dockerPath == null ? null : Paths.get(dockerPath).toAbsolutePath().toString(); + this.dockerClient = buildDockerClient(baseUrl); + prepareImage(); + // Backstop so the client is released even if callers forget to close() this executor. + Runtime.getRuntime().addShutdownHook(new Thread(this::close)); + } - if (baseUrl != null) { - var config = - DefaultDockerClientConfig.createDefaultConfigBuilder().withDockerHost(baseUrl).build(); - this.dockerClient = DockerClientBuilder.getInstance(config).build(); - } else { - this.dockerClient = DockerClientBuilder.getInstance().build(); - } + /** Test-only constructor that injects a Docker client and skips image preparation. */ + @VisibleForTesting + ContainerCodeExecutor(DockerClient dockerClient, String image) { + this.baseUrl = null; + this.image = requireNonNullElse(image, DEFAULT_IMAGE_TAG); + this.dockerPath = null; + this.dockerClient = dockerClient; + } + + /** + * Enables or disables container networking. Networking is disabled by default so executed code + * cannot reach the network, including the cloud metadata endpoint. + */ + public ContainerCodeExecutor setNetworkEnabled(boolean networkEnabled) { + this.networkEnabled = networkEnabled; + return this; + } - initContainer(); - Runtime.getRuntime().addShutdownHook(new Thread(this::cleanupContainer)); + @VisibleForTesting + void setExecutionTimeoutSeconds(long executionTimeoutSeconds) { + this.executionTimeoutSeconds = executionTimeoutSeconds; } @Override @@ -129,67 +180,107 @@ public CodeExecutionResult executeCode( ByteArrayOutputStream stdout = new ByteArrayOutputStream(); ByteArrayOutputStream stderr = new ByteArrayOutputStream(); - ExecCreateCmdResponse execCreateCmdResponse = + // Run the code as the container's own main process, wrapped in an in-container `timeout` so the + // OS terminates a runaway even if this JVM dies. A fresh, auto-removing container per execution + // isolates each run and guarantees cleanup. + CreateContainerResponse createContainerResponse = dockerClient - .execCreateCmd(container.getId()) - .withAttachStdout(true) - .withAttachStderr(true) - .withCmd("python3", "-c", codeExecutionInput.code()) + .createContainerCmd(image) + .withHostConfig(sandboxHostConfig()) + .withCmd( + "timeout", + "-k", + "5", + Long.toString(executionTimeoutSeconds), + "python3", + "-c", + codeExecutionInput.code()) .exec(); + String containerId = createContainerResponse.getId(); try { - dockerClient - .execStartCmd(execCreateCmdResponse.getId()) - .exec(new ExecStartResultCallback(stdout, stderr)) - .awaitCompletion(); + // Attach before starting so all output is captured before auto-remove reaps the container. + AttachContainerResultCallback attachCallback = + dockerClient + .attachContainerCmd(containerId) + .withStdOut(true) + .withStdErr(true) + .withFollowStream(true) + .exec(new FrameCapturingCallback(stdout, stderr)); + dockerClient.startContainerCmd(containerId).exec(); + + boolean completed = + attachCallback.awaitCompletion( + executionTimeoutSeconds + COMPLETION_GRACE_SECONDS, TimeUnit.SECONDS); + if (!completed) { + // The in-container timeout should have exited the container already; force-remove it below. + return CodeExecutionResult.builder() + .stderr( + String.format( + "Code execution timed out after %d seconds.", executionTimeoutSeconds)) + .build(); + } + return CodeExecutionResult.builder() + .stdout(stdout.toString(StandardCharsets.UTF_8)) + .stderr(stderr.toString(StandardCharsets.UTF_8)) + .build(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("Code execution was interrupted.", e); + } finally { + // Backstop: auto-remove usually reaped the container already; force-remove any remnant. + removeContainerQuietly(containerId); } - - return CodeExecutionResult.builder() - .stdout(stdout.toString(StandardCharsets.UTF_8)) - .stderr(stderr.toString(StandardCharsets.UTF_8)) - .build(); } - private void buildDockerImage() { - if (dockerPath == null) { - throw new IllegalStateException("Docker path is not set."); - } - File dockerfile = new File(dockerPath); - if (!dockerfile.exists()) { - throw new UncheckedIOException(new IOException("Invalid Docker path: " + dockerPath)); + /** Builds the hardened {@link HostConfig} applied to every execution container. */ + @VisibleForTesting + HostConfig sandboxHostConfig() { + HostConfig hostConfig = + HostConfig.newHostConfig() + .withCapDrop(Capability.ALL) + .withReadonlyRootfs(true) + .withSecurityOpts(ImmutableList.of("no-new-privileges")) + .withMemory(MEMORY_LIMIT_BYTES) + .withPidsLimit(PIDS_LIMIT) + // Remove the container when it exits so an abruptly-killed JVM cannot leak containers. + .withAutoRemove(true) + // A read-only rootfs still needs a small writable scratch space at /tmp. + .withTmpFs(ImmutableMap.of("/tmp", "rw,size=64m")); + if (!networkEnabled) { + hostConfig.withNetworkMode("none"); } + return hostConfig; + } - logger.info("Building Docker image..."); + private void removeContainerQuietly(String containerId) { try { - dockerClient.buildImageCmd(dockerfile).withTag(image).start().awaitCompletion(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("Docker image build was interrupted.", e); + dockerClient.removeContainerCmd(containerId).withForce(true).exec(); + } catch (RuntimeException e) { + // The container was most likely already auto-removed on exit; this is expected. + logger.debug("Container {} was already removed or could not be removed.", containerId, e); } - logger.info("Docker image: {} built.", image); } - private void verifyPythonInstallation() { - ExecCreateCmdResponse execCreateCmdResponse = - dockerClient.execCreateCmd(container.getId()).withCmd("which", "python3").exec(); - ByteArrayOutputStream stdout = new ByteArrayOutputStream(); - ByteArrayOutputStream stderr = new ByteArrayOutputStream(); - try (ExecStartResultCallback callback = new ExecStartResultCallback(stdout, stderr)) { - dockerClient.execStartCmd(execCreateCmdResponse.getId()).exec(callback).awaitCompletion(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("Python verification was interrupted.", e); + /** Closes the underlying Docker client, releasing its connections and threads. */ + @Override + public void close() { + try { + dockerClient.close(); } catch (IOException e) { - throw new UncheckedIOException(e); + logger.warn("Failed to close docker client", e); } } - private void initContainer() { - if (dockerClient == null) { - throw new IllegalStateException("Docker client is not initialized."); + private static DockerClient buildDockerClient(String baseUrl) { + if (baseUrl != null) { + var config = + DefaultDockerClientConfig.createDefaultConfigBuilder().withDockerHost(baseUrl).build(); + return DockerClientBuilder.getInstance(config).build(); } + return DockerClientBuilder.getInstance().build(); + } + + private void prepareImage() { if (dockerPath != null) { buildDockerImage(); } else { @@ -204,34 +295,49 @@ private void initContainer() { } logger.info("Image {} is available.", image); } - logger.info("Starting container for ContainerCodeExecutor..."); - var createContainerResponse = - dockerClient.createContainerCmd(image).withTty(true).withAttachStdin(true).exec(); - dockerClient.startContainerCmd(createContainerResponse.getId()).exec(); + } - var containers = dockerClient.listContainersCmd().withShowAll(true).exec(); - this.container = - containers.stream() - .filter(c -> c.getId().equals(createContainerResponse.getId())) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Failed to find the created container.")); + private void buildDockerImage() { + if (dockerPath == null) { + throw new IllegalStateException("Docker path is not set."); + } + File dockerfile = new File(dockerPath); + if (!dockerfile.exists()) { + throw new UncheckedIOException(new IOException("Invalid Docker path: " + dockerPath)); + } - logger.info("Container {} started.", container.getId()); - verifyPythonInstallation(); + logger.info("Building Docker image..."); + try { + dockerClient.buildImageCmd(dockerfile).withTag(image).start().awaitCompletion(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Docker image build was interrupted.", e); + } + logger.info("Docker image: {} built.", image); } - private void cleanupContainer() { - if (container == null) { - return; + /** Routes container output frames into separate stdout and stderr buffers. */ + private static final class FrameCapturingCallback extends AttachContainerResultCallback { + private final ByteArrayOutputStream stdout; + private final ByteArrayOutputStream stderr; + + FrameCapturingCallback(ByteArrayOutputStream stdout, ByteArrayOutputStream stderr) { + this.stdout = stdout; + this.stderr = stderr; } - logger.info("[Cleanup] Stopping the container..."); - dockerClient.stopContainerCmd(container.getId()).exec(); - dockerClient.removeContainerCmd(container.getId()).exec(); - logger.info("Container {} stopped and removed.", container.getId()); - try { - dockerClient.close(); - } catch (IOException e) { - logger.warn("Failed to close docker client", e); + + @Override + public void onNext(Frame frame) { + try { + if (frame.getStreamType() == StreamType.STDERR) { + stderr.write(frame.getPayload()); + } else { + stdout.write(frame.getPayload()); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + super.onNext(frame); } } } diff --git a/core/src/test/java/com/google/adk/codeexecutors/ContainerCodeExecutorTest.java b/core/src/test/java/com/google/adk/codeexecutors/ContainerCodeExecutorTest.java new file mode 100644 index 000000000..4144d86bc --- /dev/null +++ b/core/src/test/java/com/google/adk/codeexecutors/ContainerCodeExecutorTest.java @@ -0,0 +1,176 @@ +/* + * 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. + */ + +package com.google.adk.codeexecutors; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.AttachContainerCmd; +import com.github.dockerjava.api.command.CreateContainerCmd; +import com.github.dockerjava.api.command.CreateContainerResponse; +import com.github.dockerjava.api.command.RemoveContainerCmd; +import com.github.dockerjava.api.command.StartContainerCmd; +import com.github.dockerjava.api.model.Capability; +import com.github.dockerjava.api.model.HostConfig; +import com.github.dockerjava.core.command.AttachContainerResultCallback; +import com.google.adk.codeexecutors.CodeExecutionUtils.CodeExecutionInput; +import com.google.adk.codeexecutors.CodeExecutionUtils.CodeExecutionResult; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; + +/** Unit tests for {@link ContainerCodeExecutor}'s sandboxing. */ +@RunWith(JUnit4.class) +public final class ContainerCodeExecutorTest { + + private static final String IMAGE = "adk-code-executor:latest"; + private static final String CONTAINER_ID = "container-123"; + + @Test + public void sandboxHostConfig_default_appliesFullHardening() { + ContainerCodeExecutor executor = new ContainerCodeExecutor(mock(DockerClient.class), IMAGE); + + HostConfig hostConfig = executor.sandboxHostConfig(); + + assertThat(hostConfig.getNetworkMode()).isEqualTo("none"); + assertThat(hostConfig.getCapDrop()).asList().containsExactly(Capability.ALL); + assertThat(hostConfig.getReadonlyRootfs()).isTrue(); + assertThat(hostConfig.getSecurityOpts()).containsExactly("no-new-privileges"); + assertThat(hostConfig.getMemory()).isEqualTo(512L * 1024 * 1024); + assertThat(hostConfig.getPidsLimit()).isEqualTo(128L); + assertThat(hostConfig.getAutoRemove()).isTrue(); + assertThat(hostConfig.getTmpFs()).containsEntry("/tmp", "rw,size=64m"); + } + + @Test + public void sandboxHostConfig_networkEnabled_doesNotForceNoneNetwork() { + ContainerCodeExecutor executor = + new ContainerCodeExecutor(mock(DockerClient.class), IMAGE).setNetworkEnabled(true); + + HostConfig hostConfig = executor.sandboxHostConfig(); + + // When networking is explicitly enabled we leave the network mode at Docker's default. + assertThat(hostConfig.getNetworkMode()).isNull(); + // The other hardening still applies. + assertThat(hostConfig.getCapDrop()).asList().containsExactly(Capability.ALL); + assertThat(hostConfig.getAutoRemove()).isTrue(); + } + + @Test + public void executeCode_runsHardenedAutoRemoveContainerAsMainProcess_andRemovesIt() { + DockerClient client = mockDockerClient(/* driveCompletion= */ true); + ContainerCodeExecutor executor = new ContainerCodeExecutor(client, IMAGE); + + CodeExecutionResult result = + executor.executeCode( + /* invocationContext= */ null, + CodeExecutionInput.builder().code("print('hi')").build()); + + CreateContainerCmd createCmd = client.createContainerCmd(IMAGE); + + // The container is created with the hardened, auto-removing HostConfig... + ArgumentCaptor hostConfigCaptor = ArgumentCaptor.forClass(HostConfig.class); + verify(createCmd).withHostConfig(hostConfigCaptor.capture()); + assertThat(hostConfigCaptor.getValue().getNetworkMode()).isEqualTo("none"); + assertThat(hostConfigCaptor.getValue().getAutoRemove()).isTrue(); + assertThat(hostConfigCaptor.getValue().getReadonlyRootfs()).isTrue(); + + // ...runs the code as its main process, bounded by an in-container `timeout`... + ArgumentCaptor cmdCaptor = ArgumentCaptor.forClass(String[].class); + verify(createCmd).withCmd(cmdCaptor.capture()); + assertThat(cmdCaptor.getValue()) + .asList() + .containsExactly("timeout", "-k", "5", "60", "python3", "-c", "print('hi')") + .inOrder(); + + // ...and is force-removed afterwards as a backstop. + verify(client.startContainerCmd(CONTAINER_ID)).exec(); + verify(client.removeContainerCmd(CONTAINER_ID)).withForce(true); + verify(client.removeContainerCmd(CONTAINER_ID)).exec(); + assertThat(result.stderr()).isEmpty(); + } + + @Test + public void executeCode_usesConfiguredExecutionTimeout() { + DockerClient client = mockDockerClient(/* driveCompletion= */ true); + ContainerCodeExecutor executor = new ContainerCodeExecutor(client, IMAGE); + executor.setExecutionTimeoutSeconds(30); + + executor.executeCode( + /* invocationContext= */ null, CodeExecutionInput.builder().code("x = 1").build()); + + ArgumentCaptor cmdCaptor = ArgumentCaptor.forClass(String[].class); + verify(client.createContainerCmd(IMAGE)).withCmd(cmdCaptor.capture()); + assertThat(cmdCaptor.getValue()).asList().containsAtLeast("timeout", "30", "python3").inOrder(); + } + + @Test + public void close_closesDockerClient() throws Exception { + DockerClient client = mock(DockerClient.class); + ContainerCodeExecutor executor = new ContainerCodeExecutor(client, IMAGE); + + executor.close(); + + verify(client).close(); + } + + /** + * Builds a mock {@link DockerClient} whose create/attach/start/remove chain succeeds. When {@code + * driveCompletion} is true the attach callback is completed immediately so {@code + * awaitCompletion} returns without blocking. + */ + private static DockerClient mockDockerClient(boolean driveCompletion) { + DockerClient client = mock(DockerClient.class); + + CreateContainerCmd createCmd = mock(CreateContainerCmd.class); + when(client.createContainerCmd(IMAGE)).thenReturn(createCmd); + when(createCmd.withHostConfig(any())).thenReturn(createCmd); + when(createCmd.withCmd(any(String[].class))).thenReturn(createCmd); + CreateContainerResponse createResponse = mock(CreateContainerResponse.class); + when(createResponse.getId()).thenReturn(CONTAINER_ID); + when(createCmd.exec()).thenReturn(createResponse); + + AttachContainerCmd attachCmd = mock(AttachContainerCmd.class); + when(client.attachContainerCmd(CONTAINER_ID)).thenReturn(attachCmd); + when(attachCmd.withStdOut(any())).thenReturn(attachCmd); + when(attachCmd.withStdErr(any())).thenReturn(attachCmd); + when(attachCmd.withFollowStream(any())).thenReturn(attachCmd); + when(attachCmd.exec(any())) + .thenAnswer( + invocation -> { + AttachContainerResultCallback callback = invocation.getArgument(0); + if (driveCompletion) { + callback.onComplete(); + } + return callback; + }); + + StartContainerCmd startCmd = mock(StartContainerCmd.class); + when(client.startContainerCmd(CONTAINER_ID)).thenReturn(startCmd); + + RemoveContainerCmd removeCmd = mock(RemoveContainerCmd.class); + when(client.removeContainerCmd(CONTAINER_ID)).thenReturn(removeCmd); + when(removeCmd.withForce(any())).thenReturn(removeCmd); + + return client; + } +}