From 084b9ca71737602df7be7a5daaebb975f0fb19ca Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 19 Oct 2026 12:59:12 +0800 Subject: [PATCH] feat(claude): strip Claude Code identity on API keys with cloak mode never or uncloak - Strip incoming Claude Code billing attribution, CLI identity system blocks, metadata user IDs, and diagnostics when a non-CLI API key explicitly configures cloak mode as never or uncloak. - Remove Claude Code CLI headers, beta flags, and user-agent strings on uncloaked API key requests while preserving capability betas and credential header overrides. - Keep global disable-claude-cloak-mode passthrough behavior unchanged for credentials without explicit per-key cloak configuration. Closes: #6762 --- config.example.yaml | 2 +- internal/config/config_types.go | 4 +- .../executor/claude_executor_cloaking.go | 93 +++++- .../executor/claude_executor_request.go | 45 ++- .../executor/claude_executor_tokens.go | 2 + .../claude_fingerprint_policy_test.go | 291 ++++++++++++++++++ .../executor/helps/claude_client_detection.go | 5 + 7 files changed, 430 insertions(+), 12 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 372af6e52a..1a2f939fcd 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -728,7 +728,7 @@ upstream: # cloak: # optional: explicitly enable request cloaking for non-Claude-Code clients # mode: "auto" # "auto" (default inside this block): cloak only when client is not Claude Code # # "always": cloak every unconfirmed client; confirmed native Claude Code still passes through -# # "never": never apply cloaking +# # "never" / "uncloak": never apply cloaking; on non-CLI API keys, also strip incoming Claude Code identity/billing attribution # # This "cloak" block applies to this api-keys.claude key entry only. For Claude OAuth # # credentials, set the same options in the auth/token JSON file via "cloak_mode" / # # "cloak_strict_mode" / "cloak_sensitive_words" / "cloak_cache_user_id". diff --git a/internal/config/config_types.go b/internal/config/config_types.go index 5205bf2f2e..2f412157da 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -564,11 +564,11 @@ type PayloadModelRule struct { // CloakConfig configures request cloaking for non-Claude-Code clients. // Cloaking disguises API requests to appear as originating from the official Claude Code CLI. type CloakConfig struct { - // Mode controls cloaking behavior: "auto" (default), "always", or "never". + // Mode controls cloaking behavior: "auto" (default), "always", "never", or "uncloak". // Supplying this CloakConfig explicitly enables cloaking for an unprofiled API key. // - "auto": cloak unless strong request signals identify a verified native entrypoint // - "always": cloak every unconfirmed client; confirmed native Claude Code remains passthrough - // - "never": never apply cloaking + // - "never" / "uncloak": never apply cloaking; on non-CLI API keys, also strip incoming Claude Code identity and billing attribution Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` // StrictMode controls how caller system prompts are handled when cloaking. diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index 1f57ee5cb1..42c090762e 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -1584,6 +1584,7 @@ type claudeWirePolicy struct { ProfileClaudeCodeCLI bool // request fingerprint looks like Claude Code CLI ConfirmedClaudeCode bool Cloak bool + Uncloak bool } type claudeCloakSettings struct { @@ -1592,6 +1593,79 @@ type claudeCloakSettings struct { cacheUserID bool } +func isClaudeCodeIdentitySystemText(text string) bool { + trimmed := strings.TrimSpace(text) + return trimmed == claudeCodeCLIIdentity || + trimmed == "You are a Claude agent, built on Anthropic's Claude Agent SDK." +} + +func stripClaudeCodeSystemBlocks(payload []byte) []byte { + payload = util.StripClaudeCodeAttributionSystem(payload) + system := gjson.GetBytes(payload, "system") + if !system.Exists() { + return payload + } + if system.Type == gjson.String { + if !isClaudeCodeIdentitySystemText(system.String()) { + return payload + } + if updated, errDelete := sjson.DeleteBytes(payload, "system"); errDelete == nil { + return updated + } + return payload + } + if !system.IsArray() { + return payload + } + kept := make([]string, 0, len(system.Array())) + removed := false + system.ForEach(func(_, block gjson.Result) bool { + if block.Get("type").String() == "text" && isClaudeCodeIdentitySystemText(block.Get("text").String()) { + removed = true + return true + } + if block.Raw != "" { + kept = append(kept, block.Raw) + } + return true + }) + if !removed { + return payload + } + if len(kept) == 0 { + if updated, errDelete := sjson.DeleteBytes(payload, "system"); errDelete == nil { + return updated + } + return payload + } + if updated, errSet := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(kept, ",")+"]")); errSet == nil { + return updated + } + return payload +} + +func uncloakClaudeCodePayload(payload []byte, confirmedClaudeCode bool) []byte { + payload = stripClaudeCodeSystemBlocks(payload) + if metadata := gjson.GetBytes(payload, "metadata"); metadata.Exists() && metadata.IsObject() { + if userID := metadata.Get("user_id"); userID.Exists() && (confirmedClaudeCode || helps.IsValidUserID(userID.String())) { + if updated, errDelete := sjson.DeleteBytes(payload, "metadata.user_id"); errDelete == nil { + payload = updated + if remaining := gjson.GetBytes(payload, "metadata"); remaining.Exists() && remaining.IsObject() && len(remaining.Map()) == 0 { + if updatedMeta, errDeleteMeta := sjson.DeleteBytes(payload, "metadata"); errDeleteMeta == nil { + payload = updatedMeta + } + } + } + } + } + if diag := gjson.GetBytes(payload, "diagnostics"); diag.Exists() && (confirmedClaudeCode || diag.Get("previous_message_id").Exists()) { + if updated, errDelete := sjson.DeleteBytes(payload, "diagnostics"); errDelete == nil { + payload = updated + } + } + return payload +} + func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey string, confirmedClaudeCode bool) (claudeWirePolicy, claudeCloakSettings) { if auth != nil && auth.AuthKind() == cliproxyauth.AuthKindAPIKey { cfg = cfg.ForAPIKey() @@ -1608,12 +1682,14 @@ func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey sensitiveWords: attrWords, cacheUserID: attrCache, } + explicitMode := strings.ToLower(strings.TrimSpace(attrMode)) if attrMode != "" { cloakMode = attrMode } if cloakCfg != nil { if mode := strings.TrimSpace(cloakCfg.Mode); mode != "" { cloakMode = mode + explicitMode = strings.ToLower(mode) } if cloakCfg.StrictMode { settings.strictMode = true @@ -1628,23 +1704,25 @@ func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey fp := resolveClaudeFingerprintPolicy(cfg, auth, apiKey) cloakConfigured := cloakCfg != nil || attrMode != "" || attrStrict || len(attrWords) > 0 || attrCache + trimmedMode := strings.ToLower(strings.TrimSpace(cloakMode)) + uncloak := !fp.ProfileClaudeCodeCLI && (explicitMode == "never" || explicitMode == "uncloak") policy := claudeWirePolicy{ OAuth: fp.AuthIsOAuthToken, ProfileClaudeCodeCLI: fp.ProfileClaudeCodeCLI, ConfirmedClaudeCode: confirmedClaudeCode, - Cloak: (fp.ProfileClaudeCodeCLI || cloakConfigured) && !confirmedClaudeCode, + Cloak: (fp.ProfileClaudeCodeCLI || cloakConfigured) && !confirmedClaudeCode && !uncloak, + Uncloak: uncloak, } if confirmedClaudeCode { - // Native Claude Code is always a passthrough client. An operator-level - // "always" mode may cloak unknown callers, but must not overwrite a - // strongly confirmed CLI, sdk-cli, or claude-vscode fingerprint. + // Native Claude Code is always a passthrough client unless the + // credential explicitly opts out of Claude Code identity via never/uncloak. policy.Cloak = false return policy, settings } - switch strings.ToLower(strings.TrimSpace(cloakMode)) { + switch trimmedMode { case "always": policy.Cloak = true - case "never": + case "never", "uncloak": policy.Cloak = false default: // Auto applies the CLI cloak only to real Claude OAuth credentials, @@ -1681,6 +1759,9 @@ func applyCloakingInternal( ) ([]byte, bool, error) { policy, settings := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode) if !policy.Cloak { + if policy.Uncloak { + payload = uncloakClaudeCodePayload(payload, confirmedClaudeCode) + } return payload, false, nil } isExplicit := (len(explicitCacheMode) > 0 && explicitCacheMode[0]) || isExplicitPromptCacheModeContext(ctx) diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 3c92af6b94..dd9f4b58d2 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -1144,7 +1144,7 @@ func claudeCredentialUsesOAuth(auth *cliproxyauth.Auth, apiKey string) bool { return !hasAPIKeyAttr } -func copyClaudeCallerFingerprintHeaders(dst, src http.Header, confirmedClaudeCode bool) { +func copyClaudeCallerFingerprintHeaders(dst, src http.Header, confirmedClaudeCode, uncloak bool) { if dst == nil || src == nil { return } @@ -1163,6 +1163,21 @@ func copyClaudeCallerFingerprintHeaders(dst, src http.Header, confirmedClaudeCod if !confirmedClaudeCode && (strings.HasPrefix(lowerName, "x-claude-code-") || strings.HasPrefix(lowerName, "x-claude-remote-")) { continue } + if uncloak { + if lowerName == "x-app" || + lowerName == "x-client-request-id" || + lowerName == "x-client-app" || + lowerName == "x-anthropic-additional-protection" || + lowerName == "anthropic-dangerous-direct-browser-access" || + strings.HasPrefix(lowerName, "x-stainless-") || + strings.HasPrefix(lowerName, "x-claude-code-") || + strings.HasPrefix(lowerName, "x-claude-remote-") { + continue + } + if lowerName == "user-agent" && len(values) > 0 && helps.IsClaudeCodeUserAgent(values[0]) { + continue + } + } dst.Del(name) for _, value := range values { dst.Add(name, value) @@ -1221,6 +1236,11 @@ func applyClaudeHeadersWithNativeProfile( useAPIKey := !credentialUsesBearer fp := resolveClaudeFingerprintPolicy(cfg, auth, apiKey) wirePolicy, _ := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode) + if wirePolicy.Uncloak { + confirmedClaudeCode = false + helperProfile = false + sessionIDs = nil + } applyCLIFingerprint := fp.ProfileClaudeCodeCLI || wirePolicy.Cloak preserveCallerFingerprint := !applyCLIFingerprint && !confirmedClaudeCode useOAuthBetas := fp.UseOAuthBetas @@ -1356,6 +1376,21 @@ func applyClaudeHeadersWithNativeProfile( } } applyBetaHeader := func() { + if wirePolicy.Uncloak { + baseBetas = withoutClaudeBeta(baseBetas, claudeCodeBeta) + baseBetas = withoutClaudeBeta(baseBetas, claudeOAuthBeta) + baseBetas = withoutClaudeBeta(baseBetas, claudeRedactThinkingBeta) + baseBetas = withoutClaudeBeta(baseBetas, claudeAFKModeBeta) + if !gjson.GetBytes(body, "diagnostics").Exists() { + baseBetas = withoutClaudeBeta(baseBetas, claudeCacheDiagnosisBeta) + } + if !gjson.GetBytes(body, "fallback_credit_token").Exists() { + baseBetas = withoutClaudeBeta(baseBetas, claudeFallbackCreditBeta) + } + if !helps.ClaudePayloadHas1hTTL(body) { + baseBetas = withoutClaudeBeta(baseBetas, claudeExtendedCacheTTLBeta) + } + } // Enforce strict native Claude Code 2.1.280 model & turn beta gating: if !claudeRequestSupportsEffort(body, nil) { baseBetas = withoutClaudeBeta(baseBetas, claudeEffortBeta) @@ -1412,7 +1447,7 @@ func applyClaudeHeadersWithNativeProfile( defaultAccept = "text/event-stream" defaultAcceptEncoding = "identity" } - copyClaudeCallerFingerprintHeaders(r.Header, incomingHeaders, confirmedClaudeCode) + copyClaudeCallerFingerprintHeaders(r.Header, incomingHeaders, confirmedClaudeCode, wirePolicy.Uncloak) if hd.ClientRequestID == config.ClaudeClientRequestIDOff { r.Header.Del("X-Client-Request-Id") } @@ -1423,7 +1458,11 @@ func applyClaudeHeadersWithNativeProfile( // sent none must not fall through to Go's transport default // ("Go-http-client/1.1"), which upstreams read as a bot signature. Identify // as CPA instead: honest about the hop, and not a fabricated client. - misc.EnsureHeader(r.Header, incomingHeaders, "User-Agent", "CLIProxyAPI/"+buildinfo.Version) + uaSourceHeaders := incomingHeaders + if wirePolicy.Uncloak && helps.IsClaudeCodeUserAgent(helps.HeaderValueCaseInsensitive(incomingHeaders, "User-Agent")) { + uaSourceHeaders = nil + } + misc.EnsureHeader(r.Header, uaSourceHeaders, "User-Agent", "CLIProxyAPI/"+buildinfo.Version) applyBetaHeader() sessionID := "" for _, candidate := range sessionIDs { diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go index fc5011d260..8d2c9c0773 100644 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -200,6 +200,8 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy if len(settings.sensitiveWords) > 0 { body = helps.ObfuscateSensitiveWords(body, helps.BuildSensitiveWordMatcher(settings.sensitiveWords)) } + } else if policy.Uncloak { + body = uncloakClaudeCodePayload(body, confirmedClaudeCode) } // Keep count_tokens requests compatible with Anthropic cache-control constraints too. diff --git a/internal/runtime/executor/claude_fingerprint_policy_test.go b/internal/runtime/executor/claude_fingerprint_policy_test.go index c56fcc9ff2..b071dda46b 100644 --- a/internal/runtime/executor/claude_fingerprint_policy_test.go +++ b/internal/runtime/executor/claude_fingerprint_policy_test.go @@ -1243,3 +1243,294 @@ func claudeFingerprintHeaderValue(headers http.Header, name string) string { } return "" } + +func TestClaudeExecutor_OfficialAPIKeyCloakNeverStripsClaudeCodeIdentity(t *testing.T) { + const sessionID = "11111111-2222-4333-8444-555555555555" + const userID = `{"device_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","account_uuid":"11111111-2222-4333-8444-555555555555","session_id":"11111111-2222-4333-8444-555555555555"}` + const billing = "x-anthropic-billing-header: cc_version=2.1.280.d7b; cc_entrypoint=cli; cch=00000; cc_turn_origin=human;" + incoming := http.Header{ + "User-Agent": {"claude-cli/2.1.280 (external, cli)"}, + "X-App": {"cli"}, + "Anthropic-Beta": {"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24,cache-diagnosis-2026-04-07"}, + "Anthropic-Dangerous-Direct-Browser-Access": {"true"}, + "X-Claude-Code-Session-Id": {sessionID}, + "X-Claude-Code-Request-Class": {"main"}, + "X-Stainless-Package-Version": {"0.112.1"}, + "X-Stainless-Runtime-Version": {"v26.3.0"}, + "X-Stainless-Os": {"MacOS"}, + "X-Stainless-Arch": {"arm64"}, + } + payload := []byte( + `{"model":"claude-opus-4-6","max_tokens":64,"system":[{"type":"text","text":"` + billing + `"},{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude.","cache_control":{"type":"ephemeral"}},{"type":"text","text":"You are an interactive CLI tool that helps users with software engineering tasks.","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"hello"}],"metadata":{"user_id":` + `"` + strings.ReplaceAll(userID, `"`, `\"`) + `"` + `},"diagnostics":{"previous_message_id":null},"thinking":{"type":"adaptive"}}`, + ) + if detection := helps.DetectClaudeCodeRequest(incoming, payload, false); !detection.Confirmed { + t.Fatal("fixture must be classified as a confirmed native Claude Code request") + } + + for _, tc := range []struct { + name string + mode string + customUA string + stream bool + countTokens bool + wantUserAgent string + wantBetaSubstr string + }{ + { + name: "execute cloak never default user-agent", + mode: "never", + wantUserAgent: "CLIProxyAPI/", + wantBetaSubstr: "interleaved-thinking-2025-05-14", + }, + { + name: "execute cloak never custom user-agent override", + mode: "never", + customUA: "my-api-client/1.0", + wantUserAgent: "my-api-client/1.0", + wantBetaSubstr: "interleaved-thinking-2025-05-14", + }, + { + name: "stream cloak never custom user-agent override", + mode: "never", + customUA: "my-api-client/1.0", + stream: true, + wantUserAgent: "my-api-client/1.0", + wantBetaSubstr: "interleaved-thinking-2025-05-14", + }, + { + name: "execute cloak uncloak mode", + mode: "uncloak", + customUA: "my-api-client/1.0", + wantUserAgent: "my-api-client/1.0", + wantBetaSubstr: "interleaved-thinking-2025-05-14", + }, + { + name: "count_tokens cloak never", + mode: "never", + customUA: "my-api-client/1.0", + countTokens: true, + wantUserAgent: "my-api-client/1.0", + wantBetaSubstr: "token-counting-2024-11-01", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenBody, _ = io.ReadAll(req.Body) + seenHeaders = req.Header.Clone() + if tc.countTokens { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"input_tokens":12}`)), + }, nil + } + if tc.stream { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + )), + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"msg_1","type":"message","model":"claude-opus-4-6","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }, nil + }) + ctx := context.WithValue( + context.Background(), + "cliproxy.roundtripper", + http.RoundTripper(transport), + ) + + var customHeaders map[string]string + attrs := map[string]string{ + "api_key": "sk-ant-api03-promotional-key", + } + if tc.customUA != "" { + customHeaders = map[string]string{"User-Agent": tc.customUA} + attrs["header:User-Agent"] = tc.customUA + } + cfg := &config.Config{ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-api03-promotional-key", + Cloak: &config.CloakConfig{Mode: tc.mode}, + Headers: customHeaders, + }}} + auth := &cliproxyauth.Auth{ + ID: "official-promotional-key", + Attributes: attrs, + } + exec := NewClaudeExecutor(cfg) + req := cliproxyexecutor.Request{ + Model: "claude-opus-4-6", + Payload: payload, + } + opts := cliproxyexecutor.Options{ + Stream: tc.stream, + SourceFormat: sdktranslator.FormatClaude, + OriginalRequest: payload, + Headers: incoming.Clone(), + } + + if tc.countTokens { + if _, errCount := exec.CountTokens(ctx, auth, req, opts); errCount != nil { + t.Fatalf("CountTokens() error = %v", errCount) + } + } else if tc.stream { + res, errStream := exec.ExecuteStream(ctx, auth, req, opts) + if errStream != nil { + t.Fatalf("ExecuteStream() error = %v", errStream) + } + for chunk := range res.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error = %v", chunk.Err) + } + } + } else { + if _, errExec := exec.Execute(ctx, auth, req, opts); errExec != nil { + t.Fatalf("Execute() error = %v", errExec) + } + } + + if strings.Contains(string(seenBody), "x-anthropic-billing-header:") || strings.Contains(string(seenBody), "cch=") { + t.Fatalf("upstream body still contains Claude Code billing header: %s", seenBody) + } + if strings.Contains(string(seenBody), claudeCodeCLIIdentity) { + t.Fatalf("upstream body still contains Claude Code CLI identity block: %s", seenBody) + } + if got := gjson.GetBytes(seenBody, "system.#").Int(); got != 1 { + t.Fatalf("system block count = %d, want 1 preserved instruction block: %s", got, seenBody) + } + if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != "You are an interactive CLI tool that helps users with software engineering tasks." { + t.Fatalf("system.0.text = %q, want preserved caller instructions", got) + } + if gjson.GetBytes(seenBody, "metadata").Exists() { + t.Fatalf("metadata = %s, want Claude Code metadata stripped", gjson.GetBytes(seenBody, "metadata").Raw) + } + if gjson.GetBytes(seenBody, "diagnostics").Exists() { + t.Fatalf("diagnostics = %s, want Claude Code diagnostics stripped", gjson.GetBytes(seenBody, "diagnostics").Raw) + } + + ua := claudeFingerprintHeaderValue(seenHeaders, "User-Agent") + if strings.HasPrefix(strings.ToLower(ua), "claude-cli/") { + t.Fatalf("User-Agent = %q, must not forward claude-cli user-agent", ua) + } + if !strings.HasPrefix(ua, tc.wantUserAgent) { + t.Fatalf("User-Agent = %q, want prefix %q", ua, tc.wantUserAgent) + } + for _, forbiddenHdr := range []string{ + "X-App", + "Anthropic-Dangerous-Direct-Browser-Access", + "X-Claude-Code-Session-Id", + "X-Claude-Code-Request-Class", + "X-Stainless-Runtime", + "X-Stainless-Lang", + "X-Stainless-Package-Version", + } { + if got := claudeFingerprintHeaderValue(seenHeaders, forbiddenHdr); got != "" { + t.Fatalf("header %s = %q, want stripped", forbiddenHdr, got) + } + } + + betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta") + for _, forbiddenBeta := range []string{ + "claude-code-20250219", + "oauth-2025-04-20", + "redact-thinking-2026-02-12", + "cache-diagnosis-2026-04-07", + } { + if strings.Contains(betas, forbiddenBeta) { + t.Fatalf("Anthropic-Beta = %q, want %q stripped", betas, forbiddenBeta) + } + } + if !strings.Contains(betas, tc.wantBetaSubstr) { + t.Fatalf("Anthropic-Beta = %q, want capability beta %q preserved", betas, tc.wantBetaSubstr) + } + }) + } +} + +func TestClaudeExecutor_GlobalDisableClaudeCloakModePreservesConfirmedCallerIdentityWhenKeyUnconfigured(t *testing.T) { + const sessionID = "11111111-2222-4333-8444-555555555555" + const userID = `{"device_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","account_uuid":"11111111-2222-4333-8444-555555555555","session_id":"11111111-2222-4333-8444-555555555555"}` + const billing = "x-anthropic-billing-header: cc_version=2.1.280.d7b; cc_entrypoint=cli; cch=00000; cc_turn_origin=human;" + incoming := http.Header{ + "User-Agent": {"claude-cli/2.1.280 (external, cli)"}, + "X-App": {"cli"}, + "Anthropic-Beta": {"claude-code-20250219,interleaved-thinking-2025-05-14"}, + "Anthropic-Dangerous-Direct-Browser-Access": {"true"}, + "X-Claude-Code-Session-Id": {sessionID}, + "X-Stainless-Package-Version": {"0.112.1"}, + "X-Stainless-Runtime-Version": {"v26.3.0"}, + "X-Stainless-Os": {"MacOS"}, + "X-Stainless-Arch": {"arm64"}, + } + payload := []byte( + `{"model":"claude-opus-4-6","max_tokens":64,"system":[{"type":"text","text":"` + billing + `"},{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude.","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"hello"}],"metadata":{"user_id":` + `"` + strings.ReplaceAll(userID, `"`, `\"`) + `"` + `}}`, + ) + + var seenBody []byte + var seenHeaders http.Header + transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenBody, _ = io.ReadAll(req.Body) + seenHeaders = req.Header.Clone() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"msg_1","type":"message","model":"claude-opus-4-6","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }, nil + }) + ctx := context.WithValue( + context.Background(), + "cliproxy.roundtripper", + http.RoundTripper(transport), + ) + + cfg := &config.Config{ + DisableClaudeCloakMode: true, + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-api03-global-disable-passthrough", + }}, + } + auth := &cliproxyauth.Auth{ + ID: "official-global-disable-passthrough", + Attributes: map[string]string{ + "api_key": "sk-ant-api03-global-disable-passthrough", + }, + } + + if _, errExec := NewClaudeExecutor(cfg).Execute(ctx, auth, cliproxyexecutor.Request{ + Model: "claude-opus-4-6", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + OriginalRequest: payload, + Headers: incoming, + }); errExec != nil { + t.Fatalf("Execute() error = %v", errExec) + } + + if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != billing { + t.Fatalf("system.0.text = %q, want preserved caller billing header %q", got, billing) + } + if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != claudeCodeCLIIdentity { + t.Fatalf("system.1.text = %q, want preserved caller CLI identity", got) + } + if got := gjson.GetBytes(seenBody, "metadata.user_id").String(); got != userID { + t.Fatalf("metadata.user_id = %q, want preserved %q", got, userID) + } + if got := claudeFingerprintHeaderValue(seenHeaders, "X-App"); got != "cli" { + t.Fatalf("X-App = %q, want preserved cli", got) + } + if got := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta"); !strings.Contains(got, "claude-code-20250219") { + t.Fatalf("Anthropic-Beta = %q, want preserved claude-code-20250219", got) + } +} diff --git a/internal/runtime/executor/helps/claude_client_detection.go b/internal/runtime/executor/helps/claude_client_detection.go index 6d5a4c0dfd..e103f8b9ae 100644 --- a/internal/runtime/executor/helps/claude_client_detection.go +++ b/internal/runtime/executor/helps/claude_client_detection.go @@ -496,6 +496,11 @@ func claudeJSONObjectHasKeys(raw []byte, want []string) bool { return errClosing == nil && closing == json.Delim('}') && keyIndex == len(want) } +// IsClaudeCodeUserAgent reports whether userAgent identifies a Claude Code CLI client. +func IsClaudeCodeUserAgent(userAgent string) bool { + return claudeCodeUserAgentPattern.MatchString(strings.TrimSpace(userAgent)) +} + func plausibleClaudeCodeUserAgent(userAgent string, cfg *config.Config) bool { userAgent = strings.TrimSpace(userAgent) if !claudeCodeUserAgentPattern.MatchString(userAgent) || !claudeCodeNativeUserAgentPattern.MatchString(userAgent) {