ship-safe 6.4.0 → 8.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.
@@ -11,7 +11,7 @@
11
11
 
12
12
  import fs from 'fs';
13
13
  import path from 'path';
14
- import { getComplianceSummary } from '../utils/compliance-map.js';
14
+ import { getComplianceSummary, getAgenticSummary, enrichAgenticRisk } from '../utils/compliance-map.js';
15
15
 
16
16
  // =============================================================================
17
17
  // SCORING CONFIGURATION
@@ -49,6 +49,7 @@ const FALLBACK_CATEGORY_MAP = {
49
49
  'vibe': 'injection', // Vibe coding findings → Code Vulnerabilities
50
50
  'exception': 'injection', // OWASP A10:2025 — Mishandling of Exceptional Conditions
51
51
  'agent-config': 'llm', // Agent config security → AI/LLM category
52
+ 'memory-poisoning': 'llm', // Memory poisoning → AI/LLM category
52
53
  'recon': null, // skip recon findings
53
54
  };
54
55
 
@@ -87,6 +88,11 @@ export class ScoringEngine {
87
88
  };
88
89
  }
89
90
 
91
+ // ── Enrich findings with OWASP Agentic AI Top 10 metadata ──────────────
92
+ for (const finding of findings) {
93
+ enrichAgenticRisk(finding);
94
+ }
95
+
90
96
  // ── Classify findings into categories ─────────────────────────────────────
91
97
  for (const finding of findings) {
92
98
  const cat = this.resolveCategory(finding.category);
@@ -146,6 +152,14 @@ export class ScoringEngine {
146
152
  compliance = null;
147
153
  }
148
154
 
155
+ // ── OWASP Agentic AI Top 10 summary ──────────────────────────────────
156
+ let agenticSummary;
157
+ try {
158
+ agenticSummary = getAgenticSummary(findings);
159
+ } catch {
160
+ agenticSummary = null;
161
+ }
162
+
149
163
  return {
150
164
  score,
151
165
  grade,
@@ -153,6 +167,7 @@ export class ScoringEngine {
153
167
  totalFindings: findings.length,
154
168
  totalDepVulns: depVulns.length,
155
169
  compliance,
170
+ agenticSummary,
156
171
  };
157
172
  }
158
173
 
@@ -26,7 +26,7 @@ const COMPROMISED_PACKAGES = [
26
26
  {
27
27
  name: 'axios',
28
28
  badVersions: ['1.8.2'],
29
- note: 'TeamPCP/CanisterWorm campaign (Mar 31 2026). Malicious publish delivered a Remote Access Trojan with persistence.',
29
+ note: 'TeamPCP/CanisterWorm campaign (Mar 31 2026). Attributed to Sapphire Sleet (North Korean state actor) by Microsoft Threat Intelligence. Malicious publish connected to C2 domain delivering a second-stage RAT with persistence.',
30
30
  },
31
31
  {
32
32
  name: 'telnyx',
@@ -582,7 +582,8 @@ export class SupplyChainAudit extends BaseAgent {
582
582
  }));
583
583
  }
584
584
 
