From 767ddade57f189827fa956ff8081ca47404a4798 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Fri, 3 Jul 2026 09:21:32 +0000 Subject: 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 Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs --- internal/executor/nativerunner_test.go | 79 ++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) (limited to 'internal/executor/nativerunner_test.go') diff --git a/internal/executor/nativerunner_test.go b/internal/executor/nativerunner_test.go index f76b365..ab0317c 100644 --- a/internal/executor/nativerunner_test.go +++ b/internal/executor/nativerunner_test.go @@ -351,3 +351,82 @@ func TestNativeRunner_Run_ProjectDir_SandboxToolsAndPush(t *testing.T) { t.Errorf("expected pushed commit 'add hello' in origin log, got:\n%s", out) } } + +// TestNativeRunner_Run_GuardrailRejection_IsNonFatal confirms the Phase 3 +// guardrail wiring end-to-end: NativeRunner now always wraps its sandbox in +// sandbox.Guarded (see NativeRunner.newBaseSandbox), so a denylisted +// run_bash command and a write to a protected path both get rejected +// without aborting the run — the model sees the rejection reason as +// ordinary tool content (via the sandbox.RejectionError handling added to +// agentloop.dispatchTool) and the loop continues to a normal finish, +// exactly like a failed shell command would (non-zero exit code, not a +// crashed execution). +func TestNativeRunner_Run_GuardrailRejection_IsNonFatal(t *testing.T) { + 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") + + srv := fakeChatServer(t, []fakeTurn{ + // Turn 1: model tries something denylisted via run_bash. + {toolCalls: []llm.ToolCall{toolCall("c1", "run_bash", `{"command":"rm -rf /"}`)}}, + // Turn 2: model tries to write a protected path. + {toolCalls: []llm.ToolCall{toolCall("c2", "write_file", `{"path":".env","content":"SECRET=1"}`)}}, + // Turn 3: model recovers and does ordinary, allowed work. + {toolCalls: []llm.ToolCall{toolCall("c3", "write_file", `{"path":"hello.txt","content":"hello world"}`)}}, + {toolCalls: []llm.ToolCall{toolCall("c4", "run_bash", `{"command":"git add -A && git -c user.name=test -c user.email=test@example.com commit -m 'add hello'"}`)}}, + {content: "## Summary\nAdded hello.txt and committed."}, + }) + defer srv.Close() + + r := newLocalRunner(t, srv) + tt := localTask() + tt.Agent.ProjectDir = origin + exec_ := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID} + + if err := r.Run(context.Background(), tt, exec_, newStoreChannel(&fakeChannelStore{}, tt.ID)); err != nil { + t.Fatalf("Run should complete successfully despite rejected tool calls: %v", err) + } + + // The commit from turn 4 should still have landed and been pushed — + // proof the run continued normally after both rejections. + logCmd := exec.Command("git", "-C", origin, "log", "--oneline", "main") + out, err := logCmd.CombinedOutput() + if err != nil { + t.Fatalf("git log on origin: %v\n%s", err, out) + } + if !strings.Contains(string(out), "add hello") { + t.Errorf("expected pushed commit 'add hello' in origin log, got:\n%s", out) + } + + // And .env must never have been written into the sandbox — the write + // was rejected before it ever reached the wrapped Sandbox. + if _, err := os.Stat(filepath.Join(origin, ".env")); err == nil { + t.Error(".env should not exist in origin (write was rejected, nothing to push)") + } +} -- cgit v1.2.3