ship-safe 9.1.2 → 9.2.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,960 @@
1
+ /**
2
+ * Ship Safe Security Agent — Interactive Fix Loop
3
+ * ================================================
4
+ *
5
+ * Scans your codebase, then for each affected file:
6
+ * 1. Generates a precise multi-edit fix plan via LLM (one plan per file,
7
+ * addressing every finding in that file at once)
8
+ * 2. Shows you exactly what it will change (unified diff with line numbers)
9
+ * 3. Asks you to accept, skip, or quit
10
+ * 4. Applies the changes atomically
11
+ * 5. Re-scans to verify the findings are resolved
12
+ * 6. Logs every change to .ship-safe/fixes.jsonl
13
+ *
14
+ * USAGE:
15
+ * ship-safe agent [path] Interactive fix loop
16
+ * ship-safe agent . --plan-only Generate plans, never write
17
+ * ship-safe agent . --severity high Only fix high+ severity
18
+ * ship-safe agent . --branch fixes Create a branch, commit per file
19
+ * ship-safe agent . --pr After fixing, push and open a PR
20
+ * ship-safe agent . --provider deepseek-flash
21
+ *
22
+ * SAFETY:
23
+ * - Refuses to operate on a dirty git tree (use --allow-dirty to override)
24
+ * - Always shows a diff before any write
25
+ * - Re-scans after each batch to verify the fix
26
+ * - Plans may create new files (e.g., .env.example) but cannot edit
27
+ * .env, secrets, lockfiles, or build artifacts
28
+ * - Every applied change is logged for audit & undo (`ship-safe undo`)
29
+ */
30
+
31
+ import fs from 'fs';
32
+ import path from 'path';
33
+ import { createInterface } from 'readline';
34
+ import { execFileSync } from 'child_process';
35
+ import chalk from 'chalk';
36
+ import ora from 'ora';
37
+ import { autoDetectProvider } from '../providers/llm-provider.js';
38
+ import { auditCommand } from './audit.js';
39
+ import * as output from '../utils/output.js';
40
+
41
+ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
42
+ const NEVER_EDIT = [
43
+ /(^|\/)\.env(\.|$)/i,
44
+ /\.pem$|\.key$|\.p12$|\.pfx$/i,
45
+ /package-lock\.json$|yarn\.lock$|pnpm-lock\.yaml$/i,
46
+ /(^|\/)node_modules\//,
47
+ /(^|\/)dist\//,
48
+ /(^|\/)build\//,
49
+ /\.min\.(js|css)$/,
50
+ ];
51
+ // Files the agent IS allowed to create or update freely (companions to fixes)
52
+ const SAFE_NEW_FILES = [
53
+ /(^|\/)\.env\.example$/i,
54
+ /(^|\/)\.env\.sample$/i,
55
+ /(^|\/)\.gitignore$/i,
56
+ ];
57
+
58
+ const FIX_LOG_DIR = '.ship-safe';
59
+ const FIX_LOG_FILE = 'fixes.jsonl';
60
+
61
+ // =============================================================================
62
+ // MAIN
63
+ // =============================================================================
64
+
65
+ export async function agentFixCommand(targetPath = '.', options = {}) {
66
+ const root = path.resolve(targetPath);
67
+
68
+ if (!fs.existsSync(root)) {
69
+ output.error(`Path does not exist: ${root}`);
70
+ process.exit(1);
71
+ }
72
+
73
+ console.log();
74
+ output.header('Ship Safe — Security Agent');
75
+ console.log(chalk.gray(' I will scan, plan each fix, ask before changing anything,'));
76
+ console.log(chalk.gray(' and verify the fix worked. You stay in control.'));
77
+ console.log();
78
+
79
+ // ── Git safety check ─────────────────────────────────────────────────────
80
+ const initialBranch = getCurrentBranch(root);
81
+ if (!options.allowDirty) {
82
+ const state = checkGitState(root);
83
+ if (state === 'not-a-repo') {
84
+ console.log(chalk.yellow(' Note: this is not a git repository.'));
85
+ console.log(chalk.gray(' Changes cannot be reverted automatically.'));
86
+ const ok = await confirm(' Continue anyway?');
87
+ if (!ok) { console.log(chalk.gray(' Aborted.\n')); return; }
88
+ } else if (state === 'dirty') {
89
+ output.error('Working tree has uncommitted changes.');
90
+ console.log(chalk.gray(' Commit or stash first, or pass --allow-dirty.'));
91
+ process.exit(1);
92
+ }
93
+ }
94
+
95
+ // ── Optional branch isolation ────────────────────────────────────────────
96
+ let branchCreated = null;
97
+ if (options.branch) {
98
+ if (!initialBranch) {
99
+ console.log(chalk.yellow(' --branch requires a git repository. Skipping branch creation.'));
100
+ } else {
101
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);
102
+ const branchName = options.branch === true
103
+ ? `ship-safe/fixes-${stamp}`
104
+ : String(options.branch);
105
+ try {
106
+ execFileSync('git', ['checkout', '-b', branchName], { cwd: root, stdio: 'pipe' });
107
+ branchCreated = branchName;
108
+ console.log(chalk.gray(` Branch: ${chalk.cyan(branchName)}`));
109
+ } catch (err) {
110
+ output.error(`Could not create branch ${branchName}: ${err.message}`);
111
+ process.exit(1);
112
+ }
113
+ }
114
+ }
115
+
116
+ // ── Load LLM provider ────────────────────────────────────────────────────
117
+ const provider = autoDetectProvider(root, {
118
+ provider: options.provider,
119
+ model: options.model,
120
+ think: options.think || false,
121
+ });
122
+ if (!provider) {
123
+ output.error('No LLM provider available.');
124
+ console.log(chalk.gray(' Set one of: DEEPSEEK_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, MOONSHOT_API_KEY, XAI_API_KEY'));
125
+ process.exit(1);
126
+ }
127
+ console.log(chalk.gray(` Provider: ${chalk.cyan(provider.name)}`));
128
+
129
+ if (options.sandbox) {
130
+ console.log(chalk.yellow(' --sandbox is not yet implemented — falling back to in-process verification.'));
131
+ console.log(chalk.gray(' Track this in the agent\'s next milestone.'));
132
+ }
133
+
134
+ // ── Run the scan ─────────────────────────────────────────────────────────
135
+ const scanSpinner = ora({ text: 'Scanning for issues...', color: 'cyan' }).start();
136
+ let scanResult;
137
+ try {
138
+ scanResult = await auditCommand(root, { _agenticInner: true, deep: false, deps: false, noAi: true });
139
+ } catch (err) {
140
+ scanSpinner.fail('Scan failed');
141
+ output.error(err.message);
142
+ process.exit(1);
143
+ }
144
+ scanSpinner.stop();
145
+
146
+ // ── Filter findings ──────────────────────────────────────────────────────
147
+ const minSev = options.severity || 'low';
148
+ const minRank = SEV_RANK[minSev] ?? 1;
149
+
150
+ const findings = (scanResult.findings ?? []).filter(f => {
151
+ if (!f.file) return false;
152
+ if ((SEV_RANK[f.severity] ?? 0) < minRank) return false;
153
+ const rel = f.file.replace(/\\/g, '/');
154
+ if (NEVER_EDIT.some(p => p.test(rel))) return false;
155
+ const abs = path.resolve(root, f.file);
156
+ return fs.existsSync(abs);
157
+ });
158
+
159
+ if (findings.length === 0) {
160
+ output.success('No fixable findings at the requested severity.');
161
+ console.log();
162
+ return;
163
+ }
164
+
165
+ // ── Group by file ────────────────────────────────────────────────────────
166
+ const byFile = new Map();
167
+ for (const f of findings) {
168
+ const list = byFile.get(f.file) ?? [];
169
+ list.push(f);
170
+ byFile.set(f.file, list);
171
+ }
172
+
173
+ console.log(chalk.cyan(` Found ${findings.length} fixable finding(s) across ${byFile.size} file(s)`));
174
+ console.log();
175
+
176
+ // ── Fix loop ─────────────────────────────────────────────────────────────
177
+ const applied = []; // { file, plan, verified }
178
+ const skipped = []; // { file, findings, reason }
179
+ let stopped = false;
180
+ let i = 0;
181
+
182
+ for (const [filePath, fileFindings] of byFile) {
183
+ i++;
184
+ if (stopped) break;
185
+
186
+ const idx = `[${i}/${byFile.size}]`;
187
+ console.log();
188
+ console.log(chalk.bold(` ${idx} ${chalk.cyan(filePath)} ${chalk.gray(`— ${fileFindings.length} finding(s)`)}`));
189
+ for (const f of fileFindings) {
190
+ console.log(` ${severityLabel(f.severity)} ${f.title}${f.line ? chalk.gray(` (line ${f.line})`) : ''}`);
191
+ }
192
+
193
+ // Generate plan
194
+ const planSpinner = ora({ text: 'Generating fix plan...', color: 'cyan', indent: 6 }).start();
195
+ const planResult = await generateBatchPlan(provider, root, filePath, fileFindings);
196
+ planSpinner.stop();
197
+
198
+ if (!planResult.ok) {
199
+ const detail = describePlanFailure(planResult);
200
+ console.log(chalk.yellow(` ${detail.message}`));
201
+ logFailure(root, { timestamp: new Date().toISOString(), file: filePath, findings: fileFindings.map(f => ({ title: f.title, line: f.line, rule: f.rule })), ...planResult });
202
+ skipped.push({ file: filePath, findings: fileFindings, reason: detail.reason });
203
+ continue;
204
+ }
205
+ const plan = planResult.plan;
206
+
207
+ // Validate (allows new safe files like .env.example)
208
+ const validation = validatePlan(root, plan);
209
+ if (!validation.ok) {
210
+ console.log(chalk.yellow(` Plan invalid: ${validation.reason}`));
211
+ logFailure(root, { timestamp: new Date().toISOString(), file: filePath, findings: fileFindings.map(f => ({ title: f.title, line: f.line, rule: f.rule })), reason: 'validation-rejected', detail: validation.reason, plan });
212
+ skipped.push({ file: filePath, findings: fileFindings, reason: `plan-invalid: ${validation.reason}` });
213
+ continue;
214
+ }
215
+
216
+ // Show plan
217
+ printPlan(plan, root);
218
+
219
+ if (options.planOnly) {
220
+ console.log(chalk.gray(' (plan-only mode — not applying)'));
221
+ continue;
222
+ }
223
+
224
+ // Decision logic:
225
+ // --yolo → auto-accept everything
226
+ // --auto-low → auto-accept low-risk plans, prompt on medium/high
227
+ // default → prompt every time
228
+ let decision;
229
+ const risk = (plan.risk || 'medium').toLowerCase();
230
+ if (options.yolo) {
231
+ decision = 'a';
232
+ console.log(chalk.gray(' (yolo: auto-accepting)'));
233
+ } else if (options.autoLow && risk === 'low') {
234
+ decision = 'a';
235
+ console.log(chalk.gray(' (auto-low: low-risk, auto-accepting)'));
236
+ } else {
237
+ // Interactive: prompt with [e]dit option
238
+ decision = await promptDecision(plan, root);
239
+ }
240
+
241
+ if (decision === 'q' || decision === 'quit') {
242
+ console.log(chalk.gray(' Stopping.'));
243
+ stopped = true;
244
+ break;
245
+ }
246
+ if (!['a', 'accept', 'y', 'yes'].includes(decision)) {
247
+ skipped.push({ file: filePath, findings: fileFindings, reason: 'user-skipped' });
248
+ continue;
249
+ }
250
+
251
+ // Apply
252
+ let applyErr = null;
253
+ const written = [];
254
+ try {
255
+ for (const fileChange of plan.files) {
256
+ applyEdit(root, fileChange);
257
+ written.push(path.resolve(root, fileChange.path));
258
+ }
259
+ } catch (err) {
260
+ applyErr = err.message;
261
+ }
262
+
263
+ if (applyErr) {
264
+ console.log(chalk.red(` Apply failed: ${applyErr}`));
265
+ skipped.push({ file: filePath, findings: fileFindings, reason: `apply-failed: ${applyErr}` });
266
+ continue;
267
+ }
268
+
269
+ // Verify by re-scanning
270
+ const verifySpinner = ora({ text: 'Verifying...', color: 'cyan', indent: 6 }).start();
271
+ const verified = await verifyFile(root, filePath, fileFindings);
272
+ if (verified.allResolved) {
273
+ verifySpinner.succeed(chalk.green(`Fix verified — ${fileFindings.length} finding(s) resolved`));
274
+ } else if (verified.someResolved) {
275
+ verifySpinner.warn(chalk.yellow(`Partial: ${verified.resolvedCount}/${fileFindings.length} resolved`));
276
+ } else {
277
+ verifySpinner.warn(chalk.yellow('Fix applied, but findings still appear'));
278
+ }
279
+
280
+ // Per-fix commit (if branch isolation in use)
281
+ if (branchCreated) {
282
+ try {
283
+ execFileSync('git', ['add', '--', ...written], { cwd: root, stdio: 'pipe' });
284
+ const titles = fileFindings.slice(0, 3).map(f => f.title).join(', ');
285
+ const more = fileFindings.length > 3 ? ` (+${fileFindings.length - 3} more)` : '';
286
+ const msg = `fix(security): ${filePath} — ${titles}${more}`;
287
+ execFileSync('git', ['commit', '-m', msg], { cwd: root, stdio: 'pipe' });
288
+ } catch {
289
+ // commit failed — most likely nothing staged because edits were no-ops
290
+ }
291
+ }
292
+
293
+ // Log
294
+ logFix(root, {
295
+ timestamp: new Date().toISOString(),
296
+ file: filePath,
297
+ findings: fileFindings.map(f => ({ title: f.title, line: f.line, severity: f.severity, rule: f.rule })),
298
+ plan,
299
+ verified: verified.allResolved,
300
+ branch: branchCreated,
301
+ });
302
+
303
+ applied.push({ file: filePath, plan, verified });
304
+ }
305
+
306
+ // ── Final report ─────────────────────────────────────────────────────────
307
+ console.log();
308
+ console.log();
309
+ output.header('Summary');
310
+ console.log();
311
+ console.log(` ${chalk.green('Applied:')} ${applied.length} file(s)`);
312
+ console.log(` ${chalk.gray('Skipped:')} ${skipped.length} file(s)`);
313
+
314
+ if (applied.length > 0) {
315
+ console.log();
316
+ console.log(chalk.gray(' Applied:'));
317
+ for (const a of applied) {
318
+ const mark = a.verified.allResolved ? chalk.green('✓') : chalk.yellow('?');
319
+ console.log(` ${mark} ${a.file}`);
320
+ }
321
+ console.log();
322
+ console.log(chalk.gray(` Audit log: ${path.join(FIX_LOG_DIR, FIX_LOG_FILE)}`));
323
+ if (branchCreated) {
324
+ console.log(chalk.gray(` Branch: ${chalk.cyan(branchCreated)}`));
325
+ console.log(chalk.gray(` Switch back: git checkout ${initialBranch}`));
326
+ } else {
327
+ console.log(chalk.gray(' Review: git diff'));
328
+ console.log(chalk.gray(' Undo last: ship-safe undo'));
329
+ }
330
+ }
331
+
332
+ // ── PR autopilot ─────────────────────────────────────────────────────────
333
+ if (options.pr && applied.length > 0 && branchCreated) {
334
+ console.log();
335
+ await openPullRequest(root, branchCreated, applied);
336
+ } else if (options.pr && !branchCreated) {
337
+ console.log();
338
+ console.log(chalk.yellow(' --pr requires --branch. Skipping PR creation.'));
339
+ }
340
+
341
+ if (skipped.length > 0 && applied.length === 0) {
342
+ console.log();
343
+ console.log(chalk.gray(' Tip: try a different provider with --provider, or run with --plan-only'));
344
+ console.log(chalk.gray(' to inspect what would change.'));
345
+ }
346
+
347
+ console.log();
348
+ }
349
+
350
+ // =============================================================================
351
+ // PLAN GENERATION
352
+ // =============================================================================
353
+
354
+ // Generate a fix plan for a file. Returns either:
355
+ // { ok: true, plan } — plan generated successfully
356
+ // { ok: false, reason, raw, error } — failed; reason is one of:
357
+ // 'file-read-failed' | 'provider-error' | 'parse-error' |
358
+ // 'llm-declined' | 'empty-response'
359
+ // Caller decides what to do with the failure (log, persist, skip).
360
+ async function generateBatchPlan(provider, root, filePath, fileFindings) {
361
+ const abs = path.resolve(root, filePath);
362
+ let content;
363
+ try {
364
+ content = fs.readFileSync(abs, 'utf8');
365
+ } catch (err) {
366
+ return { ok: false, reason: 'file-read-failed', error: err.message };
367
+ }
368
+
369
+ const fileForPrompt = windowFileContent(content, fileFindings[0]?.line);
370
+
371
+ const findingsBlock = fileFindings.map((f, i) => `
372
+ ${i + 1}. [${f.severity.toUpperCase()}] ${f.title}${f.line ? ` (line ${f.line})` : ''}
373
+ Rule: ${f.rule ?? 'N/A'}
374
+ Description: ${f.description ?? 'N/A'}${f.fix ? `\n Suggested fix: ${f.fix}` : ''}
375
+ `).join('');
376
+
377
+ const systemPrompt = 'You are a security engineer. Produce precise code edits as structured JSON only. Never include prose, markdown, or code fences. Output a single JSON object.';
378
+
379
+ const userPrompt = `Fix all of these security findings in a single file by producing one coordinated plan.
380
+
381
+ FILE: ${filePath}
382
+
383
+ FINDINGS (${fileFindings.length}):
384
+ ${findingsBlock}
385
+
386
+ CURRENT FILE CONTENT:
387
+ \`\`\`
388
+ ${fileForPrompt}
389
+ \`\`\`
390
+
391
+ OUTPUT this exact JSON shape:
392
+ {
393
+ "summary": "one short sentence describing what you'll do across all findings",
394
+ "files": [
395
+ {
396
+ "path": "${filePath}",
397
+ "edits": [
398
+ { "find": "EXACT verbatim substring", "replace": "new string", "reason": "addresses finding N" }
399
+ ]
400
+ }
401
+ ],
402
+ "risk": "low"
403
+ }
404
+
405
+ You MAY also include companion file changes (only these are allowed):
406
+ - .env.example — add placeholders for any secrets you moved to env vars
407
+ - .gitignore — add patterns for files that should not be committed
408
+
409
+ For companion files, use this shape (no "find" needed):
410
+ { "path": ".env.example", "create": true, "content": "FULL FILE CONTENT" }
411
+ or to append:
412
+ { "path": ".gitignore", "append": "PATTERN_TO_ADD\\n" }
413
+
414
+ RULES:
415
+ - Each "find" string must appear EXACTLY ONCE in the file. Include enough context (3+ lines) for uniqueness.
416
+ - "replace" must be the corrected code. Preserve indentation and surrounding style.
417
+ - Address each finding listed above with at least one edit (or explain in summary why a finding can't be mechanically fixed).
418
+ - Risk: "low" = mechanical, "medium" = behavior change, "high" = architectural. Use "high" sparingly.
419
+ - If you cannot produce a precise mechanical plan, return {"summary":"requires manual review","files":[],"risk":"high"}
420
+ - JSON only. No prose. No code fences.`;
421
+
422
+ let response;
423
+ try {
424
+ response = await provider.complete(systemPrompt, userPrompt, {
425
+ maxTokens: 3000,
426
+ jsonMode: true,
427
+ });
428
+ } catch (err) {
429
+ return { ok: false, reason: 'provider-error', error: err.message };
430
+ }
431
+
432
+ if (!response || !response.trim()) {
433
+ return { ok: false, reason: 'empty-response', raw: response ?? '' };
434
+ }
435
+
436
+ const plan = parseJsonLoose(response);
437
+ if (!plan) {
438
+ return { ok: false, reason: 'parse-error', raw: response };
439
+ }
440
+ if (!Array.isArray(plan.files) || plan.files.length === 0) {
441
+ // The LLM returned valid JSON but explicitly declined to produce edits
442
+ // (typically with summary like "requires manual review"). This is a
443
+ // legitimate "I don't know" — not a bug.
444
+ return { ok: false, reason: 'llm-declined', raw: response, plan };
445
+ }
446
+
447
+ return { ok: true, plan };
448
+ }
449
+
450
+ function parseJsonLoose(response) {
451
+ if (!response) return null;
452
+ const cleaned = response.trim()
453
+ .replace(/^```(?:json)?\s*/i, '')
454
+ .replace(/```\s*$/i, '')
455
+ .trim();
456
+ try {
457
+ return JSON.parse(cleaned);
458
+ } catch {
459
+ const m = cleaned.match(/\{[\s\S]*\}/);
460
+ if (m) {
461
+ try { return JSON.parse(m[0]); } catch { return null; }
462
+ }
463
+ return null;
464
+ }
465
+ }
466
+
467
+ function windowFileContent(content, line) {
468
+ if (content.length <= 8000) return content;
469
+ if (!line) return content.slice(0, 8000);
470
+ const lines = content.split('\n');
471
+ const start = Math.max(0, line - 40);
472
+ const end = Math.min(lines.length, line + 40);
473
+ return lines.slice(start, end).join('\n');
474
+ }
475
+
476
+ // =============================================================================
477
+ // PLAN VALIDATION
478
+ // =============================================================================
479
+
480
+ function validatePlan(root, plan) {
481
+ if (!Array.isArray(plan.files) || plan.files.length === 0) {
482
+ return { ok: false, reason: 'no files in plan' };
483
+ }
484
+
485
+ for (const f of plan.files) {
486
+ if (!f.path) return { ok: false, reason: 'file entry missing path' };
487
+
488
+ const rel = f.path.replace(/\\/g, '/');
489
+ const isSafeNew = SAFE_NEW_FILES.some(p => p.test(rel));
490
+
491
+ // Block protected paths unless this is a known-safe companion file
492
+ if (!isSafeNew && NEVER_EDIT.some(p => p.test(rel))) {
493
+ return { ok: false, reason: `protected path: ${f.path}` };
494
+ }
495
+
496
+ const abs = path.resolve(root, f.path);
497
+ const exists = fs.existsSync(abs);
498
+
499
+ // Companion file forms (create / append)
500
+ if (f.create || f.append !== undefined) {
501
+ if (!exists && !isSafeNew) {
502
+ return { ok: false, reason: `cannot create new file at ${f.path}` };
503
+ }
504
+ if (f.create && typeof f.content !== 'string') {
505
+ return { ok: false, reason: 'create entry missing content' };
506
+ }
507
+ if (f.append && typeof f.append !== 'string') {
508
+ return { ok: false, reason: 'append must be a string' };
509
+ }
510
+ continue;
511
+ }
512
+
513
+ // Standard edit form
514
+ if (!exists) return { ok: false, reason: `file not found: ${f.path}` };
515
+ if (!Array.isArray(f.edits) || f.edits.length === 0) {
516
+ return { ok: false, reason: `no edits for ${f.path}` };
517
+ }
518
+
519
+ const content = fs.readFileSync(abs, 'utf8');
520
+ for (const e of f.edits) {
521
+ if (typeof e.find !== 'string' || typeof e.replace !== 'string') {
522
+ return { ok: false, reason: 'edit missing find/replace' };
523
+ }
524
+ if (e.find === e.replace) {
525
+ return { ok: false, reason: 'edit is a no-op' };
526
+ }
527
+ const match = locateFindString(content, e.find);
528
+ if (match.kind === 'missing') {
529
+ return { ok: false, reason: `find string not present in ${f.path}` };
530
+ }
531
+ if (match.kind === 'ambiguous') {
532
+ return { ok: false, reason: `find string is ambiguous (${match.count} matches) in ${f.path}` };
533
+ }
534
+ // Annotate the edit with the resolved match for use during apply
535
+ e._resolvedFind = match.matched;
536
+ }
537
+ }
538
+ return { ok: true };
539
+ }
540
+
541
+ // Try exact match first, then whitespace-normalized match if exact misses.
542
+ // Returns { kind: 'unique'|'ambiguous'|'missing', matched, count }
543
+ function locateFindString(haystack, needle) {
544
+ const exact = countOccurrences(haystack, needle);
545
+ if (exact === 1) return { kind: 'unique', matched: needle, count: 1 };
546
+ if (exact > 1) return { kind: 'ambiguous', matched: needle, count: exact };
547
+
548
+ // Whitespace-tolerant fallback: collapse whitespace runs and try again
549
+ const norm = (s) => s.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim();
550
+ const needleNorm = norm(needle);
551
+ if (!needleNorm) return { kind: 'missing', matched: null, count: 0 };
552
+
553
+ // Walk the haystack and check if any window normalizes to the same string
554
+ // To keep this cheap, only attempt when needle has at least one newline (likely a code block)
555
+ const lines = haystack.split('\n');
556
+ const needleLines = needleNorm.split('\n').length;
557
+ let foundIdx = -1;
558
+ let foundCount = 0;
559
+ for (let i = 0; i + needleLines <= lines.length; i++) {
560
+ const window = lines.slice(i, i + needleLines).join('\n');
561
+ if (norm(window) === needleNorm) {
562
+ foundIdx = i;
563
+ foundCount++;
564
+ if (foundCount > 1) break;
565
+ }
566
+ }
567
+ if (foundCount === 1) {
568
+ const matched = lines.slice(foundIdx, foundIdx + needleLines).join('\n');
569
+ return { kind: 'unique', matched, count: 1 };
570
+ }
571
+ if (foundCount > 1) return { kind: 'ambiguous', matched: null, count: foundCount };
572
+ return { kind: 'missing', matched: null, count: 0 };
573
+ }
574
+
575
+ function countOccurrences(haystack, needle) {
576
+ if (!needle) return 0;
577
+ let count = 0, idx = 0;
578
+ while ((idx = haystack.indexOf(needle, idx)) !== -1) { count++; idx += needle.length; }
579
+ return count;
580
+ }
581
+
582
+ // =============================================================================
583
+ // PRINTING
584
+ // =============================================================================
585
+
586
+ function printPlan(plan, _root) {
587
+ console.log();
588
+ console.log(chalk.bold(' Plan:'));
589
+ console.log(chalk.white(` ${plan.summary || '(no summary)'}`));
590
+ if (plan.risk) {
591
+ const riskColor = plan.risk === 'low' ? chalk.green : plan.risk === 'medium' ? chalk.yellow : chalk.red;
592
+ console.log(` Risk: ${riskColor(plan.risk)}`);
593
+ }
594
+ console.log();
595
+
596
+ for (const f of plan.files) {
597
+ if (f.create) {
598
+ console.log(chalk.bold(` ${chalk.green('+ ')}${f.path} ${chalk.gray('(new file)')}`));
599
+ printNewFilePreview(f.content);
600
+ continue;
601
+ }
602
+ if (f.append !== undefined) {
603
+ console.log(chalk.bold(` ${f.path} ${chalk.gray('(append)')}`));
604
+ for (const l of f.append.split('\n')) {
605
+ if (l) console.log(chalk.green(` + ${l}`));
606
+ }
607
+ continue;
608
+ }
609
+ console.log(chalk.bold(` ${f.path}`));
610
+ for (const e of f.edits) {
611
+ console.log(chalk.gray(` — ${e.reason || 'edit'}`));
612
+ printDiff(e._resolvedFind || e.find, e.replace);
613
+ }
614
+ }
615
+ console.log();
616
+ }
617
+
618
+ function printNewFilePreview(content) {
619
+ const lines = content.split('\n');
620
+ const max = 6;
621
+ const shown = lines.slice(0, max);
622
+ for (const l of shown) console.log(chalk.green(` + ${l}`));
623
+ if (lines.length > max) {
624
+ console.log(chalk.gray(` … +${lines.length - max} more line(s)`));
625
+ }
626
+ }
627
+
628
+ function printDiff(oldStr, newStr) {
629
+ const oldLines = oldStr.split('\n');
630
+ const newLines = newStr.split('\n');
631
+ for (const l of oldLines) console.log(chalk.red(` - ${l}`));
632
+ for (const l of newLines) console.log(chalk.green(` + ${l}`));
633
+ }
634
+
635
+ function severityLabel(sev) {
636
+ switch (sev) {
637
+ case 'critical': return chalk.red.bold('[CRITICAL]');
638
+ case 'high': return chalk.red('[HIGH]');
639
+ case 'medium': return chalk.yellow('[MEDIUM]');
640
+ case 'low': return chalk.blue('[LOW]');
641
+ default: return chalk.gray(`[${(sev || 'INFO').toUpperCase()}]`);
642
+ }
643
+ }
644
+
645
+ // =============================================================================
646
+ // APPLY
647
+ // =============================================================================
648
+
649
+ function applyEdit(root, fileChange) {
650
+ const abs = path.resolve(root, fileChange.path);
651
+
652
+ if (fileChange.create) {
653
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
654
+ fs.writeFileSync(abs, fileChange.content, 'utf8');
655
+ return;
656
+ }
657
+
658
+ if (fileChange.append !== undefined) {
659
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
660
+ const existing = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : '';
661
+ // Avoid duplicate appends
662
+ if (existing.includes(fileChange.append.trim())) return;
663
+ const sep = existing && !existing.endsWith('\n') ? '\n' : '';
664
+ fs.writeFileSync(abs, existing + sep + fileChange.append, 'utf8');
665
+ return;
666
+ }
667
+
668
+ let content = fs.readFileSync(abs, 'utf8');
669
+ for (const e of fileChange.edits) {
670
+ const find = e._resolvedFind || e.find;
671
+ if (!content.includes(find)) {
672
+ throw new Error(`find string drifted in ${fileChange.path} (file changed mid-plan)`);
673
+ }
674
+ content = content.replace(find, e.replace);
675
+ }
676
+ fs.writeFileSync(abs, content, 'utf8');
677
+ }
678
+
679
+ // =============================================================================
680
+ // VERIFY
681
+ // =============================================================================
682
+
683
+ async function verifyFile(root, filePath, originalFindings) {
684
+ try {
685
+ const result = await auditCommand(root, { _agenticInner: true, deep: false, deps: false, noAi: true });
686
+ const remaining = (result.findings ?? []).filter(f => f.file === filePath);
687
+
688
+ let resolvedCount = 0;
689
+ for (const orig of originalFindings) {
690
+ const stillThere = remaining.some(f =>
691
+ f.rule === orig.rule &&
692
+ Math.abs((f.line ?? 0) - (orig.line ?? 0)) <= 2,
693
+ );
694
+ if (!stillThere) resolvedCount++;
695
+ }
696
+
697
+ return {
698
+ allResolved: resolvedCount === originalFindings.length,
699
+ someResolved: resolvedCount > 0,
700
+ resolvedCount,
701
+ };
702
+ } catch {
703
+ return { allResolved: false, someResolved: false, resolvedCount: 0 };
704
+ }
705
+ }
706
+
707
+ // =============================================================================
708
+ // LOGGING
709
+ // =============================================================================
710
+
711
+ function logFix(root, entry) {
712
+ const dir = path.join(root, FIX_LOG_DIR);
713
+ const file = path.join(dir, FIX_LOG_FILE);
714
+ fs.mkdirSync(dir, { recursive: true });
715
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
716
+ }
717
+
718
+ // Append a structured record for every plan that didn't produce an apply.
719
+ // Useful for debugging regressions (the LLM said X, we rejected because Y).
720
+ function logFailure(root, entry) {
721
+ const dir = path.join(root, FIX_LOG_DIR);
722
+ const file = path.join(dir, 'failures.jsonl');
723
+ fs.mkdirSync(dir, { recursive: true });
724
+ // Truncate raw response to keep the log readable
725
+ const truncated = { ...entry };
726
+ if (typeof truncated.raw === 'string' && truncated.raw.length > 4000) {
727
+ truncated.raw = truncated.raw.slice(0, 4000) + '... [truncated]';
728
+ }
729
+ fs.appendFileSync(file, JSON.stringify(truncated) + '\n', 'utf8');
730
+ }
731
+
732
+ // Turn a structured plan failure into a user-facing one-liner.
733
+ function describePlanFailure(failure) {
734
+ switch (failure.reason) {
735
+ case 'file-read-failed':
736
+ return { message: `Could not read source file: ${failure.error}`, reason: 'file-read-failed' };
737
+ case 'provider-error':
738
+ return { message: `LLM provider error — ${failure.error}`, reason: 'provider-error' };
739
+ case 'empty-response':
740
+ return { message: 'LLM returned an empty response (try again, or switch provider).', reason: 'empty-response' };
741
+ case 'parse-error':
742
+ return { message: 'LLM returned unparseable JSON — saved to .ship-safe/failures.jsonl for inspection.', reason: 'parse-error' };
743
+ case 'llm-declined': {
744
+ const summary = failure.plan?.summary;
745
+ return { message: summary
746
+ ? `LLM declined to fix this file — ${summary}`
747
+ : 'LLM declined to fix this file (returned files=[] — needs manual review).',
748
+ reason: 'llm-declined',
749
+ };
750
+ }
751
+ default:
752
+ return { message: `Plan failed: ${failure.reason}`, reason: failure.reason };
753
+ }
754
+ }
755
+
756
+ // =============================================================================
757
+ // PR AUTOPILOT
758
+ // =============================================================================
759
+
760
+ async function openPullRequest(root, branch, applied) {
761
+ const ghAvailable = (() => {
762
+ try { execFileSync('gh', ['--version'], { stdio: 'pipe' }); return true; }
763
+ catch { return false; }
764
+ })();
765
+ if (!ghAvailable) {
766
+ console.log(chalk.yellow(' gh CLI not found. Install from https://cli.github.com to enable --pr.'));
767
+ return;
768
+ }
769
+
770
+ // Push branch
771
+ console.log(chalk.gray(' Pushing branch...'));
772
+ try {
773
+ execFileSync('git', ['push', '-u', 'origin', branch], { cwd: root, stdio: 'pipe' });
774
+ } catch (err) {
775
+ console.log(chalk.red(` Push failed: ${err.message}`));
776
+ return;
777
+ }
778
+
779
+ // Build PR body
780
+ const totalFindings = applied.reduce((n, a) => n + (a.plan.files?.[0]?.edits?.length ?? 0), 0);
781
+ const body = [
782
+ '## Ship Safe — Security Fixes',
783
+ '',
784
+ `Applied ${applied.length} file(s) of fixes (${totalFindings} edit(s)) generated and verified by the Ship Safe agent.`,
785
+ '',
786
+ '### Files changed',
787
+ ...applied.map(a => {
788
+ const mark = a.verified.allResolved ? '✓' : '⚠';
789
+ return `- ${mark} \`${a.file}\` — ${a.plan.summary || 'security fix'}`;
790
+ }),
791
+ '',
792
+ '### Notes',
793
+ '- Each fix was generated by an LLM and verified by re-scanning the file.',
794
+ '- Files marked ⚠ have residual findings; review carefully before merging.',
795
+ '- Full audit log: `.ship-safe/fixes.jsonl`',
796
+ '',
797
+ 'Generated by `ship-safe agent`.',
798
+ ].join('\n');
799
+
800
+ const title = `Security fixes: ${applied.length} file(s)`;
801
+
802
+ console.log(chalk.gray(' Opening PR...'));
803
+ let prUrl = null;
804
+ try {
805
+ prUrl = execFileSync('gh', ['pr', 'create', '--title', title, '--body', body], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
806
+ console.log(chalk.green(` PR opened: ${prUrl}`));
807
+ } catch (err) {
808
+ console.log(chalk.red(` PR creation failed: ${err.message}`));
809
+ return;
810
+ }
811
+
812
+ // If we're running inside CI on a PR, leave a comment on the originating PR
813
+ // pointing at our fix PR. Detect from common GitHub Actions env vars.
814
+ const originPr = detectOriginPrNumber();
815
+ if (originPr) {
816
+ const note = [
817
+ `### 🛡️ Ship Safe Agent — fix PR opened`,
818
+ ``,
819
+ `The Ship Safe agent found fixable security issues triggered by this PR and opened **${prUrl}** with proposed fixes.`,
820
+ ``,
821
+ `**Files changed:** ${applied.length}`,
822
+ `**Total edits:** ${totalFindings}`,
823
+ ``,
824
+ `Review the fix PR and merge if it looks good.`,
825
+ ].join('\n');
826
+ try {
827
+ execFileSync('gh', ['pr', 'comment', String(originPr), '--body', note], { cwd: root, stdio: 'pipe' });
828
+ console.log(chalk.green(` Commented on origin PR #${originPr}`));
829
+ } catch (err) {
830
+ console.log(chalk.yellow(` Could not comment on origin PR #${originPr}: ${err.message}`));
831
+ }
832
+ }
833
+ }
834
+
835
+ // Detect the PR number that triggered this CI run. Supports GitHub Actions'
836
+ // pull_request and pull_request_target events. Returns null when not in CI
837
+ // or when the event isn't a PR event.
838
+ function detectOriginPrNumber() {
839
+ // Explicit override (handy for testing or non-GHA CI providers)
840
+ if (process.env.SHIP_SAFE_ORIGIN_PR) return process.env.SHIP_SAFE_ORIGIN_PR;
841
+
842
+ // GitHub Actions: GITHUB_REF looks like "refs/pull/<n>/merge" or "refs/pull/<n>/head"
843
+ const ref = process.env.GITHUB_REF || '';
844
+ const m = ref.match(/^refs\/pull\/(\d+)\//);
845
+ if (m) return m[1];
846
+
847
+ // GitHub Actions PR event payload also exposes the number via GITHUB_EVENT_PATH
848
+ const eventPath = process.env.GITHUB_EVENT_PATH;
849
+ if (eventPath && fs.existsSync(eventPath)) {
850
+ try {
851
+ const payload = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
852
+ if (payload?.pull_request?.number) return String(payload.pull_request.number);
853
+ if (payload?.number) return String(payload.number);
854
+ } catch { /* malformed event payload */ }
855
+ }
856
+
857
+ return null;
858
+ }
859
+
860
+ // =============================================================================
861
+ // GIT
862
+ // =============================================================================
863
+
864
+ function checkGitState(root) {
865
+ try {
866
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root, stdio: 'pipe' });
867
+ } catch {
868
+ return 'not-a-repo';
869
+ }
870
+ try {
871
+ const out = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();
872
+ const meaningful = out.split('\n').filter(line => {
873
+ const path = line.slice(3).trim();
874
+ if (!path) return false;
875
+ if (path.startsWith('.ship-safe/')) return false;
876
+ if (path === 'ship-safe-report.html') return false;
877
+ return true;
878
+ });
879
+ return meaningful.length === 0 ? 'clean' : 'dirty';
880
+ } catch {
881
+ return 'clean';
882
+ }
883
+ }
884
+
885
+ function getCurrentBranch(root) {
886
+ try {
887
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, stdio: 'pipe' }).toString().trim();
888
+ } catch {
889
+ return null;
890
+ }
891
+ }
892
+
893
+ // =============================================================================
894
+ // PROMPTS
895
+ // =============================================================================
896
+
897
+ function prompt(question) {
898
+ return new Promise(resolve => {
899
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
900
+ rl.question(question, answer => { rl.close(); resolve(answer); });
901
+ });
902
+ }
903
+
904
+ async function confirm(question) {
905
+ const a = (await prompt(`${question} [y/N] `)).trim().toLowerCase();
906
+ return a === 'y' || a === 'yes';
907
+ }
908
+
909
+ // Plan decision prompt with [e]dit support: opens the plan in $EDITOR for the
910
+ // user to tweak, then re-validates. Loops until accept/skip/quit.
911
+ async function promptDecision(plan, root) {
912
+ while (true) {
913
+ const raw = (await prompt(chalk.cyan(' [a]ccept [s]kip [e]dit [q]uit > '))).trim().toLowerCase();
914
+ if (['a', 'accept', 'y', 'yes'].includes(raw)) return 'a';
915
+ if (['s', 'skip', 'n', 'no'].includes(raw)) return 's';
916
+ if (['q', 'quit'].includes(raw)) return 'q';
917
+ if (['e', 'edit'].includes(raw)) {
918
+ const edited = await editPlanInEditor(plan, root);
919
+ if (!edited) {
920
+ console.log(chalk.yellow(' Edit cancelled — keeping original plan.'));
921
+ } else {
922
+ // Mutate plan in place so the caller's reference picks up the changes
923
+ plan.summary = edited.summary;
924
+ plan.files = edited.files;
925
+ plan.risk = edited.risk;
926
+ // Re-validate then re-show
927
+ const validation = validatePlan(root, plan);
928
+ if (!validation.ok) {
929
+ console.log(chalk.red(` Edited plan invalid: ${validation.reason}`));
930
+ console.log(chalk.gray(' Returning to prompt — try editing again, or skip.'));
931
+ continue;
932
+ }
933
+ printPlan(plan, root);
934
+ }
935
+ // Loop back and re-prompt
936
+ continue;
937
+ }
938
+ console.log(chalk.gray(' Unknown choice. Type a, s, e, or q.'));
939
+ }
940
+ }
941
+
942
+ async function editPlanInEditor(plan, root) {
943
+ const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
944
+ const tmpFile = path.join(root, '.ship-safe', `plan-edit-${Date.now()}.json`);
945
+ fs.mkdirSync(path.dirname(tmpFile), { recursive: true });
946
+ // Strip _resolvedFind annotations before showing — they're internal
947
+ const exportable = JSON.parse(JSON.stringify(plan, (k, v) => k === '_resolvedFind' ? undefined : v));
948
+ fs.writeFileSync(tmpFile, JSON.stringify(exportable, null, 2), 'utf8');
949
+
950
+ try {
951
+ execFileSync(editor, [tmpFile], { stdio: 'inherit' });
952
+ const updated = JSON.parse(fs.readFileSync(tmpFile, 'utf8'));
953
+ fs.unlinkSync(tmpFile);
954
+ return updated;
955
+ } catch (err) {
956
+ try { fs.unlinkSync(tmpFile); } catch { /* best effort */ }
957
+ console.log(chalk.red(` Editor failed: ${err.message}`));
958
+ return null;
959
+ }
960
+ }