From 5131c3f64163c8b50fd124715d83ab80809242a2 Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Fri, 10 Jul 2026 20:12:33 +0530 Subject: [PATCH] feat(sandbox): ExecutionContext plumbing and fail-closed lockdown contract (#371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 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 Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS * chore: review feedback — drop redundant comment, simplify stub help text Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS --------- Co-authored-by: Claude --- errcodes/codes.go | 5 + internal/flows/proxy_flow.go | 1 + internal/runner/execute.go | 4 +- sandbox/executor/apply.go | 15 ++- sandbox/executor/apply_test.go | 49 ++++++++++ sandbox/executor/diagnostics_test.go | 2 +- sandbox/lockdown_test.go | 99 ++++++++++++++++++++ sandbox/platform/bubblewrap_linux.go | 11 ++- sandbox/platform/bubblewrap_linux_test.go | 14 +-- sandbox/platform/landlock_linux.go | 13 ++- sandbox/platform/landlock_linux_test.go | 6 +- sandbox/platform/lockdown_stub_linux_test.go | 46 +++++++++ sandbox/platform/probe_canary.go | 2 +- sandbox/platform/probe_canary_test.go | 2 +- sandbox/platform/seatbelt_darwin.go | 20 +++- sandbox/platform/seatbelt_darwin_test.go | 42 ++++++++- sandbox/sandbox.go | 53 ++++++++++- 17 files changed, 364 insertions(+), 20 deletions(-) create mode 100644 sandbox/lockdown_test.go create mode 100644 sandbox/platform/lockdown_stub_linux_test.go diff --git a/errcodes/codes.go b/errcodes/codes.go index a371f2c..76169f0 100644 --- a/errcodes/codes.go +++ b/errcodes/codes.go @@ -25,6 +25,11 @@ const ( CertTrustStore = "CertTrustStore" UnsupportedPlatform = "UnsupportedPlatform" + // Sandbox error codes. SandboxRequiresProxy is returned when a sandbox + // policy enables network_via_proxy_only but no PMG proxy is running to + // confine traffic to. + SandboxRequiresProxy = "SandboxRequiresProxy" + // Proxy error codes. ProxyPolicyViolation is returned when the proxy blocked // one or more packages by policy (malware, dependency cooldown, or a denied // suspicious package) and the run was gated with --fail-on-violation. diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 3b21594..92154e7 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -222,6 +222,7 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema executionError := runner.ExecuteWithOptions(ctx, parsedCmd, runner.ExecuteOptions{ PackageManagerName: f.pm.Name(), DryRun: cfg.DryRun, + SandboxProxyAddr: proxyAddr, Mode: runner.ExecutionModeAuto, EnvOverrides: append(packagemanager.EnvVarForProxy(proxyAddr, caCertPath), routing.ExtraEnv...), DirectEnvOverrides: ciEnvOverride(), diff --git a/internal/runner/execute.go b/internal/runner/execute.go index 93e02d5..45335a2 100644 --- a/internal/runner/execute.go +++ b/internal/runner/execute.go @@ -35,6 +35,7 @@ const outputDrainGrace = 2 * time.Second type ExecuteOptions struct { PackageManagerName string DryRun bool + SandboxProxyAddr string EnvOverrides []string DirectEnvOverrides []string PTYEnvOverrides []string @@ -107,7 +108,8 @@ func ExecuteWithOptions(ctx context.Context, pc *packagemanager.ParsedCommand, o } } - result, err := executor.ApplySandbox(ctx, cmd, opts.PackageManagerName) + 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) } diff --git a/sandbox/executor/apply.go b/sandbox/executor/apply.go index 4c24b44..cf56682 100644 --- a/sandbox/executor/apply.go +++ b/sandbox/executor/apply.go @@ -21,6 +21,7 @@ import ( type applySandboxConfig struct { sb sandbox.Sandbox + rt *sandbox.ExecutionContext } type applySandboxOpt func(*applySandboxConfig) @@ -33,6 +34,14 @@ func WithSandbox(sb sandbox.Sandbox) applySandboxOpt { } } +// WithExecutionContext provides runtime data known only at spawn time +// (e.g. the PMG proxy address) to the sandbox driver. +func WithExecutionContext(rt *sandbox.ExecutionContext) applySandboxOpt { + return func(c *applySandboxConfig) { + c.rt = rt + } +} + // ApplySandbox applies sandbox isolation to the command if sandbox mode is enabled. // This is a helper function used by both guard and proxy flows to avoid code duplication. // @@ -172,7 +181,11 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app // env slice regardless of the OS sandbox driver). scrubbed := scrubEnv(cmd, policy) - result, err := sb.Execute(ctx, cmd, policy) + if _, err := sandbox.ValidateNetworkLockdown(policy, applyConfig.rt); err != nil { + return nil, err + } + + result, err := sb.Execute(ctx, cmd, policy, applyConfig.rt) if err != nil { return nil, fmt.Errorf("failed to setup sandbox: %w", err) } diff --git a/sandbox/executor/apply_test.go b/sandbox/executor/apply_test.go index 3be13cf..1f5ab1c 100644 --- a/sandbox/executor/apply_test.go +++ b/sandbox/executor/apply_test.go @@ -1,6 +1,7 @@ package executor import ( + "context" "os" "os/exec" "path/filepath" @@ -372,3 +373,51 @@ func TestApplyProjectOverlayEmptyArgsNoop(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 0, applied) } + +type fakeApplySandbox struct { + rt *sandbox.ExecutionContext +} + +func (f *fakeApplySandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy, rt *sandbox.ExecutionContext) (*sandbox.ExecutionResult, error) { + f.rt = rt + return sandbox.NewExecutionResult(), nil +} + +func (f *fakeApplySandbox) Name() sandbox.DriverName { return "fake" } +func (f *fakeApplySandbox) IsAvailable() bool { return true } +func (f *fakeApplySandbox) Close() error { return nil } + +func TestApplySandboxLockdownRequiresExecutionContext(t *testing.T) { + profile := filepath.Join(t.TempDir(), "lockdown.yml") + require.NoError(t, os.WriteFile(profile, []byte(` +name: lockdown-apply-test +package_managers: ["npm"] +filesystem: + allow_read: ["/tmp"] +network_via_proxy_only: true +`), 0o600)) + + cfg := config.Get() + oldEnabled := cfg.Config.Sandbox.Enabled + oldOverride := cfg.SandboxProfileOverride + t.Cleanup(func() { + cfg.Config.Sandbox.Enabled = oldEnabled + cfg.SandboxProfileOverride = oldOverride + }) + cfg.Config.Sandbox.Enabled = true + cfg.SandboxProfileOverride = profile + + fake := &fakeApplySandbox{} + + _, err := ApplySandbox(context.Background(), exec.Command("npm"), "npm", WithSandbox(fake)) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires the PMG proxy") + assert.Nil(t, fake.rt) + + rt := &sandbox.ExecutionContext{ProxyAddr: "127.0.0.1:54321"} + result, err := ApplySandbox(context.Background(), exec.Command("npm"), "npm", + WithSandbox(fake), WithExecutionContext(rt)) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, rt, fake.rt) +} diff --git a/sandbox/executor/diagnostics_test.go b/sandbox/executor/diagnostics_test.go index a51dc91..6db6056 100644 --- a/sandbox/executor/diagnostics_test.go +++ b/sandbox/executor/diagnostics_test.go @@ -14,7 +14,7 @@ type fakeViolationSandbox struct { report *sandbox.ViolationReport } -func (f *fakeViolationSandbox) Execute(context.Context, *exec.Cmd, *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) { +func (f *fakeViolationSandbox) Execute(context.Context, *exec.Cmd, *sandbox.SandboxPolicy, *sandbox.ExecutionContext) (*sandbox.ExecutionResult, error) { return sandbox.NewExecutionResult(), nil } diff --git a/sandbox/lockdown_test.go b/sandbox/lockdown_test.go new file mode 100644 index 0000000..7ba692d --- /dev/null +++ b/sandbox/lockdown_test.go @@ -0,0 +1,99 @@ +package sandbox + +import ( + "testing" + + "github.com/safedep/dry/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateNetworkLockdown(t *testing.T) { + lockdownPolicy := &SandboxPolicy{ + Name: "lockdown", + NetworkViaProxyOnly: utils.PtrTo(true), + } + + tests := []struct { + name string + policy *SandboxPolicy + rt *ExecutionContext + wantPort string + wantErr string + }{ + { + name: "lockdown off returns empty port and no error", + policy: &SandboxPolicy{Name: "plain"}, + rt: nil, + }, + { + name: "nil execution context", + policy: lockdownPolicy, + rt: nil, + wantErr: "requires the PMG proxy", + }, + { + name: "empty proxy address", + policy: lockdownPolicy, + rt: &ExecutionContext{}, + wantErr: "requires the PMG proxy", + }, + { + name: "non-loopback proxy address", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "192.168.1.5:9999"}, + wantErr: "loopback", + }, + { + name: "unparseable proxy address", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "not-an-address"}, + wantErr: "loopback", + }, + { + name: "service-name proxy port", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "127.0.0.1:http"}, + wantErr: "non-numeric or out-of-range proxy port", + }, + { + name: "zero proxy port", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "127.0.0.1:0"}, + wantErr: "non-numeric or out-of-range proxy port", + }, + { + name: "out-of-range proxy port", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "127.0.0.1:70000"}, + wantErr: "non-numeric or out-of-range proxy port", + }, + { + name: "loopback ipv4 proxy address", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "127.0.0.1:54321"}, + wantPort: "54321", + }, + { + name: "loopback ipv6 proxy address", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "[::1]:54321"}, + wantPort: "54321", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + port, err := ValidateNetworkLockdown(tt.policy, tt.rt) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantPort, port) + }) + } +} diff --git a/sandbox/platform/bubblewrap_linux.go b/sandbox/platform/bubblewrap_linux.go index af63797..892c759 100644 --- a/sandbox/platform/bubblewrap_linux.go +++ b/sandbox/platform/bubblewrap_linux.go @@ -10,6 +10,7 @@ import ( "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" ) @@ -43,7 +44,15 @@ func newBubblewrapSandbox() (*bubblewrapSandbox, error) { // // 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) (*sandbox.ExecutionResult, error) { +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(). diff --git a/sandbox/platform/bubblewrap_linux_test.go b/sandbox/platform/bubblewrap_linux_test.go index e9b56e6..63850ca 100644 --- a/sandbox/platform/bubblewrap_linux_test.go +++ b/sandbox/platform/bubblewrap_linux_test.go @@ -58,7 +58,7 @@ func TestBubblewrapSandboxExecute(t *testing.T) { cmd := exec.Command("/bin/echo", "hello") ctx := context.Background() - result, err := sb.Execute(ctx, cmd, policy) + result, err := sb.Execute(ctx, cmd, policy, nil) require.NoError(t, err) require.NotNil(t, result) @@ -98,7 +98,7 @@ func TestBubblewrapSandboxExecuteCommandWrapping(t *testing.T) { cmd := exec.Command(originalCmd, originalArgs[1:]...) ctx := context.Background() - result, err := sb.Execute(ctx, cmd, policy) + result, err := sb.Execute(ctx, cmd, policy, nil) require.NoError(t, err) // Verify command structure @@ -148,7 +148,7 @@ func TestBubblewrapSandboxExecuteWithPTY(t *testing.T) { cmd := exec.Command("/bin/echo", "test") ctx := context.Background() - result, err := sb.Execute(ctx, cmd, policy) + result, err := sb.Execute(ctx, cmd, policy, nil) require.NoError(t, err) // Should have PTY-related arguments @@ -186,7 +186,7 @@ func TestBubblewrapSandboxExecuteWithNetworkIsolation(t *testing.T) { cmd := exec.Command("/bin/echo", "test") ctx := context.Background() - result, err := sb.Execute(ctx, cmd, policy) + result, err := sb.Execute(ctx, cmd, policy, nil) require.NoError(t, err) // Should have network isolation @@ -231,7 +231,7 @@ func TestBubblewrapSandboxExecutionResult(t *testing.T) { cmd := exec.Command("/bin/echo", "test") ctx := context.Background() - result, err := sb.Execute(ctx, cmd, policy) + result, err := sb.Execute(ctx, cmd, policy, nil) require.NoError(t, err) // Verify ExecutionResult properties @@ -267,7 +267,7 @@ func TestBubblewrapSandboxTranslationError(t *testing.T) { ctx := context.Background() // Should succeed even with complex patterns - result, err := sb.Execute(ctx, cmd, policy) + result, err := sb.Execute(ctx, cmd, policy, nil) assert.NoError(t, err) assert.NotNil(t, result) @@ -294,7 +294,7 @@ func TestBubblewrapSandboxEssentialBindMounts(t *testing.T) { cmd := exec.Command("/bin/echo", "test") ctx := context.Background() - result, err := sb.Execute(ctx, cmd, policy) + result, err := sb.Execute(ctx, cmd, policy, nil) require.NoError(t, err) argsStr := "" diff --git a/sandbox/platform/landlock_linux.go b/sandbox/platform/landlock_linux.go index d70c74d..a72e670 100644 --- a/sandbox/platform/landlock_linux.go +++ b/sandbox/platform/landlock_linux.go @@ -13,6 +13,9 @@ import ( "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" ) @@ -81,7 +84,15 @@ func (s *landlockSandbox) Close() error { // // 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) (*sandbox.ExecutionResult, error) { +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) diff --git a/sandbox/platform/landlock_linux_test.go b/sandbox/platform/landlock_linux_test.go index 29c957c..87be43e 100644 --- a/sandbox/platform/landlock_linux_test.go +++ b/sandbox/platform/landlock_linux_test.go @@ -63,7 +63,7 @@ func TestLandlockSandbox_Close_CleansUpResources(t *testing.T) { cmd := exec.Command("/bin/echo", "hello") ctx := context.Background() - result, err := sb.Execute(ctx, cmd, policy) + result, err := sb.Execute(ctx, cmd, policy, nil) require.NoError(t, err) require.NotNil(t, result) @@ -101,7 +101,7 @@ func TestLandlockSandbox_Execute_RewiresCmd(t *testing.T) { cmd := exec.Command("/bin/echo", "hello", "world") ctx := context.Background() - result, err := sb.Execute(ctx, cmd, policy) + result, err := sb.Execute(ctx, cmd, policy, nil) require.NoError(t, err) require.NotNil(t, result) @@ -175,7 +175,7 @@ func TestLandlockSandbox_Execute_PolicySerialized(t *testing.T) { cmd := exec.Command("/bin/echo", "test") ctx := context.Background() - result, err := sb.Execute(ctx, cmd, policy) + result, err := sb.Execute(ctx, cmd, policy, nil) require.NoError(t, err) require.NotNil(t, result) diff --git a/sandbox/platform/lockdown_stub_linux_test.go b/sandbox/platform/lockdown_stub_linux_test.go new file mode 100644 index 0000000..694979c --- /dev/null +++ b/sandbox/platform/lockdown_stub_linux_test.go @@ -0,0 +1,46 @@ +//go:build linux + +package platform + +import ( + "context" + "os/exec" + "testing" + + "github.com/safedep/dry/utils" + "github.com/safedep/pmg/sandbox" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLinuxDriversRejectNetworkViaProxyOnly(t *testing.T) { + bwrap, err := newBubblewrapSandbox() + require.NoError(t, err) + + drivers := []struct { + name string + sb sandbox.Sandbox + }{ + {"bubblewrap", bwrap}, + {"landlock", &landlockSandbox{abi: newLandlockABI(4)}}, + } + + policy := &sandbox.SandboxPolicy{ + Name: "lockdown", + PackageManagers: []string{"npm"}, + Filesystem: sandbox.FilesystemPolicy{AllowRead: []string{"/tmp"}}, + NetworkViaProxyOnly: utils.PtrTo(true), + } + + for _, d := range drivers { + t.Run(d.name, func(t *testing.T) { + cmd := exec.Command("/bin/true") + result, err := d.sb.Execute(context.Background(), cmd, policy, + &sandbox.ExecutionContext{ProxyAddr: "127.0.0.1:54321"}) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "not yet supported on this platform") + }) + } +} diff --git a/sandbox/platform/probe_canary.go b/sandbox/platform/probe_canary.go index ca784ae..459b317 100644 --- a/sandbox/platform/probe_canary.go +++ b/sandbox/platform/probe_canary.go @@ -101,7 +101,7 @@ func runCanary(ctx context.Context, name string, driver sandbox.DriverName, fact } policy := denyAllCanaryPolicy() - result, err := sb.Execute(ctx, cmd, policy) + result, err := sb.Execute(ctx, cmd, policy, nil) if err != nil { return sandbox.ProbeResult{ Name: name, diff --git a/sandbox/platform/probe_canary_test.go b/sandbox/platform/probe_canary_test.go index 4e37c9e..d820933 100644 --- a/sandbox/platform/probe_canary_test.go +++ b/sandbox/platform/probe_canary_test.go @@ -20,7 +20,7 @@ type fakeSandbox struct { closed bool } -func (f *fakeSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) { +func (f *fakeSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy, rt *sandbox.ExecutionContext) (*sandbox.ExecutionResult, error) { if f.executeErr != nil { return nil, f.executeErr } diff --git a/sandbox/platform/seatbelt_darwin.go b/sandbox/platform/seatbelt_darwin.go index 313ff48..b18cfc0 100644 --- a/sandbox/platform/seatbelt_darwin.go +++ b/sandbox/platform/seatbelt_darwin.go @@ -11,6 +11,9 @@ import ( "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" ) @@ -35,7 +38,22 @@ func newSeatbeltSandbox() (*seatbeltSandbox, error) { // // 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) (*sandbox.ExecutionResult, error) { +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) diff --git a/sandbox/platform/seatbelt_darwin_test.go b/sandbox/platform/seatbelt_darwin_test.go index ef91cbb..5574f31 100644 --- a/sandbox/platform/seatbelt_darwin_test.go +++ b/sandbox/platform/seatbelt_darwin_test.go @@ -8,8 +8,10 @@ import ( "os/exec" "testing" + "github.com/safedep/dry/utils" "github.com/safedep/pmg/sandbox" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSeatbeltDarwin(t *testing.T) { @@ -38,7 +40,7 @@ func TestSeatbeltDarwin(t *testing.T) { cmd := exec.Command("npm", "install", "lodash") npmResolvedPath := cmd.Path - result, err := sb.Execute(context.Background(), cmd, policy) + result, err := sb.Execute(context.Background(), cmd, policy, nil) assert.NoError(t, err) assert.True(t, result.ShouldRun(), "command should be runnable because seatbelt only patches the command") @@ -50,3 +52,41 @@ func TestSeatbeltDarwin(t *testing.T) { assert.NoFileExists(t, sb.tempProfilePath) } + +func TestSeatbeltExecuteLockdownValidation(t *testing.T) { + lockdownPolicy := &sandbox.SandboxPolicy{ + Name: "lockdown", + PackageManagers: []string{"npm"}, + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{"/tmp"}, + AllowWrite: []string{"/tmp"}, + }, + NetworkViaProxyOnly: utils.PtrTo(true), + } + + tests := []struct { + name string + rt *sandbox.ExecutionContext + wantErr string + }{ + {"nil execution context", nil, "requires the PMG proxy"}, + {"empty proxy address", &sandbox.ExecutionContext{}, "requires the PMG proxy"}, + {"non-loopback proxy address", &sandbox.ExecutionContext{ProxyAddr: "192.168.1.5:9999"}, "loopback"}, + {"unparseable proxy address", &sandbox.ExecutionContext{ProxyAddr: "not-an-address"}, "loopback"}, + {"valid proxy address rejected until translation lands", &sandbox.ExecutionContext{ProxyAddr: "127.0.0.1:54321"}, "not yet implemented"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sb, err := newSeatbeltSandbox() + require.NoError(t, err) + + cmd := exec.Command("/usr/bin/true") + result, err := sb.Execute(context.Background(), cmd, lockdownPolicy, tt.rt) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} diff --git a/sandbox/sandbox.go b/sandbox/sandbox.go index 8674cd7..b92be23 100644 --- a/sandbox/sandbox.go +++ b/sandbox/sandbox.go @@ -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