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
This commit is contained in:
2026-02-12 20:15:40 -06:00
parent b55b3364af
commit 5affaf77a5
10 changed files with 511 additions and 72 deletions

View File

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

@@ -87,6 +87,43 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
allowWrite = append(allowWrite, toTildePath(p, home)) 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 // Convert read paths to tilde-relative for display
var readDisplay []string var readDisplay []string
for _, p := range result.ReadPaths { 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 if len(allowWrite) > 1 { // >1 because "." is always included
fmt.Fprintf(os.Stderr, "[greywall] Discovered write paths (collapsed):\n") fmt.Fprintf(os.Stderr, "[greywall] Discovered write paths (collapsed):\n")
for _, p := range allowWrite { for _, p := range allowWrite {
@@ -118,7 +162,7 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
fmt.Fprintf(os.Stderr, "\n") fmt.Fprintf(os.Stderr, "\n")
// Build template // Build template
template := buildTemplate(cmdName, allowWrite) template := buildTemplate(cmdName, allowRead, allowWrite)
// Save template // Save template
templatePath := LearnedTemplatePath(cmdName) templatePath := LearnedTemplatePath(cmdName)
@@ -345,9 +389,18 @@ func deduplicateSubPaths(paths []string) []string {
return result 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. // 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 { type fsConfig struct {
AllowRead []string `json:"allowRead,omitempty"`
AllowWrite []string `json:"allowWrite"` AllowWrite []string `json:"allowWrite"`
DenyWrite []string `json:"denyWrite"` DenyWrite []string `json:"denyWrite"`
DenyRead []string `json:"denyRead"` DenyRead []string `json:"denyRead"`
@@ -356,11 +409,15 @@ func buildTemplate(cmdName string, allowWrite []string) string {
Filesystem fsConfig `json:"filesystem"` Filesystem fsConfig `json:"filesystem"`
} }
// Combine sensitive read patterns with .env project patterns
denyRead := append(getSensitiveReadPatterns(), getSensitiveProjectDenyPatterns()...)
cfg := templateConfig{ cfg := templateConfig{
Filesystem: fsConfig{ Filesystem: fsConfig{
AllowRead: allowRead,
AllowWrite: allowWrite, AllowWrite: allowWrite,
DenyWrite: getDangerousFilePatterns(), DenyWrite: getDangerousFilePatterns(),
DenyRead: getSensitiveReadPatterns(), DenyRead: denyRead,
}, },
} }

View File

@@ -325,8 +325,9 @@ func TestListLearnedTemplates(t *testing.T) {
} }
func TestBuildTemplate(t *testing.T) { func TestBuildTemplate(t *testing.T) {
allowRead := []string{"~/external-data"}
allowWrite := []string{".", "~/.cache/opencode", "~/.config/opencode"} allowWrite := []string{".", "~/.cache/opencode", "~/.config/opencode"}
result := buildTemplate("opencode", allowWrite) result := buildTemplate("opencode", allowRead, allowWrite)
// Check header comments // Check header comments
if !strings.Contains(result, `Learned template for "opencode"`) { if !strings.Contains(result, `Learned template for "opencode"`) {
@@ -340,6 +341,12 @@ func TestBuildTemplate(t *testing.T) {
} }
// Check content // 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"`) { if !strings.Contains(result, `"allowWrite"`) {
t.Error("template missing allowWrite field") t.Error("template missing allowWrite field")
} }
@@ -352,6 +359,22 @@ func TestBuildTemplate(t *testing.T) {
if !strings.Contains(result, `"denyRead"`) { if !strings.Contains(result, `"denyRead"`) {
t.Error("template missing denyRead field") 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) { func TestGenerateLearnedTemplate(t *testing.T) {

View File

@@ -371,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"))
@@ -389,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) {
@@ -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 { if opts.Learning {
// Skip defaultDenyRead logic in learning mode (already set up above) // Skip defaultDenyRead logic in learning mode (already set up above)
} else if defaultDenyRead { } else if defaultDenyRead {
// In defaultDenyRead mode, we only bind essential system paths read-only // Deny-by-default mode: start with empty root, then whitelist system paths + CWD
// 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] DefaultDenyRead mode enabled - tmpfs root with selective mounts\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)
}
}
} }
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", "/", "/")
} }
@@ -679,10 +838,20 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
// 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)
// 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 // Deduplicate
seen := make(map[string]bool) seen := make(map[string]bool)
for _, p := range mandatoryDeny { for _, p := range mandatoryDeny {
if !seen[p] && fileExists(p) { if !seen[p] && fileExists(p) && !maskedPaths[p] {
seen[p] = true seen[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p) bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
} }

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

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