Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/workbuddy-billing-header-sanitize.md
Original file line number Diff line number Diff line change
@@ -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。
69 changes: 62 additions & 7 deletions internal/providers/workbuddy/payload.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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")
}
149 changes: 149 additions & 0 deletions internal/providers/workbuddy/waf_fingerprint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading