10 Commits

Author SHA1 Message Date
a04f5feee2 fix: prevent learning mode from collapsing read paths to $HOME
Files directly under ~ (e.g., ~/.gitignore, ~/.npmrc) were collapsed
to the home directory, defeating sandboxing. Now keeps exact file paths
when the parent directory would be $HOME.
2026-02-13 11:38:51 -06:00
c95fca830b docs: add Linux deny-by-default lessons to experience.md
Document three issues encountered during --tmpfs / isolation:
symlinked system dirs on merged-usr distros, Landlock denying
reads on bind-mounted /dev/null, and mandatory deny paths
overriding sensitive file masks.
2026-02-12 20:16:37 -06:00
5affaf77a5 feat: deny-by-default filesystem isolation
Flip the sandbox from allow-by-default reads (--ro-bind / /) to
deny-by-default (--tmpfs / with selective mounts). This makes the
sandbox safer by default — only system paths, CWD, and explicitly
allowed paths are accessible.

- Config: DefaultDenyRead is now *bool (nil = true, deny-by-default)
  with IsDefaultDenyRead() helper; opt out via "defaultDenyRead": false
- Linux: new buildDenyByDefaultMounts() using --tmpfs / + selective
  --ro-bind for system paths, --symlink for merged-usr distros (Arch),
  --bind for CWD, and --ro-bind for user tooling/shell configs/caches
- macOS: generateReadRules() adds CWD subpath, ancestor traversal,
  home shell configs/caches; generateWriteRules() auto-allows CWD
- Landlock: deny-by-default mode allows only specific user tooling
  paths instead of blanket home directory read access
- Sensitive .env files masked within CWD via empty-file overlay on
  Linux and deny rules on macOS
- Learning templates now include allowRead and .env deny patterns
2026-02-12 20:15:40 -06:00
b55b3364af feat: add dependency status to --version and document AppArmor userns fix
Some checks failed
Build and test / Build (push) Successful in 11s
Build and test / Lint (push) Failing after 1m24s
Build and test / Test (Linux) (push) Failing after 40s
Build and test / Test (macOS) (push) Has been cancelled
Show installed dependencies, security features, and transparent proxy
availability when running --version. Detect AppArmor
unprivileged_userns restriction on Ubuntu 24.04+ and suggest the fix.
Document the RTM_NEWADDR issue in experience.md.
2026-02-11 19:31:24 -06:00
70d0685c97 fix: use UDP instead of TCP for DNS bridge to host DNS server
The DnsBridge socat relay was forwarding queries via TCP, but the
GreyHaven DNS service (gost) only listens on UDP, causing DNS
resolution failures ("Could not resolve host") inside the sandbox.
2026-02-11 19:30:56 -06:00
a470f86ee4 fix: resolve ENXIO error and skip template on failed learning runs
Some checks failed
Build and test / Build (push) Successful in 12s
Build and test / Test (macOS) (push) Has been cancelled
Build and test / Lint (push) Failing after 1m23s
Build and test / Test (Linux) (push) Failing after 46s
Skip --new-session in learning mode so interactive programs can access
/dev/tty, and run strace in the foreground to preserve terminal stdin.
Also skip template generation when the traced command exits non-zero,
since the strace trace would be incomplete.
2026-02-11 18:38:26 -06:00
7e85083c38 feat: default to GreyHaven proxy and DNS infrastructure
Default proxy to socks5://localhost:42052 and DNS to localhost:42053
when neither CLI flags nor config file specify them. This makes greywall
work out of the box with GreyHaven without requiring --proxy or --dns.

Also show both proxy and DNS in debug output on manager initialization.
2026-02-11 18:16:35 -06:00
267c82f4bd feat: default DNS to localhost:5353 when proxy is configured
When a proxy is set but no --dns flag or config dnsAddr is specified,
automatically use localhost:5353 as the DNS bridge target. This ensures
DNS queries go through GreyHaven's controlled infrastructure rather than
leaking to public resolvers via tun2socks.

Also update proxy credential injection to always set credentials
(defaulting to "proxy:proxy" when no command name is available), as
required by gost's auth flow.
2026-02-11 18:07:58 -06:00
3dd772d35a feat: add --learning mode, --template flag, and fix DNS relay
Some checks failed
Build and test / Lint (push) Failing after 1m29s
Build and test / Build (push) Successful in 13s
Build and test / Test (Linux) (push) Failing after 58s
Build and test / Test (macOS) (push) Has been cancelled
Learning mode (--learning) traces filesystem access with strace and
generates minimal sandbox config templates. A background monitor kills
strace when the main command exits so long-lived child processes (LSP
servers, file watchers) don't cause hangs.

Other changes:
- Add 'greywall templates list/show' subcommand
- Add --template flag to load specific learned templates
- Fix DNS relay: use TCP DNS (options use-vc) instead of broken UDP
  relay through tun2socks
- Filter O_DIRECTORY opens from learned read paths
- Add docs/experience.md with development notes
2026-02-11 08:22:53 -06:00
631db40665 remove banner image and assets directory
Some checks failed
Build and test / Build (push) Successful in 13s
Build and test / Test (Linux) (push) Failing after 42s
Build and test / Lint (push) Failing after 1m23s
Build and test / Test (macOS) (push) Has been cancelled
2026-02-10 16:23:19 -06:00
22 changed files with 2601 additions and 199 deletions

View File

@@ -1,5 +1,3 @@
![Greywall Banner](assets/greywall-banner.png)
# Greywall # Greywall
**The sandboxing layer of the GreyHaven platform.** **The sandboxing layer of the GreyHaven platform.**

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 KiB

View File

