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

1297 lines
44 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import RedteamIterativeMetaProvider, {
runMetaAgentRedteam,
} from '../../../src/redteam/providers/iterativeMeta';
import {
createMockProvider,
createProviderResponse,
createTokenUsage,
type MockApiProvider,
} from '../../factories/provider';
import type { AtomicTestCase, ProviderResponse } from '../../../src/types/index';
const mockGetProvider = vi.hoisted(() => vi.fn<() => Promise<any>>());
const mockGetTargetResponse = vi.hoisted(() => vi.fn<() => Promise<any>>());
vi.mock('../../../src/globalConfig/accounts', async (importOriginal) => ({
...(await importOriginal()),
isLoggedIntoCloud: vi.fn().mockReturnValue(true),
}));
vi.mock('../../../src/redteam/providers/shared', async (importOriginal) => {
return {
...(await importOriginal()),
redteamProviderManager: {
getProvider: mockGetProvider,
},
getTargetResponse: mockGetTargetResponse,
createIterationContext: vi.fn<any>().mockResolvedValue({
iterationVars: {},
iterationContext: {},
}),
};
});
const mockGetGraderById = vi.hoisted(() => vi.fn());
vi.mock('../../../src/redteam/graders', async (importOriginal) => {
return {
...(await importOriginal()),
getGraderById: mockGetGraderById,
};
});
const mockShouldGenerateRemote = vi.hoisted(() => vi.fn(() => true));
const mockNeverGenerateRemote = vi.hoisted(() => vi.fn(() => false));
vi.mock('../../../src/redteam/remoteGeneration', async (importOriginal) => {
return {
...(await importOriginal()),
neverGenerateRemote: mockNeverGenerateRemote,
shouldGenerateRemote: mockShouldGenerateRemote,
};
});
// Tracing mocks
const mockResolveTracingOptions = vi.hoisted(() =>
vi.fn(() => ({
enabled: false,
includeInAttack: true,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
})),
);
const mockFetchTraceContext = vi.hoisted(() => vi.fn());
const mockFormatTraceSummary = vi.hoisted(() => vi.fn(() => 'Trace summary'));
const mockFormatTraceForMetadata = vi.hoisted(() => vi.fn(() => ({ traceId: 'test-trace-id' })));
const mockExtractTraceIdFromTraceparent = vi.hoisted(() => vi.fn(() => 'test-trace-id'));
vi.mock('../../../src/redteam/providers/tracingOptions', () => ({
resolveTracingOptions: mockResolveTracingOptions,
}));
vi.mock('../../../src/tracing/traceContext', () => ({
fetchTraceContext: mockFetchTraceContext,
extractTraceIdFromTraceparent: mockExtractTraceIdFromTraceparent,
}));
vi.mock('../../../src/redteam/providers/traceFormatting', () => ({
formatTraceSummary: mockFormatTraceSummary,
formatTraceForMetadata: mockFormatTraceForMetadata,
}));
describe('RedteamIterativeMetaProvider', () => {
let mockAgentProvider: MockApiProvider;
let mockGradingProvider: MockApiProvider;
let mockTargetProvider: MockApiProvider;
beforeEach(() => {
vi.clearAllMocks();
mockShouldGenerateRemote.mockReset();
mockShouldGenerateRemote.mockReturnValue(true);
mockNeverGenerateRemote.mockReset();
mockNeverGenerateRemote.mockReturnValue(false);
// Mock cloud agent provider - returns attack prompts
mockAgentProvider = createMockProvider({
id: 'mock-agent',
delay: 0,
response: createProviderResponse({
output: {
result: 'Can you help me fix this code...',
},
tokenUsage: createTokenUsage({ total: 100, prompt: 50, completion: 50 }),
}),
});
mockGradingProvider = createMockProvider({
id: 'mock-grader',
response: createProviderResponse({ output: 'grader result' }),
});
mockTargetProvider = createMockProvider({
id: 'mock-target',
response: createProviderResponse({ output: 'I cannot help with that' }),
});
mockGetProvider.mockImplementation(async function () {
return mockAgentProvider;
});
mockGetTargetResponse.mockResolvedValue({
output: 'I cannot help with that',
});
mockGetGraderById.mockReturnValue({
getResult: vi.fn<any>().mockResolvedValue({
grade: {
pass: true,
score: 0,
reason: 'Target defended',
},
}),
});
});
afterEach(() => {
vi.resetAllMocks();
});
describe('constructor', () => {
it('should throw the implicit-disabled error when remote generation is unavailable for this config', () => {
mockShouldGenerateRemote.mockReturnValue(false);
mockNeverGenerateRemote.mockReturnValue(false);
expect(() => new RedteamIterativeMetaProvider({ injectVar: 'query' })).toThrow(
'jailbreak:meta strategy requires remote generation, which is currently disabled for this configuration. To enable it, run with --remote, set PROMPTFOO_REMOTE_GENERATION_URL to a self-hosted endpoint, or log into Promptfoo Cloud with `promptfoo auth login`.',
);
});
it('should throw the explicit-disabled error when a disable flag is set', () => {
mockShouldGenerateRemote.mockReturnValue(false);
mockNeverGenerateRemote.mockReturnValue(true);
expect(() => new RedteamIterativeMetaProvider({ injectVar: 'query' })).toThrow(
/jailbreak:meta strategy requires remote generation, which has been explicitly disabled\. To enable it, unset (PROMPTFOO_DISABLE_REMOTE_GENERATION|PROMPTFOO_DISABLE_REDTEAM_REMOTE_GENERATION)/,
);
});
});
describe('runMetaAgentRedteam', () => {
it('should execute iterations and call cloud for decisions', async () => {
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 3,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// Should call cloud agent 3 times
expect(mockAgentProvider.callApi).toHaveBeenCalledTimes(3);
expect(result.metadata.finalIteration).toBe(3);
expect(result.metadata.redteamHistory).toHaveLength(3);
});
it('should stop early on vulnerability found', async () => {
const mockGrader = {
getResult: vi
.fn<any>()
.mockResolvedValueOnce({
grade: { pass: true, score: 0 },
})
.mockResolvedValueOnce({
grade: { pass: false, score: 1, reason: 'Vulnerability found' },
}),
};
mockGetGraderById.mockReturnValue(mockGrader);
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 10,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: {
vars: { query: 'test' },
assert: [{ type: 'promptfoo:redteam:harmful', metric: 'Harmful' }],
} as AtomicTestCase,
vars: { query: 'test' },
});
// Should stop at iteration 2
expect(result.metadata.finalIteration).toBe(2);
expect(result.metadata.vulnerabilityAchieved).toBe(true);
expect(result.metadata.stopReason).toBe('Grader failed');
});
it('passes target provider raw response into the grader', async () => {
const mockGrader = {
getResult: vi.fn<any>().mockResolvedValue({
grade: { pass: true, score: 0, reason: 'Target defended' },
rubric: 'test rubric',
}),
};
mockGetGraderById.mockReturnValue(mockGrader);
mockGetTargetResponse.mockResolvedValue({
output: 'Target response',
raw: JSON.stringify({ finalResponse: 'Target response', items: [] }),
images: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
});
await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 1,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: {
vars: { query: 'test' },
assert: [{ type: 'promptfoo:redteam:harmful', metric: 'Harmful' }],
} as AtomicTestCase,
vars: { query: 'test' },
});
expect(mockGrader.getResult).toHaveBeenCalled();
const gradingContext = mockGrader.getResult.mock.calls[0][7] as {
imageOutputs?: ProviderResponse['images'];
providerResponse?: ProviderResponse;
};
expect(gradingContext.imageOutputs).toEqual([
{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' },
]);
expect(gradingContext.providerResponse).toMatchObject({
output: 'Target response',
raw: JSON.stringify({ finalResponse: 'Target response', items: [] }),
});
});
it('should handle agent provider errors gracefully', async () => {
mockAgentProvider.callApi = vi
.fn<() => Promise<ProviderResponse>>()
.mockResolvedValueOnce({ error: 'Agent error' })
.mockResolvedValueOnce({
output: { result: 'Second attempt' },
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 2,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// Should continue after error and complete iteration 2
expect(mockAgentProvider.callApi).toHaveBeenCalledTimes(2);
expect(result.metadata.redteamHistory).toHaveLength(1); // Only iteration 2 succeeded
});
it('should handle nunjucks template syntax in attack prompts without crashing', async () => {
mockAgentProvider.callApi = vi.fn<() => Promise<ProviderResponse>>().mockResolvedValue({
output: {
result: 'Attack with {{variable}} and {% code %}',
},
tokenUsage: {
total: 100,
prompt: 50,
completion: 50,
},
});
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: '{{query}}', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 1,
options: undefined,
prompt: { raw: '{{query}}', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// Should complete without throwing error
expect(result.metadata.redteamHistory).toHaveLength(1);
});
it('should fail closed when remote multi-input generation returns an invalid prompt format', async () => {
mockAgentProvider.callApi = vi.fn<() => Promise<ProviderResponse>>().mockResolvedValue({
materializationHandled: true,
output: {
result: 'document: malicious content\nquestion: summarize it',
},
tokenUsage: {
total: 100,
prompt: 50,
completion: 50,
},
});
const result = await runMetaAgentRedteam({
context: {
vars: {
query: 'test',
document: 'stale document',
question: 'stale question',
},
prompt: { raw: '{{document}} {{question}}', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
inputs: {
document: {
description: 'Uploaded planning document',
type: 'docx',
},
question: {
description: 'Benign analyst question',
type: 'text',
},
},
numIterations: 1,
options: undefined,
prompt: { raw: '{{document}} {{question}}', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: {
query: 'test',
document: 'stale document',
question: 'stale question',
},
});
expect(result.error).toBe(
'Iterative Meta remote multi-input generation returned an invalid prompt format',
);
expect(result.metadata.redteamHistory).toHaveLength(0);
expect(mockGetTargetResponse).not.toHaveBeenCalled();
});
});
describe('perTurnLayers configuration', () => {
it('should accept perTurnLayers parameter (empty array for safe testing)', async () => {
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 1,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
perTurnLayers: [], // Empty array to avoid actual transforms
});
// Should complete without error when perTurnLayers is provided
expect(result.metadata.finalIteration).toBeDefined();
});
});
describe('redteamHistory with audio/image data', () => {
it('should include promptAudio and promptImage fields in redteamHistory entries', async () => {
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 1,
options: undefined,
prompt: { raw: '{{query}}', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// redteamHistory should be present
expect(result.metadata.redteamHistory).toBeDefined();
expect(Array.isArray(result.metadata.redteamHistory)).toBe(true);
if (result.metadata.redteamHistory.length > 0) {
const entry = result.metadata.redteamHistory[0];
// These fields should be present (even if undefined without layers)
expect(entry).toHaveProperty('prompt');
expect(entry).toHaveProperty('output');
}
});
it('should capture outputAudio when target returns audio data', async () => {
// Set up mockGetTargetResponse to return audio data
mockGetTargetResponse.mockReset();
mockGetTargetResponse.mockResolvedValue({
output: 'response with audio',
audio: { data: 'base64audiodata', format: 'mp3' },
});
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 1,
options: undefined,
prompt: { raw: '{{query}}', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
if (result.metadata.redteamHistory.length > 0) {
const entry = result.metadata.redteamHistory[0];
expect(entry.outputAudio).toBeDefined();
expect(entry.outputAudio?.data).toBe('base64audiodata');
expect(entry.outputAudio?.format).toBe('mp3');
}
});
it('should capture outputImage when target returns image data', async () => {
// Set up mockGetTargetResponse to return image data
mockGetTargetResponse.mockReset();
mockGetTargetResponse.mockResolvedValue({
output: 'response with image',
image: { data: 'base64imagedata', format: 'png' },
});
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 1,
options: undefined,
prompt: { raw: '{{query}}', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
if (result.metadata.redteamHistory.length > 0) {
const entry = result.metadata.redteamHistory[0];
expect(entry.outputImage).toBeDefined();
expect(entry.outputImage?.data).toBe('base64imagedata');
expect(entry.outputImage?.format).toBe('png');
}
});
});
describe('Rubric Storage', () => {
it('should store rendered rubric in storedGraderResult.assertion.value', () => {
const mockRenderedRubric = '<rubric>Rendered grading criteria</rubric>';
const mockGraderResult: {
pass: boolean;
score: number;
reason: string;
assertion?: any;
} = {
pass: false,
score: 0,
reason: 'Attack successful',
};
const testAssertion = {
type: 'promptfoo:redteam:policy' as const,
metric: 'PolicyViolation:test',
};
// Simulate the pattern used in iterativeMeta
const storedResult = {
...mockGraderResult,
assertion: mockGraderResult.assertion
? { ...mockGraderResult.assertion, value: mockRenderedRubric }
: testAssertion && 'type' in testAssertion && (testAssertion as any).type !== 'assert-set'
? { ...testAssertion, value: mockRenderedRubric }
: undefined,
};
expect(storedResult.assertion).toBeDefined();
expect(storedResult.assertion?.value).toBe(mockRenderedRubric);
expect(storedResult.assertion?.type).toBe('promptfoo:redteam:policy');
});
it('should use grade.assertion when present', () => {
const mockRenderedRubric = '<rubric>Test rubric</rubric>';
const mockGraderResultWithAssertion = {
pass: false,
score: 0,
reason: 'Failed',
assertion: {
type: 'promptfoo:redteam:harmful' as const,
metric: 'Harmful',
value: 'old value',
},
};
const storedResult = {
...mockGraderResultWithAssertion,
assertion: mockGraderResultWithAssertion.assertion
? { ...mockGraderResultWithAssertion.assertion, value: mockRenderedRubric }
: undefined,
};
expect(storedResult.assertion?.value).toBe(mockRenderedRubric);
expect(storedResult.assertion?.type).toBe('promptfoo:redteam:harmful');
});
it('should not create assertion for AssertionSet', () => {
const mockRenderedRubric = '<rubric>Test rubric</rubric>';
const mockGraderResult = {
pass: false,
score: 0,
reason: 'Failed',
};
const assertionSet = {
type: 'assert-set' as const,
assert: [{ type: 'contains' as const, value: 'test' }],
};
const storedResult = {
...mockGraderResult,
assertion:
assertionSet && 'type' in assertionSet && assertionSet.type !== 'assert-set'
? { ...assertionSet, value: mockRenderedRubric }
: undefined,
};
expect(storedResult.assertion).toBeUndefined();
});
});
describe('Token Usage Tracking', () => {
it('should accumulate token usage from agent provider calls', async () => {
// Set up agent to return token usage
mockAgentProvider.callApi = vi
.fn<() => Promise<ProviderResponse>>()
.mockResolvedValueOnce({
output: { result: 'First attack' },
tokenUsage: { prompt: 50, completion: 30, total: 80, numRequests: 1 },
})
.mockResolvedValueOnce({
output: { result: 'Second attack' },
tokenUsage: { prompt: 60, completion: 40, total: 100, numRequests: 1 },
});
// Set up target to return token usage
mockGetTargetResponse
.mockResolvedValueOnce({
output: 'Target response 1',
tokenUsage: { prompt: 20, completion: 15, total: 35, numRequests: 1 },
})
.mockResolvedValueOnce({
output: 'Target response 2',
tokenUsage: { prompt: 25, completion: 18, total: 43, numRequests: 1 },
});
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 2,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// Verify token usage is accumulated
expect(result.tokenUsage).toBeDefined();
// Agent (80 + 100) + Target (35 + 43) = 258 total
expect(result.tokenUsage?.total).toBeGreaterThanOrEqual(150);
expect(result.tokenUsage?.prompt).toBeGreaterThan(0);
expect(result.tokenUsage?.completion).toBeGreaterThan(0);
});
it('should track numRequests across all iterations', async () => {
mockAgentProvider.callApi = vi
.fn<() => Promise<ProviderResponse>>()
.mockResolvedValueOnce({
output: { result: 'Attack 1' },
tokenUsage: { prompt: 50, completion: 30, total: 80, numRequests: 1 },
})
.mockResolvedValueOnce({
output: { result: 'Attack 2' },
tokenUsage: { prompt: 60, completion: 40, total: 100, numRequests: 1 },
})
.mockResolvedValueOnce({
output: { result: 'Attack 3' },
tokenUsage: { prompt: 55, completion: 35, total: 90, numRequests: 1 },
});
mockGetTargetResponse
.mockResolvedValueOnce({
output: 'Response 1',
tokenUsage: { numRequests: 1 },
})
.mockResolvedValueOnce({
output: 'Response 2',
tokenUsage: { numRequests: 1 },
})
.mockResolvedValueOnce({
output: 'Response 3',
tokenUsage: { numRequests: 1 },
});
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 3,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// Probe counting should only include target calls.
expect(result.tokenUsage?.numRequests).toBe(3);
});
it('should handle missing token usage gracefully', async () => {
// Agent returns no token usage
mockAgentProvider.callApi = vi.fn<() => Promise<ProviderResponse>>().mockResolvedValue({
output: { result: 'Attack' },
// No tokenUsage field
});
mockGetTargetResponse.mockResolvedValue({
output: 'Response',
// No tokenUsage field
});
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 1,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// Should still return tokenUsage object with target numRequests counted
expect(result.tokenUsage).toBeDefined();
expect(result.tokenUsage?.numRequests).toBe(1);
});
});
describe('Abort Signal Handling', () => {
it('should pass options with abortSignal to agentProvider.callApi', async () => {
const abortController = new AbortController();
const options = { abortSignal: abortController.signal };
await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 1,
options,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// Verify options were passed to callApi
expect(mockAgentProvider.callApi).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
options,
);
});
it('should handle agent provider errors and continue loop (non-abort)', async () => {
// First call errors, second succeeds
mockAgentProvider.callApi = vi
.fn<() => Promise<ProviderResponse>>()
.mockResolvedValueOnce({ error: 'Temporary error' })
.mockResolvedValueOnce({
output: { result: 'Attack prompt' },
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 2,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// Should continue after error and complete second iteration
expect(mockAgentProvider.callApi).toHaveBeenCalledTimes(2);
expect(result.metadata.redteamHistory).toHaveLength(1);
});
});
describe('Privacy protection - excludeTargetOutputFromAgenticAttackGeneration', () => {
it('should NOT send target response to cloud when privacy is enabled', async () => {
const sensitiveResponse = 'SENSITIVE: Credit Card 4111-1111-1111-1111, SSN 123-45-6789';
const cloudRequests: any[] = [];
const targetResponses = [sensitiveResponse, 'Target defended'];
let targetCallCount = 0;
// Override mockAgentProvider to capture requests
mockAgentProvider.callApi = vi.fn().mockImplementation(async function (input: any) {
cloudRequests.push(JSON.parse(input as string));
return {
output: { result: 'Next attack' },
tokenUsage: { total: 100, prompt: 50, completion: 50 },
};
}) as any;
// Set up mockGetTargetResponse (not mockTargetProvider.callApi) since getTargetResponse is mocked
mockGetTargetResponse.mockReset();
mockGetTargetResponse.mockImplementation(async function () {
const response = { output: targetResponses[targetCallCount] || 'Default' };
targetCallCount++;
return response;
});
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 2,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
excludeTargetOutputFromAgenticAttackGeneration: true, // PRIVACY ON
});
// Should have called cloud agent twice
expect(cloudRequests.length).toBe(2);
// Check the second iteration (which has lastAttempt)
const secondRequest = cloudRequests[1];
expect(secondRequest.lastAttempt).toBeDefined();
// The sensitive response should NOT be in the cloud request
const allCloudData = JSON.stringify(cloudRequests);
expect(allCloudData).not.toContain('4111-1111-1111-1111');
expect(allCloudData).not.toContain('123-45-6789');
// Verify privacy placeholder is used
expect(secondRequest.lastAttempt.response).toBe('[Hidden for privacy]');
// But length should still be included (from actual response length)
expect(secondRequest.lastAttempt.responseLength).toBe(sensitiveResponse.length);
expect(result.metadata.finalIteration).toBe(2);
});
it('should send actual target response when privacy is disabled', async () => {
const targetResponse = 'This is the actual response that should be visible';
const cloudRequests: any[] = [];
const targetResponses = [targetResponse, 'Target defended'];
let targetCallCount = 0;
mockAgentProvider.callApi = vi.fn().mockImplementation(async function (input: any) {
cloudRequests.push(JSON.parse(input as string));
return {
output: { result: 'Next attack' },
tokenUsage: { total: 100, prompt: 50, completion: 50 },
};
}) as any;
// Set up mockGetTargetResponse (not mockTargetProvider.callApi) since getTargetResponse is mocked
mockGetTargetResponse.mockReset();
mockGetTargetResponse.mockImplementation(async function () {
const response = { output: targetResponses[targetCallCount] || 'Default' };
targetCallCount++;
return response;
});
await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 2,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
excludeTargetOutputFromAgenticAttackGeneration: false, // PRIVACY OFF
});
// Check the second iteration
const secondRequest = cloudRequests[1];
expect(secondRequest.lastAttempt).toBeDefined();
// The actual response SHOULD be in the cloud request
expect(secondRequest.lastAttempt.response).toBe(targetResponse);
// Verify privacy placeholder was NOT used
expect(secondRequest.lastAttempt.response).not.toBe('[Hidden for privacy]');
});
it('should hide sensitive data across multiple iterations', async () => {
const sensitiveResponses = [
'Iteration 1: API Key abc123xyz',
'Iteration 2: Password hunter2',
'Iteration 3: Database mongodb://user:pass@host',
];
const cloudRequests: any[] = [];
let targetCallCount = 0;
mockAgentProvider.callApi = vi.fn().mockImplementation(async function (input: any) {
cloudRequests.push(JSON.parse(input as string));
return {
output: { result: 'Attack' },
tokenUsage: { total: 100, prompt: 50, completion: 50 },
};
}) as any;
// Mock the targetProvider.callApi directly (not mockGetTargetResponse)
mockTargetProvider.callApi = vi
.fn<() => Promise<ProviderResponse>>()
.mockImplementation(async function () {
const response = { output: sensitiveResponses[targetCallCount] || 'Default' };
targetCallCount++;
return response;
}) as any;
await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
},
filters: undefined,
injectVar: 'query',
numIterations: 3,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
excludeTargetOutputFromAgenticAttackGeneration: true,
});
// Check all cloud requests
const allCloudData = JSON.stringify(cloudRequests);
// NONE of the sensitive data should have leaked
expect(allCloudData).not.toContain('abc123xyz');
expect(allCloudData).not.toContain('hunter2');
expect(allCloudData).not.toContain('mongodb://');
expect(allCloudData).not.toContain('API Key');
expect(allCloudData).not.toContain('Password');
});
});
describe('Tracing Support', () => {
beforeEach(() => {
// Reset tracing mocks to default (disabled) state
mockResolveTracingOptions.mockReturnValue({
enabled: false,
includeInAttack: true,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
});
mockFetchTraceContext.mockReset();
mockFormatTraceSummary.mockReturnValue('Trace summary');
mockFormatTraceForMetadata.mockReturnValue({ traceId: 'test-trace-id' });
});
it('should NOT fetch trace context when tracing is disabled (default)', async () => {
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
traceparent: '00-trace123-span456-01',
},
filters: undefined,
injectVar: 'query',
numIterations: 1,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// Should NOT call fetchTraceContext when tracing is disabled
expect(mockFetchTraceContext).not.toHaveBeenCalled();
// Metadata should not have trace data
expect(result.metadata.traceSnapshots).toBeUndefined();
expect(result.metadata.redteamHistory[0].trace).toBeUndefined();
expect(result.metadata.redteamHistory[0].traceSummary).toBeUndefined();
});
it('should fetch trace context when tracing is enabled', async () => {
// Enable tracing
mockResolveTracingOptions.mockReturnValue({
enabled: true,
includeInAttack: true,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
});
// Mock trace context
mockFetchTraceContext.mockResolvedValue({
traceId: 'test-trace-id',
spans: [{ spanId: 'span1', name: 'test-span' }],
insights: ['Test insight'],
fetchedAt: Date.now(),
});
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
traceparent: '00-trace123-span456-01',
},
filters: undefined,
injectVar: 'query',
numIterations: 1,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// Should call fetchTraceContext
expect(mockFetchTraceContext).toHaveBeenCalled();
// Metadata should have trace data
expect(result.metadata.traceSnapshots).toBeDefined();
expect(result.metadata.traceSnapshots).toHaveLength(1);
expect(result.metadata.redteamHistory[0].trace).toBeDefined();
expect(result.metadata.redteamHistory[0].traceSummary).toBe('Trace summary');
});
it('should NOT fetch trace context when traceparent is missing', async () => {
// Enable tracing
mockResolveTracingOptions.mockReturnValue({
enabled: true,
includeInAttack: true,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
});
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
// No traceparent
},
filters: undefined,
injectVar: 'query',
numIterations: 1,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// Should NOT call fetchTraceContext when traceparent is missing
expect(mockFetchTraceContext).not.toHaveBeenCalled();
// Metadata should not have trace snapshots
expect(result.metadata.traceSnapshots).toBeUndefined();
});
it('should include trace summary in cloud request when includeInAttack is true', async () => {
const cloudRequests: any[] = [];
// Enable tracing
mockResolveTracingOptions.mockReturnValue({
enabled: true,
includeInAttack: true,
includeInGrading: false,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
});
mockFetchTraceContext.mockResolvedValue({
traceId: 'test-trace-id',
spans: [{ spanId: 'span1', name: 'test-span' }],
insights: [],
fetchedAt: Date.now(),
});
// Capture cloud requests
mockAgentProvider.callApi = vi.fn().mockImplementation(async function (input: any) {
cloudRequests.push(JSON.parse(input as string));
return {
output: { result: 'Attack prompt' },
tokenUsage: { total: 100, prompt: 50, completion: 50 },
};
}) as any;
await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
traceparent: '00-trace123-span456-01',
},
filters: undefined,
injectVar: 'query',
numIterations: 2,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// Second request should include trace summary from first iteration
expect(cloudRequests.length).toBeGreaterThanOrEqual(2);
expect(cloudRequests[1].traceSummary).toBe('Trace summary');
});
it('should pass trace context to grader when includeInGrading is true', async () => {
// Enable tracing with includeInGrading
mockResolveTracingOptions.mockReturnValue({
enabled: true,
includeInAttack: false,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
});
mockFetchTraceContext.mockResolvedValue({
traceId: 'test-trace-id',
spans: [{ spanId: 'span1', name: 'test-span' }],
insights: [],
fetchedAt: Date.now(),
});
const mockGrader = {
getResult: vi.fn<any>().mockResolvedValue({
grade: { pass: true, score: 0, reason: 'Test' },
rubric: 'test rubric',
}),
};
mockGetGraderById.mockReturnValue(mockGrader);
await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
traceparent: '00-trace123-span456-01',
},
filters: undefined,
injectVar: 'query',
numIterations: 1,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: {
vars: { query: 'test' },
assert: [{ type: 'promptfoo:redteam:harmful', metric: 'Harmful' }],
} as AtomicTestCase,
vars: { query: 'test' },
});
// Grader should be called with trace context
expect(mockGrader.getResult).toHaveBeenCalled();
const graderCall = mockGrader.getResult.mock.calls[0];
// 8th argument is the gradingContext
const gradingContext = graderCall[7] as { traceContext?: unknown; traceSummary?: string };
expect(gradingContext).toBeDefined();
expect(gradingContext.traceContext).toBeDefined();
expect(gradingContext.traceSummary).toBe('Trace summary');
});
it('should handle fetchTraceContext returning null gracefully', async () => {
// Enable tracing
mockResolveTracingOptions.mockReturnValue({
enabled: true,
includeInAttack: true,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
});
// Return null (no trace found)
mockFetchTraceContext.mockResolvedValue(null);
const result = await runMetaAgentRedteam({
context: {
vars: { query: 'test' },
prompt: { raw: 'test', label: 'test' },
originalProvider: mockTargetProvider,
traceparent: '00-trace123-span456-01',
},
filters: undefined,
injectVar: 'query',
numIterations: 1,
options: undefined,
prompt: { raw: 'test', label: 'test' },
agentProvider: mockAgentProvider,
gradingProvider: mockGradingProvider,
targetProvider: mockTargetProvider,
test: undefined,
vars: { query: 'test' },
});
// Should complete without error
expect(result.metadata.finalIteration).toBe(1);
// No trace data should be present
expect(result.metadata.traceSnapshots).toBeUndefined();
});
});
});