Skip to content

fix(config): reject unknown config keys and log provider origins#511

Merged
SantiagoDePolonia merged 4 commits into
mainfrom
fix/config-strict-validation
Jul 8, 2026
Merged

fix(config): reject unknown config keys and log provider origins#511
SantiagoDePolonia merged 4 commits into
mainfrom
fix/config-strict-validation

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Closes #509

Problem

A misindented providers: section is valid YAML — just not the YAML you meant:

providers:
uranium-geryon-9b:        # column zero: a new top-level key, not a provider
  type: vllm
  base_url: "http://uranium-geryon-9b:8000/v1"

It parses as providers: null plus four unrelated top-level keys, which yaml.Unmarshal discarded silently. The gateway booted with none of the operator's providers and no error. In #509 the fault stayed hidden because an env-provisioned OpenCode provider kept the registry non-empty; removing it turned the silent misconfiguration into no providers were successfully registered, which points nowhere near the real cause.

User-visible impact

Unknown keys in declarative config now fail startup by default, naming the file and every offending line:

failed to load config: failed to parse config.yaml: line 23: field uranium-geryon-9b not found; line 26: field uranium-gemma-embedding not found; line 29: field uranium-qwen-reranker not found; line 32: field plutonium-qwen-dflash not found

This also catches ordinary typos (prot: for port:, bass_url: for base_url:).

A boot log line reports where providers came from, as requested in #509:

{"msg":"config file loaded","path":"config.yaml","providers":4}
{"msg":"providers resolved","total":5,"from_config_file":4,"from_env":1,
 "config_file_providers":["plutonium-qwen-dflash","uranium-geryon-9b"],
 "env_providers":["opencode-go"]}

from_config_file: 0 while a file is loaded means the file contributed nothing. There is also no config file found listing the searched paths.

CONFIG_STRICT=false escape hatch

Strict is the right default: a dropped providers, rate_limits, budgets, or guardrails entry silently changes routing, cost, or security — a cost cap that never applies looks exactly like a cost cap that does. Strict's worst case is a boot failure with a precise message; liberal's worst case is a gateway running for weeks with a control silently absent.

But it blocks one legitimate case: rolling a binary back under a config file written for a newer version, where an unknown key is expected rather than a typo. CONFIG_STRICT=false downgrades unknown keys to warnings and boots without them:

WARN  CONFIG_STRICT=false: unknown config keys are ignored with a warning instead of aborting startup
WARN  unknown config key ignored; it has no effect path=config.yaml line=23 field=uranium-geryon-9b
INFO  providers resolved total=1 from_config_file=0 from_env=1

Three properties make the lax mode meaningfully better than the old silent behaviour:

  • Unknown keys are always detected. Lax mode still decodes with KnownFields(true) and inspects the resulting TypeError, so it names each ignored key rather than dropping it in silence the way yaml.Unmarshal did.
  • The flag relaxes which keys are accepted, never whether a value makes sense. A malformed value (port: [9999, 8080]) or a YAML syntax error stays fatal in both modes, and a file mixing an unknown key with a malformed value still fails.
  • Entity-level validation is unaffected. A virtual model whose target names a provider that does not exist still fails startup even when the key that would have declared that provider was only warned about.

Why the env layer changed too

Per the documented priority (env > yaml > defaults), env entries override YAML entries key by key. Leaving env JSON lax while YAML is strict recreates the same trap one layer up: a typo in VIRTUAL_MODELS, SET_RATE_LIMIT_*, or SET_BUDGET_* would silently win over a correct YAML entry. All three now decode through one shared helper that honours CONFIG_STRICT and downgrades only encoding/json's unknown-field error.

Two things fall out of that:

  • A latent bug. BudgetLimitConfig had no json tags, unlike its RateLimitRuleConfig neighbour which added them for exactly this env form. SET_BUDGET_X='[{"period_seconds":7200,"amount":5}]' silently dropped period_seconds, producing a limit with no window. Verified against the old code, then fixed by adding the tags.
  • failover.overrides. A removed key that old config files still carry, and that existing tests deliberately assert still loads. Strict parsing would have broken that contract, so it is modelled as an explicitly ignored field — upgrading it from silently ignored to a logged deprecation pointing at disabled_models.

Also fixed

