diff --git a/docs/experience.md b/docs/experience.md index 5ca66c4..c0ce50e 100644 --- a/docs/experience.md +++ b/docs/experience.md @@ -93,3 +93,29 @@ echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/9 ``` **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 ` (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 /.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 /.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 /.env /.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. diff --git a/internal/config/config.go b/internal/config/config.go index d6970aa..352482c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,7 +36,7 @@ type NetworkConfig struct { // FilesystemConfig defines filesystem restrictions. type FilesystemConfig struct { - DefaultDenyRead bool `json:"defaultDenyRead,omitempty"` // If true, deny reads by default except system paths and AllowRead + DefaultDenyRead *bool `json:"defaultDenyRead,omitempty"` // If nil or true, deny reads by default except system paths, CWD, and AllowRead AllowRead []string `json:"allowRead"` // Paths to allow reading (used when DefaultDenyRead is true) DenyRead []string `json:"denyRead"` AllowWrite []string `json:"allowWrite"` @@ -44,6 +44,12 @@ type FilesystemConfig struct { AllowGitConfig bool `json:"allowGitConfig,omitempty"` } +// IsDefaultDenyRead returns whether deny-by-default read mode is enabled. +// Defaults to true when not explicitly set (nil). +func (f *FilesystemConfig) IsDefaultDenyRead() bool { + return f.DefaultDenyRead == nil || *f.DefaultDenyRead +} + // CommandConfig defines command restrictions. type CommandConfig struct { Deny []string `json:"deny"` @@ -417,8 +423,8 @@ func Merge(base, override *Config) *Config { }, Filesystem: FilesystemConfig{ - // Boolean fields: true if either enables it - DefaultDenyRead: base.Filesystem.DefaultDenyRead || override.Filesystem.DefaultDenyRead, + // Pointer field: override wins if set, otherwise base (nil = deny-by-default) + DefaultDenyRead: mergeOptionalBool(base.Filesystem.DefaultDenyRead, override.Filesystem.DefaultDenyRead), // Append slices AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 21c0b81..58d268c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -410,7 +410,7 @@ func TestMerge(t *testing.T) { t.Run("merge defaultDenyRead and allowRead", func(t *testing.T) { base := &Config{ Filesystem: FilesystemConfig{ - DefaultDenyRead: true, + DefaultDenyRead: boolPtr(true), AllowRead: []string{"/home/user/project"}, }, } @@ -421,13 +421,40 @@ func TestMerge(t *testing.T) { } result := Merge(base, override) - if !result.Filesystem.DefaultDenyRead { - t.Error("expected DefaultDenyRead to be true (from base)") + if !result.Filesystem.IsDefaultDenyRead() { + t.Error("expected IsDefaultDenyRead() to be true (from base)") } if len(result.Filesystem.AllowRead) != 2 { t.Errorf("expected 2 allowRead paths, got %d: %v", len(result.Filesystem.AllowRead), result.Filesystem.AllowRead) } }) + + t.Run("defaultDenyRead nil defaults to true", func(t *testing.T) { + base := &Config{ + Filesystem: FilesystemConfig{}, + } + result := Merge(base, nil) + if !result.Filesystem.IsDefaultDenyRead() { + t.Error("expected IsDefaultDenyRead() to be true when nil (deny-by-default)") + } + }) + + t.Run("defaultDenyRead explicit false overrides", func(t *testing.T) { + base := &Config{ + Filesystem: FilesystemConfig{ + DefaultDenyRead: boolPtr(true), + }, + } + override := &Config{ + Filesystem: FilesystemConfig{ + DefaultDenyRead: boolPtr(false), + }, + } + result := Merge(base, override) + if result.Filesystem.IsDefaultDenyRead() { + t.Error("expected IsDefaultDenyRead() to be false (override explicit false)") + } + }) } func boolPtr(b bool) *bool { diff --git a/internal/sandbox/dangerous.go b/internal/sandbox/dangerous.go index cf3b1b6..978da6b 100644 --- a/internal/sandbox/dangerous.go +++ b/internal/sandbox/dangerous.go @@ -28,6 +28,30 @@ var DangerousDirectories = []string{ ".claude/agents", } +// SensitiveProjectFiles lists files within the project directory that should be +// denied for both read and write access. These commonly contain secrets. +var SensitiveProjectFiles = []string{ + ".env", + ".env.local", + ".env.development", + ".env.production", + ".env.staging", + ".env.test", +} + +// GetSensitiveProjectPaths returns concrete paths for sensitive files within the +// given directory. Only returns paths for files that actually exist. +func GetSensitiveProjectPaths(cwd string) []string { + var paths []string + for _, f := range SensitiveProjectFiles { + p := filepath.Join(cwd, f) + if _, err := os.Stat(p); err == nil { + paths = append(paths, p) + } + } + return paths +} + // GetDefaultWritePaths returns system paths that should be writable for commands to work. func GetDefaultWritePaths() []string { home, _ := os.UserHomeDir() diff --git a/internal/sandbox/integration_test.go b/internal/sandbox/integration_test.go index 51f4693..45b0a0c 100644 --- a/internal/sandbox/integration_test.go +++ b/internal/sandbox/integration_test.go @@ -123,13 +123,17 @@ func assertContains(t *testing.T, haystack, needle string) { // ============================================================================ // testConfig creates a test configuration with sensible defaults. +// Uses legacy mode (defaultDenyRead=false) for predictable testing of +// existing integration tests. Use testConfigDenyByDefault() for tests +// that specifically test deny-by-default behavior. func testConfig() *config.Config { return &config.Config{ Network: config.NetworkConfig{}, Filesystem: config.FilesystemConfig{ - DenyRead: []string{}, - AllowWrite: []string{}, - DenyWrite: []string{}, + DefaultDenyRead: boolPtr(false), // Legacy mode for existing tests + DenyRead: []string{}, + AllowWrite: []string{}, + DenyWrite: []string{}, }, Command: config.CommandConfig{ Deny: []string{}, diff --git a/internal/sandbox/learning.go b/internal/sandbox/learning.go index 7282000..18b15e9 100644 --- a/internal/sandbox/learning.go +++ b/internal/sandbox/learning.go @@ -87,6 +87,43 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string, 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 { @@ -103,6 +140,13 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string, } } + 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 { @@ -118,7 +162,7 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string, fmt.Fprintf(os.Stderr, "\n") // Build template - template := buildTemplate(cmdName, allowWrite) + template := buildTemplate(cmdName, allowRead, allowWrite) // Save template templatePath := LearnedTemplatePath(cmdName) @@ -176,9 +220,15 @@ func CollapsePaths(paths []string) []string { } } - // For standalone paths, use their parent directory + // For standalone paths, use their parent directory — but never collapse to $HOME for _, p := range standalone { - result = append(result, filepath.Dir(p)) + 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) @@ -345,9 +395,18 @@ func deduplicateSubPaths(paths []string) []string { 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, allowWrite []string) string { +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"` @@ -356,11 +415,15 @@ func buildTemplate(cmdName string, allowWrite []string) string { 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: getSensitiveReadPatterns(), + DenyRead: denyRead, }, } diff --git a/internal/sandbox/learning_test.go b/internal/sandbox/learning_test.go index 0c9c9c2..90deed4 100644 --- a/internal/sandbox/learning_test.go +++ b/internal/sandbox/learning_test.go @@ -75,9 +75,10 @@ func TestCollapsePaths(t *testing.T) { defer os.Setenv("HOME", origHome) tests := []struct { - name string - paths []string - contains []string // paths that should be in the result + 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", @@ -111,6 +112,33 @@ func TestCollapsePaths(t *testing.T) { "/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 { @@ -134,6 +162,13 @@ func TestCollapsePaths(t *testing.T) { 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) + } + } + } }) } } @@ -325,8 +360,9 @@ func TestListLearnedTemplates(t *testing.T) { } func TestBuildTemplate(t *testing.T) { + allowRead := []string{"~/external-data"} allowWrite := []string{".", "~/.cache/opencode", "~/.config/opencode"} - result := buildTemplate("opencode", allowWrite) + result := buildTemplate("opencode", allowRead, allowWrite) // Check header comments if !strings.Contains(result, `Learned template for "opencode"`) { @@ -340,6 +376,12 @@ func TestBuildTemplate(t *testing.T) { } // 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") } @@ -352,6 +394,22 @@ func TestBuildTemplate(t *testing.T) { 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) { diff --git a/internal/sandbox/linux.go b/internal/sandbox/linux.go index 95f4def..7f04fda 100644 --- a/internal/sandbox/linux.go +++ b/internal/sandbox/linux.go @@ -371,6 +371,12 @@ func getMandatoryDenyPaths(cwd string) []string { paths = append(paths, p) } + // Sensitive project files (e.g. .env) in cwd + for _, f := range SensitiveProjectFiles { + p := filepath.Join(cwd, f) + paths = append(paths, p) + } + // Git hooks in cwd paths = append(paths, filepath.Join(cwd, ".git/hooks")) @@ -389,6 +395,193 @@ func getMandatoryDenyPaths(cwd string) []string { return paths } +// buildDenyByDefaultMounts builds bwrap arguments for deny-by-default filesystem isolation. +// Starts with --tmpfs / (empty root), then selectively mounts system paths read-only, +// CWD read-write, and user tooling paths read-only. Sensitive files within CWD are masked. +func buildDenyByDefaultMounts(cfg *config.Config, cwd string, debug bool) []string { + var args []string + home, _ := os.UserHomeDir() + + // Start with empty root + args = append(args, "--tmpfs", "/") + + // System paths (read-only) - on modern distros (Arch, Fedora, etc.), + // /bin, /sbin, /lib, /lib64 are often symlinks to /usr/*. We must + // recreate these as symlinks via --symlink so the dynamic linker + // and shell can be found. Real directories get bind-mounted. + systemPaths := []string{"/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt", "/run"} + for _, p := range systemPaths { + if !fileExists(p) { + continue + } + if isSymlink(p) { + // Recreate the symlink inside the sandbox (e.g., /bin -> usr/bin) + target, err := os.Readlink(p) + if err == nil { + args = append(args, "--symlink", target, p) + } + } else { + args = append(args, "--ro-bind", p, p) + } + } + + // /sys needs to be accessible for system info + if fileExists("/sys") && canMountOver("/sys") { + args = append(args, "--ro-bind", "/sys", "/sys") + } + + // CWD: create intermediary dirs and bind read-write + if cwd != "" && fileExists(cwd) { + for _, dir := range intermediaryDirs("/", cwd) { + // Skip dirs that are already mounted as system paths + if isSystemMountPoint(dir) { + continue + } + args = append(args, "--dir", dir) + } + args = append(args, "--bind", cwd, cwd) + } + + // User tooling paths from GetDefaultReadablePaths() (read-only) + // Filter out paths already mounted (system dirs, /dev, /proc, /tmp, macOS-specific) + if home != "" { + boundDirs := make(map[string]bool) + for _, p := range GetDefaultReadablePaths() { + // Skip system paths (already bound above), special mounts, and macOS paths + if isSystemMountPoint(p) || p == "/dev" || p == "/proc" || p == "/sys" || + p == "/tmp" || p == "/private/tmp" || + strings.HasPrefix(p, "/System") || strings.HasPrefix(p, "/Library") || + strings.HasPrefix(p, "/Applications") || strings.HasPrefix(p, "/private/") || + strings.HasPrefix(p, "/nix") || strings.HasPrefix(p, "/snap") || + p == "/usr/local" || p == "/opt/homebrew" { + continue + } + if !strings.HasPrefix(p, home) { + continue // Only user tooling paths need intermediary dirs + } + if !fileExists(p) || !canMountOver(p) { + continue + } + // Create intermediary dirs between root and this path + for _, dir := range intermediaryDirs("/", p) { + if !boundDirs[dir] && !isSystemMountPoint(dir) && dir != cwd { + boundDirs[dir] = true + args = append(args, "--dir", dir) + } + } + args = append(args, "--ro-bind", p, p) + } + + // Shell config files in home (read-only, literal files) + shellConfigs := []string{".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".inputrc"} + homeIntermedaryAdded := boundDirs[home] + for _, f := range shellConfigs { + p := filepath.Join(home, f) + if fileExists(p) && canMountOver(p) { + if !homeIntermedaryAdded { + for _, dir := range intermediaryDirs("/", home) { + if !boundDirs[dir] && !isSystemMountPoint(dir) { + boundDirs[dir] = true + args = append(args, "--dir", dir) + } + } + homeIntermedaryAdded = true + } + args = append(args, "--ro-bind", p, p) + } + } + + // Home tool caches (read-only, for package managers/configs) + homeCaches := []string{".cache", ".npm", ".cargo", ".rustup", ".local", ".config"} + for _, d := range homeCaches { + p := filepath.Join(home, d) + if fileExists(p) && canMountOver(p) { + if !homeIntermedaryAdded { + for _, dir := range intermediaryDirs("/", home) { + if !boundDirs[dir] && !isSystemMountPoint(dir) { + boundDirs[dir] = true + args = append(args, "--dir", dir) + } + } + homeIntermedaryAdded = true + } + args = append(args, "--ro-bind", p, p) + } + } + } + + // User-specified allowRead paths (read-only) + if cfg != nil && cfg.Filesystem.AllowRead != nil { + boundPaths := make(map[string]bool) + + expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowRead) + for _, p := range expandedPaths { + if fileExists(p) && canMountOver(p) && + !strings.HasPrefix(p, "/dev/") && !strings.HasPrefix(p, "/proc/") && !boundPaths[p] { + boundPaths[p] = true + // Create intermediary dirs if needed + for _, dir := range intermediaryDirs("/", p) { + if !isSystemMountPoint(dir) { + args = append(args, "--dir", dir) + } + } + args = append(args, "--ro-bind", p, p) + } + } + for _, p := range cfg.Filesystem.AllowRead { + normalized := NormalizePath(p) + if !ContainsGlobChars(normalized) && fileExists(normalized) && canMountOver(normalized) && + !strings.HasPrefix(normalized, "/dev/") && !strings.HasPrefix(normalized, "/proc/") && !boundPaths[normalized] { + boundPaths[normalized] = true + for _, dir := range intermediaryDirs("/", normalized) { + if !isSystemMountPoint(dir) { + args = append(args, "--dir", dir) + } + } + args = append(args, "--ro-bind", normalized, normalized) + } + } + } + + // Mask sensitive project files within CWD by overlaying an empty regular file. + // We use an empty file instead of /dev/null because Landlock's READ_FILE right + // doesn't cover character devices, causing "Permission denied" on /dev/null mounts. + if cwd != "" { + var emptyFile string + for _, f := range SensitiveProjectFiles { + p := filepath.Join(cwd, f) + if fileExists(p) { + if emptyFile == "" { + emptyFile = filepath.Join(os.TempDir(), "greywall", "empty") + _ = os.MkdirAll(filepath.Dir(emptyFile), 0o750) + _ = os.WriteFile(emptyFile, nil, 0o444) + } + args = append(args, "--ro-bind", emptyFile, p) + if debug { + fmt.Fprintf(os.Stderr, "[greywall:linux] Masking sensitive file: %s\n", p) + } + } + } + } + + return args +} + +// isSystemMountPoint returns true if the path is a top-level system directory +// that gets mounted directly under --tmpfs / (bwrap auto-creates these). +func isSystemMountPoint(path string) bool { + switch path { + case "/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt", "/run", "/sys", + "/dev", "/proc", "/tmp", + // macOS + "/System", "/Library", "/Applications", "/private", + // Package managers + "/nix", "/snap", "/usr/local", "/opt/homebrew": + return true + } + return false +} + // WrapCommandLinux wraps a command with Linux bubblewrap sandbox. // It uses available security features (Landlock, seccomp) with graceful fallback. func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) { @@ -480,52 +673,18 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge } - defaultDenyRead := cfg != nil && cfg.Filesystem.DefaultDenyRead + defaultDenyRead := cfg != nil && cfg.Filesystem.IsDefaultDenyRead() if opts.Learning { // Skip defaultDenyRead logic in learning mode (already set up above) } else if defaultDenyRead { - // In defaultDenyRead mode, we only bind essential system paths read-only - // and user-specified allowRead paths. Everything else is inaccessible. + // Deny-by-default mode: start with empty root, then whitelist system paths + CWD if opts.Debug { - fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - binding only essential system paths\n") - } - - // Bind essential system paths read-only - // Skip /dev, /proc, /tmp as they're mounted with special options below - for _, systemPath := range GetDefaultReadablePaths() { - if systemPath == "/dev" || systemPath == "/proc" || systemPath == "/tmp" || - systemPath == "/private/tmp" { - continue - } - if fileExists(systemPath) { - bwrapArgs = append(bwrapArgs, "--ro-bind", systemPath, systemPath) - } - } - - // Bind user-specified allowRead paths - if cfg != nil && cfg.Filesystem.AllowRead != nil { - boundPaths := make(map[string]bool) - - expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowRead) - for _, p := range expandedPaths { - if fileExists(p) && !strings.HasPrefix(p, "/dev/") && !strings.HasPrefix(p, "/proc/") && !boundPaths[p] { - boundPaths[p] = true - bwrapArgs = append(bwrapArgs, "--ro-bind", p, p) - } - } - // Add non-glob paths - for _, p := range cfg.Filesystem.AllowRead { - normalized := NormalizePath(p) - if !ContainsGlobChars(normalized) && fileExists(normalized) && - !strings.HasPrefix(normalized, "/dev/") && !strings.HasPrefix(normalized, "/proc/") && !boundPaths[normalized] { - boundPaths[normalized] = true - bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized) - } - } + fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - tmpfs root with selective mounts\n") } + bwrapArgs = append(bwrapArgs, buildDenyByDefaultMounts(cfg, cwd, opts.Debug)...) } else { - // Default mode: bind entire root filesystem read-only + // Legacy mode: bind entire root filesystem read-only bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/") } @@ -679,10 +838,20 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge // subdirectory dangerous files without full tree walks that hang on large dirs. mandatoryDeny := getMandatoryDenyPaths(cwd) + // In deny-by-default mode, sensitive project files are already masked + // with --ro-bind /dev/null by buildDenyByDefaultMounts(). Skip them here + // to avoid overriding the /dev/null mask with a real ro-bind. + maskedPaths := make(map[string]bool) + if defaultDenyRead { + for _, f := range SensitiveProjectFiles { + maskedPaths[filepath.Join(cwd, f)] = true + } + } + // Deduplicate seen := make(map[string]bool) for _, p := range mandatoryDeny { - if !seen[p] && fileExists(p) { + if !seen[p] && fileExists(p) && !maskedPaths[p] { seen[p] = true bwrapArgs = append(bwrapArgs, "--ro-bind", p, p) } diff --git a/internal/sandbox/linux_landlock.go b/internal/sandbox/linux_landlock.go index 7fd8394..12ddd50 100644 --- a/internal/sandbox/linux_landlock.go +++ b/internal/sandbox/linux_landlock.go @@ -81,17 +81,55 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin } } - // Current working directory - read access (may be upgraded to write below) + // Current working directory - read+write access (project directory) if cwd != "" { - if err := ruleset.AllowRead(cwd); err != nil && debug { - fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read path: %v\n", err) + if err := ruleset.AllowReadWrite(cwd); err != nil && debug { + fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read/write path: %v\n", err) } } - // Home directory - read access - if home, err := os.UserHomeDir(); err == nil { - if err := ruleset.AllowRead(home); err != nil && debug { - fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home read path: %v\n", err) + // Home directory - read access only when not in deny-by-default mode. + // In deny-by-default mode, only specific user tooling paths are allowed, + // not the entire home directory. Landlock can't selectively deny files + // within an allowed directory, so we rely on bwrap mount overlays for + // .env file masking. + defaultDenyRead := cfg != nil && cfg.Filesystem.IsDefaultDenyRead() + if !defaultDenyRead { + if home, err := os.UserHomeDir(); err == nil { + if err := ruleset.AllowRead(home); err != nil && debug { + fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home read path: %v\n", err) + } + } + } else { + // In deny-by-default mode, allow specific user tooling paths + if home, err := os.UserHomeDir(); err == nil { + for _, p := range GetDefaultReadablePaths() { + if strings.HasPrefix(p, home) { + if err := ruleset.AllowRead(p); err != nil && debug { + fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add user tooling path %s: %v\n", p, err) + } + } + } + // Shell configs + shellConfigs := []string{".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".inputrc"} + for _, f := range shellConfigs { + p := filepath.Join(home, f) + if err := ruleset.AllowRead(p); err != nil && debug { + if !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add shell config %s: %v\n", p, err) + } + } + } + // Home caches + homeCaches := []string{".cache", ".npm", ".cargo", ".rustup", ".local", ".config"} + for _, d := range homeCaches { + p := filepath.Join(home, d) + if err := ruleset.AllowRead(p); err != nil && debug { + if !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home cache %s: %v\n", p, err) + } + } + } } } diff --git a/internal/sandbox/macos.go b/internal/sandbox/macos.go index f7b475d..c13910a 100644 --- a/internal/sandbox/macos.go +++ b/internal/sandbox/macos.go @@ -37,6 +37,7 @@ type MacOSSandboxParams struct { AllowLocalBinding bool AllowLocalOutbound bool DefaultDenyRead bool + Cwd string // Current working directory (for deny-by-default CWD allowlisting) ReadAllowPaths []string ReadDenyPaths []string WriteAllowPaths []string @@ -146,13 +147,13 @@ func getTmpdirParent() []string { } // generateReadRules generates filesystem read rules for the sandbox profile. -func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, logTag string) []string { +func generateReadRules(defaultDenyRead bool, cwd string, allowPaths, denyPaths []string, logTag string) []string { var rules []string if defaultDenyRead { // When defaultDenyRead is enabled: // 1. Allow file-read-metadata globally (needed for directory traversal, stat, etc.) - // 2. Allow file-read-data only for system paths + user-specified allowRead paths + // 2. Allow file-read-data only for system paths + CWD + user-specified allowRead paths // This lets programs see what files exist but not read their contents. // Allow metadata operations globally (stat, readdir, etc.) and root dir (for path resolution) @@ -167,6 +168,44 @@ func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, log ) } + // Allow reading CWD (full recursive read access) + if cwd != "" { + rules = append(rules, + "(allow file-read-data", + fmt.Sprintf(" (subpath %s))", escapePath(cwd)), + ) + + // Allow ancestor directory traversal (literal only, so programs can resolve CWD path) + for _, ancestor := range getAncestorDirectories(cwd) { + rules = append(rules, + fmt.Sprintf("(allow file-read-data (literal %s))", escapePath(ancestor)), + ) + } + } + + // Allow home shell configs and tool caches (read-only) + home, _ := os.UserHomeDir() + if home != "" { + // Shell config files (literal access) + shellConfigs := []string{".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".inputrc"} + for _, f := range shellConfigs { + p := filepath.Join(home, f) + rules = append(rules, + fmt.Sprintf("(allow file-read-data (literal %s))", escapePath(p)), + ) + } + + // Home tool caches (subpath access for package managers/configs) + homeCaches := []string{".cache", ".npm", ".cargo", ".rustup", ".local", ".config", ".nvm", ".pyenv", ".rbenv", ".asdf"} + for _, d := range homeCaches { + p := filepath.Join(home, d) + rules = append(rules, + "(allow file-read-data", + fmt.Sprintf(" (subpath %s))", escapePath(p)), + ) + } + } + // Allow reading data from user-specified paths for _, pathPattern := range allowPaths { normalized := NormalizePath(pathPattern) @@ -184,6 +223,24 @@ func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, log ) } } + + // Deny sensitive files within CWD (Seatbelt evaluates deny before allow) + if cwd != "" { + for _, f := range SensitiveProjectFiles { + p := filepath.Join(cwd, f) + rules = append(rules, + "(deny file-read*", + fmt.Sprintf(" (literal %s)", escapePath(p)), + fmt.Sprintf(" (with message %q))", logTag), + ) + } + // Also deny .env.* pattern via regex + rules = append(rules, + "(deny file-read*", + fmt.Sprintf(" (regex %s)", escapePath("^"+regexp.QuoteMeta(cwd)+"/\\.env\\..*$")), + fmt.Sprintf(" (with message %q))", logTag), + ) + } } else { // Allow all reads by default rules = append(rules, "(allow file-read*)") @@ -220,9 +277,19 @@ func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, log } // generateWriteRules generates filesystem write rules for the sandbox profile. -func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, logTag string) []string { +// When cwd is non-empty, it is automatically included in the write allow paths. +func generateWriteRules(cwd string, allowPaths, denyPaths []string, allowGitConfig bool, logTag string) []string { var rules []string + // Auto-allow CWD for writes (project directory should be writable) + if cwd != "" { + rules = append(rules, + "(allow file-write*", + fmt.Sprintf(" (subpath %s)", escapePath(cwd)), + fmt.Sprintf(" (with message %q))", logTag), + ) + } + // Allow TMPDIR parent on macOS for _, tmpdirParent := range getTmpdirParent() { normalized := NormalizePath(tmpdirParent) @@ -254,8 +321,11 @@ func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, log } // Combine user-specified and mandatory deny patterns - cwd, _ := os.Getwd() - mandatoryDeny := GetMandatoryDenyPatterns(cwd, allowGitConfig) + mandatoryCwd := cwd + if mandatoryCwd == "" { + mandatoryCwd, _ = os.Getwd() + } + mandatoryDeny := GetMandatoryDenyPatterns(mandatoryCwd, allowGitConfig) allDenyPaths := make([]string, 0, len(denyPaths)+len(mandatoryDeny)) allDenyPaths = append(allDenyPaths, denyPaths...) allDenyPaths = append(allDenyPaths, mandatoryDeny...) @@ -530,14 +600,14 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string { // Read rules profile.WriteString("; File read\n") - for _, rule := range generateReadRules(params.DefaultDenyRead, params.ReadAllowPaths, params.ReadDenyPaths, logTag) { + for _, rule := range generateReadRules(params.DefaultDenyRead, params.Cwd, params.ReadAllowPaths, params.ReadDenyPaths, logTag) { profile.WriteString(rule + "\n") } profile.WriteString("\n") // Write rules profile.WriteString("; File write\n") - for _, rule := range generateWriteRules(params.WriteAllowPaths, params.WriteDenyPaths, params.AllowGitConfig, logTag) { + for _, rule := range generateWriteRules(params.Cwd, params.WriteAllowPaths, params.WriteDenyPaths, params.AllowGitConfig, logTag) { profile.WriteString(rule + "\n") } @@ -562,6 +632,8 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string { // WrapCommandMacOS wraps a command with macOS sandbox restrictions. func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) { + cwd, _ := os.Getwd() + // Build allow paths: default + configured allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...) @@ -599,7 +671,8 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets, AllowLocalBinding: allowLocalBinding, AllowLocalOutbound: allowLocalOutbound, - DefaultDenyRead: cfg.Filesystem.DefaultDenyRead, + DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(), + Cwd: cwd, ReadAllowPaths: cfg.Filesystem.AllowRead, ReadDenyPaths: cfg.Filesystem.DenyRead, WriteAllowPaths: allowPaths, diff --git a/internal/sandbox/macos_test.go b/internal/sandbox/macos_test.go index 6e466e5..bae76fd 100644 --- a/internal/sandbox/macos_test.go +++ b/internal/sandbox/macos_test.go @@ -1,6 +1,7 @@ package sandbox import ( + "fmt" "strings" "testing" @@ -108,7 +109,8 @@ func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams { AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets, AllowLocalBinding: allowLocalBinding, AllowLocalOutbound: allowLocalOutbound, - DefaultDenyRead: cfg.Filesystem.DefaultDenyRead, + DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(), + Cwd: "/tmp/test-project", ReadAllowPaths: cfg.Filesystem.AllowRead, ReadDenyPaths: cfg.Filesystem.DenyRead, WriteAllowPaths: allowPaths, @@ -175,38 +177,46 @@ func TestMacOS_DefaultDenyRead(t *testing.T) { tests := []struct { name string defaultDenyRead bool + cwd string allowRead []string wantContainsBlanketAllow bool wantContainsMetadataAllow bool wantContainsSystemAllows bool wantContainsUserAllowRead bool + wantContainsCwdAllow bool }{ { - name: "default mode - blanket allow read", + name: "legacy mode - blanket allow read", defaultDenyRead: false, + cwd: "/home/user/project", allowRead: nil, wantContainsBlanketAllow: true, wantContainsMetadataAllow: false, wantContainsSystemAllows: false, wantContainsUserAllowRead: false, + wantContainsCwdAllow: false, }, { - name: "defaultDenyRead enabled - metadata allow, system data allows", + name: "defaultDenyRead enabled - metadata allow, system data allows, CWD allow", defaultDenyRead: true, + cwd: "/home/user/project", allowRead: nil, wantContainsBlanketAllow: false, wantContainsMetadataAllow: true, wantContainsSystemAllows: true, wantContainsUserAllowRead: false, + wantContainsCwdAllow: true, }, { name: "defaultDenyRead with allowRead paths", defaultDenyRead: true, - allowRead: []string{"/home/user/project"}, + cwd: "/home/user/project", + allowRead: []string{"/home/user/other"}, wantContainsBlanketAllow: false, wantContainsMetadataAllow: true, wantContainsSystemAllows: true, wantContainsUserAllowRead: true, + wantContainsCwdAllow: true, }, } @@ -215,6 +225,7 @@ func TestMacOS_DefaultDenyRead(t *testing.T) { params := MacOSSandboxParams{ Command: "echo test", DefaultDenyRead: tt.defaultDenyRead, + Cwd: tt.cwd, ReadAllowPaths: tt.allowRead, } @@ -236,6 +247,13 @@ func TestMacOS_DefaultDenyRead(t *testing.T) { t.Errorf("system path allows = %v, want %v\nProfile:\n%s", hasSystemAllows, tt.wantContainsSystemAllows, profile) } + if tt.wantContainsCwdAllow && tt.cwd != "" { + hasCwdAllow := strings.Contains(profile, fmt.Sprintf(`(subpath %q)`, tt.cwd)) + if !hasCwdAllow { + t.Errorf("CWD path %q not found in profile", tt.cwd) + } + } + if tt.wantContainsUserAllowRead && len(tt.allowRead) > 0 { hasUserAllow := strings.Contains(profile, tt.allowRead[0]) if !hasUserAllow {