585
- // ── 9. Blockchain C2 indicators (CanisterWorm / ICP) ──────────────────────
585
+ // ── 9. Trojanized package behavioral signatures ───────────────────────────
586
+ // Patterns from Axios 1.8.2, LiteLLM 1.82.7, TeamPCP campaign (Mar 2026)
586
587
  if (fs.existsSync(path.join(rootPath, 'node_modules'))) {
587
588
  try {
588
589
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
@@ -590,7 +591,132 @@ export class SupplyChainAudit extends BaseAgent {
590
591
  ...(pkg.dependencies || {}),
591
592
  ...(pkg.devDependencies || {}),
592
593
  };
593
- for (const depName of Object.keys(allDeps)) {
594
+
595
+ for (const depName of Object.keys(allDeps).slice(0, 80)) {
596
+ const depDir = path.join(rootPath, 'node_modules', depName);
597
+ const depPkgPath = path.join(depDir, 'package.json');
598
+ if (!fs.existsSync(depPkgPath)) continue;
599
+
600
+ try {
601
+ const depPkg = JSON.parse(fs.readFileSync(depPkgPath, 'utf-8'));
602
+
603
+ // 9a. Hidden dependencies added by attacker
604
+ // Axios 1.8.2 injected a hidden malicious dep that wasn't in the legitimate version.
605
+ // Check for deps that only exist in certain version ranges.
606
+ const depDeps = depPkg.dependencies || {};
607
+ for (const [subDep, subVer] of Object.entries(depDeps)) {
608
+ // Packages with very high download counts that suddenly gain unknown subdependencies
609
+ if (/^[a-z]+-[a-z0-9]+-[a-z0-9]+$/.test(subDep) && typeof subVer === 'string' && subVer.startsWith('git+')) {
610
+ findings.push(createFinding({
611
+ file: depPkgPath,
612
+ line: 0,
613
+ severity: 'critical',
614
+ category: 'supply-chain',
615
+ rule: 'TROJAN_HIDDEN_DEP',
616
+ title: `Suspicious Hidden Dependency in ${depName}: ${subDep}`,
617
+ description: `"${depName}" depends on "${subDep}" via a git URL. This matches the Axios/TeamPCP trojanization pattern where attackers inject a malicious dependency into a popular package.`,
618
+ matched: `${subDep}: ${subVer}`,
619
+ fix: `Compare this version's dependencies against the official release. If "${subDep}" was not in the previous version, this package may be trojanized.`,
620
+ }));
621
+ }
622
+ }
623
+
624
+ // 9b. Install scripts that read and exfiltrate env vars
625
+ // LiteLLM 1.82.7 harvested AWS/GCP/Azure tokens + SSH keys
626
+ const scripts = depPkg.scripts || {};
627
+ for (const hook of ['preinstall', 'install', 'postinstall']) {
628
+ const cmd = scripts[hook];
629
+ if (!cmd) continue;
630
+
631
+ // Environment variable harvesting
632
+ if (/process\.env|os\.environ|ENV\[|getenv/i.test(cmd) &&
633
+ /https?:|fetch|request|axios|curl|wget|net\./i.test(cmd)) {
634
+ findings.push(createFinding({
635
+ file: depPkgPath,
636
+ line: 0,
637
+ severity: 'critical',
638
+ category: 'supply-chain',
639
+ rule: 'TROJAN_ENV_EXFIL',
640
+ title: `Credential Harvesting in ${hook}: ${depName}`,
641
+ description: `"${depName}" reads environment variables and makes network requests during ${hook}. This is the exact pattern used in the LiteLLM/TeamPCP attack to steal cloud credentials.`,
642
+ matched: cmd.slice(0, 200),
643
+ fix: 'Remove this package immediately. Rotate any credentials (AWS, GCP, Azure tokens, SSH keys) that may have been exfiltrated.',
644
+ }));
645
+ }
646
+
647
+ // SSH key / credential file access in install scripts
648
+ if (/\.ssh|\.aws|\.azure|\.gcp|\.kube|\.docker|credentials|\.npmrc|\.pypirc/i.test(cmd)) {
649
+ findings.push(createFinding({
650
+ file: depPkgPath,
651
+ line: 0,
652
+ severity: 'critical',
653
+ category: 'supply-chain',
654
+ rule: 'TROJAN_CREDENTIAL_ACCESS',
655
+ title: `Credential File Access in ${hook}: ${depName}`,
656
+ description: `"${depName}" accesses credential files (.ssh, .aws, .kube, etc.) during ${hook}. This matches the TeamPCP credential theft pattern.`,
657
+ matched: cmd.slice(0, 200),
658
+ fix: 'Remove this package immediately and rotate all credentials in the accessed directories.',
659
+ }));
660
+ }
661
+ }
662
+
663
+ // 9c. Scan actual JS entry files for runtime exfiltration patterns
664
+ const main = depPkg.main || 'index.js';
665
+ const entryPath = path.join(depDir, main);
666
+ if (fs.existsSync(entryPath)) {
667
+ try {
668
+ const entryContent = fs.readFileSync(entryPath, 'utf-8');
669
+ if (entryContent.length < 500_000) {
670
+ // DNS-based exfiltration (encode data in subdomain)
671
+ if (/dns\.resolve|dns\.lookup/i.test(entryContent) &&
672
+ /process\.env|os\.hostname/i.test(entryContent)) {
673
+ findings.push(createFinding({
674
+ file: entryPath,
675
+ line: 1,
676
+ severity: 'high',
677
+ category: 'supply-chain',
678
+ rule: 'TROJAN_DNS_EXFIL',
679
+ title: `DNS Exfiltration Pattern: ${depName}`,
680
+ description: `"${depName}" combines DNS lookups with system/env data reads — a known technique for exfiltrating data via DNS subdomains to bypass firewalls.`,
681
+ matched: 'dns.resolve + process.env',
682
+ confidence: 'medium',
683
+ fix: 'Inspect the DNS usage. Legitimate packages rarely combine DNS with environment variable reading.',
684
+ }));
685
+ }
686
+
687
+ // WebSocket-based C2
688
+ if (/new\s+WebSocket/i.test(entryContent) &&
689
+ /process\.env|child_process|exec/i.test(entryContent)) {
690
+ findings.push(createFinding({
691
+ file: entryPath,
692
+ line: 1,
693
+ severity: 'critical',
694
+ category: 'supply-chain',
695
+ rule: 'TROJAN_WEBSOCKET_C2',
696
+ title: `WebSocket C2 Pattern: ${depName}`,
697
+ description: `"${depName}" opens WebSocket connections combined with system command execution — consistent with a Remote Access Trojan.`,
698
+ matched: 'WebSocket + child_process',
699
+ fix: 'Remove this package immediately. Scan for persistence mechanisms (cron jobs, startup scripts).',
700
+ }));
701
+ }
702
+ }
703
+ } catch { /* skip */ }
704
+ }
705
+
706
+ } catch { /* skip */ }
707
+ }
708
+ } catch { /* skip */ }
709
+ }
710
+
711
+ // ── 10. Blockchain C2 indicators (CanisterWorm / ICP) ────────────────────
712
+ if (fs.existsSync(path.join(rootPath, 'node_modules'))) {
713
+ try {
714
+ const pkg2 = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
715
+ const allDeps2 = {
716
+ ...(pkg2.dependencies || {}),
717
+ ...(pkg2.devDependencies || {}),
718
+ };
719
+ for (const depName of Object.keys(allDeps2)) {
594
720
  const depPkgPath = path.join(rootPath, 'node_modules', depName, 'package.json');
595
721
  if (!fs.existsSync(depPkgPath)) continue;
596
722
  try {
@@ -47,6 +47,12 @@ import { abomCommand } from '../commands/abom.js';
47
47
  import { updateIntelCommand } from '../commands/update-intel.js';
48
48
  import { hooksCommand } from '../commands/hooks.js';
49
49
  import { legalCommand } from '../commands/legal.js';
50
+ import { runLiveAdvisories } from '../commands/live-advisories.js';
51
+ import { envAuditCommand } from '../commands/env-audit.js';
52
+ import { autofixCommand } from '../commands/autofix.js';
53
+ import { memoryCommand } from '../utils/security-memory.js';
54
+ import { playbookCommand } from '../utils/scan-playbook.js';
55
+ import { listPluginFiles, scaffoldPlugin } from '../utils/plugin-loader.js';
50
56
  import { ABOMGenerator } from '../agents/abom-generator.js';
51
57
  import { PolicyEngine } from '../agents/policy-engine.js';
52
58
  import { SBOMGenerator } from '../agents/sbom-generator.js';
@@ -202,7 +208,7 @@ program
202
208
  // -----------------------------------------------------------------------------
203
209
  program
204
210
  .command('audit [path]')
205
- .description('Full security audit: secrets + 18 agents + deps + score + deep analysis + remediation plan')
211
+ .description('Full security audit: secrets + 22 agents + deps + score + deep analysis + remediation plan')
206
212
  .option('--json', 'Output results as JSON')
207
213
  .option('--sarif', 'Output results in SARIF format')
208
214
  .option('--csv', 'Output results as CSV')
@@ -223,6 +229,8 @@ program
223
229
  .option('--budget <cents>', 'Max spend in cents for deep analysis (default: 50)', parseInt)
224
230
  .option('--verify', 'Check if leaked secrets are still active (probes provider APIs)')
225
231
  .option('--include-legal', 'Also run the legal risk scan (DMCA, leaked source, IP disputes)')
232
+ .option('--agentic [iterations]', 'Agentic scan→fix→verify loop (default: 3 iterations, target score: 75)', (v) => v ? parseInt(v) : true)
233
+ .option('--agentic-target <score>', 'Target security score for agentic loop (default: 75)', parseInt)
226
234
  .option('-v, --verbose', 'Verbose output')
227
235
  .action(auditCommand);
228
236
 
@@ -243,7 +251,7 @@ program
243
251
  // -----------------------------------------------------------------------------
244
252
  program
245
253
  .command('red-team [path]')
246
- .description('Multi-agent security audit: 18 agents scan for 80+ attack classes')
254
+ .description('Multi-agent security audit: 22 agents scan for 80+ attack classes')
247
255
  .option('--agents <list>', 'Comma-separated list of agents to run')
248
256
  .option('--json', 'Output results as JSON')
249
257
  .option('--sarif', 'Output results in SARIF format')
@@ -268,8 +276,62 @@ program
268
276
  .description('Continuous monitoring: watch files for security issues in real-time')
269
277
  .option('--poll', 'Use polling mode (for network drives)')
270
278
  .option('--configs', 'Watch only agent config files (openclaw.json, .cursorrules, mcp.json, etc.)')
279
+ .option('--deep', 'Run full agent scanning on changes (not just pattern matching)')
280
+ .option('--status', 'Show current watch status and exit')
281
+ .option('--threshold <score>', 'Alert when score drops below threshold', parseInt)
282
+ .option('--debounce <ms>', 'Debounce interval in ms (default: 1500)', parseInt)
283
+ .option('--slack [webhook]', 'Post findings to Slack webhook URL (or set SHIP_SAFE_SLACK_WEBHOOK env var)')
284
+ .option('--pr-comment', 'Post inline findings as GitHub PR review comments (requires gh CLI)')
271
285
  .action(watchCommand);
272
286
 
287
+ // -----------------------------------------------------------------------------
288
+ // ADVISORIES COMMAND
289
+ // -----------------------------------------------------------------------------
290
+ program
291
+ .command('advisories [path]')
292
+ .description('Check dependencies against live advisory feeds (OSV.dev, GitHub Advisories)')
293
+ .option('--ecosystem <type>', 'Filter by ecosystem (npm, PyPI)')
294
+ .option('--json', 'Output as JSON')
295
+ .action(async (targetPath = '.', options) => {
296
+ const { resolve } = await import('path');
297
+ const absolutePath = resolve(targetPath);
298
+ try {
299
+ const result = await runLiveAdvisories(absolutePath, options);
300
+ if (options.json) {
301
+ console.log(JSON.stringify(result, null, 2));
302
+ return;
303
+ }
304
+ console.log();
305
+ console.log(chalk.cyan.bold(' Ship Safe — Live Advisories'));
306
+ console.log(chalk.gray(` Checked ${result.checked} dependencies against OSV.dev`));
307
+ console.log();
308
+ if (result.advisories.length === 0) {
309
+ console.log(chalk.green(' ✔ No known advisories for your current dependency versions.\n'));
310
+ } else {
311
+ const malware = result.advisories.filter(a => a.isMalware);
312
+ const vulns = result.advisories.filter(a => !a.isMalware);
313
+ if (malware.length > 0) {
314
+ console.log(chalk.red.bold(` !! ${malware.length} MALWARE ADVISORY(S) FOUND`));
315
+ for (const a of malware) {
316
+ console.log(chalk.red(` ${a.package}@${a.version} — ${a.id}: ${a.summary.slice(0, 80)}`));
317
+ }
318
+ console.log();
319
+ }
320
+ if (vulns.length > 0) {
321
+ console.log(chalk.yellow(` ${vulns.length} vulnerability advisory(s):`));
322
+ for (const a of vulns) {
323
+ const sev = a.severity === 'critical' ? chalk.red.bold(a.severity) : a.severity === 'high' ? chalk.yellow(a.severity) : chalk.blue(a.severity);
324
+ console.log(` ${sev} ${a.package}@${a.version} — ${a.id}`);
325
+ }
326
+ console.log();
327
+ }
328
+ }
329
+ } catch (err) {
330
+ console.error(chalk.red(` Error: ${err.message}\n`));
331
+ process.exit(1);
332
+ }
333
+ });
334
+
273
335
  // -----------------------------------------------------------------------------
274
336
  // SBOM COMMAND
275
337
  // -----------------------------------------------------------------------------
@@ -404,6 +466,15 @@ How it works:
404
466
  `)
405
467
  .action(hooksCommand);
406
468
 
469
+ // -----------------------------------------------------------------------------
470
+ // ENV AUDIT COMMAND
471
+ // -----------------------------------------------------------------------------
472
+ program
473
+ .command('env-audit [path]')
474
+ .description('Credential health check: verify .env coverage, cross-reference source, check git history')
475
+ .option('--json', 'Output results as JSON')
476
+ .action(envAuditCommand);
477
+
407
478
  // -----------------------------------------------------------------------------
408
479
  // LEGAL COMMAND
409
480
  // -----------------------------------------------------------------------------
@@ -430,6 +501,100 @@ program
430
501
  .description('Diagnose environment: check Node.js, git, API keys, cache, and dependencies')
431
502
  .action(doctorCommand);
432
503
 
504
+ // -----------------------------------------------------------------------------
505
+ // AUTOFIX COMMAND
506
+ // -----------------------------------------------------------------------------
507
+ program
508
+ .command('autofix [path]')
509
+ .description('Apply LLM-generated security fixes from a deep analysis report and open a GitHub PR')
510
+ .option('--report <file>', 'Path to ship-safe JSON report (default: ship-safe-report.json)')
511
+ .option('--severity <level>', 'Minimum severity to fix: critical, high, medium (default: high)')
512
+ .option('--dry-run', 'Preview fixes without applying them or creating a branch')
513
+ .option('--yes', 'Skip confirmation prompt')
514
+ .action((targetPath, options) => autofixCommand({ ...options, path: targetPath }));
515
+
516
+ // -----------------------------------------------------------------------------
517
+ // MEMORY COMMAND
518
+ // -----------------------------------------------------------------------------
519
+ program
520
+ .command('memory [subcommand]')
521
+ .description('Manage security memory: false-positive learning that auto-suppresses known safe findings')
522
+ .addHelpText('after', `
523
+ Subcommands:
524
+ list Show all suppressed findings in memory (default)
525
+ forget <key> Remove a specific entry by key
526
+ clear Wipe all memory entries
527
+
528
+ How it works:
529
+ When --deep analysis confirms a finding is a false positive, it is added to
530
+ .ship-safe/memory.json and suppressed automatically on all future scans.
531
+ `)
532
+ .argument('[args...]')
533
+ .action((subcommand, args, options) => memoryCommand(subcommand, args, options));
534
+
535
+ // -----------------------------------------------------------------------------
536
+ // PLAYBOOK COMMAND
537
+ // -----------------------------------------------------------------------------
538
+ program
539
+ .command('playbook [subcommand]')
540
+ .description('Manage scan playbooks: repo-specific context injected into every LLM analysis')
541
+ .addHelpText('after', `
542
+ Subcommands:
543
+ show Show the current playbook (default)
544
+ add-note "text" Add a custom note to the playbook
545
+
546
+ How it works:
547
+ After 2+ scans, a playbook is auto-generated in .ship-safe/playbook.md with
548
+ your repo's tech stack, auth patterns, and score history. This is injected
549
+ into the LLM system prompt so deep analysis is more accurate for your project.
550
+ `)
551
+ .argument('[args...]')
552
+ .action((subcommand, args, options) => playbookCommand(subcommand, args, options));
553
+
554
+ // -----------------------------------------------------------------------------
555
+ // PLUGINS COMMAND
556
+ // -----------------------------------------------------------------------------
557
+ program
558
+ .command('plugins [action]')
559
+ .description('Manage custom security agent plugins from .ship-safe/agents/')
560
+ .addHelpText('after', `
561
+ Actions:
562
+ list List loaded plugins (default)
563
+ new <name> Scaffold a new plugin in .ship-safe/agents/<name>.js
564
+
565
+ How it works:
566
+ Drop any .js file into .ship-safe/agents/ that exports a default class
567
+ extending BaseAgent with an analyze() method. It will be loaded automatically
568
+ on every audit or watch --deep run.
569
+ `)
570
+ .action((action, options) => {
571
+ const rootPath = path.resolve(process.cwd());
572
+ if (action === 'new') {
573
+ const pluginName = options.args?.[0] || options._name || 'my-rule';
574
+ try {
575
+ const filePath = scaffoldPlugin(rootPath, pluginName);
576
+ console.log(chalk.green(` ✔ Plugin scaffolded: ${filePath}`));
577
+ console.log(chalk.gray(' Edit the file to implement your custom rule, then run ship-safe audit to activate it.'));
578
+ } catch (err) {
579
+ console.error(chalk.red(` Error: ${err.message}`));
580
+ process.exit(1);
581
+ }
582
+ } else {
583
+ // list
584
+ const plugins = listPluginFiles(rootPath);
585
+ if (plugins.length === 0) {
586
+ console.log('\n No custom plugins found in .ship-safe/agents/');
587
+ console.log(chalk.gray(' Create one with: npx ship-safe plugins new my-rule\n'));
588
+ } else {
589
+ console.log(`\n ${chalk.cyan.bold('Custom Plugins')} — ${plugins.length} found\n`);
590
+ for (const p of plugins) {
591
+ console.log(` ${chalk.white(p.name)} ${chalk.gray(`(${(p.size / 1024).toFixed(1)} KB) ${p.path}`)}`);
592
+ }
593
+ console.log();
594
+ }
595
+ }
596
+ });
597
+
433
598
  // -----------------------------------------------------------------------------
434
599
  // PARSE AND RUN
435
600
  // -----------------------------------------------------------------------------
@@ -438,10 +603,10 @@ program
438
603
  if (process.argv.length === 2) {
439
604
  console.log(banner);
440
605
  console.log(chalk.yellow('\nQuick start:\n'));
441
- console.log(chalk.cyan.bold(' v6.0 — Full Security Audit'));
442
- console.log(chalk.white(' npx ship-safe audit . ') + chalk.gray('# Full audit: secrets + 18 agents + deps + remediation'));
606
+ console.log(chalk.cyan.bold(' v8.0 — Ship Safe × Hermes Agent'));
607
+ console.log(chalk.white(' npx ship-safe audit . ') + chalk.gray('# Full audit: secrets + 22 agents + deps + remediation'));
443
608
  console.log(chalk.white(' npx ship-safe audit . --deep') + chalk.gray('# LLM-powered taint analysis (Anthropic/Ollama)'));
444
- console.log(chalk.white(' npx ship-safe red-team . ') + chalk.gray('# 18-agent red team scan (80+ attack classes)'));
609
+ console.log(chalk.white(' npx ship-safe red-team . ') + chalk.gray('# 22-agent red team scan (80+ attack classes)'));
445
610
  console.log(chalk.white(' npx ship-safe vibe-check . ') + chalk.gray('# Fun security check with emoji & shareable badge'));
446
611
  console.log(chalk.white(' npx ship-safe benchmark . ') + chalk.gray('# Compare score against industry averages'));
447
612
  console.log(chalk.white(' npx ship-safe ci . ') + chalk.gray('# CI/CD mode: scan, score, exit code'));
@@ -464,9 +629,17 @@ if (process.argv.length === 2) {
464
629
  console.log(chalk.white(' npx ship-safe rotate . ') + chalk.gray('# Revoke exposed keys (provider guides)'));
465
630
  console.log(chalk.white(' npx ship-safe deps . ') + chalk.gray('# Audit dependencies for CVEs'));
466
631
  console.log(chalk.white(' npx ship-safe score . ') + chalk.gray('# Security health score (0-100)'));
632
+ console.log(chalk.white(' npx ship-safe env-audit . ') + chalk.gray('# Credential health check (after stripe projects env --pull)'));
467
633
  console.log(chalk.white(' npx ship-safe hooks install ') + chalk.gray('# Real-time security gate inside Claude Code (PreToolUse/PostToolUse)'));
468
634
  console.log(chalk.white(' npx ship-safe guard ') + chalk.gray('# Block git push if secrets found'));
469
635
  console.log(chalk.white(' npx ship-safe init ') + chalk.gray('# Add security configs to your project'));
636
+ console.log();
637
+ console.log(chalk.gray(' Intelligence commands:'));
638
+ console.log(chalk.white(' npx ship-safe autofix . ') + chalk.gray('# Apply LLM fixes from --deep report, open PR'));
639
+ console.log(chalk.white(' npx ship-safe memory list ') + chalk.gray('# View / manage false-positive memory'));
640
+ console.log(chalk.white(' npx ship-safe playbook show ') + chalk.gray('# View repo-specific LLM context playbook'));
641
+ console.log(chalk.white(' npx ship-safe plugins list ') + chalk.gray('# Manage custom agent plugins'));
642
+ console.log(chalk.white(' npx ship-safe watch . --deep --slack ') + chalk.gray('# Guardian mode with Slack alerts + PR comments'));
470
643
  console.log(chalk.white('\n npx ship-safe --help ') + chalk.gray('# Show all options'));
471
644
  console.log();
472
645
  process.exit(0);
@@ -17,7 +17,7 @@ import path from 'path';
17
17
  import chalk from 'chalk';
18
18
  import ora from 'ora';
19
19
  import fg from 'fast-glob';
20
- import { buildOrchestrator } from '../agents/index.js';
20
+ import { buildOrchestrator, buildOrchestratorAsync } from '../agents/index.js';
21
21
  import { LegalRiskAgent } from '../agents/legal-risk-agent.js';
22
22
  import { ScoringEngine } from '../agents/scoring-engine.js';
23
23
  import { PolicyEngine } from '../agents/policy-engine.js';
@@ -37,8 +37,11 @@ import {
37
37
  import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
38
38
  import { CacheManager } from '../utils/cache-manager.js';
39
39
  import { filterBaseline } from './baseline.js';
40
+ import { SecurityMemory } from '../utils/security-memory.js';
41
+ import { ScanPlaybook } from '../utils/scan-playbook.js';
40
42
  import { generatePDF, generatePrintHTML, isChromeAvailable } from '../utils/pdf-generator.js';
41
43
  import { SecretsVerifier } from '../utils/secrets-verifier.js';
44
+ import { applyInlineAnnotations } from './autofix.js';
42
45
 
43
46
  // =============================================================================
44
47
  // CONSTANTS
@@ -186,7 +189,7 @@ export async function auditCommand(targetPath = '.', options = {}) {
186
189
  }
187
190
 
188
191
  // ── Phase 2: Agent Scan ───────────────────────────────────────────────────
189
- const orchestrator = buildOrchestrator();
192
+ const orchestrator = await buildOrchestratorAsync(absolutePath, { quiet: true });
190
193
  const registeredAgentCount = orchestrator.agents?.length || 15;
191
194
  const agentSpinner = machineOutput ? null : ora({ text: chalk.white(`[Phase 2/4] Running ${registeredAgentCount} security agents...`), color: 'cyan' }).start();
192
195
  let agentFindings = [];
@@ -278,6 +281,30 @@ export async function auditCommand(targetPath = '.', options = {}) {
278
281
  }
279
282
  }
280
283
 
284
+ // ── Scan Playbook — update with latest recon + findings ─────────────────
285
+ try {
286
+ const playbook = new ScanPlaybook(absolutePath);
287
+ const suppressedRules = new SecurityMemory(absolutePath).list().map(e => e.rule).filter(Boolean);
288
+ playbook.update(recon, { score: scoreResult.score, grade: scoreResult.grade?.letter || scoreResult.grade, totalFindings: filteredFindings.length }, filteredFindings, suppressedRules);
289
+ } catch { /* non-fatal */ }
290
+
291
+ // ── Security Memory Filter ──────────────────────────────────────────────
292
+ // Auto-learn false positives from deep analysis results, then suppress
293
+ // any finding that memory recognises from a previous scan.
294
+ const secMemory = new SecurityMemory(absolutePath);
295
+ if (options.deep) {
296
+ // After deep analysis ran, learn any new false positives
297
+ const newFPs = secMemory.learnFromAnalysis(filteredFindings);
298
+ if (newFPs > 0 && !machineOutput) {
299
+ console.log(chalk.gray(` Memory: ${newFPs} new false positive(s) learned and will be suppressed in future scans`));
300
+ }
301
+ }
302
+ const { kept: memFiltered, suppressedCount: memSuppressed } = secMemory.filter(filteredFindings);
303
+ filteredFindings = memFiltered;
304
+ if (memSuppressed > 0 && !machineOutput) {
305
+ console.log(chalk.gray(` Memory: ${memSuppressed} previously-confirmed false positive(s) suppressed`));
306
+ }
307
+
281
308
  // Count suppressions (ship-safe-ignore comments)
282
309
  const suppressions = countSuppressions(allFiles);
283
310
 
@@ -395,6 +422,11 @@ export async function auditCommand(targetPath = '.', options = {}) {
395
422
  // ── Build Remediation Plan ────────────────────────────────────────────────
396
423
  const remediationPlan = buildRemediationPlan(filteredFindings, depVulns, absolutePath);
397
424
 
425
+ // Skip all output and file generation for inner agentic re-scans
426
+ if (options._agenticInner) {
427
+ return { score: scoreResult.score, findings: filteredFindings };
428
+ }
429
+
398
430
  // ── Output ────────────────────────────────────────────────────────────────
399
431
  console.log();
400
432
 
@@ -465,9 +497,91 @@ export async function auditCommand(targetPath = '.', options = {}) {
465
497
  console.log();
466
498
  }
467
499
 
500
+ // ── Agentic Loop (--agentic) ────────────────────────────────────────────
501
+ // Scan → annotate fixes → re-scan cycle until score >= target or maxIter.
502
+ // NOTE: process.exit() is deferred until after the loop so all iterations
503
+ // can run. The inner re-scans use _agenticInner: true to skip process.exit.
504
+ if (options.agentic && !options._agenticInner) {
505
+ const maxIter = typeof options.agentic === 'number' ? options.agentic : 3;
506
+ const targetScore = options.agenticTarget ?? 75;
507
+ let iteration = 1;
508
+ let currentScore = scoreResult.score;
509
+ let currentFindings = filteredFindings;
510
+
511
+ if (!machineOutput) {
512
+ console.log();
513
+ console.log(chalk.cyan.bold(` Agentic mode: scan→fix→verify loop (max ${maxIter} iterations, target score: ${targetScore})`));
514
+ }
515
+
516
+ while (currentScore < targetScore && iteration <= maxIter) {
517
+ if (!machineOutput) {
518
+ console.log(chalk.cyan(`\n ─── Agentic iteration ${iteration}/${maxIter} (current score: ${currentScore}) ───`));
519
+ }
520
+
521
+ const actionable = currentFindings.filter(f => f.fix && f.severity !== 'low');
522
+ if (actionable.length === 0) {
523
+ if (!machineOutput) console.log(chalk.gray(' No auto-fixable findings — stopping agentic loop.'));
524
+ break;
525
+ }
526
+
527
+ // Delegate annotation to autofix module (handles comment style, idempotency, NEVER_EDIT list)
528
+ const fixCount = applyInlineAnnotations(actionable);
529
+ if (!machineOutput) {
530
+ console.log(chalk.yellow(` Annotated ${fixCount} finding(s). Re-scanning...`));
531
+ }
532
+ if (fixCount === 0) break;
533
+
534
+ // Re-scan without recursing into the agentic loop or calling process.exit
535
+ const innerResult = await runAuditInner(targetPath, {
536
+ ...options,
537
+ agentic: false,
538
+ _agenticInner: true,
539
+ json: false,
540
+ sarif: false,
541
+ csv: false,
542
+ md: false,
543
+ html: false,
544
+ pdf: false,
545
+ quiet: true,
546
+ });
547
+
548
+ const prevScore = currentScore;
549
+ currentScore = innerResult?.score ?? currentScore;
550
+ currentFindings = innerResult?.findings ?? currentFindings;
551
+
552
+ if (!machineOutput) {
553
+ const diff = currentScore - prevScore;
554
+ const arrow = diff > 0 ? chalk.green(`↑ +${diff.toFixed(1)}`) : diff < 0 ? chalk.red(`↓ ${diff.toFixed(1)}`) : chalk.gray('→ 0');
555
+ console.log(chalk.cyan(` Re-scan score: ${currentScore} ${arrow}`));
556
+ }
557
+
558
+ iteration++;
559
+ }
560
+
561
+ if (!machineOutput) {
562
+ if (currentScore >= targetScore) {
563
+ console.log(chalk.green.bold(`\n Agentic loop complete — target score ${targetScore} reached (${currentScore}).`));
564
+ } else {
565
+ console.log(chalk.yellow(`\n Agentic loop stopped after ${iteration - 1} iteration(s). Final score: ${currentScore}`));
566
+ }
567
+ }
568
+ }
569
+
468
570
  process.exit(scoreResult.score >= 75 ? 0 : 1);
469
571
  }
470
572
 
573
+ /**
574
+ * Run a lightweight inner audit that returns { score, findings } without
575
+ * calling process.exit(). Used exclusively by the --agentic loop.
576
+ */
577
+ async function runAuditInner(targetPath, options) {
578
+ try {
579
+ return await auditCommand(targetPath, options);
580
+ } catch {
581
+ return null;
582
+ }
583
+ }
584
+
471
585
  // =============================================================================
472
586
  // REMEDIATION PLAN BUILDER
473
587
  // =============================================================================