Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View 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 ""
}

View 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
}

View file

@ -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)
}
}

View 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)
}
}

View 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
}

View 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, ']')
}

View 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))
}
}

View 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"
}

View 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
}

View 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
}

View 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())
}
}

View 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))
}

View 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)
}
}

View 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
}

View 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)
}
}
}

View 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
}

View 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)
}
}