diff --git a/redact/redact.go b/redact/redact.go index 8331552c01..3d23bcd059 100644 --- a/redact/redact.go +++ b/redact/redact.go @@ -1049,10 +1049,27 @@ func shouldSkipJSONLField(key string) bool { return false } -// shouldSkipJSONLObject returns true if the object has "type":"image" or "type":"image_url". +// codexImageTypes are the exact "type" values Codex uses for embedded +// screenshots. Matched by explicit equality (not a "_image" suffix) so an +// unrelated object shape that happens to end in "_image" (e.g. a +// "docker_image"/"container_image" record carrying a real secret in another +// field) is not skipped. +var codexImageTypes = map[string]bool{ + "input_image": true, + "output_image": true, +} + +// shouldSkipJSONLObject returns true if the object represents an image payload: +// "type":"image" or "type":"image_url" (Claude/OpenAI chat format), one of +// codexImageTypes (Codex embeds screenshots this way), or "type":"base64". +// Image data is high-entropy binary, not a secret — redacting it would +// corrupt the payload. func shouldSkipJSONLObject(obj map[string]any) bool { t, ok := obj["type"].(string) - return ok && (strings.HasPrefix(t, "image") || t == "base64") + if !ok { + return false + } + return strings.HasPrefix(t, "image") || codexImageTypes[t] || t == "base64" } func shannonEntropy(s string) float64 { diff --git a/redact/redact_test.go b/redact/redact_test.go index 2fb2fd4f67..ae6c1a0bf4 100644 --- a/redact/redact_test.go +++ b/redact/redact_test.go @@ -96,6 +96,34 @@ func TestJSONLBytes_WithSecret(t *testing.T) { } } +func TestJSONLBytes_CodexInputImage_PreservesBase64(t *testing.T) { + // Codex embeds images as "type":"input_image" with a data URL. Before the + // fix, shouldSkipJSONLObject only matched "image"-prefixed types, so this + // high-entropy base64 payload was silently destroyed into "REDACTED". + input := []byte(`{"type":"input_image","image_url":"data:image/png;base64,` + highEntropySecret + `"}`) + result, err := JSONLBytes(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(result.Bytes()) != string(input) { + t.Errorf("expected input_image payload unchanged, got %q", result.Bytes()) + } + if strings.Contains(string(result.Bytes()), RedactedPlaceholder) { + t.Errorf("expected no redaction of input_image payload, got %q", result.Bytes()) + } +} + +func TestJSONLBytes_ClaudeImage_StillPreserved(t *testing.T) { + input := []byte(`{"type":"image","source":{"data":"` + highEntropySecret + `"}}`) + result, err := JSONLBytes(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(result.Bytes()) != string(input) { + t.Errorf("expected image payload unchanged, got %q", result.Bytes()) + } +} + func TestRedactedBytes_Bytes(t *testing.T) { t.Parallel() input := []byte(`{"type":"text","content":"hello"}`) @@ -1252,6 +1280,26 @@ func TestShouldSkipJSONLObject(t *testing.T) { obj: map[string]any{"type": "base64"}, want: true, }, + { + name: "input_image type is skipped", + obj: map[string]any{"type": "input_image", "image_url": "data:image/png;base64,abc"}, + want: true, + }, + { + name: "output_image type is skipped", + obj: map[string]any{"type": "output_image"}, + want: true, + }, + { + name: "docker_image type is NOT skipped (only exact Codex image types match)", + obj: map[string]any{"type": "docker_image", "url": "https://token@registry"}, + want: false, + }, + { + name: "container_image type is NOT skipped", + obj: map[string]any{"type": "container_image"}, + want: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {