Skip to content

Commit 82df0ec

Browse files
jerm-droclaude
andcommitted
Address review: harden and simplify credential-helper
- Validate the config id read from _meta.json before joining it into a path (reject non-bare-filename / ".." values), so a corrupted or hand-edited selector cannot make setup write outside configLibrary. - Move shim create/remove inside the _meta.json file lock so concurrent setup/teardown runs cannot interleave (one run's failure-cleanup deleting a shim another run's committed config depends on). - Reuse resolveApplyConfigField for the AnthropicBaseURL/GatewayURL fallback instead of duplicating it. - Warn on a non-string appliedId in _meta.json instead of silently treating it as absent. - Drop the redundant LLMCredentialHelper bool; dispatch everywhere on LLMGatewayMode == "credential-helper" via a shared constant. - Make the managed-profile check unit-testable (injectable root) and cover it, plus warnCredentialHelperTools and the setup-failure cleanup path, with tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3b25f05 commit 82df0ec

5 files changed

Lines changed: 229 additions & 72 deletions

File tree

pkg/client/config.go

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,6 @@ type clientAppConfig struct {
285285
// LLMSettingsRelPath / LLMSettingsPlatformPrefix.
286286
LLMDetectRelPath []string
287287
LLMDetectPlatformPrefix map[Platform][]string
288-
// LLMCredentialHelper marks clients configured via the credential-helper
289-
// document model (Claude Desktop's configLibrary) rather than JSON-key
290-
// patching. When true, ConfigureLLMGateway / RevertLLMGateway dispatch to the
291-
// dedicated credential-helper writer and LLMGatewayKeys are ignored.
292-
LLMCredentialHelper bool
293288
// LLMManagedProfileDomain is the macOS managed-preferences plist domain
294289
// (e.g. "com.anthropic.claudefordesktop.plist") that, when present, overrides
295290
// the client's local config. Setup warns when detected. Empty when the client
@@ -1089,14 +1084,13 @@ var supportedClientIntegrations = []clientAppConfig{
10891084
// settings file), Desktop uses a configLibrary directory: one config
10901085
// document per saved config plus a _meta.json selector naming the active
10911086
// one. That document model does not fit JSON-key patching, so this entry
1092-
// sets LLMCredentialHelper and carries no LLMGatewayKeys — the dedicated
1093-
// credential-helper writer handles it. LLM-gateway-only (MCP not wired).
1094-
// Cast LLMClientApp → ClientApp for internal storage (see LLMClientApp).
1095-
ClientType: ClientApp(ClaudeDesktop),
1096-
Description: "Claude Desktop",
1097-
LLMGatewayOnly: true,
1098-
LLMGatewayMode: "credential-helper",
1099-
LLMCredentialHelper: true,
1087+
// uses LLMGatewayMode "credential-helper" and carries no LLMGatewayKeys —
1088+
// the dedicated credential-helper writer handles it. LLM-gateway-only (MCP
1089+
// not wired). Cast LLMClientApp → ClientApp for internal storage.
1090+
ClientType: ClientApp(ClaudeDesktop),
1091+
Description: "Claude Desktop",
1092+
LLMGatewayOnly: true,
1093+
LLMGatewayMode: "credential-helper",
11001094
// Settings file is the _meta.json selector; the writer derives the
11011095
// containing configLibrary directory from it.
11021096
LLMSettingsFile: "_meta.json",

pkg/client/llm_gateway.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func (cm *ClientManager) ConfigureLLMGateway(clientType ClientApp, cfg llmgatewa
4343

4444
// Credential-helper clients (Claude Desktop) use a document + selector model
4545
// that does not fit JSON-key patching; dispatch to the dedicated writer.
46-
if appCfg.LLMCredentialHelper {
46+
if appCfg.LLMGatewayMode == credentialHelperMode {
4747
return cm.configureCredentialHelper(appCfg, cfg)
4848
}
4949

@@ -175,7 +175,7 @@ func (cm *ClientManager) RevertLLMGateway(clientType ClientApp, configPath strin
175175
}
176176

177177
// Credential-helper clients (Claude Desktop) revert via the dedicated writer.
178-
if appCfg.LLMCredentialHelper {
178+
if appCfg.LLMGatewayMode == credentialHelperMode {
179179
return cm.revertCredentialHelper(appCfg, configPath)
180180
}
181181

pkg/client/llm_gateway_credential_helper.go

Lines changed: 99 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ package client
66
import (
77
"encoding/json"
88
"fmt"
9+
"log/slog"
910
"os"
1011
"path/filepath"
1112
"runtime"
13+
"strings"
1214

1315
"github.com/google/uuid"
1416

@@ -23,6 +25,12 @@ import (
2325
// writer here rather than going through the LLMGatewayKeys machinery.
2426

2527
const (
28+
// credentialHelperMode is the LLMGatewayMode value for clients configured via
29+
// the document + selector credential-helper model (Claude Desktop). It is the
30+
// single source of truth dispatched on in both pkg/client and pkg/llm.
31+
// #nosec G101 -- this is an LLM gateway mode identifier, not a credential.
32+
credentialHelperMode = "credential-helper"
33+
2634
// claudeDesktopManagedEntryName is the stable display name of the config
2735
// entry ToolHive owns in _meta.json. Reusing a fixed name (rather than a
2836
// fresh UUID per run) keeps setup idempotent — repeated "thv llm setup"
@@ -72,26 +80,39 @@ func (cm *ClientManager) configureCredentialHelper(appCfg *clientAppConfig, cfg
7280
return "", fmt.Errorf("creating %s: %w", dir, err)
7381
}
7482

75-
// Track whether the shim already existed so failure cleanup does not delete a
76-
// shim an earlier successful setup still depends on (setup is idempotent).
77-
shimExisted := fileExistsAt(cm.credentialHelperShimPath())
78-
shimPath, err := cm.writeCredentialHelperShim(cfg.TokenHelperCommand)
79-
if err != nil {
80-
return "", err
81-
}
82-
83-
baseURL := cfg.AnthropicBaseURL
84-
if baseURL == "" {
85-
baseURL = cfg.GatewayURL
86-
}
83+
// Reuse the shared AnthropicBaseURL resolution (falls back to GatewayURL) so
84+
// this stays in sync with direct-mode clients if the rule ever changes.
85+
baseURL, _ := resolveApplyConfigField("AnthropicBaseURL", cfg)
8786

87+
// Write the shim, config document, and _meta.json selector all inside the
88+
// lock so concurrent setup/teardown runs cannot interleave — e.g. one run's
89+
// failure-cleanup deleting a shim another run's committed config depends on.
90+
// _meta.json is written last, so a mid-write failure never leaves Claude
91+
// Desktop pointing at our config; best-effort cleanup then removes the
92+
// unreferenced files it created.
8893
var configPath string
89-
err = fileutils.WithFileLock(metaPath, func() error {
90-
meta, err := readClaudeDesktopMeta(metaPath)
94+
err := fileutils.WithFileLock(metaPath, func() error {
95+
// Track whether the shim already existed so failure cleanup does not
96+
// delete a shim an earlier successful setup still depends on.
97+
shimExisted := fileExistsAt(cm.credentialHelperShimPath())
98+
shimPath, err := cm.writeCredentialHelperShim(cfg.TokenHelperCommand)
9199
if err != nil {
92100
return err
93101
}
102+
cleanup := func(cp string) {
103+
if !shimExisted {
104+
_ = os.Remove(shimPath)
105+
}
106+
if cp != "" {
107+
_ = os.Remove(cp)
108+
}
109+
}
94110

111+
meta, err := readClaudeDesktopMeta(metaPath)
112+
if err != nil {
113+
cleanup("")
114+
return err
115+
}
95116
id := metaEntryID(meta, claudeDesktopManagedEntryName)
96117
if id == "" {
97118
id = uuid.NewString()
@@ -114,26 +135,22 @@ func (cm *ClientManager) configureCredentialHelper(appCfg *clientAppConfig, cfg
114135
}
115136
docBytes, err := json.MarshalIndent(doc, "", " ")
116137
if err != nil {
138+
cleanup("")
117139
return fmt.Errorf("encoding Claude Desktop config: %w", err)
118140
}
119-
configPath = filepath.Join(dir, id+".json")
120-
if err := fileutils.AtomicWriteFile(configPath, docBytes, 0o600); err != nil {
121-
return fmt.Errorf("writing %s: %w", configPath, err)
141+
cp := filepath.Join(dir, id+".json")
142+
if err := fileutils.AtomicWriteFile(cp, docBytes, 0o600); err != nil {
143+
cleanup(cp)
144+
return fmt.Errorf("writing %s: %w", cp, err)
122145
}
123-
124-
return writeClaudeDesktopMeta(metaPath, meta)
146+
if err := writeClaudeDesktopMeta(metaPath, meta); err != nil {
147+
cleanup(cp)
148+
return err
149+
}
150+
configPath = cp
151+
return nil
125152
})
126153
if err != nil {
127-
// Best-effort cleanup so a failed setup does not leave partial state.
128-
// _meta.json is written last inside the lock, so on failure Claude Desktop
129-
// is never left pointing at our config — only unreferenced files remain.
130-
// Only remove the shim if we created it this call.
131-
if !shimExisted {
132-
_ = os.Remove(shimPath)
133-
}
134-
if configPath != "" {
135-
_ = os.Remove(configPath)
136-
}
137154
return "", err
138155
}
139156
return configPath, nil
@@ -155,47 +172,49 @@ func (cm *ClientManager) revertCredentialHelper(appCfg *clientAppConfig, configP
155172
if configPath == "" {
156173
return nil
157174
}
175+
dir := filepath.Dir(configPath)
176+
if !fileExistsAt(dir) {
177+
// Config directory already gone — nothing to revert.
178+
return nil
179+
}
158180

159181
id := metaIDFromConfigPath(configPath)
160-
metaPath := filepath.Join(filepath.Dir(configPath), appCfg.LLMSettingsFile)
182+
metaPath := filepath.Join(dir, appCfg.LLMSettingsFile)
183+
shimPath := cm.credentialHelperShimPath()
161184

162-
// Step 1: de-reference our config in _meta.json and delete the config
163-
// document, under the file lock. After this, Claude Desktop no longer points
164-
// at our config regardless of what happens to the shim.
165-
if _, err := os.Stat(metaPath); err == nil {
166-
if lockErr := fileutils.WithFileLock(metaPath, func() error {
185+
// All steps run under the metaPath lock so a concurrent setup/teardown cannot
186+
// interleave. Ordering: de-reference _meta.json, delete the config document,
187+
// then remove the shim last — so a mid-revert failure never leaves Claude
188+
// Desktop pointing at a config whose helper is gone.
189+
return fileutils.WithFileLock(metaPath, func() error {
190+
if fileExistsAt(metaPath) {
167191
meta, err := readClaudeDesktopMeta(metaPath)
168192
if err != nil {
169193
return err
170194
}
171195
meta["entries"] = removeMetaEntry(metaEntries(meta), id)
172196
// Only clear appliedId if it still points at our config; leave a
173197
// user's own active config selection alone.
174-
if applied, _ := meta["appliedId"].(string); applied == id {
198+
applied, ok := meta["appliedId"].(string)
199+
if !ok && meta["appliedId"] != nil {
200+
slog.Warn("Claude Desktop _meta.json has a non-string appliedId; leaving it unchanged",
201+
"path", metaPath)
202+
}
203+
if applied == id {
175204
meta["appliedId"] = ""
176205
}
177206
if err := writeClaudeDesktopMeta(metaPath, meta); err != nil {
178207
return err
179208
}
180-
return removeIfExists(configPath)
181-
}); lockErr != nil {
182-
return lockErr
183209
}
184-
} else if os.IsNotExist(err) {
185-
// Selector already gone; still make sure the config document is removed.
186-
if rmErr := removeIfExists(configPath); rmErr != nil {
187-
return rmErr
210+
if err := removeIfExists(configPath); err != nil {
211+
return err
188212
}
189-
} else {
190-
return fmt.Errorf("checking %s: %w", metaPath, err)
191-
}
192-
193-
// Step 2: remove the shim, now that nothing references it.
194-
shimPath := cm.credentialHelperShimPath()
195-
if err := os.Remove(shimPath); err != nil && !os.IsNotExist(err) {
196-
return fmt.Errorf("removing credential helper shim %s: %w", shimPath, err)
197-
}
198-
return nil
213+
if err := os.Remove(shimPath); err != nil && !os.IsNotExist(err) {
214+
return fmt.Errorf("removing credential helper shim %s: %w", shimPath, err)
215+
}
216+
return nil
217+
})
199218
}
200219

201220
// credentialHelperShimPath is the fixed location of the generated shim.
@@ -243,11 +262,21 @@ func managedProfilePresent(domain string) bool {
243262
if domain == "" || runtime.GOOS != "darwin" {
244263
return false
245264
}
246-
if _, err := os.Stat(filepath.Join("/Library/Managed Preferences", domain)); err == nil {
265+
return managedProfileExistsUnder(managedPreferencesRoot, domain)
266+
}
267+
268+
// managedPreferencesRoot is the macOS managed-preferences directory. A package
269+
// variable (not a const) so tests can point it at a temp directory.
270+
var managedPreferencesRoot = "/Library/Managed Preferences"
271+
272+
// managedProfileExistsUnder reports whether a managed-preferences plist for
273+
// domain exists directly under root or under a per-user subdirectory
274+
// (root/<user>/domain). Platform-independent so it is unit-testable.
275+
func managedProfileExistsUnder(root, domain string) bool {
276+
if _, err := os.Stat(filepath.Join(root, domain)); err == nil {
247277
return true
248278
}
249-
// Per-user managed prefs live under /Library/Managed Preferences/<user>/.
250-
matches, _ := filepath.Glob(filepath.Join("/Library/Managed Preferences", "*", domain))
279+
matches, _ := filepath.Glob(filepath.Join(root, "*", domain))
251280
return len(matches) > 0
252281
}
253282

@@ -295,6 +324,10 @@ func metaEntries(meta map[string]any) []any {
295324
}
296325

297326
// metaEntryID returns the id of the entry with the given name, or "" if absent.
327+
// _meta.json lives in a directory Claude Desktop and users also write to, so the
328+
// stored id is untrusted: an unsafe value is treated as absent so the caller
329+
// mints a fresh, safe UUID rather than joining a path-traversing id into the
330+
// configLibrary path. See isSafeConfigID.
298331
func metaEntryID(meta map[string]any, name string) string {
299332
for _, e := range metaEntries(meta) {
300333
entry, ok := e.(map[string]any)
@@ -303,12 +336,22 @@ func metaEntryID(meta map[string]any, name string) string {
303336
}
304337
if n, _ := entry["name"].(string); n == name {
305338
id, _ := entry["id"].(string)
339+
if !isSafeConfigID(id) {
340+
return ""
341+
}
306342
return id
307343
}
308344
}
309345
return ""
310346
}
311347

348+
// isSafeConfigID reports whether id is a bare filename safe to join into the
349+
// configLibrary path — non-empty, no path separators, and no "..". Guards
350+
// against a corrupted or hand-edited _meta.json entry escaping configLibrary.
351+
func isSafeConfigID(id string) bool {
352+
return id != "" && filepath.Base(id) == id && !strings.Contains(id, "..")
353+
}
354+
312355
// removeMetaEntry returns entries with the entry matching id removed.
313356
func removeMetaEntry(entries []any, id string) []any {
314357
out := make([]any, 0, len(entries))

0 commit comments

Comments
 (0)