diff --git a/config.example.yaml b/config.example.yaml index 332fba705..a5ca875cd 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -134,6 +134,15 @@ ws-auth: false # - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219) # - "*-thinking" # wildcard matching suffix (e.g. claude-opus-4-5-thinking) # - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022) +# cloak: # optional: request cloaking for non-Claude-Code clients +# mode: "auto" # "auto" (default): cloak only when client is not Claude Code +# # "always": always apply cloaking +# # "never": never apply cloaking +# strict-mode: false # false (default): prepend Claude Code prompt to user system messages +# # true: strip all user system messages, keep only Claude Code prompt +# sensitive-words: # optional: words to obfuscate with zero-width characters +# - "API" +# - "proxy" # OpenAI compatibility providers # openai-compatibility: diff --git a/internal/config/config.go b/internal/config/config.go index e8ae3554f..0b327a6cf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -236,6 +236,25 @@ type PayloadModelRule struct { Protocol string `yaml:"protocol" json:"protocol"` } +// 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". + // - "auto": cloak only when client is not Claude Code (based on User-Agent) + // - "always": always apply cloaking regardless of client + // - "never": never apply cloaking + Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` + + // StrictMode controls how system prompts are handled when cloaking. + // - false (default): prepend Claude Code prompt to user system messages + // - true: strip all user system messages, keep only Claude Code prompt + StrictMode bool `yaml:"strict-mode,omitempty" json:"strict-mode,omitempty"` + + // SensitiveWords is a list of words to obfuscate with zero-width characters. + // This can help bypass certain content filters. + SensitiveWords []string `yaml:"sensitive-words,omitempty" json:"sensitive-words,omitempty"` +} + // ClaudeKey represents the configuration for a Claude API key, // including the API key itself and an optional base URL for the API endpoint. type ClaudeKey struct { @@ -260,6 +279,9 @@ type ClaudeKey struct { // ExcludedModels lists model IDs that should be excluded for this provider. ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` + + // Cloak configures request cloaking for non-Claude-Code clients. + Cloak *CloakConfig `yaml:"cloak,omitempty" json:"cloak,omitempty"` } // ClaudeModel describes a mapping between an alias and the actual upstream model name. diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 7be4f41bd..49263c732 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -67,9 +67,10 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Inject thinking config based on model metadata for thinking variants body = e.injectThinkingConfig(model, req.Metadata, body) - if !strings.HasPrefix(model, "claude-3-5-haiku") { - body = checkSystemInstructions(body) - } + // Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation) + // based on client type and configuration + body = applyCloaking(ctx, e.cfg, auth, body, model) + body = applyPayloadConfigWithRoot(e.cfg, model, to.String(), "", body, originalTranslated) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) @@ -181,7 +182,11 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A body, _ = sjson.SetBytes(body, "model", model) // Inject thinking config based on model metadata for thinking variants body = e.injectThinkingConfig(model, req.Metadata, body) - body = checkSystemInstructions(body) + + // Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation) + // based on client type and configuration + body = applyCloaking(ctx, e.cfg, auth, body, model) + body = applyPayloadConfigWithRoot(e.cfg, model, to.String(), "", body, originalTranslated) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) @@ -770,3 +775,164 @@ func checkSystemInstructions(payload []byte) []byte { } return payload } + +// getClientUserAgent extracts the client User-Agent from the gin context. +func getClientUserAgent(ctx context.Context) string { + if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { + return ginCtx.GetHeader("User-Agent") + } + return "" +} + +// getCloakConfigFromAuth extracts cloak configuration from auth attributes. +// Returns (cloakMode, strictMode, sensitiveWords). +func getCloakConfigFromAuth(auth *cliproxyauth.Auth) (string, bool, []string) { + if auth == nil || auth.Attributes == nil { + return "auto", false, nil + } + + cloakMode := auth.Attributes["cloak_mode"] + if cloakMode == "" { + cloakMode = "auto" + } + + strictMode := strings.ToLower(auth.Attributes["cloak_strict_mode"]) == "true" + + var sensitiveWords []string + if wordsStr := auth.Attributes["cloak_sensitive_words"]; wordsStr != "" { + sensitiveWords = strings.Split(wordsStr, ",") + for i := range sensitiveWords { + sensitiveWords[i] = strings.TrimSpace(sensitiveWords[i]) + } + } + + return cloakMode, strictMode, sensitiveWords +} + +// resolveClaudeKeyCloakConfig finds the matching ClaudeKey config and returns its CloakConfig. +func resolveClaudeKeyCloakConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.CloakConfig { + if cfg == nil || auth == nil { + return nil + } + + apiKey, baseURL := claudeCreds(auth) + if apiKey == "" { + return nil + } + + for i := range cfg.ClaudeKey { + entry := &cfg.ClaudeKey[i] + cfgKey := strings.TrimSpace(entry.APIKey) + cfgBase := strings.TrimSpace(entry.BaseURL) + + // Match by API key + if strings.EqualFold(cfgKey, apiKey) { + // If baseURL is specified, also check it + if baseURL != "" && cfgBase != "" && !strings.EqualFold(cfgBase, baseURL) { + continue + } + return entry.Cloak + } + } + + return nil +} + +// injectFakeUserID generates and injects a fake user ID into the request metadata. +func injectFakeUserID(payload []byte) []byte { + metadata := gjson.GetBytes(payload, "metadata") + if !metadata.Exists() { + payload, _ = sjson.SetBytes(payload, "metadata.user_id", generateFakeUserID()) + return payload + } + + existingUserID := gjson.GetBytes(payload, "metadata.user_id").String() + if existingUserID == "" || !isValidUserID(existingUserID) { + payload, _ = sjson.SetBytes(payload, "metadata.user_id", generateFakeUserID()) + } + return payload +} + +// checkSystemInstructionsWithMode injects Claude Code system prompt. +// In strict mode, it replaces all user system messages. +// In non-strict mode (default), it prepends to existing system messages. +func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { + system := gjson.GetBytes(payload, "system") + claudeCodeInstructions := `[{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."}]` + + if strictMode { + // Strict mode: replace all system messages with Claude Code prompt only + payload, _ = sjson.SetRawBytes(payload, "system", []byte(claudeCodeInstructions)) + return payload + } + + // Non-strict mode (default): prepend Claude Code prompt to existing system messages + if system.IsArray() { + if gjson.GetBytes(payload, "system.0.text").String() != "You are Claude Code, Anthropic's official CLI for Claude." { + system.ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "text" { + claudeCodeInstructions, _ = sjson.SetRaw(claudeCodeInstructions, "-1", part.Raw) + } + return true + }) + payload, _ = sjson.SetRawBytes(payload, "system", []byte(claudeCodeInstructions)) + } + } else { + payload, _ = sjson.SetRawBytes(payload, "system", []byte(claudeCodeInstructions)) + } + return payload +} + +// applyCloaking applies cloaking transformations to the payload based on config and client. +// Cloaking includes: system prompt injection, fake user ID, and sensitive word obfuscation. +func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string) []byte { + clientUserAgent := getClientUserAgent(ctx) + + // Get cloak config from ClaudeKey configuration + cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth) + + // Determine cloak settings + var cloakMode string + var strictMode bool + var sensitiveWords []string + + if cloakCfg != nil { + cloakMode = cloakCfg.Mode + strictMode = cloakCfg.StrictMode + sensitiveWords = cloakCfg.SensitiveWords + } + + // Fallback to auth attributes if no config found + if cloakMode == "" { + attrMode, attrStrict, attrWords := getCloakConfigFromAuth(auth) + cloakMode = attrMode + if !strictMode { + strictMode = attrStrict + } + if len(sensitiveWords) == 0 { + sensitiveWords = attrWords + } + } + + // Determine if cloaking should be applied + if !shouldCloak(cloakMode, clientUserAgent) { + return payload + } + + // Skip system instructions for claude-3-5-haiku models + if !strings.HasPrefix(model, "claude-3-5-haiku") { + payload = checkSystemInstructionsWithMode(payload, strictMode) + } + + // Inject fake user ID + payload = injectFakeUserID(payload) + + // Apply sensitive word obfuscation + if len(sensitiveWords) > 0 { + matcher := buildSensitiveWordMatcher(sensitiveWords) + payload = obfuscateSensitiveWords(payload, matcher) + } + + return payload +} + diff --git a/internal/runtime/executor/cloak_obfuscate.go b/internal/runtime/executor/cloak_obfuscate.go new file mode 100644 index 000000000..81781802a --- /dev/null +++ b/internal/runtime/executor/cloak_obfuscate.go @@ -0,0 +1,176 @@ +package executor + +import ( + "regexp" + "sort" + "strings" + "unicode/utf8" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// zeroWidthSpace is the Unicode zero-width space character used for obfuscation. +const zeroWidthSpace = "\u200B" + +// SensitiveWordMatcher holds the compiled regex for matching sensitive words. +type SensitiveWordMatcher struct { + regex *regexp.Regexp +} + +// buildSensitiveWordMatcher compiles a regex from the word list. +// Words are sorted by length (longest first) for proper matching. +func buildSensitiveWordMatcher(words []string) *SensitiveWordMatcher { + if len(words) == 0 { + return nil + } + + // Filter and normalize words + var validWords []string + for _, w := range words { + w = strings.TrimSpace(w) + if utf8.RuneCountInString(w) >= 2 && !strings.Contains(w, zeroWidthSpace) { + validWords = append(validWords, w) + } + } + + if len(validWords) == 0 { + return nil + } + + // Sort by length (longest first) for proper matching + sort.Slice(validWords, func(i, j int) bool { + return len(validWords[i]) > len(validWords[j]) + }) + + // Escape and join + escaped := make([]string, len(validWords)) + for i, w := range validWords { + escaped[i] = regexp.QuoteMeta(w) + } + + pattern := "(?i)" + strings.Join(escaped, "|") + re, err := regexp.Compile(pattern) + if err != nil { + return nil + } + + return &SensitiveWordMatcher{regex: re} +} + +// obfuscateWord inserts a zero-width space after the first grapheme. +func obfuscateWord(word string) string { + if strings.Contains(word, zeroWidthSpace) { + return word + } + + // Get first rune + r, size := utf8.DecodeRuneInString(word) + if r == utf8.RuneError || size >= len(word) { + return word + } + + return string(r) + zeroWidthSpace + word[size:] +} + +// obfuscateText replaces all sensitive words in the text. +func (m *SensitiveWordMatcher) obfuscateText(text string) string { + if m == nil || m.regex == nil { + return text + } + return m.regex.ReplaceAllStringFunc(text, obfuscateWord) +} + +// obfuscateSensitiveWords processes the payload and obfuscates sensitive words +// in system blocks and message content. +func obfuscateSensitiveWords(payload []byte, matcher *SensitiveWordMatcher) []byte { + if matcher == nil || matcher.regex == nil { + return payload + } + + // Obfuscate in system blocks + payload = obfuscateSystemBlocks(payload, matcher) + + // Obfuscate in messages + payload = obfuscateMessages(payload, matcher) + + return payload +} + +// obfuscateSystemBlocks obfuscates sensitive words in system blocks. +func obfuscateSystemBlocks(payload []byte, matcher *SensitiveWordMatcher) []byte { + system := gjson.GetBytes(payload, "system") + if !system.Exists() { + return payload + } + + if system.IsArray() { + modified := false + system.ForEach(func(key, value gjson.Result) bool { + if value.Get("type").String() == "text" { + text := value.Get("text").String() + obfuscated := matcher.obfuscateText(text) + if obfuscated != text { + path := "system." + key.String() + ".text" + payload, _ = sjson.SetBytes(payload, path, obfuscated) + modified = true + } + } + return true + }) + if modified { + return payload + } + } else if system.Type == gjson.String { + text := system.String() + obfuscated := matcher.obfuscateText(text) + if obfuscated != text { + payload, _ = sjson.SetBytes(payload, "system", obfuscated) + } + } + + return payload +} + +// obfuscateMessages obfuscates sensitive words in message content. +func obfuscateMessages(payload []byte, matcher *SensitiveWordMatcher) []byte { + messages := gjson.GetBytes(payload, "messages") + if !messages.Exists() || !messages.IsArray() { + return payload + } + + messages.ForEach(func(msgKey, msg gjson.Result) bool { + content := msg.Get("content") + if !content.Exists() { + return true + } + + msgPath := "messages." + msgKey.String() + + if content.Type == gjson.String { + // Simple string content + text := content.String() + obfuscated := matcher.obfuscateText(text) + if obfuscated != text { + payload, _ = sjson.SetBytes(payload, msgPath+".content", obfuscated) + } + } else if content.IsArray() { + // Array of content blocks + content.ForEach(func(blockKey, block gjson.Result) bool { + if block.Get("type").String() == "text" { + text := block.Get("text").String() + obfuscated := matcher.obfuscateText(text) + if obfuscated != text { + path := msgPath + ".content." + blockKey.String() + ".text" + payload, _ = sjson.SetBytes(payload, path, obfuscated) + } + } + return true + }) + } + + return true + }) + + return payload +} diff --git a/internal/runtime/executor/cloak_utils.go b/internal/runtime/executor/cloak_utils.go new file mode 100644 index 000000000..560ff8806 --- /dev/null +++ b/internal/runtime/executor/cloak_utils.go @@ -0,0 +1,47 @@ +package executor + +import ( + "crypto/rand" + "encoding/hex" + "regexp" + "strings" + + "github.com/google/uuid" +) + +// userIDPattern matches Claude Code format: user_[64-hex]_account__session_[uuid-v4] +var userIDPattern = regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// generateFakeUserID generates a fake user ID in Claude Code format. +// Format: user_[64-hex-chars]_account__session_[UUID-v4] +func generateFakeUserID() string { + hexBytes := make([]byte, 32) + _, _ = rand.Read(hexBytes) + hexPart := hex.EncodeToString(hexBytes) + uuidPart := uuid.New().String() + return "user_" + hexPart + "_account__session_" + uuidPart +} + +// isValidUserID checks if a user ID matches Claude Code format. +func isValidUserID(userID string) bool { + return userIDPattern.MatchString(userID) +} + +// shouldCloak determines if request should be cloaked based on config and client User-Agent. +// Returns true if cloaking should be applied. +func shouldCloak(cloakMode string, userAgent string) bool { + switch strings.ToLower(cloakMode) { + case "always": + return true + case "never": + return false + default: // "auto" or empty + // If client is Claude Code, don't cloak + return !strings.HasPrefix(userAgent, "claude-cli") + } +} + +// isClaudeCodeClient checks if the User-Agent indicates a Claude Code client. +func isClaudeCodeClient(userAgent string) bool { + return strings.HasPrefix(userAgent, "claude-cli") +}