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
58 changes: 45 additions & 13 deletions internal/sandbox/dangerous.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
)

Expand Down Expand Up @@ -292,30 +293,61 @@ func FindDangerousFiles(root string, maxDepth int) []string {
return results
}

// GetMandatoryDenyPatterns returns glob patterns for paths that must always be protected.
func appendUniquePatterns(patterns []string, values ...string) []string {
for _, value := range values {
if value == "" || slices.Contains(patterns, value) {
continue
}
patterns = append(patterns, value)
}
return patterns
}

func appendWorkspaceRecursiveFilePatterns(patterns []string, root, relativePath string) []string {
return appendUniquePatterns(
patterns,
filepath.Join(root, relativePath),
filepath.Join(root, "**", relativePath),
)
}

func appendWorkspaceRecursiveDirectoryPatterns(patterns []string, root, relativePath string) []string {
return appendUniquePatterns(
patterns,
filepath.Join(root, relativePath),
filepath.Join(root, "**", relativePath),
filepath.Join(root, "**", relativePath, "**"),
)
}

// GetMandatoryDenyPatterns returns absolute and workspace-scoped glob patterns
// for paths that must always be protected on macOS.
func GetMandatoryDenyPatterns(cwd string, allowGitConfig bool) []string {
cwd = filepath.Clean(cwd)
var patterns []string
home, _ := os.UserHomeDir()

// Dangerous files - in CWD and all subdirectories
// Dangerous files are protected in the workspace subtree and explicitly in
// the user's home directory, so shell/profile files stay protected without
// denying matching filenames everywhere on the filesystem.
for _, f := range DangerousFiles {
patterns = append(patterns, filepath.Join(cwd, f))
patterns = append(patterns, "**/"+f)
patterns = appendWorkspaceRecursiveFilePatterns(patterns, cwd, f)
if home != "" {
patterns = appendUniquePatterns(patterns, filepath.Join(home, f))
}
}

// Dangerous directories
// Dangerous directories are only scoped to the current workspace tree.
for _, d := range DangerousDirectories {
patterns = append(patterns, filepath.Join(cwd, d))
patterns = append(patterns, "**/"+d+"/**")
patterns = appendWorkspaceRecursiveDirectoryPatterns(patterns, cwd, d)
}

// Git hooks are always blocked
patterns = append(patterns, filepath.Join(cwd, ".git/hooks"))
patterns = append(patterns, "**/.git/hooks/**")
// Git hooks are always blocked throughout the workspace tree.
patterns = appendWorkspaceRecursiveDirectoryPatterns(patterns, cwd, filepath.Join(".git", "hooks"))

// Git config is conditionally blocked
// Git config is conditionally blocked throughout the workspace tree.
if !allowGitConfig {
patterns = append(patterns, filepath.Join(cwd, ".git/config"))
patterns = append(patterns, "**/.git/config")
patterns = appendWorkspaceRecursiveFilePatterns(patterns, cwd, filepath.Join(".git", "config"))
}

return patterns
Expand Down
103 changes: 84 additions & 19 deletions internal/sandbox/dangerous_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package sandbox
import (
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
)

Expand All @@ -26,6 +26,7 @@ func TestGetDefaultWritePaths(t *testing.T) {

func TestGetMandatoryDenyPatterns(t *testing.T) {
cwd := "/home/user/project"
home, _ := os.UserHomeDir()

tests := []struct {
name string
Expand All @@ -44,6 +45,13 @@ func TestGetMandatoryDenyPatterns(t *testing.T) {
filepath.Join(cwd, ".zshrc"),
filepath.Join(cwd, ".git/hooks"),
filepath.Join(cwd, ".git/config"),
filepath.Join(cwd, "**", ".gitconfig"),
filepath.Join(cwd, "**", ".bashrc"),
filepath.Join(cwd, "**", ".git", "hooks"),
filepath.Join(cwd, "**", ".git", "hooks", "**"),
filepath.Join(cwd, "**", ".git", "config"),
},
shouldNotContain: []string{
"**/.gitconfig",
"**/.bashrc",
"**/.git/hooks/**",
Expand All @@ -57,14 +65,22 @@ func TestGetMandatoryDenyPatterns(t *testing.T) {
shouldContain: []string{
filepath.Join(cwd, ".gitconfig"),
filepath.Join(cwd, ".git/hooks"),
"**/.git/hooks/**",
filepath.Join(cwd, "**", ".git", "hooks"),
filepath.Join(cwd, "**", ".git", "hooks", "**"),
},
shouldNotContain: []string{
filepath.Join(cwd, ".git/config"),
filepath.Join(cwd, "**", ".git", "config"),
},
},
}

if home != "" {
for i := range tests {
tests[i].shouldContain = append(tests[i].shouldContain, filepath.Join(home, ".gitconfig"))
}
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(tt.cwd, tt.allowGitConfig)
Expand All @@ -88,30 +104,40 @@ func TestGetMandatoryDenyPatterns(t *testing.T) {

func TestGetMandatoryDenyPatternsContainsDangerousFiles(t *testing.T) {
cwd := "/test/project"
home, _ := os.UserHomeDir()
patterns := GetMandatoryDenyPatterns(cwd, false)

// Each dangerous file should appear both as a cwd-relative path and as a glob pattern
// Each dangerous file should appear in the workspace root, throughout the
// workspace subtree, and in the user's home directory.
for _, file := range DangerousFiles {
cwdPath := filepath.Join(cwd, file)
globPattern := "**/" + file
workspacePattern := filepath.Join(cwd, "**", file)
homePath := filepath.Join(home, file)

foundCwd := false
foundGlob := false
foundWorkspacePattern := false
foundHome := home == ""

for _, p := range patterns {
if p == cwdPath {
foundCwd = true
}
if p == globPattern {
foundGlob = true
if p == workspacePattern {
foundWorkspacePattern = true
}
if p == homePath {
foundHome = true
}
}

if !foundCwd {
t.Errorf("Missing cwd-relative pattern for dangerous file %q", file)
}
if !foundGlob {
t.Errorf("Missing glob pattern for dangerous file %q", file)
if !foundWorkspacePattern {
t.Errorf("Missing workspace-scoped pattern for dangerous file %q", file)
}
if !foundHome {
t.Errorf("Missing home-directory pattern for dangerous file %q", file)
}
}
}
Expand All @@ -122,25 +148,33 @@ func TestGetMandatoryDenyPatternsContainsDangerousDirectories(t *testing.T) {

for _, dir := range DangerousDirectories {
cwdPath := filepath.Join(cwd, dir)
globPattern := "**/" + dir + "/**"
workspacePattern := filepath.Join(cwd, "**", dir)
descendantPattern := filepath.Join(cwd, "**", dir, "**")

foundCwd := false
foundGlob := false
foundWorkspacePattern := false
foundDescendantPattern := false

for _, p := range patterns {
if p == cwdPath {
foundCwd = true
}
if p == globPattern {
foundGlob = true
if p == workspacePattern {
foundWorkspacePattern = true
}
if p == descendantPattern {
foundDescendantPattern = true
}
}

if !foundCwd {
t.Errorf("Missing cwd-relative pattern for dangerous directory %q", dir)
}
if !foundGlob {
t.Errorf("Missing glob pattern for dangerous directory %q", dir)
if !foundWorkspacePattern {
t.Errorf("Missing workspace-scoped directory pattern for dangerous directory %q", dir)
}
if !foundDescendantPattern {
t.Errorf("Missing descendant pattern for dangerous directory %q", dir)
}
}
}
Expand All @@ -153,23 +187,54 @@ func TestGetMandatoryDenyPatternsGitHooksAlwaysBlocked(t *testing.T) {
patterns := GetMandatoryDenyPatterns(cwd, allowGitConfig)

foundHooksPath := false
foundHooksGlob := false
foundHooksDirPattern := false
foundHooksDescPattern := false

for _, p := range patterns {
if p == filepath.Join(cwd, ".git/hooks") {
foundHooksPath = true
}
if strings.Contains(p, ".git/hooks") && strings.HasPrefix(p, "**") {
foundHooksGlob = true
if p == filepath.Join(cwd, "**", ".git", "hooks") {
foundHooksDirPattern = true
}
if p == filepath.Join(cwd, "**", ".git", "hooks", "**") {
foundHooksDescPattern = true
}
}

if !foundHooksPath || !foundHooksGlob {
if !foundHooksPath || !foundHooksDirPattern || !foundHooksDescPattern {
t.Errorf("Git hooks should always be blocked (allowGitConfig=%v)", allowGitConfig)
}
}
}

func TestGetMandatoryDenyPatternsAreWorkspaceScoped(t *testing.T) {
cwd := "/workspace/project"
patterns := GetMandatoryDenyPatterns(cwd, false)

unscoped := []string{
"**/.gitconfig",
"**/.idea",
"**/.idea/**",
"**/.git/hooks",
"**/.git/hooks/**",
}
for _, pattern := range unscoped {
if slices.Contains(patterns, pattern) {
t.Fatalf("GetMandatoryDenyPatterns() should not contain unscoped pattern %q", pattern)
}
}

ideaDescPattern := filepath.Join(cwd, "**", ".idea", "**")
regex := regexp.MustCompile(GlobToRegex(ideaDescPattern))
if !regex.MatchString(filepath.Join(cwd, "pkg", ".idea", "workspace.xml")) {
t.Fatalf("workspace-scoped pattern %q should match nested workspace path", ideaDescPattern)
}
if regex.MatchString("/Users/jy/.pnpm-store/v3/files/pkg/.idea/workspace.xml") {
t.Fatalf("workspace-scoped pattern %q should not match outside-workspace path", ideaDescPattern)
}
}

func TestFindDangerousFiles(t *testing.T) {
// Create a temp directory tree with dangerous files at various depths:
// cwd/.bashrc (depth 0 - directly in cwd)
Expand Down
3 changes: 2 additions & 1 deletion internal/sandbox/integration_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ func runUnderLinuxSandboxDirect(t *testing.T, cfg *config.Config, command string
UseSeccomp: false,
UseEBPF: false,
ShellMode: ShellModeDefault,
WorkDir: workDir,
})
if err != nil {
return &SandboxTestResult{
Expand Down Expand Up @@ -756,7 +757,7 @@ func TestLinux_ExposedPortAllowsHostReachability(t *testing.T) {
}

command := "python3 -m http.server " + strconv.Itoa(port) + " --bind 127.0.0.1"
wrappedCmd, err := manager.WrapCommand(command)
wrappedCmd, err := manager.WrapCommandInDir(command, workspace)
if err != nil {
return fmt.Errorf("wrap command: %w", err)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/sandbox/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ func runUnderSandbox(t *testing.T, cfg *config.Config, command string, workDir s
return &SandboxTestResult{Error: err}
}

wrappedCmd, err := manager.WrapCommand(command)
wrappedCmd, err := manager.WrapCommandInDir(command, workDir)
if err != nil {
// Command was blocked before execution
return &SandboxTestResult{
Expand Down Expand Up @@ -215,7 +215,7 @@ func runUnderSandboxWithTimeout(t *testing.T, cfg *config.Config, command string
return &SandboxTestResult{Error: err}
}

wrappedCmd, err := manager.WrapCommand(command)
wrappedCmd, err := manager.WrapCommandInDir(command, workDir)
if err != nil {
return &SandboxTestResult{
ExitCode: 1,
Expand Down
7 changes: 5 additions & 2 deletions internal/sandbox/linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ type LinuxSandboxOptions struct {
ShellMode string
// Whether to run shell as login shell.
ShellLogin bool
// Working directory the sandbox policy should treat as the workspace root.
WorkDir string
}

const (
Expand Down Expand Up @@ -432,14 +434,15 @@ func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, r
}

// WrapCommandLinuxWithShell wraps a command with configurable shell selection.
func WrapCommandLinuxWithShell(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool, shellMode string, shellLogin bool) (string, error) {
func WrapCommandLinuxWithShell(cfg *config.Config, command string, workingDir string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool, shellMode string, shellLogin bool) (string, error) {
return WrapCommandLinuxWithOptions(cfg, command, bridge, reverseBridge, LinuxSandboxOptions{
UseLandlock: true,
UseSeccomp: true,
UseEBPF: true,
Debug: debug,
ShellMode: shellMode,
ShellLogin: shellLogin,
WorkDir: workingDir,
})
}

Expand Down Expand Up @@ -799,7 +802,7 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
return "", err
}

cwd, _ := os.Getwd()
cwd := ResolveSandboxWorkingDir(opts.WorkDir)
features := DetectLinuxFeatures()
runtimeExecPolicy := effectiveRuntimeExecPolicy(cfg)
useArgvRuntimeExecPolicy := runtimeExecPolicy == config.RuntimeExecPolicyArgv
Expand Down
3 changes: 2 additions & 1 deletion internal/sandbox/linux_stub.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type LinuxSandboxOptions struct {
Debug bool
ShellMode string
ShellLogin bool
WorkDir string
}

// NewLinuxBridge returns an error on non-Linux platforms.
Expand All @@ -53,7 +54,7 @@ func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, r
}

// WrapCommandLinuxWithShell returns an error on non-Linux platforms.
func WrapCommandLinuxWithShell(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool, shellMode string, shellLogin bool) (string, error) {
func WrapCommandLinuxWithShell(cfg *config.Config, command string, workingDir string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool, shellMode string, shellLogin bool) (string, error) {
return "", fmt.Errorf("Linux sandbox not available on this platform")
}

Expand Down
Loading
Loading