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

@@ -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 -- <command>\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 <name>")
fmt.Println("Use a template: greywall --template <name> -- <command>")
return nil
},
}
showCmd := &cobra.Command{
Use: "show <name>",
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] -- <command...>

69
docs/experience.md Normal file
View File

@@ -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.

View File

@@ -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"`

View File

@@ -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()
}

View File

@@ -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, "<unfinished") || strings.Contains(line, "resumed>") {
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, "<unfinished") || strings.Contains(line, "resumed>") {
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
}

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

View File

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

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

View File

@@ -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, ", "))
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

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

View File

@@ -102,4 +102,3 @@ func DecodeSandboxedCommand(encoded string) (string, error) {
}
return string(data), nil
}