summaryrefslogtreecommitdiff
path: root/internal/sandbox/dockersandbox.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.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.go')
-rw-r--r--internal/sandbox/dockersandbox.go365
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
+}