Skip to content

Commit 97fd65d

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 e97f2a9 commit 97fd65d

9 files changed

Lines changed: 475 additions & 29 deletions

File tree

internal/cli/root.go

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,13 @@ func NewRootCmd() *cobra.Command {
9898
return err
9999
}
100100
// Re-apply env and flag overrides (they take precedence over profile values)
101-
config.LoadFromEnv(cfg)
101+
if err := config.LoadFromEnv(cfg); err != nil {
102+
if bareRoot {
103+
initBareRootApp(cfg)
104+
return nil
105+
}
106+
return err
107+
}
102108
config.ApplyOverrides(cfg, config.FlagOverrides{
103109
Account: flags.Account,
104110
Project: flags.Project,
@@ -126,6 +132,24 @@ func NewRootCmd() *cobra.Command {
126132
}
127133
return fmt.Errorf("base_url (%s): %w\nFix with: basecamp config unset base_url", source, err)
128134
}
135+
136+
// Validate the LLM endpoint. The config-file accept path already
137+
// rejects non-http(s)/hostless values, but env- and profile-sourced
138+
// endpoints reach here unchecked, so re-validate the scheme/host
139+
// and enforce HTTPS (credential-gated) so an http:// non-localhost
140+
// endpoint can't leak llm_api_key in cleartext. Empty endpoint is a
141+
// no-op. Same config-subcommand skip.
142+
if err := validateLLMEndpoint(cfg.LLMEndpoint, cfg.LLMProvider, cfg.LLMAPIKey); err != nil {
143+
if bareRoot {
144+
initBareRootApp(cfg)
145+
return nil
146+
}
147+
source := cfg.Sources["llm_endpoint"]
148+
if source == "" {
149+
source = "unknown"
150+
}
151+
return fmt.Errorf("llm_endpoint (%s): %w\nFix with: basecamp config unset llm_endpoint", source, err)
152+
}
129153
}
130154

131155
// Resolve behavior preferences: explicit flag > config > version.IsDev()
@@ -528,6 +552,37 @@ func promptForProfile(cfg *config.Config) (string, error) {
528552
return selected.ID, nil
529553
}
530554

