Files
pmg/sandbox/platform/landlock_linux.go
T
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

174 lines
5.3 KiB
Go

//go:build linux
package platform
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"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"
)
// landlockSandbox implements the Sandbox interface using Landlock LSM on Linux.
// This implementation follows the CLI-wrapper pattern (like Bubblewrap):
// - Modifies the cmd in place by rewiring it to re-exec pmg with __landlock_sandbox_exec
// - Passes the translated policy via a temp file (--policy-file)
// - Passes an audit unix socket path (--audit-socket) for future audit event consumption
// - Returns ExecutionResult with executed=false
// - Caller must call cmd.Run() to execute the sandboxed command
type landlockSandbox struct {
abi *landlockABI
// Cleanup state from last Execute()
policyFile string
socketPath string
listener net.Listener
}
// newLandlockSandbox creates a new Landlock sandbox instance after verifying
// that both Landlock and seccomp user notification are available on the system.
func newLandlockSandbox() (sandbox.Sandbox, error) {
abi, err := landlockDetectABI()
if err != nil {
return nil, fmt.Errorf("landlock not available: %w", err)
}
log.Debugf("Landlock ABI V%d detected (Refer=%v, Truncate=%v, Network=%v, IoctlDev=%v, Scoping=%v)",
abi.Version, abi.HasRefer, abi.HasTruncate, abi.HasNetwork, abi.HasIoctlDev, abi.HasScoping)
return &landlockSandbox{abi: abi}, nil
}
// Name returns the name of this sandbox implementation.
func (s *landlockSandbox) Name() sandbox.DriverName {
return sandbox.DriverLandlock
}
// IsAvailable returns true if Landlock is available and functional on this system.
func (s *landlockSandbox) IsAvailable() bool {
return s.abi != nil && s.abi.Version > 0
}
// Close cleans up any resources allocated by the sandbox.
// It closes the audit socket listener and removes temporary files.
// This method is idempotent and safe to call multiple times.
func (s *landlockSandbox) Close() error {
if s.listener != nil {
_ = s.listener.Close()
s.listener = nil
}
if s.socketPath != "" {
_ = os.Remove(s.socketPath)
s.socketPath = ""
}
if s.policyFile != "" {
_ = os.Remove(s.policyFile)
s.policyFile = ""
}
return nil
}
// Execute prepares a command to run in the Landlock sandbox with the given policy.
// It translates the PMG policy to a landlockExecPolicy, serializes it to a pipe,
// and rewires the command to re-exec pmg with the __landlock_sandbox_exec subcommand.
//
// 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 *landlockSandbox) 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)", s.Name()))
}
execPolicy, err := landlockTranslatePolicy(policy, s.abi)
if err != nil {
return nil, fmt.Errorf("failed to translate policy: %w", err)
}
execPolicy.Command = cmd.Path
if len(cmd.Args) > 1 {
execPolicy.Args = cmd.Args[1:]
}
execPolicy.Env = cmd.Env
policyFile, err := os.CreateTemp("", "pmg-landlock-policy-*.json")
if err != nil {
return nil, fmt.Errorf("failed to create policy temp file: %w", err)
}
if err := json.NewEncoder(policyFile).Encode(execPolicy); err != nil {
_ = policyFile.Close()
_ = os.Remove(policyFile.Name())
return nil, fmt.Errorf("failed to write policy to temp file: %w", err)
}
policyFilePath := policyFile.Name()
_ = policyFile.Close()
s.policyFile = policyFilePath
socketPath := filepath.Join(os.TempDir(), fmt.Sprintf("pmg-landlock-audit-%d.sock", os.Getpid()))
listener, err := net.Listen("unix", socketPath)
if err != nil {
_ = os.Remove(policyFilePath)
return nil, fmt.Errorf("failed to create audit unix socket: %w", err)
}
s.socketPath = socketPath
s.listener = listener
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
if _, err := io.Copy(io.Discard, conn); err != nil {
log.Warnf("audit socket drain: %v", err)
}
if err := conn.Close(); err != nil {
log.Warnf("close audit conn: %v", err)
}
}()
selfExe, err := os.Executable()
if err != nil {
if cerr := s.Close(); cerr != nil {
log.Warnf("close landlock driver after self-exe lookup failure: %v", cerr)
}
return nil, fmt.Errorf("failed to get self executable path: %w", err)
}
originalPath := cmd.Path
originalArgs := cmd.Args
cmd.Path = selfExe
cmd.Args = []string{
"pmg", "__landlock_sandbox_exec",
"--policy-file", policyFilePath,
"--audit-socket", socketPath,
"--", originalPath,
}
if len(originalArgs) > 1 {
cmd.Args = append(cmd.Args, originalArgs[1:]...)
}
log.Debugf("Landlock sandboxed command: %s %v", cmd.Path, cmd.Args)
return sandbox.NewExecutionResult(
sandbox.WithExecutionResultExecuted(false),
sandbox.WithExecutionResultSandbox(s),
), nil
}