Skip to content

Commit 87dddc6

Browse files
bobbyjohnstxclaude
andcommitted
test: credential env stripping across all child process paths (#700)
Verify OPENROUTER_API_KEY and similar credential env vars are stripped before child processes start, while configured env maps still apply. Tests cover shell tool, session shell endpoint, monitors, diagnostics, goal checks, bang commands, plugin processes, shell hooks, MCP stdio, language servers, and custom formatters. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 391da3c commit 87dddc6

9 files changed

Lines changed: 237 additions & 3 deletions

File tree

‎internal/formatter/formatter_test.go‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,32 @@ func TestFormat_DisabledGofmtSkipsFile(t *testing.T) {
101101
}
102102
}
103103

104+
func TestFormat_CustomCommandDropsCredentialEnv(t *testing.T) {
105+
t.Setenv("OPENROUTER_API_KEY", "super-secret")
106+
dir := t.TempDir()
107+
path := filepath.Join(dir, "note.txt")
108+
if err := os.WriteFile(path, []byte("before\n"), 0o644); err != nil {
109+
t.Fatal(err)
110+
}
111+
r := Resolve(enabledConfig(map[string]config.FormatterItem{
112+
"stamp": {
113+
Command: []string{"sh", "-c", `printf '%s|%s' "$OPENROUTER_API_KEY" "$STAMP_TOKEN" > "$1"`, "sh"},
114+
Extensions: []string{".txt"},
115+
Environment: map[string]string{"STAMP_TOKEN": "from-config"},
116+
},
117+
}))
118+
if _, _, err := r.Format(context.Background(), path); err != nil {
119+
t.Fatal(err)
120+
}
121+
got, err := os.ReadFile(path)
122+
if err != nil {
123+
t.Fatal(err)
124+
}
125+
if string(got) != "|from-config" {
126+
t.Fatalf("file = %q, want the configured token and not the parent API key", got)
127+
}
128+
}
129+
104130
func TestFormat_CustomCommandRewritesFile(t *testing.T) {
105131
dir := t.TempDir()
106132
path := filepath.Join(dir, "note.txt")

‎internal/lsp/client_test.go‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,39 @@ import (
1111
"time"
1212
)
1313

14+
func TestConnect_DropsCredentialEnv(t *testing.T) {
15+
t.Setenv("OPENROUTER_API_KEY", "super-secret")
16+
out := filepath.Join(t.TempDir(), "env.txt")
17+
script := `printf '%s|%s' "$OPENROUTER_API_KEY" "$LSP_TOKEN" > '` + strings.ReplaceAll(out, `'`, `'\''`) + `'`
18+
client := newClient(ServerSpec{
19+
Command: "sh",
20+
Args: []string{"-c", script},
21+
}, t.TempDir(), map[string]string{"LSP_TOKEN": "from-config"}, time.Second)
22+
23+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
24+
defer cancel()
25+
if err := client.connect(ctx); err == nil {
26+
t.Fatal("expected initialize to fail for a shell that is not a language server")
27+
}
28+
29+
deadline := time.Now().Add(2 * time.Second)
30+
var got []byte
31+
var err error
32+
for time.Now().Before(deadline) {
33+
got, err = os.ReadFile(out)
34+
if err == nil {
35+
break
36+
}
37+
time.Sleep(20 * time.Millisecond)
38+
}
39+
if err != nil {
40+
t.Fatal(err)
41+
}
42+
if string(got) != "|from-config" {
43+
t.Fatalf("child env = %q, want the configured token and not the parent API key", got)
44+
}
45+
}
46+
1447
func TestFileURI_EncodesSpacesAndHash(t *testing.T) {
1548
dir := t.TempDir()
1649
spaced := filepath.Join(dir, "my file.go")
@@ -281,7 +314,6 @@ func TestParseLocations_ArrayOfLocations(t *testing.T) {
281314
}
282315
}
283316

284-
285317
func TestFileFromURI_PercentEncodedPath(t *testing.T) {
286318
uri := "file:///path/with%20space/file%23hash.go"
287319
got := fileFromURI(uri)

‎internal/mcp/stdio_test.go‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"fmt"
88
"os"
9+
"path/filepath"
910
"strings"
1011
"sync"
1112
"testing"
@@ -213,6 +214,43 @@ func TestStdioTransport_Connect_PerformsInitializeHandshake(t *testing.T) {
213214
}
214215
}
215216

217+
func TestStdioTransport_Connect_DropsCredentialEnv(t *testing.T) {
218+
t.Setenv("OPENROUTER_API_KEY", "super-secret")
219+
out := filepath.Join(t.TempDir(), "env.txt")
220+
script := `printf '%s|%s' "$OPENROUTER_API_KEY" "$MCP_TOKEN" > ` + strconvQuote(out)
221+
transport := NewStdioTransport("sh", []string{"-c", script}, map[string]string{"MCP_TOKEN": "from-config"})
222+
223+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
224+
defer cancel()
225+
// The shell exits after writing the file, so the MCP handshake fails.
226+
// The child environment is applied before that handshake.
227+
if err := transport.Connect(ctx); err != nil && !strings.Contains(err.Error(), "MCP initialize") {
228+
t.Fatal(err)
229+
}
230+
t.Cleanup(func() { transport.Close() })
231+
232+
deadline := time.Now().Add(2 * time.Second)
233+
var got []byte
234+
var err error
235+
for time.Now().Before(deadline) {
236+
got, err = os.ReadFile(out)
237+
if err == nil {
238+
break
239+
}
240+
time.Sleep(20 * time.Millisecond)
241+
}
242+
if err != nil {
243+
t.Fatal(err)
244+
}
245+
if string(got) != "|from-config" {
246+
t.Fatalf("child env = %q, want the configured token and not the parent API key", got)
247+
}
248+
}
249+
250+
func strconvQuote(path string) string {
251+
return `'` + strings.ReplaceAll(path, `'`, `'\''`) + `'`
252+
}
253+
216254
func TestStdioTransport_Connect_FailsOnBadCommand(t *testing.T) {
217255
transport := NewStdioTransport("nonexistent-binary-xyz-8675309", nil, nil)
218256

‎internal/plugin/env_test.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ import (
66
"testing"
77
)
88

9+
func TestExecShellCommand_DropsCredentialEnv(t *testing.T) {
10+
t.Setenv("OPENROUTER_API_KEY", "super-secret")
11+
got, err := execShellCommand(context.Background(), `printf 'ran:%s' "$OPENROUTER_API_KEY"`)
12+
if err != nil {
13+
t.Fatal(err)
14+
}
15+
if got != "ran:" {
16+
t.Fatalf("shell hook output = %q", got)
17+
}
18+
}
19+
920
func TestDefaultCommandFactory_DropsCredentialEnv(t *testing.T) {
1021
t.Setenv("OPENROUTER_API_KEY", "super-secret")
1122
cmd := defaultCommandFactory(context.Background(), "true")

‎internal/server/handler_session_test.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,58 @@ func TestShellDirect_PublishesUserMessageWithCommandPrefix(t *testing.T) {
9696
}
9797
}
9898

99+
func TestShellDirect_DropsCredentialEnv(t *testing.T) {
100+
t.Setenv("OPENROUTER_API_KEY", "super-secret")
101+
h := newTestHarness(t, nil)
102+
103+
dir := t.TempDir()
104+
body := `{"title":"Shell Env","agent":"build","model":{"modelID":"test-model","providerID":"test-provider"}}`
105+
resp, err := http.Post(h.baseURL()+"/session?directory="+dir, "application/json", strings.NewReader(body))
106+
if err != nil {
107+
t.Fatalf("create session: %v", err)
108+
}
109+
var created map[string]any
110+
json.NewDecoder(resp.Body).Decode(&created)
111+
resp.Body.Close()
112+
sessionID := created["id"].(string)
113+
114+
partSub := h.bus.Subscribe("message.part.updated")
115+
defer partSub.Unsubscribe()
116+
117+
shellBody := `{"command":"printf 'ran:%s' \"$OPENROUTER_API_KEY\""}`
118+
shellResp, err := http.Post(h.baseURL()+"/session/"+sessionID+"/shell", "application/json", strings.NewReader(shellBody))
119+
if err != nil {
120+
t.Fatalf("shell request: %v", err)
121+
}
122+
shellResp.Body.Close()
123+
124+
deadline := time.After(5 * time.Second)
125+
for {
126+
select {
127+
case evt := <-partSub.C:
128+
props := evt.Properties.(map[string]any)
129+
if props["sessionID"] != sessionID {
130+
continue
131+
}
132+
part, _ := props["part"].(map[string]any)
133+
if part["type"] != "tool" || part["tool"] != "bash" {
134+
continue
135+
}
136+
state, _ := part["state"].(map[string]any)
137+
if state["status"] != "completed" {
138+
continue
139+
}
140+
output, _ := state["output"].(string)
141+
if strings.Contains(output, "super-secret") || !strings.Contains(output, "ran:") {
142+
t.Fatalf("shell output = %q", output)
143+
}
144+
return
145+
case <-deadline:
146+
t.Fatal("timeout waiting for shell output")
147+
}
148+
}
149+
}
150+
99151
func TestShellDirect_PublishesToolPartWithCompletedStatus(t *testing.T) {
100152
h := newTestHarness(t, nil)
101153

‎internal/tool/diagnostics_test.go‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tool
22

33
import (
4+
"bytes"
45
"context"
56
"os"
67
"os/exec"
@@ -134,6 +135,19 @@ func TestDiagnostics_Timeout(t *testing.T) {
134135
_ = result
135136
}
136137

138+
func TestRunCmd_DropsCredentialEnv(t *testing.T) {
139+
t.Setenv("OPENROUTER_API_KEY", "super-secret")
140+
cmd := exec.Command("sh", "-c", `printf 'ran:%s' "$OPENROUTER_API_KEY"`)
141+
var stdout bytes.Buffer
142+
cmd.Stdout = &stdout
143+
if err := runCmd(context.Background(), cmd); err != nil {
144+
t.Fatal(err)
145+
}
146+
if got := stdout.String(); got != "ran:" {
147+
t.Fatalf("diagnostics command output = %q", got)
148+
}
149+
}
150+
137151
func TestDiagnostics_Registration(t *testing.T) {
138152
r := NewRegistry(&Context{Directory: t.TempDir()})
139153
RegisterBuiltins(r)
@@ -146,4 +160,3 @@ func TestDiagnostics_Registration(t *testing.T) {
146160
t.Errorf("expected ID 'diagnostics', got %q", def.ID)
147161
}
148162
}
149-

‎internal/tool/monitor_test.go‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,32 @@ func TestRingBuffer_DrainEmpty(t *testing.T) {
7272
}
7373
}
7474

75+
func TestMonitorManager_DropsCredentialEnv(t *testing.T) {
76+
t.Setenv("OPENROUTER_API_KEY", "super-secret")
77+
mm := NewMonitorManager()
78+
defer mm.Shutdown()
79+
80+
if _, err := mm.Start(context.Background(), `printf 'ran:%s\n' "$OPENROUTER_API_KEY"`, "env", t.TempDir(), 5*time.Second); err != nil {
81+
t.Fatal(err)
82+
}
83+
84+
deadline := time.Now().Add(2 * time.Second)
85+
var got string
86+
for time.Now().Before(deadline) {
87+
got += mm.DrainAll()
88+
if strings.Contains(got, "ran:") {
89+
break
90+
}
91+
time.Sleep(20 * time.Millisecond)
92+
}
93+
if strings.Contains(got, "super-secret") {
94+
t.Fatalf("monitor leaked the API key: %q", got)
95+
}
96+
if !strings.Contains(got, "ran:") {
97+
t.Fatalf("monitor output = %q", got)
98+
}
99+
}
100+
75101
func TestMonitorManager_StartAndStop(t *testing.T) {
76102
mm := NewMonitorManager()
77103
defer mm.Shutdown()

‎internal/tui/goal_test.go‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package tui
22

3-
import "testing"
3+
import (
4+
"strings"
5+
"testing"
6+
)
47

58
func TestGoalTracker_StatusText(t *testing.T) {
69
g := newGoalTracker("all tests pass", "go test ./... -count=1")
@@ -101,3 +104,18 @@ func TestGoalTracker_EmptyCommandSkipsShellEval(t *testing.T) {
101104
t.Errorf("Iteration = %d, want 1", g.state.Iteration)
102105
}
103106
}
107+
108+
func TestEvaluateGoal_DropsCredentialEnv(t *testing.T) {
109+
t.Setenv("OPENROUTER_API_KEY", "super-secret")
110+
msg := evaluateGoal(`printf 'ran:%s' "$OPENROUTER_API_KEY"`, t.TempDir(), 1)()
111+
got, ok := msg.(GoalEvalMsg)
112+
if !ok {
113+
t.Fatalf("message = %T", msg)
114+
}
115+
if got.Err != nil {
116+
t.Fatal(got.Err)
117+
}
118+
if strings.Contains(got.Output, "super-secret") || got.Output != "ran:" {
119+
t.Fatalf("goal output = %q", got.Output)
120+
}
121+
}

‎internal/tui/shell_env_test.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package tui
2+
3+
import "testing"
4+
5+
func TestRunUserShell_DropsCredentialEnv(t *testing.T) {
6+
t.Setenv("OPENROUTER_API_KEY", "super-secret")
7+
msg := runUserShell(`printf 'ran:%s' "$OPENROUTER_API_KEY"`, t.TempDir())()
8+
got, ok := msg.(ShellResultMsg)
9+
if !ok {
10+
t.Fatalf("message = %T", msg)
11+
}
12+
if got.Err != nil {
13+
t.Fatal(got.Err)
14+
}
15+
if got.Output != "ran:" {
16+
t.Fatalf("shell output = %q", got.Output)
17+
}
18+
}

0 commit comments

Comments
 (0)