diff --git a/changelog/unreleased/workbuddy-billing-header-sanitize.md b/changelog/unreleased/workbuddy-billing-header-sanitize.md new file mode 100644 index 00000000..acdc3321 --- /dev/null +++ b/changelog/unreleased/workbuddy-billing-header-sanitize.md @@ -0,0 +1,7 @@ +### English + +- WorkBuddy request-body sanitization now strips Claude Code billing attribution headers (`x-anthropic-billing-header` and `x-anthropic-billing-hdr`, hub #855 / v2.0.8). Those reserved keys used to go upstream as-is and were rejected with 400 / 11128. A system message that is only that header is dropped, and a leading system prompt is restored if needed so the empty text does not trigger 400 / 11151. + +### 中文 + +- WorkBuddy 请求体清洗现在会去掉 Claude Code 的计费归因头(`x-anthropic-billing-header` 与 `x-anthropic-billing-hdr`,对齐 hub #855 / v2.0.8)。这两个保留键原先会原样出站,上游直接回 400 / 11128。若整条 system 消息只剩该头,会丢弃该消息并在需要时补回一条 leading system,避免空文本触发 400 / 11151。 diff --git a/internal/providers/workbuddy/payload.go b/internal/providers/workbuddy/payload.go index 0759424f..a3f58e70 100644 --- a/internal/providers/workbuddy/payload.go +++ b/internal/providers/workbuddy/payload.go @@ -368,6 +368,18 @@ var ( wafBrandRe = regexp.MustCompile(`(?i)DeepSeek[ \t]+Harness`) wafBrandReplacement = "DeepSeek" + + // Claude Code 2.1.37+ injects a billing attribution header as the first + // system text block (hub #855 / v2.0.8). Upstream treats both spellings + // as reserved keywords and answers 400 / 11128 if they appear as-is. + // The full name is not a substring match of the "hdr" abbreviation + // (`...-hdr` then sees `er:`), so both spellings are listed explicitly. + // Line deletion also removes trailing parameters such as `cch=` that + // do not start with the `cc_` prefix used by the leftover-KV cleaner. + wafBillingHdrLineRe = regexp.MustCompile(`(?im)^[ \t]*x-anthropic-billing-(?:header|hdr)[ \t]*:[^\r\n]*(?:\r?\n|$)`) + wafBillingHdrRe = regexp.MustCompile(`(?i)x-anthropic-billing-(?:header|hdr)\s*:\s*[^;\r\n]*(?:;\s*)?`) + wafBillingHdrBareRe = regexp.MustCompile(`(?i)x-anthropic-billing-(?:header|hdr)`) + wafBillingHdrKVRe = regexp.MustCompile(`(?i)\bcc_[a-z0-9_]+=[^;\r\n]*;?\s*`) ) // rewriteIdentitySentences replaces confirmed self-intro sentences. Sentence @@ -405,9 +417,12 @@ func rewriteIdentitySentences(text string) string { // sanitizeWAFKeywords rewrites known client fingerprint phrases that trigger // upstream WorkBuddy WAF rules (code 11128 / 11-128: "Illegal API invocation -// from an unapproved channel"). Literal Main-branch rewrites apply to every -// role; identity / DSH GUI / fileref / brand rules follow upstream and only touch -// system, developer, and assistant. +// from an unapproved channel"). Literal Main-branch rewrites and Claude Code +// billing attribution headers apply to every role; identity / DSH GUI / +// fileref / brand rules follow upstream and only touch system, developer, +// and assistant. A message that becomes empty after sanitization is dropped +// (empty content is code 11151); ensureLeadingSystem then restores a +// non-empty leading system prompt if needed. func sanitizeWAFKeywords(body map[string]any) { raw, ok := body["messages"] if !ok { @@ -427,19 +442,33 @@ func sanitizeWAFKeywords(body map[string]any) { if content, ok := message["content"].(string); ok { message["content"] = sanitizeFingerprintText(content, identity) } else if parts, ok := message["content"].([]any); ok { + keptParts := make([]any, 0, len(parts)) for _, part := range parts { - if partMap, ok := part.(map[string]any); ok { - if text, ok := partMap["text"].(string); ok { - partMap["text"] = sanitizeFingerprintText(text, identity) + partMap, ok := part.(map[string]any) + if !ok { + keptParts = append(keptParts, part) + continue + } + if text, ok := partMap["text"].(string); ok { + cleaned := sanitizeFingerprintText(text, identity) + partMap["text"] = cleaned + if strings.TrimSpace(text) != "" && strings.TrimSpace(cleaned) == "" { + if typ, _ := partMap["type"].(string); typ == "text" || typ == "" { + continue + } } } + keptParts = append(keptParts, part) } + message["content"] = keptParts } // reasoning_content is model scratch, not an identity surface. if reasoning, ok := message["reasoning_content"].(string); ok { message["reasoning_content"] = sanitizeFingerprintText(reasoning, false) } } + normalizeEmptyMessageContent(body) + ensureLeadingSystem(body) } func sanitizeFingerprintText(text string, identity bool) string { @@ -457,5 +486,31 @@ func sanitizeFingerprintText(text string, identity bool) string { text = strings.ReplaceAll(text, rule.pattern, rule.replacement) } } - return text + return sanitizeBillingHeaders(text) +} + +// containsBillingHeader reports either official spelling. The abbreviated +// "hdr" form is not a substring of "header", so both needles are required. +func containsBillingHeader(text string) bool { + low := strings.ToLower(text) + return strings.Contains(low, "x-anthropic-billing-header") || + strings.Contains(low, "x-anthropic-billing-hdr") +} + +func sanitizeBillingHeaders(text string) string { + if !containsBillingHeader(text) { + return text + } + text = wafBillingHdrLineRe.ReplaceAllString(text, "") + text = wafBillingHdrRe.ReplaceAllString(text, "") + if strings.Contains(strings.ToLower(text), "cc_") { + for { + next := wafBillingHdrKVRe.ReplaceAllString(text, "") + if next == text { + break + } + text = next + } + } + return wafBillingHdrBareRe.ReplaceAllString(text, "anthropic-header") } diff --git a/internal/providers/workbuddy/waf_fingerprint_test.go b/internal/providers/workbuddy/waf_fingerprint_test.go index 2347a451..da586b15 100644 --- a/internal/providers/workbuddy/waf_fingerprint_test.go +++ b/internal/providers/workbuddy/waf_fingerprint_test.go @@ -165,3 +165,152 @@ func TestSanitizeWAFDSHFilerefParagraph(t *testing.T) { t.Fatalf("tool must stay untouched: %q", got) } } + +const billingHeaderFull = "x-anthropic-billing-header: cc_version=2.1.321.abc; " + + "cc_entrypoint=cli; cch=00000;" +const billingHeaderAbbrev = "x-anthropic-billing-hdr: cc_version=2.1.321.abc; " + + "cc_entrypoint=cli; cch=00000;" + +func TestSanitizeWAFBillingAttributionHeaders(t *testing.T) { + if got := sanitizeFingerprintText(billingHeaderFull, true); got != "" { + t.Fatalf("full header line = %q, want empty", got) + } + if got := sanitizeFingerprintText(billingHeaderAbbrev, true); got != "" { + t.Fatalf("abbrev header line = %q, want empty", got) + } + + prompt := billingHeaderFull + "\nYou are a helpful AI assistant." + if got := sanitizeFingerprintText(prompt, true); got != "You are a helpful AI assistant." { + t.Fatalf("header before prompt = %q", got) + } + if got := sanitizeFingerprintText(billingHeaderAbbrev+"\nKeep this.", true); got != "Keep this." { + t.Fatalf("abbrev header before prompt = %q", got) + } + + upper := strings.ToUpper(billingHeaderFull) + "\nKeep this." + if got := sanitizeFingerprintText(upper, true); got != "Keep this." { + t.Fatalf("case-insensitive = %q", got) + } + + if got := sanitizeFingerprintText("`x-anthropic-billing-header`", true); got != "`anthropic-header`" { + t.Fatalf("bare full key = %q", got) + } + if got := sanitizeFingerprintText("`x-anthropic-billing-hdr`", false); got != "`anthropic-header`" { + t.Fatalf("bare abbrev key = %q", got) + } + + // The longer spelling must be removed as a whole; matching only "...-hdr" + // would leave a leading "er:" in the outbound text. + if got := sanitizeFingerprintText(billingHeaderFull, false); strings.Contains(strings.ToLower(got), "er:") || strings.Contains(strings.ToLower(got), "x-anthropic-billing") { + t.Fatalf("full header leaked after identity=false sanitize: %q", got) + } + + untouched := "Hello world untouched" + if got := sanitizeFingerprintText(untouched, true); got != untouched { + t.Fatalf("unrelated text rewritten: %q", got) + } +} + +func TestSanitizeWAFBillingHeaderMessages(t *testing.T) { + body := map[string]any{ + "messages": []any{ + map[string]any{"role": "system", "content": billingHeaderFull}, + map[string]any{"role": "system", "content": billingHeaderFull + "\nYou are a helpful AI assistant."}, + map[string]any{"role": "user", "content": "hi"}, + map[string]any{"role": "user", "content": billingHeaderAbbrev + "\nPlease review this."}, + map[string]any{"role": "assistant", "content": []any{ + map[string]any{"type": "text", "text": billingHeaderFull}, + map[string]any{"type": "text", "text": "Other fields stay."}, + }}, + }, + } + sanitizeWAFKeywords(body) + msgs := body["messages"].([]any) + if len(msgs) != 4 { + t.Fatalf("expected 4 messages after dropping header-only system, got %d: %#v", len(msgs), msgs) + } + + sys := msgs[0].(map[string]any) + if sys["role"] != "system" || sys["content"] != "You are a helpful AI assistant." { + t.Fatalf("remaining system = %#v", sys) + } + if strings.Contains(fmtContent(sys["content"]), "x-anthropic-billing") { + t.Fatalf("system still contains billing header: %#v", sys) + } + + user := msgs[1].(map[string]any) + if user["role"] != "user" || user["content"] != "hi" { + t.Fatalf("untouched user changed: %#v", user) + } + + userHdr := msgs[2].(map[string]any) + if userHdr["content"] != "Please review this." { + t.Fatalf("user header not stripped: %#v", userHdr) + } + + asst := msgs[3].(map[string]any) + parts, _ := asst["content"].([]any) + if len(parts) != 1 { + t.Fatalf("header-only text part should be dropped, got %#v", parts) + } + if parts[0].(map[string]any)["text"] != "Other fields stay." { + t.Fatalf("remaining part = %#v", parts[0]) + } +} + +func TestPrepareBodySanitizesBillingHeaders(t *testing.T) { + src, _ := json.Marshal(map[string]any{ + "model": "wb", + "messages": []any{ + map[string]any{"role": "system", "content": billingHeaderFull}, + map[string]any{"role": "user", "content": "hi"}, + }, + }) + out := PrepareBody(src) + var body map[string]any + if err := json.Unmarshal(out, &body); err != nil { + t.Fatal(err) + } + msgs := body["messages"].([]any) + if len(msgs) != 2 { + t.Fatalf("expected leading system + user, got %d: %#v", len(msgs), msgs) + } + sys := msgs[0].(map[string]any) + if sys["role"] != "system" || sys["content"] != "You are a helpful assistant." { + t.Fatalf("header-only system should be replaced with placeholder: %#v", sys) + } + if strings.Contains(string(out), "x-anthropic-billing-header") || strings.Contains(string(out), "x-anthropic-billing-hdr") { + t.Fatalf("billing header leaked outbound: %s", out) + } + if got := msgs[1].(map[string]any)["content"]; got != "hi" { + t.Fatalf("user changed: %q", got) + } + + withPrompt, _ := json.Marshal(map[string]any{ + "model": "wb", + "extra": "keep-me", + "messages": []any{ + map[string]any{"role": "system", "content": billingHeaderAbbrev + "\nYou are a helpful AI assistant."}, + map[string]any{"role": "user", "content": "hello world untouched"}, + }, + }) + out = PrepareBody(withPrompt) + if err := json.Unmarshal(out, &body); err != nil { + t.Fatal(err) + } + if body["extra"] != "keep-me" { + t.Fatalf("unrelated field changed: %#v", body["extra"]) + } + msgs = body["messages"].([]any) + if got := msgs[0].(map[string]any)["content"]; got != "You are a helpful AI assistant." { + t.Fatalf("prepared system = %q", got) + } + if got := msgs[1].(map[string]any)["content"]; got != "hello world untouched" { + t.Fatalf("prepared user = %q", got) + } +} + +func fmtContent(v any) string { + s, _ := v.(string) + return s +}