summaryrefslogtreecommitdiff
path: root/internal/sandbox/dockersandbox_test.go
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 09:21:32 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 09:21:32 +0000
commit767ddade57f189827fa956ff8081ca47404a4798 (patch)
treee2b44d0e943e1cc5cf2b11e0b826902a24b63802 /internal/sandbox/dockersandbox_test.go
parent81ae7d598e7ec6afe8afb5bbdbfe807042298559 (diff)
feat(sandbox): add DockerSandbox + pre-tool-use guardrail hooks (Phase 3)
Gives native-API-driven agents (currently just the Phase 2 Anthropic adapter) real container isolation, decoupled from model invocation -- the model call happens in the Go process via provider.Provider, only tool execution happens in the container, unlike the CLI-subprocess ContainerRunner (left completely untouched) where the claude/gemini CLI runs inside the container. - internal/sandbox/dockersandbox.go: Sandbox via a long-lived `docker run -d ... sleep infinity` container (started once per execution, not per tool call), host-side git clone + bind-mount matching ContainerRunner's existing pattern, docker exec for read/write/bash/glob. Reuses images/agent-base (claudomator-agent:latest) rather than standing up a second image. WorkDir()/resume persists the host bind-mount directory (matching HostSandbox's contract); a resumed sandbox lazily starts a fresh container against that directory rather than trying to reattach to a possibly-gone one. - internal/sandbox/guard.go, hooks.go: Hook interface (CheckBash/CheckWrite), Guarded wrapper, DenylistBashHook (rm -rf /, force-push, curl|sh, sudo, chmod 777, dd if=) and ProtectedPathHook (.git/**, .env*, credentials/, .github/workflows/**). A rejection returns *RejectionError, which agentloop/tools.go now recognizes and feeds back to the model as a normal (non-fatal) tool-error result instead of aborting the run. - NativeRunner wraps whichever Sandbox it builds (Host or Docker) in Guarded{Hooks: DefaultHooks()} uniformly. The "anthropic" runner now uses DockerSandbox; "local" stays on HostSandbox by design (local models are the harness's more-trusted, lower-stakes-to-run tier). Docker is not installed in this dev environment (no docker/podman/containerd on PATH), so DockerSandbox's real container-lifecycle behavior is verified via mocked-command unit tests only -- go test -race ./... passes throughout, with the two real-daemon integration tests gated behind a dockerAvailable(t) check and skipping here. Live verification against an actual Docker host is a follow-up before relying on the "anthropic" agent type in production. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal/sandbox/dockersandbox_test.go')
-rw-r--r--internal/sandbox/dockersandbox_test.go375
1 files changed, 375 insertions, 0 deletions
diff --git a/internal/sandbox/dockersandbox_test.go b/internal/sandbox/dockersandbox_test.go
new file mode 100644
index 0000000..d95faa5
--- /dev/null
+++ b/internal/sandbox/dockersandbox_test.go
@@ -0,0 +1,375 @@
+package sandbox
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// dockerAvailable reports whether a working Docker daemon is reachable,
+// mirroring the check the task plan asked for ("confirm Docker actually IS
+// available... via `docker info`"). Real container-lifecycle tests below
+// skip gracefully when it is not, the same way
+// TestNativeRunner_Run_ProjectDir_SandboxToolsAndPush skips when `git` isn't
+// on PATH.
+func dockerAvailable(t *testing.T) bool {
+ t.Helper()
+ if _, err := exec.LookPath("docker"); err != nil {
+ return false
+ }
+ if err := exec.Command("docker", "info").Run(); err != nil {
+ return false
+ }
+ return true
+}
+
+// --- Unit tests: exec.CommandContext mocked out, mirroring
+// executor.ContainerRunner's Command-field mocking pattern, so these run
+// without Docker and assert the argument construction / control flow in
+// isolation. ---
+
+// recordingCmds captures every (name, args) pair passed to DockerSandbox's
+// cmd() so tests can assert on the docker invocations without a real
+// daemon.
+type recordingCmds struct {
+ calls [][]string
+ // containerID is returned as the (fake) stdout of the "docker run"
+ // call, so ensureContainerLocked has something to parse.
+ containerID string
+}
+
+func (r *recordingCmds) command(ctx context.Context, name string, arg ...string) *exec.Cmd {
+ call := append([]string{name}, arg...)
+ r.calls = append(r.calls, call)
+
+ if name == "docker" && len(arg) > 0 && arg[0] == "run" {
+ return exec.Command("echo", r.containerID)
+ }
+ if name == "git" && len(arg) > 0 && arg[0] == "clone" {
+ dir := arg[len(arg)-1]
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return exec.Command("false")
+ }
+ return exec.Command("true")
+ }
+ return exec.Command("true")
+}
+
+func newMockedDockerSandbox() (*DockerSandbox, *recordingCmds) {
+ rec := &recordingCmds{containerID: "fakecontainerid123"}
+ s := NewDockerSandbox("test-image:latest", "")
+ s.command = rec.command
+ return s, rec
+}
+
+func TestDockerSandbox_GitClone_StartsContainer(t *testing.T) {
+ s, rec := newMockedDockerSandbox()
+
+ if err := s.GitClone(context.Background(), "https://example.com/repo.git", ""); err != nil {
+ t.Fatalf("GitClone: %v", err)
+ }
+ defer os.RemoveAll(s.WorkDir())
+
+ if s.WorkDir() == "" {
+ t.Fatal("expected WorkDir to be set after GitClone")
+ }
+
+ var sawClone, sawRun bool
+ for _, call := range rec.calls {
+ if call[0] == "git" && len(call) > 1 && call[1] == "clone" {
+ sawClone = true
+ }
+ if call[0] == "docker" && len(call) > 1 && call[1] == "run" {
+ sawRun = true
+ // Confirm the bind mount and uid/gid remap are present, mirroring
+ // ContainerRunner.buildDockerArgs' --user/-v conventions.
+ joined := strings.Join(call, " ")
+ if !strings.Contains(joined, "--user=") {
+ t.Errorf("docker run args missing --user=: %v", call)
+ }
+ if !strings.Contains(joined, s.WorkDir()+":/workspace") {
+ t.Errorf("docker run args missing bind mount: %v", call)
+ }
+ }
+ }
+ if !sawClone {
+ t.Error("expected a git clone invocation")
+ }
+ if !sawRun {
+ t.Error("expected a docker run invocation")
+ }
+}
+
+func TestDockerSandbox_RunBash_NoWorkingDirectory_Errors(t *testing.T) {
+ s, _ := newMockedDockerSandbox()
+ _, _, _, err := s.RunBash(context.Background(), "echo hi")
+ if err == nil || !strings.Contains(err.Error(), "no sandbox working directory") {
+ t.Errorf("expected 'no sandbox working directory' error, got %v", err)
+ }
+}
+
+func TestDockerSandbox_RunBash_ReusesRunningContainer(t *testing.T) {
+ s, rec := newMockedDockerSandbox()
+ if err := s.GitClone(context.Background(), "https://example.com/repo.git", ""); err != nil {
+ t.Fatalf("GitClone: %v", err)
+ }
+ defer os.RemoveAll(s.WorkDir())
+
+ runCallsBefore := countDockerRun(rec.calls)
+
+ if _, _, _, err := s.RunBash(context.Background(), "echo hi"); err != nil {
+ t.Fatalf("RunBash: %v", err)
+ }
+ if _, _, _, err := s.RunBash(context.Background(), "echo bye"); err != nil {
+ t.Fatalf("RunBash: %v", err)
+ }
+
+ if got := countDockerRun(rec.calls); got != runCallsBefore {
+ t.Errorf("expected no additional 'docker run' calls once a container is running, went from %d to %d", runCallsBefore, got)
+ }
+
+ var execCount int
+ for _, call := range rec.calls {
+ if call[0] == "docker" && len(call) > 1 && call[1] == "exec" {
+ execCount++
+ }
+ }
+ if execCount != 2 {
+ t.Errorf("expected 2 'docker exec' calls, got %d", execCount)
+ }
+}
+
+func TestDockerSandbox_Cleanup_RemovesContainer(t *testing.T) {
+ s, rec := newMockedDockerSandbox()
+ if err := s.GitClone(context.Background(), "https://example.com/repo.git", ""); err != nil {
+ t.Fatalf("GitClone: %v", err)
+ }
+ defer os.RemoveAll(s.WorkDir())
+
+ if err := s.Cleanup(context.Background()); err != nil {
+ t.Fatalf("Cleanup: %v", err)
+ }
+
+ var sawRM bool
+ for _, call := range rec.calls {
+ if call[0] == "docker" && len(call) >= 3 && call[1] == "rm" {
+ sawRM = true
+ joined := strings.Join(call, " ")
+ if !strings.Contains(joined, "fakecontainerid123") {
+ t.Errorf("expected docker rm to target the started container, got %v", call)
+ }
+ }
+ }
+ if !sawRM {
+ t.Error("expected a 'docker rm' invocation")
+ }
+
+ // Cleanup is idempotent: a second call with no container running is a
+ // no-op, not an error.
+ if err := s.Cleanup(context.Background()); err != nil {
+ t.Errorf("second Cleanup should be a no-op, got %v", err)
+ }
+}
+
+func TestDockerSandbox_NewDockerSandbox_DefaultsImage(t *testing.T) {
+ s := NewDockerSandbox("", "")
+ if s.image != defaultSandboxImage {
+ t.Errorf("expected default image %q, got %q", defaultSandboxImage, s.image)
+ }
+}
+
+func countDockerRun(calls [][]string) int {
+ n := 0
+ for _, c := range calls {
+ if c[0] == "docker" && len(c) > 1 && c[1] == "run" {
+ n++
+ }
+ }
+ return n
+}
+
+// --- Real integration test: exercises actual container lifecycle. Skips
+// gracefully when Docker isn't available in the test environment, per the
+// same convention executor.container_test.go's TestNativeRunner-style tests
+// use for `git`. ---
+
+// TestDockerSandbox_RealContainer_Lifecycle mirrors
+// executor.TestNativeRunner_Run_ProjectDir_SandboxToolsAndPush's bare-origin-
+// repo setup, but drives DockerSandbox directly (GitClone, WriteFile,
+// RunBash, ReadFile, GitPush, Cleanup) against a real docker daemon, then
+// confirms `docker ps -a` shows the container gone after Cleanup.
+func TestDockerSandbox_RealContainer_Lifecycle(t *testing.T) {
+ if !dockerAvailable(t) {
+ t.Skip("docker not available in this environment")
+ }
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git not available")
+ }
+
+ // Bare origin repo so `git push` from the sandbox clone doesn't hit
+ // receive.denyCurrentBranch.
+ origin := t.TempDir()
+ run := func(dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
+ "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ run(origin, "init", "--bare", "-b", "main")
+
+ seed := t.TempDir()
+ run(seed, "init", "-b", "main")
+ run(seed, "remote", "add", "origin", origin)
+ if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("seed\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ run(seed, "add", "-A")
+ run(seed, "commit", "-m", "seed")
+ run(seed, "push", "origin", "main")
+
+ s := NewDockerSandbox("", "") // default image (agent-base)
+ ctx := context.Background()
+
+ if err := s.GitClone(ctx, origin, ""); err != nil {
+ t.Fatalf("GitClone: %v", err)
+ }
+ workDir := s.WorkDir()
+ containerID := s.containerID
+ defer os.RemoveAll(workDir)
+ defer s.Cleanup(ctx)
+
+ if containerID == "" {
+ t.Fatal("expected a running container after GitClone")
+ }
+
+ if err := s.WriteFile(ctx, "hello.txt", "hello from docker sandbox"); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ content, err := s.ReadFile(ctx, "hello.txt")
+ if err != nil {
+ t.Fatalf("ReadFile: %v", err)
+ }
+ if content != "hello from docker sandbox" {
+ t.Errorf("ReadFile: got %q", content)
+ }
+
+ stdout, stderr, exitCode, err := s.RunBash(ctx, "cat hello.txt && wc -l < hello.txt")
+ if err != nil {
+ t.Fatalf("RunBash: %v", err)
+ }
+ if exitCode != 0 {
+ t.Fatalf("RunBash exit code: %d, stderr: %s", exitCode, stderr)
+ }
+ if !strings.Contains(stdout, "hello from docker sandbox") {
+ t.Errorf("RunBash stdout: %q", stdout)
+ }
+
+ matches, err := s.Glob(ctx, "*.txt")
+ if err != nil {
+ t.Fatalf("Glob: %v", err)
+ }
+ if len(matches) != 1 || matches[0] != "hello.txt" {
+ t.Errorf("Glob: want [hello.txt], got %v", matches)
+ }
+
+ if _, _, _, err := s.RunBash(ctx, "git add -A && git -c user.name=test -c user.email=test@example.com commit -m 'add hello'"); err != nil {
+ t.Fatalf("RunBash (commit): %v", err)
+ }
+
+ if err := s.GitPush(ctx, ""); err != nil {
+ t.Fatalf("GitPush: %v", err)
+ }
+
+ logOut, err := exec.Command("git", "-C", origin, "log", "--oneline", "main").CombinedOutput()
+ if err != nil {
+ t.Fatalf("git log on origin: %v\n%s", err, logOut)
+ }
+ if !strings.Contains(string(logOut), "add hello") {
+ t.Errorf("expected pushed commit 'add hello' in origin log, got:\n%s", logOut)
+ }
+
+ if err := s.Cleanup(ctx); err != nil {
+ t.Fatalf("Cleanup: %v", err)
+ }
+
+ psOut, err := exec.Command("docker", "ps", "-a", "-q", "-f", "id="+containerID).CombinedOutput()
+ if err != nil {
+ t.Fatalf("docker ps: %v\n%s", err, psOut)
+ }
+ if strings.TrimSpace(string(psOut)) != "" {
+ t.Errorf("expected container %s to be gone after Cleanup, docker ps -a shows: %s", containerID, psOut)
+ }
+}
+
+func TestDockerSandbox_RealContainer_GuardedRejectionDoesNotStartExtraContainer(t *testing.T) {
+ if !dockerAvailable(t) {
+ t.Skip("docker not available in this environment")
+ }
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git not available")
+ }
+
+ origin := t.TempDir()
+ run := func(dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
+ "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ run(origin, "init", "--bare", "-b", "main")
+ seed := t.TempDir()
+ run(seed, "init", "-b", "main")
+ run(seed, "remote", "add", "origin", origin)
+ if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("seed\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ run(seed, "add", "-A")
+ run(seed, "commit", "-m", "seed")
+ run(seed, "push", "origin", "main")
+
+ base := NewDockerSandbox("", "")
+ g := &Guarded{Sandbox: base, Hooks: DefaultHooks()}
+ ctx := context.Background()
+
+ if err := g.GitClone(ctx, origin, ""); err != nil {
+ t.Fatalf("GitClone: %v", err)
+ }
+ defer os.RemoveAll(g.WorkDir())
+ defer g.Cleanup(ctx)
+
+ // A guardrail rejection must not touch the real sandbox at all — the
+ // dangerous command never reaches `docker exec`, and the write to a
+ // protected path never reaches the container's filesystem.
+ if _, _, _, err := g.RunBash(ctx, "rm -rf /"); err == nil {
+ t.Fatal("expected rejection for rm -rf /")
+ }
+ if err := g.WriteFile(ctx, ".env", "SECRET=1"); err == nil {
+ t.Fatal("expected rejection for write to .env")
+ }
+ if _, err := base.ReadFile(ctx, ".env"); err == nil {
+ t.Error("expected .env to not exist in the container (write was rejected)")
+ }
+
+ // An allowed command still works normally through the guard.
+ if _, _, exitCode, err := g.RunBash(ctx, "echo still-works"); err != nil || exitCode != 0 {
+ t.Fatalf("expected allowed command to succeed, exitCode=%d err=%v", exitCode, err)
+ }
+}