ship-safe 7.0.0 → 9.0.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.
@@ -17,7 +17,7 @@ import path from 'path';
17
17
  import chalk from 'chalk';
18
18
  import ora from 'ora';
19
19
  import fg from 'fast-glob';
20
- import { buildOrchestrator } from '../agents/index.js';
20
+ import { buildOrchestrator, buildOrchestratorAsync } from '../agents/index.js';
21
21
  import { LegalRiskAgent } from '../agents/legal-risk-agent.js';
22
22
  import { ScoringEngine } from '../agents/scoring-engine.js';
23
23
  import { PolicyEngine } from '../agents/policy-engine.js';
@@ -37,8 +37,11 @@ import {
37
37
  import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
38
38
  import { CacheManager } from '../utils/cache-manager.js';
39
39
  import { filterBaseline } from './baseline.js';
40
+ import { SecurityMemory } from '../utils/security-memory.js';
41
+ import { ScanPlaybook } from '../utils/scan-playbook.js';
40
42
  import { generatePDF, generatePrintHTML, isChromeAvailable } from '../utils/pdf-generator.js';
41
43
  import { SecretsVerifier } from '../utils/secrets-verifier.js';
44
+ import { applyInlineAnnotations } from './autofix.js';
42
45
 
43
46
  // =============================================================================
44
47
  // CONSTANTS
@@ -186,7 +189,16 @@ export async function auditCommand(targetPath = '.', options = {}) {
186
189
  }
187
190
 
188
191
  // ── Phase 2: Agent Scan ───────────────────────────────────────────────────
189
- const orchestrator = buildOrchestrator();
192
+ const orchestrator = await buildOrchestratorAsync(absolutePath, { quiet: true });
193
+
194
+ // --hermes-only: filter to llm + supply-chain category agents only
195
+ if (options.hermesOnly && orchestrator.agents) {
196
+ const hermesCategories = new Set(['llm', 'supply-chain']);
197
+ orchestrator.agents = orchestrator.agents.filter(a =>
198
+ hermesCategories.has(a.category) || hermesCategories.has(a.constructor?.category)
199
+ );
200
+ }
201
+
190
202
  const registeredAgentCount = orchestrator.agents?.length || 15;
191
203
  const agentSpinner = machineOutput ? null : ora({ text: chalk.white(`[Phase 2/4] Running ${registeredAgentCount} security agents...`), color: 'cyan' }).start();
192
204
  let agentFindings = [];
@@ -278,6 +290,30 @@ export async function auditCommand(targetPath = '.', options = {}) {
278
290
  }
279
291
  }
280
292
 
293
+ // ── Scan Playbook — update with latest recon + findings ─────────────────
294
+ try {
295
+ const playbook = new ScanPlaybook(absolutePath);
296
+ const suppressedRules = new SecurityMemory(absolutePath).list().map(e => e.rule).filter(Boolean);
297
+ playbook.update(recon, { score: scoreResult.score, grade: scoreResult.grade?.letter || scoreResult.grade, totalFindings: filteredFindings.length }, filteredFindings, suppressedRules);
298
+ } catch { /* non-fatal */ }
299
+
300
+ // ── Security Memory Filter ──────────────────────────────────────────────
301
+ // Auto-learn false positives from deep analysis results, then suppress
302
+ // any finding that memory recognises from a previous scan.
303
+ const secMemory = new SecurityMemory(absolutePath);
304
+ if (options.deep) {
305
+ // After deep analysis ran, learn any new false positives
306
+ const newFPs = secMemory.learnFromAnalysis(filteredFindings);
307
+ if (newFPs > 0 && !machineOutput) {
308
+ console.log(chalk.gray(` Memory: ${newFPs} new false positive(s) learned and will be suppressed in future scans`));
309
+ }
310
+ }
311
+ const { kept: memFiltered, suppressedCount: memSuppressed } = secMemory.filter(filteredFindings);
312
+ filteredFindings = memFiltered;
313
+ if (memSuppressed > 0 && !machineOutput) {
314
+ console.log(chalk.gray(` Memory: ${memSuppressed} previously-confirmed false positive(s) suppressed`));
315
+ }
316
+
281
317
  // Count suppressions (ship-safe-ignore comments)