A config path that exists but cannot be read — most often a directory bind-mounted where a file was expected, a common Docker mistake — was indistinguishable from a missing file and silently fell back to defaults. It is now reported: failed to read config.yaml: read config.yaml: is a directory.

Breaking change

With the default CONFIG_STRICT=true, configs carrying stray keys will not boot. The error names the key and the line, and CONFIG_STRICT=false is the documented out. Top-level YAML anchors (x-defaults: &d) are also rejected under the default — nothing in the repo or docs uses them, and anchors defined inside providers: still work.

Verification

Full suite passes; golangci-lint clean; all pre-commit hooks (including make test-race) pass. Beyond unit tests, the built binary was driven through every path against the exact config from #509:

Scenario Result
Reporter's file, default fails, naming all four lines
Reporter's file, CONFIG_STRICT=false 4 warnings, boots, from_config_file: 0
Reporter's file, indentation fixed boots, 4 vLLM providers, virtual models validate
CONFIG_STRICT=false + port: [9999, 8080] still fatal
CONFIG_STRICT=maybe rejected as non-boolean
Directory mounted as config.yaml fails with a read error
Comments-only file boots

New tests cover misindentation, nested and per-provider typos, empty and comments-only files, the ... document-end marker the reporter's file uses, the unreadable-path case, both strict and lax modes in YAML and env JSON, type errors staying fatal when lax, the budget json-tag fix, and a regression test that config.example.yaml itself parses strictly — otherwise anyone copying it could not boot.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Unknown or misspelled fields in config.yaml and structured JSON-style environment settings are rejected by default (malformed values remain fatal); multi-document YAML is rejected.
    • Strict JSON env parsing now also rejects trailing/unparsed data.
    • Mounted config paths that exist but can’t be read now fail startup instead of falling back.
  • New Features
    • CONFIG_STRICT (default: true) controls whether unknown keys are rejected or downgraded to warnings.
    • Startup logs report provider resolution counts and origins (from file vs. environment).
  • Documentation
    • Updated strictness/overlay gotchas and startup validation behavior, including strict handling of misindented providers.
  • Chores
    • Bumped Go toolchain/build images.

A misindented `providers:` section parses as a null section plus unknown
top-level keys. yaml.Unmarshal discarded those silently, so the gateway
booted with none of the operator's providers and no error — the fault only
surfaced once an env-provisioned provider was removed and startup failed
with "no providers were successfully registered".

Parse the YAML layer with KnownFields(true) so an unknown key fails startup,
naming the file and every offending line. This also catches ordinary typos
(`prot:` for `port:`, `bass_url:` for `base_url:`).

The env layer declares the same structures as JSON and overrides YAML entry
by entry, so a typo there would silently win over a correct YAML entry.
Decode VIRTUAL_MODELS, SET_RATE_LIMIT_*, and SET_BUDGET_* with
DisallowUnknownFields for the same reason.

Two fixes fall out of that:

- BudgetLimitConfig had no json tags, unlike its RateLimitRuleConfig
  neighbour which added them for exactly this env form, so `period_seconds`
  in a SET_BUDGET_* JSON array was silently dropped and produced a limit
  with no window. Add the tags.
- `failover.overrides` is a removed key that old config files still carry
  and that strict parsing would now reject. Model it as an explicitly
  ignored field, upgrading it from silently ignored to a logged deprecation
  pointing at `disabled_models`.

A config path that exists but cannot be read — a directory bind-mounted
where a file was expected — was indistinguishable from a missing file and
fell back to defaults. Report it instead.

Finally, log one line at boot showing how many providers came from the
config file versus environment discovery, with their names, so an operator
can see at a glance that a config file contributed nothing.

Closes #509

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c8c773c0-2a86-4e50-8158-4af94a0aacc6

📥 Commits

Reviewing files that changed from the base of the PR and between 7835f6a and 91db16b.

📒 Files selected for processing (4)
  • .github/workflows/release.yml
  • .github/workflows/test.yml
  • Dockerfile
  • go.mod

📝 Walkthrough

Walkthrough

This PR adds strict validation for YAML config and JSON env inputs, introduces CONFIG_STRICT, preserves legacy failover overrides with a warning, logs provider origin breakdowns at boot, and bumps the Go toolchain version.

Changes

Strict config validation and provider origin logging

