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
cmd/entire/cli/agent/claudecode
Mclaude_test.go+63/-32
Mgenerate.go+73/-27
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.