282
318
  const suppressions = countSuppressions(allFiles);
283
319
 
@@ -395,6 +431,11 @@ export async function auditCommand(targetPath = '.', options = {}) {
395
431
  // ── Build Remediation Plan ────────────────────────────────────────────────
396
432
  const remediationPlan = buildRemediationPlan(filteredFindings, depVulns, absolutePath);
397
433
 
434
+ // Skip all output and file generation for inner agentic re-scans
435
+ if (options._agenticInner) {
436
+ return { score: scoreResult.score, findings: filteredFindings };
437
+ }
438
+
398
439
  // ── Output ────────────────────────────────────────────────────────────────
399
440
  console.log();
400
441
 
@@ -465,7 +506,112 @@ export async function auditCommand(targetPath = '.', options = {}) {
465
506
  console.log();
466
507
  }
467
508
 
468
- process.exit(scoreResult.score >= 75 ? 0 : 1);
509
+ // ── Agentic Loop (--agentic) ────────────────────────────────────────────
510
+ // Scan → annotate fixes → re-scan cycle until score >= target or maxIter.
511
+ // NOTE: process.exit() is deferred until after the loop so all iterations
512
+ // can run. The inner re-scans use _agenticInner: true to skip process.exit.
513
+ if (options.agentic && !options._agenticInner) {
514
+ const maxIter = typeof options.agentic === 'number' ? options.agentic : 3;
515
+ const targetScore = options.agenticTarget ?? 75;
516
+ let iteration = 1;
517
+ let currentScore = scoreResult.score;
518
+ let currentFindings = filteredFindings;
519
+
520
+ if (!machineOutput) {
521
+ console.log();
522
+ console.log(chalk.cyan.bold(` Agentic mode: scan→fix→verify loop (max ${maxIter} iterations, target score: ${targetScore})`));
523
+ }
524
+
525
+ while (currentScore < targetScore && iteration <= maxIter) {
526
+ if (!machineOutput) {
527
+ console.log(chalk.cyan(`\n ─── Agentic iteration ${iteration}/${maxIter} (current score: ${currentScore}) ───`));
528
+ }
529
+
530
+ const actionable = currentFindings.filter(f => f.fix && f.severity !== 'low');
531
+ if (actionable.length === 0) {
532
+ if (!machineOutput) console.log(chalk.gray(' No auto-fixable findings — stopping agentic loop.'));
533
+ break;
534
+ }
535
+
536
+ // Delegate annotation to autofix module (handles comment style, idempotency, NEVER_EDIT list)
537
+ const fixCount = applyInlineAnnotations(actionable);
538
+ if (!machineOutput) {
539
+ console.log(chalk.yellow(` Annotated ${fixCount} finding(s). Re-scanning...`));
540
+ }
541
+ if (fixCount === 0) break;
542
+
543
+ // Re-scan without recursing into the agentic loop or calling process.exit
544
+ const innerResult = await runAuditInner(targetPath, {
545
+ ...options,
546
+ agentic: false,
547
+ _agenticInner: true,
548
+ json: false,
549
+ sarif: false,
550
+ csv: false,
551
+ md: false,
552
+ html: false,
553
+ pdf: false,
554
+ quiet: true,
555
+ });
556
+
557
+ const prevScore = currentScore;
558
+ currentScore = innerResult?.score ?? currentScore;
559
+ currentFindings = innerResult?.findings ?? currentFindings;
560
+
561
+ if (!machineOutput) {
562
+ const diff = currentScore - prevScore;
563
+ const arrow = diff > 0 ? chalk.green(`↑ +${diff.toFixed(1)}`) : diff < 0 ? chalk.red(`↓ ${diff.toFixed(1)}`) : chalk.gray('→ 0');
564
+ console.log(chalk.cyan(` Re-scan score: ${currentScore} ${arrow}`));
565
+ }
566
+
567
+ iteration++;
568
+ }
569
+
570
+ if (!machineOutput) {
571
+ if (currentScore >= targetScore) {
572
+ console.log(chalk.green.bold(`\n Agentic loop complete — target score ${targetScore} reached (${currentScore}).`));
573
+ } else {
574
+ console.log(chalk.yellow(`\n Agentic loop stopped after ${iteration - 1} iteration(s). Final score: ${currentScore}`));
575
+ }
576
+ }
577
+ }
578
+
579
+ // ── Exit code logic ─────────────────────────────────────────────────────
580
+ let threshold = 75;
581
+ if (options.failBelow !== undefined) {
582
+ if (options.failBelow === 'baseline') {
583
+ // Read baseline score from .ship-safe/hermes-baseline.json
584
+ const baselinePath = path.join(absolutePath, '.ship-safe', 'hermes-baseline.json');
585
+ try {
586
+ const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
587
+ threshold = baseline.score || 0;
588
+ if (!machineOutput) {
589
+ console.log(chalk.gray(` Baseline threshold: ${threshold}/100 (from ${baselinePath})`));
590
+ }
591
+ } catch {
592
+ if (!machineOutput) {
593
+ console.log(chalk.yellow(` Warning: could not read baseline — using score 0 as threshold`));
594
+ }
595
+ threshold = 0;
596
+ }
597
+ } else {
598
+ threshold = parseInt(options.failBelow, 10) || 75;
599
+ }
600
+ }
601
+
602
+ process.exit(scoreResult.score >= threshold ? 0 : 1);
603
+ }
604
+
605
+ /**
606
+ * Run a lightweight inner audit that returns { score, findings } without
607
+ * calling process.exit(). Used exclusively by the --agentic loop.
608
+ */
609
+ async function runAuditInner(targetPath, options) {
610
+ try {
611
+ return await auditCommand(targetPath, options);
612
+ } catch {
613
+ return null;
614
+ }
469
615
  }
