From d6d95bc746658935cbd326754a6d60d7fa04e190 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 24 Sep 2026 18:46:55 +0530 Subject: [PATCH 1/7] fix(sandbox): say when a session's sandbox enforcement is degraded Only `zero doctor` and `zero sandbox policy` reported a degraded sandbox, so a TUI session or a `zero exec` run went ahead with the native sandbox effectively off and nobody saw it. Both now say so once, before the first prompt or tool call, naming the downgrade reason: exec on stderr next to the grant migration notice, the TUI as a system notice when it opens. The line comes from the session's own engine through Backend.BuildPlan, the plan `zero sandbox policy` prints, so the notice and the command that explains it cannot describe two different sandboxes. Only the degraded level speaks: a disabled sandbox is the user's choice, and the unelevated Windows tier still enforces the write jail. Exec tests that expect a quiet stderr now compare against the notice this host prints under the default policy, since they run on the host's own backend and a Linux runner without the helper is degraded while macOS and Windows are not. Fixes #1041 --- internal/cli/app.go | 4 + internal/cli/exec.go | 5 + internal/cli/exec_protocol_test.go | 22 +-- internal/cli/exec_test.go | 32 ++-- internal/cli/local_control_test.go | 2 +- internal/cli/sandbox_degraded_notice_test.go | 163 +++++++++++++++++++ internal/sandbox/adapters.go | 15 ++ internal/sandbox/degraded_notice_test.go | 68 ++++++++ internal/sandbox/engine.go | 13 ++ internal/tui/model.go | 5 + internal/tui/options.go | 5 + internal/tui/startup_notices_test.go | 23 +++ 12 files changed, 329 insertions(+), 28 deletions(-) create mode 100644 internal/cli/sandbox_degraded_notice_test.go create mode 100644 internal/sandbox/degraded_notice_test.go create mode 100644 internal/tui/startup_notices_test.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 160eabc5b..0e670c588 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -959,6 +959,9 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // notice when project hooks/plugins were dropped for an untrusted workspace. hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner) emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip) + // From the engine the session's commands run through, so the notice + // describes the sandbox they actually get. + startupNotices := []string{sandboxEngine.DegradedNotice()} return deps.runTUI(context.Background(), tui.Options{ Cwd: workspaceRoot, Version: version, @@ -1046,6 +1049,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a PermissionMode: permissionMode, Notify: resolved.Notify, KeyBindings: resolved.KeyBindings, + StartupNotices: startupNotices, STT: resolved.STT, BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager), ShutdownDictationServer: sttServerManager.Shutdown, diff --git a/internal/cli/exec.go b/internal/cli/exec.go index d47964c11..a5d22b061 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -347,6 +347,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in } else if notice != "" { _, _ = fmt.Fprintln(stderr, "[zero] "+notice) } + // Before any tool runs, so a run that goes ahead with reduced isolation says + // so where its output starts, not only when someone runs `zero doctor`. + if notice := sandboxEngine.DegradedNotice(); notice != "" { + _, _ = fmt.Fprintln(stderr, "[zero] "+notice) + } executionRunner.SetPreparer(sandboxEngine) if permissionMode != agent.PermissionModePlan { mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner) diff --git a/internal/cli/exec_protocol_test.go b/internal/cli/exec_protocol_test.go index 4750572d4..8d649d3d1 100644 --- a/internal/cli/exec_protocol_test.go +++ b/internal/cli/exec_protocol_test.go @@ -70,8 +70,8 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) { if exitCode != exitSuccess { t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr, got %q", stderr.String()) + if stderr.String() != hostSandboxNotice(t) { + t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String()) } if providerBuilt { t.Fatal("provider should not be constructed for --list-tools") @@ -108,8 +108,8 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) { if exitCode != exitSuccess { t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr, got %q", stderr.String()) + if stderr.String() != hostSandboxNotice(t) { + t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String()) } if providerBuilt { t.Fatal("provider should not be constructed for --list-tools") @@ -176,8 +176,8 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) { if !closed { t.Fatal("MCP runtime was not closed after --list-tools") } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr, got %q", stderr.String()) + if stderr.String() != hostSandboxNotice(t) { + t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String()) } if !strings.Contains(stdout.String(), "mcp_docs_lookup") || !strings.Contains(stdout.String(), "Lookup documentation") { t.Fatalf("expected MCP tool in list output, got %q", stdout.String()) @@ -323,8 +323,8 @@ func TestRunExecStreamJSONOutputsRunEndAndRecordsSession(t *testing.T) { if exitCode != exitSuccess { t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr, got %q", stderr.String()) + if stderr.String() != hostSandboxNotice(t) { + t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String()) } events := decodeJSONLines(t, stdout.String()) @@ -388,8 +388,8 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) { if exitCode != exitSuccess { t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr, got %q", stderr.String()) + if stderr.String() != hostSandboxNotice(t) { + t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String()) } events := decodeJSONLines(t, stdout.String()) @@ -460,7 +460,7 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) { if exitCode != exitSuccess { t.Fatalf("approved exec exit = %d, stderr = %q", exitCode, stderr.String()) } - if stderr.String() != "" { + if stderr.String() != hostSandboxNotice(t) { t.Fatalf("approved exec wrote stderr = %q", stderr.String()) } approvedEvents := decodeJSONLines(t, stdout.String()) diff --git a/internal/cli/exec_test.go b/internal/cli/exec_test.go index 41e4483ab..017f68cfa 100644 --- a/internal/cli/exec_test.go +++ b/internal/cli/exec_test.go @@ -568,8 +568,8 @@ func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) { if !strings.Contains(stdout, "hello") { t.Fatalf("expected prompt output, got %q", stdout) } - if stderr != "" { - t.Fatalf("expected empty stderr, got %q", stderr) + if stderr != hostSandboxNotice(t) { + t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr) } } @@ -696,8 +696,8 @@ func TestRunExecAssemblesInlineAndFilePromptRelativeToCwd(t *testing.T) { if !strings.Contains(stdout, "inline prompt\n\nfile prompt") { t.Fatalf("expected inline and file prompt joined by blank line, got %q", stdout) } - if stderr != "" { - t.Fatalf("expected empty stderr, got %q", stderr) + if stderr != hostSandboxNotice(t) { + t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr) } } @@ -715,8 +715,8 @@ func TestRunExecAcceptsFileOnlyPrompt(t *testing.T) { if !strings.Contains(stdout, "file only prompt") { t.Fatalf("expected file prompt output, got %q", stdout) } - if stderr != "" { - t.Fatalf("expected empty stderr, got %q", stderr) + if stderr != hostSandboxNotice(t) { + t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr) } } @@ -798,8 +798,8 @@ func TestRunExecJSONOutputsNDJSONEvents(t *testing.T) { if exitCode != 0 { t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr) } - if stderr != "" { - t.Fatalf("expected empty stderr, got %q", stderr) + if stderr != hostSandboxNotice(t) { + t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr) } events := decodeJSONLines(t, stdout) @@ -833,8 +833,8 @@ func TestRunExecResolvesCanonicalModelAlias(t *testing.T) { if exitCode != 0 { t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr) } - if stderr != "" { - t.Fatalf("expected empty stderr for active model, got %q", stderr) + if stderr != hostSandboxNotice(t) { + t.Fatalf("expected no stderr beyond this host's sandbox notice for active model, got %q", stderr) } events := decodeJSONLines(t, stdout) if got := events[0]["model"]; got != "gpt-4.1" { @@ -926,8 +926,8 @@ func TestRunExecJSONUnsafeOutputsWarningEvent(t *testing.T) { if exitCode != 0 { t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr) } - if stderr != "" { - t.Fatalf("expected empty stderr, got %q", stderr) + if stderr != hostSandboxNotice(t) { + t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr) } events := decodeJSONLines(t, stdout) @@ -988,8 +988,8 @@ func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) { if strings.TrimSpace(stdout.String()) != "provider ok" { t.Fatalf("stdout = %q, want provider response", stdout.String()) } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr, got %q", stderr.String()) + if stderr.String() != hostSandboxNotice(t) { + t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String()) } if gotAuth != "Bearer sk-local" { t.Fatalf("Authorization = %q, want project config token", gotAuth) @@ -1204,8 +1204,8 @@ func TestRunExecAutoHighEmitsUnsafeWarning(t *testing.T) { if exitCode != exitSuccess { t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr) } - if stderr != "" { - t.Fatalf("expected empty stderr, got %q", stderr) + if stderr != hostSandboxNotice(t) { + t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr) } events := decodeJSONLines(t, stdout) diff --git a/internal/cli/local_control_test.go b/internal/cli/local_control_test.go index 921ae9e26..3f4179514 100644 --- a/internal/cli/local_control_test.go +++ b/internal/cli/local_control_test.go @@ -69,7 +69,7 @@ func TestRunExecListToolsHonorsDisabledLocalControlConfig(t *testing.T) { if exitCode != exitSuccess { t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) } - if stderr.Len() != 0 { + if stderr.String() != hostSandboxNotice(t) { t.Fatalf("stderr = %q, want empty", stderr.String()) } var payload struct { diff --git a/internal/cli/sandbox_degraded_notice_test.go b/internal/cli/sandbox_degraded_notice_test.go new file mode 100644 index 000000000..be7d95f5a --- /dev/null +++ b/internal/cli/sandbox_degraded_notice_test.go @@ -0,0 +1,163 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/mcp" + "github.com/Gitlawb/zero/internal/sandbox" + "github.com/Gitlawb/zero/internal/tools" + "github.com/Gitlawb/zero/internal/tui" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +// hostSandboxNotice is the degraded-sandbox line exec prints on THIS machine for +// a run under the default sandbox policy, or "" where the sandbox is not +// degraded. Exec tests that expect a quiet stderr compare against it rather than +// against "": unless a test pins a backend, the run gets the host's own, and a +// Linux runner without the sandbox helper is degraded where macOS and Windows +// are not. +func hostSandboxNotice(t *testing.T) string { + t.Helper() + notice := sandbox.SelectBackend(sandbox.BackendOptions{}).BuildPlan(t.TempDir(), sandbox.DefaultPolicy()).DegradedNotice() + if notice == "" { + return "" + } + return "[zero] " + notice + "\n" +} + +// unavailableTestSandbox is the backend a Linux box without the helper gets, +// pinned so a test sees the degraded case on every platform. +func unavailableTestSandbox(sandbox.BackendOptions) sandbox.Backend { + return sandbox.Backend{Name: sandbox.BackendUnavailable, Platform: "linux", Fallback: true, Message: "Linux sandbox helper is not available"} +} + +const unavailableTestSandboxNotice = "[zero] Sandbox enforcement is degraded: Linux sandbox helper is not available. See `zero sandbox policy --effective`.\n" + +func runExecWithSandbox(t *testing.T, args []string, backend func(sandbox.BackendOptions) sandbox.Backend, sandboxConfig config.SandboxConfig) (int, string, string) { + t.Helper() + var stdout, stderr bytes.Buffer + cwd := t.TempDir() + exitCode := runWithDeps(args, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { return cwd, nil }, + resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { + return config.ResolvedConfig{ + ActiveProvider: "echo", + Provider: config.ProviderProfile{ + Name: "echo", + ProviderKind: config.ProviderKindOpenAICompatible, + BaseURL: "http://127.0.0.1/v1", + Model: "echo-model", + }, + MaxTurns: 3, + Sandbox: sandboxConfig, + }, nil + }, + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return echoExecProvider{}, nil + }, + selectSandboxBackend: backend, + }) + return exitCode, stdout.String(), stderr.String() +} + +// A RUN THAT GOES AHEAD WITH REDUCED ISOLATION SAYS SO. Before this only `zero +// doctor` and `zero sandbox policy` did, so `zero exec` ran every tool with the +// native sandbox effectively off and printed nothing about it. #1041. +func TestExecSaysOnStderrWhenTheSandboxIsDegraded(t *testing.T) { + exitCode, _, stderr := runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{}) + if exitCode != 0 { + t.Fatalf("exit code = %d, stderr %q", exitCode, stderr) + } + if stderr != unavailableTestSandboxNotice { + t.Fatalf("stderr = %q, want exactly the degraded notice %q", stderr, unavailableTestSandboxNotice) + } +} + +// The notice goes to stderr in the JSON modes as well, so stdout stays one JSON +// object per line for whatever reads it. +func TestExecKeepsTheDegradedNoticeOffStdoutInJSONMode(t *testing.T) { + exitCode, stdout, stderr := runExecWithSandbox(t, []string{"exec", "--output-format", "json", "hello"}, unavailableTestSandbox, config.SandboxConfig{}) + if exitCode != 0 { + t.Fatalf("exit code = %d, stderr %q", exitCode, stderr) + } + if stderr != unavailableTestSandboxNotice { + t.Fatalf("stderr = %q, want exactly the degraded notice", stderr) + } + for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") { + var event map[string]any + if err := json.Unmarshal([]byte(line), &event); err != nil { + t.Fatalf("stdout carries a line that is not JSON: %q (%v)", line, err) + } + } +} + +// A sandbox the user turned off is their choice, not a degradation. +func TestExecSaysNothingWhenTheSandboxIsTurnedOff(t *testing.T) { + off := false + exitCode, _, stderr := runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{Enabled: &off}) + if exitCode != 0 { + t.Fatalf("exit code = %d, stderr %q", exitCode, stderr) + } + if stderr != "" { + t.Fatalf("stderr = %q, want nothing for a disabled sandbox", stderr) + } +} + +// The TUI shows the same notice when the session opens, taken from the engine +// its commands run through. +func TestTUILaunchCarriesTheDegradedNotice(t *testing.T) { + for _, testCase := range []struct { + name string + sandbox config.SandboxConfig + want bool + }{ + {name: "degraded", want: true}, + {name: "turned off", sandbox: config.SandboxConfig{Enabled: func() *bool { off := false; return &off }()}}, + } { + t.Run(testCase.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + cwd := t.TempDir() + setCLIUserConfigRoot(t) + var launched tui.Options + exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { return cwd, nil }, + resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { + return config.ResolvedConfig{MaxTurns: 12, Sandbox: testCase.sandbox}, nil + }, + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + t.Fatal("newProvider should not be called without a resolved provider") + return nil, nil + }, + registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) { + return noopMCPRuntime{}, nil + }, + selectSandboxBackend: unavailableTestSandbox, + runTUI: func(_ context.Context, options tui.Options) int { + launched = options + return 0 + }, + }) + if exitCode != 0 { + t.Fatalf("exit code = %d, stderr %q", exitCode, stderr.String()) + } + var notices []string + for _, notice := range launched.StartupNotices { + if strings.TrimSpace(notice) != "" { + notices = append(notices, notice) + } + } + want := strings.TrimSuffix(strings.TrimPrefix(unavailableTestSandboxNotice, "[zero] "), "\n") + if testCase.want && (len(notices) != 1 || notices[0] != want) { + t.Fatalf("StartupNotices = %q, want exactly %q", notices, want) + } + if !testCase.want && len(notices) != 0 { + t.Fatalf("StartupNotices = %q, want none", notices) + } + }) + } +} diff --git a/internal/sandbox/adapters.go b/internal/sandbox/adapters.go index 30e1395fb..2b707c4d3 100644 --- a/internal/sandbox/adapters.go +++ b/internal/sandbox/adapters.go @@ -207,6 +207,21 @@ func (backend Backend) EnforcementLevel(policy Policy) EnforcementLevel { return EnforcementDegraded } +// DegradedNotice is one line saying the plan's enforcement is degraded and why, +// or "" at any other level. Only degraded: a disabled sandbox is the user's own +// choice, and the unelevated Windows tier still enforces the write jail and +// says what it lacks through `zero sandbox policy`. +func (plan BackendPlan) DegradedNotice() string { + if plan.EnforcementLevel != EnforcementDegraded { + return "" + } + reason := strings.TrimRight(strings.TrimSpace(plan.DowngradeReason), ".") + if reason == "" { + reason = "no native sandbox is available" + } + return "Sandbox enforcement is degraded: " + reason + ". See `zero sandbox policy --effective`." +} + func (backend Backend) DowngradeReason(policy Policy) string { if policy.Mode == "" { policy = DefaultPolicy() diff --git a/internal/sandbox/degraded_notice_test.go b/internal/sandbox/degraded_notice_test.go new file mode 100644 index 000000000..eed69e65c --- /dev/null +++ b/internal/sandbox/degraded_notice_test.go @@ -0,0 +1,68 @@ +package sandbox + +import ( + "strings" + "testing" +) + +// Only DEGRADED says anything. A disabled sandbox is the user's own choice, and +// the unelevated Windows tier still enforces the write jail and reports what it +// lacks through `zero sandbox policy`; a notice on every session for either +// would stop meaning anything. +func TestDegradedNoticeSpeaksOnlyForADegradedPlan(t *testing.T) { + for _, level := range []EnforcementLevel{EnforcementNative, EnforcementUnelevated, EnforcementDisabled, ""} { + plan := BackendPlan{EnforcementLevel: level, DowngradeReason: "some reason"} + if notice := plan.DegradedNotice(); notice != "" { + t.Errorf("level %q produced a notice: %q", level, notice) + } + } + + plan := BackendPlan{EnforcementLevel: EnforcementDegraded, DowngradeReason: "Linux sandbox helper is not available"} + want := "Sandbox enforcement is degraded: Linux sandbox helper is not available. See `zero sandbox policy --effective`." + if got := plan.DegradedNotice(); got != want { + t.Errorf("DegradedNotice() = %q, want %q", got, want) + } +} + +// A reason that already ends a sentence keeps one period, and a missing reason +// still says what is wrong. +func TestDegradedNoticeWordsItsReasonOnce(t *testing.T) { + withPeriod := BackendPlan{EnforcementLevel: EnforcementDegraded, DowngradeReason: "Run `zero sandbox setup` first."} + if got := withPeriod.DegradedNotice(); strings.Contains(got, "..") { + t.Errorf("the reason's own period was doubled: %q", got) + } + empty := BackendPlan{EnforcementLevel: EnforcementDegraded} + if got := empty.DegradedNotice(); !strings.Contains(got, "no native sandbox is available") { + t.Errorf("a degraded plan with no reason gave no reason: %q", got) + } +} + +// THE SESSION'S NOTICE AND `zero sandbox policy` DESCRIBE ONE SANDBOX. The engine +// builds its notice from its own backend and policy through BuildPlan, the +// function the policy command prints from, rather than deciding "degraded" a +// second way. +func TestEngineDegradedNoticeComesFromThePolicyCommandsPlan(t *testing.T) { + root := t.TempDir() + unavailable := Backend{Name: BackendUnavailable, Platform: "linux", Fallback: true, Message: "Linux sandbox helper is not available"} + + engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: unavailable}) + want := unavailable.BuildPlan(root, DefaultPolicy()).DegradedNotice() + if want == "" { + t.Fatal("SETUP INVALID: an unavailable backend under the default policy is not degraded") + } + if got := engine.DegradedNotice(); got != want { + t.Errorf("engine notice = %q, want the policy command's %q", got, want) + } + + disabled := DefaultPolicy() + disabled.Mode = ModeDisabled + off := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: disabled, Backend: unavailable}) + if got := off.DegradedNotice(); got != "" { + t.Errorf("a sandbox the user turned off still produced a notice: %q", got) + } + + var none *Engine + if got := none.DegradedNotice(); got != "" { + t.Errorf("a nil engine produced a notice: %q", got) + } +} diff --git a/internal/sandbox/engine.go b/internal/sandbox/engine.go index ed7440780..64ab0342e 100644 --- a/internal/sandbox/engine.go +++ b/internal/sandbox/engine.go @@ -80,6 +80,19 @@ func (engine *Engine) CanPersistGrants() bool { return engine != nil && engine.store != nil } +// DegradedNotice is the line a session shows when this engine's sandbox cannot +// enforce what its policy asks for, or "" when it can or the sandbox is off. +// +// Built from the engine's own backend and policy through the same plan `zero +// sandbox policy` prints, so the notice and the command that explains it cannot +// describe two different sandboxes. +func (engine *Engine) DegradedNotice() string { + if engine == nil { + return "" + } + return engine.backend.BuildPlan(engine.workspaceRoot, engine.policy).DegradedNotice() +} + func (engine *Engine) ConsumeGrantMigrationNotice() (string, error) { if engine == nil || engine.store == nil { return "", nil diff --git a/internal/tui/model.go b/internal/tui/model.go index 3c69814df..a00aac857 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1095,6 +1095,11 @@ func newModel(ctx context.Context, options Options) model { for _, warning := range keyBindingWarnings { m = m.appendSystemNotice(warning) } + for _, notice := range options.StartupNotices { + if strings.TrimSpace(notice) != "" { + m = m.appendSystemNotice(notice) + } + } return m } diff --git a/internal/tui/options.go b/internal/tui/options.go index 638e82085..b2cccd8ed 100644 --- a/internal/tui/options.go +++ b/internal/tui/options.go @@ -99,6 +99,11 @@ type Options struct { // KeyBindingsConfig means "use built-in defaults" for each action. KeyBindings config.KeyBindingsConfig + // StartupNotices are shown as system notices when the session opens: facts + // about the session the user should see before the first prompt, decided by + // the caller that built it. Blank entries are skipped. + StartupNotices []string + // STT configures speech-to-text dictation (ยง docs/dictation.md). STT config.STTConfig // BuildDictationTranscriber constructs the transcriber for the current STT diff --git a/internal/tui/startup_notices_test.go b/internal/tui/startup_notices_test.go new file mode 100644 index 000000000..9f864962a --- /dev/null +++ b/internal/tui/startup_notices_test.go @@ -0,0 +1,23 @@ +package tui + +import ( + "context" + "testing" +) + +// The caller decides what a session should know before its first prompt; the +// model shows each notice as a system row, in order, and skips blank entries so +// a caller can pass one unconditionally. +func TestStartupNoticesOpenTheSession(t *testing.T) { + m := newModel(context.Background(), Options{StartupNotices: []string{"first notice", " ", "", "second notice"}}) + + var shown []string + for _, row := range m.transcript { + if row.kind == rowSystem { + shown = append(shown, row.text) + } + } + if len(shown) != 2 || shown[0] != "first notice" || shown[1] != "second notice" { + t.Fatalf("system rows = %q, want the two non-blank notices in order", shown) + } +} From 6e597477579286db9793ead643b772c498057756 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 24 Sep 2026 20:39:16 +0530 Subject: [PATCH 2/7] fix(sandbox): say so when the nesting markers skip wrapping With ZERO_SANDBOXED and ZERO_SANDBOX_BACKEND set, BuildCommandPlan passes every command through unwrapped on the word of an outer sandbox nothing verifies (#727). The notice came from Backend.BuildPlan, which does not model that path, so a native backend reported full enforcement and a session started with the markers ran every command unwrapped in silence. The engine's notice now mirrors the runner and names the markers and the unverified outer sandbox. A disabled sandbox still says nothing. hostSandboxNotice builds its expectation through the engine too, and the tests that pin the notice clear the markers so a suite run inside a Zero sandbox does not change their answer. --- internal/cli/sandbox_degraded_notice_test.go | 19 +++++++++- internal/sandbox/degraded_notice_test.go | 38 ++++++++++++++++++++ internal/sandbox/engine.go | 16 ++++++++- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/internal/cli/sandbox_degraded_notice_test.go b/internal/cli/sandbox_degraded_notice_test.go index be7d95f5a..e11712faf 100644 --- a/internal/cli/sandbox_degraded_notice_test.go +++ b/internal/cli/sandbox_degraded_notice_test.go @@ -23,13 +23,26 @@ import ( // are not. func hostSandboxNotice(t *testing.T) string { t.Helper() - notice := sandbox.SelectBackend(sandbox.BackendOptions{}).BuildPlan(t.TempDir(), sandbox.DefaultPolicy()).DegradedNotice() + engine := sandbox.NewEngine(sandbox.EngineOptions{ + WorkspaceRoot: t.TempDir(), + Policy: sandbox.DefaultPolicy(), + Backend: sandbox.SelectBackend(sandbox.BackendOptions{}), + }) + notice := engine.DegradedNotice() if notice == "" { return "" } return "[zero] " + notice + "\n" } +// clearSandboxNestingMarkers keeps a test that pins the notice from inheriting +// the nesting markers when the suite itself runs inside a Zero sandbox. +func clearSandboxNestingMarkers(t *testing.T) { + t.Helper() + t.Setenv(sandbox.EnvSandboxed, "") + t.Setenv(sandbox.EnvSandboxBackend, "") +} + // unavailableTestSandbox is the backend a Linux box without the helper gets, // pinned so a test sees the degraded case on every platform. func unavailableTestSandbox(sandbox.BackendOptions) sandbox.Backend { @@ -69,6 +82,7 @@ func runExecWithSandbox(t *testing.T, args []string, backend func(sandbox.Backen // doctor` and `zero sandbox policy` did, so `zero exec` ran every tool with the // native sandbox effectively off and printed nothing about it. #1041. func TestExecSaysOnStderrWhenTheSandboxIsDegraded(t *testing.T) { + clearSandboxNestingMarkers(t) exitCode, _, stderr := runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{}) if exitCode != 0 { t.Fatalf("exit code = %d, stderr %q", exitCode, stderr) @@ -81,6 +95,7 @@ func TestExecSaysOnStderrWhenTheSandboxIsDegraded(t *testing.T) { // The notice goes to stderr in the JSON modes as well, so stdout stays one JSON // object per line for whatever reads it. func TestExecKeepsTheDegradedNoticeOffStdoutInJSONMode(t *testing.T) { + clearSandboxNestingMarkers(t) exitCode, stdout, stderr := runExecWithSandbox(t, []string{"exec", "--output-format", "json", "hello"}, unavailableTestSandbox, config.SandboxConfig{}) if exitCode != 0 { t.Fatalf("exit code = %d, stderr %q", exitCode, stderr) @@ -98,6 +113,7 @@ func TestExecKeepsTheDegradedNoticeOffStdoutInJSONMode(t *testing.T) { // A sandbox the user turned off is their choice, not a degradation. func TestExecSaysNothingWhenTheSandboxIsTurnedOff(t *testing.T) { + clearSandboxNestingMarkers(t) off := false exitCode, _, stderr := runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{Enabled: &off}) if exitCode != 0 { @@ -111,6 +127,7 @@ func TestExecSaysNothingWhenTheSandboxIsTurnedOff(t *testing.T) { // The TUI shows the same notice when the session opens, taken from the engine // its commands run through. func TestTUILaunchCarriesTheDegradedNotice(t *testing.T) { + clearSandboxNestingMarkers(t) for _, testCase := range []struct { name string sandbox config.SandboxConfig diff --git a/internal/sandbox/degraded_notice_test.go b/internal/sandbox/degraded_notice_test.go index eed69e65c..11330e3ac 100644 --- a/internal/sandbox/degraded_notice_test.go +++ b/internal/sandbox/degraded_notice_test.go @@ -42,6 +42,7 @@ func TestDegradedNoticeWordsItsReasonOnce(t *testing.T) { // function the policy command prints from, rather than deciding "degraded" a // second way. func TestEngineDegradedNoticeComesFromThePolicyCommandsPlan(t *testing.T) { + clearNestingMarkers(t) root := t.TempDir() unavailable := Backend{Name: BackendUnavailable, Platform: "linux", Fallback: true, Message: "Linux sandbox helper is not available"} @@ -66,3 +67,40 @@ func TestEngineDegradedNoticeComesFromThePolicyCommandsPlan(t *testing.T) { t.Errorf("a nil engine produced a notice: %q", got) } } + +// clearNestingMarkers keeps a test from inheriting the markers when the suite +// itself runs inside a Zero sandbox. +func clearNestingMarkers(t *testing.T) { + t.Helper() + t.Setenv(EnvSandboxed, "") + t.Setenv(EnvSandboxBackend, "") +} + +// THE NESTING MARKERS SKIP WRAPPING, SO THEY HAVE TO BE SAID. +// +// With ZERO_SANDBOXED and ZERO_SANDBOX_BACKEND set, the runner passes every +// command through unwrapped on the word of an outer sandbox nothing verifies +// (#727). A native backend's plan knows nothing of that and reports full +// enforcement, so the session used to run every command unwrapped in silence. +func TestEngineDegradedNoticeSpeaksWhenTheNestingMarkersSkipWrapping(t *testing.T) { + root := t.TempDir() + native := Backend{Name: BackendLinuxBwrap, Platform: "linux", Available: true, NativeIsolation: true, CommandWrapping: true, Executable: "/usr/bin/bwrap"} + + clearNestingMarkers(t) + if got := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: native}).DegradedNotice(); got != "" { + t.Fatalf("SETUP INVALID: a native backend without the markers already gives a notice: %q", got) + } + + t.Setenv(EnvSandboxed, "1") + t.Setenv(EnvSandboxBackend, string(BackendLinuxBwrap)) + got := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: native}).DegradedNotice() + if !strings.Contains(got, EnvSandboxed) || !strings.Contains(got, "cannot verify") { + t.Errorf("with the nesting markers set the notice = %q, want one naming the markers and the unverified outer sandbox", got) + } + + disabled := DefaultPolicy() + disabled.Mode = ModeDisabled + if got := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: disabled, Backend: native}).DegradedNotice(); got != "" { + t.Errorf("a sandbox the user turned off still produced a notice under the markers: %q", got) + } +} diff --git a/internal/sandbox/engine.go b/internal/sandbox/engine.go index 64ab0342e..53b44749f 100644 --- a/internal/sandbox/engine.go +++ b/internal/sandbox/engine.go @@ -86,13 +86,27 @@ func (engine *Engine) CanPersistGrants() bool { // Built from the engine's own backend and policy through the same plan `zero // sandbox policy` prints, so the notice and the command that explains it cannot // describe two different sandboxes. +// +// EXCEPT WHERE THE RUNNER TAKES A DIFFERENT PATH. With the nesting markers set, +// BuildCommandPlan passes every command through unwrapped, trusting an outer +// sandbox to contain it, and the markers are ambient environment that nothing +// authenticates (#727). The plan above does not model that, so a session started +// with them ran every command unwrapped and said nothing. Reported by CodeRabbit. func (engine *Engine) DegradedNotice() string { - if engine == nil { + if engine == nil || engine.policy.Mode == ModeDisabled { return "" } + if IsAlreadySandboxed() { + return nestedSandboxNotice + } return engine.backend.BuildPlan(engine.workspaceRoot, engine.policy).DegradedNotice() } +// nestedSandboxNotice says why a session with the nesting markers set does not +// wrap its commands, and that the outer sandbox it relies on is unverified. +const nestedSandboxNotice = "Sandbox enforcement is degraded: " + EnvSandboxed + " and " + EnvSandboxBackend + + " say this session already runs inside a sandbox, so it does not wrap commands again, and Zero cannot verify that outer sandbox." + func (engine *Engine) ConsumeGrantMigrationNotice() (string, error) { if engine == nil || engine.store == nil { return "", nil From 4a6f5f077d665064537dbc904bdda7ed4b394dec Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 26 Sep 2026 09:15:35 +0530 Subject: [PATCH 3/7] fix(tui): say the startup notices again when a session is resumed /resume rebuilds the transcript from an empty one, so the startup notices went with the old transcript and the session it switched to ran under a degraded sandbox without a word. The model keeps the notices and puts them back first, before the resume summary. Rewind and compaction stay in the same session and keep the notice in the scrollback above their divider. Also pins why the notice can be decided once, at startup: grants made during a session only open the network or add allow and deny paths, never the mode, and every mode but disabled already needs the platform sandbox, so no grant reaches a degraded command plan the startup notice missed, and a deny path the backend cannot enforce is refused instead. --- internal/sandbox/degraded_notice_test.go | 81 ++++++++++++++++++++++++ internal/tui/model.go | 15 ++++- internal/tui/session.go | 7 +- internal/tui/startup_notices_test.go | 35 ++++++++++ 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/internal/sandbox/degraded_notice_test.go b/internal/sandbox/degraded_notice_test.go index 11330e3ac..9e4f420dc 100644 --- a/internal/sandbox/degraded_notice_test.go +++ b/internal/sandbox/degraded_notice_test.go @@ -1,6 +1,8 @@ package sandbox import ( + "fmt" + "path/filepath" "strings" "testing" ) @@ -104,3 +106,82 @@ func TestEngineDegradedNoticeSpeaksWhenTheNestingMarkersSkipWrapping(t *testing. t.Errorf("a sandbox the user turned off still produced a notice under the markers: %q", got) } } + +// NO GRANT REACHES A DEGRADED PLAN THE SESSION'S NOTICE MISSED. +// +// The notice is decided once, from the policy the session starts with, and +// grants made during the session change the policy each command runs under. +// They cannot open a gap: a grant only opens the network or adds allow and +// deny paths, never the mode, and every mode but disabled builds a restricted +// filesystem profile that needs the platform sandbox from the start. So a +// degraded command plan means the notice already spoke, and a disabled sandbox +// stays disabled whatever is granted. A deny path the backend cannot enforce is +// refused at the command rather than run degraded. Raised by CodeRabbit. +func TestNoGrantReachesADegradedPlanTheSessionNoticeMissed(t *testing.T) { + clearNestingMarkers(t) + cacheRoot, tempRoot := t.TempDir(), t.TempDir() + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = original }) + t.Setenv("TMPDIR", tempRoot) + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + + root := t.TempDir() + extra := t.TempDir() + secret := filepath.Join(root, "secret.txt") + enabled := true + grants := []struct { + name string + profile RequestPermissionProfile + }{ + {"network", RequestPermissionProfile{Network: &NetworkPermissions{Enabled: &enabled}}}, + {"write root", RequestPermissionProfile{FileSystem: &FileSystemPermissions{Write: []string{extra}}}}, + {"deny read", RequestPermissionProfile{FileSystem: &FileSystemPermissions{DenyRead: []string{secret}}}}, + } + unavailable := Backend{Name: BackendUnavailable, Platform: "linux", Fallback: true, Message: "Linux sandbox helper is not available"} + degradedAndSaid, refused := 0, 0 + for _, mode := range []PolicyMode{ModeEnforce, ModeDisabled} { + for _, network := range []NetworkMode{NetworkAllow, NetworkDeny} { + for _, grant := range grants { + for _, scope := range []PermissionGrantScope{PermissionGrantScopeSession, PermissionGrantScopeTurn} { + name := fmt.Sprintf("%s/%s/%s/%s", mode, network, grant.name, scope) + policy := DefaultPolicy() + policy.Mode, policy.Network = mode, network + engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy, Backend: unavailable}) + notice := engine.DegradedNotice() + undo, err := engine.GrantRequestPermissions(grant.profile, scope) + if err != nil { + t.Fatalf("%s: SETUP INVALID: grant: %v", name, err) + } + plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: root}) + undo() + // The plan holds the runtime lease until it is cleaned up, and a lease + // file still open fails the temp directory removal on Windows. + plan.Cleanup() + if err != nil { + if grant.name != "deny read" || mode == ModeDisabled { + t.Errorf("%s: refused a command it should have planned: %v", name, err) + } + refused++ + continue + } + if plan.EnforcementLevel == EnforcementDegraded { + if notice == "" { + t.Errorf("%s: the command plan is degraded but the session started with no notice", name) + } + degradedAndSaid++ + } + if mode == ModeDisabled && plan.EnforcementLevel != EnforcementDisabled { + t.Errorf("%s: a grant took a disabled sandbox to %q", name, plan.EnforcementLevel) + } + } + } + } + } + // Not vacuous: the enforced modes did reach degraded plans, and the deny + // grants were refused rather than degraded. + if degradedAndSaid == 0 || refused == 0 { + t.Fatalf("SETUP INVALID: %d degraded plans and %d refusals, so the invariant was never exercised", degradedAndSaid, refused) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index a00aac857..e5704700d 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -73,6 +73,7 @@ type model struct { ctx context.Context cwd string appVersion string + startupNotices []string // Options.StartupNotices, said again when a session is resumed userCommands []usercommands.Command // file-sourced /commands (.zero/commands) loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + / userConfigPath string @@ -984,6 +985,7 @@ func newModel(ctx context.Context, options Options) model { ctx: ctx, cwd: cwd, appVersion: strings.TrimSpace(options.Version), + startupNotices: options.StartupNotices, swarmDoneAt: map[string]time.Time{}, userCommands: loadedUserCommands, loadSkills: options.LoadSkills, @@ -1095,12 +1097,19 @@ func newModel(ctx context.Context, options Options) model { for _, warning := range keyBindingWarnings { m = m.appendSystemNotice(warning) } - for _, notice := range options.StartupNotices { + m.transcript = m.withStartupNotices(m.transcript) + return m +} + +// withStartupNotices appends the session's startup notices to rows as system +// rows, in order, skipping blank ones so a caller can pass one unconditionally. +func (m model) withStartupNotices(rows []transcriptRow) []transcriptRow { + for _, notice := range m.startupNotices { if strings.TrimSpace(notice) != "" { - m = m.appendSystemNotice(notice) + rows = appendRow(rows, rowSystem, notice) } } - return m + return rows } func (m model) doctorOptions(connectivity bool) doctor.Options { diff --git a/internal/tui/session.go b/internal/tui/session.go index a64e43c75..4da085f21 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -246,7 +246,12 @@ func (m model) handleResumeCommand(args string) (model, string) { m, loopsCleared = m.clearLoopsForSessionSwitch() } - rows := initialTranscript() + // SAID AGAIN FOR THE SESSION THIS SWITCHES TO. The startup notices describe + // the sandbox every session in this process runs under, and the rebuild below + // starts from an empty transcript, so a degraded sandbox went unmentioned for + // the resumed session. Rewind and compaction stay in the same session and keep + // the notice in the scrollback above their divider. Reported by CodeRabbit. + rows := m.withStartupNotices(initialTranscript()) rows = appendRow(rows, rowSystem, m.formatResumeSummary(*session, len(events))) if loopsCleared > 0 { rows = appendRow(rows, rowSystem, fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", loopsCleared)) diff --git a/internal/tui/startup_notices_test.go b/internal/tui/startup_notices_test.go index 9f864962a..264b91f28 100644 --- a/internal/tui/startup_notices_test.go +++ b/internal/tui/startup_notices_test.go @@ -2,7 +2,10 @@ package tui import ( "context" + "strings" "testing" + + "github.com/Gitlawb/zero/internal/sessions" ) // The caller decides what a session should know before its first prompt; the @@ -21,3 +24,35 @@ func TestStartupNoticesOpenTheSession(t *testing.T) { t.Fatalf("system rows = %q, want the two non-blank notices in order", shown) } } + +// A RESUMED SESSION IS TOLD TOO. /resume rebuilds the transcript from nothing, +// so the startup notices went with the old one, and the session it switched to +// ran under a degraded sandbox without a word. They come back first, before the +// resume summary, blank entries still skipped. Reported by CodeRabbit. +func TestResumeRepeatsTheStartupNotices(t *testing.T) { + store := testSessionStore(t) + session, err := store.Create(sessions.CreateInput{Title: "Earlier", ModelID: "gpt-4.1", Provider: "openai"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": "hello"}}); err != nil { + t.Fatalf("Append: %v", err) + } + m := newModel(context.Background(), Options{SessionStore: store, StartupNotices: []string{"sandbox notice", " "}}) + + next, _ := m.handleResumeCommand(session.SessionID) + var shown []string + for _, row := range next.transcript { + if row.kind == rowSystem { + shown = append(shown, row.text) + } + } + if len(shown) < 2 || shown[0] != "sandbox notice" { + t.Fatalf("resumed system rows = %q, want the startup notice first", shown) + } + for _, text := range shown[1:] { + if text == "sandbox notice" || strings.TrimSpace(text) == "" { + t.Fatalf("resumed system rows = %q, want the notice once and no blank row", shown) + } + } +} From b07a5491d6b4361fa7646cdaf4240473b5cdb805 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 26 Sep 2026 09:36:35 +0530 Subject: [PATCH 4/7] fix(tui): say the startup notices again when /new opens a session /new cleared the transcript and added only its own note, so the new session started under a degraded sandbox without a word, the same gap /resume had. It now puts the notices back first, the way /resume does. /clear stays in the same session and, like rewind and compaction, keeps the notice in the scrollback above its divider. --- internal/tui/model.go | 7 +++++++ internal/tui/session.go | 8 ++------ internal/tui/startup_notices_test.go | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index e5704700d..378e7f392 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1103,6 +1103,13 @@ func newModel(ctx context.Context, options Options) model { // withStartupNotices appends the session's startup notices to rows as system // rows, in order, skipping blank ones so a caller can pass one unconditionally. +// +// EVERY SESSION THIS PROCESS OPENS IS TOLD. The notices describe the sandbox +// every session here runs under, so they open the first session, and they come +// back when /new or /resume rebuilds the transcript for another one; both +// started from an empty transcript and dropped them. Rewind, compaction and +// /clear stay in the same session and keep the notice in the scrollback above +// their divider. Reported by CodeRabbit. func (m model) withStartupNotices(rows []transcriptRow) []transcriptRow { for _, notice := range m.startupNotices { if strings.TrimSpace(notice) != "" { diff --git a/internal/tui/session.go b/internal/tui/session.go index 4da085f21..1f6eec377 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -110,7 +110,7 @@ func (m model) startNewSession() model { note = "New session started. Previous session saved as " + previousID + " โ€” resume it anytime with /resume " + previousID + "." } - m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear}) + m.transcript = m.withStartupNotices(reduceTranscript(m.transcript, transcriptAction{kind: actionClear})) m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: note}) // Scrollback above can't be un-printed; a faint divider marks the boundary and // the flush frontier restarts for the fresh transcript (mirrors /clear, /resume). @@ -246,11 +246,7 @@ func (m model) handleResumeCommand(args string) (model, string) { m, loopsCleared = m.clearLoopsForSessionSwitch() } - // SAID AGAIN FOR THE SESSION THIS SWITCHES TO. The startup notices describe - // the sandbox every session in this process runs under, and the rebuild below - // starts from an empty transcript, so a degraded sandbox went unmentioned for - // the resumed session. Rewind and compaction stay in the same session and keep - // the notice in the scrollback above their divider. Reported by CodeRabbit. + // Said again for the session this switches to; see withStartupNotices. rows := m.withStartupNotices(initialTranscript()) rows = appendRow(rows, rowSystem, m.formatResumeSummary(*session, len(events))) if loopsCleared > 0 { diff --git a/internal/tui/startup_notices_test.go b/internal/tui/startup_notices_test.go index 264b91f28..30614ec48 100644 --- a/internal/tui/startup_notices_test.go +++ b/internal/tui/startup_notices_test.go @@ -56,3 +56,21 @@ func TestResumeRepeatsTheStartupNotices(t *testing.T) { } } } + +// /new is a session opening too. It cleared the transcript and added only its +// own note, so the new session started under a degraded sandbox without a +// word. Reported by CodeRabbit. +func TestNewSessionRepeatsTheStartupNotices(t *testing.T) { + m := newModel(context.Background(), Options{StartupNotices: []string{"sandbox notice", ""}}) + next := m.startNewSession() + + var shown []string + for _, row := range next.transcript { + if row.kind == rowSystem { + shown = append(shown, row.text) + } + } + if len(shown) != 2 || shown[0] != "sandbox notice" || shown[1] != "Started a new session." { + t.Fatalf("system rows after /new = %q, want the startup notice and then the new-session note", shown) + } +} From 7b8de531072cdd10cacb6c30451a9647b0e831c8 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 28 Sep 2026 08:59:08 +0530 Subject: [PATCH 5/7] fix(modelregistry): start no background refresh when the fetch is turned off The TUI and exec each started a goroutine that called RefreshModelsDevCache, which reads ZERO_DISABLE_MODELS_FETCH and the cache path when it runs. A test that turned the fetch off through t.Setenv could finish and have the setting restored before that goroutine ran, and the refresh would then fetch into the developer's real cache. StartModelsDevRefresh reads the setting before starting anything, so with the fetch off no refresh exists to outlive the test. --- internal/cli/app.go | 2 +- internal/cli/exec.go | 2 +- internal/modelregistry/modelsdev.go | 18 ++++++++++++++++++ internal/modelregistry/modelsdev_test.go | 22 ++++++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 0e670c588..2ed492322 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -695,7 +695,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a // Refresh the models.dev pricing/limits cache in the background when stale; // the overlay is read at registry construction from the cache file, so this // benefits the next run and never blocks or fails this one. - go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }() + modelregistry.StartModelsDevRefresh() workspaceRoot, err := deps.getwd() if err != nil { diff --git a/internal/cli/exec.go b/internal/cli/exec.go index a5d22b061..f0b45b826 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -181,7 +181,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // Refresh the models.dev pricing/limits cache in the background when stale; // the overlay is read at registry construction from the cache file, so this // benefits the next run and never blocks or fails this one. - go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }() + modelregistry.StartModelsDevRefresh() // A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up // front โ€” before tool-filter validation and the --list-tools branch โ€” so a diff --git a/internal/modelregistry/modelsdev.go b/internal/modelregistry/modelsdev.go index 8e92b50d6..971780925 100644 --- a/internal/modelregistry/modelsdev.go +++ b/internal/modelregistry/modelsdev.go @@ -197,6 +197,24 @@ func cachedModelsDevProviders() map[string]map[string]modelsDevModel { return modelsDevCached } +// startModelsDevRefresh starts a background RefreshModelsDevCache. Tests +// replace it to see whether a refresh was started. +var startModelsDevRefresh = func() { + go func() { _ = RefreshModelsDevCache(context.Background()) }() +} + +// StartModelsDevRefresh refreshes the models.dev cache in the background when +// the fetch is on. ZERO_DISABLE_MODELS_FETCH is read here, before anything is +// started: a refresh goroutine reads the environment when it runs, so one +// started by a caller that had turned the fetch off (a test, through t.Setenv) +// could run after that setting was restored and fetch into the real cache. +func StartModelsDevRefresh() { + if strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" { + return + } + startModelsDevRefresh() +} + // RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache // when the cache is missing or older than modelsDevRefreshAfter. It is safe to // call fire-and-forget from startup (use a goroutine); it never affects the diff --git a/internal/modelregistry/modelsdev_test.go b/internal/modelregistry/modelsdev_test.go index a5bf7c9e6..ecb4215c7 100644 --- a/internal/modelregistry/modelsdev_test.go +++ b/internal/modelregistry/modelsdev_test.go @@ -256,3 +256,25 @@ func TestDefaultModelEntriesAppliesFreshCache(t *testing.T) { } t.Fatal("claude-sonnet-4.5 not found") } + +// With the fetch off, no refresh is started at all, so none is left running to +// read the environment after a test has restored it and fetch into the real +// cache. +func TestStartModelsDevRefreshStartsNothingWhenTheFetchIsOff(t *testing.T) { + started := 0 + previous := startModelsDevRefresh + startModelsDevRefresh = func() { started++ } + t.Cleanup(func() { startModelsDevRefresh = previous }) + + t.Setenv("ZERO_DISABLE_MODELS_FETCH", "1") + StartModelsDevRefresh() + if started != 0 { + t.Fatalf("started %d refreshes with the fetch off, want none", started) + } + + t.Setenv("ZERO_DISABLE_MODELS_FETCH", "") + StartModelsDevRefresh() + if started != 1 { + t.Fatalf("started %d refreshes with the fetch on, want 1", started) + } +} From 24de9d1a6b6017e0016dde26cb8b833e1999b3dd Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 28 Sep 2026 08:59:09 +0530 Subject: [PATCH 6/7] test(cli): keep the degraded-notice tests out of the developer's user state runExecWithSandbox and the TUI launch test left the sandbox grant store and the models.dev cache at their defaults, so they read the developer's sandbox-grants.json and could migrate it, and could refresh the real models cache. Both now run under isolateCLIUserState, and both join TestCLILaunchHelpersLeaveUserStateAlone so the isolation is pinned for each of them. --- internal/cli/sandbox_degraded_notice_test.go | 60 ++++++++++++-------- internal/cli/user_state_isolation_test.go | 4 ++ 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/internal/cli/sandbox_degraded_notice_test.go b/internal/cli/sandbox_degraded_notice_test.go index e11712faf..9bd99f43b 100644 --- a/internal/cli/sandbox_degraded_notice_test.go +++ b/internal/cli/sandbox_degraded_notice_test.go @@ -53,6 +53,7 @@ const unavailableTestSandboxNotice = "[zero] Sandbox enforcement is degraded: Li func runExecWithSandbox(t *testing.T, args []string, backend func(sandbox.BackendOptions) sandbox.Backend, sandboxConfig config.SandboxConfig) (int, string, string) { t.Helper() + isolateCLIUserState(t) var stdout, stderr bytes.Buffer cwd := t.TempDir() exitCode := runWithDeps(args, &stdout, &stderr, appDeps{ @@ -124,6 +125,39 @@ func TestExecSaysNothingWhenTheSandboxIsTurnedOff(t *testing.T) { } } +// launchTUIWithSandbox runs the root command the way a bare `zero` does, with +// the sandbox backend pinned to the unavailable one, and returns the options the +// TUI would have started with. +func launchTUIWithSandbox(t *testing.T, sandboxConfig config.SandboxConfig) tui.Options { + t.Helper() + isolateCLIUserState(t) + var stdout, stderr bytes.Buffer + cwd := t.TempDir() + var launched tui.Options + exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { return cwd, nil }, + resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { + return config.ResolvedConfig{MaxTurns: 12, Sandbox: sandboxConfig}, nil + }, + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + t.Fatal("newProvider should not be called without a resolved provider") + return nil, nil + }, + registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) { + return noopMCPRuntime{}, nil + }, + selectSandboxBackend: unavailableTestSandbox, + runTUI: func(_ context.Context, options tui.Options) int { + launched = options + return 0 + }, + }) + if exitCode != 0 { + t.Fatalf("exit code = %d, stderr %q", exitCode, stderr.String()) + } + return launched +} + // The TUI shows the same notice when the session opens, taken from the engine // its commands run through. func TestTUILaunchCarriesTheDegradedNotice(t *testing.T) { @@ -137,31 +171,7 @@ func TestTUILaunchCarriesTheDegradedNotice(t *testing.T) { {name: "turned off", sandbox: config.SandboxConfig{Enabled: func() *bool { off := false; return &off }()}}, } { t.Run(testCase.name, func(t *testing.T) { - var stdout, stderr bytes.Buffer - cwd := t.TempDir() - setCLIUserConfigRoot(t) - var launched tui.Options - exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{ - getwd: func() (string, error) { return cwd, nil }, - resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { - return config.ResolvedConfig{MaxTurns: 12, Sandbox: testCase.sandbox}, nil - }, - newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { - t.Fatal("newProvider should not be called without a resolved provider") - return nil, nil - }, - registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) { - return noopMCPRuntime{}, nil - }, - selectSandboxBackend: unavailableTestSandbox, - runTUI: func(_ context.Context, options tui.Options) int { - launched = options - return 0 - }, - }) - if exitCode != 0 { - t.Fatalf("exit code = %d, stderr %q", exitCode, stderr.String()) - } + launched := launchTUIWithSandbox(t, testCase.sandbox) var notices []string for _, notice := range launched.StartupNotices { if strings.TrimSpace(notice) != "" { diff --git a/internal/cli/user_state_isolation_test.go b/internal/cli/user_state_isolation_test.go index 007c96b8d..5ee1af031 100644 --- a/internal/cli/user_state_isolation_test.go +++ b/internal/cli/user_state_isolation_test.go @@ -62,6 +62,10 @@ func TestCLILaunchHelpersLeaveUserStateAlone(t *testing.T) { {"execAdvertisesEscalateModel", func(t *testing.T) { _ = execAdvertisesEscalateModel(t, []string{"--allow-escalation", "exec", "say hi"}) }}, + {"runExecWithSandbox", func(t *testing.T) { + _, _, _ = runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{}) + }}, + {"launchTUIWithSandbox", func(t *testing.T) { _ = launchTUIWithSandbox(t, config.SandboxConfig{}) }}, } { t.Run(testCase.name, func(t *testing.T) { seedRoot, configPath, seeded, before := seedEmulatedUserConfig(t) From 233fd5184869e4feff858b3caeff53c3eeca0faa Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 28 Sep 2026 09:43:04 +0530 Subject: [PATCH 7/7] fix(cli): stop exec when the degraded-sandbox notice cannot be written The notice goes out before any tool runs, and before MCP servers and plugins start through the same sandbox, but a failed write to stderr was ignored, so the run went ahead with reduced isolation and no warning delivered. It now stops with exitCrash, the way the image and reasoning-effort notices in the same function already do. --- internal/cli/exec.go | 12 +++-- internal/cli/sandbox_degraded_notice_test.go | 47 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/internal/cli/exec.go b/internal/cli/exec.go index f0b45b826..684c4d1bc 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -347,10 +347,16 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in } else if notice != "" { _, _ = fmt.Fprintln(stderr, "[zero] "+notice) } - // Before any tool runs, so a run that goes ahead with reduced isolation says - // so where its output starts, not only when someone runs `zero doctor`. + // Before any tool runs, and before the MCP servers and plugins below start + // through the same sandbox, so a run that goes ahead with reduced isolation + // says so where its output starts, not only when someone runs `zero doctor`. + // A notice that cannot be written stops the run, as the image and effort + // notices further down do: going ahead would mean reduced isolation and no + // warning delivered. if notice := sandboxEngine.DegradedNotice(); notice != "" { - _, _ = fmt.Fprintln(stderr, "[zero] "+notice) + if _, err := fmt.Fprintln(stderr, "[zero] "+notice); err != nil { + return exitCrash + } } executionRunner.SetPreparer(sandboxEngine) if permissionMode != agent.PermissionModePlan { diff --git a/internal/cli/sandbox_degraded_notice_test.go b/internal/cli/sandbox_degraded_notice_test.go index 9bd99f43b..247e68bd1 100644 --- a/internal/cli/sandbox_degraded_notice_test.go +++ b/internal/cli/sandbox_degraded_notice_test.go @@ -125,6 +125,53 @@ func TestExecSaysNothingWhenTheSandboxIsTurnedOff(t *testing.T) { } } +// countingExecProvider is echoExecProvider, counting the completions it streams. +type countingExecProvider struct { + echoExecProvider + streamed *int +} + +func (provider countingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) { + *provider.streamed++ + return provider.echoExecProvider.StreamCompletion(ctx, request) +} + +// A degraded notice that cannot be written stops the run before the model is +// asked for anything, as the image and effort notices do, instead of going ahead +// with reduced isolation and no warning delivered. +func TestExecStopsWhenTheDegradedNoticeCannotBeWritten(t *testing.T) { + clearSandboxNestingMarkers(t) + isolateCLIUserState(t) + var stdout bytes.Buffer + cwd := t.TempDir() + streamed := 0 + exitCode := runWithDeps([]string{"exec", "hello"}, &stdout, failingWriter{}, appDeps{ + getwd: func() (string, error) { return cwd, nil }, + resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { + return config.ResolvedConfig{ + ActiveProvider: "echo", + Provider: config.ProviderProfile{ + Name: "echo", + ProviderKind: config.ProviderKindOpenAICompatible, + BaseURL: "http://127.0.0.1/v1", + Model: "echo-model", + }, + MaxTurns: 3, + }, nil + }, + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return countingExecProvider{streamed: &streamed}, nil + }, + selectSandboxBackend: unavailableTestSandbox, + }) + if exitCode != exitCrash { + t.Fatalf("exit code = %d, want %d when the degraded notice cannot be written", exitCode, exitCrash) + } + if streamed != 0 { + t.Fatalf("the model was asked %d times: the run went ahead without its warning", streamed) + } +} + // launchTUIWithSandbox runs the root command the way a bare `zero` does, with // the sandbox backend pinned to the unavailable one, and returns the options the // TUI would have started with.