mirror of
https://github.com/cexll/myclaude.git
synced 2025-12-24 13:47:58 +08:00
* fix: allow claude backend to read env from setting.json while preventing recursion Fixes #89 Problem: - --setting-sources "" prevents claude from reading ~/.claude/setting.json env - Removing it causes infinite recursion via skills/commands/agents loading Solution: - Keep --setting-sources "" to block all config sources - Add loadMinimalEnvSettings() to extract only env from setting.json - Pass env explicitly via --settings parameter - Update tests to validate dynamic --settings parameter Benefits: - Claude backend can access ANTHROPIC_API_KEY and other env vars - Skills/commands/agents remain blocked, preventing recursion - Graceful degradation if setting.json doesn't exist Generated with SWE-Agent.ai Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai> * security: pass env via process environment instead of command line Critical security fix for issue #89: - Prevents ANTHROPIC_API_KEY leakage in process command line (ps) - Prevents sensitive values from being logged in wrapper logs Changes: 1. executor.go: - Add SetEnv() method to commandRunner interface - realCmd merges env with os.Environ() and sets to cmd.Env - All test mocks implement SetEnv() 2. backend.go: - Change loadMinimalEnvSettings() to return map[string]string - Use os.UserHomeDir() instead of os.Getenv("HOME") - Add 1MB file size limit check - Only accept string values in env (reject non-strings) - Remove --settings parameter (no longer in command line) 3. Tests: - Add loadMinimalEnvSettings() unit tests - Remove --settings validation (no longer in args) - All test mocks implement SetEnv() Security improvements: - No sensitive values in argv (safe from ps/logs) - Type-safe env parsing (string-only) - File size limit prevents memory issues - Graceful degradation if setting.json missing Tests: All pass (30.912s) Generated with SWE-Agent.ai Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai> --------- Co-authored-by: SWE-Agent.ai <noreply@swe-agent.ai>
214 lines
6.8 KiB
Go
214 lines
6.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
func TestClaudeBuildArgs_ModesAndPermissions(t *testing.T) {
|
|
backend := ClaudeBackend{}
|
|
|
|
t.Run("new mode omits skip-permissions by default", func(t *testing.T) {
|
|
cfg := &Config{Mode: "new", WorkDir: "/repo"}
|
|
got := backend.BuildArgs(cfg, "todo")
|
|
want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "todo"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("new mode can opt-in skip-permissions", func(t *testing.T) {
|
|
cfg := &Config{Mode: "new", SkipPermissions: true}
|
|
got := backend.BuildArgs(cfg, "-")
|
|
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "-"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("resume mode includes session id", func(t *testing.T) {
|
|
cfg := &Config{Mode: "resume", SessionID: "sid-123", WorkDir: "/ignored"}
|
|
got := backend.BuildArgs(cfg, "resume-task")
|
|
want := []string{"-p", "--setting-sources", "", "-r", "sid-123", "--output-format", "stream-json", "--verbose", "resume-task"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("resume mode without session still returns base flags", func(t *testing.T) {
|
|
cfg := &Config{Mode: "resume", WorkDir: "/ignored"}
|
|
got := backend.BuildArgs(cfg, "follow-up")
|
|
want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "follow-up"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("resume mode can opt-in skip permissions", func(t *testing.T) {
|
|
cfg := &Config{Mode: "resume", SessionID: "sid-123", SkipPermissions: true}
|
|
got := backend.BuildArgs(cfg, "resume-task")
|
|
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "-r", "sid-123", "--output-format", "stream-json", "--verbose", "resume-task"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("nil config returns nil", func(t *testing.T) {
|
|
if backend.BuildArgs(nil, "ignored") != nil {
|
|
t.Fatalf("nil config should return nil args")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestClaudeBuildArgs_GeminiAndCodexModes(t *testing.T) {
|
|
t.Run("gemini new mode defaults workdir", func(t *testing.T) {
|
|
backend := GeminiBackend{}
|
|
cfg := &Config{Mode: "new", WorkDir: "/workspace"}
|
|
got := backend.BuildArgs(cfg, "task")
|
|
want := []string{"-o", "stream-json", "-y", "-p", "task"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("gemini resume mode uses session id", func(t *testing.T) {
|
|
backend := GeminiBackend{}
|
|
cfg := &Config{Mode: "resume", SessionID: "sid-999"}
|
|
got := backend.BuildArgs(cfg, "resume")
|
|
want := []string{"-o", "stream-json", "-y", "-r", "sid-999", "-p", "resume"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("gemini resume mode without session omits identifier", func(t *testing.T) {
|
|
backend := GeminiBackend{}
|
|
cfg := &Config{Mode: "resume"}
|
|
got := backend.BuildArgs(cfg, "resume")
|
|
want := []string{"-o", "stream-json", "-y", "-p", "resume"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("gemini nil config returns nil", func(t *testing.T) {
|
|
backend := GeminiBackend{}
|
|
if backend.BuildArgs(nil, "ignored") != nil {
|
|
t.Fatalf("nil config should return nil args")
|
|
}
|
|
})
|
|
|
|
t.Run("codex build args omits bypass flag by default", func(t *testing.T) {
|
|
const key = "CODEX_BYPASS_SANDBOX"
|
|
t.Cleanup(func() { os.Unsetenv(key) })
|
|
os.Unsetenv(key)
|
|
|
|
backend := CodexBackend{}
|
|
cfg := &Config{Mode: "new", WorkDir: "/tmp"}
|
|
got := backend.BuildArgs(cfg, "task")
|
|
want := []string{"e", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("codex build args includes bypass flag when enabled", func(t *testing.T) {
|
|
const key = "CODEX_BYPASS_SANDBOX"
|
|
t.Cleanup(func() { os.Unsetenv(key) })
|
|
os.Setenv(key, "true")
|
|
|
|
backend := CodexBackend{}
|
|
cfg := &Config{Mode: "new", WorkDir: "/tmp"}
|
|
got := backend.BuildArgs(cfg, "task")
|
|
want := []string{"e", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestClaudeBuildArgs_BackendMetadata(t *testing.T) {
|
|
tests := []struct {
|
|
backend Backend
|
|
name string
|
|
command string
|
|
}{
|
|
{backend: CodexBackend{}, name: "codex", command: "codex"},
|
|
{backend: ClaudeBackend{}, name: "claude", command: "claude"},
|
|
{backend: GeminiBackend{}, name: "gemini", command: "gemini"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
if got := tt.backend.Name(); got != tt.name {
|
|
t.Fatalf("Name() = %s, want %s", got, tt.name)
|
|
}
|
|
if got := tt.backend.Command(); got != tt.command {
|
|
t.Fatalf("Command() = %s, want %s", got, tt.command)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadMinimalEnvSettings(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
|
|
t.Run("missing file returns empty", func(t *testing.T) {
|
|
if got := loadMinimalEnvSettings(); len(got) != 0 {
|
|
t.Fatalf("got %v, want empty", got)
|
|
}
|
|
})
|
|
|
|
t.Run("valid env returns string map", func(t *testing.T) {
|
|
dir := filepath.Join(home, ".claude")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
path := filepath.Join(dir, "setting.json")
|
|
data := []byte(`{"env":{"ANTHROPIC_API_KEY":"secret","FOO":"bar"}}`)
|
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
got := loadMinimalEnvSettings()
|
|
if got["ANTHROPIC_API_KEY"] != "secret" || got["FOO"] != "bar" {
|
|
t.Fatalf("got %v, want keys present", got)
|
|
}
|
|
})
|
|
|
|
t.Run("non-string values are ignored", func(t *testing.T) {
|
|
dir := filepath.Join(home, ".claude")
|
|
path := filepath.Join(dir, "setting.json")
|
|
data := []byte(`{"env":{"GOOD":"ok","BAD":123,"ALSO_BAD":true}}`)
|
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
got := loadMinimalEnvSettings()
|
|
if got["GOOD"] != "ok" {
|
|
t.Fatalf("got %v, want GOOD=ok", got)
|
|
}
|
|
if _, ok := got["BAD"]; ok {
|
|
t.Fatalf("got %v, want BAD omitted", got)
|
|
}
|
|
if _, ok := got["ALSO_BAD"]; ok {
|
|
t.Fatalf("got %v, want ALSO_BAD omitted", got)
|
|
}
|
|
})
|
|
|
|
t.Run("oversized file returns empty", func(t *testing.T) {
|
|
dir := filepath.Join(home, ".claude")
|
|
path := filepath.Join(dir, "setting.json")
|
|
data := bytes.Repeat([]byte("a"), maxClaudeSettingsBytes+1)
|
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
if got := loadMinimalEnvSettings(); len(got) != 0 {
|
|
t.Fatalf("got %v, want empty", got)
|
|
}
|
|
})
|
|
}
|