470
616
 
471
617
  // =============================================================================
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Autofix Command — Agentic Auto-Fix PRs
3
+ * ========================================
4
+ *
5
+ * Reads findings from a ship-safe report (or the last audit run), applies the
6
+ * LLM-generated fixes from Tier 3 deep analysis, commits them to a branch, and
7
+ * opens a GitHub pull request.
8
+ *
9
+ * Requires:
10
+ * - A report with deepAnalysis.fix fields (run with `--deep` flag)
11
+ * - Git repository
12
+ * - GitHub CLI (`gh`) installed for PR creation, OR GITHUB_TOKEN + repo info
13
+ *
14
+ * USAGE:
15
+ * npx ship-safe autofix Auto-fix from last report
16
+ * npx ship-safe autofix --report report.json Auto-fix from specific report
17
+ * npx ship-safe autofix --dry-run Preview fixes without applying
18
+ * npx ship-safe autofix --severity high Only fix critical+high findings
19
+ *
20
+ * SAFETY:
21
+ * - Never auto-commits secrets, config files, or .env
22
+ * - Always creates a new branch (never pushes to main/master/develop)
23
+ * - Dry-run mode shows a diff without writing any files
24
+ * - Each fix is applied atomically — if a file fails, others continue
25
+ */
26
+
27
+ import fs from 'fs';
28
+ import path from 'path';
29
+ import { execFileSync, execSync } from 'child_process';
30
+ import chalk from 'chalk';
31
+ import * as output from '../utils/output.js';
32
+
33
+ // Severity rank for filtering
34
+ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
35
+
36
+ // Files we never auto-edit (secrets, config, generated)
37
+ const NEVER_EDIT = [
38
+ /\.env(\.|$)/i,
39
+ /\.pem$|\.key$|\.p12$|\.pfx$/i,
40
+ /package-lock\.json$|yarn\.lock$|pnpm-lock\.yaml$/i,
41
+ /\.min\.(js|css)$/,
42
+ /node_modules\//,
43
+ /dist\//,
44
+ /build\//,
45
+ ];
46
+
47
+ // =============================================================================
48
+ // MAIN
49
+ // =============================================================================
50
+
51
+ export async function autofixCommand(options = {}) {
52
+ const rootPath = path.resolve(options.path || '.');
53
+ const dryRun = options.dryRun || false;
54
+ const minSev = options.severity || 'high';
55
+ const minRank = SEV_RANK[minSev] ?? 3;
56
+ const reportPath = options.report
57
+ ? path.resolve(options.report)
58
+ : findLastReport(rootPath);
59
+
60
+ console.log();
61
+ output.header('Ship Safe — Agentic Autofix');
62
+ console.log();
63
+
64
+ if (!reportPath || !fs.existsSync(reportPath)) {
65
+ output.error('No report found. Run `npx ship-safe audit . --deep --json` first, or pass --report <path>.');
66
+ console.log(chalk.gray(' The --deep flag enables Tier 3 exploit-chain analysis that generates fix suggestions.'));
67
+ process.exit(1);
68
+ }
69
+
70
+ // ── Load Report ─────────────────────────────────────────────────────────
71
+ let report;
72
+ try {
73
+ report = JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
74
+ } catch (err) {
75
+ output.error(`Failed to parse report: ${err.message}`);
76
+ process.exit(1);
77
+ }
78
+
79
+ const findings = report.findings ?? [];
80
+ console.log(chalk.gray(` Report: ${reportPath}`));
81
+ console.log(chalk.gray(` Total findings: ${findings.length}`));
82
+ console.log();
83
+
84
+ // ── Filter to fixable findings ──────────────────────────────────────────
85
+ const fixable = findings.filter(f => {
86
+ if (!f.deepAnalysis?.fix) return false;
87
+ if ((SEV_RANK[f.severity] ?? 0) < minRank) return false;
88
+ if (!f.file) return false;
89
+ const absFile = path.resolve(rootPath, f.file);
90
+ if (NEVER_EDIT.some(p => p.test(absFile.replace(/\\/g, '/')))) return false;
91
+ if (!fs.existsSync(absFile)) return false;
92
+ return true;
93
+ });
94
+
95
+ if (fixable.length === 0) {
96
+ console.log(chalk.yellow(` No fixable findings found at severity >= ${minSev}.`));
97
+ console.log(chalk.gray(' Tip: Run `npx ship-safe audit . --deep` to generate AI-powered fix suggestions.'));
98
+ return;
99
+ }
100
+
101
+ console.log(chalk.cyan(` Found ${fixable.length} fixable finding(s) at severity >= ${minSev}:`));
102
+ console.log();
103
+
104
+ for (const f of fixable) {
105
+ const sev = f.severity === 'critical' ? chalk.red.bold(f.severity)
106
+ : f.severity === 'high' ? chalk.yellow(f.severity)
107
+ : chalk.blue(f.severity);
108
+ console.log(` ${sev} ${chalk.white(f.title)}`);
109
+ console.log(` ${chalk.gray('File:')} ${f.file}${f.line ? `:${f.line}` : ''}`);
110
+ console.log(` ${chalk.gray('Fix:')} ${f.deepAnalysis.fix}`);
111
+ console.log();
112
+ }
113
+
114
+ if (dryRun) {
115
+ console.log(chalk.yellow(' Dry-run mode — no files will be changed.'));
116
+ console.log(chalk.gray(' Remove --dry-run to apply fixes and open a PR.'));
117
+ return;
118
+ }
119
+
120
+ // ── Confirm ──────────────────────────────────────────────────────────────
121
+ if (!options.yes) {
122
+ const { createInterface } = await import('readline');
123
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
124
+ const answer = await new Promise(resolve => {
125
+ rl.question(chalk.cyan(` Apply ${fixable.length} fix(es) and open a PR? [y/N] `), resolve);
126
+ });
127
+ rl.close();
128
+ if (!/^y/i.test(answer)) {
129
+ console.log(chalk.gray('\n Cancelled.\n'));
130
+ return;
131
+ }
132
+ console.log();
133
+ }
134
+
135
+ // ── Check git state ──────────────────────────────────────────────────────
136
+ if (!isGitRepo(rootPath)) {
137
+ output.error('Not a git repository. Autofix requires git.');
138
+ process.exit(1);
139
+ }
140
+
141
+ const currentBranch = getCurrentBranch(rootPath);
142
+ const protectedBranches = ['main', 'master', 'develop', 'dev', 'production', 'staging'];
143
+ if (protectedBranches.includes(currentBranch)) {
144
+ // Work on a new branch instead
145
+ }
146
+
147
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
148
+ const branchName = `ship-safe/autofix-${timestamp}`;
149
+
150
+ console.log(chalk.gray(` Creating branch: ${branchName}`));
151
+ try {
152
+ execFileSync('git', ['checkout', '-b', branchName], { cwd: rootPath, stdio: 'pipe' });
153
+ } catch (err) {
154
+ output.error(`Failed to create branch: ${err.message}`);
155
+ process.exit(1);
156
+ }
157
+
158
+ // ── Apply fixes ──────────────────────────────────────────────────────────
159
+ const applied = [];
160
+ const failed = [];
161
+
162
+ for (const f of fixable) {
163
+ const absFile = path.resolve(rootPath, f.file);
164
+ const fix = f.deepAnalysis.fix;
165
+
166
+ try {
167
+ applyInlineAnnotation(absFile, f.line, fix);
168
+ applied.push(f);
169
+ console.log(chalk.green(` ✔ Annotated: ${f.file}:${f.line ?? ''}`));
170
+ } catch (err) {
171
+ failed.push({ finding: f, error: err.message });
172
+ console.log(chalk.yellow(` ⚠ Skipped: ${f.file} — ${err.message}`));
173
+ }
174
+ }
175
+
176
+ if (applied.length === 0) {
177
+ console.log(chalk.yellow('\n No files were changed. Cleaning up branch...'));
178
+ try {
179
+ execFileSync('git', ['checkout', currentBranch], { cwd: rootPath, stdio: 'pipe' });
180
+ execFileSync('git', ['branch', '-D', branchName], { cwd: rootPath, stdio: 'pipe' });
181
+ } catch { /* ignore cleanup errors */ }
182
+ return;
183
+ }
184
+
185
+ // ── Commit ───────────────────────────────────────────────────────────────
186
+ console.log(chalk.gray(`\n Committing ${applied.length} fix annotation(s)...`));
187
+
188
+ try {
189
+ const filesToStage = [...new Set(applied.map(f => path.resolve(rootPath, f.file)))];
190
+ execFileSync('git', ['add', ...filesToStage], { cwd: rootPath, stdio: 'pipe' });
191
+
192
+ const commitMsg = [
193
+ `fix(security): apply ship-safe autofix annotations`,
194
+ '',
195
+ `Addresses ${applied.length} finding(s) from ship-safe audit:`,
196
+ ...applied.map(f => `- ${f.severity.toUpperCase()}: ${f.title} (${f.file}${f.line ? `:${f.line}` : ''})`),
197
+ '',
198
+ 'Fix suggestions generated by ship-safe Tier 3 (Opus) deep analysis.',
199
+ 'Review each annotation and apply the suggested code change.',
200
+ ].join('\n');
201
+
202
+ execFileSync('git', ['commit', '-m', commitMsg], { cwd: rootPath, stdio: 'pipe' });
203
+ console.log(chalk.green(' Committed.'));
204
+ } catch (err) {
205
+ output.error(`Commit failed: ${err.message}`);
206
+ // Restore branch state
207
+ try { execFileSync('git', ['checkout', currentBranch], { cwd: rootPath, stdio: 'pipe' }); } catch { /* ignore */ }
208
+ process.exit(1);
209
+ }
210
+
211
+ // ── Push and open PR ─────────────────────────────────────────────────────
212
+ console.log(chalk.gray(' Pushing branch...'));
213
+ try {
214
+ execFileSync('git', ['push', '-u', 'origin', branchName], { cwd: rootPath, stdio: 'pipe' });
215
+ } catch (err) {
216
+ console.log(chalk.yellow(` Push failed: ${err.message}`));
217
+ console.log(chalk.gray(` Branch created locally: ${branchName}`));
218
+ console.log(chalk.gray(' Push manually with: git push -u origin ' + branchName));
219
+ return;
220
+ }
221
+
222
+ // ── Open PR ───────────────────────────────────────────────────────────────
223
+ const prBody = buildPRBody(applied, failed, reportPath);
224
+ const prTitle = `fix(security): ship-safe autofix — ${applied.length} finding(s)`;
225
+
226
+ let prUrl = null;
227
+ const ghAvailable = isCommandAvailable('gh');
228
+
229
+ if (ghAvailable) {
230
+ try {
231
+ const result = execFileSync('gh', [
232
+ 'pr', 'create',
233
+ '--title', prTitle,
234
+ '--body', prBody,
235
+ '--base', currentBranch,
236
+ '--head', branchName,
237
+ ], { cwd: rootPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
238
+ prUrl = result.trim();
239
+ } catch (err) {
240
+ console.log(chalk.yellow(` gh pr create failed: ${err.stderr?.toString().trim() || err.message}`));
241
+ }
242
+ }
243
+
244
+ // ── Summary ───────────────────────────────────────────────────────────────
245
+ console.log();
246
+ console.log(chalk.green.bold(` ✔ Autofix complete`));
247
+ console.log(` Applied: ${chalk.white(applied.length)} fix annotation(s)`);
248
+ if (failed.length > 0) console.log(` Skipped: ${chalk.yellow(failed.length)} (see above)`);
249
+ console.log(` Branch: ${chalk.cyan(branchName)}`);
250
+ if (prUrl) {
251
+ console.log(` PR: ${chalk.cyan(prUrl)}`);
252
+ } else {
253
+ console.log(chalk.gray(' Install `gh` (GitHub CLI) to auto-open pull requests.'));
254
+ }
255
+ console.log();
256
+ }
257
+
258
+ // =============================================================================
259
+ // HELPERS
260
+ // =============================================================================
261
+
262
+ /**
263
+ * Apply the fix as a structured comment annotation above the finding line.
264
+ * The comment explains the issue and the fix — a developer reviews and applies.
265
+ * We never blindly rewrite code; instead we annotate so engineers make the call.
266
+ */
267
+ /**
268
+ * Apply fix annotations to a list of findings in-place.
269
+ * Returns the count of files successfully annotated.
270
+ * Exported for use by the --agentic audit loop.
271
+ */
272
+ export function applyInlineAnnotations(findings) {
273
+ const NEVER_EDIT = new Set(['.env', '.env.local', '.env.production', 'secrets.json', '.npmrc', '.netrc']);
274
+ const fixable = findings.filter(f =>
275
+ f.fix && f.file && fs.existsSync(f.file) && !NEVER_EDIT.has(path.basename(f.file))
276
+ );
277
+ let count = 0;
278
+ for (const f of fixable.slice(0, 10)) {
279
+ try {
280
+ applyInlineAnnotation(f.file, f.line, f.fix);
281
+ count++;
282
+ } catch { /* skip unwritable */ }
283
+ }
284
+ return count;
285
+ }
286
+
287
+ export function applyInlineAnnotation(filePath, lineNum, fix) {
288
+ const content = fs.readFileSync(filePath, 'utf-8');
289
+ const lines = content.split('\n');
290
+ const idx = Math.max(0, (lineNum || 1) - 1);
291
+
292
+ if (idx >= lines.length) {
293
+ throw new Error(`Line ${lineNum} out of range`);
294
+ }
295
+
296
+ // Already annotated?
297
+ if (idx > 0 && /ship-safe-fix/i.test(lines[idx - 1])) return;
298
+
299
+ const indent = lines[idx].match(/^(\s*)/)?.[1] ?? '';
300
+ const isJs = /\.(js|ts|jsx|tsx|mjs|cjs|java|c|cpp|cs|go|rs|swift|kt)$/.test(filePath);
301
+ const isPy = /\.py$/.test(filePath);
302
+
303
+ // Wrap fix in a structured annotation comment
304
+ const fixLines = fix.split('\n').map(l => l.trim()).filter(Boolean);
305
+ let annotation;
306
+
307
+ if (isPy) {
308
+ annotation = [
309
+ `${indent}# ship-safe-fix [REVIEW REQUIRED]`,
310
+ ...fixLines.map(l => `${indent}# ${l}`),
311
+ ].join('\n');
312
+ } else {
313
+ annotation = [
314
+ `${indent}// ship-safe-fix [REVIEW REQUIRED]`,
315
+ ...fixLines.map(l => `${indent}// ${l}`),
316
+ ].join('\n');
317
+ }
318
+
319
+ lines.splice(idx, 0, annotation);
320
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
321
+ }
322
+
323
+ function buildPRBody(applied, failed, reportPath) {
324
+ const rows = applied.map(f =>
325
+ `| ${f.severity} | ${f.title} | \`${f.file}${f.line ? `:${f.line}` : ''}\` | ${f.deepAnalysis.fix?.slice(0, 80) ?? ''} |`
326
+ ).join('\n');
327
+
328
+ return `## Security Autofix
329
+
330
+ Ship Safe detected **${applied.length}** security finding(s) and has annotated the affected files with fix instructions. Each annotation is marked \`// ship-safe-fix [REVIEW REQUIRED]\` — **please review and apply the suggested changes before merging.**
331
+
332
+ ### Findings Fixed
333
+
334
+ | Severity | Title | Location | Fix Summary |
335
+ |----------|-------|----------|-------------|
336
+ ${rows}
337
+
338
+ ${failed.length > 0 ? `### Skipped (${failed.length})\n\n${failed.map(f => `- ${f.finding.title}: ${f.error}`).join('\n')}` : ''}
339
+
340
+ ---
341
+
342
+ > Generated by [ship-safe](https://shipsafecli.com) — AI-powered security scanner.
343
+ > Run \`npx ship-safe audit . --deep\` to regenerate findings.
344
+
345
+ 🤖 Generated with [Claude Code](https://claude.com/claude-code)`;
346
+ }
347
+
348
+ function findLastReport(rootPath) {
349
+ // Look for common report filenames in order of preference
350
+ const candidates = [
351
+ 'ship-safe-report.json',
352
+ '.ship-safe/last-report.json',
353
+ 'security-report.json',
354
+ ].map(f => path.join(rootPath, f));
355
+
356
+ return candidates.find(p => fs.existsSync(p)) ?? null;
357
+ }
358
+
359
+ function isGitRepo(rootPath) {
360
+ try {
361
+ execFileSync('git', ['rev-parse', '--git-dir'], { cwd: rootPath, stdio: 'pipe' });
362
+ return true;
363
+ } catch {
364
+ return false;
365
+ }
366
+ }
367
+
368
+ function getCurrentBranch(rootPath) {
369
+ try {
370
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootPath, encoding: 'utf-8', stdio: 'pipe' }).trim();
371
+ } catch {
372
+ return 'main';
373
+ }
374
+ }
375
+
376
+ function isCommandAvailable(cmd) {
377
+ try {
378
+ execFileSync(cmd, ['--version'], { stdio: 'pipe' });
379
+ return true;
380
+ } catch {
381
+ return false;
382
+ }
383
+ }