From 31322f239ee6a95612ed3f4cf6054e48a6761059 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 09:03:12 -0400 Subject: [PATCH] Redact the API key from preset configuration and validation errors FromSettings errors and the configured provider's request-validation errors now pass through a presets redactedError: its text has the key removed with llm.Redact, it does not unwrap to the unredacted layers, and Is still matches the llm sentinels. A key that equals a request value (temperature -0.5) or a setting no longer appears in the error. Co-Authored-By: Claude Opus 5.5 --- internal/ai/llm/presets/presets.go | 46 +++++++++++++++-- internal/ai/llm/presets/presets_test.go | 65 +++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/internal/ai/llm/presets/presets.go b/internal/ai/llm/presets/presets.go index ac80e42..a05c675 100644 --- a/internal/ai/llm/presets/presets.go +++ b/internal/ai/llm/presets/presets.go @@ -6,12 +6,14 @@ // FromSettings lives here rather than in package llm because it builds the // adapters, and every adapter imports llm. // -// Nothing in this package logs, and no error it returns contains the API -// key or quotes a setting's value. +// Nothing in this package logs, and no error it returns or unwraps to +// contains the API key, even where the key equals a setting's or request's +// value or text the error spells anyway. package presets import ( "context" + "errors" "fmt" "math" "regexp" @@ -169,6 +171,14 @@ var all = map[string]Preset{ // nil; a request that sets one keeps it. Neither is sent to a model the // preset's OmitTemperature matches. func FromSettings(s llm.Settings, key string) (llm.Provider, error) { + cp, err := fromSettings(s, key) + if err != nil { + return nil, redact(err, key) + } + return cp, nil +} + +func fromSettings(s llm.Settings, key string) (*configured, error) { p, ok := Lookup(s.Provider) if !ok { return nil, llm.ErrUnknownProvider @@ -205,7 +215,7 @@ func FromSettings(s llm.Settings, key string) (llm.Provider, error) { return nil, fmt.Errorf("%s: %w: %w", p.Name, llm.ErrInvalidSetting, err) } - cp := &configured{Provider: inner} + cp := &configured{Provider: inner, key: key} if !p.omitsTemperature(s.Model) { t := s.Temperature cp.temperature = &t @@ -246,6 +256,9 @@ func check(p Preset, s llm.Settings, key string) error { // temperature quirk to every Generate. type configured struct { llm.Provider + // key is the API key, redacted from validation errors; the adapter + // redacts its own. + key string // temperature is sent when a request sets none; nil means the model // rejects temperature, so none is ever sent. temperature *float64 @@ -256,7 +269,7 @@ type configured struct { // hides an invalid one. func (c *configured) Generate(ctx context.Context, req llm.Request) (llm.Response, error) { if err := req.Validate(); err != nil { - return llm.Response{}, fmt.Errorf("presets: generate: %w", err) + return llm.Response{}, redact(fmt.Errorf("presets: generate: %w", err), c.key) } if c.temperature == nil { req.Temperature = nil @@ -266,3 +279,28 @@ func (c *configured) Generate(ctx context.Context, req llm.Request) (llm.Respons } return c.Provider.Generate(ctx, req) } + +// redactedError is an error with the API key removed from its text. It +// does not unwrap, because every layer below may hold the key; Is still +// matches the sentinels the original error wraps. +type redactedError struct { + text string + err error +} + +func (e *redactedError) Error() string { return e.text } + +// GoString keeps %#v to the redacted Error text instead of the fields. +func (e *redactedError) GoString() string { return e.text } + +// Is reports whether the original error matches target. +func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) } + +// redact returns err with key removed from its text; a nil err or an empty +// key returns err unchanged. +func redact(err error, key string) error { + if err == nil || key == "" { + return err + } + return &redactedError{text: llm.Redact(err.Error(), key), err: err} +} diff --git a/internal/ai/llm/presets/presets_test.go b/internal/ai/llm/presets/presets_test.go index 512a47b..78c6b2c 100644 --- a/internal/ai/llm/presets/presets_test.go +++ b/internal/ai/llm/presets/presets_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "strings" @@ -399,3 +400,67 @@ func TestReadyProviderForEveryPreset(t *testing.T) { } } } + +// A key that matches a value the request carries, or text the error spells +// anyway, never appears in a validation error, and nothing is sent. +func TestGenerateValidationErrorsNeverLeakKey(t *testing.T) { + rt := &recordingTransport{} + orig := http.DefaultTransport + http.DefaultTransport = rt + t.Cleanup(func() { http.DefaultTransport = orig }) + + for _, key := range []string{"-0.5", "temperature", "llm", "negative"} { + p, err := FromSettings(llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m"}, key) + if err != nil { + t.Fatalf("%s: FromSettings: %v", key, err) + } + req := plainRequest() + req.Temperature = ptr(-0.5) + _, err = p.Generate(context.Background(), req) + if err == nil || errors.Is(err, errStop) { + t.Fatalf("%s: Generate = %v, want a validation error", key, err) + } + for e := error(err); e != nil; e = errors.Unwrap(e) { + if s := e.Error() + fmt.Sprintf("%#v", e); strings.Contains(s, key) { + t.Fatalf("%s: error leaks the key: %s", key, s) + } + } + } + if len(rt.reqs) != 0 { + t.Fatalf("sent %d requests, want 0", len(rt.reqs)) + } +} + +// A configuration error keeps its sentinel but never holds the key, even +// when the key is also a setting's value or text the error spells anyway. +func TestConfigErrorsNeverLeakKey(t *testing.T) { + cases := []struct { + name string + s llm.Settings + key string + want error + }{ + {"unknown provider named by key", llm.Settings{Provider: "llm", Model: "m"}, "llm", llm.ErrUnknownProvider}, + {"no base URL", llm.Settings{Provider: "openai_compatible", Model: "m"}, "base URL", llm.ErrMissingBaseURL}, + {"no model", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1"}, "model", llm.ErrMissingModel}, + {"key as model, no public URL", llm.Settings{Provider: "openrouter", Model: "missing"}, "missing", llm.ErrMissingPublicURL}, + {"key as base URL", llm.Settings{Provider: "openai", BaseURL: "llm", Model: "m"}, "llm", llm.ErrInvalidSetting}, + {"key as mode", llm.Settings{Provider: "openai_compatible", BaseURL: "http://l/v1", Model: "m", StructuredOutput: "xml"}, "xml", llm.ErrInvalidSetting}, + {"key as temperature", llm.Settings{Provider: "openai", Model: "m", Temperature: -0.1}, "-0.1", llm.ErrInvalidSetting}, + {"key in bad base URL", llm.Settings{Provider: "openai_compatible", BaseURL: "ftp://k3y", Model: "m"}, "k3y", llm.ErrInvalidSetting}, + {"ollama with key", llm.Settings{Provider: "ollama", BaseURL: "http://o:11434", Model: "m"}, "key", llm.ErrUnexpectedKey}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p, err := FromSettings(c.s, c.key) + if p != nil || !errors.Is(err, c.want) { + t.Fatalf("FromSettings = %v, %v; want %v", p, err, c.want) + } + for e := error(err); e != nil; e = errors.Unwrap(e) { + if s := e.Error() + fmt.Sprintf("%#v", e); strings.Contains(s, c.key) { + t.Fatalf("error leaks the key: %s", s) + } + } + }) + } +}