diff --git a/sandbox/platform/bubblewrap_translator_linux.go b/sandbox/platform/bubblewrap_translator_linux.go index 28d073b..f5fc19d 100644 --- a/sandbox/platform/bubblewrap_translator_linux.go +++ b/sandbox/platform/bubblewrap_translator_linux.go @@ -529,8 +529,19 @@ func (t *bubblewrapPolicyTranslator) processWriteRule(path string, boundPaths ma // Check if path contains glob pattern if util.ContainsGlob(path) { - // Check if the base directory is already bound (e.g., /tmp already bound, skip /tmp/**) baseDir := t.extractParentDir(path) + + // Globstar write rules always bind the parent directory read-write. Per-path + // binds interact badly with earlier read-only parent mounts (e.g. ${CWD}/**) + // and miss files beyond maxGlobDepth — see https://github.com/safedep/pmg/issues/315. + if strings.Contains(path, "**") { + args = append(args, "--bind-try", baseDir, baseDir) + boundPaths[baseDir] = true + log.Debugf("Globstar allow_write: bound parent directory '%s' (read-write)", baseDir) + return args, nil + } + + // Check if the base directory is already bound (e.g., /tmp already bound, skip /tmp/**) if boundPaths[baseDir] { log.Debugf("Skipping pattern '%s' - base directory '%s' already bound", path, baseDir) return args, nil @@ -545,14 +556,9 @@ func (t *bubblewrapPolicyTranslator) processWriteRule(path string, boundPaths ma 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) - } + args = append(args, "--bind-try", parentDir, parentDir) + boundPaths[parentDir] = true + log.Debugf("Coarse-grained fallback: bound parent directory '%s' (read-write)", parentDir) } } else { // Fine-grained: bind individual paths diff --git a/sandbox/platform/bubblewrap_translator_linux_test.go b/sandbox/platform/bubblewrap_translator_linux_test.go index 34ddc21..c7d56b1 100644 --- a/sandbox/platform/bubblewrap_translator_linux_test.go +++ b/sandbox/platform/bubblewrap_translator_linux_test.go @@ -775,6 +775,42 @@ func TestGlobFallbackThresholdGlobstar(t *testing.T) { assert.Less(t, len(args), 300, "Coarse-grained fallback should prevent argument explosion") } +// TestBubblewrapAllowWriteGlobstarBindsParentOnly verifies globstar allow_write rules +// bind the parent tree (e.g. .venv) instead of per-file mounts that break pip in-project venvs. +// See https://github.com/safedep/pmg/issues/315 +func TestBubblewrapAllowWriteGlobstarBindsParentOnly(t *testing.T) { + tmpDir := t.TempDir() + venvDir := filepath.Join(tmpDir, ".venv") + binDir := filepath.Join(venvDir, "bin") + require.NoError(t, os.MkdirAll(binDir, 0755)) + pythonPath := filepath.Join(binDir, "python") + require.NoError(t, os.Symlink("/usr/bin/python3", pythonPath)) + + // Populate enough shallow files that old logic would fine-grain bind without hitting fallback. + for i := 0; i < 30; i++ { + path := filepath.Join(venvDir, fmt.Sprintf("file%d.txt", i)) + require.NoError(t, os.WriteFile(path, []byte("x"), 0644)) + } + + config := newDefaultBubblewrapConfig() + translator := newBubblewrapPolicyTranslator(config) + + policy := &sandbox.SandboxPolicy{ + Name: "test-venv-write", + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{tmpDir + "/**"}, + AllowWrite: []string{venvDir + "/**"}, + }, + } + + args, err := translator.translate(policy) + require.NoError(t, err) + + argsStr := argSliceToString(args) + assert.Contains(t, argsStr, venvDir) + assert.NotContains(t, argsStr, pythonPath) +} + // TestGlobNoFallbackSmallPattern tests that small patterns don't trigger fallback func TestGlobNoFallbackSmallPattern(t *testing.T) { tmpDir := t.TempDir()