@@ -4,10 +4,13 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"path/filepath"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"gitea.app.monadical.io/monadical/greywall/internal/config" "gitea.app.monadical.io/monadical/greywall/internal/config"
@@ -34,6 +37,8 @@ var (
exitCode int exitCode int
showVersion bool showVersion bool
linuxFeatures bool linuxFeatures bool
learning bool
templateName string
) )
func main() { func main() {
@@ -50,9 +55,9 @@ func main() {
Long: `greywall is a command-line tool that runs commands in a sandboxed environment Long: `greywall is a command-line tool that runs commands in a sandboxed environment
with network and filesystem restrictions. with network and filesystem restrictions.
By default, all network access is blocked. Use --proxy to route traffic through By default, traffic is routed through the GreyHaven SOCKS5 proxy at localhost:42051
an external SOCKS5 proxy, or configure a proxy URL in your settings file at with DNS via localhost:42053. Use --proxy and --dns to override, or configure in
~/.config/greywall/greywall.json (or ~/Library/Application Support/greywall/greywall.json on macOS). 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 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 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 -c "echo hello && ls" # Run with shell expansion
greywall --settings config.json npm install greywall --settings config.json npm install
greywall -p 3000 -c "npm run dev" # Expose port 3000 greywall -p 3000 -c "npm run dev" # Expose port 3000
greywall --learning -- opencode # Learn filesystem needs
Configuration file format: Configuration file format:
{ {
@@ -92,16 +98,19 @@ Configuration file format:
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations") 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().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(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (default: socks5://localhost:42052)")
rootCmd.Flags().StringVar(&dnsAddr, "dns", "", "DNS server address on host (e.g., localhost:3153)") 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().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().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().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(&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.Flags().SetInterspersed(true)
rootCmd.AddCommand(newCompletionCmd(rootCmd)) rootCmd.AddCommand(newCompletionCmd(rootCmd))
rootCmd.AddCommand(newTemplatesCmd())
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) 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(" Version: %s\n", version)
fmt.Printf(" Built: %s\n", buildTime) fmt.Printf(" Built: %s\n", buildTime)
fmt.Printf(" Commit: %s\n", gitCommit) fmt.Printf(" Commit: %s\n", gitCommit)
sandbox.PrintDependencyStatus()
return nil 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 // CLI flags override config
if proxyURL != "" { if proxyURL != "" {
cfg.Network.ProxyURL = proxyURL cfg.Network.ProxyURL = proxyURL
@@ -183,8 +230,54 @@ func runCommand(cmd *cobra.Command, args []string) error {
cfg.Network.DnsAddr = dnsAddr 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 := sandbox.NewManager(cfg, debug, monitor)
manager.SetExposedPorts(ports) manager.SetExposedPorts(ports)
if learning {
manager.SetLearning(true)
manager.SetCommandName(cmdName)
}
defer manager.Cleanup() defer manager.Cleanup()
if err := manager.Initialize(); err != nil { if err := manager.Initialize(); err != nil {
@@ -263,18 +356,61 @@ func runCommand(cmd *cobra.Command, args []string) error {
}() }()
// Wait for command to finish // Wait for command to finish
commandFailed := false
if err := execCmd.Wait(); err != nil { if err := execCmd.Wait(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
// Set exit code but don't os.Exit() here - let deferred cleanup run // Set exit code but don't os.Exit() here - let deferred cleanup run
exitCode = exitErr.ExitCode() 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 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. // newCompletionCmd creates the completion subcommand for shell completions.
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command { func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -320,6 +456,71 @@ ${fpath[1]}/_greywall for zsh, ~/.config/fish/completions/greywall.fish for fish
return cmd 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. // runLandlockWrapper runs in "wrapper mode" inside the sandbox.
// It applies Landlock restrictions and then execs the user command. // It applies Landlock restrictions and then execs the user command.
// Usage: greywall --landlock-apply [--debug] -- <command...> // Usage: greywall --landlock-apply [--debug] -- <command...>

121
docs/experience.md Normal file
View 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.

View File

@@ -26,8 +26,8 @@ type Config struct {
// NetworkConfig defines network restrictions. // NetworkConfig defines network restrictions.
type NetworkConfig struct { type NetworkConfig struct {
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080) 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) DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153)
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"` AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"` AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"` AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
@@ -36,7 +36,7 @@ type NetworkConfig struct {
// FilesystemConfig defines filesystem restrictions. // FilesystemConfig defines filesystem restrictions.
type FilesystemConfig struct { 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) AllowRead []string `json:"allowRead"` // Paths to allow reading (used when DefaultDenyRead is true)
DenyRead []string `json:"denyRead"` DenyRead []string `json:"denyRead"`
AllowWrite []string `json:"allowWrite"` AllowWrite []string `json:"allowWrite"`
@@ -44,6 +44,12 @@ type FilesystemConfig struct {
AllowGitConfig bool `json:"allowGitConfig,omitempty"` 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. // CommandConfig defines command restrictions.
type CommandConfig struct { type CommandConfig struct {
Deny []string `json:"deny"` Deny []string `json:"deny"`
@@ -417,8 +423,8 @@ func Merge(base, override *Config) *Config {
}, },
Filesystem: FilesystemConfig{ Filesystem: FilesystemConfig{
// Boolean fields: true if either enables it // Pointer field: override wins if set, otherwise base (nil = deny-by-default)
DefaultDenyRead: base.Filesystem.DefaultDenyRead || override.Filesystem.DefaultDenyRead, DefaultDenyRead: mergeOptionalBool(base.Filesystem.DefaultDenyRead, override.Filesystem.DefaultDenyRead),
// Append slices // Append slices
AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead), AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead),

View File

@@ -410,7 +410,7 @@ func TestMerge(t *testing.T) {
t.Run("merge defaultDenyRead and allowRead", func(t *testing.T) { t.Run("merge defaultDenyRead and allowRead", func(t *testing.T) {
base := &Config{ base := &Config{
Filesystem: FilesystemConfig{ Filesystem: FilesystemConfig{
DefaultDenyRead: true, DefaultDenyRead: boolPtr(true),
AllowRead: []string{"/home/user/project"}, AllowRead: []string{"/home/user/project"},
}, },
} }
@@ -421,13 +421,40 @@ func TestMerge(t *testing.T) {
} }
result := Merge(base, override) result := Merge(base, override)
if !result.Filesystem.DefaultDenyRead { if !result.Filesystem.IsDefaultDenyRead() {
t.Error("expected DefaultDenyRead to be true (from base)") t.Error("expected IsDefaultDenyRead() to be true (from base)")
} }
if len(result.Filesystem.AllowRead) != 2 { if len(result.Filesystem.AllowRead) != 2 {
t.Errorf("expected 2 allowRead paths, got %d: %v", len(result.Filesystem.AllowRead), result.Filesystem.AllowRead) 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 { func boolPtr(b bool) *bool {

View File

@@ -28,6 +28,30 @@ var DangerousDirectories = []string{
".claude/agents", ".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. // GetDefaultWritePaths returns system paths that should be writable for commands to work.
func GetDefaultWritePaths() []string { func GetDefaultWritePaths() []string {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()

View File

@@ -123,13 +123,17 @@ func assertContains(t *testing.T, haystack, needle string) {
// ============================================================================ // ============================================================================
// testConfig creates a test configuration with sensible defaults. // 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 { func testConfig() *config.Config {
return &config.Config{ return &config.Config{
Network: config.NetworkConfig{}, Network: config.NetworkConfig{},
Filesystem: config.FilesystemConfig{ Filesystem: config.FilesystemConfig{
DenyRead: []string{}, DefaultDenyRead: boolPtr(false), // Legacy mode for existing tests
AllowWrite: []string{}, DenyRead: []string{},
DenyWrite: []string{}, AllowWrite: []string{},
DenyWrite: []string{},
}, },
Command: config.CommandConfig{ Command: config.CommandConfig{
Deny: []string{}, Deny: []string{},

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

View File

@@ -0,0 +1,298 @@
//go:build linux
package sandbox
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
// straceSyscallRegex matches strace output lines for file-access syscalls.
var straceSyscallRegex = regexp.MustCompile(
`(openat|mkdirat|unlinkat|renameat2|creat|symlinkat|linkat)\(`,
)
// openatWriteFlags matches O_WRONLY, O_RDWR, O_CREAT, O_TRUNC, O_APPEND flags in strace output.
var openatWriteFlags = regexp.MustCompile(`O_(?:WRONLY|RDWR|CREAT|TRUNC|APPEND)`)
// StraceResult holds parsed read and write paths from an strace log.
type StraceResult struct {
WritePaths []string
ReadPaths []string
}
// CheckStraceAvailable verifies that strace is installed and accessible.
func CheckStraceAvailable() error {
_, err := exec.LookPath("strace")
if err != nil {
return fmt.Errorf("strace is required for learning mode but not found: %w\n\nInstall it with: sudo apt install strace (Debian/Ubuntu) or sudo pacman -S strace (Arch)", err)
}
return nil
}
// ParseStraceLog reads an strace output file and extracts unique read and write paths.
func ParseStraceLog(logPath string, debug bool) (*StraceResult, error) {
f, err := os.Open(logPath) //nolint:gosec // user-controlled path from temp file - intentional
if err != nil {
return nil, fmt.Errorf("failed to open strace log: %w", err)
}
defer f.Close()
home, _ := os.UserHomeDir()
seenWrite := make(map[string]bool)
seenRead := make(map[string]bool)
result := &StraceResult{}
scanner := bufio.NewScanner(f)
// Increase buffer for long strace lines
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineCount := 0
writeCount := 0
readCount := 0
for scanner.Scan() {
line := scanner.Text()
lineCount++
// Try extracting as a write path first
writePath := extractWritePath(line)
if writePath != "" {
writeCount++
if !shouldFilterPath(writePath, home) && !seenWrite[writePath] {
seenWrite[writePath] = true
result.WritePaths = append(result.WritePaths, writePath)
}
continue
}
// Try extracting as a read path
readPath := extractReadPath(line)
if readPath != "" {
readCount++
if !shouldFilterPath(readPath, home) && !seenRead[readPath] {
seenRead[readPath] = true
result.ReadPaths = append(result.ReadPaths, readPath)
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading strace log: %w", err)
}
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Parsed strace log: %d lines, %d write syscalls, %d read syscalls, %d unique write paths, %d unique read paths\n",
lineCount, writeCount, readCount, len(result.WritePaths), len(result.ReadPaths))
}
return result, nil
}
// extractReadPath parses a single strace line and returns the read path, if any.
// Only matches openat() with O_RDONLY (no write flags).
func extractReadPath(line string) string {
if !strings.Contains(line, "openat(") {
return ""
}
// Skip failed syscalls
if strings.Contains(line, "= -1 ") {
return ""
}
// Skip resumed/unfinished lines
if strings.Contains(line, "<unfinished") || strings.Contains(line, "resumed>") {
return ""
}
// Only care about read-only opens (no write flags)
if openatWriteFlags.MatchString(line) {
return ""
}
// Skip directory opens (O_DIRECTORY) — these are just directory traversal
// (readdir/stat), not meaningful file reads
if strings.Contains(line, "O_DIRECTORY") {
return ""
}
return extractATPath(line)
}
// extractWritePath parses a single strace line and returns the write target path, if any.
func extractWritePath(line string) string {
// Skip lines that don't contain write syscalls
if !straceSyscallRegex.MatchString(line) {
return ""
}
// Skip failed syscalls (lines ending with = -1 ENOENT or similar errors)
if strings.Contains(line, "= -1 ") {
return ""
}
// Skip resumed/unfinished lines
if strings.Contains(line, "<unfinished") || strings.Contains(line, "resumed>") {
return ""
}
// Extract path based on syscall type
if strings.Contains(line, "openat(") {
return extractOpenatPath(line)
}
if strings.Contains(line, "mkdirat(") {
return extractATPath(line)
}
if strings.Contains(line, "unlinkat(") {
return extractATPath(line)
}
if strings.Contains(line, "renameat2(") {
return extractRenameatPath(line)
}
if strings.Contains(line, "creat(") {
return extractCreatPath(line)
}
if strings.Contains(line, "symlinkat(") {
return extractSymlinkTarget(line)
}
if strings.Contains(line, "linkat(") {
return extractLinkatTarget(line)
}
return ""
}
// extractOpenatPath extracts the path from an openat() line, only if write flags are present.
func extractOpenatPath(line string) string {
// Only care about writes
if !openatWriteFlags.MatchString(line) {
return ""
}
return extractATPath(line)
}
// extractATPath extracts the second argument (path) from AT_FDCWD-based syscalls.
// Pattern: syscall(AT_FDCWD, "/path/to/file", ...)
func extractATPath(line string) string {
// Find the first quoted string after AT_FDCWD
idx := strings.Index(line, "AT_FDCWD, \"")
if idx < 0 {
return ""
}
start := idx + len("AT_FDCWD, \"")
end := strings.Index(line[start:], "\"")
if end < 0 {
return ""
}
return line[start : start+end]
}
// extractCreatPath extracts the path from a creat() call.
// Pattern: creat("/path/to/file", mode)
func extractCreatPath(line string) string {
idx := strings.Index(line, "creat(\"")
if idx < 0 {
return ""
}
start := idx + len("creat(\"")
end := strings.Index(line[start:], "\"")
if end < 0 {
return ""
}
return line[start : start+end]
}
// extractRenameatPath extracts the destination path from renameat2().
// Pattern: renameat2(AT_FDCWD, "/old", AT_FDCWD, "/new", flags)
// We want both old and new paths, but primarily the new (destination) path.
func extractRenameatPath(line string) string {
// Find the second AT_FDCWD occurrence for the destination
first := strings.Index(line, "AT_FDCWD, \"")
if first < 0 {
return ""
}
rest := line[first+len("AT_FDCWD, \""):]
endFirst := strings.Index(rest, "\"")
if endFirst < 0 {
return ""
}
rest = rest[endFirst+1:]
// Find second AT_FDCWD
second := strings.Index(rest, "AT_FDCWD, \"")
if second < 0 {
// Fall back to first path
return extractATPath(line)
}
start := second + len("AT_FDCWD, \"")
end := strings.Index(rest[start:], "\"")
if end < 0 {
return extractATPath(line)
}
return rest[start : start+end]
}
// extractSymlinkTarget extracts the link path (destination) from symlinkat().
// Pattern: symlinkat("/target", AT_FDCWD, "/link")
func extractSymlinkTarget(line string) string {
// The link path is the third argument (after AT_FDCWD)
return extractATPath(line)
}
// extractLinkatTarget extracts the new link path from linkat().
// Pattern: linkat(AT_FDCWD, "/old", AT_FDCWD, "/new", flags)
func extractLinkatTarget(line string) string {
return extractRenameatPath(line)
}
// shouldFilterPath returns true if a path should be excluded from learning results.
func shouldFilterPath(path, home string) bool {
// Filter empty or relative paths
if path == "" || !strings.HasPrefix(path, "/") {
return true
}
// Filter system paths
systemPrefixes := []string{
"/proc/",
"/sys/",
"/dev/",
"/run/",
"/var/run/",
"/var/lock/",
}
for _, prefix := range systemPrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}
// Filter /tmp (sandbox has its own tmpfs)
if strings.HasPrefix(path, "/tmp/") || path == "/tmp" {
return true
}
// Filter shared object files (.so, .so.*)
base := filepath.Base(path)
if strings.HasSuffix(base, ".so") || strings.Contains(base, ".so.") {
return true
}
// Filter greywall infrastructure files
if strings.Contains(path, "greywall-") {
return true
}
// Filter paths outside home (they're typically system-level)
if home != "" && !strings.HasPrefix(path, home+"/") {
return true
}
return false
}

View File

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

View File

@@ -0,0 +1,21 @@
//go:build !linux
package sandbox
import "fmt"
// StraceResult holds parsed read and write paths from an strace log.
type StraceResult struct {
WritePaths []string
ReadPaths []string
}
// CheckStraceAvailable returns an error on non-Linux platforms.
func CheckStraceAvailable() error {
return fmt.Errorf("learning mode is only available on Linux (requires strace and bubblewrap)")
}
// ParseStraceLog returns an error on non-Linux platforms.
func ParseStraceLog(logPath string, debug bool) (*StraceResult, error) {
return nil, fmt.Errorf("strace log parsing is only available on Linux")
}

View File

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

View File

@@ -20,19 +20,19 @@ import (
// ProxyBridge bridges sandbox to an external SOCKS5 proxy via Unix socket. // ProxyBridge bridges sandbox to an external SOCKS5 proxy via Unix socket.
type ProxyBridge struct { type ProxyBridge struct {
SocketPath string // Unix socket path SocketPath string // Unix socket path
ProxyHost string // Parsed from ProxyURL ProxyHost string // Parsed from ProxyURL
ProxyPort string // Parsed from ProxyURL ProxyPort string // Parsed from ProxyURL
ProxyUser string // Username from ProxyURL (if any) ProxyUser string // Username from ProxyURL (if any)
ProxyPass string // Password from ProxyURL (if any) ProxyPass string // Password from ProxyURL (if any)
HasAuth bool // Whether credentials were provided HasAuth bool // Whether credentials were provided
process *exec.Cmd process *exec.Cmd
debug bool debug bool
} }
// DnsBridge bridges DNS queries from the sandbox to a host-side DNS server via Unix socket. // 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. // 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 { type DnsBridge struct {
SocketPath string // Unix socket path SocketPath string // Unix socket path
DnsAddr string // Host-side DNS address (host:port) DnsAddr string // Host-side DNS address (host:port)
@@ -61,10 +61,10 @@ func NewDnsBridge(dnsAddr string, debug bool) (*DnsBridge, error) {
debug: debug, debug: debug,
} }
// Start bridge: Unix socket -> DNS server TCP // Start bridge: Unix socket -> DNS server UDP
socatArgs := []string{ socatArgs := []string{
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socketPath), 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 bridge.process = exec.Command("socat", socatArgs...) //nolint:gosec // args constructed from trusted input
if debug { if debug {
@@ -122,6 +122,10 @@ type LinuxSandboxOptions struct {
Monitor bool Monitor bool
// Debug mode // Debug mode
Debug bool 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. // NewProxyBridge creates a Unix socket bridge to an external SOCKS5 proxy.
@@ -367,6 +371,12 @@ func getMandatoryDenyPaths(cwd string) []string {
paths = append(paths, p) 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 // Git hooks in cwd
paths = append(paths, filepath.Join(cwd, ".git/hooks")) paths = append(paths, filepath.Join(cwd, ".git/hooks"))
@@ -385,6 +395,193 @@ func getMandatoryDenyPaths(cwd string) []string {
return paths 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. // WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
// It uses available security features (Landlock, seccomp) with graceful fallback. // 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) { 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 // Build bwrap args with filesystem restrictions
bwrapArgs := []string{ bwrapArgs := []string{
"bwrap", "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) // Always use --unshare-net when available (network namespace isolation)
// Inside the namespace, tun2socks will provide transparent proxy access // 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 // Learning mode: permissive sandbox with home + cwd writable
if opts.Learning {
if defaultDenyRead {
// In defaultDenyRead mode, we only bind essential system paths read-only
// and user-specified allowRead paths. Everything else is inaccessible.
if opts.Debug { 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 defaultDenyRead := cfg != nil && cfg.Filesystem.IsDefaultDenyRead()
if cfg != nil && cfg.Filesystem.AllowRead != nil {
boundPaths := make(map[string]bool)
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowRead) if opts.Learning {
for _, p := range expandedPaths { // Skip defaultDenyRead logic in learning mode (already set up above)
if fileExists(p) && !strings.HasPrefix(p, "/dev/") && !strings.HasPrefix(p, "/proc/") && !boundPaths[p] { } else if defaultDenyRead {
boundPaths[p] = true // Deny-by-default mode: start with empty root, then whitelist system paths + CWD
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p) if opts.Debug {
} fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - tmpfs root with selective mounts\n")
}
// 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)
}
}
} }
bwrapArgs = append(bwrapArgs, buildDenyByDefaultMounts(cfg, cwd, opts.Debug)...)
} else { } else {
// Default mode: bind entire root filesystem read-only // Legacy mode: bind entire root filesystem read-only
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/") 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 // /tmp needs to be writable for many programs
bwrapArgs = append(bwrapArgs, "--tmpfs", "/tmp") 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. // 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 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 // 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) writablePaths := make(map[string]bool)
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 user-specified allowWrite paths // Add default write paths (system paths needed for operation)
if cfg != nil && cfg.Filesystem.AllowWrite != nil { for _, p := range GetDefaultWritePaths() {
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowWrite) // Skip /dev paths (handled by --dev) and /tmp paths (handled by --tmpfs)
for _, p := range expandedPaths { if strings.HasPrefix(p, "/dev/") || strings.HasPrefix(p, "/tmp/") || strings.HasPrefix(p, "/private/tmp/") {
continue
}
writablePaths[p] = true writablePaths[p] = true
} }
// Add non-glob paths // Add user-specified allowWrite paths
for _, p := range cfg.Filesystem.AllowWrite { if cfg != nil && cfg.Filesystem.AllowWrite != nil {
normalized := NormalizePath(p) expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowWrite)
if !ContainsGlobChars(normalized) { for _, p := range expandedPaths {
writablePaths[normalized] = true writablePaths[p] = true
} }
}
}
// Make writable paths actually writable (override read-only root) // Add non-glob paths
for p := range writablePaths { for _, p := range cfg.Filesystem.AllowWrite {
if fileExists(p) { normalized := NormalizePath(p)
bwrapArgs = append(bwrapArgs, "--bind", p, p) if !ContainsGlobChars(normalized) {
} writablePaths[normalized] = true
}
// 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 // Make writable paths actually writable (override read-only root)
for _, p := range cfg.Filesystem.DenyRead { for p := range writablePaths {
normalized := NormalizePath(p) if fileExists(p) {
if !ContainsGlobChars(normalized) && canMountOver(normalized) { bwrapArgs = append(bwrapArgs, "--bind", p, p)
if isDirectory(normalized) { }
bwrapArgs = append(bwrapArgs, "--tmpfs", normalized) }
} else {
bwrapArgs = append(bwrapArgs, "--ro-bind", "/dev/null", normalized) // 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) // Apply mandatory deny patterns (make dangerous files/dirs read-only)
// This overrides any writable mounts for these paths // This overrides any writable mounts for these paths
// //
// Note: We only use concrete paths from getMandatoryDenyPaths(), NOT glob expansion. // Note: We only use concrete paths from getMandatoryDenyPaths(), NOT glob expansion.
// GetMandatoryDenyPatterns() returns expensive **/pattern globs that require walking // GetMandatoryDenyPatterns() returns expensive **/pattern globs that require walking
// the entire directory tree - this can hang on large directories (see issue #27). // 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 // 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 // .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. // rc files in project subdirectories are uncommon and not automatically sourced.
// //
// TODO: consider depth-limited glob expansion (e.g., max 3 levels) to protect // 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. // subdirectory dangerous files without full tree walks that hang on large dirs.
mandatoryDeny := getMandatoryDenyPaths(cwd) mandatoryDeny := getMandatoryDenyPaths(cwd)
// Deduplicate // In deny-by-default mode, sensitive project files are already masked
seen := make(map[string]bool) // with --ro-bind /dev/null by buildDenyByDefaultMounts(). Skip them here
for _, p := range mandatoryDeny { // to avoid overriding the /dev/null mask with a real ro-bind.
if !seen[p] && fileExists(p) { maskedPaths := make(map[string]bool)
seen[p] = true if defaultDenyRead {
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p) for _, f := range SensitiveProjectFiles {
maskedPaths[filepath.Join(cwd, f)] = true
}
} }
}
// Handle explicit denyWrite paths (make them read-only) // Deduplicate
if cfg != nil && cfg.Filesystem.DenyWrite != nil { seen := make(map[string]bool)
expandedDenyWrite := ExpandGlobPatterns(cfg.Filesystem.DenyWrite) for _, p := range mandatoryDeny {
for _, p := range expandedDenyWrite { if !seen[p] && fileExists(p) && !maskedPaths[p] {
if fileExists(p) && !seen[p] {
seen[p] = true seen[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p) bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
} }
} }
// Add non-glob paths
for _, p := range cfg.Filesystem.DenyWrite { // Handle explicit denyWrite paths (make them read-only)
normalized := NormalizePath(p) if cfg != nil && cfg.Filesystem.DenyWrite != nil {
if !ContainsGlobChars(normalized) && fileExists(normalized) && !seen[normalized] { expandedDenyWrite := ExpandGlobPatterns(cfg.Filesystem.DenyWrite)
seen[normalized] = true for _, p := range expandedDenyWrite {
bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized) 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) // Bind the proxy bridge Unix socket into the sandbox (needs to be writable)
var dnsRelayResolvConf string // temp file path for custom resolv.conf 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). // Override /etc/resolv.conf for DNS resolution inside the sandbox.
// 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.
if dnsBridge != nil || (tun2socksPath != "" && features.CanUseTransparentProxy()) { if dnsBridge != nil || (tun2socksPath != "" && features.CanUseTransparentProxy()) {
tmpResolv, err := os.CreateTemp("", "greywall-resolv-*.conf") tmpResolv, err := os.CreateTemp("", "greywall-resolv-*.conf")
if err == nil { 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() tmpResolv.Close()
dnsRelayResolvConf = tmpResolv.Name() dnsRelayResolvConf = tmpResolv.Name()
bwrapArgs = append(bwrapArgs, "--ro-bind", dnsRelayResolvConf, "/etc/resolv.conf") bwrapArgs = append(bwrapArgs, "--ro-bind", dnsRelayResolvConf, "/etc/resolv.conf")
@@ -707,7 +926,7 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
if dnsBridge != nil { if dnsBridge != nil {
fmt.Fprintf(os.Stderr, "[greywall:linux] DNS: overriding resolv.conf -> 127.0.0.1 (bridge to %s)\n", dnsBridge.DnsAddr) fmt.Fprintf(os.Stderr, "[greywall:linux] DNS: overriding resolv.conf -> 127.0.0.1 (bridge to %s)\n", dnsBridge.DnsAddr)
} else { } 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)) `, 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 { if dnsBridge != nil {
// Dedicated DNS bridge: UDP :53 -> Unix socket -> host DNS server // Dedicated DNS bridge: UDP :53 -> Unix socket -> host DNS server
innerScript.WriteString(fmt.Sprintf(`# DNS relay: UDP queries -> Unix socket -> host DNS server (%s) 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=$! DNS_RELAY_PID=$!
`, dnsBridge.DnsAddr, dnsBridge.SocketPath)) `, 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 { } else if proxyBridge != nil {
// Fallback: no TUN support, use env-var-based proxying // Fallback: no TUN support, use env-var-based proxying
@@ -846,8 +1060,27 @@ sleep 0.3
# Run the user command # Run the user command
`) `)
// Use Landlock wrapper if available // In learning mode, wrap the command with strace to trace syscalls.
if useLandlockWrapper { // 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) // Pass config via environment variable (serialized as JSON)
// This ensures allowWrite/denyWrite rules are properly applied // This ensures allowWrite/denyWrite rules are properly applied
if cfg != nil { if cfg != nil {
@@ -897,6 +1130,9 @@ sleep 0.3
if reverseBridge != nil && len(reverseBridge.Ports) > 0 { if reverseBridge != nil && len(reverseBridge.Ports) > 0 {
featureList = append(featureList, fmt.Sprintf("inbound:%v", reverseBridge.Ports)) 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, ", ")) fmt.Fprintf(os.Stderr, "[greywall:linux] Sandbox: %s\n", strings.Join(featureList, ", "))
} }

View File

@@ -36,9 +36,9 @@ type LinuxFeatures struct {
CanUnshareNet bool CanUnshareNet bool
// Transparent proxy support // Transparent proxy support
HasIpCommand bool // ip (iproute2) available HasIpCommand bool // ip (iproute2) available
HasDevNetTun bool // /dev/net/tun exists HasDevNetTun bool // /dev/net/tun exists
HasTun2Socks bool // tun2socks embedded binary available HasTun2Socks bool // tun2socks embedded binary available
// Kernel version // Kernel version
KernelMajor int KernelMajor int
@@ -276,6 +276,107 @@ func (f *LinuxFeatures) MinimumViable() bool {
return f.HasBwrap && f.HasSocat 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 { func commandExists(name string) bool {
_, err := exec.LookPath(name) _, err := exec.LookPath(name)
return err == nil return err == nil

View File

@@ -2,6 +2,12 @@
package sandbox package sandbox
import (
"fmt"
"os/exec"
"runtime"
)
// LinuxFeatures describes available Linux sandboxing features. // LinuxFeatures describes available Linux sandboxing features.
// This is a stub for non-Linux platforms. // This is a stub for non-Linux platforms.
type LinuxFeatures struct { type LinuxFeatures struct {
@@ -51,3 +57,21 @@ func (f *LinuxFeatures) CanUseTransparentProxy() bool {
func (f *LinuxFeatures) MinimumViable() bool { func (f *LinuxFeatures) MinimumViable() bool {
return false 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")
}
}

View File

@@ -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 cwd != "" {
if err := ruleset.AllowRead(cwd); err != nil && debug { if err := ruleset.AllowReadWrite(cwd); err != nil && debug {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read path: %v\n", err) fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read/write path: %v\n", err)
} }
} }
// Home directory - read access // Home directory - read access only when not in deny-by-default mode.
if home, err := os.UserHomeDir(); err == nil { // In deny-by-default mode, only specific user tooling paths are allowed,
if err := ruleset.AllowRead(home); err != nil && debug { // not the entire home directory. Landlock can't selectively deny files
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home read path: %v\n", err) // 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)
}
}
}
} }
} }

View File

@@ -29,11 +29,13 @@ type ReverseBridge struct {
// LinuxSandboxOptions is a stub for non-Linux platforms. // LinuxSandboxOptions is a stub for non-Linux platforms.
type LinuxSandboxOptions struct { type LinuxSandboxOptions struct {
UseLandlock bool UseLandlock bool
UseSeccomp bool UseSeccomp bool
UseEBPF bool UseEBPF bool
Monitor bool Monitor bool
Debug bool Debug bool
Learning bool
StraceLogPath string
} }
// NewProxyBridge returns an error on non-Linux platforms. // NewProxyBridge returns an error on non-Linux platforms.

View File

@@ -37,6 +37,7 @@ type MacOSSandboxParams struct {
AllowLocalBinding bool AllowLocalBinding bool
AllowLocalOutbound bool AllowLocalOutbound bool
DefaultDenyRead bool DefaultDenyRead bool
Cwd string // Current working directory (for deny-by-default CWD allowlisting)
ReadAllowPaths []string ReadAllowPaths []string
ReadDenyPaths []string ReadDenyPaths []string
WriteAllowPaths []string WriteAllowPaths []string
@@ -146,13 +147,13 @@ func getTmpdirParent() []string {
} }
// generateReadRules generates filesystem read rules for the sandbox profile. // 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 var rules []string
if defaultDenyRead { if defaultDenyRead {
// When defaultDenyRead is enabled: // When defaultDenyRead is enabled:
// 1. Allow file-read-metadata globally (needed for directory traversal, stat, etc.) // 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. // 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) // 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 // Allow reading data from user-specified paths
for _, pathPattern := range allowPaths { for _, pathPattern := range allowPaths {
normalized := NormalizePath(pathPattern) 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 { } else {
// Allow all reads by default // Allow all reads by default
rules = append(rules, "(allow file-read*)") 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. // 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 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 // Allow TMPDIR parent on macOS
for _, tmpdirParent := range getTmpdirParent() { for _, tmpdirParent := range getTmpdirParent() {
normalized := NormalizePath(tmpdirParent) normalized := NormalizePath(tmpdirParent)
@@ -254,8 +321,11 @@ func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, log
} }
// Combine user-specified and mandatory deny patterns // Combine user-specified and mandatory deny patterns
cwd, _ := os.Getwd() mandatoryCwd := cwd
mandatoryDeny := GetMandatoryDenyPatterns(cwd, allowGitConfig) if mandatoryCwd == "" {
mandatoryCwd, _ = os.Getwd()
}
mandatoryDeny := GetMandatoryDenyPatterns(mandatoryCwd, allowGitConfig)
allDenyPaths := make([]string, 0, len(denyPaths)+len(mandatoryDeny)) allDenyPaths := make([]string, 0, len(denyPaths)+len(mandatoryDeny))
allDenyPaths = append(allDenyPaths, denyPaths...) allDenyPaths = append(allDenyPaths, denyPaths...)
allDenyPaths = append(allDenyPaths, mandatoryDeny...) allDenyPaths = append(allDenyPaths, mandatoryDeny...)
@@ -530,14 +600,14 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
// Read rules // Read rules
profile.WriteString("; File read\n") 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(rule + "\n")
} }
profile.WriteString("\n") profile.WriteString("\n")
// Write rules // Write rules
profile.WriteString("; File write\n") 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") profile.WriteString(rule + "\n")
} }
@@ -562,6 +632,8 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
// WrapCommandMacOS wraps a command with macOS sandbox restrictions. // WrapCommandMacOS wraps a command with macOS sandbox restrictions.
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) { func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
cwd, _ := os.Getwd()
// Build allow paths: default + configured // Build allow paths: default + configured
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...) allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
@@ -599,7 +671,8 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets, AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: allowLocalBinding, AllowLocalBinding: allowLocalBinding,
AllowLocalOutbound: allowLocalOutbound, AllowLocalOutbound: allowLocalOutbound,
DefaultDenyRead: cfg.Filesystem.DefaultDenyRead, DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(),
Cwd: cwd,
ReadAllowPaths: cfg.Filesystem.AllowRead, ReadAllowPaths: cfg.Filesystem.AllowRead,
ReadDenyPaths: cfg.Filesystem.DenyRead, ReadDenyPaths: cfg.Filesystem.DenyRead,
WriteAllowPaths: allowPaths, WriteAllowPaths: allowPaths,

View File

@@ -1,6 +1,7 @@
package sandbox package sandbox
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
@@ -11,11 +12,11 @@ import (
// the macOS sandbox profile allows outbound to the proxy host:port. // the macOS sandbox profile allows outbound to the proxy host:port.
func TestMacOS_NetworkRestrictionWithProxy(t *testing.T) { func TestMacOS_NetworkRestrictionWithProxy(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
proxyURL string proxyURL string
wantProxy bool wantProxy bool
proxyHost string proxyHost string
proxyPort string proxyPort string
}{ }{
{ {
name: "no proxy - network blocked", name: "no proxy - network blocked",
@@ -108,7 +109,8 @@ func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets, AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: allowLocalBinding, AllowLocalBinding: allowLocalBinding,
AllowLocalOutbound: allowLocalOutbound, AllowLocalOutbound: allowLocalOutbound,
DefaultDenyRead: cfg.Filesystem.DefaultDenyRead, DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(),
Cwd: "/tmp/test-project",
ReadAllowPaths: cfg.Filesystem.AllowRead, ReadAllowPaths: cfg.Filesystem.AllowRead,
ReadDenyPaths: cfg.Filesystem.DenyRead, ReadDenyPaths: cfg.Filesystem.DenyRead,
WriteAllowPaths: allowPaths, WriteAllowPaths: allowPaths,
@@ -175,38 +177,46 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
defaultDenyRead bool defaultDenyRead bool
cwd string
allowRead []string allowRead []string
wantContainsBlanketAllow bool wantContainsBlanketAllow bool
wantContainsMetadataAllow bool wantContainsMetadataAllow bool
wantContainsSystemAllows bool wantContainsSystemAllows bool
wantContainsUserAllowRead bool wantContainsUserAllowRead bool
wantContainsCwdAllow bool
}{ }{
{ {
name: "default mode - blanket allow read", name: "legacy mode - blanket allow read",
defaultDenyRead: false, defaultDenyRead: false,
cwd: "/home/user/project",
allowRead: nil, allowRead: nil,
wantContainsBlanketAllow: true, wantContainsBlanketAllow: true,
wantContainsMetadataAllow: false, wantContainsMetadataAllow: false,
wantContainsSystemAllows: false, wantContainsSystemAllows: false,
wantContainsUserAllowRead: 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, defaultDenyRead: true,
cwd: "/home/user/project",
allowRead: nil, allowRead: nil,
wantContainsBlanketAllow: false, wantContainsBlanketAllow: false,
wantContainsMetadataAllow: true, wantContainsMetadataAllow: true,
wantContainsSystemAllows: true, wantContainsSystemAllows: true,
wantContainsUserAllowRead: false, wantContainsUserAllowRead: false,
wantContainsCwdAllow: true,
}, },
{ {
name: "defaultDenyRead with allowRead paths", name: "defaultDenyRead with allowRead paths",
defaultDenyRead: true, defaultDenyRead: true,
allowRead: []string{"/home/user/project"}, cwd: "/home/user/project",
allowRead: []string{"/home/user/other"},
wantContainsBlanketAllow: false, wantContainsBlanketAllow: false,
wantContainsMetadataAllow: true, wantContainsMetadataAllow: true,
wantContainsSystemAllows: true, wantContainsSystemAllows: true,
wantContainsUserAllowRead: true, wantContainsUserAllowRead: true,
wantContainsCwdAllow: true,
}, },
} }
@@ -215,6 +225,7 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
params := MacOSSandboxParams{ params := MacOSSandboxParams{
Command: "echo test", Command: "echo test",
DefaultDenyRead: tt.defaultDenyRead, DefaultDenyRead: tt.defaultDenyRead,
Cwd: tt.cwd,
ReadAllowPaths: tt.allowRead, 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) 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 { if tt.wantContainsUserAllowRead && len(tt.allowRead) > 0 {
hasUserAllow := strings.Contains(profile, tt.allowRead[0]) hasUserAllow := strings.Contains(profile, tt.allowRead[0])
if !hasUserAllow { if !hasUserAllow {

View File

@@ -19,6 +19,9 @@ type Manager struct {
debug bool debug bool
monitor bool monitor bool
initialized 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. // NewManager creates a new sandbox manager.
@@ -35,6 +38,21 @@ func (m *Manager) SetExposedPorts(ports []int) {
m.exposedPorts = ports 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. // Initialize sets up the sandbox infrastructure.
func (m *Manager) Initialize() error { func (m *Manager) Initialize() error {
if m.initialized { if m.initialized {
@@ -102,7 +120,11 @@ func (m *Manager) Initialize() error {
m.initialized = true m.initialized = true
if m.config.Network.ProxyURL != "" { 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 { } else {
m.logDebug("Sandbox manager initialized (no proxy, network blocked)") m.logDebug("Sandbox manager initialized (no proxy, network blocked)")
} }
@@ -128,12 +150,55 @@ func (m *Manager) WrapCommand(command string) (string, error) {
case platform.MacOS: case platform.MacOS:
return WrapCommandMacOS(m.config, command, m.exposedPorts, m.debug) return WrapCommandMacOS(m.config, command, m.exposedPorts, m.debug)
case platform.Linux: 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) return WrapCommandLinux(m.config, command, m.proxyBridge, m.dnsBridge, m.reverseBridge, m.tun2socksPath, m.debug)
default: default:
return "", fmt.Errorf("unsupported platform: %s", plat) 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. // Cleanup stops the proxies and cleans up resources.
func (m *Manager) Cleanup() { func (m *Manager) Cleanup() {
if m.reverseBridge != nil { if m.reverseBridge != nil {
@@ -148,6 +213,10 @@ func (m *Manager) Cleanup() {
if m.tun2socksPath != "" { if m.tun2socksPath != "" {
os.Remove(m.tun2socksPath) os.Remove(m.tun2socksPath)
} }
if m.straceLogPath != "" {
os.Remove(m.straceLogPath)
m.straceLogPath = ""
}
m.logDebug("Sandbox manager cleaned up") m.logDebug("Sandbox manager cleaned up")
} }

View File

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