summaryrefslogtreecommitdiff
path: root/internal/executor/nativerunner.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/executor/nativerunner.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/executor/nativerunner.go')
-rw-r--r--internal/executor/nativerunner.go33
1 files changed, 31 insertions, 2 deletions
diff --git a/internal/executor/nativerunner.go b/internal/executor/nativerunner.go
index 6fe4196..fa2b89a 100644
--- a/internal/executor/nativerunner.go
+++ b/internal/executor/nativerunner.go
@@ -45,6 +45,21 @@ type NativeRunner struct {
// MaxTurns bounds the tool-use loop; defaults to agentloop.DefaultMaxTurns
// (12, matching the former executor.maxLocalToolTurns) when zero.
MaxTurns int
+
+ // SandboxKind selects the Sandbox implementation used for this runner's
+ // tool calls: "" or "host" uses sandbox.HostSandbox (host-side git clone
+ // with path-prefix confinement only); "docker" uses sandbox.DockerSandbox
+ // for real container isolation. Config-driven per provider — see
+ // internal/cli/serve.go/run.go, which set this to "docker" for the
+ // "anthropic" runner and leave it "host" (the zero value) for "local":
+ // local models are presumed more trusted/lower-stakes, so they keep the
+ // weaker but zero-overhead host sandbox per the harness design.
+ SandboxKind string
+
+ // SandboxImage is the docker image used when SandboxKind == "docker".
+ // Empty uses DockerSandbox's own default (images/agent-base, i.e.
+ // "claudomator-agent:latest").
+ SandboxImage string
}
var _ Runner = (*NativeRunner)(nil)
@@ -59,6 +74,17 @@ func (r *NativeRunner) ExecLogDir(execID string) string {
return filepath.Join(r.LogDir, execID)
}
+// newBaseSandbox constructs the unguarded Sandbox implementation selected by
+// r.SandboxKind, rooted at dir ("" for a fresh not-yet-cloned sandbox, an
+// existing directory to resume one). Callers wrap the result in
+// sandbox.Guarded before use.
+func (r *NativeRunner) newBaseSandbox(dir string) sandbox.Sandbox {
+ if r.SandboxKind == "docker" {
+ return sandbox.NewDockerSandbox(r.SandboxImage, dir)
+ }
+ return sandbox.NewHostSandbox(dir)
+}
+
// Run drives a chat completion against r.Provider with the agent back-channel
// tools (ask_user/report_summary/spawn_subtask/record_progress) and sandbox
// tools (read_file/write_file/run_bash/glob) declared, via agentloop.Loop. If
@@ -90,11 +116,14 @@ func (r *NativeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi
defer stdout.Close()
// --- Sandbox setup (git clone into a temp dir, or reuse on resume) ---
- sb := sandbox.NewHostSandbox("")
+ // Guardrails (DenylistBashHook/ProtectedPathHook) are applied uniformly
+ // by the Sandbox wrapper regardless of provider or sandbox kind — there
+ // is no per-role configurability yet.
+ sb := &sandbox.Guarded{Sandbox: r.newBaseSandbox(""), Hooks: sandbox.DefaultHooks()}
if t.Agent.ProjectDir != "" {
if e.SandboxDir != "" {
// Reuse existing sandbox on resume (BLOCKED → QUEUED path).
- sb = sandbox.NewHostSandbox(e.SandboxDir)
+ sb = &sandbox.Guarded{Sandbox: r.newBaseSandbox(e.SandboxDir), Hooks: sandbox.DefaultHooks()}
} else {
if cloneErr := sb.GitClone(ctx, t.Agent.ProjectDir, ""); cloneErr != nil {
return fmt.Errorf("native runner: %w", cloneErr)