Skip to content
Open
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ ANTHROPIC_API_KEY=your-api-key-here
# ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
# GOOGLE_APPLICATION_CREDENTIALS=./credentials/google-sa-key.json

# =============================================================================
# Spending Guard
# =============================================================================
# Shannon's text-pattern spending guard can false-positive on normal pentest
# output (e.g. "password reset", "usage limit per user"). Set to 1 to disable
# the text-pattern guard. Structured SDK error detection (billing_error,
# rate_limit) and the behavioral heuristic (turns <= 2, cost === $0) remain
# active regardless of this setting.
# SHANNON_DISABLE_SPENDING_GUARD=1

# =============================================================================
# Available Models
# =============================================================================
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const FORWARD_VARS = [
'CLAUDE_CODE_MAX_OUTPUT_TOKENS',
'OPENAI_API_KEY',
'OPENROUTER_API_KEY',
'SHANNON_DISABLE_SPENDING_GUARD',
] as const;

/**
Expand Down
124 changes: 124 additions & 0 deletions apps/worker/src/utils/__tests__/billing-detection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Tests for billing-detection.ts
*
* Validates pattern matching, false-positive avoidance, and the
* SHANNON_DISABLE_SPENDING_GUARD escape hatch.
*
* Run with: npx tsx --test apps/worker/src/utils/__tests__/billing-detection.test.ts
*/

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

// Import the functions under test.
// NOTE: spendingGuardDisabled is evaluated at module load time from
// process.env, so we test the disabled path in a separate subprocess.
import {
BILLING_API_PATTERNS,
BILLING_TEXT_PATTERNS,
isSpendingCapBehavior,
matchesBillingApiPattern,
matchesBillingTextPattern,
spendingGuardDisabled,
} from '../billing-detection.js';

// ---------------------------------------------------------------------------
// matchesBillingTextPattern
// ---------------------------------------------------------------------------
describe('matchesBillingTextPattern', () => {
it('detects actual billing messages', () => {
assert.ok(matchesBillingTextPattern('Spending cap reached. Resets at 8 AM PT.'));
assert.ok(matchesBillingTextPattern('Your spending limit has been exceeded'));
assert.ok(matchesBillingTextPattern('Budget exceeded, please upgrade'));
assert.ok(matchesBillingTextPattern('Usage limit reached'));
});

it('does NOT false-positive on common pentest vocabulary', () => {
// These are real phrases from pentesting output that previously
// triggered false positives (see issue #263).
assert.ok(!matchesBillingTextPattern('password reset'));
assert.ok(!matchesBillingTextPattern('The reset token was expired'));
assert.ok(!matchesBillingTextPattern('Account resets after 3 failed attempts'));
assert.ok(!matchesBillingTextPattern('Usage limit per user is 100 requests'));
assert.ok(!matchesBillingTextPattern('rate limit exceeded'));
});

it('returns false for empty / unrelated text', () => {
assert.ok(!matchesBillingTextPattern(''));
assert.ok(!matchesBillingTextPattern('Found SQL injection in login form'));
assert.ok(!matchesBillingTextPattern('XSS payload executed successfully'));
});
});

// ---------------------------------------------------------------------------
// matchesBillingApiPattern
// ---------------------------------------------------------------------------
describe('matchesBillingApiPattern', () => {
it('detects API billing errors', () => {
assert.ok(matchesBillingApiPattern('billing_error'));
assert.ok(matchesBillingApiPattern('credit balance is too low'));
assert.ok(matchesBillingApiPattern('insufficient credits'));
assert.ok(matchesBillingApiPattern('quota exceeded'));
assert.ok(matchesBillingApiPattern('limit will reset'));
});

it('is case-insensitive', () => {
assert.ok(matchesBillingApiPattern('BILLING_ERROR'));
assert.ok(matchesBillingApiPattern('Quota Exceeded'));
});
});

// ---------------------------------------------------------------------------
// isSpendingCapBehavior
// ---------------------------------------------------------------------------
describe('isSpendingCapBehavior', () => {
it('triggers on low-turn zero-cost billing message', () => {
assert.ok(isSpendingCapBehavior(1, 0, 'Spending cap reached'));
assert.ok(isSpendingCapBehavior(2, 0, 'Your spending limit hit'));
});

it('does NOT trigger when turns > 2', () => {
assert.ok(!isSpendingCapBehavior(3, 0, 'Spending cap reached'));
});

it('does NOT trigger when cost > 0', () => {
assert.ok(!isSpendingCapBehavior(1, 0.01, 'Spending cap reached'));
});

it('does NOT trigger on normal pentest output even with low turns', () => {
assert.ok(!isSpendingCapBehavior(1, 0, 'Found password reset vulnerability'));
assert.ok(!isSpendingCapBehavior(2, 0, 'Testing account resets'));
});
});

