From 883e90bb624ffe35c1e5474f3f00a8bbf670bc12 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Tue, 8 Sep 2026 16:08:06 +0100 Subject: [PATCH 01/10] fix!: stop loading the Kosli config file from the working directory The --config-file default fell back to the bare name "kosli" whenever $HOME/.kosli.yml was absent, which viper resolves against the current working directory. A checkout could therefore ship kosli.yml and set any flag for a command run with a real KOSLI_API_TOKEN: host or http-proxy sends the bearer token to a host of the repository's choosing, and the snapshot k8s kubeconfig flag runs a kubeconfig exec credential plugin, which --dry-run does not prevent. The default is now always the home config file, whether or not it exists. A config file in the working directory is loaded only when the user names it with --config-file or KOSLI_CONFIG_FILE. When no home directory can be resolved there is no default config file at all, rather than a bare name that reopens the working-directory search, and `kosli config` says so instead of writing a config file into the working directory. A warning names an ignored working-directory config so the change is not silent. It fires only for a file that sets org, api-token or host: a kosli.yml in a repository root is far more often a flow template, which was never loaded as CLI config. Refs kosli-dev/server#6778, kosli-dev/server#6779 BREAKING CHANGE: a kosli.{yaml,yml,json,toml} file in the current working directory is no longer loaded automatically. Pass --config-file, set KOSLI_CONFIG_FILE, or move the settings to $HOME/.kosli.yml with `kosli config`. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/config.go | 6 + cmd/kosli/configWorkingDir_test.go | 174 +++++++++++++++++++++++++++++ cmd/kosli/root.go | 105 ++++++++++++----- 3 files changed, 258 insertions(+), 27 deletions(-) create mode 100644 cmd/kosli/configWorkingDir_test.go diff --git a/cmd/kosli/config.go b/cmd/kosli/config.go index 8d64250fb..38b0b6068 100644 --- a/cmd/kosli/config.go +++ b/cmd/kosli/config.go @@ -79,6 +79,12 @@ func newConfigCmd(out io.Writer) *cobra.Command { func (o *configOptions) run() error { path := defaultConfigFilePathFunc() + // An empty path means no home directory could be resolved. Continuing would + // write the config into the current working directory, which is never where + // the default config file belongs. + if path == "" { + return fmt.Errorf("setting default config failed. Could not determine your home directory. Set HOME, or use --config-file on each command instead") + } home := filepath.Dir(path) configFileName := filepath.Base(path) permissions := os.FileMode(0600) diff --git a/cmd/kosli/configWorkingDir_test.go b/cmd/kosli/configWorkingDir_test.go new file mode 100644 index 000000000..b224fd8b6 --- /dev/null +++ b/cmd/kosli/configWorkingDir_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/suite" +) + +// WorkingDirConfigTestSuite covers the config file the CLI must NOT load: one +// sitting in the current working directory that the user never named. Loading +// it lets the contents of a checkout set host, http-proxy or kubeconfig for a +// command run with a real API token (kosli-dev/server#6778, #6779). +type WorkingDirConfigTestSuite struct { + suite.Suite +} + +func (suite *WorkingDirConfigTestSuite) TearDownTest() { + defaultConfigFilePathFunc = (&RealConfigGetter{}).defaultConfigFilePath + global = new(GlobalOpts) +} + +// stubHomeConfig points the default config path at a file that does not exist, +// which is the state that used to trigger the working-directory fallback. +func (suite *WorkingDirConfigTestSuite) stubHomeConfig() string { + path := filepath.Join(suite.T().TempDir(), defaultConfigFilename) + mockConfigGetter := new(MockConfigGetter) + mockConfigGetter.Mock.On("defaultConfigFilePath").Return(path) + defaultConfigFilePathFunc = mockConfigGetter.defaultConfigFilePath + return path +} + +// chdirWithConfig writes content to a named config file in a temp directory and +// makes that directory the working directory for the test. +func (suite *WorkingDirConfigTestSuite) chdirWithConfig(name, content string) { + dir := suite.T().TempDir() + suite.Require().NoError(os.WriteFile(filepath.Join(dir, name), []byte(content), 0600)) + suite.T().Chdir(dir) +} + +func (suite *WorkingDirConfigTestSuite) TestDefaultIsHomePathWhenHomeConfigIsAbsent() { + path := suite.stubHomeConfig() + + _, _, _, _, err := executeCommandC("version") + + suite.Require().NoError(err) + suite.Equal(path, global.ConfigFile, + "the default config file must be the home path, not a bare name that resolves to the working directory") +} + +func (suite *WorkingDirConfigTestSuite) TestWorkingDirConfigIsNotLoaded() { + for _, name := range []string{"kosli.yml", "kosli.yaml", "kosli.json", "kosli.toml"} { + suite.Run(name, func() { + defer func() { global = new(GlobalOpts) }() + suite.stubHomeConfig() + content := "host: https://attacker.example\n" + switch filepath.Ext(name) { + case ".json": + content = `{"host": "https://attacker.example"}` + case ".toml": + content = `host = "https://attacker.example"` + } + suite.chdirWithConfig(name, content) + + _, _, _, _, err := executeCommandC("version") + + suite.Require().NoError(err) + suite.Equal(defaultHost, global.Host, + "a config file in the working directory must not set the host") + }) + } +} + +func (suite *WorkingDirConfigTestSuite) TestExplicitConfigFileFlagStillLoadsWorkingDirConfig() { + suite.stubHomeConfig() + suite.chdirWithConfig("kosli.yml", "host: https://named.example\n") + + _, _, _, stderr, err := executeCommandC("version --config-file kosli.yml") + + suite.Require().NoError(err) + suite.Equal("https://named.example", global.Host, + "a config file the user names must still be loaded") + suite.NotContains(stderr, "no longer loaded automatically", + "naming the file is the supported way to load it, so there is nothing to warn about") +} + +func (suite *WorkingDirConfigTestSuite) TestConfigFileEnvVarStillLoadsWorkingDirConfig() { + suite.stubHomeConfig() + suite.chdirWithConfig("kosli.yml", "host: https://named.example\n") + suite.T().Setenv("KOSLI_CONFIG_FILE", "kosli.yml") + + _, _, _, stderr, err := executeCommandC("version") + + suite.Require().NoError(err) + suite.Equal("https://named.example", global.Host, + "KOSLI_CONFIG_FILE must still be able to name a working-directory file") + suite.NotContains(stderr, "no longer loaded automatically") +} + +// TestEmptyDefaultConfigPathLoadsNothing covers the case where no home +// directory can be resolved, so defaultConfigFilePath returns no path at all. +// The working directory must not become the fallback search location. The empty +// path is injected because homedir.Dir cannot be made to fail portably: on +// macOS it falls back to dscl even with HOME unset. +func (suite *WorkingDirConfigTestSuite) TestEmptyDefaultConfigPathLoadsNothing() { + mockConfigGetter := new(MockConfigGetter) + mockConfigGetter.Mock.On("defaultConfigFilePath").Return("") + defaultConfigFilePathFunc = mockConfigGetter.defaultConfigFilePath + suite.chdirWithConfig("kosli.yml", "host: https://attacker.example\n") + + _, _, _, _, err := executeCommandC("version") + + suite.Require().NoError(err) + suite.Equal(defaultHost, global.Host, + "with no default config path the working directory must not be searched instead") +} + +func (suite *WorkingDirConfigTestSuite) TestWarnsAboutIgnoredWorkingDirConfig() { + cases := []struct { + name string + content string + wantWarn bool + }{ + {name: "host", content: "host: https://attacker.example\n", wantWarn: true}, + {name: "org", content: "org: some-org\n", wantWarn: true}, + {name: "api token", content: "api-token: abc123\n", wantWarn: true}, + {name: "documented uppercase keys", content: "ORG: some-org\nAPI-TOKEN: abc123\n", wantWarn: true}, + // A kosli.yml in a repository root is far more often a flow template, + // which was never loaded as CLI config, so it must stay silent. + {name: "flow template shape", content: "trail:\n artifacts:\n - name: nginx\n", wantWarn: false}, + {name: "unparsable file", content: "\tnot: [valid\n", wantWarn: false}, + } + for _, tc := range cases { + suite.Run(tc.name, func() { + defer func() { global = new(GlobalOpts) }() + suite.stubHomeConfig() + suite.chdirWithConfig("kosli.yml", tc.content) + + _, _, _, stderr, err := executeCommandC("version") + + suite.Require().NoError(err) + if tc.wantWarn { + suite.Contains(stderr, "kosli.yml") + suite.Contains(stderr, "no longer loaded automatically") + suite.Contains(stderr, "--config-file kosli.yml", + "the warning must name the fix, not just the problem") + } else { + suite.NotContains(stderr, "no longer loaded automatically") + } + }) + } +} + +func TestWorkingDirConfigTestSuite(t *testing.T) { + suite.Run(t, new(WorkingDirConfigTestSuite)) +} + +// TestConfigCommandFailsWithoutHomeDirectory pins the other side of an empty +// default config path: `kosli config` must say so rather than silently writing +// a config file into the current working directory. +func (suite *WorkingDirConfigTestSuite) TestConfigCommandFailsWithoutHomeDirectory() { + mockConfigGetter := new(MockConfigGetter) + mockConfigGetter.Mock.On("defaultConfigFilePath").Return("") + defaultConfigFilePathFunc = mockConfigGetter.defaultConfigFilePath + suite.T().Chdir(suite.T().TempDir()) + + _, _, _, _, err := executeCommandC("config --org some-org") + + suite.Require().Error(err) + suite.Contains(err.Error(), "Could not determine your home directory") + _, statErr := os.Stat(defaultConfigFilename) + suite.Require().Error(statErr, "no config file may be written into the working directory") +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index f2d180ce3..2d20a1e4a 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -369,18 +369,57 @@ func (r *RealConfigGetter) defaultConfigFilePath() string { return filepath.Join(home, defaultConfigFilename) } - return "kosli" // for backward compatibility with old default config location + // With no resolvable home directory there is no default config file. A bare + // name here would make viper search the current working directory, which is + // the whole problem getConfigFileFlagDefault exists to avoid. + return "" } // defaultConfigFilePathFunc is a variable holding the implementation of defaultConfigFilePath var defaultConfigFilePathFunc = (&RealConfigGetter{}).defaultConfigFilePath -func getConfigFileFlagDefault() string { - defaultPath := defaultConfigFilePathFunc() - if _, err := os.Stat(defaultPath); err == nil { - return defaultPath +// workingDirConfigNames are the config file names the CLI used to load +// implicitly from the current working directory, before that became +// kosli-dev/server#6778 and #6779. +var workingDirConfigNames = []string{"kosli.yaml", "kosli.yml", "kosli.json", "kosli.toml"} + +// warnAboutIgnoredWorkingDirConfig reports a config file in the current working +// directory that an earlier CLI would have loaded, so that the change does not +// break a pipeline silently. +// +// Only a file that sets a global setting is reported. A kosli.yml in a +// repository root is far more often a flow template, which was never loaded as +// CLI config, and warning about those would be pure noise. +func warnAboutIgnoredWorkingDirConfig() { + for _, name := range workingDirConfigNames { + if _, err := os.Stat(name); err != nil { + continue + } + + v := viper.New() + v.SetConfigFile(name) + if err := v.ReadInConfig(); err != nil { + continue + } + if !v.IsSet("org") && !v.IsSet("api-token") && !v.IsSet("host") { + continue + } + + logger.Warn("config file [%s] in the current directory is no longer loaded automatically. To keep using it, pass --config-file %s or set KOSLI_CONFIG_FILE=%s. To apply its settings to every command, move them to your home config file with 'kosli config'.", name, name, name) + return } - return "kosli" // for backward compatibility with old default config location +} + +// getConfigFileFlagDefault returns the default --config-file value, which is +// always the home config file whether or not that file exists. It used to fall +// back to the bare name "kosli", which viper resolves against the current +// working directory: a repository could then set host, http-proxy or kubeconfig +// for a command run with a real API token, redirecting the bearer token or +// executing a kubeconfig credential plugin (kosli-dev/server#6778, #6779). +// A config file in the working directory is now loaded only when the user names +// it with --config-file or KOSLI_CONFIG_FILE. +func getConfigFileFlagDefault() string { + return defaultConfigFilePathFunc() } func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { @@ -530,38 +569,50 @@ func initialize(cmd *cobra.Command, out, errOut io.Writer) error { // we load the config file before we bind env vars to flags, // so we check for the config file env var separately here configFlag := cmd.Flags().Lookup("config-file") + namedByUser := configFlag.Changed if !configFlag.Changed { // A variable set to the empty string reports as present, but it names no // file. Overriding the default with it loads no config file at all, // silently dropping org, api-token and every other configured default. if path, exists := os.LookupEnv("KOSLI_CONFIG_FILE"); exists && path != "" { global.ConfigFile = path + namedByUser = true } } - dir, file := filepath.Split(global.ConfigFile) - file = strings.TrimSuffix(file, filepath.Ext(file)) - // Set the base name of the config file, without the file extension. - v.SetConfigName(file) + if global.ConfigFile != "" { + dir, file := filepath.Split(global.ConfigFile) + file = strings.TrimSuffix(file, filepath.Ext(file)) - // Set as many paths as you like where viper should look for the - // config file. By default, we are looking in the current working directory. - if dir == "" { - dir = "." - } - v.AddConfigPath(dir) - - // Attempt to read the config file, gracefully ignoring errors - // caused by a config file not being found. Return an error - // if we cannot parse the config file. - logger.Debug("processing config file [%s]", global.ConfigFile) - if err := v.ReadInConfig(); err != nil { - // It's okay if there isn't a config file - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - return fmt.Errorf("failed to parse config file [%s] : %v", global.ConfigFile, err) - } else { - logger.Debug("config file [%s] not found. Skipping.", global.ConfigFile) + // Set the base name of the config file, without the file extension. + v.SetConfigName(file) + + // A relative path is resolved against the current working directory, + // which is what a user asks for by naming one. The default is absolute, + // so it never reaches that case. + if dir == "" { + dir = "." } + v.AddConfigPath(dir) + + // Attempt to read the config file, gracefully ignoring errors + // caused by a config file not being found. Return an error + // if we cannot parse the config file. + logger.Debug("processing config file [%s]", global.ConfigFile) + if err := v.ReadInConfig(); err != nil { + // It's okay if there isn't a config file + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return fmt.Errorf("failed to parse config file [%s] : %v", global.ConfigFile, err) + } else { + logger.Debug("config file [%s] not found. Skipping.", global.ConfigFile) + } + } + } else { + logger.Debug("no default config file location could be determined. Skipping.") + } + + if !namedByUser { + warnAboutIgnoredWorkingDirConfig() } // When we bind flags to environment variables expect that the // environment variables are prefixed, e.g. a flag like --namespace From d0357d896ea69376957a0e09c90f394d0aebe6d4 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 9 Sep 2026 10:39:53 +0100 Subject: [PATCH 02/10] fix(config): warn on any ignored working-directory config, not a key set Review follow-ups on the working-directory config removal. Derive the candidate names from viper.SupportedExts rather than listing four by hand. viper searched a "kosli" config name in every extension it supports and loaded the first match, so kosli.env and kosli.dotenv were also working redirects and went unwarned. The six formats viper lists but has no decoder for made every command fail with "failed to parse config file" before this change, so they stay silent: nothing could have depended on them. Warn on any parseable file except a flow template, instead of on a chosen set of keys. Any key set narrow enough to write down lets some real config break in silence, which is the one thing the warning exists to prevent: a file holding only http-proxy, kubeconfig or flow was ignored and silent. A top-level trail or artifacts key marks a flow template, which is passed with --template-file and was never loaded as CLI config. Cap the file at 1 MB before parsing. It is repository-controlled and read on every command run in that directory. Warn after the flag binding so KOSLI_QUIET suppresses the message exactly as --quiet does, and pin that the home config file is still loaded, which no test covered. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/config.go | 3 +- cmd/kosli/configWorkingDir_test.go | 88 ++++++++++++++++++++++++++++-- cmd/kosli/root.go | 47 ++++++++++++---- 3 files changed, 121 insertions(+), 17 deletions(-) diff --git a/cmd/kosli/config.go b/cmd/kosli/config.go index 38b0b6068..923519410 100644 --- a/cmd/kosli/config.go +++ b/cmd/kosli/config.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "io" "os" @@ -83,7 +84,7 @@ func (o *configOptions) run() error { // write the config into the current working directory, which is never where // the default config file belongs. if path == "" { - return fmt.Errorf("setting default config failed. Could not determine your home directory. Set HOME, or use --config-file on each command instead") + return errors.New("setting default config failed. Could not determine your home directory. Set HOME, or pass --config-file to the commands you run") } home := filepath.Dir(path) configFileName := filepath.Base(path) diff --git a/cmd/kosli/configWorkingDir_test.go b/cmd/kosli/configWorkingDir_test.go index b224fd8b6..9bb2c80a5 100644 --- a/cmd/kosli/configWorkingDir_test.go +++ b/cmd/kosli/configWorkingDir_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/suite" @@ -50,7 +51,7 @@ func (suite *WorkingDirConfigTestSuite) TestDefaultIsHomePathWhenHomeConfigIsAbs } func (suite *WorkingDirConfigTestSuite) TestWorkingDirConfigIsNotLoaded() { - for _, name := range []string{"kosli.yml", "kosli.yaml", "kosli.json", "kosli.toml"} { + for _, name := range []string{"kosli.yml", "kosli.yaml", "kosli.json", "kosli.toml", "kosli.properties", "kosli.env"} { suite.Run(name, func() { defer func() { global = new(GlobalOpts) }() suite.stubHomeConfig() @@ -60,6 +61,8 @@ func (suite *WorkingDirConfigTestSuite) TestWorkingDirConfigIsNotLoaded() { content = `{"host": "https://attacker.example"}` case ".toml": content = `host = "https://attacker.example"` + case ".properties", ".env": + content = "host=https://attacker.example\n" } suite.chdirWithConfig(name, content) @@ -126,10 +129,18 @@ func (suite *WorkingDirConfigTestSuite) TestWarnsAboutIgnoredWorkingDirConfig() {name: "org", content: "org: some-org\n", wantWarn: true}, {name: "api token", content: "api-token: abc123\n", wantWarn: true}, {name: "documented uppercase keys", content: "ORG: some-org\nAPI-TOKEN: abc123\n", wantWarn: true}, + // The settings the advisories were about, and the ones a hand-picked + // key set was most likely to miss. + {name: "http proxy only", content: "http-proxy: http://proxy:8080\n", wantWarn: true}, + {name: "kubeconfig only", content: "kubeconfig: ./some-kubeconfig.yml\n", wantWarn: true}, + {name: "flow only", content: "flow: some-flow\n", wantWarn: true}, // A kosli.yml in a repository root is far more often a flow template, // which was never loaded as CLI config, so it must stay silent. - {name: "flow template shape", content: "trail:\n artifacts:\n - name: nginx\n", wantWarn: false}, + {name: "flow template shape", content: "version: 1\ntrail:\n attestations:\n - name: pull-request\n", wantWarn: false}, + {name: "flow template without version", content: "trail:\n artifacts:\n - name: nginx\n", wantWarn: false}, + {name: "artifacts only template", content: "artifacts:\n - name: nginx\n", wantWarn: false}, {name: "unparsable file", content: "\tnot: [valid\n", wantWarn: false}, + {name: "empty file", content: "", wantWarn: false}, } for _, tc := range cases { suite.Run(tc.name, func() { @@ -152,10 +163,6 @@ func (suite *WorkingDirConfigTestSuite) TestWarnsAboutIgnoredWorkingDirConfig() } } -func TestWorkingDirConfigTestSuite(t *testing.T) { - suite.Run(t, new(WorkingDirConfigTestSuite)) -} - // TestConfigCommandFailsWithoutHomeDirectory pins the other side of an empty // default config path: `kosli config` must say so rather than silently writing // a config file into the current working directory. @@ -172,3 +179,72 @@ func (suite *WorkingDirConfigTestSuite) TestConfigCommandFailsWithoutHomeDirecto _, statErr := os.Stat(defaultConfigFilename) suite.Require().Error(statErr, "no config file may be written into the working directory") } + +// TestHomeConfigIsStillLoaded pins the primary load path. Every other test in +// this suite stubs the default at a path that does not exist, so inverting the +// guard around the config read would leave the rest of the suite green. +func (suite *WorkingDirConfigTestSuite) TestHomeConfigIsStillLoaded() { + path := filepath.Join(suite.T().TempDir(), defaultConfigFilename) + suite.Require().NoError(os.WriteFile(path, []byte("host: https://home.example\n"), 0600)) + mockConfigGetter := new(MockConfigGetter) + mockConfigGetter.Mock.On("defaultConfigFilePath").Return(path) + defaultConfigFilePathFunc = mockConfigGetter.defaultConfigFilePath + + _, _, _, _, err := executeCommandC("version") + + suite.Require().NoError(err) + suite.Equal("https://home.example", global.Host, + "the home config file must still be loaded without the user naming it") +} + +// TestOversizedWorkingDirConfigIsNotParsed pins that the warning does not hand +// an arbitrarily large repository-controlled file to a parser. +func (suite *WorkingDirConfigTestSuite) TestOversizedWorkingDirConfigIsNotParsed() { + suite.stubHomeConfig() + padding := strings.Repeat("# padding\n", 200000) + suite.chdirWithConfig("kosli.yml", "org: some-org\n"+padding) + + _, _, _, stderr, err := executeCommandC("version") + + suite.Require().NoError(err) + suite.NotContains(stderr, "no longer loaded automatically", + "a file past the size ceiling must be skipped rather than parsed") +} + +// TestWarnsAboutIgnoredDotEnvConfig covers the two extensions beyond YAML/JSON +// that viper can actually decode. A kosli.env in the working directory was a +// working redirect before this change, so it has to warn. +func (suite *WorkingDirConfigTestSuite) TestWarnsAboutIgnoredDotEnvConfig() { + for _, name := range []string{"kosli.env", "kosli.dotenv"} { + suite.Run(name, func() { + defer func() { global = new(GlobalOpts) }() + suite.stubHomeConfig() + suite.chdirWithConfig(name, "host=https://attacker.example\n") + + _, _, _, stderr, err := executeCommandC("version") + + suite.Require().NoError(err) + suite.Equal(defaultHost, global.Host) + suite.Contains(stderr, name) + suite.Contains(stderr, "no longer loaded automatically") + }) + } +} + +// TestUndecodableWorkingDirConfigIsSilent pins that a format viper lists but has +// no decoder for stays quiet. Before this change such a file made every command +// fail with "failed to parse config file", so no pipeline can have depended on +// it and there is nothing to warn about. +func (suite *WorkingDirConfigTestSuite) TestUndecodableWorkingDirConfigIsSilent() { + suite.stubHomeConfig() + suite.chdirWithConfig("kosli.properties", "org=some-org\n") + + _, _, _, stderr, err := executeCommandC("version") + + suite.Require().NoError(err, "an undecodable file must no longer fail the command") + suite.NotContains(stderr, "no longer loaded automatically") +} + +func TestWorkingDirConfigTestSuite(t *testing.T) { + suite.Run(t, new(WorkingDirConfigTestSuite)) +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 2d20a1e4a..9181a2321 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "slices" "strings" "github.com/kosli-dev/cli/internal/docgen" @@ -380,19 +381,39 @@ var defaultConfigFilePathFunc = (&RealConfigGetter{}).defaultConfigFilePath // workingDirConfigNames are the config file names the CLI used to load // implicitly from the current working directory, before that became -// kosli-dev/server#6778 and #6779. -var workingDirConfigNames = []string{"kosli.yaml", "kosli.yml", "kosli.json", "kosli.toml"} +// kosli-dev/server#6778 and #6779. viper searched a "kosli" config name in +// every extension it supports and loaded the first match, so the list is +// derived from viper, in viper's own order. +var workingDirConfigNames = func() []string { + names := make([]string, 0, len(viper.SupportedExts)) + for _, ext := range viper.SupportedExts { + names = append(names, "kosli."+ext) + } + return names +}() + +// flowTemplateKeys are top-level keys that identify a flow template rather than +// a CLI config file. A template is passed with --template-file and was never +// loaded as CLI config, so an ignored one is not a broken pipeline. +var flowTemplateKeys = []string{"trail", "artifacts"} + +// maxWorkingDirConfigSize caps what the warning is willing to parse. The file is +// repository-controlled and read on every command run in that directory, and no +// real config file comes close to this. +const maxWorkingDirConfigSize = 1 << 20 // warnAboutIgnoredWorkingDirConfig reports a config file in the current working // directory that an earlier CLI would have loaded, so that the change does not // break a pipeline silently. // -// Only a file that sets a global setting is reported. A kosli.yml in a -// repository root is far more often a flow template, which was never loaded as -// CLI config, and warning about those would be pure noise. +// Every parseable file is reported except a flow template. Reporting on the file +// rather than on a chosen set of keys is deliberate: any key set narrow enough +// to be worth writing down would let some real config break in silence, which is +// the one thing this warning exists to prevent. func warnAboutIgnoredWorkingDirConfig() { for _, name := range workingDirConfigNames { - if _, err := os.Stat(name); err != nil { + info, err := os.Stat(name) + if err != nil || info.IsDir() || info.Size() > maxWorkingDirConfigSize { continue } @@ -401,7 +422,10 @@ func warnAboutIgnoredWorkingDirConfig() { if err := v.ReadInConfig(); err != nil { continue } - if !v.IsSet("org") && !v.IsSet("api-token") && !v.IsSet("host") { + if len(v.AllKeys()) == 0 { + continue + } + if slices.ContainsFunc(flowTemplateKeys, v.IsSet) { continue } @@ -611,9 +635,6 @@ func initialize(cmd *cobra.Command, out, errOut io.Writer) error { logger.Debug("no default config file location could be determined. Skipping.") } - if !namedByUser { - warnAboutIgnoredWorkingDirConfig() - } // When we bind flags to environment variables expect that the // environment variables are prefixed, e.g. a flag like --namespace // binds to an environment variable KOSLI_NAMESPACE. This helps @@ -643,6 +664,12 @@ func initialize(cmd *cobra.Command, out, errOut io.Writer) error { logger.Debug("--quiet is ignored because --debug is set") } + // Warned after the flag binding above so that KOSLI_QUIET suppresses this + // message exactly as --quiet does. + if !namedByUser { + warnAboutIgnoredWorkingDirConfig() + } + var err error kosliClient, err = requests.NewKosliClient(global.HttpProxy, global.MaxAPIRetries, global.Debug, logger) if err != nil { From 91574260ed1827c45a7ab00247ccac8b1a12fa82 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 9 Sep 2026 11:29:46 +0100 Subject: [PATCH 03/10] fix(config): scope the warning to who lost behaviour, and detect templates by shape Two more review follow-ups. Warn only while the home config file is absent. The old default fell back to the working directory only in that state, so a user who has $HOME/.kosli.yml never loaded the working-directory file. For them the message was both wrong and harmful: --config-file replaces the home config rather than adding to it, so following the advice would have dropped their org and api token. The gate is evaluated before bindFlags, which can overwrite global.ConfigFile from a config file of its own. Identify a flow template by shape rather than by key name. trail and artifacts are CLI flags as well as template keys, so a config file holding trail: my-trail was ignored and silent, which is what the file-level check was chosen to avoid. A template's trail is a mapping and its artifacts a sequence, where the flags take a string, so the two are told apart without guessing. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/configWorkingDir_test.go | 25 +++++++++++++++++++++ cmd/kosli/root.go | 35 ++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/cmd/kosli/configWorkingDir_test.go b/cmd/kosli/configWorkingDir_test.go index 9bb2c80a5..5acd955d2 100644 --- a/cmd/kosli/configWorkingDir_test.go +++ b/cmd/kosli/configWorkingDir_test.go @@ -134,6 +134,10 @@ func (suite *WorkingDirConfigTestSuite) TestWarnsAboutIgnoredWorkingDirConfig() {name: "http proxy only", content: "http-proxy: http://proxy:8080\n", wantWarn: true}, {name: "kubeconfig only", content: "kubeconfig: ./some-kubeconfig.yml\n", wantWarn: true}, {name: "flow only", content: "flow: some-flow\n", wantWarn: true}, + // trail and artifacts are CLI flags as well as template keys. As a + // scalar they are config, so they must warn. + {name: "trail as a scalar", content: "trail: my-trail\n", wantWarn: true}, + {name: "artifacts as a scalar", content: "artifacts: my-artifact\n", wantWarn: true}, // A kosli.yml in a repository root is far more often a flow template, // which was never loaded as CLI config, so it must stay silent. {name: "flow template shape", content: "version: 1\ntrail:\n attestations:\n - name: pull-request\n", wantWarn: false}, @@ -245,6 +249,27 @@ func (suite *WorkingDirConfigTestSuite) TestUndecodableWorkingDirConfigIsSilent( suite.NotContains(stderr, "no longer loaded automatically") } +// TestNoWarningWhenHomeConfigExists pins that the warning is limited to the +// population that actually lost behaviour. The old default fell back to the +// working directory only when the home config file was absent, so a user who +// has one never loaded the working-directory file, and telling them to pass +// --config-file would replace their home config rather than restore anything. +func (suite *WorkingDirConfigTestSuite) TestNoWarningWhenHomeConfigExists() { + path := filepath.Join(suite.T().TempDir(), defaultConfigFilename) + suite.Require().NoError(os.WriteFile(path, []byte("host: https://home.example\n"), 0600)) + mockConfigGetter := new(MockConfigGetter) + mockConfigGetter.Mock.On("defaultConfigFilePath").Return(path) + defaultConfigFilePathFunc = mockConfigGetter.defaultConfigFilePath + suite.chdirWithConfig("kosli.yml", "org: some-org\n") + + _, _, _, stderr, err := executeCommandC("version") + + suite.Require().NoError(err) + suite.Equal("https://home.example", global.Host) + suite.NotContains(stderr, "no longer loaded automatically", + "a user with a home config file never loaded the working-directory file") +} + func TestWorkingDirConfigTestSuite(t *testing.T) { suite.Run(t, new(WorkingDirConfigTestSuite)) } diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 9181a2321..c1d7772e2 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -6,7 +6,6 @@ import ( "io" "os" "path/filepath" - "slices" "strings" "github.com/kosli-dev/cli/internal/docgen" @@ -392,10 +391,19 @@ var workingDirConfigNames = func() []string { return names }() -// flowTemplateKeys are top-level keys that identify a flow template rather than -// a CLI config file. A template is passed with --template-file and was never -// loaded as CLI config, so an ignored one is not a broken pipeline. -var flowTemplateKeys = []string{"trail", "artifacts"} +// isFlowTemplate reports whether a parsed file is a flow template rather than +// CLI config. A template is passed with --template-file and was never loaded as +// CLI config, so an ignored one is not a broken pipeline. The shapes tell them +// apart rather than the key names, because trail and artifacts are CLI flags +// too: a template's trail is a mapping and its artifacts a sequence, where the +// flags of those names take a string, so `trail: my-trail` is config and warns. +func isFlowTemplate(v *viper.Viper) bool { + if _, ok := v.Get("trail").(map[string]any); ok { + return true + } + _, ok := v.Get("artifacts").([]any) + return ok +} // maxWorkingDirConfigSize caps what the warning is willing to parse. The file is // repository-controlled and read on every command run in that directory, and no @@ -425,7 +433,7 @@ func warnAboutIgnoredWorkingDirConfig() { if len(v.AllKeys()) == 0 { continue } - if slices.ContainsFunc(flowTemplateKeys, v.IsSet) { + if isFlowTemplate(v) { continue } @@ -635,6 +643,19 @@ func initialize(cmd *cobra.Command, out, errOut io.Writer) error { logger.Debug("no default config file location could be determined. Skipping.") } + // The old default fell back to the working directory only while the home + // config file was absent, so only that population lost behaviour. A user who + // has one never loaded the working-directory file, and passing --config-file + // would replace their home config rather than restore anything. Evaluated + // here because bindFlags can overwrite global.ConfigFile from a config file + // of its own. An unresolvable home directory leaves the path empty, which + // does not stat, and did fall back. + workingDirConfigWasLoadable := false + if !namedByUser { + _, err := os.Stat(global.ConfigFile) + workingDirConfigWasLoadable = err != nil + } + // When we bind flags to environment variables expect that the // environment variables are prefixed, e.g. a flag like --namespace // binds to an environment variable KOSLI_NAMESPACE. This helps @@ -666,7 +687,7 @@ func initialize(cmd *cobra.Command, out, errOut io.Writer) error { // Warned after the flag binding above so that KOSLI_QUIET suppresses this // message exactly as --quiet does. - if !namedByUser { + if workingDirConfigWasLoadable { warnAboutIgnoredWorkingDirConfig() } From 3d2c424c6f85b872c419493287c2aea4a8e307cf Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 9 Sep 2026 11:47:46 +0100 Subject: [PATCH 04/10] docs(config): say in the help text that the current directory is not read The --config-file usage string and the `kosli config` description are what the generated CLI reference is built from, and neither mentioned the removed behaviour. A reader of `kosli list flows --help` had only the (default ...) suffix to tell them a kosli.yml beside them is no longer picked up. The `kosli config` precedence list also named $HOME/.kosli, which has never been the filename. Corrected here rather than left contradicting the path added on the line above it. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/config.go | 9 ++++++--- cmd/kosli/root.go | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/cmd/kosli/config.go b/cmd/kosli/config.go index 923519410..1a216cbd6 100644 --- a/cmd/kosli/config.go +++ b/cmd/kosli/config.go @@ -18,15 +18,18 @@ type configOptions struct { unSetKeys []string } -const configShortDesc = `Config global Kosli flags values and store them in $HOME/.kosli . ` +const configShortDesc = `Config global Kosli flags values and store them in $HOME/.kosli.yml . ` const configLongDesc = configShortDesc + ` Flag values are determined in the following order (highest precedence first): - command line flags on each executed command. - environment variables. -- custom config file provided with --config-file flag. -- default config file in $HOME/.kosli +- custom config file provided with the --config-file flag or the KOSLI_CONFIG_FILE env var. +- default config file in $HOME/.kosli.yml + +A config file in the directory a command runs from is never read unless it is named +with --config-file or KOSLI_CONFIG_FILE. You can configure global Kosli flags (the ones that apply to all/most commands) using their dedicated convenience flags (e.g. --org). diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index c1d7772e2..f28665867 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -122,7 +122,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, httpProxyFlag = "[optional] The HTTP proxy URL including protocol and port number. e.g. 'http://proxy-server-ip:proxy-port'" dryRunFlag = "[optional] Run in dry-run mode. When enabled, no data is sent to Kosli and the CLI exits with 0 exit code regardless of any errors." maxAPIRetryFlag = "[defaulted] How many times should API calls be retried when the API host is not reachable." - configFileFlag = "[optional] The Kosli config file path." + configFileFlag = "[optional] The Kosli config file path. Config is read from this path or the default only, never implicitly from the current directory." debugFlag = "[optional] Print debug logs to stdout." quietFlag = "[optional] Suppress non-critical warning messages. Errors and normal output are not affected. If both --quiet and --debug are set, --debug wins." artifactTypeFlag = "The type of the artifact to calculate its SHA256 fingerprint. One of: [oci, docker, file, dir]. Only required if you want Kosli to calculate the fingerprint for you (i.e. when you don't specify '--fingerprint' on commands that allow it)." From 6d13ebcd61bdd43761513f311d3737dd5d94a87a Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 9 Sep 2026 11:57:00 +0100 Subject: [PATCH 05/10] fix(config): bound the warning's read to regular files and to who lost behaviour Two more review follow-ups, both on the warning rather than the fix. Skip anything that is not a regular file. os.Stat follows symlinks, so a checkout could ship kosli.yml -> /dev/zero, which reports IsDir false and Size 0 while an unbounded read waits behind it, and the size ceiling never applied. Only a regular file's Size says how much there is to read. IsRegular also covers the directory case it replaces. Ask viper what it loaded instead of stat'ing one filename. The home config is read by config name, so ~/.kosli.json is a home config too, and its owner was told their working-directory file is no longer loaded and to pass --config-file, which would have replaced the home config viper had just read for them. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/configWorkingDir_test.go | 54 ++++++++++++++++++++++++++++++ cmd/kosli/root.go | 26 +++++++------- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/cmd/kosli/configWorkingDir_test.go b/cmd/kosli/configWorkingDir_test.go index 5acd955d2..e4b547263 100644 --- a/cmd/kosli/configWorkingDir_test.go +++ b/cmd/kosli/configWorkingDir_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/stretchr/testify/suite" ) @@ -270,6 +271,59 @@ func (suite *WorkingDirConfigTestSuite) TestNoWarningWhenHomeConfigExists() { "a user with a home config file never loaded the working-directory file") } +// TestNonRegularWorkingDirConfigIsNotParsed pins that the size ceiling cannot +// be walked around with a symlink. os.Stat follows one, and a checkout can ship +// kosli.yml -> /dev/zero, which reports IsDir false and Size 0 while an +// unbounded read waits behind it. The run is bounded so that a regression fails +// here instead of hanging the package. +func (suite *WorkingDirConfigTestSuite) TestNonRegularWorkingDirConfigIsNotParsed() { + suite.stubHomeConfig() + dir := suite.T().TempDir() + suite.Require().NoError(os.Symlink(os.DevNull, filepath.Join(dir, "kosli.json"))) + suite.Require().NoError(os.Symlink("/dev/zero", filepath.Join(dir, "kosli.yml"))) + suite.T().Chdir(dir) + + type result struct { + stderr string + err error + } + done := make(chan result, 1) + go func() { + _, _, _, stderr, err := executeCommandC("version") + done <- result{stderr, err} + }() + + select { + case got := <-done: + suite.Require().NoError(got.err) + suite.NotContains(got.stderr, "no longer loaded automatically") + case <-time.After(30 * time.Second): + suite.Fail("reading a non-regular config file did not terminate") + } +} + +// TestNoWarningWhenHomeConfigIsNotYaml pins that the gate follows the config +// name the read above uses, not one filename. A home config is loaded from +// ~/.kosli.json just as happily as ~/.kosli.yml, and warning that user would +// tell them to replace a config file that was loaded moments earlier. +func (suite *WorkingDirConfigTestSuite) TestNoWarningWhenHomeConfigIsNotYaml() { + home := suite.T().TempDir() + suite.Require().NoError(os.WriteFile(filepath.Join(home, ".kosli.json"), + []byte(`{"host": "https://home.example"}`), 0600)) + mockConfigGetter := new(MockConfigGetter) + mockConfigGetter.Mock.On("defaultConfigFilePath").Return(filepath.Join(home, defaultConfigFilename)) + defaultConfigFilePathFunc = mockConfigGetter.defaultConfigFilePath + suite.chdirWithConfig("kosli.yml", "org: some-org\n") + + _, _, _, stderr, err := executeCommandC("version") + + suite.Require().NoError(err) + suite.Equal("https://home.example", global.Host, + "a home config file is loaded by config name, so .json counts") + suite.NotContains(stderr, "no longer loaded automatically", + "this user's home config was loaded, so nothing was lost") +} + func TestWorkingDirConfigTestSuite(t *testing.T) { suite.Run(t, new(WorkingDirConfigTestSuite)) } diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index f28665867..c2e01ede8 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -420,8 +420,11 @@ const maxWorkingDirConfigSize = 1 << 20 // the one thing this warning exists to prevent. func warnAboutIgnoredWorkingDirConfig() { for _, name := range workingDirConfigNames { + // Only a regular file's Size says how much there is to read. os.Stat + // follows symlinks, and a checkout can ship kosli.yml -> /dev/zero, + // which reports IsDir false and Size 0 with an unbounded read behind it. info, err := os.Stat(name) - if err != nil || info.IsDir() || info.Size() > maxWorkingDirConfigSize { + if err != nil || !info.Mode().IsRegular() || info.Size() > maxWorkingDirConfigSize { continue } @@ -643,18 +646,15 @@ func initialize(cmd *cobra.Command, out, errOut io.Writer) error { logger.Debug("no default config file location could be determined. Skipping.") } - // The old default fell back to the working directory only while the home - // config file was absent, so only that population lost behaviour. A user who - // has one never loaded the working-directory file, and passing --config-file - // would replace their home config rather than restore anything. Evaluated - // here because bindFlags can overwrite global.ConfigFile from a config file - // of its own. An unresolvable home directory leaves the path empty, which - // does not stat, and did fall back. - workingDirConfigWasLoadable := false - if !namedByUser { - _, err := os.Stat(global.ConfigFile) - workingDirConfigWasLoadable = err != nil - } + // The old default fell back to the working directory only while no home + // config file existed, so only that population lost behaviour. A user whose + // home config was loaded never loaded the working-directory file, and + // passing --config-file would replace their home config rather than restore + // anything. Asked of viper rather than stat'ed, because the read above + // matches a config name: ~/.kosli.json is a home config too. Evaluated here + // because bindFlags can overwrite global.ConfigFile from a config file of + // its own. An unresolvable home directory reads nothing, and did fall back. + workingDirConfigWasLoadable := !namedByUser && v.ConfigFileUsed() == "" // When we bind flags to environment variables expect that the // environment variables are prefixed, e.g. a flag like --namespace From f9f85825b6e7c254a42f66a220c020f06b6378a2 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 9 Sep 2026 13:16:32 +0100 Subject: [PATCH 06/10] fix(config): read the Kosli --config-file flag from the root, not the command snapshot k8s declares a local --config-file for its namespace selectors, and cobra's flag merge keeps the local one, so cmd.Flags().Lookup answered about the wrong flag on that command. Two consequences, one from this PR and one older. The warning about an ignored working-directory config was suppressed by passing an unrelated flag, on the command #6779 was reported against. And KOSLI_CONFIG_FILE was ignored whenever snapshot k8s --config-file was given, because the guard saw the k8s flag as changed. Looking the flag up on the root is unambiguous and behaves identically for every other command. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/configWorkingDir_test.go | 21 +++++++++++++++++++++ cmd/kosli/root.go | 7 ++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/cmd/kosli/configWorkingDir_test.go b/cmd/kosli/configWorkingDir_test.go index e4b547263..4d2eace20 100644 --- a/cmd/kosli/configWorkingDir_test.go +++ b/cmd/kosli/configWorkingDir_test.go @@ -324,6 +324,27 @@ func (suite *WorkingDirConfigTestSuite) TestNoWarningWhenHomeConfigIsNotYaml() { "this user's home config was loaded, so nothing was lost") } +// TestWarnsWhenAnotherCommandShadowsTheConfigFileFlag pins that the warning +// follows the Kosli config file flag, not whatever flag of that name the running +// command happens to declare. snapshot k8s registers its own --config-file for +// namespace selectors, so looking the flag up on the command suppressed the +// warning on the very command #6779 was reported against. +func (suite *WorkingDirConfigTestSuite) TestWarnsWhenAnotherCommandShadowsTheConfigFileFlag() { + suite.stubHomeConfig() + dir := suite.T().TempDir() + suite.Require().NoError(os.WriteFile(filepath.Join(dir, "kosli.yml"), + []byte("org: some-org\nhost: https://attacker.example\n"), 0600)) + suite.Require().NoError(os.WriteFile(filepath.Join(dir, "k8s-envs.yml"), + []byte("environments:\n - name: prod-env\n namespaces: [default]\n"), 0600)) + suite.T().Chdir(dir) + + _, _, _, stderr, _ := executeCommandC("snapshot k8s --config-file k8s-envs.yml --api-token DRY_RUN --org some-org") + + suite.Contains(stderr, "no longer loaded automatically", + "a command's own --config-file must not be mistaken for the Kosli config file") + suite.Equal(defaultHost, global.Host) +} + func TestWorkingDirConfigTestSuite(t *testing.T) { suite.Run(t, new(WorkingDirConfigTestSuite)) } diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index c2e01ede8..37f24901c 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -603,7 +603,12 @@ func initialize(cmd *cobra.Command, out, errOut io.Writer) error { // handle passing the config file as an env variable. // we load the config file before we bind env vars to flags, // so we check for the config file env var separately here - configFlag := cmd.Flags().Lookup("config-file") + // Asked of the root rather than of cmd, because snapshot k8s declares a + // local --config-file for its namespace selectors, and cobra's flag merge + // keeps the local one. Looking it up on cmd there answers about the wrong + // flag: it reports the Kosli config file as named when it was not, dropping + // KOSLI_CONFIG_FILE and suppressing the working-directory warning. + configFlag := cmd.Root().PersistentFlags().Lookup("config-file") namedByUser := configFlag.Changed if !configFlag.Changed { // A variable set to the empty string reports as present, but it names no From 435baa697d7283608a755ec0777a91c97a265c9f Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 9 Sep 2026 13:28:15 +0100 Subject: [PATCH 07/10] test(config): stop the shadowing test from reaching a real cluster The warning is emitted in PersistentPreRunE, which does not stop the run, and --api-token DRY_RUN suppresses only the Kosli request. The command therefore continued into runMultiEnv with --kubeconfig defaulted to $HOME/.kube/config: green in CI because the connection failed and the error was discarded, but on a machine with a current context it listed pods in the default namespace of whatever cluster that was. Naming a kubeconfig that cannot exist stops the run before any cluster is reached and turns the discarded error into an assertion. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/configWorkingDir_test.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cmd/kosli/configWorkingDir_test.go b/cmd/kosli/configWorkingDir_test.go index 4d2eace20..6cdb378ba 100644 --- a/cmd/kosli/configWorkingDir_test.go +++ b/cmd/kosli/configWorkingDir_test.go @@ -338,8 +338,15 @@ func (suite *WorkingDirConfigTestSuite) TestWarnsWhenAnotherCommandShadowsTheCon []byte("environments:\n - name: prod-env\n namespaces: [default]\n"), 0600)) suite.T().Chdir(dir) - _, _, _, stderr, _ := executeCommandC("snapshot k8s --config-file k8s-envs.yml --api-token DRY_RUN --org some-org") - + // The warning is emitted in PersistentPreRunE, which does not stop the run, + // and --api-token DRY_RUN suppresses only the Kosli request, not the cluster + // read. A kubeconfig that cannot exist stops it before any cluster is + // reached, on a developer machine with a current context as well as in CI. + _, _, _, stderr, err := executeCommandC( + "snapshot k8s --config-file k8s-envs.yml --kubeconfig " + + filepath.Join(dir, "no-such-kubeconfig") + " --api-token DRY_RUN --org some-org") + + suite.Require().Error(err, "the run must stop at the kubeconfig, never reaching a cluster") suite.Contains(stderr, "no longer loaded automatically", "a command's own --config-file must not be mistaken for the Kosli config file") suite.Equal(defaultHost, global.Host) From 8a7b9eeaff88bd62ae0c564d900505079843b026 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 9 Sep 2026 13:43:10 +0100 Subject: [PATCH 08/10] fix(config): do not offer remedies the running command cannot honour On snapshot k8s none of --config-file, -c or KOSLI_CONFIG_FILE names the Kosli config file: the command declares its own --config-file, cobra's merge drops the root's along with the shorthand, and bindFlags writes KOSLI_CONFIG_FILE into the k8s flag. Offering all three there sent a user into runMultiEnv with the file they were trying to keep, on the command #6779 was reported against. The warning now names only the remedy that works when the flag is shadowed. The shadow is what is tested for, rather than the root flag, so a caller reaching here before cobra's flag merge gets the message that is true of every other command instead of one claiming a flag the command does not declare. Also on the non-regular-file test: FailNow rather than Fail, since the goroutine is still inside the unbounded read and continuing would restore the working directory from under a live command, and skip on Windows, where /dev/zero and os.Symlink do not apply. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/configWorkingDir_test.go | 12 +++++++++++- cmd/kosli/root.go | 22 +++++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/cmd/kosli/configWorkingDir_test.go b/cmd/kosli/configWorkingDir_test.go index 6cdb378ba..3f8213429 100644 --- a/cmd/kosli/configWorkingDir_test.go +++ b/cmd/kosli/configWorkingDir_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -277,6 +278,9 @@ func (suite *WorkingDirConfigTestSuite) TestNoWarningWhenHomeConfigExists() { // unbounded read waits behind it. The run is bounded so that a regression fails // here instead of hanging the package. func (suite *WorkingDirConfigTestSuite) TestNonRegularWorkingDirConfigIsNotParsed() { + if runtime.GOOS == "windows" { + suite.T().Skip("/dev/zero and os.Symlink are POSIX-only") + } suite.stubHomeConfig() dir := suite.T().TempDir() suite.Require().NoError(os.Symlink(os.DevNull, filepath.Join(dir, "kosli.json"))) @@ -298,7 +302,10 @@ func (suite *WorkingDirConfigTestSuite) TestNonRegularWorkingDirConfigIsNotParse suite.Require().NoError(got.err) suite.NotContains(got.stderr, "no longer loaded automatically") case <-time.After(30 * time.Second): - suite.Fail("reading a non-regular config file did not terminate") + // FailNow, not Fail: the goroutine is still inside the unbounded read, + // and letting the test continue would restore the working directory + // from under a live command. + suite.FailNow("reading a non-regular config file did not terminate") } } @@ -349,6 +356,9 @@ func (suite *WorkingDirConfigTestSuite) TestWarnsWhenAnotherCommandShadowsTheCon suite.Require().Error(err, "the run must stop at the kubeconfig, never reaching a cluster") suite.Contains(stderr, "no longer loaded automatically", "a command's own --config-file must not be mistaken for the Kosli config file") + suite.Contains(stderr, "This command declares its own --config-file", + "on this command --config-file, -c and KOSLI_CONFIG_FILE all name something else, so none may be offered as the fix") + suite.NotContains(stderr, "pass --config-file kosli.yml") suite.Equal(defaultHost, global.Host) } diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 37f24901c..c7a551ed7 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -418,7 +418,19 @@ const maxWorkingDirConfigSize = 1 << 20 // rather than on a chosen set of keys is deliberate: any key set narrow enough // to be worth writing down would let some real config break in silence, which is // the one thing this warning exists to prevent. -func warnAboutIgnoredWorkingDirConfig() { +func warnAboutIgnoredWorkingDirConfig(cmd *cobra.Command) { + // snapshot k8s declares its own --config-file for namespace selectors, and + // cobra's flag merge drops the root's, the -c shorthand with it. Neither + // --config-file nor KOSLI_CONFIG_FILE can name the Kosli config file there, + // so naming them would send the user into runMultiEnv with this file. + // Tested for the shadow rather than for the root flag, so that a caller + // reaching here before the flag merge gets the message true of every other + // command instead of one claiming a flag this command does not declare. + shadowed := false + if f := cmd.Flags().Lookup("config-file"); f != nil && f != cmd.Root().PersistentFlags().Lookup("config-file") { + shadowed = true + } + for _, name := range workingDirConfigNames { // Only a regular file's Size says how much there is to read. os.Stat // follows symlinks, and a checkout can ship kosli.yml -> /dev/zero, @@ -440,7 +452,11 @@ func warnAboutIgnoredWorkingDirConfig() { continue } - logger.Warn("config file [%s] in the current directory is no longer loaded automatically. To keep using it, pass --config-file %s or set KOSLI_CONFIG_FILE=%s. To apply its settings to every command, move them to your home config file with 'kosli config'.", name, name, name) + if shadowed { + logger.Warn("config file [%s] in the current directory is no longer loaded automatically. This command declares its own --config-file, so move its settings to your home config file with 'kosli config'.", name) + } else { + logger.Warn("config file [%s] in the current directory is no longer loaded automatically. To keep using it, pass --config-file %s or set KOSLI_CONFIG_FILE=%s. To apply its settings to every command, move them to your home config file with 'kosli config'.", name, name, name) + } return } } @@ -693,7 +709,7 @@ func initialize(cmd *cobra.Command, out, errOut io.Writer) error { // Warned after the flag binding above so that KOSLI_QUIET suppresses this // message exactly as --quiet does. if workingDirConfigWasLoadable { - warnAboutIgnoredWorkingDirConfig() + warnAboutIgnoredWorkingDirConfig(cmd) } var err error From 907e6717c10f609852749f1cd0ad17938979a113 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 9 Sep 2026 13:54:08 +0100 Subject: [PATCH 09/10] fix(config): report only the file viper would have loaded viper's searchInPath returned the first existing kosli.* name in SupportedExts order and stopped, whatever that file contained. The warning loop instead skipped past a template, an empty file or an unreadable one and landed on a later name, so it could say a file "is no longer loaded automatically" when that file was never loaded: with a kosli.json template beside a real kosli.yml, viper read the template. The remedy compounded it. --config-file strips the extension and searches the config name again, so following the advice loaded the template the warning had classified as noise. Every check after the existence test now decides whether to warn about this one file rather than whether to move on, which is viper's rule exactly. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/configWorkingDir_test.go | 24 ++++++++++++++++++++++++ cmd/kosli/root.go | 23 +++++++++++++++++------ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/cmd/kosli/configWorkingDir_test.go b/cmd/kosli/configWorkingDir_test.go index 3f8213429..628d6359d 100644 --- a/cmd/kosli/configWorkingDir_test.go +++ b/cmd/kosli/configWorkingDir_test.go @@ -362,6 +362,30 @@ func (suite *WorkingDirConfigTestSuite) TestWarnsWhenAnotherCommandShadowsTheCon suite.Equal(defaultHost, global.Host) } +// TestOnlyTheFileViperWouldHaveLoadedIsReported pins that the loop stops where +// viper stopped. viper took the first existing name in SupportedExts order, so +// with a kosli.json template beside a real kosli.yml it loaded the template and +// never read the yml. Skipping ahead to the yml would claim it was loaded when +// it never was, and the remedy would resolve back to the template, since +// --config-file strips the extension and searches the name again. +func (suite *WorkingDirConfigTestSuite) TestOnlyTheFileViperWouldHaveLoadedIsReported() { + suite.stubHomeConfig() + dir := suite.T().TempDir() + suite.Require().NoError(os.WriteFile(filepath.Join(dir, "kosli.json"), + []byte(`{"trail": {"artifacts": [{"name": "nginx"}]}}`), 0600)) + suite.Require().NoError(os.WriteFile(filepath.Join(dir, "kosli.yml"), + []byte("org: some-org\n"), 0600)) + suite.T().Chdir(dir) + + _, _, _, stderr, err := executeCommandC("version") + + suite.Require().NoError(err) + suite.NotContains(stderr, "no longer loaded automatically", + "the file viper loaded was the template, and a template is not a broken pipeline") + suite.NotContains(stderr, "kosli.yml", + "kosli.yml was never the loaded file, and --config-file kosli.yml would load the template anyway") +} + func TestWorkingDirConfigTestSuite(t *testing.T) { suite.Run(t, new(WorkingDirConfigTestSuite)) } diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index c7a551ed7..a39f90514 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -432,24 +432,35 @@ func warnAboutIgnoredWorkingDirConfig(cmd *cobra.Command) { } for _, name := range workingDirConfigNames { + // viper loaded the first existing name in this order and stopped, so a + // later name was never the file it read. Everything below therefore + // decides whether to warn about this one, never whether to move on: + // skipping ahead would warn about a file that was never loaded, and name + // a remedy that resolves back to the file skipped over. + info, err := os.Stat(name) + if err != nil { + continue + } + // Only a regular file's Size says how much there is to read. os.Stat // follows symlinks, and a checkout can ship kosli.yml -> /dev/zero, // which reports IsDir false and Size 0 with an unbounded read behind it. - info, err := os.Stat(name) - if err != nil || !info.Mode().IsRegular() || info.Size() > maxWorkingDirConfigSize { - continue + if !info.Mode().IsRegular() || info.Size() > maxWorkingDirConfigSize { + return } v := viper.New() v.SetConfigFile(name) + // An unparseable file made every command fail outright before this + // change, so there is no behaviour to migrate. if err := v.ReadInConfig(); err != nil { - continue + return } if len(v.AllKeys()) == 0 { - continue + return } if isFlowTemplate(v) { - continue + return } if shadowed { From da5322af47c06a909c90e72b4cc6a15b0d377fe5 Mon Sep 17 00:00:00 2001 From: Peter Beckham Date: Wed, 9 Sep 2026 14:02:35 +0100 Subject: [PATCH 10/10] fix(config): keep looking past a directory named like a config file 907e6717 folded the directory case into the return branch added for /dev/zero, and the two want opposite answers. viper's existence check is !stat.IsDir(), so a directory named kosli.json was not the file it loaded and the search carried on; a character device was, so the loop must stop. Merging them meant a directory kosli.json beside a real kosli.yml dropped the yml in silence, which is the outcome the warning exists to prevent. The same commit also made the non-regular-file test vacuous: the loop now stops at the first existing name, so kosli.yml -> /dev/zero was never reached and the test passed with the IsRegular guard deleted. The symlink moves to kosli.json, the first name in viper's order, putting the unbounded read back on the path the loop takes. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/configWorkingDir_test.go | 26 ++++++++++++++++++++++++-- cmd/kosli/root.go | 8 +++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/cmd/kosli/configWorkingDir_test.go b/cmd/kosli/configWorkingDir_test.go index 628d6359d..e3b2eb6b6 100644 --- a/cmd/kosli/configWorkingDir_test.go +++ b/cmd/kosli/configWorkingDir_test.go @@ -283,8 +283,10 @@ func (suite *WorkingDirConfigTestSuite) TestNonRegularWorkingDirConfigIsNotParse } suite.stubHomeConfig() dir := suite.T().TempDir() - suite.Require().NoError(os.Symlink(os.DevNull, filepath.Join(dir, "kosli.json"))) - suite.Require().NoError(os.Symlink("/dev/zero", filepath.Join(dir, "kosli.yml"))) + // /dev/zero must sit on the first name in viper's SupportedExts order, + // because the loop stops at the first existing name: with the IsRegular + // guard gone this is the read that never returns. + suite.Require().NoError(os.Symlink("/dev/zero", filepath.Join(dir, "kosli.json"))) suite.T().Chdir(dir) type result struct { @@ -386,6 +388,26 @@ func (suite *WorkingDirConfigTestSuite) TestOnlyTheFileViperWouldHaveLoadedIsRep "kosli.yml was never the loaded file, and --config-file kosli.yml would load the template anyway") } +// TestDirectoryNamedLikeAConfigFileIsSkipped pins viper's existence rule, which +// is !stat.IsDir() rather than a successful stat: a directory of that name was +// never the file viper loaded, so the search carried on past it. Stopping there +// instead would drop a real config below it in silence. +func (suite *WorkingDirConfigTestSuite) TestDirectoryNamedLikeAConfigFileIsSkipped() { + suite.stubHomeConfig() + dir := suite.T().TempDir() + suite.Require().NoError(os.Mkdir(filepath.Join(dir, "kosli.json"), 0700)) + suite.Require().NoError(os.WriteFile(filepath.Join(dir, "kosli.yml"), + []byte("org: some-org\n"), 0600)) + suite.T().Chdir(dir) + + _, _, _, stderr, err := executeCommandC("version") + + suite.Require().NoError(err) + suite.Contains(stderr, "no longer loaded automatically") + suite.Contains(stderr, "kosli.yml", + "viper skipped the directory and loaded this file, so this is the one that was lost") +} + func TestWorkingDirConfigTestSuite(t *testing.T) { suite.Run(t, new(WorkingDirConfigTestSuite)) } diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index a39f90514..16654c3ea 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -442,8 +442,14 @@ func warnAboutIgnoredWorkingDirConfig(cmd *cobra.Command) { continue } + // viper's existence check is !stat.IsDir(), so a directory of this name + // was not the file it loaded. Keep looking, as it did. + if info.IsDir() { + continue + } + // Only a regular file's Size says how much there is to read. os.Stat - // follows symlinks, and a checkout can ship kosli.yml -> /dev/zero, + // follows symlinks, and a checkout can ship kosli.json -> /dev/zero, // which reports IsDir false and Size 0 with an unbounded read behind it. if !info.Mode().IsRegular() || info.Size() > maxWorkingDirConfigSize { return