Skip to content

Commit e9a7e39

Browse files
committed
fix: stop guest state probes leaking cat errors into command output
Every code-vm invocation printed cat: /run/sandbox/squid-allow.d/10-host-config.conf: No such file or directory before the agent's own output. Session setup probes for the allowlist fragment on each run, AdminOutput streams stderr to the caller, and that fragment is legitimately absent whenever extraDomains is empty — which is the default, so the message appeared every time. Fixing only the reported line would have left four more probe reads with the same defect, and this is already the second instance of it: the Squid reconfigure fix silenced the write path and left the reads alone. So reads go through Client.ReadFile, which discards stderr inside the guest and passes the path as an argument rather than interpolating it into the command string. No raw cat probes remain. Absence now returns empty content, which is what every caller already treated a read failure as. The suite asserts an agent command emits nothing of its own and that hello yields exactly hello, so noise from a future probe fails a test rather than reaching the terminal. fakeRunner matches stubs on a substring of the argv instead of its exact shape: keying on the shape had made these very tests pass empty content silently when the read changed form.
1 parent b42b52b commit e9a7e39

8 files changed

Lines changed: 78 additions & 23 deletions

File tree

internal/cli/allow.go

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,7 @@ func mergeDomains(a, b []string) []string {
119119
// and open mode the generated config has no such ACL lines, and the result is
120120
// empty — the only cost is that a redundant entry may be offered.
121121
func guestAllowedDomains(ctx context.Context, cl lima.Client) []string {
122-
out, err := cl.AdminOutput(ctx, []string{"cat", "/etc/squid/squid.conf"})
123-
if err != nil {
124-
return nil
125-
}
122+
out := cl.ReadFile(ctx, "/etc/squid/squid.conf")
126123
var domains []string
127124
for _, line := range splitLines(string(out)) {
128125
if d := strings.TrimPrefix(line, squidACLPrefix); d != line {
@@ -285,11 +282,7 @@ func collectCandidates(ctx context.Context, cl lima.Client, args []string, runni
285282
return nil, fmt.Errorf("reading denied domains needs a running VM; start it with `code-vm start`, " +
286283
"or pass domains explicitly: code-vm allow example.com")
287284
}
288-
log, err := cl.AdminOutput(ctx, []string{"cat", "/var/log/squid/access.log"})
289-
if err != nil {
290-
return nil, fmt.Errorf("read the proxy log: %w", err)
291-
}
292-
candidates := parseDeniedDomains(string(log))
285+
candidates := parseDeniedDomains(string(cl.ReadFile(ctx, "/var/log/squid/access.log")))
293286
if len(candidates) == 0 {
294287
fmt.Fprintln(out, "No denied requests in the proxy log.")
295288
}

internal/cli/firewall.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,7 @@ func setFirewallModeArgs(mode string) ([]string, error) {
3535
// file. Shared with `code-vm allow`, which reports whether a new domain took
3636
// effect live or only once allowlist mode returns.
3737
func currentFirewallMode(ctx context.Context, cl lima.Client) (string, error) {
38-
verify, err := cl.AdminOutput(ctx, []string{"cat", "/run/firewall-verify"})
39-
if err != nil {
40-
return "", fmt.Errorf("read firewall state: %w", err)
41-
}
38+
verify := cl.ReadFile(ctx, "/run/firewall-verify")
4239
for _, line := range splitLines(string(verify)) {
4340
if strings.HasPrefix(line, "FIREWALL_MODE=") {
4441
return strings.TrimPrefix(line, "FIREWALL_MODE="), nil

internal/cli/status.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ func newStatusCmd() *cobra.Command {
3535
return nil
3636
}
3737
fmt.Fprintln(out, "firewall:")
38-
verify, err := cl.AdminOutput(cmd.Context(), []string{"cat", "/run/firewall-verify"})
39-
if err != nil {
38+
verify := cl.ReadFile(cmd.Context(), "/run/firewall-verify")
39+
if len(verify) == 0 {
4040
fmt.Fprintln(out, " unavailable")
4141
return nil
4242
}

internal/lima/limactl.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,25 @@ func (c Client) AdminOutput(ctx context.Context, cmd []string) ([]byte, error) {
108108
return c.R.Output(ctx, c.AdminArgs(cmd)...)
109109
}
110110

111+
// ReadFile returns the contents of a guest file, and empty content when it does
112+
// not exist or cannot be read.
113+
//
114+
// Callers use this to probe guest state — is a fragment installed, what mode did
115+
// the firewall record — where absence is an ordinary answer. `cat` writes "No
116+
// such file or directory" to stderr, and AdminOutput streams stderr to the
117+
// caller's terminal, so probing with it directly printed that line into the
118+
// user's own command output on every invocation.
119+
//
120+
// The path travels as an argument rather than inside the command string, so it
121+
// cannot be interpreted as shell syntax.
122+
func (c Client) ReadFile(ctx context.Context, path string) []byte {
123+
out, err := c.AdminOutput(ctx, []string{"sh", "-c", `cat -- "$0" 2>/dev/null`, path})
124+
if err != nil {
125+
return nil
126+
}
127+
return out
128+
}
129+
111130
// Status returns the instance status, or "" when the instance does not exist.
112131
func (c Client) Status(ctx context.Context) (string, error) {
113132
out, err := c.R.Output(ctx, "list", c.instance(), "--format", "{{.Status}}")

internal/lima/limactl_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,27 @@ func TestClientDefaultsToTheStandardInstance(t *testing.T) {
140140
t.Errorf("a zero Client must target %q, got %v", InstanceName, got)
141141
}
142142
}
143+
144+
// Probing guest state must not print cat's "No such file or directory" into the
145+
// user's terminal: AdminOutput streams stderr, so the redirect has to happen in
146+
// the guest. The path must travel as an argument, not inside the command string.
147+
func TestReadFileSuppressesStderrAndPassesPathSafely(t *testing.T) {
148+
f := &fakeRunner{}
149+
Client{R: f}.ReadFile(context.Background(), "/run/sandbox/squid-allow.d/10-host-config.conf")
150+
if len(f.calls) != 1 {
151+
t.Fatalf("expected one call, got %v", f.calls)
152+
}
153+
argv := f.calls[0]
154+
joined := strings.Join(argv, " ")
155+
if !strings.Contains(joined, "2>/dev/null") {
156+
t.Errorf("read must discard stderr in the guest: %v", argv)
157+
}
158+
if argv[len(argv)-1] != "/run/sandbox/squid-allow.d/10-host-config.conf" {
159+
t.Errorf("path must be the last argument, not interpolated: %v", argv)
160+
}
161+
for _, a := range argv[:len(argv)-1] {
162+
if strings.Contains(a, "10-host-config.conf") {
163+
t.Errorf("path must not appear inside the command string: %v", argv)
164+
}
165+
}
166+
}

internal/session/allowlist.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@ func FragmentContent(domains []string) string {
6262
// workspace.
6363
func ApplyAllowlist(ctx context.Context, d Deps) error {
6464
dst := fragmentDir + "/" + HostFragmentName
65-
// A read failure means the fragment is absent, which counts as a change.
66-
current, _ := d.Client.AdminOutput(ctx, []string{"cat", dst})
65+
// Absent counts as a change. ReadFile keeps cat's "No such file" off the
66+
// caller's terminal: this runs on every invocation, and the fragment is
67+
// legitimately missing whenever no extra domains are configured.
68+
current := d.Client.ReadFile(ctx, dst)
6769

6870
if len(d.Config.ExtraDomains) == 0 {
6971
if len(current) == 0 {

internal/session/allowlist_test.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ func (f *fakeRunner) Run(_ context.Context, args ...string) error {
2323

2424
func (f *fakeRunner) Output(_ context.Context, args ...string) ([]byte, error) {
2525
f.calls = append(f.calls, args)
26-
return f.out[strings.Join(args, " ")], nil
26+
joined := strings.Join(args, " ")
27+
for match, content := range f.out {
28+
if strings.Contains(joined, match) {
29+
return content, nil
30+
}
31+
}
32+
return nil, nil
2733
}
2834

2935
func (f *fakeRunner) ranAny(substr string) bool {
@@ -85,9 +91,7 @@ func TestApplyAllowlistInstallsFragmentAndReloadsSquid(t *testing.T) {
8591

8692
func TestApplyAllowlistSkipsReloadWhenUnchanged(t *testing.T) {
8793
existing := FragmentContent([]string{"registry.example.com"})
88-
r := &fakeRunner{out: map[string][]byte{
89-
strings.Join(lima.Client{}.AdminArgs([]string{"cat", fragmentPath()}), " "): []byte(existing),
90-
}}
94+
r := &fakeRunner{out: map[string][]byte{fragmentPath(): []byte(existing)}}
9195
d := testDeps(t, r)
9296
d.Config.ExtraDomains = []string{"registry.example.com"}
9397
if err := ApplyAllowlist(context.Background(), d); err != nil {
@@ -113,8 +117,7 @@ func TestApplyAllowlistNoDomainsAndNoFragmentIsNoOp(t *testing.T) {
113117
// domain would stay allowed for the rest of the VM's lifetime.
114118
func TestApplyAllowlistRemovesFragmentWhenDomainsCleared(t *testing.T) {
115119
r := &fakeRunner{out: map[string][]byte{
116-
strings.Join(lima.Client{}.AdminArgs([]string{"cat", fragmentPath()}), " "): []byte(
117-
FragmentContent([]string{"registry.example.com"})),
120+
fragmentPath(): []byte(FragmentContent([]string{"registry.example.com"})),
118121
}}
119122
d := testDeps(t, r)
120123
d.Config.ExtraDomains = nil

test-vm-sandbox.sh

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,23 @@ else
181181
fi
182182
rmdir "$WORK_SUBDIR"
183183

184+
# A command's output must be exactly its own. Session setup probes guest state
185+
# on every invocation, and those reads used to leak cat's "No such file or
186+
# directory" for the allowlist fragment — which is absent whenever no extra
187+
# domains are configured, i.e. by default.
188+
QUIET_OUT=$(agent true 2>&1)
189+
if [ -z "$QUIET_OUT" ]; then
190+
pass "an agent command produces no output of its own"
191+
else
192+
fail "an agent command leaked session-setup noise: [$QUIET_OUT]"
193+
fi
194+
195+
if [ "$(agent echo hello 2>&1)" = "hello" ]; then
196+
pass "an agent command's output is exactly its own"
197+
else
198+
fail "an agent command's output is exactly its own (got: $(agent echo hello 2>&1))"
199+
fi
200+
184201
if agent env | grep -q '^DOCKER_HOST=unix:///run/user/'; then
185202
pass "DOCKER_HOST is exported to the agent"
186203
else

0 commit comments

Comments
 (0)