Files
pmg/sandbox/platform/bubblewrap_linux.go
5131c3f641 feat(sandbox): ExecutionContext plumbing and fail-closed lockdown contract (#371)
* 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>
2026-07-10 20:12:33 +05:30

121 lines
4.2 KiB
Go

//go:build linux
// +build linux
package platform
import (
"context"
"fmt"
"os/exec"
"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"
)
// 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, rt *sandbox.ExecutionContext) (*sandbox.ExecutionResult, error) {
if utils.SafelyGetValue(policy.NetworkViaProxyOnly) {
return nil, usefulerror.NewUsefulError().
WithCode(errcodes.UnsupportedPlatform).
WithHumanError("network_via_proxy_only is not yet supported on this platform").
WithHelp("Disable network_via_proxy_only for this profile.").
Wrap(fmt.Errorf("network_via_proxy_only is not yet supported on this platform (%s sandbox)", b.Name()))
}
bwrapPath, err := exec.LookPath("bwrap")
if err != nil {
return nil, usefulerror.NewUsefulError().
WithCode(errcodes.BubblewrapNotFound).
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] -- <original-command> <original-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() sandbox.DriverName {
return sandbox.DriverBubblewrap
}
// 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
}