picoclaw/pkg/tools/sandbox_test.go
dj-oyu 31caaf3836 refactor(sandbox): replace regex exec allowlist with command prefix dictionary
Replace the regex-based presetExecPatterns with presetAllowRules, a
dictionary of command prefix strings (e.g. "go test", "pnpm run lint").

Benefits:
- Readable at a glance without decoding regex alternations
- No regex compilation; matching is a simple word-prefix check
- Easy to extend with new commands per preset
- Each preset's permissions are an explicit list

ExecPolicy.AllowPattern (string) → ExecPolicy.AllowRules ([]string).
ExecTool.SetAllowPatterns → ExecTool.SetAllowRules.
Tests rewritten to use guardCommand directly instead of regex matching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 02:03:10 +09:00

360 lines
9.1 KiB
Go

package tools
import (
"testing"
)
// TestAllowedToolsForPreset checks that each preset has appropriate tool access.
func TestAllowedToolsForPreset(t *testing.T) {
tests := []struct {
name string
preset Preset
wantRead bool
wantWrite bool
wantExec bool
wantSpawn bool
wantWebSearch bool
}{
{
name: "scout",
preset: PresetScout,
wantRead: true,
wantWrite: false,
wantExec: false,
wantSpawn: false,
wantWebSearch: true,
},
{
name: "analyst",
preset: PresetAnalyst,
wantRead: true,
wantWrite: false,
wantExec: true,
wantSpawn: false,
wantWebSearch: true,
},
{
name: "coder",
preset: PresetCoder,
wantRead: true,
wantWrite: true,
wantExec: true,
wantSpawn: false,
wantWebSearch: true,
},
{
name: "worker",
preset: PresetWorker,
wantRead: true,
wantWrite: true,
wantExec: true,
wantSpawn: false,
wantWebSearch: true,
},
{
name: "coordinator",
preset: PresetCoordinator,
wantRead: true,
wantWrite: true,
wantExec: true,
wantSpawn: true,
wantWebSearch: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
allowed := AllowedToolsForPreset(tt.preset)
// Check read tools
if got := allowed["read_file"]; got != tt.wantRead {
t.Errorf("read_file: got %v, want %v", got, tt.wantRead)
}
if got := allowed["list_dir"]; got != tt.wantRead {
t.Errorf("list_dir: got %v, want %v", got, tt.wantRead)
}
// Check write tools
if got := allowed["write_file"]; got != tt.wantWrite {
t.Errorf("write_file: got %v, want %v", got, tt.wantWrite)
}
// Check exec
if got := allowed["exec"]; got != tt.wantExec {
t.Errorf("exec: got %v, want %v", got, tt.wantExec)
}
// Check spawn
if got := allowed["spawn"]; got != tt.wantSpawn {
t.Errorf("spawn: got %v, want %v", got, tt.wantSpawn)
}
// Check web_search (all presets should have it)
if got := allowed["web_search"]; got != tt.wantWebSearch {
t.Errorf("web_search: got %v, want %v", got, tt.wantWebSearch)
}
})
}
}
// TestSandboxConfigForPreset_Scout checks scout preset isolation.
func TestSandboxConfigForPreset_Scout(t *testing.T) {
config := SandboxConfigForPreset(PresetScout, "/tmp/scout")
if config.Preset != PresetScout {
t.Errorf("Preset: got %v, want %v", config.Preset, PresetScout)
}
if config.WriteRoot != "" {
t.Errorf("WriteRoot: got %q, want empty", config.WriteRoot)
}
if config.ExecPolicy != nil {
t.Errorf("ExecPolicy: got non-nil, want nil")
}
if config.SpawnablePresets != nil {
t.Errorf("SpawnablePresets: got non-nil, want nil")
}
}
// TestSandboxConfigForPreset_Coder checks coder preset isolation.
func TestSandboxConfigForPreset_Coder(t *testing.T) {
config := SandboxConfigForPreset(PresetCoder, "/tmp/coder")
if config.Preset != PresetCoder {
t.Errorf("Preset: got %v, want %v", config.Preset, PresetCoder)
}
if config.WriteRoot != "/tmp/coder" {
t.Errorf("WriteRoot: got %q, want /tmp/coder", config.WriteRoot)
}
if config.ExecPolicy == 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 {
t.Errorf("SpawnablePresets: got non-nil, want nil")
}
}
// TestSandboxConfigForPreset_Coordinator checks coordinator preset isolation.
func TestSandboxConfigForPreset_Coordinator(t *testing.T) {
config := SandboxConfigForPreset(PresetCoordinator, "/tmp/coord")
if config.Preset != PresetCoordinator {
t.Errorf("Preset: got %v, want %v", config.Preset, PresetCoordinator)
}
if config.WriteRoot != "/tmp/coord" {
t.Errorf("WriteRoot: got %q, want /tmp/coord", config.WriteRoot)
}
if config.ExecPolicy == 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 {
t.Errorf("SpawnablePresets: got nil, want non-nil")
}
// Verify coordinator cannot spawn itself
canSpawnCoordinator := false
for _, p := range config.SpawnablePresets {
if p == "coordinator" {
canSpawnCoordinator = true
break
}
}
if canSpawnCoordinator {
t.Errorf("Coordinator should not be in SpawnablePresets")
}
}
// TestPresetAllowRules_Coder validates coder exec allowlist.
func TestPresetAllowRules_Coder(t *testing.T) {
rules := presetAllowRules[PresetCoder]
if len(rules) == 0 {
t.Fatalf("coder rules missing or empty")
}
exec := NewExecTool(t.TempDir(), true)
exec.SetAllowRules(rules)
tests := []struct {
cmd string
wantOK bool
}{
{"go test ./...", true},
{"go vet ./...", true},
{"go fmt ./...", true},
{"gofmt -w file.go", true},
{"golangci-lint run", true},
{"cargo test", true},
{"cargo fmt", true},
{"cargo clippy", true},
{"pnpm test", true},
{"pnpm run test", true},
{"pnpm run lint", true},
{"pnpm run format", true},
{"bun test", true},
{"bun run test", true},
{"uv run pytest", 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},
// blocked
{"go build ./...", false},
{"npm install", false},
{"pnpm install", false},
{"pnpm run build", false},
{"cargo build", false},
{"pwd", false},
{"ls", false},
}
for _, tt := range tests {
result := exec.guardCommand(tt.cmd, t.TempDir())
gotOK := result == ""
if gotOK != tt.wantOK {
t.Errorf("cmd %q: got allowed=%v, want %v (guard: %q)", tt.cmd, gotOK, tt.wantOK, result)
}
}
}
// TestPresetAllowRules_Analyst validates analyst exec allowlist.
func TestPresetAllowRules_Analyst(t *testing.T) {
rules := presetAllowRules[PresetAnalyst]
if len(rules) == 0 {
t.Fatalf("analyst rules missing or empty")
}
exec := NewExecTool(t.TempDir(), true)
exec.SetAllowRules(rules)
tests := []struct {
cmd string
wantOK bool
}{
{"go test ./...", true},
{"go vet ./...", true},
{"git log --oneline", true},
{"git diff HEAD", true},
{"git status", true},
{"grep pattern file", true},
{"find . -name '*.go'", true},
{"curl http://example.com", true},
// blocked
{"go build ./...", false},
{"npm install", false},
{"git push", false},
{"git checkout", false},
{"pwd", false},
{"ls", false},
}
for _, tt := range tests {
result := exec.guardCommand(tt.cmd, t.TempDir())
gotOK := result == ""
if gotOK != tt.wantOK {
t.Errorf("cmd %q: got allowed=%v, want %v (guard: %q)", tt.cmd, gotOK, tt.wantOK, result)
}
}
}
// TestPresetAllowRules_Worker validates worker exec allowlist.
func TestPresetAllowRules_Worker(t *testing.T) {
rules := presetAllowRules[PresetWorker]
if len(rules) == 0 {
t.Fatalf("worker rules missing or empty")
}
exec := NewExecTool(t.TempDir(), true)
exec.SetAllowRules(rules)
tests := []struct {
cmd string
wantOK bool
}{
{"go build ./...", true},
{"go test ./...", true},
{"pnpm install", true},
{"pnpm add lodash", true},
{"pnpm run dev", true},
{"bun install", true},
{"bun build", true},
{"uv run pytest", true},
{"uv sync", true},
{"uv pip install flask", true},
{"pip install flask", true},
{"cargo build", true},
{"cargo test", true},
{"curl http://localhost:8080", true},
// blocked
{"npm install", false},
{"pwd", false},
}
for _, tt := range tests {
result := exec.guardCommand(tt.cmd, t.TempDir())
gotOK := result == ""
if gotOK != tt.wantOK {
t.Errorf("cmd %q: got allowed=%v, want %v (guard: %q)", tt.cmd, gotOK, tt.wantOK, result)
}
}
}
// TestPresetAllowRules_Coordinator validates coordinator exec allowlist.
func TestPresetAllowRules_Coordinator(t *testing.T) {
rules := presetAllowRules[PresetCoordinator]
if len(rules) == 0 {
t.Fatalf("coordinator rules missing or empty")
}
exec := NewExecTool(t.TempDir(), true)
exec.SetAllowRules(rules)
tests := []struct {
cmd string
wantOK bool
}{
{"go build ./...", true},
{"go test ./...", true},
{"pnpm install", true},
{"pnpm run dev", true},
{"bun install", true},
{"bun run dev", true},
{"curl http://localhost:8080", true},
{"wget http://127.0.0.1:3000", true},
// blocked
{"npm install", false},
{"cargo build", false},
{"pwd", false},
}
for _, tt := range tests {
result := exec.guardCommand(tt.cmd, t.TempDir())
gotOK := result == ""
if gotOK != tt.wantOK {
t.Errorf("cmd %q: got allowed=%v, want %v (guard: %q)", tt.cmd, gotOK, tt.wantOK, result)
}
}
}
// TestIsValidPreset checks preset validation.
func TestIsValidPreset(t *testing.T) {
tests := []struct {
preset Preset
valid bool
}{
{PresetScout, true},
{PresetAnalyst, true},
{PresetCoder, true},
{PresetWorker, true},
{PresetCoordinator, true},
{Preset("invalid"), false},
{Preset(""), false},
}
for _, tt := range tests {
gotValid := IsValidPreset(tt.preset)
if gotValid != tt.valid {
t.Errorf("preset %q: got %v, want %v", tt.preset, gotValid, tt.valid)
}
}
}