diff --git a/internal/signature/grok_validation.go b/internal/signature/grok_validation.go index ba09ad94..8b424ac3 100644 --- a/internal/signature/grok_validation.go +++ b/internal/signature/grok_validation.go @@ -11,12 +11,15 @@ import ( const ( // MaxGrokEncryptedContentLen is a transport safety cap for opaque replay blobs. MaxGrokEncryptedContentLen = 8 * 1024 * 1024 - // MinGrokEncryptedContentDecodedLen is a deliberately loose floor. The - // shortest observed native payload is exactly 50 bytes (grok-composer-2.5-fast), - // and those samples share no structure, so 50 is a sampling artifact rather - // than a protocol minimum. Sitting on the observed floor would silently reject - // a future shorter payload and surface as lost reasoning context, so keep - // headroom here and let the entropy check do the real filtering. + // 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. diff --git a/internal/signature/grok_validation_test.go b/internal/signature/grok_validation_test.go index ca3383d8..1c9a2d72 100644 --- a/internal/signature/grok_validation_test.go +++ b/internal/signature/grok_validation_test.go @@ -333,3 +333,60 @@ func grokEncryptedContentSamplesPath() (string, bool) { } 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) + } +} diff --git a/internal/signature/provider_compatibility.go b/internal/signature/provider_compatibility.go index 79d7530a..2eba2cc1 100644 --- a/internal/signature/provider_compatibility.go +++ b/internal/signature/provider_compatibility.go @@ -13,6 +13,13 @@ const ( // 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 @@ -67,6 +74,8 @@ func SignatureProviderFromModelName(modelName string) SignatureProvider { strings.HasPrefix(lower, "k2"), strings.HasPrefix(lower, "k3"): return SignatureProviderKimi + case strings.Contains(lower, "grok"): + return SignatureProviderGrok default: return SignatureProviderUnknown } @@ -283,6 +292,12 @@ func DecideSignatureCompatibilityForModel(targetProvider SignatureProvider, targ // 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" @@ -404,6 +419,9 @@ func signatureProviderMatchesTarget(target, detected SignatureProvider) bool { 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 } }