@artemiskit/cli 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/artemis-runs/my-project/-sEsU7KtJ7VE.json +188 -0
- package/bin/artemis.ts +13 -0
- package/dist/bin/artemis.d.ts +6 -0
- package/dist/bin/artemis.d.ts.map +1 -0
- package/dist/index.js +51297 -0
- package/dist/src/adapters.d.ts +6 -0
- package/dist/src/adapters.d.ts.map +1 -0
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/commands/compare.d.ts +6 -0
- package/dist/src/commands/compare.d.ts.map +1 -0
- package/dist/src/commands/history.d.ts +6 -0
- package/dist/src/commands/history.d.ts.map +1 -0
- package/dist/src/commands/index.d.ts +8 -0
- package/dist/src/commands/index.d.ts.map +1 -0
- package/dist/src/commands/init.d.ts +6 -0
- package/dist/src/commands/init.d.ts.map +1 -0
- package/dist/src/commands/redteam.d.ts +6 -0
- package/dist/src/commands/redteam.d.ts.map +1 -0
- package/dist/src/commands/report.d.ts +6 -0
- package/dist/src/commands/report.d.ts.map +1 -0
- package/dist/src/commands/run.d.ts +6 -0
- package/dist/src/commands/run.d.ts.map +1 -0
- package/dist/src/commands/stress.d.ts +6 -0
- package/dist/src/commands/stress.d.ts.map +1 -0
- package/dist/src/config/index.d.ts +6 -0
- package/dist/src/config/index.d.ts.map +1 -0
- package/dist/src/config/loader.d.ts +13 -0
- package/dist/src/config/loader.d.ts.map +1 -0
- package/dist/src/config/schema.d.ts +215 -0
- package/dist/src/config/schema.d.ts.map +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/utils/adapter.d.ts +71 -0
- package/dist/src/utils/adapter.d.ts.map +1 -0
- package/dist/src/utils/storage.d.ts +22 -0
- package/dist/src/utils/storage.d.ts.map +1 -0
- package/package.json +65 -0
- package/src/adapters.ts +33 -0
- package/src/cli.ts +34 -0
- package/src/commands/compare.ts +104 -0
- package/src/commands/history.ts +80 -0
- package/src/commands/index.ts +8 -0
- package/src/commands/init.ts +111 -0
- package/src/commands/redteam.ts +511 -0
- package/src/commands/report.ts +126 -0
- package/src/commands/run.ts +233 -0
- package/src/commands/stress.ts +501 -0
- package/src/config/index.ts +6 -0
- package/src/config/loader.ts +112 -0
- package/src/config/schema.ts +56 -0
- package/src/index.ts +6 -0
- package/src/utils/adapter.ts +542 -0
- package/src/utils/storage.ts +67 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redteam command - Run red-team adversarial tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { basename, join } from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
type CaseRedactionInfo,
|
|
9
|
+
type ManifestRedactionInfo,
|
|
10
|
+
type RedTeamCaseResult,
|
|
11
|
+
type RedTeamManifest,
|
|
12
|
+
type RedTeamMetrics,
|
|
13
|
+
type RedTeamSeverity,
|
|
14
|
+
type RedTeamStatus,
|
|
15
|
+
type RedactionConfig,
|
|
16
|
+
Redactor,
|
|
17
|
+
createAdapter,
|
|
18
|
+
getGitInfo,
|
|
19
|
+
parseScenarioFile,
|
|
20
|
+
} from '@artemiskit/core';
|
|
21
|
+
import {
|
|
22
|
+
CotInjectionMutation,
|
|
23
|
+
InstructionFlipMutation,
|
|
24
|
+
type Mutation,
|
|
25
|
+
RedTeamGenerator,
|
|
26
|
+
RoleSpoofMutation,
|
|
27
|
+
SeverityMapper,
|
|
28
|
+
TypoMutation,
|
|
29
|
+
UnsafeResponseDetector,
|
|
30
|
+
} from '@artemiskit/redteam';
|
|
31
|
+
import { generateJSONReport, generateRedTeamHTMLReport } from '@artemiskit/reports';
|
|
32
|
+
import chalk from 'chalk';
|
|
33
|
+
import Table from 'cli-table3';
|
|
34
|
+
import { Command } from 'commander';
|
|
35
|
+
import { nanoid } from 'nanoid';
|
|
36
|
+
import ora from 'ora';
|
|
37
|
+
import { loadConfig } from '../config/loader';
|
|
38
|
+
import {
|
|
39
|
+
buildAdapterConfig,
|
|
40
|
+
resolveModelWithSource,
|
|
41
|
+
resolveProviderWithSource,
|
|
42
|
+
} from '../utils/adapter';
|
|
43
|
+
import { createStorage } from '../utils/storage';
|
|
44
|
+
|
|
45
|
+
interface RedteamOptions {
|
|
46
|
+
provider?: string;
|
|
47
|
+
model?: string;
|
|
48
|
+
mutations?: string[];
|
|
49
|
+
count?: number;
|
|
50
|
+
save?: boolean;
|
|
51
|
+
output?: string;
|
|
52
|
+
verbose?: boolean;
|
|
53
|
+
config?: string;
|
|
54
|
+
redact?: boolean;
|
|
55
|
+
redactPatterns?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function redteamCommand(): Command {
|
|
59
|
+
const cmd = new Command('redteam');
|
|
60
|
+
|
|
61
|
+
cmd
|
|
62
|
+
.description('Run red-team adversarial tests against an LLM')
|
|
63
|
+
.argument('<scenario>', 'Path to scenario YAML file')
|
|
64
|
+
.option('-p, --provider <provider>', 'Provider to use')
|
|
65
|
+
.option('-m, --model <model>', 'Model to use')
|
|
66
|
+
.option(
|
|
67
|
+
'--mutations <mutations...>',
|
|
68
|
+
'Mutations to apply (typo, role-spoof, instruction-flip, cot-injection)'
|
|
69
|
+
)
|
|
70
|
+
.option('-c, --count <number>', 'Number of mutated prompts per case', '5')
|
|
71
|
+
.option('--save', 'Save results to storage')
|
|
72
|
+
.option('-o, --output <dir>', 'Output directory for reports')
|
|
73
|
+
.option('-v, --verbose', 'Verbose output')
|
|
74
|
+
.option('--config <path>', 'Path to config file')
|
|
75
|
+
.option('--redact', 'Enable PII/sensitive data redaction in results')
|
|
76
|
+
.option(
|
|
77
|
+
'--redact-patterns <patterns...>',
|
|
78
|
+
'Custom redaction patterns (regex or built-in: email, phone, credit_card, ssn, api_key)'
|
|
79
|
+
)
|
|
80
|
+
.action(async (scenarioPath: string, options: RedteamOptions) => {
|
|
81
|
+
const spinner = ora('Loading configuration...').start();
|
|
82
|
+
const startTime = new Date();
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// Load config file if present
|
|
86
|
+
const config = await loadConfig(options.config);
|
|
87
|
+
if (config) {
|
|
88
|
+
spinner.succeed('Loaded config file');
|
|
89
|
+
} else {
|
|
90
|
+
spinner.info('No config file found, using defaults');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Parse scenario
|
|
94
|
+
spinner.start('Loading scenario...');
|
|
95
|
+
const scenario = await parseScenarioFile(scenarioPath);
|
|
96
|
+
spinner.succeed(`Loaded scenario: ${scenario.name}`);
|
|
97
|
+
|
|
98
|
+
// Resolve provider and model with precedence and source tracking:
|
|
99
|
+
// CLI > Scenario > Config > Default
|
|
100
|
+
const { provider, source: providerSource } = resolveProviderWithSource(
|
|
101
|
+
options.provider,
|
|
102
|
+
scenario.provider,
|
|
103
|
+
config?.provider
|
|
104
|
+
);
|
|
105
|
+
const { model, source: modelSource } = resolveModelWithSource(
|
|
106
|
+
options.model,
|
|
107
|
+
scenario.model,
|
|
108
|
+
config?.model
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Build adapter config with full precedence chain and source tracking
|
|
112
|
+
spinner.start(`Connecting to ${provider}...`);
|
|
113
|
+
const { adapterConfig, resolvedConfig } = buildAdapterConfig({
|
|
114
|
+
provider,
|
|
115
|
+
model,
|
|
116
|
+
providerSource,
|
|
117
|
+
modelSource,
|
|
118
|
+
scenarioConfig: scenario.providerConfig,
|
|
119
|
+
fileConfig: config,
|
|
120
|
+
});
|
|
121
|
+
const client = await createAdapter(adapterConfig);
|
|
122
|
+
spinner.succeed(`Connected to ${provider}`);
|
|
123
|
+
|
|
124
|
+
// Set up mutations
|
|
125
|
+
const mutations = selectMutations(options.mutations);
|
|
126
|
+
const generator = new RedTeamGenerator(mutations);
|
|
127
|
+
const detector = new UnsafeResponseDetector();
|
|
128
|
+
|
|
129
|
+
console.log();
|
|
130
|
+
console.log(chalk.bold('Red-Team Testing'));
|
|
131
|
+
console.log(chalk.dim(`Mutations: ${mutations.map((m) => m.name).join(', ')}`));
|
|
132
|
+
|
|
133
|
+
// Set up redaction if enabled
|
|
134
|
+
let redactionConfig: RedactionConfig | undefined;
|
|
135
|
+
let redactor: Redactor | undefined;
|
|
136
|
+
if (options.redact) {
|
|
137
|
+
redactionConfig = {
|
|
138
|
+
enabled: true,
|
|
139
|
+
patterns: options.redactPatterns,
|
|
140
|
+
redactPrompts: true,
|
|
141
|
+
redactResponses: true,
|
|
142
|
+
redactMetadata: false,
|
|
143
|
+
replacement: '[REDACTED]',
|
|
144
|
+
};
|
|
145
|
+
redactor = new Redactor(redactionConfig);
|
|
146
|
+
console.log(
|
|
147
|
+
chalk.dim(
|
|
148
|
+
`Redaction enabled${options.redactPatterns ? ` with patterns: ${options.redactPatterns.join(', ')}` : ' (default patterns)'}`
|
|
149
|
+
)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
console.log();
|
|
153
|
+
|
|
154
|
+
const count = Number.parseInt(String(options.count)) || 5;
|
|
155
|
+
const results: RedTeamCaseResult[] = [];
|
|
156
|
+
let promptsRedacted = 0;
|
|
157
|
+
let responsesRedacted = 0;
|
|
158
|
+
let totalRedactions = 0;
|
|
159
|
+
|
|
160
|
+
// Run mutated tests for each case
|
|
161
|
+
for (const testCase of scenario.cases) {
|
|
162
|
+
console.log(chalk.bold(`Testing case: ${testCase.id}`));
|
|
163
|
+
|
|
164
|
+
const originalPrompt =
|
|
165
|
+
typeof testCase.prompt === 'string'
|
|
166
|
+
? testCase.prompt
|
|
167
|
+
: testCase.prompt.map((m) => m.content).join('\n');
|
|
168
|
+
|
|
169
|
+
const mutatedPrompts = generator.generate(originalPrompt, count);
|
|
170
|
+
|
|
171
|
+
for (const mutated of mutatedPrompts) {
|
|
172
|
+
const requestStart = Date.now();
|
|
173
|
+
try {
|
|
174
|
+
const result = await client.generate({
|
|
175
|
+
prompt: mutated.mutated,
|
|
176
|
+
model,
|
|
177
|
+
temperature: scenario.temperature,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const detection = detector.detect(result.text);
|
|
181
|
+
|
|
182
|
+
const resultStatus: RedTeamStatus = detection.unsafe ? 'unsafe' : 'safe';
|
|
183
|
+
const statusDisplay = detection.unsafe
|
|
184
|
+
? chalk.red(`UNSAFE (${detection.severity})`)
|
|
185
|
+
: chalk.green('SAFE');
|
|
186
|
+
|
|
187
|
+
console.log(` ${statusDisplay} [${mutated.mutations.join(', ')}]`);
|
|
188
|
+
|
|
189
|
+
if (detection.unsafe && options.verbose) {
|
|
190
|
+
console.log(chalk.dim(` Reasons: ${detection.reasons.join(', ')}`));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Apply redaction if enabled
|
|
194
|
+
let finalPrompt = mutated.mutated;
|
|
195
|
+
let finalResponse = result.text;
|
|
196
|
+
let caseRedaction: CaseRedactionInfo | undefined;
|
|
197
|
+
|
|
198
|
+
if (redactor) {
|
|
199
|
+
const promptResult = redactor.redactPrompt(finalPrompt);
|
|
200
|
+
const responseResult = redactor.redactResponse(finalResponse);
|
|
201
|
+
finalPrompt = promptResult.text;
|
|
202
|
+
finalResponse = responseResult.text;
|
|
203
|
+
|
|
204
|
+
if (promptResult.wasRedacted) promptsRedacted++;
|
|
205
|
+
if (responseResult.wasRedacted) responsesRedacted++;
|
|
206
|
+
totalRedactions += promptResult.redactionCount + responseResult.redactionCount;
|
|
207
|
+
|
|
208
|
+
caseRedaction = {
|
|
209
|
+
redacted: promptResult.wasRedacted || responseResult.wasRedacted,
|
|
210
|
+
promptRedacted: promptResult.wasRedacted,
|
|
211
|
+
responseRedacted: responseResult.wasRedacted,
|
|
212
|
+
redactionCount: promptResult.redactionCount + responseResult.redactionCount,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
results.push({
|
|
217
|
+
caseId: testCase.id,
|
|
218
|
+
mutation: mutated.mutations.join('+'),
|
|
219
|
+
prompt: finalPrompt,
|
|
220
|
+
response: finalResponse,
|
|
221
|
+
status: resultStatus,
|
|
222
|
+
severity: detection.severity as RedTeamSeverity,
|
|
223
|
+
reasons: detection.reasons,
|
|
224
|
+
latencyMs: Date.now() - requestStart,
|
|
225
|
+
redaction: caseRedaction,
|
|
226
|
+
});
|
|
227
|
+
} catch (error) {
|
|
228
|
+
const errorMessage = (error as Error).message;
|
|
229
|
+
const isContentFiltered = isProviderContentFilter(errorMessage);
|
|
230
|
+
|
|
231
|
+
// Apply redaction to prompt even for errors/blocked
|
|
232
|
+
let errorPrompt = mutated.mutated;
|
|
233
|
+
let errorCaseRedaction: CaseRedactionInfo | undefined;
|
|
234
|
+
|
|
235
|
+
if (redactor) {
|
|
236
|
+
const promptResult = redactor.redactPrompt(errorPrompt);
|
|
237
|
+
errorPrompt = promptResult.text;
|
|
238
|
+
|
|
239
|
+
if (promptResult.wasRedacted) promptsRedacted++;
|
|
240
|
+
totalRedactions += promptResult.redactionCount;
|
|
241
|
+
|
|
242
|
+
errorCaseRedaction = {
|
|
243
|
+
redacted: promptResult.wasRedacted,
|
|
244
|
+
promptRedacted: promptResult.wasRedacted,
|
|
245
|
+
responseRedacted: false,
|
|
246
|
+
redactionCount: promptResult.redactionCount,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (isContentFiltered) {
|
|
251
|
+
console.log(
|
|
252
|
+
` ${chalk.cyan('BLOCKED')} [${mutated.mutations.join(', ')}]: Provider content filter triggered`
|
|
253
|
+
);
|
|
254
|
+
results.push({
|
|
255
|
+
caseId: testCase.id,
|
|
256
|
+
mutation: mutated.mutations.join('+'),
|
|
257
|
+
prompt: errorPrompt,
|
|
258
|
+
response: '',
|
|
259
|
+
status: 'blocked',
|
|
260
|
+
severity: 'none',
|
|
261
|
+
reasons: ['Provider content filter blocked the request'],
|
|
262
|
+
latencyMs: Date.now() - requestStart,
|
|
263
|
+
redaction: errorCaseRedaction,
|
|
264
|
+
});
|
|
265
|
+
} else {
|
|
266
|
+
console.log(
|
|
267
|
+
` ${chalk.yellow('ERROR')} [${mutated.mutations.join(', ')}]: ${errorMessage}`
|
|
268
|
+
);
|
|
269
|
+
results.push({
|
|
270
|
+
caseId: testCase.id,
|
|
271
|
+
mutation: mutated.mutations.join('+'),
|
|
272
|
+
prompt: errorPrompt,
|
|
273
|
+
response: '',
|
|
274
|
+
status: 'error',
|
|
275
|
+
severity: 'none',
|
|
276
|
+
reasons: [errorMessage],
|
|
277
|
+
latencyMs: Date.now() - requestStart,
|
|
278
|
+
redaction: errorCaseRedaction,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
console.log();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const endTime = new Date();
|
|
287
|
+
|
|
288
|
+
// Calculate metrics
|
|
289
|
+
const metrics = calculateMetrics(results);
|
|
290
|
+
|
|
291
|
+
// Build redaction metadata if enabled
|
|
292
|
+
let redactionInfo: ManifestRedactionInfo | undefined;
|
|
293
|
+
if (redactor && redactionConfig?.enabled) {
|
|
294
|
+
redactionInfo = {
|
|
295
|
+
enabled: true,
|
|
296
|
+
patternsUsed: redactor.patternNames,
|
|
297
|
+
replacement: redactor.replacement,
|
|
298
|
+
summary: {
|
|
299
|
+
promptsRedacted,
|
|
300
|
+
responsesRedacted,
|
|
301
|
+
totalRedactions,
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Build manifest
|
|
307
|
+
const runId = `rt_${nanoid(12)}`;
|
|
308
|
+
const manifest: RedTeamManifest = {
|
|
309
|
+
version: '1.0',
|
|
310
|
+
type: 'redteam',
|
|
311
|
+
run_id: runId,
|
|
312
|
+
project: config?.project || process.env.ARTEMIS_PROJECT || 'default',
|
|
313
|
+
start_time: startTime.toISOString(),
|
|
314
|
+
end_time: endTime.toISOString(),
|
|
315
|
+
duration_ms: endTime.getTime() - startTime.getTime(),
|
|
316
|
+
config: {
|
|
317
|
+
scenario: basename(scenarioPath, '.yaml'),
|
|
318
|
+
provider,
|
|
319
|
+
model: resolvedConfig.model,
|
|
320
|
+
mutations: mutations.map((m) => m.name),
|
|
321
|
+
count_per_case: count,
|
|
322
|
+
},
|
|
323
|
+
resolved_config: resolvedConfig,
|
|
324
|
+
metrics,
|
|
325
|
+
git: await getGitInfo(),
|
|
326
|
+
provenance: {
|
|
327
|
+
run_by: process.env.USER || process.env.USERNAME || 'unknown',
|
|
328
|
+
},
|
|
329
|
+
results,
|
|
330
|
+
environment: {
|
|
331
|
+
node_version: process.version,
|
|
332
|
+
platform: process.platform,
|
|
333
|
+
arch: process.arch,
|
|
334
|
+
},
|
|
335
|
+
redaction: redactionInfo,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Display summary
|
|
339
|
+
displaySummary(metrics, runId);
|
|
340
|
+
|
|
341
|
+
// Save results if requested
|
|
342
|
+
if (options.save) {
|
|
343
|
+
spinner.start('Saving results...');
|
|
344
|
+
const storage = createStorage({ fileConfig: config });
|
|
345
|
+
const path = await storage.save(manifest);
|
|
346
|
+
spinner.succeed(`Results saved: ${path}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Generate reports if output directory specified
|
|
350
|
+
if (options.output) {
|
|
351
|
+
spinner.start('Generating reports...');
|
|
352
|
+
await mkdir(options.output, { recursive: true });
|
|
353
|
+
|
|
354
|
+
// HTML report
|
|
355
|
+
const html = generateRedTeamHTMLReport(manifest);
|
|
356
|
+
const htmlPath = join(options.output, `${runId}.html`);
|
|
357
|
+
await writeFile(htmlPath, html);
|
|
358
|
+
|
|
359
|
+
// JSON report
|
|
360
|
+
const json = generateJSONReport(manifest);
|
|
361
|
+
const jsonPath = join(options.output, `${runId}.json`);
|
|
362
|
+
await writeFile(jsonPath, json);
|
|
363
|
+
|
|
364
|
+
spinner.succeed(`Reports generated: ${options.output}`);
|
|
365
|
+
console.log(chalk.dim(` HTML: ${htmlPath}`));
|
|
366
|
+
console.log(chalk.dim(` JSON: ${jsonPath}`));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Exit with error if there were unsafe responses
|
|
370
|
+
if (metrics.unsafe_responses > 0) {
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
} catch (error) {
|
|
374
|
+
spinner.fail('Error');
|
|
375
|
+
console.error(chalk.red('Error:'), (error as Error).message);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
return cmd;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function selectMutations(names?: string[]): Mutation[] {
|
|
384
|
+
const allMutations: Record<string, Mutation> = {
|
|
385
|
+
typo: new TypoMutation(),
|
|
386
|
+
'role-spoof': new RoleSpoofMutation(),
|
|
387
|
+
'instruction-flip': new InstructionFlipMutation(),
|
|
388
|
+
'cot-injection': new CotInjectionMutation(),
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
if (!names || names.length === 0) {
|
|
392
|
+
return Object.values(allMutations);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return names.filter((name) => name in allMutations).map((name) => allMutations[name]);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Detect if an error is from a provider's content filtering system.
|
|
400
|
+
* This indicates the adversarial prompt was successfully blocked.
|
|
401
|
+
*/
|
|
402
|
+
function isProviderContentFilter(errorMessage: string): boolean {
|
|
403
|
+
const contentFilterPatterns = [
|
|
404
|
+
// Azure OpenAI
|
|
405
|
+
/content management policy/i,
|
|
406
|
+
/content filtering/i,
|
|
407
|
+
/content filter/i,
|
|
408
|
+
// OpenAI
|
|
409
|
+
/content policy/i,
|
|
410
|
+
/safety system/i,
|
|
411
|
+
/flagged.*content/i,
|
|
412
|
+
// Anthropic
|
|
413
|
+
/potentially harmful/i,
|
|
414
|
+
/safety guidelines/i,
|
|
415
|
+
// Google
|
|
416
|
+
/blocked.*safety/i,
|
|
417
|
+
/safety settings/i,
|
|
418
|
+
// Generic patterns
|
|
419
|
+
/moderation/i,
|
|
420
|
+
/inappropriate content/i,
|
|
421
|
+
];
|
|
422
|
+
|
|
423
|
+
return contentFilterPatterns.some((pattern) => pattern.test(errorMessage));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function calculateMetrics(results: RedTeamCaseResult[]): RedTeamMetrics {
|
|
427
|
+
const total = results.length;
|
|
428
|
+
const safe = results.filter((r) => r.status === 'safe').length;
|
|
429
|
+
const blocked = results.filter((r) => r.status === 'blocked').length;
|
|
430
|
+
const unsafe = results.filter((r) => r.status === 'unsafe').length;
|
|
431
|
+
const errors = results.filter((r) => r.status === 'error').length;
|
|
432
|
+
|
|
433
|
+
const defended = safe + blocked;
|
|
434
|
+
const testable = total - errors;
|
|
435
|
+
const defenseRate = testable > 0 ? defended / testable : 0;
|
|
436
|
+
|
|
437
|
+
const bySeverity = results
|
|
438
|
+
.filter((r) => r.status === 'unsafe')
|
|
439
|
+
.reduce(
|
|
440
|
+
(acc, r) => {
|
|
441
|
+
const sev = r.severity as 'low' | 'medium' | 'high' | 'critical';
|
|
442
|
+
if (sev in acc) {
|
|
443
|
+
acc[sev]++;
|
|
444
|
+
}
|
|
445
|
+
return acc;
|
|
446
|
+
},
|
|
447
|
+
{ low: 0, medium: 0, high: 0, critical: 0 }
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
total_tests: total,
|
|
452
|
+
safe_responses: safe,
|
|
453
|
+
blocked_responses: blocked,
|
|
454
|
+
unsafe_responses: unsafe,
|
|
455
|
+
error_responses: errors,
|
|
456
|
+
defended,
|
|
457
|
+
defense_rate: defenseRate,
|
|
458
|
+
by_severity: bySeverity,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function displaySummary(metrics: RedTeamMetrics, runId: string): void {
|
|
463
|
+
const table = new Table({
|
|
464
|
+
head: [chalk.bold('Metric'), chalk.bold('Value')],
|
|
465
|
+
style: { head: [], border: [] },
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
table.push(
|
|
469
|
+
['Run ID', runId],
|
|
470
|
+
['Total Tests', metrics.total_tests.toString()],
|
|
471
|
+
['Defended', chalk.green(metrics.defended.toString())],
|
|
472
|
+
[` ${chalk.dim('Model handled safely')}`, chalk.green(metrics.safe_responses.toString())],
|
|
473
|
+
[` ${chalk.dim('Provider blocked')}`, chalk.cyan(metrics.blocked_responses.toString())],
|
|
474
|
+
[
|
|
475
|
+
'Unsafe Responses',
|
|
476
|
+
metrics.unsafe_responses > 0 ? chalk.red(metrics.unsafe_responses.toString()) : '0',
|
|
477
|
+
]
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
for (const severity of ['critical', 'high', 'medium', 'low'] as const) {
|
|
481
|
+
if (metrics.by_severity[severity]) {
|
|
482
|
+
const info = SeverityMapper.getInfo(severity);
|
|
483
|
+
table.push([` ${info.label}`, metrics.by_severity[severity].toString()]);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (metrics.error_responses > 0) {
|
|
488
|
+
table.push(['Errors', chalk.yellow(metrics.error_responses.toString())]);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
console.log(chalk.bold('Summary'));
|
|
492
|
+
console.log(table.toString());
|
|
493
|
+
|
|
494
|
+
// Calculate defense rate (excluding errors from denominator)
|
|
495
|
+
const testableResults = metrics.total_tests - metrics.error_responses;
|
|
496
|
+
if (testableResults > 0) {
|
|
497
|
+
const defenseRate = (metrics.defense_rate * 100).toFixed(1);
|
|
498
|
+
console.log();
|
|
499
|
+
console.log(
|
|
500
|
+
chalk.dim(`Defense Rate: ${defenseRate}% (${metrics.defended}/${testableResults})`)
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (metrics.unsafe_responses > 0) {
|
|
505
|
+
console.log();
|
|
506
|
+
console.log(chalk.red(`⚠ ${metrics.unsafe_responses} potentially unsafe responses detected`));
|
|
507
|
+
} else if (testableResults > 0) {
|
|
508
|
+
console.log();
|
|
509
|
+
console.log(chalk.green('✓ No unsafe responses detected'));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report command - Generate reports from stored runs
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import type { AnyManifest, RedTeamManifest, RunManifest, StressManifest } from '@artemiskit/core';
|
|
8
|
+
import {
|
|
9
|
+
generateHTMLReport,
|
|
10
|
+
generateJSONReport,
|
|
11
|
+
generateRedTeamHTMLReport,
|
|
12
|
+
generateStressHTMLReport,
|
|
13
|
+
} from '@artemiskit/reports';
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
import { Command } from 'commander';
|
|
16
|
+
import ora from 'ora';
|
|
17
|
+
import { loadConfig } from '../config/loader';
|
|
18
|
+
import { createStorage } from '../utils/storage';
|
|
19
|
+
|
|
20
|
+
interface ReportOptions {
|
|
21
|
+
format?: 'html' | 'json' | 'both';
|
|
22
|
+
output?: string;
|
|
23
|
+
config?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get manifest type
|
|
28
|
+
*/
|
|
29
|
+
function getManifestType(manifest: AnyManifest): 'run' | 'redteam' | 'stress' {
|
|
30
|
+
if ('type' in manifest) {
|
|
31
|
+
if (manifest.type === 'redteam') return 'redteam';
|
|
32
|
+
if (manifest.type === 'stress') return 'stress';
|
|
33
|
+
}
|
|
34
|
+
return 'run';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate HTML report based on manifest type
|
|
39
|
+
*/
|
|
40
|
+
function generateHTML(manifest: AnyManifest): string {
|
|
41
|
+
const type = getManifestType(manifest);
|
|
42
|
+
switch (type) {
|
|
43
|
+
case 'redteam':
|
|
44
|
+
return generateRedTeamHTMLReport(manifest as RedTeamManifest);
|
|
45
|
+
case 'stress':
|
|
46
|
+
return generateStressHTMLReport(manifest as StressManifest);
|
|
47
|
+
default:
|
|
48
|
+
return generateHTMLReport(manifest as RunManifest);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate JSON report based on manifest type
|
|
54
|
+
*/
|
|
55
|
+
function generateJSON(manifest: AnyManifest): string {
|
|
56
|
+
const type = getManifestType(manifest);
|
|
57
|
+
switch (type) {
|
|
58
|
+
case 'redteam':
|
|
59
|
+
return generateJSONReport(manifest as RedTeamManifest, { pretty: true });
|
|
60
|
+
case 'stress':
|
|
61
|
+
return generateJSONReport(manifest as StressManifest, { pretty: true });
|
|
62
|
+
default:
|
|
63
|
+
return generateJSONReport(manifest as RunManifest, { pretty: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function reportCommand(): Command {
|
|
68
|
+
const cmd = new Command('report');
|
|
69
|
+
|
|
70
|
+
cmd
|
|
71
|
+
.description('Generate a report from a stored run')
|
|
72
|
+
.argument('<run-id>', 'Run ID to generate report for')
|
|
73
|
+
.option('-f, --format <format>', 'Output format (html, json, both)', 'html')
|
|
74
|
+
.option('-o, --output <dir>', 'Output directory', './artemis-output')
|
|
75
|
+
.option('--config <path>', 'Path to config file')
|
|
76
|
+
.action(async (runId: string, options: ReportOptions) => {
|
|
77
|
+
const spinner = ora('Loading run...').start();
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const config = await loadConfig(options.config);
|
|
81
|
+
const storage = createStorage({ fileConfig: config });
|
|
82
|
+
const manifest = await storage.load(runId);
|
|
83
|
+
const manifestType = getManifestType(manifest);
|
|
84
|
+
spinner.succeed(`Loaded ${manifestType} run: ${runId}`);
|
|
85
|
+
|
|
86
|
+
// Create output directory
|
|
87
|
+
const outputDir = options.output || './artemis-output';
|
|
88
|
+
await mkdir(outputDir, { recursive: true });
|
|
89
|
+
|
|
90
|
+
const format = options.format || 'html';
|
|
91
|
+
const generatedFiles: string[] = [];
|
|
92
|
+
|
|
93
|
+
if (format === 'html' || format === 'both') {
|
|
94
|
+
spinner.start('Generating HTML report...');
|
|
95
|
+
const html = generateHTML(manifest);
|
|
96
|
+
const htmlPath = join(outputDir, `${runId}.html`);
|
|
97
|
+
await writeFile(htmlPath, html);
|
|
98
|
+
generatedFiles.push(htmlPath);
|
|
99
|
+
spinner.succeed(`Generated HTML report: ${htmlPath}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (format === 'json' || format === 'both') {
|
|
103
|
+
spinner.start('Generating JSON report...');
|
|
104
|
+
const json = generateJSON(manifest);
|
|
105
|
+
const jsonPath = join(outputDir, `${runId}.json`);
|
|
106
|
+
await writeFile(jsonPath, json);
|
|
107
|
+
generatedFiles.push(jsonPath);
|
|
108
|
+
spinner.succeed(`Generated JSON report: ${jsonPath}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log();
|
|
112
|
+
console.log(chalk.bold('Report generated successfully!'));
|
|
113
|
+
console.log();
|
|
114
|
+
console.log('Files:');
|
|
115
|
+
for (const file of generatedFiles) {
|
|
116
|
+
console.log(` ${chalk.green('•')} ${file}`);
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
spinner.fail('Error');
|
|
120
|
+
console.error(chalk.red('Error:'), (error as Error).message);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return cmd;
|
|
126
|
+
}
|