Skip to content
Open
21 changes: 19 additions & 2 deletions redact/redact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions redact/redact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`)
Expand Down Expand Up @@ -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) {
Expand Down
Loading