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.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.go')
| -rw-r--r-- | internal/sandbox/dockersandbox.go | 365 |
1 files changed, 365 insertions, 0 deletions
diff --git a/internal/sandbox/dockersandbox.go b/internal/sandbox/dockersandbox.go new file mode 100644 index 0000000..537d26b --- /dev/null +++ b/internal/sandbox/dockersandbox.go @@ -0,0 +1,365 @@ +package sandbox + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path" + "strings" + "sync" +) + +// defaultSandboxImage is used when DockerSandbox is constructed with an +// empty image. It reuses images/agent-base — the same image +// executor.ContainerRunner defaults to ("claudomator-agent:latest") for the +// claude/gemini CLI-subprocess path — rather than adding a second, +// purpose-built minimal image for this phase. agent-base already has +// everything the sandbox tool set needs (git, bash, coreutils via Ubuntu +// 24.04) and `git config --system safe.directory '*'` baked in, which +// DockerSandbox's bind-mount-owned-by-host-uid setup relies on. The +// tradeoff: the image also carries Node/Go/the claude and gemini CLIs that +// a generic tool-execution sandbox never uses, so container start is +// slower and the image is larger than strictly necessary. A follow-up phase +// can split out a slim `claudomator-sandbox:latest` (git + bash + coreutils +// only) if that overhead becomes a real problem; standing up and +// maintaining a second image wasn't worth it for this phase, especially +// since Docker was not available to build/verify one in this environment. +const defaultSandboxImage = "claudomator-agent:latest" + +// DockerSandbox implements Sandbox by running tool calls inside a Docker +// container instead of directly on the host (compare HostSandbox). It +// mirrors executor.ContainerRunner's clone-on-host-then-bind-mount pattern +// rather than cloning inside the container: +// +// - GitClone clones the repo into a fresh host temp directory with a +// plain `git clone` (identical to HostSandbox.GitClone), then starts a +// container with that directory bind-mounted at /workspace and left +// running idle (`docker run -d ... sleep infinity`), so each +// ReadFile/WriteFile/RunBash/Glob call becomes a `docker exec` into an +// already-running container instead of paying container-start latency +// per call. +// - The container runs as `--user <hostUID>:<hostGID>`, matching +// ContainerRunner.buildDockerArgs, so files it creates in the bind +// mount are host-owned rather than root-owned. +// +// WorkDir/resume design: WorkDir returns the HOST bind-mount directory, not +// the in-container /workspace path. This is the value NativeRunner persists +// to executions.sandbox_dir for a BLOCKED→QUEUED resume (see +// internal/executor/nativerunner.go), and it's the only thing persisted — +// the running container's ID is not stored anywhere durable. Consequently a +// resumed DockerSandbox does NOT attempt to reattach to the original +// container; instead every tool method lazily starts a fresh container +// bind-mounted onto the persisted host directory if one isn't already +// running on this *DockerSandbox value (see ensureContainerLocked). This +// was chosen over reattachment because the original container ID has +// nowhere to live in the current single-string sandbox_dir contract, and +// even if it did, reattachment is fragile (the container may already be +// gone — host reboot, manual `docker rm`, OOM kill) where re-mounting the +// host directory into a brand new container is not: as long as the host +// directory survived, a resumed DockerSandbox behaves identically to a +// fresh one. +// +// Known limitation (not fixed in this phase): internal/agentloop.Loop.Run +// only calls Sandbox.Cleanup() on the non-blocked finish path — on +// ask_user (BLOCKED) the sandbox is deliberately left alive so a resume can +// reuse it. For HostSandbox that only leaves a temp directory on disk. For +// DockerSandbox it also leaves the idle `sleep infinity` container running +// until the task is eventually resumed and finishes cleanly (at which point +// Cleanup finally removes it), or some future reaper is added. This mirrors +// the existing resume contract as-is rather than extending it; a general +// "reap idle blocked sandboxes" mechanism is out of scope here. +type DockerSandbox struct { + mu sync.Mutex + + image string + hostDir string // host bind-mount source directory; "" until established + containerID string // "" until a container is running for hostDir + + uid, gid int + + // command allows mocking exec.CommandContext in tests, mirroring + // executor.ContainerRunner.Command. + command func(ctx context.Context, name string, arg ...string) *exec.Cmd +} + +var _ Sandbox = (*DockerSandbox)(nil) + +// NewDockerSandbox returns a DockerSandbox using image (falling back to +// defaultSandboxImage when empty). Pass hostDir="" to construct a +// not-yet-cloned sandbox — GitClone establishes the directory and starts +// the container. Pass an existing host directory (e.g. a resumed BLOCKED +// task's e.SandboxDir) to reuse a prior clone without cloning again; a +// container is started lazily on the first tool call. +func NewDockerSandbox(image, hostDir string) *DockerSandbox { + if image == "" { + image = defaultSandboxImage + } + return &DockerSandbox{ + image: image, + hostDir: hostDir, + uid: os.Getuid(), + gid: os.Getgid(), + } +} + +func (s *DockerSandbox) cmd(ctx context.Context, name string, arg ...string) *exec.Cmd { + if s.command != nil { + return s.command(ctx, name, arg...) + } + return exec.CommandContext(ctx, name, arg...) +} + +// WorkDir returns the host bind-mount directory, or "" if none has been +// established yet. See the resume design note on DockerSandbox. +func (s *DockerSandbox) WorkDir() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.hostDir +} + +// GitClone clones remote into a fresh host temp directory (if this sandbox +// has no working directory yet) and starts the idle container bind-mounting +// it. branch, if non-empty, is passed as `git clone -b <branch>`. +func (s *DockerSandbox) GitClone(ctx context.Context, remote, branch string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.hostDir == "" { + tmpDir, err := os.MkdirTemp("", "claudomator-dockersandbox-*") + if err != nil { + return fmt.Errorf("sandbox: create temp dir: %w", err) + } + s.hostDir = tmpDir + } + + args := []string{"clone"} + if branch != "" { + args = append(args, "-b", branch) + } + args = append(args, remote, s.hostDir) + out, err := s.cmd(ctx, "git", args...).CombinedOutput() + if err != nil { + os.RemoveAll(s.hostDir) + s.hostDir = "" + return fmt.Errorf("sandbox: git clone: %w\n%s", err, out) + } + + if err := s.ensureContainerLocked(ctx); err != nil { + return fmt.Errorf("sandbox: %w", err) + } + return nil +} + +// ensureContainerLocked starts a container bind-mounting s.hostDir at +// /workspace if one isn't already running for this sandbox value. Must be +// called with s.mu held, and s.hostDir must already be set. +func (s *DockerSandbox) ensureContainerLocked(ctx context.Context) error { + if s.containerID != "" { + return nil + } + if s.hostDir == "" { + return fmt.Errorf("no sandbox working directory") + } + args := []string{ + "run", "-d", + fmt.Sprintf("--user=%d:%d", s.uid, s.gid), + "-v", s.hostDir + ":/workspace", + "-w", "/workspace", + s.image, + "sleep", "infinity", + } + out, err := s.cmd(ctx, "docker", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("docker run: %w\n%s", err, strings.TrimSpace(string(out))) + } + id := strings.TrimSpace(string(out)) + if id == "" { + return fmt.Errorf("docker run: empty container ID") + } + s.containerID = id + return nil +} + +// GitPush pushes the container workspace's current HEAD to origin. branch, +// if non-empty, is used as the push ref; "" pushes HEAD, matching +// HostSandbox.GitPush. +func (s *DockerSandbox) GitPush(ctx context.Context, branch string) error { + s.mu.Lock() + if s.hostDir == "" { + s.mu.Unlock() + return fmt.Errorf("sandbox: git push: no sandbox working directory") + } + if err := s.ensureContainerLocked(ctx); err != nil { + s.mu.Unlock() + return fmt.Errorf("sandbox: git push: %w", err) + } + containerID := s.containerID + s.mu.Unlock() + + ref := branch + if ref == "" { + ref = "HEAD" + } + out, err := s.cmd(ctx, "docker", "exec", containerID, "git", "push", "origin", ref).CombinedOutput() + if err != nil { + return fmt.Errorf("sandbox: git push: %w\n%s", err, out) + } + return nil +} + +// Cleanup removes the running container (docker rm -f), if any. The +// host-side bind-mount directory is intentionally left in place — matching +// ContainerRunner's workspace-disposal behavior, where the host workspace +// (not the container) is the thing preservation/removal decisions are made +// about — so callers that want the host directory gone too must remove it +// themselves (NativeRunner/agentloop never do; they rely on the OS temp dir +// eventually being cleaned, same as HostSandbox's dir would be if a caller +// forgot to call Cleanup). +func (s *DockerSandbox) Cleanup(ctx context.Context) error { + s.mu.Lock() + containerID := s.containerID + s.containerID = "" + s.mu.Unlock() + if containerID == "" { + return nil + } + out, err := s.cmd(ctx, "docker", "rm", "-f", containerID).CombinedOutput() + if err != nil { + return fmt.Errorf("sandbox: docker rm: %w\n%s", err, out) + } + return nil +} + +// resolveContainerPath ensures a container is running and returns +// (containerID, in-container path) for p, joined against /workspace when +// relative — mirroring HostSandbox.resolvePath's absolute-path passthrough. +func (s *DockerSandbox) resolveContainerPath(ctx context.Context, p string) (containerID, containerPath string, err error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.hostDir == "" { + return "", "", fmt.Errorf("no sandbox working directory") + } + if err := s.ensureContainerLocked(ctx); err != nil { + return "", "", err + } + cp := p + if !path.IsAbs(cp) { + cp = path.Join("/workspace", cp) + } + return s.containerID, cp, nil +} + +func (s *DockerSandbox) ReadFile(ctx context.Context, p string) (string, error) { + containerID, cp, err := s.resolveContainerPath(ctx, p) + if err != nil { + return "", fmt.Errorf("read_file: %w", err) + } + var out, errBuf bytes.Buffer + cmd := s.cmd(ctx, "docker", "exec", containerID, "cat", cp) + cmd.Stdout = &out + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("read_file: %w: %s", err, strings.TrimSpace(errBuf.String())) + } + return out.String(), nil +} + +func (s *DockerSandbox) WriteFile(ctx context.Context, p, content string) error { + containerID, cp, err := s.resolveContainerPath(ctx, p) + if err != nil { + return fmt.Errorf("write_file: %w", err) + } + + dir := path.Dir(cp) + if out, err := s.cmd(ctx, "docker", "exec", containerID, "mkdir", "-p", dir).CombinedOutput(); err != nil { + return fmt.Errorf("write_file: mkdir -p %s: %w\n%s", dir, err, out) + } + + // `sh -c 'cat > "$1"' sh <path>` writes stdin to <path> without + // re-interpreting it as shell, and passes the path as $1 (arg after the + // script name) rather than interpolating it into the script string, so + // paths containing quotes/spaces/etc. can't break out. + cmd := s.cmd(ctx, "docker", "exec", "-i", containerID, "sh", "-c", `cat > "$1"`, "sh", cp) + cmd.Stdin = strings.NewReader(content) + var errBuf bytes.Buffer + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + return fmt.Errorf("write_file: %w: %s", err, strings.TrimSpace(errBuf.String())) + } + return nil +} + +func (s *DockerSandbox) RunBash(ctx context.Context, command string) (stdout, stderr string, exitCode int, err error) { + s.mu.Lock() + if s.hostDir == "" { + s.mu.Unlock() + return "", "", 0, fmt.Errorf("run_bash: no sandbox working directory") + } + if ensureErr := s.ensureContainerLocked(ctx); ensureErr != nil { + s.mu.Unlock() + return "", "", 0, fmt.Errorf("run_bash: %w", ensureErr) + } + containerID := s.containerID + s.mu.Unlock() + + cmd := s.cmd(ctx, "docker", "exec", containerID, "sh", "-c", command) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + runErr := cmd.Run() + if runErr != nil { + if exitErr, ok := runErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return "", "", 0, fmt.Errorf("run_bash: %w", runErr) + } + } + + stdout = outBuf.String() + stderr = errBuf.String() + if len(stdout) > maxToolOutput { + stdout = stdout[:maxToolOutput] + "\n[truncated]" + } + if len(stderr) > maxToolOutput { + stderr = stderr[:maxToolOutput] + "\n[truncated]" + } + return stdout, stderr, exitCode, nil +} + +// Glob lists files matching pattern relative to /workspace inside the +// container, via a shell glob expansion (`for f in <pattern>; do ...`) +// rather than `find`, to keep single-segment patterns like "*.go" behaving +// the same as HostSandbox.Glob's filepath.Glob. Unlike filepath.Glob, +// "**" does not recurse in a POSIX shell without bash's globstar — patterns +// relying on recursive "**" matching are a known approximation gap here. +func (s *DockerSandbox) Glob(ctx context.Context, pattern string) ([]string, error) { + s.mu.Lock() + if s.hostDir == "" { + s.mu.Unlock() + return nil, fmt.Errorf("glob: no sandbox working directory") + } + if err := s.ensureContainerLocked(ctx); err != nil { + s.mu.Unlock() + return nil, fmt.Errorf("glob: %w", err) + } + containerID := s.containerID + s.mu.Unlock() + + script := fmt.Sprintf(`cd /workspace && for f in %s; do [ -e "$f" ] && echo "$f"; done`, pattern) + var outBuf, errBuf bytes.Buffer + cmd := s.cmd(ctx, "docker", "exec", containerID, "sh", "-c", script) + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("glob: %w: %s", err, strings.TrimSpace(errBuf.String())) + } + var matches []string + for _, l := range strings.Split(strings.TrimSpace(outBuf.String()), "\n") { + if l != "" { + matches = append(matches, l) + } + } + return matches, nil +} |
