Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
113
backend/internal/signature/claude.go
Normal file
113
backend/internal/signature/claude.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// StripInvalidClaudeThinkingBlocks removes Claude thinking blocks whose
|
||||
// signatures are empty or not valid Claude thinking signatures after stripping
|
||||
// an optional cache prefix, unless the validation options allow an empty
|
||||
// thinking placeholder.
|
||||
func StripInvalidClaudeThinkingBlocks(payload []byte, opts ...ClaudeSignatureValidationOptions) []byte {
|
||||
messages := gjson.GetBytes(payload, "messages")
|
||||
if !messages.IsArray() {
|
||||
return payload
|
||||
}
|
||||
opt := claudeSignatureValidationOptions(opts)
|
||||
messageResults := messages.Array()
|
||||
keptMessages := make([]string, 0, len(messageResults))
|
||||
modified := false
|
||||
for _, msg := range messageResults {
|
||||
content := msg.Get("content")
|
||||
if !content.IsArray() {
|
||||
keptMessages = append(keptMessages, msg.Raw)
|
||||
continue
|
||||
}
|
||||
contentResults := content.Array()
|
||||
keptParts := make([]string, 0, len(contentResults))
|
||||
stripped := false
|
||||
for _, part := range contentResults {
|
||||
if part.Get("type").String() == "thinking" && shouldStripClaudeThinkingBlock(part, opt) {
|
||||
stripped = true
|
||||
continue
|
||||
}
|
||||
keptParts = append(keptParts, part.Raw)
|
||||
}
|
||||
if stripped {
|
||||
modified = true
|
||||
updated, _ := sjson.SetRaw(msg.Raw, "content", "["+strings.Join(keptParts, ",")+"]")
|
||||
keptMessages = append(keptMessages, updated)
|
||||
continue
|
||||
}
|
||||
keptMessages = append(keptMessages, msg.Raw)
|
||||
}
|
||||
if !modified {
|
||||
return payload
|
||||
}
|
||||
output, _ := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(keptMessages, ",")+"]"))
|
||||
return output
|
||||
}
|
||||
|
||||
// StripInvalidClaudeThinkingBlocksAndEmptyMessages also removes messages whose
|
||||
// content becomes empty after invalid thinking blocks are removed.
|
||||
func StripInvalidClaudeThinkingBlocksAndEmptyMessages(payload []byte, opts ...ClaudeSignatureValidationOptions) []byte {
|
||||
stripped := StripInvalidClaudeThinkingBlocks(payload, opts...)
|
||||
if bytes.Equal(stripped, payload) {
|
||||
return payload
|
||||
}
|
||||
messages := gjson.GetBytes(stripped, "messages")
|
||||
if !messages.IsArray() {
|
||||
return stripped
|
||||
}
|
||||
kept := make([]string, 0, len(messages.Array()))
|
||||
for _, message := range messages.Array() {
|
||||
content := message.Get("content")
|
||||
if content.IsArray() && len(content.Array()) == 0 {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, message.Raw)
|
||||
}
|
||||
stripped, _ = sjson.SetRawBytes(stripped, "messages", []byte("["+strings.Join(kept, ",")+"]"))
|
||||
return stripped
|
||||
}
|
||||
|
||||
func shouldStripClaudeThinkingBlock(part gjson.Result, opt ClaudeSignatureValidationOptions) bool {
|
||||
if opt.AllowEmptySignatureWithEmptyText && isEmptyClaudeThinkingPlaceholder(part) {
|
||||
return false
|
||||
}
|
||||
return !IsValidClaudeThinkingSignature(part.Get("signature").String(), opt)
|
||||
}
|
||||
|
||||
func isEmptyClaudeThinkingPlaceholder(part gjson.Result) bool {
|
||||
if strings.TrimSpace(part.Get("signature").String()) != "" {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(claudeThinkingBlockText(part)) == ""
|
||||
}
|
||||
|
||||
func claudeThinkingBlockText(part gjson.Result) string {
|
||||
if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
|
||||
return text.String()
|
||||
}
|
||||
|
||||
thinkingField := part.Get("thinking")
|
||||
if !thinkingField.Exists() {
|
||||
return ""
|
||||
}
|
||||
if thinkingField.Type == gjson.String {
|
||||
return thinkingField.String()
|
||||
}
|
||||
if thinkingField.IsObject() {
|
||||
if inner := thinkingField.Get("text"); inner.Exists() && inner.Type == gjson.String {
|
||||
return inner.String()
|
||||
}
|
||||
if inner := thinkingField.Get("thinking"); inner.Exists() && inner.Type == gjson.String {
|
||||
return inner.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
280
backend/internal/signature/claude_messages_sanitize.go
Normal file
280
backend/internal/signature/claude_messages_sanitize.go
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
type ClaudeMessagesSignatureSanitizeOptions struct {
|
||||
TargetProvider SignatureProvider
|
||||
TargetModel string
|
||||
DropEmptyMessages bool
|
||||
DropToolSignatures bool
|
||||
DropEmptyThinkingPlaceholders bool
|
||||
// PreserveEmptyThinkingBlocks preserves compatibility-mode thinking blocks
|
||||
// together with their original signatures, including opaque signatures.
|
||||
PreserveEmptyThinkingBlocks bool
|
||||
}
|
||||
|
||||
type SignatureSanitizeReport struct {
|
||||
TargetProvider SignatureProvider
|
||||
Preserved int
|
||||
DroppedBlocks int
|
||||
DroppedSignatures int
|
||||
ReplacedSignatures int
|
||||
Decisions []SignatureCompatibilityDecision
|
||||
}
|
||||
|
||||
// SanitizeClaudeMessagesSignaturesForModel removes or preserves Claude
|
||||
// /v1/messages signed history according to the provider family implied by
|
||||
// targetModel.
|
||||
func SanitizeClaudeMessagesSignaturesForModel(payload []byte, targetModel string) ([]byte, SignatureSanitizeReport) {
|
||||
return SanitizeClaudeMessagesSignaturesForTarget(payload, ClaudeMessagesSignatureSanitizeOptions{
|
||||
TargetProvider: SignatureProviderFromModelName(targetModel),
|
||||
TargetModel: targetModel,
|
||||
DropEmptyMessages: true,
|
||||
})
|
||||
}
|
||||
|
||||
// SanitizeClaudeMessagesForClaudeUpstream prepares a Claude /v1/messages body
|
||||
// for Claude-compatible upstreams. Valid Claude signatures are normalized to
|
||||
// provider-native E-form, valid Claude CAIS signatures are kept,
|
||||
// incompatible thinking blocks are dropped, and tool_use blocks keep only their
|
||||
// tool-call payload.
|
||||
func SanitizeClaudeMessagesForClaudeUpstream(payload []byte, targetModel string, preserveEmptyThinkingBlocks ...bool) ([]byte, SignatureSanitizeReport) {
|
||||
preserveEmpty := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0]
|
||||
return SanitizeClaudeMessagesSignaturesForTarget(payload, ClaudeMessagesSignatureSanitizeOptions{
|
||||
TargetProvider: SignatureProviderClaude,
|
||||
TargetModel: targetModel,
|
||||
DropEmptyMessages: true,
|
||||
DropToolSignatures: true,
|
||||
DropEmptyThinkingPlaceholders: !preserveEmpty,
|
||||
PreserveEmptyThinkingBlocks: preserveEmpty,
|
||||
})
|
||||
}
|
||||
|
||||
// SanitizeClaudeMessagesSignaturesForTarget applies provider-aware signature
|
||||
// compatibility rules to Claude /v1/messages history. Compatible thinking
|
||||
// signatures are preserved. Incompatible thinking blocks are removed so a user
|
||||
// can continue a conversation after switching between Claude, GPT/Codex,
|
||||
// and Gemini models.
|
||||
func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessagesSignatureSanitizeOptions) ([]byte, SignatureSanitizeReport) {
|
||||
targetProvider := normalizeSignatureTargetProvider(opts.TargetProvider)
|
||||
if targetProvider == SignatureProviderUnknown && opts.TargetModel != "" {
|
||||
targetProvider = SignatureProviderFromModelName(opts.TargetModel)
|
||||
}
|
||||
report := SignatureSanitizeReport{TargetProvider: targetProvider}
|
||||
|
||||
messages := gjson.GetBytes(payload, "messages")
|
||||
if !messages.IsArray() {
|
||||
return payload, report
|
||||
}
|
||||
|
||||
messageResults := messages.Array()
|
||||
keptMessages := make([]string, 0, len(messageResults))
|
||||
modified := false
|
||||
|
||||
for i, message := range messageResults {
|
||||
content := message.Get("content")
|
||||
if !content.IsArray() {
|
||||
keptMessages = append(keptMessages, message.Raw)
|
||||
continue
|
||||
}
|
||||
|
||||
contentResults := content.Array()
|
||||
keptParts := make([]string, 0, len(contentResults))
|
||||
messageModified := false
|
||||
|
||||
for j, part := range contentResults {
|
||||
partType := part.Get("type").String()
|
||||
if partType == "tool_use" {
|
||||
if opts.DropToolSignatures {
|
||||
updatedPart, changed := stripClaudeToolUseSignatureFields(part)
|
||||
if changed {
|
||||
messageModified = true
|
||||
report.DroppedSignatures++
|
||||
}
|
||||
keptParts = append(keptParts, updatedPart)
|
||||
continue
|
||||
}
|
||||
updatedPart, changed, decisions := sanitizeClaudeToolUseSignature(part, targetProvider, opts.TargetModel, i, j)
|
||||
report.Decisions = append(report.Decisions, decisions...)
|
||||
if changed {
|
||||
messageModified = true
|
||||
}
|
||||
for _, decision := range decisions {
|
||||
switch decision.Action {
|
||||
case SignatureActionPreserve:
|
||||
report.Preserved++
|
||||
case SignatureActionReplaceWithGeminiBypass:
|
||||
report.ReplacedSignatures++
|
||||
default:
|
||||
report.DroppedSignatures++
|
||||
}
|
||||
}
|
||||
keptParts = append(keptParts, updatedPart)
|
||||
continue
|
||||
}
|
||||
|
||||
if partType != "thinking" {
|
||||
keptParts = append(keptParts, part.Raw)
|
||||
continue
|
||||
}
|
||||
|
||||
rawSignature := part.Get("signature").String()
|
||||
if opts.PreserveEmptyThinkingBlocks {
|
||||
report.Preserved++
|
||||
keptParts = append(keptParts, part.Raw)
|
||||
continue
|
||||
}
|
||||
if targetProvider == SignatureProviderClaude && isEmptyClaudeThinkingPlaceholder(part) && !opts.DropEmptyThinkingPlaceholders {
|
||||
keptParts = append(keptParts, part.Raw)
|
||||
continue
|
||||
}
|
||||
|
||||
decision := DecideSignatureCompatibilityForModel(targetProvider, opts.TargetModel, rawSignature, SignatureBlockKindClaudeThinking)
|
||||
decision.Reason = fmt.Sprintf("messages[%d].content[%d]: %s", i, j, decision.Reason)
|
||||
report.Decisions = append(report.Decisions, decision)
|
||||
|
||||
switch decision.Action {
|
||||
case SignatureActionPreserve:
|
||||
report.Preserved++
|
||||
if decision.NormalizedSignature != "" && decision.NormalizedSignature != rawSignature {
|
||||
updated, _ := sjson.Set(part.Raw, "signature", decision.NormalizedSignature)
|
||||
keptParts = append(keptParts, updated)
|
||||
messageModified = true
|
||||
continue
|
||||
}
|
||||
keptParts = append(keptParts, part.Raw)
|
||||
case SignatureActionReplaceWithGeminiBypass:
|
||||
report.ReplacedSignatures++
|
||||
updated, _ := sjson.Set(part.Raw, "signature", decision.ReplacementSignature)
|
||||
keptParts = append(keptParts, updated)
|
||||
messageModified = true
|
||||
case SignatureActionDropSignature:
|
||||
report.DroppedSignatures++
|
||||
updated, _ := sjson.Delete(part.Raw, "signature")
|
||||
keptParts = append(keptParts, updated)
|
||||
messageModified = true
|
||||
default:
|
||||
report.DroppedBlocks++
|
||||
messageModified = true
|
||||
}
|
||||
}
|
||||
|
||||
if messageModified {
|
||||
modified = true
|
||||
if len(keptParts) == 0 && opts.DropEmptyMessages {
|
||||
continue
|
||||
}
|
||||
updated, _ := sjson.SetRaw(message.Raw, "content", "["+strings.Join(keptParts, ",")+"]")
|
||||
keptMessages = append(keptMessages, updated)
|
||||
continue
|
||||
}
|
||||
|
||||
keptMessages = append(keptMessages, message.Raw)
|
||||
}
|
||||
|
||||
if !modified {
|
||||
return payload, report
|
||||
}
|
||||
output, _ := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(keptMessages, ",")+"]"))
|
||||
return output, report
|
||||
}
|
||||
|
||||
func stripClaudeToolUseSignatureFields(part gjson.Result) (string, bool) {
|
||||
updated := part.Raw
|
||||
changed := false
|
||||
for _, sigPath := range claudeToolUseProvenancePaths() {
|
||||
if !gjson.Get(updated, sigPath).Exists() {
|
||||
continue
|
||||
}
|
||||
updated, _ = sjson.Delete(updated, sigPath)
|
||||
changed = true
|
||||
}
|
||||
if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content.google"); ok {
|
||||
updated = cleaned
|
||||
changed = true
|
||||
}
|
||||
if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content"); ok {
|
||||
updated = cleaned
|
||||
changed = true
|
||||
}
|
||||
return updated, changed
|
||||
}
|
||||
|
||||
func sanitizeClaudeToolUseSignature(part gjson.Result, targetProvider SignatureProvider, targetModel string, messageIdx, partIdx int) (string, bool, []SignatureCompatibilityDecision) {
|
||||
updated := part.Raw
|
||||
changed := false
|
||||
var decisions []SignatureCompatibilityDecision
|
||||
|
||||
for _, sigPath := range claudeToolUseSignaturePaths() {
|
||||
sigResult := part.Get(sigPath)
|
||||
if !sigResult.Exists() {
|
||||
continue
|
||||
}
|
||||
|
||||
blockKind := SignatureBlockKindGeminiFunctionCall
|
||||
if targetProvider == SignatureProviderClaude {
|
||||
blockKind = SignatureBlockKindClaudeThinking
|
||||
} else if targetProvider == SignatureProviderGPT {
|
||||
blockKind = SignatureBlockKindGPTReasoning
|
||||
}
|
||||
decision := DecideSignatureCompatibilityForModel(targetProvider, targetModel, sigResult.String(), blockKind)
|
||||
decision.Reason = fmt.Sprintf("messages[%d].content[%d].%s: %s", messageIdx, partIdx, sigPath, decision.Reason)
|
||||
decisions = append(decisions, decision)
|
||||
|
||||
switch decision.Action {
|
||||
case SignatureActionPreserve:
|
||||
if decision.NormalizedSignature != "" && decision.NormalizedSignature != sigResult.String() {
|
||||
updated, _ = sjson.Set(updated, sigPath, decision.NormalizedSignature)
|
||||
changed = true
|
||||
}
|
||||
case SignatureActionReplaceWithGeminiBypass:
|
||||
updated, _ = sjson.Set(updated, sigPath, decision.ReplacementSignature)
|
||||
changed = true
|
||||
default:
|
||||
updated, _ = sjson.Delete(updated, sigPath)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content.google"); ok {
|
||||
updated = cleaned
|
||||
changed = true
|
||||
}
|
||||
if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content"); ok {
|
||||
updated = cleaned
|
||||
changed = true
|
||||
}
|
||||
|
||||
return updated, changed, decisions
|
||||
}
|
||||
|
||||
func claudeToolUseSignaturePaths() []string {
|
||||
return []string{
|
||||
"signature",
|
||||
"thoughtSignature",
|
||||
"thought_signature",
|
||||
"extra_content.google.thought_signature",
|
||||
}
|
||||
}
|
||||
|
||||
func claudeToolUseProvenancePaths() []string {
|
||||
return append(claudeToolUseSignaturePaths(), "model")
|
||||
}
|
||||
|
||||
func deleteEmptyJSONObjectPath(raw, path string) (string, bool) {
|
||||
result := gjson.Get(raw, path)
|
||||
if !result.Exists() || !result.IsObject() || len(result.Map()) != 0 {
|
||||
return raw, false
|
||||
}
|
||||
updated, err := sjson.Delete(raw, path)
|
||||
if err != nil {
|
||||
return raw, false
|
||||
}
|
||||
return updated, true
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesEmptyThinkingInCompatMode(t *testing.T) {
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":""}]}]}`)
|
||||
|
||||
withoutCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4")
|
||||
if gjson.GetBytes(withoutCompat, "messages.0.content.#").Int() != 0 {
|
||||
t.Fatalf("default sanitizer preserved empty thinking: %s", withoutCompat)
|
||||
}
|
||||
|
||||
withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4", true)
|
||||
part := gjson.GetBytes(withCompat, "messages.0.content.0")
|
||||
if part.Get("type").String() != "thinking" || part.Get("signature").String() != "" {
|
||||
t.Fatalf("compat sanitizer dropped empty thinking: %s", withCompat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesOpaqueThinkingSignatureInCompatMode(t *testing.T) {
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"opaque-deepseek-id"}]}]}`)
|
||||
|
||||
withoutCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4")
|
||||
if gjson.GetBytes(withoutCompat, "messages.0.content.0.signature").String() != "" {
|
||||
t.Fatalf("default sanitizer preserved opaque signature: %s", withoutCompat)
|
||||
}
|
||||
|
||||
withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4", true)
|
||||
part := gjson.GetBytes(withCompat, "messages.0.content.0")
|
||||
if part.Get("type").String() != "thinking" || part.Get("signature").String() != "opaque-deepseek-id" {
|
||||
t.Fatalf("compat sanitizer dropped opaque signature: %s", withCompat)
|
||||
}
|
||||
}
|
||||
641
backend/internal/signature/claude_test.go
Normal file
641
backend/internal/signature/claude_test.go
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
func TestStripInvalidClaudeThinkingBlocks_RemovesGPTEncryptedContent(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"messages": [
|
||||
{"role":"assistant","content":[
|
||||
{"type":"thinking","thinking":"codex reasoning","signature":"gAAAAABopenai-encrypted-content"},
|
||||
{"type":"text","text":"Answer"}
|
||||
]},
|
||||
{"role":"user","content":[{"type":"text","text":"next"}]}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := StripInvalidClaudeThinkingBlocks(input)
|
||||
content := gjson.GetBytes(out, "messages.0.content").Array()
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("messages.0.content length = %d, want 1: %s", len(content), string(out))
|
||||
}
|
||||
if got := content[0].Get("text").String(); got != "Answer" {
|
||||
t.Fatalf("remaining content text = %q, want Answer", got)
|
||||
}
|
||||
if strings.Contains(string(out), "gAAAAABopenai-encrypted-content") || strings.Contains(string(out), "codex reasoning") {
|
||||
t.Fatalf("invalid thinking block was preserved: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripInvalidClaudeThinkingBlocksAndEmptyMessages_DropsMessagesLeftEmpty(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"messages": [
|
||||
{"role":"assistant","content":[
|
||||
{"type":"thinking","thinking":"codex reasoning","signature":"gAAAAABopenai-encrypted-content"}
|
||||
]},
|
||||
{"role":"user","content":[{"type":"text","text":"next"}]}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := StripInvalidClaudeThinkingBlocksAndEmptyMessages(input)
|
||||
messages := gjson.GetBytes(out, "messages").Array()
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("messages length = %d, want 1: %s", len(messages), string(out))
|
||||
}
|
||||
if got := messages[0].Get("role").String(); got != "user" {
|
||||
t.Fatalf("remaining role = %q, want user", got)
|
||||
}
|
||||
if strings.Contains(string(out), "gAAAAABopenai-encrypted-content") || strings.Contains(string(out), "codex reasoning") {
|
||||
t.Fatalf("invalid thinking block was preserved: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripInvalidClaudeThinkingBlocks_RemovesMalformedEPrefix(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"messages": [{"role":"assistant","content":[
|
||||
{"type":"thinking","thinking":"bad","signature":"Ebad"},
|
||||
{"type":"text","text":"Answer"}
|
||||
]}]
|
||||
}`)
|
||||
|
||||
out := StripInvalidClaudeThinkingBlocks(input)
|
||||
content := gjson.GetBytes(out, "messages.0.content").Array()
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("content length = %d, want 1: %s", len(content), string(out))
|
||||
}
|
||||
if strings.Contains(string(out), "Ebad") || strings.Contains(string(out), "bad") {
|
||||
t.Fatalf("malformed E-prefix thinking block was preserved: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripInvalidClaudeThinkingBlocks_Base64OnlyKeepsDecodableEPrefix(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"messages": [{"role":"assistant","content":[
|
||||
{"type":"thinking","thinking":"bad","signature":"Ebad"},
|
||||
{"type":"text","text":"Answer"}
|
||||
]}]
|
||||
}`)
|
||||
|
||||
out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{Base64Only: true})
|
||||
content := gjson.GetBytes(out, "messages.0.content").Array()
|
||||
if len(content) != 2 {
|
||||
t.Fatalf("content length = %d, want 2: %s", len(content), string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripInvalidClaudeThinkingBlocks_Base64OnlyRemovesInvalidBase64(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"messages": [{"role":"assistant","content":[
|
||||
{"type":"thinking","thinking":"bad","signature":"E!!!invalid!!!"},
|
||||
{"type":"text","text":"Answer"}
|
||||
]}]
|
||||
}`)
|
||||
|
||||
out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{Base64Only: true})
|
||||
content := gjson.GetBytes(out, "messages.0.content").Array()
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("content length = %d, want 1: %s", len(content), string(out))
|
||||
}
|
||||
if strings.Contains(string(out), "E!!!invalid!!!") || strings.Contains(string(out), "bad") {
|
||||
t.Fatalf("invalid-base64 thinking block was preserved: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripInvalidClaudeThinkingBlocks_AllowsEmptySignatureEmptyTextPlaceholder(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"messages": [{"role":"assistant","content":[
|
||||
{"type":"thinking","text":"","signature":""},
|
||||
{"type":"text","text":"Answer"}
|
||||
]}]
|
||||
}`)
|
||||
|
||||
out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{
|
||||
Base64Only: true,
|
||||
AllowEmptySignatureWithEmptyText: true,
|
||||
})
|
||||
content := gjson.GetBytes(out, "messages.0.content").Array()
|
||||
if len(content) != 2 {
|
||||
t.Fatalf("content length = %d, want 2: %s", len(content), string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripInvalidClaudeThinkingBlocks_StrictRemovesMalformedClaudeTree(t *testing.T) {
|
||||
sig := base64.StdEncoding.EncodeToString([]byte{0x12, 0xFF, 0xFE, 0xFD})
|
||||
input := []byte(`{
|
||||
"messages": [{"role":"assistant","content":[
|
||||
{"type":"thinking","thinking":"bad","signature":"` + sig + `"},
|
||||
{"type":"text","text":"Answer"}
|
||||
]}]
|
||||
}`)
|
||||
|
||||
out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{Strict: true})
|
||||
content := gjson.GetBytes(out, "messages.0.content").Array()
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("content length = %d, want 1: %s", len(content), string(out))
|
||||
}
|
||||
if strings.Contains(string(out), sig) || strings.Contains(string(out), "bad") {
|
||||
t.Fatalf("strict-invalid thinking block was preserved: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripInvalidClaudeThinkingBlocks_KeepsClaudeSignaturePrefixes(t *testing.T) {
|
||||
singleLayer := base64.StdEncoding.EncodeToString([]byte{0x12, 0x34})
|
||||
doubleLayer := base64.StdEncoding.EncodeToString([]byte(singleLayer))
|
||||
input := []byte(`{
|
||||
"messages": [{"role":"assistant","content":[
|
||||
{"type":"thinking","thinking":"one","signature":"` + singleLayer + `"},
|
||||
{"type":"thinking","thinking":"two","signature":"modelGroup#` + doubleLayer + `"}
|
||||
]}]
|
||||
}`)
|
||||
|
||||
out := StripInvalidClaudeThinkingBlocks(input)
|
||||
content := gjson.GetBytes(out, "messages.0.content").Array()
|
||||
if len(content) != 2 {
|
||||
t.Fatalf("content length = %d, want 2: %s", len(content), string(out))
|
||||
}
|
||||
}
|
||||
|
||||
const observedFable5Sample = "CAISqwIKiAEIEBgCKkBHRlRBsNiptQUWfPoOhuQKwi5LnncZVO9bB5jqOs76D7uBtgktML0zqJtNmLHXHHcgD6lk4MQu4QBXzFd1lbC3Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okZDk3NDM5NzUtNGJiMC00OTM2LTllMjgtZDViMGQyMWJkYzQ4EgxCGh+XVFFFeySAjtAaDL/A1LltGu6MMJ+eXSIwsN0oBpDrqLv22UBfkMnTotnIbkvkOyb9xZHgigG6OZVHaI3gThm+maLKmgO5PrFLKlDFYp+YZksy/wKwszJlnLTPzAK+NUlfzagOE1ymtZTXhAYK260XyFYmg/te/C231+Fr/hoX+EJoUBnrn0gD7hqMISOT+TaFEuOXYsN517GfaxgB"
|
||||
|
||||
const observedContextID = "d9743975-4bb0-4936-9e28-d5b0d21bdc48"
|
||||
|
||||
// claudeCAISParts builds Claude CAIS signatures field by field so tests can
|
||||
// assert both the observed layout and the upstream drift the validator must
|
||||
// tolerate or reject.
|
||||
type claudeCAISParts struct {
|
||||
includeTopEnvelope bool
|
||||
topEnvelope uint64
|
||||
includeTopTrailer bool
|
||||
includeContainer bool
|
||||
includeChannelBlock bool
|
||||
includeChannelID bool
|
||||
channelID uint64
|
||||
channelIDAsBytes bool
|
||||
includeChannelVerion bool
|
||||
includeSignature bool
|
||||
signatureLen int
|
||||
includeModelText bool
|
||||
modelText []byte
|
||||
includeField7 bool
|
||||
blockKind string
|
||||
contextID string
|
||||
}
|
||||
|
||||
// defaultClaudeCAISParts mirrors the layout observed on claude-fable-5 and
|
||||
// claude-opus-5 responses.
|
||||
func defaultClaudeCAISParts(model string) claudeCAISParts {
|
||||
return claudeCAISParts{
|
||||
includeTopEnvelope: true,
|
||||
topEnvelope: 2,
|
||||
includeTopTrailer: true,
|
||||
includeContainer: true,
|
||||
includeChannelBlock: true,
|
||||
includeChannelID: true,
|
||||
channelID: 16,
|
||||
includeChannelVerion: true,
|
||||
includeSignature: true,
|
||||
signatureLen: 64,
|
||||
includeModelText: true,
|
||||
modelText: []byte(model),
|
||||
includeField7: true,
|
||||
blockKind: "thinking",
|
||||
contextID: observedContextID,
|
||||
}
|
||||
}
|
||||
|
||||
func (p claudeCAISParts) encode() string {
|
||||
var channelBlock []byte
|
||||
if p.includeChannelID {
|
||||
if p.channelIDAsBytes {
|
||||
channelBlock = protowire.AppendTag(channelBlock, 1, protowire.BytesType)
|
||||
channelBlock = protowire.AppendBytes(channelBlock, []byte{0x10})
|
||||
} else {
|
||||
channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
|
||||
channelBlock = protowire.AppendVarint(channelBlock, p.channelID)
|
||||
}
|
||||
}
|
||||
if p.includeChannelVerion {
|
||||
channelBlock = protowire.AppendTag(channelBlock, 3, protowire.VarintType)
|
||||
channelBlock = protowire.AppendVarint(channelBlock, 2)
|
||||
}
|
||||
if p.includeSignature {
|
||||
channelBlock = protowire.AppendTag(channelBlock, 5, protowire.BytesType)
|
||||
channelBlock = protowire.AppendBytes(channelBlock, make([]byte, p.signatureLen))
|
||||
}
|
||||
if p.includeModelText {
|
||||
channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
|
||||
channelBlock = protowire.AppendBytes(channelBlock, p.modelText)
|
||||
}
|
||||
if p.includeField7 {
|
||||
channelBlock = protowire.AppendTag(channelBlock, 7, protowire.VarintType)
|
||||
channelBlock = protowire.AppendVarint(channelBlock, 1)
|
||||
}
|
||||
if p.blockKind != "" {
|
||||
channelBlock = protowire.AppendTag(channelBlock, 8, protowire.BytesType)
|
||||
channelBlock = protowire.AppendString(channelBlock, p.blockKind)
|
||||
}
|
||||
if p.contextID != "" {
|
||||
channelBlock = protowire.AppendTag(channelBlock, 11, protowire.BytesType)
|
||||
channelBlock = protowire.AppendString(channelBlock, p.contextID)
|
||||
}
|
||||
|
||||
var container []byte
|
||||
if p.includeChannelBlock {
|
||||
container = protowire.AppendTag(container, 1, protowire.BytesType)
|
||||
container = protowire.AppendBytes(container, channelBlock)
|
||||
}
|
||||
|
||||
var payload []byte
|
||||
if p.includeTopEnvelope {
|
||||
payload = protowire.AppendTag(payload, 1, protowire.VarintType)
|
||||
payload = protowire.AppendVarint(payload, p.topEnvelope)
|
||||
}
|
||||
if p.includeContainer {
|
||||
payload = protowire.AppendTag(payload, 2, protowire.BytesType)
|
||||
payload = protowire.AppendBytes(payload, container)
|
||||
}
|
||||
if p.includeTopTrailer {
|
||||
payload = protowire.AppendTag(payload, 3, protowire.VarintType)
|
||||
payload = protowire.AppendVarint(payload, 1)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func testClaudeCAISSignature(model string) string {
|
||||
return defaultClaudeCAISParts(model).encode()
|
||||
}
|
||||
|
||||
func TestClaudeCAISSignature_ObservedFable5Sample(t *testing.T) {
|
||||
if !IsValidClaudeCAISSignature(observedFable5Sample) {
|
||||
t.Fatal("IsValidClaudeCAISSignature(observedFable5Sample) = false, want true")
|
||||
}
|
||||
|
||||
info, err := InspectClaudeCAISSignature(observedFable5Sample)
|
||||
if err != nil {
|
||||
t.Fatalf("InspectClaudeCAISSignature failed: %v", err)
|
||||
}
|
||||
|
||||
if info.ModelText != "claude-fable-5" {
|
||||
t.Fatalf("ModelText = %q, want %q", info.ModelText, "claude-fable-5")
|
||||
}
|
||||
if info.BlockKind != "thinking" {
|
||||
t.Fatalf("BlockKind = %q, want %q", info.BlockKind, "thinking")
|
||||
}
|
||||
expectedUUID := "d9743975-4bb0-4936-9e28-d5b0d21bdc48"
|
||||
if info.ContextID != expectedUUID {
|
||||
t.Fatalf("ContextID = %q, want %q", info.ContextID, expectedUUID)
|
||||
}
|
||||
if info.FirstByte != 0x08 {
|
||||
t.Fatalf("FirstByte = 0x%02x, want 0x08", info.FirstByte)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCAISSignature_DetectSignatureProvider(t *testing.T) {
|
||||
prefixes := []string{
|
||||
"",
|
||||
"ccmax#",
|
||||
"claude-code-max#",
|
||||
"claude_code_max#",
|
||||
"cais#",
|
||||
"claude-cais#",
|
||||
"claude_cais#",
|
||||
"claude#",
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
sig := prefix + observedFable5Sample
|
||||
got := DetectSignatureProvider(sig)
|
||||
if got != SignatureProviderClaude {
|
||||
t.Errorf("DetectSignatureProvider(%q) = %q, want %q", sig, got, SignatureProviderClaude)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCAISSignature_ObservedOpus5Layout(t *testing.T) {
|
||||
signature := testClaudeCAISSignature("claude-opus-5")
|
||||
info, err := InspectClaudeCAISSignature(signature)
|
||||
if err != nil {
|
||||
t.Fatalf("InspectClaudeCAISSignature failed: %v", err)
|
||||
}
|
||||
if info.ModelText != "claude-opus-5" {
|
||||
t.Fatalf("ModelText = %q, want claude-opus-5", info.ModelText)
|
||||
}
|
||||
decision := DecideSignatureCompatibilityForModel(SignatureProviderClaude, "claude-opus-5", signature, SignatureBlockKindClaudeThinking)
|
||||
if !decision.Compatible || decision.NormalizedSignature != signature || decision.DetectedProvider != SignatureProviderClaude {
|
||||
t.Fatalf("same-model opus-5 decision = %+v, want preserved with DetectedProvider=claude", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCAISSignature_NotCompatibleWithGemini(t *testing.T) {
|
||||
if normalized, ok := CompatibleSignatureForProvider(SignatureProviderGemini, observedFable5Sample); ok || normalized != "" {
|
||||
t.Fatalf("CompatibleSignatureForProvider(Gemini) = %q, %v; want empty and false", normalized, ok)
|
||||
}
|
||||
if IsSignatureCompatibleWithProvider(SignatureProviderGemini, observedFable5Sample) {
|
||||
t.Fatal("IsSignatureCompatibleWithProvider(Gemini) = true, want false")
|
||||
}
|
||||
if isRecognizedGeminiProviderSignature(observedFable5Sample, SignatureBlockKindUnknown) {
|
||||
t.Fatal("isRecognizedGeminiProviderSignature = true, want false")
|
||||
}
|
||||
if _, err := InspectGeminiThoughtSignature(observedFable5Sample); err == nil {
|
||||
t.Fatal("InspectGeminiThoughtSignature should fail for Claude CAIS signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCAISSignature_CompatibleWithAllClaudeTargets(t *testing.T) {
|
||||
decision := DecideSignatureCompatibilityForModel(SignatureProviderClaude, "claude-fable-5", observedFable5Sample, SignatureBlockKindClaudeThinking)
|
||||
if !decision.Compatible || decision.Action != SignatureActionPreserve || decision.NormalizedSignature != observedFable5Sample || decision.DetectedProvider != SignatureProviderClaude {
|
||||
t.Fatalf("DecideSignatureCompatibilityForModel(Claude, claude-fable-5) = %+v, want compatible & preserved with DetectedProvider=claude", decision)
|
||||
}
|
||||
|
||||
decisionCase := DecideSignatureCompatibilityForModel(SignatureProviderClaude, "CLAUDE-FABLE-5", observedFable5Sample, SignatureBlockKindClaudeThinking)
|
||||
if !decisionCase.Compatible || decisionCase.Action != SignatureActionPreserve {
|
||||
t.Fatalf("DecideSignatureCompatibilityForModel case-insensitive failed: %+v", decisionCase)
|
||||
}
|
||||
|
||||
decisionDiff := DecideSignatureCompatibilityForModel(SignatureProviderClaude, "claude-opus-5", observedFable5Sample, SignatureBlockKindClaudeThinking)
|
||||
if !decisionDiff.Compatible || decisionDiff.Action != SignatureActionPreserve || decisionDiff.NormalizedSignature != observedFable5Sample {
|
||||
t.Fatalf("DecideSignatureCompatibilityForModel(Claude, claude-opus-5) = %+v, want compatible & preserved", decisionDiff)
|
||||
}
|
||||
|
||||
opus5Sig := testClaudeCAISSignature("claude-opus-5")
|
||||
decisionOpusToOpus48 := DecideSignatureCompatibilityForModel(SignatureProviderClaude, "claude-opus-4-8", opus5Sig, SignatureBlockKindClaudeThinking)
|
||||
if !decisionOpusToOpus48.Compatible || decisionOpusToOpus48.Action != SignatureActionPreserve || decisionOpusToOpus48.NormalizedSignature != opus5Sig {
|
||||
t.Fatalf("DecideSignatureCompatibilityForModel(Claude, claude-opus-4-8) with opus-5 signature = %+v, want compatible & preserved", decisionOpusToOpus48)
|
||||
}
|
||||
|
||||
if normalized, ok := CompatibleSignatureForProvider(SignatureProviderClaude, observedFable5Sample); !ok || normalized != observedFable5Sample {
|
||||
t.Fatalf("CompatibleSignatureForProvider(Claude, observedFable5Sample) = %q, %v; want %q, true", normalized, ok, observedFable5Sample)
|
||||
}
|
||||
|
||||
decisionGemini := DecideSignatureCompatibilityForModel(SignatureProviderGemini, "claude-fable-5", observedFable5Sample, SignatureBlockKindClaudeThinking)
|
||||
if decisionGemini.Compatible {
|
||||
t.Fatalf("DecideSignatureCompatibilityForModel(Gemini, claude-fable-5) = %+v, want incompatible", decisionGemini)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeClaudeMessagesForClaudeUpstream_ClaudeCAIS(t *testing.T) {
|
||||
inputSame := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + observedFable5Sample + `"},{"type":"text","text":"answer"}]}]}`)
|
||||
|
||||
outputSame, reportSame := SanitizeClaudeMessagesForClaudeUpstream(inputSame, "claude-fable-5")
|
||||
if reportSame.Preserved != 1 || reportSame.DroppedBlocks != 0 {
|
||||
t.Fatalf("unexpected report for same model: %+v", reportSame)
|
||||
}
|
||||
if got := gjson.GetBytes(outputSame, "messages.0.content.0.signature").String(); got != observedFable5Sample {
|
||||
t.Fatalf("signature = %q, want preserved %q", got, observedFable5Sample)
|
||||
}
|
||||
|
||||
inputTool := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + observedFable5Sample + `"},{"type":"text","text":"answer"},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"pwd"},"signature":"` + observedFable5Sample + `"}]}]}`)
|
||||
outputTool, reportTool := SanitizeClaudeMessagesForClaudeUpstream(inputTool, "claude-fable-5")
|
||||
if reportTool.Preserved != 1 {
|
||||
t.Fatalf("unexpected report for tool input: %+v", reportTool)
|
||||
}
|
||||
partsTool := gjson.GetBytes(outputTool, "messages.0.content").Array()
|
||||
if len(partsTool) != 3 {
|
||||
t.Fatalf("content len = %d, want 3", len(partsTool))
|
||||
}
|
||||
if partsTool[0].Get("signature").String() != observedFable5Sample {
|
||||
t.Fatalf("thinking block signature lost: %s", partsTool[0].Raw)
|
||||
}
|
||||
if partsTool[2].Get("signature").Exists() {
|
||||
t.Fatalf("tool_use signature should be stripped: %s", partsTool[2].Raw)
|
||||
}
|
||||
|
||||
inputDiff := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + observedFable5Sample + `"},{"type":"text","text":"answer"}]}]}`)
|
||||
outputDiff, reportDiff := SanitizeClaudeMessagesForClaudeUpstream(inputDiff, "claude-opus-5")
|
||||
if reportDiff.Preserved != 1 || reportDiff.DroppedBlocks != 0 {
|
||||
t.Fatalf("unexpected report for cross model: %+v", reportDiff)
|
||||
}
|
||||
partsDiff := gjson.GetBytes(outputDiff, "messages.0.content").Array()
|
||||
if len(partsDiff) != 2 {
|
||||
t.Fatalf("content len = %d, want 2: %s", len(partsDiff), outputDiff)
|
||||
}
|
||||
if got := partsDiff[0].Get("signature").String(); got != observedFable5Sample {
|
||||
t.Fatalf("thinking signature = %q, want %q", got, observedFable5Sample)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaudeCAISSignature_ToleratesUpstreamFieldDrift pins the deliberately
|
||||
// structural validation: rejecting a signature drops the whole thinking block,
|
||||
// so incidental values observed today must not become hard requirements.
|
||||
func TestClaudeCAISSignature_ToleratesUpstreamFieldDrift(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
parts claudeCAISParts
|
||||
}{
|
||||
{"observed layout", defaultClaudeCAISParts("claude-opus-5")},
|
||||
{"new channel id", func() claudeCAISParts {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.channelID = 17
|
||||
return p
|
||||
}()},
|
||||
{"new envelope version", func() claudeCAISParts {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.topEnvelope = 3
|
||||
return p
|
||||
}()},
|
||||
{"no top-level trailer", func() claudeCAISParts {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.includeTopTrailer = false
|
||||
return p
|
||||
}()},
|
||||
{"no channel version", func() claudeCAISParts {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.includeChannelVerion = false
|
||||
return p
|
||||
}()},
|
||||
{"longer signature bytes", func() claudeCAISParts {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.signatureLen = 96
|
||||
return p
|
||||
}()},
|
||||
{"no field 7", func() claudeCAISParts {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.includeField7 = false
|
||||
return p
|
||||
}()},
|
||||
{"other block kind", func() claudeCAISParts {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.blockKind = "redacted_thinking"
|
||||
return p
|
||||
}()},
|
||||
{"no block kind", func() claudeCAISParts {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.blockKind = ""
|
||||
return p
|
||||
}()},
|
||||
{"no context id", func() claudeCAISParts {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.contextID = ""
|
||||
return p
|
||||
}()},
|
||||
{"unreleased model name", func() claudeCAISParts {
|
||||
p := defaultClaudeCAISParts("claude-opus-6-preview")
|
||||
return p
|
||||
}()},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sig := tc.parts.encode()
|
||||
if _, err := InspectClaudeCAISSignature(sig); err != nil {
|
||||
t.Fatalf("InspectClaudeCAISSignature failed: %v", err)
|
||||
}
|
||||
if got := DetectSignatureProviderForBlock(sig, SignatureBlockKindClaudeThinking); got != SignatureProviderClaude {
|
||||
t.Fatalf("DetectSignatureProviderForBlock = %q, want %q", got, SignatureProviderClaude)
|
||||
}
|
||||
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + sig + `"}]}]}`)
|
||||
output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-opus-5")
|
||||
if report.Preserved != 1 || report.DroppedBlocks != 0 {
|
||||
t.Fatalf("report = %+v, want preserved thinking block", report)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != sig {
|
||||
t.Fatalf("signature = %q, want preserved %q", got, sig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCAISSignature_RejectsMalformedPayloads(t *testing.T) {
|
||||
truncated := func() string {
|
||||
decoded, err := base64.StdEncoding.DecodeString(observedFable5Sample)
|
||||
if err != nil {
|
||||
t.Fatalf("decode observed sample: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(decoded[:len(decoded)/2])
|
||||
}()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
signature string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"whitespace only", " "},
|
||||
{"not base64", "CAIS!!!not-base64"},
|
||||
{"truncated payload", truncated},
|
||||
// 'E' prefix is the classic Claude form and must not reach CAIS parsing.
|
||||
{"classic claude prefix", base64.StdEncoding.EncodeToString([]byte{0x12, 0x00})},
|
||||
// 'C' prefix but a non-0x08 marker byte, the only way to reach the marker
|
||||
// check (a 'C' prefix constrains the first byte to 0x08-0x0b).
|
||||
{"wrong marker byte", base64.StdEncoding.EncodeToString([]byte{0x0a, 0x00})},
|
||||
{"no container", func() string {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.includeContainer = false
|
||||
return p.encode()
|
||||
}()},
|
||||
{"no channel block", func() string {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.includeChannelBlock = false
|
||||
return p.encode()
|
||||
}()},
|
||||
{"no channel id", func() string {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.includeChannelID = false
|
||||
return p.encode()
|
||||
}()},
|
||||
{"channel id wrong wire type", func() string {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.channelIDAsBytes = true
|
||||
return p.encode()
|
||||
}()},
|
||||
{"no signature bytes", func() string {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.includeSignature = false
|
||||
return p.encode()
|
||||
}()},
|
||||
{"empty signature bytes", func() string {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.signatureLen = 0
|
||||
return p.encode()
|
||||
}()},
|
||||
{"no model text", func() string {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.includeModelText = false
|
||||
return p.encode()
|
||||
}()},
|
||||
{"foreign model text", func() string {
|
||||
p := defaultClaudeCAISParts("gemini-3-pro")
|
||||
return p.encode()
|
||||
}()},
|
||||
{"invalid utf-8 model text", func() string {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.modelText = []byte{'c', 'l', 'a', 'u', 'd', 'e', '-', 0xff, 0xfe}
|
||||
return p.encode()
|
||||
}()},
|
||||
{"non-uuid context id", func() string {
|
||||
p := defaultClaudeCAISParts("claude-opus-5")
|
||||
p.contextID = "not-a-canonical-uuid-value-000000000"
|
||||
return p.encode()
|
||||
}()},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if IsValidClaudeCAISSignature(tc.signature) {
|
||||
t.Fatalf("IsValidClaudeCAISSignature(%q) = true, want false", tc.signature)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaudeCAISSignature_DoesNotShadowClassicClaudeSignature guards the
|
||||
// detection order: CAIS validation runs before classic Claude validation, so it
|
||||
// must not claim E/R signatures and change how they are normalized.
|
||||
func TestClaudeCAISSignature_DoesNotShadowClassicClaudeSignature(t *testing.T) {
|
||||
classic := testClaudeThinkingSignature()
|
||||
if IsValidClaudeCAISSignature(classic) {
|
||||
t.Fatal("IsValidClaudeCAISSignature(classic Claude signature) = true, want false")
|
||||
}
|
||||
if got := DetectSignatureProviderForBlock(classic, SignatureBlockKindClaudeThinking); got != SignatureProviderClaude {
|
||||
t.Fatalf("DetectSignatureProviderForBlock(classic) = %q, want %q", got, SignatureProviderClaude)
|
||||
}
|
||||
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + classic + `"}]}]}`)
|
||||
output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4-6")
|
||||
if report.Preserved != 1 || report.DroppedBlocks != 0 {
|
||||
t.Fatalf("report = %+v, want preserved classic thinking block", report)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != classic {
|
||||
t.Fatalf("signature = %q, want provider-native E-form %q", got, classic)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaudeCAISSignature_CachePrefixSurvivesClaudeUpstreamSanitize covers the
|
||||
// cached-signature path: cache.GetModelGroup collapses every Claude model to the
|
||||
// "claude" prefix, so a CAIS signature reaches the sanitizer as "claude#..." and
|
||||
// must be replayed with the prefix stripped instead of being dropped.
|
||||
func TestClaudeCAISSignature_CachePrefixSurvivesClaudeUpstreamSanitize(t *testing.T) {
|
||||
for _, prefix := range []string{"claude#", "anthropic#", "cais#", "ccmax#"} {
|
||||
t.Run(prefix, func(t *testing.T) {
|
||||
prefixed := prefix + observedFable5Sample
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + prefixed + `"}]}]}`)
|
||||
output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-fable-5")
|
||||
if report.Preserved != 1 || report.DroppedBlocks != 0 {
|
||||
t.Fatalf("report = %+v, want preserved thinking block", report)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != observedFable5Sample {
|
||||
t.Fatalf("signature = %q, want unprefixed %q", got, observedFable5Sample)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibleAntigravityClaudeThinkingSignature_RejectsClaudeCAIS(t *testing.T) {
|
||||
if normalized, ok := CompatibleAntigravityClaudeThinkingSignature(observedFable5Sample); ok || normalized != "" {
|
||||
t.Fatalf("CompatibleAntigravityClaudeThinkingSignature(ClaudeCAIS) = %q, %v; want empty and false", normalized, ok)
|
||||
}
|
||||
if normalized, ok := CompatibleAntigravityClaudeThinkingSignature("ccmax#" + observedFable5Sample); ok || normalized != "" {
|
||||
t.Fatalf("CompatibleAntigravityClaudeThinkingSignature(ccmax#ClaudeCAIS) = %q, %v; want empty and false", normalized, ok)
|
||||
}
|
||||
if normalized, ok := CompatibleAntigravityClaudeThinkingSignature("cais#" + observedFable5Sample); ok || normalized != "" {
|
||||
t.Fatalf("CompatibleAntigravityClaudeThinkingSignature(cais#ClaudeCAIS) = %q, %v; want empty and false", normalized, ok)
|
||||
}
|
||||
if normalized, ok := CompatibleAntigravityClaudeThinkingSignature("claude-cais#" + observedFable5Sample); ok || normalized != "" {
|
||||
t.Fatalf("CompatibleAntigravityClaudeThinkingSignature(claude-cais#ClaudeCAIS) = %q, %v; want empty and false", normalized, ok)
|
||||
}
|
||||
}
|
||||
801
backend/internal/signature/claude_validation.go
Normal file
801
backend/internal/signature/claude_validation.go
Normal file
|
|
@ -0,0 +1,801 @@
|
|||
// Claude thinking signature validation.
|
||||
//
|
||||
// Spec reference: SIGNATURE-CHANNEL-SPEC.md
|
||||
//
|
||||
// Encoding detection (Spec section 3)
|
||||
//
|
||||
// Claude signatures use base64 encoding in one or two layers. The raw string's
|
||||
// first character determines the encoding depth. This is mathematically
|
||||
// equivalent to the spec's "decode first, check byte" approach:
|
||||
//
|
||||
// - E prefix: single-layer, payload[0] == 0x12, first 6 bits = 000100,
|
||||
// base64 index 4 = E.
|
||||
// - R prefix: double-layer, inner[0] == E (0x45), first 6 bits = 010001,
|
||||
// base64 index 17 = R.
|
||||
//
|
||||
// Valid signatures can be normalized to R-form (double-layer base64) before
|
||||
// sending to the Antigravity backend.
|
||||
//
|
||||
// # Protobuf structure (Spec sections 4.1 and 4.2) in strict mode only
|
||||
//
|
||||
// After base64 decoding to raw bytes, the first byte must be 0x12:
|
||||
//
|
||||
// Top-level protobuf
|
||||
// |- Field 2 (bytes): container -> extractClaudeBytesField(payload, 2)
|
||||
// | |- Field 1 (bytes): channel block -> extractClaudeBytesField(container, 1)
|
||||
// | | |- Field 1 (varint): channel_id [required] -> routing_class (11 | 12)
|
||||
// | | |- Field 2 (varint): infra [optional] -> infrastructure_class (aws=1 | google=2)
|
||||
// | | |- Field 3 (varint): version=2 -> skipped
|
||||
// | | |- Field 5 (bytes): ECDSA sig -> skipped, per Spec section 11
|
||||
// | | |- Field 6 (bytes): model_text [optional] -> schema_features
|
||||
// | | `- Field 7 (varint): unknown [optional] -> schema_features
|
||||
// | |- Field 2 (bytes): nonce 12B -> skipped
|
||||
// | |- Field 3 (bytes): session 12B -> skipped
|
||||
// | |- Field 4 (bytes): SHA-384 48B -> skipped
|
||||
// | `- Field 5 (bytes): metadata -> skipped, per Spec section 11
|
||||
// `- Field 3 (varint): =1 -> skipped
|
||||
//
|
||||
// Output dimensions (Spec section 8)
|
||||
//
|
||||
// routing_class: routing_class_11 | routing_class_12 | unknown
|
||||
// infrastructure_class: infra_default (absent) | infra_aws (1) | infra_google (2) | infra_unknown
|
||||
// schema_features: compact_schema (len 70-72, no f6/f7) | extended_model_tagged_schema (f6 exists) | unknown
|
||||
// legacy_route_hint: only for ch=11, legacy_default_group | legacy_aws_group | legacy_vertex_direct/proxy
|
||||
//
|
||||
// # Compatibility
|
||||
//
|
||||
// Verified against all confirmed spec samples (Anthropic Max 20x, Azure,
|
||||
// Vertex, Bedrock) and legacy ch=11 signatures. Both single-layer (E) and
|
||||
// double-layer (R) encodings are supported. Historical cache-mode modelGroup#
|
||||
// prefixes are stripped.
|
||||
//
|
||||
// # CAIS envelope (newest Claude Code models)
|
||||
//
|
||||
// Newer Claude Code models wrap the channel block in a CAIS envelope whose
|
||||
// decoded payload starts with 0x08 (top-level field 1 varint) instead of 0x12,
|
||||
// so the base64 string starts with 'C' instead of 'E'/'R'. The envelope version
|
||||
// varint in top-level field 1 is the ONLY structural difference from the layout
|
||||
// above; everything below it is unchanged.
|
||||
//
|
||||
// The channel block itself belongs to a newer schema generation that is shared
|
||||
// by both envelopes: channel_id 16, no infra field 2, plus a block kind (field
|
||||
// 8) and a context id (field 11). Observed traffic confirms this schema
|
||||
// appears under the classic 0x12 envelope too (opus-4-6/4-7/4-8, sonnet-5) and
|
||||
// under the CAIS envelope (opus-5, fable-5), so envelope form and channel schema
|
||||
// generation vary independently and must not be inferred from each other:
|
||||
//
|
||||
// Top-level protobuf
|
||||
// |- Field 1 (varint): envelope version [required marker, observed as 2]
|
||||
// |- Field 2 (bytes): container [required]
|
||||
// | `- Field 1 (bytes): channel block [required]
|
||||
// | |- Field 1 (varint): channel_id [required, observed as 16]
|
||||
// | |- Field 3 (varint): version [optional, observed as 2]
|
||||
// | |- Field 5 (bytes): ECDSA signature [required, observed as 64B]
|
||||
// | |- Field 6 (bytes): model_text [required, "claude-" prefixed]
|
||||
// | |- Field 7 (varint): unknown [optional, observed as 1]
|
||||
// | |- Field 8 (bytes): block kind [optional, observed as "thinking"]
|
||||
// | `- Field 11 (bytes): context id [optional, canonical UUID]
|
||||
// `- Field 3 (varint): trailer [optional, observed as 1]
|
||||
//
|
||||
// CAIS validation is structural rather than an exact replay of the observed
|
||||
// bytes. The payload is an opaque upstream-issued blob and rejecting it drops
|
||||
// the whole thinking block, so only the fields that actually identify the format
|
||||
// are required: the 0x08 marker, the nested container/channel block, the
|
||||
// signature bytes, and the "claude-" model text. Observed-but-incidental values
|
||||
// such as channel_id 16 or the "thinking" block kind are recorded for debugging
|
||||
// and checked only for wire type, so an upstream field bump cannot silently
|
||||
// erase conversation history.
|
||||
//
|
||||
// # Which provider emits which envelope
|
||||
//
|
||||
// Three providers serve Claude models, and the envelope depends on the model
|
||||
// generation rather than on the provider:
|
||||
//
|
||||
// - Claude Code OAuth subscription (Claude Code Max): opus-4-5, sonnet-4-6 and
|
||||
// every later model up to opus-5 and fable-5. Emits the CAIS envelope for
|
||||
// the newest models (opus-5, fable-5) and the single-layer E envelope for the
|
||||
// opus-4-6/4-7/4-8 and sonnet-5 generation — but both carry the same
|
||||
// channel_id 16 channel schema, so only the envelope differs.
|
||||
// - Claude Messages API: the full Claude model range, same envelopes as the
|
||||
// Claude Code OAuth subscription.
|
||||
// - Antigravity: only opus-4-6-think and sonnet-4-6, and always the
|
||||
// double-layer R form on Google infrastructure (infra_google). Antigravity
|
||||
// never issues a CAIS envelope or a single-layer E signature, and its replay
|
||||
// path requires R form, so CompatibleAntigravityClaudeThinkingSignature
|
||||
// rejects CAIS signatures.
|
||||
//
|
||||
// A single conversation therefore mixes envelopes whenever a user switches model
|
||||
// generations or providers, and every form must stay replayable toward the
|
||||
// provider that issued it.
|
||||
package signature
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
const MaxClaudeThinkingSignatureLen = 32 * 1024 * 1024
|
||||
|
||||
// ClaudeSignatureValidationOptions controls how far Claude thinking signatures
|
||||
// are inspected. The base validation always checks the cache prefix, base64
|
||||
// layers, and decoded 0x12 Claude payload marker. Strict mode additionally
|
||||
// verifies the known protobuf tree used by Claude thinking signatures.
|
||||
type ClaudeSignatureValidationOptions struct {
|
||||
// PrefixOnly only checks for an optional cache prefix followed by an E/R
|
||||
// Claude signature prefix. Use it to preserve legacy shallow cleanup.
|
||||
PrefixOnly bool
|
||||
// Base64Only checks the optional cache prefix, E/R Claude signature prefix,
|
||||
// and base64 layers without validating the decoded Claude marker or protobuf
|
||||
// tree. Use it for conservative request cleanup.
|
||||
Base64Only bool
|
||||
// AllowEmptySignatureWithEmptyText preserves empty thinking placeholders with
|
||||
// no signature and no thinking/text payload during strip operations.
|
||||
AllowEmptySignatureWithEmptyText bool
|
||||
Strict bool
|
||||
}
|
||||
|
||||
// ClaudeSignatureTree describes the protobuf fields currently used for Claude
|
||||
// thinking signature routing.
|
||||
type ClaudeSignatureTree struct {
|
||||
EncodingLayers int
|
||||
ChannelID uint64
|
||||
Field2 *uint64
|
||||
RoutingClass string
|
||||
InfrastructureClass string
|
||||
SchemaFeatures string
|
||||
ModelText string
|
||||
LegacyRouteHint string
|
||||
HasField7 bool
|
||||
}
|
||||
|
||||
func claudeSignatureValidationOptions(opts []ClaudeSignatureValidationOptions) ClaudeSignatureValidationOptions {
|
||||
if len(opts) == 0 {
|
||||
return ClaudeSignatureValidationOptions{}
|
||||
}
|
||||
return opts[0]
|
||||
}
|
||||
|
||||
// IsValidClaudeThinkingSignature returns whether rawSignature is a valid Claude
|
||||
// thinking signature under the requested validation options.
|
||||
func IsValidClaudeThinkingSignature(rawSignature string, opts ...ClaudeSignatureValidationOptions) bool {
|
||||
opt := claudeSignatureValidationOptions(opts)
|
||||
if opt.PrefixOnly {
|
||||
return HasClaudeThinkingSignaturePrefix(rawSignature)
|
||||
}
|
||||
if opt.Base64Only {
|
||||
return HasDecodableClaudeThinkingSignature(rawSignature)
|
||||
}
|
||||
_, err := NormalizeClaudeThinkingSignature(rawSignature, opts...)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// HasDecodableClaudeThinkingSignature reports whether rawSignature has the
|
||||
// Claude E/R shape and its expected base64 layer(s) can be decoded.
|
||||
func HasDecodableClaudeThinkingSignature(rawSignature string) bool {
|
||||
sig := stripClaudeSignaturePrefix(rawSignature)
|
||||
if sig == "" || len(sig) > MaxClaudeThinkingSignatureLen {
|
||||
return false
|
||||
}
|
||||
|
||||
switch sig[0] {
|
||||
case 'E':
|
||||
decoded, err := base64.StdEncoding.DecodeString(sig)
|
||||
return err == nil && len(decoded) > 0
|
||||
case 'R':
|
||||
decoded, err := base64.StdEncoding.DecodeString(sig)
|
||||
if err != nil || len(decoded) == 0 || decoded[0] != 'E' {
|
||||
return false
|
||||
}
|
||||
innerDecoded, err := base64.StdEncoding.DecodeString(string(decoded))
|
||||
return err == nil && len(innerDecoded) > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// HasClaudeThinkingSignaturePrefix reports whether rawSignature has the Claude
|
||||
// E/R signature prefix after stripping an optional cache prefix.
|
||||
func HasClaudeThinkingSignaturePrefix(rawSignature string) bool {
|
||||
sig := stripClaudeSignaturePrefix(rawSignature)
|
||||
if sig == "" {
|
||||
return false
|
||||
}
|
||||
return sig[0] == 'E' || sig[0] == 'R'
|
||||
}
|
||||
|
||||
func stripClaudeSignaturePrefix(rawSignature string) string {
|
||||
sig := strings.TrimSpace(rawSignature)
|
||||
if sig == "" {
|
||||
return ""
|
||||
}
|
||||
if idx := strings.IndexByte(sig, '#'); idx >= 0 {
|
||||
sig = strings.TrimSpace(sig[idx+1:])
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
// ValidateClaudeThinkingSignatures validates every thinking block signature in a
|
||||
// Claude messages payload.
|
||||
func ValidateClaudeThinkingSignatures(inputRawJSON []byte, opts ...ClaudeSignatureValidationOptions) error {
|
||||
messages := gjson.GetBytes(inputRawJSON, "messages")
|
||||
if !messages.IsArray() {
|
||||
return nil
|
||||
}
|
||||
|
||||
opt := claudeSignatureValidationOptions(opts)
|
||||
messageResults := messages.Array()
|
||||
for i := 0; i < len(messageResults); i++ {
|
||||
contentResults := messageResults[i].Get("content")
|
||||
if !contentResults.IsArray() {
|
||||
continue
|
||||
}
|
||||
parts := contentResults.Array()
|
||||
for j := 0; j < len(parts); j++ {
|
||||
part := parts[j]
|
||||
if part.Get("type").String() != "thinking" {
|
||||
continue
|
||||
}
|
||||
|
||||
rawSignature := strings.TrimSpace(part.Get("signature").String())
|
||||
if rawSignature == "" {
|
||||
return fmt.Errorf("messages[%d].content[%d]: missing thinking signature", i, j)
|
||||
}
|
||||
|
||||
if _, err := NormalizeClaudeThinkingSignature(rawSignature, opt); err != nil {
|
||||
return fmt.Errorf("messages[%d].content[%d]: %w", i, j, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeClaudeThinkingSignature strips any cache prefix, validates the
|
||||
// signature, and returns the double-layer R-form expected by Antigravity bypass
|
||||
// mode.
|
||||
func NormalizeClaudeThinkingSignature(rawSignature string, opts ...ClaudeSignatureValidationOptions) (string, error) {
|
||||
opt := claudeSignatureValidationOptions(opts)
|
||||
sig := stripClaudeSignaturePrefix(rawSignature)
|
||||
if sig == "" {
|
||||
return "", fmt.Errorf("empty signature")
|
||||
}
|
||||
|
||||
if len(sig) > MaxClaudeThinkingSignatureLen {
|
||||
return "", fmt.Errorf("signature exceeds maximum length (%d bytes)", MaxClaudeThinkingSignatureLen)
|
||||
}
|
||||
|
||||
switch sig[0] {
|
||||
case 'R':
|
||||
if err := validateClaudeDoubleLayerSignature(sig, opt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sig, nil
|
||||
case 'E':
|
||||
if err := validateClaudeSingleLayerSignature(sig, opt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString([]byte(sig)), nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid signature: expected 'E' or 'R' prefix, got %q", string(sig[0]))
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeClaudeProviderNativeThinkingSignature strips any cache prefix,
|
||||
// validates the signature, and returns the single-layer E-form expected by
|
||||
// Claude-native providers.
|
||||
func NormalizeClaudeProviderNativeThinkingSignature(rawSignature string, opts ...ClaudeSignatureValidationOptions) (string, error) {
|
||||
opt := claudeSignatureValidationOptions(opts)
|
||||
sig := stripClaudeSignaturePrefix(rawSignature)
|
||||
if sig == "" {
|
||||
return "", fmt.Errorf("empty signature")
|
||||
}
|
||||
|
||||
if len(sig) > MaxClaudeThinkingSignatureLen {
|
||||
return "", fmt.Errorf("signature exceeds maximum length (%d bytes)", MaxClaudeThinkingSignatureLen)
|
||||
}
|
||||
|
||||
switch sig[0] {
|
||||
case 'E':
|
||||
if err := validateClaudeSingleLayerSignature(sig, opt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sig, nil
|
||||
case 'R':
|
||||
if err := validateClaudeDoubleLayerSignature(sig, opt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(sig)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid double-layer signature: base64 decode failed: %w", err)
|
||||
}
|
||||
return string(decoded), nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid signature: expected 'E' or 'R' prefix, got %q", string(sig[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func validateClaudeDoubleLayerSignature(sig string, opt ClaudeSignatureValidationOptions) error {
|
||||
decoded, err := base64.StdEncoding.DecodeString(sig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid double-layer signature: base64 decode failed: %w", err)
|
||||
}
|
||||
if len(decoded) == 0 {
|
||||
return fmt.Errorf("invalid double-layer signature: empty after decode")
|
||||
}
|
||||
if decoded[0] != 'E' {
|
||||
return fmt.Errorf("invalid double-layer signature: inner does not start with 'E', got 0x%02x", decoded[0])
|
||||
}
|
||||
return validateClaudeSingleLayerSignatureContent(string(decoded), 2, opt)
|
||||
}
|
||||
|
||||
func validateClaudeSingleLayerSignature(sig string, opt ClaudeSignatureValidationOptions) error {
|
||||
return validateClaudeSingleLayerSignatureContent(sig, 1, opt)
|
||||
}
|
||||
|
||||
func validateClaudeSingleLayerSignatureContent(sig string, encodingLayers int, opt ClaudeSignatureValidationOptions) error {
|
||||
decoded, err := base64.StdEncoding.DecodeString(sig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid single-layer signature: base64 decode failed: %w", err)
|
||||
}
|
||||
if len(decoded) == 0 {
|
||||
return fmt.Errorf("invalid single-layer signature: empty after decode")
|
||||
}
|
||||
if decoded[0] != 0x12 {
|
||||
return fmt.Errorf("invalid Claude signature: expected first byte 0x12, got 0x%02x", decoded[0])
|
||||
}
|
||||
if !opt.Strict {
|
||||
return nil
|
||||
}
|
||||
_, err = InspectClaudeSignaturePayload(decoded, encodingLayers)
|
||||
return err
|
||||
}
|
||||
|
||||
// InspectClaudeDoubleLayerSignature decodes and inspects a double-layer Claude
|
||||
// thinking signature.
|
||||
func InspectClaudeDoubleLayerSignature(sig string) (*ClaudeSignatureTree, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(sig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid double-layer signature: base64 decode failed: %w", err)
|
||||
}
|
||||
if len(decoded) == 0 {
|
||||
return nil, fmt.Errorf("invalid double-layer signature: empty after decode")
|
||||
}
|
||||
if decoded[0] != 'E' {
|
||||
return nil, fmt.Errorf("invalid double-layer signature: inner does not start with 'E', got 0x%02x", decoded[0])
|
||||
}
|
||||
return inspectClaudeSingleLayerSignatureWithLayers(string(decoded), 2)
|
||||
}
|
||||
|
||||
// InspectClaudeSingleLayerSignature decodes and inspects a single-layer Claude
|
||||
// thinking signature.
|
||||
func InspectClaudeSingleLayerSignature(sig string) (*ClaudeSignatureTree, error) {
|
||||
return inspectClaudeSingleLayerSignatureWithLayers(sig, 1)
|
||||
}
|
||||
|
||||
func inspectClaudeSingleLayerSignatureWithLayers(sig string, encodingLayers int) (*ClaudeSignatureTree, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(sig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid single-layer signature: base64 decode failed: %w", err)
|
||||
}
|
||||
if len(decoded) == 0 {
|
||||
return nil, fmt.Errorf("invalid single-layer signature: empty after decode")
|
||||
}
|
||||
return InspectClaudeSignaturePayload(decoded, encodingLayers)
|
||||
}
|
||||
|
||||
// InspectClaudeSignaturePayload inspects the decoded Claude thinking signature
|
||||
// protobuf payload.
|
||||
func InspectClaudeSignaturePayload(payload []byte, encodingLayers int) (*ClaudeSignatureTree, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, fmt.Errorf("invalid Claude signature: empty payload")
|
||||
}
|
||||
if payload[0] != 0x12 {
|
||||
return nil, fmt.Errorf("invalid Claude signature: expected first byte 0x12, got 0x%02x", payload[0])
|
||||
}
|
||||
container, err := extractClaudeBytesField(payload, 2, "top-level protobuf")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channelBlock, err := extractClaudeBytesField(container, 1, "Claude Field 2 container")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inspectClaudeChannelBlock(channelBlock, encodingLayers)
|
||||
}
|
||||
|
||||
func inspectClaudeChannelBlock(channelBlock []byte, encodingLayers int) (*ClaudeSignatureTree, error) {
|
||||
tree := &ClaudeSignatureTree{
|
||||
EncodingLayers: encodingLayers,
|
||||
RoutingClass: "unknown",
|
||||
InfrastructureClass: "infra_unknown",
|
||||
SchemaFeatures: "unknown_schema_features",
|
||||
}
|
||||
haveChannelID := false
|
||||
hasField6 := false
|
||||
hasField7 := false
|
||||
|
||||
err := walkClaudeProtobufFields(channelBlock, func(num protowire.Number, typ protowire.Type, raw []byte) error {
|
||||
switch num {
|
||||
case 1:
|
||||
if typ != protowire.VarintType {
|
||||
return fmt.Errorf("invalid Claude signature: Field 2.1.1 channel_id must be varint")
|
||||
}
|
||||
channelID, err := decodeClaudeVarintField(raw, "Field 2.1.1 channel_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tree.ChannelID = channelID
|
||||
haveChannelID = true
|
||||
case 2:
|
||||
if typ != protowire.VarintType {
|
||||
return fmt.Errorf("invalid Claude signature: Field 2.1.2 field2 must be varint")
|
||||
}
|
||||
field2, err := decodeClaudeVarintField(raw, "Field 2.1.2 field2")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tree.Field2 = &field2
|
||||
case 6:
|
||||
if typ != protowire.BytesType {
|
||||
return fmt.Errorf("invalid Claude signature: Field 2.1.6 model_text must be bytes")
|
||||
}
|
||||
modelBytes, err := decodeClaudeBytesField(raw, "Field 2.1.6 model_text")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !utf8.Valid(modelBytes) {
|
||||
return fmt.Errorf("invalid Claude signature: Field 2.1.6 model_text is not valid UTF-8")
|
||||
}
|
||||
tree.ModelText = string(modelBytes)
|
||||
hasField6 = true
|
||||
case 7:
|
||||
if typ != protowire.VarintType {
|
||||
return fmt.Errorf("invalid Claude signature: Field 2.1.7 must be varint")
|
||||
}
|
||||
if _, err := decodeClaudeVarintField(raw, "Field 2.1.7"); err != nil {
|
||||
return err
|
||||
}
|
||||
hasField7 = true
|
||||
tree.HasField7 = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !haveChannelID {
|
||||
return nil, fmt.Errorf("invalid Claude signature: missing Field 2.1.1 channel_id")
|
||||
}
|
||||
|
||||
switch tree.ChannelID {
|
||||
case 11:
|
||||
tree.RoutingClass = "routing_class_11"
|
||||
case 12:
|
||||
tree.RoutingClass = "routing_class_12"
|
||||
}
|
||||
|
||||
if tree.Field2 == nil {
|
||||
tree.InfrastructureClass = "infra_default"
|
||||
} else {
|
||||
switch *tree.Field2 {
|
||||
case 1:
|
||||
tree.InfrastructureClass = "infra_aws"
|
||||
case 2:
|
||||
tree.InfrastructureClass = "infra_google"
|
||||
default:
|
||||
tree.InfrastructureClass = "infra_unknown"
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case hasField6:
|
||||
tree.SchemaFeatures = "extended_model_tagged_schema"
|
||||
case !hasField6 && !hasField7 && len(channelBlock) >= 70 && len(channelBlock) <= 72:
|
||||
tree.SchemaFeatures = "compact_schema"
|
||||
}
|
||||
|
||||
if tree.ChannelID == 11 {
|
||||
switch {
|
||||
case tree.Field2 == nil:
|
||||
tree.LegacyRouteHint = "legacy_default_group"
|
||||
case *tree.Field2 == 1:
|
||||
tree.LegacyRouteHint = "legacy_aws_group"
|
||||
case *tree.Field2 == 2 && tree.EncodingLayers == 2:
|
||||
tree.LegacyRouteHint = "legacy_vertex_direct"
|
||||
case *tree.Field2 == 2 && tree.EncodingLayers == 1:
|
||||
tree.LegacyRouteHint = "legacy_vertex_proxy"
|
||||
}
|
||||
}
|
||||
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
func extractClaudeBytesField(msg []byte, fieldNum protowire.Number, scope string) ([]byte, error) {
|
||||
var value []byte
|
||||
err := walkClaudeProtobufFields(msg, func(num protowire.Number, typ protowire.Type, raw []byte) error {
|
||||
if num != fieldNum {
|
||||
return nil
|
||||
}
|
||||
if typ != protowire.BytesType {
|
||||
return fmt.Errorf("invalid Claude signature: %s field %d must be bytes", scope, fieldNum)
|
||||
}
|
||||
bytesValue, err := decodeClaudeBytesField(raw, fmt.Sprintf("%s field %d", scope, fieldNum))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value = bytesValue
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("invalid Claude signature: missing %s field %d", scope, fieldNum)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func walkClaudeProtobufFields(msg []byte, visit func(num protowire.Number, typ protowire.Type, raw []byte) error) error {
|
||||
for offset := 0; offset < len(msg); {
|
||||
num, typ, n := protowire.ConsumeTag(msg[offset:])
|
||||
if n < 0 {
|
||||
return fmt.Errorf("invalid Claude signature: malformed protobuf tag: %w", protowire.ParseError(n))
|
||||
}
|
||||
offset += n
|
||||
valueLen := protowire.ConsumeFieldValue(num, typ, msg[offset:])
|
||||
if valueLen < 0 {
|
||||
return fmt.Errorf("invalid Claude signature: malformed protobuf field %d: %w", num, protowire.ParseError(valueLen))
|
||||
}
|
||||
fieldRaw := msg[offset : offset+valueLen]
|
||||
if err := visit(num, typ, fieldRaw); err != nil {
|
||||
return err
|
||||
}
|
||||
offset += valueLen
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeClaudeVarintField(raw []byte, label string) (uint64, error) {
|
||||
value, n := protowire.ConsumeVarint(raw)
|
||||
if n < 0 {
|
||||
return 0, fmt.Errorf("invalid Claude signature: failed to decode %s: %w", label, protowire.ParseError(n))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func decodeClaudeBytesField(raw []byte, label string) ([]byte, error) {
|
||||
value, n := protowire.ConsumeBytes(raw)
|
||||
if n < 0 {
|
||||
return nil, fmt.Errorf("invalid Claude signature: failed to decode %s: %w", label, protowire.ParseError(n))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// claudeCAISSignatureMarker is the decoded first byte identifying the CAIS
|
||||
// envelope (protobuf tag for top-level field 1, varint).
|
||||
const claudeCAISSignatureMarker = 0x08
|
||||
|
||||
// claudeCAISModelTextPrefix is the model_text prefix that distinguishes a CAIS
|
||||
// channel block from an arbitrary protobuf payload.
|
||||
const claudeCAISModelTextPrefix = "claude-"
|
||||
|
||||
// ClaudeCAISSignatureInfo describes the locally inspected structure of a Claude
|
||||
// CAIS thinking signature.
|
||||
type ClaudeCAISSignatureInfo struct {
|
||||
FirstByte byte
|
||||
EnvelopeVersion uint64
|
||||
ChannelID uint64
|
||||
ModelText string
|
||||
BlockKind string
|
||||
ContextID string
|
||||
|
||||
SignatureLen int
|
||||
}
|
||||
|
||||
// IsValidClaudeCAISSignature returns whether rawSignature is a valid Claude CAIS
|
||||
// thinking signature.
|
||||
func IsValidClaudeCAISSignature(rawSignature string) bool {
|
||||
_, err := InspectClaudeCAISSignature(rawSignature)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// InspectClaudeCAISSignature decodes and validates a Claude CAIS thinking
|
||||
// signature. See the CAIS envelope section in this file's package comment for
|
||||
// the layout and for why validation is structural rather than exact.
|
||||
func InspectClaudeCAISSignature(rawSignature string) (*ClaudeCAISSignatureInfo, error) {
|
||||
sig := stripClaudeSignaturePrefix(rawSignature)
|
||||
if sig == "" {
|
||||
return nil, fmt.Errorf("empty signature")
|
||||
}
|
||||
if len(sig) > MaxClaudeThinkingSignatureLen {
|
||||
return nil, fmt.Errorf("signature exceeds maximum length (%d bytes)", MaxClaudeThinkingSignatureLen)
|
||||
}
|
||||
// A payload whose first byte is 0x08 always base64-encodes to a string
|
||||
// starting with 'C' (0x08>>2 == 2). Checking that first keeps this validator
|
||||
// cheap on the hot paths that probe every signature, since classic Claude
|
||||
// (E/R) and Gemini envelopes are rejected without a base64 decode.
|
||||
if sig[0] != 'C' {
|
||||
return nil, fmt.Errorf("invalid Claude CAIS signature: expected 'C' prefix, got %q", string(sig[0]))
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(sig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid Claude CAIS signature: base64 decode failed: %w", err)
|
||||
}
|
||||
if len(decoded) == 0 {
|
||||
return nil, fmt.Errorf("invalid Claude CAIS signature: empty after decode")
|
||||
}
|
||||
if decoded[0] != claudeCAISSignatureMarker {
|
||||
return nil, fmt.Errorf("invalid Claude CAIS signature: expected first byte 0x%02x, got 0x%02x", claudeCAISSignatureMarker, decoded[0])
|
||||
}
|
||||
|
||||
info := &ClaudeCAISSignatureInfo{FirstByte: decoded[0]}
|
||||
|
||||
var container []byte
|
||||
err = walkClaudeProtobufFields(decoded, func(num protowire.Number, typ protowire.Type, raw []byte) error {
|
||||
switch num {
|
||||
case 1:
|
||||
value, errField := decodeClaudeCAISVarint(raw, typ, "CAIS top-level field 1 envelope version")
|
||||
if errField != nil {
|
||||
return errField
|
||||
}
|
||||
info.EnvelopeVersion = value
|
||||
case 2:
|
||||
value, errField := decodeClaudeCAISBytes(raw, typ, "CAIS top-level field 2 container")
|
||||
if errField != nil {
|
||||
return errField
|
||||
}
|
||||
container = value
|
||||
case 3:
|
||||
if _, errField := decodeClaudeCAISVarint(raw, typ, "CAIS top-level field 3 trailer"); errField != nil {
|
||||
return errField
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if container == nil {
|
||||
return nil, fmt.Errorf("invalid Claude CAIS signature: missing top-level field 2 container")
|
||||
}
|
||||
|
||||
var channelBlock []byte
|
||||
err = walkClaudeProtobufFields(container, func(num protowire.Number, typ protowire.Type, raw []byte) error {
|
||||
if num != 1 {
|
||||
return nil
|
||||
}
|
||||
value, errField := decodeClaudeCAISBytes(raw, typ, "CAIS container field 1 channel block")
|
||||
if errField != nil {
|
||||
return errField
|
||||
}
|
||||
channelBlock = value
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if channelBlock == nil {
|
||||
return nil, fmt.Errorf("invalid Claude CAIS signature: missing container field 1 channel block")
|
||||
}
|
||||
|
||||
var haveChannelID, haveSignatureBytes, haveModelText bool
|
||||
err = walkClaudeProtobufFields(channelBlock, func(num protowire.Number, typ protowire.Type, raw []byte) error {
|
||||
switch num {
|
||||
case 1:
|
||||
value, errField := decodeClaudeCAISVarint(raw, typ, "CAIS channel field 1 channel_id")
|
||||
if errField != nil {
|
||||
return errField
|
||||
}
|
||||
info.ChannelID = value
|
||||
haveChannelID = true
|
||||
case 3:
|
||||
if _, errField := decodeClaudeCAISVarint(raw, typ, "CAIS channel field 3 version"); errField != nil {
|
||||
return errField
|
||||
}
|
||||
case 5:
|
||||
value, errField := decodeClaudeCAISBytes(raw, typ, "CAIS channel field 5 signature bytes")
|
||||
if errField != nil {
|
||||
return errField
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return fmt.Errorf("invalid Claude CAIS signature: channel field 5 signature bytes must not be empty")
|
||||
}
|
||||
info.SignatureLen = len(value)
|
||||
haveSignatureBytes = true
|
||||
case 6:
|
||||
value, errField := decodeClaudeCAISUTF8(raw, typ, "CAIS channel field 6 model_text")
|
||||
if errField != nil {
|
||||
return errField
|
||||
}
|
||||
if !strings.HasPrefix(value, claudeCAISModelTextPrefix) {
|
||||
return fmt.Errorf("invalid Claude CAIS signature: channel field 6 model_text must start with %q, got %q", claudeCAISModelTextPrefix, value)
|
||||
}
|
||||
info.ModelText = value
|
||||
haveModelText = true
|
||||
case 7:
|
||||
if _, errField := decodeClaudeCAISVarint(raw, typ, "CAIS channel field 7"); errField != nil {
|
||||
return errField
|
||||
}
|
||||
case 8:
|
||||
value, errField := decodeClaudeCAISUTF8(raw, typ, "CAIS channel field 8 block kind")
|
||||
if errField != nil {
|
||||
return errField
|
||||
}
|
||||
info.BlockKind = value
|
||||
case 11:
|
||||
value, errField := decodeClaudeCAISUTF8(raw, typ, "CAIS channel field 11 context id")
|
||||
if errField != nil {
|
||||
return errField
|
||||
}
|
||||
if !isCanonicalUUID(value) {
|
||||
return fmt.Errorf("invalid Claude CAIS signature: channel field 11 context id must be a canonical UUID, got %q", value)
|
||||
}
|
||||
info.ContextID = value
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case !haveChannelID:
|
||||
return nil, fmt.Errorf("invalid Claude CAIS signature: missing channel field 1 channel_id")
|
||||
case !haveSignatureBytes:
|
||||
return nil, fmt.Errorf("invalid Claude CAIS signature: missing channel field 5 signature bytes")
|
||||
case !haveModelText:
|
||||
return nil, fmt.Errorf("invalid Claude CAIS signature: missing channel field 6 model_text")
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func decodeClaudeCAISVarint(raw []byte, typ protowire.Type, label string) (uint64, error) {
|
||||
if typ != protowire.VarintType {
|
||||
return 0, fmt.Errorf("invalid Claude CAIS signature: %s must be varint", label)
|
||||
}
|
||||
return decodeClaudeVarintField(raw, label)
|
||||
}
|
||||
|
||||
func decodeClaudeCAISBytes(raw []byte, typ protowire.Type, label string) ([]byte, error) {
|
||||
if typ != protowire.BytesType {
|
||||
return nil, fmt.Errorf("invalid Claude CAIS signature: %s must be bytes", label)
|
||||
}
|
||||
return decodeClaudeBytesField(raw, label)
|
||||
}
|
||||
|
||||
func decodeClaudeCAISUTF8(raw []byte, typ protowire.Type, label string) (string, error) {
|
||||
value, err := decodeClaudeCAISBytes(raw, typ, label)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !utf8.Valid(value) {
|
||||
return "", fmt.Errorf("invalid Claude CAIS signature: %s must be valid UTF-8", label)
|
||||
}
|
||||
return string(value), nil
|
||||
}
|
||||
|
||||
func isCanonicalUUID(s string) bool {
|
||||
if len(s) != 36 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
b := s[i]
|
||||
switch i {
|
||||
case 8, 13, 18, 23:
|
||||
if b != '-' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if !((b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
279
backend/internal/signature/gemini_sanitize.go
Normal file
279
backend/internal/signature/gemini_sanitize.go
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// GeminiReplaySignatureOrBypass returns a Gemini-replayable thoughtSignature.
|
||||
// Compatible Gemini signatures are normalized and preserved. Missing, unknown,
|
||||
// or cross-provider signatures are replaced with Gemini's bypass sentinel.
|
||||
func GeminiReplaySignatureOrBypass(rawSignature string, blockKind SignatureBlockKind) string {
|
||||
if signature, ok := CompatibleSignatureForProviderBlock(SignatureProviderGemini, rawSignature, blockKind); ok {
|
||||
return signature
|
||||
}
|
||||
decision := DecideSignatureCompatibility(SignatureProviderGemini, rawSignature, blockKind)
|
||||
if decision.Action == SignatureActionReplaceWithGeminiBypass && decision.ReplacementSignature != "" {
|
||||
return decision.ReplacementSignature
|
||||
}
|
||||
return GeminiSkipThoughtSignatureValidator
|
||||
}
|
||||
|
||||
// SanitizeGeminiRequestThoughtSignatures applies Gemini replay policy to a
|
||||
// Gemini-shaped request. Existing provider signatures stay on their original
|
||||
// model parts. Only a missing or incompatible first functionCall gets the bypass
|
||||
// sentinel; unsigned sibling calls remain unsigned, matching native Gemini
|
||||
// parallel-call history. functionResponse parts never carry signatures.
|
||||
func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) []byte {
|
||||
contentsPath = strings.TrimSpace(contentsPath)
|
||||
if contentsPath == "" {
|
||||
contentsPath = "contents"
|
||||
}
|
||||
|
||||
contents := util.GetGJSONBytesNoCopy(payload, contentsPath)
|
||||
if !contents.IsArray() || !geminiContentsThoughtSignaturesNeedSanitize(contents) {
|
||||
return payload
|
||||
}
|
||||
|
||||
contentsChanged := false
|
||||
contentItems := make([][]byte, 0, int(contents.Get("#").Int()))
|
||||
contents.ForEach(func(contentIdx, content gjson.Result) bool {
|
||||
parts := content.Get("parts")
|
||||
if !parts.IsArray() {
|
||||
contentItems = append(contentItems, []byte(content.Raw))
|
||||
return true
|
||||
}
|
||||
|
||||
isModelTurn := content.Get("role").String() == "model"
|
||||
firstFunctionCallSeen := false
|
||||
partsChanged := false
|
||||
partItems := make([][]byte, 0, int(parts.Get("#").Int()))
|
||||
parts.ForEach(func(partIdx, part gjson.Result) bool {
|
||||
partJSON := []byte(part.Raw)
|
||||
rawSignature, hasSignature := geminiPartThoughtSignature(part)
|
||||
if part.Get("functionResponse").Exists() {
|
||||
if hasSignature {
|
||||
partJSON = deleteGeminiPartThoughtSignatureFields(partJSON)
|
||||
partsChanged = true
|
||||
logGeminiThoughtSignatureSanitize(contentsPath, int(contentIdx.Int()), int(partIdx.Int()), SignatureCompatibilityDecision{
|
||||
TargetProvider: SignatureProviderGemini,
|
||||
BlockKind: SignatureBlockKindGeminiModelPart,
|
||||
Action: SignatureActionDropSignature,
|
||||
Reason: "functionResponse parts cannot replay thought signatures",
|
||||
}, rawSignature, true)
|
||||
}
|
||||
partItems = append(partItems, partJSON)
|
||||
return true
|
||||
}
|
||||
if !isModelTurn {
|
||||
partItems = append(partItems, partJSON)
|
||||
return true
|
||||
}
|
||||
|
||||
hasFunctionCall := part.Get("functionCall").Exists()
|
||||
isFirstFunctionCall := hasFunctionCall && !firstFunctionCallSeen
|
||||
if hasFunctionCall {
|
||||
firstFunctionCallSeen = true
|
||||
}
|
||||
if !hasFunctionCall && !hasSignature {
|
||||
partItems = append(partItems, partJSON)
|
||||
return true
|
||||
}
|
||||
|
||||
blockKind := SignatureBlockKindGeminiModelPart
|
||||
if hasFunctionCall {
|
||||
blockKind = SignatureBlockKindGeminiFunctionCall
|
||||
}
|
||||
decision := DecideSignatureCompatibility(SignatureProviderGemini, rawSignature, blockKind)
|
||||
replaySignature := ""
|
||||
switch {
|
||||
case isFirstFunctionCall:
|
||||
replaySignature = GeminiReplaySignatureOrBypass(rawSignature, blockKind)
|
||||
case hasSignature && decision.Action == SignatureActionPreserve && !IsGeminiThoughtSignatureBypass(SignaturePayloadWithoutProviderPrefix(rawSignature)):
|
||||
replaySignature = decision.NormalizedSignature
|
||||
case hasSignature:
|
||||
decision.Action = SignatureActionDropSignature
|
||||
decision.ReplacementSignature = ""
|
||||
if hasFunctionCall {
|
||||
decision.Reason = "unsigned sibling functionCalls preserve native parallel-call shape"
|
||||
} else {
|
||||
decision.Reason = "non-function model parts do not synthesize Gemini bypass signatures"
|
||||
}
|
||||
}
|
||||
|
||||
partChanged := false
|
||||
if replaySignature != "" {
|
||||
if !hasNormalizedGeminiPartThoughtSignature(part, replaySignature) {
|
||||
partJSON = deleteGeminiPartThoughtSignatureFields(partJSON)
|
||||
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", replaySignature)
|
||||
partChanged = true
|
||||
}
|
||||
} else if hasSignature {
|
||||
partJSON = deleteGeminiPartThoughtSignatureFields(partJSON)
|
||||
partChanged = true
|
||||
}
|
||||
if partChanged {
|
||||
partsChanged = true
|
||||
if decision.Action != SignatureActionPreserve {
|
||||
logGeminiThoughtSignatureSanitize(contentsPath, int(contentIdx.Int()), int(partIdx.Int()), decision, rawSignature, hasSignature)
|
||||
}
|
||||
}
|
||||
partItems = append(partItems, partJSON)
|
||||
return true
|
||||
})
|
||||
|
||||
contentJSON := []byte(content.Raw)
|
||||
if partsChanged {
|
||||
contentJSON, _ = sjson.SetRawBytes(contentJSON, "parts", joinGeminiSignatureRawArray(partItems))
|
||||
contentsChanged = true
|
||||
}
|
||||
contentItems = append(contentItems, contentJSON)
|
||||
return true
|
||||
})
|
||||
|
||||
if !contentsChanged {
|
||||
return payload
|
||||
}
|
||||
updated, errSet := sjson.SetRawBytes(payload, contentsPath, joinGeminiSignatureRawArray(contentItems))
|
||||
if errSet != nil {
|
||||
return payload
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func geminiContentsThoughtSignaturesNeedSanitize(contents gjson.Result) bool {
|
||||
needsSanitize := false
|
||||
contents.ForEach(func(_, content gjson.Result) bool {
|
||||
parts := content.Get("parts")
|
||||
if !parts.IsArray() {
|
||||
return true
|
||||
}
|
||||
isModelTurn := content.Get("role").String() == "model"
|
||||
firstFunctionCallSeen := false
|
||||
parts.ForEach(func(_, part gjson.Result) bool {
|
||||
rawSignature, hasSignature := geminiPartThoughtSignature(part)
|
||||
if part.Get("functionResponse").Exists() {
|
||||
needsSanitize = hasSignature
|
||||
return !needsSanitize
|
||||
}
|
||||
if !isModelTurn {
|
||||
return true
|
||||
}
|
||||
hasFunctionCall := part.Get("functionCall").Exists()
|
||||
isFirstFunctionCall := hasFunctionCall && !firstFunctionCallSeen
|
||||
if hasFunctionCall {
|
||||
firstFunctionCallSeen = true
|
||||
}
|
||||
if isFirstFunctionCall {
|
||||
replaySignature := GeminiReplaySignatureOrBypass(rawSignature, SignatureBlockKindGeminiFunctionCall)
|
||||
needsSanitize = !hasNormalizedGeminiPartThoughtSignature(part, replaySignature)
|
||||
return !needsSanitize
|
||||
}
|
||||
if !hasSignature {
|
||||
return true
|
||||
}
|
||||
blockKind := SignatureBlockKindGeminiModelPart
|
||||
if hasFunctionCall {
|
||||
blockKind = SignatureBlockKindGeminiFunctionCall
|
||||
}
|
||||
decision := DecideSignatureCompatibility(SignatureProviderGemini, rawSignature, blockKind)
|
||||
if decision.Action != SignatureActionPreserve || IsGeminiThoughtSignatureBypass(SignaturePayloadWithoutProviderPrefix(rawSignature)) {
|
||||
needsSanitize = true
|
||||
return false
|
||||
}
|
||||
needsSanitize = !hasNormalizedGeminiPartThoughtSignature(part, decision.NormalizedSignature)
|
||||
return !needsSanitize
|
||||
})
|
||||
return !needsSanitize
|
||||
})
|
||||
return needsSanitize
|
||||
}
|
||||
|
||||
func logGeminiThoughtSignatureSanitize(contentsPath string, contentIndex, partIndex int, decision SignatureCompatibilityDecision, rawSignature string, hasSignature bool) {
|
||||
log.WithFields(log.Fields{
|
||||
"component": "signature_sanitizer",
|
||||
"target_provider": string(SignatureProviderGemini),
|
||||
"action": string(decision.Action),
|
||||
"reason": decision.Reason,
|
||||
"contents_path": contentsPath,
|
||||
"content_index": contentIndex,
|
||||
"part_index": partIndex,
|
||||
"block_kind": string(decision.BlockKind),
|
||||
"detected_provider": string(decision.DetectedProvider),
|
||||
"has_signature": hasSignature,
|
||||
"signature_length": len(strings.TrimSpace(rawSignature)),
|
||||
}).Debug("gemini request: sanitized thoughtSignature before upstream")
|
||||
}
|
||||
|
||||
var geminiPartThoughtSignaturePaths = []string{
|
||||
"thoughtSignature",
|
||||
"thought_signature",
|
||||
"functionCall.thoughtSignature",
|
||||
"functionCall.thought_signature",
|
||||
"functionResponse.thoughtSignature",
|
||||
"functionResponse.thought_signature",
|
||||
"extra_content.google.thought_signature",
|
||||
}
|
||||
|
||||
func geminiPartThoughtSignature(part gjson.Result) (string, bool) {
|
||||
for _, path := range geminiPartThoughtSignaturePaths {
|
||||
result := part.Get(path)
|
||||
if result.Exists() {
|
||||
return result.String(), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func hasNormalizedGeminiPartThoughtSignature(part gjson.Result, replaySignature string) bool {
|
||||
canonicalCount := 0
|
||||
part.ForEach(func(key, _ gjson.Result) bool {
|
||||
if key.String() == "thoughtSignature" {
|
||||
canonicalCount++
|
||||
}
|
||||
return true
|
||||
})
|
||||
canonical := part.Get("thoughtSignature")
|
||||
if canonicalCount != 1 || canonical.Type != gjson.String || canonical.String() != replaySignature {
|
||||
return false
|
||||
}
|
||||
for _, path := range geminiPartThoughtSignaturePaths[1:] {
|
||||
if part.Get(path).Exists() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func deleteGeminiPartThoughtSignatureFields(payload []byte) []byte {
|
||||
for _, path := range geminiPartThoughtSignaturePaths {
|
||||
for gjson.GetBytes(payload, path).Exists() {
|
||||
updated, errDelete := sjson.DeleteBytes(payload, path)
|
||||
if errDelete != nil || len(updated) >= len(payload) {
|
||||
break
|
||||
}
|
||||
payload = updated
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func joinGeminiSignatureRawArray(items [][]byte) []byte {
|
||||
size := len(items) + 1
|
||||
for _, item := range items {
|
||||
size += len(item)
|
||||
}
|
||||
out := make([]byte, 0, size)
|
||||
out = append(out, '[')
|
||||
for index, item := range items {
|
||||
if index > 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, item...)
|
||||
}
|
||||
return append(out, ']')
|
||||
}
|
||||
263
backend/internal/signature/gemini_sanitize_test.go
Normal file
263
backend/internal/signature/gemini_sanitize_test.go
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func newSignatureDebugHook(t *testing.T) *test.Hook {
|
||||
t.Helper()
|
||||
|
||||
previousLevel := log.GetLevel()
|
||||
log.SetLevel(log.DebugLevel)
|
||||
hook := test.NewLocal(log.StandardLogger())
|
||||
t.Cleanup(func() {
|
||||
hook.Reset()
|
||||
log.SetLevel(previousLevel)
|
||||
})
|
||||
return hook
|
||||
}
|
||||
|
||||
func assertSignatureDebugDoesNotLeak(t *testing.T, hook *test.Hook, forbidden string) {
|
||||
t.Helper()
|
||||
|
||||
if forbidden == "" {
|
||||
return
|
||||
}
|
||||
for _, entry := range hook.AllEntries() {
|
||||
if strings.Contains(entry.Message, forbidden) {
|
||||
t.Fatalf("debug log leaked signature in message: %q", entry.Message)
|
||||
}
|
||||
for key, value := range entry.Data {
|
||||
if strings.Contains(fmt.Sprint(value), forbidden) {
|
||||
t.Fatalf("debug log leaked signature in field %q: %v", key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var benchmarkSanitizeGeminiRequestOutput []byte
|
||||
|
||||
func TestSanitizeGeminiRequestThoughtSignaturesPreservesGeminiSignature(t *testing.T) {
|
||||
sig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
|
||||
input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{}},"thoughtSignature":"` + sig + `"}]}]}`)
|
||||
|
||||
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
|
||||
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != sig {
|
||||
t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, sig, string(out))
|
||||
}
|
||||
if &out[0] != &input[0] {
|
||||
t.Fatal("compatible canonical signature payload was copied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeGeminiRequestThoughtSignaturesNormalizesDuplicateCanonicalField(t *testing.T) {
|
||||
input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{}},"thoughtSignature":"` + GeminiSkipThoughtSignatureValidator + `","thoughtSignature":"bad","thoughtSignature":"worse"}]}]}`)
|
||||
|
||||
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
|
||||
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
|
||||
t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, out)
|
||||
}
|
||||
if count := strings.Count(string(out), `"thoughtSignature"`); count != 1 {
|
||||
t.Fatalf("thoughtSignature field count = %d, want 1. Output: %s", count, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeGeminiRequestThoughtSignaturesParallelSyntheticOnlyFirstGetsBypass(t *testing.T) {
|
||||
input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"first","args":{}}},{"functionCall":{"name":"second","args":{}}}]}]}`)
|
||||
|
||||
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
|
||||
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
|
||||
t.Fatalf("first call signature = %q, want bypass sentinel; output=%s", got, out)
|
||||
}
|
||||
if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
|
||||
t.Fatalf("second parallel call should remain unsigned; output=%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeGeminiRequestThoughtSignaturesNativeParallelPreservesUnsignedSibling(t *testing.T) {
|
||||
nativeSignature := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
|
||||
input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"first","args":{}},"thoughtSignature":"` + nativeSignature + `"},{"functionCall":{"name":"second","args":{}}}]}]}`)
|
||||
|
||||
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
|
||||
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != nativeSignature {
|
||||
t.Fatalf("first call signature = %q, want native signature; output=%s", got, out)
|
||||
}
|
||||
if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
|
||||
t.Fatalf("native unsigned sibling should remain unsigned; output=%s", out)
|
||||
}
|
||||
if &out[0] != &input[0] {
|
||||
t.Fatal("already-native parallel history was copied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeGeminiRequestThoughtSignaturesRemovesPollutedSiblingBypass(t *testing.T) {
|
||||
nativeSignature := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
|
||||
input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"first","args":{}},"thoughtSignature":"` + nativeSignature + `"},{"functionCall":{"name":"second","args":{}},"thoughtSignature":"` + GeminiSkipThoughtSignatureValidator + `"}]}]}`)
|
||||
|
||||
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
|
||||
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != nativeSignature {
|
||||
t.Fatalf("first call signature = %q, want native signature; output=%s", got, out)
|
||||
}
|
||||
if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
|
||||
t.Fatalf("polluted sibling bypass should be removed; output=%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeGeminiRequestThoughtSignaturesRemovesPrefixedSiblingBypass(t *testing.T) {
|
||||
nativeSignature := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
|
||||
for _, prefix := range []string{"gemini", "google"} {
|
||||
t.Run(prefix, func(t *testing.T) {
|
||||
input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"first","args":{}},"thoughtSignature":"` + nativeSignature + `"},{"functionCall":{"name":"second","args":{}},"thoughtSignature":"` + prefix + `#` + GeminiSkipThoughtSignatureValidator + `"}]}]}`)
|
||||
|
||||
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
|
||||
|
||||
if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
|
||||
t.Fatalf("prefixed sibling bypass should be removed; output=%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeGeminiRequestThoughtSignaturesLeavesUnsignedThoughtUnsigned(t *testing.T) {
|
||||
input := []byte(`{"contents":[{"role":"model","parts":[{"text":"hidden","thought":true}]}]}`)
|
||||
|
||||
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
|
||||
|
||||
if signature := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature"); signature.Exists() {
|
||||
t.Fatalf("unsigned thought should remain unsigned; output=%s", out)
|
||||
}
|
||||
if &out[0] != &input[0] {
|
||||
t.Fatal("unsigned thought payload was copied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeGeminiRequestThoughtSignaturesReusesUnsignedFunctionResponsePayload(t *testing.T) {
|
||||
input := []byte(`{"contents":[{"role":"user","parts":[{"functionResponse":{"name":"f","response":{"result":"ok"}}}]}]}`)
|
||||
|
||||
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
|
||||
|
||||
if &out[0] != &input[0] {
|
||||
t.Fatal("unsigned function response payload was copied")
|
||||
}
|
||||
if string(out) != string(input) {
|
||||
t.Fatalf("payload changed:\n got: %s\nwant: %s", out, input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeGeminiRequestThoughtSignaturesReplacesBase64UUIDFunctionCall(t *testing.T) {
|
||||
sig := testGeminiThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3"))
|
||||
input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{},"thoughtSignature":"` + sig + `"}}]}]}`)
|
||||
|
||||
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
|
||||
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
|
||||
t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, string(out))
|
||||
}
|
||||
if gjson.GetBytes(out, "contents.0.parts.0.functionCall.thoughtSignature").Exists() {
|
||||
t.Fatalf("nested functionCall thoughtSignature should be removed. Output: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeGeminiRequestThoughtSignaturesLogsBypassReplacement(t *testing.T) {
|
||||
hook := newSignatureDebugHook(t)
|
||||
sig := testGeminiThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3"))
|
||||
input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{},"thoughtSignature":"` + sig + `"}}]}]}`)
|
||||
|
||||
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
|
||||
t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, string(out))
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, entry := range hook.AllEntries() {
|
||||
if entry.Level != log.DebugLevel {
|
||||
continue
|
||||
}
|
||||
if entry.Data["component"] != "signature_sanitizer" ||
|
||||
entry.Data["target_provider"] != string(SignatureProviderGemini) ||
|
||||
entry.Data["action"] != "replace_with_gemini_bypass" {
|
||||
continue
|
||||
}
|
||||
if entry.Data["block_kind"] != string(SignatureBlockKindGeminiFunctionCall) {
|
||||
t.Fatalf("block_kind = %v, want %s", entry.Data["block_kind"], SignatureBlockKindGeminiFunctionCall)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected debug log for Gemini thoughtSignature bypass replacement")
|
||||
}
|
||||
assertSignatureDebugDoesNotLeak(t, hook, sig)
|
||||
}
|
||||
|
||||
func TestSanitizeGeminiRequestThoughtSignaturesPreservesField2WrappedUUIDFunctionCall(t *testing.T) {
|
||||
sig := testGemini3ThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3"))
|
||||
input := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{}},"thoughtSignature":"` + sig + `"}]}]}}`)
|
||||
|
||||
out := SanitizeGeminiRequestThoughtSignatures(input, "request.contents")
|
||||
|
||||
if got := gjson.GetBytes(out, "request.contents.0.parts.0.thoughtSignature").String(); got != sig {
|
||||
t.Fatalf("thoughtSignature = %q, want wrapped UUID signature preserved. Output: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSanitizeGeminiRequestThoughtSignaturesNormalizedHistory(b *testing.B) {
|
||||
for _, turns := range []int{1, 16, 64} {
|
||||
b.Run(fmt.Sprintf("turns_%d", turns), func(b *testing.B) {
|
||||
input := normalizedGeminiSignatureHistory(turns, 8<<20)
|
||||
output := SanitizeGeminiRequestThoughtSignatures(input, "contents")
|
||||
if &output[0] != &input[0] {
|
||||
b.Fatal("normalized payload was copied")
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(input)))
|
||||
b.ResetTimer()
|
||||
|
||||
for b.Loop() {
|
||||
benchmarkSanitizeGeminiRequestOutput = SanitizeGeminiRequestThoughtSignatures(input, "contents")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedGeminiSignatureHistory(turns, totalPayloadBytes int) []byte {
|
||||
payload := strings.Repeat("x", totalPayloadBytes/turns)
|
||||
var builder strings.Builder
|
||||
builder.Grow(totalPayloadBytes + turns*256)
|
||||
builder.WriteString(`{"contents":[`)
|
||||
for i := 0; i < turns; i++ {
|
||||
if i > 0 {
|
||||
builder.WriteByte(',')
|
||||
}
|
||||
builder.WriteString(`{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{"value":"`)
|
||||
builder.WriteString(payload)
|
||||
builder.WriteString(`"}},"thoughtSignature":"`)
|
||||
builder.WriteString(GeminiSkipThoughtSignatureValidator)
|
||||
builder.WriteString(`"}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","response":{"result":"ok"}}}]}`)
|
||||
}
|
||||
builder.WriteString(`]}`)
|
||||
return []byte(builder.String())
|
||||
}
|
||||
|
||||
func TestSanitizeGeminiRequestThoughtSignaturesRemovesFunctionResponseSignature(t *testing.T) {
|
||||
input := []byte(`{"contents":[{"role":"user","parts":[{"functionResponse":{"name":"f","response":{"result":"ok"},"thoughtSignature":"bad","thoughtSignature":"worse"},"thoughtSignature":"bad"}]}]}`)
|
||||
|
||||
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
|
||||
|
||||
if gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").Exists() {
|
||||
t.Fatalf("functionResponse top-level thoughtSignature should be removed. Output: %s", string(out))
|
||||
}
|
||||
if gjson.GetBytes(out, "contents.0.parts.0.functionResponse.thoughtSignature").Exists() {
|
||||
t.Fatalf("functionResponse nested thoughtSignature should be removed. Output: %s", string(out))
|
||||
}
|
||||
}
|
||||
549
backend/internal/signature/gemini_validation.go
Normal file
549
backend/internal/signature/gemini_validation.go
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
// Gemini thought signature validation notes.
|
||||
//
|
||||
// The Antigravity Gemini request translator can preserve provider-compatible
|
||||
// Gemini thought signatures and uses the skip sentinel only for synthetic or
|
||||
// incompatible model parts.
|
||||
//
|
||||
// Gemini 3 and later models can return thoughtSignature on model content parts.
|
||||
// Function-call parts are the strict case: when a model functionCall is replayed
|
||||
// with a following functionResponse, Gemini validates that the original
|
||||
// functionCall part still carries its provider-issued thoughtSignature. Text or
|
||||
// other non-functionCall parts may also carry a signature; those should be
|
||||
// preserved when replaying native Gemini history, but they are not the primary
|
||||
// validation gate.
|
||||
//
|
||||
// Synthetic history and migration from other model families are different. If a
|
||||
// functionCall part was not produced by Gemini API, there is no real signature
|
||||
// to preserve. Gemini documents two bypass sentinels for that case:
|
||||
//
|
||||
// - "skip_thought_signature_validator"
|
||||
// - "context_engineering_is_the_way_to_go"
|
||||
//
|
||||
// This repo emits "skip_thought_signature_validator" only when the first
|
||||
// functionCall in a synthetic model turn lacks a compatible provider signature.
|
||||
// Later parallel calls and ordinary text/thought parts preserve their native
|
||||
// unsigned shape.
|
||||
//
|
||||
// This validator is intentionally more conservative than a decrypting verifier.
|
||||
// Claude has a known E/R base64 envelope and a protobuf tree in this package.
|
||||
// Gemini thought signatures are opaque provider state here, so local validation
|
||||
// checks only the transport-level protobuf envelope and leaves the wrapped
|
||||
// provider payload uninterpreted.
|
||||
//
|
||||
// Validation tiers:
|
||||
//
|
||||
// - Sentinel tier: accept the documented bypass sentinels only on the first
|
||||
// model functionCall when it is synthetic, migrated, or otherwise not
|
||||
// traceable to a prior Gemini model response in the same conversation.
|
||||
// - Opaque-shape tier: for real Gemini signatures, require a non-empty string,
|
||||
// bounded length, successful standard base64 decoding, and a known protobuf
|
||||
// envelope when the caller needs provider compatibility. The only known
|
||||
// envelope is the Gemini 3.x field-2 -> field-1 payload, whose body holds
|
||||
// either versioned opaque state or a provider UUID. Gemini 2.5 emitted a
|
||||
// repeated field-1 form; those models are out of scope and their signatures
|
||||
// are no longer a known envelope. Bare base64 UUID payloads are classified
|
||||
// separately and should be replaced with the bypass sentinel rather than
|
||||
// replayed.
|
||||
// - Replay tier: real validation means preserving the exact model part that
|
||||
// came from Gemini, including its thoughtSignature, id/name/function args,
|
||||
// part index, and ordering relative to sibling parallel function calls.
|
||||
// - Tool pairing tier: functionResponse parts must match the preceding
|
||||
// functionCall id/name and must not be interleaved between parallel calls.
|
||||
// The valid shape is all model functionCalls first, then their responses.
|
||||
// - Compatibility tier: GPT-compatible Gemini traffic stores the same state
|
||||
// under tool_calls[].extra_content.google.thought_signature. If that path is
|
||||
// translated back to native Gemini, the value must stay attached to the same
|
||||
// assistant tool call.
|
||||
//
|
||||
// Important non-goals:
|
||||
//
|
||||
// - Do not treat a Gemini thoughtSignature as a Claude signature. Similar
|
||||
// base64 prefixes are not provenance.
|
||||
// - Do not attach a signature to user functionResponse/tool-result parts.
|
||||
// - Do not log complete signatures during validation failures; log only field
|
||||
// paths, lengths, and redacted prefixes.
|
||||
// - Do not preserve client-provided signatures across model/provider/session
|
||||
// boundaries unless the request pipeline can prove they came from the same
|
||||
// Gemini conversation state.
|
||||
package signature
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxGeminiThoughtSignatureLen = 32 * 1024 * 1024
|
||||
|
||||
GeminiSkipThoughtSignatureValidator = "skip_thought_signature_validator"
|
||||
GeminiContextEngineeringBypass = "context_engineering_is_the_way_to_go"
|
||||
)
|
||||
|
||||
// GeminiThoughtSignatureValidationOptions controls how much local validation is
|
||||
// applied to Gemini thought signatures. This validation checks only the opaque
|
||||
// transport envelope; it does not prove that a signature came from Gemini or can
|
||||
// be decrypted by Gemini.
|
||||
type GeminiThoughtSignatureValidationOptions struct {
|
||||
// AllowBypassSentinel accepts Gemini's documented synthetic-history bypass
|
||||
// sentinels. Keep this false when validating provider-issued signatures.
|
||||
AllowBypassSentinel bool
|
||||
// RequireKnownEnvelope requires the decoded payload to match one of the
|
||||
// protobuf envelopes observed in Gemini samples. This rejects opaque base64
|
||||
// values such as base64 UUIDs.
|
||||
RequireKnownEnvelope bool
|
||||
// RequireObservedMarker requires the decoded payload to start with 0x12. Every
|
||||
// observed Gemini 3.x sample carries this marker, but it is only the outer
|
||||
// protobuf tag, so RequireKnownEnvelope is the stronger check and should be
|
||||
// preferred. This option exists for narrow experiments that want the marker
|
||||
// without the full envelope walk.
|
||||
RequireObservedMarker bool
|
||||
}
|
||||
|
||||
type GeminiThoughtSignatureEnvelope string
|
||||
|
||||
const (
|
||||
GeminiThoughtSignatureEnvelopeUnknown GeminiThoughtSignatureEnvelope = "unknown"
|
||||
// GeminiThoughtSignatureEnvelopeProtobufField2 is the only replay-safe Gemini
|
||||
// envelope. The repeated field-1 form emitted by Gemini 2.5 is no longer
|
||||
// recognized: those models are out of scope, and their signatures now fall
|
||||
// through to the bypass sentinel like any other unknown envelope.
|
||||
GeminiThoughtSignatureEnvelopeProtobufField2 GeminiThoughtSignatureEnvelope = "protobuf_field_2"
|
||||
GeminiThoughtSignatureEnvelopeASCIIUUID GeminiThoughtSignatureEnvelope = "ascii_uuid"
|
||||
)
|
||||
|
||||
// GeminiThoughtSignatureInfo describes the locally inspectable properties of an
|
||||
// opaque Gemini thought signature.
|
||||
type GeminiThoughtSignatureInfo struct {
|
||||
IsBypassSentinel bool
|
||||
BypassSentinel string
|
||||
DecodedLen int
|
||||
FirstByte byte
|
||||
HasObservedMarker bool
|
||||
KnownEnvelope bool
|
||||
Envelope GeminiThoughtSignatureEnvelope
|
||||
RecordCount int
|
||||
OpaquePayloadLen int
|
||||
}
|
||||
|
||||
type geminiFunctionCallRef struct {
|
||||
id string
|
||||
name string
|
||||
path string
|
||||
}
|
||||
|
||||
type geminiFunctionResponseRef struct {
|
||||
part gjson.Result
|
||||
path string
|
||||
}
|
||||
|
||||
func geminiThoughtSignatureValidationOptions(opts []GeminiThoughtSignatureValidationOptions) GeminiThoughtSignatureValidationOptions {
|
||||
if len(opts) == 0 {
|
||||
return GeminiThoughtSignatureValidationOptions{}
|
||||
}
|
||||
return opts[0]
|
||||
}
|
||||
|
||||
// IsGeminiThoughtSignatureBypass reports whether rawSignature is one of
|
||||
// Gemini's documented bypass sentinels for synthetic or migrated function-call
|
||||
// history.
|
||||
func IsGeminiThoughtSignatureBypass(rawSignature string) bool {
|
||||
switch strings.TrimSpace(rawSignature) {
|
||||
case GeminiSkipThoughtSignatureValidator, GeminiContextEngineeringBypass:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsValidGeminiThoughtSignature returns whether rawSignature has a valid local
|
||||
// Gemini thought-signature shape under opts.
|
||||
func IsValidGeminiThoughtSignature(rawSignature string, opts ...GeminiThoughtSignatureValidationOptions) bool {
|
||||
_, err := InspectGeminiThoughtSignature(rawSignature, opts...)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// InspectGeminiThoughtSignature validates and inspects the local transport
|
||||
// shape of a Gemini thought signature. It intentionally treats provider-issued
|
||||
// signatures as opaque base64 payloads.
|
||||
func InspectGeminiThoughtSignature(rawSignature string, opts ...GeminiThoughtSignatureValidationOptions) (*GeminiThoughtSignatureInfo, error) {
|
||||
opt := geminiThoughtSignatureValidationOptions(opts)
|
||||
sig := strings.TrimSpace(rawSignature)
|
||||
if sig == "" {
|
||||
return nil, fmt.Errorf("empty Gemini thought signature")
|
||||
}
|
||||
|
||||
if IsValidClaudeCAISSignature(sig) {
|
||||
return nil, fmt.Errorf("invalid Gemini thought signature: detected Claude CAIS signature")
|
||||
}
|
||||
|
||||
if IsGeminiThoughtSignatureBypass(sig) {
|
||||
if !opt.AllowBypassSentinel {
|
||||
return nil, fmt.Errorf("Gemini thought signature bypass sentinel is not allowed")
|
||||
}
|
||||
return &GeminiThoughtSignatureInfo{
|
||||
IsBypassSentinel: true,
|
||||
BypassSentinel: sig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
decoded, err := decodeGeminiThoughtSignature(sig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(decoded) == 0 {
|
||||
return nil, fmt.Errorf("invalid Gemini thought signature: empty decoded payload")
|
||||
}
|
||||
|
||||
info := &GeminiThoughtSignatureInfo{
|
||||
DecodedLen: len(decoded),
|
||||
FirstByte: decoded[0],
|
||||
HasObservedMarker: decoded[0] == 0x12,
|
||||
}
|
||||
info.Envelope, info.KnownEnvelope = classifyGeminiThoughtSignatureEnvelope(decoded)
|
||||
info.RecordCount, info.OpaquePayloadLen = inspectGeminiEnvelope(decoded, info.Envelope)
|
||||
if opt.RequireKnownEnvelope && !info.KnownEnvelope {
|
||||
return nil, fmt.Errorf("invalid Gemini thought signature: unknown envelope %q", info.Envelope)
|
||||
}
|
||||
if opt.RequireObservedMarker && !info.HasObservedMarker {
|
||||
return nil, fmt.Errorf("invalid Gemini thought signature: expected observed marker 0x12, got 0x%02x", info.FirstByte)
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ValidateGeminiThoughtSignatures validates thoughtSignature fields in a Gemini
|
||||
// native payload. The first functionCall in each model Content must have a valid
|
||||
// provider signature or allowed synthetic sentinel. Later parallel sibling calls
|
||||
// may be unsigned, but any signature they do carry must still be valid.
|
||||
func ValidateGeminiThoughtSignatures(inputRawJSON []byte, opts ...GeminiThoughtSignatureValidationOptions) error {
|
||||
contents, contentsPath := geminiContents(inputRawJSON)
|
||||
if !contents.IsArray() {
|
||||
return nil
|
||||
}
|
||||
|
||||
contentResults := contents.Array()
|
||||
for i := 0; i < len(contentResults); i++ {
|
||||
content := contentResults[i]
|
||||
parts := content.Get("parts")
|
||||
if !parts.IsArray() {
|
||||
continue
|
||||
}
|
||||
|
||||
isModelTurn := strings.EqualFold(strings.TrimSpace(content.Get("role").String()), "model")
|
||||
firstFunctionCallSeen := false
|
||||
partResults := parts.Array()
|
||||
for j := 0; j < len(partResults); j++ {
|
||||
part := partResults[j]
|
||||
hasFunctionCall := part.Get("functionCall").Exists()
|
||||
isFirstFunctionCall := isModelTurn && hasFunctionCall && !firstFunctionCallSeen
|
||||
if isModelTurn && hasFunctionCall {
|
||||
firstFunctionCallSeen = true
|
||||
}
|
||||
rawSignature, hasSignature := geminiPartThoughtSignature(part)
|
||||
if !hasFunctionCall && !hasSignature {
|
||||
continue
|
||||
}
|
||||
|
||||
partPath := fmt.Sprintf("%s[%d].parts[%d]", contentsPath, i, j)
|
||||
rawSignature = strings.TrimSpace(rawSignature)
|
||||
if part.Get("functionResponse").Exists() && hasSignature {
|
||||
return fmt.Errorf("%s: functionResponse must not carry thoughtSignature", partPath)
|
||||
}
|
||||
if rawSignature == "" {
|
||||
if isFirstFunctionCall {
|
||||
return fmt.Errorf("%s: missing thoughtSignature on first functionCall", partPath)
|
||||
}
|
||||
if hasSignature {
|
||||
return fmt.Errorf("%s: empty thoughtSignature", partPath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if IsGeminiThoughtSignatureBypass(rawSignature) && !isFirstFunctionCall {
|
||||
return fmt.Errorf("%s: Gemini bypass sentinel is allowed only on the first model functionCall", partPath)
|
||||
}
|
||||
if !hasNormalizedGeminiPartThoughtSignature(part, rawSignature) {
|
||||
return fmt.Errorf("%s: thoughtSignature must use one canonical top-level field", partPath)
|
||||
}
|
||||
if _, err := InspectGeminiThoughtSignature(rawSignature, opts...); err != nil {
|
||||
return fmt.Errorf("%s: %w", partPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateGeminiFunctionCallPairing validates the replay shape around Gemini
|
||||
// functionCall and functionResponse parts. It checks id/name pairing and
|
||||
// prevents response parts from being interleaved inside the same content as
|
||||
// function calls. It allows a final pending functionCall group because callers
|
||||
// may validate a freshly returned model step before tool outputs exist.
|
||||
func ValidateGeminiFunctionCallPairing(inputRawJSON []byte) error {
|
||||
contents, contentsPath := geminiContents(inputRawJSON)
|
||||
if !contents.IsArray() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pending []geminiFunctionCallRef
|
||||
var validationErr error
|
||||
contents.ForEach(func(contentIndex, content gjson.Result) bool {
|
||||
i := int(contentIndex.Int())
|
||||
parts := content.Get("parts")
|
||||
if !parts.IsArray() {
|
||||
if len(pending) > 0 {
|
||||
validationErr = fmt.Errorf(
|
||||
"%s[%d]: content appears before %d pending functionResponse part(s)",
|
||||
contentsPath,
|
||||
i,
|
||||
len(pending),
|
||||
)
|
||||
}
|
||||
return validationErr == nil
|
||||
}
|
||||
|
||||
var calls []geminiFunctionCallRef
|
||||
var responses []geminiFunctionResponseRef
|
||||
parts.ForEach(func(partIndex, part gjson.Result) bool {
|
||||
j := int(partIndex.Int())
|
||||
partPath := fmt.Sprintf("%s[%d].parts[%d]", contentsPath, i, j)
|
||||
if call := part.Get("functionCall"); call.Exists() {
|
||||
if call.Get("name").String() == "" {
|
||||
validationErr = fmt.Errorf("%s: missing functionCall.name", partPath)
|
||||
return false
|
||||
}
|
||||
calls = append(calls, geminiFunctionCallRef{
|
||||
id: call.Get("id").String(),
|
||||
name: call.Get("name").String(),
|
||||
path: partPath,
|
||||
})
|
||||
}
|
||||
if response := part.Get("functionResponse"); response.Exists() {
|
||||
responses = append(responses, geminiFunctionResponseRef{
|
||||
part: part,
|
||||
path: partPath,
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
if validationErr != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(calls) > 0 && len(responses) > 0:
|
||||
validationErr = fmt.Errorf(
|
||||
"%s[%d]: functionCall and functionResponse parts must not be interleaved in the same content",
|
||||
contentsPath,
|
||||
i,
|
||||
)
|
||||
case len(calls) > 0 && len(pending) > 0:
|
||||
validationErr = fmt.Errorf(
|
||||
"%s[%d]: functionCall appears before %d pending functionResponse part(s)",
|
||||
contentsPath,
|
||||
i,
|
||||
len(pending),
|
||||
)
|
||||
case len(calls) > 0:
|
||||
pending = calls
|
||||
return true
|
||||
case len(responses) == 0 && len(pending) > 0:
|
||||
validationErr = fmt.Errorf(
|
||||
"%s[%d]: content appears before %d pending functionResponse part(s)",
|
||||
contentsPath,
|
||||
i,
|
||||
len(pending),
|
||||
)
|
||||
case len(responses) == 0:
|
||||
return true
|
||||
case len(pending) == 0:
|
||||
validationErr = fmt.Errorf("%s[%d]: functionResponse without preceding functionCall", contentsPath, i)
|
||||
case len(responses) != len(pending):
|
||||
validationErr = fmt.Errorf(
|
||||
"%s[%d]: functionResponse count %d does not match pending functionCall count %d",
|
||||
contentsPath,
|
||||
i,
|
||||
len(responses),
|
||||
len(pending),
|
||||
)
|
||||
}
|
||||
if validationErr != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for responseIndex, responseRef := range responses {
|
||||
partPath := responseRef.path
|
||||
response := responseRef.part.Get("functionResponse")
|
||||
call := pending[responseIndex]
|
||||
responseID := response.Get("id").String()
|
||||
responseName := response.Get("name").String()
|
||||
|
||||
switch {
|
||||
case call.id != "" && responseID == "":
|
||||
validationErr = fmt.Errorf("%s: missing functionResponse.id for %s", partPath, call.path)
|
||||
case call.id != "" && responseID != call.id:
|
||||
validationErr = fmt.Errorf(
|
||||
"%s: functionResponse.id %q does not match functionCall.id %q at %s",
|
||||
partPath,
|
||||
responseID,
|
||||
call.id,
|
||||
call.path,
|
||||
)
|
||||
case responseName == "":
|
||||
validationErr = fmt.Errorf("%s: missing functionResponse.name", partPath)
|
||||
case call.name != "" && responseName != call.name:
|
||||
validationErr = fmt.Errorf(
|
||||
"%s: functionResponse.name %q does not match functionCall.name %q at %s",
|
||||
partPath,
|
||||
responseName,
|
||||
call.name,
|
||||
call.path,
|
||||
)
|
||||
}
|
||||
if validationErr != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
pending = nil
|
||||
return true
|
||||
})
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func decodeGeminiThoughtSignature(sig string) ([]byte, error) {
|
||||
if len(sig) > MaxGeminiThoughtSignatureLen {
|
||||
return nil, fmt.Errorf("Gemini thought signature exceeds maximum length (%d bytes)", MaxGeminiThoughtSignatureLen)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(sig)
|
||||
if err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
if decoded, rawErr := base64.RawStdEncoding.DecodeString(sig); rawErr == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid Gemini thought signature: base64 decode failed: %w", err)
|
||||
}
|
||||
|
||||
func classifyGeminiThoughtSignatureEnvelope(decoded []byte) (GeminiThoughtSignatureEnvelope, bool) {
|
||||
if len(decoded) == 0 {
|
||||
return GeminiThoughtSignatureEnvelopeUnknown, false
|
||||
}
|
||||
if isASCIIUUIDBytes(decoded) {
|
||||
return GeminiThoughtSignatureEnvelopeASCIIUUID, false
|
||||
}
|
||||
if isGeminiField2Envelope(decoded) {
|
||||
return GeminiThoughtSignatureEnvelopeProtobufField2, true
|
||||
}
|
||||
return GeminiThoughtSignatureEnvelopeUnknown, false
|
||||
}
|
||||
|
||||
func isGeminiField2Envelope(decoded []byte) bool {
|
||||
info, ok := inspectGeminiField2Envelope(decoded)
|
||||
return ok && info.RecordCount == 1 && info.OpaquePayloadLen > 0
|
||||
}
|
||||
|
||||
func inspectGeminiEnvelope(decoded []byte, envelope GeminiThoughtSignatureEnvelope) (recordCount int, opaquePayloadLen int) {
|
||||
if envelope == GeminiThoughtSignatureEnvelopeProtobufField2 {
|
||||
if info, ok := inspectGeminiField2Envelope(decoded); ok {
|
||||
return info.RecordCount, info.OpaquePayloadLen
|
||||
}
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
type geminiEnvelopeInfo struct {
|
||||
RecordCount int
|
||||
OpaquePayloadLen int
|
||||
}
|
||||
|
||||
func inspectGeminiField2Envelope(decoded []byte) (geminiEnvelopeInfo, bool) {
|
||||
value, ok := consumeGeminiField2Field1Value(decoded)
|
||||
if !ok || (!isLikelyGeminiOpaquePayload(value) && !isASCIIUUIDBytes(value)) {
|
||||
return geminiEnvelopeInfo{}, false
|
||||
}
|
||||
return geminiEnvelopeInfo{
|
||||
RecordCount: 1,
|
||||
OpaquePayloadLen: len(value),
|
||||
}, true
|
||||
}
|
||||
|
||||
func consumeGeminiField2Field1Value(decoded []byte) ([]byte, bool) {
|
||||
num, typ, n := protowire.ConsumeTag(decoded)
|
||||
if n < 0 || num != 2 || typ != protowire.BytesType {
|
||||
return nil, false
|
||||
}
|
||||
offset := n
|
||||
container, n := protowire.ConsumeBytes(decoded[offset:])
|
||||
if n < 0 {
|
||||
return nil, false
|
||||
}
|
||||
offset += n
|
||||
if offset != len(decoded) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
num, typ, n = protowire.ConsumeTag(container)
|
||||
if n < 0 || num != 1 || typ != protowire.BytesType {
|
||||
return nil, false
|
||||
}
|
||||
containerOffset := n
|
||||
value, n := protowire.ConsumeBytes(container[containerOffset:])
|
||||
if n < 0 {
|
||||
return nil, false
|
||||
}
|
||||
containerOffset += n
|
||||
if containerOffset != len(container) {
|
||||
return nil, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func isLikelyGeminiOpaquePayload(value []byte) bool {
|
||||
// The envelope body is a Google Tink primitive output: one prefix-type byte
|
||||
// (0x01 selects the TINK prefix) followed by a four-byte big-endian key id and
|
||||
// then the ciphertext. Only the prefix-type byte is checked here, because it is
|
||||
// a format constant while the key id is key material that Google rotates.
|
||||
// Pinning the key id would reduce false positives to nothing but would reject
|
||||
// every signature the moment a rotation happens, which is the worse failure.
|
||||
// That rotation is observed, not hypothetical: gemini-3.1-flash-lite carries key
|
||||
// id 0x0c39d6c7 in the archived corpus and 0x114d320f in the 2026-07-27 capture,
|
||||
// and the newer id is shared by every Gemini 3.x variant captured that day. The
|
||||
// bytes after the prefix are high-entropy provider state and stay opaque, so this
|
||||
// one format byte is the only anchor available. It leaves a 1/256 false-positive
|
||||
// rate against a caller that reproduces the protobuf envelope but not the key
|
||||
// material; provenance or target scoping, not more byte checks, closes that gap.
|
||||
return len(value) > 0 && value[0] == 0x01
|
||||
}
|
||||
|
||||
func isASCIIUUIDBytes(decoded []byte) bool {
|
||||
if len(decoded) != 36 {
|
||||
return false
|
||||
}
|
||||
for i, b := range decoded {
|
||||
switch i {
|
||||
case 8, 13, 18, 23:
|
||||
if b != '-' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if !((b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func geminiContents(inputRawJSON []byte) (gjson.Result, string) {
|
||||
if contents := util.GetGJSONBytesNoCopy(inputRawJSON, "contents"); contents.Exists() {
|
||||
return contents, "contents"
|
||||
}
|
||||
return util.GetGJSONBytesNoCopy(inputRawJSON, "request.contents"), "request.contents"
|
||||
}
|
||||
544
backend/internal/signature/gemini_validation_test.go
Normal file
544
backend/internal/signature/gemini_validation_test.go
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
func testGeminiThoughtSignature(payload []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func testGemini25ThoughtSignature(records ...[]byte) string {
|
||||
var payload []byte
|
||||
for _, record := range records {
|
||||
payload = protowire.AppendTag(payload, 1, protowire.BytesType)
|
||||
payload = protowire.AppendBytes(payload, record)
|
||||
}
|
||||
return testGeminiThoughtSignature(payload)
|
||||
}
|
||||
|
||||
func testGemini3ThoughtSignature(payload []byte) string {
|
||||
var inner []byte
|
||||
inner = protowire.AppendTag(inner, 1, protowire.BytesType)
|
||||
inner = protowire.AppendBytes(inner, payload)
|
||||
|
||||
var outer []byte
|
||||
outer = protowire.AppendTag(outer, 2, protowire.BytesType)
|
||||
outer = protowire.AppendBytes(outer, inner)
|
||||
return testGeminiThoughtSignature(outer)
|
||||
}
|
||||
|
||||
func TestInspectGeminiThoughtSignature_AcceptsOpaqueBase64(t *testing.T) {
|
||||
sig := testGeminiThoughtSignature([]byte{0x12, 0x34, 0x56})
|
||||
|
||||
info, err := InspectGeminiThoughtSignature(sig)
|
||||
if err != nil {
|
||||
t.Fatalf("InspectGeminiThoughtSignature failed: %v", err)
|
||||
}
|
||||
if info.IsBypassSentinel {
|
||||
t.Fatal("real signature should not be marked as bypass sentinel")
|
||||
}
|
||||
if info.DecodedLen != 3 {
|
||||
t.Fatalf("DecodedLen = %d, want 3", info.DecodedLen)
|
||||
}
|
||||
if info.FirstByte != 0x12 {
|
||||
t.Fatalf("FirstByte = 0x%02x, want 0x12", info.FirstByte)
|
||||
}
|
||||
if !info.HasObservedMarker {
|
||||
t.Fatal("HasObservedMarker should be true")
|
||||
}
|
||||
if info.Envelope != GeminiThoughtSignatureEnvelopeUnknown {
|
||||
t.Fatalf("Envelope = %q, want %q", info.Envelope, GeminiThoughtSignatureEnvelopeUnknown)
|
||||
}
|
||||
if info.KnownEnvelope {
|
||||
t.Fatal("KnownEnvelope should be false for incomplete opaque payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGeminiThoughtSignature_AcceptsGemini31ProField2Envelope(t *testing.T) {
|
||||
// Shape observed in CPA-API/signatures/gemini/gemini-3.1-pro.txt.
|
||||
sig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34})
|
||||
|
||||
info, err := InspectGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Gemini 3.1 Pro field-2 envelope should be known: %v", err)
|
||||
}
|
||||
if info.Envelope != GeminiThoughtSignatureEnvelopeProtobufField2 {
|
||||
t.Fatalf("Envelope = %q, want %q", info.Envelope, GeminiThoughtSignatureEnvelopeProtobufField2)
|
||||
}
|
||||
if !info.HasObservedMarker {
|
||||
t.Fatal("Gemini 3.1 Pro envelope should be marked as 0x12")
|
||||
}
|
||||
if info.RecordCount != 1 {
|
||||
t.Fatalf("RecordCount = %d, want 1", info.RecordCount)
|
||||
}
|
||||
if info.OpaquePayloadLen != 6 {
|
||||
t.Fatalf("OpaquePayloadLen = %d, want 6", info.OpaquePayloadLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGeminiThoughtSignature_AcceptsCapturedGemini31FlashLiteEnvelope(t *testing.T) {
|
||||
// Captured in CPA-API/signatures/gemini/gemini-3.1-flash-lite.txt.
|
||||
const sig = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
|
||||
|
||||
info, err := InspectGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true})
|
||||
if err != nil {
|
||||
t.Fatalf("captured Gemini 3.1 Flash Lite envelope should be known: %v", err)
|
||||
}
|
||||
if info.Envelope != GeminiThoughtSignatureEnvelopeProtobufField2 {
|
||||
t.Fatalf("Envelope = %q, want %q", info.Envelope, GeminiThoughtSignatureEnvelopeProtobufField2)
|
||||
}
|
||||
if info.RecordCount != 1 {
|
||||
t.Fatalf("RecordCount = %d, want 1", info.RecordCount)
|
||||
}
|
||||
if info.OpaquePayloadLen != 50 {
|
||||
t.Fatalf("OpaquePayloadLen = %d, want 50", info.OpaquePayloadLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGeminiThoughtSignature_AcceptsGemini3WrappedUUIDEnvelope(t *testing.T) {
|
||||
const providerUUID = "e24830a7-5cd6-42fe-998b-ee539e72b9c3"
|
||||
sig := testGemini3ThoughtSignature([]byte(providerUUID))
|
||||
|
||||
info, err := InspectGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Gemini 3 wrapped UUID envelope should be known: %v", err)
|
||||
}
|
||||
if info.Envelope != GeminiThoughtSignatureEnvelopeProtobufField2 {
|
||||
t.Fatalf("Envelope = %q, want %q", info.Envelope, GeminiThoughtSignatureEnvelopeProtobufField2)
|
||||
}
|
||||
if info.RecordCount != 1 {
|
||||
t.Fatalf("RecordCount = %d, want 1", info.RecordCount)
|
||||
}
|
||||
if info.OpaquePayloadLen != len(providerUUID) {
|
||||
t.Fatalf("OpaquePayloadLen = %d, want %d", info.OpaquePayloadLen, len(providerUUID))
|
||||
}
|
||||
if provider := DetectSignatureProviderForBlock(sig, SignatureBlockKindGeminiFunctionCall); provider != SignatureProviderGemini {
|
||||
t.Fatalf("provider = %q, want %q", provider, SignatureProviderGemini)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspectGeminiThoughtSignature_RejectsGemini25Field1Envelope pins the removal
|
||||
// of the repeated field-1 envelope. Gemini 2.5 is out of scope, so its signatures
|
||||
// are no longer a known envelope; they degrade to the bypass sentinel on Gemini
|
||||
// model parts instead of being replayed verbatim.
|
||||
func TestInspectGeminiThoughtSignature_RejectsGemini25Field1Envelope(t *testing.T) {
|
||||
sig := testGemini25ThoughtSignature([]byte{0x01, 0x8f}, []byte{0x01, 0x90, 0x91})
|
||||
|
||||
if _, err := InspectGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}); err == nil {
|
||||
t.Fatal("Gemini 2.5 field-1 envelope should no longer be a known envelope")
|
||||
}
|
||||
|
||||
info, err := InspectGeminiThoughtSignature(sig)
|
||||
if err != nil {
|
||||
t.Fatalf("inspection without RequireKnownEnvelope should still succeed: %v", err)
|
||||
}
|
||||
if info.Envelope != GeminiThoughtSignatureEnvelopeUnknown {
|
||||
t.Fatalf("Envelope = %q, want %q", info.Envelope, GeminiThoughtSignatureEnvelopeUnknown)
|
||||
}
|
||||
if info.KnownEnvelope {
|
||||
t.Fatal("KnownEnvelope should be false for the retired field-1 envelope")
|
||||
}
|
||||
|
||||
// Gemini model parts still recover through the documented sentinel.
|
||||
decision := DecideSignatureCompatibility(SignatureProviderGemini, sig, SignatureBlockKindGeminiModelPart)
|
||||
if decision.Action != SignatureActionReplaceWithGeminiBypass {
|
||||
t.Fatalf("action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass)
|
||||
}
|
||||
if decision.ReplacementSignature != GeminiSkipThoughtSignatureValidator {
|
||||
t.Fatalf("replacement = %q, want %q", decision.ReplacementSignature, GeminiSkipThoughtSignatureValidator)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGeminiThoughtSignature_RejectsMalformedKnownEnvelope(t *testing.T) {
|
||||
// Field 2 with a nested field 1 is not enough. Observed Gemini 3 payloads
|
||||
// wrap an opaque blob that starts with internal version byte 0x01.
|
||||
sig := testGemini3ThoughtSignature([]byte{0x02, 0x0c, 0x39})
|
||||
|
||||
if IsValidGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}) {
|
||||
t.Fatal("malformed Gemini 3 envelope should fail known-envelope validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGeminiThoughtSignature_ClassifiesASCIIUUIDAsOpaque(t *testing.T) {
|
||||
sig := testGeminiThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3"))
|
||||
|
||||
info, err := InspectGeminiThoughtSignature(sig)
|
||||
if err != nil {
|
||||
t.Fatalf("opaque base64 UUID should pass default validation: %v", err)
|
||||
}
|
||||
if info.Envelope != GeminiThoughtSignatureEnvelopeASCIIUUID {
|
||||
t.Fatalf("Envelope = %q, want %q", info.Envelope, GeminiThoughtSignatureEnvelopeASCIIUUID)
|
||||
}
|
||||
if info.KnownEnvelope {
|
||||
t.Fatal("base64 UUID should not be a known protobuf envelope")
|
||||
}
|
||||
if IsValidGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}) {
|
||||
t.Fatal("base64 UUID should fail when known envelope is required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGeminiThoughtSignature_ObservedMarkerOption(t *testing.T) {
|
||||
sig := testGeminiThoughtSignature([]byte{0x45, 0x12})
|
||||
|
||||
if _, err := InspectGeminiThoughtSignature(sig); err != nil {
|
||||
t.Fatalf("default validation should accept opaque base64 payload: %v", err)
|
||||
}
|
||||
_, err := InspectGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireObservedMarker: true})
|
||||
if err == nil {
|
||||
t.Fatal("RequireObservedMarker should reject payloads without 0x12 marker")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "expected observed marker") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGeminiThoughtSignature_BypassSentinelRequiresOption(t *testing.T) {
|
||||
if IsValidGeminiThoughtSignature(GeminiSkipThoughtSignatureValidator) {
|
||||
t.Fatal("bypass sentinel should not be valid by default")
|
||||
}
|
||||
|
||||
info, err := InspectGeminiThoughtSignature(GeminiSkipThoughtSignatureValidator, GeminiThoughtSignatureValidationOptions{AllowBypassSentinel: true})
|
||||
if err != nil {
|
||||
t.Fatalf("bypass sentinel should be accepted when explicitly allowed: %v", err)
|
||||
}
|
||||
if !info.IsBypassSentinel {
|
||||
t.Fatal("sentinel should be marked as bypass")
|
||||
}
|
||||
if info.BypassSentinel != GeminiSkipThoughtSignatureValidator {
|
||||
t.Fatalf("BypassSentinel = %q, want %q", info.BypassSentinel, GeminiSkipThoughtSignatureValidator)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGeminiThoughtSignature_RejectsInvalidBase64(t *testing.T) {
|
||||
if IsValidGeminiThoughtSignature("not valid base64!!!") {
|
||||
t.Fatal("invalid base64 should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiThoughtSignatures_FirstFunctionCallRequiresSignature(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"id": "call-1", "name": "read_file", "args": {}}}
|
||||
]
|
||||
}]
|
||||
}`)
|
||||
|
||||
err := ValidateGeminiThoughtSignatures(input)
|
||||
if err == nil {
|
||||
t.Fatal("missing first functionCall thoughtSignature should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing thoughtSignature on first functionCall") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiThoughtSignatures_AllowsUnsignedParallelSibling(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"functionCall": {"id": "call-1", "name": "read_file", "args": {}},
|
||||
"thoughtSignature": "skip_thought_signature_validator"
|
||||
},
|
||||
{"functionCall": {"id": "call-2", "name": "read_file", "args": {}}}
|
||||
]
|
||||
}]
|
||||
}`)
|
||||
|
||||
if err := ValidateGeminiThoughtSignatures(input, GeminiThoughtSignatureValidationOptions{AllowBypassSentinel: true}); err != nil {
|
||||
t.Fatalf("unsigned parallel sibling should be valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiThoughtSignatures_RejectsSentinelOutsideFirstFunctionCall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parts string
|
||||
}{
|
||||
{
|
||||
name: "parallel sibling",
|
||||
parts: `[
|
||||
{"functionCall":{"name":"first","args":{}},"thoughtSignature":"skip_thought_signature_validator"},
|
||||
{"functionCall":{"name":"second","args":{}},"thoughtSignature":"skip_thought_signature_validator"}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "thought part",
|
||||
parts: `[{"text":"hidden","thought":true,"thoughtSignature":"skip_thought_signature_validator"}]`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := []byte(`{"contents":[{"role":"model","parts":` + tt.parts + `}]}`)
|
||||
err := ValidateGeminiThoughtSignatures(input, GeminiThoughtSignatureValidationOptions{AllowBypassSentinel: true})
|
||||
if err == nil || !strings.Contains(err.Error(), "allowed only on the first model functionCall") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiThoughtSignatures_RejectsNonCanonicalNestedSignature(t *testing.T) {
|
||||
signature := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
|
||||
input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"first","args":{},"thoughtSignature":"` + signature + `"}}]}]}`)
|
||||
|
||||
err := ValidateGeminiThoughtSignatures(input)
|
||||
if err == nil || !strings.Contains(err.Error(), "canonical top-level field") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiThoughtSignatures_AcceptsWrappedRequestAndSentinelWhenAllowed(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"request": {
|
||||
"contents": [{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"functionCall": {"id": "call-1", "name": "read_file", "args": {}},
|
||||
"thoughtSignature": "skip_thought_signature_validator"
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
}`)
|
||||
|
||||
err := ValidateGeminiThoughtSignatures(input, GeminiThoughtSignatureValidationOptions{AllowBypassSentinel: true})
|
||||
if err != nil {
|
||||
t.Fatalf("sentinel should be valid when explicitly allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiThoughtSignatures_RejectsInvalidTextPartSignature(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"text": "previous answer", "thoughtSignature": "bad!!!"}
|
||||
]
|
||||
}]
|
||||
}`)
|
||||
|
||||
err := ValidateGeminiThoughtSignatures(input)
|
||||
if err == nil {
|
||||
t.Fatal("invalid text-part thoughtSignature should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "base64 decode failed") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiFunctionCallPairing_ValidParallelGroup(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"id": "call-1", "name": "weather", "args": {"city": "Paris"}}},
|
||||
{"functionCall": {"id": "call-2", "name": "weather", "args": {"city": "London"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"id": "call-1", "name": "weather", "response": {"temp": "15C"}}},
|
||||
{"functionResponse": {"id": "call-2", "name": "weather", "response": {"temp": "12C"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
if err := ValidateGeminiFunctionCallPairing(input); err != nil {
|
||||
t.Fatalf("valid pairing failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiFunctionCallPairing_RejectsUserBoundaryBeforeResponse(t *testing.T) {
|
||||
payload := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run","args":{}}}]},{"role":"user","parts":[{"text":"boundary"}]},{"role":"model","parts":[{"functionResponse":{"id":"call-1","name":"run","response":{"result":"ok"}}}]}]}`)
|
||||
if err := ValidateGeminiFunctionCallPairing(payload); err == nil {
|
||||
t.Fatal("user boundary before function response was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiFunctionCallPairing_RejectsEmptyContentBoundaryBeforeResponse(t *testing.T) {
|
||||
for _, boundary := range []string{
|
||||
`{"role":"user","parts":[]}`,
|
||||
`{"role":"user"}`,
|
||||
`{"role":"user","parts":null}`,
|
||||
} {
|
||||
payload := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run","args":{}}}]},` + boundary + `,{"role":"model","parts":[{"functionResponse":{"id":"call-1","name":"run","response":{"result":"ok"}}}]}]}`)
|
||||
if err := ValidateGeminiFunctionCallPairing(payload); err == nil {
|
||||
t.Fatalf("content boundary %s before function response was accepted", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiFunctionCallPairing_RejectsResponseCountMismatch(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"id": "call-1", "name": "weather", "args": {}}},
|
||||
{"functionCall": {"id": "call-2", "name": "weather", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"id": "call-1", "name": "weather", "response": {}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
err := ValidateGeminiFunctionCallPairing(input)
|
||||
if err == nil {
|
||||
t.Fatal("response count mismatch should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not match pending functionCall count") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiFunctionCallPairing_RejectsMissingFunctionCallName(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"id": "call-1", "args": {}}}
|
||||
]
|
||||
}]
|
||||
}`)
|
||||
|
||||
err := ValidateGeminiFunctionCallPairing(input)
|
||||
if err == nil {
|
||||
t.Fatal("missing functionCall name should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing functionCall.name") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiFunctionCallPairing_RejectsIDMismatch(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"id": "call-1", "name": "weather", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"id": "call-other", "name": "weather", "response": {}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
err := ValidateGeminiFunctionCallPairing(input)
|
||||
if err == nil {
|
||||
t.Fatal("id mismatch should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not match functionCall.id") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiFunctionCallPairing_RejectsMissingResponseName(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"id": "call-1", "name": "weather", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"id": "call-1", "response": {}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
err := ValidateGeminiFunctionCallPairing(input)
|
||||
if err == nil {
|
||||
t.Fatal("missing response name should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing functionResponse.name") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiFunctionCallPairing_RejectsSameContentInterleaving(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"id": "call-1", "name": "weather", "args": {}}},
|
||||
{"functionResponse": {"id": "call-1", "name": "weather", "response": {}}}
|
||||
]
|
||||
}]
|
||||
}`)
|
||||
|
||||
err := ValidateGeminiFunctionCallPairing(input)
|
||||
if err == nil {
|
||||
t.Fatal("same-content interleaving should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "must not be interleaved") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidGeminiThoughtSignature_AgyNativeSamples(t *testing.T) {
|
||||
samplesPath, ok := agyGeminiThoughtSignatureSamplesPath()
|
||||
if !ok {
|
||||
t.Skip("agy gemini corpus missing; run docs/native-prompt-capture/scripts/harvest_agy_gemini_signatures.py")
|
||||
}
|
||||
raw, err := os.ReadFile(samplesPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read samples: %v", err)
|
||||
}
|
||||
var samples []string
|
||||
if err := json.Unmarshal(raw, &samples); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(samples) < 10 {
|
||||
t.Fatalf("expected >=10 agy gemini thoughtSignature samples, got %d", len(samples))
|
||||
}
|
||||
opts := GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: false} // agy native mix includes envelopes CPA may still transport-reject separately
|
||||
for i, sig := range samples {
|
||||
if !IsValidGeminiThoughtSignature(sig, opts) {
|
||||
t.Fatalf("sample %d invalid (len=%d prefix=%q)", i, len(sig), sig[:12])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func agyGeminiThoughtSignatureSamplesPath() (string, bool) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
repo := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
path := filepath.Join(repo, "docs", "native-prompt-capture", "corpus", "agy-gemini-thought-signatures", "samples.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return path, false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
92
backend/internal/signature/gpt_validation.go
Normal file
92
backend/internal/signature/gpt_validation.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const MaxGPTReasoningSignatureLen = 32 * 1024 * 1024
|
||||
|
||||
type GPTReasoningSignatureInfo struct {
|
||||
DecodedLen int
|
||||
CiphertextLen int
|
||||
}
|
||||
|
||||
func IsValidGPTReasoningSignature(rawSignature string) bool {
|
||||
_, err := InspectGPTReasoningSignature(rawSignature)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// InspectGPTReasoningSignature validates the Fernet-like outer format used
|
||||
// by GPT/Codex reasoning encrypted_content. This is only a transport-shape
|
||||
// check; it does not prove decryptability.
|
||||
func InspectGPTReasoningSignature(rawSignature string) (*GPTReasoningSignatureInfo, error) {
|
||||
sig := strings.TrimSpace(rawSignature)
|
||||
if sig == "" {
|
||||
return nil, fmt.Errorf("empty GPT reasoning signature")
|
||||
}
|
||||
if len(sig) > MaxGPTReasoningSignatureLen {
|
||||
return nil, fmt.Errorf("GPT reasoning signature exceeds maximum length (%d bytes)", MaxGPTReasoningSignatureLen)
|
||||
}
|
||||
// The literal prefix is the cheapest discriminator and rejects every other
|
||||
// provider's envelope outright, so it runs before the full charset scan.
|
||||
// Probing this validator is on the hot path for signatures of every provider,
|
||||
// and scanning a multi-kilobyte payload only to reject it on five bytes was
|
||||
// pure waste.
|
||||
if !strings.HasPrefix(sig, "gAAAA") {
|
||||
return nil, fmt.Errorf("invalid GPT reasoning signature: expected gAAAA prefix")
|
||||
}
|
||||
if index, r, ok := firstInvalidGPTReasoningSignatureChar(sig); ok {
|
||||
return nil, fmt.Errorf("invalid GPT reasoning signature: contains non-base64url character U+%04X at byte %d", r, index)
|
||||
}
|
||||
|
||||
decoded, err := decodeGPTReasoningSignature(sig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(decoded) < 73 {
|
||||
return nil, fmt.Errorf("invalid GPT reasoning signature: decoded payload too short")
|
||||
}
|
||||
if decoded[0] != 0x80 {
|
||||
return nil, fmt.Errorf("invalid GPT reasoning signature: expected version 0x80, got 0x%02x", decoded[0])
|
||||
}
|
||||
|
||||
ciphertextLen := len(decoded) - 1 - 8 - 16 - 32
|
||||
if ciphertextLen <= 0 || ciphertextLen%16 != 0 {
|
||||
return nil, fmt.Errorf("invalid GPT reasoning signature: ciphertext length %d is not a positive AES block multiple", ciphertextLen)
|
||||
}
|
||||
|
||||
return &GPTReasoningSignatureInfo{
|
||||
DecodedLen: len(decoded),
|
||||
CiphertextLen: ciphertextLen,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeGPTReasoningSignature(sig string) ([]byte, error) {
|
||||
if decoded, err := base64.RawURLEncoding.DecodeString(sig); err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
if decoded, err := base64.URLEncoding.DecodeString(sig); err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid GPT reasoning signature: base64url decode failed")
|
||||
}
|
||||
|
||||
// gptReasoningSignatureCharSet is the base64url alphabet, padding included.
|
||||
var gptReasoningSignatureCharSet = base64AlphabetSet("-_=")
|
||||
|
||||
// firstInvalidGPTReasoningSignatureChar scans bytes against a lookup table for the
|
||||
// same reason as its Grok counterpart: every legal character is ASCII, and a
|
||||
// comparison chain mispredicts on nearly every byte of a multi-kilobyte reasoning
|
||||
// blob. The offending rune is decoded only for the error message.
|
||||
func firstInvalidGPTReasoningSignatureChar(sig string) (int, rune, bool) {
|
||||
for index := 0; index < len(sig); index++ {
|
||||
if !gptReasoningSignatureCharSet[sig[index]] {
|
||||
r, _ := utf8.DecodeRuneInString(sig[index:])
|
||||
return index, r, true
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
35
backend/internal/signature/gpt_validation_test.go
Normal file
35
backend/internal/signature/gpt_validation_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testGPTReasoningSignature() string {
|
||||
payload := make([]byte, 1+8+16+16+32)
|
||||
payload[0] = 0x80
|
||||
for i := 9; i < len(payload); i++ {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func TestDetectSignatureProvider_GPTReasoning(t *testing.T) {
|
||||
if got := DetectSignatureProvider(testGPTReasoningSignature()); got != SignatureProviderGPT {
|
||||
t.Fatalf("DetectSignatureProvider(GPT) = %q, want %q", got, SignatureProviderGPT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGPTReasoningSignatureRejectsUnicodeEllipsis(t *testing.T) {
|
||||
sig := testGPTReasoningSignature()
|
||||
polluted := sig[:20] + string(rune(0x2026)) + sig[20:]
|
||||
|
||||
_, err := InspectGPTReasoningSignature(polluted)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid GPT reasoning signature")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "non-base64url character U+2026") {
|
||||
t.Fatalf("error = %q, want U+2026 base64url detail", err.Error())
|
||||
}
|
||||
}
|
||||
169
backend/internal/signature/grok_validation.go
Normal file
169
backend/internal/signature/grok_validation.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxGrokEncryptedContentLen is a transport safety cap for opaque replay blobs.
|
||||
MaxGrokEncryptedContentLen = 8 * 1024 * 1024
|
||||
// MinGrokEncryptedContentDecodedLen is a deliberately loose floor, and the
|
||||
// headroom has already proven necessary. An earlier corpus of 207 samples put
|
||||
// the shortest native payload at exactly 50 bytes, with several samples piled
|
||||
// on that value, which read like a protocol floor; a later 215-sample capture
|
||||
// from grok-4.5 and grok-composer-2.5-fast reached 43 and 48 bytes and moved
|
||||
// it. Both corpora agree there is no structure to anchor on, so the observed
|
||||
// minimum is a sampling artifact that keeps sliding, and sitting on it would
|
||||
// silently reject a future shorter payload as lost reasoning context. Keep the
|
||||
// floor low and let the entropy check do the real filtering.
|
||||
MinGrokEncryptedContentDecodedLen = 32
|
||||
// MinGrokEncryptedContentEntropyRatio rejects obvious non-ciphertext payloads.
|
||||
// Native samples are >= 0.892 against the sample-size entropy ceiling.
|
||||
MinGrokEncryptedContentEntropyRatio = 0.85
|
||||
)
|
||||
|
||||
type GrokEncryptedContentInfo struct {
|
||||
RawLen int
|
||||
DecodedLen int
|
||||
}
|
||||
|
||||
// InspectGrokEncryptedContent validates the transport shape of xAI/Grok
|
||||
// reasoning or compaction encrypted_content. This does not prove decryptability.
|
||||
//
|
||||
// This is NOT a provider classifier and must not be used as one. Unlike Claude,
|
||||
// Gemini and GPT, xAI emits no self-describing envelope: observed payloads are
|
||||
// indistinguishable from uniform random bytes (no magic prefix, no version byte,
|
||||
// no fixed suffix, and decoded lengths spread evenly modulo the AES block size).
|
||||
// Every high-entropy unpadded standard-base64 blob therefore satisfies the checks
|
||||
// below. Callers must establish provenance before asking this question, either
|
||||
// from an explicit provider cache prefix or from a confirmed xAI target model,
|
||||
// and treat the result as a replay-safety check rather than an identification.
|
||||
func InspectGrokEncryptedContent(raw string) (*GrokEncryptedContentInfo, error) {
|
||||
sig := strings.TrimSpace(raw)
|
||||
if sig == "" {
|
||||
return nil, fmt.Errorf("empty Grok encrypted_content")
|
||||
}
|
||||
if len(sig) > MaxGrokEncryptedContentLen {
|
||||
return nil, fmt.Errorf("Grok encrypted_content exceeds maximum length (%d bytes)", MaxGrokEncryptedContentLen)
|
||||
}
|
||||
if sig != raw {
|
||||
return nil, fmt.Errorf("Grok encrypted_content has leading or trailing whitespace")
|
||||
}
|
||||
if strings.Contains(sig, "=") {
|
||||
return nil, fmt.Errorf("invalid Grok encrypted_content: expected unpadded standard base64")
|
||||
}
|
||||
if index, r, ok := firstInvalidGrokEncryptedContentChar(sig); ok {
|
||||
return nil, fmt.Errorf("invalid Grok encrypted_content: contains non-base64 character U+%04X at byte %d", r, index)
|
||||
}
|
||||
if _, _, ok := SplitSignatureProviderPrefix(sig); ok {
|
||||
return nil, fmt.Errorf("invalid Grok encrypted_content: carries another provider's cache prefix")
|
||||
}
|
||||
// Foreign-envelope rejection only has to run for the narrow set of base64
|
||||
// first characters a self-describing envelope can produce. Native xAI
|
||||
// ciphertext is uniformly distributed, so this skips the whole chain for
|
||||
// roughly 92% of real traffic without decoding anything. Every branch below
|
||||
// stays exhaustive for the candidates that do reach it: Claude CAIS in
|
||||
// particular is high-entropy standard base64 that drops its padding whenever
|
||||
// the decoded length is a multiple of 3, so the padding gate above does not
|
||||
// exclude it on its own.
|
||||
if maybeSelfDescribingSignatureEnvelope(sig) {
|
||||
if strings.HasPrefix(sig, "gAAAA") {
|
||||
return nil, fmt.Errorf("Grok encrypted_content looks like GPT/Codex reasoning signature")
|
||||
}
|
||||
if IsValidClaudeThinkingSignature(sig, ClaudeSignatureValidationOptions{Strict: true}) {
|
||||
return nil, fmt.Errorf("Grok encrypted_content looks like Claude thinking signature")
|
||||
}
|
||||
if IsValidClaudeCAISSignature(sig) {
|
||||
return nil, fmt.Errorf("Grok encrypted_content looks like Claude CAIS thinking signature")
|
||||
}
|
||||
if _, err := InspectGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}); err == nil {
|
||||
return nil, fmt.Errorf("Grok encrypted_content looks like Gemini thoughtSignature")
|
||||
}
|
||||
}
|
||||
// Kimi emits no envelope either, so the pre-filter above cannot narrow it and
|
||||
// this check has to run unconditionally. Length is the only separator the two
|
||||
// families have: Kimi is fixed at two code-path constants while xAI payload
|
||||
// length tracks reasoning volume continuously at 1-byte granularity. Neither
|
||||
// observed Kimi length appears anywhere in 1027 catalogued signatures or 215
|
||||
// native Grok samples, so rejecting them here costs no real Grok traffic.
|
||||
if IsValidKimiThinkingSignature(sig) {
|
||||
return nil, fmt.Errorf("Grok encrypted_content has a Kimi thinking signature length")
|
||||
}
|
||||
|
||||
decoded, err := decodeGrokEncryptedContent(sig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(decoded) < MinGrokEncryptedContentDecodedLen {
|
||||
return nil, fmt.Errorf("invalid Grok encrypted_content: decoded payload too short (%d bytes)", len(decoded))
|
||||
}
|
||||
if entropyRatio := byteEntropyRatio(decoded); entropyRatio < MinGrokEncryptedContentEntropyRatio {
|
||||
return nil, fmt.Errorf("invalid Grok encrypted_content: decoded payload entropy ratio %.3f below %.3f", entropyRatio, MinGrokEncryptedContentEntropyRatio)
|
||||
}
|
||||
return &GrokEncryptedContentInfo{
|
||||
RawLen: len(sig),
|
||||
DecodedLen: len(decoded),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func IsValidGrokEncryptedContent(raw string) bool {
|
||||
_, err := InspectGrokEncryptedContent(raw)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func decodeGrokEncryptedContent(sig string) ([]byte, error) {
|
||||
decoded, err := base64.RawStdEncoding.DecodeString(sig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid Grok encrypted_content: base64 decode failed: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// grokEncryptedContentCharSet is the unpadded standard base64 alphabet.
|
||||
var grokEncryptedContentCharSet = base64AlphabetSet("+/")
|
||||
|
||||
// firstInvalidGrokEncryptedContentChar scans bytes against a lookup table rather
|
||||
// than ranging over runes. Every legal character is ASCII, so rune iteration only
|
||||
// adds cost, and the table removes the branch mispredictions that dominated this
|
||||
// scan on multi-kilobyte payloads. The offending rune is decoded once, for the
|
||||
// error message, so multi-byte input is still reported accurately.
|
||||
func firstInvalidGrokEncryptedContentChar(sig string) (int, rune, bool) {
|
||||
for index := 0; index < len(sig); index++ {
|
||||
if !grokEncryptedContentCharSet[sig[index]] {
|
||||
r, _ := utf8.DecodeRuneInString(sig[index:])
|
||||
return index, r, true
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func byteEntropyRatio(buf []byte) float64 {
|
||||
if len(buf) == 0 {
|
||||
return 0
|
||||
}
|
||||
var counts [256]int
|
||||
for _, b := range buf {
|
||||
counts[b]++
|
||||
}
|
||||
n := float64(len(buf))
|
||||
entropy := 0.0
|
||||
for _, count := range counts {
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
p := float64(count) / n
|
||||
entropy -= p * math.Log2(p)
|
||||
}
|
||||
maxSymbols := len(buf)
|
||||
if maxSymbols > 256 {
|
||||
maxSymbols = 256
|
||||
}
|
||||
if maxSymbols <= 1 {
|
||||
return 0
|
||||
}
|
||||
return entropy / math.Log2(float64(maxSymbols))
|
||||
}
|
||||
392
backend/internal/signature/grok_validation_test.go
Normal file
392
backend/internal/signature/grok_validation_test.go
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
func TestInspectGrokEncryptedContent_NativeSamples(t *testing.T) {
|
||||
path, ok := grokEncryptedContentSamplesPath()
|
||||
if !ok {
|
||||
t.Skip("grok encrypted_content corpus missing; run docs/native-prompt-capture/scripts/harvest-grok-encrypted-content.sh")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read samples: %v", err)
|
||||
}
|
||||
var samples []string
|
||||
if err := json.Unmarshal(raw, &samples); err != nil {
|
||||
t.Fatalf("unmarshal samples: %v", err)
|
||||
}
|
||||
if len(samples) == 0 {
|
||||
t.Fatal("expected native Grok encrypted_content samples")
|
||||
}
|
||||
for i, sample := range samples {
|
||||
if _, err := InspectGrokEncryptedContent(sample); err != nil {
|
||||
t.Fatalf("sample[%d] should be valid, got %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGrokEncryptedContent_RejectsAgyGeminiThoughtSignatures(t *testing.T) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
path := filepath.Join(filepath.Dir(file), "testdata", "agy_gemini_thought_signature_entries.json")
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
t.Skip("agy gemini corpus missing; run harvest_agy_gemini_signatures.py")
|
||||
} else if err != nil {
|
||||
t.Fatalf("stat samples: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read samples: %v", err)
|
||||
}
|
||||
var entries []struct {
|
||||
ThoughtSignature string `json:"thoughtSignature"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &entries); err != nil {
|
||||
t.Fatalf("unmarshal samples: %v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("expected agy Gemini thought signatures")
|
||||
}
|
||||
checkedUnpaddedGemini := false
|
||||
for i, entry := range entries {
|
||||
_, err := InspectGrokEncryptedContent(entry.ThoughtSignature)
|
||||
if err == nil {
|
||||
t.Fatalf("entry[%d] should not pass as Grok encrypted_content", i)
|
||||
}
|
||||
if !strings.Contains(entry.ThoughtSignature, "=") {
|
||||
checkedUnpaddedGemini = true
|
||||
if !strings.Contains(err.Error(), "Gemini") {
|
||||
t.Fatalf("entry[%d] error = %q, want Gemini fast-reject detail", i, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
if !checkedUnpaddedGemini {
|
||||
t.Fatal("expected at least one unpadded Gemini thought signature sample")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGrokEncryptedContent_RejectsGeminiThoughtSignatureEnvelope(t *testing.T) {
|
||||
sample := testGeminiThoughtSignatureEnvelope()
|
||||
|
||||
_, err := InspectGrokEncryptedContent(sample)
|
||||
if err == nil {
|
||||
t.Fatal("expected Gemini thoughtSignature envelope to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Gemini") {
|
||||
t.Fatalf("error = %q, want Gemini fast-reject detail", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspectGrokEncryptedContent_RetiredGemini25Field1Envelope covers the
|
||||
// retired Gemini 2.5 envelope. It is no longer a known Gemini envelope, so the
|
||||
// Gemini fast-reject no longer fires for it and it falls to the residual class
|
||||
// like any other opaque payload. Recorded here so the change is deliberate rather
|
||||
// than an accident of the Gemini validator being narrowed.
|
||||
func TestInspectGrokEncryptedContent_RetiredGemini25Field1Envelope(t *testing.T) {
|
||||
sample := testGemini25Field1ThoughtSignatureEnvelope()
|
||||
if IsValidGeminiThoughtSignature(sample, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}) {
|
||||
t.Fatal("fixture should no longer be a known Gemini thoughtSignature")
|
||||
}
|
||||
if _, err := InspectGrokEncryptedContent(sample); err != nil {
|
||||
t.Fatalf("retired envelope should reach the residual transport check, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGrokEncryptedContent_RejectsClaudeThinkingSignature(t *testing.T) {
|
||||
sample := testUnpaddedClaudeThinkingSignature()
|
||||
if !IsValidClaudeThinkingSignature(sample, ClaudeSignatureValidationOptions{Strict: true}) {
|
||||
t.Fatal("fixture should be a strict Claude thinking signature")
|
||||
}
|
||||
|
||||
_, err := InspectGrokEncryptedContent(sample)
|
||||
if err == nil {
|
||||
t.Fatal("expected Claude thinking signature to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Claude") {
|
||||
t.Fatalf("error = %q, want Claude fast-reject detail", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGrokEncryptedContent_RejectsAntigravityClaudeThinkingSignature(t *testing.T) {
|
||||
sample := testUnpaddedAntigravityClaudeThinkingSignature()
|
||||
if !strings.HasPrefix(sample, "R") || strings.Contains(sample, "=") {
|
||||
t.Fatalf("fixture should be an unpadded R-form Claude signature, got prefix=%q has_padding=%t", sample[:1], strings.Contains(sample, "="))
|
||||
}
|
||||
if !IsValidClaudeThinkingSignature(sample, ClaudeSignatureValidationOptions{Strict: true}) {
|
||||
t.Fatal("fixture should be a strict Antigravity Claude thinking signature")
|
||||
}
|
||||
|
||||
_, err := InspectGrokEncryptedContent(sample)
|
||||
if err == nil {
|
||||
t.Fatal("expected Antigravity Claude thinking signature to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Claude") {
|
||||
t.Fatalf("error = %q, want Claude fast-reject detail", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspectGrokEncryptedContent_RejectsClaudeCAISSignature covers the CAIS
|
||||
// envelope emitted by the newest Claude Code models. CAIS payloads are
|
||||
// high-entropy standard base64 and drop their padding whenever the decoded
|
||||
// length is a multiple of 3, so neither the padding gate nor the classic Claude
|
||||
// strict check excludes them on their own.
|
||||
func TestInspectGrokEncryptedContent_RejectsClaudeCAISSignature(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sample string
|
||||
}{
|
||||
{name: "synthetic unpadded", sample: testUnpaddedClaudeCAISSignature()},
|
||||
{name: "observed fable-5", sample: observedFable5Sample},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if strings.Contains(tc.sample, "=") {
|
||||
t.Fatal("fixture must be unpadded so it reaches the Claude CAIS check")
|
||||
}
|
||||
if !IsValidClaudeCAISSignature(tc.sample) {
|
||||
t.Fatal("fixture should be a valid Claude CAIS signature")
|
||||
}
|
||||
if IsValidClaudeThinkingSignature(tc.sample, ClaudeSignatureValidationOptions{Strict: true}) {
|
||||
t.Fatal("CAIS fixture must not also pass classic Claude validation")
|
||||
}
|
||||
|
||||
_, err := InspectGrokEncryptedContent(tc.sample)
|
||||
if err == nil {
|
||||
t.Fatal("expected Claude CAIS signature to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CAIS") {
|
||||
t.Fatalf("error = %q, want Claude CAIS fast-reject detail", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspectGrokEncryptedContent_RejectsProviderCachePrefix keeps provenance
|
||||
// envelopes out of the residual class. A prefixed value belongs to whichever
|
||||
// provider the prefix names, and must never be replayed to xAI verbatim.
|
||||
func TestInspectGrokEncryptedContent_RejectsProviderCachePrefix(t *testing.T) {
|
||||
for _, prefix := range []string{"claude#", "anthropic#", "gemini#", "openai#", "codex#"} {
|
||||
sample := prefix + testUnpaddedClaudeCAISSignature()
|
||||
if _, err := InspectGrokEncryptedContent(sample); err == nil {
|
||||
t.Fatalf("%s prefixed payload should be rejected", prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspectGrokEncryptedContent_ThresholdMargins documents that neither
|
||||
// threshold sits on observed data. The shortest observed native payload is 50
|
||||
// decoded bytes and the lowest observed entropy ratio is 0.892, so both limits
|
||||
// keep headroom for future models rather than fitting the current corpus exactly.
|
||||
func TestInspectGrokEncryptedContent_ThresholdMargins(t *testing.T) {
|
||||
const shortestObservedDecodedLen = 50
|
||||
const lowestObservedEntropyRatio = 0.892
|
||||
|
||||
if MinGrokEncryptedContentDecodedLen >= shortestObservedDecodedLen {
|
||||
t.Fatalf("MinGrokEncryptedContentDecodedLen = %d, want below the shortest observed payload (%d) so a shorter future payload is not silently dropped",
|
||||
MinGrokEncryptedContentDecodedLen, shortestObservedDecodedLen)
|
||||
}
|
||||
if MinGrokEncryptedContentEntropyRatio >= lowestObservedEntropyRatio {
|
||||
t.Fatalf("MinGrokEncryptedContentEntropyRatio = %.3f, want below the lowest observed ratio (%.3f)",
|
||||
MinGrokEncryptedContentEntropyRatio, lowestObservedEntropyRatio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGrokEncryptedContent_RejectsForeignShapes(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"bad",
|
||||
" opaque",
|
||||
"gAAAAABinvalid-gpt-shape",
|
||||
"abcd_efg",
|
||||
base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0xa5}, MinGrokEncryptedContentDecodedLen)),
|
||||
}
|
||||
for _, sample := range cases {
|
||||
if _, err := InspectGrokEncryptedContent(sample); err == nil {
|
||||
t.Fatalf("expected invalid Grok encrypted_content, got pass for %q", sample)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGrokEncryptedContent_RejectsLowEntropyPayload(t *testing.T) {
|
||||
sample := base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{0xa5}, MinGrokEncryptedContentDecodedLen))
|
||||
|
||||
_, err := InspectGrokEncryptedContent(sample)
|
||||
if err == nil {
|
||||
t.Fatal("expected low-entropy payload to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "entropy ratio") {
|
||||
t.Fatalf("error = %q, want entropy ratio detail", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGrokEncryptedContent_RejectsInvalidBase64Length(t *testing.T) {
|
||||
_, err := InspectGrokEncryptedContent("AAAAA")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid base64 length to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "base64 decode failed") {
|
||||
t.Fatalf("error = %q, want base64 decode detail", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteEntropyRatio_SingleByteReturnsZero(t *testing.T) {
|
||||
if got := byteEntropyRatio([]byte{0xa5}); got != 0 {
|
||||
t.Fatalf("byteEntropyRatio(single byte) = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testGeminiThoughtSignatureEnvelope() string {
|
||||
payload := []byte{0x01, 0x0c}
|
||||
for i := 0; i < 97; i++ {
|
||||
payload = append(payload, byte(i))
|
||||
}
|
||||
inner := []byte{0x0a, byte(len(payload))}
|
||||
inner = append(inner, payload...)
|
||||
outer := []byte{0x12, byte(len(inner))}
|
||||
outer = append(outer, inner...)
|
||||
return base64.RawStdEncoding.EncodeToString(outer)
|
||||
}
|
||||
|
||||
func testGemini25Field1ThoughtSignatureEnvelope() string {
|
||||
payload := []byte{0x01}
|
||||
for i := 0; len(payload) < 128; i++ {
|
||||
payload = append(payload, byte((i*37+11)%251))
|
||||
}
|
||||
|
||||
var decoded []byte
|
||||
decoded = protowire.AppendTag(decoded, 1, protowire.BytesType)
|
||||
decoded = protowire.AppendBytes(decoded, payload)
|
||||
return base64.RawStdEncoding.EncodeToString(decoded)
|
||||
}
|
||||
|
||||
func testUnpaddedClaudeThinkingSignature() string {
|
||||
return testClaudeThinkingSignatureWithOpaqueLen(35)
|
||||
}
|
||||
|
||||
// testUnpaddedClaudeCAISSignature builds a CAIS signature whose base64 form
|
||||
// carries no "=" padding, which is the shape that used to slip past the Grok
|
||||
// unpadded-base64 gate. The model text length is varied because padding depends
|
||||
// on the encoded payload length.
|
||||
func testUnpaddedClaudeCAISSignature() string {
|
||||
for suffix := 0; suffix < 8; suffix++ {
|
||||
parts := defaultClaudeCAISParts("claude-opus-5" + strings.Repeat("x", suffix))
|
||||
if sample := parts.encode(); !strings.Contains(sample, "=") {
|
||||
return sample
|
||||
}
|
||||
}
|
||||
panic("could not build an unpadded Claude CAIS fixture")
|
||||
}
|
||||
|
||||
func testUnpaddedAntigravityClaudeThinkingSignature() string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(testClaudeThinkingSignatureWithOpaqueLen(41)))
|
||||
}
|
||||
|
||||
func testClaudeThinkingSignatureWithOpaqueLen(opaqueLen int) string {
|
||||
var channelBlock []byte
|
||||
channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
|
||||
channelBlock = protowire.AppendVarint(channelBlock, 12)
|
||||
channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType)
|
||||
channelBlock = protowire.AppendVarint(channelBlock, 2)
|
||||
channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
|
||||
channelBlock = protowire.AppendString(channelBlock, "claude-sonnet-4-6")
|
||||
|
||||
var container []byte
|
||||
container = protowire.AppendTag(container, 1, protowire.BytesType)
|
||||
container = protowire.AppendBytes(container, channelBlock)
|
||||
|
||||
var payload []byte
|
||||
payload = protowire.AppendTag(payload, 2, protowire.BytesType)
|
||||
payload = protowire.AppendBytes(payload, container)
|
||||
payload = protowire.AppendTag(payload, 3, protowire.VarintType)
|
||||
payload = protowire.AppendVarint(payload, 1)
|
||||
payload = protowire.AppendTag(payload, 4, protowire.BytesType)
|
||||
opaque := make([]byte, 0, opaqueLen)
|
||||
for i := 0; len(opaque) < opaqueLen; i++ {
|
||||
opaque = append(opaque, byte((i*41+17)%251))
|
||||
}
|
||||
payload = protowire.AppendBytes(payload, opaque)
|
||||
return base64.StdEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func grokEncryptedContentSamplesPath() (string, bool) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
repo := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
path := filepath.Join(repo, "docs", "native-prompt-capture", "corpus", "grok-encrypted-content", "samples.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return path, false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
func TestSignatureProviderFromModelName_Grok(t *testing.T) {
|
||||
for _, model := range []string{"grok-4.5", "grok-4.5-build", "grok-composer-2.5-fast", "grok-code-fast-1"} {
|
||||
t.Run(model, func(t *testing.T) {
|
||||
if got := SignatureProviderFromModelName(model); got != SignatureProviderGrok {
|
||||
t.Errorf("SignatureProviderFromModelName(%q) = %q, want %q", model, got, SignatureProviderGrok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectSignatureProvider_NeverClassifiesGrok pins the contract that xAI is
|
||||
// a target-only family. Its ciphertext carries no envelope, no version byte and
|
||||
// no fixed length, so a positive detection rule would necessarily also claim
|
||||
// unrelated opaque payloads. Callers establish an xAI target from provenance and
|
||||
// then use InspectGrokEncryptedContent as a replay-safety check.
|
||||
func TestDetectSignatureProvider_NeverClassifiesGrok(t *testing.T) {
|
||||
path, ok := grokEncryptedContentSamplesPath()
|
||||
if !ok {
|
||||
t.Skip("grok encrypted_content corpus missing; run docs/native-prompt-capture/scripts/harvest-grok-encrypted-content.sh")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read grok corpus: %v", err)
|
||||
}
|
||||
var samples []string
|
||||
if err := json.Unmarshal(raw, &samples); err != nil {
|
||||
var wrapped struct {
|
||||
Samples []string `json:"samples"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrapped); err != nil {
|
||||
t.Fatalf("parse grok corpus: %v", err)
|
||||
}
|
||||
samples = wrapped.Samples
|
||||
}
|
||||
if len(samples) == 0 {
|
||||
t.Skip("grok encrypted_content corpus is empty")
|
||||
}
|
||||
for _, sig := range samples {
|
||||
if got := DetectSignatureProvider(sig); got != SignatureProviderUnknown {
|
||||
t.Fatalf("DetectSignatureProvider = %q, want %q for native encrypted_content", got, SignatureProviderUnknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecideSignatureCompatibility_GrokDropsBlock contrasts with the Kimi
|
||||
// policy: xAI decrypts the blob and answers 400 for foreign or mutated input, so
|
||||
// an incompatible block cannot survive by shedding just its signature.
|
||||
func TestDecideSignatureCompatibility_GrokDropsBlock(t *testing.T) {
|
||||
decision := DecideSignatureCompatibility(SignatureProviderGrok, observedFable5Sample, SignatureBlockKindUnknown)
|
||||
if decision.Compatible {
|
||||
t.Fatalf("Claude signature reported compatible with a Grok target")
|
||||
}
|
||||
if decision.Action != SignatureActionDropBlock {
|
||||
t.Errorf("Action = %q, want %q", decision.Action, SignatureActionDropBlock)
|
||||
}
|
||||
}
|
||||
161
backend/internal/signature/kimi_validation.go
Normal file
161
backend/internal/signature/kimi_validation.go
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Kimi thinking signatures carry no self-describing envelope. Every byte is
|
||||
// indistinguishable from uniform random data: a per-offset scan over 44 samples
|
||||
// x 9709 bytes found zero positions below a 4-sigma floor, so there is no magic
|
||||
// prefix, version byte, key id or timestamp to anchor on the way GPT (Fernet),
|
||||
// Claude (CAIS/protobuf) and Gemini (Tink envelope) all provide.
|
||||
//
|
||||
// What Kimi does expose is size. The raw signature length is fixed per protocol
|
||||
// mode and completely independent of the content it accompanies:
|
||||
//
|
||||
// non-streaming : 12946 characters (9709 bytes)
|
||||
// streaming : 4340 characters (3255 bytes)
|
||||
//
|
||||
// This is not quantization of a variable payload into buckets. There is no
|
||||
// bucketing behaviour at all: a response whose thinking text grew from 6 to
|
||||
// 14,803 characters (thinking_tokens 1 -> 6188, output_tokens 29 -> 2709) emits
|
||||
// a byte-identical signature length, and a non-streaming response carrying a
|
||||
// single thinking token still emits the full 12946. The two values are code-path
|
||||
// constants, not size classes.
|
||||
//
|
||||
// Empirical basis for treating the pair as complete:
|
||||
// - All 8 Kimi models exposed upstream (k2, k2.5, k2.6, k2-thinking, k2.7-code,
|
||||
// k2.7-code-highspeed, k3, k3-256k) x streaming/non-streaming = 16 combinations,
|
||||
// no exceptions.
|
||||
// - Additional paths that produced no third value: thinking budget 128..12000,
|
||||
// absent thinking field, max_tokens truncation mid-thinking, non-English
|
||||
// prompts, 135k-character inputs, multi-turn replay of signed history, tool
|
||||
// calls, tool_result continuation, and the interleaved-thinking beta header.
|
||||
// - Two independent collection paths agree: CPA request logs (57 unique samples)
|
||||
// and the mitmproxy harvest in
|
||||
// .agents/skills/cpa-signature-catalog-and-collection/data/signatures/kimi/
|
||||
// (61 unique samples) both yield exactly {4340, 12946}.
|
||||
//
|
||||
// Cross-family safety: across 1027 catalog signatures plus 215 native Grok
|
||||
// samples, no Claude, Gemini, GPT or Grok value lands on either length. The
|
||||
// nearest miss is a 4344-character GPT token, which the gAAAA probe claims long
|
||||
// before this check runs.
|
||||
//
|
||||
// Fragility this check accepts, and why it still runs last: the length pair is
|
||||
// an observed regularity, not a protocol contract. Kimi never reads the field
|
||||
// back - replaying an empty string, a single character, non-base64 text or a
|
||||
// mutated blob all return 200, and omitting the signature entirely also returns
|
||||
// 200, because reasoning continuity on that endpoint travels in OpenAI-style
|
||||
// reasoning_content instead. A gateway change could therefore move these values
|
||||
// without any client-visible error. Running the self-describing validators first
|
||||
// bounds the damage: a drift only costs Kimi its own identification and cannot
|
||||
// mislabel another provider's signature.
|
||||
const (
|
||||
// KimiThinkingSignatureNonStreamingLen is the raw character length Kimi emits
|
||||
// for non-streaming Messages responses.
|
||||
KimiThinkingSignatureNonStreamingLen = 12946
|
||||
// KimiThinkingSignatureStreamingLen is the raw character length Kimi emits in
|
||||
// the streaming signature_delta event.
|
||||
KimiThinkingSignatureStreamingLen = 4340
|
||||
)
|
||||
|
||||
// KimiThinkingSignatureMode records which upstream code path produced a
|
||||
// signature. It is derived from length alone and carries no decoded content.
|
||||
type KimiThinkingSignatureMode string
|
||||
|
||||
const (
|
||||
KimiThinkingSignatureModeNonStreaming KimiThinkingSignatureMode = "non_streaming"
|
||||
KimiThinkingSignatureModeStreaming KimiThinkingSignatureMode = "streaming"
|
||||
)
|
||||
|
||||
// kimiThinkingSignatureLens maps every accepted raw length to the mode that
|
||||
// produces it. Keeping this as a package-level map rather than inline constants
|
||||
// leaves room for a calibration pass to register a newly observed length without
|
||||
// touching the probe itself.
|
||||
var kimiThinkingSignatureLens = map[int]KimiThinkingSignatureMode{
|
||||
KimiThinkingSignatureNonStreamingLen: KimiThinkingSignatureModeNonStreaming,
|
||||
KimiThinkingSignatureStreamingLen: KimiThinkingSignatureModeStreaming,
|
||||
}
|
||||
|
||||
// MinKimiThinkingSignatureEntropyRatio keeps a same-length attacker-supplied
|
||||
// filler from claiming the family. Native samples sit at 0.997+ against the
|
||||
// sample-size ceiling, so this floor has multiple sigma of headroom while still
|
||||
// rejecting padded or repetitive input.
|
||||
const MinKimiThinkingSignatureEntropyRatio = 0.85
|
||||
|
||||
// KimiThinkingSignatureInfo describes an accepted Kimi thinking signature.
|
||||
type KimiThinkingSignatureInfo struct {
|
||||
RawLen int
|
||||
DecodedLen int
|
||||
Mode KimiThinkingSignatureMode
|
||||
}
|
||||
|
||||
// InspectKimiThinkingSignature validates the transport shape of a Kimi Messages
|
||||
// thinking signature.
|
||||
//
|
||||
// Unlike the Claude, Gemini and GPT validators this proves nothing about the
|
||||
// payload: it reports that the value has the size and character class Kimi
|
||||
// produces. Because size is the only available signal, this probe must run after
|
||||
// every self-describing envelope check has declined, so that a Claude, Gemini or
|
||||
// GPT signature can never be captured by a length coincidence.
|
||||
func InspectKimiThinkingSignature(raw string) (*KimiThinkingSignatureInfo, error) {
|
||||
sig := strings.TrimSpace(raw)
|
||||
if sig == "" {
|
||||
return nil, fmt.Errorf("empty Kimi thinking signature")
|
||||
}
|
||||
if sig != raw {
|
||||
return nil, fmt.Errorf("Kimi thinking signature has leading or trailing whitespace")
|
||||
}
|
||||
mode, ok := kimiThinkingSignatureLens[len(sig)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid Kimi thinking signature: unexpected length %d", len(sig))
|
||||
}
|
||||
if strings.Contains(sig, "=") {
|
||||
return nil, fmt.Errorf("invalid Kimi thinking signature: expected unpadded standard base64")
|
||||
}
|
||||
if index, r, ok := firstInvalidGrokEncryptedContentChar(sig); ok {
|
||||
return nil, fmt.Errorf("invalid Kimi thinking signature: contains non-base64 character U+%04X at byte %d", r, index)
|
||||
}
|
||||
if _, _, ok := SplitSignatureProviderPrefix(sig); ok {
|
||||
return nil, fmt.Errorf("invalid Kimi thinking signature: carries another provider's cache prefix")
|
||||
}
|
||||
// Defense in depth. DetectSignatureProviderForBlock already runs the
|
||||
// self-describing probes first, but this validator is exported and callers
|
||||
// may reach it directly, so a foreign envelope of coincidentally matching
|
||||
// length must not be accepted here either.
|
||||
if maybeSelfDescribingSignatureEnvelope(sig) {
|
||||
if strings.HasPrefix(sig, "gAAAA") {
|
||||
return nil, fmt.Errorf("Kimi thinking signature looks like GPT/Codex reasoning signature")
|
||||
}
|
||||
if IsValidClaudeCAISSignature(sig) {
|
||||
return nil, fmt.Errorf("Kimi thinking signature looks like Claude CAIS thinking signature")
|
||||
}
|
||||
if IsValidClaudeThinkingSignature(sig, ClaudeSignatureValidationOptions{Strict: true}) {
|
||||
return nil, fmt.Errorf("Kimi thinking signature looks like Claude thinking signature")
|
||||
}
|
||||
if IsValidGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}) {
|
||||
return nil, fmt.Errorf("Kimi thinking signature looks like Gemini thoughtSignature")
|
||||
}
|
||||
}
|
||||
decoded, err := base64.RawStdEncoding.DecodeString(sig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid Kimi thinking signature: base64 decode failed: %w", err)
|
||||
}
|
||||
if entropyRatio := byteEntropyRatio(decoded); entropyRatio < MinKimiThinkingSignatureEntropyRatio {
|
||||
return nil, fmt.Errorf("invalid Kimi thinking signature: decoded payload entropy ratio %.3f below %.3f", entropyRatio, MinKimiThinkingSignatureEntropyRatio)
|
||||
}
|
||||
return &KimiThinkingSignatureInfo{
|
||||
RawLen: len(sig),
|
||||
DecodedLen: len(decoded),
|
||||
Mode: mode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsValidKimiThinkingSignature reports whether raw has the transport shape of a
|
||||
// Kimi thinking signature.
|
||||
func IsValidKimiThinkingSignature(raw string) bool {
|
||||
_, err := InspectKimiThinkingSignature(raw)
|
||||
return err == nil
|
||||
}
|
||||
370
backend/internal/signature/kimi_validation_test.go
Normal file
370
backend/internal/signature/kimi_validation_test.go
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// kimiSignatureCorpusPath locates the harvested Kimi signature corpus. The
|
||||
// corpus lives with the collection skill that produced it and is not tracked in
|
||||
// this repository, matching how the Grok and Gemini native corpora are handled:
|
||||
// tests that need real traffic skip when it is absent rather than committing
|
||||
// captured payloads.
|
||||
func kimiSignatureCorpusPath() (string, bool) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
repo := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
path := filepath.Join(repo, ".agents", "skills", "cpa-signature-catalog-and-collection", "data", "signatures", "kimi", "samples.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return path, false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
// masterSignatureCatalogPath locates the cross-provider signature catalog from
|
||||
// the same collection skill.
|
||||
func masterSignatureCatalogPath() (string, bool) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
repo := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
path := filepath.Join(repo, ".agents", "skills", "cpa-signature-catalog-and-collection", "data", "master_signatures_catalog.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return path, false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
const kimiCorpusSkipReason = "kimi signature corpus missing; see .agents/skills/cpa-signature-catalog-and-collection"
|
||||
|
||||
func loadKimiCorpus(t *testing.T) []string {
|
||||
t.Helper()
|
||||
path, ok := kimiSignatureCorpusPath()
|
||||
if !ok {
|
||||
t.Skip(kimiCorpusSkipReason)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read kimi corpus: %v", err)
|
||||
}
|
||||
var doc struct {
|
||||
Samples []struct {
|
||||
Signature string `json:"signature"`
|
||||
} `json:"samples"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("parse kimi corpus: %v", err)
|
||||
}
|
||||
out := make([]string, 0, len(doc.Samples))
|
||||
for _, sample := range doc.Samples {
|
||||
if sample.Signature != "" {
|
||||
out = append(out, sample.Signature)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Skip(kimiCorpusSkipReason)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// synthesizeKimiSignature builds a signature-shaped payload of the requested
|
||||
// decoded size from a seeded PRNG. Kimi identification rests entirely on raw
|
||||
// length plus the payload being high-entropy unpadded base64, and none of that
|
||||
// requires captured traffic, so the contract tests below run everywhere instead
|
||||
// of depending on a local corpus.
|
||||
func synthesizeKimiSignature(t *testing.T, decodedLen int, seed int64) string {
|
||||
t.Helper()
|
||||
buf := make([]byte, decodedLen)
|
||||
prng := rand.New(rand.NewSource(seed))
|
||||
if _, err := prng.Read(buf); err != nil {
|
||||
t.Fatalf("synthesize payload: %v", err)
|
||||
}
|
||||
return base64.RawStdEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// TestKimiThinkingSignatureLengths_MatchDecodedSizes pins the arithmetic that
|
||||
// makes the two constants reachable at all: unpadded base64 of 9709 and 3255
|
||||
// bytes is exactly 12946 and 4340 characters. A future edit that changes one
|
||||
// constant without the other would otherwise produce a length no real payload
|
||||
// can have.
|
||||
func TestKimiThinkingSignatureLengths_MatchDecodedSizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
decodedLen int
|
||||
wantRawLen int
|
||||
}{
|
||||
{"non streaming", 9709, KimiThinkingSignatureNonStreamingLen},
|
||||
{"streaming", 3255, KimiThinkingSignatureStreamingLen},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sig := synthesizeKimiSignature(t, tc.decodedLen, 1)
|
||||
if len(sig) != tc.wantRawLen {
|
||||
t.Fatalf("raw length = %d, want %d", len(sig), tc.wantRawLen)
|
||||
}
|
||||
info, err := InspectKimiThinkingSignature(sig)
|
||||
if err != nil {
|
||||
t.Fatalf("synthesized payload rejected: %v", err)
|
||||
}
|
||||
if info.DecodedLen != tc.decodedLen {
|
||||
t.Errorf("DecodedLen = %d, want %d", info.DecodedLen, tc.decodedLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectKimiThinkingSignature_ReportsMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
decodedLen int
|
||||
wantMode KimiThinkingSignatureMode
|
||||
}{
|
||||
{"non streaming", 9709, KimiThinkingSignatureModeNonStreaming},
|
||||
{"streaming", 3255, KimiThinkingSignatureModeStreaming},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
info, err := InspectKimiThinkingSignature(synthesizeKimiSignature(t, tc.decodedLen, 7))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if info.Mode != tc.wantMode {
|
||||
t.Errorf("Mode = %q, want %q", info.Mode, tc.wantMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspectKimiThinkingSignature_RejectsNeighbouringLengths is the core
|
||||
// negative test for a size-only probe: one character in either direction must
|
||||
// fall out of the family.
|
||||
func TestInspectKimiThinkingSignature_RejectsNeighbouringLengths(t *testing.T) {
|
||||
for _, decodedLen := range []int{9709, 3255} {
|
||||
native := synthesizeKimiSignature(t, decodedLen, 3)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sig string
|
||||
}{
|
||||
{"one character short", native[:len(native)-1]},
|
||||
{"one character long", native + "A"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if IsValidKimiThinkingSignature(tc.sig) {
|
||||
t.Errorf("length %d accepted as Kimi signature", len(tc.sig))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectKimiThinkingSignature_RejectsMalformedInput(t *testing.T) {
|
||||
native := synthesizeKimiSignature(t, 3255, 5)
|
||||
tests := []struct {
|
||||
name string
|
||||
sig string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"whitespace only", " "},
|
||||
{"leading whitespace", " " + native},
|
||||
{"trailing whitespace", native + " "},
|
||||
{"padded base64", native[:len(native)-2] + "=="},
|
||||
{"non base64 character", native[:len(native)-1] + "!"},
|
||||
{"provider cache prefix", "claude#" + native},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if IsValidKimiThinkingSignature(tc.sig) {
|
||||
t.Errorf("malformed input accepted as Kimi signature")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspectKimiThinkingSignature_RejectsLowEntropyFiller pins the one attack a
|
||||
// length check alone cannot survive: a caller that knows the constant and pads
|
||||
// to it with structured bytes.
|
||||
func TestInspectKimiThinkingSignature_RejectsLowEntropyFiller(t *testing.T) {
|
||||
for _, length := range []int{KimiThinkingSignatureStreamingLen, KimiThinkingSignatureNonStreamingLen} {
|
||||
if IsValidKimiThinkingSignature(strings.Repeat("A", length)) {
|
||||
t.Errorf("repeated-character filler of length %d accepted as Kimi signature", length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspectKimiThinkingSignature_RejectsSelfDescribingEnvelope guards the
|
||||
// exported entry point. DetectSignatureProviderForBlock already runs the
|
||||
// envelope probes first, but callers can reach this validator directly, so a
|
||||
// foreign envelope must not be accepted here on length alone.
|
||||
func TestInspectKimiThinkingSignature_RejectsSelfDescribingEnvelope(t *testing.T) {
|
||||
if IsValidKimiThinkingSignature(observedFable5Sample) {
|
||||
t.Errorf("Claude CAIS sample accepted as Kimi signature")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectSignatureProvider_KimiRunsAfterEnvelopeProbes pins the ordering
|
||||
// invariant. Kimi's base64 is uniformly distributed, so roughly 6% of real
|
||||
// signatures start with one of the "CERg" envelope characters; those must still
|
||||
// resolve to Kimi after the envelope probes decline, and a real envelope must
|
||||
// never be captured by the size probe.
|
||||
func TestDetectSignatureProvider_KimiRunsAfterEnvelopeProbes(t *testing.T) {
|
||||
if got := DetectSignatureProvider(observedFable5Sample); got != SignatureProviderClaude {
|
||||
t.Fatalf("DetectSignatureProvider = %q, want %q for a Claude CAIS sample", got, SignatureProviderClaude)
|
||||
}
|
||||
|
||||
var checked int
|
||||
for seed := int64(0); seed < 200 && checked < 3; seed++ {
|
||||
sig := synthesizeKimiSignature(t, 3255, seed)
|
||||
if !maybeSelfDescribingSignatureEnvelope(sig) {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
if got := DetectSignatureProvider(sig); got != SignatureProviderKimi {
|
||||
t.Fatalf("DetectSignatureProvider = %q, want %q for an envelope-prefixed Kimi payload", got, SignatureProviderKimi)
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Skip("no synthesized payload landed on an envelope first character")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectGrokEncryptedContent_RejectsKimiLengths(t *testing.T) {
|
||||
for _, decodedLen := range []int{9709, 3255} {
|
||||
sig := synthesizeKimiSignature(t, decodedLen, 11)
|
||||
if IsValidGrokEncryptedContent(sig) {
|
||||
t.Errorf("Kimi-length payload (%d bytes) accepted as Grok encrypted_content", decodedLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureProviderFromModelName_Kimi(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want SignatureProvider
|
||||
}{
|
||||
{"kimi-k3", SignatureProviderKimi},
|
||||
{"kimi-k3-256k", SignatureProviderKimi},
|
||||
{"kimi-k2.7-code-highspeed", SignatureProviderKimi},
|
||||
{"k3", SignatureProviderKimi},
|
||||
{"k2-thinking", SignatureProviderKimi},
|
||||
{"moonshot-v1-128k", SignatureProviderKimi},
|
||||
{"claude-opus-5", SignatureProviderClaude},
|
||||
{"gemini-3.6-flash", SignatureProviderGemini},
|
||||
{"gpt-5.6-sol", SignatureProviderGPT},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.model, func(t *testing.T) {
|
||||
if got := SignatureProviderFromModelName(tc.model); got != tc.want {
|
||||
t.Errorf("SignatureProviderFromModelName(%q) = %q, want %q", tc.model, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecideSignatureCompatibility_KimiDropsSignatureNotBlock encodes the
|
||||
// measured upstream behaviour: Kimi returns 200 for a mutated, truncated,
|
||||
// non-base64 or entirely absent thinking signature, so a foreign signature costs
|
||||
// the field rather than the reasoning text.
|
||||
func TestDecideSignatureCompatibility_KimiDropsSignatureNotBlock(t *testing.T) {
|
||||
decision := DecideSignatureCompatibility(SignatureProviderKimi, observedFable5Sample, SignatureBlockKindClaudeThinking)
|
||||
if decision.Compatible {
|
||||
t.Fatalf("Claude signature reported compatible with a Kimi target")
|
||||
}
|
||||
if decision.Action != SignatureActionDropSignature {
|
||||
t.Errorf("Action = %q, want %q", decision.Action, SignatureActionDropSignature)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideSignatureCompatibility_KimiPreservesNativeSignature(t *testing.T) {
|
||||
native := synthesizeKimiSignature(t, 9709, 13)
|
||||
decision := DecideSignatureCompatibility(SignatureProviderKimi, native, SignatureBlockKindClaudeThinking)
|
||||
if !decision.Compatible {
|
||||
t.Fatalf("Kimi-shaped signature reported incompatible with a Kimi target: %s", decision.Reason)
|
||||
}
|
||||
if decision.Action != SignatureActionPreserve {
|
||||
t.Errorf("Action = %q, want %q", decision.Action, SignatureActionPreserve)
|
||||
}
|
||||
if decision.NormalizedSignature != native {
|
||||
t.Errorf("NormalizedSignature was rewritten for a Kimi signature")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspectKimiThinkingSignature_NativeCorpus validates the synthesized
|
||||
// contract above against real harvested traffic when the corpus is available.
|
||||
func TestInspectKimiThinkingSignature_NativeCorpus(t *testing.T) {
|
||||
modes := map[KimiThinkingSignatureMode]int{}
|
||||
for _, sig := range loadKimiCorpus(t) {
|
||||
info, err := InspectKimiThinkingSignature(sig)
|
||||
if err != nil {
|
||||
t.Fatalf("native Kimi signature (len %d) rejected: %v", len(sig), err)
|
||||
}
|
||||
if got := DetectSignatureProvider(sig); got != SignatureProviderKimi {
|
||||
t.Fatalf("DetectSignatureProvider = %q, want %q", got, SignatureProviderKimi)
|
||||
}
|
||||
modes[info.Mode]++
|
||||
}
|
||||
if modes[KimiThinkingSignatureModeNonStreaming] == 0 || modes[KimiThinkingSignatureModeStreaming] == 0 {
|
||||
t.Fatalf("corpus does not cover both modes: %v", modes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectSignatureProvider_KimiProbeDoesNotDisturbCatalog replays the whole
|
||||
// cross-provider catalog to prove the size probe changed nothing for the
|
||||
// self-describing families and never claims a Grok payload.
|
||||
func TestDetectSignatureProvider_KimiProbeDoesNotDisturbCatalog(t *testing.T) {
|
||||
path, ok := masterSignatureCatalogPath()
|
||||
if !ok {
|
||||
t.Skip("signature catalog missing; see .agents/skills/cpa-signature-catalog-and-collection")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read signature catalog: %v", err)
|
||||
}
|
||||
var doc struct {
|
||||
Records []struct {
|
||||
FullSignature string `json:"full_signature"`
|
||||
ClaimedProvider string `json:"claimed_provider"`
|
||||
} `json:"records"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("parse signature catalog: %v", err)
|
||||
}
|
||||
|
||||
matrix := map[string]map[SignatureProvider]int{}
|
||||
for _, record := range doc.Records {
|
||||
if record.FullSignature == "" {
|
||||
continue
|
||||
}
|
||||
detected := DetectSignatureProvider(record.FullSignature)
|
||||
if matrix[record.ClaimedProvider] == nil {
|
||||
matrix[record.ClaimedProvider] = map[SignatureProvider]int{}
|
||||
}
|
||||
matrix[record.ClaimedProvider][detected]++
|
||||
}
|
||||
if len(matrix) == 0 {
|
||||
t.Skip("signature catalog has no usable records")
|
||||
}
|
||||
|
||||
for claimed, row := range matrix {
|
||||
t.Logf("%-8s -> %v", claimed, row)
|
||||
if captured := row[SignatureProviderKimi]; captured > 0 {
|
||||
t.Errorf("%d %s signatures captured by the Kimi size probe", captured, claimed)
|
||||
}
|
||||
}
|
||||
// xAI stays in the residual class by contract: its ciphertext carries no
|
||||
// envelope and no fixed length, so any positive claim would also capture
|
||||
// unrelated opaque payloads.
|
||||
for detected, count := range matrix["grok"] {
|
||||
if detected != SignatureProviderUnknown {
|
||||
t.Errorf("%d grok signatures classified as %q, want %q", count, detected, SignatureProviderUnknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
468
backend/internal/signature/provider_compatibility.go
Normal file
468
backend/internal/signature/provider_compatibility.go
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
package signature
|
||||
|
||||
import "strings"
|
||||
|
||||
type SignatureProvider string
|
||||
|
||||
const (
|
||||
SignatureProviderUnknown SignatureProvider = "unknown"
|
||||
SignatureProviderClaude SignatureProvider = "claude"
|
||||
SignatureProviderGemini SignatureProvider = "gemini"
|
||||
SignatureProviderGeminiBypass SignatureProvider = "gemini_bypass"
|
||||
SignatureProviderGPT SignatureProvider = "gpt"
|
||||
// SignatureProviderKimi is identified by fixed signature size rather than by
|
||||
// an envelope. See kimi_validation.go for the empirical basis and its limits.
|
||||
SignatureProviderKimi SignatureProvider = "kimi"
|
||||
// SignatureProviderGrok is a target-only family. DetectSignatureProvider never
|
||||
// returns it: xAI emits no envelope, no version byte and no fixed length, and
|
||||
// its ciphertext is statistically indistinguishable from uniform random bytes,
|
||||
// so any positive claim would also capture every other opaque blob. Grok
|
||||
// handling is provenance-first - establish the target from the model or route,
|
||||
// then use InspectGrokEncryptedContent as a replay-safety shape check.
|
||||
SignatureProviderGrok SignatureProvider = "grok"
|
||||
)
|
||||
|
||||
type SignatureBlockKind string
|
||||
|
||||
const (
|
||||
SignatureBlockKindUnknown SignatureBlockKind = "unknown"
|
||||
SignatureBlockKindClaudeThinking SignatureBlockKind = "claude_thinking"
|
||||
SignatureBlockKindGeminiModelPart SignatureBlockKind = "gemini_model_part"
|
||||
SignatureBlockKindGeminiFunctionCall SignatureBlockKind = "gemini_function_call"
|
||||
SignatureBlockKindGPTReasoning SignatureBlockKind = "gpt_reasoning"
|
||||
)
|
||||
|
||||
type SignatureCompatibilityAction string
|
||||
|
||||
const (
|
||||
SignatureActionPreserve SignatureCompatibilityAction = "preserve"
|
||||
SignatureActionDropBlock SignatureCompatibilityAction = "drop_block"
|
||||
SignatureActionDropSignature SignatureCompatibilityAction = "drop_signature"
|
||||
SignatureActionReplaceWithGeminiBypass SignatureCompatibilityAction = "replace_with_gemini_bypass"
|
||||
SignatureActionNoCompatibleReplacement SignatureCompatibilityAction = "no_compatible_replacement"
|
||||
)
|
||||
|
||||
type SignatureCompatibilityDecision struct {
|
||||
TargetProvider SignatureProvider
|
||||
DetectedProvider SignatureProvider
|
||||
BlockKind SignatureBlockKind
|
||||
Compatible bool
|
||||
Action SignatureCompatibilityAction
|
||||
ReplacementSignature string
|
||||
NormalizedSignature string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// SignatureProviderFromModelName maps common model names to the provider family
|
||||
// whose signed history can be safely replayed for that model.
|
||||
func SignatureProviderFromModelName(modelName string) SignatureProvider {
|
||||
lower := strings.ToLower(strings.TrimSpace(modelName))
|
||||
switch {
|
||||
case strings.Contains(lower, "claude"):
|
||||
return SignatureProviderClaude
|
||||
case strings.Contains(lower, "gemini"):
|
||||
return SignatureProviderGemini
|
||||
case strings.Contains(lower, "gpt"),
|
||||
strings.Contains(lower, "openai"),
|
||||
strings.Contains(lower, "codex"),
|
||||
strings.HasPrefix(lower, "o1"),
|
||||
strings.HasPrefix(lower, "o3"),
|
||||
strings.HasPrefix(lower, "o4"):
|
||||
return SignatureProviderGPT
|
||||
case strings.Contains(lower, "kimi"),
|
||||
strings.Contains(lower, "moonshot"),
|
||||
strings.HasPrefix(lower, "k2"),
|
||||
strings.HasPrefix(lower, "k3"):
|
||||
return SignatureProviderKimi
|
||||
case strings.Contains(lower, "grok"):
|
||||
return SignatureProviderGrok
|
||||
default:
|
||||
return SignatureProviderUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// selfDescribingSignatureFirstChars are the base64 first characters that a
|
||||
// self-describing provider envelope can produce. A base64 first character is
|
||||
// exactly the first payload byte shifted right by two, so a single character
|
||||
// comparison rules out every known envelope without decoding anything:
|
||||
//
|
||||
// 'C' -> 0x08..0x0b : Claude CAIS (0x08)
|
||||
// 'E' -> 0x10..0x13 : Claude single-layer (0x12), Gemini protobuf_field_2 (0x12)
|
||||
// 'R' -> 0x44..0x47 : Claude double-layer R (0x45, inner 'E')
|
||||
// 'g' -> 0x80..0x83 : GPT Fernet reasoning (0x80)
|
||||
//
|
||||
// Gemini's ascii_uuid envelope is deliberately absent. Its first byte is the
|
||||
// first hex character of the UUID, which spreads over 'M', 'N', 'O', 'Y' and 'Z'
|
||||
// depending on the value, and it is never a replay-safe envelope: it resolves to
|
||||
// SignatureProviderUnknown whether or not it reaches the validators, and Gemini
|
||||
// model parts recover it through the bypass sentinel keyed on block kind. Listing
|
||||
// one of its five possible characters would only look like coverage.
|
||||
//
|
||||
// Any provider added here must also be validated in
|
||||
// DetectSignatureProviderForBlock, otherwise its signatures would fall through
|
||||
// to the residual class. TestSelfDescribingSignatureFirstChars_CoversEveryKnownEnvelope
|
||||
// fails when a replay-safe envelope is missing from this set.
|
||||
const selfDescribingSignatureFirstChars = "CERg"
|
||||
|
||||
// base64AlphabetSet builds a byte lookup table for the alphanumeric base64 core
|
||||
// plus the alphabet-specific characters in extra. Signature charset validation
|
||||
// runs over multi-kilobyte payloads, and a comparison chain over base64 text
|
||||
// mispredicts on nearly every byte because the characters are effectively random;
|
||||
// a single table load is branch-free and measures about an order of magnitude
|
||||
// faster on the observed corpora.
|
||||
func base64AlphabetSet(extra string) [256]bool {
|
||||
var set [256]bool
|
||||
for c := byte('A'); c <= 'Z'; c++ {
|
||||
set[c] = true
|
||||
}
|
||||
for c := byte('a'); c <= 'z'; c++ {
|
||||
set[c] = true
|
||||
}
|
||||
for c := byte('0'); c <= '9'; c++ {
|
||||
set[c] = true
|
||||
}
|
||||
for i := 0; i < len(extra); i++ {
|
||||
set[extra[i]] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// maybeSelfDescribingSignatureEnvelope reports whether rawSignature can possibly
|
||||
// be a self-describing provider envelope. It is a structural pre-filter, not a
|
||||
// classifier: a false result is conclusive, a true result only narrows the
|
||||
// candidate set. Opaque ciphertext that carries no envelope (xAI/Grok
|
||||
// encrypted_content) is uniformly distributed over the byte space, so this
|
||||
// rejects roughly 92% of it with one comparison and no allocation.
|
||||
func maybeSelfDescribingSignatureEnvelope(rawSignature string) bool {
|
||||
if rawSignature == "" {
|
||||
return false
|
||||
}
|
||||
return strings.IndexByte(selfDescribingSignatureFirstChars, rawSignature[0]) >= 0
|
||||
}
|
||||
|
||||
// DetectSignatureProvider classifies the provider family that can replay
|
||||
// rawSignature. It intentionally uses Claude strict validation before Gemini
|
||||
// detection because Gemini 3 signatures also decode from an E-prefixed base64
|
||||
// string and can look Claude-like under shallow prefix checks.
|
||||
func DetectSignatureProvider(rawSignature string) SignatureProvider {
|
||||
return DetectSignatureProviderForBlock(rawSignature, SignatureBlockKindUnknown)
|
||||
}
|
||||
|
||||
// DetectSignatureProviderForBlock classifies rawSignature with block-kind
|
||||
// context. UUID-shaped payloads are deliberately not classified as replay-safe
|
||||
// provider signatures; callers targeting Gemini should replace them with the
|
||||
// bypass sentinel.
|
||||
func DetectSignatureProviderForBlock(rawSignature string, blockKind SignatureBlockKind) SignatureProvider {
|
||||
sig := strings.TrimSpace(rawSignature)
|
||||
if sig == "" {
|
||||
return SignatureProviderUnknown
|
||||
}
|
||||
|
||||
if prefixedProvider, unprefixed, ok := SplitSignatureProviderPrefix(sig); ok {
|
||||
switch prefixedProvider {
|
||||
case SignatureProviderGemini:
|
||||
if IsGeminiThoughtSignatureBypass(unprefixed) {
|
||||
return SignatureProviderGeminiBypass
|
||||
}
|
||||
if isRecognizedGeminiProviderSignature(unprefixed, blockKind) {
|
||||
return SignatureProviderGemini
|
||||
}
|
||||
case SignatureProviderClaude:
|
||||
if IsValidClaudeThinkingSignature(unprefixed, ClaudeSignatureValidationOptions{Strict: true}) || IsValidClaudeCAISSignature(unprefixed) {
|
||||
return SignatureProviderClaude
|
||||
}
|
||||
case SignatureProviderGPT:
|
||||
if IsValidGPTReasoningSignature(unprefixed) {
|
||||
return SignatureProviderGPT
|
||||
}
|
||||
}
|
||||
return SignatureProviderUnknown
|
||||
}
|
||||
if strings.Contains(sig, "#") {
|
||||
return SignatureProviderUnknown
|
||||
}
|
||||
|
||||
// The bypass sentinel is a plain literal rather than an envelope, so it must
|
||||
// be matched before the structural pre-filter below rejects it.
|
||||
if IsGeminiThoughtSignatureBypass(sig) {
|
||||
return SignatureProviderGeminiBypass
|
||||
}
|
||||
// Probes run from the strongest marker to the weakest:
|
||||
// 1. GPT carries the literal "gAAAA" prefix, which pins both the version
|
||||
// byte and the high timestamp bytes.
|
||||
// 2. Claude CAIS carries marker 0x08 plus a literal "claude-" model text.
|
||||
// 3. Claude single/double-layer carries marker 0x12 plus the same literal.
|
||||
// 4. Gemini validates wire shape only and has no literal to anchor on, so
|
||||
// it is the weakest judge and goes last.
|
||||
//
|
||||
// This ordering is defense in depth rather than a correctness requirement:
|
||||
// Claude envelopes carry extra top-level fields beyond the container, which
|
||||
// fails the single-record shape Gemini requires, so the two families stay
|
||||
// separable in either order. TestGeminiEnvelopeNeverClaimsClaudeSignatures
|
||||
// pins that invariant so a looser Gemini envelope check cannot make the
|
||||
// order silently start mattering.
|
||||
//
|
||||
// The envelope pre-filter gates only the envelope probes. A blob that cannot
|
||||
// be an envelope skips straight to the size probe below rather than returning
|
||||
// early, because Kimi's uniformly distributed base64 starts with one of
|
||||
// "CERg" about 6% of the time and would otherwise be dropped by whichever
|
||||
// side of the gate it happened to land on.
|
||||
if maybeSelfDescribingSignatureEnvelope(sig) {
|
||||
if IsValidGPTReasoningSignature(sig) {
|
||||
return SignatureProviderGPT
|
||||
}
|
||||
if IsValidClaudeCAISSignature(sig) {
|
||||
return SignatureProviderClaude
|
||||
}
|
||||
if IsValidClaudeThinkingSignature(sig, ClaudeSignatureValidationOptions{Strict: true}) {
|
||||
return SignatureProviderClaude
|
||||
}
|
||||
if isRecognizedGeminiProviderSignature(sig, blockKind) {
|
||||
return SignatureProviderGemini
|
||||
}
|
||||
}
|
||||
// Kimi carries no envelope, so it can only be claimed once every
|
||||
// self-describing probe above has declined. Ordering it last means a length
|
||||
// coincidence can never capture another provider's signature, and a future
|
||||
// drift in Kimi's sizes costs Kimi its own identification rather than
|
||||
// corrupting a neighbouring family.
|
||||
if IsValidKimiThinkingSignature(sig) {
|
||||
return SignatureProviderKimi
|
||||
}
|
||||
return SignatureProviderUnknown
|
||||
}
|
||||
|
||||
func IsSignatureCompatibleWithProvider(targetProvider SignatureProvider, rawSignature string) bool {
|
||||
decision := DecideSignatureCompatibility(targetProvider, rawSignature, SignatureBlockKindUnknown)
|
||||
return decision.Compatible
|
||||
}
|
||||
|
||||
// DecideSignatureCompatibility returns the safe handling policy for replaying a
|
||||
// signed block into targetProvider.
|
||||
func DecideSignatureCompatibility(targetProvider SignatureProvider, rawSignature string, blockKind SignatureBlockKind) SignatureCompatibilityDecision {
|
||||
return DecideSignatureCompatibilityForModel(targetProvider, "", rawSignature, blockKind)
|
||||
}
|
||||
|
||||
// DecideSignatureCompatibilityForModel returns the safe handling policy for replaying a
|
||||
// signed block into targetProvider for targetModel.
|
||||
func DecideSignatureCompatibilityForModel(targetProvider SignatureProvider, targetModel string, rawSignature string, blockKind SignatureBlockKind) SignatureCompatibilityDecision {
|
||||
targetProvider = normalizeSignatureTargetProvider(targetProvider)
|
||||
if blockKind == "" {
|
||||
blockKind = SignatureBlockKindUnknown
|
||||
}
|
||||
|
||||
detected := DetectSignatureProviderForBlock(rawSignature, blockKind)
|
||||
decision := SignatureCompatibilityDecision{
|
||||
TargetProvider: targetProvider,
|
||||
DetectedProvider: detected,
|
||||
BlockKind: blockKind,
|
||||
}
|
||||
|
||||
if signatureProviderMatchesTarget(targetProvider, detected) {
|
||||
decision.Compatible = true
|
||||
decision.Action = SignatureActionPreserve
|
||||
decision.NormalizedSignature = normalizeCompatibleSignatureForProvider(targetProvider, rawSignature, blockKind)
|
||||
decision.Reason = claudeCompatibleSignatureReason(targetProvider, rawSignature, targetModel)
|
||||
return decision
|
||||
}
|
||||
|
||||
decision.Compatible = false
|
||||
switch targetProvider {
|
||||
case SignatureProviderGemini:
|
||||
if blockKind == SignatureBlockKindGeminiFunctionCall || blockKind == SignatureBlockKindGeminiModelPart || blockKind == SignatureBlockKindUnknown {
|
||||
decision.Action = SignatureActionReplaceWithGeminiBypass
|
||||
decision.ReplacementSignature = GeminiSkipThoughtSignatureValidator
|
||||
decision.Reason = "Gemini can bypass synthetic or incompatible model-part signatures with the documented sentinel"
|
||||
return decision
|
||||
}
|
||||
decision.Action = SignatureActionDropBlock
|
||||
decision.Reason = "signature is not compatible with Gemini and this block is not a bypass-safe Gemini model part"
|
||||
case SignatureProviderClaude:
|
||||
decision.Action = SignatureActionDropBlock
|
||||
decision.Reason = "Claude has no cross-provider bypass sentinel for thinking blocks"
|
||||
case SignatureProviderGPT:
|
||||
decision.Action = SignatureActionDropBlock
|
||||
decision.Reason = "GPT reasoning encrypted_content cannot be synthesized from another provider signature"
|
||||
case SignatureProviderKimi:
|
||||
// Kimi is the only target that can keep the reasoning text when the
|
||||
// signature does not match. Its Messages endpoint never reads the field
|
||||
// back: a mutated, truncated, non-base64 or absent signature all return
|
||||
// 200, because reasoning continuity there travels in OpenAI-style
|
||||
// reasoning_content instead. Dropping the block would discard recoverable
|
||||
// thinking text for no upstream benefit, so drop only the signature.
|
||||
decision.Action = SignatureActionDropSignature
|
||||
decision.Reason = "Kimi does not validate replayed thinking signatures, so the block survives without one"
|
||||
case SignatureProviderGrok:
|
||||
// xAI decrypts encrypted_content and rejects the request with 400
|
||||
// "Could not decrypt" when the blob is foreign or mutated, so a
|
||||
// non-matching value has to leave with the block.
|
||||
decision.Action = SignatureActionDropBlock
|
||||
decision.Reason = "xAI verifies encrypted_content on replay and rejects foreign or mutated blobs"
|
||||
default:
|
||||
decision.Action = SignatureActionNoCompatibleReplacement
|
||||
decision.Reason = "unknown target provider"
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
func SplitSignatureProviderPrefix(rawSignature string) (SignatureProvider, string, bool) {
|
||||
prefix, rest, ok := strings.Cut(strings.TrimSpace(rawSignature), "#")
|
||||
if !ok {
|
||||
return SignatureProviderUnknown, rawSignature, false
|
||||
}
|
||||
provider := SignatureProviderFromCachePrefix(prefix)
|
||||
if provider == SignatureProviderUnknown {
|
||||
return SignatureProviderUnknown, rawSignature, false
|
||||
}
|
||||
return provider, strings.TrimSpace(rest), true
|
||||
}
|
||||
|
||||
// SignatureProviderFromCachePrefix maps this repo's explicit provider-prefix
|
||||
// envelope to a provider family. This is intentionally stricter than
|
||||
// SignatureProviderFromModelName so arbitrary model names such as
|
||||
// "claude-cache#..." cannot be mistaken for trusted provider provenance.
|
||||
func SignatureProviderFromCachePrefix(prefix string) SignatureProvider {
|
||||
switch strings.ToLower(strings.TrimSpace(prefix)) {
|
||||
case "claude", "anthropic", "cais", "claude-cais", "claude_cais", "ccmax", "claude-code-max", "claude_code_max":
|
||||
return SignatureProviderClaude
|
||||
case "gemini", "google":
|
||||
return SignatureProviderGemini
|
||||
case "openai", "gpt", "codex":
|
||||
return SignatureProviderGPT
|
||||
default:
|
||||
return SignatureProviderUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// SignaturePayloadWithoutProviderPrefix strips this repo's provider cache prefix
|
||||
// when present. The returned string is the value that should be replayed to an
|
||||
// upstream provider.
|
||||
func SignaturePayloadWithoutProviderPrefix(rawSignature string) string {
|
||||
if _, unprefixed, ok := SplitSignatureProviderPrefix(rawSignature); ok {
|
||||
return unprefixed
|
||||
}
|
||||
return strings.TrimSpace(rawSignature)
|
||||
}
|
||||
|
||||
// CompatibleSignatureForProvider returns a replayable provider-native signature
|
||||
// for targetProvider. It strips this repo's provider prefix and normalizes
|
||||
// Claude signatures to the format expected by the target when possible.
|
||||
func CompatibleSignatureForProvider(targetProvider SignatureProvider, rawSignature string) (string, bool) {
|
||||
return CompatibleSignatureForProviderBlock(targetProvider, rawSignature, SignatureBlockKindUnknown)
|
||||
}
|
||||
|
||||
// CompatibleSignatureForProviderBlock returns a replayable provider-native
|
||||
// signature for targetProvider when the source block kind is known.
|
||||
func CompatibleSignatureForProviderBlock(targetProvider SignatureProvider, rawSignature string, blockKind SignatureBlockKind) (string, bool) {
|
||||
decision := DecideSignatureCompatibility(targetProvider, rawSignature, blockKind)
|
||||
if !decision.Compatible || decision.NormalizedSignature == "" {
|
||||
return "", false
|
||||
}
|
||||
return decision.NormalizedSignature, true
|
||||
}
|
||||
|
||||
// CompatibleAntigravityClaudeThinkingSignature returns the double-layer R-form
|
||||
// required by Antigravity Claude replay. It only accepts signatures that are
|
||||
// strictly identifiable as Claude, so Gemini E-prefixed envelopes cannot slip
|
||||
// through the looser Antigravity bypass normalization path.
|
||||
func CompatibleAntigravityClaudeThinkingSignature(rawSignature string) (string, bool) {
|
||||
if DetectSignatureProviderForBlock(rawSignature, SignatureBlockKindClaudeThinking) != SignatureProviderClaude {
|
||||
return "", false
|
||||
}
|
||||
normalized, err := NormalizeClaudeThinkingSignature(
|
||||
SignaturePayloadWithoutProviderPrefix(rawSignature),
|
||||
ClaudeSignatureValidationOptions{Strict: true},
|
||||
)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
// claudeCompatibleSignatureReason explains why a matching signature is
|
||||
// replayable. Claude CAIS signatures carry the issuing model inside the payload,
|
||||
// so the embedded model and the target model are both reported to make signature
|
||||
// decisions traceable in debug logs.
|
||||
func claudeCompatibleSignatureReason(targetProvider SignatureProvider, rawSignature, targetModel string) string {
|
||||
const genericReason = "signature provider matches target provider"
|
||||
if targetProvider != SignatureProviderClaude {
|
||||
return genericReason
|
||||
}
|
||||
info, err := InspectClaudeCAISSignature(SignaturePayloadWithoutProviderPrefix(rawSignature))
|
||||
if err != nil {
|
||||
return genericReason
|
||||
}
|
||||
reason := "valid Claude CAIS signature with embedded model " + info.ModelText + " is compatible with any Claude target"
|
||||
if trimmedModel := strings.TrimSpace(targetModel); trimmedModel != "" {
|
||||
reason += ", including target model " + trimmedModel
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
func normalizeSignatureTargetProvider(provider SignatureProvider) SignatureProvider {
|
||||
switch provider {
|
||||
case SignatureProviderGeminiBypass:
|
||||
return SignatureProviderGemini
|
||||
default:
|
||||
return provider
|
||||
}
|
||||
}
|
||||
|
||||
func signatureProviderMatchesTarget(target, detected SignatureProvider) bool {
|
||||
switch target {
|
||||
case SignatureProviderGemini:
|
||||
return detected == SignatureProviderGemini || detected == SignatureProviderGeminiBypass
|
||||
case SignatureProviderClaude:
|
||||
return detected == SignatureProviderClaude
|
||||
case SignatureProviderGPT:
|
||||
return detected == SignatureProviderGPT
|
||||
case SignatureProviderKimi:
|
||||
return detected == SignatureProviderKimi
|
||||
default:
|
||||
// SignatureProviderGrok is deliberately absent. Detection never yields it,
|
||||
// so a Grok target must decide replay safety from provenance plus
|
||||
// InspectGrokEncryptedContent rather than from a detected-provider match.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCompatibleSignatureForProvider(targetProvider SignatureProvider, rawSignature string, blockKind SignatureBlockKind) string {
|
||||
payload := SignaturePayloadWithoutProviderPrefix(rawSignature)
|
||||
switch normalizeSignatureTargetProvider(targetProvider) {
|
||||
case SignatureProviderClaude:
|
||||
if IsValidClaudeCAISSignature(payload) {
|
||||
return payload
|
||||
}
|
||||
normalized, err := NormalizeClaudeProviderNativeThinkingSignature(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return normalized
|
||||
case SignatureProviderGemini:
|
||||
if IsGeminiThoughtSignatureBypass(payload) {
|
||||
return payload
|
||||
}
|
||||
if isRecognizedGeminiProviderSignature(payload, blockKind) {
|
||||
return payload
|
||||
}
|
||||
case SignatureProviderGPT:
|
||||
if IsValidGPTReasoningSignature(payload) {
|
||||
return payload
|
||||
}
|
||||
case SignatureProviderKimi:
|
||||
if IsValidKimiThinkingSignature(payload) {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isRecognizedGeminiProviderSignature(rawSignature string, blockKind SignatureBlockKind) bool {
|
||||
if IsValidClaudeCAISSignature(rawSignature) {
|
||||
return false
|
||||
}
|
||||
if IsValidGeminiThoughtSignature(rawSignature, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
471
backend/internal/signature/provider_compatibility_test.go
Normal file
471
backend/internal/signature/provider_compatibility_test.go
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
package signature
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
func testClaudeThinkingSignature() string {
|
||||
channelBlock := []byte{}
|
||||
channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
|
||||
channelBlock = protowire.AppendVarint(channelBlock, 12)
|
||||
channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType)
|
||||
channelBlock = protowire.AppendVarint(channelBlock, 2)
|
||||
channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
|
||||
channelBlock = protowire.AppendString(channelBlock, "claude-sonnet-4-6")
|
||||
|
||||
container := []byte{}
|
||||
container = protowire.AppendTag(container, 1, protowire.BytesType)
|
||||
container = protowire.AppendBytes(container, channelBlock)
|
||||
|
||||
payload := []byte{}
|
||||
payload = protowire.AppendTag(payload, 2, protowire.BytesType)
|
||||
payload = protowire.AppendBytes(payload, container)
|
||||
payload = protowire.AppendTag(payload, 3, protowire.VarintType)
|
||||
payload = protowire.AppendVarint(payload, 1)
|
||||
return base64.StdEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
// TestBase64AlphabetSet_MatchesEncoderAlphabets pins the charset lookup tables
|
||||
// against the encoders they stand in for. A wrong table would silently accept
|
||||
// bytes that are not valid base64, or reject a legal payload character.
|
||||
func TestBase64AlphabetSet_MatchesEncoderAlphabets(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
set [256]bool
|
||||
alphabet string
|
||||
}{
|
||||
{"grok unpadded std", grokEncryptedContentCharSet, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"},
|
||||
{"gpt base64url", gptReasoningSignatureCharSet, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_="},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
allowed := map[byte]bool{}
|
||||
for i := 0; i < len(tc.alphabet); i++ {
|
||||
allowed[tc.alphabet[i]] = true
|
||||
}
|
||||
for c := 0; c < 256; c++ {
|
||||
want := allowed[byte(c)]
|
||||
if got := tc.set[c]; got != want {
|
||||
t.Errorf("%s: byte 0x%02x (%q) accepted=%v, want %v", tc.name, c, string(rune(c)), got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// replaySafeEnvelopeFixtures returns one fixture per self-describing provider
|
||||
// envelope that carries replayable state. Every entry must survive the structural
|
||||
// pre-filter, because losing one would silently reclassify that provider.
|
||||
func replaySafeEnvelopeFixtures() map[string]struct {
|
||||
sig string
|
||||
want SignatureProvider
|
||||
} {
|
||||
return map[string]struct {
|
||||
sig string
|
||||
want SignatureProvider
|
||||
}{
|
||||
"claude single-layer E": {testClaudeThinkingSignature(), SignatureProviderClaude},
|
||||
"claude double-layer R": {testUnpaddedAntigravityClaudeThinkingSignature(), SignatureProviderClaude},
|
||||
"claude CAIS": {testClaudeCAISSignature("claude-fable-5"), SignatureProviderClaude},
|
||||
"gemini protobuf field2": {testGeminiThoughtSignatureEnvelope(), SignatureProviderGemini},
|
||||
"gpt fernet": {testGPTReasoningSignature(), SignatureProviderGPT},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfDescribingSignatureFirstChars_CoversEveryKnownEnvelope guards the
|
||||
// structural pre-filter. DetectSignatureProviderForBlock skips every provider
|
||||
// validator when maybeSelfDescribingSignatureEnvelope returns false, so an
|
||||
// envelope missing from selfDescribingSignatureFirstChars would silently fall
|
||||
// through to the residual class. Adding a provider envelope without registering
|
||||
// its base64 first character fails here.
|
||||
func TestSelfDescribingSignatureFirstChars_CoversEveryKnownEnvelope(t *testing.T) {
|
||||
for name, fixture := range replaySafeEnvelopeFixtures() {
|
||||
if !maybeSelfDescribingSignatureEnvelope(fixture.sig) {
|
||||
t.Errorf("%s: first char %q is not in selfDescribingSignatureFirstChars %q; register it or detection will skip this envelope",
|
||||
name, string(fixture.sig[0]), selfDescribingSignatureFirstChars)
|
||||
}
|
||||
}
|
||||
|
||||
// The pre-filter must not be so wide that it stops filtering. Opaque xAI
|
||||
// ciphertext is the shape it exists to reject.
|
||||
for _, sig := range []string{
|
||||
"K1ZAIbzDbO",
|
||||
"jQDLUr+fD8RFP8nbkkfI",
|
||||
"qcgG7jzxH3D6mlVLBBaKXaG3",
|
||||
} {
|
||||
if maybeSelfDescribingSignatureEnvelope(sig) {
|
||||
t.Errorf("opaque ciphertext %q must not look like a self-describing envelope", sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiASCIIUUIDIsGateIndependent documents why ascii_uuid is excluded from
|
||||
// selfDescribingSignatureFirstChars. Its first byte is the first hex character of
|
||||
// the UUID, so the base64 first character spreads over several values, and none of
|
||||
// them need to be registered: the envelope is never replay-safe, so it resolves to
|
||||
// SignatureProviderUnknown either way and Gemini model parts recover it through the
|
||||
// bypass sentinel keyed on block kind.
|
||||
func TestGeminiASCIIUUIDIsGateIndependent(t *testing.T) {
|
||||
// First hex digit chosen to land on distinct base64 first characters.
|
||||
for _, uuid := range []string{
|
||||
"09743975-4bb0-4936-9e28-d5b0d21bdc48",
|
||||
"49743975-4bb0-4936-9e28-d5b0d21bdc48",
|
||||
"89743975-4bb0-4936-9e28-d5b0d21bdc48",
|
||||
"a9743975-4bb0-4936-9e28-d5b0d21bdc48",
|
||||
"e9743975-4bb0-4936-9e28-d5b0d21bdc48",
|
||||
} {
|
||||
sig := testGeminiThoughtSignature([]byte(uuid))
|
||||
if got := DetectSignatureProvider(sig); got != SignatureProviderUnknown {
|
||||
t.Errorf("uuid %q: DetectSignatureProvider = %q, want %q regardless of the pre-filter",
|
||||
uuid[:8], got, SignatureProviderUnknown)
|
||||
}
|
||||
decision := DecideSignatureCompatibility(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall)
|
||||
if decision.Action != SignatureActionReplaceWithGeminiBypass {
|
||||
t.Errorf("uuid %q: action = %q, want %q", uuid[:8], decision.Action, SignatureActionReplaceWithGeminiBypass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectSignatureProviderForBlock_ClassifiesEveryKnownEnvelope pins the
|
||||
// classification of each envelope so a reordering of the validator chain cannot
|
||||
// silently reassign one provider's signatures to another.
|
||||
func TestDetectSignatureProviderForBlock_ClassifiesEveryKnownEnvelope(t *testing.T) {
|
||||
for name, fixture := range replaySafeEnvelopeFixtures() {
|
||||
if got := DetectSignatureProvider(fixture.sig); got != fixture.want {
|
||||
t.Errorf("%s: DetectSignatureProvider = %q, want %q", name, got, fixture.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiEnvelopeNeverClaimsClaudeSignatures pins the invariant that keeps
|
||||
// Claude and Gemini separable independently of probe order in
|
||||
// DetectSignatureProviderForBlock. Gemini validates wire shape only and has no
|
||||
// literal marker, so it is the weakest judge; Claude envelopes survive it solely
|
||||
// because they carry extra top-level fields beyond the container and therefore
|
||||
// fail Gemini's single-record shape. Loosening the Gemini envelope check would
|
||||
// make probe order start mattering, and fails here first.
|
||||
func TestGeminiEnvelopeNeverClaimsClaudeSignatures(t *testing.T) {
|
||||
for name, sig := range map[string]string{
|
||||
"single-layer E": testClaudeThinkingSignature(),
|
||||
"single-layer E opaque": testClaudeThinkingSignatureWithOpaqueLen(64),
|
||||
"double-layer R": testUnpaddedAntigravityClaudeThinkingSignature(),
|
||||
"CAIS synthetic": testClaudeCAISSignature("claude-opus-5"),
|
||||
"CAIS observed": observedFable5Sample,
|
||||
} {
|
||||
if isRecognizedGeminiProviderSignature(sig, SignatureBlockKindUnknown) {
|
||||
t.Errorf("claude %s is claimed by the Gemini envelope check; probe order in DetectSignatureProviderForBlock is now load-bearing", name)
|
||||
}
|
||||
if got := DetectSignatureProvider(sig); got != SignatureProviderClaude {
|
||||
t.Errorf("claude %s: DetectSignatureProvider = %q, want %q", name, got, SignatureProviderClaude)
|
||||
}
|
||||
if _, ok := CompatibleSignatureForProvider(SignatureProviderGemini, sig); ok {
|
||||
t.Errorf("claude %s must not be replayable as a Gemini signature", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectSignatureProvider_UsesProviderPrefix(t *testing.T) {
|
||||
claudeSig := "claude#" + testClaudeThinkingSignature()
|
||||
if got := DetectSignatureProvider(claudeSig); got != SignatureProviderClaude {
|
||||
t.Fatalf("DetectSignatureProvider(claude#...) = %q, want %q", got, SignatureProviderClaude)
|
||||
}
|
||||
|
||||
geminiSig := "gemini#" + testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
|
||||
if got := DetectSignatureProvider(geminiSig); got != SignatureProviderGemini {
|
||||
t.Fatalf("DetectSignatureProvider(gemini#...) = %q, want %q", got, SignatureProviderGemini)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectSignatureProvider_RejectsMisleadingClaudePrefix(t *testing.T) {
|
||||
mislabeledGeminiSig := "claude#" + testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
|
||||
if got := DetectSignatureProvider(mislabeledGeminiSig); got != SignatureProviderUnknown {
|
||||
t.Fatalf("DetectSignatureProvider(mislabeled claude#Gemini) = %q, want %q", got, SignatureProviderUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectSignatureProvider_Gemini3EPrefixDoesNotLookClaude(t *testing.T) {
|
||||
// This byte shape base64-encodes with an E prefix but is a Gemini field-2
|
||||
// envelope, not a Claude thinking-signature tree.
|
||||
geminiSig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34})
|
||||
if !strings.HasPrefix(geminiSig, "E") {
|
||||
t.Fatalf("test signature should start with E, got %q", geminiSig[:1])
|
||||
}
|
||||
if got := DetectSignatureProvider(geminiSig); got != SignatureProviderGemini {
|
||||
t.Fatalf("DetectSignatureProvider(Gemini E-prefix) = %q, want %q", got, SignatureProviderGemini)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibleSignatureForProvider_ClaudeUsesProviderNativeEForm(t *testing.T) {
|
||||
nativeSig := testClaudeThinkingSignature()
|
||||
doubleEncoded := base64.StdEncoding.EncodeToString([]byte(nativeSig))
|
||||
|
||||
normalized, ok := CompatibleSignatureForProvider(SignatureProviderClaude, doubleEncoded)
|
||||
if !ok {
|
||||
t.Fatal("double-layer Claude signature should be compatible")
|
||||
}
|
||||
if normalized != nativeSig {
|
||||
t.Fatalf("CompatibleSignatureForProvider(Claude) = %q, want provider-native %q", normalized, nativeSig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibleAntigravityClaudeThinkingSignature_UsesDoubleLayerRForm(t *testing.T) {
|
||||
nativeSig := testClaudeThinkingSignature()
|
||||
expected := base64.StdEncoding.EncodeToString([]byte(nativeSig))
|
||||
|
||||
normalized, ok := CompatibleAntigravityClaudeThinkingSignature(nativeSig)
|
||||
if !ok {
|
||||
t.Fatal("Claude signature should be compatible with Antigravity Claude")
|
||||
}
|
||||
if normalized != expected {
|
||||
t.Fatalf("CompatibleAntigravityClaudeThinkingSignature = %q, want %q", normalized, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibleAntigravityClaudeThinkingSignature_RejectsGeminiEPrefix(t *testing.T) {
|
||||
geminiSig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34})
|
||||
if !strings.HasPrefix(geminiSig, "E") {
|
||||
t.Fatalf("test signature should start with E, got %q", geminiSig[:1])
|
||||
}
|
||||
if normalized, ok := CompatibleAntigravityClaudeThinkingSignature(geminiSig); ok || normalized != "" {
|
||||
t.Fatalf("Gemini E-prefix signature normalized=%q ok=%v, want rejected", normalized, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectSignatureProvider_DoesNotClassifyArbitraryBase64AsGemini(t *testing.T) {
|
||||
opaque := testGeminiThoughtSignature([]byte{0x45, 0x12})
|
||||
if got := DetectSignatureProvider(opaque); got != SignatureProviderUnknown {
|
||||
t.Fatalf("DetectSignatureProvider(arbitrary base64) = %q, want %q", got, SignatureProviderUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiASCIIUUIDSignatureUsesBypass(t *testing.T) {
|
||||
plainUUID := "e24830a7-5cd6-42fe-998b-ee539e72b9c3"
|
||||
sig := testGeminiThoughtSignature([]byte(plainUUID))
|
||||
|
||||
if got := DetectSignatureProvider(plainUUID); got != SignatureProviderUnknown {
|
||||
t.Fatalf("DetectSignatureProvider(plain UUID) = %q, want %q", got, SignatureProviderUnknown)
|
||||
}
|
||||
if got := DetectSignatureProvider("gemini#" + plainUUID); got != SignatureProviderUnknown {
|
||||
t.Fatalf("DetectSignatureProvider(gemini#plain UUID) = %q, want %q", got, SignatureProviderUnknown)
|
||||
}
|
||||
|
||||
if got := DetectSignatureProvider(sig); got != SignatureProviderUnknown {
|
||||
t.Fatalf("DetectSignatureProvider(UUID) = %q, want %q", got, SignatureProviderUnknown)
|
||||
}
|
||||
if got := DetectSignatureProvider("gemini#" + sig); got != SignatureProviderUnknown {
|
||||
t.Fatalf("DetectSignatureProvider(gemini#UUID) = %q, want %q", got, SignatureProviderUnknown)
|
||||
}
|
||||
if got := DetectSignatureProviderForBlock(sig, SignatureBlockKindGeminiFunctionCall); got != SignatureProviderUnknown {
|
||||
t.Fatalf("DetectSignatureProviderForBlock(UUID tool call) = %q, want %q", got, SignatureProviderUnknown)
|
||||
}
|
||||
if _, ok := CompatibleSignatureForProvider(SignatureProviderGemini, sig); ok {
|
||||
t.Fatal("UUID signature should not be compatible")
|
||||
}
|
||||
if normalized, ok := CompatibleSignatureForProviderBlock(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall); ok || normalized != "" {
|
||||
t.Fatalf("UUID tool-call signature normalized=%q ok=%v, want empty and false", normalized, ok)
|
||||
}
|
||||
decision := DecideSignatureCompatibility(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall)
|
||||
if decision.Action != SignatureActionReplaceWithGeminiBypass {
|
||||
t.Fatalf("function-call UUID action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass)
|
||||
}
|
||||
if decision.ReplacementSignature != GeminiSkipThoughtSignatureValidator {
|
||||
t.Fatalf("function-call UUID replacement = %q, want %q", decision.ReplacementSignature, GeminiSkipThoughtSignatureValidator)
|
||||
}
|
||||
decision = DecideSignatureCompatibility(SignatureProviderGemini, sig, SignatureBlockKindGeminiModelPart)
|
||||
if decision.Action != SignatureActionReplaceWithGeminiBypass {
|
||||
t.Fatalf("model-part UUID action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiWrappedUUIDFunctionCallSignatureIsCompatible(t *testing.T) {
|
||||
sig := testGemini3ThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3"))
|
||||
|
||||
if got := DetectSignatureProvider(sig); got != SignatureProviderGemini {
|
||||
t.Fatalf("DetectSignatureProvider(wrapped UUID) = %q, want %q", got, SignatureProviderGemini)
|
||||
}
|
||||
if got := DetectSignatureProviderForBlock(sig, SignatureBlockKindGeminiFunctionCall); got != SignatureProviderGemini {
|
||||
t.Fatalf("DetectSignatureProviderForBlock(wrapped UUID tool call) = %q, want %q", got, SignatureProviderGemini)
|
||||
}
|
||||
if normalized, ok := CompatibleSignatureForProviderBlock(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall); !ok || normalized != sig {
|
||||
t.Fatalf("wrapped UUID tool-call signature normalized=%q ok=%v, want original and true", normalized, ok)
|
||||
}
|
||||
for _, blockKind := range []SignatureBlockKind{SignatureBlockKindGeminiFunctionCall, SignatureBlockKindGeminiModelPart} {
|
||||
decision := DecideSignatureCompatibility(SignatureProviderGemini, sig, blockKind)
|
||||
if !decision.Compatible || decision.Action != SignatureActionPreserve || decision.NormalizedSignature != sig {
|
||||
t.Fatalf("wrapped UUID decision for %s = %+v, want preserved", blockKind, decision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibleSignatureForProvider_StripsGeminiPrefix(t *testing.T) {
|
||||
sig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
|
||||
normalized, ok := CompatibleSignatureForProvider(SignatureProviderGemini, "gemini#"+sig)
|
||||
if !ok {
|
||||
t.Fatal("gemini-prefixed signature should be compatible with Gemini")
|
||||
}
|
||||
if normalized != sig {
|
||||
t.Fatalf("normalized = %q, want %q", normalized, sig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSignatureProviderPrefix_UsesStrictProviderAliases(t *testing.T) {
|
||||
gptSig := "gpt#" + testGPTReasoningSignature()
|
||||
if got := DetectSignatureProvider(gptSig); got != SignatureProviderGPT {
|
||||
t.Fatalf("DetectSignatureProvider(gpt#...) = %q, want %q", got, SignatureProviderGPT)
|
||||
}
|
||||
|
||||
mislabeledPrefix := "claude-cache#" + testClaudeThinkingSignature()
|
||||
if _, _, ok := SplitSignatureProviderPrefix(mislabeledPrefix); ok {
|
||||
t.Fatal("claude-cache# should not be accepted as an explicit provider prefix")
|
||||
}
|
||||
if got := DetectSignatureProvider(mislabeledPrefix); got != SignatureProviderUnknown {
|
||||
t.Fatalf("DetectSignatureProvider(claude-cache#...) = %q, want %q", got, SignatureProviderUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideSignatureCompatibility_GeminiFunctionCallUsesBypass(t *testing.T) {
|
||||
decision := DecideSignatureCompatibility(SignatureProviderGemini, "claude#"+testClaudeThinkingSignature(), SignatureBlockKindGeminiFunctionCall)
|
||||
if decision.Action != SignatureActionReplaceWithGeminiBypass {
|
||||
t.Fatalf("Action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass)
|
||||
}
|
||||
if decision.ReplacementSignature != GeminiSkipThoughtSignatureValidator {
|
||||
t.Fatalf("ReplacementSignature = %q, want %q", decision.ReplacementSignature, GeminiSkipThoughtSignatureValidator)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeClaudeMessagesSignaturesForModel_NormalizesSameProviderClaude(t *testing.T) {
|
||||
nativeSig := testClaudeThinkingSignature()
|
||||
sig := "claude#" + nativeSig
|
||||
input := []byte(`{"model":"claude-sonnet","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`)
|
||||
expectedSig, err := NormalizeClaudeProviderNativeThinkingSignature(nativeSig)
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeClaudeProviderNativeThinkingSignature failed: %v", err)
|
||||
}
|
||||
|
||||
output, report := SanitizeClaudeMessagesSignaturesForModel(input, "claude-sonnet-4-5")
|
||||
if report.Preserved != 1 || report.DroppedBlocks != 0 {
|
||||
t.Fatalf("unexpected report: %+v", report)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != expectedSig {
|
||||
t.Fatalf("signature = %q, want normalized %q", got, expectedSig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeClaudeMessagesSignaturesForModel_DropsClaudeThinkingForGemini(t *testing.T) {
|
||||
sig := "claude#" + testClaudeThinkingSignature()
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"drop","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`)
|
||||
|
||||
output, report := SanitizeClaudeMessagesSignaturesForModel(input, "gemini-3.5-flash")
|
||||
if report.DroppedBlocks != 1 {
|
||||
t.Fatalf("DroppedBlocks = %d, want 1; report=%+v", report.DroppedBlocks, report)
|
||||
}
|
||||
content := gjson.GetBytes(output, "messages.0.content").Array()
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("content length = %d, want 1: %s", len(content), output)
|
||||
}
|
||||
if got := content[0].Get("text").String(); got != "answer" {
|
||||
t.Fatalf("remaining text = %q, want answer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeClaudeMessagesSignaturesForModel_PreservesGeminiThinkingForGemini(t *testing.T) {
|
||||
nativeSig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
|
||||
sig := "gemini#" + nativeSig
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`)
|
||||
|
||||
output, report := SanitizeClaudeMessagesSignaturesForModel(input, "gemini-3.5-flash")
|
||||
if report.Preserved != 1 || report.DroppedBlocks != 0 {
|
||||
t.Fatalf("unexpected report: %+v", report)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != nativeSig {
|
||||
t.Fatalf("signature = %q, want normalized %q", got, nativeSig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeClaudeMessagesSignaturesForModel_PreservesGPTForGPT(t *testing.T) {
|
||||
sig := testGPTReasoningSignature()
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`)
|
||||
|
||||
output, report := SanitizeClaudeMessagesSignaturesForModel(input, "gpt-5.2")
|
||||
if report.Preserved != 1 || report.DroppedBlocks != 0 {
|
||||
t.Fatalf("unexpected report: %+v", report)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != sig {
|
||||
t.Fatalf("signature = %q, want preserved %q", got, sig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeClaudeMessagesSignaturesForModel_DropsEmptyAssistantMessage(t *testing.T) {
|
||||
sig := "claude#" + testClaudeThinkingSignature()
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"drop","signature":"` + sig + `"}]},{"role":"user","content":[{"type":"text","text":"next"}]}]}`)
|
||||
|
||||
output, report := SanitizeClaudeMessagesSignaturesForModel(input, "gpt-5.2")
|
||||
if report.DroppedBlocks != 1 {
|
||||
t.Fatalf("DroppedBlocks = %d, want 1", report.DroppedBlocks)
|
||||
}
|
||||
messages := gjson.GetBytes(output, "messages").Array()
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("messages length = %d, want 1: %s", len(messages), output)
|
||||
}
|
||||
if got := messages[0].Get("role").String(); got != "user" {
|
||||
t.Fatalf("remaining role = %q, want user", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeClaudeMessagesForClaudeUpstream_DropsInvalidThinkingAndCleansToolUse(t *testing.T) {
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"drop me","signature":""},{"type":"text","text":"answer"},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"git status"},"signature":"bad","thoughtSignature":"bad2","thought_signature":"bad3","model":"claude-sonnet-4-5","extra_content":{"google":{"thought_signature":"bad4"}}}]}]}`)
|
||||
|
||||
output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4-5")
|
||||
if report.DroppedBlocks != 1 {
|
||||
t.Fatalf("DroppedBlocks = %d, want 1; report=%+v", report.DroppedBlocks, report)
|
||||
}
|
||||
parts := gjson.GetBytes(output, "messages.0.content").Array()
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("content length = %d, want 2: %s", len(parts), output)
|
||||
}
|
||||
if parts[0].Get("type").String() != "text" {
|
||||
t.Fatalf("first remaining part = %s, want text", parts[0].Raw)
|
||||
}
|
||||
toolUse := parts[1]
|
||||
if toolUse.Get("type").String() != "tool_use" {
|
||||
t.Fatalf("second remaining part = %s, want tool_use", toolUse.Raw)
|
||||
}
|
||||
if got := toolUse.Get("id").String(); got != "toolu_1" {
|
||||
t.Fatalf("tool_use id = %q, want toolu_1", got)
|
||||
}
|
||||
for _, path := range []string{
|
||||
"signature",
|
||||
"thoughtSignature",
|
||||
"thought_signature",
|
||||
"model",
|
||||
"extra_content",
|
||||
} {
|
||||
if toolUse.Get(path).Exists() {
|
||||
t.Fatalf("tool_use.%s should be removed: %s", path, toolUse.Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeClaudeMessagesForClaudeUpstream_NormalizesValidThinkingAndDropsEmptyMessage(t *testing.T) {
|
||||
nativeSig := testClaudeThinkingSignature()
|
||||
doubleEncoded := base64.StdEncoding.EncodeToString([]byte(nativeSig))
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + doubleEncoded + `"},{"type":"text","text":"answer"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"drop"}]},{"role":"user","content":[{"type":"text","text":"next"}]}]}`)
|
||||
|
||||
output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4-5")
|
||||
if report.Preserved != 1 || report.DroppedBlocks != 1 {
|
||||
t.Fatalf("unexpected report: %+v", report)
|
||||
}
|
||||
messages := gjson.GetBytes(output, "messages").Array()
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("messages length = %d, want 2: %s", len(messages), output)
|
||||
}
|
||||
if got := messages[0].Get("content.0.signature").String(); got != nativeSig {
|
||||
t.Fatalf("signature = %q, want provider-native %q", got, nativeSig)
|
||||
}
|
||||
if got := messages[1].Get("role").String(); got != "user" {
|
||||
t.Fatalf("remaining second role = %q, want user", got)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue