From 7a8516b0c6af07c744e87d928fd600bf599b8a93 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 4 Jul 2026 23:17:44 +0000 Subject: [PATCH] Redact direct API keys in request formatting --- docs/consumers/pkg-scriptorium.md | 6 ++- engine_test.go | 74 +++++++++++++++++++++++++++++++ formatting.go | 51 +++++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 formatting.go diff --git a/docs/consumers/pkg-scriptorium.md b/docs/consumers/pkg-scriptorium.md index 6185779..1cf1849 100644 --- a/docs/consumers/pkg-scriptorium.md +++ b/docs/consumers/pkg-scriptorium.md @@ -97,7 +97,9 @@ _ = result.Artifact `RunResult` includes the run ID, output artifact, raw output, validation result, prompt/profile/model metadata, effective model parameters, input hashes, token/cache usage, and timing fields. Validation content failures return a successful `RunResult` with failed validation status. Runtime validation errors return `ErrValidation`. -For the public Go API, pass provider credentials with `RunRequest.APIKey`. The value is request-scoped, uses `json:"-"`, is preferred over profile `api_key_env` by the default OpenAI-compatible client, and is not included in `PreparedRun` or `RunResult` JSON. Do not store raw keys in config, prompt files, or profile YAML. +For the public Go API, pass provider credentials with `RunRequest.APIKey`. The value is request-scoped, uses `json:"-"`, is preferred over profile `api_key_env` by the default OpenAI-compatible client, and is not included in `PreparedRun` or `RunResult` JSON. Normal Go string formatting of `RunRequest` reports only whether a direct key is set. Do not store raw keys in config, prompt files, or profile YAML. + +Avoid logging raw request structs with reflection-based debug dumpers; exported fields remain visible to tools that bypass `String` and `GoString` methods. ## Inject An LLM Client @@ -116,7 +118,7 @@ func (fakeLLM) Generate(ctx context.Context, req scriptorium.GenerateRequest) (* engine, err := scriptorium.NewEngine(cfg, scriptorium.WithLLMClient(fakeLLM{})) ``` -The injected client receives the rendered prompt, effective execution target, target presence metadata for explicit numeric overrides, structured-output spec, and request API key when provided. `GenerateRequest.APIKey` also uses `json:"-"`; custom and fake clients should avoid logging or serializing it. `WithLLMClient(nil)` returns `ErrInvalidConfig`. +The injected client receives the rendered prompt, effective execution target, target presence metadata for explicit numeric overrides, structured-output spec, and request API key when provided. `GenerateRequest.APIKey` also uses `json:"-"`, and normal Go string formatting reports only whether a direct key is set. Custom and fake clients should avoid logging or serializing it. `WithLLMClient(nil)` returns `ErrInvalidConfig`. ## Request Overrides diff --git a/engine_test.go b/engine_test.go index 5a4962b..fdc2d64 100644 --- a/engine_test.go +++ b/engine_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "math" "net/http" "net/http/httptest" @@ -118,6 +119,79 @@ func TestPreparedRunJSONDoesNotExposeSecretOrTargetPresence(t *testing.T) { } } +func TestRunRequestFormattingRedactsDirectAPIKey(t *testing.T) { + const secret = "run-request-secret" + req := scriptorium.RunRequest{ + PromptID: "generic.markdown_summary", + ProfileID: "local-fast", + APIKey: secret, + Inputs: map[string]scriptorium.ArtifactRef{ + "transcript": scriptorium.Inline("Rin opens the gate."), + }, + } + + for _, formatted := range []string{ + fmt.Sprint(req), + fmt.Sprintf("%+v", req), + fmt.Sprintf("%#v", req), + } { + if strings.Contains(formatted, secret) { + t.Fatalf("formatted RunRequest leaked API key: %s", formatted) + } + if !strings.Contains(formatted, "APIKeySet:true") { + t.Fatalf("formatted RunRequest should indicate an API key is set, got %s", formatted) + } + } + + payload, err := json.Marshal(req) + if err != nil { + t.Fatalf("expected RunRequest to marshal, got %v", err) + } + if strings.Contains(string(payload), secret) { + t.Fatalf("RunRequest JSON leaked API key: %s", payload) + } +} + +func TestGenerateRequestFormattingRedactsDirectAPIKey(t *testing.T) { + const secret = "generate-request-secret" + req := scriptorium.GenerateRequest{ + Prompt: scriptorium.RenderedPrompt{Messages: []scriptorium.RenderedMessage{ + {Role: "user", Content: "secret prompt content"}, + }}, + Target: scriptorium.ExecutionTarget{ + Model: "test-model", + ExtraParams: map[string]any{ + "provider_option": "on", + }, + }, + APIKey: secret, + } + + for _, formatted := range []string{ + fmt.Sprint(req), + fmt.Sprintf("%+v", req), + fmt.Sprintf("%#v", req), + } { + if strings.Contains(formatted, secret) { + t.Fatalf("formatted GenerateRequest leaked API key: %s", formatted) + } + if strings.Contains(formatted, "secret prompt content") { + t.Fatalf("formatted GenerateRequest leaked prompt content: %s", formatted) + } + if !strings.Contains(formatted, "APIKeySet:true") { + t.Fatalf("formatted GenerateRequest should indicate an API key is set, got %s", formatted) + } + } + + payload, err := json.Marshal(req) + if err != nil { + t.Fatalf("expected GenerateRequest to marshal, got %v", err) + } + if strings.Contains(string(payload), secret) { + t.Fatalf("GenerateRequest JSON leaked API key: %s", payload) + } +} + func TestPreparePreservesExplicitZeroExecutionOverrides(t *testing.T) { engine := newExampleEngine(t) zeroFloat := 0.0 diff --git a/formatting.go b/formatting.go new file mode 100644 index 0000000..910f094 --- /dev/null +++ b/formatting.go @@ -0,0 +1,51 @@ +package scriptorium + +import "fmt" + +// String returns a concise request summary without exposing direct API keys. +func (r RunRequest) String() string { + return r.redactedString() +} + +// GoString returns a concise request summary without exposing direct API keys. +func (r RunRequest) GoString() string { + return r.redactedString() +} + +func (r RunRequest) redactedString() string { + return fmt.Sprintf( + "scriptorium.RunRequest{PromptID:%q PromptVersion:%q ProfileID:%q APIKeySet:%t Inputs:%d Vars:%d ExecutionSet:%t ValidationSet:%t Metadata:%d}", + r.PromptID, + r.PromptVersion, + r.ProfileID, + r.APIKey != "", + len(r.Inputs), + len(r.Vars), + r.Execution != nil, + r.Validation != nil, + len(r.Metadata), + ) +} + +// String returns a concise request summary without exposing direct API keys or +// rendered prompt content. +func (r GenerateRequest) String() string { + return r.redactedString() +} + +// GoString returns a concise request summary without exposing direct API keys or +// rendered prompt content. +func (r GenerateRequest) GoString() string { + return r.redactedString() +} + +func (r GenerateRequest) redactedString() string { + return fmt.Sprintf( + "scriptorium.GenerateRequest{Messages:%d Model:%q APIKeySet:%t StructuredOutputSet:%t ExtraParams:%d}", + len(r.Prompt.Messages), + r.Target.Model, + r.APIKey != "", + r.StructuredOutput != nil, + len(r.Target.ExtraParams), + ) +}