diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 239c6bd51..53a56d9d1 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -939,6 +939,12 @@ func providerSensitiveEnvKeys(resolved config.ResolvedConfig) []string { // applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto // the default policy. func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy { + // An explicit `"sandbox": {"enabled": false}` disables the sandbox: every + // request is allowed and commands run unwrapped. Only reachable from global + // config / CLI — project config never sets Enabled (see mergeProjectConfig). + if cfg.Enabled != nil && !*cfg.Enabled { + policy.Mode = sandbox.ModeDisabled + } if network := strings.TrimSpace(cfg.Network); network != "" { switch sandbox.NetworkMode(network) { case sandbox.NetworkAllow, sandbox.NetworkDeny: diff --git a/internal/cli/sandbox_test.go b/internal/cli/sandbox_test.go index 8145fe514..16489f407 100644 --- a/internal/cli/sandbox_test.go +++ b/internal/cli/sandbox_test.go @@ -1001,3 +1001,18 @@ func sandboxPolicyCapabilityStatus(capabilities []sandbox.BackendCapability, key } return "" } + +func TestApplyConfiguredSandboxPolicyEnabledFalseDisables(t *testing.T) { + disabled := false + if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &disabled}); got.Mode != sandbox.ModeDisabled { + t.Fatalf("enabled:false must yield ModeDisabled, got %q", got.Mode) + } + // Omitted (nil) and explicit true keep the default enforcing mode. + if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.Mode == sandbox.ModeDisabled { + t.Fatal("omitted enabled must not disable the sandbox") + } + enabled := true + if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &enabled}); got.Mode == sandbox.ModeDisabled { + t.Fatal("enabled:true must not disable the sandbox") + } +} diff --git a/internal/config/resolver.go b/internal/config/resolver.go index 54315f80e..0d9b11786 100644 --- a/internal/config/resolver.go +++ b/internal/config/resolver.go @@ -93,6 +93,16 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) { if err != nil { return ResolvedConfig{}, err } + // Sandbox.Enabled is NOT accepted from a provider command, for the same + // reason project config cannot set it (see mergeProjectConfig): only + // global config and the CLI may turn the sandbox off. A provider command + // is an arbitrary executable named in config, and LoadProviderCommand + // parses its stdout into a full FileConfig — so without this, a command + // returning a valid provider plus {"sandbox":{"enabled":false}} would + // disable the very sandbox meant to constrain what it can do. Cleared + // here rather than in mergeConfig because that helper is shared with the + // trusted global-config merge, which must keep honouring the setting. + commandConfig.Sandbox.Enabled = nil mergeConfig(&cfg, commandConfig) } @@ -216,6 +226,9 @@ func mergeConfig(dst *FileConfig, src FileConfig) { mergeProvider(dst, provider) } mergeMCPConfig(&dst.MCP, src.MCP, true) + if src.Sandbox.Enabled != nil { + dst.Sandbox.Enabled = src.Sandbox.Enabled + } if network := strings.TrimSpace(src.Sandbox.Network); network != "" { dst.Sandbox.Network = network } @@ -273,6 +286,10 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error { if err := mergeProjectMCPConfig(&dst.MCP, src.MCP); err != nil { return err } + // Sandbox.Enabled is intentionally NOT merged from project config: a cloned + // repo's .zero/config.json must not be able to disable the sandbox that + // constrains it. Only global config and CLI can turn the sandbox off. + // // 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 @@ -688,6 +705,9 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) { if overrides.MaxTurns > 0 { cfg.MaxTurns = overrides.MaxTurns } + if overrides.Sandbox.Enabled != nil { + cfg.Sandbox.Enabled = overrides.Sandbox.Enabled + } if overrides.Sandbox.BlockUnixSockets { cfg.Sandbox.BlockUnixSockets = true } diff --git a/internal/config/resolver_test.go b/internal/config/resolver_test.go index 5931f00d0..35c8cf872 100644 --- a/internal/config/resolver_test.go +++ b/internal/config/resolver_test.go @@ -1744,6 +1744,85 @@ func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) { } } +func TestResolveSandboxEnabledUserConfigDisables(t *testing.T) { + userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`) + resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled { + t.Fatalf("user config enabled:false must resolve to a false pointer, got %v", resolved.Sandbox.Enabled) + } +} + +func TestResolveSandboxDisableIgnoredFromProjectConfig(t *testing.T) { + // Security: a cloned repo's project config must NOT be able to disable the + // sandbox that constrains it. Only global config / CLI can turn it off. + userPath := writeConfig(t, `{}`) + projectPath := writeConfig(t, `{"sandbox": {"enabled": false}}`) + resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if resolved.Sandbox.Enabled != nil { + t.Fatalf("project config enabled:false must be ignored, got %v — a repo could disable the sandbox", *resolved.Sandbox.Enabled) + } +} + +func TestResolveSandboxEnabledIgnoredFromProviderCommand(t *testing.T) { + // Security: a provider command is an arbitrary executable named in config, and + // its stdout is parsed into a full FileConfig — so it must NOT be able to reach + // Sandbox.Enabled, in either direction. Same rule as project config: only global + // config / CLI may turn the sandbox off. + for _, tc := range []struct { + name string + value string + }{ + {name: "cannot disable", value: "false"}, + {name: "cannot enable", value: "true"}, + } { + t.Run(tc.name, func(t *testing.T) { + command := writeCommand(t, commandScript{ + Stdout: `{"activeProvider":"cmd","providers":[{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}],"sandbox":{"enabled":` + tc.value + `}}`, + }) + resolved, err := Resolve(ResolveOptions{ProviderCommand: command, Env: map[string]string{}}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + // The provider itself must still be applied — only the sandbox toggle is dropped. + if resolved.ActiveProvider != "cmd" { + t.Fatalf("ActiveProvider = %q, want cmd (provider command must still apply)", resolved.ActiveProvider) + } + if resolved.Sandbox.Enabled != nil { + t.Fatalf("provider command set sandbox.enabled=%s, got %v — a provider command could toggle the sandbox", tc.value, *resolved.Sandbox.Enabled) + } + }) + } +} + +func TestResolveSandboxEnabledCLIOverride(t *testing.T) { + // A CLI/programmatic override (applied after every config source) must be able + // to disable the sandbox, even with no config file. + disabled := false + resolved, err := Resolve(ResolveOptions{Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &disabled}}}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled { + t.Fatalf("CLI override enabled:false must disable the sandbox, got %v", resolved.Sandbox.Enabled) + } + // An explicit true override must win over a lower-precedence global-config false. + userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`) + enabled := true + resolved2, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &enabled}}}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if resolved2.Sandbox.Enabled == nil || !*resolved2.Sandbox.Enabled { + t.Fatalf("CLI override enabled:true must win over config false, got %v", resolved2.Sandbox.Enabled) + } +} + func TestResolveNotifyValid(t *testing.T) { path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`) resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}}) diff --git a/internal/config/types.go b/internal/config/types.go index 718d1702f..8e9cc5476 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -62,6 +62,12 @@ func HasProviderProfile(profile ProviderProfile) bool { } type SandboxConfig struct { + // Enabled turns the sandbox off when set to false (`"sandbox": {"enabled": + // false}`). A pointer distinguishes an explicit false (disable) from an + // omitted key (keep the default: enabled). Honored from the GLOBAL user + // config and CLI only — deliberately NOT project config, so a cloned repo + // cannot disable the sandbox that constrains it. nil / true keep enforcement. + Enabled *bool `json:"enabled,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