Skip to content

Commit c41ca9d

Browse files
committed
feat(config): hard guard for settings.json env conflicts
Detect when settings.json's env field contains keys that would silently override ccc's provider env (Claude Code's settings.json env overrides the process env passed via syscall.Exec, empirically verified). Refuse to launch claude — and refuse to run validate — when such conflicts are present, instead of silently stripping the keys. A key is considered conflicting if it: - starts with ANTHROPIC_ or CLAUDE_, OR - collides with any key defined in base/provider env. The error message lists offending keys (without values, to avoid leaking secrets) and points the user at the fix: remove from settings.json's env and move provider configuration to ccc.json. ccc never modifies the user's settings.json — the user is the only one who can resolve the conflict. BREAKING CHANGE: previously, ANTHROPIC_*/CLAUDE_* keys and managed keys in settings.json env were silently filtered out when writing the merged settings. Now they cause a hard error and refuse to start claude until the user removes them.
1 parent 34dc00f commit c41ca9d

5 files changed

Lines changed: 683 additions & 0 deletions

File tree

internal/cli/cli.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,13 @@ func Run(cmd *Command) error {
306306

307307
// runValidate executes the validate command.
308308
func runValidate(cfg *config.Config, opts *ValidateCommand) error {
309+
// Guard: refuse to validate when settings.json contains env keys that would
310+
// silently override provider env. The check mirrors the launch-path guard so
311+
// users get the same actionable error from both entry points.
312+
if err := checkValidateEnvConflict(cfg, opts); err != nil {
313+
return err
314+
}
315+
309316
// Create a config adapter for the validate package
310317
cfgAdapter := &configAdapter{cfg: cfg}
311318

@@ -317,6 +324,76 @@ func runValidate(cfg *config.Config, opts *ValidateCommand) error {
317324
return validate.Run(cfgAdapter, validateOpts)
318325
}
319326

327+
// checkValidateEnvConflict runs the same env-conflict guard used by runClaude, scoped
328+
// to the providers about to be validated:
329+
// - --all: union of base env + every provider's env (from-strictest stance).
330+
// - --provider X (or current): base env + provider X's env.
331+
//
332+
// Returns nil when no conflicts, a formatted error otherwise.
333+
func checkValidateEnvConflict(cfg *config.Config, opts *ValidateCommand) error {
334+
userSettings, err := config.LoadSettings()
335+
if err != nil {
336+
return fmt.Errorf("failed to load settings.json for conflict check: %w", err)
337+
}
338+
if userSettings == nil {
339+
return nil
340+
}
341+
342+
managedEnvKeys := make(map[string]bool)
343+
for key := range config.GetEnv(cfg.Settings) {
344+
managedEnvKeys[key] = true
345+
}
346+
347+
providerNames := validateTargetProviders(cfg, opts)
348+
for _, name := range providerNames {
349+
providerSettings, ok := cfg.Providers[name]
350+
if !ok {
351+
continue
352+
}
353+
for key := range config.GetEnv(providerSettings) {
354+
managedEnvKeys[key] = true
355+
}
356+
}
357+
358+
conflicts := config.DetectSettingsEnvConflicts(userSettings, managedEnvKeys)
359+
if len(conflicts) == 0 {
360+
return nil
361+
}
362+
363+
return fmt.Errorf("%s", config.FormatEnvConflictError(
364+
config.GetSettingsPath(),
365+
config.GetConfigPath(),
366+
conflicts,
367+
))
368+
}
369+
370+
// validateTargetProviders returns the provider names whose env keys should contribute
371+
// to managedEnvKeys for the validate guard. For --all (or when a specific provider is
372+
// not provided and there's no current provider), it returns every configured provider.
373+
func validateTargetProviders(cfg *config.Config, opts *ValidateCommand) []string {
374+
if opts.ValidateAll {
375+
names := make([]string, 0, len(cfg.Providers))
376+
for name := range cfg.Providers {
377+
names = append(names, name)
378+
}
379+
return names
380+
}
381+
382+
name := opts.Provider
383+
if name == "" {
384+
name = cfg.CurrentProvider
385+
}
386+
if name == "" {
387+
// No target identifiable: treat as --all to stay strict.
388+
names := make([]string, 0, len(cfg.Providers))
389+
for n := range cfg.Providers {
390+
names = append(names, n)
391+
}
392+
return names
393+
}
394+
return []string{name}
395+
}
396+
320397
// configAdapter adapts config.Config to the validate.Config interface.
321398
type configAdapter struct {
322399
cfg *config.Config

internal/cli/exec.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,49 @@ func executeProcess(path string, args []string, env []string) error {
2020
return syscall.Exec(path, args, env)
2121
}
2222

23+
// checkSettingsEnvConflict refuses to start claude when settings.json's env field
24+
// contains keys that would silently override the provider env ccc passes to claude.
25+
// See docs/discuss-20260519-env-priority.md for the empirical proof that settings.json
26+
// env > process env in Claude Code.
27+
//
28+
// managedEnvKeys are derived from base env (cfg.Settings.env) plus the active
29+
// provider's env. A non-nil error means the user must clean settings.json before ccc
30+
// will launch claude — ccc never modifies the user's settings.json on their behalf.
31+
func checkSettingsEnvConflict(cfg *config.Config, providerName string) error {
32+
providerSettings, ok := cfg.Providers[providerName]
33+
if !ok {
34+
// Provider lookup failure is surfaced later by SwitchWithHook; we just skip the guard.
35+
return nil
36+
}
37+
38+
userSettings, err := config.LoadSettings()
39+
if err != nil {
40+
return fmt.Errorf("failed to load settings.json for conflict check: %w", err)
41+
}
42+
if userSettings == nil {
43+
return nil
44+
}
45+
46+
managedEnvKeys := make(map[string]bool)
47+
for key := range config.GetEnv(cfg.Settings) {
48+
managedEnvKeys[key] = true
49+
}
50+
for key := range config.GetEnv(providerSettings) {
51+
managedEnvKeys[key] = true
52+
}
53+
54+
conflicts := config.DetectSettingsEnvConflicts(userSettings, managedEnvKeys)
55+
if len(conflicts) == 0 {
56+
return nil
57+
}
58+
59+
return fmt.Errorf("%s", config.FormatEnvConflictError(
60+
config.GetSettingsPath(),
61+
config.GetConfigPath(),
62+
conflicts,
63+
))
64+
}
65+
2366
// determineProvider determines which provider to use based on the command and config.
2467
func determineProvider(cmd *Command, cfg *config.Config) string {
2568
if cmd.Provider != "" {
@@ -62,6 +105,14 @@ func runClaude(cfg *config.Config, cmd *Command) error {
62105
return fmt.Errorf("no providers configured")
63106
}
64107

108+
// Guard: refuse to start claude when settings.json contains env keys that
109+
// would silently override the provider env ccc passes to the claude process.
110+
// Must run BEFORE SwitchWithHook so we never rewrite settings.json while leaving
111+
// the conflict in place. See docs/discuss-20260519-env-priority.md.
112+
if err := checkSettingsEnvConflict(cfg, providerName); err != nil {
113+
return err
114+
}
115+
65116
// Check if supervisor ID is already set in environment
66117
// (e.g., from previous supervisor iteration or when ccc is called again)
67118
supervisorID := os.Getenv("CCC_SUPERVISOR_ID")

internal/cli/exec_test.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/guyskk/ccc/internal/config"
10+
)
11+
12+
// writeSettingsJSON writes a settings.json into the test config dir.
13+
func writeSettingsJSON(t *testing.T, content string) {
14+
t.Helper()
15+
path := filepath.Join(config.GetDir(), "settings.json")
16+
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
17+
t.Fatalf("write settings.json: %v", err)
18+
}
19+
}
20+
21+
func TestCheckSettingsEnvConflict_NoSettingsFile(t *testing.T) {
22+
cleanup := setupTestDir(t)
23+
defer cleanup()
24+
25+
cfg := &config.Config{
26+
Settings: map[string]interface{}{},
27+
Providers: map[string]map[string]interface{}{
28+
"glm": {
29+
"env": map[string]interface{}{
30+
"ANTHROPIC_BASE_URL": "https://example.com",
31+
"ANTHROPIC_AUTH_TOKEN": "sk-x",
32+
},
33+
},
34+
},
35+
}
36+
37+
if err := checkSettingsEnvConflict(cfg, "glm"); err != nil {
38+
t.Errorf("expected no error when settings.json missing, got: %v", err)
39+
}
40+
}
41+
42+
func TestCheckSettingsEnvConflict_NoConflict(t *testing.T) {
43+
cleanup := setupTestDir(t)
44+
defer cleanup()
45+
46+
writeSettingsJSON(t, `{"env":{"MY_CUSTOM_VAR":"value"}}`)
47+
48+
cfg := &config.Config{
49+
Settings: map[string]interface{}{},
50+
Providers: map[string]map[string]interface{}{
51+
"glm": {
52+
"env": map[string]interface{}{
53+
"ANTHROPIC_BASE_URL": "https://example.com",
54+
"ANTHROPIC_AUTH_TOKEN": "sk-x",
55+
},
56+
},
57+
},
58+
}
59+
60+
if err := checkSettingsEnvConflict(cfg, "glm"); err != nil {
61+
t.Errorf("expected no error when only safe custom env present, got: %v", err)
62+
}
63+
}
64+
65+
func TestCheckSettingsEnvConflict_PrefixHit(t *testing.T) {
66+
cleanup := setupTestDir(t)
67+
defer cleanup()
68+
69+
writeSettingsJSON(t, `{"env":{"ANTHROPIC_BASE_URL":"https://old.example.com"}}`)
70+
71+
cfg := &config.Config{
72+
Settings: map[string]interface{}{},
73+
Providers: map[string]map[string]interface{}{
74+
"glm": {
75+
"env": map[string]interface{}{
76+
"ANTHROPIC_BASE_URL": "https://new.example.com",
77+
"ANTHROPIC_AUTH_TOKEN": "sk-x",
78+
},
79+
},
80+
},
81+
}
82+
83+
err := checkSettingsEnvConflict(cfg, "glm")
84+
if err == nil {
85+
t.Fatal("expected conflict error, got nil")
86+
}
87+
if !strings.Contains(err.Error(), "ANTHROPIC_BASE_URL") {
88+
t.Errorf("error should mention conflict key, got: %v", err)
89+
}
90+
if strings.Contains(err.Error(), "https://old.example.com") {
91+
t.Errorf("error must not contain user value (leak risk), got: %v", err)
92+
}
93+
}
94+
95+
func TestCheckSettingsEnvConflict_ManagedHit(t *testing.T) {
96+
cleanup := setupTestDir(t)
97+
defer cleanup()
98+
99+
writeSettingsJSON(t, `{"env":{"API_TIMEOUT":"60000"}}`)
100+
101+
cfg := &config.Config{
102+
Settings: map[string]interface{}{
103+
"env": map[string]interface{}{
104+
"API_TIMEOUT": "30000",
105+
},
106+
},
107+
Providers: map[string]map[string]interface{}{
108+
"glm": {
109+
"env": map[string]interface{}{
110+
"ANTHROPIC_BASE_URL": "https://example.com",
111+
"ANTHROPIC_AUTH_TOKEN": "sk-x",
112+
},
113+
},
114+
},
115+
}
116+
117+
err := checkSettingsEnvConflict(cfg, "glm")
118+
if err == nil {
119+
t.Fatal("expected conflict error for managed key, got nil")
120+
}
121+
if !strings.Contains(err.Error(), "API_TIMEOUT") {
122+
t.Errorf("error should mention conflict key API_TIMEOUT, got: %v", err)
123+
}
124+
}
125+
126+
func TestCheckSettingsEnvConflict_UnknownProvider(t *testing.T) {
127+
cleanup := setupTestDir(t)
128+
defer cleanup()
129+
130+
writeSettingsJSON(t, `{"env":{"ANTHROPIC_BASE_URL":"https://x"}}`)
131+
132+
cfg := &config.Config{
133+
Settings: map[string]interface{}{},
134+
Providers: map[string]map[string]interface{}{},
135+
}
136+
137+
// Unknown provider: guard skips silently, leaves the real error to SwitchWithHook.
138+
if err := checkSettingsEnvConflict(cfg, "missing"); err != nil {
139+
t.Errorf("expected guard to skip on unknown provider, got: %v", err)
140+
}
141+
}
142+
143+
func TestCheckValidateEnvConflict_NoSettingsFile(t *testing.T) {
144+
cleanup := setupTestDir(t)
145+
defer cleanup()
146+
147+
cfg := &config.Config{
148+
Settings: map[string]interface{}{},
149+
Providers: map[string]map[string]interface{}{
150+
"glm": {"env": map[string]interface{}{"ANTHROPIC_BASE_URL": "https://x"}},
151+
},
152+
}
153+
154+
if err := checkValidateEnvConflict(cfg, &ValidateCommand{}); err != nil {
155+
t.Errorf("expected no error when settings.json missing, got: %v", err)
156+
}
157+
}
158+
159+
func TestCheckValidateEnvConflict_AllProvidersStrict(t *testing.T) {
160+
cleanup := setupTestDir(t)
161+
defer cleanup()
162+
163+
// settings.json only has a key that overlaps with provider "kimi"'s env,
164+
// not "glm"'s. With --all the guard must still detect it.
165+
writeSettingsJSON(t, `{"env":{"ANTHROPIC_SMALL_FAST_MODEL":"old"}}`)
166+
167+
cfg := &config.Config{
168+
Settings: map[string]interface{}{},
169+
Providers: map[string]map[string]interface{}{
170+
"glm": {"env": map[string]interface{}{"ANTHROPIC_BASE_URL": "https://glm"}},
171+
"kimi": {"env": map[string]interface{}{
172+
"ANTHROPIC_BASE_URL": "https://kimi",
173+
"ANTHROPIC_SMALL_FAST_MODEL": "kimi-fast",
174+
}},
175+
},
176+
}
177+
178+
err := checkValidateEnvConflict(cfg, &ValidateCommand{ValidateAll: true})
179+
if err == nil {
180+
t.Fatal("expected conflict for ANTHROPIC_SMALL_FAST_MODEL under --all, got nil")
181+
}
182+
if !strings.Contains(err.Error(), "ANTHROPIC_SMALL_FAST_MODEL") {
183+
t.Errorf("error should mention conflict key, got: %v", err)
184+
}
185+
}
186+
187+
func TestCheckValidateEnvConflict_SingleProviderScope(t *testing.T) {
188+
cleanup := setupTestDir(t)
189+
defer cleanup()
190+
191+
// settings.json has ANTHROPIC_BASE_URL which always triggers prefix rule,
192+
// regardless of which provider is targeted.
193+
writeSettingsJSON(t, `{"env":{"ANTHROPIC_BASE_URL":"https://old"}}`)
194+
195+
cfg := &config.Config{
196+
CurrentProvider: "glm",
197+
Settings: map[string]interface{}{},
198+
Providers: map[string]map[string]interface{}{
199+
"glm": {"env": map[string]interface{}{"ANTHROPIC_BASE_URL": "https://glm"}},
200+
},
201+
}
202+
203+
err := checkValidateEnvConflict(cfg, &ValidateCommand{Provider: "glm"})
204+
if err == nil {
205+
t.Fatal("expected conflict, got nil")
206+
}
207+
if !strings.Contains(err.Error(), "ANTHROPIC_BASE_URL") {
208+
t.Errorf("error should mention conflict key, got: %v", err)
209+
}
210+
}
211+
212+
func TestCheckValidateEnvConflict_NoConflict(t *testing.T) {
213+
cleanup := setupTestDir(t)
214+
defer cleanup()
215+
216+
writeSettingsJSON(t, `{"env":{"MY_CUSTOM":"x"}}`)
217+
218+
cfg := &config.Config{
219+
Settings: map[string]interface{}{},
220+
Providers: map[string]map[string]interface{}{
221+
"glm": {"env": map[string]interface{}{"ANTHROPIC_BASE_URL": "https://x"}},
222+
},
223+
}
224+
225+
if err := checkValidateEnvConflict(cfg, &ValidateCommand{ValidateAll: true}); err != nil {
226+
t.Errorf("expected no error when only safe env present, got: %v", err)
227+
}
228+
}

0 commit comments

Comments
 (0)