Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions internal/sandbox/integration_macos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,87 @@ func TestMacOS_SeatbeltAllowsTmpGreywall(t *testing.T) {
assertFileExists(t, testFile)
}

// ============================================================================
// Sensitive File Read Blocking Tests
// ============================================================================

// TestMacOS_SeatbeltBlocksEnvFileRead verifies that .env files cannot be read
// even though the workspace directory has a broad file-read-data allow.
// This is the regression test for the Seatbelt deny file-read* vs file-read-data bug:
// Seatbelt ignores wildcard denies (file-read*) when a specific allow (file-read-data)
// covers the same path. Deny rules must use file-read-data to be effective.
func TestMacOS_SeatbeltBlocksEnvFileRead(t *testing.T) {
skipIfAlreadySandboxed(t)

workspace := createTempWorkspace(t)
createTestFile(t, workspace, ".env", "SECRET_KEY=supersecret")
createTestFile(t, workspace, "README.md", "hello")

cfg := testConfigWithWorkspace(workspace)
cfg.Filesystem.DefaultDenyRead = boolPtr(true)
cfg.Filesystem.AllowRead = []string{workspace}
cfg.Filesystem.DenyRead = []string{filepath.Join(workspace, ".env")}

// .env should be blocked
result := runUnderSandbox(t, cfg, "cat "+filepath.Join(workspace, ".env"), workspace)
assertBlocked(t, result)

// Regular files should still be readable
result = runUnderSandbox(t, cfg, "cat "+filepath.Join(workspace, "README.md"), workspace)
assertAllowed(t, result)
assertContains(t, result.Stdout, "hello")
}

// TestMacOS_SeatbeltBlocksEnvVariantsRead verifies that .env.* variants are also blocked.
func TestMacOS_SeatbeltBlocksEnvVariantsRead(t *testing.T) {
skipIfAlreadySandboxed(t)

workspace := createTempWorkspace(t)
createTestFile(t, workspace, ".env.local", "LOCAL_SECRET=abc")
createTestFile(t, workspace, ".env.production", "PROD_SECRET=xyz")

cfg := testConfigWithWorkspace(workspace)
cfg.Filesystem.DefaultDenyRead = boolPtr(true)
cfg.Filesystem.AllowRead = []string{workspace}
cfg.Filesystem.DenyRead = []string{
filepath.Join(workspace, ".env.local"),
filepath.Join(workspace, ".env.production"),
}

result := runUnderSandbox(t, cfg, "cat "+filepath.Join(workspace, ".env.local"), workspace)
assertBlocked(t, result)

result = runUnderSandbox(t, cfg, "cat "+filepath.Join(workspace, ".env.production"), workspace)
assertBlocked(t, result)
}

// TestMacOS_SeatbeltBlocksUserDenyReadPaths verifies that user-configured
// denyRead paths are blocked even in legacy mode (defaultDenyRead=false).
func TestMacOS_SeatbeltBlocksUserDenyReadPaths(t *testing.T) {
skipIfAlreadySandboxed(t)

workspace := createTempWorkspace(t)
secretDir := filepath.Join(workspace, "secrets")
if err := os.MkdirAll(secretDir, 0o750); err != nil {
t.Fatalf("failed to create secrets dir: %v", err)
}
createTestFile(t, workspace, "secrets/api_key.txt", "my-api-key")
createTestFile(t, workspace, "public.txt", "public data")

cfg := testConfigWithWorkspace(workspace)
cfg.Filesystem.DefaultDenyRead = boolPtr(false) // legacy mode
cfg.Filesystem.DenyRead = []string{secretDir}

// Secret file should be blocked
result := runUnderSandbox(t, cfg, "cat "+filepath.Join(secretDir, "api_key.txt"), workspace)
assertBlocked(t, result)

// Public file should still be readable
result = runUnderSandbox(t, cfg, "cat public.txt", workspace)
assertAllowed(t, result)
assertContains(t, result.Stdout, "public data")
}

// ============================================================================
// Network Blocking Tests
// ============================================================================
Expand Down
18 changes: 9 additions & 9 deletions internal/sandbox/macos.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,19 +229,21 @@ func generateReadRules(defaultDenyRead bool, cwd string, allowPaths, denyPaths [
}
}

// Deny sensitive files within CWD (Seatbelt evaluates deny before allow)
// Deny sensitive files within CWD.
// Must use file-read-data (not file-read*) because Seatbelt ignores
// wildcard denies when a specific allow (file-read-data) covers the same path.
if cwd != "" {
for _, f := range SensitiveProjectFiles {
p := filepath.Join(cwd, f)
rules = append(rules,
"(deny file-read*",
"(deny file-read-data",
fmt.Sprintf(" (literal %s)", escapePath(p)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
// Also deny .env.* pattern via regex
rules = append(rules,
"(deny file-read*",
"(deny file-read-data",
fmt.Sprintf(" (regex %s)", escapePath("^"+regexp.QuoteMeta(cwd)+"/\\.env\\..*$")),
fmt.Sprintf(" (with message %q))", logTag),
)
Expand All @@ -252,23 +254,21 @@ func generateReadRules(defaultDenyRead bool, cwd string, allowPaths, denyPaths [
}

// In both modes, deny specific paths (denyRead takes precedence).
// Note: We use file-read* (not file-read-data) so denied paths are fully hidden.
// In defaultDenyRead mode, this overrides the global file-read-metadata allow,
// meaning denied paths can't even be listed or stat'd - more restrictive than
// default mode where denied paths are still visible but unreadable.
// Must use file-read-data (not file-read*) because Seatbelt ignores
// wildcard denies when a specific allow (file-read-data) covers the same path.
for _, pathPattern := range denyPaths {
normalized := NormalizePath(pathPattern)

if ContainsGlobChars(normalized) {
regex := GlobToRegex(normalized)
rules = append(rules,
"(deny file-read*",
"(deny file-read-data",
fmt.Sprintf(" (regex %s)", escapePath(regex)),
fmt.Sprintf(" (with message %q))", logTag),
)
} else {
rules = append(rules,
"(deny file-read*",
"(deny file-read-data",
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
fmt.Sprintf(" (with message %q))", logTag),
)
Expand Down
102 changes: 102 additions & 0 deletions internal/sandbox/macos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,108 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
}
}

// TestMacOS_DenyReadUsesExactOperation verifies that deny read rules use file-read-data
// (not file-read*) in the generated Seatbelt profile. Seatbelt ignores wildcard denies
// (file-read*) when a specific allow (file-read-data) covers the same path.
func TestMacOS_DenyReadUsesExactOperation(t *testing.T) {
tests := []struct {
name string
defaultDenyRead bool
denyRead []string
cwd string
}{
{
name: "defaultDenyRead with sensitive project files",
defaultDenyRead: true,
denyRead: nil,
cwd: "/home/user/project",
},
{
name: "defaultDenyRead with user denyRead paths",
defaultDenyRead: true,
denyRead: []string{"/home/user/secrets", "/home/user/.ssh/id_*"},
cwd: "/home/user/project",
},
{
name: "legacy mode with user denyRead paths",
defaultDenyRead: false,
denyRead: []string{"/home/user/secrets"},
cwd: "/home/user/project",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := MacOSSandboxParams{
Command: "echo test",
DefaultDenyRead: tt.defaultDenyRead,
Cwd: tt.cwd,
ReadDenyPaths: tt.denyRead,
}

profile := GenerateSandboxProfile(params)

// Must NOT contain "deny file-read*" (wildcard deny is ineffective)
if strings.Contains(profile, "(deny file-read*") {
t.Errorf("profile must NOT use 'deny file-read*' (wildcard deny is ignored by Seatbelt when a specific allow covers the same path)\nProfile:\n%s", profile)
}

// Must contain "deny file-read-data" for deny rules to work
if tt.defaultDenyRead || len(tt.denyRead) > 0 {
if !strings.Contains(profile, "(deny file-read-data") {
t.Errorf("profile must use 'deny file-read-data' for deny rules to be effective\nProfile:\n%s", profile)
}
}
})
}
}

// TestMacOS_DenyReadSensitiveProjectFiles verifies that the generated profile
// contains deny rules for all sensitive project files (.env, .env.local, etc.).
func TestMacOS_DenyReadSensitiveProjectFiles(t *testing.T) {
cwd := "/home/user/project"
params := MacOSSandboxParams{
Command: "cat .env",
DefaultDenyRead: true,
Cwd: cwd,
}

profile := GenerateSandboxProfile(params)

for _, f := range SensitiveProjectFiles {
expected := fmt.Sprintf(`(deny file-read-data
(literal %q)`, cwd+"/"+f)
if !strings.Contains(profile, expected) {
t.Errorf("profile missing deny rule for sensitive file %q\nExpected to contain: %s", f, expected)
}
}

// Also check .env.* regex pattern
if !strings.Contains(profile, `(deny file-read-data
(regex`) {
t.Errorf("profile missing regex deny rule for .env.* pattern")
}
}

// TestMacOS_DenyReadUserPaths verifies that user-configured denyRead paths
// appear in the generated profile with file-read-data (not file-read*).
func TestMacOS_DenyReadUserPaths(t *testing.T) {
params := MacOSSandboxParams{
Command: "echo test",
DefaultDenyRead: false,
Cwd: "/home/user/project",
ReadDenyPaths: []string{"/home/user/secrets"},
}

profile := GenerateSandboxProfile(params)

expected := `(deny file-read-data
(subpath "/home/user/secrets")`
if !strings.Contains(profile, expected) {
t.Errorf("profile missing deny rule for user denyRead path\nExpected: %s\nProfile:\n%s", expected, profile)
}
}

// TestExpandMacOSTmpPaths verifies that /tmp and /private/tmp paths are properly mirrored.
func TestExpandMacOSTmpPaths(t *testing.T) {
tests := []struct {
Expand Down
Loading