Skip to content
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
6 changes: 6 additions & 0 deletions pkg/claude/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *Ru
args := BuildArgs(prompt, opts)

cmd := execCommand(ctx, c.BinPath, args...)
if opts.WorkingDirectory != "" {
cmd.Dir = opts.WorkingDirectory
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
Expand Down Expand Up @@ -165,6 +168,9 @@ func (c *ClaudeClient) RunFromStdinCtx(ctx context.Context, stdin io.Reader, pro
args := BuildArgs(prompt, opts)

cmd := execCommand(ctx, c.BinPath, args...)
if opts.WorkingDirectory != "" {
cmd.Dir = opts.WorkingDirectory
}
cmd.Stdin = stdin
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
Expand Down
246 changes: 246 additions & 0 deletions pkg/claude/claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ func TestHelperProcess(t *testing.T) {
}
defer os.Exit(0)

if os.Getenv("GO_HELPER_PRINT_PWD") == "1" {
wd, err := os.Getwd()
if err != nil {
os.Stderr.WriteString(err.Error())
os.Exit(1)
}
os.Stdout.Write([]byte(wd))
return
}

if os.Getenv("GO_HELPER_STREAM_PWD") == "1" {
wd, err := os.Getwd()
if err != nil {
os.Stderr.WriteString(err.Error())
os.Exit(1)
}
os.Stdout.Write([]byte(`{"type":"result","subtype":"success","total_cost_usd":0.0,"duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"result":"` + wd + `","session_id":"test-session"}` + "\n"))
return
}

output := os.Getenv("GO_HELPER_OUTPUT")
exitCode := int(os.Getenv("GO_HELPER_EXIT_CODE")[0] - '0')

Expand Down Expand Up @@ -107,6 +127,184 @@ func TestRunPrompt(t *testing.T) {
}
}

func TestRunPrompt_WorkingDirectory(t *testing.T) {
originalExecCommand := execCommand
defer func() {
execCommand = originalExecCommand
}()

wantDir := t.TempDir()
execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--", name}
cs = append(cs, arg...)
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = []string{
"GO_WANT_HELPER_PROCESS=1",
"GO_HELPER_PRINT_PWD=1",
}
return cmd
}

client := &ClaudeClient{BinPath: "claude"}
result, err := client.RunPrompt("Hello, Claude", &RunOptions{
Format: TextOutput,
WorkingDirectory: wantDir,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
gotDir, err := filepath.EvalSymlinks(strings.TrimSpace(result.Result))
if err != nil {
t.Fatalf("EvalSymlinks(result) error = %v", err)
}
resolvedWantDir, err := filepath.EvalSymlinks(wantDir)
if err != nil {
t.Fatalf("EvalSymlinks(wantDir) error = %v", err)
}
if gotDir != resolvedWantDir {
t.Fatalf("expected cwd %q, got %q", resolvedWantDir, gotDir)
}
}

func TestRunFromStdin_WorkingDirectory(t *testing.T) {
originalExecCommand := execCommand
defer func() {
execCommand = originalExecCommand
}()

wantDir := t.TempDir()
execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--", name}
cs = append(cs, arg...)
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = []string{
"GO_WANT_HELPER_PROCESS=1",
"GO_HELPER_PRINT_PWD=1",
}
return cmd
}

client := &ClaudeClient{BinPath: "claude"}
result, err := client.RunFromStdin(strings.NewReader("stdin input"), "Hello, Claude", &RunOptions{
Format: TextOutput,
WorkingDirectory: wantDir,
})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
gotDir, err := filepath.EvalSymlinks(strings.TrimSpace(result.Result))
if err != nil {
t.Fatalf("EvalSymlinks(result) error = %v", err)
}
resolvedWantDir, err := filepath.EvalSymlinks(wantDir)
if err != nil {
t.Fatalf("EvalSymlinks(wantDir) error = %v", err)
}
if gotDir != resolvedWantDir {
t.Fatalf("expected cwd %q, got %q", resolvedWantDir, gotDir)
}
}

// TestWorkingDirectory_EmptyInheritsParentCwd verifies that when WorkingDirectory
// is empty, the Claude subprocess inherits the parent process's cwd across all
// three entry points. Guards against accidentally always setting cmd.Dir.
func TestWorkingDirectory_EmptyInheritsParentCwd(t *testing.T) {
originalExecCommand := execCommand
defer func() {
execCommand = originalExecCommand
}()

parentCwd, err := os.Getwd()
if err != nil {
t.Fatalf("os.Getwd() error = %v", err)
}
resolvedParent, err := filepath.EvalSymlinks(parentCwd)
if err != nil {
t.Fatalf("EvalSymlinks(parent) error = %v", err)
}

assertInheritedCwd := func(t *testing.T, got string) {
t.Helper()
resolved, err := filepath.EvalSymlinks(strings.TrimSpace(got))
if err != nil {
t.Fatalf("EvalSymlinks(result) error = %v", err)
}
if resolved != resolvedParent {
t.Fatalf("expected child to inherit parent cwd %q, got %q", resolvedParent, resolved)
}
}

t.Run("RunPrompt", func(t *testing.T) {
execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--", name}
cs = append(cs, arg...)
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = []string{
"GO_WANT_HELPER_PROCESS=1",
"GO_HELPER_PRINT_PWD=1",
}
return cmd
}

client := &ClaudeClient{BinPath: "claude"}
result, err := client.RunPrompt("Hello, Claude", &RunOptions{Format: TextOutput})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
assertInheritedCwd(t, result.Result)
})

t.Run("RunFromStdin", func(t *testing.T) {
execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--", name}
cs = append(cs, arg...)
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = []string{
"GO_WANT_HELPER_PROCESS=1",
"GO_HELPER_PRINT_PWD=1",
}
return cmd
}

client := &ClaudeClient{BinPath: "claude"}
result, err := client.RunFromStdin(strings.NewReader("in"), "Hello, Claude", &RunOptions{Format: TextOutput})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
assertInheritedCwd(t, result.Result)
})

