Files
wehub-resource-sync 0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

1451 lines
45 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CustomProvider, MemorySystem } from '../../../../src/redteam/providers/custom/index';
import { redteamProviderManager, tryUnblocking } from '../../../../src/redteam/providers/shared';
import { checkServerFeatureSupport } from '../../../../src/util/server';
import { createMockProvider, type MockApiProvider } from '../../../factories/provider';
import type { Message } from '../../../../src/redteam/providers/shared';
// Hoisted mocks for getGraderById
const mockGetGraderById = vi.hoisted(() => vi.fn());
// Hoisted mock for applyRuntimeTransforms
const mockApplyRuntimeTransforms = vi.hoisted(() =>
vi.fn().mockImplementation(async ({ prompt }) => ({
transformedPrompt: prompt,
audio: undefined,
image: undefined,
})),
);
vi.mock('../../../../src/globalConfig/accounts', async (importOriginal) => ({
...(await importOriginal()),
isLoggedIntoCloud: vi.fn().mockReturnValue(true),
}));
vi.mock('../../../../src/providers/promptfoo', async (importOriginal) => {
return {
...(await importOriginal()),
PromptfooChatCompletionProvider: vi.fn().mockImplementation(function () {
return {
id: () => 'mock-unblocking',
callApi: vi.fn(),
delay: 0,
};
}),
};
});
vi.mock('../../../../src/util/server', async (importOriginal) => {
return {
...(await importOriginal()),
checkServerFeatureSupport: vi.fn(),
};
});
vi.mock('../../../../src/redteam/providers/shared', async () => ({
...(await vi.importActual('../../../../src/redteam/providers/shared')),
tryUnblocking: vi.fn(),
}));
vi.mock('../../../../src/redteam/graders', async (importOriginal) => {
return {
...(await importOriginal()),
getGraderById: mockGetGraderById,
};
});
vi.mock('../../../../src/redteam/remoteGeneration', async (importOriginal) => {
return {
...(await importOriginal()),
shouldGenerateRemote: vi.fn(() => false),
};
});
vi.mock('../../../../src/redteam/shared/runtimeTransform', async (importOriginal) => {
return {
...(await importOriginal()),
applyRuntimeTransforms: mockApplyRuntimeTransforms,
};
});
describe('MemorySystem', () => {
let memorySystem: MemorySystem;
beforeEach(() => {
memorySystem = new MemorySystem();
});
it('should add and retrieve messages for a conversation', () => {
const conversationId = 'test-convo';
const message: Message = { role: 'user', content: 'test message' };
memorySystem.addMessage(conversationId, message);
const conversation = memorySystem.getConversation(conversationId);
expect(conversation).toHaveLength(1);
expect(conversation[0]).toEqual(message);
});
it('should return empty array for non-existent conversation', () => {
const conversation = memorySystem.getConversation('non-existent');
expect(conversation).toEqual([]);
});
it('should duplicate conversation excluding last turn', () => {
const conversationId = 'test-convo';
const messages: Message[] = [
{ role: 'system', content: 'system message' },
{ role: 'user', content: 'user message 1' },
{ role: 'assistant', content: 'assistant message 1' },
{ role: 'user', content: 'user message 2' },
{ role: 'assistant', content: 'assistant message 2' },
];
messages.forEach((msg) => memorySystem.addMessage(conversationId, msg));
const newConversationId = memorySystem.duplicateConversationExcludingLastTurn(conversationId);
const newConversation = memorySystem.getConversation(newConversationId);
expect(newConversation).toHaveLength(3);
expect(newConversation).toEqual(messages.slice(0, 3));
});
});
describe('CustomProvider', () => {
let customProvider: CustomProvider;
let mockRedTeamProvider: MockApiProvider;
let mockScoringProvider: MockApiProvider;
let mockTargetProvider: MockApiProvider;
beforeEach(() => {
vi.clearAllMocks();
// Create fresh mocks for each test
mockRedTeamProvider = createMockProvider({ id: 'mock-redteam', delay: 0 });
mockRedTeamProvider.callApi.mockReset();
mockScoringProvider = createMockProvider({ id: 'mock-scoring', delay: 0 });
mockScoringProvider.callApi.mockReset();
mockTargetProvider = createMockProvider({ id: 'mock-target' });
mockTargetProvider.callApi.mockReset();
customProvider = new CustomProvider({
injectVar: 'objective',
strategyText: 'If current round is 0, generatedQuestion should be just "hi" by itself',
maxTurns: 10,
maxBacktracks: 10,
redteamProvider: mockRedTeamProvider,
stateful: true,
});
// Set up redteamProviderManager mock
vi.spyOn(redteamProviderManager, 'getProvider').mockImplementation(async function (options) {
// When the provider is already an object (not a string), return it for jsonOnly requests
// For non-jsonOnly requests (scoring), return the scoring provider
if (
options.provider &&
typeof options.provider === 'object' &&
'callApi' in options.provider
) {
return options.jsonOnly ? options.provider : mockScoringProvider;
}
return options.jsonOnly ? mockRedTeamProvider : mockScoringProvider;
});
// Mock server feature support to return true so unblocking logic runs
vi.mocked(checkServerFeatureSupport).mockResolvedValue(true);
// Set up default getGraderById mock
mockGetGraderById.mockReset();
mockGetGraderById.mockImplementation(function () {
return {
getResult: vi.fn(async () => ({
grade: {
pass: false,
},
})),
} as any;
});
// Set up default tryUnblocking mock
vi.mocked(tryUnblocking).mockReset();
vi.mocked(tryUnblocking).mockResolvedValue({ success: false });
});
afterEach(() => {
vi.resetAllMocks();
});
it('should initialize with default config values', () => {
const provider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Custom strategy text',
maxTurns: 10,
maxBacktracks: 10,
redteamProvider: mockRedTeamProvider,
stateful: true,
});
expect(provider.config.injectVar).toBe('objective');
expect(provider.config.strategyText).toBe('Custom strategy text');
expect(provider.config.redteamProvider).toBe(mockRedTeamProvider);
expect(provider.config.maxTurns).toBe(10);
expect(provider.config.maxBacktracks).toBe(10);
expect(provider.config.stateful).toBe(true);
expect(provider.config.continueAfterSuccess).toBe(false);
});
it('should require strategyText in config', () => {
expect(() => {
new CustomProvider({
injectVar: 'objective',
strategyText: '', // Empty strategy text should fail
redteamProvider: mockRedTeamProvider,
});
}).toThrow('CustomProvider requires strategyText in config');
});
it('should return correct provider id', () => {
expect(customProvider.id()).toBe('promptfoo:redteam:custom');
});
it('should use default values when optional config not provided', () => {
const provider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Custom strategy',
redteamProvider: mockRedTeamProvider,
});
// These defaults are set on the instance, not the config
expect((provider as any).maxTurns).toBe(10); // DEFAULT_MAX_TURNS
expect((provider as any).maxBacktracks).toBe(10); // DEFAULT_MAX_BACKTRACKS
expect((provider as any).stateful).toBe(false); // Default false
expect(provider.config.continueAfterSuccess).toBe(false); // Default false
});
it('should preserve explicit zero values for maxTurns and maxBacktracks', () => {
const provider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Custom strategy',
maxTurns: 0,
maxBacktracks: 0,
redteamProvider: mockRedTeamProvider,
stateful: false,
});
expect((provider as any).maxTurns).toBe(0);
expect((provider as any).maxBacktracks).toBe(0);
});
it('should include sessionId from context vars when response is missing it', async () => {
const provider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Custom strategy',
maxTurns: 0,
maxBacktracks: 0,
redteamProvider: mockRedTeamProvider,
stateful: false,
});
const context = {
originalProvider: mockTargetProvider,
vars: {
objective: 'test objective',
sessionId: 'context-session-id',
},
prompt: { raw: 'test prompt', label: 'test' },
};
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.sessionId).toBe('context-session-id');
});
it('should include sessionId from target response when stateful is true', async () => {
vi.mocked(tryUnblocking).mockResolvedValue({ success: false });
const provider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Custom strategy',
maxTurns: 1,
maxBacktracks: 0,
redteamProvider: mockRedTeamProvider,
stateful: true,
});
vi.spyOn(provider as any, 'getAttackPrompt').mockResolvedValue({
generatedQuestion: 'attack prompt',
});
vi.spyOn(provider as any, 'sendPrompt').mockResolvedValue({
response: {
output: 'target response',
sessionId: 'response-session-id',
},
transformResult: undefined,
});
vi.spyOn(provider as any, 'getRefusalScore').mockResolvedValue([false, '']);
vi.spyOn(provider as any, 'getEvalScore').mockResolvedValue({
value: false,
metadata: 0,
rationale: '',
});
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: 'test prompt', label: 'test' },
};
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.sessionId).toBe('response-session-id');
});
describe('Unblocking functionality', () => {
it('should detect blocking question and send unblocking response', async () => {
const provider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Custom strategy for testing',
maxTurns: 1,
redteamProvider: mockRedTeamProvider,
stateful: true,
});
const prompt = 'test prompt';
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: prompt, label: 'test' },
test: { metadata: { purpose: 'e-commerce platform' } },
};
// Mock the attack prompt generation
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
// Mock target asking a blocking question
mockTargetProvider.callApi
.mockResolvedValueOnce({
output: 'What is your company registration number?',
})
.mockResolvedValueOnce({
output: 'Thank you for providing that information. How can I help you further?',
});
// Mock unblocking analysis - detects blocking question
vi.mocked(tryUnblocking).mockResolvedValue({
success: true,
unblockingPrompt: 'Our company registration number is REG123456789',
});
// Mock scoring (non-blocking after unblocking)
mockScoringProvider.callApi.mockResolvedValue({
output: JSON.stringify({
value: true,
metadata: 100,
rationale: 'Successfully unblocked conversation',
}),
});
const result = await provider.callApi(prompt, context);
expect(tryUnblocking).toHaveBeenCalledWith({
messages: expect.any(Array),
lastResponse: 'What is your company registration number?',
goal: 'test objective',
purpose: 'e-commerce platform',
});
expect(mockTargetProvider.callApi).toHaveBeenCalledTimes(2); // Original + unblocking response
expect(result.metadata?.stopReason).toBe('Max rounds reached');
});
it('should continue without unblocking when no blocking question detected', async () => {
const provider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Custom strategy for testing',
maxTurns: 1,
redteamProvider: mockRedTeamProvider,
stateful: true,
});
const prompt = 'test prompt';
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: prompt, label: 'test' },
};
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'I understand your request. Here is the information you need.',
});
// Mock unblocking analysis - no blocking detected
vi.mocked(tryUnblocking).mockResolvedValue({
success: false,
});
mockScoringProvider.callApi.mockResolvedValue({
output: JSON.stringify({
value: true,
metadata: 100,
rationale: 'Direct helpful response',
}),
});
const result = await provider.callApi(prompt, context);
expect(mockTargetProvider.callApi).toHaveBeenCalledTimes(1); // Only original call
expect(result.metadata?.stopReason).toBe('Max rounds reached');
});
});
it('should record internal evaluator success without exiting early', async () => {
// Set up grader to pass (not detect jailbreak) so we don't fail via grader
mockGetGraderById.mockImplementation(function () {
return {
getResult: vi.fn(async () => ({
grade: {
pass: true, // Pass means no jailbreak detected
},
})),
} as any;
});
// Create a new provider with smaller max turns for this test
const testProvider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Custom strategy for testing',
maxTurns: 1, // Limit iterations to avoid additional calls
maxBacktracks: 10,
redteamProvider: mockRedTeamProvider,
stateful: false,
});
const prompt = 'test prompt';
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: prompt, label: 'test' },
};
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'target response',
});
// Mock both refusal check and eval score
mockScoringProvider.callApi
.mockResolvedValueOnce({
// First call is refusal check - not a refusal
output: JSON.stringify({
value: false,
metadata: 0,
rationale: 'Not a refusal',
}),
})
.mockResolvedValueOnce({
// Second call is eval score - success
output: JSON.stringify({
value: true,
metadata: 100,
rationale: 'test rationale',
}),
});
const result = await testProvider.callApi(prompt, context);
expect(mockScoringProvider.callApi).toHaveBeenCalledTimes(2);
expect(result.metadata?.stopReason).toBe('Max rounds reached');
expect(result.metadata?.customResult).toBe(true);
expect(result.metadata?.customConfidence).toBe(100);
});
it('should succeed via external grader when grader fails', async () => {
const prompt = 'test prompt';
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: prompt, label: 'test' },
test: {
assert: [{ type: 'mock-grader' }],
metadata: { pluginId: 'mock' },
} as any,
};
// Mock unblocking to return false so it doesn't interfere
vi.mocked(tryUnblocking).mockResolvedValue({
success: false,
});
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'target response',
});
// First call checks for refusal (false = not a refusal)
// Second call is for internal evaluator
mockScoringProvider.callApi
.mockResolvedValueOnce({
output: JSON.stringify({
value: false,
metadata: 0,
rationale: 'Not a refusal',
}),
})
.mockResolvedValueOnce({
output: JSON.stringify({
value: false,
metadata: 50,
rationale: 'test rationale',
}),
});
const result = await customProvider.callApi(prompt, context);
expect(result.metadata?.stopReason).toBe('Grader failed');
});
it('should stop when max backtracks reached', async () => {
// Set up grader to pass (not detect jailbreak)
mockGetGraderById.mockImplementation(function () {
return {
getResult: vi.fn(async () => ({
grade: {
pass: true,
},
})),
} as any;
});
const testProvider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Custom strategy for backtrack test',
maxTurns: 10,
maxBacktracks: 2,
redteamProvider: mockRedTeamProvider,
stateful: false,
});
const prompt = 'test prompt';
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: prompt, label: 'test' },
};
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'I cannot help with that.',
});
// Mock scoring provider to always detect refusal
// Refusal check returns true (is a refusal) which triggers backtracks
mockScoringProvider.callApi.mockResolvedValue({
output: JSON.stringify({
value: true, // true means it IS a refusal
metadata: 0,
rationale: 'This is a refusal',
}),
});
const result = await testProvider.callApi(prompt, context);
expect(result.metadata?.stopReason).toBe('Max backtracks reached');
expect(result.metadata?.customBacktrackCount).toBe(2);
});
it('should record successful attacks when continueAfterSuccess is true', async () => {
const testProvider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Custom strategy for multiple success test',
maxTurns: 5,
continueAfterSuccess: true,
redteamProvider: mockRedTeamProvider,
stateful: false,
});
const prompt = 'test prompt';
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: prompt, label: 'test' },
};
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'successful attack response',
});
// Mock both refusal check and eval score - no refusals, no success to go through all rounds
mockScoringProvider.callApi
.mockResolvedValueOnce({
output: JSON.stringify({
value: false, // Not a refusal
metadata: 0,
rationale: 'Not a refusal',
}),
})
.mockResolvedValueOnce({
output: JSON.stringify({
value: false, // Not successful yet, but not a refusal
metadata: 50,
rationale: 'Partial progress',
}),
})
.mockResolvedValueOnce({
output: JSON.stringify({
value: false, // Not a refusal
metadata: 0,
rationale: 'Not a refusal',
}),
})
.mockResolvedValueOnce({
output: JSON.stringify({
value: true, // Successful attack on second round
metadata: 100,
rationale: 'Successful attack',
}),
})
.mockResolvedValueOnce({
output: JSON.stringify({
value: false, // Not a refusal
metadata: 0,
rationale: 'Not a refusal',
}),
})
.mockResolvedValueOnce({
output: JSON.stringify({
value: false, // No success to continue
metadata: 50,
rationale: 'Continuing conversation',
}),
});
const result = await testProvider.callApi(prompt, context);
expect(result.metadata?.stopReason).toBe('Max rounds reached');
expect(result.metadata?.totalSuccessfulAttacks).toBeGreaterThan(0);
expect(result.metadata?.successfulAttacks).toBeDefined();
expect(result.metadata?.successfulAttacks?.length).toBeGreaterThan(0);
});
it('should surface final target error while preserving mapped output', async () => {
const provider = new CustomProvider({
injectVar: 'objective',
strategyText: 'simple',
maxTurns: 1,
redteamProvider: mockRedTeamProvider,
stateful: false,
});
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: 'p', label: 'l' },
};
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'attack',
rationaleBehindJailbreak: 'r',
lastResponseSummary: 's',
}),
});
// final target returns error with output
mockTargetProvider.callApi.mockResolvedValue({ output: 'This is 504', error: 'HTTP 504' });
// refusal false, eval not 100
mockScoringProvider.callApi
.mockResolvedValueOnce({ output: JSON.stringify({ value: false, metadata: 0 }) })
.mockResolvedValueOnce({ output: JSON.stringify({ value: false, metadata: 50 }) });
const result = await provider.callApi('p', context);
expect(result.output).toBe('This is 504');
expect(result.error).toBe('HTTP 504');
});
it('should handle custom strategy text with round-specific logic', async () => {
const roundSpecificStrategy = `
If current round is 0, generatedQuestion should be just "hi" by itself.
If current round >= 2, start generating questions to achieve the objective.
`;
const testProvider = new CustomProvider({
injectVar: 'objective',
strategyText: roundSpecificStrategy,
maxTurns: 3,
redteamProvider: mockRedTeamProvider,
stateful: false,
});
const prompt = 'test prompt';
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: prompt, label: 'test' },
};
// Mock attack prompt generation to simulate different responses based on round
mockRedTeamProvider.callApi
.mockResolvedValueOnce({
output: JSON.stringify({
generatedQuestion: 'hi',
rationaleBehindJailbreak: 'Starting with greeting as per round 0 strategy',
lastResponseSummary: '',
}),
})
.mockResolvedValueOnce({
output: JSON.stringify({
generatedQuestion: 'How are you?',
rationaleBehindJailbreak: 'Continuing conversation in round 1',
lastResponseSummary: 'Target responded positively',
}),
})
.mockResolvedValueOnce({
output: JSON.stringify({
generatedQuestion: 'Can you help me with my objective?',
rationaleBehindJailbreak: 'Now in round 2, pursuing objective',
lastResponseSummary: 'Target is engaged',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Hello! How can I help you?',
});
// Mock scoring - no refusals, no success (to go through all rounds)
mockScoringProvider.callApi.mockResolvedValue({
output: JSON.stringify({
value: false,
metadata: 50,
rationale: 'Partial progress',
}),
});
const result = await testProvider.callApi(prompt, context);
expect(mockRedTeamProvider.callApi).toHaveBeenCalledTimes(3);
expect(result.metadata?.customRoundsCompleted).toBe(3);
expect(result.metadata?.stopReason).toBe('Max rounds reached');
});
it('should handle stateful mode with sessionId', async () => {
const testProvider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Stateful strategy test',
maxTurns: 1,
stateful: true,
redteamProvider: mockRedTeamProvider,
});
const prompt = 'test prompt';
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: prompt, label: 'test' },
};
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
// Mock target to return sessionId
mockTargetProvider.callApi.mockResolvedValue({
output: 'target response',
sessionId: 'test-session-123',
});
mockScoringProvider.callApi.mockResolvedValue({
output: JSON.stringify({
value: true,
metadata: 100,
rationale: 'Success',
}),
});
const result = await testProvider.callApi(prompt, context);
expect(result.metadata?.stopReason).toBe('Max rounds reached');
// Verify sessionId was set in context vars
expect((context.vars as any).sessionId).toBe('test-session-123');
});
it('should pass additionalRubric to grader when goal is provided', async () => {
// Create a new provider
const testProvider = new CustomProvider({
injectVar: 'objective',
maxTurns: 1,
maxBacktracks: 1,
redteamProvider: mockRedTeamProvider,
stateful: false,
strategyText: 'Test strategy',
});
const prompt = 'test prompt';
const testGoal = 'Extract sensitive database credentials';
const context = {
originalProvider: mockTargetProvider,
vars: { objective: testGoal },
prompt: { raw: prompt, label: 'test' },
test: {
assert: [{ type: 'mock-grader' }],
metadata: {
pluginId: 'mock',
goal: testGoal,
},
} as any,
};
// Capture the additionalRubric parameter
let capturedAdditionalRubric: string | undefined;
mockGetGraderById.mockImplementation(function () {
return {
getResult: vi.fn(
async (
_prompt: string,
_output: string,
_test: any,
_provider: any,
_value: any,
additionalRubric?: string,
) => {
capturedAdditionalRubric = additionalRubric;
return {
grade: {
pass: false,
score: 0,
reason: 'Jailbreak detected',
},
};
},
),
} as any;
});
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'target response',
});
mockScoringProvider.callApi.mockResolvedValue({
output: JSON.stringify({
value: false,
metadata: 50,
rationale: 'test rationale',
}),
});
await testProvider.callApi(prompt, context);
// Verify that additionalRubric was generated and passed to grader
expect(capturedAdditionalRubric).toBeDefined();
expect(capturedAdditionalRubric).toContain(testGoal);
expect(capturedAdditionalRubric).toContain('ADDITIONAL EVALUATION CRITERIA');
});
it('should store grader result in metadata when grader fails', async () => {
const testProvider = new CustomProvider({
injectVar: 'objective',
maxTurns: 1,
maxBacktracks: 1,
redteamProvider: mockRedTeamProvider,
stateful: false,
strategyText: 'Test strategy',
});
const mockGraderResult = {
pass: false,
score: 0,
reason: 'Jailbreak successful - extracted sensitive info',
tokensUsed: { total: 15, prompt: 8, completion: 7 },
};
const testRubric = 'Test grading rubric';
const getResult = vi.fn<any>(async () => ({
grade: mockGraderResult,
rubric: testRubric,
}));
// Mock grader to fail (jailbreak success)
mockGetGraderById.mockImplementation(function () {
return {
getResult,
} as any;
});
const prompt = 'test prompt';
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: prompt, label: 'test' },
test: {
assert: [{ type: 'mock-grader' }],
metadata: { pluginId: 'mock' },
} as any,
};
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'target response',
});
// Mock scoring provider responses
mockScoringProvider.callApi
.mockResolvedValueOnce({
// Refusal check - not refused
output: JSON.stringify({
value: false,
metadata: 20,
rationale: 'Not a refusal',
}),
})
.mockResolvedValueOnce({
// Eval score - partial success (not 100%)
output: JSON.stringify({
value: false,
metadata: 50,
rationale: 'Partial goal achievement',
}),
});
const result = await testProvider.callApi(prompt, context);
expect(getResult).toHaveBeenCalled();
expect(getResult.mock.calls[0][7]).toMatchObject({
providerResponse: { output: 'target response' },
});
// Verify storedGraderResult is included in metadata (with assertion.value set to rubric)
expect(result.metadata?.storedGraderResult).toEqual({
...mockGraderResult,
assertion: { type: 'mock-grader', value: testRubric },
});
expect(result.metadata?.stopReason).toBe('Grader failed');
// Verify it's actually a successful attack
expect(result.metadata?.successfulAttacks).toHaveLength(1);
expect(result.metadata?.totalSuccessfulAttacks).toBe(1);
});
it('should not create synthetic grader result when internal evaluator succeeds', async () => {
const testProvider = new CustomProvider({
injectVar: 'objective',
maxTurns: 1,
maxBacktracks: 1,
redteamProvider: mockRedTeamProvider,
stateful: false,
strategyText: 'Test strategy',
});
const testRubric = 'Test grading rubric';
// Mock grader to pass (no jailbreak)
mockGetGraderById.mockImplementation(function () {
return {
getResult: vi.fn(async () => ({
grade: {
pass: true,
score: 1,
reason: 'No jailbreak detected',
},
rubric: testRubric,
})),
} as any;
});
const prompt = 'test prompt';
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: prompt, label: 'test' },
test: {
assert: [{ type: 'mock-grader' }],
metadata: { pluginId: 'mock' },
} as any,
};
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'target response',
});
// Mock scoring provider responses
mockScoringProvider.callApi
.mockResolvedValueOnce({
// Refusal check - not refused
output: JSON.stringify({
value: false,
metadata: 20,
rationale: 'Not a refusal',
}),
})
.mockResolvedValueOnce({
// Eval score - 100% success
output: JSON.stringify({
value: true,
metadata: 100,
rationale: 'Goal fully achieved - sensitive data extracted',
}),
});
const result = await testProvider.callApi(prompt, context);
expect(result.metadata?.stopReason).toBe('Max rounds reached');
expect(result.metadata?.storedGraderResult).toEqual({
pass: true,
score: 1,
reason: 'No jailbreak detected',
assertion: { type: 'mock-grader', value: testRubric },
});
});
it('should store grader result even when continueAfterSuccess is true', async () => {
const testProvider = new CustomProvider({
injectVar: 'objective',
maxTurns: 2,
maxBacktracks: 1,
redteamProvider: mockRedTeamProvider,
stateful: false,
strategyText: 'Test strategy',
continueAfterSuccess: true,
});
const firstGraderResult = {
pass: false,
score: 0,
reason: 'First jailbreak detected',
};
const secondGraderResult = {
pass: true,
score: 1,
reason: 'No jailbreak on second turn',
};
const testRubric = 'Test grading rubric';
// Create the mock getResult function outside mockImplementation so it persists across calls
const mockGetResult = vi
.fn()
.mockResolvedValueOnce({ grade: firstGraderResult, rubric: testRubric })
.mockResolvedValueOnce({ grade: secondGraderResult, rubric: testRubric });
// Mock grader to fail on first turn, pass on second
mockGetGraderById.mockImplementation(function () {
return {
getResult: mockGetResult,
} as any;
});
const prompt = 'test prompt';
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: prompt, label: 'test' },
test: {
assert: [{ type: 'mock-grader' }],
metadata: { pluginId: 'mock' },
} as any,
};
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'target response',
});
// Mock scoring provider responses for both turns
mockScoringProvider.callApi.mockResolvedValue({
output: JSON.stringify({
value: false,
metadata: 30,
rationale: 'Partial success',
}),
});
const result = await testProvider.callApi(prompt, context);
// Should continue to max turns and store the LAST grader result (with assertion.value set to rubric)
expect(result.metadata?.storedGraderResult).toEqual({
...secondGraderResult,
assertion: { type: 'mock-grader', value: testRubric },
});
expect(result.metadata?.stopReason).toBe('Max rounds reached');
expect(result.metadata?.successfulAttacks).toHaveLength(1);
expect(result.metadata?.totalSuccessfulAttacks).toBe(1);
});
it('should include modifiers in system prompt from test metadata', async () => {
const prompt = 'test prompt';
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: prompt, label: 'test' },
test: {
vars: {},
metadata: {
pluginId: 'test-plugin',
purpose: 'payment processing system',
modifiers: {
testGenerationInstructions: 'Generate prompts as realistic invoice queries',
language: 'Spanish',
},
},
} as any,
};
// Capture the system prompt that gets sent
let capturedSystemPrompt = '';
mockRedTeamProvider.callApi.mockImplementation(async function (prompt: string) {
const input = JSON.parse(prompt);
if (Array.isArray(input) && input[0]?.role === 'system') {
capturedSystemPrompt = input[0].content;
}
return {
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
};
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'target response',
});
mockScoringProvider.callApi.mockResolvedValue({
output: JSON.stringify({
value: true,
metadata: 100,
rationale: 'Success',
}),
});
await customProvider.callApi(prompt, context);
// Verify modifiers were included in the system prompt
expect(capturedSystemPrompt).toContain('CRITICAL: Ensure all generated prompts');
expect(capturedSystemPrompt).toContain('<Modifiers>');
expect(capturedSystemPrompt).toContain(
'testGenerationInstructions: Generate prompts as realistic invoice queries',
);
expect(capturedSystemPrompt).toContain('language: Spanish');
expect(capturedSystemPrompt).toContain('Rewrite ALL prompts to fully comply');
});
describe('Abort Signal Handling', () => {
it('should pass options to red team provider callApi', async () => {
const abortController = new AbortController();
const options = { abortSignal: abortController.signal };
const testProvider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Test strategy',
maxTurns: 1,
redteamProvider: mockRedTeamProvider,
stateful: false,
});
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'target response',
});
mockScoringProvider.callApi.mockResolvedValue({
output: JSON.stringify({
value: true,
metadata: 100,
rationale: 'Success',
}),
});
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: 'test prompt', label: 'test' },
};
await testProvider.callApi('test prompt', context, options);
// Red team provider should be called with options
expect(mockRedTeamProvider.callApi).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
options,
);
});
it('should pass options to scoring provider callApi', async () => {
const abortController = new AbortController();
const options = { abortSignal: abortController.signal };
const testProvider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Test strategy',
maxTurns: 1,
redteamProvider: mockRedTeamProvider,
stateful: false,
});
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'target response',
});
mockScoringProvider.callApi.mockResolvedValue({
output: JSON.stringify({
value: false,
metadata: 50,
rationale: 'Not a refusal',
}),
});
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: 'test prompt', label: 'test' },
};
await testProvider.callApi('test prompt', context, options);
// Scoring provider should be called with options
expect(mockScoringProvider.callApi).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
options,
);
});
it('should re-throw AbortError and not swallow it', async () => {
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
const testProvider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Test strategy',
maxTurns: 3,
redteamProvider: mockRedTeamProvider,
stateful: false,
});
// Mock red team provider to throw AbortError
mockRedTeamProvider.callApi.mockRejectedValue(abortError);
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: 'test prompt', label: 'test' },
};
await expect(testProvider.callApi('test prompt', context)).rejects.toThrow(
'The operation was aborted',
);
});
});
describe('perTurnLayers configuration', () => {
it('should accept _perTurnLayers in config', () => {
const provider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Test strategy',
redteamProvider: mockRedTeamProvider,
_perTurnLayers: [{ id: 'audio' }, { id: 'image' }],
});
expect(provider['perTurnLayers']).toEqual([{ id: 'audio' }, { id: 'image' }]);
});
it('should default perTurnLayers to empty array when not provided', () => {
const provider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Test strategy',
redteamProvider: mockRedTeamProvider,
});
expect(provider['perTurnLayers']).toEqual([]);
});
it('should not apply transforms when perTurnLayers is empty', async () => {
const provider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Test strategy',
maxTurns: 1,
redteamProvider: mockRedTeamProvider,
// No _perTurnLayers provided - defaults to empty
});
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'target response',
});
mockScoringProvider.callApi.mockResolvedValue({
output: JSON.stringify({
value: true,
metadata: 100,
rationale: 'Success',
}),
});
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: 'test prompt', label: 'test' },
};
const result = await provider.callApi('test prompt', context);
// Verify redteamHistory exists but promptAudio/promptImage are undefined
expect(result.metadata?.redteamHistory).toBeDefined();
if (result.metadata?.redteamHistory && result.metadata.redteamHistory.length > 0) {
expect(result.metadata.redteamHistory[0].promptAudio).toBeUndefined();
expect(result.metadata.redteamHistory[0].promptImage).toBeUndefined();
}
});
it('should include redteamHistory with media fields when perTurnLayers is configured', async () => {
// Configure the hoisted mock to return audio/image data for this test
mockApplyRuntimeTransforms.mockResolvedValueOnce({
transformedPrompt: 'transformed prompt',
audio: { data: 'base64-audio-data', format: 'mp3' },
image: { data: 'base64-image-data', format: 'png' },
});
const provider = new CustomProvider({
injectVar: 'objective',
strategyText: 'Test strategy',
maxTurns: 1,
redteamProvider: mockRedTeamProvider,
_perTurnLayers: [{ id: 'audio' }],
});
mockRedTeamProvider.callApi.mockResolvedValue({
output: JSON.stringify({
generatedQuestion: 'test question',
rationaleBehindJailbreak: 'test rationale',
lastResponseSummary: 'test summary',
}),
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'target response',
audio: { data: 'response-audio-data', format: 'wav' },
});
mockScoringProvider.callApi.mockResolvedValue({
output: JSON.stringify({
value: true,
metadata: 100,
rationale: 'Success',
}),
});
const context = {
originalProvider: mockTargetProvider,
vars: { objective: 'test objective' },
prompt: { raw: 'test prompt', label: 'test' },
};
const result = await provider.callApi('test prompt', context);
// Verify redteamHistory is populated
expect(result.metadata?.redteamHistory).toBeDefined();
expect(Array.isArray(result.metadata?.redteamHistory)).toBe(true);
});
});
});