diff --git a/docs/sandbox.md b/docs/sandbox.md
index cbfcc8e..a26ed8e 100644
--- a/docs/sandbox.md
+++ b/docs/sandbox.md
@@ -107,14 +107,31 @@ Next time you run `pmg pnpm install`, the custom policy template will be used in
### Platform-Specific Limitations
-**Linux (Bubblewrap)**:
-- **Filesystem permissions are coarse-grained**: Linux sandbox uses bind mounts for filesystem isolation. When you specify a glob pattern like `${CWD}/*.txt`, the pattern is expanded to matching files at policy translation time, but Bubblewrap mounts entire directories rather than individual files. This means filesystem access control is at the directory level, not file-pattern level.
-- **Example**: A policy allowing `${CWD}/node_modules/**` will mount the entire `node_modules` directory tree, not selectively filter files by pattern.
-- **Network filtering**: All-or-nothing network isolation (via `--unshare-net`). Host-specific filtering is not enforced in the initial implementation.
+
+Linux (Bubblewrap)
-**macOS (Seatbelt)**:
-- **Network filtering is limited**: Seatbelt supports network rules in policies, but fine-grained host:port filtering is not consistently enforced across all connection types.
-- **Filesystem permissions are precise**: Uses regex-based pattern matching, allowing file-level access control.
+**Filesystem permissions are coarse-grained**: [Bubblewrap](https://github.com/containers/bubblewrap) uses bind mounts for filesystem isolation.
+
+To prevent `Argument list too long` errors with large directory trees, PMG automatically uses
+coarse-grained fallback strategies when glob patterns match many files.
+
+**Fallback Behavior:**
+
+- **Small patterns** (< 100 matches): Individual files are mounted (fine-grained, most precise)
+- **Large patterns** (> 100 matches): Parent directory is mounted (coarse-grained, scalable)
+- **Threshold**: 100 paths per pattern triggers coarse-grained fallback
+
+**Network filtering**: All-or-nothing network isolation (via `--unshare-net`). Host-specific
+filtering is not enforced.
+
+
+
+
+macOS (Seatbelt)
+
+**Network filtering is limited**: Seatbelt supports network rules in policies, but fine-grained `host:port` filtering is not enforced.
+
+
## Concepts
diff --git a/sandbox/platform/bubblewrap_config_linux.go b/sandbox/platform/bubblewrap_config_linux.go
index 0e43f99..2c32ac8 100644
--- a/sandbox/platform/bubblewrap_config_linux.go
+++ b/sandbox/platform/bubblewrap_config_linux.go
@@ -34,6 +34,15 @@ type bubblewrapConfig struct {
// Prevents memory exhaustion from patterns matching huge directory trees.
maxGlobPaths int
+ // Glob fallback threshold for coarse-grained binding.
+ // When glob expansion yields more than this many paths, fallback to binding
+ // the parent directory instead of individual files for scalability.
+ globFallbackThreshold int
+
+ // Total argument limit for bwrap command.
+ // Warns when total arguments exceed this limit (approaching ARG_MAX).
+ totalArgsLimit int
+
// Whether to unshare the network namespace by default if policy has no network rules.
// When true and no network rules specified, completely isolates network access.
unshareNetworkByDefault bool
@@ -118,8 +127,10 @@ func newDefaultBubblewrapConfig() *bubblewrapConfig {
// Glob expansion limits
// Conservative defaults to prevent DoS via huge glob patterns
- maxGlobDepth: 5, // Scan up to 5 directory levels
- maxGlobPaths: 10000, // Maximum 10k paths per glob pattern
+ maxGlobDepth: 5, // Scan up to 5 directory levels
+ maxGlobPaths: 10000, // Maximum 10k paths per glob pattern
+ globFallbackThreshold: 100, // Fallback to parent dir above 100 paths
+ totalArgsLimit: 8000, // Total bwrap argument safety limit
// Network isolation (default: isolate network if no rules)
unshareNetworkByDefault: true,
diff --git a/sandbox/platform/bubblewrap_translator_linux.go b/sandbox/platform/bubblewrap_translator_linux.go
index e172c30..bb32b83 100644
--- a/sandbox/platform/bubblewrap_translator_linux.go
+++ b/sandbox/platform/bubblewrap_translator_linux.go
@@ -45,6 +45,7 @@ func (t *bubblewrapPolicyTranslator) translate(policy *sandbox.SandboxPolicy) ([
if err != nil {
return nil, fmt.Errorf("failed to add essential system permissions: %w", err)
}
+
args = append(args, systemArgs...)
// 2. Add isolation namespaces
@@ -56,6 +57,7 @@ func (t *bubblewrapPolicyTranslator) translate(policy *sandbox.SandboxPolicy) ([
if err != nil {
return nil, fmt.Errorf("failed to translate filesystem rules: %w", err)
}
+
args = append(args, filesystemArgs...)
// 4. Add PTY support if needed
@@ -68,7 +70,14 @@ func (t *bubblewrapPolicyTranslator) translate(policy *sandbox.SandboxPolicy) ([
tmpdirArgs := t.addTmpdirSupport()
args = append(args, tmpdirArgs...)
- log.Debugf("Translated policy '%s' to %d bwrap arguments", policy.Name, len(args))
+ // 6. Check total argument limit and log warning if exceeded
+ // Do not fail, let bwrap fail naturally if it does.
+ if len(args) > t.config.totalArgsLimit {
+ log.Warnf("Total bwrap arguments (%d) exceeds safety limit (%d), sandbox may fail with 'Argument list too long' error",
+ len(args), t.config.totalArgsLimit)
+ }
+
+ log.Debugf("Translated policy '%s' to %d bwrap arguments (limit: %d)", policy.Name, len(args), t.config.totalArgsLimit)
return args, nil
}
@@ -80,7 +89,6 @@ func (t *bubblewrapPolicyTranslator) addEssentialSystemPermissions() ([]string,
// Add essential system paths (read-only)
for _, path := range t.config.getEssentialSystemPaths() {
- // Use --ro-bind-try which doesn't fail if path doesn't exist
args = append(args, "--ro-bind-try", path, path)
}
@@ -231,17 +239,28 @@ func (t *bubblewrapPolicyTranslator) processReadRule(path string, boundPaths map
// Check if path contains glob pattern
if util.ContainsGlob(path) {
- // Expand glob pattern to concrete paths
- paths, err := t.expandGlobPattern(path, t.config.maxGlobDepth, t.config.maxGlobPaths)
+ // Expand glob pattern to concrete paths with fallback detection
+ paths, useFallback, err := t.expandGlobPattern(path, t.config.maxGlobDepth, t.config.maxGlobPaths)
if err != nil {
return nil, fmt.Errorf("failed to expand glob pattern: %w", err)
}
- // Create read-only bind for each expanded path
- for _, p := range paths {
- if !boundPaths[p] {
- args = append(args, "--ro-bind-try", p, p)
- boundPaths[p] = true
+ if useFallback {
+ // Coarse-grained: bind parent directory
+ for _, parentDir := range paths {
+ if !boundPaths[parentDir] {
+ args = append(args, "--ro-bind-try", parentDir, parentDir)
+ boundPaths[parentDir] = true
+ log.Debugf("Coarse-grained fallback: bound parent directory '%s' (read-only)", parentDir)
+ }
+ }
+ } else {
+ // Fine-grained: bind individual paths
+ for _, p := range paths {
+ if !boundPaths[p] {
+ args = append(args, "--ro-bind-try", p, p)
+ boundPaths[p] = true
+ }
}
}
} else {
@@ -261,22 +280,36 @@ func (t *bubblewrapPolicyTranslator) processWriteRule(path string, boundPaths ma
// Check if path contains glob pattern
if util.ContainsGlob(path) {
- // Expand glob pattern to concrete paths
- paths, err := t.expandGlobPattern(path, t.config.maxGlobDepth, t.config.maxGlobPaths)
+ // Expand glob pattern to concrete paths with fallback detection
+ paths, useFallback, err := t.expandGlobPattern(path, t.config.maxGlobDepth, t.config.maxGlobPaths)
if err != nil {
return nil, fmt.Errorf("failed to expand glob pattern: %w", err)
}
- // Create read-write bind for each expanded path
- for _, p := range paths {
- if !boundPaths[p] {
- args = append(args, "--bind-try", p, p)
- boundPaths[p] = true
- } else {
- // Path already bound as read-only, upgrade to read-write
- // This is a limitation of the simple approach - we'd need to track
- // and replace the previous bind. For now, log warning.
- log.Warnf("Path '%s' already bound, cannot upgrade to read-write", p)
+ if useFallback {
+ // Coarse-grained: bind parent directory
+ for _, parentDir := range paths {
+ if !boundPaths[parentDir] {
+ args = append(args, "--bind-try", parentDir, parentDir)
+ boundPaths[parentDir] = true
+ log.Debugf("Coarse-grained fallback: bound parent directory '%s' (read-write)", parentDir)
+ } else {
+ // Path already bound, skip (likely already bound as read-only from essential paths)
+ log.Debugf("Parent directory '%s' already bound, skipping duplicate bind", parentDir)
+ }
+ }
+ } else {
+ // Fine-grained: bind individual paths
+ for _, p := range paths {
+ if !boundPaths[p] {
+ args = append(args, "--bind-try", p, p)
+ boundPaths[p] = true
+ } else {
+ // Path already bound as read-only, upgrade to read-write
+ // This is a limitation of the simple approach - we'd need to track
+ // and replace the previous bind. For now, log warning.
+ log.Warnf("Path '%s' already bound, cannot upgrade to read-write", p)
+ }
}
}
} else {
@@ -298,7 +331,9 @@ func (t *bubblewrapPolicyTranslator) processDenyRule(path string) ([]string, err
// For glob patterns, expand and deny each path
if util.ContainsGlob(path) {
// For deny rules, we scan for existing files matching the pattern
- paths, err := t.expandGlobPattern(path, t.config.mandatoryDenyScanDepth, t.config.maxGlobPaths)
+ // Note: For deny rules, we ignore the fallback indicator since we want to
+ // deny all matched paths individually for maximum security
+ paths, _, err := t.expandGlobPattern(path, t.config.mandatoryDenyScanDepth, t.config.maxGlobPaths)
if err != nil {
// If glob expansion fails, it's not critical for deny rules
return args, nil
@@ -350,25 +385,49 @@ func (t *bubblewrapPolicyTranslator) findFirstNonExistentPath(path string) strin
// expandGlobPattern expands a glob pattern to a list of concrete paths.
// Implements depth limiting and path count limiting to prevent DoS.
-func (t *bubblewrapPolicyTranslator) expandGlobPattern(pattern string, maxDepth int, maxPaths int) ([]string, error) {
+// Returns (paths, useFallback, error) where useFallback indicates if
+// coarse-grained parent directory fallback should be used.
+func (t *bubblewrapPolicyTranslator) expandGlobPattern(pattern string, maxDepth int, maxPaths int) ([]string, bool, error) {
// Handle ** globstar patterns specially
if strings.Contains(pattern, "**") {
- return t.expandGlobstarPattern(pattern, maxDepth, maxPaths)
+ paths, err := t.expandGlobstarPattern(pattern, maxDepth, maxPaths)
+ if err != nil {
+ return nil, false, err
+ }
+
+ // Check if we should use fallback
+ if len(paths) > t.config.globFallbackThreshold {
+ log.Warnf("Glob pattern '%s' matched %d paths (threshold: %d), using coarse-grained parent directory fallback for scalability",
+ pattern, len(paths), t.config.globFallbackThreshold)
+
+ parentDir := t.extractParentDir(pattern)
+ return []string{parentDir}, true, nil
+ }
+
+ return paths, false, nil
}
// Use filepath.Glob for simple patterns (*, ?, [])
matches, err := filepath.Glob(pattern)
if err != nil {
- return nil, fmt.Errorf("glob expansion failed: %w", err)
+ return nil, false, fmt.Errorf("glob expansion failed: %w", err)
}
- // Limit number of matches
+ // Check fallback threshold before applying maxPaths limit
+ if len(matches) > t.config.globFallbackThreshold {
+ log.Warnf("Glob pattern '%s' matched %d paths (threshold: %d), using coarse-grained parent directory fallback for scalability",
+ pattern, len(matches), t.config.globFallbackThreshold)
+ parentDir := t.extractParentDir(pattern)
+ return []string{parentDir}, true, nil
+ }
+
+ // Limit number of matches (shouldn't happen if fallback threshold < maxPaths)
if len(matches) > maxPaths {
log.Warnf("Glob pattern '%s' matched %d paths, limiting to %d", pattern, len(matches), maxPaths)
matches = matches[:maxPaths]
}
- return matches, nil
+ return matches, false, nil
}
// expandGlobstarPattern expands patterns containing ** (recursive glob).
@@ -451,6 +510,40 @@ func (t *bubblewrapPolicyTranslator) walkWithDepthLimit(root string, suffix stri
return err
}
+// extractParentDir extracts the parent directory from a glob pattern.
+// This is used for coarse-grained fallback when glob expansion yields too many paths.
+//
+// Examples:
+// - ${CWD}/node_modules/** → ${CWD}/node_modules
+// - ${HOME}/.cache/pnpm/** → ${HOME}/.cache/pnpm
+// - /tmp/*.txt → /tmp
+// - /usr/lib/**/*.so → /usr/lib
+// - ${CWD}/package.json.* → ${CWD}
+func (t *bubblewrapPolicyTranslator) extractParentDir(pattern string) string {
+ // Remove trailing /** or /*
+ pattern = strings.TrimSuffix(pattern, "/**")
+ pattern = strings.TrimSuffix(pattern, "/*")
+
+ // Remove any remaining glob characters and find the parent directory
+ idx := strings.IndexAny(pattern, "*?[")
+ if idx >= 0 {
+ // Glob found - truncate at glob character and get the directory
+ pattern = pattern[:idx]
+ // Get the directory containing the file/pattern
+ pattern = filepath.Dir(pattern)
+ }
+
+ // Clean up trailing separator
+ pattern = strings.TrimSuffix(pattern, string(filepath.Separator))
+
+ // If pattern is now empty or just a separator, default to current directory
+ if pattern == "" || pattern == string(filepath.Separator) {
+ return "."
+ }
+
+ return pattern
+}
+
// addPTYSupport adds arguments for pseudo-terminal support.
// Required for interactive package manager commands.
func (t *bubblewrapPolicyTranslator) addPTYSupport() []string {
diff --git a/sandbox/platform/bubblewrap_translator_linux_test.go b/sandbox/platform/bubblewrap_translator_linux_test.go
index 680e02a..5900cfb 100644
--- a/sandbox/platform/bubblewrap_translator_linux_test.go
+++ b/sandbox/platform/bubblewrap_translator_linux_test.go
@@ -4,6 +4,7 @@
package platform
import (
+ "fmt"
"os"
"path/filepath"
"testing"
@@ -496,8 +497,8 @@ func TestBubblewrapTranslatorProcessDenyRule(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyWrite: []string{
- testFile, // Existing file
- nonExistentPath, // Non-existent file
+ testFile, // Existing file
+ nonExistentPath, // Non-existent file
},
},
}
@@ -659,11 +660,219 @@ func TestFindFirstNonExistentPath(t *testing.T) {
}
}
+// TestGlobFallbackThreshold verifies coarse-grained fallback behavior when patterns match too many paths
+func TestGlobFallbackThreshold(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Create 150 files (exceeds threshold of 100)
+ for i := 0; i < 150; i++ {
+ filePath := filepath.Join(tmpDir, fmt.Sprintf("file%d.txt", i))
+ require.NoError(t, os.WriteFile(filePath, []byte("test"), 0644))
+ }
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+
+ policy := &sandbox.SandboxPolicy{
+ Name: "test-fallback",
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{tmpDir + "/*.txt"},
+ },
+ }
+
+ args, err := translator.translate(policy)
+ require.NoError(t, err)
+
+ argsStr := argSliceToString(args)
+
+ // Should bind parent directory (tmpDir), not individual files
+ assert.Contains(t, argsStr, tmpDir)
+
+ // Should NOT contain individual file paths (fallback to parent dir)
+ assert.NotContains(t, argsStr, "file1.txt")
+ assert.NotContains(t, argsStr, "file50.txt")
+ assert.NotContains(t, argsStr, "file100.txt")
+
+ // Verify total argument count is reasonable (coarse-grained fallback should prevent explosion)
+ assert.Less(t, len(args), 300, "Coarse-grained fallback should prevent argument explosion")
+}
+
+// TestGlobFallbackThresholdGlobstar tests fallback with ** globstar patterns
+func TestGlobFallbackThresholdGlobstar(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Create deep directory structure with 200 files (exceeds threshold)
+ for i := 0; i < 10; i++ {
+ subDir := filepath.Join(tmpDir, fmt.Sprintf("dir%d", i))
+ require.NoError(t, os.MkdirAll(subDir, 0755))
+ for j := 0; j < 20; j++ {
+ filePath := filepath.Join(subDir, fmt.Sprintf("file%d.txt", j))
+ require.NoError(t, os.WriteFile(filePath, []byte("test"), 0644))
+ }
+ }
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+
+ policy := &sandbox.SandboxPolicy{
+ Name: "test-fallback-globstar",
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowWrite: []string{tmpDir + "/**"},
+ },
+ }
+
+ args, err := translator.translate(policy)
+ require.NoError(t, err)
+
+ argsStr := argSliceToString(args)
+
+ // Should bind parent directory (tmpDir)
+ assert.Contains(t, argsStr, tmpDir)
+
+ // Should NOT contain individual subdirectory paths (fallback to parent)
+ assert.NotContains(t, argsStr, "dir1")
+ assert.NotContains(t, argsStr, "dir5")
+
+ // Verify total argument count is reasonable
+ assert.Less(t, len(args), 300, "Coarse-grained fallback should prevent argument explosion")
+}
+
+// TestGlobNoFallbackSmallPattern tests that small patterns don't trigger fallback
+func TestGlobNoFallbackSmallPattern(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Create only 10 files (below threshold)
+ for i := 0; i < 10; i++ {
+ filePath := filepath.Join(tmpDir, fmt.Sprintf("file%d.txt", i))
+ require.NoError(t, os.WriteFile(filePath, []byte("test"), 0644))
+ }
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+
+ policy := &sandbox.SandboxPolicy{
+ Name: "test-no-fallback",
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{tmpDir + "/*.txt"},
+ },
+ }
+
+ args, err := translator.translate(policy)
+ require.NoError(t, err)
+
+ argsStr := argSliceToString(args)
+
+ // Should bind individual files (no fallback)
+ assert.Contains(t, argsStr, "file0.txt")
+ assert.Contains(t, argsStr, "file5.txt")
+}
+
+// TestTotalArgsLimit verifies global argument limit warning
+func TestTotalArgsLimit(t *testing.T) {
+ // Create policy with many patterns that would exceed limit
+ policy := &sandbox.SandboxPolicy{
+ Name: "test-args-limit",
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: make([]string, 1000), // 1000 patterns
+ },
+ }
+
+ // Fill with literal paths to avoid glob expansion
+ for i := 0; i < 1000; i++ {
+ policy.Filesystem.AllowRead[i] = fmt.Sprintf("/tmp/path%d", i)
+ }
+
+ config := newDefaultBubblewrapConfig()
+ config.totalArgsLimit = 500 // Set low for testing
+ translator := newBubblewrapPolicyTranslator(config)
+
+ args, err := translator.translate(policy)
+ require.NoError(t, err) // Should not error, just warn
+
+ // Verify args were generated despite exceeding limit
+ assert.Greater(t, len(args), config.totalArgsLimit)
+}
+
+// TestExtractParentDir tests the extractParentDir helper function
+func TestExtractParentDir(t *testing.T) {
+ cases := []struct {
+ name string
+ pattern string
+ expected string
+ }{
+ {
+ name: "double star pattern",
+ pattern: "/home/user/node_modules/**",
+ expected: "/home/user/node_modules",
+ },
+ {
+ name: "single star pattern",
+ pattern: "/tmp/*.txt",
+ expected: "/tmp",
+ },
+ {
+ name: "middle glob",
+ pattern: "/usr/lib/*.so",
+ expected: "/usr/lib",
+ },
+ {
+ name: "complex glob",
+ pattern: "/home/user/.cache/**/*.log",
+ expected: "/home/user/.cache",
+ },
+ {
+ name: "file-level glob with dot",
+ pattern: "/home/user/project/package.json.*",
+ expected: "/home/user/project",
+ },
+ {
+ name: "file-level glob in CWD",
+ pattern: "/home/user/project/*.lock",
+ expected: "/home/user/project",
+ },
+ {
+ name: "question mark glob",
+ pattern: "/tmp/file?.txt",
+ expected: "/tmp",
+ },
+ {
+ name: "bracket glob",
+ pattern: "/usr/lib/lib[abc].so",
+ expected: "/usr/lib",
+ },
+ {
+ name: "no glob",
+ pattern: "/home/user/file.txt",
+ expected: "/home/user/file.txt",
+ },
+ {
+ name: "trailing double star",
+ pattern: "/home/user/cache/**",
+ expected: "/home/user/cache",
+ },
+ {
+ name: "trailing single star",
+ pattern: "/var/log/*",
+ expected: "/var/log",
+ },
+ }
+
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ result := translator.extractParentDir(tt.pattern)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
// Helper function to convert arg slice to string for easier assertion
func argSliceToString(args []string) string {
result := ""
for _, arg := range args {
result += arg + " "
}
+
return result
}