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..61678d7 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) @@ -345,9 +389,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 +409,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..1f5d3a3 100644 --- a/internal/sandbox/learning_test.go +++ b/internal/sandbox/learning_test.go @@ -325,8 +325,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 +341,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 +359,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 {