Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions go/internal/store/agent_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,8 @@ func yamlMapEntries(node any) []yamlMapEntry {
// provider surface (CP-4): providers.<name>.apiKey, or providers.<name>.headers.<h> 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 {
Expand All @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions go/internal/store/agent_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading