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
}
|