feat: add --learning mode, --template flag, and fix DNS relay
Some checks failed
Build and test / Lint (push) Failing after 1m29s
Build and test / Build (push) Successful in 13s
Build and test / Test (Linux) (push) Failing after 58s
Build and test / Test (macOS) (push) Has been cancelled

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:
2026-02-11 08:22:53 -06:00
parent 631db40665
commit 3dd772d35a
14 changed files with 1854 additions and 124 deletions

View 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")
}
}