Skip to content

Commit 242f699

Browse files
committed
Close config trust-boundary gaps and gate the completion loader
- Trust-gate cache_dir, cache_enabled, llm_model, llm_max_concurrent and llm_token_budget so an untrusted local/repo config can't redirect cache writes or amplify paid-LLM usage; only honored from trusted sources. 'config set' now also warns when these keys are written to an untrusted local config so the value isn't silently ignored on the next load. - Scheme/host-validate llm_endpoint on accept (rejects file://, hostless and malformed forms) and re-validate at the root.go enforcement point so env/ profile-sourced endpoints can't slip a non-http(s) scheme past RequireSecureURL, which only blocks http:// for non-localhost; this prevents leaking llm_api_key in cleartext. - Gate the completion profile loader behind the TrustStore and strip control characters from completion descriptions.
1 parent 3f9352d commit 242f699

7 files changed

Lines changed: 309 additions & 18 deletions

File tree

internal/cli/root.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,36 @@ func NewRootCmd() *cobra.Command {
126126
}
127127
return fmt.Errorf("base_url (%s): %w\nFix with: basecamp config unset base_url", source, err)
128128
}
129+
130+
// Validate the LLM endpoint. The config-file accept path already
131+
// rejects non-http(s)/hostless values, but env- and profile-sourced
132+
// endpoints reach here unchecked, so re-validate the scheme/host
133+
// (RequireSecureURL only blocks http:// for non-localhost — it
134+
// would let file://, ssh:// etc. through). Then enforce HTTPS so an
135+
// http:// non-localhost endpoint can't leak llm_api_key in
136+
// cleartext. Empty endpoint is a no-op. Same config-subcommand skip.
137+
if cfg.LLMEndpoint != "" && !config.IsHTTPURL(cfg.LLMEndpoint) {
138+
if bareRoot {
139+
initBareRootApp(cfg)
140+
return nil
141+
}
142+
source := cfg.Sources["llm_endpoint"]
143+
if source == "" {
144+
source = "unknown"
145+
}
146+
return fmt.Errorf("llm_endpoint (%s): must be an http:// or https:// URL with a host\nFix with: basecamp config unset llm_endpoint", source)
147+
}
148+
if err := hostutil.RequireSecureURL(cfg.LLMEndpoint); err != nil {
149+
if bareRoot {
150+
initBareRootApp(cfg)
151+
return nil
152+
}
153+
source := cfg.Sources["llm_endpoint"]
154+
if source == "" {
155+
source = "unknown"
156+
}
157+
return fmt.Errorf("llm_endpoint (%s): %w\nFix with: basecamp config unset llm_endpoint", source, err)
158+
}
129159
}
130160

131161
// Resolve behavior preferences: explicit flag > config > version.IsDev()

internal/commands/config.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -343,12 +343,13 @@ Valid keys: account_id, project_id (or project), todolist_id, base_url, cache_di
343343
return fmt.Errorf("failed to write config: %w", err)
344344
}
345345

