summaryrefslogtreecommitdiff
path: root/internal/executor
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor')
-rw-r--r--internal/executor/nativerunner.go33
-rw-r--r--internal/executor/nativerunner_test.go79
2 files changed, 110 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)
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)")
+ }
+}