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.
- package/cli/agents/llm-redteam.js +24 -2
- package/cli/bin/ship-safe.js +55 -9
- package/cli/commands/agent-fix.js +960 -0
- package/cli/commands/audit.js +22 -6
- package/cli/commands/shell.js +506 -0
- package/cli/commands/undo.js +143 -0
- package/cli/providers/llm-provider.js +113 -16
- package/package.json +1 -1
|
@@ -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
|
+
}
|