Compare commits

...
Author SHA1 Message Date
Gnanam e6d43c62d9 sandbox: wire scoped network egress through the config surface
The engine and the Linux/Windows backends already enforce NetworkScoped (an
egress allowlist), but no static config could reach it: resolver.go rejected
"scoped" ("expected allow or deny"), the config-to-policy translations silently
ignored it, and there was no config field for the allowed-domains list — so
scoped was reachable only via the runtime permission flow.

- Add SandboxConfig.NetworkAllowedDomains / NetworkDeniedDomains; document
  network: "scoped".
- resolver.go: accept "scoped"; reject it with an empty allowlist (fail loud
  rather than let the engine silently collapse it to deny); union the domain
  lists from GLOBAL user config only, never project config (mirrors
  AdditionalWriteRoots) so a cloned repo cannot widen its own egress.
- applyConfiguredSandboxPolicy (the central exec/TUI/sandbox-CLI path) and
  doctorSandboxPolicy: thread scoped mode + the domain lists into the Policy.

Tests: resolver accepts scoped+domains, rejects empty-allowlist scoped, rejects
unknown modes, and a project config cannot widen the allowlist; both policy
translations carry the lists through. The engine still fails closed (empty
allowlist or an unenforceable backend collapses to deny).
2026-06-19 12:37:09 +05:30
7 changed files with 169 additions and 10 deletions

No files matched your search

