File size: 9,362 Bytes
6d60378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
// sanitize.go 出站请求体脱敏:剥离上游内容审核黑名单指纹。
//
// 背景:客户端(Claude Code 类 CLI)在 system prompt 注入若干固定模板句,
// 上游内容审核按逐字精确匹配拦截(非语义审核),一字改动即可绕过。
// 策略:键值/header 型指纹整段剥离;承载语义的模板句最小改写(换一词),语义不变。
package upstream

import (
	"regexp"
	"strings"
)

// sanitizeFeatures 特征预检:任一命中才进入净化(strings.Contains 快速路径,
// 普通请求全不中 → 原样返回,零分配)。
var sanitizeFeatures = []string{
	"x-anthropic-billing-header", // header 键值段键名
	"cc_entrypoint=",             // 尾随裸键值(截断前缀即可命中)
	"You are Claude Code",        // 身份句(截断前缀即可命中)
	"Main branch (",              // 注入指令句(截断前缀即可命中)
	"You are a coding agent running in the Codex CLI", // Codex instructions 首段(截断前缀即可命中)
	"github.com/anthropics/",                          // 反馈句里的 Anthropic 仓库链接
	"11128",                                           // 上游反探测:裸数字错误码
}

// sanitizeHdrRe 剥离层:header 键名即触发(与值无关),整段删除。
var sanitizeHdrRe = regexp.MustCompile(`(?i)x-anthropic-billing-header:[^;\n]*;?\s*`)

// sanitizeBareHdrRe 兜底层:裸键名(无冒号无值)同样是指纹——2026-09-13 实验 F4
// 证实 assistant 消息里反引号引用裸键名即触发 11128,而剥离层要求冒号、对裸串无效。
// 键值形态被整段删除后,残留的裸键名做最小缩写(header→hdr):破坏逐字匹配、
// 语义不变、保留可读性。大小写不敏感,覆盖 X-Anthropic-... 变体。
//
// 注意该正则不要求冒号,是 sanitizeHdrRe 的超集——hasFingerprint 与 sanitizeText
// 中两者并用:先删键值形态(sanitizeHdrRe),再缩写残留裸键名(本正则),
// 替换语义不同(整段删除 vs 最小缩写),不可合并为一个正则。
var sanitizeBareHdrRe = regexp.MustCompile(`(?i)x-anthropic-billing-header`)

// sanitizeKvRe 剥离层:尾随裸键值(cc_xxx=...;)循环清理。
var sanitizeKvRe = regexp.MustCompile(`(?i)\bcc_[a-z0-9_]+=[^;\n]*;?\s*`)

// sanitizeRewrites 改写层:全模板句逐字替换(每句只改一个词,语义不变)。
//
// 身份句的匹配串**不带结尾标点**(只到 "…for Claude" 为止):
// CLI 版这句以句号收尾("…for Claude."),桌面版(claude-desktop-3p / Agent SDK)
// 以逗号接后继内容("…for Claude, running within the Claude Agent SDK.")。
// 带句号的整句只匹配前者,桌面版会漏网、指纹原样发上游 → 400 code=11128。
// 去掉结尾标点后两种形态一并覆盖(替换串同样不带标点,让原有标点原样保留)。
// 注意仍要求 "You are Claude Code, " 前缀,不做更宽的子串替换,
// 以免误伤 TestExactMatchOnlyVariantNotTouched 所保护的零散文本。
var sanitizeRewrites = [][2]string{
	{
		"You are Claude Code, Anthropic's official CLI for Claude",
		"You are Claude Code, Anthropic's official CLI tool for Claude",
	},
	{
		"Main branch (you will usually use this for PRs)",
		"Default branch (you will usually use this for PRs)",
	},
	{
		"You are a coding agent running in the Codex CLI, a terminal-based coding assistant.",
		"You are a coding agent running in the Codex CLI tool, a terminal-based coding assistant.",
	},
	{
		// 反馈句:整句带 Anthropic 仓库链接,上游按整句拦截(只留链接或只留半边均不拦,
		// 实测需整句同时出现)。give→provide 一词之差即可绕过,语义不变。
		"To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues",
		"To provide feedback, users should report the issue at https://github.com/anthropics/claude-code/issues",
	},
	{
		// 上游反探测:只要请求体里出现裸数字 11128 就整单拦截(与该数字的上下文无关——
		// "code=11128" / 裸 "11128" / "错误码 11128" / "Code=11128" 全部命中;
		// 相邻的 11148 / 11101 / 11115 / 99999 均放行)。11128 正是本类拦截自身的错误码,
		// 上游据此识别"在讨论/回显其内部错误码"的请求。
		// 代价:用户对话中任何 11128 都会被改写——但这串数字出现在请求里本身就是拦截条件,
		// 不改写必然失败。插入连字符保留可读性与指代(零宽空格无效,实测上游会归一化)。
		"11128",
		"11-128",
	},
}

