From 3dd772d35a50fcbbd1ea01f212c2de7536957547 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 11 Feb 2026 08:22:53 -0600 Subject: [PATCH] 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 --- cmd/greywall/main.go | 176 ++++++++++- docs/experience.md | 69 ++++ internal/config/config.go | 4 +- internal/sandbox/learning.go | 377 ++++++++++++++++++++++ internal/sandbox/learning_linux.go | 298 ++++++++++++++++++ internal/sandbox/learning_linux_test.go | 243 ++++++++++++++ internal/sandbox/learning_stub.go | 21 ++ internal/sandbox/learning_test.go | 401 ++++++++++++++++++++++++ internal/sandbox/linux.go | 295 ++++++++++------- internal/sandbox/linux_features.go | 6 +- internal/sandbox/linux_stub.go | 12 +- internal/sandbox/macos_test.go | 10 +- internal/sandbox/manager.go | 65 ++++ internal/sandbox/utils.go | 1 - 14 files changed, 1854 insertions(+), 124 deletions(-) create mode 100644 docs/experience.md create mode 100644 internal/sandbox/learning.go create mode 100644 internal/sandbox/learning_linux.go create mode 100644 internal/sandbox/learning_linux_test.go create mode 100644 internal/sandbox/learning_stub.go create mode 100644 internal/sandbox/learning_test.go diff --git a/cmd/greywall/main.go b/cmd/greywall/main.go index f7f20be..e2ffc4d 100644 --- a/cmd/greywall/main.go +++ b/cmd/greywall/main.go @@ -4,10 +4,13 @@ package main import ( "encoding/json" "fmt" + "net/url" "os" "os/exec" "os/signal" + "path/filepath" "strconv" + "strings" "syscall" "gitea.app.monadical.io/monadical/greywall/internal/config" @@ -34,6 +37,8 @@ var ( exitCode int showVersion bool linuxFeatures bool + learning bool + templateName string ) func main() { @@ -68,6 +73,7 @@ Examples: greywall -c "echo hello && ls" # Run with shell expansion greywall --settings config.json npm install greywall -p 3000 -c "npm run dev" # Expose port 3000 + greywall --learning -- opencode # Learn filesystem needs Configuration file format: { @@ -98,10 +104,13 @@ Configuration file format: rootCmd.Flags().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)") rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information") rootCmd.Flags().BoolVar(&linuxFeatures, "linux-features", false, "Show available Linux security features and exit") + rootCmd.Flags().BoolVar(&learning, "learning", false, "Run in learning mode: trace filesystem access and generate a config template") + rootCmd.Flags().StringVar(&templateName, "template", "", "Load a specific learned template by name (see: greywall templates list)") rootCmd.Flags().SetInterspersed(true) rootCmd.AddCommand(newCompletionCmd(rootCmd)) + rootCmd.AddCommand(newTemplatesCmd()) if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -175,6 +184,43 @@ func runCommand(cmd *cobra.Command, args []string) error { } } + // Extract command name for learned template lookup + cmdName := extractCommandName(args, cmdString) + + // Load learned template (when NOT in learning mode) + if !learning { + // Determine which template to load: --template flag takes priority + var templatePath string + var templateLabel string + if templateName != "" { + templatePath = sandbox.LearnedTemplatePath(templateName) + templateLabel = templateName + } else if cmdName != "" { + templatePath = sandbox.LearnedTemplatePath(cmdName) + templateLabel = cmdName + } + + if templatePath != "" { + learnedCfg, loadErr := config.Load(templatePath) + if loadErr != nil { + if debug { + fmt.Fprintf(os.Stderr, "[greywall] Warning: failed to load learned template: %v\n", loadErr) + } + } else if learnedCfg != nil { + cfg = config.Merge(cfg, learnedCfg) + if debug { + fmt.Fprintf(os.Stderr, "[greywall] Auto-loaded learned template for %q\n", templateLabel) + } + } else if templateName != "" { + // Explicit --template but file doesn't exist + return fmt.Errorf("learned template %q not found at %s\nRun: greywall templates list", templateName, templatePath) + } else if cmdName != "" { + // No template found for this command - suggest creating one + fmt.Fprintf(os.Stderr, "[greywall] No learned template for %q. Run with --learning to create one.\n", cmdName) + } + } + } + // CLI flags override config if proxyURL != "" { cfg.Network.ProxyURL = proxyURL @@ -183,8 +229,33 @@ func runCommand(cmd *cobra.Command, args []string) error { cfg.Network.DnsAddr = dnsAddr } + // Auto-inject command name as SOCKS5 proxy username when no credentials are set. + // This lets the proxy identify which sandboxed command originated the traffic. + if cfg.Network.ProxyURL != "" && cmdName != "" { + if u, err := url.Parse(cfg.Network.ProxyURL); err == nil && u.User == nil { + u.User = url.User(cmdName) + cfg.Network.ProxyURL = u.String() + if debug { + fmt.Fprintf(os.Stderr, "[greywall] Auto-set proxy username to %q\n", cmdName) + } + } + } + + // Learning mode setup + if learning { + if err := sandbox.CheckStraceAvailable(); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "[greywall] Learning mode: tracing filesystem access for %q\n", cmdName) + fmt.Fprintf(os.Stderr, "[greywall] WARNING: The sandbox filesystem is relaxed during learning. Do not use for untrusted code.\n") + } + manager := sandbox.NewManager(cfg, debug, monitor) manager.SetExposedPorts(ports) + if learning { + manager.SetLearning(true) + manager.SetCommandName(cmdName) + } defer manager.Cleanup() if err := manager.Initialize(); err != nil { @@ -267,14 +338,50 @@ func runCommand(cmd *cobra.Command, args []string) error { if exitErr, ok := err.(*exec.ExitError); ok { // Set exit code but don't os.Exit() here - let deferred cleanup run exitCode = exitErr.ExitCode() - return nil + // Continue to template generation even if command exited non-zero + } else { + return fmt.Errorf("command failed: %w", err) + } + } + + // Generate learned template after command completes + if learning && manager.IsLearning() { + fmt.Fprintf(os.Stderr, "[greywall] Analyzing filesystem access patterns...\n") + templatePath, genErr := manager.GenerateLearnedTemplate(cmdName) + if genErr != nil { + fmt.Fprintf(os.Stderr, "[greywall] Warning: failed to generate template: %v\n", genErr) + } else { + fmt.Fprintf(os.Stderr, "[greywall] Template saved to: %s\n", templatePath) + fmt.Fprintf(os.Stderr, "[greywall] Next run will auto-load this template.\n") } - return fmt.Errorf("command failed: %w", err) } return nil } +// extractCommandName extracts a human-readable command name from the arguments. +// For args like ["opencode"], returns "opencode". +// For -c "opencode --foo", returns "opencode". +// Strips path prefixes (e.g., /usr/bin/opencode -> opencode). +func extractCommandName(args []string, cmdStr string) string { + var name string + switch { + case len(args) > 0: + name = args[0] + case cmdStr != "": + // Take first token from the command string + parts := strings.Fields(cmdStr) + if len(parts) > 0 { + name = parts[0] + } + } + if name == "" { + return "" + } + // Strip path prefix + return filepath.Base(name) +} + // newCompletionCmd creates the completion subcommand for shell completions. func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command { cmd := &cobra.Command{ @@ -320,6 +427,71 @@ ${fpath[1]}/_greywall for zsh, ~/.config/fish/completions/greywall.fish for fish return cmd } +// newTemplatesCmd creates the templates subcommand for managing learned templates. +func newTemplatesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "templates", + Short: "Manage learned sandbox templates", + Long: `List and inspect learned sandbox templates. + +Templates are created by running greywall with --learning and are stored in: + ` + sandbox.LearnedTemplateDir() + ` + +Examples: + greywall templates list # List all learned templates + greywall templates show opencode # Show the content of a template`, + } + + listCmd := &cobra.Command{ + Use: "list", + Short: "List all learned templates", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + templates, err := sandbox.ListLearnedTemplates() + if err != nil { + return fmt.Errorf("failed to list templates: %w", err) + } + if len(templates) == 0 { + fmt.Println("No learned templates found.") + fmt.Printf("Create one with: greywall --learning -- \n") + return nil + } + fmt.Printf("Learned templates (%s):\n\n", sandbox.LearnedTemplateDir()) + for _, t := range templates { + fmt.Printf(" %s\n", t.Name) + } + fmt.Println() + fmt.Println("Show a template: greywall templates show ") + fmt.Println("Use a template: greywall --template -- ") + return nil + }, + } + + showCmd := &cobra.Command{ + Use: "show ", + Short: "Show the content of a learned template", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + templatePath := sandbox.LearnedTemplatePath(name) + data, err := os.ReadFile(templatePath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("template %q not found\nRun: greywall templates list", name) + } + return fmt.Errorf("failed to read template: %w", err) + } + fmt.Printf("Template: %s\n", name) + fmt.Printf("Path: %s\n\n", templatePath) + fmt.Print(string(data)) + return nil + }, + } + + cmd.AddCommand(listCmd, showCmd) + return cmd +} + // runLandlockWrapper runs in "wrapper mode" inside the sandbox. // It applies Landlock restrictions and then execs the user command. // Usage: greywall --landlock-apply [--debug] -- diff --git a/docs/experience.md b/docs/experience.md new file mode 100644 index 0000000..38eb19e --- /dev/null +++ b/docs/experience.md @@ -0,0 +1,69 @@ +# Greywall Development Notes + +Lessons learned and issues encountered during development. + +--- + +## strace log hidden by tmpfs mount ordering + +**Problem:** Learning mode strace log was always empty ("No additional write paths discovered"). The log file was bind-mounted into `/tmp/greywall-strace-*.log` inside the sandbox, but `--tmpfs /tmp` was declared later in the bwrap args, creating a fresh tmpfs that hid the bind-mount. + +**Fix:** Move the strace log bind-mount to AFTER `--tmpfs /tmp` in the bwrap argument list. Later mounts override earlier ones for the same path. + +--- + +## strace -f hangs on long-lived child processes + +**Problem:** `greywall --learning -- opencode` would hang after exiting opencode. `strace -f` follows forked children and waits for ALL of them to exit. Apps like opencode spawn LSP servers, file watchers, etc. that outlive the main process. + +**Approach 1 - Attach via strace -p:** Run the command in the background, attach strace with `-p PID`. Failed because bwrap restricts `ptrace(PTRACE_SEIZE)` — ptrace only works parent-to-child, not for attaching to arbitrary processes. + +**Approach 2 - Background monitor:** Run `strace -- command &` and spawn a monitor subshell that polls `/proc/STRACE_PID/task/STRACE_PID/children`. When strace's direct child (the main command) exits, the children file becomes empty — grandchildren are reparented to PID 1, not strace. Monitor then kills strace. + +**Fix:** Approach 2 with two additional fixes: +- Added `-I2` flag to strace. Default `-I3` (used when `-o FILE PROG`) blocks all fatal signals, so the monitor's `kill` was silently ignored. +- Added `kill -TERM -1` after strace exits to clean up orphaned processes. Without this, orphans inherit stdout/stderr pipe FDs, and Go's `cmd.Wait()` blocks until they close. + +--- + +## UDP DNS doesn't work through tun2socks + +**Problem:** DNS resolution failed inside the sandbox. The socat DNS relay converted UDP DNS queries to UDP and sent them to 1.1.1.1:53 through tun2socks, but tun2socks (v2.5.2) doesn't reliably handle UDP DNS forwarding through SOCKS5. + +**Approach 1 - UDP-to-TCP relay with socat:** Can't work because TCP DNS requires a 2-byte length prefix (RFC 1035 section 4.2.2) that socat can't add. + +**Approach 2 - Embed a Go DNS relay binary:** Would work but adds build complexity for a simple problem. + +**Fix:** Set resolv.conf to `nameserver 1.1.1.1` with `options use-vc` instead of pointing at a local relay. `use-vc` forces the resolver to use TCP, which tun2socks handles natively. Supported by glibc, Go 1.21+, and c-ares. Removed the broken socat UDP relay entirely. + +--- + +## DNS relay protocol mismatch (original bug) + +**Problem:** The original DNS relay used `socat UDP4-RECVFROM:53,fork TCP:1.1.1.1:53` — converting UDP DNS to TCP. This silently fails because TCP DNS requires a 2-byte big-endian length prefix per RFC 1035 section 4.2.2 that raw UDP DNS packets don't have. The DNS server receives a malformed TCP stream and drops it. + +**Fix:** Superseded by the `options use-vc` approach above. + +--- + +## strace captures directory traversals as file reads + +**Problem:** Learning mode listed `/`, `/home`, `/home/user`, `/home/user/.cache` etc. as "read" paths. These are `openat(O_RDONLY|O_DIRECTORY)` calls used for `readdir()` traversal, not meaningful file reads. + +**Fix:** Filter out `openat` calls containing `O_DIRECTORY` in `extractReadPath()`. + +--- + +## SOCKS5 proxy credentials and protocol + +**Problem:** DNS resolution through the SOCKS5 proxy failed with authentication errors. Two issues: wrong credentials (`x:x` vs `proxy:proxy`) and wrong protocol (`socks5://` vs `socks5h://`). + +**Key distinction:** `socks5://` resolves DNS locally then sends the IP to the proxy. `socks5h://` sends the hostname to the proxy for remote DNS resolution. With tun2socks, the distinction matters less (tun2socks intercepts at IP level), but using `socks5h://` is still correct for the proxy bridge configuration. + +--- + +## gost SOCKS5 requires authentication flow + +**Problem:** gost's SOCKS5 server always selects authentication method 0x02 (username/password), even when no real credentials are needed. Clients that only offer method 0x00 (no auth) get rejected. + +**Fix:** Always include credentials in the proxy URL (e.g., `proxy:proxy@`). In tun2socks proxy URL construction, include `userinfo` so tun2socks offers both auth methods during SOCKS5 negotiation. diff --git a/internal/config/config.go b/internal/config/config.go index 29567a6..d6970aa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,8 +26,8 @@ type Config struct { // NetworkConfig defines network restrictions. type NetworkConfig struct { - ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080) - DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153) + ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080) + DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153) AllowUnixSockets []string `json:"allowUnixSockets,omitempty"` AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"` AllowLocalBinding bool `json:"allowLocalBinding,omitempty"` diff --git a/internal/sandbox/learning.go b/internal/sandbox/learning.go new file mode 100644 index 0000000..7282000 --- /dev/null +++ b/internal/sandbox/learning.go @@ -0,0 +1,377 @@ +package sandbox + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// wellKnownParents are directories under $HOME where applications typically +// create their own subdirectory (e.g., ~/.cache/opencode, ~/.config/opencode). +var wellKnownParents = []string{ + ".cache", + ".config", + ".local/share", + ".local/state", + ".local/lib", + ".data", +} + +// LearnedTemplateDir returns the directory where learned templates are stored. +func LearnedTemplateDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "greywall", "learned") + } + return filepath.Join(configDir, "greywall", "learned") +} + +// LearnedTemplatePath returns the path where a command's learned template is stored. +func LearnedTemplatePath(cmdName string) string { + return filepath.Join(LearnedTemplateDir(), SanitizeTemplateName(cmdName)+".json") +} + +// SanitizeTemplateName sanitizes a command name for use as a filename. +// Only allows alphanumeric, dash, underscore, and dot characters. +func SanitizeTemplateName(name string) string { + re := regexp.MustCompile(`[^a-zA-Z0-9._-]`) + sanitized := re.ReplaceAllString(name, "_") + // Collapse multiple underscores + for strings.Contains(sanitized, "__") { + sanitized = strings.ReplaceAll(sanitized, "__", "_") + } + sanitized = strings.Trim(sanitized, "_.") + if sanitized == "" { + return "unknown" + } + return sanitized +} + +// GenerateLearnedTemplate parses an strace log, collapses paths, and saves a template. +// Returns the path where the template was saved. +func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string, error) { + result, err := ParseStraceLog(straceLogPath, debug) + if err != nil { + return "", fmt.Errorf("failed to parse strace log: %w", err) + } + + home, _ := os.UserHomeDir() + + // Filter write paths: remove default writable and sensitive paths + var filteredWrites []string + for _, p := range result.WritePaths { + if isDefaultWritablePath(p) { + continue + } + if isSensitivePath(p, home) { + if debug { + fmt.Fprintf(os.Stderr, "[greywall] Skipping sensitive path: %s\n", p) + } + continue + } + filteredWrites = append(filteredWrites, p) + } + + // Collapse write paths into minimal directory set + collapsed := CollapsePaths(filteredWrites) + + // Convert write paths to tilde-relative + var allowWrite []string + allowWrite = append(allowWrite, ".") // Always include cwd + for _, p := range collapsed { + allowWrite = append(allowWrite, toTildePath(p, home)) + } + + // Convert read paths to tilde-relative for display + var readDisplay []string + for _, p := range result.ReadPaths { + readDisplay = append(readDisplay, toTildePath(p, home)) + } + + // Print all discovered paths + fmt.Fprintf(os.Stderr, "\n") + + if len(readDisplay) > 0 { + fmt.Fprintf(os.Stderr, "[greywall] Discovered read paths:\n") + for _, p := range readDisplay { + fmt.Fprintf(os.Stderr, "[greywall] %s\n", p) + } + } + + if len(allowWrite) > 1 { // >1 because "." is always included + fmt.Fprintf(os.Stderr, "[greywall] Discovered write paths (collapsed):\n") + for _, p := range allowWrite { + if p == "." { + continue + } + fmt.Fprintf(os.Stderr, "[greywall] %s\n", p) + } + } else { + fmt.Fprintf(os.Stderr, "[greywall] No additional write paths discovered.\n") + } + + fmt.Fprintf(os.Stderr, "\n") + + // Build template + template := buildTemplate(cmdName, allowWrite) + + // Save template + templatePath := LearnedTemplatePath(cmdName) + if err := os.MkdirAll(filepath.Dir(templatePath), 0o755); err != nil { + return "", fmt.Errorf("failed to create template directory: %w", err) + } + + if err := os.WriteFile(templatePath, []byte(template), 0o644); err != nil { + return "", fmt.Errorf("failed to write template: %w", err) + } + + // Display the template content + fmt.Fprintf(os.Stderr, "[greywall] Generated template:\n") + for _, line := range strings.Split(template, "\n") { + if line != "" { + fmt.Fprintf(os.Stderr, "[greywall] %s\n", line) + } + } + fmt.Fprintf(os.Stderr, "\n") + + return templatePath, nil +} + +// CollapsePaths groups write paths into minimal directory set. +// Uses "application directory" detection for well-known parents. +func CollapsePaths(paths []string) []string { + if len(paths) == 0 { + return nil + } + + home, _ := os.UserHomeDir() + + // Group paths by application directory + appDirPaths := make(map[string][]string) // appDir -> list of paths + var standalone []string // paths that don't fit an app dir + + for _, p := range paths { + appDir := findApplicationDirectory(p, home) + if appDir != "" { + appDirPaths[appDir] = append(appDirPaths[appDir], p) + } else { + standalone = append(standalone, p) + } + } + + var result []string + + // For each app dir group: if 2+ paths share it, use the app dir + // If only 1 path, use its parent directory + for appDir, groupPaths := range appDirPaths { + if len(groupPaths) >= 2 { + result = append(result, appDir) + } else { + result = append(result, filepath.Dir(groupPaths[0])) + } + } + + // For standalone paths, use their parent directory + for _, p := range standalone { + result = append(result, filepath.Dir(p)) + } + + // Sort and deduplicate (remove sub-paths of other paths) + sort.Strings(result) + result = deduplicateSubPaths(result) + + return result +} + +// findApplicationDirectory finds the app-level directory for a path. +// For paths under well-known parents (e.g., ~/.cache/opencode/foo), +// returns the first directory below the well-known parent (e.g., ~/.cache/opencode). +func findApplicationDirectory(path, home string) string { + if home == "" { + return "" + } + + for _, parent := range wellKnownParents { + prefix := filepath.Join(home, parent) + "/" + if strings.HasPrefix(path, prefix) { + // Get the first directory below the well-known parent + rest := strings.TrimPrefix(path, prefix) + parts := strings.SplitN(rest, "/", 2) + if len(parts) > 0 && parts[0] != "" { + return filepath.Join(home, parent, parts[0]) + } + } + } + + return "" +} + +// isDefaultWritablePath checks if a path is already writable by default in the sandbox. +func isDefaultWritablePath(path string) bool { + // /tmp is always writable (tmpfs in sandbox) + if strings.HasPrefix(path, "/tmp/") || path == "/tmp" { + return false // /tmp inside sandbox is tmpfs, not host /tmp + } + + for _, p := range GetDefaultWritePaths() { + if path == p || strings.HasPrefix(path, p+"/") { + return true + } + } + + return false +} + +// isSensitivePath checks if a path is sensitive and should not be made writable. +func isSensitivePath(path, home string) bool { + if home == "" { + return false + } + + // Check against DangerousFiles + for _, f := range DangerousFiles { + dangerous := filepath.Join(home, f) + if path == dangerous { + return true + } + } + + // Check for .env files + base := filepath.Base(path) + if base == ".env" || strings.HasPrefix(base, ".env.") { + return true + } + + // Check SSH keys + sshDir := filepath.Join(home, ".ssh") + if strings.HasPrefix(path, sshDir+"/") { + return true + } + + // Check GPG + gnupgDir := filepath.Join(home, ".gnupg") + if strings.HasPrefix(path, gnupgDir+"/") { + return true + } + + return false +} + +// getDangerousFilePatterns returns denyWrite entries for DangerousFiles. +func getDangerousFilePatterns() []string { + var patterns []string + for _, f := range DangerousFiles { + patterns = append(patterns, "~/"+f) + } + return patterns +} + +// getSensitiveReadPatterns returns denyRead entries for sensitive data. +func getSensitiveReadPatterns() []string { + return []string{ + "~/.ssh/id_*", + "~/.gnupg/**", + } +} + +// toTildePath converts an absolute path to a tilde-relative path if under home. +func toTildePath(p, home string) string { + if home != "" && strings.HasPrefix(p, home+"/") { + return "~/" + strings.TrimPrefix(p, home+"/") + } + return p +} + +// LearnedTemplateInfo holds metadata about a learned template. +type LearnedTemplateInfo struct { + Name string // template name (without .json) + Path string // full path to the template file +} + +// ListLearnedTemplates returns all available learned templates. +func ListLearnedTemplates() ([]LearnedTemplateInfo, error) { + dir := LearnedTemplateDir() + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var templates []LearnedTemplateInfo + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + name := strings.TrimSuffix(e.Name(), ".json") + templates = append(templates, LearnedTemplateInfo{ + Name: name, + Path: filepath.Join(dir, e.Name()), + }) + } + return templates, nil +} + +// deduplicateSubPaths removes paths that are sub-paths of other paths in the list. +// Assumes the input is sorted. +func deduplicateSubPaths(paths []string) []string { + if len(paths) == 0 { + return nil + } + + var result []string + for i, p := range paths { + isSubPath := false + for j, other := range paths { + if i == j { + continue + } + if strings.HasPrefix(p, other+"/") { + isSubPath = true + break + } + } + if !isSubPath { + result = append(result, p) + } + } + + return result +} + +// buildTemplate generates the JSONC template content for a learned config. +func buildTemplate(cmdName string, allowWrite []string) string { + type fsConfig struct { + AllowWrite []string `json:"allowWrite"` + DenyWrite []string `json:"denyWrite"` + DenyRead []string `json:"denyRead"` + } + type templateConfig struct { + Filesystem fsConfig `json:"filesystem"` + } + + cfg := templateConfig{ + Filesystem: fsConfig{ + AllowWrite: allowWrite, + DenyWrite: getDangerousFilePatterns(), + DenyRead: getSensitiveReadPatterns(), + }, + } + + data, _ := json.MarshalIndent(cfg, "", " ") + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("// Learned template for %q\n", cmdName)) + sb.WriteString(fmt.Sprintf("// Generated by: greywall --learning -- %s\n", cmdName)) + sb.WriteString("// Review and adjust paths as needed\n") + sb.Write(data) + sb.WriteString("\n") + + return sb.String() +} diff --git a/internal/sandbox/learning_linux.go b/internal/sandbox/learning_linux.go new file mode 100644 index 0000000..9411258 --- /dev/null +++ b/internal/sandbox/learning_linux.go @@ -0,0 +1,298 @@ +//go:build linux + +package sandbox + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +// straceSyscallRegex matches strace output lines for file-access syscalls. +var straceSyscallRegex = regexp.MustCompile( + `(openat|mkdirat|unlinkat|renameat2|creat|symlinkat|linkat)\(`, +) + +// openatWriteFlags matches O_WRONLY, O_RDWR, O_CREAT, O_TRUNC, O_APPEND flags in strace output. +var openatWriteFlags = regexp.MustCompile(`O_(?:WRONLY|RDWR|CREAT|TRUNC|APPEND)`) + +// StraceResult holds parsed read and write paths from an strace log. +type StraceResult struct { + WritePaths []string + ReadPaths []string +} + +// CheckStraceAvailable verifies that strace is installed and accessible. +func CheckStraceAvailable() error { + _, err := exec.LookPath("strace") + if err != nil { + return fmt.Errorf("strace is required for learning mode but not found: %w\n\nInstall it with: sudo apt install strace (Debian/Ubuntu) or sudo pacman -S strace (Arch)", err) + } + return nil +} + +// ParseStraceLog reads an strace output file and extracts unique read and write paths. +func ParseStraceLog(logPath string, debug bool) (*StraceResult, error) { + f, err := os.Open(logPath) //nolint:gosec // user-controlled path from temp file - intentional + if err != nil { + return nil, fmt.Errorf("failed to open strace log: %w", err) + } + defer f.Close() + + home, _ := os.UserHomeDir() + seenWrite := make(map[string]bool) + seenRead := make(map[string]bool) + result := &StraceResult{} + + scanner := bufio.NewScanner(f) + // Increase buffer for long strace lines + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + lineCount := 0 + writeCount := 0 + readCount := 0 + + for scanner.Scan() { + line := scanner.Text() + lineCount++ + + // Try extracting as a write path first + writePath := extractWritePath(line) + if writePath != "" { + writeCount++ + if !shouldFilterPath(writePath, home) && !seenWrite[writePath] { + seenWrite[writePath] = true + result.WritePaths = append(result.WritePaths, writePath) + } + continue + } + + // Try extracting as a read path + readPath := extractReadPath(line) + if readPath != "" { + readCount++ + if !shouldFilterPath(readPath, home) && !seenRead[readPath] { + seenRead[readPath] = true + result.ReadPaths = append(result.ReadPaths, readPath) + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading strace log: %w", err) + } + + if debug { + fmt.Fprintf(os.Stderr, "[greywall] Parsed strace log: %d lines, %d write syscalls, %d read syscalls, %d unique write paths, %d unique read paths\n", + lineCount, writeCount, readCount, len(result.WritePaths), len(result.ReadPaths)) + } + + return result, nil +} + +// extractReadPath parses a single strace line and returns the read path, if any. +// Only matches openat() with O_RDONLY (no write flags). +func extractReadPath(line string) string { + if !strings.Contains(line, "openat(") { + return "" + } + + // Skip failed syscalls + if strings.Contains(line, "= -1 ") { + return "" + } + + // Skip resumed/unfinished lines + if strings.Contains(line, "") { + return "" + } + + // Only care about read-only opens (no write flags) + if openatWriteFlags.MatchString(line) { + return "" + } + + // Skip directory opens (O_DIRECTORY) — these are just directory traversal + // (readdir/stat), not meaningful file reads + if strings.Contains(line, "O_DIRECTORY") { + return "" + } + + return extractATPath(line) +} + +// extractWritePath parses a single strace line and returns the write target path, if any. +func extractWritePath(line string) string { + // Skip lines that don't contain write syscalls + if !straceSyscallRegex.MatchString(line) { + return "" + } + + // Skip failed syscalls (lines ending with = -1 ENOENT or similar errors) + if strings.Contains(line, "= -1 ") { + return "" + } + + // Skip resumed/unfinished lines + if strings.Contains(line, "") { + return "" + } + + // Extract path based on syscall type + if strings.Contains(line, "openat(") { + return extractOpenatPath(line) + } + if strings.Contains(line, "mkdirat(") { + return extractATPath(line) + } + if strings.Contains(line, "unlinkat(") { + return extractATPath(line) + } + if strings.Contains(line, "renameat2(") { + return extractRenameatPath(line) + } + if strings.Contains(line, "creat(") { + return extractCreatPath(line) + } + if strings.Contains(line, "symlinkat(") { + return extractSymlinkTarget(line) + } + if strings.Contains(line, "linkat(") { + return extractLinkatTarget(line) + } + + return "" +} + +// extractOpenatPath extracts the path from an openat() line, only if write flags are present. +func extractOpenatPath(line string) string { + // Only care about writes + if !openatWriteFlags.MatchString(line) { + return "" + } + return extractATPath(line) +} + +// extractATPath extracts the second argument (path) from AT_FDCWD-based syscalls. +// Pattern: syscall(AT_FDCWD, "/path/to/file", ...) +func extractATPath(line string) string { + // Find the first quoted string after AT_FDCWD + idx := strings.Index(line, "AT_FDCWD, \"") + if idx < 0 { + return "" + } + start := idx + len("AT_FDCWD, \"") + end := strings.Index(line[start:], "\"") + if end < 0 { + return "" + } + return line[start : start+end] +} + +// extractCreatPath extracts the path from a creat() call. +// Pattern: creat("/path/to/file", mode) +func extractCreatPath(line string) string { + idx := strings.Index(line, "creat(\"") + if idx < 0 { + return "" + } + start := idx + len("creat(\"") + end := strings.Index(line[start:], "\"") + if end < 0 { + return "" + } + return line[start : start+end] +} + +// extractRenameatPath extracts the destination path from renameat2(). +// Pattern: renameat2(AT_FDCWD, "/old", AT_FDCWD, "/new", flags) +// We want both old and new paths, but primarily the new (destination) path. +func extractRenameatPath(line string) string { + // Find the second AT_FDCWD occurrence for the destination + first := strings.Index(line, "AT_FDCWD, \"") + if first < 0 { + return "" + } + rest := line[first+len("AT_FDCWD, \""):] + endFirst := strings.Index(rest, "\"") + if endFirst < 0 { + return "" + } + rest = rest[endFirst+1:] + + // Find second AT_FDCWD + second := strings.Index(rest, "AT_FDCWD, \"") + if second < 0 { + // Fall back to first path + return extractATPath(line) + } + start := second + len("AT_FDCWD, \"") + end := strings.Index(rest[start:], "\"") + if end < 0 { + return extractATPath(line) + } + return rest[start : start+end] +} + +// extractSymlinkTarget extracts the link path (destination) from symlinkat(). +// Pattern: symlinkat("/target", AT_FDCWD, "/link") +func extractSymlinkTarget(line string) string { + // The link path is the third argument (after AT_FDCWD) + return extractATPath(line) +} + +// extractLinkatTarget extracts the new link path from linkat(). +// Pattern: linkat(AT_FDCWD, "/old", AT_FDCWD, "/new", flags) +func extractLinkatTarget(line string) string { + return extractRenameatPath(line) +} + +// shouldFilterPath returns true if a path should be excluded from learning results. +func shouldFilterPath(path, home string) bool { + // Filter empty or relative paths + if path == "" || !strings.HasPrefix(path, "/") { + return true + } + + // Filter system paths + systemPrefixes := []string{ + "/proc/", + "/sys/", + "/dev/", + "/run/", + "/var/run/", + "/var/lock/", + } + for _, prefix := range systemPrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + + // Filter /tmp (sandbox has its own tmpfs) + if strings.HasPrefix(path, "/tmp/") || path == "/tmp" { + return true + } + + // Filter shared object files (.so, .so.*) + base := filepath.Base(path) + if strings.HasSuffix(base, ".so") || strings.Contains(base, ".so.") { + return true + } + + // Filter greywall infrastructure files + if strings.Contains(path, "greywall-") { + return true + } + + // Filter paths outside home (they're typically system-level) + if home != "" && !strings.HasPrefix(path, home+"/") { + return true + } + + return false +} diff --git a/internal/sandbox/learning_linux_test.go b/internal/sandbox/learning_linux_test.go new file mode 100644 index 0000000..c48e47f --- /dev/null +++ b/internal/sandbox/learning_linux_test.go @@ -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 `, + 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) + } +} diff --git a/internal/sandbox/learning_stub.go b/internal/sandbox/learning_stub.go new file mode 100644 index 0000000..c07acd0 --- /dev/null +++ b/internal/sandbox/learning_stub.go @@ -0,0 +1,21 @@ +//go:build !linux + +package sandbox + +import "fmt" + +// StraceResult holds parsed read and write paths from an strace log. +type StraceResult struct { + WritePaths []string + ReadPaths []string +} + +// CheckStraceAvailable returns an error on non-Linux platforms. +func CheckStraceAvailable() error { + return fmt.Errorf("learning mode is only available on Linux (requires strace and bubblewrap)") +} + +// ParseStraceLog returns an error on non-Linux platforms. +func ParseStraceLog(logPath string, debug bool) (*StraceResult, error) { + return nil, fmt.Errorf("strace log parsing is only available on Linux") +} diff --git a/internal/sandbox/learning_test.go b/internal/sandbox/learning_test.go new file mode 100644 index 0000000..0c9c9c2 --- /dev/null +++ b/internal/sandbox/learning_test.go @@ -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") + } +} diff --git a/internal/sandbox/linux.go b/internal/sandbox/linux.go index 879227f..9266160 100644 --- a/internal/sandbox/linux.go +++ b/internal/sandbox/linux.go @@ -20,12 +20,12 @@ import ( // ProxyBridge bridges sandbox to an external SOCKS5 proxy via Unix socket. type ProxyBridge struct { - SocketPath string // Unix socket path - ProxyHost string // Parsed from ProxyURL - ProxyPort string // Parsed from ProxyURL - ProxyUser string // Username from ProxyURL (if any) - ProxyPass string // Password from ProxyURL (if any) - HasAuth bool // Whether credentials were provided + SocketPath string // Unix socket path + ProxyHost string // Parsed from ProxyURL + ProxyPort string // Parsed from ProxyURL + ProxyUser string // Username from ProxyURL (if any) + ProxyPass string // Password from ProxyURL (if any) + HasAuth bool // Whether credentials were provided process *exec.Cmd debug bool } @@ -122,6 +122,10 @@ type LinuxSandboxOptions struct { Monitor bool // Debug mode Debug bool + // Learning mode: permissive sandbox with strace tracing + Learning bool + // Path to host-side strace log file (bind-mounted into sandbox) + StraceLogPath string } // NewProxyBridge creates a Unix socket bridge to an external SOCKS5 proxy. @@ -451,9 +455,30 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge } } + // Learning mode: permissive sandbox with home + cwd writable + if opts.Learning { + if opts.Debug { + fmt.Fprintf(os.Stderr, "[greywall:linux] Learning mode: binding root read-only, home + cwd writable\n") + } + // Bind entire root read-only as baseline + bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/") + + // Make home and cwd writable (overrides read-only) + home, _ := os.UserHomeDir() + if home != "" && fileExists(home) { + bwrapArgs = append(bwrapArgs, "--bind", home, home) + } + if cwd != "" && fileExists(cwd) && cwd != home { + bwrapArgs = append(bwrapArgs, "--bind", cwd, cwd) + } + + } + defaultDenyRead := cfg != nil && cfg.Filesystem.DefaultDenyRead - if defaultDenyRead { + if opts.Learning { + // Skip defaultDenyRead logic in learning mode (already set up above) + } else if defaultDenyRead { // In defaultDenyRead mode, we only bind essential system paths read-only // and user-specified allowRead paths. Everything else is inaccessible. if opts.Debug { @@ -507,6 +532,11 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge // /tmp needs to be writable for many programs bwrapArgs = append(bwrapArgs, "--tmpfs", "/tmp") + // Bind strace log file into sandbox AFTER --tmpfs /tmp so it's visible + if opts.Learning && opts.StraceLogPath != "" { + bwrapArgs = append(bwrapArgs, "--bind", opts.StraceLogPath, opts.StraceLogPath) + } + // Ensure /etc/resolv.conf is readable inside the sandbox. // On some systems (e.g., WSL), /etc/resolv.conf is a symlink to a path // on a separate mount point (e.g., /mnt/wsl/resolv.conf) that isn't @@ -560,112 +590,118 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge } } - writablePaths := make(map[string]bool) + // In learning mode, skip writable paths, deny rules, and mandatory deny + // (the sandbox is already permissive with home + cwd writable) + if !opts.Learning { - // Add default write paths (system paths needed for operation) - for _, p := range GetDefaultWritePaths() { - // Skip /dev paths (handled by --dev) and /tmp paths (handled by --tmpfs) - if strings.HasPrefix(p, "/dev/") || strings.HasPrefix(p, "/tmp/") || strings.HasPrefix(p, "/private/tmp/") { - continue - } - writablePaths[p] = true - } + writablePaths := make(map[string]bool) - // Add user-specified allowWrite paths - if cfg != nil && cfg.Filesystem.AllowWrite != nil { - expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowWrite) - for _, p := range expandedPaths { + // Add default write paths (system paths needed for operation) + for _, p := range GetDefaultWritePaths() { + // Skip /dev paths (handled by --dev) and /tmp paths (handled by --tmpfs) + if strings.HasPrefix(p, "/dev/") || strings.HasPrefix(p, "/tmp/") || strings.HasPrefix(p, "/private/tmp/") { + continue + } writablePaths[p] = true } - // Add non-glob paths - for _, p := range cfg.Filesystem.AllowWrite { - normalized := NormalizePath(p) - if !ContainsGlobChars(normalized) { - writablePaths[normalized] = true + // Add user-specified allowWrite paths + if cfg != nil && cfg.Filesystem.AllowWrite != nil { + expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowWrite) + for _, p := range expandedPaths { + writablePaths[p] = true } - } - } - // Make writable paths actually writable (override read-only root) - for p := range writablePaths { - if fileExists(p) { - bwrapArgs = append(bwrapArgs, "--bind", p, p) - } - } - - // Handle denyRead paths - hide them - // For directories: use --tmpfs to replace with empty tmpfs - // For files: use --ro-bind /dev/null to mask with empty file - // Skip symlinks: they may point outside the sandbox and cause mount errors - if cfg != nil && cfg.Filesystem.DenyRead != nil { - expandedDenyRead := ExpandGlobPatterns(cfg.Filesystem.DenyRead) - for _, p := range expandedDenyRead { - if canMountOver(p) { - if isDirectory(p) { - bwrapArgs = append(bwrapArgs, "--tmpfs", p) - } else { - // Mask file with /dev/null (appears as empty, unreadable) - bwrapArgs = append(bwrapArgs, "--ro-bind", "/dev/null", p) + // Add non-glob paths + for _, p := range cfg.Filesystem.AllowWrite { + normalized := NormalizePath(p) + if !ContainsGlobChars(normalized) { + writablePaths[normalized] = true } } } - // Add non-glob paths - for _, p := range cfg.Filesystem.DenyRead { - normalized := NormalizePath(p) - if !ContainsGlobChars(normalized) && canMountOver(normalized) { - if isDirectory(normalized) { - bwrapArgs = append(bwrapArgs, "--tmpfs", normalized) - } else { - bwrapArgs = append(bwrapArgs, "--ro-bind", "/dev/null", normalized) + // Make writable paths actually writable (override read-only root) + for p := range writablePaths { + if fileExists(p) { + bwrapArgs = append(bwrapArgs, "--bind", p, p) + } + } + + // Handle denyRead paths - hide them + // For directories: use --tmpfs to replace with empty tmpfs + // For files: use --ro-bind /dev/null to mask with empty file + // Skip symlinks: they may point outside the sandbox and cause mount errors + if cfg != nil && cfg.Filesystem.DenyRead != nil { + expandedDenyRead := ExpandGlobPatterns(cfg.Filesystem.DenyRead) + for _, p := range expandedDenyRead { + if canMountOver(p) { + if isDirectory(p) { + bwrapArgs = append(bwrapArgs, "--tmpfs", p) + } else { + // Mask file with /dev/null (appears as empty, unreadable) + bwrapArgs = append(bwrapArgs, "--ro-bind", "/dev/null", p) + } + } + } + + // Add non-glob paths + for _, p := range cfg.Filesystem.DenyRead { + normalized := NormalizePath(p) + if !ContainsGlobChars(normalized) && canMountOver(normalized) { + if isDirectory(normalized) { + bwrapArgs = append(bwrapArgs, "--tmpfs", normalized) + } else { + bwrapArgs = append(bwrapArgs, "--ro-bind", "/dev/null", normalized) + } } } } - } - // Apply mandatory deny patterns (make dangerous files/dirs read-only) - // This overrides any writable mounts for these paths - // - // Note: We only use concrete paths from getMandatoryDenyPaths(), NOT glob expansion. - // GetMandatoryDenyPatterns() returns expensive **/pattern globs that require walking - // the entire directory tree - this can hang on large directories (see issue #27). - // - // The concrete paths cover dangerous files in cwd and home directory. Files like - // .bashrc in subdirectories are not protected, but this may be lower-risk since shell - // rc files in project subdirectories are uncommon and not automatically sourced. - // - // TODO: consider depth-limited glob expansion (e.g., max 3 levels) to protect - // subdirectory dangerous files without full tree walks that hang on large dirs. - mandatoryDeny := getMandatoryDenyPaths(cwd) + // Apply mandatory deny patterns (make dangerous files/dirs read-only) + // This overrides any writable mounts for these paths + // + // Note: We only use concrete paths from getMandatoryDenyPaths(), NOT glob expansion. + // GetMandatoryDenyPatterns() returns expensive **/pattern globs that require walking + // the entire directory tree - this can hang on large directories (see issue #27). + // + // The concrete paths cover dangerous files in cwd and home directory. Files like + // .bashrc in subdirectories are not protected, but this may be lower-risk since shell + // rc files in project subdirectories are uncommon and not automatically sourced. + // + // TODO: consider depth-limited glob expansion (e.g., max 3 levels) to protect + // subdirectory dangerous files without full tree walks that hang on large dirs. + mandatoryDeny := getMandatoryDenyPaths(cwd) - // Deduplicate - seen := make(map[string]bool) - for _, p := range mandatoryDeny { - if !seen[p] && fileExists(p) { - seen[p] = true - bwrapArgs = append(bwrapArgs, "--ro-bind", p, p) - } - } - - // Handle explicit denyWrite paths (make them read-only) - if cfg != nil && cfg.Filesystem.DenyWrite != nil { - expandedDenyWrite := ExpandGlobPatterns(cfg.Filesystem.DenyWrite) - for _, p := range expandedDenyWrite { - if fileExists(p) && !seen[p] { + // Deduplicate + seen := make(map[string]bool) + for _, p := range mandatoryDeny { + if !seen[p] && fileExists(p) { seen[p] = true bwrapArgs = append(bwrapArgs, "--ro-bind", p, p) } } - // Add non-glob paths - for _, p := range cfg.Filesystem.DenyWrite { - normalized := NormalizePath(p) - if !ContainsGlobChars(normalized) && fileExists(normalized) && !seen[normalized] { - seen[normalized] = true - bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized) + + // Handle explicit denyWrite paths (make them read-only) + if cfg != nil && cfg.Filesystem.DenyWrite != nil { + expandedDenyWrite := ExpandGlobPatterns(cfg.Filesystem.DenyWrite) + for _, p := range expandedDenyWrite { + if fileExists(p) && !seen[p] { + seen[p] = true + bwrapArgs = append(bwrapArgs, "--ro-bind", p, p) + } + } + // Add non-glob paths + for _, p := range cfg.Filesystem.DenyWrite { + normalized := NormalizePath(p) + if !ContainsGlobChars(normalized) && fileExists(normalized) && !seen[normalized] { + seen[normalized] = true + bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized) + } } } - } + + } // end if !opts.Learning // Bind the proxy bridge Unix socket into the sandbox (needs to be writable) var dnsRelayResolvConf string // temp file path for custom resolv.conf @@ -693,13 +729,21 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge ) } - // Override /etc/resolv.conf to point DNS at our local relay (port 53). - // Inside the sandbox, a socat relay on UDP :53 converts queries to the - // DNS bridge (Unix socket -> host DNS server) or to TCP through the tunnel. + // Override /etc/resolv.conf for DNS resolution inside the sandbox. if dnsBridge != nil || (tun2socksPath != "" && features.CanUseTransparentProxy()) { tmpResolv, err := os.CreateTemp("", "greywall-resolv-*.conf") if err == nil { - _, _ = tmpResolv.WriteString("nameserver 127.0.0.1\n") + if dnsBridge != nil { + // DNS bridge: point at local socat relay (UDP :53 -> Unix socket -> host DNS server) + _, _ = tmpResolv.WriteString("nameserver 127.0.0.1\n") + } else { + // tun2socks: point at public DNS with TCP mode. + // tun2socks intercepts TCP traffic and forwards through the SOCKS5 proxy, + // but doesn't reliably handle UDP DNS. "options use-vc" forces the resolver + // to use TCP (RFC 1035 §4.2.2), which tun2socks handles natively. + // Supported by glibc, Go 1.21+, c-ares, and most DNS resolver libraries. + _, _ = tmpResolv.WriteString("nameserver 1.1.1.1\nnameserver 8.8.8.8\noptions use-vc\n") + } tmpResolv.Close() dnsRelayResolvConf = tmpResolv.Name() bwrapArgs = append(bwrapArgs, "--ro-bind", dnsRelayResolvConf, "/etc/resolv.conf") @@ -707,7 +751,7 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge if dnsBridge != nil { fmt.Fprintf(os.Stderr, "[greywall:linux] DNS: overriding resolv.conf -> 127.0.0.1 (bridge to %s)\n", dnsBridge.DnsAddr) } else { - fmt.Fprintf(os.Stderr, "[greywall:linux] DNS: overriding resolv.conf -> 127.0.0.1 (TCP relay through tunnel)\n") + fmt.Fprintf(os.Stderr, "[greywall:linux] DNS: overriding resolv.conf -> 1.1.1.1 (TCP via tun2socks tunnel)\n") } } } @@ -778,7 +822,9 @@ TUN2SOCKS_PID=$! `, proxyBridge.SocketPath, tun2socksProxyURL)) - // DNS relay: convert UDP DNS queries on port 53 so apps can resolve names. + // DNS relay: only needed when using a dedicated DNS bridge. + // When using tun2socks without a DNS bridge, resolv.conf is configured with + // "options use-vc" to force TCP DNS, which tun2socks handles natively. if dnsBridge != nil { // Dedicated DNS bridge: UDP :53 -> Unix socket -> host DNS server innerScript.WriteString(fmt.Sprintf(`# DNS relay: UDP queries -> Unix socket -> host DNS server (%s) @@ -786,13 +832,6 @@ socat UDP4-RECVFROM:53,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 & DNS_RELAY_PID=$! `, dnsBridge.DnsAddr, dnsBridge.SocketPath)) - } else { - // Fallback: UDP :53 -> TCP to public DNS through the tunnel - innerScript.WriteString(`# DNS relay: UDP queries -> TCP 1.1.1.1:53 (through tun2socks tunnel) -socat UDP4-RECVFROM:53,fork,reuseaddr TCP:1.1.1.1:53 >/dev/null 2>&1 & -DNS_RELAY_PID=$! - -`) } } else if proxyBridge != nil { // Fallback: no TUN support, use env-var-based proxying @@ -846,8 +885,49 @@ sleep 0.3 # Run the user command `) - // Use Landlock wrapper if available - if useLandlockWrapper { + // In learning mode, wrap the command with strace to trace syscalls. + // strace -f follows forked children, which means it hangs if the app spawns + // long-lived child processes (LSP servers, file watchers, etc.). + // To handle this, we run strace in the background and spawn a monitor that + // detects when the main command (strace's direct child) exits by polling + // /proc/STRACE_PID/task/STRACE_PID/children, then kills strace. + if opts.Learning && opts.StraceLogPath != "" { + innerScript.WriteString(fmt.Sprintf(`# Learning mode: trace filesystem access +strace -f -qq -I2 -e trace=openat,open,creat,mkdir,mkdirat,unlinkat,renameat,renameat2,symlinkat,linkat -o %s -- %s & +GREYWALL_STRACE_PID=$! + +# Monitor: detect when the main command exits, then kill strace. +# strace's direct child is the command. When it exits, the children file +# becomes empty (grandchildren are reparented to init in the PID namespace). +( + sleep 1 + while kill -0 $GREYWALL_STRACE_PID 2>/dev/null; do + CHILDREN=$(cat /proc/$GREYWALL_STRACE_PID/task/$GREYWALL_STRACE_PID/children 2>/dev/null) + if [ -z "$CHILDREN" ]; then + sleep 0.5 + kill $GREYWALL_STRACE_PID 2>/dev/null + break + fi + sleep 1 + done +) & +GREYWALL_MONITOR_PID=$! + +trap 'kill -INT $GREYWALL_STRACE_PID 2>/dev/null' INT +trap 'kill -TERM $GREYWALL_STRACE_PID 2>/dev/null' TERM +wait $GREYWALL_STRACE_PID 2>/dev/null +kill $GREYWALL_MONITOR_PID 2>/dev/null +wait $GREYWALL_MONITOR_PID 2>/dev/null +# Kill any orphaned child processes (LSP servers, file watchers, etc.) +# that were spawned by the traced command and reparented to PID 1. +# Without this, greywall hangs until they exit (they hold pipe FDs open). +kill -TERM -1 2>/dev/null +sleep 0.1 +`, + ShellQuoteSingle(opts.StraceLogPath), command, + )) + } else if useLandlockWrapper { + // Use Landlock wrapper if available // Pass config via environment variable (serialized as JSON) // This ensures allowWrite/denyWrite rules are properly applied if cfg != nil { @@ -897,6 +977,9 @@ sleep 0.3 if reverseBridge != nil && len(reverseBridge.Ports) > 0 { featureList = append(featureList, fmt.Sprintf("inbound:%v", reverseBridge.Ports)) } + if opts.Learning { + featureList = append(featureList, "learning(strace)") + } fmt.Fprintf(os.Stderr, "[greywall:linux] Sandbox: %s\n", strings.Join(featureList, ", ")) } diff --git a/internal/sandbox/linux_features.go b/internal/sandbox/linux_features.go index 9a5e74d..df4380a 100644 --- a/internal/sandbox/linux_features.go +++ b/internal/sandbox/linux_features.go @@ -36,9 +36,9 @@ type LinuxFeatures struct { CanUnshareNet bool // Transparent proxy support - HasIpCommand bool // ip (iproute2) available - HasDevNetTun bool // /dev/net/tun exists - HasTun2Socks bool // tun2socks embedded binary available + HasIpCommand bool // ip (iproute2) available + HasDevNetTun bool // /dev/net/tun exists + HasTun2Socks bool // tun2socks embedded binary available // Kernel version KernelMajor int diff --git a/internal/sandbox/linux_stub.go b/internal/sandbox/linux_stub.go index 05d0c98..713e72f 100644 --- a/internal/sandbox/linux_stub.go +++ b/internal/sandbox/linux_stub.go @@ -29,11 +29,13 @@ type ReverseBridge struct { // LinuxSandboxOptions is a stub for non-Linux platforms. type LinuxSandboxOptions struct { - UseLandlock bool - UseSeccomp bool - UseEBPF bool - Monitor bool - Debug bool + UseLandlock bool + UseSeccomp bool + UseEBPF bool + Monitor bool + Debug bool + Learning bool + StraceLogPath string } // NewProxyBridge returns an error on non-Linux platforms. diff --git a/internal/sandbox/macos_test.go b/internal/sandbox/macos_test.go index 639a2bc..6e466e5 100644 --- a/internal/sandbox/macos_test.go +++ b/internal/sandbox/macos_test.go @@ -11,11 +11,11 @@ import ( // the macOS sandbox profile allows outbound to the proxy host:port. func TestMacOS_NetworkRestrictionWithProxy(t *testing.T) { tests := []struct { - name string - proxyURL string - wantProxy bool - proxyHost string - proxyPort string + name string + proxyURL string + wantProxy bool + proxyHost string + proxyPort string }{ { name: "no proxy - network blocked", diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 0a0ba9e..dcd08c6 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -19,6 +19,9 @@ type Manager struct { debug bool monitor bool initialized bool + learning bool // learning mode: permissive sandbox with strace + straceLogPath string // host-side temp file for strace output + commandName string // name of the command being learned } // NewManager creates a new sandbox manager. @@ -35,6 +38,21 @@ func (m *Manager) SetExposedPorts(ports []int) { m.exposedPorts = ports } +// SetLearning enables or disables learning mode. +func (m *Manager) SetLearning(enabled bool) { + m.learning = enabled +} + +// SetCommandName sets the command name for learning mode template generation. +func (m *Manager) SetCommandName(name string) { + m.commandName = name +} + +// IsLearning returns whether learning mode is enabled. +func (m *Manager) IsLearning() bool { + return m.learning +} + // Initialize sets up the sandbox infrastructure. func (m *Manager) Initialize() error { if m.initialized { @@ -128,12 +146,55 @@ func (m *Manager) WrapCommand(command string) (string, error) { case platform.MacOS: return WrapCommandMacOS(m.config, command, m.exposedPorts, m.debug) case platform.Linux: + if m.learning { + return m.wrapCommandLearning(command) + } return WrapCommandLinux(m.config, command, m.proxyBridge, m.dnsBridge, m.reverseBridge, m.tun2socksPath, m.debug) default: return "", fmt.Errorf("unsupported platform: %s", plat) } } +// wrapCommandLearning creates a permissive sandbox with strace for learning mode. +func (m *Manager) wrapCommandLearning(command string) (string, error) { + // Create host-side temp file for strace output + tmpFile, err := os.CreateTemp("", "greywall-strace-*.log") + if err != nil { + return "", fmt.Errorf("failed to create strace log file: %w", err) + } + tmpFile.Close() + m.straceLogPath = tmpFile.Name() + + m.logDebug("Strace log file: %s", m.straceLogPath) + + return WrapCommandLinuxWithOptions(m.config, command, m.proxyBridge, m.dnsBridge, m.reverseBridge, m.tun2socksPath, LinuxSandboxOptions{ + UseLandlock: false, // Disabled: seccomp blocks ptrace which strace needs + UseSeccomp: false, // Disabled: conflicts with strace + UseEBPF: false, + Debug: m.debug, + Learning: true, + StraceLogPath: m.straceLogPath, + }) +} + +// GenerateLearnedTemplate generates a config template from the strace log collected during learning. +func (m *Manager) GenerateLearnedTemplate(cmdName string) (string, error) { + if m.straceLogPath == "" { + return "", fmt.Errorf("no strace log available (was learning mode enabled?)") + } + + templatePath, err := GenerateLearnedTemplate(m.straceLogPath, cmdName, m.debug) + if err != nil { + return "", err + } + + // Clean up strace log since we've processed it + os.Remove(m.straceLogPath) + m.straceLogPath = "" + + return templatePath, nil +} + // Cleanup stops the proxies and cleans up resources. func (m *Manager) Cleanup() { if m.reverseBridge != nil { @@ -148,6 +209,10 @@ func (m *Manager) Cleanup() { if m.tun2socksPath != "" { os.Remove(m.tun2socksPath) } + if m.straceLogPath != "" { + os.Remove(m.straceLogPath) + m.straceLogPath = "" + } m.logDebug("Sandbox manager cleaned up") } diff --git a/internal/sandbox/utils.go b/internal/sandbox/utils.go index 5f38da6..4b888cd 100644 --- a/internal/sandbox/utils.go +++ b/internal/sandbox/utils.go @@ -102,4 +102,3 @@ func DecodeSandboxedCommand(encoded string) (string, error) { } return string(data), nil } -