From 0dc81c755594b95d2a5ac9896941adb0b27201bf Mon Sep 17 00:00:00 2001 From: KRATOS Date: Sun, 19 Jul 2026 10:34:42 +0530 Subject: [PATCH 1/3] feat(sandbox): disable the sandbox via config (#687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a sandbox.enabled field (`"sandbox": {"enabled": false}`). ModeDisabled already short-circuits the engine but had no config surface. - SandboxConfig.Enabled *bool (pointer distinguishes an explicit false from an omitted key). - mergeConfig honors it (global config / CLI); applyConfiguredSandboxPolicy maps enabled:false to ModeDisabled. - mergeProjectConfig intentionally does NOT merge it: a cloned repo must not be able to disable the sandbox that constrains it — same posture as AdditionalWriteRoots and the Network tighten-only rule. --- internal/cli/exec.go | 6 ++++++ internal/cli/sandbox_test.go | 15 +++++++++++++++ internal/config/resolver.go | 7 +++++++ internal/config/resolver_test.go | 25 +++++++++++++++++++++++++ internal/config/types.go | 6 ++++++ 5 files changed, 59 insertions(+) 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..870dc0047 100644 --- a/internal/config/resolver.go +++ b/internal/config/resolver.go @@ -216,6 +216,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 +276,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 diff --git a/internal/config/resolver_test.go b/internal/config/resolver_test.go index 5931f00d0..ed4464528 100644 --- a/internal/config/resolver_test.go +++ b/internal/config/resolver_test.go @@ -1744,6 +1744,31 @@ 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 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 From 2a68d9c4b12f578c15e198656c1ca73a46270aa4 Mon Sep 17 00:00:00 2001 From: KRATOS Date: Sun, 19 Jul 2026 18:13:06 +0530 Subject: [PATCH 2/3] fix(config): honor sandbox.enabled from the CLI override path (#687 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyOverrides copied BlockUnixSockets/MonitorDenials but not the new Enabled tri-state pointer, so a CLI/programmatic Overrides{Sandbox:{Enabled:&false}} was dropped — contradicting the 'global config / CLI can disable' contract. Preserve the pointer in applyOverrides (runs after all config sources, so an explicit override wins) and add a resolver regression test: an explicit false override disables, and an explicit true override wins over a lower-precedence config false. --- internal/config/resolver.go | 3 +++ internal/config/resolver_test.go | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/internal/config/resolver.go b/internal/config/resolver.go index 870dc0047..0344173ee 100644 --- a/internal/config/resolver.go +++ b/internal/config/resolver.go @@ -695,6 +695,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 ed4464528..7ab940360 100644 --- a/internal/config/resolver_test.go +++ b/internal/config/resolver_test.go @@ -1769,6 +1769,29 @@ func TestResolveSandboxDisableIgnoredFromProjectConfig(t *testing.T) { } } +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{}}) From 76ef636f4b96f709b69f69ef39b9954cdc622709 Mon Sep 17 00:00:00 2001 From: KRATOS Date: Mon, 20 Jul 2026 12:29:47 +0530 Subject: [PATCH 3/3] fix(config): keep sandbox.enabled out of the provider-command path (#687 review) mergeConfig now copies Sandbox.Enabled, and it is shared by two callers: the global-config merge (trusted) and the provider-command merge (not). A provider command is an arbitrary executable named in config, and LoadProviderCommand parses its stdout into a full FileConfig -- so one returning a valid provider plus {"sandbox":{"enabled":false}} could disable the sandbox meant to constrain it, despite the setting being documented as global-config/CLI only. Confirmed reproducible in both directions before the fix. Clear Sandbox.Enabled from the provider-command config before merging, mirroring the carve-out mergeProjectConfig already applies for the same reason. Done at the call site rather than inside mergeConfig because that helper must keep honouring the setting for the trusted global-config merge. Adds TestResolveSandboxEnabledIgnoredFromProviderCommand covering both directions (cannot disable, cannot enable) and asserting the provider itself is still applied, so the guard cannot be satisfied by dropping the command wholesale. --- internal/config/resolver.go | 10 ++++++++++ internal/config/resolver_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/internal/config/resolver.go b/internal/config/resolver.go index 0344173ee..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) } diff --git a/internal/config/resolver_test.go b/internal/config/resolver_test.go index 7ab940360..35c8cf872 100644 --- a/internal/config/resolver_test.go +++ b/internal/config/resolver_test.go @@ -1769,6 +1769,37 @@ func TestResolveSandboxDisableIgnoredFromProjectConfig(t *testing.T) { } } +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.