fix(dispatch): pass injected apiKeyHelper via 0600 file, not argv · Entire
fix(dispatch): pass injected apiKeyHelper via 0600 file, not argv
25a7379→main·
alishakawaguchi·yesterday·2 files·+125 added/-31 removed
The apiKeyHelper injected for auth was passed as an inline --settings JSON string, which lands in the process argv (visible via ps, /proc/
Write the minimal {"apiKeyHelper": ...} settings to a 0600 temp file (os.CreateTemp) and pass it as --settings
Verified end-to-end: during a real dispatch --local the spawned claude argv shows only "--settings
Addresses trail #884 review finding.
Changes
2
cmd/entire/cli/agent/claudecode
Mclaude_test.go+68/-17
Mgenerate.go+57/-14
80 unmodified lines
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
80 unmodified lines
if got != "" {
t.Fatalf("--setting-sources = %q, want %q (must load no sources)", got, "")
}
// With no apiKeyHelper, we inject nothing extra.
// With no settings path, we inject nothing extra.
if _, ok := flagValue(args, "--settings"); ok {
t.Fatalf("--settings must be absent when there is no apiKeyHelper: %v", args)
t.Fatalf("--settings must be absent when there is no settings path: %v", args)
}
}
func TestBuildGenerateArgs_InjectsOnlyAPIKeyHelper(t *testing.T) {
func TestBuildGenerateArgs_PassesSettingsAsPath(t *testing.T) {
t.Parallel()
helper := `echo "sk-ant-x" && printf '%s'` // exercises quoting/escaping
args := buildGenerateArgs("haiku", helper)
// The injected settings must be passed as a file path, not inline JSON, so a
// key-bearing apiKeyHelper never lands in argv (ps / /proc/<pid>/cmdline).
path := "/tmp/entire-claude-auth-123.json"
args := buildGenerateArgs("haiku", path)
// 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)
}
raw, ok := flagValue(args, "--settings")
got, ok := flagValue(args, "--settings")
if !ok {
t.Fatalf("--settings flag missing; apiKeyHelper was not injected: %v", args)
t.Fatalf("--settings flag missing: %v", args)
}
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 got != path {
t.Fatalf("--settings = %q, want the file path %q", got, path)
}
if injected["apiKeyHelper"] != helper {
t.Fatalf("injected apiKeyHelper = %v, want %q", injected["apiKeyHelper"], helper)
}
// Guard against regressing to inline JSON in argv.
if strings.Contains(got, "{") {
t.Fatalf("--settings must be a path, not inline JSON: %q", got)
}
// Must inject ONLY auth — never hooks or permissions.
if len(injected) != 1 {
t.Fatalf("--settings must contain only apiKeyHelper, got %v", injected)
}
}
func TestWriteAuthSettingsFile_WritesOnlyAPIKeyHelper0600(t *testing.T) {
t.Parallel()
helper := `echo "sk-ant-secret"` // could embed a literal key
path, cleanup, err := writeAuthSettingsFile(helper)
if err != nil {
t.Fatalf("writeAuthSettingsFile: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup func is nil")
}
defer cleanup()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat settings file: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("settings file perm = %o, want 0600", perm)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read settings file: %v", err)
}
var settings map[string]any
if err := json.Unmarshal(data, &settings); err != nil {
t.Fatalf("settings file is not valid JSON: %v (%s)", err, data)
}
if settings["apiKeyHelper"] != helper {
t.Fatalf("apiKeyHelper = %v, want %q", settings["apiKeyHelper"], helper)
}
if len(settings) != 1 {
t.Fatalf("settings file must contain only apiKeyHelper, got %v", settings)
}
cleanup()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("cleanup did not remove settings file (stat err=%v)", err)
}
}
func TestWriteAuthSettingsFile_EmptyHelperNoFile(t *testing.T) {
t.Parallel()
path, cleanup, err := writeAuthSettingsFile("")
if err != nil {
t.Fatalf("writeAuthSettingsFile(\""): %v", err)
}
if path != "" {
t.Fatalf("path = %q, want empty for no apiKeyHelper", path)
}
if cleanup != nil {
t.Fatal("cleanup should be nil when no file is written")
}
}
Mcmd/entire/cli/agent/claudecode/claude_test.go+68/-17
26 unmodified lines
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
53 unmodified lines
140
141
142
110
111
112
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
26 unmodified lines
// 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.
// permissions), we extract only apiKeyHelper and re-inject it via a --settings
// file (settingsPath), 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.
func buildGenerateArgs(model, apiKeyHelper string) []string {
// The injected settings are passed as a file path, not an inline JSON string:
// apiKeyHelper can embed a literal key, and an inline value would land in the
// process argv (visible via ps / /proc/<pid>/cmdline / EDR tooling). The file is
// written 0600 (see writeAuthSettingsFile), matching settings.json's protection.
//
// 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 (settingsPath == "").
func buildGenerateArgs(model, settingsPath string) []string {
args := []string{
"--print", "--output-format", "json",
"--model", model,
"--setting-sources", "",
}
if strings.TrimSpace(apiKeyHelper) != "" {
if injected, err := json.Marshal(map[string]string{"apiKeyHelper": apiKeyHelper}); err == nil {
args = append(args, "--settings", string(injected))
}
if settingsPath != "" {
args = append(args, "--settings", settingsPath)
}
return args
}
// writeAuthSettingsFile writes a minimal claude settings file containing only
// the given apiKeyHelper and returns its path plus a cleanup func. The file is
// created 0600 so the (possibly key-bearing) helper is no more exposed than the
// user's own settings.json. Returns ("", nil, nil) when apiKeyHelper is empty.
func writeAuthSettingsFile(apiKeyHelper string) (string, func(), error) {
if strings.TrimSpace(apiKeyHelper) == "" {
return "", nil, nil
}
data, err := json.Marshal(map[string]string{"apiKeyHelper": apiKeyHelper})
if err != nil {
return "", nil, fmt.Errorf("marshal auth settings: %w", err)
}
f, err := os.CreateTemp("", "entire-claude-auth-*.json") // 0600 by default
if err != nil {
return "", nil, fmt.Errorf("create auth settings file: %w", err)
}
path := f.Name()
cleanup := func() { _ = os.Remove(path) }
if _, err := f.Write(data); err != nil {
_ = f.Close()
cleanup()
return "", nil, fmt.Errorf("write auth settings file: %w", err)
}
if err := f.Close(); err != nil {
cleanup()
return "", nil, fmt.Errorf("close auth settings file: %w", err)
}
return path, cleanup, nil
}
// userClaudeSettingsPath resolves the user's claude settings.json the same way
// the claude CLI does: $CLAUDE_CONFIG_DIR/settings.json when set, otherwise
// ~/.claude/settings.json.
53 unmodified lines
}
// Run isolated from all setting sources (see buildGenerateArgs), re-injecting
// only the user's apiKeyHelper so API-billing auth keeps working without
// re-inheriting user hooks or tool permissions.
cmd := commandRunner(ctx, claudePath, buildGenerateArgs(model, readUserAPIKeyHelper())...)
// only the user's apiKeyHelper (via a 0600 file, never argv) so API-billing
// auth keeps working without re-inheriting user hooks or tool permissions.
// Best-effort: if extracting/writing the helper fails, fall back to running
// without it (env/keychain auth still work) rather than failing the call.
settingsPath, cleanup, err := writeAuthSettingsFile(readUserAPIKeyHelper())
if err != nil {
settingsPath = ""
}
if cleanup != nil {
defer cleanup()
}
cmd := commandRunner(ctx, claudePath, buildGenerateArgs(model, settingsPath)...)
// Isolate from the user's git repo to prevent recursive hook triggers
// and index pollution (matches agent.RunIsolatedTextGeneratorCLI behavior).