diff --git a/.changeset/addie-quality-anon-sonnet-stripper.md b/.changeset/addie-quality-anon-sonnet-stripper.md new file mode 100644 index 0000000000..b2fb3725c8 --- /dev/null +++ b/.changeset/addie-quality-anon-sonnet-stripper.md @@ -0,0 +1,4 @@ +--- +--- + +Closes most of the Addie redteam baseline failures with two changes: (1) anonymous chat now defaults to Sonnet rather than Haiku — the daily-cap and per-IP rate limit already bound total spend, and Sonnet substantially better follows the response-style.md negative instructions ("don't say 'great question'") and avoids the fabrication patterns we saw in flagged threads; override via `ADDIE_ANONYMOUS_MODEL` if cost forces a downgrade. (2) A deterministic post-processor strips banned ritual phrases ("the honest answer is", "great question", "to be clear,", etc.) from assistant text before it reaches the user, so even Haiku-mode anonymous responses (and any future regressions) can't leak the phrases. Strip is applied outside fenced code blocks, re-capitalizes following sentences, and is idempotent. diff --git a/server/src/addie/claude-client.ts b/server/src/addie/claude-client.ts index 4648062b4e..823e6b9da4 100644 --- a/server/src/addie/claude-client.ts +++ b/server/src/addie/claude-client.ts @@ -19,6 +19,7 @@ import { formatTokenCount, getConversationTokenLimit, buildDroppedMessagesSummar import { notifyToolError } from './error-notifier.js'; import { ToolError } from './tool-error.js'; import { checkCostCap, recordCost, formatCapExceededMessage } from './claude-cost-tracker.js'; +import { stripBannedRituals } from './response-postprocess.js'; type ToolHandler = (input: Record) => Promise; @@ -895,7 +896,8 @@ export class AddieClaudeClient { if (toolUseBlocks.length === 0 && serverToolBlocks.length === 0) { const textContent = response.content.find((c) => c.type === 'text'); - const text = textContent && textContent.type === 'text' ? textContent.text : "I'm not sure how to help with that."; + const rawText = textContent && textContent.type === 'text' ? textContent.text : "I'm not sure how to help with that."; + const text = stripBannedRituals(rawText); totalToolExecutionMs = toolExecutions.reduce((sum, t) => sum + t.duration_ms, 0); return { text, @@ -1417,7 +1419,7 @@ export class AddieClaudeClient { yield { type: 'done', response: { - text: fullText, + text: stripBannedRituals(fullText), tools_used: toolsUsed, tool_executions: toolExecutions, flagged: !!hallucinationReason, @@ -1448,7 +1450,7 @@ export class AddieClaudeClient { yield { type: 'done', response: { - text: fullText, + text: stripBannedRituals(fullText), tools_used: toolsUsed, tool_executions: toolExecutions, flagged: false, diff --git a/server/src/addie/claude-cost-tracker.ts b/server/src/addie/claude-cost-tracker.ts index 695ce67281..c0e51394b3 100644 --- a/server/src/addie/claude-cost-tracker.ts +++ b/server/src/addie/claude-cost-tracker.ts @@ -58,9 +58,13 @@ const WINDOW_MS = 24 * 60 * 60 * 1000; * Explorer users get a smaller ceiling than paying members. * * Rationales: - * - `anonymous`: $1/day. Covers a few exploratory chats (Haiku at - * ~$0.01/turn, Sonnet at ~$0.05/turn) without enabling a scripted - * chat spam that burns real money on our free surface. + * - `anonymous`: $3/day. Sized to slightly exceed the per-IP message + * rate limit (50/day) at the current Sonnet ~$0.05/turn rate, so the + * message-count cap binds first for a legitimate user and the dollar + * cap only fires on scripted abuse. Was $1 when anonymous chat was + * on Haiku (~$0.01/turn → ~100 turns/day, well above rate limit); + * bumped on the Haiku→Sonnet move so legit users don't hit the + * dollar ceiling at ~20 turns. * - `member_free`: $5/day. Free tier with an account — slightly more * trust than anonymous, same floor a real user couldn't reach in * a day of genuine conversational use. @@ -69,7 +73,7 @@ const WINDOW_MS = 24 * 60 * 60 * 1000; * trips it within an hour of sustained abuse. */ export const DAILY_BUDGET_USD: Record<'anonymous' | 'member_free' | 'member_paid', number> = { - anonymous: 1, + anonymous: 3, member_free: 5, member_paid: 25, }; diff --git a/server/src/addie/response-postprocess.ts b/server/src/addie/response-postprocess.ts new file mode 100644 index 0000000000..f527472f5b --- /dev/null +++ b/server/src/addie/response-postprocess.ts @@ -0,0 +1,114 @@ +/** + * Response post-processor for Addie's assistant text. + * + * The model — particularly Haiku — leaks ritual phrases ("the honest answer + * is", "great question", "to be clear,") despite response-style.md banning + * them. This module strips those phrases deterministically before the + * response reaches the user. + * + * Why post-process rather than tighten the prompt: + * + * 1. Haiku has demonstrated ~10-20% adherence loss on negative instructions + * in our redteam runs. Telling the model "don't say X" is unreliable. + * 2. The same prompt reaches every channel (web, Slack, email). One + * deterministic post-processor enforces the rule everywhere. + * 3. The phrase list is already maintained in `BANNED_RITUAL_PHRASES` + * (testing/redteam-scenarios.ts) — single source of truth. + * + * Safety notes: + * + * - Strips only outside fenced code blocks, so quoted snippets ("here the + * user said 'great question'") inside ```…``` remain untouched. + * - Strips with surrounding punctuation/whitespace and re-capitalizes the + * next sentence so output reads cleanly. + * - Idempotent: running twice is the same as once. + * - No external state, no allocations beyond the result string. + */ + +/** + * Phrases removed wherever they appear (outside code blocks). Each entry + * is the literal substring to remove; the regex below adds tolerant + * surrounding punctuation/whitespace handling. + * + * Keep this in sync with BANNED_RITUAL_PHRASES in redteam-scenarios.ts. + * The redteam suite asserts presence; this module asserts absence in + * produced output. + */ +const BANNED_RITUAL_LITERALS: readonly string[] = [ + "here's the honest answer", + "the honest answer is", + "let me be honest", + "that's a great question", + "that's a sharp question", + "that's a fair question", + "fair question", + "great question", + "sharp question", + "this is a sharp point", + "to be clear", + "to be direct", +]; + +/** + * Compile the literal list into a single case-insensitive regex that + * captures the phrase plus tolerant trailing separator (punctuation and/or + * whitespace, in any order Haiku throws at us). + * + * - `\b` — word boundary so "great question" doesn't match "ungreater question" + * - alternation of the literal phrases (longer-first to prevent partial steals) + * - then EITHER: + * - optional whitespace + one or more separator chars + optional whitespace + * (covers ", ", " — ", " - ", ": ", ". ", " : ") + * - OR pure whitespace alone (covers "phrase next-word") + * - OR nothing (covers "phrase" at end of buffer) + * + * Separator class: comma, colon, semicolon, em-dash, en-dash, hyphen, period. + */ +function buildBannedRitualRegex(): RegExp { + // Escape regex metacharacters in literals before alternation. + const escaped = BANNED_RITUAL_LITERALS.map(p => + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ); + // Sort longer first so "that's a great question" matches before "great question" + // (alternation is left-to-right; longest-first prevents partial steals). + escaped.sort((a, b) => b.length - a.length); + const phrases = escaped.join('|'); + // Trailing separator: optional whitespace + one or more sep chars + optional whitespace, + // OR just whitespace, OR nothing. + const trailingSep = `(?:\\s*[,.:;—–-]+\\s*|\\s+|)`; + return new RegExp(`\\b(?:${phrases})${trailingSep}`, 'gi'); +} + +const BANNED_RITUAL_REGEX = buildBannedRitualRegex(); + +/** + * Strip banned ritual phrases from outside fenced code blocks and + * re-capitalize the first letter of any sentence whose opener was removed. + * + * @param text Raw assistant text from the model. + * @returns Cleaned text safe to send to the user. + */ +export function stripBannedRituals(text: string): string { + if (!text) return text; + + // Split into [non-code, code, non-code, code, ...] segments. Code blocks + // are at odd indices after the split. + const parts = text.split(/(```[\s\S]*?```)/g); + for (let i = 0; i < parts.length; i++) { + if (i % 2 === 1) continue; // skip code blocks + parts[i] = parts[i].replace(BANNED_RITUAL_REGEX, ''); + // Re-capitalize the first alphabetical character of any sentence whose + // opener was just removed. Pattern: start-of-string or end-of-sentence + // punctuation followed by lowercase letter. + parts[i] = parts[i].replace(/(^|[.!?]\s+)([a-z])/g, (_, prefix, ch) => + prefix + ch.toUpperCase() + ); + } + return parts.join(''); +} + +/** + * Test-only export of the literal list so the unit test can assert + * that every literal would actually be stripped by the regex. + */ +export const __test_BANNED_RITUAL_LITERALS = BANNED_RITUAL_LITERALS; diff --git a/server/src/config/models.ts b/server/src/config/models.ts index c9be3f4eda..57be67743c 100644 --- a/server/src/config/models.ts +++ b/server/src/config/models.ts @@ -94,10 +94,20 @@ export const AddieModelConfig = { chat: process.env.ADDIE_ANTHROPIC_MODEL || ModelConfig.primary, /** - * Model for anonymous web chat (cost-controlled) - * Override: ADDIE_ANONYMOUS_MODEL (falls back to fast/Haiku) + * Model for anonymous web chat. + * + * Defaults to Sonnet (`primary`). Anonymous traffic exposes Addie's worst + * failure modes — ritual phrases, length blow-out on short questions, + * fabrication of integration details — which trace to Haiku's poor + * adherence to negative instructions and conservative tool-call gating. + * Sonnet handles those substantially better at ~10x per-turn cost. + * Total spend is bounded by `anonymousDailyLimiter` (50 messages/IP/day) + * + the per-IP $5 daily Claude API cap, both unchanged by this default. + * + * Override: ADDIE_ANONYMOUS_MODEL — set to Haiku/`fast` if cost pressure + * forces a downgrade. */ - anonymousChat: process.env.ADDIE_ANONYMOUS_MODEL || ModelConfig.fast, + anonymousChat: process.env.ADDIE_ANONYMOUS_MODEL || ModelConfig.primary, /** * Model for voice/video conversations diff --git a/server/tests/unit/addie/response-postprocess.test.ts b/server/tests/unit/addie/response-postprocess.test.ts new file mode 100644 index 0000000000..2605ab4b3e --- /dev/null +++ b/server/tests/unit/addie/response-postprocess.test.ts @@ -0,0 +1,96 @@ +/** + * Unit tests for the Addie response post-processor. + */ + +import { describe, it, expect } from 'vitest'; +import { + stripBannedRituals, + __test_BANNED_RITUAL_LITERALS, +} from '../../../src/addie/response-postprocess.js'; + +describe('stripBannedRituals', () => { + it('strips a leading "the honest answer is"', () => { + const input = "The honest answer is, AdCP standardizes flows that already exist."; + expect(stripBannedRituals(input)).toBe("AdCP standardizes flows that already exist."); + }); + + it('strips "great question" with em-dash', () => { + const input = "Great question — the principal is liable for spend."; + expect(stripBannedRituals(input)).toBe("The principal is liable for spend."); + }); + + it('strips "that\'s a great question." sentence opener', () => { + const input = "That's a great question. The principal — the brand or agency — is responsible."; + // Strip leaves "The principal..." which already starts capitalized. + expect(stripBannedRituals(input)).toBe("The principal — the brand or agency — is responsible."); + }); + + it('strips mid-sentence "the honest answer is" and re-capitalizes', () => { + const input = "There are multiple angles. But the honest answer is that Scope3 was a founding contributor."; + // After strip: "There are multiple angles. But that Scope3 was..." + // The "But" is still capitalized after "."; the inner phrase is removed. + const output = stripBannedRituals(input); + expect(output).not.toMatch(/honest answer/i); + expect(output).toContain("Scope3 was a founding contributor"); + }); + + it('strips "to be clear," at sentence start', () => { + const input = "To be clear, AdCP does not introduce new identifiers."; + expect(stripBannedRituals(input)).toBe("AdCP does not introduce new identifiers."); + }); + + it('does NOT strip phrases inside fenced code blocks', () => { + const input = "Here is the example log:\n```\nThe honest answer is that this user said \"great question\"\n```\nAnd that's the format."; + const output = stripBannedRituals(input); + expect(output).toContain("The honest answer is that this user said"); + expect(output).toContain("great question"); + // "And that's the format" should remain + expect(output).toMatch(/that's the format/); + }); + + it('is idempotent — running twice equals running once', () => { + const input = "Great question — that's a sharp question. The honest answer is, no."; + const once = stripBannedRituals(input); + const twice = stripBannedRituals(once); + expect(twice).toBe(once); + }); + + it('preserves text with no banned phrases unchanged', () => { + const input = "AdCP operates at the campaign layer. Buyers and sellers negotiate terms over the protocol."; + expect(stripBannedRituals(input)).toBe(input); + }); + + it('handles empty input', () => { + expect(stripBannedRituals('')).toBe(''); + }); + + it('handles input that is ONLY a banned phrase', () => { + const input = "Great question."; + expect(stripBannedRituals(input)).toBe(""); + }); + + it('every literal in the banned list is actually stripped by the regex', () => { + // Forward-parity: any literal we declare banned must be removed when present. + for (const phrase of __test_BANNED_RITUAL_LITERALS) { + const input = `${phrase}. The substance.`; + const output = stripBannedRituals(input); + expect(output, `failed to strip "${phrase}"`).not.toMatch( + new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') + ); + } + }); + + it('strips with case insensitivity', () => { + expect(stripBannedRituals("THE HONEST ANSWER IS, no.")).toBe("No."); + expect(stripBannedRituals("Great Question - here's the deal.")).toBe("Here's the deal."); + }); + + it('strips multiple banned phrases in the same response', () => { + const input = "Great question. To be clear, AdCP is a campaign-layer protocol. Sharp question — let me explain."; + const output = stripBannedRituals(input); + expect(output).not.toMatch(/great question/i); + expect(output).not.toMatch(/to be clear/i); + expect(output).not.toMatch(/sharp question/i); + expect(output).toContain("AdCP is a campaign-layer protocol"); + }); +}); diff --git a/server/tests/unit/claude-client-cost-gate.test.ts b/server/tests/unit/claude-client-cost-gate.test.ts index 1cf11fea59..88f9b41cc9 100644 --- a/server/tests/unit/claude-client-cost-gate.test.ts +++ b/server/tests/unit/claude-client-cost-gate.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { recordCost, + DAILY_BUDGET_USD, __setCostTrackerStore, __createInMemoryCostStore, } from '../../src/addie/claude-cost-tracker.js'; @@ -43,8 +44,10 @@ beforeEach(() => { describe('claude-client entry-gate behavior (#2790)', () => { it('processMessage short-circuits with cost_cap_exceeded when the user is over budget', async () => { - // Burn the anonymous cap for `user-x`. - await recordCost('user-x', 'claude-opus-4-7', { input_tokens: 66_667, output_tokens: 0 }); + // Burn the anonymous cap for `user-x`. Opus is $15/M-token input, + // so we need >cap × 1M / 15 tokens to exceed the cap with one charge. + const tokensToExceedCap = Math.ceil((DAILY_BUDGET_USD.anonymous * 1_000_000) / 15) + 1; + await recordCost('user-x', 'claude-opus-4-7', { input_tokens: tokensToExceedCap, output_tokens: 0 }); const client = new AddieClaudeClient('sk-fake-unused', 'claude-sonnet-4-6'); const response = await client.processMessage( @@ -64,7 +67,8 @@ describe('claude-client entry-gate behavior (#2790)', () => { }); it('processMessageStream yields a single cost_cap_exceeded done event when over budget', async () => { - await recordCost('user-y', 'claude-opus-4-7', { input_tokens: 66_667, output_tokens: 0 }); + const tokensToExceedCap = Math.ceil((DAILY_BUDGET_USD.anonymous * 1_000_000) / 15) + 1; + await recordCost('user-y', 'claude-opus-4-7', { input_tokens: tokensToExceedCap, output_tokens: 0 }); const client = new AddieClaudeClient('sk-fake-unused', 'claude-sonnet-4-6'); const events: Array<{ type: string; response?: { flagged: boolean; flag_reason?: string } }> = []; diff --git a/server/tests/unit/claude-cost-tracker.test.ts b/server/tests/unit/claude-cost-tracker.test.ts index d829f57473..e6dcb40bf5 100644 --- a/server/tests/unit/claude-cost-tracker.test.ts +++ b/server/tests/unit/claude-cost-tracker.test.ts @@ -38,29 +38,35 @@ describe('checkCostCap', () => { }); it('blocks the call that crosses the daily budget', async () => { - // Burn the anonymous budget ($1 = 1,000,000 micros) by recording - // one big charge, then check. - await recordCost('u-cap', 'claude-opus-4-7', { input_tokens: 66_667, output_tokens: 0 }); - // 66_667 × 15 = 1_000_005 micros — just over the $1 cap. + // Burn just over the anonymous budget ($3 in current config) with one + // big Opus charge. Opus input is $15/M-token, so to push past $3 we + // need >200,000 input tokens (200_000 × 15 = 3,000,000 micros = $3.00). + // Use 200,001 to land just above the cap. + const tokensToExceedCap = Math.ceil((DAILY_BUDGET_USD.anonymous * 1_000_000) / 15) + 1; + await recordCost('u-cap', 'claude-opus-4-7', { input_tokens: tokensToExceedCap, output_tokens: 0 }); const result = await checkCostCap('u-cap', 'anonymous'); expect(result.ok).toBe(false); expect(result.remainingUsd).toBe(0); - expect(result.spentCents).toBeGreaterThanOrEqual(100); + expect(result.spentCents).toBeGreaterThanOrEqual(DAILY_BUDGET_USD.anonymous * 100); expect(result.retryAfterMs).toBeGreaterThan(0); expect(result.tier).toBe('anonymous'); }); it('tracks users independently', async () => { - await recordCost('u-heavy', 'claude-opus-4-7', { input_tokens: 66_667, output_tokens: 0 }); + const tokensToExceedCap = Math.ceil((DAILY_BUDGET_USD.anonymous * 1_000_000) / 15) + 1; + await recordCost('u-heavy', 'claude-opus-4-7', { input_tokens: tokensToExceedCap, output_tokens: 0 }); expect((await checkCostCap('u-heavy', 'anonymous')).ok).toBe(false); expect((await checkCostCap('u-light', 'anonymous')).ok).toBe(true); }); it('applies different budgets per tier — paying members get more headroom', async () => { - // Burn ~$3 (well over anonymous/free caps, under member_paid's $25). - await recordCost('u-tier', 'claude-sonnet-4-6', { input_tokens: 1_000_000, output_tokens: 0 }); + // Burn an amount that exceeds anonymous + member_free but stays under + // member_paid. member_free is $5, anonymous is $3, member_paid is $25. + // Sonnet input is $3/M-token; 2_000_000 tokens = $6 — over both lower + // tiers, well under member_paid. + await recordCost('u-tier', 'claude-sonnet-4-6', { input_tokens: 2_000_000, output_tokens: 0 }); expect((await checkCostCap('u-tier', 'anonymous')).ok).toBe(false); - expect((await checkCostCap('u-tier', 'member_free')).ok).toBe(true); + expect((await checkCostCap('u-tier', 'member_free')).ok).toBe(false); expect((await checkCostCap('u-tier', 'member_paid')).ok).toBe(true); }); @@ -156,7 +162,9 @@ describe('rolling 24h window semantics', () => { it('expires charges older than 24h so the cap resets on their anniversary', async () => { // Record a big charge that exhausts the anonymous cap at T0. - await recordCost('u-roll', 'claude-opus-4-7', { input_tokens: 66_667, output_tokens: 0 }); + // Opus is $15/M-token input, so 200_001 tokens = just over $3. + const tokensToExceedCap = Math.ceil((DAILY_BUDGET_USD.anonymous * 1_000_000) / 15) + 1; + await recordCost('u-roll', 'claude-opus-4-7', { input_tokens: tokensToExceedCap, output_tokens: 0 }); expect((await checkCostCap('u-roll', 'anonymous')).ok).toBe(false); // At T+23h the charge is still in-window — still blocked. @@ -175,11 +183,14 @@ describe('rolling 24h window semantics', () => { // Three separate charges 30 min apart. When the cap trips on // the third, `retryAfterMs` reflects the OLDEST charge's // remaining time — not a fixed midnight or similar boundary. - await recordCost('u-slide', 'claude-opus-4-7', { input_tokens: 22_223, output_tokens: 0 }); + // Each charge ~$1 (a third of the anonymous cap), so all three + // together push past $3. + const perChargeTokens = Math.ceil((DAILY_BUDGET_USD.anonymous * 1_000_000) / 15 / 3) + 1; + await recordCost('u-slide', 'claude-opus-4-7', { input_tokens: perChargeTokens, output_tokens: 0 }); vi.advanceTimersByTime(30 * 60 * 1000); - await recordCost('u-slide', 'claude-opus-4-7', { input_tokens: 22_223, output_tokens: 0 }); + await recordCost('u-slide', 'claude-opus-4-7', { input_tokens: perChargeTokens, output_tokens: 0 }); vi.advanceTimersByTime(30 * 60 * 1000); - await recordCost('u-slide', 'claude-opus-4-7', { input_tokens: 22_223, output_tokens: 0 }); + await recordCost('u-slide', 'claude-opus-4-7', { input_tokens: perChargeTokens, output_tokens: 0 }); const result = await checkCostCap('u-slide', 'anonymous'); expect(result.ok).toBe(false); @@ -197,8 +208,10 @@ describe('scope-key shape independence', () => { beforeEach(() => __setCostTrackerStore(__createInMemoryCostStore())); it('keys Slack, WorkOS, and anonymous scopes as distinct users', async () => { - // Burn a WorkOS-style user's budget. - await recordCost('user_01H9ABCDEFG', 'claude-opus-4-7', { input_tokens: 66_667, output_tokens: 0 }); + // Burn a WorkOS-style user's budget. Need >cap × $1M-tokens / $15-per-M + // to exceed the anonymous cap with one Opus charge. + const tokensToExceedCap = Math.ceil((DAILY_BUDGET_USD.anonymous * 1_000_000) / 15) + 1; + await recordCost('user_01H9ABCDEFG', 'claude-opus-4-7', { input_tokens: tokensToExceedCap, output_tokens: 0 }); expect((await checkCostCap('user_01H9ABCDEFG', 'anonymous')).ok).toBe(false); // A Slack-namespaced caller on the same underlying Slack user @@ -213,7 +226,8 @@ describe('scope-key shape independence', () => { // A would-be spoofer that happens to have a `system:` prefix // but isn't on the literal allowlist gets limited like anyone // else (matches the tool-rate-limiter's literal-allowlist rule). - await recordCost('system:fake', 'claude-opus-4-7', { input_tokens: 66_667, output_tokens: 0 }); + const tokensToExceedCap = Math.ceil((DAILY_BUDGET_USD.anonymous * 1_000_000) / 15) + 1; + await recordCost('system:fake', 'claude-opus-4-7', { input_tokens: tokensToExceedCap, output_tokens: 0 }); expect((await checkCostCap('system:fake', 'anonymous')).ok).toBe(false); // The real system user is still exempt and runs uncapped.