// sanitizeText 单段文本净化:预检不中 → 返回原串(零分配)。
func sanitizeText(text string) string {
	if !hasFingerprint(text) {
		return text
	}
	for _, rw := range sanitizeRewrites {
		text = strings.ReplaceAll(text, rw[0], rw[1])
	}
	if sanitizeHdrRe.MatchString(text) {
		text = sanitizeHdrRe.ReplaceAllString(text, "")
	}
	if strings.Contains(text, "cc_") {
		prev := ""
		for prev != text { // 清尾随裸 kv(cc_version=...; cc_entrypoint=...;)
			prev = text
			text = sanitizeKvRe.ReplaceAllString(text, "")
		}
	}
	// 兜底:键值形态已在上面整段删除,这里只剩裸键名(引用/示例文本形态)。
	text = sanitizeBareHdrRe.ReplaceAllString(text, "x-anthropic-billing-hdr")
	return strings.TrimSpace(text)
}

// hasFingerprint 特征预检:先走 strings.Contains 快速路径(零分配);
// header 键名有大小写变体(X-Anthropic-...)且可能以裸键名形态出现(无冒号),
// Contains 大小写敏感、sanitizeHdrRe 要求冒号——两者都会漏掉「混合大小写 + 裸键名」,
// 必须再用不要求冒号的 (?i) 正则兜底(sanitizeBareHdrRe),否则整条净化被跳过。
// sanitizeBareHdrRe 不要求冒号,是 sanitizeHdrRe 的超集,故无需再单独匹配后者。
func hasFingerprint(text string) bool {
	for _, f := range sanitizeFeatures {
		if strings.Contains(text, f) {
			return true
		}
	}
	return sanitizeBareHdrRe.MatchString(text)
}

// sanitizeContent 兼容字符串与多模态数组;只动 text part,image 等 part 不动。
// 返回净化后的值及是否发生变化。
func sanitizeContent(v any) (any, bool) {
	switch c := v.(type) {
	case string:
		s := sanitizeText(c)
		return s, s != c
	case []any:
		changed := false
		for _, p := range c {
			m, ok := p.(map[string]any)
			if !ok {
				continue
			}
			text, ok := m["text"].(string)
			if !ok {
				continue
			}
			if s := sanitizeText(text); s != text {
				m["text"] = s
				changed = true
			}
		}
		return c, changed
	}
	return v, false
}

// sanitizeToolCalls 净化 assistant.tool_calls[].function.arguments。
//
// arguments 是**字符串化的 JSON**(不是对象),因此按文本走 sanitizeText 即可。
// 这块长期是盲区:工具调用消息的 content 通常是 null,而旧版 sanitizeMessages
// 在 content 缺失时直接 continue,整条消息连 tool_calls 一起被跳过——
// 于是历史里任何写进工具参数的被拦字符串(文件名、命令、写入内容)都会原样漏出。
func sanitizeToolCalls(v any) bool {
	callList, ok := v.([]any)
	if !ok {
		return false
	}
	changed := false
	for _, c := range callList {
		call, ok := c.(map[string]any)
		if !ok {
			continue
		}
		fn, ok := call["function"].(map[string]any)
		if !ok {
			continue
		}
		args, ok := fn["arguments"].(string)
		if !ok {
			continue
		}
		if s := sanitizeText(args); s != args {
			fn["arguments"] = s
			changed = true
		}
	}
	return changed
}

// sanitizeMessages 净化 messages 中的 content 与 tool_calls;任一命中返回 true。
func sanitizeMessages(messages []any) bool {
	changed := false
	for _, msg := range messages {
		m, ok := msg.(map[string]any)
		if !ok {
			continue
		}
		// content 与 tool_calls 各自独立判断:content 可以为 null(工具调用轮),
		// 早期版本在此 continue,导致这类消息的 tool_calls 完全不被净化。
		if c, ok := m["content"]; ok {
			if nc, ch := sanitizeContent(c); ch {
				m["content"] = nc
				changed = true
			}
		}
		// reasoning_content(思维链回填字段,见 thinking.go/sse.go)实测同样
		// 携带指纹,与 content 同等净化。string 形态直接走 sanitizeText。
		if rc, ok := m["reasoning_content"].(string); ok {
			if s := sanitizeText(rc); s != rc {
				m["reasoning_content"] = s
				changed = true
			}
		}
		// reasoning:另一形态的思维链字段。thinking.go 的回填会把客户端送来的
		// reasoning_content 镜像进 reasoning(反之亦然),而此前只洗 content /
		// reasoning_content / tool_calls —— 镜像进 reasoning 的指纹原样出站,
		// 裸 "11128" 这种反探测串同样致命。
		if r, ok := m["reasoning"].(string); ok {
			if s := sanitizeText(r); s != r {
				m["reasoning"] = s
				changed = true
			}
		}
		if tc, ok := m["tool_calls"]; ok {
			if sanitizeToolCalls(tc) {
				changed = true
			}
		}
	}
	return changed
}