Skip to content

fix(config)!: stop loading the config file from the working directory - #1152

Merged
sami-alajrami merged 10 commits into
mainfrom
fix-no-implicit-cwd-config
Sep 9, 2026
Merged

fix(config)!: stop loading the config file from the working directory#1152
sami-alajrami merged 10 commits into
mainfrom
fix-no-implicit-cwd-config

Conversation

@pbeckham

@pbeckham pbeckham commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Closes kosli-dev/server#6778 and kosli-dev/server#6779.

The --config-file default fell back to the bare name kosli whenever $HOME/.kosli.yml was absent, and viper resolves that against the current working directory. A checkout could therefore ship kosli.yml and set any Kosli flag for a command run with a real API token. The default is now always the home config file, and a working-directory config is read only when named.

Why

Two confirmed vulnerabilities, both reachable by a developer or CI job running an ordinary Kosli command in an untrusted checkout with KOSLI_API_TOKEN set. A repository-controlled host or http-proxy sends Authorization: Bearer <real token> to a host of the repository's choosing. A repository-controlled kubeconfig on snapshot k8s runs a kubeconfig exec credential plugin, which is arbitrary command execution, and --dry-run does not prevent it. Exposure is widest where tokens live: a CI runner has no $HOME/.kosli.yml, the state that triggered the fallback.

What changed

getConfigFileFlagDefault no longer degrades to a relative name, so viper's search path is always the home directory. initialize skips the config read when no default path resolves, and kosli config fails clearly rather than writing into the working directory. The --config-file and kosli config help strings now say config is never read implicitly from the current directory, since the published reference is generated from them.

Behaviour is unchanged for --config-file, KOSLI_CONFIG_FILE, $HOME/.kosli.yml and KOSLI_* environment variables.

So the removal does not break a pipeline silently, an ignored working-directory config produces a warning naming the file and the fix. When to warn is the fiddly half: only while no home config was loaded, since nobody else lost behaviour and --config-file would replace a home config rather than restore one; only for a decodable regular file under 1 MB, since it is repository-controlled and read on every command run; and never for a flow template, told apart by trail being a mapping or artifacts a sequence, because both are CLI flags as well as template keys.

Verification

New WorkingDirConfigTestSuite in cmd/kosli/configWorkingDir_test.go, 14 cases. They pin that none of the six decodable extensions can set host from the working directory, the warning text across nine file shapes, the home-config load path in both .yml and .json form, and that a kosli.yml -> /dev/zero symlink is skipped rather than read unbounded.

Both reported scenarios were reproduced against a build of a5ecf036 and re-run against this branch: the bearer token now stops at the default host, and the kubeconfig credential plugin does not execute. The Test workflow gates this PR with make test_integration_full and golangci-lint.

Follow-up

Docs are on 6778-no-implicit-cwd-config in kosli-dev/docs, to merge with the release.

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) <noreply@anthropic.com>
@github-actions github-actions Bot added the go Pull requests that update go code label Sep 8, 2026
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @pbeckham's task in 6m 11s —— View job


Review of 907e6717 + da5322af

  • Static analysis
  • Read the changed files at HEAD
  • Verify the two new commits against viper 1.21's own source
  • Post findings

Static analysis

Check Result
go build ./... clean
go vet ./... clean
go mod tidy + git diff --exit-code go.mod go.sum clean

go test needs approval in this environment, so the suite was not run. Everything below is read from the sources.

Verified against viper 1.21

Both new commits claim to reproduce viper's search rule, so I checked the rule rather than the claim.

file.go:75-92searchInPath iterates SupportedExts and returns on the first hit, no parse attempted:

for _, ext := range SupportedExts {
    if b, _ := exists(v.fs, filepath.Join(in, v.configName+"."+ext)); b {
        return filepath.Join(in, v.configName+"."+ext)
    }
}

file.go:95-104exists is !stat.IsDir(), and swallows every non-IsNotExist error into false because the caller discards it:

stat, err := fs.Stat(path)
if err == nil {
    return !stat.IsDir(), nil
}
if os.IsNotExist(err) { return false, nil }
return false, err

root.go:434-470 now mirrors all three branches exactly — continue on any stat error (broken symlink, ELOOP, EACCES), continue on a directory, return on everything the search would have stopped at. viper.go:304 confirms the order (json, toml, yaml, yml, properties, …) and encoding.go:166-181 confirms only json/toml/yaml/yml/dotenv/env have a decoder, so TestUndecodableWorkingDirConfigIsSilent rests on real behaviour and not on a coincidence. v.configType is never set, so the extensionless kosli fallback at file.go:85-89 stays unreachable and the name list is complete.

