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:
243
internal/sandbox/learning_linux_test.go
Normal file
243
internal/sandbox/learning_linux_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
//go:build linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractWritePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "openat with O_WRONLY",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/.cache/opencode/db", O_WRONLY|O_CREAT, 0644) = 3`,
|
||||
expected: "/home/user/.cache/opencode/db",
|
||||
},
|
||||
{
|
||||
name: "openat with O_RDWR",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/.cache/opencode/data", O_RDWR|O_CREAT, 0644) = 3`,
|
||||
expected: "/home/user/.cache/opencode/data",
|
||||
},
|
||||
{
|
||||
name: "openat with O_CREAT",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/file.txt", O_CREAT|O_WRONLY, 0644) = 3`,
|
||||
expected: "/home/user/file.txt",
|
||||
},
|
||||
{
|
||||
name: "openat read-only ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/readme.txt", O_RDONLY) = 3`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "mkdirat",
|
||||
line: `12345 mkdirat(AT_FDCWD, "/home/user/.cache/opencode", 0755) = 0`,
|
||||
expected: "/home/user/.cache/opencode",
|
||||
},
|
||||
{
|
||||
name: "unlinkat",
|
||||
line: `12345 unlinkat(AT_FDCWD, "/home/user/temp.txt", 0) = 0`,
|
||||
expected: "/home/user/temp.txt",
|
||||
},
|
||||
{
|
||||
name: "creat",
|
||||
line: `12345 creat("/home/user/newfile", 0644) = 3`,
|
||||
expected: "/home/user/newfile",
|
||||
},
|
||||
{
|
||||
name: "failed syscall ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/nonexistent", O_WRONLY|O_CREAT, 0644) = -1 ENOENT (No such file or directory)`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "unfinished syscall ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/file", O_WRONLY <unfinished ...>`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "non-write syscall ignored",
|
||||
line: `12345 read(3, "data", 1024) = 5`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "renameat2 returns destination",
|
||||
line: `12345 renameat2(AT_FDCWD, "/home/user/old.txt", AT_FDCWD, "/home/user/new.txt", 0) = 0`,
|
||||
expected: "/home/user/new.txt",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractWritePath(tt.line)
|
||||
if got != tt.expected {
|
||||
t.Errorf("extractWritePath(%q) = %q, want %q", tt.line, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldFilterPath(t *testing.T) {
|
||||
home := "/home/testuser"
|
||||
tests := []struct {
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{"/proc/self/maps", true},
|
||||
{"/sys/kernel/mm/transparent_hugepage", true},
|
||||
{"/dev/null", true},
|
||||
{"/tmp/somefile", true},
|
||||
{"/run/user/1000/bus", true},
|
||||
{"/home/testuser/.cache/opencode/db", false},
|
||||
{"/usr/lib/libfoo.so", true}, // .so file
|
||||
{"/usr/lib/libfoo.so.1", true}, // .so.X file
|
||||
{"/tmp/greywall-strace-abc.log", true}, // greywall infrastructure
|
||||
{"relative/path", true}, // relative path
|
||||
{"", true}, // empty path
|
||||
{"/other/user/file", true}, // outside home
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := shouldFilterPath(tt.path, home)
|
||||
if got != tt.expected {
|
||||
t.Errorf("shouldFilterPath(%q, %q) = %v, want %v", tt.path, home, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStraceLog(t *testing.T) {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
logContent := strings.Join([]string{
|
||||
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/db") + `", O_WRONLY|O_CREAT, 0644) = 3`,
|
||||
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/ver") + `", O_WRONLY, 0644) = 4`,
|
||||
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".config/testapp/conf.json") + `", O_RDONLY) = 5`,
|
||||
`12345 openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 6`,
|
||||
`12345 mkdirat(AT_FDCWD, "` + filepath.Join(home, ".config/testapp") + `", 0755) = 0`,
|
||||
`12345 openat(AT_FDCWD, "/tmp/somefile", O_WRONLY|O_CREAT, 0644) = 7`,
|
||||
`12345 openat(AT_FDCWD, "/proc/self/maps", O_RDONLY) = 8`,
|
||||
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/db") + `", O_WRONLY, 0644) = 9`, // duplicate
|
||||
}, "\n")
|
||||
|
||||
logFile := filepath.Join(t.TempDir(), "strace.log")
|
||||
if err := os.WriteFile(logFile, []byte(logContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := ParseStraceLog(logFile, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseStraceLog() error: %v", err)
|
||||
}
|
||||
|
||||
// Write paths: should have unique home paths only (no /tmp, /proc)
|
||||
for _, p := range result.WritePaths {
|
||||
if !strings.HasPrefix(p, home+"/") {
|
||||
t.Errorf("WritePaths returned path outside home: %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Should not have duplicates in write paths
|
||||
seen := make(map[string]bool)
|
||||
for _, p := range result.WritePaths {
|
||||
if seen[p] {
|
||||
t.Errorf("WritePaths returned duplicate: %q", p)
|
||||
}
|
||||
seen[p] = true
|
||||
}
|
||||
|
||||
// Should have the expected write paths
|
||||
expectedWrites := map[string]bool{
|
||||
filepath.Join(home, ".cache/testapp/db"): false,
|
||||
filepath.Join(home, ".cache/testapp/ver"): false,
|
||||
filepath.Join(home, ".config/testapp"): false,
|
||||
}
|
||||
for _, p := range result.WritePaths {
|
||||
if _, ok := expectedWrites[p]; ok {
|
||||
expectedWrites[p] = true
|
||||
}
|
||||
}
|
||||
for p, found := range expectedWrites {
|
||||
if !found {
|
||||
t.Errorf("WritePaths missing expected path: %q, got: %v", p, result.WritePaths)
|
||||
}
|
||||
}
|
||||
|
||||
// Should have the expected read paths (only home paths, not /etc or /proc)
|
||||
expectedRead := filepath.Join(home, ".config/testapp/conf.json")
|
||||
foundRead := false
|
||||
for _, p := range result.ReadPaths {
|
||||
if p == expectedRead {
|
||||
foundRead = true
|
||||
}
|
||||
if !strings.HasPrefix(p, home+"/") {
|
||||
t.Errorf("ReadPaths returned path outside home: %q", p)
|
||||
}
|
||||
}
|
||||
if !foundRead {
|
||||
t.Errorf("ReadPaths missing expected path: %q, got: %v", expectedRead, result.ReadPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractReadPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "openat with O_RDONLY",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/.config/app/conf", O_RDONLY) = 3`,
|
||||
expected: "/home/user/.config/app/conf",
|
||||
},
|
||||
{
|
||||
name: "openat with write flags ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/file", O_WRONLY|O_CREAT, 0644) = 3`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "non-openat ignored",
|
||||
line: `12345 read(3, "data", 1024) = 5`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "failed openat ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/nonexistent", O_RDONLY) = -1 ENOENT (No such file or directory)`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "directory open ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user", O_RDONLY|O_DIRECTORY) = 3`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "directory open with cloexec ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/.cache", O_RDONLY|O_CLOEXEC|O_DIRECTORY) = 4`,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractReadPath(tt.line)
|
||||
if got != tt.expected {
|
||||
t.Errorf("extractReadPath(%q) = %q, want %q", tt.line, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStraceAvailable(t *testing.T) {
|
||||
// This test just verifies the function doesn't panic.
|
||||
// The result depends on whether strace is installed on the test system.
|
||||
err := CheckStraceAvailable()
|
||||
if err != nil {
|
||||
t.Logf("strace not available (expected in some CI environments): %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user