@orchagent/cli 0.3.28 → 0.3.31

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.
@@ -162,6 +162,8 @@ function registerPublishCommand(program) {
162
162
  .option('--skills <skills>', 'Default skills (comma-separated, e.g., org/skill@v1,org/other@v1)')
163
163
  .option('--skills-locked', 'Lock default skills (callers cannot override via headers)')
164
164
  .option('--docker', 'Include Dockerfile for custom environment (builds E2B template)')
165
+ .option('--price <amount>', 'Set price per call in USD (e.g., 0.50 for $0.50/call)')
166
+ .option('--pricing-mode <mode>', 'Pricing mode: free or per_call (default: free)')
165
167
  .action(async (options) => {
166
168
  if (options.private) {
167
169
  process.stderr.write('Warning: --private is deprecated (private is now the default). You can safely remove it.\n');
@@ -230,12 +232,47 @@ function registerPublishCommand(program) {
230
232
  skill_files: hasMultipleFiles ? skillFiles : undefined,
231
233
  });
232
234
  const skillVersion = skillResult.agent?.version || 'v1';
235
+ const skillAgentId = skillResult.agent?.id;
236
+ // Handle pricing for skills
237
+ if (skillAgentId && (options.price || options.pricingMode)) {
238
+ let pricingMode = 'free';
239
+ let pricePerCallCents;
240
+ if (options.price) {
241
+ const priceFloat = parseFloat(options.price);
242
+ if (isNaN(priceFloat) || priceFloat < 0) {
243
+ throw new errors_1.CliError('Price must be a positive number', errors_1.ExitCodes.INVALID_INPUT);
244
+ }
245
+ if (priceFloat === 0) {
246
+ pricingMode = 'free';
247
+ }
248
+ else if (priceFloat < 0.01) {
249
+ throw new errors_1.CliError('Price must be at least $0.01 USD', errors_1.ExitCodes.INVALID_INPUT);
250
+ }
251
+ else {
252
+ pricingMode = 'per_call';
253
+ pricePerCallCents = Math.round(priceFloat * 100);
254
+ }
255
+ }
256
+ else if (options.pricingMode) {
257
+ pricingMode = options.pricingMode === 'per_call' ? 'per_call' : 'free';
258
+ }
259
+ // Set pricing
260
+ if (pricingMode === 'per_call' && !pricePerCallCents) {
261
+ throw new errors_1.CliError('--price required when using per_call mode', errors_1.ExitCodes.INVALID_INPUT);
262
+ }
263
+ await (0, api_1.setAgentPricing)(config, skillAgentId, pricingMode, pricePerCallCents);
264
+ }
233
265
  await (0, analytics_1.track)('cli_publish', { agent_type: 'skill', multi_file: hasMultipleFiles });
234
266
  process.stdout.write(`\nPublished skill: ${org.slug}/${skillData.frontmatter.name}@${skillVersion}\n`);
235
267
  if (hasMultipleFiles) {
236
268
  process.stdout.write(`Files: ${skillFiles.length} files included\n`);
237
269
  }
238
270
  process.stdout.write(`Public: ${options.public ? 'yes' : 'no'}\n`);
271
+ // Show pricing info
272
+ if (options.price && parseFloat(options.price) > 0) {
273
+ const price = parseFloat(options.price);
274
+ process.stdout.write(`Pricing: $${price.toFixed(2)} USD per call\n`);
275
+ }
239
276
  }
240
277
  catch (err) {
241
278
  if (handleSecurityFlaggedError(err)) {
@@ -498,11 +535,45 @@ function registerPublishCommand(program) {
498
535
  await promises_1.default.rm(tempDir, { recursive: true, force: true });
499
536
  }
500
537
  }
538
+ // Handle pricing for agents
539
+ if (agentId && (options.price || options.pricingMode)) {
540
+ let pricingMode = 'free';
541
+ let pricePerCallCents;
542
+ if (options.price) {
543
+ const priceFloat = parseFloat(options.price);
544
+ if (isNaN(priceFloat) || priceFloat < 0) {
545
+ throw new errors_1.CliError('Price must be a positive number', errors_1.ExitCodes.INVALID_INPUT);
546
+ }
547
+ if (priceFloat === 0) {
548
+ pricingMode = 'free';
549
+ }
550
+ else if (priceFloat < 0.01) {
551
+ throw new errors_1.CliError('Price must be at least $0.01 USD', errors_1.ExitCodes.INVALID_INPUT);
552
+ }
553
+ else {
554
+ pricingMode = 'per_call';
555
+ pricePerCallCents = Math.round(priceFloat * 100);
556
+ }
557
+ }
558
+ else if (options.pricingMode) {
559
+ pricingMode = options.pricingMode === 'per_call' ? 'per_call' : 'free';
560
+ }
561
+ // Set pricing
562
+ if (pricingMode === 'per_call' && !pricePerCallCents) {
563
+ throw new errors_1.CliError('--price required when using per_call mode', errors_1.ExitCodes.INVALID_INPUT);
564
+ }
565
+ await (0, api_1.setAgentPricing)(config, agentId, pricingMode, pricePerCallCents);
566
+ }
501
567
  await (0, analytics_1.track)('cli_publish', { agent_type: manifest.type, hosted: shouldUploadBundle });
502
568
  process.stdout.write(`\nPublished agent: ${org.slug}/${manifest.name}@${assignedVersion}\n`);
503
569
  process.stdout.write(`Type: ${manifest.type}${shouldUploadBundle ? ' (hosted)' : ''}\n`);
504
570
  process.stdout.write(`Providers: ${supportedProviders.join(', ')}\n`);
505
571
  process.stdout.write(`Public: ${options.public ? 'yes' : 'no'}\n`);
572
+ // Show pricing info
573
+ if (options.price && parseFloat(options.price) > 0) {
574
+ const price = parseFloat(options.price);
575
+ process.stdout.write(`Pricing: $${price.toFixed(2)} USD per call\n`);
576
+ }
506
577
  if (result.service_key) {
507
578
  process.stdout.write(`\nService key (save this - shown only once):\n`);
508
579
  process.stdout.write(` ${result.service_key}\n`);
@@ -74,6 +74,50 @@ async function downloadAgent(config, org, agent, version) {
74
74
  return await (0, api_1.publicRequest)(config, `/public/agents/${org}/${agent}/${version}/download`);
75
75
  }
76
76
  catch (err) {
77
+ // Check for paid-agent error
78
+ if (err instanceof api_1.ApiError && err.status === 403) {
79
+ const payload = err.payload;
80
+ if (payload?.error?.code === 'PAID_AGENT_SERVER_ONLY') {
81
+ // Paid agent - try owner path if authenticated
82
+ if (config.apiKey) {
83
+ try {
84
+ const myAgents = await (0, api_1.listMyAgents)(config);
85
+ const matchingAgent = myAgents.find(a => a.name === agent && a.version === version && a.org_slug === org);
86
+ if (matchingAgent) {
87
+ // Owner! Fetch from authenticated endpoint
88
+ const agentData = await (0, api_1.request)(config, 'GET', `/agents/${matchingAgent.id}`);
89
+ // Convert Agent to AgentDownload format
90
+ return {
91
+ id: agentData.id,
92
+ type: agentData.type,
93
+ name: agentData.name,
94
+ version: agentData.version,
95
+ description: agentData.description,
96
+ prompt: agentData.prompt,
97
+ input_schema: agentData.input_schema,
98
+ output_schema: agentData.output_schema,
99
+ supported_providers: agentData.supported_providers || ['any'],
100
+ default_models: agentData.default_models,
101
+ source_url: agentData.source_url,
102
+ pip_package: agentData.pip_package,
103
+ run_command: agentData.run_command,
104
+ url: agentData.url,
105
+ has_bundle: !!agentData.code_bundle_url,
106
+ entrypoint: agentData.entrypoint,
107
+ };
108
+ }
109
+ }
110
+ catch {
111
+ // Not owner or other error, fall through
112
+ }
113
+ }
114
+ // Non-owner - block with helpful message
115
+ const price = payload.error.price_per_call_cents || 0;
116
+ const priceStr = price ? `$${(price / 100).toFixed(2)}/call` : 'PAID';
117
+ throw new errors_1.CliError(`This agent is paid (${priceStr}) and runs on server only.\n\n` +
118
+ `Use: orch call ${org}/${agent}@${version} --input '{...}'`);
119
+ }
120
+ }
77
121
  if (!(err instanceof api_1.ApiError) || err.status !== 404)
78
122
  throw err;
79
123
  }
@@ -788,6 +832,12 @@ Examples:
788
832
  orch run orchagent/leak-finder --download-only
789
833
 
790
834
  Note: Use 'run' for local execution, 'call' for server-side execution.
835
+
836
+ Paid Agents:
837
+ Paid agents run on server only for non-owners.
838
+ You CAN download and run your own paid agents for development/testing.
839
+
840
+ For other users' paid agents, use 'orch call' instead.
791
841
  `)
792
842
  .action(async (agentRef, args, options) => {
793
843
  // Merge --data alias into --input
@@ -5,6 +5,7 @@ const config_1 = require("../lib/config");
5
5
  const api_1 = require("../lib/api");
6
6
  const output_1 = require("../lib/output");
7
7
  const analytics_1 = require("../lib/analytics");
8
+ const pricing_1 = require("../lib/pricing");
8
9
  const DEFAULT_LIMIT = 20;
9
10
  function registerSearchCommand(program) {
10
11
  program
@@ -15,7 +16,14 @@ function registerSearchCommand(program) {
15
16
  .option('--recent', 'Show most recently published')
16
17
  .option('--type <type>', 'Filter by type: agents, skills, all (default: all)', 'all')
17
18
  .option('--limit <n>', `Max results (default: ${DEFAULT_LIMIT})`, String(DEFAULT_LIMIT))
19
+ .option('--free', 'Show only free agents')
20
+ .option('--paid', 'Show only paid agents')
18
21
  .option('--json', 'Output raw JSON')
22
+ .addHelpText('after', `
23
+ Pricing Filters:
24
+ --free Show only free agents
25
+ --paid Show only paid agents
26
+ `)
19
27
  .action(async (query, options) => {
20
28
  const config = await (0, config_1.getResolvedConfig)();
21
29
  const limit = parseInt(options.limit, 10) || DEFAULT_LIMIT;
@@ -39,6 +47,13 @@ function registerSearchCommand(program) {
39
47
  else if (options.type === 'skills') {
40
48
  agents = agents.filter(a => a.type === 'skill');
41
49
  }
50
+ // Filter by pricing if requested
51
+ if (options.free) {
52
+ agents = agents.filter(a => !(0, pricing_1.isPaidAgent)(a));
53
+ }
54
+ if (options.paid) {
55
+ agents = agents.filter(a => (0, pricing_1.isPaidAgent)(a));
56
+ }
42
57
  // Sort results
43
58
  if (options.popular || (!query && !options.recent)) {
44
59
  agents.sort((a, b) => (b.stars_count ?? 0) - (a.stars_count ?? 0));
@@ -0,0 +1,344 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.registerSecurityCommand = registerSecurityCommand;
7
+ const promises_1 = __importDefault(require("fs/promises"));
8
+ const chalk_1 = __importDefault(require("chalk"));
9
+ const config_1 = require("../lib/config");
10
+ const api_1 = require("../lib/api");
11
+ const errors_1 = require("../lib/errors");
12
+ const output_1 = require("../lib/output");
13
+ const spinner_1 = require("../lib/spinner");
14
+ const llm_1 = require("../lib/llm");
15
+ const analytics_1 = require("../lib/analytics");
16
+ const DEFAULT_VERSION = 'latest';
17
+ function parseAgentRef(value) {
18
+ const [ref, versionPart] = value.split('@');
19
+ const version = versionPart?.trim() || DEFAULT_VERSION;
20
+ const segments = ref.split('/');
21
+ if (segments.length === 1) {
22
+ return { agent: segments[0], version };
23
+ }
24
+ if (segments.length === 2) {
25
+ return { org: segments[0], agent: segments[1], version };
26
+ }
27
+ if (segments.length === 3) {
28
+ return { org: segments[0], agent: segments[1], version: segments[2] };
29
+ }
30
+ throw new errors_1.CliError('Invalid agent reference. Use org/agent/version or org/agent@version format.');
31
+ }
32
+ // Severity color mapping
33
+ function severityColor(severity) {
34
+ switch (severity.toLowerCase()) {
35
+ case 'critical':
36
+ return chalk_1.default.red.bold(severity.toUpperCase());
37
+ case 'high':
38
+ return chalk_1.default.red(severity.toUpperCase());
39
+ case 'medium':
40
+ return chalk_1.default.yellow(severity.toUpperCase());
41
+ case 'low':
42
+ return chalk_1.default.blue(severity.toUpperCase());
43
+ default:
44
+ return severity;
45
+ }
46
+ }
47
+ // Risk level color mapping
48
+ function riskLevelColor(level) {
49
+ switch (level.toLowerCase()) {
50
+ case 'critical':
51
+ return chalk_1.default.bgRed.white.bold(` ${level.toUpperCase()} `);
52
+ case 'high':
53
+ return chalk_1.default.bgRed.white(` ${level.toUpperCase()} `);
54
+ case 'medium':
55
+ return chalk_1.default.bgYellow.black(` ${level.toUpperCase()} `);
56
+ case 'low':
57
+ return chalk_1.default.bgBlue.white(` ${level.toUpperCase()} `);
58
+ case 'minimal':
59
+ return chalk_1.default.bgGreen.white(` ${level.toUpperCase()} `);
60
+ default:
61
+ return level;
62
+ }
63
+ }
64
+ function formatSummaryOutput(result) {
65
+ process.stdout.write('\n');
66
+ process.stdout.write(chalk_1.default.bold('Security Scan Results\n'));
67
+ process.stdout.write('━'.repeat(50) + '\n\n');
68
+ // Agent info
69
+ process.stdout.write(`${chalk_1.default.bold('Agent:')} ${result.agent_id}\n`);
70
+ process.stdout.write(`${chalk_1.default.bold('Scan Time:')} ${result.scan_timestamp}\n\n`);
71
+ // Risk level banner
72
+ process.stdout.write(`${chalk_1.default.bold('Risk Level:')} ${riskLevelColor(result.risk_level)}\n\n`);
73
+ // Summary stats
74
+ process.stdout.write(`${chalk_1.default.bold('Attacks Tested:')} ${result.total_attacks}\n`);
75
+ process.stdout.write(`${chalk_1.default.bold('Vulnerabilities Found:')} ${result.vulnerabilities_found}\n\n`);
76
+ // Breakdown by severity
77
+ if (Object.keys(result.summary.by_severity).length > 0) {
78
+ process.stdout.write(chalk_1.default.bold('By Severity:\n'));
79
+ const severityOrder = ['critical', 'high', 'medium', 'low'];
80
+ for (const sev of severityOrder) {
81
+ const count = result.summary.by_severity[sev];
82
+ if (count && count > 0) {
83
+ process.stdout.write(` ${severityColor(sev)}: ${count}\n`);
84
+ }
85
+ }
86
+ process.stdout.write('\n');
87
+ }
88
+ // Breakdown by category
89
+ if (Object.keys(result.summary.by_category).length > 0) {
90
+ process.stdout.write(chalk_1.default.bold('By Category:\n'));
91
+ for (const [cat, count] of Object.entries(result.summary.by_category)) {
92
+ if (count > 0) {
93
+ process.stdout.write(` ${cat}: ${count}\n`);
94
+ }
95
+ }
96
+ process.stdout.write('\n');
97
+ }
98
+ // Top vulnerabilities (show first 5)
99
+ if (result.vulnerabilities.length > 0) {
100
+ process.stdout.write(chalk_1.default.bold('Top Issues:\n'));
101
+ const topVulns = result.vulnerabilities.slice(0, 5);
102
+ for (const vuln of topVulns) {
103
+ process.stdout.write(`\n ${severityColor(vuln.severity)} - ${chalk_1.default.bold(vuln.attack_name)}\n`);
104
+ process.stdout.write(` Category: ${vuln.category}\n`);
105
+ if (vuln.attack_description) {
106
+ const desc = vuln.attack_description.length > 100
107
+ ? vuln.attack_description.slice(0, 97) + '...'
108
+ : vuln.attack_description;
109
+ process.stdout.write(` ${chalk_1.default.dim(desc)}\n`);
110
+ }
111
+ }
112
+ if (result.vulnerabilities.length > 5) {
113
+ process.stdout.write(`\n ${chalk_1.default.dim(`... and ${result.vulnerabilities.length - 5} more`)}\n`);
114
+ }
115
+ process.stdout.write('\n');
116
+ }
117
+ // Suggestion
118
+ if (result.vulnerabilities_found > 0) {
119
+ process.stdout.write(chalk_1.default.yellow('Tip: Use --output markdown for a detailed report.\n'));
120
+ }
121
+ else {
122
+ process.stdout.write(chalk_1.default.green('No vulnerabilities detected. Your agent appears secure.\n'));
123
+ }
124
+ }
125
+ function registerSecurityCommand(program) {
126
+ const security = program
127
+ .command('security')
128
+ .description('Security scanning and vulnerability testing for agents');
129
+ security
130
+ .command('test <agent>')
131
+ .description('Run dynamic security test against an agent')
132
+ .option('--categories <cats...>', 'Filter by attack categories')
133
+ .option('--severities <sevs...>', 'Filter by severities (critical, high, medium, low)')
134
+ .option('--max-attacks <n>', 'Limit number of attacks', parseInt)
135
+ .option('--output <format>', 'Output format: json, markdown, summary', 'summary')
136
+ .option('--output-file <path>', 'Write report to file')
137
+ .option('--key <key>', 'LLM API key (overrides env vars)')
138
+ .option('--provider <provider>', 'LLM provider (openai, anthropic, gemini)')
139
+ .addHelpText('after', `
140
+ Examples:
141
+ orch security test my-org/my-agent/1.0.0
142
+ orch security test my-org/my-agent@latest --categories persona_roleplay logic_trap
143
+ orch security test my-org/my-agent/1.0.0 --severities critical high
144
+ orch security test my-org/my-agent/1.0.0 --output markdown --output-file report.md
145
+ orch security test my-org/my-agent/1.0.0 --max-attacks 10 --output json
146
+ `)
147
+ .action(async (agentRef, options) => {
148
+ const resolved = await (0, config_1.getResolvedConfig)();
149
+ if (!resolved.apiKey) {
150
+ throw new errors_1.CliError('Missing API key. Run `orchagent login` first.');
151
+ }
152
+ const parsed = parseAgentRef(agentRef);
153
+ const configFile = await (0, config_1.loadConfig)();
154
+ const org = parsed.org ?? configFile.workspace ?? resolved.defaultOrg;
155
+ if (!org) {
156
+ throw new errors_1.CliError('Missing org. Use org/agent or set default org.');
157
+ }
158
+ const agentId = `${org}/${parsed.agent}/${parsed.version}`;
159
+ // Detect LLM key for the scan
160
+ let llmKey;
161
+ let llmProvider;
162
+ if (options.key) {
163
+ if (!options.provider) {
164
+ throw new errors_1.CliError('When using --key, you must also specify --provider (openai, anthropic, or gemini)');
165
+ }
166
+ (0, llm_1.validateProvider)(options.provider);
167
+ llmKey = options.key;
168
+ llmProvider = options.provider;
169
+ }
170
+ else {
171
+ const detected = await (0, llm_1.detectLlmKey)(['any'], resolved);
172
+ if (detected) {
173
+ llmKey = detected.key;
174
+ llmProvider = detected.provider;
175
+ }
176
+ }
177
+ // Build request body
178
+ const requestBody = {
179
+ agent_id: agentId,
180
+ };
181
+ if (options.categories && options.categories.length > 0) {
182
+ requestBody.categories = options.categories;
183
+ }
184
+ if (options.severities && options.severities.length > 0) {
185
+ requestBody.severities = options.severities;
186
+ }
187
+ if (options.maxAttacks) {
188
+ requestBody.max_attacks = options.maxAttacks;
189
+ }
190
+ const url = `${resolved.apiUrl.replace(/\/$/, '')}/security/test`;
191
+ // Make the API call with a spinner
192
+ const spinner = (0, spinner_1.createSpinner)(`Scanning ${agentId} for vulnerabilities...`);
193
+ spinner.start();
194
+ // Build headers - LLM key goes in X-LLM-API-Key header
195
+ const headers = {
196
+ 'Content-Type': 'application/json',
197
+ Authorization: `Bearer ${resolved.apiKey}`,
198
+ };
199
+ if (llmKey) {
200
+ headers['X-LLM-API-Key'] = llmKey;
201
+ }
202
+ let response;
203
+ try {
204
+ response = await (0, api_1.safeFetchWithRetryForCalls)(url, {
205
+ method: 'POST',
206
+ headers,
207
+ body: JSON.stringify(requestBody),
208
+ timeoutMs: 300000, // 5 minutes - scans can take time
209
+ });
210
+ }
211
+ catch (err) {
212
+ spinner.fail(`Scan failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
213
+ throw err;
214
+ }
215
+ if (!response.ok) {
216
+ const text = await response.text();
217
+ let payload;
218
+ try {
219
+ payload = JSON.parse(text);
220
+ }
221
+ catch {
222
+ payload = text;
223
+ }
224
+ const message = typeof payload === 'object' && payload
225
+ ? payload.error?.message ||
226
+ payload.message ||
227
+ response.statusText
228
+ : response.statusText;
229
+ spinner.fail(`Scan failed: ${message}`);
230
+ throw new errors_1.CliError(message);
231
+ }
232
+ spinner.succeed(`Scan completed for ${agentId}`);
233
+ const result = await response.json();
234
+ // Track successful scan
235
+ await (0, analytics_1.track)('cli_security_scan', {
236
+ agent: agentId,
237
+ vulnerabilities_found: result.vulnerabilities_found,
238
+ risk_level: result.risk_level,
239
+ });
240
+ // Handle output
241
+ const outputFormat = options.output || 'summary';
242
+ if (options.outputFile) {
243
+ let content;
244
+ if (outputFormat === 'json') {
245
+ content = JSON.stringify(result, null, 2);
246
+ }
247
+ else if (outputFormat === 'markdown' && result.markdown_report) {
248
+ content = result.markdown_report;
249
+ }
250
+ else if (outputFormat === 'markdown') {
251
+ // Generate basic markdown if server didn't provide one
252
+ content = generateMarkdownReport(result);
253
+ }
254
+ else {
255
+ // For summary output to file, use markdown
256
+ content = generateMarkdownReport(result);
257
+ }
258
+ await promises_1.default.writeFile(options.outputFile, content, 'utf8');
259
+ process.stdout.write(`Report saved to ${options.outputFile}\n`);
260
+ return;
261
+ }
262
+ // Print to stdout based on format
263
+ if (outputFormat === 'json') {
264
+ (0, output_1.printJson)(result);
265
+ }
266
+ else if (outputFormat === 'markdown') {
267
+ if (result.markdown_report) {
268
+ process.stdout.write(result.markdown_report);
269
+ }
270
+ else {
271
+ process.stdout.write(generateMarkdownReport(result));
272
+ }
273
+ }
274
+ else {
275
+ // summary format
276
+ formatSummaryOutput(result);
277
+ }
278
+ });
279
+ }
280
+ function generateMarkdownReport(result) {
281
+ const lines = [];
282
+ lines.push('# Security Scan Report');
283
+ lines.push('');
284
+ lines.push(`**Agent:** ${result.agent_id}`);
285
+ lines.push(`**Scan Time:** ${result.scan_timestamp}`);
286
+ lines.push(`**Risk Level:** ${result.risk_level.toUpperCase()}`);
287
+ lines.push('');
288
+ lines.push('## Summary');
289
+ lines.push('');
290
+ lines.push(`- Total Attacks Tested: ${result.total_attacks}`);
291
+ lines.push(`- Vulnerabilities Found: ${result.vulnerabilities_found}`);
292
+ lines.push('');
293
+ if (Object.keys(result.summary.by_severity).length > 0) {
294
+ lines.push('### By Severity');
295
+ lines.push('');
296
+ for (const [sev, count] of Object.entries(result.summary.by_severity)) {
297
+ if (count > 0) {
298
+ lines.push(`- ${sev.toUpperCase()}: ${count}`);
299
+ }
300
+ }
301
+ lines.push('');
302
+ }
303
+ if (Object.keys(result.summary.by_category).length > 0) {
304
+ lines.push('### By Category');
305
+ lines.push('');
306
+ for (const [cat, count] of Object.entries(result.summary.by_category)) {
307
+ if (count > 0) {
308
+ lines.push(`- ${cat}: ${count}`);
309
+ }
310
+ }
311
+ lines.push('');
312
+ }
313
+ if (result.vulnerabilities.length > 0) {
314
+ lines.push('## Vulnerabilities');
315
+ lines.push('');
316
+ for (const vuln of result.vulnerabilities) {
317
+ lines.push(`### ${vuln.attack_name}`);
318
+ lines.push('');
319
+ lines.push(`- **Severity:** ${vuln.severity.toUpperCase()}`);
320
+ lines.push(`- **Category:** ${vuln.category}`);
321
+ lines.push(`- **Attack ID:** ${vuln.attack_id}`);
322
+ lines.push('');
323
+ if (vuln.attack_description) {
324
+ lines.push(vuln.attack_description);
325
+ lines.push('');
326
+ }
327
+ if (vuln.leaked_content) {
328
+ lines.push('**Leaked Content:**');
329
+ lines.push('```');
330
+ lines.push(vuln.leaked_content);
331
+ lines.push('```');
332
+ lines.push('');
333
+ }
334
+ if (vuln.response_snippet) {
335
+ lines.push('**Response Snippet:**');
336
+ lines.push('```');
337
+ lines.push(vuln.response_snippet);
338
+ lines.push('```');
339
+ lines.push('');
340
+ }
341
+ }
342
+ }
343
+ return lines.join('\n');
344
+ }