diff --git a/.github/workflows/pmg-e2e.yml b/.github/workflows/pmg-e2e.yml
index 4afce1a..82db595 100644
--- a/.github/workflows/pmg-e2e.yml
+++ b/.github/workflows/pmg-e2e.yml
@@ -443,3 +443,61 @@ jobs:
- name: Run Sandbox E2E Test
run: pmg --sandbox npm exec -- node test/sandbox-e2e.js
+
+ sandbox-e2e-linux:
+ name: Sandbox E2E - Linux (Bubblewrap)
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - name: Checkout Source
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+
+ - name: Setup Go
+ uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
+ with:
+ go-version-file: go.mod
+
+ - name: Setup Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 20
+ check-latest: true
+
+ - name: Install Bubblewrap
+ run: sudo apt-get update && sudo apt-get install -y bubblewrap
+
+ - name: Verify Bubblewrap Installation
+ run: bwrap --version
+
+ - name: Build PMG
+ run: make
+
+ - name: Add pmg to PATH
+ run: echo "$GITHUB_WORKSPACE/bin" >> $GITHUB_PATH
+
+ - name: Setup PMG
+ run: pmg setup install
+
+ - name: Create Test Directories for Sandbox Permissions Tests
+ run: mkdir -p ~/.aws ~/.gcloud ~/.kube ~/.ssh ~/.gnupg ~/.docker
+
+ - name: Create Test Files for Sandbox Permissions Tests
+ run: |
+ touch ~/.aws/credentials
+ touch ~/.gcloud/credentials.json
+ touch ~/.kube/config
+ touch ~/.ssh/id_rsa
+ touch ~/.gnupg/pubring.kbx
+ touch ~/.docker/config.json
+
+ - name: Disable AppArmor for Bubblewrap
+ run: |
+ sudo systemctl stop apparmor
+ sudo systemctl disable apparmor
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
+
+ - name: Run Sandbox E2E Test
+ run: pmg --sandbox --sandbox-profile npm-restrictive npm exec -- node test/sandbox-e2e.js
diff --git a/README.md b/README.md
index f0a1b65..e5c1305 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,10 @@ See [example](https://safedep.io/malicious-npm-package-express-cookie-parser/)
- Blocks malicious packages at install time
- No configuration required, just install and use
- Maintains package installation event log for transparency and audit trail
+- Enforces least privilege and defense in depth using OS native sandboxing
+
+PMG guarantees its own artifact integrity using GitHub and npm attestations. Users can cryptographically prove that the binary they run
+matches the source code they reviewed, eliminating the risk of tampered or malicious builds. See [why and how to trust PMG](docs/trust.md).
## PMG in Action
diff --git a/config/config.template.yml b/config/config.template.yml
index 66473d9..4d18730 100644
--- a/config/config.template.yml
+++ b/config/config.template.yml
@@ -57,8 +57,14 @@ trusted_packages:
#
# Currently supported platforms:
# - macOS (using Seatbelt sandbox-exec)
-# - Linux (planned: Bubblewrap or seccomp-bpf)
+# - Linux (using Bubblewrap with namespace isolation)
# - Windows (planned)
+#
+# Platform-specific limitations:
+# - Linux: Filesystem permissions use coarse-grained bind mounts. Glob patterns (e.g., *.txt)
+# are expanded at policy translation time, but entire directories may be mounted rather than
+# individual matching files. This is less precise than macOS regex-based filtering.
+# - macOS: Network filtering is limited (all-or-nothing for most policies).
sandbox:
# Enable sandbox mode (opt-in, default: false for backward compatibility)
enabled: false
diff --git a/docs/sandbox.md b/docs/sandbox.md
index 1fd1e08..0094384 100644
--- a/docs/sandbox.md
+++ b/docs/sandbox.md
@@ -5,6 +5,31 @@ PMG sandbox design goal is to protect against unknown supply chain attacks using
We do not want to re-invent sandbox and likely rely on OS native sandbox primitives. This is at the cost of developer experience,
where we have to work within the limitations of the sandbox implementations that we use.
+## Requirements
+
+- Bubblewrap on Linux
+- Seatbelt on MacOS
+
+
+Bubblewrap Installation on Linux
+
+For Debian-based Linux distributions, you can install Bubblewrap with the following command:
+
+```bash
+sudo apt install bubblewrap
+```
+
+For Arch Linux, you can install Bubblewrap with the following command:
+
+```bash
+sudo pacman -S bubblewrap
+```
+
+For other Linux distributions, you can install Bubblewrap from the package manager of your choice.
+See [Bubblewrap Installation](https://github.com/containers/bubblewrap#installation) for more details.
+
+
+
## Usage
- Make sure sandbox is enabled in your `config.yml` file.
@@ -99,11 +124,39 @@ Next time you run `pmg pnpm install`, the custom policy template will be used in
## Supported Platforms
-| Platform | Supported | Implementation |
-| -------- | --------- | ---------------------------------- |
-| MacOS | Yes | Seatbelt sandbox-exec |
-| Linux | No | Bubblewrap / seccomp-bpf (planned) |
-| Windows | No | Not yet supported |
+| Platform | Supported | Implementation |
+| -------- | --------- | ----------------------------------- |
+| MacOS | Yes | Seatbelt sandbox-exec |
+| Linux | Yes | Bubblewrap with namespace isolation |
+| Windows | No | Not yet supported |
+
+### Platform-Specific Limitations
+
+
+Linux (Bubblewrap)
+
+**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
@@ -176,6 +229,29 @@ Use `log(1)` to filter the log file by the log tag or generic `PMG_SBX_` prefix.
log show --last 5m --predicate 'message ENDSWITH "PMG_SBX_"' --style compact
```
+### Linux
+
+Linux sandbox implementation uses Bubblewrap for namespace-based isolation. Enable debug logging to see translated sandbox arguments:
+
+```bash
+APP_LOG_LEVEL=debug APP_LOG_FILE=/tmp/pmg-debug.log pmg --sandbox --sandbox-profile=npm-restrictive npm install express
+```
+
+Review the debug log to see the translated `bwrap` command-line arguments:
+
+```bash
+grep "Bubblewrap arguments" /tmp/pmg-debug.log
+```
+
+To debug sandbox violations, you can manually test commands with increased verbosity by running the sandbox command directly:
+
+```bash
+# Extract the bwrap command from debug logs and run with --verbose
+bwrap --verbose [arguments...] -- npm install express
+```
+
+**Note**: Unlike macOS, Bubblewrap does not provide real-time violation logging. Policy violations typically manifest as `EACCES` (Permission denied) errors.
+
## References
- https://github.com/anthropic-experimental/sandbox-runtime
diff --git a/docs/trust.md b/docs/trust.md
index 96265e1..8adda0e 100644
--- a/docs/trust.md
+++ b/docs/trust.md
@@ -10,7 +10,7 @@ The assertion in [2] cannot be *implicit*. If so, it breaks the entire security
## Security Goals
- Adopt software supply chain security best practices so that PMG users can *verify* and only then trust PMG
-- PMG is open source, built in public and reviewed by the community for trust in code
+- PMG is open source, built in public and reviewed by the community for verifiable source of truth
- PMG leverages GitHub build attestation to verify the integrity of the PMG binary with source provenance
- PMG npm package has build attestation to verify the integrity of the PMG binary and build environment with source provenance
- PMG security model is multi-layered without single point of failure
@@ -48,7 +48,7 @@ Install verified binary for your platform:
gh release download $RELEASE_TAG -R safedep/pmg --dir ./pmg-$RELEASE_TAG
```
-Install the platform specific binary from `./$pmg-$RELEASE_TAG`. To see binary specific attestation metadata, run:
+Install the platform specific binary from `./pmg-$RELEASE_TAG`. To see binary specific attestation metadata, run:
```bash
gh attestation verify pmg_Linux_x86_64.tar.gz -R safedep/pmg --format json
diff --git a/sandbox/executor/apply.go b/sandbox/executor/apply.go
index e478736..55ba1e1 100644
--- a/sandbox/executor/apply.go
+++ b/sandbox/executor/apply.go
@@ -56,7 +56,12 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app
policy, err = registry.GetProfile(cfg.SandboxProfileOverride)
if err != nil {
- return nil, fmt.Errorf("failed to load override sandbox policy %s: %w", cfg.SandboxProfileOverride, err)
+ return nil, usefulerror.Useful().
+ WithCode("sandbox_policy_load_failed").
+ WithHumanError(fmt.Sprintf("failed to load override sandbox policy %s: %s", cfg.SandboxProfileOverride, err)).
+ WithHelp("Please check the sandbox profile path and try again.").
+ WithAdditionalHelp("See more at: https://github.com/safedep/pmg/blob/main/docs/sandbox.md").
+ Wrap(fmt.Errorf("failed to load override sandbox policy %s: %w", cfg.SandboxProfileOverride, err))
}
} else {
log.Debugf("Looking up sandbox policy for %s", pmName)
@@ -68,6 +73,7 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app
policyRef, exists := cfg.Config.Sandbox.Policies[pmName]
if !exists {
return nil, usefulerror.Useful().
+ WithCode("sandbox_policy_not_configured").
WithHumanError(fmt.Sprintf("no sandbox policy configured for %s", pmName)).
WithHelp("Please configure a sandbox policy for this package manager in the config file.").
WithAdditionalHelp("See https://github.com/safedep/pmg/blob/main/docs/sandbox.md for more information.").
@@ -123,7 +129,12 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app
}
if !sb.IsAvailable() {
- return nil, fmt.Errorf("sandbox %s is required but not available", sb.Name())
+ return nil, usefulerror.Useful().
+ WithCode("sandbox_not_available").
+ WithHumanError(fmt.Sprintf("sandbox %s is required but not available", sb.Name())).
+ WithHelp("Please install the sandbox provider and try again.").
+ WithAdditionalHelp("See more at: https://github.com/safedep/pmg/blob/main/docs/sandbox.md").
+ Wrap(fmt.Errorf("sandbox %s is required but not available", sb.Name()))
}
log.Debugf("Running %s in %s sandbox with policy %s", pmName, sb.Name(), policy.Name)
diff --git a/sandbox/platform/bubblewrap_config_linux.go b/sandbox/platform/bubblewrap_config_linux.go
new file mode 100644
index 0000000..d773852
--- /dev/null
+++ b/sandbox/platform/bubblewrap_config_linux.go
@@ -0,0 +1,216 @@
+//go:build linux
+// +build linux
+
+package platform
+
+import (
+ "os"
+)
+
+// bubblewrapConfig contains configuration for Bubblewrap sandbox behavior.
+// This allows for tuning sandbox isolation without hardcoding magic values
+// throughout the translator.
+type bubblewrapConfig struct {
+ // Essential system paths that are always mounted read-only for package managers
+ // to function. These paths provide access to system libraries, binaries, and
+ // runtime dependencies.
+ essentialSystemPaths []string
+
+ // Essential device files that must be accessible in the sandbox.
+ // These are critical for basic I/O operations and random number generation.
+ essentialDevices []string
+
+ // Proc filesystem paths to mount. The /proc filesystem provides runtime
+ // information about processes, system resources, and kernel parameters.
+ procPaths []string
+
+ // Maximum depth for glob pattern expansion. Limits filesystem traversal
+ // to prevent excessive scanning of deep directory trees.
+ // Set to 0 for unlimited depth (not recommended).
+ maxGlobDepth int
+
+ // Maximum number of paths to expand from a single glob pattern.
+ // 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
+
+ // Whether to unshare the PID namespace. Isolates process tree visibility.
+ // Recommended for security but may break some package managers that inspect processes.
+ unsharePID bool
+
+ // Whether to unshare the IPC namespace. Isolates System V IPC and POSIX message queues.
+ unshareIPC bool
+
+ // Whether to create a new session (setsid). Detaches from terminal session.
+ newSession bool
+
+ // Whether to die when parent process exits. Ensures cleanup of orphaned sandboxes.
+ dieWithParent bool
+
+ // Seccomp filter configuration
+ seccomp seccompConfig
+
+ // Maximum depth to scan for mandatory deny patterns (e.g., .env files in subdirectories)
+ // Set to 0 to only check literal paths, higher values scan subdirectories.
+ mandatoryDenyScanDepth int
+}
+
+// seccompConfig contains seccomp-bpf filter settings
+type seccompConfig struct {
+ // Whether to enable seccomp filtering
+ enabled bool
+
+ // Path to seccomp filter file (BPF bytecode)
+ // If empty, uses built-in default filter
+ filterPath string
+
+ // Syscalls to deny (blocklist approach)
+ // Common dangerous syscalls: ptrace, kexec_load, module_init, etc.
+ deniedSyscalls []string
+}
+
+// newDefaultBubblewrapConfig creates a bubblewrap config with safe default values.
+// These defaults are based on:
+// - Common Linux filesystem layouts
+// - Anthropic Sandbox Runtime implementation patterns
+//
+// This config is for maintaining safe defaults for the sandbox. Future enhancements
+// will allow the user to override the config through policy or sandbox config available at PMG level.
+func newDefaultBubblewrapConfig() *bubblewrapConfig {
+ return &bubblewrapConfig{
+ // Essential system paths (read-only)
+ // Based on Filesystem Hierarchy Standard (FHS)
+ essentialSystemPaths: []string{
+ "/usr", // User binaries, libraries, documentation
+ "/lib", // Essential shared libraries
+ "/lib64", // 64-bit libraries (on x86_64 systems)
+ "/bin", // Essential command binaries (may be symlink to /usr/bin)
+ "/sbin", // System binaries (may be symlink to /usr/sbin)
+ "/etc", // System configuration files (read-only access needed for DNS, etc.)
+ "/opt", // Optional application software packages
+ "/var/lib", // Variable state information (package databases, etc.)
+ "/sys", // Sysfs - kernel and device information
+ },
+
+ // Essential device files
+ // Required for basic I/O, randomness, and null device operations
+ essentialDevices: []string{
+ "/dev/null",
+ "/dev/zero",
+ "/dev/random",
+ "/dev/urandom",
+ "/dev/full",
+ "/dev/tty", // For terminal operations
+ },
+
+ // Proc filesystem paths
+ // Provides process and system information
+ procPaths: []string{
+ "/proc", // Full proc filesystem
+ },
+
+ // 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
+ 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,
+
+ // Process/IPC isolation
+ unsharePID: true, // Isolate PID namespace
+ unshareIPC: true, // Isolate IPC namespace
+ newSession: true, // Create new session
+ dieWithParent: true, // Cleanup on parent exit
+
+ // Seccomp configuration
+ seccomp: seccompConfig{
+ enabled: false, // Disabled by default (Phase 4 enhancement)
+ filterPath: "", // Use built-in filter when enabled
+ deniedSyscalls: []string{
+ // Dangerous syscalls that should be blocked
+ "ptrace", // Process tracing (debugging/injection)
+ "kexec_load", // Load new kernel
+ "module_init", // Load kernel modules
+ "reboot", // System reboot
+ "swapon", // Enable swap
+ "swapoff", // Disable swap
+ "mount", // Mount filesystems
+ "umount", // Unmount filesystems
+ "pivot_root", // Change root filesystem
+ "chroot", // Change root directory
+ "unshare", // Create new namespaces (prevent nested sandboxing)
+ "setns", // Join existing namespace
+ "acct", // Process accounting
+ "add_key", // Add key to kernel keyring
+ "request_key", // Request key from kernel
+ "keyctl", // Manipulate kernel keyring
+ "ioperm", // Set port I/O permissions
+ "iopl", // Set I/O privilege level
+ "perf_event_open", // Performance monitoring
+ },
+ },
+
+ // Scan depth for finding dangerous files in project directories
+ mandatoryDenyScanDepth: 3,
+ }
+}
+
+// shouldUnshareNetwork determines whether to isolate network based on policy.
+// Returns true if network should be completely isolated (--unshare-net).
+func (c *bubblewrapConfig) shouldUnshareNetwork(hasAllowRules bool, hasDenyAll bool) bool {
+ // If there are allow rules, don't isolate network
+ // Note: bubblewrap can't do per-host filtering, so allow rules mean "allow network"
+ // The allow_outbound rules serve as documentation of intended access
+ if hasAllowRules {
+ return false
+ }
+
+ // No allow rules - check if we should deny all
+ if hasDenyAll {
+ return true
+ }
+
+ // No allow rules and no deny-all - use default behavior
+ return c.unshareNetworkByDefault
+}
+
+// getEssentialSystemPaths returns essential system paths for read-only binding.
+// Filters out paths that don't exist on this system (e.g., /lib64 on 32-bit).
+func (c *bubblewrapConfig) getEssentialSystemPaths() []string {
+ existingPaths := make([]string, 0, len(c.essentialSystemPaths))
+ for _, path := range c.essentialSystemPaths {
+ if _, err := os.Stat(path); err == nil {
+ existingPaths = append(existingPaths, path)
+ }
+ }
+
+ return existingPaths
+}
+
+// getEssentialDevices returns essential device files for binding.
+// Filters out devices that don't exist on this system.
+func (c *bubblewrapConfig) getEssentialDevices() []string {
+ existingDevices := make([]string, 0, len(c.essentialDevices))
+ for _, device := range c.essentialDevices {
+ if _, err := os.Stat(device); err == nil {
+ existingDevices = append(existingDevices, device)
+ }
+ }
+
+ return existingDevices
+}
diff --git a/sandbox/platform/bubblewrap_linux.go b/sandbox/platform/bubblewrap_linux.go
new file mode 100644
index 0000000..1b3d72f
--- /dev/null
+++ b/sandbox/platform/bubblewrap_linux.go
@@ -0,0 +1,110 @@
+//go:build linux
+// +build linux
+
+package platform
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+
+ "github.com/safedep/dry/log"
+ "github.com/safedep/pmg/sandbox"
+ "github.com/safedep/pmg/usefulerror"
+)
+
+// bubblewrapSandbox implements the Sandbox interface using Bubblewrap (bwrap) on Linux.
+// Bubblewrap is a low-level unprivileged sandboxing tool that uses Linux namespaces
+// to isolate processes with controlled access to filesystem, network, and IPC resources.
+//
+// This implementation follows the CLI-wrapper pattern (like Seatbelt on macOS):
+// - Modifies the cmd in place by wrapping it with `bwrap` CLI
+// - Returns ExecutionResult with executed=false
+// - Caller must call cmd.Run() to execute the sandboxed command
+type bubblewrapSandbox struct {
+ config *bubblewrapConfig
+ translator *bubblewrapPolicyTranslator
+}
+
+// newBubblewrapSandbox creates a new Bubblewrap sandbox instance with default configuration.
+func newBubblewrapSandbox() (*bubblewrapSandbox, error) {
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+
+ return &bubblewrapSandbox{
+ config: config,
+ translator: translator,
+ }, nil
+}
+
+// Execute prepares a command to run in the Bubblewrap sandbox with the given policy.
+// It translates the PMG policy to bwrap CLI arguments and wraps the command execution.
+//
+// This implementation modifies the cmd in place and does NOT execute it.
+// Returns ExecutionResult with executed=false, indicating the caller must run cmd.Run().
+func (b *bubblewrapSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) {
+ bwrapPath, err := exec.LookPath("bwrap")
+ if err != nil {
+ return nil, usefulerror.Useful().
+ WithCode("bubblewrap_not_found").
+ WithHumanError("Bubblewrap binary not found").
+ WithHelp("See more at: https://github.com/safedep/pmg/blob/main/docs/sandbox.md").
+ Wrap(fmt.Errorf("bubblewrap binary not found: %w", err))
+ }
+
+ bwrapArgs, err := b.translator.translate(policy)
+ if err != nil {
+ return nil, fmt.Errorf("failed to translate sandbox policy to bubblewrap arguments: %w", err)
+ }
+
+ log.Debugf("Bubblewrap arguments: %v", bwrapArgs)
+
+ originalPath := cmd.Path
+ originalArgs := cmd.Args
+
+ // Build bwrap command: bwrap [bwrap-args] --
+ // The "--" separator is important to distinguish bwrap args from command args
+ cmd.Path = bwrapPath
+ cmd.Args = []string{"bwrap"}
+
+ // Add all translated bwrap arguments
+ cmd.Args = append(cmd.Args, bwrapArgs...)
+
+ // Add separator
+ cmd.Args = append(cmd.Args, "--")
+
+ // Add original command
+ cmd.Args = append(cmd.Args, originalPath)
+
+ // Add original arguments (skip argv[0] which is the command itself)
+ if len(originalArgs) > 1 {
+ cmd.Args = append(cmd.Args, originalArgs[1:]...)
+ }
+
+ log.Debugf("Sandboxed command: %s %v", cmd.Path, cmd.Args)
+
+ return sandbox.NewExecutionResult(sandbox.WithExecutionResultSandbox(b)), nil
+}
+
+// Name returns the name of this sandbox implementation.
+func (b *bubblewrapSandbox) Name() string {
+ return "bubblewrap"
+}
+
+// IsAvailable returns true if bubblewrap (bwrap) is available on this system.
+// Checks by attempting to locate the bwrap binary in PATH.
+func (b *bubblewrapSandbox) IsAvailable() bool {
+ _, err := exec.LookPath("bwrap")
+ return err == nil
+}
+
+// Close cleans up any resources allocated by the sandbox.
+// For Bubblewrap, there are no temporary files to clean up (unlike Seatbelt),
+// since all configuration is passed via CLI arguments.
+//
+// This method is idempotent and safe to call multiple times.
+func (b *bubblewrapSandbox) Close() error {
+ // Bubblewrap doesn't create temporary files like Seatbelt does,
+ // so there's nothing to clean up. All isolation is via CLI args.
+ return nil
+}
diff --git a/sandbox/platform/bubblewrap_linux_test.go b/sandbox/platform/bubblewrap_linux_test.go
new file mode 100644
index 0000000..47a2e45
--- /dev/null
+++ b/sandbox/platform/bubblewrap_linux_test.go
@@ -0,0 +1,320 @@
+//go:build linux
+// +build linux
+
+package platform
+
+import (
+ "context"
+ "os/exec"
+ "testing"
+
+ "github.com/safedep/dry/utils"
+ "github.com/safedep/pmg/sandbox"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBubblewrapSandboxCreation(t *testing.T) {
+ sb, err := newBubblewrapSandbox()
+ require.NoError(t, err)
+ assert.NotNil(t, sb)
+ assert.Equal(t, "bubblewrap", sb.Name())
+}
+
+func TestBubblewrapSandboxIsAvailable(t *testing.T) {
+ sb, err := newBubblewrapSandbox()
+ require.NoError(t, err)
+
+ // This test will pass if bwrap is installed, skip if not
+ if !sb.IsAvailable() {
+ t.Skip("bubblewrap (bwrap) is not installed on this system")
+ }
+
+ assert.True(t, sb.IsAvailable())
+}
+
+func TestBubblewrapSandboxExecute(t *testing.T) {
+ sb, err := newBubblewrapSandbox()
+ require.NoError(t, err)
+
+ if !sb.IsAvailable() {
+ t.Skip("bubblewrap (bwrap) is not installed on this system")
+ }
+
+ policy := &sandbox.SandboxPolicy{
+ Name: "test",
+ Description: "test policy",
+ PackageManagers: []string{"test"},
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{"/usr", "/lib", "/bin"},
+ AllowWrite: []string{"/tmp"},
+ },
+ Network: sandbox.NetworkPolicy{
+ AllowOutbound: []string{"*:*"},
+ },
+ }
+
+ // Create a simple command to wrap
+ cmd := exec.Command("/bin/echo", "hello")
+
+ ctx := context.Background()
+ result, err := sb.Execute(ctx, cmd, policy)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ // Result should indicate caller must run the command
+ assert.True(t, result.ShouldRun(), "Bubblewrap should return executed=false")
+
+ // Command should be modified to use bwrap
+ assert.Equal(t, "bwrap", cmd.Args[0])
+ assert.Contains(t, cmd.Args, "/bin/echo")
+ assert.Contains(t, cmd.Args, "hello")
+
+ // Cleanup
+ err = result.Close()
+ assert.NoError(t, err)
+}
+
+func TestBubblewrapSandboxExecuteCommandWrapping(t *testing.T) {
+ sb, err := newBubblewrapSandbox()
+ require.NoError(t, err)
+
+ if !sb.IsAvailable() {
+ t.Skip("bubblewrap (bwrap) is not installed on this system")
+ }
+
+ policy := &sandbox.SandboxPolicy{
+ Name: "test",
+ Description: "test policy",
+ PackageManagers: []string{"npm"},
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{"/usr"},
+ },
+ }
+
+ // Create a command with multiple arguments
+ originalCmd := "/usr/bin/node"
+ originalArgs := []string{"/usr/bin/node", "--version"}
+ cmd := exec.Command(originalCmd, originalArgs[1:]...)
+
+ ctx := context.Background()
+ result, err := sb.Execute(ctx, cmd, policy)
+ require.NoError(t, err)
+
+ // Verify command structure
+ // bwrap [bwrap-args] -- /usr/bin/node --version
+ assert.Contains(t, cmd.Args, "bwrap")
+ assert.Contains(t, cmd.Args, "--") // Separator
+ assert.Contains(t, cmd.Args, originalCmd)
+ assert.Contains(t, cmd.Args, "--version")
+
+ // Find the separator and verify structure
+ separatorIdx := -1
+ for i, arg := range cmd.Args {
+ if arg == "--" {
+ separatorIdx = i
+ break
+ }
+ }
+ assert.NotEqual(t, -1, separatorIdx, "Should have -- separator")
+
+ // After separator should be the original command and args
+ afterSeparator := cmd.Args[separatorIdx+1:]
+ assert.Equal(t, originalCmd, afterSeparator[0])
+ assert.Equal(t, "--version", afterSeparator[1])
+
+ // Cleanup
+ err = result.Close()
+ assert.NoError(t, err)
+}
+
+func TestBubblewrapSandboxExecuteWithPTY(t *testing.T) {
+ sb, err := newBubblewrapSandbox()
+ require.NoError(t, err)
+
+ if !sb.IsAvailable() {
+ t.Skip("bubblewrap (bwrap) is not installed on this system")
+ }
+
+ policy := &sandbox.SandboxPolicy{
+ Name: "test",
+ Description: "test with PTY",
+ PackageManagers: []string{"npm"},
+ AllowPTY: utils.PtrTo(true),
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{"/usr"},
+ },
+ }
+
+ cmd := exec.Command("/bin/echo", "test")
+ ctx := context.Background()
+ result, err := sb.Execute(ctx, cmd, policy)
+ require.NoError(t, err)
+
+ // Should have PTY-related arguments
+ argsStr := ""
+ for _, arg := range cmd.Args {
+ argsStr += arg + " "
+ }
+ assert.Contains(t, argsStr, "/dev/pts")
+ assert.Contains(t, argsStr, "/dev/ptmx")
+
+ // Cleanup
+ err = result.Close()
+ assert.NoError(t, err)
+}
+
+func TestBubblewrapSandboxExecuteWithNetworkIsolation(t *testing.T) {
+ sb, err := newBubblewrapSandbox()
+ require.NoError(t, err)
+
+ if !sb.IsAvailable() {
+ t.Skip("bubblewrap (bwrap) is not installed on this system")
+ }
+
+ policy := &sandbox.SandboxPolicy{
+ Name: "test",
+ Description: "test with network isolation",
+ PackageManagers: []string{"npm"},
+ Network: sandbox.NetworkPolicy{
+ DenyOutbound: []string{"*:*"},
+ },
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{"/usr"},
+ },
+ }
+
+ cmd := exec.Command("/bin/echo", "test")
+ ctx := context.Background()
+ result, err := sb.Execute(ctx, cmd, policy)
+ require.NoError(t, err)
+
+ // Should have network isolation
+ argsStr := ""
+ for _, arg := range cmd.Args {
+ argsStr += arg + " "
+ }
+ assert.Contains(t, argsStr, "--unshare-net")
+
+ // Cleanup
+ err = result.Close()
+ assert.NoError(t, err)
+}
+
+func TestBubblewrapSandboxClose(t *testing.T) {
+ sb, err := newBubblewrapSandbox()
+ require.NoError(t, err)
+
+ // Close should be idempotent
+ err = sb.Close()
+ assert.NoError(t, err)
+
+ err = sb.Close()
+ assert.NoError(t, err)
+}
+
+func TestBubblewrapSandboxExecutionResult(t *testing.T) {
+ sb, err := newBubblewrapSandbox()
+ require.NoError(t, err)
+
+ if !sb.IsAvailable() {
+ t.Skip("bubblewrap (bwrap) is not installed on this system")
+ }
+
+ policy := &sandbox.SandboxPolicy{
+ Name: "test",
+ PackageManagers: []string{"test"},
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{"/usr"},
+ },
+ }
+
+ cmd := exec.Command("/bin/echo", "test")
+ ctx := context.Background()
+ result, err := sb.Execute(ctx, cmd, policy)
+ require.NoError(t, err)
+
+ // Verify ExecutionResult properties
+ assert.True(t, result.ShouldRun(), "Bubblewrap uses CLI wrapper, should return executed=false")
+
+ // Close should succeed
+ err = result.Close()
+ assert.NoError(t, err)
+
+ // Multiple closes should be safe
+ err = result.Close()
+ assert.NoError(t, err)
+}
+
+func TestBubblewrapSandboxTranslationError(t *testing.T) {
+ sb, err := newBubblewrapSandbox()
+ require.NoError(t, err)
+
+ if !sb.IsAvailable() {
+ t.Skip("bubblewrap (bwrap) is not installed on this system")
+ }
+
+ // Create a policy with invalid patterns (shouldn't cause translation error)
+ policy := &sandbox.SandboxPolicy{
+ Name: "test",
+ PackageManagers: []string{"test"},
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{"/usr"},
+ },
+ }
+
+ cmd := exec.Command("/bin/echo", "test")
+ ctx := context.Background()
+
+ // Should succeed even with complex patterns
+ result, err := sb.Execute(ctx, cmd, policy)
+ assert.NoError(t, err)
+ assert.NotNil(t, result)
+
+ // Cleanup
+ if result != nil {
+ _ = result.Close()
+ }
+}
+
+func TestBubblewrapSandboxEssentialBindMounts(t *testing.T) {
+ sb, err := newBubblewrapSandbox()
+ require.NoError(t, err)
+
+ if !sb.IsAvailable() {
+ t.Skip("bubblewrap (bwrap) is not installed on this system")
+ }
+
+ policy := &sandbox.SandboxPolicy{
+ Name: "test",
+ PackageManagers: []string{"test"},
+ // Minimal policy - should still get essential mounts
+ Filesystem: sandbox.FilesystemPolicy{},
+ }
+
+ cmd := exec.Command("/bin/echo", "test")
+ ctx := context.Background()
+ result, err := sb.Execute(ctx, cmd, policy)
+ require.NoError(t, err)
+
+ argsStr := ""
+ for _, arg := range cmd.Args {
+ argsStr += arg + " "
+ }
+
+ // Should have essential system paths
+ assert.Contains(t, argsStr, "/usr")
+
+ // Should have essential devices
+ assert.Contains(t, argsStr, "/dev/null")
+
+ // Should have proc filesystem
+ assert.Contains(t, argsStr, "--proc")
+
+ // Should have tmpdir
+ assert.Contains(t, argsStr, "--bind")
+
+ // Cleanup
+ err = result.Close()
+ assert.NoError(t, err)
+}
diff --git a/sandbox/platform/bubblewrap_translator_linux.go b/sandbox/platform/bubblewrap_translator_linux.go
new file mode 100644
index 0000000..be217ca
--- /dev/null
+++ b/sandbox/platform/bubblewrap_translator_linux.go
@@ -0,0 +1,706 @@
+//go:build linux
+// +build linux
+
+package platform
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/safedep/dry/log"
+ "github.com/safedep/dry/utils"
+ "github.com/safedep/pmg/sandbox"
+ "github.com/safedep/pmg/sandbox/util"
+)
+
+// bubblewrapPolicyTranslator translates PMG SandboxPolicy to Bubblewrap (bwrap) CLI arguments.
+//
+// Bubblewrap uses command-line arguments instead of profile files (like Seatbelt).
+// The translator generates arguments for:
+// - Filesystem bind mounts (--bind, --ro-bind, --dev-bind)
+// - Network isolation (--unshare-net)
+// - Process isolation (--unshare-pid, --unshare-ipc)
+// - Device access (--dev-bind /dev/null, etc.)
+// - Essential system permissions
+type bubblewrapPolicyTranslator struct {
+ config *bubblewrapConfig
+}
+
+// newBubblewrapPolicyTranslator creates a new translator with the given config.
+func newBubblewrapPolicyTranslator(config *bubblewrapConfig) *bubblewrapPolicyTranslator {
+ return &bubblewrapPolicyTranslator{
+ config: config,
+ }
+}
+
+// translate converts a PMG SandboxPolicy to bwrap CLI arguments.
+// Returns a slice of arguments to pass to the bwrap command.
+func (t *bubblewrapPolicyTranslator) translate(policy *sandbox.SandboxPolicy) ([]string, error) {
+ args := []string{}
+
+ // 1. Add essential system permissions (filesystem, devices, proc)
+ systemArgs, err := t.addEssentialSystemPermissions()
+ if err != nil {
+ return nil, fmt.Errorf("failed to add essential system permissions: %w", err)
+ }
+
+ args = append(args, systemArgs...)
+
+ // 2. Add isolation namespaces
+ isolationArgs := t.addIsolationNamespaces(policy)
+ args = append(args, isolationArgs...)
+
+ // 3. Add filesystem rules (allow read, allow write, deny patterns)
+ filesystemArgs, err := t.translateFilesystem(policy)
+ if err != nil {
+ return nil, fmt.Errorf("failed to translate filesystem rules: %w", err)
+ }
+
+ args = append(args, filesystemArgs...)
+
+ // 4. Add PTY support if needed
+ if utils.SafelyGetValue(policy.AllowPTY) {
+ ptyArgs := t.addPTYSupport()
+ args = append(args, ptyArgs...)
+ }
+
+ // 5. Add tmpdir support (package managers need writable temp directory)
+ tmpdirArgs := t.addTmpdirSupport()
+ args = append(args, tmpdirArgs...)
+
+ // 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
+}
+
+// addEssentialSystemPermissions adds bind mounts for essential system paths and devices
+// that package managers need to function properly.
+func (t *bubblewrapPolicyTranslator) addEssentialSystemPermissions() ([]string, error) {
+ args := []string{}
+
+ // Add essential system paths (read-only)
+ for _, path := range t.config.getEssentialSystemPaths() {
+ args = append(args, "--ro-bind-try", path, path)
+ }
+
+ // Add essential device files
+ for _, device := range t.config.getEssentialDevices() {
+ args = append(args, "--dev-bind-try", device, device)
+ }
+
+ // Add proc filesystem (read-only for safety)
+ for _, procPath := range t.config.procPaths {
+ args = append(args, "--proc", procPath)
+ }
+
+ return args, nil
+}
+
+// addIsolationNamespaces adds namespace isolation arguments based on policy and config.
+func (t *bubblewrapPolicyTranslator) addIsolationNamespaces(policy *sandbox.SandboxPolicy) []string {
+ args := []string{}
+
+ // Network isolation
+ hasAllowRules := len(policy.Network.AllowOutbound) > 0
+ hasDenyAll := false
+ for _, pattern := range policy.Network.DenyOutbound {
+ if pattern == "*:*" {
+ hasDenyAll = true
+ break
+ }
+ }
+
+ if t.config.shouldUnshareNetwork(hasAllowRules, hasDenyAll) {
+ args = append(args, "--unshare-net")
+ log.Debugf("Network isolated (--unshare-net)")
+ } else {
+ log.Debugf("Network allowed (no --unshare-net)")
+ }
+
+ // PID namespace isolation
+ if t.config.unsharePID {
+ args = append(args, "--unshare-pid")
+ }
+
+ // IPC namespace isolation
+ if t.config.unshareIPC {
+ args = append(args, "--unshare-ipc")
+ }
+
+ // New session
+ if t.config.newSession {
+ args = append(args, "--new-session")
+ }
+
+ // Die with parent
+ if t.config.dieWithParent {
+ args = append(args, "--die-with-parent")
+ }
+
+ return args
+}
+
+// translateFilesystem converts filesystem policy rules to bwrap bind mount arguments.
+//
+// Bubblewrap filesystem isolation works via bind mounts:
+// - --ro-bind: Read-only bind mount
+// - --bind: Read-write bind mount
+// - --dev-bind: Device file bind mount
+// - --tmpfs: Temporary file system mount (used to hide specific files/directories)
+// - Paths not mounted are inaccessible (deny-by-default)
+//
+// Strategy:
+// 1. Start with essential system paths (added separately)
+// 2. Add user-specified allow_read paths FIRST (read-only bind mounts)
+// This establishes the base filesystem view (e.g., "/" for full access)
+// 3. Add user-specified allow_write paths SECOND (read-write bind mounts)
+// These OVERRIDE earlier read-only binds (bwrap: later mounts win)
+// 4. Handle deny patterns by mounting /dev/null or read-only for directories
+// 5. Add mandatory deny patterns
+func (t *bubblewrapPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPolicy) ([]string, error) {
+ args := []string{}
+
+ // Track paths we've already bound for read and write to avoid duplicates
+ // Bubblewrap later mounts win, so we need to track both read and write bound paths.
+ readBoundPaths := make(map[string]bool)
+ writeBoundPaths := make(map[string]bool)
+
+ // Add essential system paths to bound paths (already handled separately)
+ for _, path := range t.config.getEssentialSystemPaths() {
+ readBoundPaths[path] = true
+ }
+
+ // Mark tmpdir as already bound (will be handled by addTmpdirSupport())
+ // This prevents conflicts from policy patterns like /tmp/**
+ tmpDir := os.TempDir()
+ writeBoundPaths[tmpDir] = true
+
+ // 1. Process allow_read rules FIRST (read-only bind mounts)
+ // This establishes the base read-only filesystem view (including "/" if specified)
+ for _, pattern := range policy.Filesystem.AllowRead {
+ expanded, err := util.ExpandVariables(pattern)
+ if err != nil {
+ log.Warnf("Failed to expand variables in allow_read pattern '%s': %v", pattern, err)
+ continue
+ }
+
+ // Glob chars are handled by the processReadRule function.
+ readArgs, err := t.processReadRule(expanded, readBoundPaths)
+ if err != nil {
+ log.Warnf("Failed to process allow_read rule '%s': %v", expanded, err)
+ continue
+ }
+
+ args = append(args, readArgs...)
+ }
+
+ // 2. Process allow_write rules SECOND (read-write bind mounts)
+ // These OVERRIDE earlier read-only binds (bwrap: later mounts win)
+ // Use a separate map so we don't skip paths that need write access
+ writeBoundPaths[tmpDir] = true // tmpdir handled by addTmpdirSupport
+ for _, pattern := range policy.Filesystem.AllowWrite {
+ expanded, err := util.ExpandVariables(pattern)
+ if err != nil {
+ log.Warnf("Failed to expand variables in allow_write pattern '%s': %v", pattern, err)
+ continue
+ }
+
+ // Glob chars are handled by the processWriteRule function.
+ writeArgs, err := t.processWriteRule(expanded, writeBoundPaths)
+ if err != nil {
+ log.Warnf("Failed to process allow_write rule '%s': %v", expanded, err)
+ continue
+ }
+
+ args = append(args, writeArgs...)
+ }
+
+ // 3. Process deny_write rules (mount /dev/null to prevent access)
+ allowGitConfig := utils.SafelyGetValue(policy.AllowGitConfig)
+ denyPatterns := append([]string{}, policy.Filesystem.DenyWrite...)
+
+ // Add mandatory deny patterns (credentials - these get completely hidden)
+ mandatoryDenies := util.GetMandatoryDenyPatterns(allowGitConfig)
+ denyPatterns = append(denyPatterns, mandatoryDenies...)
+
+ for _, pattern := range denyPatterns {
+ expanded, err := util.ExpandVariables(pattern)
+ if err != nil {
+ log.Warnf("Failed to expand variables in deny pattern '%s': %v", pattern, err)
+ continue
+ }
+
+ denyArgs, err := t.processDenyRule(expanded)
+ if err != nil {
+ log.Debugf("Deny rule '%s' skipped: %v", expanded, err)
+ continue
+ }
+
+ args = append(args, denyArgs...)
+ }
+
+ // 4. Process mandatory credential directories - completely hide them with tmpfs
+ // This blocks both read AND write access (more secure than read-only mount)
+ hiddenDirs := make(map[string]bool)
+ for _, pattern := range mandatoryDenies {
+ expanded, err := util.ExpandVariables(pattern)
+ if err != nil {
+ continue
+ }
+
+ var dirsToHide []string
+ if util.ContainsGlob(expanded) {
+ matches, err := filepath.Glob(expanded)
+ if err != nil {
+ continue
+ }
+
+ dirsToHide = matches
+ } else {
+ dirsToHide = []string{expanded}
+ }
+
+ for _, dir := range dirsToHide {
+ if hiddenDirs[dir] {
+ continue
+ }
+
+ if info, err := os.Stat(dir); err == nil && info.IsDir() {
+ args = append(args, "--tmpfs", dir)
+ hiddenDirs[dir] = true
+
+ log.Debugf("Hiding credential directory '%s' with tmpfs", dir)
+ }
+ }
+ }
+
+ // 5. Process deny_exec rules (mount /dev/null over executables)
+ for _, exePath := range policy.Process.DenyExec {
+ expanded, err := util.ExpandVariables(exePath)
+ if err != nil {
+ log.Warnf("Failed to expand variables in deny_exec pattern '%s': %v", exePath, err)
+ continue
+ }
+
+ // Handle glob patterns (e.g., /usr/bin/python*)
+ if util.ContainsGlob(expanded) {
+ matches, err := filepath.Glob(expanded)
+ if err != nil {
+ log.Warnf("Failed to expand deny_exec glob '%s': %v", expanded, err)
+ continue
+ }
+ for _, match := range matches {
+ if info, err := os.Stat(match); err == nil && !info.IsDir() {
+ args = append(args, "--ro-bind", "/dev/null", match)
+ log.Debugf("Blocked execution of '%s'", match)
+ }
+ }
+ } else {
+ if info, err := os.Stat(expanded); err == nil && !info.IsDir() {
+ args = append(args, "--ro-bind", "/dev/null", expanded)
+ log.Debugf("Blocked execution of '%s'", expanded)
+ }
+ }
+ }
+
+ return args, nil
+}
+
+// processReadRule handles a single allow_read rule, expanding globs and creating ro-bind mounts.
+func (t *bubblewrapPolicyTranslator) processReadRule(path string, boundPaths map[string]bool) ([]string, error) {
+ args := []string{}
+
+ // Check if path contains glob pattern
+ if util.ContainsGlob(path) {
+ // Check if the base directory is already bound
+ baseDir := t.extractParentDir(path)
+ if boundPaths[baseDir] {
+ log.Debugf("Skipping pattern '%s' - base directory '%s' already bound", path, baseDir)
+ return args, nil
+ }
+
+ // 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)
+ }
+
+ 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 {
+ // Literal path - create read-only bind
+ if !boundPaths[path] {
+ args = append(args, "--ro-bind-try", path, path)
+ boundPaths[path] = true
+ }
+ }
+
+ return args, nil
+}
+
+// processWriteRule handles a single allow_write rule, expanding globs and creating rw-bind mounts.
+func (t *bubblewrapPolicyTranslator) processWriteRule(path string, boundPaths map[string]bool) ([]string, error) {
+ args := []string{}
+
+ // 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)
+ if boundPaths[baseDir] {
+ log.Debugf("Skipping pattern '%s' - base directory '%s' already bound", path, baseDir)
+ return args, nil
+ }
+
+ // 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)
+ }
+
+ 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 {
+ // Check if path exists - if not, bind parent directory instead
+ // This allows creating new directories (e.g., node_modules/** when node_modules doesn't exist)
+ pathToBind := p
+ if _, err := os.Stat(p); os.IsNotExist(err) {
+ parentDir := filepath.Dir(p)
+ if parentDir != "" && parentDir != "." && parentDir != "/" {
+ pathToBind = parentDir
+ log.Debugf("Path '%s' doesn't exist, binding parent '%s' as writable to allow creation", p, parentDir)
+ }
+ }
+
+ if !boundPaths[pathToBind] {
+ args = append(args, "--bind-try", pathToBind, pathToBind)
+ boundPaths[pathToBind] = true
+ } else {
+ // Path already bound, add another bind to upgrade to read-write
+ // bwrap: later mounts override earlier ones
+ args = append(args, "--bind-try", pathToBind, pathToBind)
+ log.Debugf("Path '%s' already bound, adding write bind to override", pathToBind)
+ }
+ }
+ }
+ } else {
+ // Literal path - create read-write bind
+ if !boundPaths[path] {
+ args = append(args, "--bind-try", path, path)
+ boundPaths[path] = true
+ }
+ }
+
+ return args, nil
+}
+
+// processDenyRule handles deny rules by mounting /dev/null to prevent file access.
+// This technique is borrowed from Anthropic's sandbox-runtime.
+func (t *bubblewrapPolicyTranslator) processDenyRule(path string) ([]string, error) {
+ args := []string{}
+
+ // For glob patterns, expand and deny each path
+ if util.ContainsGlob(path) {
+ // For deny rules, we scan for existing files matching the pattern
+ // 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
+ }
+
+ for _, p := range paths {
+ info, err := os.Stat(p)
+ if err == nil {
+ if info.IsDir() {
+ // For directories, mount as read-only to prevent writes
+ // This overrides any previous writable bind of parent directories
+ args = append(args, "--ro-bind-try", p, p)
+ log.Debugf("Deny rule: mounted directory '%s' as read-only", p)
+ } else {
+ // For files, mount /dev/null to prevent access
+ args = append(args, "--ro-bind", "/dev/null", p)
+ }
+ }
+ }
+ } else {
+ // For literal paths, check if they exist
+ if info, err := os.Stat(path); err == nil {
+ if info.IsDir() {
+ // For directories, mount as read-only to prevent writes
+ // This overrides any previous writable bind of parent directories
+ args = append(args, "--ro-bind-try", path, path)
+ log.Debugf("Deny rule: mounted directory '%s' as read-only", path)
+ } else {
+ // File exists - mount /dev/null over it
+ args = append(args, "--ro-bind", "/dev/null", path)
+ }
+ } else if os.IsNotExist(err) {
+ // File doesn't exist - skip it
+ // IMPORTANT: We cannot use --ro-bind /dev/null for non-existent paths because
+ // bwrap creates the file on the host filesystem as a mount point, which leaves
+ // empty files (.env, .aws, etc.) in the user's directory after sandbox exits.
+ // Non-existent files are harmless (no secrets to leak), and blocking creation
+ // in writable directories isn't critical since an attacker creating an empty
+ // .env is not a security threat.
+ log.Debugf("Deny rule: skipping non-existent path '%s' (bwrap would create empty file as mount point)", path)
+ }
+ }
+
+ return args, nil
+}
+
+// findFirstNonExistentPath walks up the directory tree to find the first path component
+// that doesn't exist. This allows us to block file creation by mounting /dev/null.
+//
+// Example: If /home/user/.env doesn't exist but /home/user does, returns /home/user/.env
+func (t *bubblewrapPolicyTranslator) findFirstNonExistentPath(path string) string {
+ path = filepath.Clean(path)
+
+ // Walk up the tree
+ for path != "/" && path != "." {
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ // Check if parent exists
+ parent := filepath.Dir(path)
+ if _, err := os.Stat(parent); err == nil {
+ // Parent exists, this is the first non-existent path
+ return path
+ }
+ }
+ path = filepath.Dir(path)
+ }
+
+ return ""
+}
+
+// expandGlobPattern expands a glob pattern to a list of concrete paths.
+// Implements depth limiting and path count limiting to prevent DoS.
+// 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, "**") {
+ 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, false, fmt.Errorf("glob expansion failed: %w", err)
+ }
+
+ // 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, false, nil
+}
+
+// expandGlobstarPattern expands patterns containing ** (recursive glob).
+// This requires custom implementation since filepath.Glob doesn't support **.
+func (t *bubblewrapPolicyTranslator) expandGlobstarPattern(pattern string, maxDepth int, maxPaths int) ([]string, error) {
+ // Split pattern at **
+ parts := strings.Split(pattern, "**")
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("only one ** globstar supported per pattern")
+ }
+
+ basePath := strings.TrimSuffix(parts[0], "/")
+ suffix := strings.TrimPrefix(parts[1], "/")
+
+ // If base path is empty, it would walk from root which is prohibitively expensive.
+ // Skip such patterns to prevent filesystem scan timeouts.
+ if basePath == "" {
+ log.Debugf("Skipping globstar pattern '%s' with empty base path (would walk from root)", pattern)
+ return []string{}, nil
+ }
+
+ // Expand base path variables
+ var err error
+ basePath, err = util.ExpandVariables(basePath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to expand base path: %w", err)
+ }
+
+ // Check if base path exists
+ if _, err := os.Stat(basePath); os.IsNotExist(err) {
+ // Base path doesn't exist yet, return just the base
+ return []string{basePath}, nil
+ }
+
+ matches := []string{}
+
+ // Walk the directory tree with depth limiting
+ err = t.walkWithDepthLimit(basePath, suffix, maxDepth, maxPaths, &matches)
+ if err != nil {
+ return nil, fmt.Errorf("failed to walk directory tree: %w", err)
+ }
+
+ return matches, nil
+}
+
+// walkWithDepthLimit walks a directory tree with depth limiting.
+func (t *bubblewrapPolicyTranslator) walkWithDepthLimit(root string, suffix string, maxDepth int, maxPaths int, matches *[]string) error {
+ err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ // Skip paths we can't access
+ return nil
+ }
+
+ // Calculate depth relative to root
+ relPath, err := filepath.Rel(root, path)
+ if err != nil {
+ return nil
+ }
+ // When relPath is "." (the root itself), depth should be 0
+ // strings.Split(".", "/") returns ["."] with length 1, causing off-by-one error
+ depth := 0
+ if relPath != "." {
+ depth = len(strings.Split(relPath, string(filepath.Separator)))
+ }
+
+ // Enforce depth limit
+ if maxDepth > 0 && depth > maxDepth {
+ if info.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ // Match suffix
+ if suffix == "" || strings.HasSuffix(path, suffix) {
+ *matches = append(*matches, path)
+
+ // Enforce path count limit
+ if len(*matches) >= maxPaths {
+ return filepath.SkipAll
+ }
+ }
+
+ return nil
+ })
+
+ 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 {
+ args := []string{}
+
+ // Bind /dev/pts for PTY allocation
+ args = append(args, "--dev-bind-try", "/dev/pts", "/dev/pts")
+
+ // Bind /dev/ptmx for PTY master
+ args = append(args, "--dev-bind-try", "/dev/ptmx", "/dev/ptmx")
+
+ return args
+}
+
+// addTmpdirSupport adds arguments for temporary directory access.
+// Package managers need writable temp space for downloads, extraction, etc.
+func (t *bubblewrapPolicyTranslator) addTmpdirSupport() []string {
+ args := []string{}
+ tmpDir := os.TempDir()
+
+ // Bind tmp directory as writable
+ // Use --bind instead of --bind-try to ensure it's available
+ args = append(args, "--bind", tmpDir, tmpDir)
+
+ return args
+}
diff --git a/sandbox/platform/bubblewrap_translator_linux_test.go b/sandbox/platform/bubblewrap_translator_linux_test.go
new file mode 100644
index 0000000..dca942b
--- /dev/null
+++ b/sandbox/platform/bubblewrap_translator_linux_test.go
@@ -0,0 +1,917 @@
+//go:build linux
+// +build linux
+
+package platform
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/safedep/dry/utils"
+ "github.com/safedep/pmg/sandbox"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBubblewrapTranslatorBasicTranslation(t *testing.T) {
+ policy := &sandbox.SandboxPolicy{
+ Name: "test",
+ Description: "test policy",
+ PackageManagers: []string{"npm"},
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{"/tmp"},
+ AllowWrite: []string{"/tmp"},
+ },
+ Network: sandbox.NetworkPolicy{
+ AllowOutbound: []string{"registry.npmjs.org:443"},
+ },
+ }
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ args, err := translator.translate(policy)
+ require.NoError(t, err)
+ require.NotEmpty(t, args)
+
+ // Convert to string for easier assertion
+ argsStr := argSliceToString(args)
+
+ // Essential system paths should be mounted read-only
+ assert.Contains(t, argsStr, "--ro-bind-try")
+ assert.Contains(t, argsStr, "/usr")
+ assert.Contains(t, argsStr, "/lib")
+
+ // Essential devices should be mounted
+ assert.Contains(t, argsStr, "--dev-bind-try")
+ assert.Contains(t, argsStr, "/dev/null")
+
+ // Proc filesystem should be mounted
+ assert.Contains(t, argsStr, "--proc")
+ assert.Contains(t, argsStr, "/proc")
+
+ // User-specified paths should be mounted
+ assert.Contains(t, argsStr, "/tmp")
+
+ // Network should be allowed (no --unshare-net)
+ assert.NotContains(t, argsStr, "--unshare-net")
+
+ // Process isolation should be enabled
+ assert.Contains(t, argsStr, "--unshare-pid")
+ assert.Contains(t, argsStr, "--unshare-ipc")
+
+ // Die with parent
+ assert.Contains(t, argsStr, "--die-with-parent")
+}
+
+func TestBubblewrapTranslatorFilesystemRules(t *testing.T) {
+ cases := []struct {
+ name string
+ policy *sandbox.SandboxPolicy
+ assert func(t *testing.T, args []string, err error)
+ }{
+ {
+ name: "simple read-only path",
+ policy: &sandbox.SandboxPolicy{
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{"/usr/local"},
+ },
+ },
+ assert: func(t *testing.T, args []string, err error) {
+ require.NoError(t, err)
+ argsStr := argSliceToString(args)
+ // Should have read-only bind for the path
+ assert.Contains(t, argsStr, "--ro-bind-try")
+ assert.Contains(t, argsStr, "/usr/local")
+ },
+ },
+ {
+ name: "simple read-write path",
+ policy: &sandbox.SandboxPolicy{
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowWrite: []string{"/tmp/test"},
+ },
+ },
+ assert: func(t *testing.T, args []string, err error) {
+ require.NoError(t, err)
+ argsStr := argSliceToString(args)
+ // Should have read-write bind for the path
+ assert.Contains(t, argsStr, "--bind-try")
+ assert.Contains(t, argsStr, "/tmp/test")
+ },
+ },
+ {
+ name: "variable expansion in paths",
+ policy: &sandbox.SandboxPolicy{
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{"${HOME}/.npmrc"},
+ AllowWrite: []string{"${CWD}/node_modules"},
+ },
+ },
+ assert: func(t *testing.T, args []string, err error) {
+ require.NoError(t, err)
+ argsStr := argSliceToString(args)
+
+ homeDir, err := os.UserHomeDir()
+ require.NoError(t, err)
+ cwd, err := os.Getwd()
+ require.NoError(t, err)
+
+ // Variables should be expanded
+ assert.Contains(t, argsStr, homeDir+"/.npmrc")
+ assert.Contains(t, argsStr, cwd+"/node_modules")
+ },
+ },
+ {
+ name: "deny write with /dev/null mount",
+ policy: &sandbox.SandboxPolicy{
+ Filesystem: sandbox.FilesystemPolicy{
+ DenyWrite: []string{"/etc/passwd"},
+ },
+ },
+ assert: func(t *testing.T, args []string, err error) {
+ require.NoError(t, err)
+ argsStr := argSliceToString(args)
+
+ // Should mount /dev/null over denied path if it exists
+ // Since /etc/passwd exists, it should be blocked
+ if _, err := os.Stat("/etc/passwd"); err == nil {
+ assert.Contains(t, argsStr, "--ro-bind")
+ assert.Contains(t, argsStr, "/dev/null")
+ assert.Contains(t, argsStr, "/etc/passwd")
+ }
+ },
+ },
+ {
+ name: "multiple paths",
+ policy: &sandbox.SandboxPolicy{
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{
+ "/usr/bin",
+ "/usr/lib",
+ "/var/log",
+ },
+ AllowWrite: []string{
+ "/tmp/output",
+ "/var/tmp/cache",
+ },
+ },
+ },
+ assert: func(t *testing.T, args []string, err error) {
+ require.NoError(t, err)
+ argsStr := argSliceToString(args)
+
+ // All read paths should be present
+ assert.Contains(t, argsStr, "/usr/bin")
+ assert.Contains(t, argsStr, "/usr/lib")
+ assert.Contains(t, argsStr, "/var/log")
+
+ // All write paths should be present
+ assert.Contains(t, argsStr, "/tmp/output")
+ assert.Contains(t, argsStr, "/var/tmp/cache")
+ },
+ },
+ }
+
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ args, err := translator.translate(tt.policy)
+ tt.assert(t, args, err)
+ })
+ }
+}
+
+func TestBubblewrapTranslatorGlobPatterns(t *testing.T) {
+ // Create a temporary directory structure for testing glob expansion
+ tmpDir := t.TempDir()
+
+ // Create test files
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "subdir1"), 0755))
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "subdir2"), 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("test"), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "file2.log"), []byte("test"), 0644))
+
+ cases := []struct {
+ name string
+ policy *sandbox.SandboxPolicy
+ assert func(t *testing.T, args []string, err error)
+ }{
+ {
+ name: "glob pattern with *",
+ policy: &sandbox.SandboxPolicy{
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{tmpDir + "/*.txt"},
+ },
+ },
+ assert: func(t *testing.T, args []string, err error) {
+ require.NoError(t, err)
+ argsStr := argSliceToString(args)
+
+ // Should expand to concrete file
+ assert.Contains(t, argsStr, "file1.txt")
+ // Should NOT match .log files
+ assert.NotContains(t, argsStr, "file2.log")
+ },
+ },
+ {
+ name: "glob pattern with ** (recursive)",
+ policy: &sandbox.SandboxPolicy{
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowWrite: []string{tmpDir + "/**"},
+ },
+ },
+ assert: func(t *testing.T, args []string, err error) {
+ require.NoError(t, err)
+ argsStr := argSliceToString(args)
+
+ // Should include the base directory
+ assert.Contains(t, argsStr, tmpDir)
+ // Should include subdirectories
+ assert.Contains(t, argsStr, "subdir1")
+ assert.Contains(t, argsStr, "subdir2")
+ },
+ },
+ {
+ name: "non-existent glob pattern",
+ policy: &sandbox.SandboxPolicy{
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowRead: []string{"/nonexistent/path/**"},
+ },
+ },
+ assert: func(t *testing.T, args []string, err error) {
+ require.NoError(t, err)
+ argsStr := argSliceToString(args)
+
+ // Should still include the base path (even if doesn't exist)
+ assert.Contains(t, argsStr, "/nonexistent/path")
+ },
+ },
+ }
+
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ args, err := translator.translate(tt.policy)
+ tt.assert(t, args, err)
+ })
+ }
+}
+
+func TestBubblewrapTranslatorNetworkIsolation(t *testing.T) {
+ cases := []struct {
+ name string
+ policy *sandbox.SandboxPolicy
+ assert func(t *testing.T, args []string, err error)
+ }{
+ {
+ name: "network allowed with allow rules",
+ policy: &sandbox.SandboxPolicy{
+ Network: sandbox.NetworkPolicy{
+ AllowOutbound: []string{"registry.npmjs.org:443"},
+ },
+ },
+ assert: func(t *testing.T, args []string, err error) {
+ require.NoError(t, err)
+ argsStr := argSliceToString(args)
+
+ // Should NOT have --unshare-net (network allowed)
+ assert.NotContains(t, argsStr, "--unshare-net")
+ },
+ },
+ {
+ name: "network isolated with deny all",
+ policy: &sandbox.SandboxPolicy{
+ Network: sandbox.NetworkPolicy{
+ DenyOutbound: []string{"*:*"},
+ },
+ },
+ assert: func(t *testing.T, args []string, err error) {
+ require.NoError(t, err)
+ argsStr := argSliceToString(args)
+
+ // Should have --unshare-net (network denied)
+ assert.Contains(t, argsStr, "--unshare-net")
+ },
+ },
+ {
+ name: "network isolated by default when no rules",
+ policy: &sandbox.SandboxPolicy{
+ Network: sandbox.NetworkPolicy{},
+ },
+ assert: func(t *testing.T, args []string, err error) {
+ require.NoError(t, err)
+ argsStr := argSliceToString(args)
+
+ // With default config (unshareNetworkByDefault: true), should isolate
+ assert.Contains(t, argsStr, "--unshare-net")
+ },
+ },
+ {
+ name: "network allowed when config disables default isolation",
+ policy: &sandbox.SandboxPolicy{
+ Network: sandbox.NetworkPolicy{},
+ },
+ assert: func(t *testing.T, args []string, err error) {
+ require.NoError(t, err)
+ // This test needs a custom config, so we can't assert here
+ // Just verify no error
+ },
+ },
+ }
+
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ args, err := translator.translate(tt.policy)
+ tt.assert(t, args, err)
+ })
+ }
+}
+
+func TestBubblewrapTranslatorPTYSupport(t *testing.T) {
+ t.Run("PTY disabled by default", func(t *testing.T) {
+ policy := &sandbox.SandboxPolicy{
+ AllowPTY: utils.PtrTo(false),
+ }
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ args, err := translator.translate(policy)
+ require.NoError(t, err)
+
+ argsStr := argSliceToString(args)
+
+ // Should NOT have PTY device bindings
+ assert.NotContains(t, argsStr, "/dev/pts")
+ assert.NotContains(t, argsStr, "/dev/ptmx")
+ })
+
+ t.Run("PTY enabled when requested", func(t *testing.T) {
+ policy := &sandbox.SandboxPolicy{
+ AllowPTY: utils.PtrTo(true),
+ }
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ args, err := translator.translate(policy)
+ require.NoError(t, err)
+
+ argsStr := argSliceToString(args)
+
+ // Should have PTY device bindings
+ assert.Contains(t, argsStr, "/dev/pts")
+ assert.Contains(t, argsStr, "/dev/ptmx")
+ assert.Contains(t, argsStr, "--dev-bind-try")
+ })
+}
+
+func TestBubblewrapTranslatorMandatoryDenies(t *testing.T) {
+ // Create temp directory with some dangerous files
+ tmpDir := t.TempDir()
+ sshDir := filepath.Join(tmpDir, ".ssh")
+ require.NoError(t, os.MkdirAll(sshDir, 0700))
+ require.NoError(t, os.WriteFile(filepath.Join(sshDir, "id_rsa"), []byte("fake key"), 0600))
+
+ policy := &sandbox.SandboxPolicy{
+ Filesystem: sandbox.FilesystemPolicy{
+ // Even with broad write permissions...
+ AllowWrite: []string{tmpDir + "/**"},
+ },
+ }
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ args, err := translator.translate(policy)
+ require.NoError(t, err)
+
+ argsStr := argSliceToString(args)
+
+ // Mandatory deny patterns should be present
+ // Note: The actual paths depend on the current working directory and home
+ // We just verify that /dev/null mounting is used
+ assert.Contains(t, argsStr, "--ro-bind")
+ assert.Contains(t, argsStr, "/dev/null")
+}
+
+func TestBubblewrapTranslatorGitConfigDeny(t *testing.T) {
+ t.Run("git config denied by default", func(t *testing.T) {
+ policy := &sandbox.SandboxPolicy{
+ AllowGitConfig: utils.PtrTo(false),
+ }
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ _, err := translator.translate(policy)
+ require.NoError(t, err)
+
+ // Git config should be in deny patterns
+ // (we can't easily assert the exact args without creating a .git directory)
+ })
+
+ t.Run("git config allowed when explicitly set", func(t *testing.T) {
+ policy := &sandbox.SandboxPolicy{
+ AllowGitConfig: utils.PtrTo(true),
+ }
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ _, err := translator.translate(policy)
+ require.NoError(t, err)
+
+ // Should succeed without adding git config to deny patterns
+ })
+}
+
+func TestBubblewrapConfigDefaults(t *testing.T) {
+ config := newDefaultBubblewrapConfig()
+
+ // Essential system paths
+ assert.NotEmpty(t, config.essentialSystemPaths)
+ assert.Contains(t, config.essentialSystemPaths, "/usr")
+ assert.Contains(t, config.essentialSystemPaths, "/lib")
+
+ // Essential devices
+ assert.NotEmpty(t, config.essentialDevices)
+ assert.Contains(t, config.essentialDevices, "/dev/null")
+ assert.Contains(t, config.essentialDevices, "/dev/random")
+
+ // Glob limits
+ assert.Equal(t, 5, config.maxGlobDepth)
+ assert.Equal(t, 10000, config.maxGlobPaths)
+
+ // Isolation settings
+ assert.True(t, config.unshareNetworkByDefault)
+ assert.True(t, config.unsharePID)
+ assert.True(t, config.unshareIPC)
+ assert.True(t, config.dieWithParent)
+}
+
+func TestBubblewrapConfigEssentialPaths(t *testing.T) {
+ config := newDefaultBubblewrapConfig()
+
+ // Get essential system paths (filters out non-existent)
+ paths := config.getEssentialSystemPaths()
+ assert.NotEmpty(t, paths)
+
+ // All returned paths should exist
+ for _, path := range paths {
+ _, err := os.Stat(path)
+ assert.NoError(t, err, "Essential path %s should exist", path)
+ }
+}
+
+func TestBubblewrapConfigEssentialDevices(t *testing.T) {
+ config := newDefaultBubblewrapConfig()
+
+ // Get essential devices (filters out non-existent)
+ devices := config.getEssentialDevices()
+ assert.NotEmpty(t, devices)
+
+ // All returned devices should exist
+ for _, device := range devices {
+ _, err := os.Stat(device)
+ assert.NoError(t, err, "Essential device %s should exist", device)
+ }
+}
+
+func TestBubblewrapTranslatorProcessDenyRule(t *testing.T) {
+ t.Run("existing file is blocked with /dev/null", func(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Create a test file that exists
+ testFile := filepath.Join(tmpDir, "existing.txt")
+ require.NoError(t, os.WriteFile(testFile, []byte("test"), 0644))
+
+ policy := &sandbox.SandboxPolicy{
+ Filesystem: sandbox.FilesystemPolicy{
+ DenyWrite: []string{testFile},
+ },
+ }
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ args, err := translator.translate(policy)
+ require.NoError(t, err)
+
+ argsStr := argSliceToString(args)
+
+ // Existing file should be mounted with /dev/null
+ assert.Contains(t, argsStr, "--ro-bind")
+ assert.Contains(t, argsStr, "/dev/null")
+ assert.Contains(t, argsStr, testFile)
+ })
+
+ t.Run("non-existent file is skipped to avoid creating empty files", func(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Non-existent file - should be skipped because using --ro-bind /dev/null
+ // on non-existent paths causes bwrap to create the file as a mount point
+ nonExistentPath := filepath.Join(tmpDir, ".env")
+
+ policy := &sandbox.SandboxPolicy{
+ Filesystem: sandbox.FilesystemPolicy{
+ AllowWrite: []string{tmpDir}, // Allow writes to tmpDir
+ DenyWrite: []string{nonExistentPath},
+ },
+ }
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ args, err := translator.translate(policy)
+ require.NoError(t, err)
+
+ argsStr := argSliceToString(args)
+
+ // Non-existent file should NOT be in args
+ // Using --ro-bind /dev/null on non-existent paths creates empty files
+ assert.NotContains(t, argsStr, nonExistentPath)
+ })
+
+ t.Run("existing directory is mounted read-only", func(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Create a directory
+ testDir := filepath.Join(tmpDir, "secrets")
+ require.NoError(t, os.MkdirAll(testDir, 0755))
+
+ policy := &sandbox.SandboxPolicy{
+ Filesystem: sandbox.FilesystemPolicy{
+ DenyWrite: []string{testDir},
+ },
+ }
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ args, err := translator.translate(policy)
+ require.NoError(t, err)
+
+ argsStr := argSliceToString(args)
+
+ // Existing directory should be mounted read-only
+ assert.Contains(t, argsStr, "--ro-bind-try")
+ assert.Contains(t, argsStr, testDir)
+ })
+}
+
+func TestBubblewrapTranslatorTmpdirSupport(t *testing.T) {
+ policy := &sandbox.SandboxPolicy{}
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+ args, err := translator.translate(policy)
+ require.NoError(t, err)
+
+ argsStr := argSliceToString(args)
+
+ // Tmpdir should be mounted as writable
+ tmpDir := os.TempDir()
+ assert.Contains(t, argsStr, "--bind")
+ assert.Contains(t, argsStr, tmpDir)
+}
+
+func TestExpandGlobstarPattern(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Create a directory structure
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "dir1", "subdir"), 0755))
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "dir2"), 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "file.txt"), []byte("test"), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "dir1", "file2.txt"), []byte("test"), 0644))
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+
+ cases := []struct {
+ name string
+ pattern string
+ maxDepth int
+ maxPaths int
+ assert func(t *testing.T, matches []string, err error)
+ }{
+ {
+ name: "simple globstar",
+ pattern: tmpDir + "/**",
+ maxDepth: 3,
+ maxPaths: 100,
+ assert: func(t *testing.T, matches []string, err error) {
+ require.NoError(t, err)
+ assert.NotEmpty(t, matches)
+ // Should include base directory
+ assert.Contains(t, matches, tmpDir)
+ },
+ },
+ {
+ name: "globstar with depth limit",
+ pattern: tmpDir + "/**",
+ maxDepth: 1,
+ maxPaths: 100,
+ assert: func(t *testing.T, matches []string, err error) {
+ require.NoError(t, err)
+ // Should be limited by depth
+ for _, match := range matches {
+ rel, err := filepath.Rel(tmpDir, match)
+ require.NoError(t, err)
+ depth := len(filepath.SplitList(rel))
+ assert.LessOrEqual(t, depth, 2) // Base + 1 level
+ }
+ },
+ },
+ {
+ name: "globstar with count limit",
+ pattern: tmpDir + "/**",
+ maxDepth: 10,
+ maxPaths: 2,
+ assert: func(t *testing.T, matches []string, err error) {
+ require.NoError(t, err)
+ // Should be limited by count
+ assert.LessOrEqual(t, len(matches), 2)
+ },
+ },
+ {
+ name: "non-existent base path",
+ pattern: "/nonexistent/path/**",
+ maxDepth: 3,
+ maxPaths: 100,
+ assert: func(t *testing.T, matches []string, err error) {
+ require.NoError(t, err)
+ // Should return the base path even if it doesn't exist
+ assert.Contains(t, matches, "/nonexistent/path")
+ },
+ },
+ }
+
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ matches, err := translator.expandGlobstarPattern(tt.pattern, tt.maxDepth, tt.maxPaths)
+ tt.assert(t, matches, err)
+ })
+ }
+}
+
+func TestFindFirstNonExistentPath(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Create a directory structure
+ existingDir := filepath.Join(tmpDir, "existing")
+ require.NoError(t, os.MkdirAll(existingDir, 0755))
+
+ config := newDefaultBubblewrapConfig()
+ translator := newBubblewrapPolicyTranslator(config)
+
+ cases := []struct {
+ name string
+ path string
+ expected string
+ }{
+ {
+ name: "file in existing directory",
+ path: filepath.Join(existingDir, "nonexistent.txt"),
+ expected: filepath.Join(existingDir, "nonexistent.txt"),
+ },
+ {
+ name: "nested non-existent path",
+ path: filepath.Join(existingDir, "deep", "nested", "file.txt"),
+ expected: filepath.Join(existingDir, "deep"),
+ },
+ {
+ name: "completely non-existent path",
+ path: "/totally/nonexistent/path/file.txt",
+ expected: "", // No parent exists, can't block creation
+ },
+ }
+
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ result := translator.findFirstNonExistentPath(tt.path)
+ if tt.expected == "" {
+ // For completely non-existent paths, we might get empty or a high-level path
+ // Just verify no panic
+ assert.True(t, true)
+ } else {
+ assert.Equal(t, tt.expected, result)
+ }
+ })
+ }
+}
+
+// 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
+}
diff --git a/sandbox/platform/platform_linux.go b/sandbox/platform/platform_linux.go
index aee0ff3..80b432f 100644
--- a/sandbox/platform/platform_linux.go
+++ b/sandbox/platform/platform_linux.go
@@ -4,13 +4,11 @@
package platform
import (
- "errors"
-
"github.com/safedep/pmg/sandbox"
)
// NewSandbox creates a platform-specific sandbox instance for Linux.
-// TODO: Implement Bubblewrap or seccomp-bpf based sandbox.
+// Uses Bubblewrap (bwrap) for filesystem, network, and process isolation.
func NewSandbox() (sandbox.Sandbox, error) {
- return nil, errors.New("sandbox not yet implemented for Linux")
+ return newBubblewrapSandbox()
}
diff --git a/test/sandbox-e2e.js b/test/sandbox-e2e.js
index 4b47a53..e236960 100644
--- a/test/sandbox-e2e.js
+++ b/test/sandbox-e2e.js
@@ -6,6 +6,27 @@ const os = require('os');
const home = os.homedir();
const results = { passed: 0, failed: 0, tests: [] };
+// Helper to test if a directory is blocked (either EPERM or empty via tmpfs)
+function isDirectoryBlocked(dirPath) {
+ try {
+ const contents = fs.readdirSync(dirPath);
+ // On Linux/bwrap, tmpfs makes directory empty (credentials hidden)
+ // On macOS/seatbelt, access is denied (EPERM)
+ if (contents.length === 0) {
+ return { blocked: true, reason: 'empty via tmpfs' };
+ }
+ return { blocked: false, reason: 'contents readable' };
+ } catch (e) {
+ if (e.code === 'EPERM') {
+ return { blocked: true, reason: 'EPERM' };
+ }
+ if (e.code === 'ENOENT') {
+ return { blocked: true, reason: 'does not exist', skip: true };
+ }
+ return { blocked: true, reason: e.code, skip: true };
+ }
+}
+
function test(name, fn) {
try {
const result = fn();
@@ -26,66 +47,62 @@ console.log('--- Tests that SHOULD be BLOCKED ---\n');
// Test 1: Read ~/.ssh (should be blocked)
test('BLOCK: Read ~/.ssh directory', () => {
- try {
- fs.readdirSync(path.join(home, '.ssh'));
- console.log(' ❌ FAIL: Could read ~/.ssh');
- return false;
- } catch (e) {
- if (e.code === 'EPERM') {
- console.log(' ✅ PASS: ~/.ssh blocked (EPERM)');
- return true;
- }
- console.log(` ⚠️ SKIP: ~/.ssh - ${e.code} (may not exist)`);
- return true; // ENOENT is okay if dir doesn't exist
+ const result = isDirectoryBlocked(path.join(home, '.ssh'));
+ if (result.skip) {
+ console.log(` ⚠️ SKIP: ~/.ssh - ${result.reason}`);
+ return true;
}
+ if (result.blocked) {
+ console.log(` ✅ PASS: ~/.ssh blocked (${result.reason})`);
+ return true;
+ }
+ console.log(' ❌ FAIL: Could read ~/.ssh contents');
+ return false;
});
// Test 2: Read ~/.aws (should be blocked)
test('BLOCK: Read ~/.aws directory', () => {
- try {
- fs.readdirSync(path.join(home, '.aws'));
- console.log(' ❌ FAIL: Could read ~/.aws');
- return false;
- } catch (e) {
- if (e.code === 'EPERM') {
- console.log(' ✅ PASS: ~/.aws blocked (EPERM)');
- return true;
- }
- console.log(` ⚠️ SKIP: ~/.aws - ${e.code} (may not exist)`);
+ const result = isDirectoryBlocked(path.join(home, '.aws'));
+ if (result.skip) {
+ console.log(` ⚠️ SKIP: ~/.aws - ${result.reason}`);
return true;
}
+ if (result.blocked) {
+ console.log(` ✅ PASS: ~/.aws blocked (${result.reason})`);
+ return true;
+ }
+ console.log(' ❌ FAIL: Could read ~/.aws contents');
+ return false;
});
// Test 3: Read ~/.kube (should be blocked)
-test('BLOCK: Read ~/.kube/config', () => {
- try {
- fs.readFileSync(path.join(home, '.kube', 'config'));
- console.log(' ❌ FAIL: Could read ~/.kube/config');
- return false;
- } catch (e) {
- if (e.code === 'EPERM') {
- console.log(' ✅ PASS: ~/.kube/config blocked (EPERM)');
- return true;
- }
- console.log(` ⚠️ SKIP: ~/.kube/config - ${e.code} (may not exist)`);
+test('BLOCK: Read ~/.kube directory', () => {
+ const result = isDirectoryBlocked(path.join(home, '.kube'));
+ if (result.skip) {
+ console.log(` ⚠️ SKIP: ~/.kube - ${result.reason}`);
return true;
}
+ if (result.blocked) {
+ console.log(` ✅ PASS: ~/.kube blocked (${result.reason})`);
+ return true;
+ }
+ console.log(' ❌ FAIL: Could read ~/.kube contents');
+ return false;
});
// Test 4: Read ~/.gcloud (should be blocked)
test('BLOCK: Read ~/.gcloud directory', () => {
- try {
- fs.readdirSync(path.join(home, '.gcloud'));
- console.log(' ❌ FAIL: Could read ~/.gcloud');
- return false;
- } catch (e) {
- if (e.code === 'EPERM') {
- console.log(' ✅ PASS: ~/.gcloud blocked (EPERM)');
- return true;
- }
- console.log(` ⚠️ SKIP: ~/.gcloud - ${e.code} (may not exist)`);
+ const result = isDirectoryBlocked(path.join(home, '.gcloud'));
+ if (result.skip) {
+ console.log(` ⚠️ SKIP: ~/.gcloud - ${result.reason}`);
return true;
}
+ if (result.blocked) {
+ console.log(` ✅ PASS: ~/.gcloud blocked (${result.reason})`);
+ return true;
+ }
+ console.log(' ❌ FAIL: Could read ~/.gcloud contents');
+ return false;
});
// Test 5: Write to /etc (should be blocked)