agent-security-scanner-mcp 3.1.0 → 3.3.0

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.
@@ -0,0 +1,640 @@
1
+ // src/tools/scan-prompt.js
2
+ import { z } from "zod";
3
+ import { readFileSync, existsSync } from "fs";
4
+ import { dirname, join } from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { createHash } from "crypto";
7
+
8
+ // Handle both ESM and CJS bundling
9
+ let __dirname;
10
+ try {
11
+ __dirname = dirname(fileURLToPath(import.meta.url));
12
+ } catch {
13
+ __dirname = process.cwd();
14
+ }
15
+
16
+ // Risk thresholds for action determination
17
+ const RISK_THRESHOLDS = {
18
+ CRITICAL: 85,
19
+ HIGH: 65,
20
+ MEDIUM: 40,
21
+ LOW: 20
22
+ };
23
+
24
+ // Category weights for risk calculation
25
+ const CATEGORY_WEIGHTS = {
26
+ "exfiltration": 1.0,
27
+ "malicious-injection": 1.0,
28
+ "system-manipulation": 1.0,
29
+ "social-engineering": 0.8,
30
+ "obfuscation": 0.7,
31
+ "agent-manipulation": 0.9,
32
+ "prompt-injection": 0.9,
33
+ "prompt-injection-content": 1.0,
34
+ "prompt-injection-jailbreak": 1.0,
35
+ "prompt-injection-extraction": 0.9,
36
+ "prompt-injection-delimiter": 0.8,
37
+ "prompt-injection-encoded": 0.9,
38
+ "prompt-injection-context": 0.8,
39
+ "prompt-injection-privilege": 0.85,
40
+ "prompt-injection-multi-turn": 0.7,
41
+ "prompt-injection-output": 0.9,
42
+ // OpenClaw-specific categories
43
+ "data_exfiltration": 1.0,
44
+ "messaging_abuse": 0.95,
45
+ "credential_theft": 1.0,
46
+ "autonomous_harm": 0.9,
47
+ "service_attack": 0.95,
48
+ "unknown": 0.5
49
+ };
50
+
51
+ // Confidence multipliers
52
+ const CONFIDENCE_MULTIPLIERS = {
53
+ "HIGH": 1.0,
54
+ "MEDIUM": 0.7,
55
+ "LOW": 0.4
56
+ };
57
+
58
+ // Load agent attack rules from YAML
59
+ function loadAgentAttackRules() {
60
+ try {
61
+ const rulesPath = join(__dirname, '..', '..', 'rules', 'agent-attacks.security.yaml');
62
+ if (!existsSync(rulesPath)) {
63
+ console.error("Agent attack rules file not found");
64
+ return [];
65
+ }
66
+
67
+ const yaml = readFileSync(rulesPath, 'utf-8');
68
+ const rules = [];
69
+
70
+ // Simple YAML parsing for rules
71
+ const ruleBlocks = yaml.split(/^ - id:/m).slice(1);
72
+
73
+ for (const block of ruleBlocks) {
74
+ const lines = (' - id:' + block).split('\n');
75
+ const rule = {
76
+ id: '',
77
+ severity: 'WARNING',
78
+ message: '',
79
+ patterns: [],
80
+ metadata: {}
81
+ };
82
+
83
+ let inPatterns = false;
84
+ let inMetadata = false;
85
+
86
+ for (const line of lines) {
87
+ if (line.match(/^\s+- id:\s*/)) {
88
+ rule.id = line.replace(/^\s+- id:\s*/, '').trim();
89
+ } else if (line.match(/^\s+severity:\s*/)) {
90
+ rule.severity = line.replace(/^\s+severity:\s*/, '').trim();
91
+ } else if (line.match(/^\s+message:\s*/)) {
92
+ rule.message = line.replace(/^\s+message:\s*["']?/, '').replace(/["']$/, '').trim();
93
+ } else if (line.match(/^\s+patterns:\s*$/)) {
94
+ inPatterns = true;
95
+ inMetadata = false;
96
+ } else if (line.match(/^\s+metadata:\s*$/)) {
97
+ inPatterns = false;
98
+ inMetadata = true;
99
+ } else if (inPatterns && line.match(/^\s+- /)) {
100
+ let pattern = line.replace(/^\s+- /, '').trim();
101
+ pattern = pattern.replace(/^["']|["']$/g, '');
102
+ // Strip Python-style inline flags - JS doesn't support them
103
+ pattern = pattern.replace(/^\(\?i\)/, '');
104
+ // Unescape double backslashes from YAML (\\s -> \s)
105
+ pattern = pattern.replace(/\\\\/g, '\\');
106
+ if (pattern) rule.patterns.push(pattern);
107
+ } else if (inMetadata && line.match(/^\s+\w+:/)) {
108
+ const match = line.match(/^\s+(\w+):\s*["']?([^"'\n]+)["']?/);
109
+ if (match) {
110
+ rule.metadata[match[1]] = match[2].trim();
111
+ }
112
+ } else if (line.match(/^\s+languages:/)) {
113
+ inPatterns = false;
114
+ inMetadata = false;
115
+ }
116
+ }
117
+
118
+ if (rule.id && rule.patterns.length > 0) {
119
+ rules.push(rule);
120
+ }
121
+ }
122
+
123
+ return rules;
124
+ } catch (error) {
125
+ console.error("Error loading agent attack rules:", error.message);
126
+ return [];
127
+ }
128
+ }
129
+
130
+ // Also load prompt injection rules
131
+ function loadPromptInjectionRules() {
132
+ try {
133
+ const rulesPath = join(__dirname, '..', '..', 'rules', 'prompt-injection.security.yaml');
134
+ if (!existsSync(rulesPath)) {
135
+ return [];
136
+ }
137
+
138
+ const yaml = readFileSync(rulesPath, 'utf-8');
139
+ const rules = [];
140
+
141
+ const ruleBlocks = yaml.split(/^ - id:/m).slice(1);
142
+
143
+ for (const block of ruleBlocks) {
144
+ const lines = (' - id:' + block).split('\n');
145
+ const rule = {
146
+ id: '',
147
+ severity: 'WARNING',
148
+ message: '',
149
+ patterns: [],
150
+ metadata: {}
151
+ };
152
+
153
+ let inPatterns = false;
154
+ let inMetadata = false;
155
+
156
+ for (const line of lines) {
157
+ if (line.match(/^\s+- id:\s*/)) {
158
+ rule.id = line.replace(/^\s+- id:\s*/, '').trim();
159
+ } else if (line.match(/^\s+severity:\s*/)) {
160
+ rule.severity = line.replace(/^\s+severity:\s*/, '').trim();
161
+ } else if (line.match(/^\s+message:\s*/)) {
162
+ rule.message = line.replace(/^\s+message:\s*["']?/, '').replace(/["']$/, '').trim();
163
+ } else if (line.match(/^\s+patterns:\s*$/)) {
164
+ inPatterns = true;
165
+ inMetadata = false;
166
+ } else if (line.match(/^\s+metadata:\s*$/)) {
167
+ inPatterns = false;
168
+ inMetadata = true;
169
+ } else if (inPatterns && line.match(/^\s+- /)) {
170
+ let pattern = line.replace(/^\s+- /, '').trim();
171
+ pattern = pattern.replace(/^["']|["']$/g, '');
172
+ // Strip Python-style inline flags - JS doesn't support them
173
+ pattern = pattern.replace(/^\(\?i\)/, '');
174
+ // Unescape double backslashes from YAML (\\s -> \s)
175
+ pattern = pattern.replace(/\\\\/g, '\\');
176
+ if (pattern) rule.patterns.push(pattern);
177
+ } else if (inMetadata && line.match(/^\s+\w+:/)) {
178
+ const match = line.match(/^\s+(\w+):\s*["']?([^"'\n]+)["']?/);
179
+ if (match) {
180
+ rule.metadata[match[1]] = match[2].trim();
181
+ }
182
+ }
183
+ }
184
+
185
+ // Only include generic rules (content patterns, not code patterns)
186
+ if (rule.id && rule.patterns.length > 0 && rule.id.startsWith('generic.prompt')) {
187
+ rules.push(rule);
188
+ }
189
+ }
190
+
191
+ return rules;
192
+ } catch (error) {
193
+ console.error("Error loading prompt injection rules:", error.message);
194
+ return [];
195
+ }
196
+ }
197
+
198
+ // Load OpenClaw-specific rules
199
+ function loadOpenClawRules() {
200
+ try {
201
+ const rulesPath = join(__dirname, '..', '..', 'rules', 'openclaw.security.yaml');
202
+ if (!existsSync(rulesPath)) {
203
+ return [];
204
+ }
205
+
206
+ const yaml = readFileSync(rulesPath, 'utf-8');
207
+ const rules = [];
208
+
209
+ const ruleBlocks = yaml.split(/^ - id:/m).slice(1);
210
+
211
+ for (const block of ruleBlocks) {
212
+ const lines = (' - id:' + block).split('\n');
213
+ const rule = {
214
+ id: '',
215
+ severity: 'WARNING',
216
+ message: '',
217
+ patterns: [],
218
+ metadata: {}
219
+ };
220
+
221
+ let inPatterns = false;
222
+
223
+ for (const line of lines) {
224
+ if (line.match(/^\s+- id:\s*/)) {
225
+ rule.id = line.replace(/^\s+- id:\s*/, '').trim();
226
+ } else if (line.match(/^\s+severity:\s*/)) {
227
+ rule.severity = line.replace(/^\s+severity:\s*/, '').trim();
228
+ } else if (line.match(/^\s+category:\s*/)) {
229
+ rule.metadata.category = line.replace(/^\s+category:\s*/, '').trim();
230
+ } else if (line.match(/^\s+action:\s*/)) {
231
+ rule.metadata.action = line.replace(/^\s+action:\s*/, '').trim();
232
+ } else if (line.match(/^\s+message:\s*/)) {
233
+ rule.message = line.replace(/^\s+message:\s*["']?/, '').replace(/["']$/, '').trim();
234
+ } else if (line.match(/^\s+patterns:\s*$/)) {
235
+ inPatterns = true;
236
+ } else if (inPatterns && line.match(/^\s+- /)) {
237
+ let pattern = line.replace(/^\s+- /, '').trim();
238
+ pattern = pattern.replace(/^["']|["']$/g, '');
239
+ pattern = pattern.replace(/\\\\/g, '\\');
240
+ if (pattern) rule.patterns.push(pattern);
241
+ } else if (line.match(/^\s+\w+:/) && !line.match(/^\s+- /)) {
242
+ inPatterns = false;
243
+ }
244
+ }
245
+
246
+ if (rule.id && rule.patterns.length > 0) {
247
+ // Set confidence and risk score based on severity
248
+ rule.metadata.confidence = rule.severity === 'CRITICAL' ? 'HIGH' : 'MEDIUM';
249
+ rule.metadata.risk_score = rule.severity === 'CRITICAL' ? '90' : '70';
250
+ rules.push(rule);
251
+ }
252
+ }
253
+
254
+ return rules;
255
+ } catch (error) {
256
+ console.error("Error loading OpenClaw rules:", error.message);
257
+ return [];
258
+ }
259
+ }
260
+
261
+ // Calculate risk score from findings
262
+ function calculateRiskScore(findings, context) {
263
+ if (findings.length === 0) return 0;
264
+
265
+ let totalScore = 0;
266
+
267
+ for (const finding of findings) {
268
+ const riskScore = parseInt(finding.risk_score) || 50;
269
+ const category = finding.category || 'unknown';
270
+ const confidence = finding.confidence || 'MEDIUM';
271
+
272
+ const categoryWeight = CATEGORY_WEIGHTS[category] || 0.5;
273
+ const confidenceMultiplier = CONFIDENCE_MULTIPLIERS[confidence] || 0.7;
274
+
275
+ totalScore += (riskScore / 100) * categoryWeight * confidenceMultiplier * 100;
276
+ }
277
+
278
+ // Average the scores but boost for multiple findings
279
+ let avgScore = totalScore / findings.length;
280
+
281
+ // Enhanced compound boosting
282
+ if (findings.length > 1) {
283
+ // Cross-category boost: if findings span multiple categories, boost by 0.15
284
+ const uniqueCategories = new Set(findings.map(f => f.category || 'unknown'));
285
+ if (uniqueCategories.size > 1) {
286
+ avgScore = avgScore * (1 + 0.15);
287
+ }
288
+
289
+ // Mixed-severity boost: if both ERROR and WARNING present, 1.1x
290
+ const hasError = findings.some(f => f.severity === 'ERROR');
291
+ const hasWarning = findings.some(f => f.severity === 'WARNING');
292
+ if (hasError && hasWarning) {
293
+ avgScore = avgScore * 1.1;
294
+ }
295
+
296
+ // Per-finding boost (smaller than before)
297
+ avgScore = avgScore * (1 + (findings.length - 1) * 0.05);
298
+ }
299
+
300
+ avgScore = Math.min(100, avgScore);
301
+
302
+ // Apply sensitivity adjustment (wider spread for meaningful impact)
303
+ if (context?.sensitivity_level === 'high') {
304
+ avgScore = Math.min(100, avgScore * 1.5);
305
+ } else if (context?.sensitivity_level === 'low') {
306
+ avgScore = avgScore * 0.5;
307
+ }
308
+
309
+ return Math.round(avgScore);
310
+ }
311
+
312
+ // Determine action based on risk score, findings, and context
313
+ function determineAction(riskScore, findings, context) {
314
+ // Adjust thresholds based on sensitivity level
315
+ let blockThreshold = RISK_THRESHOLDS.HIGH;
316
+ let warnThreshold = RISK_THRESHOLDS.MEDIUM;
317
+ let logThreshold = RISK_THRESHOLDS.LOW;
318
+
319
+ if (context?.sensitivity_level === 'high') {
320
+ blockThreshold = 50;
321
+ warnThreshold = 30;
322
+ logThreshold = 15;
323
+ } else if (context?.sensitivity_level === 'low') {
324
+ blockThreshold = 75;
325
+ warnThreshold = 50;
326
+ logThreshold = 30;
327
+ }
328
+
329
+ // Check for any BLOCK action findings
330
+ const hasBlockFinding = findings.some(f => f.action === 'BLOCK');
331
+ if (hasBlockFinding || riskScore >= RISK_THRESHOLDS.CRITICAL) {
332
+ return 'BLOCK';
333
+ }
334
+
335
+ if (riskScore >= blockThreshold) {
336
+ return 'BLOCK';
337
+ }
338
+
339
+ const hasWarnFinding = findings.some(f => f.action === 'WARN');
340
+ if (hasWarnFinding || riskScore >= warnThreshold) {
341
+ return 'WARN';
342
+ }
343
+
344
+ const hasLogFinding = findings.some(f => f.action === 'LOG');
345
+ if (hasLogFinding || riskScore >= logThreshold) {
346
+ return 'LOG';
347
+ }
348
+
349
+ return 'ALLOW';
350
+ }
351
+
352
+ // Determine risk level from score
353
+ function getRiskLevel(score) {
354
+ if (score >= RISK_THRESHOLDS.CRITICAL) return 'CRITICAL';
355
+ if (score >= RISK_THRESHOLDS.HIGH) return 'HIGH';
356
+ if (score >= RISK_THRESHOLDS.MEDIUM) return 'MEDIUM';
357
+ if (score >= RISK_THRESHOLDS.LOW) return 'LOW';
358
+ return 'NONE';
359
+ }
360
+
361
+ // Generate explanation from findings
362
+ function generateExplanation(findings, action) {
363
+ if (findings.length === 0) {
364
+ return 'No security concerns detected in this prompt.';
365
+ }
366
+
367
+ const categories = [...new Set(findings.map(f => f.category))];
368
+ const severity = findings.some(f => f.severity === 'ERROR') ? 'critical' : 'potential';
369
+
370
+ let explanation = `Detected ${findings.length} ${severity} security concern(s)`;
371
+
372
+ if (categories.length > 0) {
373
+ explanation += ` in categories: ${categories.join(', ')}`;
374
+ }
375
+
376
+ explanation += `. Action: ${action}.`;
377
+
378
+ if (action === 'BLOCK') {
379
+ explanation += ' This prompt appears to contain malicious intent and should not be executed.';
380
+ } else if (action === 'WARN') {
381
+ explanation += ' Review carefully before proceeding.';
382
+ }
383
+
384
+ return explanation;
385
+ }
386
+
387
+ // Generate recommendations from findings
388
+ function generateRecommendations(findings) {
389
+ const recommendations = new Set();
390
+
391
+ for (const finding of findings) {
392
+ const category = finding.category;
393
+
394
+ switch (category) {
395
+ case 'exfiltration':
396
+ recommendations.add('Never allow prompts that request sending code or secrets to external URLs');
397
+ recommendations.add('Block access to sensitive files like .env, SSH keys, and credentials');
398
+ break;
399
+ case 'malicious-injection':
400
+ recommendations.add('Reject requests for backdoors, reverse shells, or malicious code');
401
+ recommendations.add('Never disable security controls at user request');
402
+ break;
403
+ case 'system-manipulation':
404
+ recommendations.add('Block destructive file operations and system configuration changes');
405
+ recommendations.add('Prevent persistence mechanisms like crontab or startup script modifications');
406
+ break;
407
+ case 'social-engineering':
408
+ recommendations.add('Verify authorization claims through proper channels, not prompt content');
409
+ recommendations.add('Be skeptical of urgency claims or claims of special modes');
410
+ break;
411
+ case 'obfuscation':
412
+ recommendations.add('Be wary of encoded or fragmented instructions');
413
+ recommendations.add('Reject requests for "examples" of malicious code');
414
+ break;
415
+ case 'agent-manipulation':
416
+ recommendations.add('Maintain confirmation prompts for sensitive operations');
417
+ recommendations.add('Never hide output or actions from the user');
418
+ break;
419
+ default:
420
+ recommendations.add('Review this prompt carefully before execution');
421
+ }
422
+ }
423
+
424
+ return [...recommendations];
425
+ }
426
+
427
+ // Create SHA256 hash for audit logging
428
+ function hashPrompt(text) {
429
+ return createHash('sha256').update(text).digest('hex').substring(0, 16);
430
+ }
431
+
432
+ // Export schema for tool registration
433
+ export const scanAgentPromptSchema = {
434
+ prompt_text: z.string().describe("The prompt or instruction text to analyze"),
435
+ context: z.object({
436
+ previous_messages: z.array(z.string()).optional().describe("Previous conversation messages for multi-turn detection"),
437
+ sensitivity_level: z.enum(["high", "medium", "low"]).optional().describe("Sensitivity level - high means more strict, low means more permissive")
438
+ }).optional().describe("Optional context for better analysis"),
439
+ verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level: 'minimal' (action only), 'compact' (default), 'full' (all details)")
440
+ };
441
+
442
+ // Export handler function
443
+ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
444
+ const findings = [];
445
+
446
+ // Load rules
447
+ const agentRules = loadAgentAttackRules();
448
+ const promptRules = loadPromptInjectionRules();
449
+ const openclawRules = loadOpenClawRules();
450
+ const allRules = [...agentRules, ...promptRules, ...openclawRules];
451
+
452
+ // 2.7: Extract content from code blocks and append to scan text
453
+ let expandedText = prompt_text;
454
+ const codeBlockRegex = /```[\s\S]*?```/g;
455
+ const codeBlocks = prompt_text.match(codeBlockRegex);
456
+ if (codeBlocks) {
457
+ for (const block of codeBlocks) {
458
+ // Strip the ``` delimiters and extract inner content
459
+ const inner = block.replace(/^```\w*\n?/, '').replace(/\n?```$/, '');
460
+ expandedText += '\n' + inner;
461
+ }
462
+ }
463
+
464
+ // Scan expanded text against all rules
465
+ for (const rule of allRules) {
466
+ for (const pattern of rule.patterns) {
467
+ try {
468
+ const regex = new RegExp(pattern, 'i');
469
+ const match = expandedText.match(regex);
470
+
471
+ if (match) {
472
+ findings.push({
473
+ rule_id: rule.id,
474
+ category: rule.metadata.category || 'unknown',
475
+ severity: rule.severity,
476
+ message: rule.message,
477
+ matched_text: match[0].substring(0, 100),
478
+ confidence: rule.metadata.confidence || 'MEDIUM',
479
+ risk_score: rule.metadata.risk_score || '50',
480
+ action: rule.metadata.action || 'WARN'
481
+ });
482
+ break; // Only one match per rule
483
+ }
484
+ } catch (e) {
485
+ // Skip invalid regex
486
+ }
487
+ }
488
+ }
489
+
490
+ // 2.8: Runtime base64 decode-and-rescan
491
+ const base64Regex = /[A-Za-z0-9+/]{40,}={0,2}/g;
492
+ const b64Matches = expandedText.match(base64Regex);
493
+ if (b64Matches) {
494
+ for (const b64str of b64Matches) {
495
+ try {
496
+ const decoded = Buffer.from(b64str, 'base64').toString('utf-8');
497
+ // Check printability: >70% ASCII printable characters
498
+ const printable = decoded.split('').filter(c => c.charCodeAt(0) >= 32 && c.charCodeAt(0) <= 126).length;
499
+ if (printable / decoded.length > 0.7) {
500
+ // Re-scan decoded text against prompt rules only
501
+ for (const rule of allRules) {
502
+ if (!rule.id.startsWith('generic.prompt')) continue;
503
+ for (const pattern of rule.patterns) {
504
+ try {
505
+ const regex = new RegExp(pattern, 'i');
506
+ const match = decoded.match(regex);
507
+ if (match) {
508
+ findings.push({
509
+ rule_id: rule.id + '.base64-decoded',
510
+ category: rule.metadata.category || 'unknown',
511
+ severity: rule.severity,
512
+ message: rule.message + ' (detected in base64-decoded content)',
513
+ matched_text: match[0].substring(0, 100),
514
+ confidence: rule.metadata.confidence || 'MEDIUM',
515
+ risk_score: rule.metadata.risk_score || '50',
516
+ action: rule.metadata.action || 'WARN'
517
+ });
518
+ break;
519
+ }
520
+ } catch (e) {
521
+ // Skip invalid regex
522
+ }
523
+ }
524
+ }
525
+ }
526
+ } catch (e) {
527
+ // Skip invalid base64
528
+ }
529
+ }
530
+ }
531
+
532
+ // Multi-turn escalation detection (Bug 9)
533
+ if (context?.previous_messages && Array.isArray(context.previous_messages) && context.previous_messages.length > 0) {
534
+ let prevMatchCount = 0;
535
+ for (const prevMsg of context.previous_messages) {
536
+ for (const rule of allRules) {
537
+ for (const pattern of rule.patterns) {
538
+ try {
539
+ const regex = new RegExp(pattern, 'i');
540
+ if (regex.test(prevMsg)) {
541
+ prevMatchCount++;
542
+ break;
543
+ }
544
+ } catch (e) {
545
+ // Skip invalid regex
546
+ }
547
+ }
548
+ if (prevMatchCount > 0) break;
549
+ }
550
+ if (prevMatchCount > 0) break;
551
+ }
552
+
553
+ // If both previous and current messages have matches, flag escalation
554
+ if (prevMatchCount > 0 && findings.length > 0) {
555
+ findings.push({
556
+ rule_id: 'multi-turn.escalation',
557
+ category: 'social-engineering',
558
+ severity: 'WARNING',
559
+ message: 'Multi-turn escalation detected: suspicious patterns found in both previous and current messages.',
560
+ matched_text: 'escalation across conversation turns',
561
+ confidence: 'MEDIUM',
562
+ risk_score: '70',
563
+ action: 'WARN'
564
+ });
565
+ }
566
+ }
567
+
568
+ // Calculate risk score
569
+ const riskScore = calculateRiskScore(findings, context);
570
+ const action = determineAction(riskScore, findings, context);
571
+ const riskLevel = getRiskLevel(riskScore);
572
+ const explanation = generateExplanation(findings, action);
573
+ const recommendations = generateRecommendations(findings);
574
+
575
+ // Create audit info
576
+ const audit = {
577
+ timestamp: new Date().toISOString(),
578
+ prompt_hash: hashPrompt(prompt_text),
579
+ prompt_length: prompt_text.length,
580
+ rules_checked: allRules.length,
581
+ context_provided: !!context
582
+ };
583
+
584
+ // Determine verbosity (default: compact)
585
+ const level = verbosity || 'compact';
586
+
587
+ let result;
588
+ switch (level) {
589
+ case 'minimal':
590
+ result = {
591
+ action,
592
+ risk_level: riskLevel,
593
+ findings_count: findings.length,
594
+ message: findings.length > 0
595
+ ? `${action}: ${findings.length} concern(s) detected. Use verbosity='compact' for details.`
596
+ : "ALLOW: No security concerns detected."
597
+ };
598
+ break;
599
+ case 'full':
600
+ result = {
601
+ action,
602
+ risk_score: riskScore,
603
+ risk_level: riskLevel,
604
+ findings_count: findings.length,
605
+ findings: findings.map(f => ({
606
+ rule_id: f.rule_id,
607
+ category: f.category,
608
+ severity: f.severity,
609
+ message: f.message,
610
+ matched_text: f.matched_text,
611
+ confidence: f.confidence
612
+ })),
613
+ explanation,
614
+ recommendations,
615
+ audit
616
+ };
617
+ break;
618
+ case 'compact':
619
+ default:
620
+ result = {
621
+ action,
622
+ risk_score: riskScore,
623
+ risk_level: riskLevel,
624
+ findings_count: findings.length,
625
+ findings: findings.map(f => ({
626
+ rule_id: f.rule_id,
627
+ severity: f.severity,
628
+ message: f.message
629
+ })),
630
+ recommendations
631
+ };
632
+ }
633
+
634
+ return {
635
+ content: [{
636
+ type: "text",
637
+ text: JSON.stringify(result, null, 2)
638
+ }]
639
+ };
640
+ }