mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat(sandbox): ExecutionContext plumbing and fail-closed lockdown contract
Network lockdown needs the PMG proxy's address, which is only known at
spawn time. Thread an ExecutionContext from the proxy flow through the
runner and executor into every sandbox driver, and enforce the
network_via_proxy_only fail-closed contract: lockdown without a running
loopback proxy, or on a driver that cannot enforce it, is a hard error —
never a silent fallback to unrestricted network.
- sandbox.ExecutionContext{ProxyAddr} + 4-arg Sandbox.Execute
- sandbox.ValidateLockdown validates the proxy address (loopback only)
with usefulerror code SandboxRequiresProxy
- Seatbelt validates lockdown before translation (translation itself
lands next); bubblewrap and landlock reject lockdown as unsupported
until Linux enforcement is implemented
- executor.WithExecutionContext, runner.ExecuteOptions.SandboxProxyAddr,
proxy flow passes the live proxy address
- ApplySandbox also validates centrally before invoking the driver
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* fix(sandbox): require numeric in-range proxy port in ValidateLockdown
The validated port string is embedded into generated sandbox profiles,
so service names, zero, and out-of-range ports are refused.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* fix(sandbox): fail closed on Seatbelt lockdown until translation lands
A lockdown policy that passed proxy validation would silently receive
the pre-lockdown network rules from the translator. Reject it until the
lockdown profile translation is implemented, keeping the window between
plumbing and enforcement fail-closed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
* chore: review feedback — drop redundant comment, simplify stub help text
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS
---------
Co-authored-by: Claude <noreply@anthropic.com>
158 lines
4.6 KiB
Go
158 lines
4.6 KiB
Go
//go:build darwin
|
|
// +build darwin
|
|
|
|
package platform
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"time"
|
|
|
|
"github.com/safedep/dry/log"
|
|
"github.com/safedep/dry/usefulerror"
|
|
"github.com/safedep/dry/utils"
|
|
"github.com/safedep/pmg/errcodes"
|
|
"github.com/safedep/pmg/sandbox"
|
|
)
|
|
|
|
type seatbeltSandbox struct {
|
|
translator *seatbeltPolicyTranslator
|
|
tempProfilePath string
|
|
cleanupCompleted bool
|
|
policyName string
|
|
logTag string
|
|
startedAt time.Time
|
|
}
|
|
|
|
func newSeatbeltSandbox() (*seatbeltSandbox, error) {
|
|
return &seatbeltSandbox{
|
|
translator: newSeatbeltPolicyTranslator(),
|
|
}, 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.
|
|
//
|
|
// 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, rt *sandbox.ExecutionContext) (*sandbox.ExecutionResult, error) {
|
|
if _, err := sandbox.ValidateNetworkLockdown(policy, rt); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Temporary fail-closed stub until the Seatbelt translator emits the
|
|
// lockdown profile: without it, a lockdown policy would silently get the
|
|
// pre-lockdown network rules. Removed when lockdown translation lands.
|
|
if utils.SafelyGetValue(policy.NetworkViaProxyOnly) {
|
|
return nil, usefulerror.NewUsefulError().
|
|
WithCode(errcodes.UnsupportedPlatform).
|
|
WithHumanError("network_via_proxy_only is not yet enforced by this pmg build").
|
|
WithHelp("Disable network_via_proxy_only for this profile until lockdown enforcement ships.").
|
|
Wrap(fmt.Errorf("network_via_proxy_only translation is not yet implemented (%s sandbox)", s.Name()))
|
|
}
|
|
|
|
sbProfile, err := s.translator.translate(policy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to translate sandbox policy: %w", err)
|
|
}
|
|
|
|
// Log pattern referred in sandbox.md as debugging guidance
|
|
s.logTag = s.translator.LogTag()
|
|
s.policyName = policy.Name
|
|
s.startedAt = time.Now()
|
|
|
|
log.Debugf("MacOS Seatbelt sandbox log tag: %s", s.logTag)
|
|
|
|
tmpFile, err := os.CreateTemp("", "pmg-sandbox-*.sb")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create temporary sandbox profile: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
if err := tmpFile.Close(); err != nil {
|
|
log.Warnf("failed to close temporary sandbox profile: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Storing the path is required for cleanup in Close()
|
|
s.tempProfilePath = tmpFile.Name()
|
|
|
|
if _, err := tmpFile.WriteString(sbProfile); err != nil {
|
|
if err := os.Remove(s.tempProfilePath); err != nil {
|
|
log.Warnf("failed to remove temporary sandbox profile: %v", err)
|
|
}
|
|
|
|
s.tempProfilePath = ""
|
|
return nil, fmt.Errorf("failed to write sandbox profile: %w", err)
|
|
}
|
|
|
|
log.Debugf("Seatbelt profile written to %s", s.tempProfilePath)
|
|
|
|
debugLogPolicyContent(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", s.tempProfilePath,
|
|
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 sandbox.NewExecutionResult(sandbox.WithExecutionResultSandbox(s)), nil
|
|
}
|
|
|
|
// Name returns the name of this sandbox implementation.
|
|
func (s *seatbeltSandbox) Name() sandbox.DriverName {
|
|
return sandbox.DriverSeatbelt
|
|
}
|
|
|
|
// IsAvailable returns true if sandbox-exec is available on this system.
|
|
func (s *seatbeltSandbox) IsAvailable() bool {
|
|
_, err := exec.LookPath("sandbox-exec")
|
|
return err == nil
|
|
}
|
|
|
|
// Close cleans up the temporary seatbelt profile file.
|
|
func (s *seatbeltSandbox) Close() error {
|
|
if s.cleanupCompleted || s.tempProfilePath == "" {
|
|
return nil
|
|
}
|
|
|
|
log.Debugf("Cleaning up seatbelt profile: %s", s.tempProfilePath)
|
|
|
|
err := os.Remove(s.tempProfilePath)
|
|
s.cleanupCompleted = true
|
|
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to remove seatbelt profile %s: %w", s.tempProfilePath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// debugLogPolicyContent logs the policy content when explicitly debugging is enabled.
|
|
func debugLogPolicyContent(content string) {
|
|
filePath := os.Getenv("PMG_SANDBOX_DEBUG_LOG_SEATBELT_POLICY_CONTENT")
|
|
if filePath == "" {
|
|
return
|
|
}
|
|
|
|
if err := os.WriteFile(filePath, []byte(content), 0600); err != nil {
|
|
log.Warnf("failed to write seatbelt policy content to %s: %v", filePath, err)
|
|
}
|
|
}
|