Skip to content

fix(config): layer project .cu.yml correctly and stop Save leaking it into global config - #43

Merged
timimsms merged 2 commits into
mainfrom
fix/config-precedence-and-save
Aug 28, 2026
Merged

timimsms merged 2 commits into
mainfrom
fix/config-precedence-and-save

Conversation

@timimsms

Copy link
Copy Markdown
Owner

Summary

Fixes the two halves of #37: project .cu.yml was loaded at the wrong precedence, and Save wrote far more than it should.

Precedence. Project config merged with viper.Set() — viper's override slot, which outranks everything. The documented chain is flags > env > project > global; the actual behaviour was project > flags > env > global. It now merges via MergeConfigMap into the config layer, so project values override the global file while still losing to env and flags.

Save. Save() serialized the entire merged viper state into the global file, so cu config set inside any project baked that project's values into ~/.config/cu/config.yaml. It now starts from the file on disk and applies only values written through Set.

Why this is more than a precedence bug. Because those two combine, a project .cu.yml containing api_token was written into the global config, replacing the real token. Reproduced against v0.1.0:

$ cat .cu.yml
default_list: from-project
api_token: EVIL-token
$ cu config set default_workspace ws-1
$ cat ~/.config/cu/config.yaml
api_token: EVIL-token      # was: real-token
default_list: from-project
output: yaml

Any cloned repository could substitute the credential used for API calls. Credential keys from a project file are now dropped with a warning, implementing the credential blocklist from the accepted context-layer design (§2.4).

Same fixture on this branch:

$ cu config set default_workspace ws-1
cu: ignoring "api_token" in /…/.cu.yml — credentials come from the keyring, environment, or your global config
$ cat ~/.config/cu/config.yaml
api_token: real-token
default_space: global-space
default_workspace: ws-1

Also: config loading moves entirely into config.Init. It previously ran in both cobra.OnInitialize and PersistentPreRunE, in that order, so a re-read could clobber the merged layer.

Tests

New TestProjectConfigPrecedence (project > global, env > project, flag > project, credentials ignored) and TestSaveDoesNotLeakProjectConfig. All four precedence cases also verified end-to-end with a built binary against a sandboxed HOME.

Checklist

  • ./scripts/ci.sh passes locally — except errcheck, which reports the same 27 pre-existing findings on main, none in files this PR touches
  • Commit messages use conventional prefixes
  • CLI docs regenerated if command help text changed — no help text changed
  • Docs updated if user-facing behavior changed

Project config was merged with viper.Set, which writes viper's override slot —
outranking flags and environment variables and inverting the documented
precedence. It now merges via MergeConfigMap into the config layer, so the
chain is flags > env > project .cu.yml > global config > defaults.

Save serialized the whole merged viper state into the global file, so
`cu config set` inside any project baked that project's values into
~/.config/cu/config.yaml. Save now starts from the file on disk and applies
only values written through Set.

Together these were worse than a precedence bug: with the released binary, a
project .cu.yml containing api_token overwrote the real token in the global
config. Credential keys from a project file are now dropped with a warning,
per the context-layer design's credential blocklist.

Config loading also moves entirely into config.Init. It previously ran in both
cobra.OnInitialize and PersistentPreRunE, in that order, so any re-read could
clobber the merged layer.

Fixes #37

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aqbmccWm1tqttmBUCR5rv
ClickUp: 86dxbeqyt
@timimsms

Copy link
Copy Markdown
Owner Author

Review — blocking on one item

The layering rework is correct and the regression tests pin exactly the two bugs described. MergeConfigMap into the config layer instead of viper.Set into the override slot is the right fix, and staging Set writes so Save can't serialize the merged state is the right shape for the second one. Removing cobra.OnInitialize(initConfig) is safe — no subcommand overrides PersistentPreRunE, so config.Init still runs on every path.

Blocking: the credential guard has a hole the tests don't cover

Init keeps viper.AddConfigPath(".") on the global layer:

viper.AddConfigPath(DefaultConfigDir)
viper.AddConfigPath(".")            // <-- 
viper.SetConfigType(ConfigType)
viper.SetConfigName(ConfigFileName)

So a config.yaml sitting in the working directory is loaded as the global config layer. It never passes through the project overlay, which means it never hits the new credentialKeys filter. The comment on credentialKeys says a committed file must not be able to substitute the credential used for API calls — but only .cu.yml is actually guarded.

Reproduced against this branch (fresh machine, empty ~/.config/cu, repo containing both files):

repo/config.yaml  ->  api_token: pk_ATTACKER, default_list: attacker-list
repo/.cu.yml      ->  api_token: pk_PROJECT

cu: ignoring "api_token" in .../repo/.cu.yml — credentials come from the keyring, ...
ConfigFileUsed = ".../repo/config.yaml"
api_token      = "pk_ATTACKER"     <-- guard bypassed
default_list   = "attacker-list"

Severity is bounded today: nothing actually reads api_token out of config — auth.Manager only ever goes to the keyring — so this is not a live credential path, it's a defensive guard with a gap plus an unrelated repo file silently reconfiguring the tool. But this PR is the one that establishes the guarantee, and the fix is deleting that one line. The project layer already handles "config that lives in the repo", explicitly and by its own filename.

Deleting viper.AddConfigPath(".") leaves the whole suite green (go test ./... clean), including the four new precedence tests.

Non-blocking

  1. --config help text still says config.yml. ConfigType is "yaml", so globalPath()/Save() write config.yaml. The flag description in root.go advertises $HOME/.config/cu/config.yml. Pre-existing, but Save now names that path in a user-visible way, so the mismatch is easier to trip over.

  2. Set stages every key including api_token. cu config set api_token <token> will now persist a plaintext token into the global config file — and Get reads config before keyring order isn't involved, so it's inert, but it writes a secret to disk. Worth either refusing credentialKeys in Set too (symmetry with the project-file guard) or a follow-up.

viper searched "." for config.yaml alongside the configured directory, so a
config.yaml in a repo was read as the *global* layer. That path never passes
through the project overlay, and so never hits the credentialKeys filter --
the guard covered .cu.yml only, and any repo could still substitute api_token
and the rest of the config.

Not a live credential path today, since auth.Manager reads only the keyring,
but it makes the guarantee the filter advertises actually hold. Repo-local
configuration already has its own file and its own layer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ZEGsLHBQ2GzP6v48i4hXz
ClickUp: 86dxbeqyt
@timimsms
timimsms merged commit 7bc3ee3 into main Aug 28, 2026
16 checks passed
@timimsms
timimsms deleted the fix/config-precedence-and-save branch August 28, 2026 07:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant