feat: Sandbox implementation with seatbelt

This commit is contained in:
Abhisek Datta
2026-01-08 13:40:22 +05:30
parent 6e830c4c3d
commit ed59693f88
20 changed files with 1235 additions and 0 deletions
+4
View File
@@ -21,4 +21,8 @@ func ApplyCobraFlags(cmd *cobra.Command) {
globalConfig.Config.SkipEventLogging, "Skip event logging") globalConfig.Config.SkipEventLogging, "Skip event logging")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.ExperimentalProxyMode, "experimental-proxy-mode", cmd.PersistentFlags().BoolVar(&globalConfig.Config.ExperimentalProxyMode, "experimental-proxy-mode",
globalConfig.Config.ExperimentalProxyMode, "Use experimental proxy-based interception (EXPERIMENTAL)") globalConfig.Config.ExperimentalProxyMode, "Use experimental proxy-based interception (EXPERIMENTAL)")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.Sandbox.Enabled, "sandbox",
globalConfig.Config.Sandbox.Enabled, "Enable sandbox mode to isolate package manager processes (EXPERIMENTAL)")
cmd.PersistentFlags().StringVar(&globalConfig.Config.Sandbox.ViolationMode, "sandbox-violation-mode",
globalConfig.Config.Sandbox.ViolationMode, "How to handle sandbox policy violations: block, warn, or allow")
} }
+27
View File
@@ -59,6 +59,33 @@ type Config struct {
// ExperimentalProxyMode enables experimental proxy-based package interception. // ExperimentalProxyMode enables experimental proxy-based package interception.
// When enabled, PMG starts a proxy server and intercepts package manager requests in real-time. // When enabled, PMG starts a proxy server and intercepts package manager requests in real-time.
ExperimentalProxyMode bool `mapstructure:"experimental_proxy_mode"` ExperimentalProxyMode bool `mapstructure:"experimental_proxy_mode"`
// Sandbox enables sandboxing of package manager processes with controlled filesystem,
// network, and process execution access. Provides defense-in-depth against supply chain attacks.
Sandbox SandboxConfig `mapstructure:"sandbox"`
}
// SandboxConfig configures the sandbox system for isolating package manager processes.
type SandboxConfig struct {
// Enabled enables sandbox mode (opt-in by default for backward compatibility).
Enabled bool `mapstructure:"enabled"`
// ViolationMode defines how policy violations are handled (block, warn, or allow).
ViolationMode string `mapstructure:"violation_mode"`
// Policies maps package manager names to their sandbox policy references.
// Key is package manager name (e.g., "npm", "pip"), value is policy reference.
Policies map[string]SandboxPolicyRef `mapstructure:"policies"`
}
// SandboxPolicyRef references a sandbox policy for a specific package manager.
type SandboxPolicyRef struct {
// Enabled enables sandboxing for this specific package manager.
Enabled bool `mapstructure:"enabled"`
// Profile is the name of a built-in profile (e.g., "npm-restrictive")
// or an absolute path to a custom YAML policy file.
Profile string `mapstructure:"profile"`
} }
// TrustedPackage is a package that is trusted by the user and will be ignored by the security guardrails. // TrustedPackage is a package that is trusted by the user and will be ignored by the security guardrails.
+67
View File
@@ -46,3 +46,70 @@ experimental_proxy_mode: false
trusted_packages: trusted_packages:
- purl: pkg:npm/@safedep/pmg - purl: pkg:npm/@safedep/pmg
reason: "PMG is a trusted package for PMG" reason: "PMG is a trusted package for PMG"
# Sandbox configuration (EXPERIMENTAL)
# When enabled, package managers run in sandboxed environments with restricted
# filesystem, network, and process execution access. This provides defense-in-depth
# protection against malicious install scripts and supply chain attacks.
#
# Currently supported platforms:
# - macOS (using Seatbelt sandbox-exec)
# - Linux (coming soon: Bubblewrap or seccomp-bpf)
# - Windows (coming soon)
sandbox:
# Enable sandbox mode (opt-in, default: false for backward compatibility)
enabled: false
# How to handle policy violations: block | warn | allow
# - block: Prevent execution on policy violation (recommended)
# - warn: Log warning but allow execution
# - allow: Allow all operations (disables sandbox)
violation_mode: block
# Per-package-manager sandbox policies
# Each package manager can have its own policy to account for unique security characteristics
policies:
# npm ecosystem
npm:
enabled: true
profile: npm-restrictive # Built-in profile or path to custom YAML
pnpm:
enabled: true
profile: npm-restrictive
yarn:
enabled: true
profile: npm-restrictive
bun:
enabled: true
profile: npm-restrictive
# PyPI ecosystem
pip:
enabled: true
profile: pypi-restrictive
pip3:
enabled: true
profile: pypi-restrictive
poetry:
enabled: true
profile: pypi-restrictive
uv:
enabled: true
profile: pypi-restrictive
# Usage:
# 1. Enable sandbox globally: set sandbox.enabled to true
# 2. Enable via CLI flag: pmg --sandbox npm install lodash
# 3. Use custom profile: pmg --sandbox-profile=/path/to/policy.yml npm install
#
# Built-in profiles:
# - npm-restrictive: Balanced security for npm/pnpm/yarn/bun
# - pypi-restrictive: Balanced security for pip/poetry/uv
#
# See sandbox/profiles/ directory for profile definitions
+7
View File
@@ -17,6 +17,7 @@ import (
"github.com/safedep/pmg/internal/eventlog" "github.com/safedep/pmg/internal/eventlog"
"github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/packagemanager" "github.com/safedep/pmg/packagemanager"
"github.com/safedep/pmg/sandbox"
) )
type PackageManagerGuardInteraction struct { type PackageManagerGuardInteraction struct {
@@ -220,6 +221,12 @@ func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *package
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
// Apply sandbox if enabled
pmName := g.packageManager.Name()
if err := sandbox.ApplySandbox(ctx, cmd, pmName, ""); err != nil {
return fmt.Errorf("failed to apply sandbox: %w", err)
}
// We will fail based on executed command's exit code. This is important // We will fail based on executed command's exit code. This is important
// because other tools (scripts, CI etc.) may depend on this exit code. // because other tools (scripts, CI etc.) may depend on this exit code.
return cmd.Run() return cmd.Run()
+7
View File
@@ -17,6 +17,7 @@ import (
"github.com/safedep/pmg/proxy" "github.com/safedep/pmg/proxy"
"github.com/safedep/pmg/proxy/certmanager" "github.com/safedep/pmg/proxy/certmanager"
"github.com/safedep/pmg/proxy/interceptors" "github.com/safedep/pmg/proxy/interceptors"
"github.com/safedep/pmg/sandbox"
) )
type proxyFlow struct { type proxyFlow struct {
@@ -241,6 +242,12 @@ func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemana
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
// Apply sandbox if enabled (sandbox preserves proxy environment variables already set on cmd.Env)
pmName := f.pm.Name()
if err := sandbox.ApplySandbox(ctx, cmd, pmName, "proxy mode"); err != nil {
return fmt.Errorf("failed to apply sandbox: %w", err)
}
log.Debugf("Executing command: %s %v", parsedCmd.Command.Exe, parsedCmd.Command.Args) log.Debugf("Executing command: %s %v", parsedCmd.Command.Exe, parsedCmd.Command.Args)
log.Debugf("Proxy environment: HTTP_PROXY=%s, HTTPS_PROXY=%s, NODE_EXTRA_CA_CERTS=%s", proxyURL, proxyURL, caCertPath) log.Debugf("Proxy environment: HTTP_PROXY=%s, HTTPS_PROXY=%s, NODE_EXTRA_CA_CERTS=%s", proxyURL, proxyURL, caCertPath)
+79
View File
@@ -0,0 +1,79 @@
package sandbox
import (
"context"
"fmt"
"os/exec"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
)
// ApplySandbox applies sandbox isolation to the command if sandbox mode is enabled.
// This is a helper function used by both guard and proxy flows to avoid code duplication.
//
// Parameters:
// - ctx: Context for the sandbox execution
// - cmd: The exec.Cmd to be sandboxed (will be modified in place)
// - pmName: Package manager name (e.g., "npm", "pip")
// - mode: Optional mode description for logging (e.g., "proxy mode", empty for default)
//
// Returns an error if sandbox setup fails, or nil if sandbox is not enabled/available.
// Gracefully degrades with warnings if sandbox is unavailable on the platform.
func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, mode string) error {
cfg := config.Get()
// Check if sandbox is enabled globally
if !cfg.Config.Sandbox.Enabled {
return nil // Sandbox disabled, skip
}
// Check if sandbox policy exists for this package manager
policyRef, exists := cfg.Config.Sandbox.Policies[pmName]
if !exists || !policyRef.Enabled {
log.Debugf("No sandbox policy enabled for %s", pmName)
return nil
}
// Load the sandbox policy
registry := NewProfileRegistry()
policy, err := registry.GetProfile(policyRef.Profile)
if err != nil {
return fmt.Errorf("failed to load sandbox policy %s: %w", policyRef.Profile, err)
}
// Validate that the policy applies to this package manager
if !policy.AppliesToPackageManager(pmName) {
log.Warnf("Sandbox policy %s does not apply to %s", policy.Name, pmName)
return nil
}
// Create platform-specific sandbox
sb, err := NewSandbox()
if err != nil {
// Sandbox not available on this platform - log warning and continue
log.Warnf("Sandbox not available on this platform: %v", err)
log.Warnf("Continuing without sandbox protection")
return nil
}
if !sb.IsAvailable() {
log.Warnf("Sandbox %s not available, running without sandbox", sb.Name())
return nil
}
// Build log message with optional mode suffix
logMsg := fmt.Sprintf("Running %s in %s sandbox with policy %s", pmName, sb.Name(), policy.Name)
if mode != "" {
logMsg += fmt.Sprintf(" (%s)", mode)
}
log.Infof(logMsg)
// Execute sandbox setup (modifies cmd in place)
// Note: The sandbox preserves any environment variables already set on cmd.Env
if err := sb.Execute(ctx, cmd, policy); err != nil {
return fmt.Errorf("failed to setup sandbox: %w", err)
}
return nil
}
+142
View File
@@ -0,0 +1,142 @@
package sandbox
import (
"fmt"
"strings"
)
// SandboxPolicy represents a parsed and validated sandbox policy that defines
// filesystem, network, and process execution restrictions for package managers.
type SandboxPolicy struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
PackageManagers []string `yaml:"package_managers" json:"package_managers"`
ViolationMode string `yaml:"violation_mode" json:"violation_mode"`
Filesystem FilesystemPolicy `yaml:"filesystem" json:"filesystem"`
Network NetworkPolicy `yaml:"network" json:"network"`
Process ProcessPolicy `yaml:"process" json:"process"`
}
// FilesystemPolicy defines allowed and denied filesystem access patterns.
// Deny rules have higher priority than allow rules.
type FilesystemPolicy struct {
AllowRead []string `yaml:"allow_read" json:"allow_read"`
AllowWrite []string `yaml:"allow_write" json:"allow_write"`
DenyRead []string `yaml:"deny_read" json:"deny_read"`
DenyWrite []string `yaml:"deny_write" json:"deny_write"`
}
// NetworkPolicy defines allowed and denied network access patterns.
// Patterns are in the format "host:port" or "*:*" for wildcards.
type NetworkPolicy struct {
AllowOutbound []string `yaml:"allow_outbound" json:"allow_outbound"`
DenyOutbound []string `yaml:"deny_outbound" json:"deny_outbound"`
}
// ProcessPolicy defines allowed and denied process execution patterns.
// Patterns can be specific paths or glob patterns.
type ProcessPolicy struct {
AllowExec []string `yaml:"allow_exec" json:"allow_exec"`
DenyExec []string `yaml:"deny_exec" json:"deny_exec"`
}
// ViolationMode represents how sandbox policy violations are handled.
type ViolationMode int
const (
// ViolationModeBlock blocks execution when a policy violation is detected (default).
ViolationModeBlock ViolationMode = iota
// ViolationModeWarn logs a warning but allows execution to continue.
ViolationModeWarn
// ViolationModeAllow allows all operations (effectively disables the sandbox).
ViolationModeAllow
)
// String returns the string representation of the violation mode.
func (v ViolationMode) String() string {
switch v {
case ViolationModeBlock:
return "block"
case ViolationModeWarn:
return "warn"
case ViolationModeAllow:
return "allow"
default:
return "unknown"
}
}
// ParseViolationMode parses a violation mode string into a ViolationMode value.
func ParseViolationMode(s string) (ViolationMode, error) {
switch strings.ToLower(s) {
case "block":
return ViolationModeBlock, nil
case "warn":
return ViolationModeWarn, nil
case "allow":
return ViolationModeAllow, nil
default:
return ViolationModeBlock, fmt.Errorf("invalid violation mode: %s (must be block, warn, or allow)", s)
}
}
// Validate validates the sandbox policy for correctness.
// Returns an error if the policy is invalid.
func (p *SandboxPolicy) Validate() error {
if p.Name == "" {
return fmt.Errorf("policy name is required")
}
if len(p.PackageManagers) == 0 {
return fmt.Errorf("policy must specify at least one package manager")
}
// Validate violation mode
if p.ViolationMode != "" {
if _, err := ParseViolationMode(p.ViolationMode); err != nil {
return fmt.Errorf("invalid violation mode: %w", err)
}
}
// Validate that at least some rules are defined
hasRules := len(p.Filesystem.AllowRead) > 0 ||
len(p.Filesystem.AllowWrite) > 0 ||
len(p.Filesystem.DenyRead) > 0 ||
len(p.Filesystem.DenyWrite) > 0 ||
len(p.Network.AllowOutbound) > 0 ||
len(p.Network.DenyOutbound) > 0 ||
len(p.Process.AllowExec) > 0 ||
len(p.Process.DenyExec) > 0
if !hasRules {
return fmt.Errorf("policy must define at least one access rule")
}
return nil
}
// GetViolationMode returns the parsed violation mode for the policy.
// Returns ViolationModeBlock if not specified or invalid.
func (p *SandboxPolicy) GetViolationMode() ViolationMode {
if p.ViolationMode == "" {
return ViolationModeBlock
}
mode, err := ParseViolationMode(p.ViolationMode)
if err != nil {
return ViolationModeBlock
}
return mode
}
// AppliesToPackageManager returns true if this policy applies to the given package manager.
func (p *SandboxPolicy) AppliesToPackageManager(pm string) bool {
pmLower := strings.ToLower(pm)
for _, supported := range p.PackageManagers {
if strings.ToLower(supported) == pmLower {
return true
}
}
return false
}
+65
View File
@@ -0,0 +1,65 @@
# PMG Sandbox Profiles
This directory contains built-in sandbox policies for PMG package managers.
## Available Profiles
### npm-restrictive
Restrictive policy for the npm ecosystem (npm, pnpm, yarn, bun).
**Features:**
- Allows read access to current directory, package manager configs, and caches
- Restricts write access to `node_modules/` and lockfiles only
- Blocks access to sensitive files (`~/.ssh`, `~/.aws`, `.env` files)
- Allows network access to npm registries only
- Permits Node.js and git execution, blocks shell and curl/wget
**Use when:** You want balanced protection for npm package installations
### pypi-restrictive
Restrictive policy for the PyPI ecosystem (pip, pip3, poetry, uv).
**Features:**
- Allows read access to current directory, pip configs, and caches
- Restricts write access to virtual environments and package caches
- Blocks access to sensitive files (`~/.ssh`, `~/.aws`, `.env` files)
- Allows network access to PyPI registries only
- Permits Python, compilers (for native extensions), and git
**Use when:** You want balanced protection for pip package installations
## Custom Policies
You can create custom sandbox policies by:
1. Copying one of the built-in profiles
2. Modifying the rules to suit your needs
3. Referencing the custom profile in your PMG config:
```yaml
sandbox:
enabled: true
policies:
npm:
enabled: true
profile: /path/to/custom-npm-policy.yml
```
## Policy Schema
See the [Policy Schema Documentation](../policy.go) for details on the YAML structure.
### Supported Variables
- `${HOME}`: User home directory
- `${CWD}`: Current working directory
- `${PM_CACHE}`: Package manager cache directory
- `${TMPDIR}`: Temporary directory
### Violation Modes
- `block`: Block execution on policy violation (recommended)
- `warn`: Log warning but allow execution
- `allow`: Allow all operations (disables sandbox)
+73
View File
@@ -0,0 +1,73 @@
name: npm-restrictive
description: Restrictive sandbox policy for npm ecosystem (npm, pnpm, yarn, bun)
package_managers:
- npm
- pnpm
- yarn
- bun
violation_mode: block
filesystem:
allow_read:
- ${CWD}/**
- ${HOME}/.npmrc
- ${HOME}/.yarnrc
- ${HOME}/.yarnrc.yml
- ${HOME}/.bundle
- ${PM_CACHE}/**
- /usr/local/**
- /Library/**
- /System/Library/**
- /private/var/**
allow_write:
- ${CWD}/node_modules/**
- ${PM_CACHE}/**
- ${CWD}/package-lock.json
- ${CWD}/yarn.lock
- ${CWD}/pnpm-lock.yaml
- ${CWD}/bun.lockb
- ${TMPDIR}/**
deny_read:
- ${HOME}/.ssh/**
- ${HOME}/.aws/**
- ${HOME}/.gcloud/**
- ${HOME}/.kube/**
- "**/.env"
- "**/.env.*"
- ${HOME}/.docker/config.json
deny_write:
- ${HOME}/.ssh/**
- ${HOME}/.aws/**
- /etc/**
- /usr/**
- /bin/**
- /sbin/**
network:
allow_outbound:
- registry.npmjs.org:443
- registry.yarnpkg.com:443
- npm.pkg.github.com:443
- github.com:443
deny_outbound:
- "*:*"
process:
allow_exec:
- /usr/bin/node
- /usr/local/bin/node
- ${PM_CACHE}/**
- /usr/bin/git
- /usr/local/bin/git
deny_exec:
- /usr/bin/curl
- /usr/bin/wget
- /bin/bash
- /bin/sh
- /usr/bin/python*
+60
View File
@@ -0,0 +1,60 @@
name: pypi-restrictive
description: Restrictive sandbox policy for PyPI ecosystem (pip, poetry, uv)
package_managers:
- pip
- pip3
- poetry
- uv
violation_mode: block
filesystem:
allow_read:
- ${CWD}/**
- ${HOME}/.config/pip/**
- ${HOME}/.pip/**
- ${HOME}/.poetry/**
- ${PM_CACHE}/**
- /usr/local/**
- /Library/**
- /System/Library/**
allow_write:
- ${CWD}/.venv/**
- ${CWD}/venv/**
- ${PM_CACHE}/**
- ${HOME}/.local/lib/python*/**
- ${TMPDIR}/**
deny_read:
- ${HOME}/.ssh/**
- ${HOME}/.aws/**
- ${HOME}/.gcloud/**
- "**/.env"
deny_write:
- ${HOME}/.ssh/**
- /etc/**
- /usr/**
network:
allow_outbound:
- pypi.org:443
- files.pythonhosted.org:443
- github.com:443
deny_outbound:
- "*:*"
process:
allow_exec:
- /usr/bin/python*
- /usr/local/bin/python*
- /usr/bin/gcc
- /usr/bin/clang
- /usr/bin/git
deny_exec:
- /usr/bin/curl
- /usr/bin/wget
- /bin/bash
+148
View File
@@ -0,0 +1,148 @@
package sandbox
import (
"embed"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
//go:embed profiles/*.yml
var profilesFS embed.FS
// defaultProfileRegistry implements ProfileRegistry with support for
// built-in embedded profiles and custom user-provided profiles.
type defaultProfileRegistry struct {
mu sync.RWMutex
profiles map[string]*SandboxPolicy
}
// newDefaultProfileRegistry creates a new profile registry and loads built-in profiles.
func newDefaultProfileRegistry() *defaultProfileRegistry {
registry := &defaultProfileRegistry{
profiles: make(map[string]*SandboxPolicy),
}
// Load built-in profiles from embedded filesystem
if err := registry.loadBuiltinProfiles(); err != nil {
// Log error but don't fail - graceful degradation
fmt.Fprintf(os.Stderr, "Warning: failed to load built-in sandbox profiles: %v\n", err)
}
return registry
}
// loadBuiltinProfiles loads all built-in YAML profiles from the embedded filesystem.
func (r *defaultProfileRegistry) loadBuiltinProfiles() error {
entries, err := profilesFS.ReadDir("profiles")
if err != nil {
return fmt.Errorf("failed to read profiles directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yml") {
continue
}
profilePath := filepath.Join("profiles", entry.Name())
data, err := profilesFS.ReadFile(profilePath)
if err != nil {
return fmt.Errorf("failed to read profile %s: %w", entry.Name(), err)
}
policy, err := parsePolicy(data)
if err != nil {
return fmt.Errorf("failed to parse profile %s: %w", entry.Name(), err)
}
if err := policy.Validate(); err != nil {
return fmt.Errorf("invalid profile %s: %w", entry.Name(), err)
}
r.mu.Lock()
r.profiles[policy.Name] = policy
r.mu.Unlock()
}
return nil
}
// GetProfile retrieves a policy by name.
// First checks built-in profiles, then attempts to load as a custom file path.
func (r *defaultProfileRegistry) GetProfile(name string) (*SandboxPolicy, error) {
// Check if it's a built-in profile
r.mu.RLock()
if policy, exists := r.profiles[name]; exists {
r.mu.RUnlock()
return policy, nil
}
r.mu.RUnlock()
// Not a built-in profile - try to load as custom file
if fileExists(name) {
return r.LoadCustomProfile(name)
}
return nil, fmt.Errorf("sandbox profile not found: %s (not a built-in profile and file does not exist)", name)
}
// LoadCustomProfile loads a policy from a custom YAML file path.
func (r *defaultProfileRegistry) LoadCustomProfile(path string) (*SandboxPolicy, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read custom profile %s: %w", path, err)
}
policy, err := parsePolicy(data)
if err != nil {
return nil, fmt.Errorf("failed to parse custom profile %s: %w", path, err)
}
if err := policy.Validate(); err != nil {
return nil, fmt.Errorf("invalid custom profile %s: %w", path, err)
}
// Cache the custom profile for future use
r.mu.Lock()
r.profiles[policy.Name] = policy
r.mu.Unlock()
return policy, nil
}
// ListProfiles returns the names of all built-in profiles.
func (r *defaultProfileRegistry) ListProfiles() []string {
r.mu.RLock()
defer r.mu.RUnlock()
profiles := make([]string, 0, len(r.profiles))
for name := range r.profiles {
profiles = append(profiles, name)
}
return profiles
}
// parsePolicy parses a YAML policy file into a SandboxPolicy struct.
func parsePolicy(data []byte) (*SandboxPolicy, error) {
var policy SandboxPolicy
if err := yaml.Unmarshal(data, &policy); err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
}
return &policy, nil
}
// fileExists checks if a file exists and is not a directory.
func fileExists(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return !info.IsDir()
}
+47
View File
@@ -0,0 +1,47 @@
package sandbox
import (
"context"
"os/exec"
)
// Sandbox represents a platform-specific sandbox executor that isolates
// package manager processes with controlled access to filesystem, network,
// and process execution resources.
type Sandbox interface {
// Execute runs a command in the sandbox with the given policy.
// The command may be modified in place (e.g., wrapped with sandbox-exec).
// Returns an error if the sandbox setup fails.
Execute(ctx context.Context, cmd *exec.Cmd, policy *SandboxPolicy) error
// Name returns the sandbox implementation name (e.g., "seatbelt", "bubblewrap").
Name() string
// IsAvailable returns true if the sandbox is available and functional on this platform.
IsAvailable() bool
}
// NewSandbox creates a new platform-specific sandbox instance.
// The implementation is selected at compile time using build tags.
// Returns an error if the sandbox is not available on the current platform.
func NewSandbox() (Sandbox, error) {
return newPlatformSandbox()
}
// ProfileRegistry manages built-in and custom sandbox policies.
type ProfileRegistry interface {
// GetProfile retrieves a policy by name.
// Name can be a built-in profile (e.g., "npm-restrictive") or a path to a custom YAML file.
GetProfile(name string) (*SandboxPolicy, error)
// LoadCustomProfile loads a policy from a custom YAML file path.
LoadCustomProfile(path string) (*SandboxPolicy, error)
// ListProfiles returns the names of all built-in profiles.
ListProfiles() []string
}
// NewProfileRegistry creates a new profile registry with built-in policies.
func NewProfileRegistry() ProfileRegistry {
return newDefaultProfileRegistry()
}
+61
View File
@@ -0,0 +1,61 @@
//go:build darwin
// +build darwin
package sandbox
import (
"context"
"os/exec"
"github.com/safedep/pmg/sandbox/seatbelt"
)
// darwinSandboxAdapter adapts the seatbelt implementation to the Sandbox interface.
type darwinSandboxAdapter struct {
seatbelt *seatbelt.SeatbeltSandbox
}
// newPlatformSandbox creates a platform-specific sandbox instance for macOS.
// Uses Seatbelt (sandbox-exec) for process isolation.
func newPlatformSandbox() (Sandbox, error) {
sb, err := seatbelt.NewSeatbeltSandbox()
if err != nil {
return nil, err
}
return &darwinSandboxAdapter{seatbelt: sb}, nil
}
func (d *darwinSandboxAdapter) Execute(ctx context.Context, cmd *exec.Cmd, policy *SandboxPolicy) error {
// Convert sandbox.SandboxPolicy to seatbelt.SandboxPolicy
seatbeltPolicy := &seatbelt.SandboxPolicy{
Name: policy.Name,
Description: policy.Description,
PackageManagers: policy.PackageManagers,
ViolationMode: policy.ViolationMode,
Filesystem: seatbelt.FilesystemPolicy{
AllowRead: policy.Filesystem.AllowRead,
AllowWrite: policy.Filesystem.AllowWrite,
DenyRead: policy.Filesystem.DenyRead,
DenyWrite: policy.Filesystem.DenyWrite,
},
Network: seatbelt.NetworkPolicy{
AllowOutbound: policy.Network.AllowOutbound,
DenyOutbound: policy.Network.DenyOutbound,
},
Process: seatbelt.ProcessPolicy{
AllowExec: policy.Process.AllowExec,
DenyExec: policy.Process.DenyExec,
},
}
return d.seatbelt.Execute(ctx, cmd, seatbeltPolicy)
}
func (d *darwinSandboxAdapter) Name() string {
return d.seatbelt.Name()
}
func (d *darwinSandboxAdapter) IsAvailable() bool {
return d.seatbelt.IsAvailable()
}
+13
View File
@@ -0,0 +1,13 @@
//go:build linux
// +build linux
package sandbox
import "errors"
// newPlatformSandbox creates a platform-specific sandbox instance for Linux.
// Currently not implemented - returns an error.
// Future implementations will use Bubblewrap or seccomp-bpf.
func newPlatformSandbox() (Sandbox, error) {
return nil, errors.New("sandbox not yet implemented for Linux (coming soon: Bubblewrap or seccomp-bpf)")
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !darwin && !linux && !windows
// +build !darwin,!linux,!windows
package sandbox
import "fmt"
// newPlatformSandbox returns an error on unsupported platforms.
func newPlatformSandbox() (Sandbox, error) {
return nil, fmt.Errorf("sandbox is not supported on this platform")
}
+13
View File
@@ -0,0 +1,13 @@
//go:build windows
// +build windows
package sandbox
import "errors"
// newPlatformSandbox creates a platform-specific sandbox instance for Windows.
// Currently not implemented - returns an error.
// Future implementations will use AppContainer or Job Objects.
func newPlatformSandbox() (Sandbox, error) {
return nil, errors.New("sandbox not yet implemented for Windows (coming soon: AppContainer or Job Objects)")
}
+84
View File
@@ -0,0 +1,84 @@
//go:build darwin
// +build darwin
package seatbelt
import (
"context"
"fmt"
"os"
"os/exec"
"github.com/safedep/dry/log"
)
// SeatbeltSandbox implements the Sandbox interface using macOS Seatbelt (sandbox-exec).
type SeatbeltSandbox struct {
translator *PolicyTranslator
}
// NewSeatbeltSandbox creates a new Seatbelt sandbox instance.
func NewSeatbeltSandbox() (*SeatbeltSandbox, error) {
return &SeatbeltSandbox{
translator: NewPolicyTranslator(),
}, nil
}
// Execute runs a command in the Seatbelt sandbox with the given policy.
// It translates the PMG policy to Seatbelt Profile Language (.sb) and wraps
// the command execution with sandbox-exec.
func (s *SeatbeltSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *SandboxPolicy) error {
// Translate PMG policy to Seatbelt profile
sbProfile, err := s.translator.Translate(policy)
if err != nil {
return fmt.Errorf("failed to translate sandbox policy: %w", err)
}
// Write Seatbelt profile to temporary file
tmpFile, err := os.CreateTemp("", "pmg-sandbox-*.sb")
if err != nil {
return fmt.Errorf("failed to create temporary sandbox profile: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(sbProfile); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to write sandbox profile: %w", err)
}
tmpFile.Close()
log.Debugf("Seatbelt profile written to %s", tmpFile.Name())
log.Debugf("Seatbelt profile content:\n%s", sbProfile)
// Modify command to run via sandbox-exec
originalPath := cmd.Path
originalArgs := cmd.Args
// sandbox-exec -f <profile> <command> <args...>
cmd.Path = "/usr/bin/sandbox-exec"
cmd.Args = []string{
"sandbox-exec",
"-f", tmpFile.Name(),
originalPath,
}
// Append 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 nil
}
// Name returns the name of this sandbox implementation.
func (s *SeatbeltSandbox) Name() string {
return "seatbelt"
}
// IsAvailable returns true if sandbox-exec is available on this system.
func (s *SeatbeltSandbox) IsAvailable() bool {
_, err := exec.LookPath("sandbox-exec")
return err == nil
}
+234
View File
@@ -0,0 +1,234 @@
//go:build darwin
// +build darwin
package seatbelt
import (
"fmt"
"path/filepath"
"strings"
"github.com/safedep/pmg/sandbox/util"
)
// PolicyTranslator translates PMG sandbox policies to Seatbelt Profile Language (.sb).
type PolicyTranslator struct{}
// NewPolicyTranslator creates a new policy translator.
func NewPolicyTranslator() *PolicyTranslator {
return &PolicyTranslator{}
}
// SandboxPolicy represents a parsed sandbox policy (defined here to avoid import cycle).
type SandboxPolicy struct {
Name string
Description string
PackageManagers []string
ViolationMode string
Filesystem FilesystemPolicy
Network NetworkPolicy
Process ProcessPolicy
}
type FilesystemPolicy struct {
AllowRead []string
AllowWrite []string
DenyRead []string
DenyWrite []string
}
type NetworkPolicy struct {
AllowOutbound []string
DenyOutbound []string
}
type ProcessPolicy struct {
AllowExec []string
DenyExec []string
}
// Translate converts a PMG SandboxPolicy to Seatbelt Profile Language.
func (t *PolicyTranslator) Translate(policy *SandboxPolicy) (string, error) {
var sb strings.Builder
// Header
sb.WriteString("(version 1)\n")
sb.WriteString(fmt.Sprintf(";; PMG Sandbox Policy: %s\n", policy.Name))
sb.WriteString(fmt.Sprintf(";; %s\n", policy.Description))
sb.WriteString(";; Generated by PMG sandbox system\n\n")
// Default policy: deny by default for maximum security
sb.WriteString("(deny default)\n\n")
// Allow basic system operations required for any process
sb.WriteString(";; Basic system access\n")
sb.WriteString("(allow process-fork)\n")
sb.WriteString("(allow process-exec-interpreter)\n")
sb.WriteString("(allow sysctl-read)\n")
sb.WriteString("(allow mach-lookup)\n")
sb.WriteString("(allow mach-register)\n")
sb.WriteString("(allow ipc-posix-shm)\n")
sb.WriteString("(allow signal)\n\n")
// Filesystem rules
if err := t.translateFilesystem(policy, &sb); err != nil {
return "", fmt.Errorf("failed to translate filesystem rules: %w", err)
}
// Network rules
if err := t.translateNetwork(policy, &sb); err != nil {
return "", fmt.Errorf("failed to translate network rules: %w", err)
}
// Process execution rules
if err := t.translateProcess(policy, &sb); err != nil {
return "", fmt.Errorf("failed to translate process rules: %w", err)
}
return sb.String(), nil
}
// translateFilesystem translates filesystem access rules.
func (t *PolicyTranslator) translateFilesystem(policy *SandboxPolicy, sb *strings.Builder) error {
sb.WriteString(";; Filesystem access\n")
// Expand and add allow read rules
for _, pattern := range policy.Filesystem.AllowRead {
expanded, err := util.ExpandVariables(pattern)
if err != nil {
return fmt.Errorf("failed to expand pattern %s: %w", pattern, err)
}
// Handle glob patterns vs literal paths
if util.ContainsGlob(expanded) {
// For glob patterns, use subpath with the base directory
baseDir := filepath.Dir(strings.TrimSuffix(expanded, "/**"))
sb.WriteString(fmt.Sprintf("(allow file-read* (subpath \"%s\"))\n", baseDir))
} else {
sb.WriteString(fmt.Sprintf("(allow file-read* (subpath \"%s\"))\n", expanded))
}
}
sb.WriteString("\n")
// Expand and add allow write rules
for _, pattern := range policy.Filesystem.AllowWrite {
expanded, err := util.ExpandVariables(pattern)
if err != nil {
return fmt.Errorf("failed to expand pattern %s: %w", pattern, err)
}
if util.ContainsGlob(expanded) {
baseDir := filepath.Dir(strings.TrimSuffix(expanded, "/**"))
sb.WriteString(fmt.Sprintf("(allow file-write* (subpath \"%s\"))\n", baseDir))
} else {
sb.WriteString(fmt.Sprintf("(allow file-write* (subpath \"%s\"))\n", expanded))
}
}
sb.WriteString("\n")
// Deny rules have higher priority (applied after allow)
// Note: Seatbelt evaluates rules in order, so denies after allows will override
for _, pattern := range policy.Filesystem.DenyRead {
expanded, err := util.ExpandVariables(pattern)
if err != nil {
return fmt.Errorf("failed to expand pattern %s: %w", pattern, err)
}
if util.ContainsGlob(expanded) {
baseDir := filepath.Dir(strings.TrimSuffix(expanded, "/**"))
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\"))\n", baseDir))
} else {
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\"))\n", expanded))
}
}
for _, pattern := range policy.Filesystem.DenyWrite {
expanded, err := util.ExpandVariables(pattern)
if err != nil {
return fmt.Errorf("failed to expand pattern %s: %w", pattern, err)
}
if util.ContainsGlob(expanded) {
baseDir := filepath.Dir(strings.TrimSuffix(expanded, "/**"))
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\"))\n", baseDir))
} else {
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\"))\n", expanded))
}
}
sb.WriteString("\n")
return nil
}
// translateNetwork translates network access rules.
func (t *PolicyTranslator) translateNetwork(policy *SandboxPolicy, sb *strings.Builder) error {
sb.WriteString(";; Network access\n")
// If there are allow outbound rules, allow network-outbound generally
// (Seatbelt doesn't support fine-grained host:port filtering in all cases)
// Note: This is a limitation of Seatbelt - for more fine-grained control,
// consider using a network filtering solution or firewall rules
if len(policy.Network.AllowOutbound) > 0 {
sb.WriteString(";; Network outbound allowed to specific hosts\n")
sb.WriteString(";; Note: Seatbelt has limited host-based filtering, consider using firewall rules for strict control\n")
sb.WriteString("(allow network-outbound)\n")
}
// If deny outbound includes "*:*", block all network
for _, pattern := range policy.Network.DenyOutbound {
if pattern == "*:*" {
sb.WriteString(";; Network outbound denied\n")
sb.WriteString("(deny network-outbound)\n")
break
}
}
sb.WriteString("\n")
return nil
}
// translateProcess translates process execution rules.
func (t *PolicyTranslator) translateProcess(policy *SandboxPolicy, sb *strings.Builder) error {
sb.WriteString(";; Process execution\n")
// Add allow exec rules
for _, exePath := range policy.Process.AllowExec {
expanded, err := util.ExpandVariables(exePath)
if err != nil {
return fmt.Errorf("failed to expand exec path %s: %w", exePath, err)
}
if util.ContainsGlob(expanded) {
// For glob patterns, use subpath to allow anything under that directory
baseDir := filepath.Dir(strings.TrimSuffix(expanded, "/**"))
sb.WriteString(fmt.Sprintf("(allow process-exec* (subpath \"%s\"))\n", baseDir))
} else {
sb.WriteString(fmt.Sprintf("(allow process-exec* (literal \"%s\"))\n", expanded))
}
}
sb.WriteString("\n")
// Add deny exec rules
for _, exePath := range policy.Process.DenyExec {
expanded, err := util.ExpandVariables(exePath)
if err != nil {
return fmt.Errorf("failed to expand exec path %s: %w", exePath, err)
}
if util.ContainsGlob(expanded) {
baseDir := filepath.Dir(strings.TrimSuffix(expanded, "/**"))
sb.WriteString(fmt.Sprintf("(deny process-exec* (subpath \"%s\"))\n", baseDir))
} else {
sb.WriteString(fmt.Sprintf("(deny process-exec* (literal \"%s\"))\n", expanded))
}
}
sb.WriteString("\n")
return nil
}
+67
View File
@@ -0,0 +1,67 @@
package util
import (
"os"
"path/filepath"
"strings"
)
// ExpandVariables expands known variables in a path or pattern.
// Supported variables:
// - ${HOME}: User home directory
// - ${CWD}: Current working directory
// - ${TMPDIR}: Temporary directory
func ExpandVariables(pattern string) (string, error) {
result := pattern
// Get home directory
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
// Get current working directory
cwd, err := os.Getwd()
if err != nil {
return "", err
}
// Get temp directory
tmpDir := os.TempDir()
// Replace variables
replacer := strings.NewReplacer(
"${HOME}", home,
"${CWD}", cwd,
"${TMPDIR}", tmpDir,
)
result = replacer.Replace(result)
// Clean up path (resolve .., ., etc.)
result = filepath.Clean(result)
return result, nil
}
// ContainsGlob returns true if the pattern contains glob wildcards.
func ContainsGlob(pattern string) bool {
return strings.Contains(pattern, "*") ||
strings.Contains(pattern, "?") ||
strings.Contains(pattern, "[")
}
// ExpandPathList expands variables in a list of paths/patterns.
func ExpandPathList(patterns []string) ([]string, error) {
result := make([]string, 0, len(patterns))
for _, pattern := range patterns {
expanded, err := ExpandVariables(pattern)
if err != nil {
return nil, err
}
result = append(result, expanded)
}
return result, nil
}
+26
View File
@@ -0,0 +1,26 @@
package sandbox
import "github.com/safedep/pmg/sandbox/util"
// ExpandVariables expands known variables in a path or pattern.
// This is a convenience wrapper around util.ExpandVariables.
//
// Supported variables:
// - ${HOME}: User home directory
// - ${CWD}: Current working directory
// - ${TMPDIR}: Temporary directory
func ExpandVariables(pattern string) (string, error) {
return util.ExpandVariables(pattern)
}
// ExpandPathList expands variables in a list of paths/patterns.
// This is a convenience wrapper around util.ExpandPathList.
func ExpandPathList(patterns []string) ([]string, error) {
return util.ExpandPathList(patterns)
}
// ContainsGlob returns true if the pattern contains glob wildcards.
// This is a convenience wrapper around util.ContainsGlob.
func ContainsGlob(pattern string) bool {
return util.ContainsGlob(pattern)
}