feat: add --learning mode, --template flag, and fix DNS relay
Learning mode (--learning) traces filesystem access with strace and generates minimal sandbox config templates. A background monitor kills strace when the main command exits so long-lived child processes (LSP servers, file watchers) don't cause hangs. Other changes: - Add 'greywall templates list/show' subcommand - Add --template flag to load specific learned templates - Fix DNS relay: use TCP DNS (options use-vc) instead of broken UDP relay through tun2socks - Filter O_DIRECTORY opens from learned read paths - Add docs/experience.md with development notes
This commit is contained in:
401
internal/sandbox/learning_test.go
Normal file
401
internal/sandbox/learning_test.go
Normal file
@@ -0,0 +1,401 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeTemplateName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"opencode", "opencode"},
|
||||
{"my-app", "my-app"},
|
||||
{"my_app", "my_app"},
|
||||
{"my.app", "my.app"},
|
||||
{"my app", "my_app"},
|
||||
{"/usr/bin/opencode", "usr_bin_opencode"},
|
||||
{"my@app!v2", "my_app_v2"},
|
||||
{"", "unknown"},
|
||||
{"///", "unknown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := SanitizeTemplateName(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("SanitizeTemplateName(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLearnedTemplatePath(t *testing.T) {
|
||||
path := LearnedTemplatePath("opencode")
|
||||
if !strings.HasSuffix(path, "/learned/opencode.json") {
|
||||
t.Errorf("LearnedTemplatePath(\"opencode\") = %q, expected suffix /learned/opencode.json", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindApplicationDirectory(t *testing.T) {
|
||||
home := "/home/testuser"
|
||||
tests := []struct {
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{"/home/testuser/.cache/opencode/db/main.sqlite", "/home/testuser/.cache/opencode"},
|
||||
{"/home/testuser/.cache/opencode/version", "/home/testuser/.cache/opencode"},
|
||||
{"/home/testuser/.config/opencode/settings.json", "/home/testuser/.config/opencode"},
|
||||
{"/home/testuser/.local/share/myapp/data", "/home/testuser/.local/share/myapp"},
|
||||
{"/home/testuser/.local/state/myapp/log", "/home/testuser/.local/state/myapp"},
|
||||
// Not under a well-known parent
|
||||
{"/home/testuser/documents/file.txt", ""},
|
||||
{"/home/testuser/.cache", ""},
|
||||
// Different home
|
||||
{"/other/user/.cache/app/file", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := findApplicationDirectory(tt.path, home)
|
||||
if got != tt.expected {
|
||||
t.Errorf("findApplicationDirectory(%q, %q) = %q, want %q", tt.path, home, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapsePaths(t *testing.T) {
|
||||
// Temporarily override home for testing
|
||||
origHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", "/home/testuser")
|
||||
defer os.Setenv("HOME", origHome)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
paths []string
|
||||
contains []string // paths that should be in the result
|
||||
}{
|
||||
{
|
||||
name: "multiple paths under same app dir",
|
||||
paths: []string{
|
||||
"/home/testuser/.cache/opencode/db/main.sqlite",
|
||||
"/home/testuser/.cache/opencode/version",
|
||||
},
|
||||
contains: []string{"/home/testuser/.cache/opencode"},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
paths: nil,
|
||||
contains: nil,
|
||||
},
|
||||
{
|
||||
name: "single path uses parent dir",
|
||||
paths: []string{
|
||||
"/home/testuser/.cache/opencode/version",
|
||||
},
|
||||
contains: []string{"/home/testuser/.cache/opencode"},
|
||||
},
|
||||
{
|
||||
name: "paths from different app dirs",
|
||||
paths: []string{
|
||||
"/home/testuser/.cache/opencode/db",
|
||||
"/home/testuser/.cache/opencode/version",
|
||||
"/home/testuser/.config/opencode/settings.json",
|
||||
},
|
||||
contains: []string{
|
||||
"/home/testuser/.cache/opencode",
|
||||
"/home/testuser/.config/opencode",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := CollapsePaths(tt.paths)
|
||||
if tt.contains == nil {
|
||||
if got != nil {
|
||||
t.Errorf("CollapsePaths() = %v, want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, want := range tt.contains {
|
||||
found := false
|
||||
for _, g := range got {
|
||||
if g == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("CollapsePaths() = %v, missing expected path %q", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDefaultWritablePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{"/dev/null", true},
|
||||
{"/dev/stdout", true},
|
||||
{"/tmp/somefile", false}, // /tmp is tmpfs inside sandbox, not host /tmp
|
||||
{"/home/user/file", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := isDefaultWritablePath(tt.path)
|
||||
if got != tt.expected {
|
||||
t.Errorf("isDefaultWritablePath(%q) = %v, want %v", tt.path, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSensitivePath(t *testing.T) {
|
||||
home := "/home/testuser"
|
||||
tests := []struct {
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{"/home/testuser/.bashrc", true},
|
||||
{"/home/testuser/.gitconfig", true},
|
||||
{"/home/testuser/.ssh/id_rsa", true},
|
||||
{"/home/testuser/.ssh/known_hosts", true},
|
||||
{"/home/testuser/.gnupg/secring.gpg", true},
|
||||
{"/home/testuser/.env", true},
|
||||
{"/home/testuser/project/.env.local", true},
|
||||
{"/home/testuser/.cache/opencode/db", false},
|
||||
{"/home/testuser/documents/readme.txt", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := isSensitivePath(tt.path, home)
|
||||
if got != tt.expected {
|
||||
t.Errorf("isSensitivePath(%q, %q) = %v, want %v", tt.path, home, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeduplicateSubPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
paths []string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "removes sub-paths",
|
||||
paths: []string{"/home/user/.cache", "/home/user/.cache/opencode"},
|
||||
expected: []string{"/home/user/.cache"},
|
||||
},
|
||||
{
|
||||
name: "keeps independent paths",
|
||||
paths: []string{"/home/user/.cache/opencode", "/home/user/.config/opencode"},
|
||||
expected: []string{"/home/user/.cache/opencode", "/home/user/.config/opencode"},
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
paths: nil,
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := deduplicateSubPaths(tt.paths)
|
||||
if len(got) != len(tt.expected) {
|
||||
t.Errorf("deduplicateSubPaths(%v) = %v, want %v", tt.paths, got, tt.expected)
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.expected[i] {
|
||||
t.Errorf("deduplicateSubPaths(%v)[%d] = %q, want %q", tt.paths, i, got[i], tt.expected[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDangerousFilePatterns(t *testing.T) {
|
||||
patterns := getDangerousFilePatterns()
|
||||
if len(patterns) == 0 {
|
||||
t.Error("getDangerousFilePatterns() returned empty list")
|
||||
}
|
||||
// Check some expected patterns
|
||||
found := false
|
||||
for _, p := range patterns {
|
||||
if p == "~/.bashrc" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("getDangerousFilePatterns() missing ~/.bashrc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSensitiveReadPatterns(t *testing.T) {
|
||||
patterns := getSensitiveReadPatterns()
|
||||
if len(patterns) == 0 {
|
||||
t.Error("getSensitiveReadPatterns() returned empty list")
|
||||
}
|
||||
found := false
|
||||
for _, p := range patterns {
|
||||
if p == "~/.ssh/id_*" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("getSensitiveReadPatterns() missing ~/.ssh/id_*")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToTildePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
home string
|
||||
expected string
|
||||
}{
|
||||
{"/home/user/.cache/opencode", "/home/user", "~/.cache/opencode"},
|
||||
{"/other/path", "/home/user", "/other/path"},
|
||||
{"/home/user/.cache/opencode", "", "/home/user/.cache/opencode"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := toTildePath(tt.path, tt.home)
|
||||
if got != tt.expected {
|
||||
t.Errorf("toTildePath(%q, %q) = %q, want %q", tt.path, tt.home, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLearnedTemplates(t *testing.T) {
|
||||
// Use a temp dir to isolate from real user config
|
||||
tmpDir := t.TempDir()
|
||||
origConfigDir := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", origConfigDir)
|
||||
|
||||
// Initially empty
|
||||
templates, err := ListLearnedTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("ListLearnedTemplates() error: %v", err)
|
||||
}
|
||||
if len(templates) != 0 {
|
||||
t.Errorf("expected empty list, got %v", templates)
|
||||
}
|
||||
|
||||
// Create some templates
|
||||
dir := LearnedTemplateDir()
|
||||
os.MkdirAll(dir, 0o755)
|
||||
os.WriteFile(filepath.Join(dir, "opencode.json"), []byte("{}"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "myapp.json"), []byte("{}"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "notjson.txt"), []byte(""), 0o644) // should be ignored
|
||||
|
||||
templates, err = ListLearnedTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("ListLearnedTemplates() error: %v", err)
|
||||
}
|
||||
if len(templates) != 2 {
|
||||
t.Errorf("expected 2 templates, got %d: %v", len(templates), templates)
|
||||
}
|
||||
|
||||
names := make(map[string]bool)
|
||||
for _, tmpl := range templates {
|
||||
names[tmpl.Name] = true
|
||||
}
|
||||
if !names["opencode"] {
|
||||
t.Error("missing template 'opencode'")
|
||||
}
|
||||
if !names["myapp"] {
|
||||
t.Error("missing template 'myapp'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTemplate(t *testing.T) {
|
||||
allowWrite := []string{".", "~/.cache/opencode", "~/.config/opencode"}
|
||||
result := buildTemplate("opencode", allowWrite)
|
||||
|
||||
// Check header comments
|
||||
if !strings.Contains(result, `Learned template for "opencode"`) {
|
||||
t.Error("template missing header comment with command name")
|
||||
}
|
||||
if !strings.Contains(result, "greywall --learning -- opencode") {
|
||||
t.Error("template missing generation command")
|
||||
}
|
||||
if !strings.Contains(result, "Review and adjust paths as needed") {
|
||||
t.Error("template missing review comment")
|
||||
}
|
||||
|
||||
// Check content
|
||||
if !strings.Contains(result, `"allowWrite"`) {
|
||||
t.Error("template missing allowWrite field")
|
||||
}
|
||||
if !strings.Contains(result, `"~/.cache/opencode"`) {
|
||||
t.Error("template missing expected allowWrite path")
|
||||
}
|
||||
if !strings.Contains(result, `"denyWrite"`) {
|
||||
t.Error("template missing denyWrite field")
|
||||
}
|
||||
if !strings.Contains(result, `"denyRead"`) {
|
||||
t.Error("template missing denyRead field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateLearnedTemplate(t *testing.T) {
|
||||
// Create a temp dir for templates
|
||||
tmpDir := t.TempDir()
|
||||
origConfigDir := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", origConfigDir)
|
||||
|
||||
// Create a fake strace log
|
||||
home, _ := os.UserHomeDir()
|
||||
logContent := strings.Join([]string{
|
||||
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/db.sqlite") + `", O_WRONLY|O_CREAT, 0644) = 3`,
|
||||
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/version") + `", O_WRONLY|O_CREAT, 0644) = 3`,
|
||||
`12345 mkdirat(AT_FDCWD, "` + filepath.Join(home, ".config/testapp") + `", 0755) = 0`,
|
||||
`12345 openat(AT_FDCWD, "/tmp/somefile", O_WRONLY|O_CREAT, 0644) = 3`,
|
||||
`12345 openat(AT_FDCWD, "/proc/self/maps", O_RDONLY) = 3`,
|
||||
}, "\n")
|
||||
|
||||
logFile := filepath.Join(tmpDir, "strace.log")
|
||||
if err := os.WriteFile(logFile, []byte(logContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
templatePath, err := GenerateLearnedTemplate(logFile, "testapp", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateLearnedTemplate() error: %v", err)
|
||||
}
|
||||
|
||||
if templatePath == "" {
|
||||
t.Fatal("GenerateLearnedTemplate() returned empty path")
|
||||
}
|
||||
|
||||
// Read and verify template
|
||||
data, err := os.ReadFile(templatePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read template: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "testapp") {
|
||||
t.Error("template doesn't contain command name")
|
||||
}
|
||||
if !strings.Contains(content, "allowWrite") {
|
||||
t.Error("template doesn't contain allowWrite")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user