From e5f669f6e6541d2867def50458e7b7bc8b39b39e Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 11 Oct 2026 22:39:33 -0400 Subject: [PATCH] fix(store): refuse non-string models.yml header values at the door (RIG-3134) rejectCredentialModels skipped a providers..headers value that was not a string, so a mapping, list, number or null value was never checked by isEnvIndirection. It also skipped a headers node that was a list. The installed SDK schema types every header value as a string, so none of these shapes is a valid models.yml. A headers list is worse: the SDK's createLiveConfigHeaders sends each list item under its index as a header name, so a literal secret in a list reaches the request. Both shapes are now refused with the offending path named. Co-authored-by: Matt Wilkinson --- go/internal/store/agent_config.go | 14 +++++++++--- go/internal/store/agent_config_test.go | 31 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/go/internal/store/agent_config.go b/go/internal/store/agent_config.go index bd4f9403a..d38c40f2a 100644 --- a/go/internal/store/agent_config.go +++ b/go/internal/store/agent_config.go @@ -976,6 +976,8 @@ func yamlMapEntries(node any) []yamlMapEntry { // provider surface (CP-4): providers..apiKey, or providers..headers. set // to a NON-env-indirection literal (a pinned secret). An env-referenced header passes. // The door can't see the container env, so "is an env reference" is a syntactic judgment. +// A header value that is not a string, or a headers node that is not a mapping, is +// refused rather than skipped: the SDK schema admits only string values. func rejectCredentialModels(mapping map[string]any, joined string) error { providers, present := mapping["providers"] if !present { @@ -986,13 +988,19 @@ func rejectCredentialModels(mapping map[string]any, joined string) error { return fmt.Errorf("%w: models member %q sets providers.%s.apiKey (provider credentials never ride the config bundle)", ErrInvalidArgument, joined, p.key) } headers, ok := yamlMapIndex(p.val, "headers") - if !ok { + if !ok || headers == nil { continue } - for _, hdr := range yamlMapEntries(headers) { + // The SDK types headers as a string map, yet sends a list's items as + // headers named by index, so a non-mapping headers node is refused. + entries := yamlMapEntries(headers) + if entries == nil { + return fmt.Errorf("%w: models member %q sets providers.%s.headers to a non-mapping (headers must map names to env references)", ErrInvalidArgument, joined, p.key) + } + for _, hdr := range entries { s, ok := hdr.val.(string) if !ok { - continue + return fmt.Errorf("%w: models member %q sets providers.%s.headers.%s to a non-string value (pin it to an env reference instead)", ErrInvalidArgument, joined, p.key, hdr.key) } if !isEnvIndirection(s) { return fmt.Errorf("%w: models member %q sets providers.%s.headers.%s to a literal secret (pin it to an env reference instead)", ErrInvalidArgument, joined, p.key, hdr.key) diff --git a/go/internal/store/agent_config_test.go b/go/internal/store/agent_config_test.go index 4499720b2..1ff4c12d3 100644 --- a/go/internal/store/agent_config_test.go +++ b/go/internal/store/agent_config_test.go @@ -415,6 +415,8 @@ func TestValidateConfigBundleAcceptsNewMembers(t *testing.T) { {"top-level models.yml mapping", []tarEntry{{name: "models.yml", content: "providers:\n x:\n baseUrl: https://y\n"}}}, {"models.yml headers env reference", []tarEntry{{name: "models.yml", content: "providers:\n x:\n headers:\n X-Org: MY_ORG_ENV\n"}}}, {"models.yml headers !command indirection", []tarEntry{{name: "models.yml", content: "providers:\n x:\n headers:\n Authorization: \"!op read secret\"\n"}}}, + {"models.yml empty headers mapping", []tarEntry{{name: "models.yml", content: "providers:\n x:\n headers: {}\n"}}}, + {"models.yml null headers", []tarEntry{{name: "models.yml", content: "providers:\n x:\n headers: ~\n"}}}, // Symmetric to the shielded-credential models.yml reject cases: a nested // non-string key with NO credential leaf must still be ACCEPTED. Guards // against yamlMapEntries over-rejecting benign models config. @@ -560,6 +562,35 @@ func TestValidateConfigBundleRejectsCredentialKeys(t *testing.T) { member: tarEntry{name: "models.yml", content: "providers:\n 0: junk\n openai:\n 1: junk\n apiKey: sk-secret\n"}, wantSub: "providers.openai.apiKey", }, + // The SDK schema types every header value as a string, so a value of any + // other shape is not checked by isEnvIndirection; reject it here. + { + name: "models header value mapping", + member: tarEntry{name: "models.yml", content: "providers:\n x:\n headers:\n Authorization:\n v: \"Bearer sk-live-123\"\n"}, + wantSub: "providers.x.headers.Authorization", + }, + { + name: "models header value list", + member: tarEntry{name: "models.yml", content: "providers:\n x:\n headers:\n Authorization: [\"Bearer sk-live-123\"]\n"}, + wantSub: "providers.x.headers.Authorization", + }, + { + name: "models header value number", + member: tarEntry{name: "models.yml", content: "providers:\n x:\n headers:\n X-Key: 1234567890\n"}, + wantSub: "providers.x.headers.X-Key", + }, + { + name: "models header value null", + member: tarEntry{name: "models.yml", content: "providers:\n x:\n headers:\n X-Key:\n"}, + wantSub: "providers.x.headers.X-Key", + }, + { + // The SDK accepts a headers list and sends each item under its index + // as the header name, so a literal secret in a list reaches the wire. + name: "models headers list", + member: tarEntry{name: "models.yml", content: "providers:\n x:\n headers:\n - \"Bearer sk-live-123\"\n"}, + wantSub: "providers.x.headers", + }, { // The only denylist entry whose leaf is a RECORD not a scalar — the // first case where yamlPathIsSet terminates on a map node. Pins the