ship-safe 5.0.1 → 6.1.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.
Files changed (43) hide show
  1. package/README.md +110 -23
  2. package/cli/agents/abom-generator.js +225 -0
  3. package/cli/agents/agent-config-scanner.js +547 -0
  4. package/cli/agents/agentic-security-agent.js +1 -1
  5. package/cli/agents/api-fuzzer.js +1 -1
  6. package/cli/agents/auth-bypass-agent.js +2 -2
  7. package/cli/agents/config-auditor.js +3 -11
  8. package/cli/agents/exception-handler-agent.js +187 -0
  9. package/cli/agents/html-reporter.js +532 -370
  10. package/cli/agents/index.js +11 -1
  11. package/cli/agents/mcp-security-agent.js +182 -0
  12. package/cli/agents/pii-compliance-agent.js +4 -4
  13. package/cli/agents/scoring-engine.js +25 -6
  14. package/cli/agents/vibe-coding-agent.js +250 -0
  15. package/cli/bin/ship-safe.js +96 -6
  16. package/cli/commands/abom.js +73 -0
  17. package/cli/commands/agent.js +4 -4
  18. package/cli/commands/audit.js +15 -7
  19. package/cli/commands/baseline.js +1 -1
  20. package/cli/commands/benchmark.js +327 -0
  21. package/cli/commands/ci.js +81 -1
  22. package/cli/commands/deps.js +73 -4
  23. package/cli/commands/diff.js +200 -0
  24. package/cli/commands/doctor.js +14 -4
  25. package/cli/commands/fix.js +1 -1
  26. package/cli/commands/guard.js +99 -0
  27. package/cli/commands/init.js +407 -349
  28. package/cli/commands/openclaw.js +378 -0
  29. package/cli/commands/red-team.js +2 -2
  30. package/cli/commands/remediate.js +153 -7
  31. package/cli/commands/scan-skill.js +329 -0
  32. package/cli/commands/update-intel.js +55 -0
  33. package/cli/commands/vibe-check.js +276 -0
  34. package/cli/commands/watch.js +124 -4
  35. package/cli/data/threat-intel.json +85 -0
  36. package/cli/index.js +9 -0
  37. package/cli/utils/cache-manager.js +1 -1
  38. package/cli/utils/compliance-map.js +125 -0
  39. package/cli/utils/output.js +5 -2
  40. package/cli/utils/patterns.js +3 -0
  41. package/cli/utils/pdf-generator.js +1 -1
  42. package/cli/utils/threat-intel.js +167 -0
  43. package/package.json +2 -2
@@ -19,6 +19,7 @@
19
19
 
20
20
  import fs from 'fs';
21
21
  import path from 'path';
22
+ import { execFileSync } from 'child_process';
22
23
  import { buildOrchestrator } from '../agents/index.js';
23
24
  import { ScoringEngine } from '../agents/scoring-engine.js';
24
25
  import { PolicyEngine } from '../agents/policy-engine.js';
@@ -85,7 +86,7 @@ export async function ciCommand(targetPath = '.', options = {}) {
85
86
 
86
87
  // ── Agent Scan ───────────────────────────────────────────────────────────
87
88
  const orchestrator = buildOrchestrator();
88
- const results = await orchestrator.runAll(absolutePath, { quiet: true });
89
+ const results = await orchestrator.runAll(absolutePath, { quiet: true }); // ship-safe-ignore — orchestrator result, not LLM output triggering actions
89
90
  const agentFindings = results.findings;
90
91
 
91
92
  // ── Dependency Audit ─────────────────────────────────────────────────────
@@ -164,6 +165,15 @@ export async function ciCommand(targetPath = '.', options = {}) {
164
165
  }
165
166
  }
166
167
 
