From 7f4069f73b4768e617b08e089086b5c8aea6c910 Mon Sep 17 00:00:00 2001 From: Fabio Leitao Date: Tue, 29 Sep 2026 20:04:15 -0300 Subject: [PATCH] fix(sandbox): announce degraded native enforcement at TUI session start and on exec stderr Today only `zero doctor` and `zero sandbox policy` say that native enforcement is degraded; a TUI session or a `zero exec` run proceeds with reduced isolation and no notice. Add the one-line notice approved on the issue: - sandbox.Engine.EnforcementStatus reports the level and downgrade reason through the same SandboxManager decision that command execution uses (so it includes the Windows setup tiers and the nested-sandbox pass-through guard); - sandbox.EnforcementWarning turns a degraded level into one sentence naming the reason, or "" when enforcement is native/unelevated or the sandbox is explicitly disabled by policy (those stay quiet); - the TUI shows it as a system row at session start; `zero exec` writes it as a warning on stderr. The exec hook is nil in injected test deps so hermetic CLI protocol tests do not depend on the host's sandbox availability; defaultAppDeps wires it. Not in this change: making a plain `go build` binary find its sandbox helper (tracked in #946). Refs #1041 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0142QEjA7eEFcdXTpcTmcAZk --- internal/cli/app.go | 7 +- internal/cli/exec.go | 8 +++ internal/cli/exec_test.go | 49 +++++++++++++- internal/sandbox/enforcement_warning_test.go | 67 ++++++++++++++++++++ internal/sandbox/engine.go | 45 +++++++++++++ internal/tui/model.go | 11 +++- internal/tui/rendering.go | 11 ++++ internal/tui/startup_test.go | 24 +++++++ 8 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 internal/sandbox/enforcement_warning_test.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 160eabc5b..b5c595a06 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -111,6 +111,10 @@ type appDeps struct { checkUpdate func(context.Context, update.Options) (update.Result, error) applyUpdate func(context.Context, update.Options) (update.ApplyResult, error) now func() time.Time + // sandboxEnforcementWarning is set only by defaultAppDeps. Keeping it nil in + // injected test deps prevents ambient host sandbox availability from adding + // warnings to otherwise-hermetic CLI protocol tests; focused tests opt in. + sandboxEnforcementWarning func(*sandbox.Engine) string } type mcpToolRuntime interface { @@ -193,7 +197,8 @@ func defaultAppDeps() appDeps { newSandboxStore: func() (*sandbox.GrantStore, error) { return sandbox.NewGrantStore(sandbox.StoreOptions{}) }, - selectSandboxBackend: sandbox.SelectBackend, + selectSandboxBackend: sandbox.SelectBackend, + sandboxEnforcementWarning: sandbox.EnforcementWarning, runSandboxSetupHelper: func(path string, args []string, stdout io.Writer, stderr io.Writer) error { cmd := exec.Command(path, args...) cmd.Stdout = stdout diff --git a/internal/cli/exec.go b/internal/cli/exec.go index d47964c11..258272d42 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -581,6 +581,14 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in if writer.err != nil { return exitCrash } + if deps.sandboxEnforcementWarning != nil { + if warning := deps.sandboxEnforcementWarning(sandboxEngine); warning != "" { + writer.warning(warning) + if writer.err != nil { + return exitCrash + } + } + } // Surface the unsafe-permissions warning whenever the run resolves to unsafe // mode, covering BOTH --skip-permissions-unsafe and --auto high (which also // resolves to PermissionModeUnsafe). Previously only the explicit flag path diff --git a/internal/cli/exec_test.go b/internal/cli/exec_test.go index 41e4483ab..bdaeb55af 100644 --- a/internal/cli/exec_test.go +++ b/internal/cli/exec_test.go @@ -17,6 +17,7 @@ import ( "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/modelregistry" + "github.com/Gitlawb/zero/internal/sandbox" "github.com/Gitlawb/zero/internal/sessions" "github.com/Gitlawb/zero/internal/zeroruntime" ) @@ -650,6 +651,50 @@ func execResolvedConfig() config.ResolvedConfig { } } +func TestRunExecEmitsDegradedSandboxWarning(t *testing.T) { + workspace := t.TempDir() + store, err := sandbox.NewGrantStore(sandbox.StoreOptions{ + FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"), + }) + if err != nil { + t.Fatalf("NewGrantStore() error = %v", err) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"exec", "--no-completion-gate", "hello"}, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { return workspace, nil }, + resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { + resolved := execResolvedConfig() + resolved.MaxTurns = 1 + return resolved, nil + }, + resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { + return config.MCPConfig{}, nil + }, + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return echoExecProvider{}, nil + }, + newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }, + selectSandboxBackend: func(sandbox.BackendOptions) sandbox.Backend { + return sandbox.Backend{ + Name: sandbox.BackendUnavailable, + Platform: "linux", + Message: "Linux sandbox helper is not available", + } + }, + sandboxEnforcementWarning: sandbox.EnforcementWarning, + }) + + if exitCode != exitSuccess { + t.Fatalf("exitCode = %d, want %d; stderr=%q", exitCode, exitSuccess, stderr.String()) + } + for _, want := range []string{"WARNING", "Sandbox enforcement is degraded", "Linux sandbox helper is not available", "zero doctor"} { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("stderr = %q, want %q", stderr.String(), want) + } + } +} + type recordingExecProvider struct { called *bool } @@ -980,7 +1025,9 @@ func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - exitCode := Run([]string{"exec", "--cwd", root, "hello provider"}, &stdout, &stderr) + deps := defaultAppDeps() + deps.sandboxEnforcementWarning = nil // this test covers provider wiring, not host sandbox availability + exitCode := runWithDeps([]string{"exec", "--cwd", root, "hello provider"}, &stdout, &stderr, deps) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String()) diff --git a/internal/sandbox/enforcement_warning_test.go b/internal/sandbox/enforcement_warning_test.go new file mode 100644 index 000000000..45f1c4e7b --- /dev/null +++ b/internal/sandbox/enforcement_warning_test.go @@ -0,0 +1,67 @@ +package sandbox + +import ( + "strings" + "testing" +) + +func TestEnforcementWarningUsesManagerDecision(t *testing.T) { + t.Setenv(EnvSandboxed, "") + t.Setenv(EnvSandboxBackend, "") + workspace := t.TempDir() + + t.Run("degraded", func(t *testing.T) { + engine := NewEngine(EngineOptions{ + WorkspaceRoot: workspace, + Policy: DefaultPolicy(), + Backend: Backend{ + Name: BackendUnavailable, + Platform: "linux", + Message: "Linux sandbox helper is not available", + }, + }) + + level, reason := engine.EnforcementStatus() + if level != EnforcementDegraded || reason != "Linux sandbox helper is not available" { + t.Fatalf("EnforcementStatus() = %q, %q; want degraded with helper reason", level, reason) + } + warning := EnforcementWarning(engine) + for _, want := range []string{"degraded", reason, "zero doctor"} { + if !strings.Contains(warning, want) { + t.Fatalf("EnforcementWarning() = %q, want %q", warning, want) + } + } + }) + + t.Run("native", func(t *testing.T) { + engine := NewEngine(EngineOptions{ + WorkspaceRoot: workspace, + Policy: DefaultPolicy(), + Backend: Backend{ + Name: BackendLinuxBwrap, + Available: true, + Platform: "linux", + Executable: "/usr/bin/zero-linux-sandbox", + }, + }) + if warning := EnforcementWarning(engine); warning != "" { + t.Fatalf("EnforcementWarning() = %q, want silence for native enforcement", warning) + } + }) + + t.Run("disabled", func(t *testing.T) { + policy := DefaultPolicy() + policy.Mode = ModeDisabled + engine := NewEngine(EngineOptions{ + WorkspaceRoot: workspace, + Policy: policy, + Backend: Backend{ + Name: BackendUnavailable, + Platform: "linux", + }, + }) + if warning := EnforcementWarning(engine); warning != "" { + t.Fatalf("EnforcementWarning() = %q, want silence for explicitly disabled sandbox", warning) + } + }) +} diff --git a/internal/sandbox/engine.go b/internal/sandbox/engine.go index ed7440780..7942ed7a3 100644 --- a/internal/sandbox/engine.go +++ b/internal/sandbox/engine.go @@ -76,6 +76,51 @@ func (engine *Engine) Scope() *Scope { return engine.scope } +// EnforcementStatus reports the platform enforcement that commands launched by +// this engine would receive. It deliberately delegates to SandboxManager so +// startup warnings use the same policy calculation as command execution, +// including the Windows setup tiers and the nested-sandbox pass-through guard. +func (engine *Engine) EnforcementStatus() (EnforcementLevel, string) { + if engine == nil { + return EnforcementDisabled, "" + } + policy := engine.effectivePolicy(engine.policy) + preference := SandboxPreferenceAuto + if IsAlreadySandboxed() || policy.Mode == ModeDisabled { + preference = SandboxPreferenceForbid + } + request, err := NewSandboxManager(SandboxManagerOptions{ + GOOS: engine.backend.Platform, + Backend: engine.backend, + }).BuildExecutionRequest(SandboxManagerRequest{ + WorkspaceRoot: engine.workspaceRoot, + Policy: policy, + Scope: engine.scope, + Preference: preference, + }) + if err != nil { + return EnforcementDegraded, err.Error() + } + return request.EnforcementLevel, request.DowngradeReason +} + +// EnforcementWarning returns an actionable user-facing warning only when the +// engine has fallen back to degraded enforcement. Disabled policy is an +// explicit configuration choice and native/unelevated enforcement is active, +// so those states stay quiet. +func EnforcementWarning(engine *Engine) string { + level, reason := engine.EnforcementStatus() + if level != EnforcementDegraded { + return "" + } + warning := "Sandbox enforcement is degraded" + reason = strings.TrimSpace(reason) + if reason != "" { + warning += " (" + strings.TrimRight(reason, ".") + ")" + } + return warning + ": shell commands run with reduced isolation. Run `zero doctor` for setup guidance." +} + func (engine *Engine) CanPersistGrants() bool { return engine != nil && engine.store != nil } diff --git a/internal/tui/model.go b/internal/tui/model.go index 3c69814df..a2f387619 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -925,6 +925,15 @@ func newModel(ctx context.Context, options Options) model { sessionStore = sessions.NewStore(sessions.StoreOptions{}) } sandboxStore := options.SandboxStore + sandboxWarning := sandbox.EnforcementWarning(options.AgentOptions.Sandbox) + transcript := initialTranscript() + if sandboxWarning != "" { + transcript = appendTranscriptRow(transcript, transcriptRow{ + kind: rowSystem, + tool: "sandbox-warning", + text: sandboxWarning, + }) + } modelCatalog, err := modelregistry.DefaultRegistry() if err != nil { panic(err) @@ -1034,7 +1043,7 @@ func newModel(ctx context.Context, options Options) model { hasDarkBg: true, userAgent: options.UserAgent, usageTracker: usageTracker, - transcript: initialTranscript(), + transcript: transcript, transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries), transcriptInteraction: &transcriptRenderInteraction{}, prService: prService, diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index e619acb41..c4acf9f22 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -241,6 +241,9 @@ func (m model) renderRowModeUncached(row transcriptRow, width int, rc rowContext if row.tool == "peer" { return renderPeerMessageRow(row.text, width) } + if row.tool == "sandbox-warning" { + return renderSandboxWarning(row.text, width) + } if payload, ok := planCardTranscriptPayload(row.text); ok { return renderPlanCardRow(payload, width) } @@ -1050,6 +1053,14 @@ func renderErrorRow(row transcriptRow, width int) string { return note } +func renderSandboxWarning(text string, width int) string { + lines := wrapPlainText(text, maxInt(16, width-4)) + for index := range lines { + lines[index] = zeroTheme.amber.Render(lines[index]) + } + return styledBlock(width, lines, zeroTheme.permBorder) +} + // noteBox is the bordered one-note container behind system and error rows. func noteBox(text string, width int, borderStyle lipgloss.Style, textStyle lipgloss.Style) string { raw := strings.Split(strings.TrimRight(strings.ReplaceAll(text, "\r\n", "\n"), "\n"), "\n") diff --git a/internal/tui/startup_test.go b/internal/tui/startup_test.go index 9408cbc7c..dacfe4367 100644 --- a/internal/tui/startup_test.go +++ b/internal/tui/startup_test.go @@ -6,6 +6,9 @@ import ( "testing" "charm.land/lipgloss/v2" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/sandbox" ) func TestEmptyStateShowsBrandAndTaglineOnly(t *testing.T) { @@ -48,6 +51,27 @@ func TestEmptyStateShowsVersion(t *testing.T) { assertContains(t, view, "v0.2.0") } +func TestDegradedSandboxWarningAppearsOnStartup(t *testing.T) { + workspace := t.TempDir() + engine := sandbox.NewEngine(sandbox.EngineOptions{ + WorkspaceRoot: workspace, + Policy: sandbox.DefaultPolicy(), + Backend: sandbox.Backend{ + Name: sandbox.BackendUnavailable, + Platform: "linux", + Message: "Linux sandbox helper is not available", + }, + }) + m := newModel(context.Background(), Options{ + AgentOptions: agent.Options{Sandbox: engine}, + }) + m.width, m.height = 100, 30 + + view := plainRender(t, m.View()) + assertContains(t, view, "Sandbox enforcement is degraded") + assertContains(t, view, "Linux sandbox helper is not available") +} + func TestDisplayVersion(t *testing.T) { cases := []struct{ in, want string }{ {"0.2.0", "v0.2.0"},