fix(anthropic): recognize auto mode classifier

This commit is contained in:
wucm667
2026-07-29 16:55:42 +08:00
parent 5a6143097d
commit 54b1f8f6b3
2 changed files with 185 additions and 2 deletions
@@ -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",
"- `<transcript>`:",
"## HARD BLOCK",
"## SOFT BLOCK",
"## Classification Process",
"## Output Format",
"<block>yes</block><reason>",
"<block>no</block>",
}
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)
@@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
@@ -135,6 +136,139 @@ 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: "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))
body["system"] = append(body["system"].([]any), 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 的