|
| 1 | +package cli |
| 2 | + |
| 3 | +import ( |
| 4 | + "bytes" |
| 5 | + "context" |
| 6 | + "encoding/json" |
| 7 | + "strings" |
| 8 | + "testing" |
| 9 | + |
| 10 | + "github.com/Gitlawb/zero/internal/config" |
| 11 | + "github.com/Gitlawb/zero/internal/mcp" |
| 12 | + "github.com/Gitlawb/zero/internal/tools" |
| 13 | +) |
| 14 | + |
| 15 | +// disclosingExecRuntime is an MCP runtime that launched a process under reduced |
| 16 | +// enforcement. |
| 17 | +type disclosingExecRuntime struct { |
| 18 | + noopMCPRuntime |
| 19 | + disclosures []mcp.StartupDisclosure |
| 20 | +} |
| 21 | + |
| 22 | +func (r disclosingExecRuntime) StartupDisclosures() []mcp.StartupDisclosure { return r.disclosures } |
| 23 | + |
| 24 | +const execDisclosureNotice = "denyRead is configured, so the write jail is not confining writes" |
| 25 | + |
| 26 | +// isolateConfigDirs points every config/cache root at test-owned storage. |
| 27 | +// |
| 28 | +// Without it these tests build a sandbox engine against the developer's REAL |
| 29 | +// config dir, trigger the one-time grant migration there, and the migration |
| 30 | +// notice then turns up on a LATER test's stderr, failing whichever test happens |
| 31 | +// to assert an empty one. The failure moves between runs, which is what makes it |
| 32 | +// look like flakiness rather than contamination. |
| 33 | +func isolateConfigDirs(t *testing.T) { |
| 34 | + t.Helper() |
| 35 | + dir := t.TempDir() |
| 36 | + t.Setenv("HOME", dir) |
| 37 | + t.Setenv("USERPROFILE", dir) |
| 38 | + t.Setenv("APPDATA", dir) |
| 39 | + t.Setenv("LOCALAPPDATA", dir) |
| 40 | + t.Setenv("XDG_CONFIG_HOME", dir) |
| 41 | + t.Setenv("XDG_CACHE_HOME", dir) |
| 42 | +} |
| 43 | + |
| 44 | +func execDisclosureDeps(cwd string) appDeps { |
| 45 | + return appDeps{ |
| 46 | + getwd: func() (string, error) { return cwd, nil }, |
| 47 | + resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil }, |
| 48 | + resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { |
| 49 | + return config.MCPConfig{Servers: map[string]config.MCPServerConfig{ |
| 50 | + "docs": {Type: "stdio", Command: "docs-mcp"}, |
| 51 | + }}, nil |
| 52 | + }, |
| 53 | + registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) { |
| 54 | + return disclosingExecRuntime{disclosures: []mcp.StartupDisclosure{ |
| 55 | + {Name: "docs", Notices: []string{execDisclosureNotice}}, |
| 56 | + }}, nil |
| 57 | + }, |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +// A HEADLESS RUN IS A DISCLOSURE SURFACE TOO. |
| 62 | +// |
| 63 | +// `zero exec` registers workspace MCP servers through the same sandbox-backed |
| 64 | +// runner interactive startup uses, so a stdio server here can launch under the |
| 65 | +// weakened token and serve the whole run. Reporting the disclosure only from the |
| 66 | +// TUI meant every text, JSON, stream-JSON and --list-tools caller was told |
| 67 | +// nothing about the enforcement trade, for a process that was already running. |
| 68 | +func TestExecReportsMCPStartupDisclosures(t *testing.T) { |
| 69 | + isolateConfigDirs(t) |
| 70 | + for _, format := range []string{"", "--output-format=json", "--output-format=stream-json"} { |
| 71 | + name := format |
| 72 | + if name == "" { |
| 73 | + name = "text" |
| 74 | + } |
| 75 | + t.Run(name, func(t *testing.T) { |
| 76 | + args := []string{"exec", "--list-tools"} |
| 77 | + if format != "" { |
| 78 | + args = append(args, format) |
| 79 | + } |
| 80 | + var stdout, stderr bytes.Buffer |
| 81 | + if code := runWithDeps(args, &stdout, &stderr, execDisclosureDeps(t.TempDir())); code != exitSuccess { |
| 82 | + t.Fatalf("exit = %d, stderr = %s", code, stderr.String()) |
| 83 | + } |
| 84 | + if !strings.Contains(stderr.String(), execDisclosureNotice) { |
| 85 | + t.Errorf("the headless run said nothing about the enforcement trade: %q", stderr.String()) |
| 86 | + } |
| 87 | + if !strings.Contains(stderr.String(), "docs") { |
| 88 | + t.Errorf("the report does not name the server: %q", stderr.String()) |
| 89 | + } |
| 90 | + // The machine-readable surfaces must stay parseable: the disclosure |
| 91 | + // belongs on stderr precisely so stdout framing is untouched. |
| 92 | + if format == "--output-format=json" { |
| 93 | + var any map[string]any |
| 94 | + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &any); err != nil { |
| 95 | + t.Errorf("stdout is no longer valid JSON: %v (%q)", err, stdout.String()) |
| 96 | + } |
| 97 | + } |
| 98 | + if format == "--output-format=stream-json" { |
| 99 | + for _, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") { |
| 100 | + if strings.TrimSpace(line) == "" { |
| 101 | + continue |
| 102 | + } |
| 103 | + var any map[string]any |
| 104 | + if err := json.Unmarshal([]byte(line), &any); err != nil { |
| 105 | + t.Errorf("a stream-json line is not valid JSON: %v (%q)", err, line) |
| 106 | + } |
| 107 | + } |
| 108 | + } |
| 109 | + }) |
| 110 | + } |
| 111 | +} |
| 112 | + |
| 113 | +// A run whose servers launched nothing says nothing. |
| 114 | +func TestExecWithoutDisclosuresStaysQuiet(t *testing.T) { |
| 115 | + isolateConfigDirs(t) |
| 116 | + deps := execDisclosureDeps(t.TempDir()) |
| 117 | + deps.registerMCPTools = func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) { |
| 118 | + return noopMCPRuntime{}, nil |
| 119 | + } |
| 120 | + var stdout, stderr bytes.Buffer |
| 121 | + if code := runWithDeps([]string{"exec", "--list-tools"}, &stdout, &stderr, deps); code != exitSuccess { |
| 122 | + t.Fatalf("exit = %d, stderr = %s", code, stderr.String()) |
| 123 | + } |
| 124 | + if strings.Contains(stderr.String(), "reduced enforcement") { |
| 125 | + t.Errorf("a run with nothing to disclose printed one: %q", stderr.String()) |
| 126 | + } |
| 127 | +} |
0 commit comments