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>
322 lines
8.3 KiB
Go
322 lines
8.3 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/safedep/dry/log"
|
|
"github.com/safedep/dry/usefulerror"
|
|
"github.com/safedep/pmg/errcodes"
|
|
"github.com/safedep/pmg/internal/pty"
|
|
"github.com/safedep/pmg/internal/shim"
|
|
"github.com/safedep/pmg/packagemanager"
|
|
"github.com/safedep/pmg/sandbox"
|
|
"github.com/safedep/pmg/sandbox/executor"
|
|
)
|
|
|
|
type ExecutionMode int
|
|
|
|
const (
|
|
ExecutionModeDirect ExecutionMode = iota
|
|
ExecutionModePTY
|
|
ExecutionModeAuto
|
|
)
|
|
|
|
// outputDrainGrace bounds how long we wait for the PTY output reader to finish
|
|
// after the child exits before forcing it to stop.
|
|
const outputDrainGrace = 2 * time.Second
|
|
|
|
type ExecuteOptions struct {
|
|
PackageManagerName string
|
|
DryRun bool
|
|
SandboxProxyAddr string
|
|
EnvOverrides []string
|
|
DirectEnvOverrides []string
|
|
PTYEnvOverrides []string
|
|
Mode ExecutionMode
|
|
|
|
// BeforeDirectRun runs after command/env construction and before sandbox
|
|
// application for non-PTY execution. Use this for setup that must exist even
|
|
// when a sandbox implementation executes the child inside ApplySandbox.
|
|
BeforeDirectRun func() error
|
|
|
|
// PreparePTYSession runs after the PTY session and routers are created, but
|
|
// before waiting for the child process. Use this to wire interactive routing,
|
|
// prompts, or output buffering around an already-started PTY child.
|
|
PreparePTYSession func(*PTYRuntime) error
|
|
|
|
IsInteractive func() bool
|
|
}
|
|
|
|
type PTYRuntime struct {
|
|
Session pty.InteractiveSession
|
|
OutputRouter *pty.OutputRouter
|
|
InputRouter *pty.InputRouter
|
|
PromptReader *io.PipeReader
|
|
PromptWriter *io.PipeWriter
|
|
}
|
|
|
|
// Execute runs a package manager command without proxy or guard analysis.
|
|
// It applies sandbox policy if configured, then executes the command directly.
|
|
func Execute(ctx context.Context, pc *packagemanager.ParsedCommand, pmName string, dryRun bool) error {
|
|
return ExecuteWithOptions(ctx, pc, ExecuteOptions{
|
|
PackageManagerName: pmName,
|
|
DryRun: dryRun,
|
|
Mode: ExecutionModeDirect,
|
|
})
|
|
}
|
|
|
|
// ExecuteWithOptions runs a package manager command through PMG's shared
|
|
// execution path: real binary resolution, environment setup, sandbox
|
|
// application, command launch, sandbox cleanup, and exit error wrapping.
|
|
func ExecuteWithOptions(ctx context.Context, pc *packagemanager.ParsedCommand, opts ExecuteOptions) error {
|
|
if len(pc.Command.Exe) == 0 {
|
|
return fmt.Errorf("no command to execute")
|
|
}
|
|
|
|
if opts.DryRun {
|
|
log.Debugf("Dry run, skipping command execution")
|
|
return nil
|
|
}
|
|
|
|
realBinary, err := shim.ResolveRealBinary(pc.Command.Exe)
|
|
if err != nil {
|
|
var notFound *shim.BinaryNotFoundError
|
|
if errors.As(err, ¬Found) {
|
|
return notFound
|
|
}
|
|
return fmt.Errorf("failed to resolve real %s binary: %w", pc.Command.Exe, err)
|
|
}
|
|
|
|
mode := executionMode(opts)
|
|
|
|
cmd := exec.CommandContext(ctx, realBinary, pc.Command.Args...)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Env = commandEnv(modeEnvOverrides(opts, mode))
|
|
|
|
if mode != ExecutionModePTY && opts.BeforeDirectRun != nil {
|
|
if err := opts.BeforeDirectRun(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
result, err := executor.ApplySandbox(ctx, cmd, opts.PackageManagerName,
|
|
executor.WithExecutionContext(&sandbox.ExecutionContext{ProxyAddr: opts.SandboxProxyAddr}))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to apply sandbox: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
if err := result.Close(); err != nil {
|
|
log.Errorf("failed to close sandbox: %v", err)
|
|
}
|
|
}()
|
|
|
|
switch mode {
|
|
case ExecutionModePTY:
|
|
return runPTY(ctx, cmd, cmd.Env, result, opts.PackageManagerName, opts.PreparePTYSession)
|
|
default:
|
|
return runDirect(cmd, result, opts.PackageManagerName)
|
|
}
|
|
}
|
|
|
|
func runDirect(cmd *exec.Cmd, result *sandbox.ExecutionResult, pmName string) error {
|
|
if !result.ShouldRun() {
|
|
return nil
|
|
}
|
|
|
|
log.Debugf("Running command with args: %s: %v", cmd.Path, cmd.Args[1:])
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
executor.ObserveViolations(result, err)
|
|
return classify(err, pmName, result.ScrubbedEnvCount())
|
|
}
|
|
|
|
log.Debugf("Command completed successfully")
|
|
return nil
|
|
}
|
|
|
|
func runPTY(
|
|
ctx context.Context,
|
|
cmd *exec.Cmd,
|
|
env []string,
|
|
result *sandbox.ExecutionResult,
|
|
pmName string,
|
|
beforeWait func(*PTYRuntime) error,
|
|
) error {
|
|
if !result.ShouldRun() {
|
|
return usefulerror.NewUsefulError().
|
|
Wrap(fmt.Errorf("sandbox not supported for PTY sessions")).
|
|
WithCode(errcodes.InvalidArgument).
|
|
WithHumanError("Sandbox executed command cannot be used with PTY session. Please use non-interactive TTY mode instead.")
|
|
}
|
|
|
|
cmdExe := cmd.Path
|
|
cmdArgs := cmd.Args[1:]
|
|
|
|
log.Debugf("Running command with args: %s: %v", cmdExe, cmdArgs)
|
|
|
|
sessionConfig := pty.NewSessionConfig(cmdExe, cmdArgs, env)
|
|
sess, err := pty.NewSession(ctx, sessionConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create pty session: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := sess.Close(); err != nil {
|
|
log.Warnf("failed to close pty session: %v", err)
|
|
}
|
|
}()
|
|
|
|
outputRouter, err := pty.NewOutputRouter(os.Stdout)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create output router: %w", err)
|
|
}
|
|
|
|
// The output reader normally ends on its own when the PTY master reports
|
|
// EOF after the child exits. copyCtx lets us stop it otherwise: on parent
|
|
// cancellation (Ctrl+C) and on the drain-grace path below, which guards
|
|
// against a lingering descendant keeping the slave open (no EOF).
|
|
copyCtx, stopCopy := context.WithCancel(ctx)
|
|
defer stopCopy()
|
|
|
|
copyDone := make(chan struct{})
|
|
go func() {
|
|
defer close(copyDone)
|
|
if err := sess.CopyOutputContext(copyCtx, outputRouter); err != nil {
|
|
log.Errorf("failed to copy output: %v", err)
|
|
}
|
|
}()
|
|
|
|
inputRouter, err := pty.NewInputRouter(sess.PtyWriter())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create input router: %w", err)
|
|
}
|
|
|
|
promptReader, promptWriter := io.Pipe()
|
|
defer func() {
|
|
if err := promptWriter.Close(); err != nil {
|
|
log.Warnf("failed to close prompt writer: %v", err)
|
|
}
|
|
}()
|
|
defer func() {
|
|
if err := promptReader.Close(); err != nil {
|
|
log.Warnf("failed to close prompt reader: %v", err)
|
|
}
|
|
}()
|
|
|
|
inputCtx, cancelInput := context.WithCancel(ctx)
|
|
inputDone := make(chan struct{})
|
|
go func() {
|
|
defer close(inputDone)
|
|
inputRouter.ReadLoopContext(inputCtx, os.Stdin)
|
|
}()
|
|
defer func() {
|
|
cancelInput()
|
|
<-inputDone
|
|
}()
|
|
|
|
if beforeWait != nil {
|
|
runtime := &PTYRuntime{
|
|
Session: sess,
|
|
OutputRouter: outputRouter,
|
|
InputRouter: inputRouter,
|
|
PromptReader: promptReader,
|
|
PromptWriter: promptWriter,
|
|
}
|
|
|
|
if err := beforeWait(runtime); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
sessionError := sess.Wait()
|
|
|
|
// Child has exited. Let the reader drain to EOF, but bound the wait so a
|
|
// lingering descendant holding the slave open cannot block teardown.
|
|
select {
|
|
case <-copyDone:
|
|
case <-time.After(outputDrainGrace):
|
|
log.Debugf("output drain grace exceeded, stopping pty reader")
|
|
stopCopy()
|
|
<-copyDone
|
|
}
|
|
|
|
if sessionError != nil {
|
|
executor.ObserveViolations(result, sessionError)
|
|
return classify(sessionError, pmName, result.ScrubbedEnvCount())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func executionMode(opts ExecuteOptions) ExecutionMode {
|
|
if opts.Mode != ExecutionModeAuto {
|
|
return opts.Mode
|
|
}
|
|
|
|
isInteractive := pty.IsInteractiveTerminal
|
|
if opts.IsInteractive != nil {
|
|
isInteractive = opts.IsInteractive
|
|
}
|
|
|
|
if isInteractive() {
|
|
return ExecutionModePTY
|
|
}
|
|
|
|
return ExecutionModeDirect
|
|
}
|
|
|
|
func commandEnv(overrides []string) []string {
|
|
return mergeEnv(shim.FilterPMGFromEnv(os.Environ()), overrides)
|
|
}
|
|
|
|
func modeEnvOverrides(opts ExecuteOptions, mode ExecutionMode) []string {
|
|
overrides := append([]string{}, opts.EnvOverrides...)
|
|
switch mode {
|
|
case ExecutionModePTY:
|
|
overrides = append(overrides, opts.PTYEnvOverrides...)
|
|
default:
|
|
overrides = append(overrides, opts.DirectEnvOverrides...)
|
|
}
|
|
|
|
return overrides
|
|
}
|
|
|
|
func mergeEnv(base, overrides []string) []string {
|
|
env := append([]string{}, base...)
|
|
indexByKey := make(map[string]int, len(env))
|
|
|
|
for i, entry := range env {
|
|
key, _, ok := strings.Cut(entry, "=")
|
|
if ok {
|
|
indexByKey[key] = i
|
|
}
|
|
}
|
|
|
|
for _, entry := range overrides {
|
|
key, _, ok := strings.Cut(entry, "=")
|
|
if !ok {
|
|
env = append(env, entry)
|
|
continue
|
|
}
|
|
|
|
if idx, exists := indexByKey[key]; exists {
|
|
env[idx] = entry
|
|
continue
|
|
}
|
|
|
|
indexByKey[key] = len(env)
|
|
env = append(env, entry)
|
|
}
|
|
|
|
return env
|
|
}
|