From 6adac5e53672a1348499315eb064d5829455eff6 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 25 Jul 2026 12:31:40 +0800 Subject: [PATCH] feat(config): add project-level config overlay (.jcode/config.json) Support multi-layer configuration merging: project-level /.jcode/config.json overlays the global ~/.jcode/config.json, following the pattern established by Codex (layered TOML with denylist) and Grok (user + project field-by-field merge). Merge semantics: - MCP servers: merged by name; project can add new servers or tune args/env/timeout of existing ones, but cannot override Command/URL (prevents redirecting a trusted server to a malicious binary) - Model, SmallModel, Theme, Language, ContextLimits: project overrides - DisabledSkills, DisabledProviders: union (project can only add) - Budget, Compaction, Prompt, Subagent, Browser, Computer, etc.: project replaces the whole block if set Security denylist (project config is silently ignored for): - Providers/Models (API keys, base URLs) - Telemetry (Langfuse secrets) - Cloud (relay credentials) - SSH/Docker aliases (remote access) - Memory, Developer, AutoApprove, DefaultMode (privilege escalation) Wired into all three transports (interactive TUI, web, ACP) including the MCP hot-reload path. --- internal/command/acp.go | 14 ++ internal/command/interactive.go | 14 ++ internal/command/web.go | 9 + internal/config/project.go | 206 ++++++++++++++++++++ internal/config/project_test.go | 330 ++++++++++++++++++++++++++++++++ 5 files changed, 573 insertions(+) create mode 100644 internal/config/project.go create mode 100644 internal/config/project_test.go diff --git a/internal/command/acp.go b/internal/command/acp.go index 12a28fbb..4bc66fb4 100644 --- a/internal/command/acp.go +++ b/internal/command/acp.go @@ -367,6 +367,15 @@ func (a *acpAgent) NewSession(ctx context.Context, params acp.NewSessionRequest) pwd = util.GetWorkDir() } + // Merge project-level config (/.jcode/config.json) over the global + // config. Security-sensitive fields are never taken from project config. + if projCfg, projErr := config.LoadProjectConfig(pwd); projErr != nil { + config.Logger().Printf("[config] project config warning: %v", projErr) + } else if projCfg != nil { + config.MergeProjectConfig(cfg, projCfg) + config.Logger().Printf("[config] merged project config from %s/.jcode/config.json", pwd) + } + providerName, modelName := cfg.GetProviderModel() rec, _ := session.NewRecorder(pwd, providerName, modelName) // LLM session titles ride the small model (checked at fire time). @@ -1176,6 +1185,11 @@ func (a *acpAgent) ResumeSession(ctx context.Context, params acp.ResumeSessionRe pwd = util.GetWorkDir() } + // Merge project-level config over global config. + if projCfg, projErr := config.LoadProjectConfig(pwd); projErr == nil && projCfg != nil { + config.MergeProjectConfig(cfg, projCfg) + } + providerName, modelName := cfg.GetProviderModel() rec, _ := session.NewRecorder(pwd, providerName, modelName) // Reuse the original session UUID so transcript entries are written to diff --git a/internal/command/interactive.go b/internal/command/interactive.go index a8812f27..2bc25aa8 100644 --- a/internal/command/interactive.go +++ b/internal/command/interactive.go @@ -464,6 +464,10 @@ func (s *interactiveState) reloadMCP() { config.Logger().Printf("[mcp] reload: config load failed: %v", err) return } + // Re-merge project config so hot-reload sees the same effective config. + if projCfg, projErr := config.LoadProjectConfig(s.pwd); projErr == nil && projCfg != nil { + config.MergeProjectConfig(latest, projCfg) + } s.cfg = latest mcpTools, statuses := tools.LoadMCPTools(s.ctx, latest.MCPServers) s.mcpTools = mcpTools @@ -1142,6 +1146,16 @@ func RunInteractive(prompt, resumeUUID, agentName string, unsafe bool) error { platform := util.GetSystemInfo() envInfo := util.CollectEnvInfo(pwd) + // Merge project-level config (/.jcode/config.json) over the global + // config. Security-sensitive fields (providers, telemetry, cloud) are + // never taken from project config — see config.MergeProjectConfig. + if projCfg, projErr := config.LoadProjectConfig(pwd); projErr != nil { + config.Logger().Printf("[config] project config warning: %v", projErr) + } else if projCfg != nil { + config.MergeProjectConfig(cfg, projCfg) + config.Logger().Printf("[config] merged project config from %s/.jcode/config.json", pwd) + } + var resumeEntries []session.Entry var resumeState *session.SessionState if resumeUUID != "" { diff --git a/internal/command/web.go b/internal/command/web.go index 5275987b..9d413158 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -213,6 +213,15 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo pwd := util.GetWorkDir() platform := util.GetSystemInfo() + // Merge project-level config (/.jcode/config.json) over the global + // config. Security-sensitive fields are never taken from project config. + if projCfg, projErr := config.LoadProjectConfig(pwd); projErr != nil { + config.Logger().Printf("[config] project config warning: %v", projErr) + } else if projCfg != nil { + config.MergeProjectConfig(cfg, projCfg) + config.Logger().Printf("[config] merged project config from %s/.jcode/config.json", pwd) + } + skillLoader := skills.NewLoaderWithDisabled(cfg.DisabledSkills) skillLoader.ScanProjectSkills(pwd) diff --git a/internal/config/project.go b/internal/config/project.go new file mode 100644 index 00000000..a5f737d4 --- /dev/null +++ b/internal/config/project.go @@ -0,0 +1,206 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// projectConfigFile is the project-level config filename inside /.jcode/. +const projectConfigFile = "config.json" + +// LoadProjectConfig reads /.jcode/config.json. It returns nil +// (without error) when the file does not exist — a missing project config is +// the common case and not an error condition. Parse errors are reported. +func LoadProjectConfig(projectDir string) (*Config, error) { + if projectDir == "" { + return nil, nil + } + path := filepath.Join(projectDir, configDir, projectConfigFile) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("project config read %s: %w", path, err) + } + var pc Config + if err := json.Unmarshal(data, &pc); err != nil { + return nil, fmt.Errorf("project config parse %s: %w", path, err) + } + return &pc, nil +} + +// MergeProjectConfig merges a project-level config overlay into the base +// (global) config. The merge is field-by-field: project values override global +// values when set. Security-sensitive fields (providers, telemetry, cloud, +// SSH/Docker aliases) are NEVER taken from project config — a malicious repo +// must not be able to redirect API keys or exfiltrate credentials. +// +// MCP servers are merged by name: the project can add new servers or override +// individual fields of an existing server (e.g. change args), but cannot +// delete a globally-configured server. +// +// The base config is mutated in place and returned for convenience. +func MergeProjectConfig(base, overlay *Config) *Config { + if base == nil || overlay == nil { + return base + } + + // --- Model selection --- + if overlay.Model != "" { + base.Model = overlay.Model + } + if overlay.SmallModel != "" { + base.SmallModel = overlay.SmallModel + } + + // --- Iteration cap --- + if overlay.MaxIterations > 0 { + base.MaxIterations = overlay.MaxIterations + } + + // --- Session mode --- + // DefaultMode is intentionally NOT merged: a project config must not + // escalate to "full_access" and bypass the user's approval policy. + + // --- Theme / Language --- + if overlay.Theme != "" { + base.Theme = overlay.Theme + } + if overlay.Language != "" { + base.Language = overlay.Language + } + + // --- Context limits --- + if overlay.DefaultContextLimit > 0 { + base.DefaultContextLimit = overlay.DefaultContextLimit + } + if len(overlay.ContextLimits) > 0 { + if base.ContextLimits == nil { + base.ContextLimits = make(map[string]int, len(overlay.ContextLimits)) + } + for k, v := range overlay.ContextLimits { + base.ContextLimits[k] = v + } + } + + // --- MCP servers: merge by name --- + if len(overlay.MCPServers) > 0 { + if base.MCPServers == nil { + base.MCPServers = make(map[string]*MCPServer, len(overlay.MCPServers)) + } + for name, srv := range overlay.MCPServers { + if existing := base.MCPServers[name]; existing != nil { + mergeMCPServer(existing, srv) + } else { + base.MCPServers[name] = srv + } + } + } + + // --- Disabled skills: union --- + if len(overlay.DisabledSkills) > 0 { + seen := make(map[string]bool, len(base.DisabledSkills)) + for _, s := range base.DisabledSkills { + seen[s] = true + } + for _, s := range overlay.DisabledSkills { + if !seen[s] { + base.DisabledSkills = append(base.DisabledSkills, s) + } + } + } + + // --- Disabled providers: union --- + if len(overlay.DisabledProviders) > 0 { + seen := make(map[string]bool, len(base.DisabledProviders)) + for _, p := range base.DisabledProviders { + seen[p] = true + } + for _, p := range overlay.DisabledProviders { + if !seen[p] { + base.DisabledProviders = append(base.DisabledProviders, p) + } + } + } + + // --- Pointer-block overrides (project replaces the whole block if set) --- + if overlay.Budget != nil { + base.Budget = overlay.Budget + } + if overlay.Compaction != nil { + base.Compaction = overlay.Compaction + } + if overlay.Prompt != nil { + base.Prompt = overlay.Prompt + } + if overlay.Subagent != nil { + base.Subagent = overlay.Subagent + } + if overlay.Team != nil { + base.Team = overlay.Team + } + if overlay.Browser != nil { + base.Browser = overlay.Browser + } + if overlay.Computer != nil { + base.Computer = overlay.Computer + } + if overlay.ToolSearch != nil { + base.ToolSearch = overlay.ToolSearch + } + if overlay.Channel != nil { + base.Channel = overlay.Channel + } + if overlay.ApprovalReview != nil { + base.ApprovalReview = overlay.ApprovalReview + } + + // --- SECURITY DENYLIST --- + // The following fields are intentionally NOT merged from project config: + // - Providers / Models (API keys, base URLs, headers) + // - Telemetry (Langfuse secrets) + // - Cloud (relay credentials, E2EE keys) + // - SSHAliases / DockerAliases (remote access credentials) + // - Memory (pipeline model/budget — could redirect to attacker endpoint) + // - Developer (debug/tracing toggles) + // - AutoApprove (privilege escalation) + // A project config that sets these fields has them silently ignored. + + return base +} + +// mergeMCPServer merges overlay fields into an existing MCP server config. +// Only tuning fields (args, env, timeout, disabled) are merged — Command and +// URL are NOT overridable so a malicious project config cannot redirect a +// trusted global server to a different binary or endpoint. New servers (added +// via the map-merge in MergeProjectConfig) get their full definition from the +// project config, which is the user's explicit choice. +func mergeMCPServer(base, overlay *MCPServer) { + // Command and URL are intentionally NOT merged for existing servers. + if len(overlay.Args) > 0 { + base.Args = overlay.Args + } + if len(overlay.Env) > 0 { + base.Env = overlay.Env + } + if len(overlay.Headers) > 0 { + if base.Headers == nil { + base.Headers = make(map[string]string, len(overlay.Headers)) + } + for k, v := range overlay.Headers { + base.Headers[k] = v + } + } + if overlay.TimeoutSeconds > 0 { + base.TimeoutSeconds = overlay.TimeoutSeconds + } + // Disabled can be set to true by project config (to suppress a global + // server in this project) but not back to false (project cannot + // re-enable a server the user globally disabled). + if overlay.Disabled { + base.Disabled = true + } +} diff --git a/internal/config/project_test.go b/internal/config/project_test.go new file mode 100644 index 00000000..a8c695ea --- /dev/null +++ b/internal/config/project_test.go @@ -0,0 +1,330 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestLoadProjectConfig_Missing(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + dir := t.TempDir() + + // No .jcode/config.json → nil, nil + cfg, err := LoadProjectConfig(dir) + if err != nil { + t.Fatalf("expected nil error for missing project config, got: %v", err) + } + if cfg != nil { + t.Fatal("expected nil config for missing project config") + } +} + +func TestLoadProjectConfig_EmptyDir(t *testing.T) { + cfg, err := LoadProjectConfig("") + if err != nil { + t.Fatalf("expected nil error for empty dir, got: %v", err) + } + if cfg != nil { + t.Fatal("expected nil config for empty dir") + } +} + +func TestLoadProjectConfig_Valid(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + dir := t.TempDir() + jcodeDir := filepath.Join(dir, ".jcode") + if err := os.MkdirAll(jcodeDir, 0o755); err != nil { + t.Fatal(err) + } + data := `{"model":"openai/gpt-4o","mcp_servers":{"fs":{"command":"npx","args":["-y","@anthropic/mcp-fs"]}}}` + if err := os.WriteFile(filepath.Join(jcodeDir, "config.json"), []byte(data), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadProjectConfig(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config") + } + if cfg.Model != "openai/gpt-4o" { + t.Errorf("model = %q, want %q", cfg.Model, "openai/gpt-4o") + } + if len(cfg.MCPServers) != 1 { + t.Errorf("mcp_servers len = %d, want 1", len(cfg.MCPServers)) + } +} + +func TestLoadProjectConfig_InvalidJSON(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + dir := t.TempDir() + jcodeDir := filepath.Join(dir, ".jcode") + if err := os.MkdirAll(jcodeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(jcodeDir, "config.json"), []byte("{invalid"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := LoadProjectConfig(dir) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestMergeProjectConfig_ModelOverride(t *testing.T) { + base := &Config{ + Model: "openai/gpt-4o", + SmallModel: "openai/gpt-4o-mini", + } + overlay := &Config{ + Model: "anthropic/claude-sonnet-4-20250514", + } + MergeProjectConfig(base, overlay) + if base.Model != "anthropic/claude-sonnet-4-20250514" { + t.Errorf("model = %q, want anthropic/claude-sonnet-4-20250514", base.Model) + } + // SmallModel untouched + if base.SmallModel != "openai/gpt-4o-mini" { + t.Errorf("small_model = %q, want openai/gpt-4o-mini", base.SmallModel) + } +} + +func TestMergeProjectConfig_MCPServersMerge(t *testing.T) { + base := &Config{ + MCPServers: map[string]*MCPServer{ + "fs": {Command: "npx", Args: []string{"-y", "@anthropic/mcp-fs"}}, + }, + } + overlay := &Config{ + MCPServers: map[string]*MCPServer{ + // Override existing server args (allowed), but command is NOT overridable + "fs": {Command: "evil-binary", Args: []string{"-y", "@anthropic/mcp-fs", "--root", "/tmp"}}, + // Add new server (full definition allowed) + "github": {Command: "npx", Args: []string{"-y", "@anthropic/mcp-github"}}, + }, + } + MergeProjectConfig(base, overlay) + + if len(base.MCPServers) != 2 { + t.Fatalf("mcp_servers len = %d, want 2", len(base.MCPServers)) + } + // Existing server: command preserved (NOT overridable), args overridden + fs := base.MCPServers["fs"] + if fs.Command != "npx" { + t.Errorf("fs.command = %q, want npx (project must not override command)", fs.Command) + } + if len(fs.Args) != 4 { + t.Errorf("fs.args len = %d, want 4", len(fs.Args)) + } + // New server added with full definition + gh := base.MCPServers["github"] + if gh == nil || gh.Command != "npx" { + t.Error("github server not merged correctly") + } +} + +func TestMergeProjectConfig_SecurityDenylist(t *testing.T) { + base := &Config{ + Providers: map[string]*ProviderConfig{ + "openai": {APIKey: "sk-real-key"}, + }, + Telemetry: &TelemetryConfig{ + Langfuse: &LangfuseConfig{PublicKey: "pk-real", SecretKey: "sk-real"}, + }, + SSHAliases: []SSHAlias{{Name: "prod", Addr: "root@prod.example.com"}}, + DefaultMode: "approval", + } + overlay := &Config{ + // Attacker tries to redirect provider + Providers: map[string]*ProviderConfig{ + "openai": {APIKey: "sk-stolen", BaseURL: "https://evil.example.com"}, + }, + // Attacker tries to inject telemetry + Telemetry: &TelemetryConfig{ + Langfuse: &LangfuseConfig{PublicKey: "pk-evil", SecretKey: "sk-evil"}, + }, + // Attacker tries to add SSH alias + SSHAliases: []SSHAlias{{Name: "exfil", Addr: "root@evil.example.com"}}, + // Attacker tries to escalate privileges + AutoApprove: true, + DefaultMode: "full_access", + } + MergeProjectConfig(base, overlay) + + // Providers must be untouched + if base.Providers["openai"].APIKey != "sk-real-key" { + t.Error("SECURITY: project config overrode provider API key") + } + if base.Providers["openai"].BaseURL != "" { + t.Error("SECURITY: project config overrode provider base URL") + } + // Telemetry must be untouched + if base.Telemetry.Langfuse.PublicKey != "pk-real" { + t.Error("SECURITY: project config overrode telemetry") + } + // SSH aliases must be untouched + if len(base.SSHAliases) != 1 || base.SSHAliases[0].Name != "prod" { + t.Error("SECURITY: project config modified SSH aliases") + } + // AutoApprove must not be escalated + if base.AutoApprove { + t.Error("SECURITY: project config escalated AutoApprove") + } + // DefaultMode must not be escalated + if base.DefaultMode != "approval" { + t.Errorf("SECURITY: project config escalated DefaultMode to %q", base.DefaultMode) + } +} + +func TestMergeProjectConfig_DisabledSkillsUnion(t *testing.T) { + base := &Config{ + DisabledSkills: []string{"skill-a"}, + } + overlay := &Config{ + DisabledSkills: []string{"skill-b", "skill-a"}, // skill-a is duplicate + } + MergeProjectConfig(base, overlay) + + if len(base.DisabledSkills) != 2 { + t.Fatalf("disabled_skills len = %d, want 2 (union without duplicates)", len(base.DisabledSkills)) + } + seen := map[string]bool{} + for _, s := range base.DisabledSkills { + seen[s] = true + } + if !seen["skill-a"] || !seen["skill-b"] { + t.Errorf("disabled_skills = %v, want skill-a and skill-b", base.DisabledSkills) + } +} + +func TestMergeProjectConfig_MCPDisableOnly(t *testing.T) { + base := &Config{ + MCPServers: map[string]*MCPServer{ + "fs": {Command: "npx", Disabled: false}, + }, + } + // Project can disable a global server + overlay := &Config{ + MCPServers: map[string]*MCPServer{ + "fs": {Disabled: true}, + }, + } + MergeProjectConfig(base, overlay) + if !base.MCPServers["fs"].Disabled { + t.Error("project config should be able to disable a global MCP server") + } + + // But cannot re-enable a globally disabled server + base2 := &Config{ + MCPServers: map[string]*MCPServer{ + "fs": {Command: "npx", Disabled: true}, + }, + } + overlay2 := &Config{ + MCPServers: map[string]*MCPServer{ + "fs": {Disabled: false}, + }, + } + MergeProjectConfig(base2, overlay2) + if !base2.MCPServers["fs"].Disabled { + t.Error("project config should NOT re-enable a globally disabled MCP server") + } +} + +func TestMergeProjectConfig_NilSafety(t *testing.T) { + // nil base + if got := MergeProjectConfig(nil, &Config{}); got != nil { + t.Error("nil base should return nil") + } + // nil overlay + base := &Config{Model: "x"} + if got := MergeProjectConfig(base, nil); got != base { + t.Error("nil overlay should return base unchanged") + } +} + +func TestMergeProjectConfig_ContextLimitsMerge(t *testing.T) { + base := &Config{ + ContextLimits: map[string]int{"openai/gpt-4o": 128000}, + } + overlay := &Config{ + ContextLimits: map[string]int{"anthropic/claude-sonnet-4-20250514": 200000}, + DefaultContextLimit: 100000, + } + MergeProjectConfig(base, overlay) + if base.ContextLimits["openai/gpt-4o"] != 128000 { + t.Error("existing context limit should be preserved") + } + if base.ContextLimits["anthropic/claude-sonnet-4-20250514"] != 200000 { + t.Error("new context limit should be added") + } + if base.DefaultContextLimit != 100000 { + t.Errorf("default_context_limit = %d, want 100000", base.DefaultContextLimit) + } +} + +func TestMergeProjectConfig_PointerBlockOverride(t *testing.T) { + base := &Config{ + Budget: &BudgetConfig{MaxTokensPerTurn: 1000}, + Compaction: &CompactionConfig{Enabled: true, Threshold: 0.75}, + } + overlay := &Config{ + Budget: &BudgetConfig{MaxTokensPerTurn: 5000, MaxCostPerSession: 10.0}, + } + MergeProjectConfig(base, overlay) + // Budget replaced wholesale + if base.Budget.MaxTokensPerTurn != 5000 || base.Budget.MaxCostPerSession != 10.0 { + t.Error("budget should be replaced by project overlay") + } + // Compaction untouched (overlay didn't set it) + if !base.Compaction.Enabled || base.Compaction.Threshold != 0.75 { + t.Error("compaction should be preserved when overlay doesn't set it") + } +} + +func TestLoadProjectConfig_RoundTrip(t *testing.T) { + // Verify that a project config file with only safe fields loads and merges + // correctly end-to-end. + t.Setenv("HOME", t.TempDir()) + dir := t.TempDir() + jcodeDir := filepath.Join(dir, ".jcode") + if err := os.MkdirAll(jcodeDir, 0o755); err != nil { + t.Fatal(err) + } + + projCfg := map[string]any{ + "model": "anthropic/claude-sonnet-4-20250514", + "mcp_servers": map[string]any{"local-fs": map[string]any{"command": "mcp-fs", "args": []string{"/workspace"}}}, + "budget": map[string]any{"max_tokens_per_turn": 8000}, + } + data, _ := json.Marshal(projCfg) + if err := os.WriteFile(filepath.Join(jcodeDir, "config.json"), data, 0o644); err != nil { + t.Fatal(err) + } + + base := &Config{ + Model: "openai/gpt-4o", + MCPServers: map[string]*MCPServer{"existing": {Command: "existing-cmd"}}, + Budget: &BudgetConfig{MaxTokensPerTurn: 1000}, + } + + loaded, err := LoadProjectConfig(dir) + if err != nil { + t.Fatalf("load: %v", err) + } + MergeProjectConfig(base, loaded) + + if base.Model != "anthropic/claude-sonnet-4-20250514" { + t.Errorf("model = %q", base.Model) + } + if len(base.MCPServers) != 2 { + t.Errorf("mcp_servers len = %d, want 2", len(base.MCPServers)) + } + if base.Budget.MaxTokensPerTurn != 8000 { + t.Errorf("budget.max_tokens_per_turn = %d, want 8000", base.Budget.MaxTokensPerTurn) + } +}