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
package/cli/commands/audit.js
CHANGED
|
@@ -329,9 +329,10 @@ export async function auditCommand(targetPath = '.', options = {}) {
|
|
|
329
329
|
// ── AI Classification (optional, with LLM cache) ───────────────────────
|
|
330
330
|
if (options.ai !== false) {
|
|
331
331
|
const provider = autoDetectProvider(absolutePath, {
|
|
332
|
-
provider:
|
|
333
|
-
baseUrl:
|
|
334
|
-
model:
|
|
332
|
+
provider: options.provider,
|
|
333
|
+
baseUrl: options.baseUrl,
|
|
334
|
+
model: options.model,
|
|
335
|
+
think: options.think || false,
|
|
335
336
|
});
|
|
336
337
|
if (provider && filteredFindings.length > 0 && filteredFindings.length <= 50) {
|
|
337
338
|
const aiSpinner = machineOutput ? null : ora({ text: `Classifying with ${provider.name}...`, color: 'cyan' }).start();
|
|
@@ -908,6 +909,20 @@ function outputSARIF(findings, rootPath) {
|
|
|
908
909
|
// FILE SCANNING (inline from scan.js to avoid circular deps)
|
|
909
910
|
// =============================================================================
|
|
910
911
|
|
|
912
|
+
// Walk up from `start` looking for `name`. Returns the absolute path or null.
|
|
913
|
+
// Bounded to 8 ancestors to avoid runaway loops on weird filesystems.
|
|
914
|
+
function findUpwards(start, name) {
|
|
915
|
+
let dir = path.resolve(start);
|
|
916
|
+
for (let i = 0; i < 8; i++) {
|
|
917
|
+
const candidate = path.join(dir, name);
|
|
918
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
919
|
+
const parent = path.dirname(dir);
|
|
920
|
+
if (parent === dir) return null;
|
|
921
|
+
dir = parent;
|
|
922
|
+
}
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
|
|
911
926
|
async function findFiles(rootPath) {
|
|
912
927
|
const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
|
|
913
928
|
|
|
@@ -915,9 +930,10 @@ async function findFiles(rootPath) {
|
|
|
915
930
|
const gitignoreGlobs = loadGitignorePatterns(rootPath);
|
|
916
931
|
globIgnore.push(...gitignoreGlobs);
|
|
917
932
|
|
|
918
|
-
// Load .ship-safeignore
|
|
919
|
-
|
|
920
|
-
|
|
933
|
+
// Load .ship-safeignore — walk up to the project root so subdirectory scans
|
|
934
|
+
// still honor the repo-level ignore file.
|
|
935
|
+
const ignorePath = findUpwards(rootPath, '.ship-safeignore');
|
|
936
|
+
if (ignorePath) {
|
|
921
937
|
try {
|
|
922
938
|
const patterns = fs.readFileSync(ignorePath, 'utf-8')
|
|
923
939
|
.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell Command — Interactive REPL
|
|
3
|
+
* =================================
|
|
4
|
+
*
|
|
5
|
+
* `ship-safe shell` drops you into a persistent interactive session.
|
|
6
|
+
*
|
|
7
|
+
* Slash commands:
|
|
8
|
+
* /scan Re-scan the project and show a summary
|
|
9
|
+
* /agent Run the interactive agent fix loop
|
|
10
|
+
* /undo Revert the last fix
|
|
11
|
+
* /findings List findings from the last scan
|
|
12
|
+
* /show <n> Show the full detail of finding number <n>
|
|
13
|
+
* /clear Clear the screen
|
|
14
|
+
* /help List commands
|
|
15
|
+
* /quit Exit the shell
|
|
16
|
+
*
|
|
17
|
+
* Anything else is treated as a free-form prompt to the configured LLM,
|
|
18
|
+
* with the last scan results provided as context.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createInterface } from 'readline';
|
|
22
|
+
import { execFileSync, spawnSync } from 'child_process';
|
|
23
|
+
import { readFileSync as fsReadFileSync } from 'fs';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
import { fileURLToPath } from 'url';
|
|
26
|
+
import chalk from 'chalk';
|
|
27
|
+
import ora from 'ora';
|
|
28
|
+
import { autoDetectProvider } from '../providers/llm-provider.js';
|
|
29
|
+
import { auditCommand } from './audit.js';
|
|
30
|
+
import { agentFixCommand } from './agent-fix.js';
|
|
31
|
+
import { undoCommand } from './undo.js';
|
|
32
|
+
import * as output from '../utils/output.js';
|
|
33
|
+
|
|
34
|
+
const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
35
|
+
|
|
36
|
+
// Read version from package.json so the banner stays in sync with releases.
|
|
37
|
+
const PKG_VERSION = (() => {
|
|
38
|
+
try {
|
|
39
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
const pkg = JSON.parse(fsReadFileSync(path.join(here, '..', '..', 'package.json'), 'utf8'));
|
|
41
|
+
return pkg.version;
|
|
42
|
+
} catch { return ''; }
|
|
43
|
+
})();
|
|
44
|
+
|
|
45
|
+
const BANNER_LINES = [
|
|
46
|
+
' ███████╗██╗ ██╗██╗██████╗ ███████╗ █████╗ ███████╗███████╗',
|
|
47
|
+
' ██╔════╝██║ ██║██║██╔══██╗ ██╔════╝██╔══██╗██╔════╝██╔════╝',
|
|
48
|
+
' ███████╗███████║██║██████╔╝ ███████╗███████║█████╗ █████╗ ',
|
|
49
|
+
' ╚════██║██╔══██║██║██╔═══╝ ╚════██║██╔══██║██╔══╝ ██╔══╝ ',
|
|
50
|
+
' ███████║██║ ██║██║██║ ███████║██║ ██║██║ ███████╗',
|
|
51
|
+
' ╚══════╝╚═╝ ╚═╝╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// Characters drawn from the same box-drawing + block set the banner uses,
|
|
55
|
+
// so random frames look plausibly related rather than pure noise.
|
|
56
|
+
const GLITCH_CHARS = '█▓▒░╔╗╚╝╠╣╦╩╬═║┼┤├┬┴┐└┘┌─│╱╲╳';
|
|
57
|
+
|
|
58
|
+
function glitchFrame(line, reveal) {
|
|
59
|
+
// `reveal` is 0.0..1.0 — characters left of the reveal frontier are real,
|
|
60
|
+
// everything to the right is randomised from the glitch set.
|
|
61
|
+
return line.split('').map((ch, i) => {
|
|
62
|
+
const pos = i / line.length;
|
|
63
|
+
if (pos < reveal || ch === ' ') return ch;
|
|
64
|
+
return GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)];
|
|
65
|
+
}).join('');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sleep(ms) {
|
|
69
|
+
return new Promise(r => setTimeout(r, ms));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function shipSafeBannerAnimated() {
|
|
73
|
+
const cols = process.stdout.columns;
|
|
74
|
+
const narrow = typeof cols === 'number' && cols > 0 && cols < 70;
|
|
75
|
+
if (narrow) {
|
|
76
|
+
console.log(chalk.cyan.bold(' S H I P S A F E'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const lines = BANNER_LINES;
|
|
81
|
+
const FRAMES = 8; // glitch passes before the line locks in
|
|
82
|
+
const FRAME_MS = 18; // ms between frames within a line
|
|
83
|
+
const LINE_MS = 28; // ms between lines starting
|
|
84
|
+
|
|
85
|
+
// Print an empty placeholder so we know exactly where each line lives.
|
|
86
|
+
// We'll overwrite them in-place using cursor-up escape codes.
|
|
87
|
+
process.stdout.write('\n');
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
process.stdout.write(' '.repeat(line.length) + '\n');
|
|
90
|
+
}
|
|
91
|
+
process.stdout.write('\n');
|
|
92
|
+
|
|
93
|
+
for (let li = 0; li < lines.length; li++) {
|
|
94
|
+
const line = lines[li];
|
|
95
|
+
// Move cursor up to this line's row (from the blank line after the banner).
|
|
96
|
+
const stepsUp = lines.length - li + 1;
|
|
97
|
+
process.stdout.write(`\x1B[${stepsUp}A`); // cursor up N
|
|
98
|
+
|
|
99
|
+
// Animate: reveal marches from 0 → 1 across FRAMES frames.
|
|
100
|
+
for (let f = 0; f <= FRAMES; f++) {
|
|
101
|
+
const reveal = f / FRAMES;
|
|
102
|
+
const scrambled = glitchFrame(line, reveal);
|
|
103
|
+
process.stdout.write('\r' + chalk.cyan(scrambled));
|
|
104
|
+
if (f < FRAMES) await sleep(FRAME_MS);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Write the final clean line and move back down to the bottom blank line.
|
|
108
|
+
process.stdout.write('\r' + chalk.cyan(line));
|
|
109
|
+
process.stdout.write(`\x1B[${stepsUp}B`); // cursor down N
|
|
110
|
+
await sleep(LINE_MS);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function shellCommand(targetPath = '.', options = {}) {
|
|
115
|
+
const root = path.resolve(targetPath);
|
|
116
|
+
|
|
117
|
+
// Session state — persists across commands within this REPL
|
|
118
|
+
const state = {
|
|
119
|
+
root,
|
|
120
|
+
provider: null,
|
|
121
|
+
lastScan: null,
|
|
122
|
+
history: [], // [{ role: 'user'|'assistant', content }]
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Try to load a provider eagerly (non-fatal if none available)
|
|
126
|
+
state.provider = autoDetectProvider(root, {
|
|
127
|
+
provider: options.provider,
|
|
128
|
+
model: options.model,
|
|
129
|
+
think: options.think || false,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Branded entry: big wordmark, version + provider + cwd, then a CTA hint.
|
|
133
|
+
// Modeled on Claude Code / Gemini CLI / Aider — establish the tool's identity
|
|
134
|
+
// in the first second, then get out of the way.
|
|
135
|
+
console.log();
|
|
136
|
+
await shipSafeBannerAnimated();
|
|
137
|
+
// trailing blank line is already written by the animation loop
|
|
138
|
+
const ver = PKG_VERSION ? `v${PKG_VERSION}` : '';
|
|
139
|
+
const prov = state.provider ? chalk.cyan(state.provider.name) : chalk.yellow('no provider');
|
|
140
|
+
const cwd = chalk.gray(prettyCwd(root));
|
|
141
|
+
console.log(` ${chalk.bold(ver)} ${chalk.gray('·')} ${prov} ${chalk.gray('·')} ${cwd}`);
|
|
142
|
+
console.log();
|
|
143
|
+
console.log(chalk.gray(' /scan to find issues · /agent to fix them · /help for more'));
|
|
144
|
+
console.log();
|
|
145
|
+
|
|
146
|
+
const rl = createInterface({
|
|
147
|
+
input: process.stdin,
|
|
148
|
+
output: process.stdout,
|
|
149
|
+
terminal: true,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const ask = (prompt) => new Promise(resolve => rl.question(prompt, resolve));
|
|
153
|
+
|
|
154
|
+
// Graceful Ctrl-D
|
|
155
|
+
rl.on('close', () => {
|
|
156
|
+
console.log();
|
|
157
|
+
process.exit(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
let running = true;
|
|
161
|
+
while (running) {
|
|
162
|
+
const line = (await ask(chalk.cyan('shipsafe › '))).trim();
|
|
163
|
+
if (!line) continue;
|
|
164
|
+
|
|
165
|
+
if (line.startsWith('/')) {
|
|
166
|
+
running = await handleSlashCommand(line, state, options);
|
|
167
|
+
} else {
|
|
168
|
+
await handlePrompt(line, state);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
rl.close();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// =============================================================================
|
|
176
|
+
// SLASH COMMANDS
|
|
177
|
+
// =============================================================================
|
|
178
|
+
|
|
179
|
+
async function handleSlashCommand(line, state, options) {
|
|
180
|
+
const [raw, ...args] = line.slice(1).split(/\s+/);
|
|
181
|
+
const cmd = raw.toLowerCase();
|
|
182
|
+
|
|
183
|
+
switch (cmd) {
|
|
184
|
+
case 'help':
|
|
185
|
+
case '?':
|
|
186
|
+
printHelp();
|
|
187
|
+
return true;
|
|
188
|
+
|
|
189
|
+
case 'quit':
|
|
190
|
+
case 'exit':
|
|
191
|
+
case 'bye':
|
|
192
|
+
console.log(chalk.gray(' Bye.'));
|
|
193
|
+
return false;
|
|
194
|
+
|
|
195
|
+
case 'clear':
|
|
196
|
+
process.stdout.write('\x1Bc');
|
|
197
|
+
return true;
|
|
198
|
+
|
|
199
|
+
case 'scan':
|
|
200
|
+
case 'rescan': {
|
|
201
|
+
const spinner = ora({ text: 'Scanning...', color: 'cyan' }).start();
|
|
202
|
+
try {
|
|
203
|
+
const result = await auditCommand(state.root, { _agenticInner: true, deep: false, deps: false, noAi: true });
|
|
204
|
+
state.lastScan = result;
|
|
205
|
+
spinner.stop();
|
|
206
|
+
printScanSummary(result);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
spinner.fail(err.message);
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case 'diff': {
|
|
214
|
+
// Show working-tree diff so the user can review changes from /agent
|
|
215
|
+
try {
|
|
216
|
+
const out = execFileSync('git', ['diff', '--no-color', ...args], { cwd: state.root, stdio: ['ignore', 'pipe', 'pipe'] }).toString();
|
|
217
|
+
if (!out.trim()) {
|
|
218
|
+
console.log(chalk.gray(' No changes.'));
|
|
219
|
+
} else {
|
|
220
|
+
console.log();
|
|
221
|
+
console.log(out);
|
|
222
|
+
}
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.log(chalk.red(` git diff failed: ${err.message}`));
|
|
225
|
+
}
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
case 'git': {
|
|
230
|
+
// Pass through to git so the user can poke around (status, log, stash, etc.)
|
|
231
|
+
// without leaving the shell. Inherit stdio so paged commands work.
|
|
232
|
+
const result = spawnSync('git', args, { cwd: state.root, stdio: 'inherit' });
|
|
233
|
+
if (result.error) console.log(chalk.red(` ${result.error.message}`));
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
case 'plan': {
|
|
238
|
+
// Preview a fix plan for ONE finding without applying.
|
|
239
|
+
// Usage: /plan <n> (1-based, from /findings)
|
|
240
|
+
if (!state.lastScan) {
|
|
241
|
+
console.log(chalk.yellow(' No scan results yet. Run /scan first.'));
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
const findings = state.lastScan.findings ?? [];
|
|
245
|
+
const n = parseInt(args[0], 10);
|
|
246
|
+
if (!Number.isInteger(n) || n < 1 || n > findings.length) {
|
|
247
|
+
console.log(chalk.yellow(` Usage: /plan <n> (1..${findings.length})`));
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
const f = findings[n - 1];
|
|
251
|
+
if (!f.file) {
|
|
252
|
+
console.log(chalk.yellow(' Finding has no file path — cannot plan.'));
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
// Delegate to agent in plan-only mode, scoped narrowly.
|
|
256
|
+
// Building a one-finding workflow inline duplicates a lot of agent-fix logic;
|
|
257
|
+
// run the full agent restricted to this file's directory + plan-only.
|
|
258
|
+
const dir = path.dirname(path.resolve(state.root, f.file));
|
|
259
|
+
console.log(chalk.gray(` Generating plan for finding ${n} in ${f.file}...`));
|
|
260
|
+
try {
|
|
261
|
+
await agentFixCommand(dir, { ...options, planOnly: true, allowDirty: true, severity: f.severity || 'low' });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
console.log(chalk.red(` Plan failed: ${err.message}`));
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
case 'findings': {
|
|
269
|
+
if (!state.lastScan) {
|
|
270
|
+
console.log(chalk.yellow(' No scan results yet. Run /scan first.'));
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
printFindingsList(state.lastScan.findings ?? []);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
case 'show': {
|
|
278
|
+
if (!state.lastScan) {
|
|
279
|
+
console.log(chalk.yellow(' No scan results yet. Run /scan first.'));
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
const n = parseInt(args[0], 10);
|
|
283
|
+
const findings = state.lastScan.findings ?? [];
|
|
284
|
+
if (!Number.isInteger(n) || n < 1 || n > findings.length) {
|
|
285
|
+
console.log(chalk.yellow(` Usage: /show <n> (1..${findings.length})`));
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
printFindingDetail(findings[n - 1], n);
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
case 'agent':
|
|
293
|
+
case 'fix': {
|
|
294
|
+
// Hand off to agent command. Pass through caller options + any inline flags.
|
|
295
|
+
const opts = { ...options };
|
|
296
|
+
for (const a of args) {
|
|
297
|
+
if (a === '--plan-only') opts.planOnly = true;
|
|
298
|
+
if (a === '--allow-dirty') opts.allowDirty = true;
|
|
299
|
+
if (a === '--branch') opts.branch = true;
|
|
300
|
+
if (a === '--pr') opts.pr = true;
|
|
301
|
+
if (a.startsWith('--severity=')) opts.severity = a.slice('--severity='.length);
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
await agentFixCommand(state.root, opts);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
console.log(chalk.red(` Agent failed: ${err.message}`));
|
|
307
|
+
}
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
case 'undo': {
|
|
312
|
+
try {
|
|
313
|
+
await undoCommand(state.root, { all: args.includes('--all'), dryRun: args.includes('--dry-run') });
|
|
314
|
+
} catch (err) {
|
|
315
|
+
console.log(chalk.red(` Undo failed: ${err.message}`));
|
|
316
|
+
}
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
case 'provider': {
|
|
321
|
+
const name = args[0];
|
|
322
|
+
if (!name) {
|
|
323
|
+
console.log(chalk.gray(` Current: ${state.provider?.name ?? 'none'}`));
|
|
324
|
+
console.log(chalk.gray(' Usage: /provider <deepseek-flash|openai|kimi|anthropic|deepseek>'));
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
const next = autoDetectProvider(state.root, { provider: name });
|
|
328
|
+
if (!next) {
|
|
329
|
+
console.log(chalk.yellow(` Could not load provider "${name}" — is the API key set?`));
|
|
330
|
+
} else {
|
|
331
|
+
state.provider = next;
|
|
332
|
+
console.log(chalk.green(` Provider switched to ${next.name}.`));
|
|
333
|
+
}
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
default:
|
|
338
|
+
console.log(chalk.yellow(` Unknown command: /${cmd}. Type /help for the list.`));
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// =============================================================================
|
|
344
|
+
// FREE-FORM PROMPT
|
|
345
|
+
// =============================================================================
|
|
346
|
+
|
|
347
|
+
async function handlePrompt(text, state) {
|
|
348
|
+
if (!state.provider) {
|
|
349
|
+
console.log(chalk.yellow(' No LLM provider available. Set DEEPSEEK_API_KEY (or another supported key) and restart.'));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
state.history.push({ role: 'user', content: text });
|
|
354
|
+
|
|
355
|
+
const systemPrompt = buildSystemPrompt(state);
|
|
356
|
+
const userPrompt = buildConversationPrompt(state);
|
|
357
|
+
|
|
358
|
+
// Stream tokens as they arrive so the REPL feels alive.
|
|
359
|
+
// Falls back transparently to one-shot complete() for providers without
|
|
360
|
+
// real streaming (the base class default yields the whole response).
|
|
361
|
+
process.stdout.write('\n ');
|
|
362
|
+
let collected = '';
|
|
363
|
+
try {
|
|
364
|
+
for await (const chunk of state.provider.stream(systemPrompt, userPrompt, { maxTokens: 1500 })) {
|
|
365
|
+
collected += chunk;
|
|
366
|
+
// Indent any new lines that appear inside a streamed chunk
|
|
367
|
+
process.stdout.write(chalk.white(chunk.replace(/\n/g, '\n ')));
|
|
368
|
+
}
|
|
369
|
+
process.stdout.write('\n\n');
|
|
370
|
+
state.history.push({ role: 'assistant', content: collected.trim() });
|
|
371
|
+
} catch (err) {
|
|
372
|
+
process.stdout.write('\n');
|
|
373
|
+
console.log(chalk.red(` Provider call failed: ${err.message}`));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function buildSystemPrompt(state) {
|
|
378
|
+
const scanSummary = state.lastScan
|
|
379
|
+
? `Latest scan: score ${state.lastScan.score ?? '?'}/100, ${state.lastScan.findings?.length ?? 0} finding(s).`
|
|
380
|
+
: 'No scan has been run yet in this session.';
|
|
381
|
+
|
|
382
|
+
return [
|
|
383
|
+
'You are the Ship Safe security agent embedded in a developer\'s CLI.',
|
|
384
|
+
'You give precise, security-focused answers. Prefer concrete fixes and references over abstract advice.',
|
|
385
|
+
`Project root: ${state.root}`,
|
|
386
|
+
scanSummary,
|
|
387
|
+
'When findings are referenced, cite them by index (1-based, in the order shown by /findings).',
|
|
388
|
+
].join('\n');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function buildConversationPrompt(state) {
|
|
392
|
+
// Keep last ~10 turns to bound context size
|
|
393
|
+
const recent = state.history.slice(-10);
|
|
394
|
+
|
|
395
|
+
let context = '';
|
|
396
|
+
if (state.lastScan?.findings?.length) {
|
|
397
|
+
const findings = state.lastScan.findings.slice(0, 25).map((f, i) =>
|
|
398
|
+
`${i + 1}. [${f.severity}] ${f.title}${f.file ? ` — ${f.file}${f.line ? `:${f.line}` : ''}` : ''}`,
|
|
399
|
+
).join('\n');
|
|
400
|
+
context = `\nKnown findings:\n${findings}\n\n`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const turns = recent.map(t => `${t.role === 'user' ? 'USER' : 'ASSISTANT'}: ${t.content}`).join('\n\n');
|
|
404
|
+
return `${context}${turns}\n\nASSISTANT:`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function formatAssistant(text) {
|
|
408
|
+
return text
|
|
409
|
+
.split('\n')
|
|
410
|
+
.map(line => chalk.white(` ${line}`))
|
|
411
|
+
.join('\n');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// =============================================================================
|
|
415
|
+
// PRINTING
|
|
416
|
+
// =============================================================================
|
|
417
|
+
|
|
418
|
+
function printHelp() {
|
|
419
|
+
console.log();
|
|
420
|
+
console.log(chalk.bold(' Commands:'));
|
|
421
|
+
console.log(' /scan, /rescan Re-scan the project');
|
|
422
|
+
console.log(' /findings List the latest scan\'s findings');
|
|
423
|
+
console.log(' /show <n> Show full detail of finding <n>');
|
|
424
|
+
console.log(' /plan <n> Preview a fix plan for finding <n> (no writes)');
|
|
425
|
+
console.log(' /agent [--plan-only] Run the interactive fix loop');
|
|
426
|
+
console.log(' /undo [--all] Revert the last fix (or all)');
|
|
427
|
+
console.log(' /diff [path] Show git working-tree diff');
|
|
428
|
+
console.log(' /git <args> Pass through to git (status, log, stash, ...)');
|
|
429
|
+
console.log(' /provider <name> Switch LLM provider');
|
|
430
|
+
console.log(' /clear Clear the screen');
|
|
431
|
+
console.log(' /quit Exit');
|
|
432
|
+
console.log();
|
|
433
|
+
console.log(chalk.gray(' Or just type a question — the agent will answer with scan context.'));
|
|
434
|
+
console.log();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function printScanSummary(result) {
|
|
438
|
+
const findings = result.findings ?? [];
|
|
439
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
440
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity] ?? 0) + 1;
|
|
441
|
+
|
|
442
|
+
console.log();
|
|
443
|
+
console.log(chalk.bold(` Score: ${gradeColor(result.score)(`${result.score ?? '?'}/100`)}`));
|
|
444
|
+
console.log(` Findings: ${chalk.red(counts.critical || 0)} critical, ${chalk.red(counts.high || 0)} high, ${chalk.yellow(counts.medium || 0)} medium, ${chalk.blue(counts.low || 0)} low`);
|
|
445
|
+
console.log();
|
|
446
|
+
console.log(chalk.gray(' /findings to list, /agent to fix.'));
|
|
447
|
+
console.log();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function printFindingsList(findings) {
|
|
451
|
+
if (findings.length === 0) {
|
|
452
|
+
console.log(chalk.green(' No findings.'));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
// Sort by severity desc
|
|
456
|
+
const sorted = [...findings].sort((a, b) => (SEV_RANK[b.severity] ?? 0) - (SEV_RANK[a.severity] ?? 0));
|
|
457
|
+
console.log();
|
|
458
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
459
|
+
const f = sorted[i];
|
|
460
|
+
console.log(` ${chalk.gray(`${i + 1}.`.padStart(4))} ${sevTag(f.severity)} ${f.title} ${chalk.gray(f.file ?? '')}${f.line ? chalk.gray(`:${f.line}`) : ''}`);
|
|
461
|
+
}
|
|
462
|
+
console.log();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function printFindingDetail(f, n) {
|
|
466
|
+
console.log();
|
|
467
|
+
console.log(chalk.bold(` Finding ${n}: ${f.title}`));
|
|
468
|
+
console.log(` Severity: ${sevTag(f.severity)}`);
|
|
469
|
+
if (f.file) console.log(` File: ${f.file}${f.line ? `:${f.line}` : ''}`);
|
|
470
|
+
if (f.rule) console.log(` Rule: ${f.rule}`);
|
|
471
|
+
if (f.cwe) console.log(` CWE: ${f.cwe}`);
|
|
472
|
+
if (f.description) {
|
|
473
|
+
console.log();
|
|
474
|
+
console.log(chalk.gray(' Description:'));
|
|
475
|
+
console.log(` ${f.description}`);
|
|
476
|
+
}
|
|
477
|
+
if (f.fix) {
|
|
478
|
+
console.log();
|
|
479
|
+
console.log(chalk.gray(' Suggested fix:'));
|
|
480
|
+
console.log(` ${f.fix}`);
|
|
481
|
+
}
|
|
482
|
+
console.log();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function sevTag(sev) {
|
|
486
|
+
switch (sev) {
|
|
487
|
+
case 'critical': return chalk.red.bold('[CRITICAL]');
|
|
488
|
+
case 'high': return chalk.red('[HIGH]');
|
|
489
|
+
case 'medium': return chalk.yellow('[MEDIUM]');
|
|
490
|
+
case 'low': return chalk.blue('[LOW]');
|
|
491
|
+
default: return chalk.gray(`[${(sev || 'INFO').toUpperCase()}]`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function prettyCwd(p) {
|
|
496
|
+
const home = process.env.HOME || '';
|
|
497
|
+
if (home && p.startsWith(home + '/')) return '~' + p.slice(home.length);
|
|
498
|
+
return p;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function gradeColor(score) {
|
|
502
|
+
if (score == null) return chalk.gray;
|
|
503
|
+
if (score >= 80) return chalk.green;
|
|
504
|
+
if (score >= 60) return chalk.yellow;
|
|
505
|
+
return chalk.red;
|
|
506
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Undo Command
|
|
3
|
+
* ============
|
|
4
|
+
*
|
|
5
|
+
* Reverts changes applied by `ship-safe agent`.
|
|
6
|
+
*
|
|
7
|
+
* Reads .ship-safe/fixes.jsonl, takes the most recent entry (or all entries
|
|
8
|
+
* with --all), and reverses each edit. Per-fix git commits made by the agent
|
|
9
|
+
* are preferred over manual reversal when available.
|
|
10
|
+
*
|
|
11
|
+
* USAGE:
|
|
12
|
+
* ship-safe undo Revert the last applied fix
|
|
13
|
+
* ship-safe undo --all Revert every fix in the log
|
|
14
|
+
* ship-safe undo --dry-run Show what would be reverted, but don't write
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { execFileSync } from 'child_process';
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
import * as output from '../utils/output.js';
|
|
22
|
+
|
|
23
|
+
const FIX_LOG_PATH = '.ship-safe/fixes.jsonl';
|
|
24
|
+
|
|
25
|
+
export async function undoCommand(targetPath = '.', options = {}) {
|
|
26
|
+
const root = path.resolve(targetPath);
|
|
27
|
+
const logPath = path.join(root, FIX_LOG_PATH);
|
|
28
|
+
|
|
29
|
+
if (!fs.existsSync(logPath)) {
|
|
30
|
+
output.error(`No fix log found at ${FIX_LOG_PATH}`);
|
|
31
|
+
console.log(chalk.gray(' Run `ship-safe agent` first to apply fixes.'));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const entries = fs.readFileSync(logPath, 'utf8')
|
|
36
|
+
.split('\n')
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map(line => { try { return JSON.parse(line); } catch { return null; } })
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
|
|
41
|
+
if (entries.length === 0) {
|
|
42
|
+
output.error('Fix log is empty.');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const toUndo = options.all ? [...entries].reverse() : [entries[entries.length - 1]];
|
|
47
|
+
|
|
48
|
+
console.log();
|
|
49
|
+
output.header('Ship Safe — Undo');
|
|
50
|
+
console.log();
|
|
51
|
+
console.log(chalk.gray(` Reverting ${toUndo.length} fix(es) from ${FIX_LOG_PATH}`));
|
|
52
|
+
console.log();
|
|
53
|
+
|
|
54
|
+
let reverted = 0;
|
|
55
|
+
let failed = 0;
|
|
56
|
+
|
|
57
|
+
for (const entry of toUndo) {
|
|
58
|
+
const file = entry.file || entry.finding?.file || '(unknown)';
|
|
59
|
+
console.log(chalk.bold(` ${chalk.cyan(file)}`));
|
|
60
|
+
|
|
61
|
+
if (options.dryRun) {
|
|
62
|
+
console.log(chalk.gray(` Would reverse plan: ${entry.plan?.summary || 'no summary'}`));
|
|
63
|
+
reverted++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
reverseEntry(root, entry);
|
|
69
|
+
console.log(chalk.green(' Reverted.'));
|
|
70
|
+
reverted++;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.log(chalk.red(` Failed: ${err.message}`));
|
|
73
|
+
failed++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Truncate the log
|
|
78
|
+
if (!options.dryRun && reverted > 0) {
|
|
79
|
+
const remaining = options.all ? [] : entries.slice(0, -1);
|
|
80
|
+
if (remaining.length === 0) {
|
|
81
|
+
fs.unlinkSync(logPath);
|
|
82
|
+
} else {
|
|
83
|
+
fs.writeFileSync(logPath, remaining.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf8');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log();
|
|
88
|
+
console.log(chalk.green(` Reverted: ${reverted}`));
|
|
89
|
+
if (failed > 0) console.log(chalk.red(` Failed: ${failed}`));
|
|
90
|
+
console.log();
|
|
91
|
+
|
|
92
|
+
if (failed > 0) {
|
|
93
|
+
console.log(chalk.gray(' For failed entries, try `git checkout` or `git reset --hard` if you committed via --branch.'));
|
|
94
|
+
console.log();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function reverseEntry(root, entry) {
|
|
99
|
+
const plan = entry.plan;
|
|
100
|
+
if (!plan || !Array.isArray(plan.files) || plan.files.length === 0) {
|
|
101
|
+
throw new Error('entry has no plan to reverse');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const fileChange of plan.files) {
|
|
105
|
+
const abs = path.resolve(root, fileChange.path);
|
|
106
|
+
|
|
107
|
+
if (fileChange.create) {
|
|
108
|
+
// We created the file — delete it
|
|
109
|
+
if (fs.existsSync(abs)) fs.unlinkSync(abs);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (fileChange.append !== undefined) {
|
|
114
|
+
if (!fs.existsSync(abs)) continue;
|
|
115
|
+
const current = fs.readFileSync(abs, 'utf8');
|
|
116
|
+
// Try to remove the appended text (it may be at the end)
|
|
117
|
+
const idx = current.lastIndexOf(fileChange.append);
|
|
118
|
+
if (idx === -1) {
|
|
119
|
+
throw new Error(`appended text not found in ${fileChange.path}`);
|
|
120
|
+
}
|
|
121
|
+
const reverted = current.slice(0, idx) + current.slice(idx + fileChange.append.length);
|
|
122
|
+
fs.writeFileSync(abs, reverted, 'utf8');
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Standard edits — reverse find/replace
|
|
127
|
+
if (!fs.existsSync(abs)) {
|
|
128
|
+
throw new Error(`file no longer exists: ${fileChange.path}`);
|
|
129
|
+
}
|
|
130
|
+
let content = fs.readFileSync(abs, 'utf8');
|
|
131
|
+
// Reverse in opposite order in case edits are positionally dependent
|
|
132
|
+
const reversed = [...fileChange.edits].reverse();
|
|
133
|
+
for (const e of reversed) {
|
|
134
|
+
const newStr = e.replace;
|
|
135
|
+
const oldStr = e._resolvedFind || e.find;
|
|
136
|
+
if (!content.includes(newStr)) {
|
|
137
|
+
throw new Error(`reverted text not found in ${fileChange.path} (file changed since fix)`);
|
|
138
|
+
}
|
|
139
|
+
content = content.replace(newStr, oldStr);
|
|
140
|
+
}
|
|
141
|
+
fs.writeFileSync(abs, content, 'utf8');
|
|
142
|
+
}
|
|
143
|
+
}
|