diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 09:21:32 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 09:21:32 +0000 |
| commit | 767ddade57f189827fa956ff8081ca47404a4798 (patch) | |
| tree | e2b44d0e943e1cc5cf2b11e0b826902a24b63802 /internal/sandbox/dockersandbox_test.go | |
| parent | 81ae7d598e7ec6afe8afb5bbdbfe807042298559 (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.go | 375 |
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) + } +} |
