ship-safe 8.0.0 → 9.0.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.
@@ -234,9 +234,19 @@ export class Orchestrator {
234
234
  allFindings = await analyzer.analyze(allFindings, { rootPath: absolutePath, recon });
235
235
  const stats = analyzer.getStats();
236
236
  if (deepSpinner) {
237
- deepSpinner.succeed(chalk.green(
238
- `Deep analysis: ${stats.analyzedCount} findings analyzed (${stats.spentCents}c spent)`
239
- ));
237
+ if (stats.multiTier) {
238
+ const tierNote = stats.tier3Count > 0
239
+ ? `, ${stats.tier3Count} escalated to Opus`
240
+ : stats.tier2Count > 0 ? `, ${stats.tier2Count} via Sonnet` : '';
241
+ const skipNote = stats.skippedCount > 0 ? `, ${stats.skippedCount} triaged away` : '';
242
+ deepSpinner.succeed(chalk.green(
243
+ `Deep analysis (Haiku→Sonnet→Opus): ${stats.analyzedCount} analyzed${tierNote}${skipNote} (${stats.spentCents}¢)`
244
+ ));
245
+ } else {
246
+ deepSpinner.succeed(chalk.green(
247
+ `Deep analysis: ${stats.analyzedCount} findings analyzed (${stats.spentCents}¢)`
248
+ ));
249
+ }
240
250
  }
241
251
  } catch (err) {
242
252
  if (deepSpinner) deepSpinner.fail(chalk.yellow(`Deep analysis failed: ${err.message}`));
@@ -125,6 +125,8 @@ program
125
125
  .option('--headers', 'Only copy security headers config')
126
126
  .option('--agents', 'Only add security rules to AI agent instruction files (CLAUDE.md, .cursor/rules/, .windsurfrules, copilot-instructions.md)')
127
127
  .option('--openclaw', 'Generate a hardened openclaw.json template')
128
+ .option('--hermes', 'Bootstrap Hermes Agent security config (allowlist, integrity hashes, CI)')
129
+ .option('--from <url>', 'Fetch a pre-built Hermes config bundle from a setup URL (used with --hermes)')
128
130
  .action(initCommand);
129
131
 
130
132
  // -----------------------------------------------------------------------------
@@ -231,6 +233,8 @@ program
231
233
  .option('--include-legal', 'Also run the legal risk scan (DMCA, leaked source, IP disputes)')
232
234
  .option('--agentic [iterations]', 'Agentic scan→fix→verify loop (default: 3 iterations, target score: 75)', (v) => v ? parseInt(v) : true)
233
235
  .option('--agentic-target <score>', 'Target security score for agentic loop (default: 75)', parseInt)
236
+ .option('--hermes-only', 'Run only Hermes-relevant agents (llm + supply-chain categories) for fast CI')
237
+ .option('--fail-below <threshold>', 'Exit 1 if score is below threshold (number or "baseline")')
234
238
  .option('-v, --verbose', 'Verbose output')
235
239
  .action(auditCommand);
236
240
 
@@ -190,6 +190,15 @@ export async function auditCommand(targetPath = '.', options = {}) {
190
190
 
191
191
  // ── Phase 2: Agent Scan ───────────────────────────────────────────────────
192
192
  const orchestrator = await buildOrchestratorAsync(absolutePath, { quiet: true });
193
+
194
+ // --hermes-only: filter to llm + supply-chain category agents only
195
+ if (options.hermesOnly && orchestrator.agents) {
196
+ const hermesCategories = new Set(['llm', 'supply-chain']);
197
+ orchestrator.agents = orchestrator.agents.filter(a =>
198
+ hermesCategories.has(a.category) || hermesCategories.has(a.constructor?.category)
199
+ );
200
+ }
201
+
193
202
  const registeredAgentCount = orchestrator.agents?.length || 15;
194
203
  const agentSpinner = machineOutput ? null : ora({ text: chalk.white(`[Phase 2/4] Running ${registeredAgentCount} security agents...`), color: 'cyan' }).start();
195
204
  let agentFindings = [];
@@ -567,7 +576,30 @@ export async function auditCommand(targetPath = '.', options = {}) {
567
576
  }
568
577
  }
569
578
 
570
- process.exit(scoreResult.score >= 75 ? 0 : 1);
579
+ // ── Exit code logic ─────────────────────────────────────────────────────
580
+ let threshold = 75;
581
+ if (options.failBelow !== undefined) {
582
+ if (options.failBelow === 'baseline') {
583
+ // Read baseline score from .ship-safe/hermes-baseline.json
584
+ const baselinePath = path.join(absolutePath, '.ship-safe', 'hermes-baseline.json');
585
+ try {
586
+ const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
587
+ threshold = baseline.score || 0;
588
+ if (!machineOutput) {
589
+ console.log(chalk.gray(` Baseline threshold: ${threshold}/100 (from ${baselinePath})`));
590
+ }
591
+ } catch {
592
+ if (!machineOutput) {
593
+ console.log(chalk.yellow(` Warning: could not read baseline — using score 0 as threshold`));
594
+ }
595
+ threshold = 0;
596
+ }
597
+ } else {
598
+ threshold = parseInt(options.failBelow, 10) || 75;
599
+ }
600
+ }
601
+
602
+ process.exit(scoreResult.score >= threshold ? 0 : 1);
571
603
  }
