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>
This commit is contained in:
Abhisek Datta
2026-07-10 20:12:33 +05:30
committed by GitHub
co-authored by Claude Fable 5
parent 22d6eabb6b
commit 5131c3f641
17 changed files with 364 additions and 20 deletions
+52 -1
View File
@@ -2,9 +2,57 @@ 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
@@ -153,8 +201,11 @@ type Sandbox interface {
// - 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) (*ExecutionResult, error)
Execute(ctx context.Context, cmd *exec.Cmd, policy *SandboxPolicy, rt *ExecutionContext) (*ExecutionResult, error)
// Name returns the sandbox driver identifier.
Name() DriverName