summaryrefslogtreecommitdiff
path: root/internal/sandbox/hooks.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/hooks.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/hooks.go')
-rw-r--r--internal/sandbox/hooks.go154
1 files changed, 154 insertions, 0 deletions
diff --git a/internal/sandbox/hooks.go b/internal/sandbox/hooks.go
new file mode 100644
index 0000000..e40e6d5
--- /dev/null
+++ b/internal/sandbox/hooks.go
@@ -0,0 +1,154 @@
+package sandbox
+
+import (
+ "context"
+ "fmt"
+ "path"
+ "regexp"
+ "strings"
+)
+
+// DefaultBashDenylist is the built-in set of dangerous command patterns
+// DenylistBashHook rejects. It intentionally stays narrow: a false positive
+// on an ordinary dev workflow (git status, go test ./..., ls, rm of a
+// specific file, chmod 644, ...) is worse than missing an exotic bypass,
+// because a rejection is not fatal (see RejectionError) — the model reads
+// the reason and can still get equivalent work done another way. This list
+// is a speed bump against obviously catastrophic or exfiltration-flavored
+// commands, not a security boundary; the sandbox's own isolation (container
+// or host path confinement) is what actually contains a run.
+var DefaultBashDenylist = []*regexp.Regexp{
+ // rm -rf / or rm -rf /* — wipe the filesystem root. Deliberately scoped
+ // to "/" or "/*" as the target so "rm -rf /workspace/tmp" (a normal,
+ // scoped cleanup) is not caught.
+ regexp.MustCompile(`\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*[rf][a-zA-Z]*|--recursive\s+--force|--force\s+--recursive)\s+/(\s|\*|$)`),
+ // force-push to a remote.
+ regexp.MustCompile(`\bgit\s+push\b[^\n]*(--force\b|(^|\s)-f(\s|$))`),
+ // pipe a remote script straight into a shell.
+ regexp.MustCompile(`\bcurl\b[^|\n]*\|\s*(sudo\s+)?(sh|bash|zsh)\b`),
+ regexp.MustCompile(`\bwget\b[^|\n]*\|\s*(sudo\s+)?(sh|bash|zsh)\b`),
+ // privilege escalation.
+ regexp.MustCompile(`(^|[;&|]\s*)sudo\s`),
+ // world-writable permission grants.
+ regexp.MustCompile(`\bchmod\s+(-R\s+)?0?777\b`),
+ // raw disk writes.
+ regexp.MustCompile(`\bdd\s+if=`),
+}
+
+// DenylistBashHook rejects run_bash commands matching any of Patterns
+// (DefaultBashDenylist if nil). It never inspects write_file calls.
+type DenylistBashHook struct {
+ // Patterns overrides DefaultBashDenylist when non-nil, so callers can
+ // tune the list without editing this file.
+ Patterns []*regexp.Regexp
+}
+
+func (h *DenylistBashHook) patterns() []*regexp.Regexp {
+ if h.Patterns != nil {
+ return h.Patterns
+ }
+ return DefaultBashDenylist
+}
+
+func (h *DenylistBashHook) CheckBash(_ context.Context, command string) error {
+ for _, p := range h.patterns() {
+ if p.MatchString(command) {
+ return fmt.Errorf("command matches denylisted pattern %q", p.String())
+ }
+ }
+ return nil
+}
+
+// CheckWrite is a no-op; DenylistBashHook only gates run_bash.
+func (h *DenylistBashHook) CheckWrite(context.Context, string) error { return nil }
+
+var _ Hook = (*DenylistBashHook)(nil)
+
+// DefaultProtectedPathPatterns is the built-in set of path patterns
+// ProtectedPathHook rejects writes to. A pattern ending in "/**" protects
+// that directory and everything under it at any depth (path.Match has no
+// cross-slash wildcard, so this suffix is handled specially — see
+// matchProtectedPattern); any other pattern is matched with path.Match
+// (shell-glob semantics: "*" matches within one path segment).
+//
+// Patterns are checked against every path suffix of the write target
+// (i.e. at every "/"-boundary), so ".git/**" rejects both ".git/config" and
+// "nested/project/.git/config", not just a top-level ".git".
+var DefaultProtectedPathPatterns = []string{
+ ".git/**",
+ ".env",
+ ".env.*",
+ ".github/workflows/**",
+}
+
+// DefaultProtectedPathSubstrings is matched as a plain substring of the
+// (cleaned, slash-normalized) write path — for patterns glob syntax can't
+// express, like "any path containing a credentials/ directory".
+var DefaultProtectedPathSubstrings = []string{
+ "credentials/",
+}
+
+// ProtectedPathHook rejects write_file calls whose path matches any of
+// Patterns/Substrings (DefaultProtectedPathPatterns/
+// DefaultProtectedPathSubstrings if nil). It never inspects run_bash
+// commands — a determined agent can still edit a protected file via
+// run_bash (echo/sed/etc.); that's accepted for this phase, see
+// internal/sandbox/dockersandbox.go and hostsandbox.go doc comments on the
+// overall trust model. ProtectedPathHook exists to stop the common case: a
+// model calling write_file directly on something like .env or
+// .git/config.
+type ProtectedPathHook struct {
+ Patterns []string
+ Substrings []string
+}
+
+func (h *ProtectedPathHook) patterns() []string {
+ if h.Patterns != nil {
+ return h.Patterns
+ }
+ return DefaultProtectedPathPatterns
+}
+
+func (h *ProtectedPathHook) substrings() []string {
+ if h.Substrings != nil {
+ return h.Substrings
+ }
+ return DefaultProtectedPathSubstrings
+}
+
+func (h *ProtectedPathHook) CheckWrite(_ context.Context, p string) error {
+ clean := strings.TrimPrefix(path.Clean(strings.ReplaceAll(p, "\\", "/")), "/")
+
+ for _, sub := range h.substrings() {
+ if strings.Contains(clean, sub) {
+ return fmt.Errorf("path %q contains protected substring %q", p, sub)
+ }
+ }
+
+ parts := strings.Split(clean, "/")
+ for i := range parts {
+ suffix := strings.Join(parts[i:], "/")
+ for _, pat := range h.patterns() {
+ if matchProtectedPattern(pat, suffix) {
+ return fmt.Errorf("path %q matches protected pattern %q", p, pat)
+ }
+ }
+ }
+ return nil
+}
+
+// CheckBash is a no-op; ProtectedPathHook only gates write_file.
+func (h *ProtectedPathHook) CheckBash(context.Context, string) error { return nil }
+
+var _ Hook = (*ProtectedPathHook)(nil)
+
+// matchProtectedPattern matches name against pat. A pat ending in "/**"
+// means "this directory or anything beneath it"; any other pat uses
+// path.Match's shell-glob semantics ("*" bounded by "/").
+func matchProtectedPattern(pat, name string) bool {
+ if prefix, ok := strings.CutSuffix(pat, "/**"); ok {
+ return name == prefix || strings.HasPrefix(name, prefix+"/")
+ }
+ ok, _ := path.Match(pat, name)
+ return ok
+}