+10 -1
View File
@@ -724,10 +724,19 @@ func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfi
policy = applyConfiguredAutonomyCeiling(policy, cfg.MaxAutonomy)
if network := strings.TrimSpace(cfg.Network); network != "" {
switch sandbox.NetworkMode(network) {
case sandbox.NetworkAllow, sandbox.NetworkDeny:
case sandbox.NetworkAllow, sandbox.NetworkDeny, sandbox.NetworkScoped:
policy.Network = sandbox.NetworkMode(network)
}
}
// Scoped egress allow/deny lists from (global) config; the engine consults
// these only under NetworkScoped and falls closed to deny on an empty
// allowlist or a backend that can't enforce scoping.
if len(cfg.NetworkAllowedDomains) > 0 {
policy.AllowedDomains = append([]string(nil), cfg.NetworkAllowedDomains...)
}
if len(cfg.NetworkDeniedDomains) > 0 {
policy.DeniedDomains = append([]string(nil), cfg.NetworkDeniedDomains...)
}
// Opt-in hardening/diagnostic flags: only ever turn a feature ON from
// config, never off, so a programmatic default can't be silently disabled by
// an omitted key.
+29
View File
@@ -0,0 +1,29 @@
package cli
import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
)
// The central config->policy translation must carry scoped mode + the egress
// allow/deny lists into the engine policy (this path feeds exec, the TUI app,
// and the sandbox CLI commands).
func TestApplyConfiguredSandboxPolicyWiresScopedNetwork(t *testing.T) {
cfg := config.SandboxConfig{
Network: "scoped",
NetworkAllowedDomains: []string{"github.com", "pypi.org"},
NetworkDeniedDomains: []string{"evil.example"},
}
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), cfg)
if policy.Network != sandbox.NetworkScoped {
t.Fatalf("Network = %q, want scoped", policy.Network)
}
if len(policy.AllowedDomains) != 2 || policy.AllowedDomains[0] != "github.com" {
t.Fatalf("AllowedDomains = %#v", policy.AllowedDomains)
}
if len(policy.DeniedDomains) != 1 || policy.DeniedDomains[0] != "evil.example" {
t.Fatalf("DeniedDomains = %#v", policy.DeniedDomains)
}
}
+29 -5
View File
@@ -83,8 +83,14 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
if network := strings.TrimSpace(cfg.Sandbox.Network); network != "" {
switch sandbox.NetworkMode(network) {
case sandbox.NetworkAllow, sandbox.NetworkDeny:
case sandbox.NetworkScoped:
// Scoped with no allowlist would collapse to deny in the engine; reject
// it loudly so the operator isn't surprised by a silent downgrade.
if !hasNonEmptyDomain(cfg.Sandbox.NetworkAllowedDomains) {
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: scoped requires a non-empty sandbox.networkAllowedDomains", network)
}
default:
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: expected allow or deny", network)
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: expected allow, deny, or scoped", network)
}
}
if mode := strings.TrimSpace(cfg.Notify.Mode); mode != "" {
@@ -171,6 +177,11 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
dst.Sandbox.Network = network
}
// Scoped egress allow/deny lists are a GRANT, layered here (global user
// config) like AdditionalWriteRoots and likewise NOT taken from project
// config (see mergeProjectConfig).
dst.Sandbox.NetworkAllowedDomains = unionStrings(dst.Sandbox.NetworkAllowedDomains, src.Sandbox.NetworkAllowedDomains)
dst.Sandbox.NetworkDeniedDomains = unionStrings(dst.Sandbox.NetworkDeniedDomains, src.Sandbox.NetworkDeniedDomains)
dst.Sandbox.AdditionalWriteRoots = unionStrings(dst.Sandbox.AdditionalWriteRoots, src.Sandbox.AdditionalWriteRoots)
if src.Sandbox.BlockUnixSockets {
dst.Sandbox.BlockUnixSockets = true
@@ -211,10 +222,11 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
mergeProvider(dst, provider)
}
mergeMCPConfig(&dst.MCP, src.MCP)
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
// config: a cloned repo's .zero/config.json must not be able to grant
// itself write access outside the workspace. Global config and CLI flags
// are the only config sources for write roots.
// Sandbox.AdditionalWriteRoots and Sandbox.NetworkAllowedDomains/
// NetworkDeniedDomains are intentionally NOT merged from project config: a
// cloned repo's .zero/config.json must not be able to grant itself write
// access outside the workspace, nor widen its own network egress allowlist.
// Global config (and CLI flags) are the only sources for those grants.
if maxAutonomy := strings.TrimSpace(src.Sandbox.MaxAutonomy); maxAutonomy != "" {
dst.Sandbox.MaxAutonomy = maxAutonomy
}
@@ -631,6 +643,18 @@ func hasProviderFields(profile ProviderProfile) bool {
// base, preserving order. Used for additive config keys like
// sandbox.additionalWriteRoots where a later layer must not erase earlier
// grants.
// hasNonEmptyDomain reports whether domains contains at least one entry that is
// non-empty after trimming — used to reject a scoped network policy that has no
// usable allowlist.
func hasNonEmptyDomain(domains []string) bool {
for _, domain := range domains {
if strings.TrimSpace(domain) != "" {
return true
}
}
return false
}
func unionStrings(base []string, extra []string) []string {
seen := make(map[string]struct{}, len(base))
for _, value := range base {
+60
View File
@@ -0,0 +1,60 @@
package config
import (
"strings"
"testing"
)
func TestResolveAcceptsScopedNetworkWithAllowlist(t *testing.T) {
userPath := writeConfig(t, `{
"sandbox": {"network": "scoped", "networkAllowedDomains": ["github.com", "pypi.org"], "networkDeniedDomains": ["evil.example"]}
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Sandbox.Network != "scoped" {
t.Fatalf("Network = %q, want scoped", resolved.Sandbox.Network)
}
if len(resolved.Sandbox.NetworkAllowedDomains) != 2 || resolved.Sandbox.NetworkAllowedDomains[0] != "github.com" {
t.Fatalf("NetworkAllowedDomains = %#v", resolved.Sandbox.NetworkAllowedDomains)
}
if len(resolved.Sandbox.NetworkDeniedDomains) != 1 {
t.Fatalf("NetworkDeniedDomains = %#v", resolved.Sandbox.NetworkDeniedDomains)
}
}
func TestResolveRejectsScopedNetworkWithoutAllowlist(t *testing.T) {
userPath := writeConfig(t, `{"sandbox": {"network": "scoped"}}`)
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
if err == nil || !strings.Contains(err.Error(), "scoped requires a non-empty sandbox.networkAllowedDomains") {
t.Fatalf("expected scoped-without-allowlist error, got %v", err)
}
}
func TestResolveRejectsUnknownNetworkMode(t *testing.T) {
userPath := writeConfig(t, `{"sandbox": {"network": "bogus"}}`)
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
if err == nil || !strings.Contains(err.Error(), "expected allow, deny, or scoped") {
t.Fatalf("expected unknown-mode error, got %v", err)
}
}
// Security: a project config (a cloned repo) must NOT be able to add to the
// scoped egress allowlist — only the global user config can.
func TestResolveProjectCannotWidenNetworkAllowlist(t *testing.T) {
userPath := writeConfig(t, `{"sandbox": {"network": "scoped", "networkAllowedDomains": ["github.com"]}}`)
projectPath := writeConfig(t, `{"sandbox": {"networkAllowedDomains": ["exfil.example"]}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
for _, d := range resolved.Sandbox.NetworkAllowedDomains {
if d == "exfil.example" {
t.Fatalf("project config widened the egress allowlist: %#v", resolved.Sandbox.NetworkAllowedDomains)
}
}
if len(resolved.Sandbox.NetworkAllowedDomains) != 1 || resolved.Sandbox.NetworkAllowedDomains[0] != "github.com" {
t.Fatalf("allowlist = %#v, want [github.com] only", resolved.Sandbox.NetworkAllowedDomains)
}
}
+15 -3
View File
@@ -59,10 +59,22 @@ func HasProviderProfile(profile ProviderProfile) bool {
type SandboxConfig struct {
MaxAutonomy string `json:"maxAutonomy,omitempty"`
// Network controls whether shell commands classified as network-touching
// (curl, git push, package installs, …) are allowed: "allow" or "deny".
// Empty keeps the built-in default (deny). Without this knob the engine's
// hard-coded NetworkDeny was unreachable from any config surface.
// (curl, git push, package installs, …) are allowed: "allow", "deny", or
// "scoped". Empty keeps the built-in default (deny). "scoped" permits egress
// only to NetworkAllowedDomains (minus NetworkDeniedDomains) and REQUIRES a
// non-empty NetworkAllowedDomains; it falls closed to "deny" on a backend that
// cannot enforce scoped egress.
Network string `json:"network,omitempty"`
// NetworkAllowedDomains is the egress allowlist consulted only when Network is
// "scoped": the sandboxed process may reach only these domains (minus
// NetworkDeniedDomains) and nothing else. Ignored for "allow"/"deny". Like
// AdditionalWriteRoots this is a GRANT, so it is honored from the GLOBAL user
// config only — never project config — so a cloned repo cannot widen its own
// network egress.
NetworkAllowedDomains []string `json:"networkAllowedDomains,omitempty"`
// NetworkDeniedDomains subtracts from NetworkAllowedDomains under "scoped".
// Global-user-config only, for the same reason as NetworkAllowedDomains.
NetworkDeniedDomains []string `json:"networkDeniedDomains,omitempty"`
// AdditionalWriteRoots lists directories outside the workspace the sandbox
// allows writes in. Each entry must be an existing directory; entries are
// normalized (~-expanded, absolutized, symlink-resolved) at startup and an
+7 -1
View File
@@ -121,9 +121,15 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
func doctorSandboxPolicy(cfg config.SandboxConfig) sandbox.Policy {
policy := sandbox.DefaultPolicy()
switch sandbox.NetworkMode(cfg.Network) {
case sandbox.NetworkAllow, sandbox.NetworkDeny:
case sandbox.NetworkAllow, sandbox.NetworkDeny, sandbox.NetworkScoped:
policy.Network = sandbox.NetworkMode(cfg.Network)
}
if len(cfg.NetworkAllowedDomains) > 0 {
policy.AllowedDomains = append([]string(nil), cfg.NetworkAllowedDomains...)
}
if len(cfg.NetworkDeniedDomains) > 0 {
policy.DeniedDomains = append([]string(nil), cfg.NetworkDeniedDomains...)
}
policy.MonitorDenials = cfg.MonitorDenials
return policy
}
+19
View File
@@ -0,0 +1,19 @@
package doctor
import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
)
func TestDoctorSandboxPolicyWiresScopedNetwork(t *testing.T) {
cfg := config.SandboxConfig{Network: "scoped", NetworkAllowedDomains: []string{"github.com"}}
policy := doctorSandboxPolicy(cfg)
if policy.Network != sandbox.NetworkScoped {
t.Fatalf("Network = %q, want scoped", policy.Network)
}
if len(policy.AllowedDomains) != 1 || policy.AllowedDomains[0] != "github.com" {
t.Fatalf("AllowedDomains = %#v", policy.AllowedDomains)
}
}