Layer / File(s) Summary
Strict JSON decode helper and env parsers
config/merge.go, config/budget.go, config/budget_test.go, config/ratelimit.go, config/ratelimit_test.go, config/virtualmodels.go, config/virtualmodels_test.go
Adds strict JSON decoding for env-var payloads and threads strict/lax behavior through budget, rate limit, and virtual model parsing with tests for unknown fields, trailing data, and valid decoding.
Strict YAML config decoding and file discovery
config/config.go, config/config_strict_test.go, config/config_test.go, docs/advanced/config-yaml.mdx, .env.template
Refactors config loading to resolve CONFIG_STRICT, read the first available config file, decode YAML with KnownFields(true), normalize errors, and handle empty or comment-only overlays; adds strict-mode tests and related docs.
Deprecate failover.overrides field
config/failover.go
Adds a deprecated Overrides field to FailoverConfig and clears it with a warning when present.
Provider origin classification and boot logging
internal/providers/config.go, internal/providers/config_test.go, internal/providers/init.go, docs/advanced/config-yaml.mdx
Adds providerOrigins, uses it during Init, and logs config-file vs env-derived provider counts and names.

Go toolchain version bump

Layer / File(s) Summary
Toolchain version updates
.github/workflows/release.yml, .github/workflows/test.yml, Dockerfile, go.mod
Bumps the Go version from 1.26.4 to 1.26.5 across build, release, workflow, and module settings.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Poem

I’m a rabbit in config, ears up and bright,
Unknown keys now hop out of sight. 🐰
File or env, their origins glow,
And strict little burrows keep errors in tow.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The Go 1.26.5 toolchain/Docker/CI bumps are unrelated to #509 and appear outside the linked issue's config-validation scope. Move the Go version and build-image bumps to a separate PR unless they are explicitly required for this issue.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main changes: stricter config validation and provider-origin logging.
Linked Issues check ✅ Passed The PR addresses #509 by failing misindented YAML, rejecting unknown keys, and adding startup logs that split providers by config vs env.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/config-strict-validation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

codecov-commenter commented Jul 8, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 96.49123% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
config/config.go 94.44% 1 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

Mostly safe, with one contained strict-validation bug to fix before merging.

The YAML parsing, provider-origin logging, and compatibility paths are covered by focused tests. The shared JSON env decoder still accepts trailing malformed data after the first valid JSON value, so one intended strictness path can silently misapply config.

config/merge.go

T-Rex T-Rex Logs

What T-Rex did

  • I reproduced a targeted test in the config package that exercises decodeStrictJSON with a valid JSON array followed by a trailing malformed object, and the test passed because the trailing suffix was ignored and the first value was decoded.
  • I ran the strict provider boot test and observed an initial misindented fixture error about missing fields, corrected the fixture to load two providers, and then encountered a separate error reading config.yaml which was a directory.
  • The boot test run completed with exit code 0 as shown in the captured log.

View all artifacts

T-Rex Ran code and verified through T-Rex

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant App as Startup
participant Config as config.Load
participant YAML as applyYAML/readConfigFile
participant Env as env overrides
participant Providers as providers.Init
participant Log as slog

App->>Config: Load()
Config->>YAML: read config file
alt no config file
    YAML->>Log: no config file found(searched paths)
    YAML-->>Config: empty providers
else file found
    YAML->>YAML: strict YAML decode(KnownFields)
    alt unknown key/read error
        YAML-->>Config: startup error with file/line
    else valid file
        YAML->>Log: config file loaded(path, provider count)
        YAML-->>Config: raw providers
    end
end
Config->>Env: apply env overrides and strict env JSON
Env-->>Config: merged config
Config-->>App: LoadResult(config, raw providers)
App->>Providers: Init(LoadResult)
Providers->>Providers: resolve providers(env overlays + credential filtering)
Providers->>Providers: providerOrigins(raw, resolved)
Providers->>Log: providers resolved(total, from_config_file, from_env)
Providers-->>App: registry/router/cache or startup error
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant App as Startup
participant Config as config.Load
participant YAML as applyYAML/readConfigFile
participant Env as env overrides
participant Providers as providers.Init
participant Log as slog

App->>Config: Load()
Config->>YAML: read config file
alt no config file
    YAML->>Log: no config file found(searched paths)
    YAML-->>Config: empty providers
