vibecheck-mcp-server 2.0.1

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,1275 @@
1
+ /**
2
+ * GUARDRAIL MCP Premium Tools
3
+ *
4
+ * Premium command palette tools for top-notch UX:
5
+ * - Ship Check (GO/NO-GO)
6
+ * - Run Reality Mode
7
+ * - Run MockProof Gate
8
+ * - Run Airlock (SupplyChain)
9
+ * - Open Last Run Report
10
+ * - Open Last Replay
11
+ * - Re-run Last Check
12
+ * - Doctor (Fix my setup)
13
+ * - Policies (Quick Edit)
14
+ */
15
+
16
+ import fs from 'fs/promises';
17
+ import path from 'path';
18
+ import { execSync, spawn } from 'child_process';
19
+ import { checkFeatureAccess } from "./tier-auth.js";
20
+
21
+ // State management (in-memory for MCP session, persisted to disk)
22
+ class MCPState {
23
+ constructor() {
24
+ this.runs = new Map();
25
+ this.lastRunByTool = new Map();
26
+ this.findings = new Map();
27
+ this.artifacts = new Map();
28
+ this.fixModeState = null;
29
+ this.stateDir = '';
30
+ }
31
+
32
+ async initialize(projectPath) {
33
+ this.stateDir = path.join(projectPath, '.GUARDRAIL', 'mcp-state');
34
+ await fs.mkdir(this.stateDir, { recursive: true });
35
+ await this.loadState();
36
+ }
37
+
38
+ async loadState() {
39
+ try {
40
+ const statePath = path.join(this.stateDir, 'state.json');
41
+ const data = JSON.parse(await fs.readFile(statePath, 'utf-8'));
42
+ if (data.runs) this.runs = new Map(Object.entries(data.runs));
43
+ if (data.lastRunByTool) this.lastRunByTool = new Map(Object.entries(data.lastRunByTool));
44
+ if (data.findings) this.findings = new Map(Object.entries(data.findings));
45
+ if (data.fixModeState) this.fixModeState = data.fixModeState;
46
+ } catch {
47
+ // Fresh state
48
+ }
49
+ }
50
+
51
+ async saveState() {
52
+ const statePath = path.join(this.stateDir, 'state.json');
53
+ await fs.writeFile(statePath, JSON.stringify({
54
+ runs: Object.fromEntries(this.runs),
55
+ lastRunByTool: Object.fromEntries(this.lastRunByTool),
56
+ findings: Object.fromEntries(this.findings),
57
+ fixModeState: this.fixModeState,
58
+ }, null, 2));
59
+ }
60
+
61
+ generateRunId(tool) {
62
+ return `${tool}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
63
+ }
64
+
65
+ async recordRun(result) {
66
+ const id = this.generateRunId(result.tool);
67
+ const run = { ...result, id };
68
+ this.runs.set(id, run);
69
+ this.lastRunByTool.set(result.tool, id);
70
+
71
+ for (const finding of run.findings || []) {
72
+ finding.runId = id;
73
+ this.findings.set(finding.id, finding);
74
+ }
75
+
76
+ await this.saveState();
77
+ return run;
78
+ }
79
+
80
+ getLastRun(tool) {
81
+ if (tool) {
82
+ const runId = this.lastRunByTool.get(tool);
83
+ return runId ? this.runs.get(runId) : null;
84
+ }
85
+ let latest = null;
86
+ for (const run of this.runs.values()) {
87
+ if (!latest || new Date(run.timestamp) > new Date(latest.timestamp)) {
88
+ latest = run;
89
+ }
90
+ }
91
+ return latest;
92
+ }
93
+ }
94
+
95
+ const state = new MCPState();
96
+
97
+ // Policy configuration manager
98
+ class PolicyManager {
99
+ constructor() {
100
+ this.configPath = '';
101
+ this.config = this.getDefaultConfig();
102
+ }
103
+
104
+ getDefaultConfig() {
105
+ return {
106
+ version: '1.0.0',
107
+ rules: {},
108
+ allowlist: { domains: [], packages: [], paths: [], patterns: [] },
109
+ ignore: { paths: ['node_modules', '__tests__', '*.test.*', '*.spec.*'], files: [] },
110
+ profiles: {
111
+ default: { flows: ['auth', 'checkout', 'dashboard'] },
112
+ strict: { extends: 'default', rules: { 'fake-api-domain': 'error' } },
113
+ ci: { extends: 'strict' },
114
+ },
115
+ };
116
+ }
117
+
118
+ async initialize(projectPath) {
119
+ this.configPath = path.join(projectPath, '.GUARDRAILrc');
120
+ await this.load();
121
+ }
122
+
123
+ async load() {
124
+ try {
125
+ const content = await fs.readFile(this.configPath, 'utf-8');
126
+ this.config = { ...this.getDefaultConfig(), ...JSON.parse(content) };
127
+ } catch {
128
+ this.config = this.getDefaultConfig();
129
+ }
130
+ }
131
+
132
+ async save() {
133
+ await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2));
134
+ }
135
+
136
+ async exists() {
137
+ try {
138
+ await fs.access(this.configPath);
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ async create() {
146
+ this.config = this.getDefaultConfig();
147
+ await this.save();
148
+ }
149
+
150
+ generateDiffPreview(patch) {
151
+ let preview = '';
152
+ switch (patch.action) {
153
+ case 'allowlist_domain':
154
+ preview = `You are adding: allowlist.domains += "${patch.target}"`;
155
+ break;
156
+ case 'allowlist_package':
157
+ preview = `You are adding: allowlist.packages += "${patch.target}"`;
158
+ break;
159
+ case 'ignore_path':
160
+ preview = `You are adding: ignore.paths += "${patch.target}"`;
161
+ break;
162
+ case 'downgrade_rule':
163
+ preview = `You are changing: rules.${patch.target}.severity = "error" → "warn"`;
164
+ break;
165
+ }
166
+ return { patch, preview };
167
+ }
168
+
169
+ async applyPatch(patch) {
170
+ const diff = this.generateDiffPreview(patch);
171
+ switch (patch.action) {
172
+ case 'allowlist_domain':
173
+ if (!this.config.allowlist.domains.includes(patch.target)) {
174
+ this.config.allowlist.domains.push(patch.target);
175
+ }
176
+ break;
177
+ case 'allowlist_package':
178
+ if (!this.config.allowlist.packages.includes(patch.target)) {
179
+ this.config.allowlist.packages.push(patch.target);
180
+ }
181
+ break;
182
+ case 'ignore_path':
183
+ if (!this.config.ignore.paths.includes(patch.target)) {
184
+ this.config.ignore.paths.push(patch.target);
185
+ }
186
+ break;
187
+ case 'downgrade_rule':
188
+ this.config.rules[patch.target] = {
189
+ severity: 'warn',
190
+ auditNote: patch.auditNote || 'Downgraded by user',
191
+ updatedAt: new Date().toISOString(),
192
+ };
193
+ break;
194
+ }
195
+ await this.save();
196
+ return diff;
197
+ }
198
+ }
199
+
200
+ const policy = new PolicyManager();
201
+
202
+ // Premium tool definitions
203
+ export const PREMIUM_TOOLS = [
204
+ // Command Palette Commands
205
+ {
206
+ name: 'get_status',
207
+ description: 'Get GUARDRAIL server status, connection info, workspace trust, and last run summary',
208
+ inputSchema: {
209
+ type: 'object',
210
+ properties: {
211
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
212
+ },
213
+ },
214
+ },
215
+ {
216
+ name: 'run_ship',
217
+ description: 'GUARDRAIL: Ship Check (GO/NO-GO) - Full ship-worthiness check with MockProof + Reality + Badge',
218
+ inputSchema: {
219
+ type: 'object',
220
+ properties: {
221
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
222
+ profile: { type: 'string', description: 'Profile to use (default, strict, ci)', default: 'default' },
223
+ flows: { type: 'array', items: { type: 'string' }, description: 'Specific flows to test' },
224
+ },
225
+ },
226
+ },
227
+ {
228
+ name: 'run_reality',
229
+ description: 'GUARDRAIL: Run Reality Mode - Spin up app and detect fake data at runtime',
230
+ inputSchema: {
231
+ type: 'object',
232
+ properties: {
233
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
234
+ flow: { type: 'string', description: 'Flow to test (auth, checkout, dashboard)', default: 'auth' },
235
+ profile: { type: 'string', description: 'Profile to use', default: 'default' },
236
+ baseUrl: { type: 'string', description: 'Base URL of running app', default: 'http://localhost:3000' },
237
+ },
238
+ },
239
+ },
240
+ {
241
+ name: 'run_mockproof',
242
+ description: 'GUARDRAIL: Run MockProof Gate - Static import graph scan for banned patterns',
243
+ inputSchema: {
244
+ type: 'object',
245
+ properties: {
246
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
247
+ profile: { type: 'string', description: 'Profile to use', default: 'default' },
248
+ },
249
+ },
250
+ },
251
+ {
252
+ name: 'run_airlock',
253
+ description: 'GUARDRAIL: Run Airlock (SupplyChain) - SBOM generation, vulnerability scan, license check',
254
+ inputSchema: {
255
+ type: 'object',
256
+ properties: {
257
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
258
+ profile: { type: 'string', description: 'Profile to use', default: 'default' },
259
+ },
260
+ },
261
+ },
262
+ {
263
+ name: 'get_last_run',
264
+ description: 'GUARDRAIL: Open Last Run Report - Get details of the most recent check',
265
+ inputSchema: {
266
+ type: 'object',
267
+ properties: {
268
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
269
+ tool: { type: 'string', description: 'Filter by tool (ship, reality, mockproof, airlock)' },
270
+ },
271
+ },
272
+ },
273
+ {
274
+ name: 'open_artifact',
275
+ description: 'GUARDRAIL: Open artifact (report, replay, trace, sarif, badge)',
276
+ inputSchema: {
277
+ type: 'object',
278
+ properties: {
279
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
280
+ type: { type: 'string', enum: ['report', 'replay', 'trace', 'sarif', 'badge'], description: 'Artifact type' },
281
+ runId: { type: 'string', description: 'Specific run ID (optional, defaults to last run)' },
282
+ },
283
+ },
284
+ },
285
+ {
286
+ name: 'rerun_last_check',
287
+ description: 'GUARDRAIL: Re-run Last Check - Repeat the previous check with same parameters',
288
+ inputSchema: {
289
+ type: 'object',
290
+ properties: {
291
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
292
+ },
293
+ },
294
+ },
295
+ {
296
+ name: 'run_doctor',
297
+ description: 'GUARDRAIL: Doctor (Fix my setup) - Diagnose and auto-fix environment issues',
298
+ inputSchema: {
299
+ type: 'object',
300
+ properties: {
301
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
302
+ autoFix: { type: 'boolean', description: 'Automatically fix issues', default: false },
303
+ },
304
+ },
305
+ },
306
+ {
307
+ name: 'edit_policies',
308
+ description: 'GUARDRAIL: Policies (Quick Edit) - View and modify .GUARDRAILrc settings',
309
+ inputSchema: {
310
+ type: 'object',
311
+ properties: {
312
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
313
+ action: { type: 'string', enum: ['view', 'allowlist_domain', 'allowlist_package', 'ignore_path', 'downgrade_rule'], default: 'view' },
314
+ target: { type: 'string', description: 'Target for action (domain, package, path, or rule ID)' },
315
+ auditNote: { type: 'string', description: 'Audit note for team visibility (when downgrading rules)' },
316
+ },
317
+ },
318
+ },
319
+ {
320
+ name: 'explain_finding',
321
+ description: 'Get detailed explanation of a finding with evidence, trace, and fix suggestions',
322
+ inputSchema: {
323
+ type: 'object',
324
+ properties: {
325
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
326
+ findingId: { type: 'string', description: 'Finding ID to explain' },
327
+ },
328
+ required: ['findingId'],
329
+ },
330
+ },
331
+ {
332
+ name: 'policy_patch',
333
+ description: 'Apply atomic policy changes with diff preview',
334
+ inputSchema: {
335
+ type: 'object',
336
+ properties: {
337
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
338
+ patches: {
339
+ type: 'array',
340
+ items: {
341
+ type: 'object',
342
+ properties: {
343
+ action: { type: 'string', enum: ['allowlist_domain', 'allowlist_package', 'ignore_path', 'downgrade_rule'] },
344
+ target: { type: 'string' },
345
+ auditNote: { type: 'string' },
346
+ },
347
+ required: ['action', 'target'],
348
+ },
349
+ description: 'Array of policy patches to apply',
350
+ },
351
+ dryRun: { type: 'boolean', description: 'Preview changes without applying', default: false },
352
+ },
353
+ required: ['patches'],
354
+ },
355
+ },
356
+ {
357
+ name: 'enter_fix_mode',
358
+ description: 'Enter Fix Mode - Interactive blocker resolution with checklist',
359
+ inputSchema: {
360
+ type: 'object',
361
+ properties: {
362
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
363
+ runId: { type: 'string', description: 'Run ID to fix (defaults to last NO-SHIP run)' },
364
+ },
365
+ },
366
+ },
367
+ {
368
+ name: 'fix_mode_status',
369
+ description: 'Get current Fix Mode status and remaining blockers',
370
+ inputSchema: {
371
+ type: 'object',
372
+ properties: {
373
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
374
+ },
375
+ },
376
+ },
377
+ {
378
+ name: 'mark_fix_complete',
379
+ description: 'Mark a blocker as fixed in Fix Mode',
380
+ inputSchema: {
381
+ type: 'object',
382
+ properties: {
383
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
384
+ findingId: { type: 'string', description: 'Finding ID to mark as fixed' },
385
+ },
386
+ required: ['findingId'],
387
+ },
388
+ },
389
+ {
390
+ name: 'exit_fix_mode',
391
+ description: 'Exit Fix Mode and optionally re-run ship check',
392
+ inputSchema: {
393
+ type: 'object',
394
+ properties: {
395
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
396
+ rerunCheck: { type: 'boolean', description: 'Re-run ship check after exiting', default: true },
397
+ },
398
+ },
399
+ },
400
+ {
401
+ name: 'export_sarif',
402
+ description: 'Export findings as SARIF for VS Code diagnostics and GitHub Code Scanning',
403
+ inputSchema: {
404
+ type: 'object',
405
+ properties: {
406
+ projectPath: { type: 'string', description: 'Path to project', default: '.' },
407
+ runId: { type: 'string', description: 'Run ID to export (defaults to last run)' },
408
+ outputPath: { type: 'string', description: 'Output path for SARIF file', default: '.GUARDRAIL/results.sarif' },
409
+ },
410
+ },
411
+ },
412
+ ];
413
+
414
+ // Premium tool handlers
415
+ export async function handlePremiumTool(name, args, logger) {
416
+ const projectPath = args?.projectPath || process.cwd();
417
+
418
+ // Map premium tools to required features (all require starter+)
419
+ const featureMap = {
420
+ 'run_ship': 'smells', // ship check requires starter+
421
+ 'run_reality': 'smells', // reality mode requires starter+
422
+ 'run_mockproof': 'smells', // mockproof requires starter+
423
+ 'run_airlock': 'breaking', // supply chain analysis requires pro+
424
+ 'get_last_run': 'verify', // basic access
425
+ 'open_artifact': 'verify', // basic access
426
+ 'rerun_last_check': 'verify', // basic access
427
+ 'run_doctor': 'verify', // basic access
428
+ 'edit_policies': 'breaking', // policy editing requires pro+
429
+ 'explain_finding': 'quality', // explanations require starter+
430
+ 'policy_patch': 'smells', // patching requires starter+
431
+ 'enter_fix_mode': 'smells', // fix mode requires starter+
432
+ 'get_status': 'verify' // status check is free
433
+ };
434
+
435
+ const requiredFeature = featureMap[name];
436
+ if (requiredFeature) {
437
+ const access = await checkFeatureAccess(requiredFeature, args?.apiKey);
438
+ if (!access.hasAccess) {
439
+ return {
440
+ content: [{
441
+ type: "text",
442
+ text: `🚫 UPGRADE REQUIRED\n\n${access.reason}\n\nCurrent tier: ${access.tier}\nUpgrade at: ${access.upgradeUrl}`
443
+ }],
444
+ isError: true
445
+ };
446
+ }
447
+ }
448
+
449
+ // Initialize state and policy managers
450
+ await state.initialize(projectPath);
451
+ await policy.initialize(projectPath);
452
+
453
+ switch (name) {
454
+ case 'get_status':
455
+ return await handleGetStatus(projectPath);
456
+
457
+ case 'run_ship':
458
+ return await handleRunShip(projectPath, args);
459
+
460
+ case 'run_reality':
461
+ return await handleRunReality(projectPath, args);
462
+
463
+ case 'run_mockproof':
464
+ return await handleRunMockproof(projectPath, args);
465
+
466
+ case 'run_airlock':
467
+ return await handleRunAirlock(projectPath, args);
468
+
469
+ case 'get_last_run':
470
+ return await handleGetLastRun(projectPath, args?.tool);
471
+
472
+ case 'open_artifact':
473
+ return await handleOpenArtifact(projectPath, args);
474
+
475
+ case 'rerun_last_check':
476
+ return await handleRerunLastCheck(projectPath);
477
+
478
+ case 'run_doctor':
479
+ return await handleRunDoctor(projectPath, args?.autoFix);
480
+
481
+ case 'edit_policies':
482
+ return await handleEditPolicies(projectPath, args);
483
+
484
+ case 'explain_finding':
485
+ return await handleExplainFinding(projectPath, args?.findingId);
486
+
487
+ case 'policy_patch':
488
+ return await handlePolicyPatch(projectPath, args);
489
+
490
+ case 'enter_fix_mode':
491
+ return await handleEnterFixMode(projectPath, args?.runId);
492
+
493
+ case 'fix_mode_status':
494
+ return await handleFixModeStatus(projectPath);
495
+
496
+ case 'mark_fix_complete':
497
+ return await handleMarkFixComplete(projectPath, args?.findingId);
498
+
499
+ case 'exit_fix_mode':
500
+ return await handleExitFixMode(projectPath, args?.rerunCheck);
501
+
502
+ case 'export_sarif':
503
+ return await handleExportSarif(projectPath, args);
504
+
505
+ default:
506
+ return null;
507
+ }
508
+ }
509
+
510
+ // Handler implementations
511
+ async function handleGetStatus(projectPath) {
512
+ const configExists = await policy.exists();
513
+ const lastRun = state.getLastRun();
514
+
515
+ let response = `**GUARDRAIL Status**\n\n`;
516
+ response += `Connected: āœ…\n`;
517
+ response += `Mode: ${process.env.CI ? 'CI' : 'Local'}\n`;
518
+ response += `Workspace: ${configExists ? 'trusted' : 'untrusted (no .GUARDRAILrc)'}\n`;
519
+ response += `Version: 1.0.0\n\n`;
520
+
521
+ if (lastRun) {
522
+ response += `**Last Run**\n`;
523
+ response += `Tool: ${lastRun.tool}\n`;
524
+ response += `Verdict: ${lastRun.verdict}\n`;
525
+ response += `Time: ${lastRun.timestamp}\n`;
526
+ } else {
527
+ response += `No previous runs found.\n`;
528
+ }
529
+
530
+ response += `\n---\n`;
531
+ response += `Runs locally | Artifacts saved to .GUARDRAIL/ | No upload unless you export`;
532
+
533
+ return { content: [{ type: 'text', text: response }] };
534
+ }
535
+
536
+ async function handleRunShip(projectPath, args) {
537
+ const startTime = Date.now();
538
+ let verdict = 'SHIP';
539
+ let blockers = [];
540
+ let findings = [];
541
+
542
+ try {
543
+ // Run ship check
544
+ const result = execSync('npx ts-node src/bin/ship.ts check --json', {
545
+ cwd: projectPath,
546
+ encoding: 'utf8',
547
+ maxBuffer: 10 * 1024 * 1024,
548
+ stdio: ['pipe', 'pipe', 'pipe'],
549
+ });
550
+
551
+ // Parse results
552
+ const reportPath = path.join(projectPath, '.GUARDRAIL', 'ship', 'ship-report.json');
553
+ try {
554
+ const report = JSON.parse(await fs.readFile(reportPath, 'utf-8'));
555
+ verdict = report.verdict === 'ship' ? 'SHIP' : 'NO-SHIP';
556
+
557
+ if (report.results?.mockproof?.violations) {
558
+ for (const v of report.results.mockproof.violations) {
559
+ const finding = {
560
+ id: `mp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
561
+ ruleId: v.pattern || 'mock-import',
562
+ title: v.message || 'Mock import detected',
563
+ severity: 'critical',
564
+ file: v.file,
565
+ line: v.line || 1,
566
+ evidence: { type: 'import', content: v.snippet || '' },
567
+ };
568
+ findings.push(finding);
569
+ blockers.push(finding);
570
+ }
571
+ }
572
+ } catch {}
573
+ } catch (error) {
574
+ verdict = 'NO-SHIP';
575
+ const output = error.stdout || error.message;
576
+ if (output.includes('VERDICT: FAIL')) {
577
+ const violations = output.match(/āŒ .+/g) || [];
578
+ for (const v of violations) {
579
+ findings.push({
580
+ id: `mp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
581
+ ruleId: 'banned-pattern',
582
+ title: v.replace('āŒ ', ''),
583
+ severity: 'critical',
584
+ file: 'unknown',
585
+ line: 1,
586
+ evidence: { type: 'code', content: v },
587
+ });
588
+ }
589
+ blockers = findings;
590
+ }
591
+ }
592
+
593
+ const duration = Date.now() - startTime;
594
+
595
+ // Record run
596
+ const run = await state.recordRun({
597
+ tool: 'ship',
598
+ verdict,
599
+ profile: args?.profile || 'default',
600
+ timestamp: new Date().toISOString(),
601
+ duration,
602
+ findings,
603
+ blockers,
604
+ warnings: [],
605
+ artifacts: [
606
+ { id: 'report', type: 'report', name: 'Ship Report', path: '.GUARDRAIL/ship/ship-report.json' },
607
+ ],
608
+ summary: {
609
+ totalFindings: findings.length,
610
+ criticalCount: findings.filter(f => f.severity === 'critical').length,
611
+ highCount: 0,
612
+ mediumCount: 0,
613
+ lowCount: 0,
614
+ },
615
+ });
616
+
617
+ // Format toast response
618
+ const toast = formatToast(run);
619
+
620
+ let response = `**${verdict === 'SHIP' ? 'šŸš€ SHIP' : 'šŸ›‘ NO-SHIP'}**\n\n`;
621
+ response += `${toast}\n\n`;
622
+
623
+ if (blockers.length > 0) {
624
+ response += `**Blockers (${blockers.length}):**\n`;
625
+ for (const b of blockers.slice(0, 6)) {
626
+ response += `• \`${b.file}:${b.line}\` - ${b.title}\n`;
627
+ }
628
+ if (blockers.length > 6) {
629
+ response += `... and ${blockers.length - 6} more\n`;
630
+ }
631
+ response += `\n**Chips:** MOCKPROOF\n`;
632
+ response += `**Duration:** ${duration}ms\n\n`;
633
+ response += `Use \`enter_fix_mode\` to start fixing blockers.`;
634
+ } else {
635
+ response += `All checks passed. Ready to deploy!\n`;
636
+ response += `**Duration:** ${duration}ms`;
637
+ }
638
+
639
+ return { content: [{ type: 'text', text: response }] };
640
+ }
641
+
642
+ async function handleRunReality(projectPath, args) {
643
+ const flow = args?.flow || 'auth';
644
+ const baseUrl = args?.baseUrl || 'http://localhost:3000';
645
+
646
+ let response = `**Reality Mode: ${flow}**\n\n`;
647
+ response += `Target: ${baseUrl}\n`;
648
+ response += `Flow: ${flow}\n\n`;
649
+
650
+ try {
651
+ // Generate and run reality mode test
652
+ const testDir = path.join(projectPath, '.GUARDRAIL', 'ship', 'reality-mode');
653
+ await fs.mkdir(testDir, { recursive: true });
654
+
655
+ const result = execSync(`npx ts-node src/bin/ship.ts reality --url ${baseUrl}`, {
656
+ cwd: projectPath,
657
+ encoding: 'utf8',
658
+ maxBuffer: 10 * 1024 * 1024,
659
+ });
660
+
661
+ response += `āœ… Reality Mode test generated\n\n`;
662
+ response += `**To run the scan:**\n`;
663
+ response += `\`npx playwright test .GUARDRAIL/ship/reality-mode/reality-mode.spec.ts\`\n\n`;
664
+ response += `This will:\n`;
665
+ response += `1. Open your app at ${baseUrl}\n`;
666
+ response += `2. Intercept all network requests\n`;
667
+ response += `3. Click through UI flows\n`;
668
+ response += `4. Detect fake APIs and demo data\n`;
669
+ response += `5. Generate replay + verdict`;
670
+
671
+ await state.recordRun({
672
+ tool: 'reality',
673
+ verdict: 'REVIEW',
674
+ flow,
675
+ timestamp: new Date().toISOString(),
676
+ duration: 0,
677
+ findings: [],
678
+ blockers: [],
679
+ warnings: [],
680
+ artifacts: [
681
+ { id: 'replay', type: 'replay', name: 'Reality Mode Replay', path: '.GUARDRAIL/ship/reality-mode/' },
682
+ ],
683
+ summary: { totalFindings: 0, criticalCount: 0, highCount: 0, mediumCount: 0, lowCount: 0 },
684
+ });
685
+ } catch (error) {
686
+ response += `āš ļø Reality Mode setup issue: ${error.message}\n`;
687
+ response += `\nMake sure Playwright is installed: \`npm install -D @playwright/test\``;
688
+ }
689
+
690
+ return { content: [{ type: 'text', text: response }] };
691
+ }
692
+
693
+ async function handleRunMockproof(projectPath, args) {
694
+ const startTime = Date.now();
695
+ let verdict = 'PASS';
696
+ let findings = [];
697
+
698
+ try {
699
+ const result = execSync('npx ts-node src/bin/ship.ts mockproof --json', {
700
+ cwd: projectPath,
701
+ encoding: 'utf8',
702
+ maxBuffer: 10 * 1024 * 1024,
703
+ });
704
+
705
+ if (result.includes('VERDICT: PASS')) {
706
+ verdict = 'PASS';
707
+ }
708
+ } catch (error) {
709
+ verdict = 'FAIL';
710
+ const output = error.stdout || error.message;
711
+ const violations = output.match(/āŒ .+/g) || [];
712
+ for (const v of violations) {
713
+ findings.push({
714
+ id: `mp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
715
+ ruleId: 'mock-import',
716
+ title: v.replace('āŒ ', ''),
717
+ severity: 'critical',
718
+ file: 'unknown',
719
+ line: 1,
720
+ evidence: { type: 'import', content: v },
721
+ });
722
+ }
723
+ }
724
+
725
+ const duration = Date.now() - startTime;
726
+
727
+ await state.recordRun({
728
+ tool: 'mockproof',
729
+ verdict,
730
+ timestamp: new Date().toISOString(),
731
+ duration,
732
+ findings,
733
+ blockers: findings,
734
+ warnings: [],
735
+ artifacts: [],
736
+ summary: { totalFindings: findings.length, criticalCount: findings.length, highCount: 0, mediumCount: 0, lowCount: 0 },
737
+ });
738
+
739
+ let response = `**MockProof Gate: ${verdict}**\n\n`;
740
+
741
+ if (verdict === 'PASS') {
742
+ response += `āœ… No banned imports detected in production code.\n`;
743
+ } else {
744
+ response += `šŸ›‘ Found ${findings.length} violation(s):\n\n`;
745
+ for (const f of findings.slice(0, 10)) {
746
+ response += `• ${f.title}\n`;
747
+ }
748
+ }
749
+
750
+ response += `\n**Duration:** ${duration}ms`;
751
+
752
+ return { content: [{ type: 'text', text: response }] };
753
+ }
754
+
755
+ async function handleRunAirlock(projectPath, args) {
756
+ let response = `**Airlock (Supply Chain)**\n\n`;
757
+
758
+ // npm audit
759
+ try {
760
+ execSync('npm audit --json', { cwd: projectPath, encoding: 'utf-8' });
761
+ response += `āœ… **npm audit:** No vulnerabilities\n`;
762
+ } catch (error) {
763
+ if (error.stdout) {
764
+ try {
765
+ const result = JSON.parse(error.stdout);
766
+ const vulns = result.metadata?.vulnerabilities || {};
767
+ const total = (vulns.critical || 0) + (vulns.high || 0) + (vulns.moderate || 0);
768
+ if (total > 0) {
769
+ response += `āš ļø **npm audit:** ${total} vulnerabilities\n`;
770
+ response += ` Critical: ${vulns.critical || 0}, High: ${vulns.high || 0}, Moderate: ${vulns.moderate || 0}\n`;
771
+ } else {
772
+ response += `āœ… **npm audit:** No vulnerabilities\n`;
773
+ }
774
+ } catch {
775
+ response += `āš ļø **npm audit:** Could not parse\n`;
776
+ }
777
+ }
778
+ }
779
+
780
+ // Dependency count
781
+ try {
782
+ const pkg = JSON.parse(await fs.readFile(path.join(projectPath, 'package.json'), 'utf-8'));
783
+ const deps = Object.keys(pkg.dependencies || {}).length;
784
+ const devDeps = Object.keys(pkg.devDependencies || {}).length;
785
+ response += `\n**Dependencies:** ${deps} prod, ${devDeps} dev\n`;
786
+ } catch {}
787
+
788
+ response += `\n**License compliance:** Run full scan with \`npm run ship:badge\``;
789
+
790
+ await state.recordRun({
791
+ tool: 'airlock',
792
+ verdict: 'REVIEW',
793
+ timestamp: new Date().toISOString(),
794
+ duration: 0,
795
+ findings: [],
796
+ blockers: [],
797
+ warnings: [],
798
+ artifacts: [],
799
+ summary: { totalFindings: 0, criticalCount: 0, highCount: 0, mediumCount: 0, lowCount: 0 },
800
+ });
801
+
802
+ return { content: [{ type: 'text', text: response }] };
803
+ }
804
+
805
+ async function handleGetLastRun(projectPath, tool) {
806
+ const run = state.getLastRun(tool);
807
+
808
+ if (!run) {
809
+ return { content: [{ type: 'text', text: 'No previous runs found.' }] };
810
+ }
811
+
812
+ let response = `**Last Run: ${run.tool.toUpperCase()}**\n\n`;
813
+ response += `ID: \`${run.id}\`\n`;
814
+ response += `Verdict: **${run.verdict}**\n`;
815
+ response += `Time: ${run.timestamp}\n`;
816
+ response += `Duration: ${run.duration}ms\n\n`;
817
+
818
+ if (run.findings.length > 0) {
819
+ response += `**Findings (${run.findings.length}):**\n`;
820
+ for (const f of run.findings.slice(0, 5)) {
821
+ response += `• \`${f.file}:${f.line}\` - ${f.title}\n`;
822
+ }
823
+ if (run.findings.length > 5) {
824
+ response += `... and ${run.findings.length - 5} more\n`;
825
+ }
826
+ }
827
+
828
+ if (run.artifacts?.length > 0) {
829
+ response += `\n**Artifacts:**\n`;
830
+ for (const a of run.artifacts) {
831
+ response += `• ${a.type}: ${a.path}\n`;
832
+ }
833
+ }
834
+
835
+ return { content: [{ type: 'text', text: response }] };
836
+ }
837
+
838
+ async function handleOpenArtifact(projectPath, args) {
839
+ const run = args?.runId ? state.runs.get(args.runId) : state.getLastRun();
840
+ const type = args?.type || 'report';
841
+
842
+ if (!run) {
843
+ return { content: [{ type: 'text', text: 'No run found. Run a check first.' }] };
844
+ }
845
+
846
+ const artifact = run.artifacts?.find(a => a.type === type);
847
+
848
+ if (!artifact) {
849
+ return { content: [{ type: 'text', text: `No ${type} artifact found for this run.` }] };
850
+ }
851
+
852
+ const artifactPath = path.join(projectPath, artifact.path);
853
+
854
+ try {
855
+ const content = await fs.readFile(artifactPath, 'utf-8');
856
+ return { content: [{ type: 'text', text: `**${artifact.name}**\n\nPath: ${artifact.path}\n\n\`\`\`json\n${content.substring(0, 2000)}\n\`\`\`` }] };
857
+ } catch {
858
+ return { content: [{ type: 'text', text: `Artifact path: ${artifact.path}\n\nOpen this file to view the full ${type}.` }] };
859
+ }
860
+ }
861
+
862
+ async function handleRerunLastCheck(projectPath) {
863
+ const lastRun = state.getLastRun();
864
+
865
+ if (!lastRun) {
866
+ return { content: [{ type: 'text', text: 'No previous run to repeat.' }] };
867
+ }
868
+
869
+ switch (lastRun.tool) {
870
+ case 'ship':
871
+ return await handleRunShip(projectPath, { profile: lastRun.profile });
872
+ case 'reality':
873
+ return await handleRunReality(projectPath, { flow: lastRun.flow });
874
+ case 'mockproof':
875
+ return await handleRunMockproof(projectPath, {});
876
+ case 'airlock':
877
+ return await handleRunAirlock(projectPath, {});
878
+ default:
879
+ return { content: [{ type: 'text', text: `Cannot re-run ${lastRun.tool}` }] };
880
+ }
881
+ }
882
+
883
+ async function handleRunDoctor(projectPath, autoFix) {
884
+ const checks = [];
885
+
886
+ // Node version
887
+ const nodeVersion = process.version;
888
+ const nodeMajor = parseInt(nodeVersion.slice(1).split('.')[0], 10);
889
+ checks.push({
890
+ name: 'Node.js Version',
891
+ status: nodeMajor >= 18 ? 'pass' : 'fail',
892
+ message: `Node.js ${nodeVersion}`,
893
+ });
894
+
895
+ // Playwright
896
+ let hasPlaywright = false;
897
+ try {
898
+ const pkg = JSON.parse(await fs.readFile(path.join(projectPath, 'package.json'), 'utf-8'));
899
+ hasPlaywright = !!pkg.dependencies?.['@playwright/test'] || !!pkg.devDependencies?.['@playwright/test'];
900
+ } catch {}
901
+ checks.push({
902
+ name: 'Playwright Installed',
903
+ status: hasPlaywright ? 'pass' : 'fail',
904
+ message: hasPlaywright ? 'Playwright found' : 'Required for Reality Mode',
905
+ fix: 'npm install -D @playwright/test',
906
+ });
907
+
908
+ // .GUARDRAILrc
909
+ const hasConfig = await policy.exists();
910
+ checks.push({
911
+ name: 'GUARDRAIL Config',
912
+ status: hasConfig ? 'pass' : 'warn',
913
+ message: hasConfig ? '.GUARDRAILrc found' : 'No .GUARDRAILrc',
914
+ fix: 'GUARDRAIL init',
915
+ });
916
+
917
+ // TypeScript
918
+ let hasTsConfig = false;
919
+ try {
920
+ await fs.access(path.join(projectPath, 'tsconfig.json'));
921
+ hasTsConfig = true;
922
+ } catch {}
923
+ checks.push({
924
+ name: 'TypeScript Config',
925
+ status: hasTsConfig ? 'pass' : 'warn',
926
+ message: hasTsConfig ? 'tsconfig.json found' : 'JavaScript project',
927
+ });
928
+
929
+ // Format response
930
+ let response = `**GUARDRAIL Doctor**\n\n`;
931
+
932
+ const healthy = checks.every(c => c.status !== 'fail');
933
+
934
+ for (const check of checks) {
935
+ const icon = check.status === 'pass' ? 'āœ…' : check.status === 'fail' ? 'āŒ' : 'āš ļø';
936
+ response += `${icon} **${check.name}:** ${check.message}\n`;
937
+ if (check.status !== 'pass' && check.fix) {
938
+ response += ` Fix: \`${check.fix}\`\n`;
939
+ }
940
+ }
941
+
942
+ response += `\n**Status:** ${healthy ? 'HEALTHY' : 'NEEDS ATTENTION'}\n`;
943
+
944
+ if (!hasConfig) {
945
+ response += `\n**Next Step:** Run \`edit_policies\` with action "create" to initialize .GUARDRAILrc`;
946
+ } else if (healthy) {
947
+ response += `\n**Next Step:** Run \`run_ship\` for your first Ship Check`;
948
+ }
949
+
950
+ // Auto-fix if requested
951
+ if (autoFix) {
952
+ response += `\n\n**Auto-Fix Results:**\n`;
953
+ if (!hasConfig) {
954
+ await policy.create();
955
+ response += `āœ… Created .GUARDRAILrc\n`;
956
+ }
957
+ }
958
+
959
+ return { content: [{ type: 'text', text: response }] };
960
+ }
961
+
962
+ async function handleEditPolicies(projectPath, args) {
963
+ const action = args?.action || 'view';
964
+
965
+ if (action === 'view') {
966
+ const config = policy.config;
967
+ let response = `**GUARDRAIL Policies**\n\n`;
968
+ response += `**Allowlisted Domains:** ${config.allowlist.domains.length > 0 ? config.allowlist.domains.join(', ') : 'none'}\n`;
969
+ response += `**Allowlisted Packages:** ${config.allowlist.packages.length > 0 ? config.allowlist.packages.join(', ') : 'none'}\n`;
970
+ response += `**Ignored Paths:** ${config.ignore.paths.join(', ')}\n`;
971
+ response += `**Profiles:** ${Object.keys(config.profiles).join(', ')}\n\n`;
972
+
973
+ if (Object.keys(config.rules).length > 0) {
974
+ response += `**Custom Rules:**\n`;
975
+ for (const [id, rule] of Object.entries(config.rules)) {
976
+ response += `• ${id}: ${rule.severity}`;
977
+ if (rule.auditNote) response += ` (${rule.auditNote})`;
978
+ response += `\n`;
979
+ }
980
+ }
981
+
982
+ response += `\nPath: .GUARDRAILrc`;
983
+ return { content: [{ type: 'text', text: response }] };
984
+ }
985
+
986
+ if (!args?.target) {
987
+ return { content: [{ type: 'text', text: 'Target required for this action.' }] };
988
+ }
989
+
990
+ const diff = await policy.applyPatch({
991
+ action,
992
+ target: args.target,
993
+ auditNote: args.auditNote,
994
+ });
995
+
996
+ return { content: [{ type: 'text', text: `**Policy Updated**\n\n${diff.preview}\n\nSaved to .GUARDRAILrc` }] };
997
+ }
998
+
999
+ async function handleExplainFinding(projectPath, findingId) {
1000
+ const finding = state.findings.get(findingId);
1001
+
1002
+ if (!finding) {
1003
+ return { content: [{ type: 'text', text: `Finding ${findingId} not found.` }] };
1004
+ }
1005
+
1006
+ let response = `**Finding: ${finding.title}**\n\n`;
1007
+
1008
+ // Why
1009
+ response += `## Why\n`;
1010
+ response += `**Rule:** ${finding.ruleId}\n`;
1011
+ response += `**Severity:** ${finding.severity.toUpperCase()}\n`;
1012
+ response += `**Location:** \`${finding.file}:${finding.line}\`\n\n`;
1013
+
1014
+ // Evidence
1015
+ response += `## Evidence\n`;
1016
+ response += `**Type:** ${finding.evidence.type}\n`;
1017
+ response += `\`\`\`\n${finding.evidence.content}\n\`\`\`\n\n`;
1018
+
1019
+ // Trace
1020
+ if (finding.evidence.trace) {
1021
+ response += `## Trace\n`;
1022
+ for (const step of finding.evidence.trace) {
1023
+ response += `→ ${step}\n`;
1024
+ }
1025
+ response += `\n`;
1026
+ }
1027
+
1028
+ // Fix
1029
+ response += `## Fix\n`;
1030
+ response += `1. Open \`${finding.file}\` at line ${finding.line}\n`;
1031
+ response += `2. Remove or replace the flagged pattern\n`;
1032
+ response += `3. Re-run ship check to verify\n\n`;
1033
+
1034
+ // Policy
1035
+ response += `## Policy\n`;
1036
+ response += `• Allowlist domain: \`edit_policies\` with action="allowlist_domain"\n`;
1037
+ response += `• Ignore path: \`edit_policies\` with action="ignore_path"\n`;
1038
+ response += `• Downgrade rule: \`edit_policies\` with action="downgrade_rule"`;
1039
+
1040
+ return { content: [{ type: 'text', text: response }] };
1041
+ }
1042
+
1043
+ async function handlePolicyPatch(projectPath, args) {
1044
+ const patches = args?.patches || [];
1045
+ const dryRun = args?.dryRun || false;
1046
+
1047
+ if (patches.length === 0) {
1048
+ return { content: [{ type: 'text', text: 'No patches provided.' }] };
1049
+ }
1050
+
1051
+ let response = `**Policy Patch${dryRun ? ' (Dry Run)' : ''}**\n\n`;
1052
+
1053
+ for (const patch of patches) {
1054
+ const diff = policy.generateDiffPreview(patch);
1055
+ response += `${diff.preview}\n`;
1056
+
1057
+ if (!dryRun) {
1058
+ await policy.applyPatch(patch);
1059
+ }
1060
+ }
1061
+
1062
+ if (dryRun) {
1063
+ response += `\n*No changes made (dry run)*`;
1064
+ } else {
1065
+ response += `\nāœ… Applied ${patches.length} patch(es) to .GUARDRAILrc`;
1066
+ }
1067
+
1068
+ return { content: [{ type: 'text', text: response }] };
1069
+ }
1070
+
1071
+ async function handleEnterFixMode(projectPath, runId) {
1072
+ const run = runId ? state.runs.get(runId) : state.getLastRun();
1073
+
1074
+ if (!run) {
1075
+ return { content: [{ type: 'text', text: 'No run found.' }] };
1076
+ }
1077
+
1078
+ if (run.verdict === 'SHIP' || run.verdict === 'PASS') {
1079
+ return { content: [{ type: 'text', text: 'No blockers to fix. Run already passed!' }] };
1080
+ }
1081
+
1082
+ state.fixModeState = {
1083
+ active: true,
1084
+ runId: run.id,
1085
+ blockers: run.blockers,
1086
+ completed: [],
1087
+ remaining: run.blockers.map(b => b.id),
1088
+ };
1089
+ await state.saveState();
1090
+
1091
+ let response = `**Fix Mode Activated**\n\n`;
1092
+ response += `Run: ${run.id}\n`;
1093
+ response += `Blockers: ${run.blockers.length}\n\n`;
1094
+ response += `**Checklist:**\n`;
1095
+
1096
+ for (const blocker of run.blockers) {
1097
+ response += `☐ \`${blocker.file}:${blocker.line}\` - ${blocker.title}\n`;
1098
+ response += ` Open file | Suggested fix | Re-run\n`;
1099
+ }
1100
+
1101
+ response += `\nUse \`mark_fix_complete\` after fixing each issue.\n`;
1102
+ response += `Use \`exit_fix_mode\` when done to re-run ship check.`;
1103
+
1104
+ return { content: [{ type: 'text', text: response }] };
1105
+ }
1106
+
1107
+ async function handleFixModeStatus(projectPath) {
1108
+ if (!state.fixModeState || !state.fixModeState.active) {
1109
+ return { content: [{ type: 'text', text: 'Fix Mode is not active.' }] };
1110
+ }
1111
+
1112
+ const fm = state.fixModeState;
1113
+ const blockers = fm.blockers;
1114
+
1115
+ let response = `**Fix Mode Status**\n\n`;
1116
+ response += `Completed: ${fm.completed.length}/${blockers.length}\n`;
1117
+ response += `Remaining: ${fm.remaining.length}\n\n`;
1118
+
1119
+ response += `**Checklist:**\n`;
1120
+ for (const blocker of blockers) {
1121
+ const done = fm.completed.includes(blocker.id);
1122
+ response += `${done ? 'āœ…' : '☐'} \`${blocker.file}:${blocker.line}\` - ${blocker.title}\n`;
1123
+ }
1124
+
1125
+ if (fm.remaining.length === 0) {
1126
+ response += `\nšŸŽ‰ All blockers addressed! Run \`exit_fix_mode\` to verify.`;
1127
+ }
1128
+
1129
+ return { content: [{ type: 'text', text: response }] };
1130
+ }
1131
+
1132
+ async function handleMarkFixComplete(projectPath, findingId) {
1133
+ if (!state.fixModeState || !state.fixModeState.active) {
1134
+ return { content: [{ type: 'text', text: 'Fix Mode is not active.' }] };
1135
+ }
1136
+
1137
+ const fm = state.fixModeState;
1138
+
1139
+ if (!fm.remaining.includes(findingId)) {
1140
+ return { content: [{ type: 'text', text: `Finding ${findingId} not in remaining list.` }] };
1141
+ }
1142
+
1143
+ fm.remaining = fm.remaining.filter(id => id !== findingId);
1144
+ fm.completed.push(findingId);
1145
+ await state.saveState();
1146
+
1147
+ let response = `āœ… Marked ${findingId} as fixed.\n\n`;
1148
+ response += `Remaining: ${fm.remaining.length}/${fm.blockers.length}\n`;
1149
+
1150
+ if (fm.remaining.length === 0) {
1151
+ response += `\nšŸŽ‰ All blockers addressed! Run \`exit_fix_mode\` to verify.`;
1152
+ }
1153
+
1154
+ return { content: [{ type: 'text', text: response }] };
1155
+ }
1156
+
1157
+ async function handleExitFixMode(projectPath, rerunCheck) {
1158
+ if (!state.fixModeState || !state.fixModeState.active) {
1159
+ return { content: [{ type: 'text', text: 'Fix Mode is not active.' }] };
1160
+ }
1161
+
1162
+ const fm = state.fixModeState;
1163
+ state.fixModeState = null;
1164
+ await state.saveState();
1165
+
1166
+ let response = `**Fix Mode Exited**\n\n`;
1167
+ response += `Fixed: ${fm.completed.length}/${fm.blockers.length}\n`;
1168
+
1169
+ if (rerunCheck !== false) {
1170
+ response += `\nRe-running ship check...\n\n`;
1171
+ const result = await handleRunShip(projectPath, {});
1172
+ return result;
1173
+ }
1174
+
1175
+ return { content: [{ type: 'text', text: response }] };
1176
+ }
1177
+
1178
+ async function handleExportSarif(projectPath, args) {
1179
+ const run = args?.runId ? state.runs.get(args.runId) : state.getLastRun();
1180
+ const outputPath = args?.outputPath || '.GUARDRAIL/results.sarif';
1181
+
1182
+ if (!run) {
1183
+ return { content: [{ type: 'text', text: 'No run found to export.' }] };
1184
+ }
1185
+
1186
+ // Generate SARIF structure
1187
+ const sarif = {
1188
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
1189
+ version: '2.1.0',
1190
+ runs: [{
1191
+ tool: {
1192
+ driver: {
1193
+ name: 'GUARDRAIL',
1194
+ version: '1.0.0',
1195
+ informationUri: 'https://GUARDRAIL.dev',
1196
+ rules: generateSarifRules(run.findings),
1197
+ },
1198
+ },
1199
+ results: run.findings.map((f, idx) => ({
1200
+ ruleId: f.ruleId,
1201
+ ruleIndex: idx,
1202
+ level: f.severity === 'critical' || f.severity === 'high' ? 'error' : 'warning',
1203
+ message: { text: `${f.title}\n\n${f.evidence?.content || ''}` },
1204
+ locations: [{
1205
+ physicalLocation: {
1206
+ artifactLocation: { uri: f.file.replace(/\\/g, '/') },
1207
+ region: {
1208
+ startLine: f.line,
1209
+ startColumn: f.column || 1,
1210
+ },
1211
+ },
1212
+ }],
1213
+ fingerprints: { 'GUARDRAIL/v1': f.id },
1214
+ })),
1215
+ invocations: [{
1216
+ executionSuccessful: run.verdict === 'SHIP' || run.verdict === 'PASS',
1217
+ startTimeUtc: run.timestamp,
1218
+ }],
1219
+ }],
1220
+ };
1221
+
1222
+ // Save to file
1223
+ const fullPath = path.join(projectPath, outputPath);
1224
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
1225
+ await fs.writeFile(fullPath, JSON.stringify(sarif, null, 2));
1226
+
1227
+ let response = `**SARIF Export**\n\n`;
1228
+ response += `Run: ${run.id}\n`;
1229
+ response += `Findings: ${run.findings.length}\n`;
1230
+ response += `Output: ${outputPath}\n\n`;
1231
+ response += `Use this file with:\n`;
1232
+ response += `• VS Code SARIF Viewer extension\n`;
1233
+ response += `• GitHub Code Scanning\n`;
1234
+ response += `• Any SARIF 2.1.0 compatible tool`;
1235
+
1236
+ return { content: [{ type: 'text', text: response }] };
1237
+ }
1238
+
1239
+ function generateSarifRules(findings) {
1240
+ const ruleMap = new Map();
1241
+
1242
+ for (const f of findings) {
1243
+ if (!ruleMap.has(f.ruleId)) {
1244
+ ruleMap.set(f.ruleId, {
1245
+ id: f.ruleId,
1246
+ name: f.title,
1247
+ shortDescription: { text: f.title },
1248
+ fullDescription: { text: f.description || f.title },
1249
+ defaultConfiguration: {
1250
+ level: f.severity === 'critical' || f.severity === 'high' ? 'error' : 'warning',
1251
+ },
1252
+ });
1253
+ }
1254
+ }
1255
+
1256
+ return Array.from(ruleMap.values());
1257
+ }
1258
+
1259
+ function formatToast(run) {
1260
+ const verdict = run.verdict === 'SHIP' || run.verdict === 'PASS' ? 'SHIP' : 'NO-SHIP';
1261
+ const blockerCount = run.blockers?.length || 0;
1262
+ const hasReplay = run.artifacts?.some(a => a.type === 'replay');
1263
+
1264
+ let toast = verdict;
1265
+ if (blockerCount > 0) {
1266
+ toast += ` • ${blockerCount} blocker${blockerCount > 1 ? 's' : ''}`;
1267
+ }
1268
+ if (hasReplay) {
1269
+ toast += ' • Replay ready';
1270
+ }
1271
+
1272
+ return toast;
1273
+ }
1274
+
1275
+ export { state, policy };