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.
@@ -13,6 +13,7 @@
13
13
  import fs from 'fs';
14
14
  import path from 'path';
15
15
  import chalk from 'chalk';
16
+ import { execFileSync } from 'child_process';
16
17
  import { SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, SECRET_PATTERNS, SECURITY_PATTERNS } from '../utils/patterns.js';
17
18
  import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
18
19
  import * as output from '../utils/output.js';
@@ -289,11 +290,13 @@ function showWatchStatus(rootPath) {
289
290
  // =============================================================================
290
291
 
291
292
  async function watchDeep(absolutePath, options = {}) {
292
- const { buildOrchestrator } = await import('../agents/index.js');
293
+ const { buildOrchestratorAsync } = await import('../agents/index.js');
293
294
  const { ReconAgent } = await import('../agents/recon-agent.js');
294
295
 
295
- const debounceMs = options.debounce || 1500;
296
- const threshold = options.threshold || null;
296
+ const debounceMs = options.debounce || 1500;
297
+ const threshold = options.threshold || null;
298
+ const slackWebhook = options.slack || process.env.SHIP_SAFE_SLACK_WEBHOOK || null;
299
+ const prComments = options.prComment || false;
297
300
  const scoringEngine = new ScoringEngine();
298
301
 
299
302
  console.log();
@@ -301,7 +304,9 @@ async function watchDeep(absolutePath, options = {}) {
301
304
  console.log();
302
305
  console.log(chalk.cyan(' Running full agent scans on file changes'));
303
306
  console.log(chalk.gray(` Debounce: ${debounceMs}ms`));
304
- if (threshold) console.log(chalk.gray(` Threshold: ${threshold}/100`));
307
+ if (threshold) console.log(chalk.gray(` Threshold: ${threshold}/100`));
308
+ if (slackWebhook) console.log(chalk.gray(' Slack: notifications enabled'));
309
+ if (prComments) console.log(chalk.gray(' PR: inline comments enabled (requires gh CLI)'));
305
310
  console.log(chalk.gray(' Press Ctrl+C to stop'));
306
311
  console.log();
307
312
 
@@ -332,7 +337,7 @@ async function watchDeep(absolutePath, options = {}) {
332
337
  console.log(chalk.gray(` [${timestamp}] ${files.length} file(s) changed — deep scanning...`));
333
338
 
334
339
  try {
335
- const orchestrator = buildOrchestrator();
340
+ const orchestrator = await buildOrchestratorAsync(absolutePath, { quiet: true });
336
341
  const context = {
337
342
  rootPath: absolutePath,
338
343
  files,
@@ -391,6 +396,16 @@ async function watchDeep(absolutePath, options = {}) {
391
396
  if (threshold && scoreResult.score < threshold) {
392
397
  console.log(chalk.red.bold(` ⚠ Score ${scoreResult.score} below threshold ${threshold}\n`));
393
398
  }
399
+
400
+ // ── Slack Notification ──────────────────────────────────────────────
401
+ if (slackWebhook && findings.length > 0) {
402
+ await postSlackAlert(slackWebhook, findings, scoreResult, absolutePath).catch(() => {});
403
+ }
404
+
405
+ // ── GitHub PR Inline Comments ────────────────────────────────────────
406
+ if (prComments && findings.length > 0) {
407
+ await postPRComments(findings, absolutePath).catch(() => {});
408
+ }
394
409
  } catch (err) {
395
410
  console.log(chalk.red(` [${timestamp}] Scan error: ${err.message}\n`));
396
411
  }
@@ -431,6 +446,128 @@ async function watchDeep(absolutePath, options = {}) {
431
446
  }
432
447
  }
433
448
 
449
+ // =============================================================================
450
+ // SLACK NOTIFICATIONS
451
+ // =============================================================================
452
+
453
+ /**
454
+ * Post a security alert to a Slack webhook.
455
+ * Webhook URL can be set via --slack or SHIP_SAFE_SLACK_WEBHOOK env var.
456
+ */
457
+ async function postSlackAlert(webhookUrl, findings, scoreResult, rootPath) {
458
+ const repoName = path.basename(rootPath);
459
+ const criticals = findings.filter(f => f.severity === 'critical').length;
460
+ const highs = findings.filter(f => f.severity === 'high').length;
461
+
462
+ const color = criticals > 0 ? 'danger' : highs > 0 ? 'warning' : 'good';
463
+ const emoji = criticals > 0 ? ':rotating_light:' : highs > 0 ? ':warning:' : ':shield:';
464
+
465
+ const topFindings = findings
466
+ .filter(f => f.severity === 'critical' || f.severity === 'high')
467
+ .slice(0, 5)
468
+ .map(f => `• *${f.severity.toUpperCase()}* ${f.title} — \`${f.file ? path.basename(f.file) : '?'}${f.line ? `:${f.line}` : ''}\``)
469
+ .join('\n');
470
+
471
+ const payload = {
472
+ attachments: [{
473
+ color,
474
+ fallback: `Ship Safe: ${findings.length} security finding(s) in ${repoName}`,
475
+ title: `${emoji} Ship Safe — Security Alert`,
476
+ text: `*${repoName}* — Score: *${scoreResult.score ?? '?'}/100* — ${findings.length} finding(s) (${criticals} critical, ${highs} high)`,
477
+ fields: topFindings ? [{ title: 'Top Findings', value: topFindings, short: false }] : [],
478
+ footer: 'ship-safe watch --deep',
479
+ ts: Math.floor(Date.now() / 1000),
480
+ }],
481
+ };
482
+
483
+ const res = await fetch(webhookUrl, {
484
+ method: 'POST',
485
+ headers: { 'Content-Type': 'application/json' },
486
+ body: JSON.stringify(payload),
487
+ signal: AbortSignal.timeout(10000),
488
+ });
489
+
490
+ if (!res.ok) {
491
+ console.log(chalk.yellow(` [Slack] Notification failed: HTTP ${res.status}`));
492
+ }
493
+ }
494
+
495
+ // =============================================================================
496
+ // GITHUB PR INLINE COMMENTS
497
+ // =============================================================================
498
+
499
+ /**
500
+ * Post inline security comments to the currently open PR (if any).
501
+ * Requires `gh` CLI to be installed and authenticated.
502
+ *
503
+ * Posts a review comment for each critical/high finding in a changed file.
504
+ */
505
+ async function postPRComments(findings, rootPath) {
506
+ // Check if gh is available
507
+ try {
508
+ execFileSync('gh', ['--version'], { stdio: 'pipe' });
509
+ } catch {
510
+ console.log(chalk.gray(' [PR] gh CLI not found — skipping PR comments'));
511
+ return;
512
+ }
513
+
514
+ // Get current PR number
515
+ let prNumber;
516
+ try {
517
+ const prJson = execFileSync('gh', ['pr', 'view', '--json', 'number'], {
518
+ cwd: rootPath, encoding: 'utf-8', stdio: 'pipe',
519
+ });
520
+ prNumber = JSON.parse(prJson).number;
521
+ } catch {
522
+ return; // No open PR on this branch
523
+ }
524
+
525
+ // Get current commit SHA
526
+ let sha;
527
+ try {
528
+ sha = execFileSync('git', ['rev-parse', 'HEAD'], {
529
+ cwd: rootPath, encoding: 'utf-8', stdio: 'pipe',
530
+ }).trim();
531
+ } catch {
532
+ return;
533
+ }
534
+
535
+ const criticalOrHigh = findings.filter(f =>
536
+ (f.severity === 'critical' || f.severity === 'high') && f.file && f.line
537
+ ).slice(0, 10); // Max 10 comments per scan
538
+
539
+ for (const f of criticalOrHigh) {
540
+ const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
541
+ const body = [
542
+ `**Ship Safe — ${f.severity.toUpperCase()} finding**`,
543
+ '',
544
+ `**${f.title}**`,
545
+ f.description || '',
546
+ '',
547
+ f.remediation ? `**Fix:** ${f.remediation}` : '',
548
+ '',
549
+ `_[${f.rule}] — detected by ship-safe watch_`,
550
+ ].filter(l => l !== undefined).join('\n');
551
+
552
+ try {
553
+ execFileSync('gh', [
554
+ 'api',
555
+ `repos/{owner}/{repo}/pulls/${prNumber}/comments`,
556
+ '--method', 'POST',
557
+ '--field', `body=${body}`,
558
+ '--field', `commit_id=${sha}`,
559
+ '--field', `path=${relFile}`,
560
+ '--field', `line=${f.line}`,
561
+ '--field', 'side=RIGHT',
562
+ ], { cwd: rootPath, stdio: 'pipe' });
563
+ } catch { /* individual comment failure is non-fatal */ }
564
+ }
565
+
566
+ if (criticalOrHigh.length > 0) {
567
+ console.log(chalk.gray(` [PR #${prNumber}] Posted ${criticalOrHigh.length} inline comment(s)`));
568
+ }
569
+ }
570
+
434
571
  // =============================================================================
435
572
  // CONFIG WATCH — scanConfigFiles
436
573
  // =============================================================================
@@ -92,6 +92,9 @@ class AnthropicProvider extends BaseLLMProvider {
92
92
  this.baseUrl = options.baseUrl || 'https://api.anthropic.com/v1/messages';
93
93
  }
94
94
 
95
+ /** Whether this provider supports guaranteed-JSON tool-use output */
96
+ get supportsStructuredOutput() { return true; }
97
+
95
98
  async complete(systemPrompt, userPrompt, options = {}) {
96
99
  const response = await fetch(this.baseUrl, {
97
100
  method: 'POST',
@@ -101,7 +104,7 @@ class AnthropicProvider extends BaseLLMProvider {
101
104
  'content-type': 'application/json',
102
105
  },
103
106
  body: JSON.stringify({
104
- model: this.model,
107
+ model: options.model || this.model,
105
108
  max_tokens: options.maxTokens || 2048,
106
109
  system: systemPrompt,
107
110
  messages: [{ role: 'user', content: userPrompt }],
@@ -109,12 +112,57 @@ class AnthropicProvider extends BaseLLMProvider {
109
112
  });
110
113
 
111
114
  if (!response.ok) {
112
- throw new Error(`Anthropic API error: HTTP ${response.status}`);
115
+ const body = await response.text().catch(() => '');
116
+ throw new Error(`Anthropic API error: HTTP ${response.status} ${body.slice(0, 200)}`);
113
117
  }
114
118
 
115
119
  const data = await response.json();
116
120
  return data.content?.[0]?.text || '';
117
121
  }
122
+
123
+ /**
124
+ * Complete with guaranteed-JSON output via Anthropic tool-use API.
125
+ * The LLM is forced to call the named tool, so the response always matches
126
+ * the provided JSON Schema — no regex cleanup needed.
127
+ *
128
+ * @param {string} systemPrompt
129
+ * @param {string} userPrompt
130
+ * @param {string} toolName — Name of the forced tool call
131
+ * @param {object} inputSchema — JSON Schema for the tool's input
132
+ * @param {object} options — { maxTokens, model }
133
+ * @returns {Promise<object|null>} — Parsed tool input object, or null on failure
134
+ */
135
+ async completeWithTools(systemPrompt, userPrompt, toolName, inputSchema, options = {}) {
136
+ const response = await fetch(this.baseUrl, {
137
+ method: 'POST',
138
+ headers: {
139
+ 'x-api-key': this.apiKey,
140
+ 'anthropic-version': '2023-06-01',
141
+ 'content-type': 'application/json',
142
+ },
143
+ body: JSON.stringify({
144
+ model: options.model || this.model,
145
+ max_tokens: options.maxTokens || 2048,
146
+ system: systemPrompt,
147
+ messages: [{ role: 'user', content: userPrompt }],
148
+ tools: [{
149
+ name: toolName,
150
+ description: `Report ${toolName} results`,
151
+ input_schema: inputSchema,
152
+ }],
153
+ tool_choice: { type: 'tool', name: toolName },
154
+ }),
155
+ });
156
+
157
+ if (!response.ok) {
158
+ const body = await response.text().catch(() => '');
159
+ throw new Error(`Anthropic API error: HTTP ${response.status} ${body.slice(0, 200)}`);
160
+ }
161
+
162
+ const data = await response.json();
163
+ const toolUse = data.content?.find(b => b.type === 'tool_use');
164
+ return toolUse?.input ?? null;
165
+ }
118
166
  }
119
167
 
120
168
  // =============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ship-safe",
3
- "version": "8.0.0",
3
+ "version": "9.0.0",
4
4
  "description": "AI-powered multi-agent security platform. 22 agents scan 80+ attack classes including 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": {