diff --git a/backend/internal/service/claude_code_validator.go b/backend/internal/service/claude_code_validator.go index 923f30b7cc..52f351f1b3 100644 --- a/backend/internal/service/claude_code_validator.go +++ b/backend/internal/service/claude_code_validator.go @@ -48,9 +48,14 @@ var claudeCodeSystemPrompts = []string{ } const ( + // These markers identify Claude Code's official security-monitor classifier + // request without coupling validation to every wording change in the prompt. + claudeCodeSecurityMonitorPromptPrefix = "You are a security monitor for autonomous AI coding agents." + claudeCodeSecurityMonitorPromptMinLen = 10_000 + // claudeCodeBillingHeaderPrefix 是 Claude Code 在 system 数组首块注入的计费归因块前缀。 - // 该块存在于所有真实 Claude Code CLI 请求中(含安全监视器等无身份 prose 的子请求), - // 格式固定、不随提示词改版漂移,是比身份 prose 更稳定的客户端标识。 + // 大多数真实 CLI 请求(含部分无身份 prose 的子请求)会携带该块;不携带该块的 + // 固定官方辅助请求由独立规则识别。该格式比身份 prose 更稳定。 // 生成见 gateway_billing_block.go;同类识别见 pkg/apicompat/anthropic_to_responses.go。 claudeCodeBillingHeaderPrefix = "x-anthropic-billing-header" // claudeCodeEntrypointMarker 标识计费块携带入口归因字段。不绑定具体入口值 @@ -167,6 +172,10 @@ func (v *ClaudeCodeValidator) hasClaudeCodeSystemPrompt(body map[string]any) boo return false } + if isClaudeCodeSecurityMonitorPrompt(systemEntries) { + return true + } + // 检查每个 system entry for _, entry := range systemEntries { entryMap, ok := entry.(map[string]any) @@ -196,6 +205,46 @@ func (v *ClaudeCodeValidator) hasClaudeCodeSystemPrompt(body map[string]any) boo return false } +func isClaudeCodeSecurityMonitorPrompt(systemEntries []any) bool { + if len(systemEntries) != 1 { + return false + } + + entry, ok := systemEntries[0].(map[string]any) + if !ok { + return false + } + + entryType, ok := entry["type"].(string) + if !ok || entryType != "text" { + return false + } + + text, ok := entry["text"].(string) + if !ok || len(text) < claudeCodeSecurityMonitorPromptMinLen || + !strings.HasPrefix(text, claudeCodeSecurityMonitorPromptPrefix) { + return false + } + + markers := []string{ + "## Threat Model", + "- ``:", + "## HARD BLOCK", + "## SOFT BLOCK", + "## Classification Process", + "## Output Format", + "yes", + "no", + } + for _, marker := range markers { + if !strings.Contains(text, marker) { + return false + } + } + + return true +} + // bestSimilarityScore 计算文本与所有 Claude Code 模板的最佳相似度 func (v *ClaudeCodeValidator) bestSimilarityScore(text string) float64 { normalizedText := normalizePrompt(text) diff --git a/backend/internal/service/claude_code_validator_test.go b/backend/internal/service/claude_code_validator_test.go index d54177d132..8fd0f2126c 100644 --- a/backend/internal/service/claude_code_validator_test.go +++ b/backend/internal/service/claude_code_validator_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" @@ -135,6 +136,152 @@ func TestClaudeCodeValidator_BillingBlockRecognizedWithoutIdentityPrompt(t *test require.True(t, ok) } +func TestClaudeCodeValidator_SecurityMonitorWithoutBillingBlock(t *testing.T) { + monitorPrompt, err := os.ReadFile("testdata/security_monitor_system_prompt.txt") + require.NoError(t, err) + + validHeaders := map[string]string{ + "User-Agent": "claude-cli/2.1.220 (external, cli)", + "X-App": "cli", + "anthropic-beta": "claude-code-20250219", + "anthropic-version": "2023-06-01", + } + validBody := func(prompt string) map[string]any { + return map[string]any{ + "model": "claude-haiku-4-5-20251001", + "system": []any{ + map[string]any{"type": "text", "text": prompt}, + }, + "metadata": map[string]any{"user_id": claudeCodeMetadataUserIDJSON}, + } + } + + tests := []struct { + name string + headers map[string]string + body map[string]any + wantAccept bool + }{ + { + name: "official classifier request", + headers: validHeaders, + body: validBody(string(monitorPrompt)), + wantAccept: true, + }, + { + name: "classifier output with category element", + headers: validHeaders, + body: validBody(strings.Replace( + string(monitorPrompt), + "yes", + "yesExact BLOCK Rule Name", + 1, + )), + wantAccept: true, + }, + { + name: "non-Claude user agent", + headers: map[string]string{ + "User-Agent": "curl/8.0.0", + "X-App": "cli", + "anthropic-beta": "claude-code-20250219", + "anthropic-version": "2023-06-01", + }, + body: validBody(string(monitorPrompt)), + }, + { + name: "missing X-App", + headers: map[string]string{ + "User-Agent": validHeaders["User-Agent"], + "anthropic-beta": validHeaders["anthropic-beta"], + "anthropic-version": validHeaders["anthropic-version"], + }, + body: validBody(string(monitorPrompt)), + }, + { + name: "missing anthropic-beta", + headers: map[string]string{ + "User-Agent": validHeaders["User-Agent"], + "X-App": validHeaders["X-App"], + "anthropic-version": validHeaders["anthropic-version"], + }, + body: validBody(string(monitorPrompt)), + }, + { + name: "missing anthropic-version", + headers: map[string]string{ + "User-Agent": validHeaders["User-Agent"], + "X-App": validHeaders["X-App"], + "anthropic-beta": validHeaders["anthropic-beta"], + }, + body: validBody(string(monitorPrompt)), + }, + { + name: "missing metadata", + headers: validHeaders, + body: map[string]any{ + "model": "claude-haiku-4-5-20251001", + "system": []any{map[string]any{"type": "text", "text": string(monitorPrompt)}}, + }, + }, + { + name: "invalid metadata user ID", + headers: validHeaders, + body: func() map[string]any { + body := validBody(string(monitorPrompt)) + body["metadata"] = map[string]any{"user_id": "invalid"} + return body + }(), + }, + { + name: "unrelated prompt", + headers: validHeaders, + body: validBody("You are a different security classifier for coding agents."), + wantAccept: false, + }, + { + name: "opening sentence alone", + headers: validHeaders, + body: validBody(claudeCodeSecurityMonitorPromptPrefix), + wantAccept: false, + }, + { + name: "opening sentence plus arbitrary altered suffix", + headers: validHeaders, + body: validBody(claudeCodeSecurityMonitorPromptPrefix + "\n\n" + + strings.Repeat("This is arbitrary altered classifier content. ", 300)), + wantAccept: false, + }, + { + name: "multiple system entries without billing block", + headers: validHeaders, + body: func() map[string]any { + body := validBody(string(monitorPrompt)) + system, ok := body["system"].([]any) + require.True(t, ok) + body["system"] = append(system, map[string]any{ + "type": "text", + "text": "Additional unrelated system content.", + }) + return body + }(), + wantAccept: false, + }, + } + + validator := NewClaudeCodeValidator() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil) + for name, value := range tt.headers { + req.Header.Set(name, value) + } + + require.Equal(t, tt.wantAccept, validator.Validate(req, tt.body)) + }) + } +} + func TestClaudeCodeValidator_BillingBlockVSCodeEntrypointRecognized(t *testing.T) { // 回归:Claude Code 在 VSCode 扩展内运行时,计费块入口为 cc_entrypoint=claude-vscode // 而非 cli。其安全监视器子请求同样不携带身份 prose,此前写死 cc_entrypoint=cli 的