555+
// validateLLMEndpoint rejects non-http(s)/hostless endpoints always, and requires
556+
// HTTPS for a non-localhost endpoint only when a credential could be transmitted.
557+
//
558+
// RequireSecureURL only blocks http:// for non-localhost — it would let file://,
559+
// ssh:// etc. through — so the IsHTTPURL scheme/host check stays unconditional.
560+
//
561+
// The HTTPS/credential gate is skipped for credential-less providers ("ollama",
562+
// "apple", "none", "disabled"): they never transmit llm_api_key, so a remote http:// endpoint
563+
// (e.g. a LAN Ollama) is allowed even when a global key exists for a different
564+
// provider. For credentialed/ambiguous providers ("openai", "anthropic", "auto",
565+
// or "") the gate applies — an http:// non-localhost endpoint is rejected when an
566+
// llm_api_key is present, since the secret would otherwise leak in cleartext.
567+
// Without a key there is no secret to leak. An empty endpoint is a no-op.
568+
func validateLLMEndpoint(endpoint, provider, apiKey string) error {
569+
if endpoint == "" {
570+
return nil
571+
}
572+
if !config.IsHTTPURL(endpoint) {
573+
return fmt.Errorf("must be an http:// or https:// URL with a host")
574+
}
575+
switch provider {
576+
case "ollama", "apple", "none", "disabled":
577+
// Credential-less providers never send the key; skip the HTTPS gate.
578+
return nil
579+
}
580+
if apiKey != "" {
581+
return hostutil.RequireSecureURL(endpoint)
582+
}
583+
return nil
584+
}
585+
531586
// isConfigCmd returns true if cmd is "config" or any of its subcommands.
532587
// Used to skip HTTPS enforcement so users can repair a bad base_url.
533588
func isConfigCmd(cmd *cobra.Command) bool {

internal/cli/root_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,51 @@ import (
1414
"github.com/basecamp/basecamp-cli/internal/version"
1515
)
1616

17+
// TestLLMEndpointValidation exercises the production validateLLMEndpoint helper
18+
// in root.go: the scheme/host check is unconditional, while the HTTPS gate is
19+
// only enforced for credentialed/ambiguous providers when a key is present.
20+
// Credential-less providers (ollama, apple, none) never send the key, so a
21+
// remote http endpoint is allowed even when a key exists for a different provider.
22+
func TestLLMEndpointValidation(t *testing.T) {
23+
tests := []struct {
24+
name string
25+
endpoint string
26+
provider string
27+
apiKey string
28+
wantOK bool
29+
}{
30+
{"file scheme rejected", "file:///etc/passwd", "", "", false},
31+
{"ssh scheme rejected", "ssh://host", "", "", false},
32+
{"hostless https rejected", "https:example.com", "", "", false},
33+
{"http remote with credential rejected", "http://remote-host:1234", "", "secret", false},
34+
{"http remote without credential accepted", "http://remote-host:1234", "", "", true},
35+
{"https remote accepted", "https://remote-host", "", "", true},
36+
{"https remote with credential accepted", "https://remote-host", "", "secret", true},
37+
{"localhost with credential accepted", "http://localhost:11434", "", "secret", true},
38+
{"empty endpoint no-op", "", "", "", true},
39+
{"empty endpoint with key no-op", "", "", "secret", true},
40+
// Credential-less provider: remote http allowed even with a stray key.
41+
{"ollama remote http with key accepted", "http://192.168.1.10:11434", "ollama", "secret", true},
42+
// Credentialed/ambiguous providers: key still gates remote http.
43+
{"openai remote http with key rejected", "http://remote:1234", "openai", "secret", false},
44+
{"auto remote http with key rejected", "http://remote:1234", "auto", "secret", false},
45+
{"anthropic remote http with key rejected", "http://remote:1234", "anthropic", "secret", false},
46+
// Unconditional scheme check fires before the credential-less exemption.
47+
{"ollama file scheme rejected", "file:///etc/passwd", "ollama", "secret", false},
48+
}
49+
50+
for _, tt := range tests {
51+
t.Run(tt.name, func(t *testing.T) {
52+
err := validateLLMEndpoint(tt.endpoint, tt.provider, tt.apiKey)
53+
if tt.wantOK {
54+
assert.NoError(t, err)
55+
} else {
56+
assert.Error(t, err)
57+
}
58+
})
59+
}
60+
}
61+
1762
func TestResolvePreferences(t *testing.T) {
1863
boolPtr := func(b bool) *bool { return &b }
1964
intPtr := func(i int) *int { return &i }

internal/commands/config.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,11 @@ Valid keys: account_id, project_id (or project), todolist_id, base_url, cache_di
297297
return output.ErrUsage(fmt.Sprintf("llm_provider must be one of: anthropic, openai, ollama, apple, none, auto (got %q)", value))
298298
}
299299
configData[key] = value
300+
case "llm_endpoint":
301+
if !config.IsHTTPURL(value) {
302+
return output.ErrUsage("llm_endpoint must be an http:// or https:// URL with a host")
303+
}
304+
configData[key] = value
300305
case "llm_max_concurrent":
301306
level, err := strconv.Atoi(value)
302307
if err != nil || level < 1 || level > 10 {
@@ -343,12 +348,13 @@ Valid keys: account_id, project_id (or project), todolist_id, base_url, cache_di
343348
return fmt.Errorf("failed to write config: %w", err)
344349
}
345350

346-
// Warn when writing authority keys to local config without trust
347-
if !global && isAuthorityKey(key) {
351+
// Warn when writing trust-gated keys to local config without trust —
352+
// otherwise the value is silently ignored on the next load.
353+
if !global && isTrustGatedKey(key) {
348354
absPath, _ := filepath.Abs(configPath)
349355
ts := config.LoadTrustStore(config.GlobalConfigDir())
350356
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))
357+
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))
352358
}
353359
}
354360

@@ -406,6 +412,21 @@ func isAuthorityKey(key string) bool {
406412
return false
407413
}
408414

415+
// isTrustGatedKey reports whether key is ignored when loaded from an untrusted
416+
// local/repo config. It's the authority keys plus the cache/LLM keys gated for
417+
// the same reason (cache redirection, paid-model substitution, cost
418+
// amplification), so `config set` warns before a local write silently no-ops.
419+
func isTrustGatedKey(key string) bool {
420+
if isAuthorityKey(key) {
421+
return true
422+
}
423+
switch key {
424+
case "cache_dir", "cache_enabled", "llm_model", "llm_max_concurrent", "llm_token_budget":
425+
return true
426+
}
427+
return false
428+
}
429+
409430
// redactSecret returns "(set)" if the value is non-empty, empty string otherwise.
410431
func redactSecret(value string) string {
411432
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 || (r >= 0x80 && r <= 0x9f) {
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/completion/completer_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,28 @@ func newTestCmd() *cobra.Command {
2424
return cmd
2525
}
2626

27+
func TestSanitizeCompletionDesc(t *testing.T) {
28+
tests := []struct {
29+
name string
30+
in string
31+
want string
32+
}{
33+
{"C0 control SOH stripped", "a\x01b", "ab"},
34+
{"C0 control US stripped", "a\x1fb", "ab"},
35+
{"DEL stripped", "a\x7fb", "ab"},
36+
{"C1 control 0x80 stripped", "a\u0080b", "ab"},
37+
{"C1 control 0x9f stripped", "a\u009fb", "ab"},
38+
{"valid accented rune passes through", "café", "café"},
39+
{"valid emoji rune passes through", "party🎉", "party🎉"},
40+
}
41+
42+
for _, tt := range tests {
43+
t.Run(tt.name, func(t *testing.T) {
44+
assert.Equal(t, tt.want, sanitizeCompletionDesc(tt.in))
45+
})
46+
}
47+
}
48+
2749
func TestRankProjects(t *testing.T) {
2850
now := time.Now()
2951
projects := []CachedProject{

0 commit comments

Comments
 (0)