// ---------------------------------------------------------------------------
// Pattern lists – sanity checks
// ---------------------------------------------------------------------------
describe('pattern lists', () => {
it('BILLING_TEXT_PATTERNS does not contain bare "resets"', () => {
const patterns: readonly string[] = BILLING_TEXT_PATTERNS;
assert.ok(!patterns.includes('resets'), '"resets" should have been removed (see #263)');
});

it('BILLING_TEXT_PATTERNS uses "usage limit reached" not bare "usage limit"', () => {
const patterns: readonly string[] = BILLING_TEXT_PATTERNS;
assert.ok(!patterns.includes('usage limit'), 'bare "usage limit" is too broad');
assert.ok(patterns.includes('usage limit reached'));
});

it('BILLING_API_PATTERNS is unchanged and contains expected entries', () => {
const patterns: readonly string[] = BILLING_API_PATTERNS;
assert.ok(patterns.includes('billing_error'));
assert.ok(patterns.includes('quota exceeded'));
});
});

// ---------------------------------------------------------------------------
// SHANNON_DISABLE_SPENDING_GUARD flag
// ---------------------------------------------------------------------------
describe('SHANNON_DISABLE_SPENDING_GUARD', () => {
it('is disabled by default in test environment', () => {
// Unless the test runner sets the env var, the guard should be active
assert.equal(spendingGuardDisabled, false);
});
});
31 changes: 29 additions & 2 deletions apps/worker/src/utils/billing-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,35 @@
*
* This module provides defense-in-depth detection with shared pattern lists
* to prevent drift between detection points.
*
* The text-pattern guard can produce false positives when pentest output
* contains billing-sounding phrases (e.g. "password reset", "usage limit
* per user"). Set SHANNON_DISABLE_SPENDING_GUARD=1 to bypass the
* text-pattern checks entirely while still preserving structured-error
* and behavioral (zero-cost) detection.
*/

/**
* When true, all text-pattern spending guard checks are skipped.
* Structured SDK error detection (billing_error, rate_limit, etc.) and
* the behavioral heuristic (turns <= 2 && cost === 0) remain active.
*/
export const spendingGuardDisabled = process.env.SHANNON_DISABLE_SPENDING_GUARD === '1';

/**
* Text patterns for SDK output sniffing (what Claude says).
* Used by message-handlers.ts and the behavioral heuristic.
*
* NOTE: Only patterns that are unambiguous in a pentesting context belong
* here. "resets" was removed because it matches innocuous pentest
* vocabulary like "password reset" / "reset token" (see #263).
*/
export const BILLING_TEXT_PATTERNS = [
'spending cap',
'spending limit',
'cap reached',
'budget exceeded',
'usage limit',
'resets',
'usage limit reached',
] as const;

/**
Expand All @@ -50,8 +66,15 @@ export const BILLING_API_PATTERNS = [
/**
* Checks if text matches any billing text pattern.
* Used for sniffing SDK output content for spending cap messages.
*
* Returns false immediately when SHANNON_DISABLE_SPENDING_GUARD=1,
* letting the caller fall through to structured-error or behavioral
* detection instead.
*/
export function matchesBillingTextPattern(text: string): boolean {
if (spendingGuardDisabled) {
return false;
}
const lowerText = text.toLowerCase();
return BILLING_TEXT_PATTERNS.some((pattern) => lowerText.includes(pattern));
}
Expand All @@ -76,6 +99,10 @@ export function matchesBillingApiPattern(message: string): boolean {
* 2. Zero cost ($0)
* 3. Text matches billing patterns
*
* NOTE: The text-pattern leg respects SHANNON_DISABLE_SPENDING_GUARD;
* when the guard is disabled, this function can only return true if
* the caller adds an additional check.
*
* @param turns - Number of turns the agent took
* @param cost - Total cost in USD
* @param resultText - The result text from the agent
Expand Down