Skip to content
This repository was archived by the owner on Mar 21, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions internal/claude/process_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"syscall"
"time"

"github.com/zhubert/plural/internal/claudeconfig"
"github.com/zhubert/plural/internal/paths"
)

Expand Down Expand Up @@ -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
Expand All @@ -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",
Comment on lines 1001 to 1018

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildContainerRunArgs() now mounts the host Claude config dir directly. If CLAUDE_CONFIG_DIR is set to a relative path (or includes whitespace), docker bind mounts may fail at runtime (bind mounts generally require absolute host paths) and the failure won't be caught here. Consider normalizing/validating the resolved directory (e.g., ensure absolute) before constructing the -v argument and returning a clear error if it's invalid.

Copilot uses AI. Check for mistakes.
}

Expand Down Expand Up @@ -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
}

Expand Down
39 changes: 39 additions & 0 deletions internal/claude/process_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
20 changes: 20 additions & 0 deletions internal/claudeconfig/config_dir.go
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +11 to +19

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetClaudeConfigDir() returns CLAUDE_CONFIG_DIR as-is. If the env var contains leading/trailing whitespace or a relative path, callers can misbehave (e.g., validatePlanPath compares against an absolute path, and docker -v bind mounts require an absolute host path). Consider trimming whitespace and normalizing to an absolute, cleaned path before returning (or clearly documenting that only absolute paths are supported).

Copilot uses AI. Check for mistakes.
}
51 changes: 51 additions & 0 deletions internal/claudeconfig/config_dir_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
9 changes: 5 additions & 4 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"sync"
"time"

"github.com/zhubert/plural/internal/claudeconfig"
"github.com/zhubert/plural/internal/logger"
)

Expand Down Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Comment on lines +1007 to +1010

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test case uses os.Getenv("HOME") to construct the default ~/.claude path. On platforms/environments where HOME isn't set (or doesn't reflect os.UserHomeDir()), the test may still pass but won't actually validate the intended behavior. Prefer using os.UserHomeDir() and filepath.Join(home, ".claude", ...) for the default-path case.

Copilot uses AI. Check for mistakes.
{
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 {
Expand Down
5 changes: 3 additions & 2 deletions internal/plugins/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"time"

"github.com/zhubert/plural/internal/claudeconfig"
"github.com/zhubert/plural/internal/logger"
)

Expand All @@ -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
}
Comment on lines 35 to 42

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getClaudeDir() now hides the error from GetClaudeConfigDir() and forces callers to handle failure via an empty string, which leads to misleading downstream errors like "could not find home directory" even though the home dir may be fine. Consider returning (string, error) here and propagating a more accurate error message (claude config dir), especially now that the source may be CLAUDE_CONFIG_DIR.

Copilot uses AI. Check for mistakes.

// knownMarketplacesFile is the structure of ~/.claude/plugins/known_marketplaces.json
Expand Down
Loading