168
+ // ── GitHub PR Comment ──────────────────────────────────────────────────
169
+ if (options.githubPr) {
170
+ try {
171
+ postPRComment(scoreResult, allFindings, depVulns, absolutePath, duration);
172
+ } catch (err) {
173
+ console.log(`[ship-safe] Warning: Could not post PR comment: ${err.message}`);
174
+ }
175
+ }
176
+
167
177
  // ── Exit Code ────────────────────────────────────────────────────────────
168
178
  const pass = determinePass(scoreResult, allFindings, threshold, failOn);
169
179
  if (!pass) {
@@ -242,6 +252,76 @@ function buildSARIF(findings, rootPath) {
242
252
  };
243
253
  }
244
254
 
255
+ /**
256
+ * Post a summary comment on the current GitHub PR using the `gh` CLI.
257
+ * Requires: `gh` installed and authenticated, running in a PR context.
258
+ */
259
+ function postPRComment(scoreResult, findings, depVulns, rootPath, duration) {
260
+ // Detect PR number from environment (GitHub Actions sets GITHUB_REF)
261
+ let prNumber = process.env.GITHUB_PR_NUMBER || '';
262
+
263
+ if (!prNumber) {
264
+ // Try to detect from GITHUB_REF (refs/pull/123/merge)
265
+ const ref = process.env.GITHUB_REF || '';
266
+ const match = ref.match(/refs\/pull\/(\d+)\//);
267
+ if (match) prNumber = match[1];
268
+ }
269
+
270
+ if (!prNumber) {
271
+ // Try gh pr view to get current PR
272
+ try {
273
+ const prJson = execFileSync('gh', ['pr', 'view', '--json', 'number'], { // ship-safe-ignore — execFileSync, not MCP
274
+ cwd: rootPath, stdio: ['pipe', 'pipe', 'pipe'], // ship-safe-ignore
275
+ }).toString();
276
+ const parsed = JSON.parse(prJson);
277
+ prNumber = String(parsed.number);
278
+ } catch {
279
+ console.log('[ship-safe] No PR detected — skipping PR comment');
280
+ return;
281
+ }
282
+ }
283
+
284
+ const critical = findings.filter(f => f.severity === 'critical').length;
285
+ const high = findings.filter(f => f.severity === 'high').length;
286
+ const medium = findings.filter(f => f.severity === 'medium').length;
287
+ const low = findings.filter(f => f.severity === 'low').length;
288
+
289
+ const gradeEmoji = { A: '🟢', B: '🔵', C: '🟡', D: '🟠', F: '🔴' };
290
+ const emoji = gradeEmoji[scoreResult.grade.letter] || '⚪';
291
+
292
+ // Build markdown body
293
+ let body = `## ${emoji} Ship Safe Security Report\n\n`;
294
+ body += `| Metric | Value |\n|--------|-------|\n`;
295
+ body += `| **Score** | ${scoreResult.score}/100 (${scoreResult.grade.letter}) |\n`;
296
+ body += `| **Findings** | ${findings.length} total (${critical}C ${high}H ${medium}M ${low}L) |\n`;
297
+ body += `| **Dep CVEs** | ${depVulns.length} |\n`;
298
+ body += `| **Duration** | ${duration}s |\n\n`;
299
+
300
+ if (critical > 0 || high > 0) {
301
+ body += `### Critical & High Findings\n\n`;
302
+ body += `| Severity | File | Issue |\n|----------|------|-------|\n`;
303
+ for (const f of findings.filter(f => f.severity === 'critical' || f.severity === 'high').slice(0, 20)) {
304
+ const rel = path.relative(rootPath, f.file).replace(/\\/g, '/');
305
+ body += `| ${f.severity.toUpperCase()} | \`${rel}:${f.line}\` | ${(f.title || f.rule).slice(0, 60)} |\n`;
306
+ }
307
+ body += '\n';
308
+ }
309
+
310
+ if (findings.length === 0 && depVulns.length === 0) {
311
+ body += '> No security issues found — looking good! 🎉\n\n';
312
+ }
313
+
314
+ body += `<sub>Generated by <a href="https://shipsafecli.com">Ship Safe</a></sub>`;
315
+
316
+ // Post comment via gh CLI
317
+ execFileSync('gh', ['pr', 'comment', prNumber, '--body', body], { // ship-safe-ignore — execFileSync, not MCP
318
+ cwd: rootPath,
319
+ stdio: ['pipe', 'pipe', 'pipe'], // ship-safe-ignore
320
+ });
321
+
322
+ console.log(`[ship-safe] PR comment posted on #${prNumber}`);
323
+ }
324
+
245
325
  async function findFiles(rootPath) {
246
326
  const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
247
327
  const gitignoreGlobs = loadGitignorePatterns(rootPath);
@@ -87,15 +87,34 @@ export async function depsCommand(targetPath = '.', options = {}) {
87
87
  process.exit(0);
88
88
  }
89
89
 
90
- // ── 4. Display findings ───────────────────────────────────────────────────
90
+ // ── 4. Enrich with EPSS scores ──────────────────────────────────────────
91
+ const cves = vulns.map(v => v.cve).filter(Boolean);
92
+ if (cves.length > 0) {
93
+ const epssSpinner = ora({ text: 'Fetching EPSS exploit probability scores...', color: 'cyan' }).start();
94
+ try {
95
+ const epssData = await fetchEPSS(cves);
96
+ for (const v of vulns) {
97
+ if (v.cve && epssData[v.cve]) {
98
+ v.epss = epssData[v.cve].epss;
99
+ v.percentile = epssData[v.cve].percentile;
100
+ }
101
+ }
102
+ epssSpinner.succeed(chalk.gray(`EPSS scores fetched for ${Object.keys(epssData).length} CVE(s)`));
103
+ } catch {
104
+ epssSpinner.stop();
105
+ // EPSS is optional — continue without it
106
+ }
107
+ }
108
+
109
+ // ── 5. Display findings ───────────────────────────────────────────────────
91
110
  printDepFindings(vulns, pm);
92
111
 
93
- // ── 5. Optionally fix ─────────────────────────────────────────────────────
112
+ // ── 6. Optionally fix ─────────────────────────────────────────────────────
94
113
  if (options.fix) {
95
114
  console.log();
96
115
  console.log(chalk.cyan(` Running: ${pm.fixCommand}`));
97
116
  try {
98
- execSync(pm.fixCommand, { cwd: absolutePath, stdio: 'inherit' });
117
+ execSync(pm.fixCommand, { cwd: absolutePath, stdio: 'inherit' }); // ship-safe-ignore — command is a hardcoded package manager command, not user input
99
118
  } catch {
100
119
  output.warning('Fix command exited with errors — some vulnerabilities may require manual updates.');
101
120
  }
@@ -178,7 +197,7 @@ function detectPackageManager(rootPath) {
178
197
  function runAudit(pm, cwd) {
179
198
  let stdout;
180
199
  try {
181
- stdout = execSync(pm.auditCommand, { cwd, stdio: ['pipe', 'pipe', 'pipe'] }).toString();
200
+ stdout = execSync(pm.auditCommand, { cwd, stdio: ['pipe', 'pipe', 'pipe'] }).toString(); // ship-safe-ignore — command is a hardcoded package manager audit command, not user input
182
201
  } catch (err) {
183
202
  // npm/yarn/pnpm exit with code 1 when vulns found — that's expected
184
203
  if (err.stdout) {
@@ -366,6 +385,46 @@ function parseBundlerAudit(text) {
366
385
  return vulns;
367
386
  }
368
387
 
388
+ // =============================================================================
389
+ // EPSS (Exploit Prediction Scoring System)
390
+ // =============================================================================
391
+
392
+ /**
393
+ * Fetch EPSS scores from FIRST.org API for a list of CVEs.
394
+ * Returns { 'CVE-2023-1234': { epss: 0.942, percentile: 0.99 }, ... }
395
+ * API docs: https://www.first.org/epss/api
396
+ */
397
+ async function fetchEPSS(cves) {
398
+ if (cves.length === 0) return {};
399
+
400
+ // API accepts up to 100 CVEs per request — batch if needed
401
+ const results = {};
402
+ const batches = [];
403
+ for (let i = 0; i < cves.length; i += 100) {
404
+ batches.push(cves.slice(i, i + 100));
405
+ }
406
+
407
+ for (const batch of batches) {
408
+ const url = `https://api.first.org/data/v1/epss?cve=${batch.join(',')}`; // ship-safe-ignore — hardcoded FIRST.org API endpoint, CVE IDs are from audit results not user input
409
+ const response = await fetch(url, { // ship-safe-ignore — EPSS API fetch with fixed base URL
410
+ headers: { 'Accept': 'application/json' },
411
+ signal: AbortSignal.timeout(10000),
412
+ });
413
+
414
+ if (!response.ok) continue;
415
+
416
+ const json = await response.json();
417
+ for (const entry of (json.data || [])) {
418
+ results[entry.cve] = {
419
+ epss: parseFloat(entry.epss),
420
+ percentile: parseFloat(entry.percentile),
421
+ };
422
+ }
423
+ }
424
+
425
+ return results;
426
+ }
427
+
369
428
  // =============================================================================
370
429
  // OUTPUT
371
430
  // =============================================================================
@@ -408,6 +467,16 @@ function printDepFindings(vulns, pm) {
408
467
  if (v.cve) {
409
468
  console.log(chalk.gray(` ${v.cve}`) + (v.url ? chalk.gray(` ${v.url}`) : ''));
410
469
  }
470
+ if (v.epss != null) {
471
+ const pct = (v.epss * 100).toFixed(1);
472
+ const epssColor = v.epss >= 0.5 ? chalk.red.bold
473
+ : v.epss >= 0.1 ? chalk.yellow
474
+ : chalk.gray;
475
+ const label = v.epss >= 0.5 ? ' — actively exploited in the wild'
476
+ : v.epss >= 0.1 ? ' — elevated exploit activity'
477
+ : '';
478
+ console.log(epssColor(` EPSS: ${pct}% exploit probability`) + chalk.gray(label));
479
+ }
411
480
  if (v.fix) {
412
481
  console.log(chalk.gray(' Fix: ') + chalk.cyan(v.fix));
413
482
  }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Diff Command
3
+ * =============
4
+ *
5
+ * Scan only changed files (git diff) for security issues.
6
+ * Much faster than a full audit — perfect for pre-commit hooks and PR reviews.
7
+ *
8
+ * USAGE:
9
+ * ship-safe diff Scan uncommitted changes (staged + unstaged)
10
+ * ship-safe diff --staged Scan only staged changes
11
+ * ship-safe diff HEAD~3 Scan changes in last 3 commits
12
+ * ship-safe diff main Scan changes since branching from main
13
+ */
14
+
15
+ import { execFileSync } from 'child_process';
16
+ import path from 'path';
17
+ import chalk from 'chalk';
18
+ import ora from 'ora';
19
+ import { SECRET_PATTERNS, SECURITY_PATTERNS, SKIP_EXTENSIONS, SKIP_FILENAMES } from '../utils/patterns.js';
20
+ import { buildOrchestrator } from '../agents/index.js';
21
+ import { ScoringEngine } from '../agents/scoring-engine.js';
22
+
23
+ // =============================================================================
24
+ // DIFF COMMAND
25
+ // =============================================================================
26
+
27
+ export async function diffCommand(ref, options) {
28
+ const targetPath = options.path || process.cwd();
29
+ const absolutePath = path.resolve(targetPath);
30
+
31
+ // ── Get changed files from git ────────────────────────────────────────────
32
+ const spinner = ora(chalk.white('Getting changed files from git...')).start();
33
+
34
+ let gitArgs;
35
+ if (options.staged) {
36
+ gitArgs = ['diff', '--cached', '--name-only', '--diff-filter=ACMR'];
37
+ } else if (ref) {
38
+ gitArgs = ['diff', '--name-only', '--diff-filter=ACMR', ref];
39
+ } else {
40
+ // Uncommitted changes: both staged and unstaged
41
+ gitArgs = ['diff', '--name-only', '--diff-filter=ACMR', 'HEAD'];
42
+ }
43
+
44
+ let changedFiles;
45
+ try {
46
+ const output = execFileSync('git', gitArgs, {
47
+ cwd: absolutePath,
48
+ encoding: 'utf-8',
49
+ timeout: 10_000,
50
+ }).trim();
51
+
52
+ if (!output) {
53
+ spinner.succeed(chalk.green('No changed files detected'));
54
+ console.log(chalk.gray('\n Nothing to scan. Your working tree is clean.\n'));
55
+ return;
56
+ }
57
+
58
+ changedFiles = output
59
+ .split('\n')
60
+ .map(f => f.trim())
61
+ .filter(f => f.length > 0)
62
+ .map(f => path.resolve(absolutePath, f))
63
+ .filter(f => {
64
+ const ext = path.extname(f).toLowerCase();
65
+ if (SKIP_EXTENSIONS.has(ext)) return false;
66
+ const basename = path.basename(f);
67
+ if (SKIP_FILENAMES.has(basename)) return false;
68
+ return true;
69
+ });
70
+
71
+ spinner.succeed(chalk.white(`${changedFiles.length} changed file(s) to scan`));
72
+ } catch (err) {
73
+ // Fallback for repos with no commits yet
74
+ if (err.message?.includes('unknown revision')) {
75
+ try {
76
+ const output = execFileSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], {
77
+ cwd: absolutePath,
78
+ encoding: 'utf-8',
79
+ timeout: 10_000,
80
+ }).trim();
81
+
82
+ changedFiles = output
83
+ .split('\n')
84
+ .map(f => f.trim())
85
+ .filter(f => f.length > 0)
86
+ .map(f => path.resolve(absolutePath, f))
87
+ .filter(f => {
88
+ const ext = path.extname(f).toLowerCase();
89
+ if (SKIP_EXTENSIONS.has(ext)) return false;
90
+ const basename = path.basename(f);
91
+ if (SKIP_FILENAMES.has(basename)) return false;
92
+ return true;
93
+ });
94
+
95
+ if (!changedFiles.length) {
96
+ spinner.succeed(chalk.green('No changed files detected'));
97
+ return;
98
+ }
99
+ spinner.succeed(chalk.white(`${changedFiles.length} changed file(s) to scan`));
100
+ } catch {
101
+ spinner.fail(chalk.red('Not a git repository or git not available'));
102
+ process.exit(1);
103
+ }
104
+ } else {
105
+ spinner.fail(chalk.red('Failed to get changed files from git'));
106
+ console.error(chalk.gray(` ${err.message}`));
107
+ process.exit(1);
108
+ }
109
+ }
110
+
111
+ if (changedFiles.length === 0) {
112
+ console.log(chalk.gray('\n No scannable files in the diff.\n'));
113
+ return;
114
+ }
115
+
116
+ // ── Print header ──────────────────────────────────────────────────────────
117
+ console.log();
118
+ console.log(chalk.cyan('═'.repeat(60)));
119
+ console.log(chalk.cyan.bold(' Ship Safe — Diff Scan'));
120
+ console.log(chalk.cyan('═'.repeat(60)));
121
+ console.log();
122
+ console.log(chalk.gray(` Scanning ${changedFiles.length} changed file(s):`));
123
+ for (const f of changedFiles.slice(0, 10)) {
124
+ console.log(chalk.gray(` ${path.relative(absolutePath, f)}`));
125
+ }
126
+ if (changedFiles.length > 10) {
127
+ console.log(chalk.gray(` ... and ${changedFiles.length - 10} more`));
128
+ }
129
+ console.log();
130
+
131
+ // ── Run agents on changed files only ──────────────────────────────────────
132
+ const agentSpinner = ora(chalk.white('Running security agents on diff...')).start();
133
+
134
+ const orchestrator = buildOrchestrator();
135
+ const results = await orchestrator.runAll(absolutePath, {
136
+ timeout: options.timeout || 30_000,
137
+ changedFiles,
138
+ });
139
+
140
+ const findings = results.findings || [];
141
+ agentSpinner.succeed(
142
+ findings.length === 0
143
+ ? chalk.green('No security issues in changed files')
144
+ : chalk.yellow(`${findings.length} finding(s) in changed files`)
145
+ );
146
+
147
+ // ── Score ─────────────────────────────────────────────────────────────────
148
+ if (findings.length > 0) {
149
+ const scoringEngine = new ScoringEngine();
150
+ const scoreResult = scoringEngine.compute(findings, []);
151
+ scoreResult.score = Math.round(scoreResult.score * 10) / 10;
152
+
153
+ const scoreColor = scoreResult.score >= 75 ? chalk.green.bold : scoreResult.score >= 60 ? chalk.yellow.bold : chalk.red.bold;
154
+
155
+ console.log();
156
+ console.log(chalk.cyan(' ' + '─'.repeat(56)));
157
+
158
+ // Print findings
159
+ let shown = 0;
160
+ for (const f of findings) {
161
+ if (shown >= 20) {
162
+ console.log(chalk.gray(`\n ... and ${findings.length - 20} more findings`));
163
+ break;
164
+ }
165
+ const sevColor = f.severity === 'critical' ? chalk.red :
166
+ f.severity === 'high' ? chalk.yellow :
167
+ f.severity === 'medium' ? chalk.cyan : chalk.gray;
168
+ const relPath = path.relative(absolutePath, f.file);
169
+ console.log(` ${sevColor(`[${f.severity.toUpperCase()}]`)} ${chalk.white(f.title)}`);
170
+ console.log(chalk.gray(` ${relPath}:${f.line} → ${f.fix || f.description}`));
171
+ shown++;
172
+ }
173
+
174
+ console.log();
175
+ console.log(chalk.cyan(' ' + '─'.repeat(56)));
176
+ console.log(
177
+ chalk.white.bold(' Diff Score: ') +
178
+ scoreColor(`${scoreResult.score}/100 ${scoreResult.grade.letter}`)
179
+ );
180
+ console.log(chalk.cyan(' ' + '─'.repeat(56)));
181
+ }
182
+
183
+ // ── JSON output ───────────────────────────────────────────────────────────
184
+ if (options.json) {
185
+ const output = {
186
+ command: 'diff',
187
+ ref: ref || (options.staged ? '--staged' : 'HEAD'),
188
+ changedFiles: changedFiles.map(f => path.relative(absolutePath, f)),
189
+ findings,
190
+ totalFindings: findings.length,
191
+ };
192
+ console.log(JSON.stringify(output, null, 2));
193
+ }
194
+
195
+ console.log();
196
+ console.log(chalk.cyan('═'.repeat(60)));
197
+ console.log();
198
+
199
+ process.exit(findings.length > 0 ? 1 : 0);
200
+ }
@@ -17,10 +17,20 @@ import { readFileSync } from 'fs';
17
17
  import { fileURLToPath } from 'url';
18
18
  import { dirname, join } from 'path';
19
19
 
20
- const __filename = fileURLToPath(import.meta.url);
20
+ const __filename = fileURLToPath(import.meta.url); // ship-safe-ignore — module's own path via import.meta.url, not user input
21
21
  const __dirname = dirname(__filename);
22
22
  const PACKAGE_VERSION = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')).version;
23
23
 
24
+ function isNewerVersion(latest, current) {
25
+ const a = latest.split('.').map(Number);
26
+ const b = current.split('.').map(Number);
27
+ for (let i = 0; i < 3; i++) {
28
+ if ((a[i] || 0) > (b[i] || 0)) return true;
29
+ if ((a[i] || 0) < (b[i] || 0)) return false;
30
+ }
31
+ return false;
32
+ }
33
+
24
34
  export async function doctorCommand() {
25
35
  console.log();
26
36
  console.log(chalk.cyan.bold(' Ship Safe Doctor'));
@@ -66,8 +76,8 @@ export async function doctorCommand() {
66
76
 
67
77
  // 4. API keys
68
78
  const apiKeys = [
69
- { name: 'Anthropic API key', env: 'ANTHROPIC_API_KEY', required: false },
70
- { name: 'OpenAI API key', env: 'OPENAI_API_KEY', required: false },
79
+ { name: 'Anthropic API key', env: 'ANTHROPIC_API_KEY', required: false }, // ship-safe-ignore — env var names in diagnostic check, no key values
80
+ { name: 'OpenAI API key', env: 'OPENAI_API_KEY', required: false }, // ship-safe-ignore — env var name in diagnostic check, no key value
71
81
  { name: 'Google AI API key', env: 'GOOGLE_API_KEY', required: false },
72
82
  ];
73
83
  for (const key of apiKeys) {
@@ -115,7 +125,7 @@ export async function doctorCommand() {
115
125
  const latest = execFileSync('npm', ['view', 'ship-safe', 'version'], {
116
126
  encoding: 'utf-8', timeout: 5000, shell: true,
117
127
  }).trim();
118
- if (latest && latest !== PACKAGE_VERSION) {
128
+ if (latest && latest !== PACKAGE_VERSION && isNewerVersion(latest, PACKAGE_VERSION)) {
119
129
  const msg = ['v', latest, ' available (current: v', PACKAGE_VERSION, ')'].join('');
120
130
  info(msg);
121
131
  } else if (latest) {
@@ -148,7 +148,7 @@ function buildEnvVarSuggestions(results) {
148
148
 
149
149
  /**
150
150
  * Convert a pattern name to a sensible env var name.
151
- * e.g. "OpenAI API Key" → "OPENAI_API_KEY"
151
+ * e.g. "OpenAI API Key" → "OPENAI_API_KEY" // ship-safe-ignore — env var name in doc comment, not a secret value
152
152
  */
153
153
  function patternToEnvVar(patternName) {
154
154
  return patternName
@@ -136,6 +136,10 @@ export async function guardCommand(action, options = {}) {
136
136
  process.exit(1);
137
137
  }
138
138
 
139
+ if (options.generateHooks) {
140
+ return generateClaudeHooks(cwd);
141
+ }
142
+
139
143
  if (action === 'remove') {
140
144
  return removeHooks(gitDir, cwd);
141
145
  }
@@ -278,6 +282,101 @@ function removeHooks(gitDir, cwd) {
278
282
  }
279
283
  }
280
284
 
285
+ // =============================================================================
286
+ // CLAUDE CODE DEFENSIVE HOOKS
287
+ // =============================================================================
288
+
289
+ function generateClaudeHooks(cwd) {
290
+ output.header('Generating Defensive Claude Code Hooks');
291
+
292
+ const claudeDir = path.join(cwd, '.claude');
293
+ const settingsPath = path.join(claudeDir, 'settings.json');
294
+
295
+ // Defensive hooks that block common attack patterns
296
+ const preToolCmd = [
297
+ 'node -e "',
298
+ 'const c=process.argv[1]||String();',
299
+ 'const bad=[/curl.*[|].*(?:bash|sh|node|python)/i,/wget.*[|].*(?:bash|sh)/i,',
300
+ '/rm\\s+-rf\\s+\\//,/webhook[.]site|requestbin|ngrok[.]io|pipedream/i,',
301
+ '/base64.*-d.*[|].*(?:bash|sh)/i];',
302
+ 'const m=bad.find(r=>r.test(c));',
303
+ 'if(m){console.error(String.fromCharCode(10060)+String.fromCharCode(32)+c.slice(0,80));process.exit(1)}',
304
+ '" "$INPUT"',
305
+ ].join('');
306
+
307
+ const postToolCmd = [
308
+ 'node -e "',
309
+ 'const f=process.argv[1]||String();',
310
+ 'if(/[.]env$|[.]env[.]/.test(f)){console.log(String.fromCharCode(9888)+String.fromCharCode(32)+f)}',
311
+ '" "$INPUT"',
312
+ ].join('');
313
+
314
+ const defensiveHooks = {
315
+ hooks: {
316
+ PreToolUse: [
317
+ {
318
+ matcher: 'Bash',
319
+ command: preToolCmd,
320
+ description: 'Ship Safe: Block dangerous command patterns (curl|bash, rm -rf /, exfil domains)',
321
+ },
322
+ ],
323
+ PostToolUse: [
324
+ {
325
+ matcher: 'Write',
326
+ command: postToolCmd,
327
+ description: 'Ship Safe: Alert when .env files are modified',
328
+ },
329
+ ],
330
+ },
331
+ };
332
+
333
+ // Merge with existing settings
334
+ let existing = {};
335
+ if (fs.existsSync(settingsPath)) {
336
+ try {
337
+ existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
338
+ } catch { /* fresh start */ }
339
+ }
340
+
341
+ // Merge hooks — don't overwrite existing hooks, append
342
+ if (!existing.hooks) existing.hooks = {};
343
+ if (!existing.hooks.PreToolUse) existing.hooks.PreToolUse = [];
344
+ if (!existing.hooks.PostToolUse) existing.hooks.PostToolUse = [];
345
+
346
+ // Check if ship-safe hooks already present
347
+ const hasPreHook = existing.hooks.PreToolUse.some(h => h.description?.includes('Ship Safe'));
348
+ const hasPostHook = existing.hooks.PostToolUse.some(h => h.description?.includes('Ship Safe'));
349
+
350
+ if (hasPreHook && hasPostHook) {
351
+ output.warning('Ship Safe hooks already installed in .claude/settings.json');
352
+ return;
353
+ }
354
+
355
+ if (!hasPreHook) {
356
+ existing.hooks.PreToolUse.push(...defensiveHooks.hooks.PreToolUse);
357
+ }
358
+ if (!hasPostHook) {
359
+ existing.hooks.PostToolUse.push(...defensiveHooks.hooks.PostToolUse);
360
+ }
361
+
362
+ // Write
363
+ if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
364
+ fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + '\n');
365
+
366
+ output.success('Defensive hooks installed in .claude/settings.json');
367
+ console.log();
368
+ console.log(chalk.gray(' Hooks installed:'));
369
+ console.log(chalk.gray(' PreToolUse → Block curl|bash, rm -rf /, exfil domains'));
370
+ console.log(chalk.gray(' PostToolUse → Alert on .env file modifications'));
371
+ console.log();
372
+ console.log(chalk.gray(' These hooks protect against:'));
373
+ console.log(chalk.gray(' • Remote code execution via piped downloads'));
374
+ console.log(chalk.gray(' • Data exfiltration to webhook.site/ngrok/requestbin'));
375
+ console.log(chalk.gray(' • Destructive filesystem operations'));
376
+ console.log(chalk.gray(' • Unauthorized .env modifications'));
377
+ console.log();
378
+ }
379
+
281
380
  // =============================================================================
282
381
  // UTILITIES
283
382
  // =============================================================================