package sandbox import ( "context" "fmt" "net" "os/exec" "strconv" "github.com/safedep/dry/usefulerror" "github.com/safedep/dry/utils" "github.com/safedep/pmg/errcodes" ) // ExecutionContext carries runtime data known only at spawn time. type ExecutionContext struct { // ProxyAddr is the loopback TCP address of the running PMG proxy // (e.g. "127.0.0.1:54321"). Empty when no proxy flow is active. ProxyAddr string } // ValidateNetworkLockdown enforces the network_via_proxy_only fail-closed contract // for drivers that support it. Returns the validated proxy port, or "" when // lockdown is off. func ValidateNetworkLockdown(policy *SandboxPolicy, rt *ExecutionContext) (string, error) { if !utils.SafelyGetValue(policy.NetworkViaProxyOnly) { return "", nil } if rt == nil || rt.ProxyAddr == "" { return "", usefulerror.NewUsefulError(). WithCode(errcodes.SandboxRequiresProxy). WithHumanError("network_via_proxy_only requires the PMG proxy flow"). WithHelp("This sandbox profile confines all network access to the PMG proxy, but no proxy is running."). Wrap(fmt.Errorf("policy %s requires the PMG proxy flow: network_via_proxy_only is set but no proxy address was provided", policy.Name)) } host, port, err := net.SplitHostPort(rt.ProxyAddr) if err != nil { return "", fmt.Errorf("network_via_proxy_only: proxy address %q is not host:port and not loopback: %w", rt.ProxyAddr, err) } ip := net.ParseIP(host) if ip == nil || !ip.IsLoopback() { return "", fmt.Errorf("network_via_proxy_only: refusing non-loopback proxy address %q", rt.ProxyAddr) } portNum, err := strconv.Atoi(port) if err != nil || portNum < 1 || portNum > 65535 { return "", fmt.Errorf("network_via_proxy_only: refusing non-numeric or out-of-range proxy port in %q", rt.ProxyAddr) } return port, nil } // DriverName identifies a sandbox driver implementation. Returned by // Sandbox.Name() and used wherever code needs to refer to a specific driver. type DriverName string const ( DriverSeatbelt DriverName = "seatbelt" DriverBubblewrap DriverName = "bubblewrap" DriverLandlock DriverName = "landlock" ) // ViolationKind is PMG's normalized taxonomy for sandbox denials. type ViolationKind string const ( ViolationKindFSRead ViolationKind = "fs_read" ViolationKindFSWrite ViolationKind = "fs_write" ViolationKindFSDeleteOrRename ViolationKind = "fs_delete_or_rename" ViolationKindExec ViolationKind = "exec" ViolationKindNetworkConnect ViolationKind = "network_connect" ViolationKindNetworkBind ViolationKind = "network_bind" ViolationKindGenericDeny ViolationKind = "generic_deny" ) // ViolationReport is a best-effort sandbox violation summary collected from a // sandbox implementation after command execution fails. type ViolationReport struct { SandboxName DriverName PolicyName string CorrelationID string Violations []Violation } // Violation captures one sandbox denial event. type Violation struct { Kind ViolationKind RawKind string Target string RuleTarget string Process string RawLog string RuleLabel string } type violationReporter interface { BestEffortViolation(err error) (*ViolationReport, error) } // ExecutionResult represents the result of executing a command in a sandbox. // It contains sandbox internal state and allows for future extension with // additional metadata (e.g., exit codes, resource usage, violation events). // Callers must call Close() after cmd.Run() completes to clean up resources. type ExecutionResult struct { executed bool sandbox Sandbox scrubbedEnvCount int } // ExecutionResultOpt is a function that can be used to configure an ExecutionResult. type ExecutionResultOpt func(*ExecutionResult) // WithSandbox sets the sandbox for the ExecutionResult. func WithExecutionResultSandbox(sb Sandbox) ExecutionResultOpt { return func(r *ExecutionResult) { r.sandbox = sb } } // WithExecuted sets the executed flag for the ExecutionResult. func WithExecutionResultExecuted(executed bool) ExecutionResultOpt { return func(r *ExecutionResult) { r.executed = executed } } // NewExecutionResult creates a new ExecutionResult. func NewExecutionResult(opts ...ExecutionResultOpt) *ExecutionResult { r := &ExecutionResult{} for _, opt := range opts { opt(r) } return r } // ShouldRun returns true if the caller should execute cmd.Run(). func (r *ExecutionResult) ShouldRun() bool { return !r.executed } // SetScrubbedEnvCount records how many environment variables were scrubbed // from the child process per the resolved environment policy. func (r *ExecutionResult) SetScrubbedEnvCount(count int) { r.scrubbedEnvCount = count } // ScrubbedEnvCount returns how many environment variables were scrubbed from // the child process. Used to hint at scrubbing as a possible cause when the // child fails. func (r *ExecutionResult) ScrubbedEnvCount() int { if r == nil { return 0 } return r.scrubbedEnvCount } // BestEffortViolation returns sandbox-specific best-effort violation details. // Implementations may use platform logs or other weak signals, so callers // should treat the result as advisory. func (r *ExecutionResult) BestEffortViolation(err error) (*ViolationReport, error) { if r == nil || r.sandbox == nil { return nil, nil } reporter, ok := r.sandbox.(violationReporter) if !ok { return nil, nil } return reporter.BestEffortViolation(err) } // Close cleans up any resources allocated by the sandbox. // Must be called after cmd.Run() completes. func (r *ExecutionResult) Close() error { if r.sandbox != nil { return r.sandbox.Close() } return nil } // 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 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 // // rt carries runtime data known only at spawn time; drivers treat a nil // rt as &ExecutionContext{}. // // Callers must check result.ShouldRun() and only call cmd.Run() if true. Execute(ctx context.Context, cmd *exec.Cmd, policy *SandboxPolicy, rt *ExecutionContext) (*ExecutionResult, error) // Name returns the sandbox driver identifier. Name() DriverName // IsAvailable returns true if the sandbox is available and functional on this platform. IsAvailable() bool // Close cleans up any resources allocated by the sandbox (e.g., temporary files). // Must be called after cmd.Run() completes. Idempotent - safe to call multiple times. Close() error } // ProfileInfo describes a user profile discovered on disk. type ProfileInfo struct { // Name is the profile name, derived from the file name without extension. Name string // Path is the absolute path to the profile file. Path string // Shadowed is true when a built-in profile of the same name exists and // will win during name-based resolution. Shadowed bool } // ProfileSource identifies where a profile came from. type ProfileSource string const ( ProfileSourceBuiltin ProfileSource = "builtin" ProfileSourceUser ProfileSource = "user" ) // ProfileSummary describes a discoverable profile for listing purposes. type ProfileSummary struct { Name string Source ProfileSource Path string // "" for builtins; absolute path for user profiles Inherits string PackageManagers []string Description string Shadowed bool // true when a user file is masked by a same-named builtin } // ResolveOptions tunes variable expansion when resolving a policy for display // or diffing. Zero values mean "use the current process environment". type ResolveOptions struct { CWD string Home string TmpDir string } // 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"), the bare name of a // user profile under UserProfileDir(), or a path to a custom YAML file. // Resolution order: built-ins first, then the user profile directory. GetProfile(name string) (*SandboxPolicy, error) // LoadCustomProfile loads a policy from a custom YAML file path. LoadCustomProfile(path string) (*SandboxPolicy, error) // ListProfiles returns all discoverable profiles: built-ins first, then // user profiles (including shadowed entries). ListProfiles() ([]ProfileSummary, error) // ResolveProfile loads name and returns a policy with all path-bearing // fields expanded against opts (or the process environment). ResolveProfile(name string, opts ResolveOptions) (*SandboxPolicy, error) // UserProfileDir returns the directory scanned for user profiles. UserProfileDir() string // ListUserProfiles enumerates *.yml / *.yaml files under UserProfileDir(). // A missing directory returns an empty slice with no error. ListUserProfiles() ([]ProfileInfo, error) // BuiltinProfileYAML returns the embedded YAML bytes for a built-in // profile. Returns false if name is not a built-in. BuiltinProfileYAML(name string) ([]byte, bool) } // RegistryOption configures a ProfileRegistry. type RegistryOption func(*registryOptions) type registryOptions struct { userProfileDir string } // WithUserProfileDir sets the directory the registry uses to discover user // profiles. The directory does not need to exist at construction time. func WithUserProfileDir(dir string) RegistryOption { return func(o *registryOptions) { o.userProfileDir = dir } } // NewProfileRegistry creates a new profile registry with built-in policies. func NewProfileRegistry(opts ...RegistryOption) (ProfileRegistry, error) { return newDefaultProfileRegistry(opts...) }