feat(sandbox): restrict curl/wget to localhost and RFC 1918 in all exec presets
Subagents creating servers could not verify their own work because curl/wget had no access to localhost. This adds local-network-only curl/wget access to all exec-capable presets: - coder/worker: curl|wget added to exec allowlist (previously absent) - analyst/coordinator: existing curl|wget allowlist kept as-is - All exec presets: ExecPolicy.LocalNetOnly=true enforced at guardCommand Uses net.ParseIP + IP.IsLoopback() + IP.IsPrivate() (Go stdlib) for host validation. DNS resolution is intentionally avoided to prevent DNS rebinding. External HTTP remains available via the web_fetch tool. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
aab927abc4
commit
ba2c0055f8
5 changed files with 170 additions and 3 deletions
|
|
@ -23,6 +23,7 @@ func IsValidPreset(p Preset) bool {
|
||||||
// ExecPolicy defines which commands are allowed for execution.
|
// ExecPolicy defines which commands are allowed for execution.
|
||||||
type ExecPolicy struct {
|
type ExecPolicy struct {
|
||||||
AllowPattern string // Prefix-match regex; matched commands are allowed
|
AllowPattern string // Prefix-match regex; matched commands are allowed
|
||||||
|
LocalNetOnly bool // Restrict curl/wget to localhost and RFC 1918 private addresses
|
||||||
}
|
}
|
||||||
|
|
||||||
// SandboxConfig describes the sandbox isolation policy for a preset.
|
// SandboxConfig describes the sandbox isolation policy for a preset.
|
||||||
|
|
@ -44,11 +45,13 @@ type SubagentEnvironment struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// presetExecPatterns maps presets to command allowlist regexes.
|
// presetExecPatterns maps presets to command allowlist regexes.
|
||||||
|
// curl/wget are included where exec is allowed; LocalNetOnly in ExecPolicy
|
||||||
|
// ensures all curl/wget requests are restricted to localhost and RFC 1918 addresses.
|
||||||
var presetExecPatterns = map[Preset]string{
|
var presetExecPatterns = map[Preset]string{
|
||||||
PresetScout: ``, // No exec allowed
|
PresetScout: ``, // No exec allowed
|
||||||
PresetAnalyst: `^(go\s+(test|vet)|git\s+(log|diff|status)|curl|wget|grep|find)\b`,
|
PresetAnalyst: `^(go\s+(test|vet)|git\s+(log|diff|status)|curl|wget|grep|find)\b`,
|
||||||
PresetCoder: `^(go\s+(test|vet|fmt)|gofmt|goimports|golangci-lint|prettier|eslint|black|ruff|cargo\s+(test|fmt|clippy)|pnpm\s+(test|run\s+(test|lint|format))|bun\s+(test|run\s+(test|lint|format))|uv\s+run\s+)\b`,
|
PresetCoder: `^(go\s+(test|vet|fmt)|gofmt|goimports|golangci-lint|prettier|eslint|black|ruff|cargo\s+(test|fmt|clippy)|pnpm\s+(test|run\s+(test|lint|format))|bun\s+(test|run\s+(test|lint|format))|uv\s+run\s+|curl|wget)\b`,
|
||||||
PresetWorker: `^(go\s+|pnpm\s+(install|add|run|test|build)|bun\s+(install|add|run|test|build)|uv\s+(run|sync|add|pip\s+install)|pip\s+install|cargo\s+)\b`,
|
PresetWorker: `^(go\s+|pnpm\s+(install|add|run|test|build)|bun\s+(install|add|run|test|build)|uv\s+(run|sync|add|pip\s+install)|pip\s+install|cargo\s+|curl|wget)\b`,
|
||||||
PresetCoordinator: `^(go\s+|pnpm\s+|bun\s+|curl|wget)\b`,
|
PresetCoordinator: `^(go\s+|pnpm\s+|bun\s+|curl|wget)\b`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,11 +110,14 @@ func SandboxConfigForPreset(p Preset, writeRoot string) SandboxConfig {
|
||||||
config.WriteRoot = writeRoot
|
config.WriteRoot = writeRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set ExecPolicy if exec is allowed and pattern is non-empty
|
// Set ExecPolicy if exec is allowed and pattern is non-empty.
|
||||||
|
// LocalNetOnly is always true: curl/wget in subagents is for local server
|
||||||
|
// testing only; external HTTP access goes through the web_fetch tool.
|
||||||
if allowed["exec"] {
|
if allowed["exec"] {
|
||||||
if pattern := presetExecPatterns[p]; pattern != "" {
|
if pattern := presetExecPatterns[p]; pattern != "" {
|
||||||
config.ExecPolicy = &ExecPolicy{
|
config.ExecPolicy = &ExecPolicy{
|
||||||
AllowPattern: pattern,
|
AllowPattern: pattern,
|
||||||
|
LocalNetOnly: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,8 @@ func TestSandboxConfigForPreset_Coder(t *testing.T) {
|
||||||
}
|
}
|
||||||
if config.ExecPolicy == nil {
|
if config.ExecPolicy == nil {
|
||||||
t.Errorf("ExecPolicy: got nil, want non-nil")
|
t.Errorf("ExecPolicy: got nil, want non-nil")
|
||||||
|
} else if !config.ExecPolicy.LocalNetOnly {
|
||||||
|
t.Errorf("ExecPolicy.LocalNetOnly: got false, want true")
|
||||||
}
|
}
|
||||||
if config.SpawnablePresets != nil {
|
if config.SpawnablePresets != nil {
|
||||||
t.Errorf("SpawnablePresets: got non-nil, want nil")
|
t.Errorf("SpawnablePresets: got non-nil, want nil")
|
||||||
|
|
@ -146,6 +148,8 @@ func TestSandboxConfigForPreset_Coordinator(t *testing.T) {
|
||||||
}
|
}
|
||||||
if config.ExecPolicy == nil {
|
if config.ExecPolicy == nil {
|
||||||
t.Errorf("ExecPolicy: got nil, want non-nil")
|
t.Errorf("ExecPolicy: got nil, want non-nil")
|
||||||
|
} else if !config.ExecPolicy.LocalNetOnly {
|
||||||
|
t.Errorf("ExecPolicy.LocalNetOnly: got false, want true")
|
||||||
}
|
}
|
||||||
if config.SpawnablePresets == nil {
|
if config.SpawnablePresets == nil {
|
||||||
t.Errorf("SpawnablePresets: got nil, want non-nil")
|
t.Errorf("SpawnablePresets: got nil, want non-nil")
|
||||||
|
|
@ -186,6 +190,9 @@ func TestPresetExecPatterns_Coder(t *testing.T) {
|
||||||
{"go build ./...", false},
|
{"go build ./...", false},
|
||||||
{"npm install", false},
|
{"npm install", false},
|
||||||
{"pnpm test", true},
|
{"pnpm test", true},
|
||||||
|
// curl/wget are in the allowlist; LocalNetOnly enforcement is at runtime
|
||||||
|
{"curl http://localhost:3000/health", true},
|
||||||
|
{"wget http://127.0.0.1:8080/status", true},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -122,6 +124,7 @@ type ExecTool struct {
|
||||||
denyPatterns []*regexp.Regexp
|
denyPatterns []*regexp.Regexp
|
||||||
allowPatterns []*regexp.Regexp
|
allowPatterns []*regexp.Regexp
|
||||||
restrictToWorkspace bool
|
restrictToWorkspace bool
|
||||||
|
localNetOnly bool // restrict curl/wget to localhost + RFC 1918
|
||||||
|
|
||||||
// Background process management
|
// Background process management
|
||||||
bgMu sync.Mutex
|
bgMu sync.Mutex
|
||||||
|
|
@ -727,6 +730,14 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restrict curl/wget to localhost and RFC 1918 private addresses.
|
||||||
|
// External HTTP access is available via the web_fetch tool.
|
||||||
|
if t.localNetOnly && isCurlOrWget(cmd) {
|
||||||
|
if errMsg := checkCurlLocalNet(cmd); errMsg != "" {
|
||||||
|
return errMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if t.restrictToWorkspace {
|
if t.restrictToWorkspace {
|
||||||
if strings.Contains(cmd, "..\\") || strings.Contains(cmd, "../") {
|
if strings.Contains(cmd, "..\\") || strings.Contains(cmd, "../") {
|
||||||
return "Command blocked by safety guard (path traversal detected)"
|
return "Command blocked by safety guard (path traversal detected)"
|
||||||
|
|
@ -842,6 +853,54 @@ func (t *ExecTool) SetAllowPatterns(patterns []string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *ExecTool) SetLocalNetOnly(v bool) {
|
||||||
|
t.localNetOnly = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCurlOrWget reports whether command is a curl or wget invocation.
|
||||||
|
func isCurlOrWget(command string) bool {
|
||||||
|
fields := strings.Fields(command)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
base := filepath.Base(fields[0])
|
||||||
|
return base == "curl" || base == "wget"
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCurlLocalNet validates that all http/https URLs in a curl/wget command
|
||||||
|
// target localhost or RFC 1918 private addresses.
|
||||||
|
// Returns an error message string, or empty string if the command is allowed.
|
||||||
|
func checkCurlLocalNet(command string) string {
|
||||||
|
for _, token := range strings.Fields(command) {
|
||||||
|
token = strings.Trim(token, "\"'")
|
||||||
|
if !strings.HasPrefix(token, "http://") && !strings.HasPrefix(token, "https://") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u, err := url.Parse(token)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
host := u.Hostname()
|
||||||
|
if !isLocalHost(host) {
|
||||||
|
return fmt.Sprintf("Command blocked by safety guard (curl/wget is restricted to localhost and private network; %q is a public address)", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLocalHost reports whether host is localhost or a loopback/RFC 1918 private IP.
|
||||||
|
// DNS resolution is intentionally avoided to prevent DNS rebinding attacks.
|
||||||
|
func isLocalHost(host string) bool {
|
||||||
|
if strings.EqualFold(host, "localhost") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ip.IsLoopback() || ip.IsPrivate()
|
||||||
|
}
|
||||||
|
|
||||||
// SetBgMaxLifetimeForTest overrides bgMaxLifetime for testing purposes.
|
// SetBgMaxLifetimeForTest overrides bgMaxLifetime for testing purposes.
|
||||||
// This is exposed only for tests; the returned function restores the original value.
|
// This is exposed only for tests; the returned function restores the original value.
|
||||||
var bgMaxLifetimeOverride time.Duration
|
var bgMaxLifetimeOverride time.Duration
|
||||||
|
|
|
||||||
|
|
@ -983,3 +983,97 @@ func TestExecTool_Bg_RingBufferOverflow(t *testing.T) {
|
||||||
t.Errorf("ring buffer exceeded max size: %d > %d", bufLen, bgRingBufSize)
|
t.Errorf("ring buffer exceeded max size: %d > %d", bufLen, bgRingBufSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIsLocalHost verifies localhost and RFC 1918 detection using net package.
|
||||||
|
func TestIsLocalHost(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
host string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Loopback / localhost
|
||||||
|
{"localhost", true},
|
||||||
|
{"LOCALHOST", true},
|
||||||
|
{"127.0.0.1", true},
|
||||||
|
{"127.0.0.2", true},
|
||||||
|
{"::1", true},
|
||||||
|
// RFC 1918 private ranges
|
||||||
|
{"10.0.0.1", true},
|
||||||
|
{"10.255.255.255", true},
|
||||||
|
{"172.16.0.1", true},
|
||||||
|
{"172.31.255.255", true},
|
||||||
|
{"192.168.0.1", true},
|
||||||
|
{"192.168.1.100", true},
|
||||||
|
// Public addresses
|
||||||
|
{"8.8.8.8", false},
|
||||||
|
{"1.1.1.1", false},
|
||||||
|
{"example.com", false},
|
||||||
|
{"api.github.com", false},
|
||||||
|
// Edge: non-private but routable private-looking address
|
||||||
|
{"172.15.255.255", false}, // just below 172.16/12
|
||||||
|
{"172.32.0.0", false}, // just above 172.31/12
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := isLocalHost(tt.host)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("isLocalHost(%q) = %v, want %v", tt.host, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckCurlLocalNet verifies URL-level enforcement for curl/wget commands.
|
||||||
|
func TestCheckCurlLocalNet(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
cmd string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
// Allowed: localhost and private IPs
|
||||||
|
{"curl http://localhost:3000/health", false},
|
||||||
|
{"curl -v http://127.0.0.1:8080/api/status", false},
|
||||||
|
{"wget http://192.168.1.10/file.bin", false},
|
||||||
|
{"curl -X POST http://10.0.0.5:9000/webhook", false},
|
||||||
|
// Blocked: public addresses
|
||||||
|
{"curl http://example.com", true},
|
||||||
|
{"wget https://releases.github.com/v1.tar.gz", true},
|
||||||
|
{"curl http://8.8.8.8/data", true},
|
||||||
|
// Allowed: no http URL (e.g. --help, --version — no network access)
|
||||||
|
{"curl --help", false},
|
||||||
|
{"curl --version", false},
|
||||||
|
{"wget --help", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
errMsg := checkCurlLocalNet(tt.cmd)
|
||||||
|
gotErr := errMsg != ""
|
||||||
|
if gotErr != tt.wantErr {
|
||||||
|
t.Errorf("checkCurlLocalNet(%q): gotErr=%v wantErr=%v (msg: %q)",
|
||||||
|
tt.cmd, gotErr, tt.wantErr, errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecTool_LocalNetOnly verifies curl/wget blocking via SetLocalNetOnly.
|
||||||
|
func TestExecTool_LocalNetOnly(t *testing.T) {
|
||||||
|
tool := NewExecTool("", false)
|
||||||
|
tool.SetLocalNetOnly(true)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
cmd string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"curl http://localhost:3000", false},
|
||||||
|
{"curl http://example.com", true},
|
||||||
|
{"echo hello", false}, // non-curl not affected
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := tool.Execute(ctx, map[string]any{"command": tt.cmd})
|
||||||
|
if tt.wantErr && !result.IsError {
|
||||||
|
t.Errorf("cmd %q: expected blocked, but succeeded", tt.cmd)
|
||||||
|
}
|
||||||
|
if !tt.wantErr && result.IsError && strings.Contains(result.ForLLM, "safety guard") {
|
||||||
|
t.Errorf("cmd %q: expected allowed, but safety guard blocked: %s", tt.cmd, result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,7 @@ func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string)
|
||||||
execTool := sm.execTool
|
execTool := sm.execTool
|
||||||
if config.ExecPolicy != nil {
|
if config.ExecPolicy != nil {
|
||||||
_ = execTool.SetAllowPatterns([]string{config.ExecPolicy.AllowPattern})
|
_ = execTool.SetAllowPatterns([]string{config.ExecPolicy.AllowPattern})
|
||||||
|
execTool.SetLocalNetOnly(config.ExecPolicy.LocalNetOnly)
|
||||||
}
|
}
|
||||||
registry.Register(execTool)
|
registry.Register(execTool)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue