diff --git a/internal/claude/process_manager.go b/internal/claude/process_manager.go index a1db8a5..b3f1ed6 100644 --- a/internal/claude/process_manager.go +++ b/internal/claude/process_manager.go @@ -16,6 +16,7 @@ import ( "syscall" "time" + "github.com/zhubert/plural/internal/claudeconfig" "github.com/zhubert/plural/internal/paths" ) @@ -998,9 +999,9 @@ type containerRunResult struct { // buildContainerRunArgs constructs the arguments for `docker run` that wraps // the Claude CLI process inside a Docker container. func buildContainerRunArgs(config ProcessConfig, claudeArgs []string) (containerRunResult, error) { - homeDir, err := os.UserHomeDir() + claudeDir, err := claudeconfig.GetClaudeConfigDir() if err != nil { - return containerRunResult{}, fmt.Errorf("failed to determine home directory: %w", err) + return containerRunResult{}, fmt.Errorf("failed to determine claude config directory: %w", err) } containerName := "plural-" + config.SessionID @@ -1013,7 +1014,7 @@ func buildContainerRunArgs(config ProcessConfig, claudeArgs []string) (container "run", "-i", "--rm", "--name", containerName, "-v", config.WorkingDir + ":/workspace", - "-v", homeDir + "/.claude:/home/claude/.claude-host:ro", + "-v", claudeDir + ":/home/claude/.claude-host:ro", "-w", "/workspace", } @@ -1172,15 +1173,15 @@ func readKeychainOAuthToken() string { return creds.ClaudeAiOauth.AccessToken } -// credentialsFileExists checks whether ~/.claude/.credentials.json exists. +// credentialsFileExists checks whether .credentials.json exists in the Claude config dir. // This file is created by "claude login" (interactive OAuth) and contains // refresh tokens that Claude CLI can use to obtain access tokens. func credentialsFileExists() bool { - home, err := os.UserHomeDir() + dir, err := claudeconfig.GetClaudeConfigDir() if err != nil { return false } - _, err = os.Stat(filepath.Join(home, ".claude", ".credentials.json")) + _, err = os.Stat(filepath.Join(dir, ".credentials.json")) return err == nil } diff --git a/internal/claude/process_manager_test.go b/internal/claude/process_manager_test.go index cad7422..00a6d0d 100644 --- a/internal/claude/process_manager_test.go +++ b/internal/claude/process_manager_test.go @@ -2273,6 +2273,45 @@ func TestBuildCommandArgs_DisableStreamingChunks(t *testing.T) { }) } +func TestCredentialsFileExists_WithClaudeConfigDir(t *testing.T) { + // Create a custom directory pointed to by CLAUDE_CONFIG_DIR + customDir := t.TempDir() + credFile := filepath.Join(customDir, ".credentials.json") + if err := os.WriteFile(credFile, []byte(`{"refreshToken":"test"}`), 0600); err != nil { + t.Fatalf("failed to create credentials file: %v", err) + } + + t.Setenv("CLAUDE_CONFIG_DIR", customDir) + // Point HOME at an empty dir to ensure we're not falling back + t.Setenv("HOME", t.TempDir()) + + if !credentialsFileExists() { + t.Error("credentialsFileExists should return true when CLAUDE_CONFIG_DIR is set and .credentials.json exists there") + } +} + +func TestBuildContainerRunArgs_ClaudeConfigDir(t *testing.T) { + // When CLAUDE_CONFIG_DIR is set, the volume mount should use that directory + customDir := "/custom/claude/config" + t.Setenv("CLAUDE_CONFIG_DIR", customDir) + + config := ProcessConfig{ + SessionID: "test-configdir", + WorkingDir: "/tmp", + ContainerImage: "plural-claude", + } + + result, err := buildContainerRunArgs(config, []string{"--print"}) + if err != nil { + t.Fatalf("buildContainerRunArgs failed: %v", err) + } + + expectedMount := customDir + ":/home/claude/.claude-host:ro" + if !containsArg(result.Args, expectedMount) { + t.Errorf("args should contain volume mount %q, got: %v", expectedMount, result.Args) + } +} + func TestCredentialsFileExists_WithFile(t *testing.T) { // Create a temp directory to act as home tmpHome := t.TempDir() diff --git a/internal/claudeconfig/config_dir.go b/internal/claudeconfig/config_dir.go new file mode 100644 index 0000000..a3204a9 --- /dev/null +++ b/internal/claudeconfig/config_dir.go @@ -0,0 +1,20 @@ +package claudeconfig + +import ( + "os" + "path/filepath" +) + +// GetClaudeConfigDir returns the Claude Code configuration directory. +// If the CLAUDE_CONFIG_DIR environment variable is set, it is used as-is. +// Otherwise, falls back to ~/.claude. +func GetClaudeConfigDir() (string, error) { + if dir := os.Getenv("CLAUDE_CONFIG_DIR"); dir != "" { + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".claude"), nil +} diff --git a/internal/claudeconfig/config_dir_test.go b/internal/claudeconfig/config_dir_test.go new file mode 100644 index 0000000..80b9fda --- /dev/null +++ b/internal/claudeconfig/config_dir_test.go @@ -0,0 +1,51 @@ +package claudeconfig + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGetClaudeConfigDir(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to get home dir: %v", err) + } + + tests := []struct { + name string + envVal string + want string + wantErr bool + }{ + { + name: "env var set returns env var value", + envVal: "/custom/claude/config", + want: "/custom/claude/config", + }, + { + name: "env var unset returns ~/.claude", + envVal: "", + want: filepath.Join(home, ".claude"), + }, + { + name: "env var with trailing slash is returned as-is", + envVal: "/custom/dir/", + want: "/custom/dir/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("CLAUDE_CONFIG_DIR", tt.envVal) + + got, err := GetClaudeConfigDir() + if (err != nil) != tt.wantErr { + t.Fatalf("GetClaudeConfigDir() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("GetClaudeConfigDir() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 2aa0f86..dc54e01 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -14,6 +14,7 @@ import ( "sync" "time" + "github.com/zhubert/plural/internal/claudeconfig" "github.com/zhubert/plural/internal/logger" ) @@ -896,16 +897,16 @@ func (s *Server) readPlanFromPath(planPath string) string { return string(content) } -// validatePlanPath ensures the given path resolves to within ~/.claude/plans/. +// validatePlanPath ensures the given path resolves to within the Claude plans directory. // This prevents path traversal attacks where a malicious filePath argument // could read arbitrary files from the filesystem. func validatePlanPath(planPath string) error { - homeDir, err := os.UserHomeDir() + claudeDir, err := claudeconfig.GetClaudeConfigDir() if err != nil { - return fmt.Errorf("cannot determine home directory: %w", err) + return fmt.Errorf("cannot determine claude config directory: %w", err) } - allowedDir := filepath.Join(homeDir, ".claude", "plans") + allowedDir := filepath.Join(claudeDir, "plans") // Clean and resolve the path to eliminate ../ traversal absPath, err := filepath.Abs(planPath) diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index f3756ef..cf82031 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -986,6 +986,45 @@ func TestReadPlanFromPath(t *testing.T) { }) } +func TestValidatePlanPath_ClaudeConfigDir(t *testing.T) { + // When CLAUDE_CONFIG_DIR is set, plans must be under that directory. + customDir := t.TempDir() + t.Setenv("CLAUDE_CONFIG_DIR", customDir) + + plansDir := filepath.Join(customDir, "plans") + + tests := []struct { + name string + path string + wantErr bool + }{ + { + name: "valid path in custom plans directory", + path: filepath.Join(plansDir, "my-plan.md"), + wantErr: false, + }, + { + name: "path in default ~/.claude/plans is rejected when CLAUDE_CONFIG_DIR is set", + path: filepath.Join(os.Getenv("HOME"), ".claude", "plans", "plan.md"), + wantErr: true, + }, + { + name: "path traversal still rejected", + path: filepath.Join(plansDir, "..", "..", "etc", "passwd"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePlanPath(tt.path) + if (err != nil) != tt.wantErr { + t.Errorf("validatePlanPath(%q) error = %v, wantErr %v", tt.path, err, tt.wantErr) + } + }) + } +} + func TestValidatePlanPath(t *testing.T) { homeDir, err := os.UserHomeDir() if err != nil { diff --git a/internal/plugins/plugins.go b/internal/plugins/plugins.go index d3c5a9f..4512de9 100644 --- a/internal/plugins/plugins.go +++ b/internal/plugins/plugins.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/zhubert/plural/internal/claudeconfig" "github.com/zhubert/plural/internal/logger" ) @@ -33,11 +34,11 @@ type Plugin struct { // getClaudeDir returns the Claude config directory path func getClaudeDir() string { - home, err := os.UserHomeDir() + dir, err := claudeconfig.GetClaudeConfigDir() if err != nil { return "" } - return filepath.Join(home, ".claude") + return dir } // knownMarketplacesFile is the structure of ~/.claude/plugins/known_marketplaces.json