feat(sandbox): Seatbelt lockdown translation — deny-all outbound, allow loopback proxy port (#372)

* feat(sandbox): Seatbelt lockdown translation confines outbound to the PMG proxy

Under network_via_proxy_only the Seatbelt profile now denies all
network-outbound (with a target=direct violation marker) and allows only
the loopback proxy port. SBPL is last-match-wins, so the broad deny is
emitted first, specific allows after, and the allow_network_bind rules
last — keeping loopback-to-loopback dev traffic working under lockdown.
allow_direct_dns re-opens the /var/run/mDNSResponder unix socket that
the deny otherwise covers.

Replaces the temporary fail-closed rejection in the Seatbelt driver with
the real translation; non-lockdown profiles translate byte-identically
to before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS

* test(sandbox): assert deny marker presence before ordering comparison

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS

* feat(sandbox): render lockdown profiles without a proxy as deny-only with runtime note

pmg sandbox profile show renders profiles for debugging and must not
fail on lockdown profiles. Without a running proxy the translator keeps
the broad deny (rendered profile stays fail-closed, never looser than
runtime) and documents the runtime-only proxy-port allow in an SBPL
comment instead of fabricating a port. Execution is unaffected: the
driver validates the proxy address before translating.

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 21:11:56 +05:30
committed by GitHub
co-authored by Claude Fable 5
parent 5131c3f641
commit 3ac83a436d
5 changed files with 210 additions and 60 deletions
@@ -37,7 +37,7 @@ func TestSeatbeltTranslatorDarwinCommonTranslation(t *testing.T) {
}
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(policy)
actual, err := translator.translate(policy, nil)
assert.NoError(t, err)
// Test common translation
@@ -61,7 +61,7 @@ func TestSeatbeltTranslatorDarwinAlwaysAllowsFSEventsMachLookup(t *testing.T) {
policy := &sandbox.SandboxPolicy{}
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(policy)
actual, err := translator.translate(policy, nil)
require.NoError(t, err)
assert.Contains(t, actual, `(global-name "com.apple.FSEvents")`)
@@ -282,7 +282,7 @@ func TestSeatbeltTranslatorDarwinFilesystemTranslation(t *testing.T) {
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(tt.policy)
actual, err := translator.translate(tt.policy, nil)
tt.assert(t, actual, err)
})
}
@@ -341,7 +341,7 @@ func TestSeatbeltTranslatorDarwinProcessTranslation(t *testing.T) {
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(tt.policy)
actual, err := translator.translate(tt.policy, nil)
tt.assert(t, actual, err)
})
}
@@ -540,7 +540,7 @@ func TestFilesystemTranslationWithMoveProtection(t *testing.T) {
translator := newSeatbeltPolicyTranslator()
translator.enableMoveBlockingMitigation = true
actual, err := translator.translate(policy)
actual, err := translator.translate(policy, nil)
assert.NoError(t, err)
// Should contain deny read rule
@@ -571,7 +571,7 @@ func TestPTYSupport(t *testing.T) {
}
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(policy)
actual, err := translator.translate(policy, nil)
assert.NoError(t, err)
// Should NOT contain PTY rules
@@ -588,7 +588,7 @@ func TestPTYSupport(t *testing.T) {
}
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(policy)
actual, err := translator.translate(policy, nil)
assert.NoError(t, err)
// Should contain PTY rules
@@ -611,7 +611,7 @@ func TestNetworkBindSupport(t *testing.T) {
}
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(policy)
actual, err := translator.translate(policy, nil)
assert.NoError(t, err)
assert.Contains(t, actual, ";; Local network bind (localhost only)")
@@ -633,7 +633,7 @@ func TestNetworkBindSupport(t *testing.T) {
}
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(policy)
actual, err := translator.translate(policy, nil)
assert.NoError(t, err)
// Localhost bind from AllowNetworkBind
@@ -654,7 +654,7 @@ func TestNetworkBindSupport(t *testing.T) {
}
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(policy)
actual, err := translator.translate(policy, nil)
assert.NoError(t, err)
// Should NOT contain localhost bind
@@ -672,7 +672,7 @@ func TestNetworkBindSupport(t *testing.T) {
}
translator := newSeatbeltPolicyTranslator()
actual, err := translator.translate(policy)
actual, err := translator.translate(policy, nil)
assert.NoError(t, err)
assert.NotContains(t, actual, "network-bind")
@@ -743,3 +743,101 @@ func translateFilesystemForTest(t *testing.T, policy *sandbox.SandboxPolicy) str
require.NoError(t, tr.translateFilesystem(policy, &sb))
return sb.String()
}
func TestTranslateNetworkLockdown(t *testing.T) {
rt := &sandbox.ExecutionContext{ProxyAddr: "127.0.0.1:54321"}
basePolicy := func() *sandbox.SandboxPolicy {
return &sandbox.SandboxPolicy{
Name: "lockdown-translate",
PackageManagers: []string{"golang"},
Filesystem: sandbox.FilesystemPolicy{
AllowRead: []string{"/tmp"},
AllowWrite: []string{"/tmp"},
},
Network: sandbox.NetworkPolicy{
AllowOutbound: []string{"proxy.golang.org:443"},
},
NetworkViaProxyOnly: utils.PtrTo(true),
}
}
denyMarker := `|kind=network-outbound|target=direct"))`
proxyAllow := `(allow network-outbound (remote ip "localhost:54321"))`
blanketAllow := "(allow network-outbound)\n"
dnsAllow := `(allow network-outbound (remote unix-socket (path-literal "/var/run/mDNSResponder")))`
bindRule := `(allow network* (local ip "localhost:*"))`
tests := []struct {
name string
rt *sandbox.ExecutionContext
mutate func(*sandbox.SandboxPolicy)
assert func(t *testing.T, out string)
}{
{
name: "lockdown base",
rt: rt,
mutate: func(p *sandbox.SandboxPolicy) {},
assert: func(t *testing.T, out string) {
assert.Contains(t, out, denyMarker)
assert.Contains(t, out, proxyAllow)
assert.NotContains(t, out, "mDNSResponder")
assert.NotContains(t, out, blanketAllow)
},
},
{
name: "allow_direct_dns reopens mDNSResponder",
rt: rt,
mutate: func(p *sandbox.SandboxPolicy) { p.AllowDirectDNS = utils.PtrTo(true) },
assert: func(t *testing.T, out string) {
assert.Contains(t, out, dnsAllow)
},
},
{
name: "allow_network_bind rules come after the lockdown deny",
rt: rt,
mutate: func(p *sandbox.SandboxPolicy) { p.AllowNetworkBind = utils.PtrTo(true) },
assert: func(t *testing.T, out string) {
assert.Contains(t, out, denyMarker)
assert.Contains(t, out, bindRule)
denyIdx := strings.Index(out, denyMarker)
bindIdx := strings.Index(out, bindRule)
require.GreaterOrEqual(t, denyIdx, 0)
assert.Greater(t, bindIdx, denyIdx, "bind rules must come after the lockdown deny (SBPL last-match-wins)")
},
},
{
name: "render without proxy context stays deny-only and documents the runtime allow",
rt: nil,
mutate: func(p *sandbox.SandboxPolicy) {},
assert: func(t *testing.T, out string) {
assert.Contains(t, out, denyMarker)
assert.Contains(t, out, "Rendered without a running PMG proxy")
// The runtime allow is documented in an SBPL comment; assert no
// actual (non-comment) allow rule is emitted.
assert.NotContains(t, out, "\n(allow network-outbound (remote ip")
},
},
{
name: "lockdown off keeps blanket allow",
rt: rt,
mutate: func(p *sandbox.SandboxPolicy) { p.NetworkViaProxyOnly = nil },
assert: func(t *testing.T, out string) {
assert.Contains(t, out, blanketAllow)
assert.NotContains(t, out, denyMarker)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
policy := basePolicy()
tt.mutate(policy)
translator := newSeatbeltPolicyTranslator()
out, err := translator.translate(policy, tt.rt)
require.NoError(t, err)
tt.assert(t, out)
})
}
}