t.Run("StreamPrompt", func(t *testing.T) {
execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--", name}
cs = append(cs, arg...)
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = []string{
"GO_WANT_HELPER_PROCESS=1",
"GO_HELPER_STREAM_PWD=1",
}
return cmd
}

client := &ClaudeClient{BinPath: "claude"}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

messageCh, errCh := client.StreamPrompt(ctx, "Test", &RunOptions{})

var got Message
for msg := range messageCh {
got = msg
}
for err := range errCh {
if err != nil {
t.Fatalf("Streaming error: %v", err)
}
}
assertInheritedCwd(t, got.Result)
})
}

func TestStreamPrompt(t *testing.T) {
// For streaming test, we'll create a simple mock that sends predefined messages
originalExecCommand := execCommand
Expand Down Expand Up @@ -1144,6 +1342,54 @@ func TestRunPromptCtx_ContextDeadlineExceeded(t *testing.T) {
})
}

func TestStreamPrompt_WorkingDirectory(t *testing.T) {
originalExecCommand := execCommand
defer func() {
execCommand = originalExecCommand
}()

wantDir := t.TempDir()
execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--", name}
cs = append(cs, arg...)
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = []string{
"GO_WANT_HELPER_PROCESS=1",
"GO_HELPER_STREAM_PWD=1",
}
return cmd
}

client := &ClaudeClient{BinPath: "claude"}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

messageCh, errCh := client.StreamPrompt(ctx, "Test streaming", &RunOptions{
WorkingDirectory: wantDir,
})

var got Message
for msg := range messageCh {
got = msg
}
for err := range errCh {
if err != nil {
t.Fatalf("Streaming error: %v", err)
}
}
gotDir, err := filepath.EvalSymlinks(got.Result)
if err != nil {
t.Fatalf("EvalSymlinks(result) error = %v", err)
}
resolvedWantDir, err := filepath.EvalSymlinks(wantDir)
if err != nil {
t.Fatalf("EvalSymlinks(wantDir) error = %v", err)
}
if gotDir != resolvedWantDir {
t.Fatalf("expected cwd %q, got %q", resolvedWantDir, gotDir)
}
}