572
604
 
573
605
  /**
@@ -55,6 +55,11 @@ export async function initCommand(options = {}) {
55
55
  return handleOpenClawInit(targetDir, options.force, results);
56
56
  }
57
57
 
58
+ // Handle --hermes --from <url>
59
+ if (options.hermes) {
60
+ return handleHermesInit(targetDir, options);
61
+ }
62
+
58
63
  const hasSpecificFlag = options.gitignore || options.headers || options.agents;
59
64
  const copyGitignore = hasSpecificFlag ? !!options.gitignore : true;
60
65
  const copyHeaders = hasSpecificFlag ? !!options.headers : true;
@@ -301,6 +306,105 @@ async function handleAgentFiles(targetDir, force, results) {
301
306
  }
302
307
  }
303
308
 
309
+ // =============================================================================
310
+ // HERMES AGENT INIT
311
+ // =============================================================================
312
+
313
+ async function handleHermesInit(targetDir, options) {
314
+ const fromUrl = options.from;
315
+
316
+ if (!fromUrl) {
317
+ console.error(chalk.red('\nError: --hermes requires --from <setup-url>'));
318
+ console.error(chalk.gray(' Generate a setup URL at: https://shipsafecli.com/app/deploy'));
319
+ console.error(chalk.gray(' Then run: npx ship-safe init --hermes --from <url>\n'));
320
+ process.exit(1);
321
+ }
322
+
323
+ // Validate the URL is from a trusted origin
324
+ let parsed;
325
+ try {
326
+ parsed = new URL(fromUrl);
327
+ } catch {
328
+ console.error(chalk.red('\nError: Invalid URL: ' + fromUrl + '\n'));
329
+ process.exit(1);
330
+ }
331
+
332
+ const TRUSTED_HOSTS = ['shipsafecli.com', 'www.shipsafecli.com', 'localhost', '127.0.0.1'];
333
+ if (!TRUSTED_HOSTS.includes(parsed.hostname)) {
334
+ console.error(chalk.red('\nError: Setup URL must be from shipsafecli.com (got: ' + parsed.hostname + ')'));
335
+ console.error(chalk.gray(' Only URLs generated by the Ship Safe webapp are trusted.\n'));
336
+ process.exit(1);
337
+ }
338
+
339
+ console.log();
340
+ output.header('Hermes Agent Security Setup');
341
+ console.log();
342
+ console.log(chalk.gray('Fetching config from:'), chalk.cyan(fromUrl));
343
+ console.log();
344
+
345
+ // Fetch the config bundle
346
+ let data;
347
+ try {
348
+ const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
349
+ const res = await fetch(fromUrl, { headers: { 'Accept': 'application/json' } });
350
+ if (!res.ok) {
351
+ const body = await res.json().catch(() => ({}));
352
+ throw new Error(body.error ?? `HTTP ${res.status}`);
353
+ }
354
+ data = await res.json();
355
+ } catch (err) {
356
+ console.error(chalk.red('\nFailed to fetch setup config: ' + err.message + '\n'));
357
+ process.exit(1);
358
+ }
359
+
360
+ if (!data.files || !Array.isArray(data.files) || data.files.length === 0) {
361
+ console.error(chalk.red('\nInvalid config bundle — no files returned.\n'));
362
+ process.exit(1);
363
+ }
364
+
365
+ // Write each file
366
+ const written = [];
367
+ const skipped = [];
368
+
369
+ for (const { path: filePath, content } of data.files) {
370
+ // Sanitize path — no traversal
371
+ const normalized = path.normalize(filePath).replace(/^(\.\.(\/|\\|$))+/, '');
372
+ const absPath = path.join(targetDir, normalized);
373
+
374
+ if (fs.existsSync(absPath) && !options.force) {
375
+ skipped.push(normalized);
376
+ continue;
377
+ }
378
+
379
+ const dir = path.dirname(absPath);
380
+ if (!fs.existsSync(dir)) {
381
+ fs.mkdirSync(dir, { recursive: true });
382
+ }
383
+ fs.writeFileSync(absPath, content, 'utf-8');
384
+ written.push(normalized);
385
+ }
386
+
387
+ // Print results
388
+ console.log(chalk.green.bold('Files written:'));
389
+ for (const f of written) {
390
+ console.log(chalk.green(` ✔ ${f}`));
391
+ }
392
+ if (skipped.length > 0) {
393
+ console.log();
394
+ console.log(chalk.yellow.bold('Skipped (already exist — use -f to overwrite):'));
395
+ for (const f of skipped) {
396
+ console.log(chalk.yellow(` → ${f}`));
397
+ }
398
+ }
399
+
400
+ console.log();
401
+ console.log(chalk.cyan.bold('Next steps:'));
402
+ console.log(chalk.white(' 1.') + ' Populate your baseline: ' + chalk.cyan('npx ship-safe audit .'));
403
+ console.log(chalk.white(' 2.') + ' Auto-fix findings: ' + chalk.cyan('npx ship-safe audit . --agentic 3 --agentic-target 80'));
404
+ console.log(chalk.white(' 3.') + ' Commit everything and push — CI runs on every PR.');
405
+ console.log();
406
+ }
407
+
304
408
  // =============================================================================
305
409
  // OPENCLAW HARDENED CONFIG
306
410
  // =============================================================================
@@ -24,6 +24,9 @@
24
24
  * scan_secrets - Scan a directory for leaked secrets
25
25
  * get_checklist - Return the launch-day security checklist
26
26
  * analyze_file - Analyze a single file for security issues
27
+ * scan_repo - Run a full multi-agent security scan on a repo
28
+ * get_findings - Read findings from a saved ship-safe report file
29
+ * suppress_finding - Add a ship-safe-ignore comment to suppress a finding
27
30
  *
28
31
  * PROTOCOL:
29
32
  * JSON-RPC 2.0 over stdio (MCP spec: https://modelcontextprotocol.io)
@@ -34,6 +37,10 @@ import path from 'path';
34
37
  import fg from 'fast-glob';
35
38
  import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, TEST_FILE_PATTERNS, MAX_FILE_SIZE } from '../utils/patterns.js';
36
39
  import { isHighEntropyMatch } from '../utils/entropy.js';
40
+ import { buildOrchestrator } from '../agents/index.js';
41
+ import { ScoringEngine } from '../agents/scoring-engine.js';
42
+ import { autoDetectProvider } from '../providers/llm-provider.js';
43
+ import { DeepAnalyzer } from '../agents/deep-analyzer.js';
37
44
 
38
45
  // =============================================================================
39
46
  // MCP TOOL DEFINITIONS
@@ -80,6 +87,74 @@ const TOOLS = [
80
87
  required: ['path'],
81
88
  },
82
89
  },
90
+ {
91
+ name: 'scan_repo',
92
+ description: 'Run a full multi-agent security scan on a repository or directory. Runs all 20+ ship-safe security agents (injection, auth bypass, secrets, supply chain, LLM security, etc.) and returns a structured findings report with severity ratings and remediation advice. Use this when the user asks to audit, scan, or check the security of their project.',
93
+ inputSchema: {
94
+ type: 'object',
95
+ properties: {
96
+ path: {
97
+ type: 'string',
98
+ description: 'The directory path to scan. Use "." for current directory.',
99
+ },
100
+ agents: {
101
+ type: 'array',
102
+ items: { type: 'string' },
103
+ description: 'Specific agent names to run (optional). Omit to run all agents.',
104
+ },
105
+ llm: {
106
+ type: 'boolean',
107
+ description: 'Enable LLM-powered deep analysis for critical/high findings (default: false). Requires ANTHROPIC_API_KEY or similar env var.',
108
+ },
109
+ outputFile: {
110
+ type: 'string',
111
+ description: 'Optional path to save the JSON report for later retrieval with get_findings.',
112
+ },
113
+ },
114
+ required: ['path'],
115
+ },
116
+ },
117
+ {
118
+ name: 'get_findings',
119
+ description: 'Read and return findings from a ship-safe JSON report file previously saved by scan_repo or the ship-safe CLI (npx ship-safe audit --json). Useful for reviewing or referencing a prior scan without re-running it.',
120
+ inputSchema: {
121
+ type: 'object',
122
+ properties: {
123
+ reportPath: {
124
+ type: 'string',
125
+ description: 'Path to the ship-safe JSON report file (e.g. ship-safe-report.json).',
126
+ },
127
+ severity: {
128
+ type: 'string',
129
+ enum: ['critical', 'high', 'medium', 'low'],
130
+ description: 'Filter findings by minimum severity (optional).',
131
+ },
132
+ },
133
+ required: ['reportPath'],
134
+ },
135
+ },
136
+ {
137
+ name: 'suppress_finding',
138
+ description: 'Add a ship-safe-ignore comment to a specific line in a file to suppress a false-positive security finding. The comment tells ship-safe\'s scanner to skip that line in future scans. Always explain why the suppression is safe.',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ file: {
143
+ type: 'string',
144
+ description: 'Path to the file containing the false-positive finding.',
145
+ },
146
+ line: {
147
+ type: 'number',
148
+ description: 'Line number of the finding to suppress (1-indexed).',
149
+ },
150
+ reason: {
151
+ type: 'string',
152
+ description: 'Brief explanation of why this finding is a false positive (appended to the ignore comment).',
153
+ },
154
+ },
155
+ required: ['file', 'line', 'reason'],
156
+ },
157
+ },
83
158
  ];
84
159
 
85
160
  // =============================================================================
@@ -163,6 +238,192 @@ async function analyzeFile({ path: filePath }) {
163
238
  };
164
239
  }
165
240
 
241
+ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, outputFile }) {
242
+ const rootPath = path.resolve(targetPath);
243
+
244
+ if (!fs.existsSync(rootPath)) {
245
+ return { error: `Path does not exist: ${rootPath}` };
246
+ }
247
+
248
+ // MCP communicates over stdout as JSON-RPC. Suppress all console output during
249
+ // the scan so spinner text and log lines don't pollute the transport stream.
250
+ const noop = () => {};
251
+ const savedLog = console.log;
252
+ const savedWarn = console.warn;
253
+ const savedError = console.error;
254
+ const savedInfo = console.info;
255
+ console.log = console.warn = console.error = console.info = noop;
256
+
257
+ try {
258
+ const orchestrator = buildOrchestrator();
259
+ const context = { rootPath };
260
+
261
+ // Run all agents (quiet:true suppresses ora spinners; console is already nulled)
262
+ const { findings, recon } = await orchestrator.runAll(rootPath, {
263
+ agents: agentFilter,
264
+ timeout: 30000,
265
+ concurrency: 6,
266
+ quiet: true,
267
+ });
268
+
269
+ // Optional: LLM deep analysis
270
+ let deepStats = null;
271
+ if (llm) {
272
+ const provider = autoDetectProvider(rootPath, {});
273
+ if (provider) {
274
+ const analyzer = new DeepAnalyzer({ provider, budgetCents: 50, verbose: false });
275
+ await analyzer.analyze(findings, { rootPath, recon });
276
+ deepStats = analyzer.getStats();
277
+ }
278
+ }
279
+
280
+ // Score
281
+ const scorer = new ScoringEngine();
282
+ const { score, grade } = scorer.score(findings);
283
+
284
+ const SEV_ORDER = ['critical', 'high', 'medium', 'low'];
285
+ const bySeverity = {};
286
+ for (const sev of SEV_ORDER) {
287
+ bySeverity[sev] = findings.filter(f => f.severity === sev).length;
288
+ }
289
+
290
+ const report = {
291
+ scannedAt: new Date().toISOString(),
292
+ rootPath,
293
+ score,
294
+ grade,
295
+ totalFindings: findings.length,
296
+ bySeverity,
297
+ findings: findings.map(f => ({
298
+ title: f.title,
299
+ severity: f.severity,
300
+ category: f.category,
301
+ rule: f.rule,
302
+ file: f.file ? path.relative(rootPath, f.file) : null,
303
+ line: f.line,
304
+ description: f.description,
305
+ remediation: f.remediation,
306
+ confidence: f.confidence,
307
+ ...(f.deepAnalysis ? { deepAnalysis: f.deepAnalysis } : {}),
308
+ })),
309
+ ...(deepStats ? { deepAnalysis: deepStats } : {}),
310
+ summary: `Score: ${score}/100 (${grade}) — ${findings.length} finding(s): ${bySeverity.critical} critical, ${bySeverity.high} high, ${bySeverity.medium} medium, ${bySeverity.low} low.`,
311
+ };
312
+
313
+ if (outputFile) {
314
+ const outPath = path.resolve(outputFile);
315
+ fs.writeFileSync(outPath, JSON.stringify(report, null, 2), 'utf-8');
316
+ report.savedTo = outPath;
317
+ }
318
+
319
+ return report;
320
+ } catch (err) {
321
+ return { error: `Scan failed: ${err.message}` };
322
+ } finally {
323
+ // Always restore console so other tool calls are not affected
324
+ console.log = savedLog;
325
+ console.warn = savedWarn;
326
+ console.error = savedError;
327
+ console.info = savedInfo;
328
+ }
329
+ }
330
+
331
+ function getFindings({ reportPath, severity }) {
332
+ const absPath = path.resolve(reportPath);
333
+
334
+ if (!fs.existsSync(absPath)) {
335
+ return { error: `Report file not found: ${absPath}` };
336
+ }
337
+
338
+ let report;
339
+ try {
340
+ report = JSON.parse(fs.readFileSync(absPath, 'utf-8'));
341
+ } catch (err) {
342
+ return { error: `Failed to parse report: ${err.message}` };
343
+ }
344
+
345
+ const findings = report.findings ?? [];
346
+ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
347
+ const filtered = severity
348
+ ? findings.filter(f => (SEV_RANK[f.severity] ?? 0) >= (SEV_RANK[severity] ?? 0))
349
+ : findings;
350
+
351
+ return {
352
+ reportPath: absPath,
353
+ scannedAt: report.scannedAt,
354
+ score: report.score,
355
+ grade: report.grade,
356
+ totalFindings: filtered.length,
357
+ bySeverity: report.bySeverity,
358
+ findings: filtered,
359
+ summary: report.summary,
360
+ ...(severity ? { filter: `severity >= ${severity}` } : {}),
361
+ };
362
+ }
363
+
364
+ function suppressFinding({ file, line, reason }) {
365
+ const absPath = path.resolve(file);
366
+
367
+ if (!fs.existsSync(absPath)) {
368
+ return { error: `File not found: ${absPath}` };
369
+ }
370
+
371
+ let content;
372
+ try {
373
+ content = fs.readFileSync(absPath, 'utf-8');
374
+ } catch (err) {
375
+ return { error: `Cannot read file: ${err.message}` };
376
+ }
377
+
378
+ const lines = content.split('\n');
379
+ const lineIdx = line - 1; // Convert to 0-indexed
380
+
381
+ if (lineIdx < 0 || lineIdx >= lines.length) {
382
+ return { error: `Line ${line} is out of range (file has ${lines.length} lines)` };
383
+ }
384
+
385
+ const targetLine = lines[lineIdx];
386
+
387
+ // Already suppressed?
388
+ if (/ship-safe-ignore/i.test(targetLine)) {
389
+ return { alreadySuppressed: true, file: absPath, line, message: 'Line already has a ship-safe-ignore comment.' };
390
+ }
391
+
392
+ // Detect indentation and comment style
393
+ const indent = targetLine.match(/^(\s*)/)?.[1] ?? '';
394
+ const isJs = /\.(js|ts|jsx|tsx|mjs|cjs|java|c|cpp|cs|go|rs|swift|kt)$/.test(file);
395
+ const isPy = /\.py$/.test(file);
396
+ const isRb = /\.rb$/.test(file);
397
+ const isHtml = /\.(html?|vue|svelte)$/.test(file);
398
+
399
+ let ignoreComment;
400
+ if (isHtml) {
401
+ ignoreComment = `${indent}<!-- ship-safe-ignore — ${reason} -->`;
402
+ } else if (isPy || isRb) {
403
+ ignoreComment = `${indent}# ship-safe-ignore — ${reason}`;
404
+ } else {
405
+ ignoreComment = `${indent}// ship-safe-ignore — ${reason}`;
406
+ }
407
+
408
+ // Insert ignore comment on the line BEFORE the finding
409
+ lines.splice(lineIdx, 0, ignoreComment);
410
+
411
+ try {
412
+ fs.writeFileSync(absPath, lines.join('\n'), 'utf-8');
413
+ } catch (err) {
414
+ return { error: `Cannot write file: ${err.message}` };
415
+ }
416
+
417
+ return {
418
+ suppressed: true,
419
+ file: absPath,
420
+ originalLine: line,
421
+ insertedLine: line, // The ignore comment is now on this line, original moved to line+1
422
+ comment: ignoreComment,
423
+ message: `Added ship-safe-ignore comment before line ${line} in ${path.basename(file)}.`,
424
+ };
425
+ }
426
+
166
427
  // =============================================================================
167
428
  // SCAN UTILITIES (shared with scan command)
168
429
  // =============================================================================
@@ -283,6 +544,15 @@ async function handleRequest(request) {
283
544
  case 'analyze_file':
284
545
  result = await analyzeFile(args);
285
546
  break;
547
+ case 'scan_repo':
548
+ result = await scanRepo(args);
549
+ break;
550
+ case 'get_findings':
551
+ result = getFindings(args);
552
+ break;
553
+ case 'suppress_finding':
554
+ result = suppressFinding(args);
555
+ break;
286
556
  default:
287
557
  return respondError(-32601, `Unknown tool: ${name}`);
288
558
  }