fix(anthropic): recognize classifier requests with extra system entries

Real claude-cli/2.1.220 auto-mode classifier requests carry two system
entries: the security-monitor prompt plus an appended session-context
block. The previous len(systemEntries) != 1 guard rejected them before
any content check ran, so claude_code_only groups kept refusing the
classifier (#5152, follow-up to #5041/#5048).

Scan every entry for the monitor prompt instead of requiring exactly
one. Discrimination is unchanged: the matching entry still needs the
10k-char minimum, the fixed prefix, and all eight markers.
This commit is contained in:
shaw
2026-08-01 10:32:43 +08:00
parent dd9a177a62
commit 2ef1246295
2 changed files with 87 additions and 31 deletions
@@ -205,43 +205,54 @@ func (v *ClaudeCodeValidator) hasClaudeCodeSystemPrompt(body map[string]any) boo
return false
}
// claudeCodeSecurityMonitorMarkers 与固定前缀、长度下限共同构成分类器提示词的
// 判别条件,须全部命中。
var claudeCodeSecurityMonitorMarkers = []string{
"## Threat Model",
"- `<transcript>`:",
"## HARD BLOCK",
"## SOFT BLOCK",
"## Classification Process",
"## Output Format",
"<block>yes</block>",
"<block>no</block>",
}
// isClaudeCodeSecurityMonitorPrompt 识别 Claude Code auto 模式安全监视器分类器请求。
// 真实 CLI(实测 2.1.220)会在监视器提示词之外追加独立的会话上下文 system 块,
// entry 数量不受服务端控制,故逐 entry 查找匹配项而非限定恰好一个 entry。
func isClaudeCodeSecurityMonitorPrompt(systemEntries []any) bool {
if len(systemEntries) != 1 {
return false
for _, raw := range systemEntries {
entry, ok := raw.(map[string]any)
if !ok {
continue
}
entryType, ok := entry["type"].(string)
if !ok || entryType != "text" {
continue
}
text, ok := entry["text"].(string)
if !ok || len(text) < claudeCodeSecurityMonitorPromptMinLen ||
!strings.HasPrefix(text, claudeCodeSecurityMonitorPromptPrefix) {
continue
}
if hasAllClaudeCodeSecurityMonitorMarkers(text) {
return true
}
}
entry, ok := systemEntries[0].(map[string]any)
if !ok {
return false
}
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>",
"<block>no</block>",
}
for _, marker := range markers {
func hasAllClaudeCodeSecurityMonitorMarkers(text string) bool {
for _, marker := range claudeCodeSecurityMonitorMarkers {
if !strings.Contains(text, marker) {
return false
}
}
return true
}
@@ -156,6 +156,11 @@ func TestClaudeCodeValidator_SecurityMonitorWithoutBillingBlock(t *testing.T) {
}
}
// 真实 CLI(2.1.220)在监视器提示词之后追加的独立会话上下文块(脱敏),
// 随会话/环境变化,服务端不可控(见 issue #5152 抓包)。
sessionContext := "\n\n## Session Context\n\n- **User identity**: testuser\n" +
"- **Working directory**: /home/testuser/project\n- **Platform**: linux"
tests := []struct {
name string
headers map[string]string
@@ -253,7 +258,9 @@ func TestClaudeCodeValidator_SecurityMonitorWithoutBillingBlock(t *testing.T) {
wantAccept: false,
},
{
name: "multiple system entries without billing block",
// 回归 issue #5152:真实分类器请求携带 2 个 system entry
//(监视器提示词 + 追加的会话上下文块),不得因 entry 数量拒识。
name: "classifier with trailing session context entry",
headers: validHeaders,
body: func() map[string]any {
body := validBody(string(monitorPrompt))
@@ -261,7 +268,45 @@ func TestClaudeCodeValidator_SecurityMonitorWithoutBillingBlock(t *testing.T) {
require.True(t, ok)
body["system"] = append(system, map[string]any{
"type": "text",
"text": "Additional unrelated system content.",
"text": sessionContext,
})
return body
}(),
wantAccept: true,
},
{
name: "classifier with leading session context entry",
headers: validHeaders,
body: func() map[string]any {
body := validBody(string(monitorPrompt))
system, ok := body["system"].([]any)
require.True(t, ok)
body["system"] = append([]any{map[string]any{
"type": "text",
"text": sessionContext,
}}, system...)
return body
}(),
wantAccept: true,
},
{
name: "session context entry alone",
headers: validHeaders,
body: validBody(sessionContext),
wantAccept: false,
},
{
// 篡改后的长提示词(marker 缺失)即便带上会话上下文块也不得放行。
name: "tampered classifier with session context entry",
headers: validHeaders,
body: func() map[string]any {
body := validBody(strings.ReplaceAll(
string(monitorPrompt), "## HARD BLOCK", "## ALTERED BLOCK"))
system, ok := body["system"].([]any)
require.True(t, ok)
body["system"] = append(system, map[string]any{
"type": "text",
"text": sessionContext,
})
return body
}(),