Files
pmg/sandbox/executor/apply_test.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

424 lines
14 KiB
Go

package executor
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/safedep/dry/utils"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/sandbox"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplyRuntimeOverrides_Read(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/existing"},
},
}
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
{Type: config.SandboxAllowRead, Value: "/new/path", Raw: "read=/new/path"},
})
assert.Contains(t, policy.Filesystem.AllowRead, "/existing")
assert.Contains(t, policy.Filesystem.AllowRead, "/new/path")
}
func TestApplyRuntimeOverrides_Write(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
AllowWrite: []string{"/existing"},
},
}
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
{Type: config.SandboxAllowWrite, Value: "/new/file", Raw: "write=/new/file"},
})
assert.Contains(t, policy.Filesystem.AllowWrite, "/existing")
assert.Contains(t, policy.Filesystem.AllowWrite, "/new/file")
}
func TestApplyRuntimeOverrides_Exec(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Process: sandbox.ProcessPolicy{
AllowExec: []string{"/usr/bin/node"},
},
}
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
{Type: config.SandboxAllowExec, Value: "/usr/bin/curl", Raw: "exec=/usr/bin/curl"},
})
assert.Contains(t, policy.Process.AllowExec, "/usr/bin/node")
assert.Contains(t, policy.Process.AllowExec, "/usr/bin/curl")
}
func TestApplyRuntimeOverrides_Env(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Environment: sandbox.EnvironmentPolicy{
Allow: []string{"NPM_TOKEN"},
},
}
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
{Type: config.SandboxAllowEnv, Value: "AWS_PROFILE", Raw: "env=AWS_PROFILE"},
})
assert.Contains(t, policy.Environment.Allow, "NPM_TOKEN")
assert.Contains(t, policy.Environment.Allow, "AWS_PROFILE")
}
func TestScrubEnv_RemovesDeniedKeepsAllowed(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Name: "test",
Environment: sandbox.EnvironmentPolicy{
Allow: []string{"NPM_TOKEN"},
},
}
cmd := &exec.Cmd{Env: []string{
"PATH=/usr/bin",
"NPM_TOKEN=keep-me",
"AWS_SECRET_ACCESS_KEY=scrub-me",
"GITHUB_TOKEN=scrub-me-too",
}}
scrubbed := scrubEnv(cmd, policy)
assert.Equal(t, 2, scrubbed)
assert.Contains(t, cmd.Env, "PATH=/usr/bin")
assert.Contains(t, cmd.Env, "NPM_TOKEN=keep-me")
assert.NotContains(t, cmd.Env, "AWS_SECRET_ACCESS_KEY=scrub-me")
assert.NotContains(t, cmd.Env, "GITHUB_TOKEN=scrub-me-too")
}
func TestScrubEnv_AllowOverrideUnscrubs(t *testing.T) {
policy := &sandbox.SandboxPolicy{Name: "test"}
// Simulate a --sandbox-allow env=AWS_SESSION_TOKEN override having been merged.
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
{Type: config.SandboxAllowEnv, Value: "AWS_SESSION_TOKEN", Raw: "env=AWS_SESSION_TOKEN"},
})
cmd := &exec.Cmd{Env: []string{"AWS_SESSION_TOKEN=kept"}}
scrubbed := scrubEnv(cmd, policy)
assert.Equal(t, 0, scrubbed)
assert.Contains(t, cmd.Env, "AWS_SESSION_TOKEN=kept")
}
func TestScrubEnv_NilEnvPopulatedThenScrubbed(t *testing.T) {
t.Setenv("GITHUB_TOKEN", "should-be-scrubbed")
t.Setenv("PMG_ENV_SCRUB_MARKER", "kept")
policy := &sandbox.SandboxPolicy{Name: "test"}
cmd := &exec.Cmd{Env: nil}
scrubEnv(cmd, policy)
require.NotNil(t, cmd.Env)
assert.Contains(t, cmd.Env, "PMG_ENV_SCRUB_MARKER=kept")
assert.NotContains(t, cmd.Env, "GITHUB_TOKEN=should-be-scrubbed")
}
func TestApplyRuntimeOverrides_NetConnect(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Network: sandbox.NetworkPolicy{
AllowOutbound: []string{"registry.npmjs.org:443"},
},
}
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
{Type: config.SandboxAllowNetConnect, Value: "example.com:443", Raw: "net-connect=example.com:443"},
})
assert.Contains(t, policy.Network.AllowOutbound, "registry.npmjs.org:443")
assert.Contains(t, policy.Network.AllowOutbound, "example.com:443")
}
func TestApplyRuntimeOverrides_NetBind(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Network: sandbox.NetworkPolicy{
AllowBind: []string{},
},
}
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
{Type: config.SandboxAllowNetBind, Value: "127.0.0.1:3000", Raw: "net-bind=127.0.0.1:3000"},
})
assert.Contains(t, policy.Network.AllowBind, "127.0.0.1:3000")
assert.NotNil(t, policy.AllowNetworkBind)
assert.True(t, *policy.AllowNetworkBind)
}
func TestApplyRuntimeOverrides_NetBindPreservesExistingTrue(t *testing.T) {
policy := &sandbox.SandboxPolicy{
AllowNetworkBind: utils.PtrTo(true),
Network: sandbox.NetworkPolicy{
AllowBind: []string{"localhost:8080"},
},
}
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
{Type: config.SandboxAllowNetBind, Value: "127.0.0.1:3000", Raw: "net-bind=127.0.0.1:3000"},
})
assert.Contains(t, policy.Network.AllowBind, "localhost:8080")
assert.Contains(t, policy.Network.AllowBind, "127.0.0.1:3000")
assert.True(t, *policy.AllowNetworkBind)
}
func TestApplyRuntimeOverrides_MultipleOverrides(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{},
Process: sandbox.ProcessPolicy{},
Network: sandbox.NetworkPolicy{},
}
overrides := []config.SandboxAllowOverride{
{Type: config.SandboxAllowWrite, Value: "/path/a", Raw: "write=/path/a"},
{Type: config.SandboxAllowWrite, Value: "/path/b", Raw: "write=/path/b"},
{Type: config.SandboxAllowExec, Value: "/usr/bin/curl", Raw: "exec=/usr/bin/curl"},
{Type: config.SandboxAllowNetConnect, Value: "example.com:443", Raw: "net-connect=example.com:443"},
}
applyRuntimeOverrides(policy, overrides)
assert.Len(t, policy.Filesystem.AllowWrite, 2)
assert.Len(t, policy.Process.AllowExec, 1)
assert.Len(t, policy.Network.AllowOutbound, 1)
}
func TestApplyRuntimeOverrides_EmptyOverrides(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
AllowWrite: []string{"/existing"},
},
}
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{})
// Policy should be unchanged
assert.Equal(t, []string{"/existing"}, policy.Filesystem.AllowWrite)
}
func TestApplyRuntimeOverrides_DenyListsUnmodifiedWhenNoConflict(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyWrite: []string{"/protected"},
},
Process: sandbox.ProcessPolicy{
DenyExec: []string{"/usr/bin/curl"},
},
Network: sandbox.NetworkPolicy{
DenyOutbound: []string{"*:*"},
},
}
overrides := []config.SandboxAllowOverride{
{Type: config.SandboxAllowWrite, Value: "/something", Raw: "write=/something"},
{Type: config.SandboxAllowExec, Value: "/usr/bin/wget", Raw: "exec=/usr/bin/wget"},
{Type: config.SandboxAllowNetConnect, Value: "example.com:443", Raw: "net-connect=example.com:443"},
}
applyRuntimeOverrides(policy, overrides)
// Deny lists should be unchanged when overrides don't conflict
assert.Equal(t, []string{"/protected"}, policy.Filesystem.DenyWrite)
assert.Equal(t, []string{"/usr/bin/curl"}, policy.Process.DenyExec)
assert.Equal(t, []string{"*:*"}, policy.Network.DenyOutbound)
}
func TestApplyRuntimeOverrides_RemovesExactDenyConflict(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyRead: []string{"/secret", "/other"},
DenyWrite: []string{"/protected", "/tmp/data"},
},
Process: sandbox.ProcessPolicy{
DenyExec: []string{"/usr/bin/curl", "/bin/bash"},
},
}
overrides := []config.SandboxAllowOverride{
{Type: config.SandboxAllowRead, Value: "/secret", Raw: "read=/secret"},
{Type: config.SandboxAllowWrite, Value: "/protected", Raw: "write=/protected"},
{Type: config.SandboxAllowExec, Value: "/bin/bash", Raw: "exec=/bin/bash"},
}
applyRuntimeOverrides(policy, overrides)
// Exact matches should be removed from deny lists
assert.Equal(t, []string{"/other"}, policy.Filesystem.DenyRead)
assert.Equal(t, []string{"/tmp/data"}, policy.Filesystem.DenyWrite)
assert.Equal(t, []string{"/usr/bin/curl"}, policy.Process.DenyExec)
// Allow lists should have the overrides
assert.Contains(t, policy.Filesystem.AllowRead, "/secret")
assert.Contains(t, policy.Filesystem.AllowWrite, "/protected")
assert.Contains(t, policy.Process.AllowExec, "/bin/bash")
}
func TestApplyRuntimeOverrides_PreservesGlobDenyPatterns(t *testing.T) {
policy := &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyRead: []string{"/etc/**"},
DenyWrite: []string{"/usr/**"},
},
Process: sandbox.ProcessPolicy{
DenyExec: []string{"/usr/bin/*"},
},
}
overrides := []config.SandboxAllowOverride{
{Type: config.SandboxAllowRead, Value: "/etc/hosts", Raw: "read=/etc/hosts"},
{Type: config.SandboxAllowWrite, Value: "/usr/local/bin/tool", Raw: "write=/usr/local/bin/tool"},
{Type: config.SandboxAllowExec, Value: "/usr/bin/git", Raw: "exec=/usr/bin/git"},
}
applyRuntimeOverrides(policy, overrides)
// Glob/wildcard deny patterns must NOT be removed — only exact matches are removed
assert.Equal(t, []string{"/etc/**"}, policy.Filesystem.DenyRead)
assert.Equal(t, []string{"/usr/**"}, policy.Filesystem.DenyWrite)
assert.Equal(t, []string{"/usr/bin/*"}, policy.Process.DenyExec)
}
func TestApplyRuntimeOverrides_VariableDenyNotRemovedByAbsoluteOverride(t *testing.T) {
// Known limitation: deny entries using ${CWD} or ${HOME} variables are NOT
// removed by overrides that resolve to absolute paths. removeExactMatch uses
// literal string comparison, so "${CWD}/blocked.txt" != "/actual/cwd/blocked.txt".
// The override still adds the path to the allow list, but the unexpanded deny
// entry remains and will take precedence once the translator expands it.
cwd, err := os.Getwd()
assert.NoError(t, err)
absolutePath := filepath.Join(cwd, "blocked.txt")
policy := &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyWrite: []string{"${CWD}/blocked.txt"},
},
}
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
{Type: config.SandboxAllowWrite, Value: absolutePath, Raw: "write=./blocked.txt"},
})
// The override is added to the allow list
assert.Contains(t, policy.Filesystem.AllowWrite, absolutePath)
// But the ${CWD} deny entry is NOT removed because the strings don't match literally.
// This means the deny rule will still shadow the allow after variable expansion.
assert.Equal(t, []string{"${CWD}/blocked.txt"}, policy.Filesystem.DenyWrite)
}
func TestApplyProjectOverlayAppendsEntries(t *testing.T) {
dir := t.TempDir()
repo := "/repo/example"
_, err := sandbox.SaveOverlay(dir, repo, &sandbox.Overlay{
Allow: []sandbox.OverlayAllow{
{Type: config.SandboxAllowWrite, Value: "/repo/example/.astro"},
{Type: config.SandboxAllowNetBind, Value: "localhost:4321"},
},
})
require.NoError(t, err)
policy := &sandbox.SandboxPolicy{Name: "test"}
applied, err := applyProjectOverlay(policy, dir, repo, false)
assert.NoError(t, err)
assert.Equal(t, 2, applied)
assert.Contains(t, policy.Filesystem.AllowWrite, "/repo/example/.astro")
assert.Contains(t, policy.Network.AllowBind, "localhost:4321")
if assert.NotNil(t, policy.AllowNetworkBind) {
assert.True(t, *policy.AllowNetworkBind)
}
}
func TestApplyProjectOverlaySkippedWhenLocked(t *testing.T) {
dir := t.TempDir()
repo := "/repo/example"
_, err := sandbox.SaveOverlay(dir, repo, &sandbox.Overlay{
Allow: []sandbox.OverlayAllow{{Type: config.SandboxAllowWrite, Value: "/repo/example/.astro"}},
})
require.NoError(t, err)
policy := &sandbox.SandboxPolicy{Name: "test"}
applied, err := applyProjectOverlay(policy, dir, repo, true)
assert.NoError(t, err)
assert.Equal(t, 0, applied)
assert.Empty(t, policy.Filesystem.AllowWrite)
}
func TestApplyProjectOverlayMissingFileIsNoop(t *testing.T) {
dir := filepath.Join(t.TempDir(), "no-such")
policy := &sandbox.SandboxPolicy{Name: "test"}
applied, err := applyProjectOverlay(policy, dir, "/repo/example", false)
assert.NoError(t, err)
assert.Equal(t, 0, applied)
_, statErr := os.Stat(dir)
assert.True(t, os.IsNotExist(statErr))
}
func TestApplyProjectOverlayEmptyArgsNoop(t *testing.T) {
policy := &sandbox.SandboxPolicy{Name: "test"}
applied, err := applyProjectOverlay(policy, "", "", false)
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)
}