diff --git a/internal/sandbox/dangerous.go b/internal/sandbox/dangerous.go index e4007b4..a428366 100644 --- a/internal/sandbox/dangerous.go +++ b/internal/sandbox/dangerous.go @@ -4,6 +4,7 @@ import ( "io/fs" "os" "path/filepath" + "slices" "strings" ) @@ -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 diff --git a/internal/sandbox/dangerous_test.go b/internal/sandbox/dangerous_test.go index 144cdbb..656aee6 100644 --- a/internal/sandbox/dangerous_test.go +++ b/internal/sandbox/dangerous_test.go @@ -3,8 +3,8 @@ package sandbox import ( "os" "path/filepath" + "regexp" "slices" - "strings" "testing" ) @@ -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 @@ -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/**", @@ -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) @@ -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) } } } @@ -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) } } } @@ -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) diff --git a/internal/sandbox/integration_linux_test.go b/internal/sandbox/integration_linux_test.go index 3d68ddf..4f71ae0 100644 --- a/internal/sandbox/integration_linux_test.go +++ b/internal/sandbox/integration_linux_test.go @@ -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{ @@ -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) } diff --git a/internal/sandbox/integration_test.go b/internal/sandbox/integration_test.go index cc086aa3..633be18 100644 --- a/internal/sandbox/integration_test.go +++ b/internal/sandbox/integration_test.go @@ -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{ @@ -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, diff --git a/internal/sandbox/linux.go b/internal/sandbox/linux.go index e1d5d87..41d053f 100644 --- a/internal/sandbox/linux.go +++ b/internal/sandbox/linux.go @@ -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 ( @@ -432,7 +434,7 @@ 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, @@ -440,6 +442,7 @@ func WrapCommandLinuxWithShell(cfg *config.Config, command string, bridge *Linux Debug: debug, ShellMode: shellMode, ShellLogin: shellLogin, + WorkDir: workingDir, }) } @@ -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 diff --git a/internal/sandbox/linux_stub.go b/internal/sandbox/linux_stub.go index 90ce3cb..6e4279a 100644 --- a/internal/sandbox/linux_stub.go +++ b/internal/sandbox/linux_stub.go @@ -29,6 +29,7 @@ type LinuxSandboxOptions struct { Debug bool ShellMode string ShellLogin bool + WorkDir string } // NewLinuxBridge returns an error on non-Linux platforms. @@ -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") } diff --git a/internal/sandbox/macos.go b/internal/sandbox/macos.go index 5f95957..774fb25 100644 --- a/internal/sandbox/macos.go +++ b/internal/sandbox/macos.go @@ -27,6 +27,7 @@ func generateSessionSuffix() string { // MacOSSandboxParams contains parameters for macOS sandbox wrapping. type MacOSSandboxParams struct { Command string + WorkingDirectory string NeedsNetworkRestriction bool HTTPProxyPort int SOCKSProxyPort int @@ -274,7 +275,7 @@ func generateReadRules(defaultDenyRead, strictDenyRead bool, allowPaths, denyPat } // generateWriteRules generates filesystem write rules for the sandbox profile. -func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, logTag string) []string { +func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, workingDir, logTag string) []string { builder := newSeatbeltRuleBuilder() // Allow TMPDIR parent on macOS @@ -308,7 +309,7 @@ func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, log } // Combine user-specified and mandatory deny patterns - cwd, _ := os.Getwd() + cwd := ResolveSandboxWorkingDir(workingDir) mandatoryDeny := GetMandatoryDenyPatterns(cwd, allowGitConfig) allDenyPaths := make([]string, 0, len(denyPaths)+len(mandatoryDeny)) allDenyPaths = append(allDenyPaths, denyPaths...) @@ -622,7 +623,7 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string { // Write rules profile.WriteString("; File write\n") - for _, rule := range generateWriteRules(params.WriteAllowPaths, params.WriteDenyPaths, params.AllowGitConfig, logTag) { + for _, rule := range generateWriteRules(params.WriteAllowPaths, params.WriteDenyPaths, params.AllowGitConfig, params.WorkingDirectory, logTag) { profile.WriteString(rule + "\n") } @@ -646,7 +647,7 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string { } // WrapCommandMacOS wraps a command with macOS sandbox restrictions. -func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort int, exposedPorts []int, debug bool, shellMode string, shellLogin bool) (string, error) { +func WrapCommandMacOS(cfg *config.Config, command string, workingDir string, httpPort, socksPort int, exposedPorts []int, debug bool, shellMode string, shellLogin bool) (string, error) { // In wildcard mode ("*"), still run the proxy for apps that respect // HTTP_PROXY, but allow direct connections for apps that don't. hasWildcardAllow := hasWildcardAllowedDomain(cfg) @@ -697,6 +698,7 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in params := MacOSSandboxParams{ Command: command, + WorkingDirectory: ResolveSandboxWorkingDir(workingDir), NeedsNetworkRestriction: needsNetworkRestriction, HTTPProxyPort: httpPort, SOCKSProxyPort: socksPort, diff --git a/internal/sandbox/macos_test.go b/internal/sandbox/macos_test.go index 8f22f8f..5930451 100644 --- a/internal/sandbox/macos_test.go +++ b/internal/sandbox/macos_test.go @@ -1,6 +1,8 @@ package sandbox import ( + "os" + "path/filepath" "regexp" "strings" "testing" @@ -480,7 +482,7 @@ func TestGenerateWriteRules_DeduplicatesSharedAncestorMoveRules(t *testing.T) { rules := generateWriteRules(nil, []string{ "/fence-issue-74-home/.pypirc", "/fence-issue-74-home/.netrc", - }, false, logTag) + }, false, "", logTag) tests := []struct { name string @@ -524,7 +526,7 @@ func TestGenerateWriteRules_DeduplicatesExactDuplicateRules(t *testing.T) { rules := generateWriteRules(nil, []string{ "/fence-issue-74-dup/.pypirc", "/fence-issue-74-dup/.pypirc", - }, false, logTag) + }, false, "", logTag) tests := []struct { name string @@ -562,3 +564,31 @@ func TestGenerateWriteRules_DeduplicatesExactDuplicateRules(t *testing.T) { } } } + +func TestGenerateWriteRules_UsesWorkspaceScopedMandatoryDenyPatterns(t *testing.T) { + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + + workspace := t.TempDir() + if err := os.Chdir(workspace); err != nil { + t.Fatalf("failed to chdir to workspace: %v", err) + } + defer func() { + _ = os.Chdir(originalWD) + }() + + rules := generateWriteRules([]string{workspace}, nil, false, workspace, "test-log") + joinedRules := strings.Join(rules, "\n") + + scopedRegex := escapePath(GlobToRegex(filepath.Join(ResolveSandboxWorkingDir(workspace), "**", ".idea", "**"))) + if !strings.Contains(joinedRules, "(regex "+scopedRegex+")") { + t.Fatalf("expected scoped .idea deny regex in rules, got:\n%s", joinedRules) + } + + unscopedRegex := escapePath(GlobToRegex("**/.idea/**")) + if strings.Contains(joinedRules, "(regex "+unscopedRegex+")") { + t.Fatalf("unexpected unscoped .idea deny regex in rules:\n%s", joinedRules) + } +} diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index ba5c323..333d81d 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -112,6 +112,12 @@ func (m *Manager) Initialize() error { // WrapCommand wraps a command with sandbox restrictions. // Returns an error if the command is blocked by policy. func (m *Manager) WrapCommand(command string) (string, error) { + return m.WrapCommandInDir(command, "") +} + +// WrapCommandInDir wraps a command with sandbox restrictions using the given +// working directory as the workspace root for mandatory path protection. +func (m *Manager) WrapCommandInDir(command string, workingDir string) (string, error) { if !m.initialized { if err := m.Initialize(); err != nil { return "", err @@ -127,11 +133,13 @@ func (m *Manager) WrapCommand(command string) (string, error) { if effectiveRuntimeExecPolicy(m.config) == config.RuntimeExecPolicyArgv && plat != platform.Linux { return "", fmt.Errorf("command.runtimeExecPolicy=%q is only supported on Linux", config.RuntimeExecPolicyArgv) } + + workingDir = ResolveSandboxWorkingDir(workingDir) switch plat { case platform.MacOS: - return WrapCommandMacOS(m.config, command, m.httpPort, m.socksPort, m.exposedPorts, m.debug, m.shellMode, m.shellLogin) + return WrapCommandMacOS(m.config, command, workingDir, m.httpPort, m.socksPort, m.exposedPorts, m.debug, m.shellMode, m.shellLogin) case platform.Linux: - return WrapCommandLinuxWithShell(m.config, command, m.linuxBridge, m.reverseBridge, m.debug, m.shellMode, m.shellLogin) + return WrapCommandLinuxWithShell(m.config, command, workingDir, m.linuxBridge, m.reverseBridge, m.debug, m.shellMode, m.shellLogin) default: return "", fmt.Errorf("unsupported platform: %s", plat) } diff --git a/internal/sandbox/utils.go b/internal/sandbox/utils.go index 4741946..2c4e188 100644 --- a/internal/sandbox/utils.go +++ b/internal/sandbox/utils.go @@ -53,6 +53,25 @@ func NormalizePath(pathPattern string) string { return normalized } +// ResolveSandboxWorkingDir returns the directory that sandbox policy should use +// as its workspace root. When a caller provides an explicit working directory, +// prefer that over the process cwd so wrapping matches the command's exec dir. +func ResolveSandboxWorkingDir(workingDir string) string { + if workingDir == "" { + cwd, err := os.Getwd() + if err == nil { + return cwd + } + return "." + } + + if abs, err := filepath.Abs(workingDir); err == nil { + return filepath.Clean(abs) + } + + return filepath.Clean(workingDir) +} + // GenerateProxyEnvVars creates environment variables for proxy configuration. func GenerateProxyEnvVars(httpPort, socksPort int) []string { tmpDir := ensureSandboxTMPDIR()