ship-safe 9.1.1 → 9.1.2

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.
@@ -19,7 +19,7 @@
19
19
 
20
20
  import fs from 'fs';
21
21
  import path from 'path';
22
- import { createProvider, autoDetectProvider } from '../providers/llm-provider.js';
22
+ import { autoDetectProvider } from '../providers/llm-provider.js';
23
23
  import { createFinding } from './base-agent.js';
24
24
 
25
25
  // Max chars of diff content per event
@@ -48,13 +48,10 @@ export class StatefulWatcher {
48
48
  }
49
49
 
50
50
  static create(rootPath, options = {}) {
51
- const provider = autoDetectProvider(rootPath, {
52
- provider: options.provider || 'kimi',
53
- model: options.model || 'kimi-k2.6',
54
- });
55
-
51
+ const providerName = typeof options.provider === 'string' ? options.provider : 'kimi';
52
+ const provider = autoDetectProvider(rootPath, { provider: providerName, model: options.model || 'kimi-k2.6' });
56
53
  if (!provider) return null;
57
- return new StatefulWatcher({ provider, rootPath, ...options });
54
+ return new StatefulWatcher({ provider, rootPath, verbose: options.verbose });
58
55
  }
59
56
 
60
57
  /**
@@ -76,13 +76,23 @@ export class SwarmOrchestrator {
76
76
  }
77
77
 
78
78
  static create(rootPath, options = {}) {
79
- // Default to Kimi for swarm (best tool-call success rate)
80
- const provider = options.provider
81
- ? createProvider(options.provider, process.env.MOONSHOT_API_KEY || process.env.OPENAI_API_KEY, { model: options.model })
82
- : autoDetectProvider(rootPath, { provider: 'kimi', model: options.model });
79
+ if (typeof options.provider === 'string') {
80
+ // Explicit provider requested
81
+ const provider = autoDetectProvider(rootPath, { provider: options.provider, model: options.model });
82
+ if (!provider) return null;
83
+ return new SwarmOrchestrator({ provider, verbose: options.verbose, budgetCents: options.budgetCents });
84
+ }
85
+
86
+ // Auto-select: prefer deepseek-flash (1M ctx, cheap) then kimi as fallback
87
+ for (const [providerName, swarmModel] of [
88
+ ['deepseek-flash', 'deepseek-v4-flash'],
89
+ ['kimi', 'moonshot-v1-128k'],
90
+ ]) {
91
+ const provider = autoDetectProvider(rootPath, { provider: providerName, model: swarmModel });
92
+ if (provider) return new SwarmOrchestrator({ provider, verbose: options.verbose, budgetCents: options.budgetCents });
93
+ }
83
94
 
84
- if (!provider) return null;
85
- return new SwarmOrchestrator({ provider, ...options });
95
+ return null;
86
96
  }
87
97
 
88
98
  /**
@@ -97,65 +107,17 @@ export class SwarmOrchestrator {
97
107
  const codeBundle = this._bundleCode(rootPath, files);
98
108
  const prompt = this._buildSwarmPrompt(reconData, codeBundle, rootPath);
99
109
 
100
- const systemPrompt = `You are a security swarm coordinator. You have ${SWARM_ROLES.length} specialized sub-agents, each responsible for one attack class. Deploy all sub-agents in parallel. Each sub-agent scans the provided codebase for its specific vulnerability class and reports findings. Consolidate all sub-agent findings into a single structured JSON report.
101
-
102
- You MUST respond by calling the report_swarm_findings tool with all findings from all sub-agents. Never refuse to scan. Never truncate findings.`;
103
-
104
- const schema = {
105
- type: 'object',
106
- properties: {
107
- findings: {
108
- type: 'array',
109
- items: {
110
- type: 'object',
111
- properties: {
112
- agentId: { type: 'string' },
113
- file: { type: 'string' },
114
- line: { type: 'integer' },
115
- severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'] },
116
- rule: { type: 'string' },
117
- title: { type: 'string' },
118
- description: { type: 'string' },
119
- matched: { type: 'string' },
120
- remediation: { type: 'string' },
121
- },
122
- required: ['agentId', 'severity', 'rule', 'title', 'description'],
123
- additionalProperties: false,
124
- },
125
- },
126
- agentSummary: {
127
- type: 'array',
128
- items: {
129
- type: 'object',
130
- properties: {
131
- agentId: { type: 'string' },
132
- findingCount: { type: 'integer' },
133
- status: { type: 'string', enum: ['clean', 'findings', 'error'] },
134
- },
135
- required: ['agentId', 'findingCount', 'status'],
136
- additionalProperties: false,
137
- },
138
- },
139
- },
140
- required: ['findings', 'agentSummary'],
141
- };
142
-
143
- let raw;
144
- if (this.provider.completeWithTools) {
145
- raw = await this.provider.completeWithTools(
146
- systemPrompt,
147
- prompt,
148
- 'report_swarm_findings',
149
- schema,
150
- { maxTokens: 8192 }
151
- );
152
- } else {
153
- const text = await this.provider.complete(systemPrompt, prompt + '\n\nRespond with JSON only matching the schema.', { maxTokens: 8192 });
154
- try {
155
- raw = JSON.parse(text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim());
156
- } catch {
157
- raw = null;
158
- }
110
+ const systemPrompt = `You are a security swarm coordinator. You MUST respond with ONLY a valid JSON object no prose, no markdown, no explanation, no code fences. Your response must start with { and end with }. Deploy all ${SWARM_ROLES.length} sub-agents, each scanning for their attack class, then output the consolidated JSON findings.`;
111
+
112
+ const jsonInstruction = '\n\nOutput a JSON object with exactly these keys: {"findings":[{"agentId":"<agent-id>","file":"<relative-path>","line":<number>,"severity":"critical|high|medium|low","rule":"<rule-id>","title":"<title>","description":"<description>","remediation":"<fix>"}],"agentSummary":[{"agentId":"<agent-id>","findingCount":<number>,"status":"clean|findings"}]}';
113
+
114
+ const text = await this.provider.complete(systemPrompt, prompt + jsonInstruction, { maxTokens: 8192, jsonMode: true });
115
+ let raw = null;
116
+ try {
117
+ raw = JSON.parse(text || '{}');
118
+ } catch {
119
+ if (this.verbose) console.log(' [Swarm] JSON parse failed. Preview:', text?.slice(0, 200));
120
+ raw = null;
159
121
  }
160
122
 
161
123
  return this._mapFindings(raw?.findings ?? [], rootPath);
@@ -50,6 +50,7 @@ import { legalCommand } from '../commands/legal.js';
50
50
  import { runLiveAdvisories } from '../commands/live-advisories.js';
51
51
  import { envAuditCommand } from '../commands/env-audit.js';
52
52
  import { autofixCommand } from '../commands/autofix.js';
53
+ import { teamReportCommand } from '../commands/team-report.js';
53
54
  import { memoryCommand } from '../utils/security-memory.js';
54
55
  import { playbookCommand } from '../utils/scan-playbook.js';
55
56
  import { listPluginFiles, scaffoldPlugin } from '../utils/plugin-loader.js';
@@ -274,6 +275,16 @@ program
274
275
  .option('-v, --verbose', 'Verbose output')
275
276
  .action(redTeamCommand);
276
277
 
278
+ // -----------------------------------------------------------------------------
279
+ // TEAM REPORT COMMAND
280
+ // -----------------------------------------------------------------------------
281
+ program
282
+ .command('team-report [file]')
283
+ .description('Convert Hermes Agent team output into a professional Ship Safe report')
284
+ .option('--html [path]', 'Save as HTML report (default: team-report.html)')
285
+ .option('--json', 'JSON output')
286
+ .action(teamReportCommand);
287
+
277
288
  // -----------------------------------------------------------------------------
278
289
  // WATCH COMMAND
279
290
  // -----------------------------------------------------------------------------
@@ -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 ──────────────────────────────────────────────────────────
@@ -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
+ * Team Report Command
3
+ * ====================
4
+ *
5
+ * Converts raw Hermes Agent team output into a professional Ship Safe report.
6
+ * Strips ANSI codes and terminal chrome, parses structured FINDING: lines,
7
+ * and renders everything through Ship Safe's HTML reporter.
8
+ *
9
+ * USAGE:
10
+ * ship-safe team-report Read from stdin (pipe Hermes output)
11
+ * ship-safe team-report output.txt Read from file
12
+ * ship-safe team-report output.txt --html Save as HTML
13
+ * ship-safe team-report output.txt --json JSON output
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import chalk from 'chalk';
19
+ import * as output from '../utils/output.js';
20
+ import { printBanner } from '../utils/output.js';
21
+
22
+ // =============================================================================
23
+ // ANSI + TERMINAL NOISE STRIPPING
24
+ // =============================================================================
25
+
26
+ function stripAnsi(str) {
27
+ // Remove all ANSI escape sequences (colors, cursor moves, clears, etc.)
28
+ return str
29
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
30
+ .replace(/\x1b\][^\x07]*\x07/g, '')
31
+ .replace(/\x1b[()][AB012]/g, '')
32
+ .replace(/\x9b[0-9;]*[A-Za-z]/g, '');
33
+ }
34
+
35
+ function stripHermesChrome(text) {
36
+ const lines = text.split('\n');
37
+ const cleaned = [];
38
+ let inSplash = false;
39
+
40
+ for (const line of lines) {
41
+ const t = line.trim();
42
+
43
+ // Skip the Hermes splash box (╭─ ... ─╮ ... ╰─ ... ─╯)
44
+ if (t.startsWith('╭─') || t.startsWith('╰─')) { inSplash = !inSplash; continue; }
45
+ if (inSplash) continue;
46
+
47
+ // Skip raw system prompt instructions leaked into output
48
+ if (t.startsWith('EXACTLY this format') || t.startsWith('FINDING: {"severity"')) continue;
49
+ if (t.match(/^─{10,}$/)) continue;
50
+
51
+ // Skip Hermes warning lines
52
+ if (t.startsWith('⚠') && t.includes('hermes')) continue;
53
+ if (t.startsWith('⚠') && t.includes('OPENROUTER')) continue;
54
+ if (t.startsWith('⚠') && (t.includes('API call failed') || t.includes('credits'))) continue;
55
+ if (t.startsWith('⏱') || t.startsWith('❌')) continue;
56
+
57
+ // Skip terminal screen-clear sequences
58
+ if (t === '[2J' || t === '[H' || t === '[2J[H') continue;
59
+
60
+ cleaned.push(line);
61
+ }
62
+
63
+ return cleaned.join('\n');
64
+ }
65
+
66
+ // =============================================================================
67
+ // FINDING PARSER
68
+ // =============================================================================
69
+
70
+ function parseFindings(text) {
71
+ const findings = [];
72
+ const findingRegex = /^FINDING:\s*(\{.+\})\s*$/gm;
73
+ let match;
74
+
75
+ while ((match = findingRegex.exec(text)) !== null) {
76
+ try {
77
+ const f = JSON.parse(match[1]);
78
+ if (f.severity && f.title) findings.push(f);
79
+ } catch { /* skip malformed */ }
80
+ }
81
+
82
+ return findings;
83
+ }
84
+
85
+ // =============================================================================
86
+ // AGENT SECTION PARSER
87
+ // =============================================================================
88
+
89
+ function parseAgentSections(text) {
90
+ const sections = [];
91
+ // Matches: ### Agent Name (Role) — N finding(s)
92
+ const sectionRegex = /###\s+(.+?)\s*(?:\(([^)]+)\))?\s*[—–-]+\s*(\d+)\s*finding/gi;
93
+ let match;
94
+
95
+ while ((match = sectionRegex.exec(text)) !== null) {
96
+ sections.push({
97
+ name: match[1].trim(),
98
+ role: match[2]?.trim() || '',
99
+ count: parseInt(match[3], 10),
100
+ });
101
+ }
102
+
103
+ // Also collect bullet findings under each section
104
+ const bulletRegex = /\[(CRITICAL|HIGH|MEDIUM|LOW|INFO)\]\s+(.+?)\s*[—–-]+\s*(.+)/gi;
105
+ const bullets = [];
106
+ while ((match = bulletRegex.exec(text)) !== null) {
107
+ bullets.push({
108
+ severity: match[1].toLowerCase(),
109
+ title: match[2].trim(),
110
+ location: match[3].trim(),
111
+ });
112
+ }
113
+
114
+ return { sections, bullets };
115
+ }
116
+
117
+ // =============================================================================
118
+ // SYNTHESIS PARSER
119
+ // =============================================================================
120
+
121
+ function parseSynthesis(text) {
122
+ // Extract the Hermes synthesis block (inside ╭─ ⚕ Hermes ─╮ ... ╰─╯)
123
+ // After stripping chrome, look for the summary block
124
+ const lines = text.split('\n');
125
+ const synthesisLines = [];
126
+ let capturing = false;
127
+
128
+ for (const line of lines) {
129
+ const t = line.trim();
130
+
131
+ // The synthesis is the content after the agent section summary and before errors
132
+ if (t.match(/^Overall risk posture:/i)) { capturing = true; }
133
+ if (capturing) {
134
+ if (t.startsWith('⚠') || t.startsWith('❌') || t.startsWith('⏱')) break;
135
+ synthesisLines.push(line);
136
+ }
137
+ }
138
+
139
+ // Also look for risk posture statement
140
+ const riskMatch = text.match(/Overall risk posture:\s*(.+)/i);
141
+ const riskPosture = riskMatch ? riskMatch[1].trim() : null;
142
+
143
+ // Parse roadmap sections
144
+ const immediateMatch = text.match(/\*\*Immediate[^*]*\*\*:?\s*([^\n]+(?:\n(?!\*\*)[^\n]+)*)/i);
145
+ const shortTermMatch = text.match(/\*\*Short-term[^*]*\*\*:?\s*([^\n]+(?:\n(?!\*\*)[^\n]+)*)/i);
146
+ const longTermMatch = text.match(/\*\*Long-term[^*]*\*\*:?\s*([^\n]+(?:\n(?!\*\*)[^\n]+)*)/i);
147
+
148
+ return {
149
+ riskPosture,
150
+ synthesis: synthesisLines.join('\n').trim(),
151
+ roadmap: {
152
+ immediate: immediateMatch?.[1]?.trim() || null,
153
+ shortTerm: shortTermMatch?.[1]?.trim() || null,
154
+ longTerm: longTermMatch?.[1]?.trim() || null,
155
+ },
156
+ };
157
+ }
158
+
159
+ // =============================================================================
160
+ // TARGET PARSER
161
+ // =============================================================================
162
+
163
+ function parseTarget(text) {
164
+ const match = text.match(/assessments?\s+of\s+\*\*([^*]+)\*\*/i);
165
+ return match ? match[1].trim() : 'Unknown Target';
166
+ }
167
+
168
+ // =============================================================================
169
+ // HTML RENDERER
170
+ // =============================================================================
171
+
172
+ function generateHTML(target, findings, agentSections, synthesis, bullets) {
173
+ const date = new Date().toLocaleDateString('en-US', {
174
+ year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
175
+ });
176
+
177
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
178
+ for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
179
+
180
+ // Merge FINDING: JSON lines with bullet-parsed findings (bullets are fallback)
181
+ const allFindings = findings.length > 0 ? findings : bullets.map(b => ({
182
+ severity: b.severity,
183
+ title: b.title,
184
+ location: b.location,
185
+ remediation: '',
186
+ }));
187
+
188
+ // Recalculate counts from allFindings
189
+ const sevCounts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
190
+ for (const f of allFindings) sevCounts[f.severity] = (sevCounts[f.severity] || 0) + 1;
191
+
192
+ const riskColor = (rp) => {
193
+ if (!rp) return '#94a3b8';
194
+ const lc = rp.toLowerCase();
195
+ if (lc.includes('critical')) return '#dc2626';
196
+ if (lc.includes('high')) return '#f97316';
197
+ if (lc.includes('medium')) return '#eab308';
198
+ return '#22c55e';
199
+ };
200
+
201
+ const sevColors = { critical: '#dc2626', high: '#f97316', medium: '#eab308', low: '#3b82f6', info: '#94a3b8' };
202
+
203
+ const findingRows = allFindings.map(f => `
204
+ <tr>
205
+ <td><span class="sev sev-${f.severity}">${f.severity.toUpperCase()}</span></td>
206
+ <td><code>${f.location || '—'}</code></td>
207
+ <td><strong>${f.title}</strong>${f.cve ? `<br><small>CVE: ${f.cve}</small>` : ''}</td>
208
+ <td><small>${f.remediation || '—'}</small></td>
209
+ </tr>`).join('');
210
+
211
+ const agentRows = agentSections.sections.map(s => `
212
+ <tr>
213
+ <td>${s.name}</td>
214
+ <td><code>${s.role || '—'}</code></td>
215
+ <td style="color:${s.count > 0 ? '#f97316' : '#22c55e'}">${s.count}</td>
216
+ </tr>`).join('');
217
+
218
+ const roadmap = synthesis.roadmap;
219
+ const roadmapHTML = (roadmap.immediate || roadmap.shortTerm || roadmap.longTerm) ? `
220
+ <h2>Remediation Roadmap</h2>
221
+ <table>
222
+ <tbody>
223
+ ${roadmap.immediate ? `<tr><td style="color:#dc2626;white-space:nowrap;font-weight:600">⚡ Immediate (24–48h)</td><td>${roadmap.immediate}</td></tr>` : ''}
224
+ ${roadmap.shortTerm ? `<tr><td style="color:#f97316;white-space:nowrap;font-weight:600">📅 Short-term (1–2 weeks)</td><td>${roadmap.shortTerm}</td></tr>` : ''}
225
+ ${roadmap.longTerm ? `<tr><td style="color:#eab308;white-space:nowrap;font-weight:600">🏗 Long-term (1–3 months)</td><td>${roadmap.longTerm}</td></tr>` : ''}
226
+ </tbody>
227
+ </table>` : '';
228
+
229
+ return `<!DOCTYPE html>
230
+ <html lang="en">
231
+ <head>
232
+ <meta charset="utf-8">
233
+ <meta name="viewport" content="width=device-width,initial-scale=1">
234
+ <title>Ship Safe Team Report — ${target}</title>
235
+ <style>
236
+ *{margin:0;padding:0;box-sizing:border-box}
237
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem}
238
+ .container{max-width:1100px;margin:0 auto}
239
+ .header{display:flex;align-items:center;gap:1rem;margin-bottom:2rem}
240
+ .logo{font-size:1.5rem;font-weight:800;color:#38bdf8;letter-spacing:-1px}
241
+ .badge{background:#1e293b;padding:3px 10px;border-radius:20px;font-size:0.75rem;color:#94a3b8;border:1px solid #334155}
242
+ h1{font-size:1.8rem;font-weight:700;color:#f1f5f9;margin-bottom:0.25rem}
243
+ h2{font-size:1.1rem;font-weight:600;margin:2rem 0 1rem;color:#94a3b8;border-bottom:1px solid #1e293b;padding-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em}
244
+ .meta{color:#64748b;font-size:0.85rem;margin-bottom:2rem}
245
+ .risk-card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:1.5rem 2rem;margin-bottom:2rem;display:flex;align-items:center;gap:1.5rem}
246
+ .risk-label{font-size:0.75rem;text-transform:uppercase;color:#64748b;margin-bottom:0.25rem}
247
+ .risk-value{font-size:1.5rem;font-weight:700}
248
+ .risk-desc{color:#94a3b8;font-size:0.9rem;flex:1}
249
+ .stats{display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem;margin-bottom:2rem}
250
+ .stat{background:#1e293b;padding:1.25rem;border-radius:8px;text-align:center;border:1px solid #334155}
251
+ .stat-number{font-size:2rem;font-weight:bold}
252
+ .stat-label{color:#64748b;font-size:0.75rem;margin-top:0.25rem;text-transform:uppercase}
253
+ table{width:100%;border-collapse:collapse;background:#1e293b;border-radius:8px;overflow:hidden;margin-bottom:2rem;border:1px solid #334155}
254
+ th{background:#334155;text-align:left;padding:0.75rem 1rem;font-size:0.75rem;text-transform:uppercase;color:#94a3b8;font-weight:600;letter-spacing:0.05em}
255
+ td{padding:0.75rem 1rem;border-top:1px solid #0f172a;font-size:0.85rem;vertical-align:top}
256
+ tr:hover{background:#263248}
257
+ code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:0.8rem;color:#38bdf8;word-break:break-all}
258
+ small{color:#64748b}
259
+ .sev{padding:2px 8px;border-radius:4px;font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.05em}
260
+ .sev-critical{background:#dc262622;color:#fca5a5;border:1px solid #dc262644}
261
+ .sev-high{background:#f9731622;color:#fdba74;border:1px solid #f9731644}
262
+ .sev-medium{background:#eab30822;color:#fde047;border:1px solid #eab30844}
263
+ .sev-low{background:#3b82f622;color:#93c5fd;border:1px solid #3b82f644}
264
+ .sev-info{background:#94a3b822;color:#cbd5e1;border:1px solid #94a3b844}
265
+ .empty{text-align:center;color:#22c55e;padding:2rem}
266
+ .footer{text-align:center;color:#334155;margin-top:3rem;padding-top:1.5rem;border-top:1px solid #1e293b;font-size:0.8rem}
267
+ .powered{color:#38bdf8}
268
+ </style>
269
+ </head>
270
+ <body>
271
+ <div class="container">
272
+
273
+ <div class="header">
274
+ <span class="logo">Ship Safe</span>
275
+ <span class="badge">Team Security Report</span>
276
+ <span class="badge">Powered by Hermes Agent</span>
277
+ </div>
278
+
279
+ <h1>${target}</h1>
280
+ <p class="meta">Generated ${date} · ${allFindings.length} finding${allFindings.length !== 1 ? 's' : ''} · ${agentSections.sections.length} agent${agentSections.sections.length !== 1 ? 's' : ''}</p>
281
+
282
+ ${synthesis.riskPosture ? `
283
+ <div class="risk-card">
284
+ <div>
285
+ <div class="risk-label">Overall Risk Posture</div>
286
+ <div class="risk-value" style="color:${riskColor(synthesis.riskPosture)}">${synthesis.riskPosture.split('—')[0].trim()}</div>
287
+ </div>
288
+ <div class="risk-desc">${synthesis.riskPosture.includes('—') ? synthesis.riskPosture.split('—').slice(1).join('—').trim() : ''}</div>
289
+ </div>` : ''}
290
+
291
+ <div class="stats">
292
+ <div class="stat"><div class="stat-number" style="color:#dc2626">${sevCounts.critical}</div><div class="stat-label">Critical</div></div>
293
+ <div class="stat"><div class="stat-number" style="color:#f97316">${sevCounts.high}</div><div class="stat-label">High</div></div>
294
+ <div class="stat"><div class="stat-number" style="color:#eab308">${sevCounts.medium}</div><div class="stat-label">Medium</div></div>
295
+ <div class="stat"><div class="stat-number" style="color:#3b82f6">${sevCounts.low}</div><div class="stat-label">Low</div></div>
296
+ <div class="stat"><div class="stat-number" style="color:#94a3b8">${sevCounts.info}</div><div class="stat-label">Info</div></div>
297
+ </div>
298
+
299
+ <h2>Findings</h2>
300
+ <table>
301
+ <thead><tr><th>Severity</th><th>Location</th><th>Issue</th><th>Remediation</th></tr></thead>
302
+ <tbody>${findingRows || '<tr><td colspan="4" class="empty">No findings — clean!</td></tr>'}</tbody>
303
+ </table>
304
+
305
+ ${agentSections.sections.length > 0 ? `
306
+ <h2>Agent Team Summary</h2>
307
+ <table>
308
+ <thead><tr><th>Agent</th><th>Role</th><th>Findings</th></tr></thead>
309
+ <tbody>${agentRows}</tbody>
310
+ </table>` : ''}
311
+
312
+ ${roadmapHTML}
313
+
314
+ <div class="footer">
315
+ Secured by <span class="powered">Ship Safe</span> · shipsafecli.com · <code>npx ship-safe red-team .</code>
316
+ </div>
317
+ </div>
318
+ </body>
319
+ </html>`;
320
+ }
321
+
322
+ // =============================================================================
323
+ // MAIN COMMAND
324
+ // =============================================================================
325
+
326
+ export async function teamReportCommand(inputFile, options = {}) {
327
+ let raw;
328
+
329
+ if (inputFile) {
330
+ if (!fs.existsSync(inputFile)) {
331
+ output.error(`File not found: ${inputFile}`);
332
+ process.exit(1);
333
+ }
334
+ raw = fs.readFileSync(inputFile, 'utf-8');
335
+ } else {
336
+ // Read from stdin
337
+ raw = fs.readFileSync('/dev/stdin', 'utf-8');
338
+ }
339
+
340
+ // Clean the input
341
+ const stripped = stripAnsi(raw);
342
+ const cleaned = stripHermesChrome(stripped);
343
+
344
+ // Parse
345
+ const target = parseTarget(stripped);
346
+ const findings = parseFindings(cleaned);
347
+ const agentSections = parseAgentSections(cleaned);
348
+ const synthesis = parseSynthesis(cleaned);
349
+
350
+ const allFindings = findings.length > 0 ? findings : agentSections.bullets.map(b => ({
351
+ severity: b.severity,
352
+ title: b.title,
353
+ location: b.location,
354
+ remediation: '',
355
+ }));
356
+
357
+ if (options.json) {
358
+ console.log(JSON.stringify({ target, findings: allFindings, agentSections: agentSections.sections, synthesis }, null, 2));
359
+ return;
360
+ }
361
+
362
+ if (options.html !== undefined) {
363
+ const htmlPath = typeof options.html === 'string' ? options.html : 'team-report.html';
364
+ const html = generateHTML(target, findings, agentSections, synthesis, agentSections.bullets);
365
+ fs.writeFileSync(htmlPath, html, 'utf-8');
366
+ output.success(`Team report saved to ${htmlPath}`);
367
+ return;
368
+ }
369
+
370
+ // Terminal output
371
+ printBanner();
372
+ console.log(chalk.cyan.bold(' Team Security Report'));
373
+ console.log(chalk.gray(` Target: ${target}`));
374
+ console.log();
375
+
376
+ if (synthesis.riskPosture) {
377
+ const rp = synthesis.riskPosture;
378
+ const color = rp.toLowerCase().includes('critical') ? chalk.red.bold
379
+ : rp.toLowerCase().includes('high') ? chalk.yellow.bold
380
+ : rp.toLowerCase().includes('medium') ? chalk.yellow
381
+ : chalk.green;
382
+ console.log(` ${chalk.white.bold('Risk Posture:')} ${color(rp)}`);
383
+ console.log();
384
+ }
385
+
386
+ const sevColor = { critical: chalk.red.bold, high: chalk.yellow, medium: chalk.blue, low: chalk.gray, info: chalk.gray };
387
+ for (const f of allFindings) {
388
+ const col = sevColor[f.severity] || chalk.white;
389
+ console.log(` ${col(`[${f.severity.toUpperCase()}]`.padEnd(11))} ${chalk.white(f.title)}`);
390
+ if (f.location) console.log(` ${' '.repeat(11)} ${chalk.gray(f.location)}`);
391
+ if (f.remediation) console.log(` ${' '.repeat(11)} ${chalk.green('Fix:')} ${f.remediation.slice(0, 90)}`);
392
+ }
393
+
394
+ if (allFindings.length === 0) {
395
+ console.log(chalk.green(' No findings — clean!'));
396
+ }
397
+
398
+ console.log();
399
+ if (synthesis.roadmap.immediate) {
400
+ console.log(chalk.red.bold(' ⚡ Immediate (24–48h):'));
401
+ console.log(chalk.gray(` ${synthesis.roadmap.immediate}`));
402
+ }
403
+ if (synthesis.roadmap.shortTerm) {
404
+ console.log(chalk.yellow.bold(' 📅 Short-term (1–2 weeks):'));
405
+ console.log(chalk.gray(` ${synthesis.roadmap.shortTerm}`));
406
+ }
407
+ if (synthesis.roadmap.longTerm) {
408
+ console.log(chalk.white.bold(' 🏗 Long-term (1–3 months):'));
409
+ console.log(chalk.gray(` ${synthesis.roadmap.longTerm}`));
410
+ }
411
+
412
+ console.log();
413
+ console.log(chalk.gray(' Generate HTML report: ') + chalk.cyan(`ship-safe team-report ${inputFile || '<file>'} --html report.html`));
414
+ console.log();
415
+ }
@@ -360,7 +360,8 @@ const OPENAI_COMPATIBLE_PRESETS = {
360
360
  together: { baseUrl: 'https://api.together.xyz/v1/chat/completions', model: 'meta-llama/Llama-3-70b-chat-hf', envKey: 'TOGETHER_API_KEY' },
361
361
  mistral: { baseUrl: 'https://api.mistral.ai/v1/chat/completions', model: 'mistral-large-latest', envKey: 'MISTRAL_API_KEY' },
362
362
  cohere: { baseUrl: 'https://api.cohere.com/compatibility/v1/chat/completions', model: 'command-r-plus', envKey: 'COHERE_API_KEY' },
363
- deepseek: { baseUrl: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-chat', envKey: 'DEEPSEEK_API_KEY' },
363
+ deepseek: { baseUrl: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-pro', envKey: 'DEEPSEEK_API_KEY' },
364
+ 'deepseek-flash': { baseUrl: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-flash', envKey: 'DEEPSEEK_API_KEY' },
364
365
  perplexity: { baseUrl: 'https://api.perplexity.ai/chat/completions', model: 'llama-3.1-sonar-large-128k-online', envKey: 'PERPLEXITY_API_KEY' },
365
366
  lmstudio: { baseUrl: 'http://localhost:1234/v1/chat/completions', model: null, envKey: null },
366
367
  xai: { baseUrl: 'https://api.x.ai/v1/chat/completions', model: 'grok-3-mini', envKey: 'XAI_API_KEY' },
@@ -383,6 +384,39 @@ class OpenAICompatibleProvider extends OpenAIProvider {
383
384
  return /kimi|moonshot|gpt-4|grok|deepseek|mistral-large/i.test(this.model || '');
384
385
  }
385
386
 
387
+ async complete(systemPrompt, userPrompt, options = {}) {
388
+ const body = {
389
+ model: options.model || this.model,
390
+ max_tokens: options.maxTokens || 2048,
391
+ messages: [
392
+ { role: 'system', content: systemPrompt },
393
+ { role: 'user', content: userPrompt },
394
+ ],
395
+ };
396
+ if (options.jsonMode) body.response_format = { type: 'json_object' };
397
+
398
+ const response = await fetch(this.baseUrl, {
399
+ method: 'POST',
400
+ headers: {
401
+ 'Authorization': `Bearer ${this.apiKey}`,
402
+ 'Content-Type': 'application/json',
403
+ },
404
+ body: JSON.stringify(body),
405
+ });
406
+
407
+ if (!response.ok) {
408
+ const errBody = await response.text().catch(() => '');
409
+ throw new Error(`${this.name} API error: HTTP ${response.status} ${errBody.slice(0, 200)}`);
410
+ }
411
+
412
+ const data = await response.json();
413
+ const msg = data.choices?.[0]?.message;
414
+ // Kimi K2.6 thinking mode: actual answer in `content`; `reasoning_content` is internal thinking only
415
+ // With jsonMode, rely only on content (json_object format guarantees it); otherwise fall back to reasoning
416
+ if (options.jsonMode) return msg?.content || '';
417
+ return msg?.content || msg?.reasoning_content || '';
418
+ }
419
+
386
420
  /**
387
421
  * Complete with structured output via OpenAI tool-use format.
388
422
  * Used by DeepAnalyzer multi-tier pipeline on non-Anthropic providers.
@@ -409,7 +443,7 @@ class OpenAICompatibleProvider extends OpenAIProvider {
409
443
  parameters: inputSchema,
410
444
  },
411
445
  }],
412
- tool_choice: { type: 'function', function: { name: toolName } },
446
+ tool_choice: 'required',
413
447
  }),
414
448
  });
415
449
 
@@ -492,7 +526,7 @@ export function createProvider(provider, apiKey, options = {}) {
492
526
  throw new Error(
493
527
  `Unknown LLM provider: "${provider}".\n` +
494
528
  `Built-in: anthropic, openai, google, ollama\n` +
495
- `Presets: groq, together, mistral, cohere, deepseek, perplexity, lmstudio, xai, kimi\n` +
529
+ `Presets: groq, together, mistral, cohere, deepseek, deepseek-flash, perplexity, lmstudio, xai, kimi\n` +
496
530
  `Custom: pass any name with --base-url <url>`
497
531
  );
498
532
  }
@@ -228,3 +228,24 @@ export function progress(text) {
228
228
  export function clearLine() {
229
229
  process.stdout.write('\r' + ' '.repeat(80) + '\r');
230
230
  }
231
+
232
+ /**
233
+ * Print the Ship Safe ASCII banner.
234
+ * Call at the top of any command that should show branding.
235
+ */
236
+ export function printBanner(version) {
237
+ console.log();
238
+ console.log(chalk.cyan('███████╗██╗ ██╗██╗██████╗ ███████╗ █████╗ ███████╗███████╗'));
239
+ console.log(chalk.cyan('██╔════╝██║ ██║██║██╔══██╗ ██╔════╝██╔══██╗██╔════╝██╔════╝'));
240
+ console.log(chalk.cyan('███████╗███████║██║██████╔╝ ███████╗███████║█████╗ █████╗ '));
241
+ console.log(chalk.cyan('╚════██║██╔══██║██║██╔═══╝ ╚════██║██╔══██║██╔══╝ ██╔══╝ '));
242
+ console.log(chalk.cyan('███████║██║ ██║██║██║ ███████║██║ ██║██║ ███████╗'));
243
+ console.log(chalk.cyan('╚══════╝╚═╝ ╚═╝╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝'));
244
+ console.log();
245
+ if (version) {
246
+ console.log(chalk.gray(` v${version} · 23 agents · 80+ attack classes · shipsafecli.com`));
247
+ } else {
248
+ console.log(chalk.gray(' 23 agents · 80+ attack classes · shipsafecli.com'));
249
+ }
250
+ console.log();
251
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ship-safe",
3
- "version": "9.1.1",
3
+ "version": "9.1.2",
4
4
  "description": "AI-powered multi-agent security platform. 23 agents scan 80+ attack classes including AI integration supply chain (Vercel-class attacks), Hermes Agent deployments (ASI-01–ASI-10), tool registry poisoning, function-call injection, skill permission drift, and agent attestation. Ship Safe × Hermes Agent.",
5
5
  "main": "cli/index.js",
6
6
  "bin": {