refactor: Sandbox for separation of boundaries

This commit is contained in:
Abhisek Datta
2026-01-08 14:11:10 +05:30
parent aa2a6cc214
commit b92ba00cfa
17 changed files with 197 additions and 194 deletions
+21 -14
View File
@@ -1,4 +1,4 @@
package sandbox
package executor
import (
"context"
@@ -7,6 +7,8 @@ import (
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/sandbox/platform"
)
// ApplySandbox applies sandbox isolation to the command if sandbox mode is enabled.
@@ -18,44 +20,48 @@ import (
// - pmName: Package manager name (e.g., "npm", "pip") used to determine the sandbox policy to apply
// - 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.
// Returns:
// - ExecutionResult: Contains execution state. Callers must check result.ShouldRun() before calling cmd.Run().
// - error: Non-nil if sandbox setup fails
//
// If sandbox is not enabled/available, returns a result indicating the caller should run the command.
// Gracefully degrades with warnings if sandbox is unavailable on the platform.
func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, mode string) error {
func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, mode string) (*sandbox.ExecutionResult, error) {
cfg := config.Get()
if !cfg.Config.Sandbox.Enabled {
return nil
return sandbox.NewExecutionResult(false), nil
}
// Lookup the sandbox policy for the package manager based on config
policyRef, exists := cfg.Config.Sandbox.Policies[pmName]
if !exists || !policyRef.Enabled {
log.Debugf("No sandbox policy enabled for %s", pmName)
return nil
return sandbox.NewExecutionResult(false), nil
}
registry := NewProfileRegistry()
registry := sandbox.NewProfileRegistry()
policy, err := registry.GetProfile(policyRef.Profile)
if err != nil {
return fmt.Errorf("failed to load sandbox policy %s: %w", policyRef.Profile, err)
return nil, fmt.Errorf("failed to load sandbox policy %s: %w", policyRef.Profile, err)
}
if !policy.AppliesToPackageManager(pmName) {
log.Warnf("Sandbox policy %s does not apply to %s", policy.Name, pmName)
return nil
return sandbox.NewExecutionResult(false), nil
}
// Create platform-specific sandbox
sb, err := NewSandbox()
sb, err := platform.NewSandbox()
if err != nil {
log.Warnf("Sandbox not available on this platform: %v", err)
log.Warnf("Continuing without sandbox protection")
return nil
return sandbox.NewExecutionResult(false), nil
}
if !sb.IsAvailable() {
log.Warnf("Sandbox %s not available, running without sandbox", sb.Name())
return nil
return sandbox.NewExecutionResult(false), nil
}
logMsg := fmt.Sprintf("Running %s in %s sandbox with policy %s", pmName, sb.Name(), policy.Name)
@@ -65,9 +71,10 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, mode string
log.Infof("%s", logMsg)
if err := sb.Execute(ctx, cmd, policy); err != nil {
return fmt.Errorf("failed to setup sandbox: %w", err)
result, err := sb.Execute(ctx, cmd, policy)
if err != nil {
return nil, fmt.Errorf("failed to setup sandbox: %w", err)
}
return nil
return result, nil
}
+12
View File
@@ -0,0 +1,12 @@
//go:build darwin
// +build darwin
package platform
import "github.com/safedep/pmg/sandbox"
// NewSandbox creates a platform-specific sandbox instance for macOS.
// Uses Seatbelt (sandbox-exec) for process isolation.
func NewSandbox() (sandbox.Sandbox, error) {
return newSeatbeltSandbox()
}
+16
View File
@@ -0,0 +1,16 @@
//go:build linux
// +build linux
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.
func NewSandbox() (sandbox.Sandbox, error) {
return nil, errors.New("sandbox not yet implemented for Linux")
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !darwin && !linux && !windows
// +build !darwin,!linux,!windows
package platform
import (
"errors"
"github.com/safedep/pmg/sandbox"
)
// NewSandbox returns an error on unsupported platforms.
func NewSandbox() (sandbox.Sandbox, error) {
return nil, errors.New("sandbox not supported on this platform")
}
+16
View File
@@ -0,0 +1,16 @@
//go:build windows
// +build windows
package platform
import (
"errors"
"github.com/safedep/pmg/sandbox"
)
// NewSandbox creates a platform-specific sandbox instance for Windows.
// TODO: Implement AppContainer or Job Objects based sandbox.
func NewSandbox() (sandbox.Sandbox, error) {
return nil, errors.New("sandbox not yet implemented for Windows")
}
@@ -1,7 +1,7 @@
//go:build darwin
// +build darwin
package seatbelt
package platform
import (
"context"
@@ -10,40 +10,45 @@ import (
"os/exec"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/sandbox"
)
// SeatbeltSandbox implements the Sandbox interface using macOS Seatbelt (sandbox-exec).
type SeatbeltSandbox struct {
translator *PolicyTranslator
// 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(),
// 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 {
//
// 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 (s *seatbeltSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) {
// Translate PMG policy to Seatbelt profile
sbProfile, err := s.translator.Translate(policy)
sbProfile, err := s.translator.translate(policy)
if err != nil {
return fmt.Errorf("failed to translate sandbox policy: %w", err)
return nil, 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)
return nil, 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)
return nil, fmt.Errorf("failed to write sandbox profile: %w", err)
}
tmpFile.Close()
@@ -69,16 +74,17 @@ func (s *SeatbeltSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *Sa
log.Debugf("Sandboxed command: %s %v", cmd.Path, cmd.Args)
return nil
// Return ExecutionResult indicating we only modified cmd, didn't execute it
return sandbox.NewExecutionResult(false), nil
}
// Name returns the name of this sandbox implementation.
func (s *SeatbeltSandbox) Name() string {
func (s *seatbeltSandbox) Name() string {
return "seatbelt"
}
// IsAvailable returns true if sandbox-exec is available on this system.
func (s *SeatbeltSandbox) IsAvailable() bool {
func (s *seatbeltSandbox) IsAvailable() bool {
_, err := exec.LookPath("sandbox-exec")
return err == nil
}
@@ -1,54 +1,27 @@
//go:build darwin
// +build darwin
package seatbelt
package platform
import (
"fmt"
"path/filepath"
"strings"
"github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/sandbox/util"
)
// PolicyTranslator translates PMG sandbox policies to Seatbelt Profile Language (.sb).
type PolicyTranslator struct{}
// policyTranslator translates PMG sandbox policies to Seatbelt Profile Language (.sb).
type policyTranslator struct{}
// NewPolicyTranslator creates a new policy translator.
func NewPolicyTranslator() *PolicyTranslator {
return &PolicyTranslator{}
// 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) {
// translate converts a PMG SandboxPolicy to Seatbelt Profile Language.
func (t *policyTranslator) translate(policy *sandbox.SandboxPolicy) (string, error) {
var sb strings.Builder
// Header
@@ -89,7 +62,7 @@ func (t *PolicyTranslator) Translate(policy *SandboxPolicy) (string, error) {
}
// translateFilesystem translates filesystem access rules.
func (t *PolicyTranslator) translateFilesystem(policy *SandboxPolicy, sb *strings.Builder) error {
func (t *policyTranslator) translateFilesystem(policy *sandbox.SandboxPolicy, sb *strings.Builder) error {
sb.WriteString(";; Filesystem access\n")
// Expand and add allow read rules
@@ -164,7 +137,7 @@ func (t *PolicyTranslator) translateFilesystem(policy *SandboxPolicy, sb *string
}
// translateNetwork translates network access rules.
func (t *PolicyTranslator) translateNetwork(policy *SandboxPolicy, sb *strings.Builder) error {
func (t *policyTranslator) translateNetwork(policy *sandbox.SandboxPolicy, sb *strings.Builder) error {
sb.WriteString(";; Network access\n")
// If there are allow outbound rules, allow network-outbound generally
@@ -192,7 +165,7 @@ func (t *PolicyTranslator) translateNetwork(policy *SandboxPolicy, sb *strings.B
}
// translateProcess translates process execution rules.
func (t *PolicyTranslator) translateProcess(policy *SandboxPolicy, sb *strings.Builder) error {
func (t *policyTranslator) translateProcess(policy *sandbox.SandboxPolicy, sb *strings.Builder) error {
sb.WriteString(";; Process execution\n")
// Add allow exec rules
+46 -10
View File
@@ -5,14 +5,56 @@ import (
"os/exec"
)
// ExecutionResult represents the result of applying a sandbox to a command.
// It encapsulates the execution state and allows for future extension with
// additional metadata (e.g., exit codes, resource usage, violation events).
type ExecutionResult struct {
executed bool
// Future fields can be added here without breaking the API:
// - exitCode int
// - resourceUsage ResourceStats
// - violations []ViolationEvent
}
// NewExecutionResult creates a new ExecutionResult.
// If executed is true, it indicates the sandbox executed the command directly.
// If executed is false, the sandbox only modified the command and the caller must execute it.
func NewExecutionResult(executed bool) *ExecutionResult {
return &ExecutionResult{
executed: executed,
}
}
// WasExecuted returns true if the sandbox executed the command directly.
// If false, the caller must execute the command using cmd.Run().
func (r *ExecutionResult) WasExecuted() bool {
return r.executed
}
// ShouldRun returns true if the caller should execute cmd.Run().
// This is the inverse of WasExecuted() and may be more intuitive at call sites.
func (r *ExecutionResult) ShouldRun() bool {
return !r.executed
}
// 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
// Execute prepares or runs a command in the sandbox with the given policy.
//
// Behavior varies by implementation:
// - CLI-based sandboxes (Seatbelt, Bubblewrap): Modify cmd in place by wrapping it
// with sandbox CLI (e.g., sandbox-exec). Returns ExecutionResult with executed=false.
// - Library-based sandboxes: Execute the command directly within the sandbox.
// Returns ExecutionResult with executed=true.
//
// Returns:
// - ExecutionResult: Contains execution state and metadata
// - error: Non-nil if sandbox setup or execution failed
//
// Callers must check result.ShouldRun() and only call cmd.Run() if true.
Execute(ctx context.Context, cmd *exec.Cmd, policy *SandboxPolicy) (*ExecutionResult, error)
// Name returns the sandbox implementation name (e.g., "seatbelt", "bubblewrap").
Name() string
@@ -21,12 +63,6 @@ type Sandbox interface {
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 {
-61
View File
@@ -1,61 +0,0 @@
//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()
}
-11
View File
@@ -1,11 +0,0 @@
//go:build linux
// +build linux
package sandbox
import "errors"
// newPlatformSandbox creates a platform-specific sandbox instance for Linux.
func newPlatformSandbox() (Sandbox, error) {
return nil, errors.New("sandbox not yet implemented for Linux (coming soon: Bubblewrap or seccomp-bpf)")
}
-11
View File
@@ -1,11 +0,0 @@
//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")
}
-11
View File
@@ -1,11 +0,0 @@
//go:build windows
// +build windows
package sandbox
import "errors"
// newPlatformSandbox creates a platform-specific sandbox instance for Windows.
func newPlatformSandbox() (Sandbox, error) {
return nil, errors.New("sandbox not yet implemented for Windows")
}