func TestStreamPrompt_ContextCancellation(t *testing.T) {
originalExecCommand := execCommand
defer func() {
Expand Down
8 changes: 7 additions & 1 deletion pkg/claude/dangerous/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import (
"github.com/lancekrogers/claude-code-go/pkg/claude"
)

// execCommand is overridable in tests to mock subprocess execution.
var execCommand = exec.CommandContext

// DangerousClient provides access to unsafe Claude Code operations
// WARNING: This client can bypass critical security controls
// REQUIREMENT: Must set CLAUDE_ENABLE_DANGEROUS="i-accept-all-risks"
Expand Down Expand Up @@ -193,7 +196,10 @@ func (c *DangerousClient) runWithDangerousFlags(ctx context.Context, prompt stri
}

// Create command with context support
cmd := exec.CommandContext(ctx, c.ClaudeClient.BinPath, args...)
cmd := execCommand(ctx, c.ClaudeClient.BinPath, args...)
if opts != nil && opts.WorkingDirectory != "" {
cmd.Dir = opts.WorkingDirectory
}

// Set custom environment if requested
if useCustomEnv && len(c.envVars) > 0 {
Expand Down
67 changes: 67 additions & 0 deletions pkg/claude/dangerous/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,32 @@ package dangerous
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/lancekrogers/claude-code-go/pkg/claude"
)

// TestHelperProcess isn't a real test - it's used to mock exec.Command in this package.
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
defer os.Exit(0)

if os.Getenv("GO_HELPER_PRINT_PWD") == "1" {
wd, err := os.Getwd()
if err != nil {
os.Stderr.WriteString(err.Error())
os.Exit(1)
}
os.Stdout.Write([]byte(`{"type":"result","subtype":"success","total_cost_usd":0.0,"duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"result":"` + wd + `","session_id":"test-session"}` + "\n"))
return
}
}

func TestNewDangerousClient_RequiresEnvironmentVariable(t *testing.T) {
// Clear environment
originalEnv := os.Getenv("CLAUDE_ENABLE_DANGEROUS")
Expand Down Expand Up @@ -521,3 +541,50 @@ func TestDangerousClient_ResetClearsAll(t *testing.T) {
t.Errorf("Expected 0 warnings after reset, got %d", len(client.GetSecurityWarnings()))
}
}

func TestBYPASS_ALL_PERMISSIONS_CTX_WorkingDirectory(t *testing.T) {
originalDangerous := os.Getenv("CLAUDE_ENABLE_DANGEROUS")
defer os.Setenv("CLAUDE_ENABLE_DANGEROUS", originalDangerous)
os.Setenv("CLAUDE_ENABLE_DANGEROUS", "i-accept-all-risks")

originalExecCommand := execCommand
defer func() {
execCommand = originalExecCommand
}()

wantDir := t.TempDir()
execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--", name}
cs = append(cs, arg...)
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = []string{
"GO_WANT_HELPER_PROCESS=1",
"GO_HELPER_PRINT_PWD=1",
}
return cmd
}

client, err := NewDangerousClient("claude")
if err != nil {
t.Fatalf("NewDangerousClient() error = %v", err)
}

result, err := client.BYPASS_ALL_PERMISSIONS_CTX(context.Background(), "Hello", &claude.RunOptions{
Format: claude.JSONOutput,
WorkingDirectory: wantDir,
})
if err != nil {
t.Fatalf("BYPASS_ALL_PERMISSIONS_CTX() error = %v", err)
}
gotDir, err := filepath.EvalSymlinks(strings.TrimSpace(result.Result))
if err != nil {
t.Fatalf("EvalSymlinks(result) error = %v", err)
}
resolvedWantDir, err := filepath.EvalSymlinks(wantDir)
if err != nil {
t.Fatalf("EvalSymlinks(wantDir) error = %v", err)
}
if gotDir != resolvedWantDir {
t.Fatalf("expected cwd %q, got %q", resolvedWantDir, gotDir)
}
}
Loading
Loading