else file found
    YAML->>YAML: strict YAML decode(KnownFields)
    alt unknown key/read error
        YAML-->>Config: startup error with file/line
    else valid file
        YAML->>Log: config file loaded(path, provider count)
        YAML-->>Config: raw providers
    end
end
Config->>Env: apply env overrides and strict env JSON
Env-->>Config: merged config
Config-->>App: LoadResult(config, raw providers)
App->>Providers: Init(LoadResult)
Providers->>Providers: resolve providers(env overlays + credential filtering)
Providers->>Providers: providerOrigins(raw, resolved)
Providers->>Log: providers resolved(total, from_config_file, from_env)
Providers-->>App: registry/router/cache or startup error
Loading

Reviews (1): Last reviewed commit: "fix(config): reject unknown config keys ..." | Re-trigger Greptile

Comment thread config/merge.go
Comment on lines +12 to +16
func decodeStrictJSON(raw string, target any) error {
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.DisallowUnknownFields()
return decoder.Decode(target)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Reject trailing JSON
Decode succeeds after the first JSON value and leaves trailing bytes unread, so VIRTUAL_MODELS='[{"source":"a"}] {"targts":[]}' or SET_RATE_LIMIT_X='[{"period":"minute","max_requests":1}] junk' boots successfully while ignoring the malformed suffix. This regresses the previous json.Unmarshal behavior and weakens the new strict env validation; decode a second token and require io.EOF after the first value.

Artifacts

Repro: targeted Go test for trailing JSON acceptance

  • Contains supporting evidence from the run (text/x-go; charset=utf-8).

Repro: verbose go test output showing trailing malformed JSON accepted

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Strict parsing is the right default — a dropped providers, rate_limits,
budgets, or guardrails entry silently changes routing, cost, or security —
but it blocks one legitimate case: rolling a binary back under a config file
written for a newer version, where an unknown key is expected rather than a
typo.

CONFIG_STRICT=false downgrades unknown keys to warnings that name the file,
line, and field, then boots without them. The default stays true.

Unknown keys are always detected: lax mode still decodes with
KnownFields(true) and inspects the resulting TypeError, so it can report each
ignored key instead of dropping it in silence the way yaml.Unmarshal did.

The flag relaxes which keys are accepted, never whether a value makes sense.
A malformed value (`port: [9999, 8080]`) and a YAML syntax error stay fatal
in both modes, and a file mixing an unknown key with a malformed value still
fails. The same rule applies to the JSON env layer, where only
encoding/json's unknown-field error is downgraded.

Entity-level validation is unaffected: a virtual model whose target names a
provider that does not exist still fails startup even when the key that would
have declared that provider was ignored with a warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
config/ratelimit_test.go (1)

410-418: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a lax-mode test case and make this table-driven.

The test only covers strict=true. A strict=false case verifying that unknown fields are downgraded to warnings (and parsing succeeds) would close the behavior-change test gap. As per coding guidelines, **/*_test.go files should use table-driven tests.

♻️ Proposed table-driven test with strict and lax cases
 func TestParseRateLimitEnvLimits_RejectsUnknownField(t *testing.T) {
-	_, err := parseRateLimitEnvLimits(`[{"period":"minute","max_requsts":100}]`, true)
-	if err == nil {
-		t.Fatal("parseRateLimitEnvLimits() error = nil, want unknown-field error")
-	}
-	if !strings.Contains(err.Error(), "max_requsts") {
-		t.Fatalf("parseRateLimitEnvLimits() error = %q, want it to name the unknown field", err)
-	}
+	tests := []struct {
+		name    string
+		strict  bool
+		wantErr bool
+	}{
+		{
+			name:    "strict rejects unknown field",
+			strict:  true,
+			wantErr: true,
+		},
+		{
+			name:    "lax downgrades unknown field to warning",
+			strict:  false,
+			wantErr: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			limits, err := parseRateLimitEnvLimits(`[{"period":"minute","max_requsts":100}]`, tt.strict)
+			if tt.wantErr {
+				if err == nil {
+					t.Fatal("parseRateLimitEnvLimits() error = nil, want unknown-field error")
+				}
+				if !strings.Contains(err.Error(), "max_requsts") {
+					t.Fatalf("parseRateLimitEnvLimits() error = %q, want it to name the unknown field", err)
+				}
+			} else {
+				if err != nil {
+					t.Fatalf("parseRateLimitEnvLimits() error = %v, want nil (lax mode)", err)
+				}
+				if len(limits) != 1 || limits[0].Period != "minute" {
+					t.Fatalf("parseRateLimitEnvLimits() limits = %v, want one rule with period minute", limits)
+				}
+			}
+		})
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/ratelimit_test.go` around lines 410 - 418, The test for
parseRateLimitEnvLimits currently only checks strict mode and should be
converted into a table-driven test in
TestParseRateLimitEnvLimits_RejectsUnknownField. Add cases for both strict=true
and strict=false, using the existing parseRateLimitEnvLimits helper and
verifying that unknown fields still error in strict mode but are accepted as
warnings in lax mode. Keep the assertions focused on the unknown field name and
the success path, and structure the cases so the behavior difference is explicit
and easy to extend.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@config/merge.go`:
- Around line 14-29: decodeStrictJSON currently accepts extra trailing content
because Decoder.Decode only reads the first JSON value, so add an explicit
end-of-input verification after the initial decode in decodeStrictJSON. Use the
existing decodeStrictJSON helper (and its json.Decoder setup) to ensure inputs
like valid JSON followed by junk are rejected, and avoid using Decoder.More()
for this top-level check; keep decodeIaCJSON unchanged except for relying on the
stricter behavior.

In `@docs/advanced/config-yaml.mdx`:
- Around line 176-177: The warning count in the config example is inconsistent
with the single unknown YAML key shown in the earlier sample. Update the
sentence in the docs so the behavior described by the CONFIG_STRICT=false case
matches the one unrecognized top-level key in the YAML example, and reference
the surrounding CONFIG_STRICT explanation in config-yaml.mdx to keep the
strict/lax parsing description aligned.

---

Outside diff comments:
In `@config/ratelimit_test.go`:
- Around line 410-418: The test for parseRateLimitEnvLimits currently only
checks strict mode and should be converted into a table-driven test in
TestParseRateLimitEnvLimits_RejectsUnknownField. Add cases for both strict=true
and strict=false, using the existing parseRateLimitEnvLimits helper and
verifying that unknown fields still error in strict mode but are accepted as
warnings in lax mode. Keep the assertions focused on the unknown field name and
the success path, and structure the cases so the behavior difference is explicit
and easy to extend.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b7f4615b-c4c7-464a-97ef-4cce9a80569c

📥 Commits

Reviewing files that changed from the base of the PR and between b91f967 and 1f0b33e.

📒 Files selected for processing (12)
  • .env.template
  • config/budget.go
  • config/budget_test.go
  • config/config.go
  • config/config_strict_test.go
  • config/config_test.go
  • config/merge.go
  • config/ratelimit.go
  • config/ratelimit_test.go
  • config/virtualmodels.go
  • config/virtualmodels_test.go
  • docs/advanced/config-yaml.mdx

Comment thread config/merge.go
Comment thread docs/advanced/config-yaml.mdx Outdated
@mintlify

mintlify Bot commented Jul 8, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
gomodel 🟢 Ready View Preview Jul 8, 2026, 3:31 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Swapping json.Unmarshal for a json.Decoder to get DisallowUnknownFields
regressed a check nobody asked for but everybody relied on: Decode stops
after the first JSON value and leaves the rest unread, so

    VIRTUAL_MODELS='[{"source":"a"}] {"targts":[]}'

booted happily while ignoring the suffix. json.Unmarshal rejects trailing
data; silently applying half an env var is precisely the failure this path
exists to prevent. Require io.EOF after the decoded value.

The YAML layer had the same hole, pre-dating this branch: both yaml.Unmarshal
and yaml.Decoder read one document, so every key after a `---` separator was
applied nowhere. Reject a second document.

Both are structural faults, not unknown keys, so they stay fatal under
CONFIG_STRICT=false.

Also correct the misindentation gotcha in the docs, whose prose counted keys
the sample did not contain. The sample now carries two providers and the
quoted error is the binary's verbatim output.

Reported by Greptile and CodeRabbit on #511.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Addressed both bot findings.

Trailing JSON (P1 / Major — @greptile-apps, @coderabbitai). Confirmed and fixed. Swapping json.Unmarshal for a json.Decoder to get DisallowUnknownFields silently regressed trailing-data rejection:

Unmarshal:  err=invalid character 'T' after top-level value
Decode:     err=<nil>  v=[{Period:daily}]     ← accepted `[{...}] THIS IS GARBAGE`

decodeStrictJSON now requires io.EOF after the decoded value. Tests cover a garbage suffix, a concatenated second JSON value (Greptile's exact repro), and a newline-separated one — each in both strict and lax mode, since trailing data is a structural fault rather than an unknown key and stays fatal under CONFIG_STRICT=false. Verified that trailing whitespace is still accepted.

The same hole existed in the YAML layer, pre-dating this branch and unflagged: yaml.Unmarshal and yaml.Decoder both read exactly one document, so every key after a --- separator was applied nowhere. Now rejected, also fatal in both modes. The ... document-end marker still parses.

Docs (Minor — @coderabbitai). Correct: the prose counted keys the sample did not contain. The sample now carries two providers, and the quoted error string is the binary's verbatim output rather than my paraphrase.

Verified with unit, contract, e2e, and Docker-backed integration suites, golangci-lint across all build tags, and by driving the real binary through each path.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
fd . -t f config/merge.go config/config.go
echo "--- merge.go ---"
cat -n config/merge.go
echo "--- config.go (grep decoder/EOF) ---"
rg -n "NewDecoder|DisallowUnknownFields|io.EOF|decodeStrictJSON" config/config.go config/merge.go

Length of output: 724


🏁 Script executed:

#!/bin/bash
set -e
fd merge.go
fd config.go
echo "--- merge.go ---"
fd merge.go --exec cat -n {}
echo "--- config.go (grep decoder/EOF) ---"
fd '^(merge|config)\.go$' -p config --exec rg -n "NewDecoder|DisallowUnknownFields|io.EOF|decodeStrictJSON" {}

Length of output: 10211


🏁 Script executed:

#!/bin/bash
set -e
CFG=$(fd '^config\.go$' config)
echo "$CFG"
rg -n "NewDecoder|DisallowUnknownFields|io.EOF|multi.?doc|---" "$CFG" | head -50

Length of output: 686


Verified—the fixes described in your comment are present in the current code. decodeStrictJSON (config/merge.go, Lines 28-41) now calls decoder.Token() after Decode() and rejects anything but io.EOF, closing the trailing-JSON hole for both the array and single-object env paths. The YAML side (config/config.go, Lines 298-337) now checks for a second document via decoder.Decode returning non-EOF and fails with an explicit only one YAML document is supported... after a '---' separator error, while ... document-end markers still parse fine per your stated behavior.

Nice catch turning Greptile's repro into full coverage across both the JSON and YAML layers — that's exactly the kind of regression that's easy to miss when swapping Unmarshal for a streaming decoder. Thanks for the detailed writeup and verification steps.


✏️ Learnings added
Learnt from: SantiagoDePolonia
URL: https://github.com/ENTERPILOT/GoModel/pull/511

Timestamp: 2026-07-08T16:54:54.951Z
Learning: In ENTERPILOT/GoModel, config/merge.go's `decodeStrictJSON` uses `json.NewDecoder` with `DisallowUnknownFields()` to reject unknown JSON fields for env-provided IaC config (VIRTUAL_MODELS, SET_RATE_LIMIT_*, SET_BUDGET_*). Because `json.Decoder.Decode` only consumes the first JSON value and leaves trailing data unread, `decodeStrictJSON` must additionally call `decoder.Token()` and verify it returns `io.EOF` to reject trailing/concatenated JSON data — a plain switch from `json.Unmarshal` to `json.Decoder` silently regresses trailing-data rejection. Similarly, in config/config.go, `applyYAML`'s `yaml.NewDecoder` only reads the first YAML document; it must call `Decode` again and error if it does not return `io.EOF`, to reject a second document after a `---` separator (the `...` document-end marker is still fine and should still parse).

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown

Both fixes look solid.

Trailing JSON: decodeStrictJSON correctly gates on io.EOF after decoder.Decode, and the lax path in decodeIaCJSON naturally inherits the same protection — a trailing-data error from decodeStrictJSON doesn't match isUnknownFieldJSONError, so it propagates regardless of strict. The YAML equivalent in ensureSingleDocument is clean: decoding into yaml.Node sidesteps the known-fields check while still detecting a second document.

Docs: The misindented sample now has two providers, and the error string is byte-for-byte what the binary emits. The from_config_file: 0 callout in the prose is a good touch — that's exactly the signal an operator needs to diagnose the misindentation case.

LGTM on both.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/advanced/config-yaml.mdx (1)

88-92: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

JSON log example: from_config_file count doesn't match the provider list.

from_config_file=4 and total=5, but config_file_providers lists only 2 names. An operator using this example to understand the log format would expect the array length to match the count.

📝 Suggested fix
 {"msg":"providers resolved","total":5,"from_config_file":4,"from_env":1,
- "config_file_providers":["plutonium-qwen-dflash","uranium-gemma-embedding"],
+ "config_file_providers":["plutonium-qwen-dflash","uranium-gemma-embedding","neptunium-llama-vanadium","kryptonite-mistral-fusion"],
  "env_providers":["opencode-go"]}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/advanced/config-yaml.mdx` around lines 88 - 92, The JSON log example for
the “providers resolved” message has inconsistent counts, since from_config_file
says 4 while config_file_providers only lists 2 entries. Update the example
values so total, from_config_file, env, and the provider arrays all agree,
keeping the log shape in sync with the intended format in the config YAML docs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@docs/advanced/config-yaml.mdx`:
- Around line 88-92: The JSON log example for the “providers resolved” message
has inconsistent counts, since from_config_file says 4 while
config_file_providers only lists 2 entries. Update the example values so total,
from_config_file, env, and the provider arrays all agree, keeping the log shape
in sync with the intended format in the config YAML docs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e8bb1223-4136-48fd-91fe-73363eb74659

📥 Commits

Reviewing files that changed from the base of the PR and between 1f0b33e and 7835f6a.

📒 Files selected for processing (5)
  • config/config.go
  • config/config_strict_test.go
  • config/merge.go
  • config/virtualmodels_test.go
  • docs/advanced/config-yaml.mdx

govulncheck reports the Encrypted Client Hello privacy leak in crypto/tls
(GO-2026-5856), fixed in go1.26.5. It reproduces on pristine main at e0eb0ee,
so CI is red independently of this branch; the reachable traces are the
dashboard, redis, and mongo TLS paths, none of them new.

Bump every pin so the shipped binary is patched, not just CI:

  .github/workflows/test.yml     GO_VERSION 1.26.4 -> 1.26.5
  .github/workflows/release.yml  go-version 1.26.4 -> 1.26.5
  Dockerfile                     golang:1.26.4-alpine3.23 -> 1.26.5
  go.mod                         go 1.26.4 -> go 1.26.5

Verified: govulncheck reports no vulnerabilities with and without the swagger
tag; unit, contract, e2e, and integration suites pass; golangci-lint is clean
across all build tags; and the release image builds and reports go1.26.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Note: this PR now also bumps Go to 1.26.5

Unrelated to the config changes, folded in at the author's request because it blocks CI.

govulncheck began reporting GO-2026-5856 (Encrypted Client Hello privacy leak in crypto/tls, fixed in go1.26.5). It is not a regression from this branch — I reproduced it on pristine origin/main at e0eb0eef with no changes applied:

Fixed in: crypto/tls@go1.26.5
Your code is affected by 1 vulnerability from the Go standard library.

The reachable traces are the existing dashboard, redis, and mongo TLS paths. main is red right now for the same reason.

Bumped every pin, so the shipped binary is patched rather than just CI:

Location Before After
.github/workflows/test.yml GO_VERSION: "1.26.4" "1.26.5"
.github/workflows/release.yml go-version: 1.26.4 1.26.5
Dockerfile golang:1.26.4-alpine3.23 golang:1.26.5-alpine3.23
go.mod go 1.26.4 go 1.26.5

Verified locally on go1.26.5: govulncheck reports no vulnerabilities both with and without the swagger tag; unit, contract, e2e, and Docker-backed integration suites pass; golangci-lint is clean across all build tags; and the release image builds and reports gomodel dev (... go1.26.5).

If you would rather keep this PR focused, the last commit (91db16b8) cherry-picks cleanly onto main on its own.

@SantiagoDePolonia SantiagoDePolonia merged commit 8e53f67 into main Jul 8, 2026
19 checks passed
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.

Validation with ENV and config

2 participants