From 0e7f78c70b616403965600fe293367c489a557b8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 29 May 2026 22:55:52 -0700 Subject: [PATCH] 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. --- internal/cli/root.go | 14 +- internal/cli/root_test.go | 25 +++ internal/commands/config.go | 38 +++- internal/commands/config_test.go | 98 ++++++++- internal/commands/tui.go | 7 +- internal/completion/cache.go | 22 +- internal/completion/cache_test.go | 79 +++++++ internal/completion/completer.go | 49 ++++- internal/completion/completer_test.go | 93 ++++++++ internal/config/config.go | 89 +++++++- internal/config/config_test.go | 205 +++++++++++++++++- internal/tui/workspace/session.go | 18 +- internal/tui/workspace/session_test.go | 55 +++++ internal/tui/workspace/summarize/provider.go | 67 ++++++ .../tui/workspace/summarize/provider_test.go | 83 +++++++ 15 files changed, 905 insertions(+), 37 deletions(-) create mode 100644 internal/tui/workspace/session_test.go create mode 100644 internal/tui/workspace/summarize/provider_test.go diff --git a/internal/cli/root.go b/internal/cli/root.go index 3c8b89986..8b69fbc6f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -98,7 +98,13 @@ func NewRootCmd() *cobra.Command { return err } // Re-apply env and flag overrides (they take precedence over profile values) - config.LoadFromEnv(cfg) + if err := config.LoadFromEnv(cfg); err != nil { + if bareRoot { + initBareRootApp(cfg) + return nil + } + return err + } config.ApplyOverrides(cfg, config.FlagOverrides{ Account: flags.Account, Project: flags.Project, @@ -128,6 +134,12 @@ func NewRootCmd() *cobra.Command { } } + // Note: llm_endpoint is deliberately NOT validated here. It is + // consumed only by the dev-gated TUI's summarize path, which + // fail-closes via summarize.ValidateEndpoint in + // workspace.NewSession — validating it at startup would brick + // unrelated commands over a value they never use. + // Resolve behavior preferences: explicit flag > config > version.IsDev() resolvePreferences(cmd, cfg, &flags) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index ad8a02035..636d03c78 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -15,6 +15,31 @@ import ( "github.com/basecamp/basecamp-cli/internal/version" ) +// TestBadLLMEndpointDoesNotBlockUnrelatedCommands is a regression test for +// the startup-validation lockout: llm_endpoint is consumed only by the +// dev-gated TUI's summarize path (which fail-closes via +// summarize.ValidateEndpoint in workspace.NewSession — see TestValidateEndpoint +// there), so PersistentPreRunE must not reject an endpoint that would fail +// that validation. Here the openai provider + API key + plain-http remote +// endpoint is the worst case, and an ordinary command still runs. +func TestBadLLMEndpointDoesNotBlockUnrelatedCommands(t *testing.T) { + isolateRootTest(t) + t.Setenv("BASECAMP_LLM_PROVIDER", "openai") + t.Setenv("BASECAMP_LLM_API_KEY", "secret") + t.Setenv("BASECAMP_LLM_ENDPOINT", "http://remote-host:1234") + + root := NewRootCmd() + root.AddCommand(&cobra.Command{ + Use: "noop", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + }) + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"noop"}) + + require.NoError(t, root.Execute()) +} + func TestResolvePreferences(t *testing.T) { boolPtr := func(b bool) *bool { return &b } intPtr := func(i int) *int { return &i } diff --git a/internal/commands/config.go b/internal/commands/config.go index d588f2432..ea5c39324 100644 --- a/internal/commands/config.go +++ b/internal/commands/config.go @@ -14,6 +14,7 @@ import ( "github.com/basecamp/basecamp-cli/internal/appctx" "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/hostutil" "github.com/basecamp/basecamp-cli/internal/output" "github.com/basecamp/basecamp-cli/internal/tui/resolve" ) @@ -291,10 +292,21 @@ Valid keys: account_id, project_id (or project), todolist_id, base_url, cache_di case "llm_provider": validProviders := map[string]bool{ "anthropic": true, "openai": true, "ollama": true, - "apple": true, "none": true, "auto": true, + "apple": true, "none": true, "disabled": true, "auto": true, } if !validProviders[value] { - return output.ErrUsage(fmt.Sprintf("llm_provider must be one of: anthropic, openai, ollama, apple, none, auto (got %q)", value)) + return output.ErrUsage(fmt.Sprintf("llm_provider must be one of: anthropic, openai, ollama, apple, none, disabled, auto (got %q)", value)) + } + configData[key] = value + case "llm_endpoint": + if !config.IsHTTPURL(value) { + return output.ErrUsage("llm_endpoint must be an http:// or https:// URL with a host") + } + // Warn (don't reject) on plain-http non-localhost endpoints: they're + // fine for ollama, but the LLM path fails closed for credentialed + // providers (openai) when an API key is set. + if err := hostutil.RequireSecureURL(value); err != nil { + fmt.Fprintln(os.Stderr, "warning: llm_endpoint is plain http on a non-localhost host; the LLM feature will refuse it if an API key is set with the openai provider") } configData[key] = value case "llm_max_concurrent": @@ -343,12 +355,13 @@ Valid keys: account_id, project_id (or project), todolist_id, base_url, cache_di return fmt.Errorf("failed to write config: %w", err) } - // Warn when writing authority keys to local config without trust - if !global && isAuthorityKey(key) { + // Warn when writing trust-gated keys to local config without trust — + // otherwise the value is silently ignored on the next load. + if !global && isTrustGatedKey(key) { absPath, _ := filepath.Abs(configPath) ts := config.LoadTrustStore(config.GlobalConfigDir()) if ts == nil || !ts.IsTrusted(configPath) { - 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)) + 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)) } } @@ -406,6 +419,21 @@ func isAuthorityKey(key string) bool { return false } +// isTrustGatedKey reports whether key is ignored when loaded from an untrusted +// local/repo config. It's the authority keys plus the cache/LLM keys gated for +// the same reason (cache redirection, paid-model substitution, cost +// amplification), so `config set` warns before a local write silently no-ops. +func isTrustGatedKey(key string) bool { + if isAuthorityKey(key) { + return true + } + switch key { + case "cache_dir", "cache_enabled", "llm_model", "llm_max_concurrent", "llm_token_budget": + return true + } + return false +} + // redactSecret returns "(set)" if the value is non-empty, empty string otherwise. func redactSecret(value string) string { if value != "" { diff --git a/internal/commands/config_test.go b/internal/commands/config_test.go index 8bcbdc7d7..932bb4365 100644 --- a/internal/commands/config_test.go +++ b/internal/commands/config_test.go @@ -337,12 +337,82 @@ func TestConfigSet_AuthorityKeyWarnsWithPath(t *testing.T) { stderr := string(buf[:n]) absPath := filepath.Join(tmpDir, ".basecamp", "config.json") - assert.Contains(t, stderr, "authority key") + assert.Contains(t, stderr, `"base_url"`) assert.Contains(t, stderr, "requires trust") assert.Contains(t, stderr, absPath, "warning must include the exact config path") assert.Contains(t, stderr, "'"+absPath+"'", "path must be single-quoted for shell safety") } +// TestConfigSet_GatedNonAuthorityKeyWarns verifies the trust warning also fires +// for the cache/LLM keys gated on load (cache_dir, llm_model, …), so a local +// `config set` doesn't silently produce a value that's ignored next run. +func TestConfigSet_GatedNonAuthorityKeyWarns(t *testing.T) { + app, _ := setupConfigTestApp(t) + + tmpDir, _ := filepath.EvalSymlinks(t.TempDir()) + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(tmpDir)) + defer os.Chdir(origDir) + require.NoError(t, os.MkdirAll(".basecamp", 0755)) + + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + err := executeConfigCommand(app, "set", "cache_dir", "/tmp/somewhere") + require.NoError(t, err) + + w.Close() + var buf [4096]byte + n, _ := r.Read(buf[:]) + os.Stderr = origStderr + + stderr := string(buf[:n]) + assert.Contains(t, stderr, `"cache_dir"`) + assert.Contains(t, stderr, "requires trust") +} + +// --- config set llm_endpoint insecure-endpoint warning tests --- + +// setLLMEndpointCapturingStderr runs `config set --global llm_endpoint ` +// and returns what was written to stderr. Global scope keeps the trust-gated +// local-config warning (llm_endpoint is an authority key) out of the capture. +func setLLMEndpointCapturingStderr(t *testing.T, value string) string { + t.Helper() + app, _ := setupConfigTestApp(t) + + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + err := executeConfigCommand(app, "set", "--global", "llm_endpoint", value) + + w.Close() + var buf [4096]byte + n, _ := r.Read(buf[:]) + os.Stderr = origStderr + + require.NoError(t, err) + return string(buf[:n]) +} + +// TestConfigSet_LLMEndpointInsecureWarns verifies that a plain-http +// non-localhost llm_endpoint is accepted (ollama legitimately uses http) but +// warns that the LLM path will refuse it for credentialed providers. +func TestConfigSet_LLMEndpointInsecureWarns(t *testing.T) { + stderr := setLLMEndpointCapturingStderr(t, "http://remote:8080") + assert.Contains(t, stderr, "warning: llm_endpoint is plain http on a non-localhost host") + assert.Contains(t, stderr, "the LLM feature will refuse it") +} + +func TestConfigSet_LLMEndpointSecureNoWarning(t *testing.T) { + assert.Empty(t, setLLMEndpointCapturingStderr(t, "https://remote:8080")) +} + +func TestConfigSet_LLMEndpointLocalhostHTTPNoWarning(t *testing.T) { + assert.Empty(t, setLLMEndpointCapturingStderr(t, "http://localhost:11434")) +} + // --- Config project tests --- // setupConfigProjectTestApp creates a test app wired to an httptest server @@ -423,6 +493,32 @@ func TestConfigSet_ProjectAlias(t *testing.T) { assert.Equal(t, "12345", saved["project_id"]) } +func TestConfigSet_LLMProviderValidation(t *testing.T) { + app, _ := setupConfigTestApp(t) + + tmpDir, _ := filepath.EvalSymlinks(t.TempDir()) + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(tmpDir)) + defer os.Chdir(origDir) + + require.NoError(t, os.MkdirAll(".basecamp", 0755)) + + // "disabled" is valid: DetectProvider treats it like "none". + err := executeConfigCommand(app, "set", "llm_provider", "disabled") + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(tmpDir, ".basecamp", "config.json")) + require.NoError(t, err) + var saved map[string]any + require.NoError(t, json.Unmarshal(data, &saved)) + assert.Equal(t, "disabled", saved["llm_provider"]) + + // Unknown providers are rejected, and the error lists "disabled". + err = executeConfigCommand(app, "set", "llm_provider", "bogus") + require.Error(t, err) + assert.Contains(t, err.Error(), "disabled") +} + func TestConfigUnset_ProjectAlias(t *testing.T) { app, _ := setupConfigTestApp(t) diff --git a/internal/commands/tui.go b/internal/commands/tui.go index b59e27b19..b09d8a23f 100644 --- a/internal/commands/tui.go +++ b/internal/commands/tui.go @@ -90,7 +90,10 @@ func NewTUICmd() *cobra.Command { } } - session := workspace.NewSession(app) + session, err := workspace.NewSession(app) + if err != nil { + return err + } defer session.Shutdown() // Deep-link: parse URL argument and set initial navigation target. @@ -111,7 +114,7 @@ func NewTUICmd() *cobra.Command { p := tea.NewProgram(model) - _, err := p.Run() + _, err = p.Run() model.CloseWatcher() return err }, diff --git a/internal/completion/cache.go b/internal/completion/cache.go index 7c3588b86..e882be5ca 100644 --- a/internal/completion/cache.go +++ b/internal/completion/cache.go @@ -10,6 +10,8 @@ import ( "path/filepath" "sync" "time" + + "github.com/basecamp/basecamp-cli/internal/config" ) // CachedProject holds project data for tab completion. @@ -366,13 +368,23 @@ func loadConfigForCompletion() *configForCompletion { loadProfilesFromFile(cfg, globalPath) } - // Repo config (walk up to find .git, then .basecamp/config.json) + // profiles is an authority key (it can redirect authenticated traffic), so + // repo/local configs must be explicitly trusted before their profiles feed + // completion — mirroring config.loadFromFile's trust gating. System/global + // configs above are trusted by location. + trust := config.LoadTrustStore(config.GlobalConfigDir()) + + // Repo config (walk up to find .git, then .basecamp/config.json). + // .git may be a file (worktree/submodule marker) rather than a directory, + // so check existence only — mirroring config.RepoConfigPath. if dir, err := os.Getwd(); err == nil { for { gitPath := filepath.Join(dir, ".git") - if fi, err := os.Stat(gitPath); err == nil && fi.IsDir() { + if _, err := os.Stat(gitPath); err == nil { repoConfig := filepath.Join(dir, ".basecamp", "config.json") - loadProfilesFromFile(cfg, repoConfig) + if trust != nil && trust.IsTrusted(repoConfig) { + loadProfilesFromFile(cfg, repoConfig) + } break } parent := filepath.Dir(dir) @@ -385,7 +397,9 @@ func loadConfigForCompletion() *configForCompletion { // Local config localPath := filepath.Join(".basecamp", "config.json") - loadProfilesFromFile(cfg, localPath) + if trust != nil && trust.IsTrusted(localPath) { + loadProfilesFromFile(cfg, localPath) + } return cfg } diff --git a/internal/completion/cache_test.go b/internal/completion/cache_test.go index 6e687a166..d92f3ac58 100644 --- a/internal/completion/cache_test.go +++ b/internal/completion/cache_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/config" ) func TestStore_SaveAndLoad(t *testing.T) { @@ -434,3 +436,80 @@ func TestStore_AccountsPreservedOnOtherUpdates(t *testing.T) { require.NoError(t, err) assert.Equal(t, 1, len(loaded.Accounts)) } + +// setupCompletionRepo builds a hermetic repo layout for loadConfigForCompletion +// tests: an isolated XDG_CONFIG_HOME (global config + trust store), a repo root +// containing .git (directory or worktree-style file) and .basecamp/config.json +// with a "repoprofile" profile, and CWD set to a subdirectory so the repo config +// is only reachable via the .git walk-up (not as a local config). Returns the +// repo config path. +func setupCompletionRepo(t *testing.T, gitAsFile bool) string { + t.Helper() + + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + repoDir := t.TempDir() + gitPath := filepath.Join(repoDir, ".git") + if gitAsFile { + require.NoError(t, os.WriteFile(gitPath, []byte("gitdir: /elsewhere/worktrees/x\n"), 0o600)) + } else { + require.NoError(t, os.Mkdir(gitPath, 0o755)) + } + + require.NoError(t, os.Mkdir(filepath.Join(repoDir, ".basecamp"), 0o755)) + cfgPath := filepath.Join(repoDir, ".basecamp", "config.json") + cfgJSON := `{"profiles":{"repoprofile":{"base_url":"https://repo.example.com"}}}` + require.NoError(t, os.WriteFile(cfgPath, []byte(cfgJSON), 0o600)) + + subDir := filepath.Join(repoDir, "sub") + require.NoError(t, os.Mkdir(subDir, 0o755)) + t.Chdir(subDir) + + return cfgPath +} + +// trustConfigPath marks path as trusted in the same trust store that +// loadConfigForCompletion consults (trusted-configs.json under the global +// config dir, here redirected via XDG_CONFIG_HOME). +func trustConfigPath(t *testing.T, path string) { + t.Helper() + require.NoError(t, config.NewTrustStore(config.GlobalConfigDir()).Trust(path)) +} + +func TestLoadConfigForCompletion_TrustedRepoConfig(t *testing.T) { + cfgPath := setupCompletionRepo(t, false) + trustConfigPath(t, cfgPath) + + cfg := loadConfigForCompletion() + + require.Contains(t, cfg.Profiles, "repoprofile") + assert.Equal(t, "https://repo.example.com", cfg.Profiles["repoprofile"].BaseURL) +} + +func TestLoadConfigForCompletion_UntrustedRepoConfig(t *testing.T) { + setupCompletionRepo(t, false) + + cfg := loadConfigForCompletion() + + assert.NotContains(t, cfg.Profiles, "repoprofile") +} + +func TestLoadConfigForCompletion_GitFileWorktreeMarker(t *testing.T) { + // .git as a file (worktree/submodule marker) must anchor the repo root + // the same as a .git directory. + cfgPath := setupCompletionRepo(t, true) + trustConfigPath(t, cfgPath) + + cfg := loadConfigForCompletion() + + require.Contains(t, cfg.Profiles, "repoprofile") + assert.Equal(t, "https://repo.example.com", cfg.Profiles["repoprofile"].BaseURL) +} + +func TestLoadConfigForCompletion_UntrustedGitFileWorktreeMarker(t *testing.T) { + setupCompletionRepo(t, true) + + cfg := loadConfigForCompletion() + + assert.NotContains(t, cfg.Profiles, "repoprofile") +} diff --git a/internal/completion/completer.go b/internal/completion/completer.go index 74468eb28..a71326296 100644 --- a/internal/completion/completer.go +++ b/internal/completion/completer.go @@ -91,7 +91,7 @@ func (c *Completer) ProjectCompletion() cobra.CompletionFunc { // Use ID as completion value with name as description completion := cobra.CompletionWithDesc( fmt.Sprintf("%d", p.ID), - p.Name, + sanitizeCompletionDesc(p.Name), ) completions = append(completions, completion) } @@ -114,6 +114,12 @@ func (c *Completer) ProjectNameCompletion() cobra.CompletionFunc { toCompleteLower := strings.ToLower(toComplete) var completions []cobra.Completion for _, p := range ranked { + // Skip names carrying control characters: the value must round-trip + // verbatim for name resolution, so it can't be sanitized. The + // project stays reachable by ID. + if hasControlChars(p.Name) { + continue + } nameLower := strings.ToLower(p.Name) if strings.HasPrefix(nameLower, toCompleteLower) || strings.Contains(nameLower, toCompleteLower) { @@ -164,7 +170,7 @@ func (c *Completer) PeopleCompletion() cobra.CompletionFunc { } completion := cobra.CompletionWithDesc( fmt.Sprintf("%d", p.ID), - desc, + sanitizeCompletionDesc(desc), ) completions = append(completions, completion) } @@ -199,6 +205,12 @@ func (c *Completer) PeopleNameCompletion() cobra.CompletionFunc { }) for _, p := range sorted { + // Skip names carrying control characters: the value must round-trip + // verbatim for name resolution, so it can't be sanitized. The + // person stays reachable by ID. + if hasControlChars(p.Name) { + continue + } nameLower := strings.ToLower(p.Name) if strings.HasPrefix(nameLower, toCompleteLower) || strings.Contains(nameLower, toCompleteLower) { @@ -237,7 +249,7 @@ func (c *Completer) AccountCompletion() cobra.CompletionFunc { strings.HasPrefix(nameLower, toCompleteLower) || strings.Contains(nameLower, toCompleteLower) { // Use ID as completion value with name as description - completions = append(completions, cobra.CompletionWithDesc(idStr, a.Name)) + completions = append(completions, cobra.CompletionWithDesc(idStr, sanitizeCompletionDesc(a.Name))) } } @@ -266,11 +278,17 @@ func (c *Completer) ProfileCompletion() cobra.CompletionFunc { toCompleteLower := strings.ToLower(toComplete) var completions []cobra.Completion for _, p := range sorted { + // Skip names carrying control characters: the profile name is the + // completion value and must round-trip verbatim, so it can't be + // sanitized. + if hasControlChars(p.Name) { + continue + } nameLower := strings.ToLower(p.Name) if strings.HasPrefix(nameLower, toCompleteLower) || strings.Contains(nameLower, toCompleteLower) { // Use name as completion value with base URL as description - completions = append(completions, cobra.CompletionWithDesc(p.Name, p.BaseURL)) + completions = append(completions, cobra.CompletionWithDesc(p.Name, sanitizeCompletionDesc(p.BaseURL))) } } @@ -278,6 +296,29 @@ func (c *Completer) ProfileCompletion() cobra.CompletionFunc { } } +// sanitizeCompletionDesc drops control characters (including ESC) from a +// completion description. Descriptions can carry API- or config-controlled +// strings (project/person/account names, profile base_url) which the shell +// renders to the terminal; stripping control bytes prevents terminal injection. +func sanitizeCompletionDesc(s string) string { + return strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { + return -1 + } + return r + }, s) +} + +// hasControlChars reports whether s would be altered by sanitizeCompletionDesc: +// it contains control characters (C0 including ESC, DEL, or C1) or invalid +// UTF-8 (strings.Map rewrites invalid bytes to U+FFFD, so a raw C1 byte like +// \x9b also differs). Used to SKIP name-valued completion candidates outright — +// sanitizing a completion VALUE would break resolution, which matches by the +// exact name string. +func hasControlChars(s string) bool { + return s != sanitizeCompletionDesc(s) +} + // rankProjects returns projects sorted by priority: // 1. HQ (purpose="hq") // 2. Bookmarked diff --git a/internal/completion/completer_test.go b/internal/completion/completer_test.go index e21cd30ea..4713562a4 100644 --- a/internal/completion/completer_test.go +++ b/internal/completion/completer_test.go @@ -24,6 +24,28 @@ func newTestCmd() *cobra.Command { return cmd } +func TestSanitizeCompletionDesc(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"C0 control SOH stripped", "a\x01b", "ab"}, + {"C0 control US stripped", "a\x1fb", "ab"}, + {"DEL stripped", "a\x7fb", "ab"}, + {"C1 control 0x80 stripped", "a\u0080b", "ab"}, + {"C1 control 0x9f stripped", "a\u009fb", "ab"}, + {"valid accented rune passes through", "café", "café"}, + {"valid emoji rune passes through", "party🎉", "party🎉"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, sanitizeCompletionDesc(tt.in)) + }) + } +} + func TestRankProjects(t *testing.T) { now := time.Now() projects := []CachedProject{ @@ -336,3 +358,74 @@ func TestCompleterAccountEmptyCache(t *testing.T) { assert.Len(t, completions, 0, "expected no completions with empty cache") assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive, "expected NoFileComp directive") } + +func TestHasControlChars(t *testing.T) { + assert.False(t, hasControlChars("Normal Name")) + assert.False(t, hasControlChars("café party🎉")) + assert.True(t, hasControlChars("Evil\x1b[31m"), "ESC (C0)") + assert.True(t, hasControlChars("Evil\u009b31m"), "CSI (C1)") + assert.True(t, hasControlChars("Evil\x9bRaw"), "raw C1 byte (invalid UTF-8)") + assert.True(t, hasControlChars("Evil\x7f"), "DEL") +} + +// Name-valued completions can't sanitize the value (resolution matches the +// exact name string), so candidates whose names carry control characters — +// ESC (C0) or CSI (C1) sequences that could inject into the user's terminal +// via the completion machinery — must be skipped entirely. +func TestProjectNameCompletionSkipsControlCharNames(t *testing.T) { + tmpDir := t.TempDir() + store := NewStore(tmpDir) + + require.NoError(t, store.UpdateProjects([]CachedProject{ + {ID: 1, Name: "Good Project"}, + {ID: 2, Name: "Evil\x1b]0;pwned\x07Project"}, + {ID: 3, Name: "Evil\u009b31mProject"}, + })) + + fn := newTestCompleter(tmpDir).ProjectNameCompletion() + completions, _ := fn(newTestCmd(), nil, "") + + require.Len(t, completions, 1) + assert.Equal(t, "Good Project", completions[0]) +} + +func TestPeopleNameCompletionSkipsControlCharNames(t *testing.T) { + tmpDir := t.TempDir() + store := NewStore(tmpDir) + + require.NoError(t, store.UpdatePeople([]CachedPerson{ + {ID: 1, Name: "Alice Smith"}, + {ID: 2, Name: "Bob\x1b[2JJones"}, + {ID: 3, Name: "Carol\u009b31mWilliams"}, + })) + + fn := newTestCompleter(tmpDir).PeopleNameCompletion() + completions, _ := fn(newTestCmd(), nil, "a") + + // "a" matches Alice and the malicious C-a-rol name; only Alice survives + // the control-char skip ("me" doesn't match "a", Bob doesn't contain "a"). + require.Len(t, completions, 1) + assert.Equal(t, "Alice Smith", completions[0]) +} + +func TestProfileCompletionSkipsControlCharNames(t *testing.T) { + // Profiles load from config files; isolate global config and make sure + // no repo/local config is reachable from CWD. + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Chdir(t.TempDir()) + + require.NoError(t, os.MkdirAll(filepath.Join(configHome, "basecamp"), 0o700)) + cfgJSON := `{"profiles":{` + + `"good":{"base_url":"https://good.example.com"},` + + `"evil\u001b[31m":{"base_url":"https://evil.example.com"},` + + `"evil\u009bcsi":{"base_url":"https://evil2.example.com"}}}` + require.NoError(t, os.WriteFile( + filepath.Join(configHome, "basecamp", "config.json"), []byte(cfgJSON), 0o600)) + + fn := newTestCompleter(t.TempDir()).ProfileCompletion() + completions, _ := fn(newTestCmd(), nil, "") + + require.Len(t, completions, 1) + assert.Equal(t, "good\thttps://good.example.com", completions[0]) +} diff --git a/internal/config/config.go b/internal/config/config.go index e9662ac8c..5efb2a150 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ package config import ( "encoding/json" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -144,7 +145,9 @@ func Load(overrides FlagOverrides) (*Config, error) { } // Load from environment - LoadFromEnv(cfg) + if err := LoadFromEnv(cfg); err != nil { + return nil, err + } // Apply flag overrides ApplyOverrides(cfg, overrides) @@ -196,12 +199,24 @@ func loadFromFile(cfg *Config, path string, source Source, trust *TrustStore) { cfg.Sources["scope"] = string(source) } if v, ok := fileCfg["cache_dir"].(string); ok && v != "" { - cfg.CacheDir = v - cfg.Sources["cache_dir"] = string(source) + // cache_dir redirects every cache write (completion, resilience, TUI + // workspace, recents, traces). An untrusted local/repo config could + // point it at any user-writable path, so gate it like other authority + // keys. filepath.Clean normalizes the accepted value. + if untrusted { + fmt.Fprintf(os.Stderr, "warning: ignoring cache_dir %q from %s config at %s\n (trust-gated key from local/repo config; run `basecamp config trust %s` to allow)\n", v, source, path, ShellQuote(path)) + } else { + cfg.CacheDir = filepath.Clean(v) + cfg.Sources["cache_dir"] = string(source) + } } if v, ok := fileCfg["cache_enabled"].(bool); ok { - cfg.CacheEnabled = v - cfg.Sources["cache_enabled"] = string(source) + if untrusted { + fmt.Fprintf(os.Stderr, "warning: ignoring cache_enabled from %s config at %s\n (trust-gated key from local/repo config; run `basecamp config trust %s` to allow)\n", source, path, ShellQuote(path)) + } else { + cfg.CacheEnabled = v + cfg.Sources["cache_enabled"] = string(source) + } } if v, ok := fileCfg["format"].(string); ok && v != "" { cfg.Format = v @@ -237,8 +252,14 @@ func loadFromFile(cfg *Config, path string, source Source, trust *TrustStore) { } } if v, ok := fileCfg["llm_model"].(string); ok && v != "" { - cfg.LLMModel = v - cfg.Sources["llm_model"] = string(source) + // Gate like other LLM authority keys: an untrusted config could + // silently substitute a costlier paid model. + if untrusted { + fmt.Fprintf(os.Stderr, "warning: ignoring llm_model %q from %s config at %s\n (trust-gated key from local/repo config; run `basecamp config trust %s` to allow)\n", v, source, path, ShellQuote(path)) + } else { + cfg.LLMModel = v + cfg.Sources["llm_model"] = string(source) + } } if v, ok := fileCfg["llm_api_key"].(string); ok && v != "" { // Secret: only from global/system config, never local/repo @@ -253,6 +274,13 @@ func loadFromFile(cfg *Config, path string, source Source, trust *TrustStore) { if untrusted { 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)) } else { + // Keep the value even if malformed (non-http(s)/hostless): + // summarize.ValidateEndpoint rejects it at the point of + // consumption (the TUI's LLM path) when the provider sends + // llm_api_key to the endpoint, failing closed rather than + // silently dropping it and falling back to the default provider. + // Other commands never consume it, so `config unset llm_endpoint` + // can always repair. cfg.LLMEndpoint = v cfg.Sources["llm_endpoint"] = string(source) } @@ -260,7 +288,11 @@ func loadFromFile(cfg *Config, path string, source Source, trust *TrustStore) { if v, ok := fileCfg["llm_max_concurrent"]; ok { if fv, ok := v.(float64); ok { iv := int(fv) - if iv >= 1 && iv <= 10 && fv == float64(iv) { + // Gate like other LLM authority keys: block a malicious repo from + // inflating paid-LLM concurrency (cost amplification). + if untrusted { + fmt.Fprintf(os.Stderr, "warning: ignoring llm_max_concurrent from %s config at %s\n (trust-gated key from local/repo config; run `basecamp config trust %s` to allow)\n", source, path, ShellQuote(path)) + } else if iv >= 1 && iv <= 10 && fv == float64(iv) { cfg.LLMMaxConcurrent = iv cfg.Sources["llm_max_concurrent"] = string(source) } @@ -269,7 +301,10 @@ func loadFromFile(cfg *Config, path string, source Source, trust *TrustStore) { if v, ok := fileCfg["llm_token_budget"]; ok { if fv, ok := v.(float64); ok { iv := int(fv) - if iv >= 100 && iv <= 100000 && fv == float64(iv) { + // Gate like other LLM authority keys (cost amplification). + if untrusted { + fmt.Fprintf(os.Stderr, "warning: ignoring llm_token_budget from %s config at %s\n (trust-gated key from local/repo config; run `basecamp config trust %s` to allow)\n", source, path, ShellQuote(path)) + } else if iv >= 100 && iv <= 100000 && fv == float64(iv) { cfg.LLMTokenBudget = iv cfg.Sources["llm_token_budget"] = string(source) } @@ -335,7 +370,20 @@ func loadFromFile(cfg *Config, path string, source Source, trust *TrustStore) { // LoadFromEnv loads configuration from environment variables. // Exported so root.go can re-apply after profile overlay. -func LoadFromEnv(cfg *Config) { +// +// Values are stored as-is, malformed or not — mirroring the trusted-file +// path in loadFromFile. Validation happens at use-time in +// summarize.ValidateEndpoint (the TUI's LLM path, the only consumer of +// llm_endpoint), which fails closed for providers that send llm_api_key +// to the endpoint. Other commands never consume the value, so +// `basecamp config unset llm_endpoint` (or unsetting the env var) can +// always repair a bad value. Erroring here would block every command — +// including the repair path — for all providers, even ones that never +// consume the endpoint. +// +// Always returns nil today; the error return is kept so validation can be +// reintroduced for a specific variable without churning every caller. +func LoadFromEnv(cfg *Config) error { if v := os.Getenv("BASECAMP_BASE_URL"); v != "" { cfg.BaseURL = v cfg.Sources["base_url"] = string(SourceEnv) @@ -385,9 +433,17 @@ func LoadFromEnv(cfg *Config) { cfg.Sources["llm_api_key"] = string(SourceEnv) } if v := os.Getenv("BASECAMP_LLM_ENDPOINT"); v != "" { + // Keep the value even if malformed (non-http(s)/hostless), mirroring + // the trusted-file path in loadFromFile: summarize.ValidateEndpoint + // rejects it at the point of consumption (the TUI's LLM path) for + // providers that send llm_api_key to the endpoint, failing closed + // rather than silently dropping it and falling back to the default + // provider. Other commands never consume it, so + // `config unset llm_endpoint` can always repair. cfg.LLMEndpoint = v cfg.Sources["llm_endpoint"] = string(SourceEnv) } + return nil } // NonInteractiveEnv reports whether BASECAMP_NONINTERACTIVE is set to a truthy @@ -675,6 +731,19 @@ func NormalizeBaseURL(url string) string { return strings.TrimSuffix(url, "/") } +// IsHTTPURL reports whether rawURL is an absolute http(s) URL with a host. +// Used to reject non-web schemes (file://, etc.) and hostless forms +// (e.g. "https:example.com", which url.Parse accepts with an empty Host) +// for llm_endpoint, since those would later be concatenated into a request +// URL and produce confusing/invalid requests. +func IsHTTPURL(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" +} + // ShellQuote returns a POSIX single-quoted string safe for copy-paste into // a shell. Single quotes inside the value are escaped as '\” (end quote, // escaped literal quote, resume quote). diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 60d9244b9..08a2cf278 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -117,7 +117,7 @@ func TestLoadFromEnv(t *testing.T) { os.Setenv("BASECAMP_CACHE_ENABLED", "false") cfg := Default() - LoadFromEnv(cfg) + require.NoError(t, LoadFromEnv(cfg)) // Verify values loaded assert.Equal(t, "http://env.example.com", cfg.BaseURL) @@ -149,7 +149,7 @@ func TestLoadFromEnvPrecedence(t *testing.T) { os.Setenv("BASECAMP_BASE_URL", "http://basecamp.example.com") cfg := Default() - LoadFromEnv(cfg) + require.NoError(t, LoadFromEnv(cfg)) assert.Equal(t, "http://basecamp.example.com", cfg.BaseURL) } @@ -286,7 +286,7 @@ func TestFullLayeringPrecedence(t *testing.T) { // Apply layers in order loadFromFile(cfg, globalConfig, SourceGlobal, nil) loadFromFile(cfg, localConfig, SourceLocal, nil) - LoadFromEnv(cfg) + require.NoError(t, LoadFromEnv(cfg)) ApplyOverrides(cfg, FlagOverrides{ // No flag overrides }) @@ -382,7 +382,7 @@ func TestCacheEnabledEnvParsing(t *testing.T) { cfg := Default() cfg.CacheEnabled = tt.startValue - LoadFromEnv(cfg) + require.NoError(t, LoadFromEnv(cfg)) assert.Equal(t, tt.expected, cfg.CacheEnabled) }) @@ -404,7 +404,7 @@ func TestCacheEnabledEnvEmpty(t *testing.T) { cfg := Default() cfg.CacheEnabled = true - LoadFromEnv(cfg) + require.NoError(t, LoadFromEnv(cfg)) // Should remain true (env var not set, so doesn't change) assert.True(t, cfg.CacheEnabled) @@ -781,6 +781,195 @@ func TestLoadFromFile_BaseURLRejectedFromLocal(t *testing.T) { assert.Equal(t, "https://3.basecampapi.com", cfg.BaseURL, "local config should not override base_url") } +// TestLoadFromFile_AuthorityKeysRejectedFromLocal verifies the sibling +// authority keys closed by the security audit (cache_dir, cache_enabled, +// llm_model, llm_max_concurrent, llm_token_budget) are ignored — with a +// warning — when they appear in an untrusted local/repo config, while their +// default values are preserved. +func TestLoadFromFile_AuthorityKeysRejectedFromLocal(t *testing.T) { + defaults := Default() + + cases := []struct { + name string + json string + warnFrag string + assertDef func(t *testing.T, cfg *Config) + }{ + { + name: "cache_dir", + json: `{"cache_dir":"/home/victim/.ssh"}`, + warnFrag: "ignoring cache_dir", + assertDef: func(t *testing.T, cfg *Config) { + assert.Equal(t, defaults.CacheDir, cfg.CacheDir) + assert.Empty(t, cfg.Sources["cache_dir"]) + }, + }, + { + name: "cache_enabled", + json: `{"cache_enabled":false}`, + warnFrag: "ignoring cache_enabled", + assertDef: func(t *testing.T, cfg *Config) { + assert.Equal(t, defaults.CacheEnabled, cfg.CacheEnabled) + assert.Empty(t, cfg.Sources["cache_enabled"]) + }, + }, + { + name: "llm_model", + json: `{"llm_model":"gpt-4-turbo-expensive"}`, + warnFrag: "ignoring llm_model", + assertDef: func(t *testing.T, cfg *Config) { + assert.Equal(t, defaults.LLMModel, cfg.LLMModel) + assert.Empty(t, cfg.Sources["llm_model"]) + }, + }, + { + name: "llm_max_concurrent", + json: `{"llm_max_concurrent":10}`, + warnFrag: "ignoring llm_max_concurrent", + assertDef: func(t *testing.T, cfg *Config) { + assert.Equal(t, defaults.LLMMaxConcurrent, cfg.LLMMaxConcurrent) + assert.Empty(t, cfg.Sources["llm_max_concurrent"]) + }, + }, + { + name: "llm_token_budget", + json: `{"llm_token_budget":100000}`, + warnFrag: "ignoring llm_token_budget", + assertDef: func(t *testing.T, cfg *Config) { + assert.Equal(t, defaults.LLMTokenBudget, cfg.LLMTokenBudget) + assert.Empty(t, cfg.Sources["llm_token_budget"]) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + require.NoError(t, os.WriteFile(configPath, []byte(tc.json), 0644)) + + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + cfg := Default() + loadFromFile(cfg, configPath, SourceLocal, nil) // nil trust → untrusted + + w.Close() + var buf [1024]byte + n, _ := r.Read(buf[:]) + os.Stderr = origStderr + + assert.Contains(t, string(buf[:n]), tc.warnFrag) + tc.assertDef(t, cfg) + }) + } +} + +// TestLoadFromFile_AuthorityKeysAcceptedFromGlobal confirms the same keys are +// honored from a trusted (global) source, so the gating doesn't over-reach. +func TestLoadFromFile_AuthorityKeysAcceptedFromGlobal(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + require.NoError(t, os.WriteFile(configPath, []byte(`{ + "cache_dir":"/var/cache/basecamp", + "cache_enabled":false, + "llm_model":"gpt-4", + "llm_max_concurrent":7, + "llm_token_budget":5000 + }`), 0644)) + + cfg := Default() + loadFromFile(cfg, configPath, SourceGlobal, nil) + + assert.Equal(t, "/var/cache/basecamp", cfg.CacheDir) + assert.False(t, cfg.CacheEnabled) + assert.Equal(t, "gpt-4", cfg.LLMModel) + assert.Equal(t, 7, cfg.LLMMaxConcurrent) + assert.Equal(t, 5000, cfg.LLMTokenBudget) +} + +// TestLoadFromFile_LLMEndpointMalformedKept verifies a malformed (non-http(s) or +// hostless) llm_endpoint from a trusted source is KEPT rather than warned+dropped. +// Dropping it would silently fall back to the default provider; keeping it lets +// the dev-gated TUI summarize path fail closed at consumption +// (summarize.ValidateEndpoint in workspace.NewSession) while config subcommands +// can still repair via `config unset llm_endpoint`. +func TestLoadFromFile_LLMEndpointMalformedKept(t *testing.T) { + for _, bad := range []string{"file:///etc/passwd", "https:example.com", "ssh://host/x"} { + t.Run(bad, func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + body, _ := json.Marshal(map[string]any{"llm_endpoint": bad}) + require.NoError(t, os.WriteFile(configPath, body, 0644)) + + cfg := Default() + loadFromFile(cfg, configPath, SourceGlobal, nil) + + assert.Equal(t, bad, cfg.LLMEndpoint) + assert.Equal(t, "global", cfg.Sources["llm_endpoint"]) + }) + } +} + +// TestLoadFromEnv_LLMEndpointMalformedKept verifies a non-http(s) or hostless +// BASECAMP_LLM_ENDPOINT is stored as-is rather than erroring at load time, +// mirroring the trusted-file path. Erroring here would block every command — +// including the `config unset llm_endpoint` repair path — for all providers. +// summarize.ValidateEndpoint (called from workspace.NewSession, the only +// consumer of llm_endpoint) rejects the value at use time for providers that +// send llm_api_key to the endpoint, so the typo still surfaces instead of +// silently falling back to the default provider. +func TestLoadFromEnv_LLMEndpointMalformedKept(t *testing.T) { + for _, bad := range []string{"file:///etc/passwd", "https:example.com", "ssh://host/x"} { + t.Run(bad, func(t *testing.T) { + t.Setenv("BASECAMP_LLM_ENDPOINT", bad) + + cfg := Default() + require.NoError(t, LoadFromEnv(cfg)) + + assert.Equal(t, bad, cfg.LLMEndpoint) + assert.Equal(t, "env", cfg.Sources["llm_endpoint"]) + }) + } +} + +// TestLoadFromEnv_LLMEndpointAccepted verifies a valid endpoint loads with env +// provenance and no error. +func TestLoadFromEnv_LLMEndpointAccepted(t *testing.T) { + t.Setenv("BASECAMP_LLM_ENDPOINT", "https://api.openai.com/v1") + + cfg := Default() + require.NoError(t, LoadFromEnv(cfg)) + + assert.Equal(t, "https://api.openai.com/v1", cfg.LLMEndpoint) + assert.Equal(t, "env", cfg.Sources["llm_endpoint"]) +} + +// TestLoadFromEnv_LLMEndpointEmpty verifies an unset BASECAMP_LLM_ENDPOINT is a +// no-op: no error, and no endpoint or provenance recorded. +func TestLoadFromEnv_LLMEndpointEmpty(t *testing.T) { + t.Setenv("BASECAMP_LLM_ENDPOINT", "") + + cfg := Default() + require.NoError(t, LoadFromEnv(cfg)) + + assert.Empty(t, cfg.LLMEndpoint) + assert.Empty(t, cfg.Sources["llm_endpoint"]) +} + +// TestIsHTTPURL covers the scheme+host validation reused at llm_endpoint's +// call sites: config-set acceptance (internal/commands/config.go) and +// consumption-time validation (summarize.ValidateEndpoint). +func TestIsHTTPURL(t *testing.T) { + for _, ok := range []string{"http://localhost:11434", "https://api.openai.com/v1", "http://127.0.0.1:8080/x", "http://host:8080", "https://host", "http://[::1]:8080"} { + assert.True(t, IsHTTPURL(ok), "%q should be accepted", ok) + } + for _, bad := range []string{"", "file:///etc/passwd", "ssh://h/x", "https:example.com", "https://", "not a url", "/relative", "http://:8080", "https://:443"} { + assert.False(t, IsHTTPURL(bad), "%q should be rejected", bad) + } +} + func TestLoadFromFile_BaseURLNoWarningForGlobal(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.json") @@ -961,7 +1150,7 @@ func TestPreferencesFromEnv(t *testing.T) { os.Setenv("BASECAMP_STATS", "0") cfg := Default() - LoadFromEnv(cfg) + require.NoError(t, LoadFromEnv(cfg)) require.NotNil(t, cfg.Hints) assert.True(t, *cfg.Hints) @@ -994,7 +1183,7 @@ func TestPreferencesEnvOverridesFile(t *testing.T) { cfg := Default() loadFromFile(cfg, configPath, SourceGlobal, nil) - LoadFromEnv(cfg) + require.NoError(t, LoadFromEnv(cfg)) require.NotNil(t, cfg.Hints) assert.False(t, *cfg.Hints, "env should override file") @@ -1042,7 +1231,7 @@ func TestEnvInvalidBoolIgnored(t *testing.T) { os.Setenv("BASECAMP_HINTS", "banana") cfg := Default() - LoadFromEnv(cfg) + require.NoError(t, LoadFromEnv(cfg)) assert.Nil(t, cfg.Hints, "invalid env bool should leave pointer nil") } diff --git a/internal/tui/workspace/session.go b/internal/tui/workspace/session.go index fe441d146..223160609 100644 --- a/internal/tui/workspace/session.go +++ b/internal/tui/workspace/session.go @@ -2,6 +2,7 @@ package workspace import ( "context" + "fmt" "path/filepath" "strconv" "sync" @@ -39,7 +40,20 @@ type Session struct { } // NewSession creates a session from the fully-initialized App. -func NewSession(app *appctx.App) *Session { +// It fails closed when llm_endpoint is invalid for the configured provider: +// this is the only path that consumes llm_endpoint (via DetectProvider), so +// validation happens here rather than in root command setup, where it would +// brick unrelated commands over a value they never use. +func NewSession(app *appctx.App) (*Session, error) { + if err := summarize.ValidateEndpoint( + app.Config.LLMEndpoint, app.Config.LLMProvider, app.Config.LLMAPIKey, + ); err != nil { + source := app.Config.Sources["llm_endpoint"] + if source == "" { + source = "unknown" + } + return nil, fmt.Errorf("llm_endpoint (%s): %w\nFix with: basecamp config unset llm_endpoint", source, err) + } ctx, cancel := context.WithCancel(context.Background()) ms := data.NewMultiStore(app.SDK) s := &Session{ @@ -87,7 +101,7 @@ func NewSession(app *appctx.App) *Session { } s.summarizer = summarize.NewSummarizer(provider, cache, maxConc) - return s + return s, nil } // App returns the underlying appctx.App. diff --git a/internal/tui/workspace/session_test.go b/internal/tui/workspace/session_test.go new file mode 100644 index 000000000..99198d950 --- /dev/null +++ b/internal/tui/workspace/session_test.go @@ -0,0 +1,55 @@ +package workspace + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/config" +) + +// newSessionTestConfig builds an isolated config for NewSession tests. +func newSessionTestConfig(t *testing.T) *config.Config { + t.Helper() + cfg := config.Default() + cfg.CacheDir = t.TempDir() + return cfg +} + +// NewSession is the sole consumer of llm_endpoint (via summarize.DetectProvider), +// so it must fail closed on an endpoint that would leak llm_api_key — with the +// same repair-hint error the root command used to emit at startup. +func TestNewSessionRejectsBadLLMEndpoint(t *testing.T) { + cfg := newSessionTestConfig(t) + cfg.LLMProvider = "openai" + cfg.LLMAPIKey = "secret" + cfg.LLMEndpoint = "http://remote-host:1234" + cfg.Sources["llm_endpoint"] = "env" + + session, err := NewSession(appctx.NewApp(cfg)) + + require.Error(t, err) + assert.Nil(t, session) + assert.Contains(t, err.Error(), "llm_endpoint (env)") + assert.Contains(t, err.Error(), "Fix with: basecamp config unset llm_endpoint") +} + +// A malformed endpoint for a provider that never consumes it must not block +// the session — mirroring the provider exemption in summarize.ValidateEndpoint. +// anthropic ignores the custom endpoint entirely, so a stale malformed value +// must not lock the LLM path out. (ollama, by contrast, does consume the +// endpoint and so is not exempt — see TestValidateEndpoint.) +func TestNewSessionAllowsUnconsumedLLMEndpoint(t *testing.T) { + cfg := newSessionTestConfig(t) + cfg.LLMProvider = "anthropic" + cfg.LLMAPIKey = "secret" + cfg.LLMEndpoint = "file:///etc/passwd" + + session, err := NewSession(appctx.NewApp(cfg)) + + require.NoError(t, err) + require.NotNil(t, session) + session.Shutdown() +} diff --git a/internal/tui/workspace/summarize/provider.go b/internal/tui/workspace/summarize/provider.go index cd9ce6733..323735477 100644 --- a/internal/tui/workspace/summarize/provider.go +++ b/internal/tui/workspace/summarize/provider.go @@ -2,8 +2,12 @@ package summarize import ( "context" + "fmt" "net/http" "time" + + "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/hostutil" ) // Provider generates summaries via an LLM or similar service. @@ -11,6 +15,69 @@ type Provider interface { Complete(ctx context.Context, prompt string, maxTokens int) (string, error) } +// ValidateEndpoint validates llm_endpoint only for providers that could +// send llm_api_key to it: the scheme/host structural check plus an HTTPS gate +// when a credential is present. It lives here, next to DetectProvider, because +// this package owns the provider→endpoint routing it reasons about — and it +// must run before DetectProvider on every LLM path, since config keeps file- +// and env-sourced endpoints even if malformed. llm_endpoint is consumed ONLY +// on this path, so callers elsewhere (e.g. root command setup) must not gate +// unrelated commands on it. +// +// The provider exemption runs FIRST, before any structural check. Providers +// that neither send llm_api_key to llm_endpoint nor consume the endpoint at +// all skip ALL validation: "apple", "none", and "disabled" transmit no key +// and ignore the endpoint; "anthropic" ignores the custom endpoint entirely +// (NewAnthropicProvider takes no endpoint and always calls +// https://api.anthropic.com); and "auto"/"" auto-detect only Apple or a +// hardcoded localhost Ollama (autoDetectProvider never receives the endpoint +// or key). Rejecting a malformed endpoint for these providers would block the +// LLM path over a value that is never consumed — e.g. a stale llm_endpoint +// left over from a previous provider — a repair lockout with no security +// payoff. +// +// "ollama" is credential-less but DOES consume llm_endpoint (OllamaProvider +// posts to endpoint+"/api/generate"), so it does NOT skip validation: it falls +// through to the structural IsHTTPURL check, which fails closed on a malformed +// endpoint (file://, hostless, non-http(s)) at config time rather than deep in +// the summarizer at use time. But ollama IS exempt from the HTTPS/apiKey gate: +// OllamaProvider never transmits llm_api_key (DetectProvider calls +// NewOllamaProvider(endpoint, model), dropping the key), yet ValidateEndpoint +// receives the RAW Config.LLMAPIKey, which may be a stale value left from a +// previous provider. Gating ollama on that key would reject a plain-HTTP LAN +// Ollama (http://192.168.x.x:11434) and block the TUI over a secret that is +// never sent, so the apiKey branch below explicitly skips ollama — it stays +// endpoint-consuming but credential-less, subject only to the structural check. +// +// For "openai" (which sends the key to the configured endpoint) and any +// unknown provider name (fail closed on names we can't reason about), the +// structural check rejects non-http(s)/hostless endpoints, and when an +// llm_api_key is present the endpoint must also pass RequireSecureURL so the +// secret can't leak in cleartext to a non-localhost http:// endpoint. +// RequireSecureURL only blocks http:// for non-localhost — it would let +// file://, ssh:// etc. through — so the IsHTTPURL scheme/host check runs even +// without a key. An empty endpoint is a no-op. +func ValidateEndpoint(endpoint, provider, apiKey string) error { + if endpoint == "" { + return nil + } + switch provider { + case "apple", "none", "disabled", "anthropic", "auto", "": + // These providers never send the key to llm_endpoint and never consume + // it — apple/none/disabled are credential-less and ignore the endpoint, + // anthropic ignores the custom endpoint, and auto/"" detect only local + // providers without endpoint or key — so skip all endpoint validation. + return nil + } + if !config.IsHTTPURL(endpoint) { + return fmt.Errorf("must be an http:// or https:// URL with a host") + } + if apiKey != "" && provider != "ollama" { + return hostutil.RequireSecureURL(endpoint) + } + return nil +} + // DetectProvider returns the best available provider based on configuration. func DetectProvider(providerName, endpoint, apiKey, model string) Provider { switch providerName { diff --git a/internal/tui/workspace/summarize/provider_test.go b/internal/tui/workspace/summarize/provider_test.go new file mode 100644 index 000000000..19fdb8687 --- /dev/null +++ b/internal/tui/workspace/summarize/provider_test.go @@ -0,0 +1,83 @@ +package summarize + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestValidateEndpoint exercises ValidateEndpoint, the fail-closed gate that +// runs at llm_endpoint's sole point of consumption (workspace.NewSession, +// just before DetectProvider). Providers that neither send llm_api_key to +// llm_endpoint nor consume the endpoint (apple, none, disabled, anthropic, +// auto, "") skip ALL validation — including the structural scheme/host check — +// so a stale or malformed endpoint they never consume can't lock the LLM path +// out. ollama is credential-less but DOES consume the endpoint, so it runs the +// structural check (rejecting malformed endpoints) while its always-empty key +// keeps a plain-HTTP LAN endpoint valid. For openai (which sends the key to +// the endpoint) and unknown provider names, the structural check runs, plus +// the HTTPS gate when a key is present. +func TestValidateEndpoint(t *testing.T) { + tests := []struct { + name string + endpoint string + provider string + apiKey string + wantOK bool + }{ + // Empty provider auto-detects local-only providers and never consumes + // the endpoint, so even structurally malformed values are accepted. + {"file scheme accepted for empty provider", "file:///etc/passwd", "", "", true}, + {"ssh scheme accepted for empty provider", "ssh://host", "", "", true}, + {"hostless https accepted for empty provider", "https:example.com", "", "", true}, + {"http remote with credential accepted (empty provider auto-detects local only)", "http://remote-host:1234", "", "secret", true}, + {"http remote without credential accepted", "http://remote-host:1234", "", "", true}, + {"https remote accepted", "https://remote-host", "", "", true}, + {"https remote with credential accepted", "https://remote-host", "", "secret", true}, + {"localhost with credential accepted", "http://localhost:11434", "", "secret", true}, + {"empty endpoint no-op", "", "", "", true}, + {"empty endpoint with key no-op", "", "", "secret", true}, + // ollama is credential-less but consumes the endpoint, so it runs the + // structural check. A plain-HTTP LAN endpoint is valid (empty key never + // trips the HTTPS gate); malformed endpoints fail closed at config time. + {"ollama plain-http LAN accepted", "http://192.168.1.5:11434", "ollama", "", true}, + // A stale llm_api_key from a prior provider must NOT gate ollama: it never + // transmits the key, so the plain-HTTP LAN endpoint stays valid and the TUI + // is not blocked over a secret that is never sent. + {"ollama plain-http LAN with stale key accepted", "http://192.168.1.10:11434", "ollama", "stale-key", true}, + {"ollama empty endpoint no-op", "", "ollama", "", true}, + {"ollama file scheme rejected", "file:///x", "ollama", "", false}, + {"ollama hostless https rejected", "https:example.com", "ollama", "", false}, + {"ollama hostless http rejected", "http://:11434", "ollama", "", false}, + {"ollama ssh scheme rejected", "ssh://host", "ollama", "", false}, + // Anthropic ignores the custom endpoint, so the key never reaches it. + {"anthropic remote http with key accepted", "http://remote:1234", "anthropic", "secret", true}, + // Auto-detection never passes the endpoint or key to a provider. + {"auto remote http with key accepted", "http://remote:1234", "auto", "secret", true}, + // Endpoint-unused providers skip the structural check too: a malformed + // leftover value must not block a path that never consumes it. + {"anthropic file scheme accepted", "file:///etc/passwd", "anthropic", "secret", true}, + {"disabled hostless https accepted", "https:example.com", "disabled", "secret", true}, + {"auto hostless https accepted", "https:example.com", "auto", "", true}, + // openai sends the key to the endpoint: key still gates remote http. + {"openai remote http with key rejected", "http://remote:1234", "openai", "secret", false}, + // openai runs the structural check regardless of key presence. + {"openai hostless https with key rejected", "https:example.com", "openai", "secret", false}, + {"openai file scheme rejected", "file:///etc/passwd", "openai", "", false}, + {"openai https remote with key accepted", "https://remote-host", "openai", "secret", true}, + // Unknown provider names fail closed like openai. + {"unknown provider hostless https rejected", "https:example.com", "mystery", "", false}, + {"unknown provider remote http with key rejected", "http://remote:1234", "mystery", "secret", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateEndpoint(tt.endpoint, tt.provider, tt.apiKey) + if tt.wantOK { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +}