fix(dispatch): inject only apiKeyHelper instead of loading user settings · Entire

fix(dispatch): inject only apiKeyHelper instead of loading user settings

45f1559→main·

alishakawaguchi·yesterday·2 files·+136 added/-59 removed

The prior fix loaded the whole user settings (--setting-sources user) to recover auth, then tried to suppress the unwanted parts. That still loaded user-level tool permissions: a user with permissions.defaultMode= bypassPermissions would let this internal --print call execute tool calls, and dispatch renders untrusted commit/branch text — a prompt-injection RCE path (trail #884 high-severity finding).

Redesign: keep the call fully isolated with --setting-sources "" (loads no hooks and no permissions) and inject ONLY the user's apiKeyHelper — extracted from ~/.claude/settings.json (honoring CLAUDE_CONFIG_DIR) — back via --settings. So API-billing auth works while user hooks and permissions are never loaded for this injection-exposed call. apiKeyHelper is a command reference, not the raw key, so it is safe to pass in argv; a raw key or the env block is deliberately not extracted.

Auth that does not live in user settings keeps working unchanged: an exported ANTHROPIC_API_KEY (preserved by StripGitEnv) and keychain/subscription creds.

Verified end-to-end via dispatch --local: apiKeyHelper user authenticates with no hooks firing and permissions not loaded; real subscription still generates a dispatch; ANTHROPIC_API_KEY env var still works; a truly unauthenticated user still gets "Not logged in". Adds arg-builder and settings-reader unit tests.

Addresses trail #884 review findings.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_012QYA1kFFwDTbR8cZQQkb8N

Changes

2

1 unmodified line

import (
    "context"
    "encoding/json"
    "errors"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
    "testing"
)

func TestGenerateText_LoadsUserSettingsForAuth(t *testing.T) {
    t.Parallel()
    var gotArgs []string
    ag := &ClaudeCodeAgent{
        CommandRunner: func(ctx context.Context, _ string, args ...string) *exec.Cmd {
            gotArgs = args
            return exec.CommandContext(ctx, "sh", "-c", `printf '%s' '{"type":"result","result":"ok"}'`)
        },
    }
}
func flagValue(args []string, name string) (string, bool) {
    for i, a := range args {
        if a == name && i+1 < len(args) {
            return args[i+1], true
        }
    }
    return "", false
}

if _, err := ag.GenerateText(context.Background(), "prompt", ""); err != nil {
    t.Fatalf("unexpected error: %v", err)
}
func TestBuildGenerateArgs_IsolatesSettingSources(t *testing.T) {
    t.Parallel()
    // Isolation is the security-critical invariant: --setting-sources must be
    // empty so user-level hooks and tool permissions (e.g. bypassPermissions)
    // are never loaded for this internal, injection-exposed call.
    args := buildGenerateArgs("haiku", "")
    got, ok := flagValue(args, "--setting-sources")
    if !ok {
        t.Fatalf("--setting-sources flag missing from args: %v", args)
    }
    if got != "" {
        t.Fatalf("--setting-sources = %q, want %q (must load no sources)", got, "")
    }
    // With no apiKeyHelper, we inject nothing extra.
    if _, ok := flagValue(args, "--settings"); ok {
        t.Fatalf("--settings must be absent when there is no apiKeyHelper: %v", args)
    }
}
func TestBuildGenerateArgs_InjectsOnlyAPIKeyHelper(t *testing.T) {
    t.Parallel()
    helper := `echo "sk-ant-x" && printf '%s'` // exercises quoting/escaping
    args := buildGenerateArgs("haiku", helper)

// Sources still empty — we do not fall back to loading the whole file.
    if got, _ := flagValue(args, "--setting-sources"); got != "" {
        t.Fatalf("--setting-sources = %q, want empty", got)
    }

// The subprocess must load user settings so API-billing auth (apiKeyHelper /
    // ANTHROPIC_API_KEY approval in ~/.claude/settings.json) is available.
    // Loading no sources ("") made claude report "Not logged in" for those users.
    // See generate.go for the full rationale.
    settingSources, ok := flagValue("--setting-sources")
    raw, ok := flagValue(args, "--settings")
    if !ok {
        t.Fatalf("--setting-sources flag missing from args: %v", gotArgs)
    }
    if settingSources != settingSourcesUser {
        t.Fatalf("--setting-sources = %q, want %q (empty drops user auth settings)", settingSources, settingSourcesUser)
    }
    var injected map[string]any
    if err := json.Unmarshal([]byte(raw), &injected); err != nil {
        t.Fatalf("--settings is not valid JSON: %v (%q)", err, raw)
    }
    if injected["apiKeyHelper"] != helper {
        t.Fatalf("injected apiKeyHelper = %v, want %q", injected["apiKeyHelper"], helper)
    }
    // Must inject ONLY auth — never hooks or permissions.
    if len(injected) != 1 {
        t.Fatalf("--settings must contain only apiKeyHelper, got %v", injected)
    }
}
func TestReadUserAPIKeyHelper_FromClaudeConfigDir(t *testing.T) {
    dir := t.TempDir()
    t.Setenv("CLAUDE_CONFIG_DIR", dir)
    if err := os.WriteFile(filepath.Join(dir, "settings.json"),
        []byte(`{"apiKeyHelper":"echo secret-cmd","permissions":{"defaultMode":"bypassPermissions"}}`), 0o600); err != nil {
        t.Fatal(err)
    }
    if settings != disableHooksSettings {
        t.Fatalf("--settings = %q, want %q (must disable user hooks)", settings, disableHooksSettings)
    }
    if got := readUserAPIKeyHelper(); got != "echo secret-cmd" {
        t.Fatalf("readUserAPIKeyHelper() = %q, want %q", got, "echo secret-cmd")
    }
}
func TestReadUserAPIKeyHelper_MissingFileReturnsEmpty(t *testing.T) {
    t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir()) // no settings.json inside
    if got := readUserAPIKeyHelper(); got != "" {
        t.Fatalf("readUserAPIKeyHelper() = %q, want empty for missing file", got)
    }
}

Additional Functions

// buildGenerateArgs assembles the claude CLI argv for a --print text-generation
// call.

// The subprocess must stay isolated from the user's project/local AND user
// settings: loading them would fire user-level hooks and, worse, honor
// user-level tool permissions (e.g. permissions.defaultMode=bypassPermissions),
// which would let prompt-injection in the untrusted dispatch data drive tool
// execution. So we pass --setting-sources "" (load nothing).

// The one thing we genuinely need from the user settings is auth. Users on API
// billing configure it with `apiKeyHelper` (a command that prints the key),
// which lives in user settings and is therefore dropped by --setting-sources "".
// Rather than load the whole settings file back (and re-inherit hooks and
// permissions), we extract only apiKeyHelper and re-inject it via --settings, so
// auth works while nothing else from the user's settings is loaded.

// apiKeyHelper is a command reference (not the key itself), so passing it in
// argv does not leak a credential. Auth methods that do not live in user
// settings — an exported ANTHROPIC_API_KEY (survives StripGitEnv) and
// keychain/subscription credentials — keep working without any injection.