From 788a031003851aed2a637a285a40f508b82fcccd Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Fri, 12 Jun 2026 12:11:12 +0530 Subject: [PATCH] Fix glob parent directory allowance for patterns with glob characters (#331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sandbox): support pnpm workspaces and macOS cache dir in pnpm profile pnpm in a workspace (monorepo) creates a node_modules directory inside every workspace package to symlink direct dependencies. The profile only allowed writes to the root node_modules, so installs failed with EPERM on mkdir of e.g. apps/mobile/node_modules. pnpm on macOS also writes its cache (lockfile verification, metadata) under ~/Library/Caches/pnpm, while the base profile only covers the XDG path ~/.cache/pnpm. Fixes are scoped to the pnpm leaf profile, not the shared npm-restrictive base. Ref: https://github.com/safedep/pmg/issues/329 * fix(sandbox): emit regex parent rule for nested-glob allow patterns on Seatbelt For allow patterns ending in /**, the translator auto-allows the parent directory so mkdir/stat of the directory itself succeeds. The rule was always emitted as a literal, which can never match when the parent still contains glob characters (e.g. ${CWD}/**/node_modules from a workspace allowance) — silently leaving the directory's own creation denied. Emit a regex rule for glob-bearing parents instead. This stays strictly narrower than the Linux drivers (Bubblewrap binds the prefix before the first /** read-write; Landlock grants the glob expansion or its parent), and deny rules are emitted after allows, so mandatory credential denies still override. Ref: https://github.com/safedep/pmg/issues/329 --------- Co-authored-by: Claude --- sandbox/platform/seatbelt_translator_darwin.go | 16 +++++++++++++++- .../platform/seatbelt_translator_darwin_test.go | 16 ++++++++++++++++ sandbox/profiles/pnpm.yml | 15 +++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/sandbox/platform/seatbelt_translator_darwin.go b/sandbox/platform/seatbelt_translator_darwin.go index 0c7573d..3d4761e 100644 --- a/sandbox/platform/seatbelt_translator_darwin.go +++ b/sandbox/platform/seatbelt_translator_darwin.go @@ -109,8 +109,16 @@ func getAncestorDirectories(pathStr string) []string { return ancestors } -// globDoubleStarAutoAllowParentDirIfNeeded checks if a pattern ends with /** and emits a literal rule for the parent directory. +// globDoubleStarAutoAllowParentDirIfNeeded checks if a pattern ends with /** and emits a rule for the parent directory. // This ensures operations like mkdir('dir') or stat('dir') succeed before accessing dir/** contents. +// +// When the parent itself still contains glob characters (e.g. ${CWD}/**/node_modules), +// a literal rule can never match a real path, so a regex rule is emitted instead. +// This grants access only to directories matching the parent pattern — strictly +// narrower than the Linux drivers, which bind the prefix before the first /** +// (Bubblewrap) or the glob's expansion/parent (Landlock) read-write. Deny rules +// are emitted after allows and override them, so mandatory credential denies +// are unaffected. func globDoubleStarAutoAllowParentDirIfNeeded(sb *strings.Builder, pattern string, expanded string, operation string) { if !strings.HasSuffix(expanded, "/**") { return @@ -127,6 +135,12 @@ func globDoubleStarAutoAllowParentDirIfNeeded(sb *strings.Builder, pattern strin sb.WriteString("\n") sb.WriteString("(allow ") sb.WriteString(operation) + if util.ContainsGlob(parentDir) { + sb.WriteString(" (regex #\"") + sb.WriteString(util.GlobToRegex(parentDir)) + sb.WriteString("\"))\n") + return + } sb.WriteString(" (literal \"") sb.WriteString(parentDir) sb.WriteString("\"))\n") diff --git a/sandbox/platform/seatbelt_translator_darwin_test.go b/sandbox/platform/seatbelt_translator_darwin_test.go index 3b26260..1c56e4c 100644 --- a/sandbox/platform/seatbelt_translator_darwin_test.go +++ b/sandbox/platform/seatbelt_translator_darwin_test.go @@ -107,6 +107,22 @@ func TestSeatbeltTranslatorDarwinFilesystemTranslation(t *testing.T) { assert.Contains(t, actual, "^/path/to/dir/.*$") }, }, + { + name: "glob pattern with /** and glob parent", + policy: &sandbox.SandboxPolicy{ + Filesystem: sandbox.FilesystemPolicy{ + AllowWrite: []string{"/project/**/node_modules/**"}, + }, + }, + assert: func(t *testing.T, actual string, err error) { + assert.NoError(t, err) + // Parent still contains a glob, so the auto-allow must be a + // regex rule — a literal with ** can never match a real path. + assert.Contains(t, actual, `(allow file-write* (regex #"^/project/(.*/)?node_modules$"))`) + assert.Contains(t, actual, `^/project/(.*/)?node_modules/.*$`) + assert.NotContains(t, actual, `(literal "/project/**/node_modules")`) + }, + }, { name: "glob pattern with *.txt", policy: &sandbox.SandboxPolicy{ diff --git a/sandbox/profiles/pnpm.yml b/sandbox/profiles/pnpm.yml index aa1ec8c..2b6183b 100644 --- a/sandbox/profiles/pnpm.yml +++ b/sandbox/profiles/pnpm.yml @@ -19,10 +19,16 @@ environment: - NODE_EXTRA_CA_CERTS filesystem: + allow_read: + # pnpm cache on macOS lives under ~/Library/Caches (lockfile verification, + # metadata). The base profile only covers the XDG path ~/.cache/pnpm. + - ${HOME}/Library/Caches/pnpm/** + allow_write: # pnpm needs write access here - ${HOME}/Library/pnpm/.tools/** - ${HOME}/.pnpm-store/** + - ${HOME}/Library/Caches/pnpm/** # `pnpm i` creates the tmp files in local dir, at least on MacOS - ${CWD}/_tmp_* @@ -39,4 +45,13 @@ filesystem: # Need access for dependency resolution - ${CWD}/.pnpm-store + # Workspaces (monorepos): pnpm creates a node_modules inside every + # workspace package to symlink its direct dependencies. The bare + # **/node_modules form is required in addition to **/node_modules/**: + # the automatic parent-directory allowance only works for literal + # parents, and this parent contains a glob, so mkdir of the directory + # itself must be matched explicitly. + - ${CWD}/**/node_modules + - ${CWD}/**/node_modules/** +