Compare commits
10 Commits
5bb42db57a
...
feat-isola
| Author | SHA1 | Date | |
|---|---|---|---|
| a04f5feee2 | |||
| c95fca830b | |||
| 5affaf77a5 | |||
| b55b3364af | |||
| 70d0685c97 | |||
| a470f86ee4 | |||
| 7e85083c38 | |||
| 267c82f4bd | |||
| 3dd772d35a | |||
| 631db40665 |
@@ -1,5 +1,3 @@
|
||||

|
||||
|
||||
# Greywall
|
||||
|
||||
**The sandboxing layer of the GreyHaven platform.**
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 407 KiB |
@@ -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() {
|
||||
@@ -50,9 +55,9 @@ func main() {
|
||||
Long: `greywall is a command-line tool that runs commands in a sandboxed environment
|
||||
with network and filesystem restrictions.
|
||||
|
||||
By default, all network access is blocked. Use --proxy to route traffic through
|
||||
an external SOCKS5 proxy, or configure a proxy URL in your settings file at
|
||||
~/.config/greywall/greywall.json (or ~/Library/Application Support/greywall/greywall.json on macOS).
|
||||
By default, traffic is routed through the GreyHaven SOCKS5 proxy at localhost:42051
|
||||
with DNS via localhost:42053. Use --proxy and --dns to override, or configure in
|
||||
your settings file at ~/.config/greywall/greywall.json (or ~/Library/Application Support/greywall/greywall.json on macOS).
|
||||
|
||||
On Linux, greywall uses tun2socks for truly transparent proxying: all TCP/UDP traffic
|
||||
from any binary is captured at the kernel level via a TUN device and forwarded
|
||||
@@ -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:
|
||||
{
|
||||
@@ -92,16 +98,19 @@ Configuration file format:
|
||||
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations")
|
||||
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: OS config directory)")
|
||||
rootCmd.Flags().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (e.g., socks5://localhost:1080)")
|
||||
rootCmd.Flags().StringVar(&dnsAddr, "dns", "", "DNS server address on host (e.g., localhost:3153)")
|
||||
rootCmd.Flags().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (default: socks5://localhost:42052)")
|
||||
rootCmd.Flags().StringVar(&dnsAddr, "dns", "", "DNS server address on host (default: localhost:42053)")
|
||||
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
|
||||
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)
|
||||
@@ -116,6 +125,7 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf(" Version: %s\n", version)
|
||||
fmt.Printf(" Built: %s\n", buildTime)
|
||||
fmt.Printf(" Commit: %s\n", gitCommit)
|
||||
sandbox.PrintDependencyStatus()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -175,6 +185,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 +230,54 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
||||
cfg.Network.DnsAddr = dnsAddr
|
||||
}
|
||||
|
||||
// GreyHaven defaults: when no proxy or DNS is configured (neither via CLI
|
||||
// nor config file), use the standard GreyHaven infrastructure ports.
|
||||
if cfg.Network.ProxyURL == "" {
|
||||
cfg.Network.ProxyURL = "socks5://localhost:42052"
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] Defaulting proxy to socks5://localhost:42052\n")
|
||||
}
|
||||
}
|
||||
if cfg.Network.DnsAddr == "" {
|
||||
cfg.Network.DnsAddr = "localhost:42053"
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] Defaulting DNS to localhost:42053\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-inject proxy credentials so the proxy can identify the sandboxed command.
|
||||
// - If a command name is available, use it as the username with "proxy" as password.
|
||||
// - If no command name, default to "proxy:proxy" (required by gost for auth).
|
||||
// This always overrides any existing credentials in the URL.
|
||||
if cfg.Network.ProxyURL != "" {
|
||||
if u, err := url.Parse(cfg.Network.ProxyURL); err == nil {
|
||||
proxyUser := "proxy"
|
||||
if cmdName != "" {
|
||||
proxyUser = cmdName
|
||||
}
|
||||
u.User = url.UserPassword(proxyUser, "proxy")
|
||||
cfg.Network.ProxyURL = u.String()
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] Auto-set proxy credentials to %q:proxy\n", proxyUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -263,18 +356,61 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
||||
}()
|
||||
|
||||
// Wait for command to finish
|
||||
commandFailed := false
|
||||
if err := execCmd.Wait(); err != nil {
|
||||
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
|
||||
commandFailed = true
|
||||
} else {
|
||||
return fmt.Errorf("command failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate learned template after command completes successfully.
|
||||
// Skip template generation if the command failed — the strace trace
|
||||
// is likely incomplete and would produce an unreliable template.
|
||||
if learning && manager.IsLearning() {
|
||||
if commandFailed {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] Skipping template generation: command exited with code %d\n", exitCode)
|
||||
} else {
|
||||
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 +456,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...>
|
||||
|
||||
121
docs/experience.md
Normal file
121
docs/experience.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## Network namespaces fail on Ubuntu 24.04 (`RTM_NEWADDR: Operation not permitted`)
|
||||
|
||||
**Problem:** On Ubuntu 24.04 (tested in a KVM guest with bridged virtio/virbr0), `--version` reports `bwrap(no-netns)` and transparent proxy is unavailable. `kernel.unprivileged_userns_clone=1` is set, bwrap and socat are installed, but `bwrap --unshare-net` fails with:
|
||||
```
|
||||
bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted
|
||||
```
|
||||
|
||||
**Cause:** Ubuntu 24.04 introduced `kernel.apparmor_restrict_unprivileged_userns` (default: 1). This strips capabilities like `CAP_NET_ADMIN` from processes inside unprivileged user namespaces, even without a bwrap-specific AppArmor profile. Bubblewrap creates the network namespace successfully but cannot configure the loopback interface (adding 127.0.0.1 via netlink RTM_NEWADDR requires `CAP_NET_ADMIN`). Not a hypervisor issue — happens on bare metal Ubuntu 24.04 too.
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
sysctl kernel.apparmor_restrict_unprivileged_userns # likely returns 1
|
||||
bwrap --unshare-net --ro-bind / / -- /bin/true # reproduces the error
|
||||
```
|
||||
|
||||
**Fix:** Disable the restriction (requires root on the guest):
|
||||
```bash
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
# Persist across reboots:
|
||||
echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/99-greywall-userns.conf
|
||||
```
|
||||
|
||||
**Alternative:** Accept the limitation — greywall still works for filesystem sandboxing, seccomp, and Landlock. Network access is blocked outright rather than redirected through a proxy.
|
||||
|
||||
---
|
||||
|
||||
## Linux: symlinked system dirs invisible after `--tmpfs /`
|
||||
|
||||
**Problem:** On merged-usr distros (Arch, Fedora, modern Ubuntu), `/bin`, `/sbin`, `/lib`, `/lib64` are symlinks (e.g., `/bin -> usr/bin`). When switching from `--ro-bind / /` to `--tmpfs /` for deny-by-default isolation, these symlinks don't exist in the empty root. The `canMountOver()` helper explicitly rejects symlinks, so `--ro-bind /bin /bin` was silently skipped. Result: `execvp /usr/bin/bash: No such file or directory` — bash exists at `/usr/bin/bash` but the dynamic linker at `/lib64/ld-linux-x86-64.so.2` can't be found because `/lib64` is missing.
|
||||
|
||||
**Diagnosis:** The error message is misleading. `execvp` reports "No such file or directory" both when the binary is missing and when the ELF interpreter (dynamic linker) is missing. The actual binary `/usr/bin/bash` existed via the `/usr` bind-mount, but the symlink `/lib64 -> usr/lib` was gone.
|
||||
|
||||
**Fix:** Check each system path with `isSymlink()` before mounting. Symlinks get `--symlink <target> <path>` (bwrap recreates the symlink inside the sandbox); real directories get `--ro-bind`. On Arch: `--symlink usr/bin /bin`, `--symlink usr/bin /sbin`, `--symlink usr/lib /lib`, `--symlink usr/lib /lib64`.
|
||||
|
||||
---
|
||||
|
||||
## Linux: Landlock denies reads on bind-mounted /dev/null
|
||||
|
||||
**Problem:** To mask `.env` files inside CWD, the initial approach used `--ro-bind /dev/null <cwd>/.env`. Inside the sandbox, `.env` appeared as a character device (bind mounts preserve file type). Landlock's `LANDLOCK_ACCESS_FS_READ_FILE` right only covers regular files, not character devices. Result: `cat .env` returned "Permission denied" instead of empty content.
|
||||
|
||||
**Fix:** Use an empty regular file (`/tmp/greywall/empty`, 0 bytes, mode 0444) as the mask source instead of `/dev/null`. Landlock sees a regular file and allows the read. The file is created once in a fixed location under the greywall temp dir.
|
||||
|
||||
---
|
||||
|
||||
## Linux: mandatory deny paths override sensitive file masks
|
||||
|
||||
**Problem:** In deny-by-default mode, `buildDenyByDefaultMounts()` correctly masked `.env` with `--ro-bind /tmp/greywall/empty <cwd>/.env`. But later in `WrapCommandLinuxWithOptions()`, the mandatory deny paths section called `getMandatoryDenyPaths()` which included `.env` files (added for write protection). It then applied `--ro-bind <cwd>/.env <cwd>/.env`, binding the real file over the empty mask. bwrap applies mounts in order, so the later ro-bind undid the masking.
|
||||
|
||||
**Fix:** Track paths already masked by `buildDenyByDefaultMounts()` in a set. Skip those paths in the mandatory deny section to preserve the empty-file overlay.
|
||||
@@ -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"`
|
||||
@@ -36,7 +36,7 @@ type NetworkConfig struct {
|
||||
|
||||
// FilesystemConfig defines filesystem restrictions.
|
||||
type FilesystemConfig struct {
|
||||
DefaultDenyRead bool `json:"defaultDenyRead,omitempty"` // If true, deny reads by default except system paths and AllowRead
|
||||
DefaultDenyRead *bool `json:"defaultDenyRead,omitempty"` // If nil or true, deny reads by default except system paths, CWD, and AllowRead
|
||||
AllowRead []string `json:"allowRead"` // Paths to allow reading (used when DefaultDenyRead is true)
|
||||
DenyRead []string `json:"denyRead"`
|
||||
AllowWrite []string `json:"allowWrite"`
|
||||
@@ -44,6 +44,12 @@ type FilesystemConfig struct {
|
||||
AllowGitConfig bool `json:"allowGitConfig,omitempty"`
|
||||
}
|
||||
|
||||
// IsDefaultDenyRead returns whether deny-by-default read mode is enabled.
|
||||
// Defaults to true when not explicitly set (nil).
|
||||
func (f *FilesystemConfig) IsDefaultDenyRead() bool {
|
||||
return f.DefaultDenyRead == nil || *f.DefaultDenyRead
|
||||
}
|
||||
|
||||
// CommandConfig defines command restrictions.
|
||||
type CommandConfig struct {
|
||||
Deny []string `json:"deny"`
|
||||
@@ -417,8 +423,8 @@ func Merge(base, override *Config) *Config {
|
||||
},
|
||||
|
||||
Filesystem: FilesystemConfig{
|
||||
// Boolean fields: true if either enables it
|
||||
DefaultDenyRead: base.Filesystem.DefaultDenyRead || override.Filesystem.DefaultDenyRead,
|
||||
// Pointer field: override wins if set, otherwise base (nil = deny-by-default)
|
||||
DefaultDenyRead: mergeOptionalBool(base.Filesystem.DefaultDenyRead, override.Filesystem.DefaultDenyRead),
|
||||
|
||||
// Append slices
|
||||
AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead),
|
||||
|
||||
@@ -410,7 +410,7 @@ func TestMerge(t *testing.T) {
|
||||
t.Run("merge defaultDenyRead and allowRead", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Filesystem: FilesystemConfig{
|
||||
DefaultDenyRead: true,
|
||||
DefaultDenyRead: boolPtr(true),
|
||||
AllowRead: []string{"/home/user/project"},
|
||||
},
|
||||
}
|
||||
@@ -421,13 +421,40 @@ func TestMerge(t *testing.T) {
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
if !result.Filesystem.DefaultDenyRead {
|
||||
t.Error("expected DefaultDenyRead to be true (from base)")
|
||||
if !result.Filesystem.IsDefaultDenyRead() {
|
||||
t.Error("expected IsDefaultDenyRead() to be true (from base)")
|
||||
}
|
||||
if len(result.Filesystem.AllowRead) != 2 {
|
||||
t.Errorf("expected 2 allowRead paths, got %d: %v", len(result.Filesystem.AllowRead), result.Filesystem.AllowRead)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaultDenyRead nil defaults to true", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Filesystem: FilesystemConfig{},
|
||||
}
|
||||
result := Merge(base, nil)
|
||||
if !result.Filesystem.IsDefaultDenyRead() {
|
||||
t.Error("expected IsDefaultDenyRead() to be true when nil (deny-by-default)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaultDenyRead explicit false overrides", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Filesystem: FilesystemConfig{
|
||||
DefaultDenyRead: boolPtr(true),
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Filesystem: FilesystemConfig{
|
||||
DefaultDenyRead: boolPtr(false),
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
if result.Filesystem.IsDefaultDenyRead() {
|
||||
t.Error("expected IsDefaultDenyRead() to be false (override explicit false)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
|
||||
@@ -28,6 +28,30 @@ var DangerousDirectories = []string{
|
||||
".claude/agents",
|
||||
}
|
||||
|
||||
// SensitiveProjectFiles lists files within the project directory that should be
|
||||
// denied for both read and write access. These commonly contain secrets.
|
||||
var SensitiveProjectFiles = []string{
|
||||
".env",
|
||||
".env.local",
|
||||
".env.development",
|
||||
".env.production",
|
||||
".env.staging",
|
||||
".env.test",
|
||||
}
|
||||
|
||||
// GetSensitiveProjectPaths returns concrete paths for sensitive files within the
|
||||
// given directory. Only returns paths for files that actually exist.
|
||||
func GetSensitiveProjectPaths(cwd string) []string {
|
||||
var paths []string
|
||||
for _, f := range SensitiveProjectFiles {
|
||||
p := filepath.Join(cwd, f)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// GetDefaultWritePaths returns system paths that should be writable for commands to work.
|
||||
func GetDefaultWritePaths() []string {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
@@ -123,13 +123,17 @@ func assertContains(t *testing.T, haystack, needle string) {
|
||||
// ============================================================================
|
||||
|
||||
// testConfig creates a test configuration with sensible defaults.
|
||||
// Uses legacy mode (defaultDenyRead=false) for predictable testing of
|
||||
// existing integration tests. Use testConfigDenyByDefault() for tests
|
||||
// that specifically test deny-by-default behavior.
|
||||
func testConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Network: config.NetworkConfig{},
|
||||
Filesystem: config.FilesystemConfig{
|
||||
DenyRead: []string{},
|
||||
AllowWrite: []string{},
|
||||
DenyWrite: []string{},
|
||||
DefaultDenyRead: boolPtr(false), // Legacy mode for existing tests
|
||||
DenyRead: []string{},
|
||||
AllowWrite: []string{},
|
||||
DenyWrite: []string{},
|
||||
},
|
||||
Command: config.CommandConfig{
|
||||
Deny: []string{},
|
||||
|
||||
440
internal/sandbox/learning.go
Normal file
440
internal/sandbox/learning.go
Normal file
@@ -0,0 +1,440 @@
|
||||
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))
|
||||
}
|
||||
|
||||
// Filter read paths: remove system defaults, CWD subtree, and sensitive paths
|
||||
cwd, _ := os.Getwd()
|
||||
var filteredReads []string
|
||||
defaultReadable := GetDefaultReadablePaths()
|
||||
for _, p := range result.ReadPaths {
|
||||
// Skip system defaults
|
||||
isDefault := false
|
||||
for _, dp := range defaultReadable {
|
||||
if p == dp || strings.HasPrefix(p, dp+"/") {
|
||||
isDefault = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isDefault {
|
||||
continue
|
||||
}
|
||||
// Skip CWD subtree (auto-included)
|
||||
if cwd != "" && (p == cwd || strings.HasPrefix(p, cwd+"/")) {
|
||||
continue
|
||||
}
|
||||
// Skip sensitive paths
|
||||
if isSensitivePath(p, home) {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] Skipping sensitive read path: %s\n", p)
|
||||
}
|
||||
continue
|
||||
}
|
||||
filteredReads = append(filteredReads, p)
|
||||
}
|
||||
|
||||
// Collapse read paths and convert to tilde-relative
|
||||
collapsedReads := CollapsePaths(filteredReads)
|
||||
var allowRead []string
|
||||
for _, p := range collapsedReads {
|
||||
allowRead = append(allowRead, 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(allowRead) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] Additional read paths (beyond system + CWD):\n")
|
||||
for _, p := range allowRead {
|
||||
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, allowRead, 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 — but never collapse to $HOME
|
||||
for _, p := range standalone {
|
||||
parent := filepath.Dir(p)
|
||||
if parent == home {
|
||||
// Keep exact file path to avoid opening entire home directory
|
||||
result = append(result, p)
|
||||
} else {
|
||||
result = append(result, parent)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// getSensitiveProjectDenyPatterns returns denyRead entries for sensitive project files.
|
||||
func getSensitiveProjectDenyPatterns() []string {
|
||||
return []string{
|
||||
".env",
|
||||
".env.*",
|
||||
}
|
||||
}
|
||||
|
||||
// buildTemplate generates the JSONC template content for a learned config.
|
||||
func buildTemplate(cmdName string, allowRead, allowWrite []string) string {
|
||||
type fsConfig struct {
|
||||
AllowRead []string `json:"allowRead,omitempty"`
|
||||
AllowWrite []string `json:"allowWrite"`
|
||||
DenyWrite []string `json:"denyWrite"`
|
||||
DenyRead []string `json:"denyRead"`
|
||||
}
|
||||
type templateConfig struct {
|
||||
Filesystem fsConfig `json:"filesystem"`
|
||||
}
|
||||
|
||||
// Combine sensitive read patterns with .env project patterns
|
||||
denyRead := append(getSensitiveReadPatterns(), getSensitiveProjectDenyPatterns()...)
|
||||
|
||||
cfg := templateConfig{
|
||||
Filesystem: fsConfig{
|
||||
AllowRead: allowRead,
|
||||
AllowWrite: allowWrite,
|
||||
DenyWrite: getDangerousFilePatterns(),
|
||||
DenyRead: denyRead,
|
||||
},
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
298
internal/sandbox/learning_linux.go
Normal file
298
internal/sandbox/learning_linux.go
Normal 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
|
||||
}
|
||||
243
internal/sandbox/learning_linux_test.go
Normal file
243
internal/sandbox/learning_linux_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
//go:build linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractWritePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "openat with O_WRONLY",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/.cache/opencode/db", O_WRONLY|O_CREAT, 0644) = 3`,
|
||||
expected: "/home/user/.cache/opencode/db",
|
||||
},
|
||||
{
|
||||
name: "openat with O_RDWR",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/.cache/opencode/data", O_RDWR|O_CREAT, 0644) = 3`,
|
||||
expected: "/home/user/.cache/opencode/data",
|
||||
},
|
||||
{
|
||||
name: "openat with O_CREAT",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/file.txt", O_CREAT|O_WRONLY, 0644) = 3`,
|
||||
expected: "/home/user/file.txt",
|
||||
},
|
||||
{
|
||||
name: "openat read-only ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/readme.txt", O_RDONLY) = 3`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "mkdirat",
|
||||
line: `12345 mkdirat(AT_FDCWD, "/home/user/.cache/opencode", 0755) = 0`,
|
||||
expected: "/home/user/.cache/opencode",
|
||||
},
|
||||
{
|
||||
name: "unlinkat",
|
||||
line: `12345 unlinkat(AT_FDCWD, "/home/user/temp.txt", 0) = 0`,
|
||||
expected: "/home/user/temp.txt",
|
||||
},
|
||||
{
|
||||
name: "creat",
|
||||
line: `12345 creat("/home/user/newfile", 0644) = 3`,
|
||||
expected: "/home/user/newfile",
|
||||
},
|
||||
{
|
||||
name: "failed syscall ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/nonexistent", O_WRONLY|O_CREAT, 0644) = -1 ENOENT (No such file or directory)`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "unfinished syscall ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/file", O_WRONLY <unfinished ...>`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "non-write syscall ignored",
|
||||
line: `12345 read(3, "data", 1024) = 5`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "renameat2 returns destination",
|
||||
line: `12345 renameat2(AT_FDCWD, "/home/user/old.txt", AT_FDCWD, "/home/user/new.txt", 0) = 0`,
|
||||
expected: "/home/user/new.txt",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractWritePath(tt.line)
|
||||
if got != tt.expected {
|
||||
t.Errorf("extractWritePath(%q) = %q, want %q", tt.line, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldFilterPath(t *testing.T) {
|
||||
home := "/home/testuser"
|
||||
tests := []struct {
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{"/proc/self/maps", true},
|
||||
{"/sys/kernel/mm/transparent_hugepage", true},
|
||||
{"/dev/null", true},
|
||||
{"/tmp/somefile", true},
|
||||
{"/run/user/1000/bus", true},
|
||||
{"/home/testuser/.cache/opencode/db", false},
|
||||
{"/usr/lib/libfoo.so", true}, // .so file
|
||||
{"/usr/lib/libfoo.so.1", true}, // .so.X file
|
||||
{"/tmp/greywall-strace-abc.log", true}, // greywall infrastructure
|
||||
{"relative/path", true}, // relative path
|
||||
{"", true}, // empty path
|
||||
{"/other/user/file", true}, // outside home
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := shouldFilterPath(tt.path, home)
|
||||
if got != tt.expected {
|
||||
t.Errorf("shouldFilterPath(%q, %q) = %v, want %v", tt.path, home, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStraceLog(t *testing.T) {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
logContent := strings.Join([]string{
|
||||
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/db") + `", O_WRONLY|O_CREAT, 0644) = 3`,
|
||||
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/ver") + `", O_WRONLY, 0644) = 4`,
|
||||
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".config/testapp/conf.json") + `", O_RDONLY) = 5`,
|
||||
`12345 openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 6`,
|
||||
`12345 mkdirat(AT_FDCWD, "` + filepath.Join(home, ".config/testapp") + `", 0755) = 0`,
|
||||
`12345 openat(AT_FDCWD, "/tmp/somefile", O_WRONLY|O_CREAT, 0644) = 7`,
|
||||
`12345 openat(AT_FDCWD, "/proc/self/maps", O_RDONLY) = 8`,
|
||||
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/db") + `", O_WRONLY, 0644) = 9`, // duplicate
|
||||
}, "\n")
|
||||
|
||||
logFile := filepath.Join(t.TempDir(), "strace.log")
|
||||
if err := os.WriteFile(logFile, []byte(logContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := ParseStraceLog(logFile, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseStraceLog() error: %v", err)
|
||||
}
|
||||
|
||||
// Write paths: should have unique home paths only (no /tmp, /proc)
|
||||
for _, p := range result.WritePaths {
|
||||
if !strings.HasPrefix(p, home+"/") {
|
||||
t.Errorf("WritePaths returned path outside home: %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Should not have duplicates in write paths
|
||||
seen := make(map[string]bool)
|
||||
for _, p := range result.WritePaths {
|
||||
if seen[p] {
|
||||
t.Errorf("WritePaths returned duplicate: %q", p)
|
||||
}
|
||||
seen[p] = true
|
||||
}
|
||||
|
||||
// Should have the expected write paths
|
||||
expectedWrites := map[string]bool{
|
||||
filepath.Join(home, ".cache/testapp/db"): false,
|
||||
filepath.Join(home, ".cache/testapp/ver"): false,
|
||||
filepath.Join(home, ".config/testapp"): false,
|
||||
}
|
||||
for _, p := range result.WritePaths {
|
||||
if _, ok := expectedWrites[p]; ok {
|
||||
expectedWrites[p] = true
|
||||
}
|
||||
}
|
||||
for p, found := range expectedWrites {
|
||||
if !found {
|
||||
t.Errorf("WritePaths missing expected path: %q, got: %v", p, result.WritePaths)
|
||||
}
|
||||
}
|
||||
|
||||
// Should have the expected read paths (only home paths, not /etc or /proc)
|
||||
expectedRead := filepath.Join(home, ".config/testapp/conf.json")
|
||||
foundRead := false
|
||||
for _, p := range result.ReadPaths {
|
||||
if p == expectedRead {
|
||||
foundRead = true
|
||||
}
|
||||
if !strings.HasPrefix(p, home+"/") {
|
||||
t.Errorf("ReadPaths returned path outside home: %q", p)
|
||||
}
|
||||
}
|
||||
if !foundRead {
|
||||
t.Errorf("ReadPaths missing expected path: %q, got: %v", expectedRead, result.ReadPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractReadPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "openat with O_RDONLY",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/.config/app/conf", O_RDONLY) = 3`,
|
||||
expected: "/home/user/.config/app/conf",
|
||||
},
|
||||
{
|
||||
name: "openat with write flags ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/file", O_WRONLY|O_CREAT, 0644) = 3`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "non-openat ignored",
|
||||
line: `12345 read(3, "data", 1024) = 5`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "failed openat ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/nonexistent", O_RDONLY) = -1 ENOENT (No such file or directory)`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "directory open ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user", O_RDONLY|O_DIRECTORY) = 3`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "directory open with cloexec ignored",
|
||||
line: `12345 openat(AT_FDCWD, "/home/user/.cache", O_RDONLY|O_CLOEXEC|O_DIRECTORY) = 4`,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractReadPath(tt.line)
|
||||
if got != tt.expected {
|
||||
t.Errorf("extractReadPath(%q) = %q, want %q", tt.line, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStraceAvailable(t *testing.T) {
|
||||
// This test just verifies the function doesn't panic.
|
||||
// The result depends on whether strace is installed on the test system.
|
||||
err := CheckStraceAvailable()
|
||||
if err != nil {
|
||||
t.Logf("strace not available (expected in some CI environments): %v", err)
|
||||
}
|
||||
}
|
||||
21
internal/sandbox/learning_stub.go
Normal file
21
internal/sandbox/learning_stub.go
Normal 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")
|
||||
}
|
||||
459
internal/sandbox/learning_test.go
Normal file
459
internal/sandbox/learning_test.go
Normal file
@@ -0,0 +1,459 @@
|
||||
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
|
||||
notContains []string // paths that must NOT 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",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "files directly under home stay as exact paths",
|
||||
paths: []string{
|
||||
"/home/testuser/.gitignore",
|
||||
"/home/testuser/.npmrc",
|
||||
},
|
||||
contains: []string{
|
||||
"/home/testuser/.gitignore",
|
||||
"/home/testuser/.npmrc",
|
||||
},
|
||||
notContains: []string{"/home/testuser"},
|
||||
},
|
||||
{
|
||||
name: "mix of home files and app dir paths",
|
||||
paths: []string{
|
||||
"/home/testuser/.gitignore",
|
||||
"/home/testuser/.cache/opencode/db/main.sqlite",
|
||||
"/home/testuser/.cache/opencode/version",
|
||||
"/home/testuser/.npmrc",
|
||||
},
|
||||
contains: []string{
|
||||
"/home/testuser/.gitignore",
|
||||
"/home/testuser/.npmrc",
|
||||
"/home/testuser/.cache/opencode",
|
||||
},
|
||||
notContains: []string{"/home/testuser"},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
for _, bad := range tt.notContains {
|
||||
for _, g := range got {
|
||||
if g == bad {
|
||||
t.Errorf("CollapsePaths() = %v, should NOT contain %q", got, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
allowRead := []string{"~/external-data"}
|
||||
allowWrite := []string{".", "~/.cache/opencode", "~/.config/opencode"}
|
||||
result := buildTemplate("opencode", allowRead, 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, `"allowRead"`) {
|
||||
t.Error("template missing allowRead field")
|
||||
}
|
||||
if !strings.Contains(result, `"~/external-data"`) {
|
||||
t.Error("template missing expected allowRead path")
|
||||
}
|
||||
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")
|
||||
}
|
||||
// Check .env patterns are included in denyRead
|
||||
if !strings.Contains(result, `".env"`) {
|
||||
t.Error("template missing .env in denyRead")
|
||||
}
|
||||
if !strings.Contains(result, `".env.*"`) {
|
||||
t.Error("template missing .env.* in denyRead")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTemplateNoAllowRead(t *testing.T) {
|
||||
result := buildTemplate("simple-cmd", nil, []string{"."})
|
||||
|
||||
// When allowRead is nil, it should be omitted from JSON
|
||||
if strings.Contains(result, `"allowRead"`) {
|
||||
t.Error("template should omit allowRead when nil")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -20,19 +20,19 @@ 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
|
||||
}
|
||||
|
||||
// DnsBridge bridges DNS queries from the sandbox to a host-side DNS server via Unix socket.
|
||||
// Inside the sandbox, a socat relay converts UDP DNS queries (port 53) to the Unix socket.
|
||||
// On the host, socat forwards from the Unix socket to the actual DNS server (TCP).
|
||||
// On the host, socat forwards from the Unix socket to the actual DNS server (UDP).
|
||||
type DnsBridge struct {
|
||||
SocketPath string // Unix socket path
|
||||
DnsAddr string // Host-side DNS address (host:port)
|
||||
@@ -61,10 +61,10 @@ func NewDnsBridge(dnsAddr string, debug bool) (*DnsBridge, error) {
|
||||
debug: debug,
|
||||
}
|
||||
|
||||
// Start bridge: Unix socket -> DNS server TCP
|
||||
// Start bridge: Unix socket -> DNS server UDP
|
||||
socatArgs := []string{
|
||||
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socketPath),
|
||||
fmt.Sprintf("TCP:%s", dnsAddr),
|
||||
fmt.Sprintf("UDP:%s", dnsAddr),
|
||||
}
|
||||
bridge.process = exec.Command("socat", socatArgs...) //nolint:gosec // args constructed from trusted input
|
||||
if debug {
|
||||
@@ -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.
|
||||
@@ -367,6 +371,12 @@ func getMandatoryDenyPaths(cwd string) []string {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
|
||||
// Sensitive project files (e.g. .env) in cwd
|
||||
for _, f := range SensitiveProjectFiles {
|
||||
p := filepath.Join(cwd, f)
|
||||
paths = append(paths, p)
|
||||
}
|
||||
|
||||
// Git hooks in cwd
|
||||
paths = append(paths, filepath.Join(cwd, ".git/hooks"))
|
||||
|
||||
@@ -385,6 +395,193 @@ func getMandatoryDenyPaths(cwd string) []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
// buildDenyByDefaultMounts builds bwrap arguments for deny-by-default filesystem isolation.
|
||||
// Starts with --tmpfs / (empty root), then selectively mounts system paths read-only,
|
||||
// CWD read-write, and user tooling paths read-only. Sensitive files within CWD are masked.
|
||||
func buildDenyByDefaultMounts(cfg *config.Config, cwd string, debug bool) []string {
|
||||
var args []string
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
// Start with empty root
|
||||
args = append(args, "--tmpfs", "/")
|
||||
|
||||
// System paths (read-only) - on modern distros (Arch, Fedora, etc.),
|
||||
// /bin, /sbin, /lib, /lib64 are often symlinks to /usr/*. We must
|
||||
// recreate these as symlinks via --symlink so the dynamic linker
|
||||
// and shell can be found. Real directories get bind-mounted.
|
||||
systemPaths := []string{"/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt", "/run"}
|
||||
for _, p := range systemPaths {
|
||||
if !fileExists(p) {
|
||||
continue
|
||||
}
|
||||
if isSymlink(p) {
|
||||
// Recreate the symlink inside the sandbox (e.g., /bin -> usr/bin)
|
||||
target, err := os.Readlink(p)
|
||||
if err == nil {
|
||||
args = append(args, "--symlink", target, p)
|
||||
}
|
||||
} else {
|
||||
args = append(args, "--ro-bind", p, p)
|
||||
}
|
||||
}
|
||||
|
||||
// /sys needs to be accessible for system info
|
||||
if fileExists("/sys") && canMountOver("/sys") {
|
||||
args = append(args, "--ro-bind", "/sys", "/sys")
|
||||
}
|
||||
|
||||
// CWD: create intermediary dirs and bind read-write
|
||||
if cwd != "" && fileExists(cwd) {
|
||||
for _, dir := range intermediaryDirs("/", cwd) {
|
||||
// Skip dirs that are already mounted as system paths
|
||||
if isSystemMountPoint(dir) {
|
||||
continue
|
||||
}
|
||||
args = append(args, "--dir", dir)
|
||||
}
|
||||
args = append(args, "--bind", cwd, cwd)
|
||||
}
|
||||
|
||||
// User tooling paths from GetDefaultReadablePaths() (read-only)
|
||||
// Filter out paths already mounted (system dirs, /dev, /proc, /tmp, macOS-specific)
|
||||
if home != "" {
|
||||
boundDirs := make(map[string]bool)
|
||||
for _, p := range GetDefaultReadablePaths() {
|
||||
// Skip system paths (already bound above), special mounts, and macOS paths
|
||||
if isSystemMountPoint(p) || p == "/dev" || p == "/proc" || p == "/sys" ||
|
||||
p == "/tmp" || p == "/private/tmp" ||
|
||||
strings.HasPrefix(p, "/System") || strings.HasPrefix(p, "/Library") ||
|
||||
strings.HasPrefix(p, "/Applications") || strings.HasPrefix(p, "/private/") ||
|
||||
strings.HasPrefix(p, "/nix") || strings.HasPrefix(p, "/snap") ||
|
||||
p == "/usr/local" || p == "/opt/homebrew" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(p, home) {
|
||||
continue // Only user tooling paths need intermediary dirs
|
||||
}
|
||||
if !fileExists(p) || !canMountOver(p) {
|
||||
continue
|
||||
}
|
||||
// Create intermediary dirs between root and this path
|
||||
for _, dir := range intermediaryDirs("/", p) {
|
||||
if !boundDirs[dir] && !isSystemMountPoint(dir) && dir != cwd {
|
||||
boundDirs[dir] = true
|
||||
args = append(args, "--dir", dir)
|
||||
}
|
||||
}
|
||||
args = append(args, "--ro-bind", p, p)
|
||||
}
|
||||
|
||||
// Shell config files in home (read-only, literal files)
|
||||
shellConfigs := []string{".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".inputrc"}
|
||||
homeIntermedaryAdded := boundDirs[home]
|
||||
for _, f := range shellConfigs {
|
||||
p := filepath.Join(home, f)
|
||||
if fileExists(p) && canMountOver(p) {
|
||||
if !homeIntermedaryAdded {
|
||||
for _, dir := range intermediaryDirs("/", home) {
|
||||
if !boundDirs[dir] && !isSystemMountPoint(dir) {
|
||||
boundDirs[dir] = true
|
||||
args = append(args, "--dir", dir)
|
||||
}
|
||||
}
|
||||
homeIntermedaryAdded = true
|
||||
}
|
||||
args = append(args, "--ro-bind", p, p)
|
||||
}
|
||||
}
|
||||
|
||||
// Home tool caches (read-only, for package managers/configs)
|
||||
homeCaches := []string{".cache", ".npm", ".cargo", ".rustup", ".local", ".config"}
|
||||
for _, d := range homeCaches {
|
||||
p := filepath.Join(home, d)
|
||||
if fileExists(p) && canMountOver(p) {
|
||||
if !homeIntermedaryAdded {
|
||||
for _, dir := range intermediaryDirs("/", home) {
|
||||
if !boundDirs[dir] && !isSystemMountPoint(dir) {
|
||||
boundDirs[dir] = true
|
||||
args = append(args, "--dir", dir)
|
||||
}
|
||||
}
|
||||
homeIntermedaryAdded = true
|
||||
}
|
||||
args = append(args, "--ro-bind", p, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User-specified allowRead paths (read-only)
|
||||
if cfg != nil && cfg.Filesystem.AllowRead != nil {
|
||||
boundPaths := make(map[string]bool)
|
||||
|
||||
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowRead)
|
||||
for _, p := range expandedPaths {
|
||||
if fileExists(p) && canMountOver(p) &&
|
||||
!strings.HasPrefix(p, "/dev/") && !strings.HasPrefix(p, "/proc/") && !boundPaths[p] {
|
||||
boundPaths[p] = true
|
||||
// Create intermediary dirs if needed
|
||||
for _, dir := range intermediaryDirs("/", p) {
|
||||
if !isSystemMountPoint(dir) {
|
||||
args = append(args, "--dir", dir)
|
||||
}
|
||||
}
|
||||
args = append(args, "--ro-bind", p, p)
|
||||
}
|
||||
}
|
||||
for _, p := range cfg.Filesystem.AllowRead {
|
||||
normalized := NormalizePath(p)
|
||||
if !ContainsGlobChars(normalized) && fileExists(normalized) && canMountOver(normalized) &&
|
||||
!strings.HasPrefix(normalized, "/dev/") && !strings.HasPrefix(normalized, "/proc/") && !boundPaths[normalized] {
|
||||
boundPaths[normalized] = true
|
||||
for _, dir := range intermediaryDirs("/", normalized) {
|
||||
if !isSystemMountPoint(dir) {
|
||||
args = append(args, "--dir", dir)
|
||||
}
|
||||
}
|
||||
args = append(args, "--ro-bind", normalized, normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mask sensitive project files within CWD by overlaying an empty regular file.
|
||||
// We use an empty file instead of /dev/null because Landlock's READ_FILE right
|
||||
// doesn't cover character devices, causing "Permission denied" on /dev/null mounts.
|
||||
if cwd != "" {
|
||||
var emptyFile string
|
||||
for _, f := range SensitiveProjectFiles {
|
||||
p := filepath.Join(cwd, f)
|
||||
if fileExists(p) {
|
||||
if emptyFile == "" {
|
||||
emptyFile = filepath.Join(os.TempDir(), "greywall", "empty")
|
||||
_ = os.MkdirAll(filepath.Dir(emptyFile), 0o750)
|
||||
_ = os.WriteFile(emptyFile, nil, 0o444)
|
||||
}
|
||||
args = append(args, "--ro-bind", emptyFile, p)
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Masking sensitive file: %s\n", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// isSystemMountPoint returns true if the path is a top-level system directory
|
||||
// that gets mounted directly under --tmpfs / (bwrap auto-creates these).
|
||||
func isSystemMountPoint(path string) bool {
|
||||
switch path {
|
||||
case "/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt", "/run", "/sys",
|
||||
"/dev", "/proc", "/tmp",
|
||||
// macOS
|
||||
"/System", "/Library", "/Applications", "/private",
|
||||
// Package managers
|
||||
"/nix", "/snap", "/usr/local", "/opt/homebrew":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
|
||||
// It uses available security features (Landlock, seccomp) with graceful fallback.
|
||||
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
|
||||
@@ -418,9 +615,15 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
|
||||
// Build bwrap args with filesystem restrictions
|
||||
bwrapArgs := []string{
|
||||
"bwrap",
|
||||
"--new-session",
|
||||
"--die-with-parent",
|
||||
}
|
||||
// --new-session calls setsid() which detaches from the controlling terminal.
|
||||
// Skip it in learning mode so interactive programs (TUIs, prompts) can
|
||||
// read from /dev/tty. Learning mode already relaxes security constraints
|
||||
// (no seccomp, no landlock), so skipping new-session is acceptable.
|
||||
if !opts.Learning {
|
||||
bwrapArgs = append(bwrapArgs, "--new-session")
|
||||
}
|
||||
bwrapArgs = append(bwrapArgs, "--die-with-parent")
|
||||
|
||||
// Always use --unshare-net when available (network namespace isolation)
|
||||
// Inside the namespace, tun2socks will provide transparent proxy access
|
||||
@@ -451,50 +654,37 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
|
||||
}
|
||||
}
|
||||
|
||||
defaultDenyRead := cfg != nil && cfg.Filesystem.DefaultDenyRead
|
||||
|
||||
if defaultDenyRead {
|
||||
// In defaultDenyRead mode, we only bind essential system paths read-only
|
||||
// and user-specified allowRead paths. Everything else is inaccessible.
|
||||
// Learning mode: permissive sandbox with home + cwd writable
|
||||
if opts.Learning {
|
||||
if opts.Debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - binding only essential system paths\n")
|
||||
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)
|
||||
}
|
||||
|
||||
// Bind essential system paths read-only
|
||||
// Skip /dev, /proc, /tmp as they're mounted with special options below
|
||||
for _, systemPath := range GetDefaultReadablePaths() {
|
||||
if systemPath == "/dev" || systemPath == "/proc" || systemPath == "/tmp" ||
|
||||
systemPath == "/private/tmp" {
|
||||
continue
|
||||
}
|
||||
if fileExists(systemPath) {
|
||||
bwrapArgs = append(bwrapArgs, "--ro-bind", systemPath, systemPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bind user-specified allowRead paths
|
||||
if cfg != nil && cfg.Filesystem.AllowRead != nil {
|
||||
boundPaths := make(map[string]bool)
|
||||
defaultDenyRead := cfg != nil && cfg.Filesystem.IsDefaultDenyRead()
|
||||
|
||||
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowRead)
|
||||
for _, p := range expandedPaths {
|
||||
if fileExists(p) && !strings.HasPrefix(p, "/dev/") && !strings.HasPrefix(p, "/proc/") && !boundPaths[p] {
|
||||
boundPaths[p] = true
|
||||
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
|
||||
}
|
||||
}
|
||||
// Add non-glob paths
|
||||
for _, p := range cfg.Filesystem.AllowRead {
|
||||
normalized := NormalizePath(p)
|
||||
if !ContainsGlobChars(normalized) && fileExists(normalized) &&
|
||||
!strings.HasPrefix(normalized, "/dev/") && !strings.HasPrefix(normalized, "/proc/") && !boundPaths[normalized] {
|
||||
boundPaths[normalized] = true
|
||||
bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized)
|
||||
}
|
||||
}
|
||||
if opts.Learning {
|
||||
// Skip defaultDenyRead logic in learning mode (already set up above)
|
||||
} else if defaultDenyRead {
|
||||
// Deny-by-default mode: start with empty root, then whitelist system paths + CWD
|
||||
if opts.Debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - tmpfs root with selective mounts\n")
|
||||
}
|
||||
bwrapArgs = append(bwrapArgs, buildDenyByDefaultMounts(cfg, cwd, opts.Debug)...)
|
||||
} else {
|
||||
// Default mode: bind entire root filesystem read-only
|
||||
// Legacy mode: bind entire root filesystem read-only
|
||||
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/")
|
||||
}
|
||||
|
||||
@@ -507,6 +697,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 +755,128 @@ 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)
|
||||
// In deny-by-default mode, sensitive project files are already masked
|
||||
// with --ro-bind /dev/null by buildDenyByDefaultMounts(). Skip them here
|
||||
// to avoid overriding the /dev/null mask with a real ro-bind.
|
||||
maskedPaths := make(map[string]bool)
|
||||
if defaultDenyRead {
|
||||
for _, f := range SensitiveProjectFiles {
|
||||
maskedPaths[filepath.Join(cwd, f)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) && !maskedPaths[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 +904,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 +926,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 +997,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 +1007,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 +1060,27 @@ 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.
|
||||
// Run strace in the foreground so the traced command retains terminal
|
||||
// access (stdin, /dev/tty) for interactive programs like TUIs.
|
||||
// If the app spawns long-lived child processes, strace -f may hang
|
||||
// after the main command exits; the user can Ctrl+C to stop it.
|
||||
// A SIGCHLD trap kills strace once its direct child exits, handling
|
||||
// the common case of background daemons (LSP servers, watchers).
|
||||
if opts.Learning && opts.StraceLogPath != "" {
|
||||
innerScript.WriteString(fmt.Sprintf(`# Learning mode: trace filesystem access (foreground for terminal access)
|
||||
strace -f -qq -I2 -e trace=openat,open,creat,mkdir,mkdirat,unlinkat,renameat,renameat2,symlinkat,linkat -o %s -- %s
|
||||
GREYWALL_STRACE_EXIT=$?
|
||||
# Kill any orphaned child processes (LSP servers, file watchers, etc.)
|
||||
# that were spawned by the traced command and reparented to PID 1.
|
||||
kill -TERM -1 2>/dev/null
|
||||
sleep 0.1
|
||||
exit $GREYWALL_STRACE_EXIT
|
||||
`,
|
||||
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 +1130,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, ", "))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -276,6 +276,107 @@ func (f *LinuxFeatures) MinimumViable() bool {
|
||||
return f.HasBwrap && f.HasSocat
|
||||
}
|
||||
|
||||
// PrintDependencyStatus prints dependency status with install suggestions for Linux.
|
||||
func PrintDependencyStatus() {
|
||||
features := DetectLinuxFeatures()
|
||||
|
||||
fmt.Printf("\n Platform: linux (kernel %d.%d)\n", features.KernelMajor, features.KernelMinor)
|
||||
|
||||
fmt.Printf("\n Dependencies (required):\n")
|
||||
|
||||
allGood := true
|
||||
if features.HasBwrap {
|
||||
fmt.Printf(" ✓ bubblewrap (bwrap)\n")
|
||||
} else {
|
||||
fmt.Printf(" ✗ bubblewrap (bwrap) — REQUIRED\n")
|
||||
allGood = false
|
||||
}
|
||||
if features.HasSocat {
|
||||
fmt.Printf(" ✓ socat\n")
|
||||
} else {
|
||||
fmt.Printf(" ✗ socat — REQUIRED\n")
|
||||
allGood = false
|
||||
}
|
||||
|
||||
if !allGood {
|
||||
fmt.Printf("\n Install missing dependencies:\n")
|
||||
fmt.Printf(" %s\n", suggestInstallCmd(features))
|
||||
}
|
||||
|
||||
fmt.Printf("\n Security features: %s\n", features.Summary())
|
||||
|
||||
if features.CanUseTransparentProxy() {
|
||||
fmt.Printf(" Transparent proxy: available\n")
|
||||
} else {
|
||||
parts := []string{}
|
||||
if !features.HasIpCommand {
|
||||
parts = append(parts, "iproute2")
|
||||
}
|
||||
if !features.HasDevNetTun {
|
||||
parts = append(parts, "/dev/net/tun")
|
||||
}
|
||||
if !features.CanUnshareNet {
|
||||
parts = append(parts, "network namespace")
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
fmt.Printf(" Transparent proxy: unavailable (missing: %s)\n", strings.Join(parts, ", "))
|
||||
} else {
|
||||
fmt.Printf(" Transparent proxy: unavailable\n")
|
||||
}
|
||||
|
||||
if !features.CanUnshareNet && features.HasBwrap {
|
||||
if val := readSysctl("kernel/apparmor_restrict_unprivileged_userns"); val == "1" {
|
||||
fmt.Printf("\n Note: AppArmor is restricting unprivileged user namespaces.\n")
|
||||
fmt.Printf(" This prevents bwrap --unshare-net (needed for transparent proxy).\n")
|
||||
fmt.Printf(" To fix: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0\n")
|
||||
fmt.Printf(" Persist: echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/99-greywall-userns.conf\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if allGood {
|
||||
fmt.Printf("\n Status: ready\n")
|
||||
} else {
|
||||
fmt.Printf("\n Status: missing required dependencies\n")
|
||||
}
|
||||
}
|
||||
|
||||
func suggestInstallCmd(features *LinuxFeatures) string {
|
||||
var missing []string
|
||||
if !features.HasBwrap {
|
||||
missing = append(missing, "bubblewrap")
|
||||
}
|
||||
if !features.HasSocat {
|
||||
missing = append(missing, "socat")
|
||||
}
|
||||
pkgs := strings.Join(missing, " ")
|
||||
|
||||
switch {
|
||||
case commandExists("apt-get"):
|
||||
return fmt.Sprintf("sudo apt install %s", pkgs)
|
||||
case commandExists("dnf"):
|
||||
return fmt.Sprintf("sudo dnf install %s", pkgs)
|
||||
case commandExists("yum"):
|
||||
return fmt.Sprintf("sudo yum install %s", pkgs)
|
||||
case commandExists("pacman"):
|
||||
return fmt.Sprintf("sudo pacman -S %s", pkgs)
|
||||
case commandExists("apk"):
|
||||
return fmt.Sprintf("sudo apk add %s", pkgs)
|
||||
case commandExists("zypper"):
|
||||
return fmt.Sprintf("sudo zypper install %s", pkgs)
|
||||
default:
|
||||
return fmt.Sprintf("install %s using your package manager", pkgs)
|
||||
}
|
||||
}
|
||||
|
||||
func readSysctl(name string) string {
|
||||
data, err := os.ReadFile("/proc/sys/" + name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func commandExists(name string) bool {
|
||||
_, err := exec.LookPath(name)
|
||||
return err == nil
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// LinuxFeatures describes available Linux sandboxing features.
|
||||
// This is a stub for non-Linux platforms.
|
||||
type LinuxFeatures struct {
|
||||
@@ -51,3 +57,21 @@ func (f *LinuxFeatures) CanUseTransparentProxy() bool {
|
||||
func (f *LinuxFeatures) MinimumViable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// PrintDependencyStatus prints dependency status for non-Linux platforms.
|
||||
func PrintDependencyStatus() {
|
||||
if runtime.GOOS == "darwin" {
|
||||
fmt.Printf("\n Platform: macOS\n")
|
||||
fmt.Printf("\n Dependencies (required):\n")
|
||||
if _, err := exec.LookPath("sandbox-exec"); err == nil {
|
||||
fmt.Printf(" ✓ sandbox-exec (Seatbelt)\n")
|
||||
fmt.Printf("\n Status: ready\n")
|
||||
} else {
|
||||
fmt.Printf(" ✗ sandbox-exec — REQUIRED (should be built-in on macOS)\n")
|
||||
fmt.Printf("\n Status: missing required dependencies\n")
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("\n Platform: %s (unsupported)\n", runtime.GOOS)
|
||||
fmt.Printf("\n Status: this platform is not supported\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,17 +81,55 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
|
||||
}
|
||||
}
|
||||
|
||||
// Current working directory - read access (may be upgraded to write below)
|
||||
// Current working directory - read+write access (project directory)
|
||||
if cwd != "" {
|
||||
if err := ruleset.AllowRead(cwd); err != nil && debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read path: %v\n", err)
|
||||
if err := ruleset.AllowReadWrite(cwd); err != nil && debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read/write path: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Home directory - read access
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if err := ruleset.AllowRead(home); err != nil && debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home read path: %v\n", err)
|
||||
// Home directory - read access only when not in deny-by-default mode.
|
||||
// In deny-by-default mode, only specific user tooling paths are allowed,
|
||||
// not the entire home directory. Landlock can't selectively deny files
|
||||
// within an allowed directory, so we rely on bwrap mount overlays for
|
||||
// .env file masking.
|
||||
defaultDenyRead := cfg != nil && cfg.Filesystem.IsDefaultDenyRead()
|
||||
if !defaultDenyRead {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if err := ruleset.AllowRead(home); err != nil && debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home read path: %v\n", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// In deny-by-default mode, allow specific user tooling paths
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
for _, p := range GetDefaultReadablePaths() {
|
||||
if strings.HasPrefix(p, home) {
|
||||
if err := ruleset.AllowRead(p); err != nil && debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add user tooling path %s: %v\n", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shell configs
|
||||
shellConfigs := []string{".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".inputrc"}
|
||||
for _, f := range shellConfigs {
|
||||
p := filepath.Join(home, f)
|
||||
if err := ruleset.AllowRead(p); err != nil && debug {
|
||||
if !os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add shell config %s: %v\n", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Home caches
|
||||
homeCaches := []string{".cache", ".npm", ".cargo", ".rustup", ".local", ".config"}
|
||||
for _, d := range homeCaches {
|
||||
p := filepath.Join(home, d)
|
||||
if err := ruleset.AllowRead(p); err != nil && debug {
|
||||
if !os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home cache %s: %v\n", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -37,6 +37,7 @@ type MacOSSandboxParams struct {
|
||||
AllowLocalBinding bool
|
||||
AllowLocalOutbound bool
|
||||
DefaultDenyRead bool
|
||||
Cwd string // Current working directory (for deny-by-default CWD allowlisting)
|
||||
ReadAllowPaths []string
|
||||
ReadDenyPaths []string
|
||||
WriteAllowPaths []string
|
||||
@@ -146,13 +147,13 @@ func getTmpdirParent() []string {
|
||||
}
|
||||
|
||||
// generateReadRules generates filesystem read rules for the sandbox profile.
|
||||
func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, logTag string) []string {
|
||||
func generateReadRules(defaultDenyRead bool, cwd string, allowPaths, denyPaths []string, logTag string) []string {
|
||||
var rules []string
|
||||
|
||||
if defaultDenyRead {
|
||||
// When defaultDenyRead is enabled:
|
||||
// 1. Allow file-read-metadata globally (needed for directory traversal, stat, etc.)
|
||||
// 2. Allow file-read-data only for system paths + user-specified allowRead paths
|
||||
// 2. Allow file-read-data only for system paths + CWD + user-specified allowRead paths
|
||||
// This lets programs see what files exist but not read their contents.
|
||||
|
||||
// Allow metadata operations globally (stat, readdir, etc.) and root dir (for path resolution)
|
||||
@@ -167,6 +168,44 @@ func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, log
|
||||
)
|
||||
}
|
||||
|
||||
// Allow reading CWD (full recursive read access)
|
||||
if cwd != "" {
|
||||
rules = append(rules,
|
||||
"(allow file-read-data",
|
||||
fmt.Sprintf(" (subpath %s))", escapePath(cwd)),
|
||||
)
|
||||
|
||||
// Allow ancestor directory traversal (literal only, so programs can resolve CWD path)
|
||||
for _, ancestor := range getAncestorDirectories(cwd) {
|
||||
rules = append(rules,
|
||||
fmt.Sprintf("(allow file-read-data (literal %s))", escapePath(ancestor)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow home shell configs and tool caches (read-only)
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
// Shell config files (literal access)
|
||||
shellConfigs := []string{".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".inputrc"}
|
||||
for _, f := range shellConfigs {
|
||||
p := filepath.Join(home, f)
|
||||
rules = append(rules,
|
||||
fmt.Sprintf("(allow file-read-data (literal %s))", escapePath(p)),
|
||||
)
|
||||
}
|
||||
|
||||
// Home tool caches (subpath access for package managers/configs)
|
||||
homeCaches := []string{".cache", ".npm", ".cargo", ".rustup", ".local", ".config", ".nvm", ".pyenv", ".rbenv", ".asdf"}
|
||||
for _, d := range homeCaches {
|
||||
p := filepath.Join(home, d)
|
||||
rules = append(rules,
|
||||
"(allow file-read-data",
|
||||
fmt.Sprintf(" (subpath %s))", escapePath(p)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow reading data from user-specified paths
|
||||
for _, pathPattern := range allowPaths {
|
||||
normalized := NormalizePath(pathPattern)
|
||||
@@ -184,6 +223,24 @@ func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, log
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Deny sensitive files within CWD (Seatbelt evaluates deny before allow)
|
||||
if cwd != "" {
|
||||
for _, f := range SensitiveProjectFiles {
|
||||
p := filepath.Join(cwd, f)
|
||||
rules = append(rules,
|
||||
"(deny file-read*",
|
||||
fmt.Sprintf(" (literal %s)", escapePath(p)),
|
||||
fmt.Sprintf(" (with message %q))", logTag),
|
||||
)
|
||||
}
|
||||
// Also deny .env.* pattern via regex
|
||||
rules = append(rules,
|
||||
"(deny file-read*",
|
||||
fmt.Sprintf(" (regex %s)", escapePath("^"+regexp.QuoteMeta(cwd)+"/\\.env\\..*$")),
|
||||
fmt.Sprintf(" (with message %q))", logTag),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Allow all reads by default
|
||||
rules = append(rules, "(allow file-read*)")
|
||||
@@ -220,9 +277,19 @@ func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, log
|
||||
}
|
||||
|
||||
// generateWriteRules generates filesystem write rules for the sandbox profile.
|
||||
func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, logTag string) []string {
|
||||
// When cwd is non-empty, it is automatically included in the write allow paths.
|
||||
func generateWriteRules(cwd string, allowPaths, denyPaths []string, allowGitConfig bool, logTag string) []string {
|
||||
var rules []string
|
||||
|
||||
// Auto-allow CWD for writes (project directory should be writable)
|
||||
if cwd != "" {
|
||||
rules = append(rules,
|
||||
"(allow file-write*",
|
||||
fmt.Sprintf(" (subpath %s)", escapePath(cwd)),
|
||||
fmt.Sprintf(" (with message %q))", logTag),
|
||||
)
|
||||
}
|
||||
|
||||
// Allow TMPDIR parent on macOS
|
||||
for _, tmpdirParent := range getTmpdirParent() {
|
||||
normalized := NormalizePath(tmpdirParent)
|
||||
@@ -254,8 +321,11 @@ func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, log
|
||||
}
|
||||
|
||||
// Combine user-specified and mandatory deny patterns
|
||||
cwd, _ := os.Getwd()
|
||||
mandatoryDeny := GetMandatoryDenyPatterns(cwd, allowGitConfig)
|
||||
mandatoryCwd := cwd
|
||||
if mandatoryCwd == "" {
|
||||
mandatoryCwd, _ = os.Getwd()
|
||||
}
|
||||
mandatoryDeny := GetMandatoryDenyPatterns(mandatoryCwd, allowGitConfig)
|
||||
allDenyPaths := make([]string, 0, len(denyPaths)+len(mandatoryDeny))
|
||||
allDenyPaths = append(allDenyPaths, denyPaths...)
|
||||
allDenyPaths = append(allDenyPaths, mandatoryDeny...)
|
||||
@@ -530,14 +600,14 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
||||
|
||||
// Read rules
|
||||
profile.WriteString("; File read\n")
|
||||
for _, rule := range generateReadRules(params.DefaultDenyRead, params.ReadAllowPaths, params.ReadDenyPaths, logTag) {
|
||||
for _, rule := range generateReadRules(params.DefaultDenyRead, params.Cwd, params.ReadAllowPaths, params.ReadDenyPaths, logTag) {
|
||||
profile.WriteString(rule + "\n")
|
||||
}
|
||||
profile.WriteString("\n")
|
||||
|
||||
// Write rules
|
||||
profile.WriteString("; File write\n")
|
||||
for _, rule := range generateWriteRules(params.WriteAllowPaths, params.WriteDenyPaths, params.AllowGitConfig, logTag) {
|
||||
for _, rule := range generateWriteRules(params.Cwd, params.WriteAllowPaths, params.WriteDenyPaths, params.AllowGitConfig, logTag) {
|
||||
profile.WriteString(rule + "\n")
|
||||
}
|
||||
|
||||
@@ -562,6 +632,8 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
||||
|
||||
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
|
||||
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
// Build allow paths: default + configured
|
||||
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
|
||||
|
||||
@@ -599,7 +671,8 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de
|
||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||
AllowLocalBinding: allowLocalBinding,
|
||||
AllowLocalOutbound: allowLocalOutbound,
|
||||
DefaultDenyRead: cfg.Filesystem.DefaultDenyRead,
|
||||
DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(),
|
||||
Cwd: cwd,
|
||||
ReadAllowPaths: cfg.Filesystem.AllowRead,
|
||||
ReadDenyPaths: cfg.Filesystem.DenyRead,
|
||||
WriteAllowPaths: allowPaths,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -11,11 +12,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",
|
||||
@@ -108,7 +109,8 @@ func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
|
||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||
AllowLocalBinding: allowLocalBinding,
|
||||
AllowLocalOutbound: allowLocalOutbound,
|
||||
DefaultDenyRead: cfg.Filesystem.DefaultDenyRead,
|
||||
DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(),
|
||||
Cwd: "/tmp/test-project",
|
||||
ReadAllowPaths: cfg.Filesystem.AllowRead,
|
||||
ReadDenyPaths: cfg.Filesystem.DenyRead,
|
||||
WriteAllowPaths: allowPaths,
|
||||
@@ -175,38 +177,46 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultDenyRead bool
|
||||
cwd string
|
||||
allowRead []string
|
||||
wantContainsBlanketAllow bool
|
||||
wantContainsMetadataAllow bool
|
||||
wantContainsSystemAllows bool
|
||||
wantContainsUserAllowRead bool
|
||||
wantContainsCwdAllow bool
|
||||
}{
|
||||
{
|
||||
name: "default mode - blanket allow read",
|
||||
name: "legacy mode - blanket allow read",
|
||||
defaultDenyRead: false,
|
||||
cwd: "/home/user/project",
|
||||
allowRead: nil,
|
||||
wantContainsBlanketAllow: true,
|
||||
wantContainsMetadataAllow: false,
|
||||
wantContainsSystemAllows: false,
|
||||
wantContainsUserAllowRead: false,
|
||||
wantContainsCwdAllow: false,
|
||||
},
|
||||
{
|
||||
name: "defaultDenyRead enabled - metadata allow, system data allows",
|
||||
name: "defaultDenyRead enabled - metadata allow, system data allows, CWD allow",
|
||||
defaultDenyRead: true,
|
||||
cwd: "/home/user/project",
|
||||
allowRead: nil,
|
||||
wantContainsBlanketAllow: false,
|
||||
wantContainsMetadataAllow: true,
|
||||
wantContainsSystemAllows: true,
|
||||
wantContainsUserAllowRead: false,
|
||||
wantContainsCwdAllow: true,
|
||||
},
|
||||
{
|
||||
name: "defaultDenyRead with allowRead paths",
|
||||
defaultDenyRead: true,
|
||||
allowRead: []string{"/home/user/project"},
|
||||
cwd: "/home/user/project",
|
||||
allowRead: []string{"/home/user/other"},
|
||||
wantContainsBlanketAllow: false,
|
||||
wantContainsMetadataAllow: true,
|
||||
wantContainsSystemAllows: true,
|
||||
wantContainsUserAllowRead: true,
|
||||
wantContainsCwdAllow: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -215,6 +225,7 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
||||
params := MacOSSandboxParams{
|
||||
Command: "echo test",
|
||||
DefaultDenyRead: tt.defaultDenyRead,
|
||||
Cwd: tt.cwd,
|
||||
ReadAllowPaths: tt.allowRead,
|
||||
}
|
||||
|
||||
@@ -236,6 +247,13 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
||||
t.Errorf("system path allows = %v, want %v\nProfile:\n%s", hasSystemAllows, tt.wantContainsSystemAllows, profile)
|
||||
}
|
||||
|
||||
if tt.wantContainsCwdAllow && tt.cwd != "" {
|
||||
hasCwdAllow := strings.Contains(profile, fmt.Sprintf(`(subpath %q)`, tt.cwd))
|
||||
if !hasCwdAllow {
|
||||
t.Errorf("CWD path %q not found in profile", tt.cwd)
|
||||
}
|
||||
}
|
||||
|
||||
if tt.wantContainsUserAllowRead && len(tt.allowRead) > 0 {
|
||||
hasUserAllow := strings.Contains(profile, tt.allowRead[0])
|
||||
if !hasUserAllow {
|
||||
|
||||
@@ -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 {
|
||||
@@ -102,7 +120,11 @@ func (m *Manager) Initialize() error {
|
||||
|
||||
m.initialized = true
|
||||
if m.config.Network.ProxyURL != "" {
|
||||
m.logDebug("Sandbox manager initialized (proxy: %s)", m.config.Network.ProxyURL)
|
||||
dnsInfo := "none"
|
||||
if m.config.Network.DnsAddr != "" {
|
||||
dnsInfo = m.config.Network.DnsAddr
|
||||
}
|
||||
m.logDebug("Sandbox manager initialized (proxy: %s, dns: %s)", m.config.Network.ProxyURL, dnsInfo)
|
||||
} else {
|
||||
m.logDebug("Sandbox manager initialized (no proxy, network blocked)")
|
||||
}
|
||||
@@ -128,12 +150,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 +213,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")
|
||||
}
|
||||
|
||||
|
||||
@@ -102,4 +102,3 @@ func DecodeSandboxedCommand(encoded string) (string, error) {
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user