Also checked for test contamination: no tracked kosli.<ext> anywhere in the repo, and no t.Parallel() in cmd/kosli, so the t.Chdir calls are safe and no other suite starts emitting the warning.

Finding

kosli config cannot honour the remedy it is shownroot.go:422. config.go:68 rejects --config-file in its PreRunE, but the flag is inherited rather than shadowed, so shadowed is false and the full "pass --config-file kosli.yml" wording is printed. Following it gives cannot use --config-file with config command. Reachable on the likeliest path: the warning requires that no home config was loaded, which is exactly the state of a first-time kosli config run, and a checkout with a kosli.yml is the premise of the whole PR. Suggestion is inline, including the wording that stays true for snapshot k8s.

Not raising

  • The 1 MB ceiling and the IsRegular guard both return silently, so a genuinely >1 MB config or a FIFO-backed one is dropped without a word. Both match what viper would have stopped at, and the old CLI hung on the FIFO, so there is no behaviour to migrate — deliberate, and the comments say so.
  • Up to 12 os.Stat calls per command run in the working directory. Negligible next to a single HTTP request.

Good work

da5322af is the finding I raised, but the fix is better than what I suggested: splitting IsDir from IsRegular rather than folding them, with each branch's comment naming the viper line it mirrors, so the next reader can check the mirror instead of trusting it. 907e6717 retired a follow-up rather than adding one — stopping at the first existing name closed the extension-stripping mismatch as a side effect, which is the rare case of a fix shrinking the outstanding list.

The test suite has held up unusually well across nine rounds of review, and the reason is that each case pins why rather than what: TestNonRegularWorkingDirConfigIsNotParsed was quietly neutered by 907e6717 and the fix moved /dev/zero onto kosli.json with a comment explaining why the position is load-bearing, rather than leaving a green test guarding nothing. The 30-second FailNow harness turning a hang into a readable failure, and the stubHomeConfig helper making "no home config" the explicit precondition of every case, are both worth keeping as patterns.
· branch fix-no-implicit-cwd-config

@pbeckham pbeckham changed the title Stop loading the Kosli config file from the working directory fix(config)!: stop loading the config file from the working directory Sep 8, 2026
Comment thread cmd/kosli/root.go Outdated
Comment thread cmd/kosli/root.go Outdated
Comment thread cmd/kosli/root.go
Comment thread cmd/kosli/config.go
Comment thread cmd/kosli/configWorkingDir_test.go Outdated
Comment thread cmd/kosli/root.go Outdated
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) <noreply@anthropic.com>
Comment thread cmd/kosli/root.go
Comment thread cmd/kosli/root.go Outdated
…lates 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) <noreply@anthropic.com>
Comment thread cmd/kosli/root.go Outdated
Comment thread cmd/kosli/root.go Outdated
…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) <noreply@anthropic.com>
pbeckham added a commit to kosli-dev/docs that referenced this pull request Sep 9, 2026
Regenerated from kosli-dev/cli 3d2c424c: the --config-file description now
says config is never read implicitly from the current directory, and the
kosli config precedence list names $HOME/.kosli.yml rather than
$HOME/.kosli.

Refs kosli-dev/cli#1152
…t 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) <noreply@anthropic.com>
Comment thread cmd/kosli/root.go Outdated
… 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) <noreply@anthropic.com>
Comment thread cmd/kosli/root.go
Comment thread cmd/kosli/configWorkingDir_test.go Outdated
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) <noreply@anthropic.com>
Comment thread cmd/kosli/root.go Outdated
Comment thread cmd/kosli/root.go
Comment thread cmd/kosli/configWorkingDir_test.go
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) <noreply@anthropic.com>
Comment thread cmd/kosli/root.go
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) <noreply@anthropic.com>
Comment thread cmd/kosli/root.go
Comment thread cmd/kosli/configWorkingDir_test.go Outdated
907e671 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) <noreply@anthropic.com>
Comment thread cmd/kosli/root.go
@sami-alajrami
sami-alajrami merged commit 890fc4d into main Sep 9, 2026
22 checks passed
@sami-alajrami
sami-alajrami deleted the fix-no-implicit-cwd-config branch September 9, 2026 13:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change fix go Pull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants