ship-safe 9.1.1 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,7 @@ import {
35
35
  loadGitignorePatterns
36
36
  } from '../utils/patterns.js';
37
37
  import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
38
+ import { printBanner } from '../utils/output.js';
38
39
  import { CacheManager } from '../utils/cache-manager.js';
39
40
  import { filterBaseline } from './baseline.js';
40
41
  import { SecurityMemory } from '../utils/security-memory.js';
@@ -89,11 +90,7 @@ export async function auditCommand(targetPath = '.', options = {}) {
89
90
  }
90
91
 
91
92
  if (!machineOutput) {
92
- console.log();
93
- console.log(chalk.cyan('═'.repeat(60)));
94
- console.log(chalk.cyan.bold(' Ship Safe — Full Security Audit'));
95
- console.log(chalk.cyan('═'.repeat(60)));
96
- console.log();
93
+ printBanner();
97
94
  }
98
95
 
99
96
  // ── Cache Layer ──────────────────────────────────────────────────────────
@@ -332,9 +329,10 @@ export async function auditCommand(targetPath = '.', options = {}) {
332
329
  // ── AI Classification (optional, with LLM cache) ───────────────────────
333
330
  if (options.ai !== false) {
334
331
  const provider = autoDetectProvider(absolutePath, {
335
- provider: options.provider,
336
- baseUrl: options.baseUrl,
337
- model: options.model,
332
+ provider: options.provider,
333
+ baseUrl: options.baseUrl,
334
+ model: options.model,
335
+ think: options.think || false,
338
336
  });
339
337
  if (provider && filteredFindings.length > 0 && filteredFindings.length <= 50) {
340
338
  const aiSpinner = machineOutput ? null : ora({ text: `Classifying with ${provider.name}...`, color: 'cyan' }).start();
@@ -911,6 +909,20 @@ function outputSARIF(findings, rootPath) {
911
909
  // FILE SCANNING (inline from scan.js to avoid circular deps)
912
910
  // =============================================================================
913
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
+
914
926
  async function findFiles(rootPath) {
915
927
  const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
916
928
 
@@ -918,9 +930,10 @@ async function findFiles(rootPath) {
918
930
  const gitignoreGlobs = loadGitignorePatterns(rootPath);
919
931
  globIgnore.push(...gitignoreGlobs);
920
932
 
921
- // Load .ship-safeignore
922
- const ignorePath = path.join(rootPath, '.ship-safeignore');
923
- if (fs.existsSync(ignorePath)) {
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) {
924
937
  try {
925
938
  const patterns = fs.readFileSync(ignorePath, 'utf-8')
926
939
  .split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
@@ -27,6 +27,7 @@ import { SBOMGenerator } from '../agents/sbom-generator.js';
27
27
  import { autoDetectProvider } from '../providers/llm-provider.js';
28
28
  import { runDepsAudit } from './deps.js';
29
29
  import * as output from '../utils/output.js';
30
+ import { printBanner } from '../utils/output.js';
30
31
 
31
32
  export async function redTeamCommand(targetPath = '.', options = {}) {
32
33
  const absolutePath = path.resolve(targetPath);
@@ -42,20 +43,21 @@ export async function redTeamCommand(targetPath = '.', options = {}) {
42
43
  let recon = {};
43
44
  let agentResults = [];
44
45
 
45
- // ── 1a. Swarm mode (Kimi K2.6 native parallel execution) ─────────────────
46
+ // ── 1a. Swarm mode (parallel execution via best available provider) ────────
46
47
  if (options.swarm) {
47
- output.header('Ship Safe — Kimi K2.6 Swarm Mode');
48
+ printBanner();
49
+ output.header('AI Swarm Mode');
48
50
  console.log();
49
51
 
50
52
  const swarm = SwarmOrchestrator.create(absolutePath, {
51
- provider: options.provider || 'kimi',
53
+ provider: options.provider,
52
54
  model: options.model,
53
55
  verbose: options.verbose,
54
56
  budgetCents: options.budget || 200,
55
57
  });
56
58
 
57
59
  if (!swarm) {
58
- output.error('Swarm mode requires MOONSHOT_API_KEY (Kimi K2.6). Set it and retry.');
60
+ output.error('Swarm mode requires DEEPSEEK_API_KEY or MOONSHOT_API_KEY. Set one and retry.');
59
61
  process.exit(1);
60
62
  }
61
63
 
@@ -66,7 +68,8 @@ export async function redTeamCommand(targetPath = '.', options = {}) {
66
68
  const files = await reconAgent.discoverFiles(absolutePath);
67
69
  reconSpinner.succeed(chalk.green('Attack surface mapped'));
68
70
 
69
- const swarmSpinner = ora({ text: `Deploying ${chalk.cyan('23 swarm agents')} via Kimi K2.6...`, color: 'cyan' }).start();
71
+ const providerLabel = swarm.provider?.name || 'AI';
72
+ const swarmSpinner = ora({ text: `Deploying ${chalk.cyan('23 swarm agents')} via ${providerLabel}...`, color: 'cyan' }).start();
70
73
  try {
71
74
  findings = await swarm.run(absolutePath, recon, files);
72
75
  swarmSpinner.succeed(chalk.green(`Swarm complete — ${findings.length} finding(s)`));
@@ -79,7 +82,8 @@ export async function redTeamCommand(targetPath = '.', options = {}) {
79
82
 
80
83
  } else {
81
84
  // ── 1b. Standard local orchestration ───────────────────────────────────
82
- output.header('Ship Safe v4.0 — Multi-Agent Security Audit');
85
+ printBanner();
86
+ output.header('Multi-Agent Security Audit');
83
87
  console.log();
84
88
 
85
89
  const orchestrator = await buildOrchestratorAsync(absolutePath, { quiet: true });
@@ -0,0 +1,415 @@
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 path from 'path';
24
+ import chalk from 'chalk';
25
+ import ora from 'ora';
26
+ import { autoDetectProvider } from '../providers/llm-provider.js';
27
+ import { auditCommand } from './audit.js';
28
+ import { agentFixCommand } from './agent-fix.js';
29
+ import { undoCommand } from './undo.js';
30
+ import * as output from '../utils/output.js';
31
+
32
+ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
33
+
34
+ export async function shellCommand(targetPath = '.', options = {}) {
35
+ const root = path.resolve(targetPath);
36
+
37
+ // Session state — persists across commands within this REPL
38
+ const state = {
39
+ root,
40
+ provider: null,
41
+ lastScan: null,
42
+ history: [], // [{ role: 'user'|'assistant', content }]
43
+ };
44
+
45
+ // Try to load a provider eagerly (non-fatal if none available)
46
+ state.provider = autoDetectProvider(root, {
47
+ provider: options.provider,
48
+ model: options.model,
49
+ think: options.think || false,
50
+ });
51
+
52
+ console.log();
53
+ output.header('Ship Safe — Interactive Shell');
54
+ console.log(chalk.gray(` cwd: ${root}`));
55
+ console.log(chalk.gray(` provider: ${state.provider ? chalk.cyan(state.provider.name) : chalk.yellow('none — set DEEPSEEK_API_KEY or similar')}`));
56
+ console.log();
57
+ console.log(chalk.gray(' Type /help for commands, anything else to ask the agent.'));
58
+ console.log(chalk.gray(' /quit or Ctrl-D to exit.'));
59
+ console.log();
60
+
61
+ const rl = createInterface({
62
+ input: process.stdin,
63
+ output: process.stdout,
64
+ terminal: true,
65
+ });
66
+
67
+ const ask = (prompt) => new Promise(resolve => rl.question(prompt, resolve));
68
+
69
+ // Graceful Ctrl-D
70
+ rl.on('close', () => {
71
+ console.log();
72
+ process.exit(0);
73
+ });
74
+
75
+ let running = true;
76
+ while (running) {
77
+ const line = (await ask(chalk.cyan('shipsafe › '))).trim();
78
+ if (!line) continue;
79
+
80
+ if (line.startsWith('/')) {
81
+ running = await handleSlashCommand(line, state, options);
82
+ } else {
83
+ await handlePrompt(line, state);
84
+ }
85
+ }
86
+
87
+ rl.close();
88
+ }
89
+
90
+ // =============================================================================
91
+ // SLASH COMMANDS
92
+ // =============================================================================
93
+
94
+ async function handleSlashCommand(line, state, options) {
95
+ const [raw, ...args] = line.slice(1).split(/\s+/);
96
+ const cmd = raw.toLowerCase();
97
+
98
+ switch (cmd) {
99
+ case 'help':
100
+ case '?':
101
+ printHelp();
102
+ return true;
103
+
104
+ case 'quit':
105
+ case 'exit':
106
+ case 'bye':
107
+ console.log(chalk.gray(' Bye.'));
108
+ return false;
109
+
110
+ case 'clear':
111
+ process.stdout.write('\x1Bc');
112
+ return true;
113
+
114
+ case 'scan':
115
+ case 'rescan': {
116
+ const spinner = ora({ text: 'Scanning...', color: 'cyan' }).start();
117
+ try {
118
+ const result = await auditCommand(state.root, { _agenticInner: true, deep: false, deps: false, noAi: true });
119
+ state.lastScan = result;
120
+ spinner.stop();
121
+ printScanSummary(result);
122
+ } catch (err) {
123
+ spinner.fail(err.message);
124
+ }
125
+ return true;
126
+ }
127
+
128
+ case 'diff': {
129
+ // Show working-tree diff so the user can review changes from /agent
130
+ try {
131
+ const out = execFileSync('git', ['diff', '--no-color', ...args], { cwd: state.root, stdio: ['ignore', 'pipe', 'pipe'] }).toString();
132
+ if (!out.trim()) {
133
+ console.log(chalk.gray(' No changes.'));
134
+ } else {
135
+ console.log();
136
+ console.log(out);
137
+ }
138
+ } catch (err) {
139
+ console.log(chalk.red(` git diff failed: ${err.message}`));
140
+ }
141
+ return true;
142
+ }
143
+
144
+ case 'git': {
145
+ // Pass through to git so the user can poke around (status, log, stash, etc.)
146
+ // without leaving the shell. Inherit stdio so paged commands work.
147
+ const result = spawnSync('git', args, { cwd: state.root, stdio: 'inherit' });
148
+ if (result.error) console.log(chalk.red(` ${result.error.message}`));
149
+ return true;
150
+ }
151
+
152
+ case 'plan': {
153
+ // Preview a fix plan for ONE finding without applying.
154
+ // Usage: /plan <n> (1-based, from /findings)
155
+ if (!state.lastScan) {
156
+ console.log(chalk.yellow(' No scan results yet. Run /scan first.'));
157
+ return true;
158
+ }
159
+ const findings = state.lastScan.findings ?? [];
160
+ const n = parseInt(args[0], 10);
161
+ if (!Number.isInteger(n) || n < 1 || n > findings.length) {
162
+ console.log(chalk.yellow(` Usage: /plan <n> (1..${findings.length})`));
163
+ return true;
164
+ }
165
+ const f = findings[n - 1];
166
+ if (!f.file) {
167
+ console.log(chalk.yellow(' Finding has no file path — cannot plan.'));
168
+ return true;
169
+ }
170
+ // Delegate to agent in plan-only mode, scoped narrowly.
171
+ // Building a one-finding workflow inline duplicates a lot of agent-fix logic;
172
+ // run the full agent restricted to this file's directory + plan-only.
173
+ const dir = path.dirname(path.resolve(state.root, f.file));
174
+ console.log(chalk.gray(` Generating plan for finding ${n} in ${f.file}...`));
175
+ try {
176
+ await agentFixCommand(dir, { ...options, planOnly: true, allowDirty: true, severity: f.severity || 'low' });
177
+ } catch (err) {
178
+ console.log(chalk.red(` Plan failed: ${err.message}`));
179
+ }
180
+ return true;
181
+ }
182
+
183
+ case 'findings': {
184
+ if (!state.lastScan) {
185
+ console.log(chalk.yellow(' No scan results yet. Run /scan first.'));
186
+ return true;
187
+ }
188
+ printFindingsList(state.lastScan.findings ?? []);
189
+ return true;
190
+ }
191
+
192
+ case 'show': {
193
+ if (!state.lastScan) {
194
+ console.log(chalk.yellow(' No scan results yet. Run /scan first.'));
195
+ return true;
196
+ }
197
+ const n = parseInt(args[0], 10);
198
+ const findings = state.lastScan.findings ?? [];
199
+ if (!Number.isInteger(n) || n < 1 || n > findings.length) {
200
+ console.log(chalk.yellow(` Usage: /show <n> (1..${findings.length})`));
201
+ return true;
202
+ }
203
+ printFindingDetail(findings[n - 1], n);
204
+ return true;
205
+ }
206
+
207
+ case 'agent':
208
+ case 'fix': {
209
+ // Hand off to agent command. Pass through caller options + any inline flags.
210
+ const opts = { ...options };
211
+ for (const a of args) {
212
+ if (a === '--plan-only') opts.planOnly = true;
213
+ if (a === '--allow-dirty') opts.allowDirty = true;
214
+ if (a === '--branch') opts.branch = true;
215
+ if (a === '--pr') opts.pr = true;
216
+ if (a.startsWith('--severity=')) opts.severity = a.slice('--severity='.length);
217
+ }
218
+ try {
219
+ await agentFixCommand(state.root, opts);
220
+ } catch (err) {
221
+ console.log(chalk.red(` Agent failed: ${err.message}`));
222
+ }
223
+ return true;
224
+ }
225
+
226
+ case 'undo': {
227
+ try {
228
+ await undoCommand(state.root, { all: args.includes('--all'), dryRun: args.includes('--dry-run') });
229
+ } catch (err) {
230
+ console.log(chalk.red(` Undo failed: ${err.message}`));
231
+ }
232
+ return true;
233
+ }
234
+
235
+ case 'provider': {
236
+ const name = args[0];
237
+ if (!name) {
238
+ console.log(chalk.gray(` Current: ${state.provider?.name ?? 'none'}`));
239
+ console.log(chalk.gray(' Usage: /provider <deepseek-flash|openai|kimi|anthropic|deepseek>'));
240
+ return true;
241
+ }
242
+ const next = autoDetectProvider(state.root, { provider: name });
243
+ if (!next) {
244
+ console.log(chalk.yellow(` Could not load provider "${name}" — is the API key set?`));
245
+ } else {
246
+ state.provider = next;
247
+ console.log(chalk.green(` Provider switched to ${next.name}.`));
248
+ }
249
+ return true;
250
+ }
251
+
252
+ default:
253
+ console.log(chalk.yellow(` Unknown command: /${cmd}. Type /help for the list.`));
254
+ return true;
255
+ }
256
+ }
257
+
258
+ // =============================================================================
259
+ // FREE-FORM PROMPT
260
+ // =============================================================================
261
+
262
+ async function handlePrompt(text, state) {
263
+ if (!state.provider) {
264
+ console.log(chalk.yellow(' No LLM provider available. Set DEEPSEEK_API_KEY (or another supported key) and restart.'));
265
+ return;
266
+ }
267
+
268
+ state.history.push({ role: 'user', content: text });
269
+
270
+ const systemPrompt = buildSystemPrompt(state);
271
+ const userPrompt = buildConversationPrompt(state);
272
+
273
+ // Stream tokens as they arrive so the REPL feels alive.
274
+ // Falls back transparently to one-shot complete() for providers without
275
+ // real streaming (the base class default yields the whole response).
276
+ process.stdout.write('\n ');
277
+ let collected = '';
278
+ try {
279
+ for await (const chunk of state.provider.stream(systemPrompt, userPrompt, { maxTokens: 1500 })) {
280
+ collected += chunk;
281
+ // Indent any new lines that appear inside a streamed chunk
282
+ process.stdout.write(chalk.white(chunk.replace(/\n/g, '\n ')));
283
+ }
284
+ process.stdout.write('\n\n');
285
+ state.history.push({ role: 'assistant', content: collected.trim() });
286
+ } catch (err) {
287
+ process.stdout.write('\n');
288
+ console.log(chalk.red(` Provider call failed: ${err.message}`));
289
+ }
290
+ }
291
+
292
+ function buildSystemPrompt(state) {
293
+ const scanSummary = state.lastScan
294
+ ? `Latest scan: score ${state.lastScan.score ?? '?'}/100, ${state.lastScan.findings?.length ?? 0} finding(s).`
295
+ : 'No scan has been run yet in this session.';
296
+
297
+ return [
298
+ 'You are the Ship Safe security agent embedded in a developer\'s CLI.',
299
+ 'You give precise, security-focused answers. Prefer concrete fixes and references over abstract advice.',
300
+ `Project root: ${state.root}`,
301
+ scanSummary,
302
+ 'When findings are referenced, cite them by index (1-based, in the order shown by /findings).',
303
+ ].join('\n');
304
+ }
305
+
306
+ function buildConversationPrompt(state) {
307
+ // Keep last ~10 turns to bound context size
308
+ const recent = state.history.slice(-10);
309
+
310
+ let context = '';
311
+ if (state.lastScan?.findings?.length) {
312
+ const findings = state.lastScan.findings.slice(0, 25).map((f, i) =>
313
+ `${i + 1}. [${f.severity}] ${f.title}${f.file ? ` — ${f.file}${f.line ? `:${f.line}` : ''}` : ''}`,
314
+ ).join('\n');
315
+ context = `\nKnown findings:\n${findings}\n\n`;
316
+ }
317
+
318
+ const turns = recent.map(t => `${t.role === 'user' ? 'USER' : 'ASSISTANT'}: ${t.content}`).join('\n\n');
319
+ return `${context}${turns}\n\nASSISTANT:`;
320
+ }
321
+
322
+ function formatAssistant(text) {
323
+ return text
324
+ .split('\n')
325
+ .map(line => chalk.white(` ${line}`))
326
+ .join('\n');
327
+ }
328
+
329
+ // =============================================================================
330
+ // PRINTING
331
+ // =============================================================================
332
+
333
+ function printHelp() {
334
+ console.log();
335
+ console.log(chalk.bold(' Commands:'));
336
+ console.log(' /scan, /rescan Re-scan the project');
337
+ console.log(' /findings List the latest scan\'s findings');
338
+ console.log(' /show <n> Show full detail of finding <n>');
339
+ console.log(' /plan <n> Preview a fix plan for finding <n> (no writes)');
340
+ console.log(' /agent [--plan-only] Run the interactive fix loop');
341
+ console.log(' /undo [--all] Revert the last fix (or all)');
342
+ console.log(' /diff [path] Show git working-tree diff');
343
+ console.log(' /git <args> Pass through to git (status, log, stash, ...)');
344
+ console.log(' /provider <name> Switch LLM provider');
345
+ console.log(' /clear Clear the screen');
346
+ console.log(' /quit Exit');
347
+ console.log();
348
+ console.log(chalk.gray(' Or just type a question — the agent will answer with scan context.'));
349
+ console.log();
350
+ }
351
+
352
+ function printScanSummary(result) {
353
+ const findings = result.findings ?? [];
354
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
355
+ for (const f of findings) counts[f.severity] = (counts[f.severity] ?? 0) + 1;
356
+
357
+ console.log();
358
+ console.log(chalk.bold(` Score: ${gradeColor(result.score)(`${result.score ?? '?'}/100`)}`));
359
+ 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`);
360
+ console.log();
361
+ console.log(chalk.gray(' /findings to list, /agent to fix.'));
362
+ console.log();
363
+ }
364
+
365
+ function printFindingsList(findings) {
366
+ if (findings.length === 0) {
367
+ console.log(chalk.green(' No findings.'));
368
+ return;
369
+ }
370
+ // Sort by severity desc
371
+ const sorted = [...findings].sort((a, b) => (SEV_RANK[b.severity] ?? 0) - (SEV_RANK[a.severity] ?? 0));
372
+ console.log();
373
+ for (let i = 0; i < sorted.length; i++) {
374
+ const f = sorted[i];
375
+ console.log(` ${chalk.gray(`${i + 1}.`.padStart(4))} ${sevTag(f.severity)} ${f.title} ${chalk.gray(f.file ?? '')}${f.line ? chalk.gray(`:${f.line}`) : ''}`);
376
+ }
377
+ console.log();
378
+ }
379
+
380
+ function printFindingDetail(f, n) {
381
+ console.log();
382
+ console.log(chalk.bold(` Finding ${n}: ${f.title}`));
383
+ console.log(` Severity: ${sevTag(f.severity)}`);
384
+ if (f.file) console.log(` File: ${f.file}${f.line ? `:${f.line}` : ''}`);
385
+ if (f.rule) console.log(` Rule: ${f.rule}`);
386
+ if (f.cwe) console.log(` CWE: ${f.cwe}`);
387
+ if (f.description) {
388
+ console.log();
389
+ console.log(chalk.gray(' Description:'));
390
+ console.log(` ${f.description}`);
391
+ }
392
+ if (f.fix) {
393
+ console.log();
394
+ console.log(chalk.gray(' Suggested fix:'));
395
+ console.log(` ${f.fix}`);
396
+ }
397
+ console.log();
398
+ }
399
+
400
+ function sevTag(sev) {
401
+ switch (sev) {
402
+ case 'critical': return chalk.red.bold('[CRITICAL]');
403
+ case 'high': return chalk.red('[HIGH]');
404
+ case 'medium': return chalk.yellow('[MEDIUM]');
405
+ case 'low': return chalk.blue('[LOW]');
406
+ default: return chalk.gray(`[${(sev || 'INFO').toUpperCase()}]`);
407
+ }
408
+ }
409
+
410
+ function gradeColor(score) {
411
+ if (score == null) return chalk.gray;
412
+ if (score >= 80) return chalk.green;
413
+ if (score >= 60) return chalk.yellow;
414
+ return chalk.red;
415
+ }