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

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