346-
// Warn when writing authority keys to local config without trust
347-
if !global && isAuthorityKey(key) {
346+
// Warn when writing trust-gated keys to local config without trust —
347+
// otherwise the value is silently ignored on the next load.
348+
if !global && isTrustGatedKey(key) {
348349
absPath, _ := filepath.Abs(configPath)
349350
ts := config.LoadTrustStore(config.GlobalConfigDir())
350351
if ts == nil || !ts.IsTrusted(configPath) {
351-
fmt.Fprintf(os.Stderr, "warning: authority key %q in local config requires trust to take effect; run:\n basecamp config trust %s\n", key, config.ShellQuote(absPath))
352+
fmt.Fprintf(os.Stderr, "warning: %q in local config requires trust to take effect; run:\n basecamp config trust %s\n", key, config.ShellQuote(absPath))
352353
}
353354
}
354355

@@ -406,6 +407,21 @@ func isAuthorityKey(key string) bool {
406407
return false
407408
}
408409

410+
// isTrustGatedKey reports whether key is ignored when loaded from an untrusted
411+
// local/repo config. It's the authority keys plus the cache/LLM keys gated for
412+
// the same reason (cache redirection, paid-model substitution, cost
413+
// amplification), so `config set` warns before a local write silently no-ops.
414+
func isTrustGatedKey(key string) bool {
415+
if isAuthorityKey(key) {
416+
return true
417+
}
418+
switch key {
419+
case "cache_dir", "cache_enabled", "llm_model", "llm_max_concurrent", "llm_token_budget":
420+
return true
421+
}
422+
return false
423+
}
424+
409425
// redactSecret returns "(set)" if the value is non-empty, empty string otherwise.
410426
func redactSecret(value string) string {
411427
if value != "" {

internal/commands/config_test.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,12 +337,41 @@ func TestConfigSet_AuthorityKeyWarnsWithPath(t *testing.T) {
337337
stderr := string(buf[:n])
338338
absPath := filepath.Join(tmpDir, ".basecamp", "config.json")
339339

340-
assert.Contains(t, stderr, "authority key")
340+
assert.Contains(t, stderr, `"base_url"`)
341341
assert.Contains(t, stderr, "requires trust")
342342
assert.Contains(t, stderr, absPath, "warning must include the exact config path")
343343
assert.Contains(t, stderr, "'"+absPath+"'", "path must be single-quoted for shell safety")
344344
}
345345

346+
// TestConfigSet_GatedNonAuthorityKeyWarns verifies the trust warning also fires
347+
// for the cache/LLM keys gated on load (cache_dir, llm_model, …), so a local
348+
// `config set` doesn't silently produce a value that's ignored next run.
349+
func TestConfigSet_GatedNonAuthorityKeyWarns(t *testing.T) {
350+
app, _ := setupConfigTestApp(t)
351+
352+
tmpDir, _ := filepath.EvalSymlinks(t.TempDir())
353+
origDir, _ := os.Getwd()
354+
require.NoError(t, os.Chdir(tmpDir))
355+
defer os.Chdir(origDir)
356+
require.NoError(t, os.MkdirAll(".basecamp", 0755))
357+
358+
origStderr := os.Stderr
359+
r, w, _ := os.Pipe()
360+
os.Stderr = w
361+
362+
err := executeConfigCommand(app, "set", "cache_dir", "/tmp/somewhere")
363+
require.NoError(t, err)
364+
365+
w.Close()
366+
var buf [4096]byte
367+
n, _ := r.Read(buf[:])
368+
os.Stderr = origStderr
369+
370+
stderr := string(buf[:n])
371+
assert.Contains(t, stderr, `"cache_dir"`)
372+
assert.Contains(t, stderr, "requires trust")
373+
}
374+
346375
// --- Config project tests ---
347376

348377
// setupConfigProjectTestApp creates a test app wired to an httptest server

internal/completion/cache.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"path/filepath"
1111
"sync"
1212
"time"
13+
14+
"github.com/basecamp/basecamp-cli/internal/config"
1315
)
1416

1517
// CachedProject holds project data for tab completion.
@@ -366,13 +368,21 @@ func loadConfigForCompletion() *configForCompletion {
366368
loadProfilesFromFile(cfg, globalPath)
367369
}
368370

371+
// profiles is an authority key (it can redirect authenticated traffic), so
372+
// repo/local configs must be explicitly trusted before their profiles feed
373+
// completion — mirroring config.loadFromFile's trust gating. System/global
374+
// configs above are trusted by location.
375+
trust := config.LoadTrustStore(config.GlobalConfigDir())
376+
369377
// Repo config (walk up to find .git, then .basecamp/config.json)
370378
if dir, err := os.Getwd(); err == nil {
371379
for {
372380
gitPath := filepath.Join(dir, ".git")
373381
if fi, err := os.Stat(gitPath); err == nil && fi.IsDir() {
374382
repoConfig := filepath.Join(dir, ".basecamp", "config.json")
375-
loadProfilesFromFile(cfg, repoConfig)
383+
if trust != nil && trust.IsTrusted(repoConfig) {
384+
loadProfilesFromFile(cfg, repoConfig)
385+
}
376386
break
377387
}
378388
parent := filepath.Dir(dir)
@@ -385,7 +395,9 @@ func loadConfigForCompletion() *configForCompletion {
385395

386396
// Local config
387397
localPath := filepath.Join(".basecamp", "config.json")
388-
loadProfilesFromFile(cfg, localPath)
398+
if trust != nil && trust.IsTrusted(localPath) {
399+
loadProfilesFromFile(cfg, localPath)
400+
}
389401

390402
return cfg
391403
}

internal/completion/completer.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func (c *Completer) ProjectCompletion() cobra.CompletionFunc {
9191
// Use ID as completion value with name as description
9292
completion := cobra.CompletionWithDesc(
9393
fmt.Sprintf("%d", p.ID),
94-
p.Name,
94+
sanitizeCompletionDesc(p.Name),
9595
)
9696
completions = append(completions, completion)
9797
}
@@ -164,7 +164,7 @@ func (c *Completer) PeopleCompletion() cobra.CompletionFunc {
164164
}
165165
completion := cobra.CompletionWithDesc(
166166
fmt.Sprintf("%d", p.ID),
167-
desc,
167+
sanitizeCompletionDesc(desc),
168168
)
169169
completions = append(completions, completion)
170170
}
@@ -237,7 +237,7 @@ func (c *Completer) AccountCompletion() cobra.CompletionFunc {
237237
strings.HasPrefix(nameLower, toCompleteLower) ||
238238
strings.Contains(nameLower, toCompleteLower) {
239239
// Use ID as completion value with name as description
240-
completions = append(completions, cobra.CompletionWithDesc(idStr, a.Name))
240+
completions = append(completions, cobra.CompletionWithDesc(idStr, sanitizeCompletionDesc(a.Name)))
241241
}
242242
}
243243

@@ -270,14 +270,27 @@ func (c *Completer) ProfileCompletion() cobra.CompletionFunc {
270270
if strings.HasPrefix(nameLower, toCompleteLower) ||
271271
strings.Contains(nameLower, toCompleteLower) {
272272
// Use name as completion value with base URL as description
273-
completions = append(completions, cobra.CompletionWithDesc(p.Name, p.BaseURL))
273+
completions = append(completions, cobra.CompletionWithDesc(p.Name, sanitizeCompletionDesc(p.BaseURL)))
274274
}
275275
}
276276

277277
return completions, cobra.ShellCompDirectiveNoFileComp
278278
}
279279
}
280280

281+
// sanitizeCompletionDesc drops control characters (including ESC) from a
282+
// completion description. Descriptions can carry API- or config-controlled
283+
// strings (project/person/account names, profile base_url) which the shell
284+
// renders to the terminal; stripping control bytes prevents terminal injection.
285+
func sanitizeCompletionDesc(s string) string {
286+
return strings.Map(func(r rune) rune {
287+
if r < 0x20 || r == 0x7f {
288+
return -1
289+
}
290+
return r
291+
}, s)
292+
}
293+
281294
// rankProjects returns projects sorted by priority:
282295
// 1. HQ (purpose="hq")
283296
// 2. Bookmarked

internal/config/config.go

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package config
44
import (
55
"encoding/json"
66
"fmt"
7+
"net/url"
78
"os"
89
"path/filepath"
910
"strings"
@@ -196,12 +197,24 @@ func loadFromFile(cfg *Config, path string, source Source, trust *TrustStore) {
196197
cfg.Sources["scope"] = string(source)
197198
}
198199
if v, ok := fileCfg["cache_dir"].(string); ok && v != "" {
199-
cfg.CacheDir = v
200-
cfg.Sources["cache_dir"] = string(source)
200+
// cache_dir redirects every cache write (completion, resilience, TUI
201+
// workspace, recents, traces). An untrusted local/repo config could
202+
// point it at any user-writable path, so gate it like other authority
203+
// keys. filepath.Clean normalizes the accepted value.
204+
if untrusted {
205+
fmt.Fprintf(os.Stderr, "warning: ignoring cache_dir %q from %s config at %s\n (authority key from local/repo config; run `basecamp config trust %s` to allow)\n", v, source, path, ShellQuote(path))
206+
} else {
207+
cfg.CacheDir = filepath.Clean(v)
208+
cfg.Sources["cache_dir"] = string(source)
209+
}
201210
}
202211
if v, ok := fileCfg["cache_enabled"].(bool); ok {
203-
cfg.CacheEnabled = v
204-
cfg.Sources["cache_enabled"] = string(source)
212+
if untrusted {
213+
fmt.Fprintf(os.Stderr, "warning: ignoring cache_enabled from %s config at %s\n (authority key from local/repo config; run `basecamp config trust %s` to allow)\n", source, path, ShellQuote(path))
214+
} else {
215+
cfg.CacheEnabled = v
216+
cfg.Sources["cache_enabled"] = string(source)
217+
}
205218
}
206219
if v, ok := fileCfg["format"].(string); ok && v != "" {
207220
cfg.Format = v
@@ -237,8 +250,14 @@ func loadFromFile(cfg *Config, path string, source Source, trust *TrustStore) {
237250
}
238251
}
239252
if v, ok := fileCfg["llm_model"].(string); ok && v != "" {
240-
cfg.LLMModel = v
241-
cfg.Sources["llm_model"] = string(source)
253+
// Gate like other LLM authority keys: an untrusted config could
254+
// silently substitute a costlier paid model.
255+
if untrusted {
256+
fmt.Fprintf(os.Stderr, "warning: ignoring llm_model %q from %s config at %s\n (authority key from local/repo config; run `basecamp config trust %s` to allow)\n", v, source, path, ShellQuote(path))
257+
} else {
258+
cfg.LLMModel = v
259+
cfg.Sources["llm_model"] = string(source)
260+
}
242261
}
243262
if v, ok := fileCfg["llm_api_key"].(string); ok && v != "" {
244263
// Secret: only from global/system config, never local/repo
@@ -252,6 +271,11 @@ func loadFromFile(cfg *Config, path string, source Source, trust *TrustStore) {
252271
if v, ok := fileCfg["llm_endpoint"].(string); ok && v != "" {
253272
if untrusted {
254273
fmt.Fprintf(os.Stderr, "warning: ignoring llm_endpoint %q from %s config at %s\n (authority key from local/repo config; run `basecamp config trust %s` to allow)\n", v, source, path, ShellQuote(path))
274+
} else if !IsHTTPURL(v) {
275+
// Reject non-http(s) schemes (file://, etc.) and hostless/malformed
276+
// forms. https enforcement for non-localhost endpoints happens later
277+
// in root.go via RequireSecureURL.
278+
fmt.Fprintf(os.Stderr, "warning: ignoring llm_endpoint %q from %s config at %s (must be an http:// or https:// URL with a host)\n", v, source, path)
255279
} else {
256280
cfg.LLMEndpoint = v
257281
cfg.Sources["llm_endpoint"] = string(source)
@@ -260,7 +284,11 @@ func loadFromFile(cfg *Config, path string, source Source, trust *TrustStore) {
260284
if v, ok := fileCfg["llm_max_concurrent"]; ok {
261285
if fv, ok := v.(float64); ok {
262286
iv := int(fv)
263-
if iv >= 1 && iv <= 10 && fv == float64(iv) {
287+
// Gate like other LLM authority keys: block a malicious repo from
288+
// inflating paid-LLM concurrency (cost amplification).
289+
if untrusted {
290+
fmt.Fprintf(os.Stderr, "warning: ignoring llm_max_concurrent from %s config at %s\n (authority key from local/repo config; run `basecamp config trust %s` to allow)\n", source, path, ShellQuote(path))
291+
} else if iv >= 1 && iv <= 10 && fv == float64(iv) {
264292
cfg.LLMMaxConcurrent = iv
265293
cfg.Sources["llm_max_concurrent"] = string(source)
266294
}
@@ -269,7 +297,10 @@ func loadFromFile(cfg *Config, path string, source Source, trust *TrustStore) {
269297
if v, ok := fileCfg["llm_token_budget"]; ok {
270298
if fv, ok := v.(float64); ok {
271299
iv := int(fv)
272-
if iv >= 100 && iv <= 100000 && fv == float64(iv) {
300+
// Gate like other LLM authority keys (cost amplification).
301+
if untrusted {
302+
fmt.Fprintf(os.Stderr, "warning: ignoring llm_token_budget from %s config at %s\n (authority key from local/repo config; run `basecamp config trust %s` to allow)\n", source, path, ShellQuote(path))
303+
} else if iv >= 100 && iv <= 100000 && fv == float64(iv) {
273304
cfg.LLMTokenBudget = iv
274305
cfg.Sources["llm_token_budget"] = string(source)
275306
}
@@ -660,6 +691,19 @@ func NormalizeBaseURL(url string) string {
660691
return strings.TrimSuffix(url, "/")
661692
}
662693

694+
// IsHTTPURL reports whether rawURL is an absolute http(s) URL with a host.
695+
// Used to reject non-web schemes (file://, etc.) and hostless forms
696+
// (e.g. "https:example.com", which url.Parse accepts with an empty Host)
697+
// for llm_endpoint, since those would later be concatenated into a request
698+
// URL and produce confusing/invalid requests.
699+
func IsHTTPURL(rawURL string) bool {
700+
u, err := url.Parse(rawURL)
701+
if err != nil {
702+
return false
703+
}
704+
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
705+
}
706+
663707
// ShellQuote returns a POSIX single-quoted string safe for copy-paste into
664708
// a shell. Single quotes inside the value are escaped as '\” (end quote,
665709
// escaped literal quote, resume quote).

0 commit comments

Comments
 (0)