diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 09:21:32 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 09:21:32 +0000 |
| commit | 767ddade57f189827fa956ff8081ca47404a4798 (patch) | |
| tree | e2b44d0e943e1cc5cf2b11e0b826902a24b63802 /internal/sandbox/hooks.go | |
| parent | 81ae7d598e7ec6afe8afb5bbdbfe807042298559 (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.go | 154 |
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 +} |
