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/sandbox/guard.go | 79 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 internal/sandbox/guard.go (limited to 'internal/sandbox/guard.go') diff --git a/internal/sandbox/guard.go b/internal/sandbox/guard.go new file mode 100644 index 0000000..03cb964 --- /dev/null +++ b/internal/sandbox/guard.go @@ -0,0 +1,79 @@ +package sandbox + +import ( + "context" + "fmt" +) + +// Hook is a pre-tool-use guardrail check. Implementations inspect a +// proposed run_bash command or write_file path and return a non-nil error +// to reject it; the error's message becomes the human-readable reason +// surfaced back to the model. Hooks are stateless checks — Guarded is +// responsible for turning a rejection into a RejectionError so the +// agentloop can feed it back to the model as ordinary (non-fatal) tool +// content instead of aborting the whole execution. +type Hook interface { + CheckBash(ctx context.Context, command string) error + CheckWrite(ctx context.Context, path string) error +} + +// RejectionError is returned by Guarded.RunBash/WriteFile when a Hook +// rejects the call. internal/agentloop/tools.go specifically recognizes +// this type (via errors.As) and, instead of aborting the run the way an +// ordinary Sandbox error does, feeds Reason back to the model as normal +// tool-result content — a rejected command becomes something the model can +// read and self-correct around, not a crashed execution. +type RejectionError struct { + // Op is the tool that was rejected: "run_bash" or "write_file". + Op string + // Reason is the Hook's error message explaining the rejection. + Reason string +} + +func (e *RejectionError) Error() string { + return fmt.Sprintf("%s rejected by guardrail: %s", e.Op, e.Reason) +} + +// Guarded wraps a Sandbox with a set of Hooks that run before RunBash/ +// WriteFile delegate to the wrapped Sandbox. All other Sandbox methods +// (ReadFile, Glob, GitClone, GitPush, WorkDir, Cleanup) pass straight +// through unchanged — guardrails only gate the two tools capable of taking +// destructive or exfiltrating action (shell execution and file writes). +type Guarded struct { + Sandbox + Hooks []Hook +} + +var _ Sandbox = (*Guarded)(nil) + +// RunBash runs every Hook.CheckBash before delegating to the wrapped +// Sandbox. On rejection it returns a *RejectionError instead of calling the +// wrapped Sandbox at all — the command never executes. +func (g *Guarded) RunBash(ctx context.Context, command string) (stdout, stderr string, exitCode int, err error) { + for _, h := range g.Hooks { + if rejectErr := h.CheckBash(ctx, command); rejectErr != nil { + return "", "", 0, &RejectionError{Op: "run_bash", Reason: rejectErr.Error()} + } + } + return g.Sandbox.RunBash(ctx, command) +} + +// WriteFile runs every Hook.CheckWrite before delegating to the wrapped +// Sandbox. On rejection it returns a *RejectionError instead of calling the +// wrapped Sandbox at all — the file is never written. +func (g *Guarded) WriteFile(ctx context.Context, path, content string) error { + for _, h := range g.Hooks { + if rejectErr := h.CheckWrite(ctx, path); rejectErr != nil { + return &RejectionError{Op: "write_file", Reason: rejectErr.Error()} + } + } + return g.Sandbox.WriteFile(ctx, path, content) +} + +// DefaultHooks returns the standard guardrail hook set applied uniformly to +// every NativeRunner sandbox (Host or Docker) regardless of provider — see +// internal/executor/nativerunner.go. There is no per-role configurability +// yet; that lands with the role/config model in a later phase. +func DefaultHooks() []Hook { + return []Hook{&DenylistBashHook{}, &ProtectedPathHook{}} +} -- cgit v1.2.3