Skip to content

Commit 06b32b0

Browse files
fix(cli): report MCP startup disclosures from headless exec
`zero exec` registers workspace MCP servers through the same sandbox-backed runner interactive startup uses, so a stdio server here can launch under the weakened token and serve the whole run. Only the TUI reported the disclosure, so every text, JSON, stream-JSON and --list-tools caller was told nothing about the enforcement trade for a process that was already running. Reported immediately after registration, before --list-tools and before the first result, since both return early. On stderr, which is where the skipped-server and trust notices already go, so stdout framing is untouched; the regression asserts the JSON and stream-JSON output still parses. The test isolates HOME, APPDATA, LOCALAPPDATA and the XDG roots. Without that it builds a sandbox engine against the real config dir, triggers the one-time grant migration there, and the notice surfaces on a later test's stderr, failing whichever test happens to assert an empty one. That moves between runs and reads as flakiness rather than as contamination.
1 parent 496f633 commit 06b32b0

2 files changed

Lines changed: 132 additions & 0 deletions

File tree

‎internal/cli/exec.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
345345
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
346346
}
347347
defer closeMCPRuntime(stderr, mcpRuntime)
348+
// Said HERE, before --list-tools and before the first result, because both
349+
// return early and the process this describes is already running by now. On
350+
// stderr, so text, JSON and stream-JSON framing on stdout are untouched:
351+
// this is the same channel the skipped-server and trust notices use.
352+
reportMCPStartupDisclosures(stderr, mcpRuntime)
348353
}
349354
pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
350355
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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

Comments
 (0)