hackmyagent 0.16.7 → 0.17.1

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 (47) hide show
  1. package/dist/.integrity-manifest.json +1 -1
  2. package/dist/arp/index.d.ts +3 -3
  3. package/dist/arp/index.d.ts.map +1 -1
  4. package/dist/arp/index.js +9 -9
  5. package/dist/arp/index.js.map +1 -1
  6. package/dist/arp/intelligence/behavioral-risk-server.d.ts +2 -2
  7. package/dist/arp/intelligence/behavioral-risk-server.js +1 -1
  8. package/dist/arp/intelligence/behavioral-risk.d.ts +9 -9
  9. package/dist/arp/intelligence/behavioral-risk.js +6 -6
  10. package/dist/arp/intelligence/coordinator.js +2 -2
  11. package/dist/arp/intelligence/runtime-twin.d.ts +157 -0
  12. package/dist/arp/intelligence/runtime-twin.d.ts.map +1 -0
  13. package/dist/arp/intelligence/runtime-twin.js +479 -0
  14. package/dist/arp/intelligence/runtime-twin.js.map +1 -0
  15. package/dist/arp/types.d.ts +4 -3
  16. package/dist/arp/types.d.ts.map +1 -1
  17. package/dist/cli.js +699 -186
  18. package/dist/cli.js.map +1 -1
  19. package/dist/hardening/index.d.ts +1 -1
  20. package/dist/hardening/index.d.ts.map +1 -1
  21. package/dist/hardening/index.js +2 -1
  22. package/dist/hardening/index.js.map +1 -1
  23. package/dist/hardening/scanner.d.ts +16 -0
  24. package/dist/hardening/scanner.d.ts.map +1 -1
  25. package/dist/hardening/scanner.js +28 -18
  26. package/dist/hardening/scanner.js.map +1 -1
  27. package/dist/hardening/taxonomy.d.ts +5 -0
  28. package/dist/hardening/taxonomy.d.ts.map +1 -1
  29. package/dist/hardening/taxonomy.js +66 -0
  30. package/dist/hardening/taxonomy.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +4 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/nanomind-core/analyzers/credential-analyzer.js +6 -0
  36. package/dist/nanomind-core/analyzers/credential-analyzer.js.map +1 -1
  37. package/dist/nanomind-core/compiler/semantic-compiler.js +7 -2
  38. package/dist/nanomind-core/compiler/semantic-compiler.js.map +1 -1
  39. package/dist/nanomind-core/scanner-bridge.d.ts.map +1 -1
  40. package/dist/nanomind-core/scanner-bridge.js +33 -5
  41. package/dist/nanomind-core/scanner-bridge.js.map +1 -1
  42. package/dist/registry/publish.d.ts.map +1 -1
  43. package/dist/registry/publish.js +3 -4
  44. package/dist/registry/publish.js.map +1 -1
  45. package/dist/scanner/external-scanner.js +2 -2
  46. package/dist/scanner/external-scanner.js.map +1 -1
  47. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -112,15 +112,21 @@ const noColorEnv = process.env.NO_COLOR !== undefined || !process.stdout.isTTY;
112
112
  // Color codes - will be cleared if --no-color is passed
113
113
  let colors = {
114
114
  green: '\x1b[32m',
115
+ brightGreen: '\x1b[92m',
115
116
  yellow: '\x1b[33m',
116
117
  red: '\x1b[31m',
117
118
  brightRed: '\x1b[91m',
118
119
  cyan: '\x1b[36m',
120
+ blue: '\x1b[34m',
121
+ magenta: '\x1b[35m',
119
122
  dim: '\x1b[2m',
123
+ bold: '\x1b[1m',
124
+ white: '\x1b[97m',
125
+ underline: '\x1b[4m',
120
126
  reset: '\x1b[0m',
121
127
  };
122
128
  if (noColorEnv) {
123
- colors = { green: '', yellow: '', red: '', brightRed: '', cyan: '', dim: '', reset: '' };
129
+ colors = { green: '', brightGreen: '', yellow: '', red: '', brightRed: '', cyan: '', blue: '', magenta: '', dim: '', bold: '', white: '', underline: '', reset: '' };
124
130
  }
125
131
  // Deprecation warning for removed HMAC auth
126
132
  if (process.env.HMA_COMMUNITY_SECRET) {
@@ -128,36 +134,29 @@ if (process.env.HMA_COMMUNITY_SECRET) {
128
134
  }
129
135
  program
130
136
  .name('hackmyagent')
131
- .description(`Find it. Break it. Fix it.
137
+ .description(`Security scanner for AI agents. ${CHECK_COUNT} checks, ${index_1.PAYLOAD_STATS.total} attack payloads, auto-fix.
132
138
 
133
- The hacker's toolkit for AI agents. ${CHECK_COUNT} security checks, ${index_1.PAYLOAD_STATS.total} attack
134
- payloads, auto-fix with rollback, and OASB benchmark compliance.
135
-
136
- Documentation: https://hackmyagent.com/docs
137
-
138
- Updates (v${index_1.VERSION}):
139
- - 10 new static analysis patterns (NEMO series)
140
- - Community trust contributions
141
- - ${CHECK_COUNT} checks across 60 categories
139
+ Scan before you install. Harden before you deploy. Red-team before you ship.
142
140
 
143
141
  Examples:
144
- $ hackmyagent secure Find vulnerabilities (${CHECK_COUNT} checks)
145
- $ hackmyagent attack --local Break it with ${index_1.PAYLOAD_STATS.total} attack payloads
146
- $ hackmyagent secure --fix Fix issues automatically
147
- $ hackmyagent fix-all Run all security plugins
148
- $ hackmyagent scan example.com Scan external infrastructure`)
149
- .version('hackmyagent ' + index_1.VERSION, '-v, --version', 'Output the version number')
142
+ $ hackmyagent check <package> Is this package safe to install?
143
+ $ hackmyagent secure Full project scan (${CHECK_COUNT} checks)
144
+ $ hackmyagent secure --fix Auto-fix with rollback
145
+ $ hackmyagent attack --local Red-team with ${index_1.PAYLOAD_STATS.total} payloads
146
+ $ hackmyagent scan-soul Governance compliance scan
147
+ $ hackmyagent scan example.com External infrastructure scan`)
148
+ .version(`hackmyagent ${index_1.VERSION} — security scanner for AI agents`, '-v, --version', 'Output the version number')
150
149
  .option('--no-color', 'Disable colored output (also respects NO_COLOR env)');
151
150
  program.addHelpText('beforeAll', `
152
151
  Quick start:
152
+ $ hackmyagent check <package> Is this safe to install?
153
153
  $ hackmyagent secure Scan current directory (${CHECK_COUNT} checks)
154
- $ hackmyagent fix-all --with-aim Auto-fix + create agent identity
155
- $ hackmyagent attack Red-team your agent
154
+ $ hackmyagent secure --fix Auto-fix with rollback
156
155
  `);
157
156
  program.hook('preAction', (thisCommand) => {
158
157
  const opts = thisCommand.opts();
159
158
  if (opts.color === false) {
160
- colors = { green: '', yellow: '', red: '', brightRed: '', cyan: '', dim: '', reset: '' };
159
+ colors = { green: '', brightGreen: '', yellow: '', red: '', brightRed: '', cyan: '', blue: '', magenta: '', dim: '', bold: '', white: '', underline: '', reset: '' };
161
160
  }
162
161
  });
163
162
  // Risk level colors and symbols
@@ -172,37 +171,44 @@ program
172
171
  .command('check')
173
172
  .description(`Check if a package, repo, or skill is safe
174
173
 
175
- Accepts npm packages, GitHub repos, local paths, or skill identifiers:
176
- • npm package: queries the registry; downloads + scans (${CHECK_COUNT} checks + NanoMind) if data is missing or stale (>${STALE_SCAN_DAYS}d)
177
- • PyPI package: downloads + scans (${CHECK_COUNT} checks + NanoMind)
178
- GitHub repo: queries the registry; shallow clones + scans if data is missing or stale (>${STALE_SCAN_DAYS}d)
179
- Local path: runs NanoMind semantic analysis
180
- Skill identifier: verifies publisher, permissions, revocation
174
+ Downloads + scans (${CHECK_COUNT} checks + NanoMind) by default, with trust context from the OpenA2A registry.
175
+
176
+ Accepts:
177
+ npm package: hackmyagent check express
178
+ PyPI package: hackmyagent check pip:requests
179
+ GitHub repo: hackmyagent check getsentry/sentry-mcp
180
+ • Local path: hackmyagent check ./my-agent/
181
+ • Skill: hackmyagent check @publisher/skill
182
+ • URL: hackmyagent check https://example.com/agent-v1.tar.gz
181
183
 
182
- Use --rescan to skip the registry cache and force a fresh local scan.
184
+ Output includes: verdict, security score, findings with fix commands, registry trust context, and path forward for recovery.
183
185
 
184
186
  Risk levels: low, medium, high, critical
185
187
  Exit code 1 if high/critical risk detected.
186
188
 
187
189
  Examples:
188
- $ hackmyagent check express
189
- $ hackmyagent check @modelcontextprotocol/server-filesystem
190
- $ hackmyagent check @sentry/mcp-server --rescan
191
- $ hackmyagent check modelcontextprotocol/servers
192
- $ hackmyagent check https://github.com/punkpeye/awesome-mcp-servers
193
- $ hackmyagent check ./my-agent/
194
- $ hackmyagent check @publisher/skill --verbose
195
- $ hackmyagent check pip:requests
196
- $ hackmyagent check pypi:flask
197
- $ hackmyagent check modelcontextprotocol/servers --json
198
- $ hackmyagent check https://gitlab.com/org/repo
199
- $ hackmyagent check https://example.com/agent-v1.tar.gz`)
190
+ $ hackmyagent check @sentry/mcp-server
191
+ $ hackmyagent check pip:flask
192
+ $ hackmyagent check getsentry/sentry-mcp --verbose
193
+ $ hackmyagent check ./my-agent/ --json
194
+ $ hackmyagent check express --no-scan # registry only (fast)
195
+ $ hackmyagent check express --no-registry # offline mode`)
200
196
  .argument('<target>', 'npm package, PyPI package (pip: or pypi: prefix), local path, GitHub repo, or skill identifier')
201
- .option('-v, --verbose', 'Show detailed verification info')
197
+ .option('-v, --verbose', 'Show detailed verification info (check IDs, categories)')
202
198
  .option('--json', 'Output as JSON (for scripting/CI)')
203
- .option('--offline', 'Skip DNS verification (offline mode)')
204
- .option('--rescan', 'Force a fresh local scan, bypassing cached registry data')
199
+ .option('--no-scan', 'Registry only, skip local scan (fast mode for CI)')
200
+ .option('--no-registry', 'Local scan only, skip registry lookup (offline mode)')
201
+ .option('--offline', 'Alias for --no-registry')
202
+ .option('--rescan', 'Deprecated: local scan is now the default')
205
203
  .action(async (skill, options) => {
204
+ // Commander parses --no-scan as scan:false, --no-registry as registry:false
205
+ // Normalize: --offline is alias for --no-registry
206
+ if (options.offline)
207
+ options.registry = false;
208
+ // --rescan deprecation
209
+ if (options.rescan && !options.json && !globalCiMode) {
210
+ console.error(`${colors.yellow}Note: --rescan is deprecated. Local scan is now the default.${RESET()}`);
211
+ }
206
212
  try {
207
213
  // Detect local file/directory paths - run NanoMind scan instead of registry lookup
208
214
  const { existsSync, statSync } = await Promise.resolve().then(() => __importStar(require('node:fs')));
@@ -212,7 +218,6 @@ Examples:
212
218
  if (isLocalPath) {
213
219
  // Local path: run NanoMind semantic analysis directly
214
220
  const targetDir = statSync(resolved).isFile() ? dirname(resolved) : resolved;
215
- const targetFile = statSync(resolved).isFile() ? resolved : null;
216
221
  const { orchestrateNanoMind } = await Promise.resolve().then(() => __importStar(require('./nanomind-core/orchestrate.js')));
217
222
  const nmResult = await orchestrateNanoMind(targetDir, [], { silent: !!options.json });
218
223
  const issues = nmResult.mergedFindings.filter((f) => !f.passed);
@@ -232,27 +237,16 @@ Examples:
232
237
  });
233
238
  return;
234
239
  }
240
+ displayUnifiedCheck({
241
+ name: resolved,
242
+ sourceLabel: 'local',
243
+ nanomindScan: {
244
+ compiledArtifacts: nmResult.compiledArtifacts,
245
+ findings: issues,
246
+ },
247
+ verbose: !!options.verbose,
248
+ });
235
249
  const risk = critical.length > 0 ? 'critical' : high.length > 0 ? 'high' : issues.length > 0 ? 'medium' : 'low';
236
- const riskDisplay = RISK_DISPLAY[risk];
237
- console.log(`\n${riskDisplay.color()}${riskDisplay.symbol} ${risk.toUpperCase()} RISK${RESET()}\n`);
238
- console.log(`Path: ${resolved}`);
239
- console.log(`Semantic analysis: ${nmResult.compiledArtifacts} file(s) analyzed\n`);
240
- if (issues.length === 0) {
241
- console.log(`${colors.green}No security issues detected.${RESET()}\n`);
242
- }
243
- else {
244
- console.log(`Findings: ${critical.length} critical, ${high.length} high, ${issues.length - critical.length - high.length} other\n`);
245
- for (const f of issues.slice(0, 10)) {
246
- const sev = SEVERITY_DISPLAY[f.severity];
247
- console.log(`${sev.color()}${sev.symbol} [${f.checkId}] ${f.description}${RESET()}`);
248
- if (f.fix)
249
- console.log(` Fix: ${f.fix}`);
250
- }
251
- if (issues.length > 10)
252
- console.log(`\n ... and ${issues.length - 10} more`);
253
- console.log();
254
- }
255
- printCheckNextSteps(resolved);
256
250
  if (risk === 'critical' || risk === 'high')
257
251
  process.exit(1);
258
252
  return;
@@ -273,9 +267,23 @@ Examples:
273
267
  return;
274
268
  }
275
269
  // npm package name: download, run full HMA scan, clean up
270
+ // On npm 404, fall through to skill check (skill identifiers look like @scope/name)
276
271
  if (looksLikeNpmPackage(skill)) {
277
- await checkNpmPackage(skill, options);
278
- return;
272
+ try {
273
+ await checkNpmPackage(skill, options);
274
+ return;
275
+ }
276
+ catch (npmErr) {
277
+ if (npmErr instanceof Error && npmErr.name === 'NpmNotFoundError') {
278
+ // Not on npm — fall through to skill check
279
+ if (!options.json && !globalCiMode) {
280
+ console.error(`Package "${skill}" not found on npm. Trying as skill identifier...`);
281
+ }
282
+ }
283
+ else {
284
+ throw npmErr; // Re-throw non-404 errors
285
+ }
286
+ }
279
287
  }
280
288
  // --rescan only applies to targets that otherwise hit the registry cache.
281
289
  // For skill identifiers we fall through to the registry lookup below.
@@ -366,10 +374,10 @@ Examples:
366
374
  });
367
375
  // Severity colors and symbols for secure command
368
376
  const SEVERITY_DISPLAY = {
369
- critical: { symbol: '[!!]', color: () => colors.brightRed },
370
- high: { symbol: '[!]', color: () => colors.red },
371
- medium: { symbol: '[~]', color: () => colors.yellow },
372
- low: { symbol: '[.]', color: () => colors.green },
377
+ critical: { symbol: '[!!]', label: 'CRITICAL', color: () => colors.brightRed },
378
+ high: { symbol: '[!]', label: 'HIGH', color: () => colors.red },
379
+ medium: { symbol: '[~]', label: 'MEDIUM', color: () => colors.yellow },
380
+ low: { symbol: '[.]', label: 'LOW', color: () => colors.green },
373
381
  };
374
382
  /**
375
383
  * Display check command findings with optional verbose details.
@@ -408,6 +416,378 @@ function displayCheckFindings(failed, verbose) {
408
416
  console.log(`\n ${colors.green}No security issues found.${RESET()}`);
409
417
  }
410
418
  }
419
+ function stripAnsi(s) {
420
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
421
+ }
422
+ /** Right-align a value at a fixed column width */
423
+ function rightAlign(left, right, width = 68) {
424
+ const leftLen = stripAnsi(left).length;
425
+ const rightLen = stripAnsi(right).length;
426
+ const pad = Math.max(1, width - leftLen - rightLen);
427
+ return `${left}${' '.repeat(pad)}${right}`;
428
+ }
429
+ /** Extract the actionable core of a fix/guidance string.
430
+ * Takes the first sentence, strips file path prefixes that duplicate
431
+ * the finding header, and wraps at terminal width. Never truncates with "...".
432
+ */
433
+ function cleanFixText(text, fileAlreadyShown) {
434
+ // Take first meaningful line (skip blank lines)
435
+ let line = text.split('\n').map(l => l.trim()).filter(Boolean)[0] || text;
436
+ // Strip "In <file>," prefix when file is already shown in the finding header
437
+ if (fileAlreadyShown) {
438
+ const escapedFile = fileAlreadyShown.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
439
+ line = line.replace(new RegExp(`^In ${escapedFile},?\\s*`, 'i'), '');
440
+ // Capitalize first letter after stripping
441
+ if (line.length > 0)
442
+ line = line[0].toUpperCase() + line.slice(1);
443
+ }
444
+ return line;
445
+ }
446
+ /** Shorten a file path for display — show filename + parent dir only */
447
+ function shortenPath(filePath) {
448
+ const parts = filePath.split('/');
449
+ if (parts.length <= 2)
450
+ return filePath;
451
+ return parts.slice(-2).join('/');
452
+ }
453
+ function displayUnifiedCheck(opts) {
454
+ const { name, sourceLabel, projectType, localScan, registry, verbose, version, nanomindScan } = opts;
455
+ // ── Visual helpers ──────────────────────────────────────────────────
456
+ const METER_WIDTH = 20;
457
+ const divider = (label) => {
458
+ if (label) {
459
+ console.log(`\n ${colors.dim}──${RESET()} ${colors.bold}${label}${RESET()} ${colors.dim}${'─'.repeat(Math.max(1, 56 - label.length))}${RESET()}`);
460
+ }
461
+ else {
462
+ console.log(` ${colors.dim}${'─'.repeat(62)}${RESET()}`);
463
+ }
464
+ };
465
+ const scoreMeter = (value, max = 100) => {
466
+ const pct = Math.round((value / max) * METER_WIDTH);
467
+ const meterColor = value >= 70 ? colors.green : value >= 40 ? colors.yellow : colors.red;
468
+ const filled = '━'.repeat(pct);
469
+ const empty = '━'.repeat(METER_WIDTH - pct);
470
+ return `${meterColor}${filled}${RESET()}${colors.dim}${empty}${RESET()} ${meterColor}${colors.bold}${value}${RESET()}${colors.dim}/${max}${RESET()}`;
471
+ };
472
+ const sevBadge = (sev) => {
473
+ const d = SEVERITY_DISPLAY[sev];
474
+ return `${d.color()}${colors.bold}${d.label}${RESET()}`;
475
+ };
476
+ // ── Compute findings ────────────────────────────────────────────────
477
+ let failed = [];
478
+ let score = 0;
479
+ let maxScore = 100;
480
+ let critical = 0, high = 0, medium = 0, low = 0;
481
+ if (localScan) {
482
+ failed = localScan.findings.filter(f => !f.passed);
483
+ score = localScan.score;
484
+ maxScore = localScan.maxScore;
485
+ critical = failed.filter(f => f.severity === 'critical').length;
486
+ high = failed.filter(f => f.severity === 'high').length;
487
+ medium = failed.filter(f => f.severity === 'medium').length;
488
+ low = failed.filter(f => f.severity === 'low').length;
489
+ }
490
+ else if (nanomindScan) {
491
+ const issues = nanomindScan.findings.filter(f => !f.passed);
492
+ critical = issues.filter(f => f.severity === 'critical').length;
493
+ high = issues.filter(f => f.severity === 'high').length;
494
+ medium = issues.filter(f => f.severity === 'medium').length;
495
+ low = issues.filter(f => f.severity === 'low').length;
496
+ failed = issues.map(f => ({
497
+ checkId: f.checkId || '',
498
+ name: f.name || f.description || '',
499
+ description: f.description || '',
500
+ category: f.category || '',
501
+ severity: f.severity,
502
+ passed: false,
503
+ message: f.message || f.description || '',
504
+ fixable: false,
505
+ file: f.file,
506
+ line: f.line,
507
+ fix: f.fix,
508
+ guidance: f.guidance,
509
+ attackClass: f.attackClass,
510
+ }));
511
+ // Use the canonical scoring formula (exponential decay + 0.4x governance weight)
512
+ const scoreResult = (0, index_1.calculateSecurityScore)(issues);
513
+ score = scoreResult.score;
514
+ maxScore = scoreResult.maxScore;
515
+ }
516
+ else if (registry?.found) {
517
+ score = Math.round(registry.trustScore * 100);
518
+ maxScore = 100;
519
+ }
520
+ const totalFindings = critical + high + medium + low;
521
+ // ── Header ──────────────────────────────────────────────────────────
522
+ const typeLabel = (registry?.packageType || projectType || 'unknown').replace(/_/g, ' ');
523
+ const meta = [typeLabel];
524
+ if (version)
525
+ meta.unshift(`v${version}`);
526
+ if (sourceLabel)
527
+ meta.push(sourceLabel);
528
+ if (nanomindScan)
529
+ meta.push(`${nanomindScan.compiledArtifacts} files analyzed`);
530
+ if (localScan?.filesScanned)
531
+ meta.push(`${localScan.filesScanned} files scanned`);
532
+ console.log();
533
+ console.log(` ${colors.bold}${colors.white}${name}${RESET()} ${colors.dim}${meta.join(' · ')}${RESET()}`);
534
+ // ── Verdict + Score ─────────────────────────────────────────────────
535
+ if (localScan || nanomindScan) {
536
+ let verdictText;
537
+ let verdictColor;
538
+ if (critical > 0) {
539
+ verdictColor = colors.brightRed;
540
+ verdictText = `${critical} critical issue${critical > 1 ? 's' : ''} found`;
541
+ }
542
+ else if (high > 0) {
543
+ verdictColor = colors.red;
544
+ verdictText = `${high} high-severity issue${high > 1 ? 's' : ''} found`;
545
+ }
546
+ else if (totalFindings > 0) {
547
+ verdictColor = colors.yellow;
548
+ verdictText = `${totalFindings} issue${totalFindings > 1 ? 's' : ''} found`;
549
+ }
550
+ else {
551
+ verdictColor = colors.green;
552
+ verdictText = 'No security issues found';
553
+ }
554
+ console.log(` ${verdictColor}${colors.bold}${verdictText}${RESET()}`);
555
+ console.log();
556
+ console.log(` Security ${scoreMeter(score, maxScore)}`);
557
+ }
558
+ else if (registry?.found) {
559
+ const normalized = normalizeTrustVerdict(registry.verdict);
560
+ let verdictText;
561
+ let verdictColor;
562
+ if (normalized === 'blocked') {
563
+ verdictColor = colors.brightRed;
564
+ verdictText = 'Blocked by registry';
565
+ }
566
+ else if (normalized === 'warning') {
567
+ verdictColor = colors.yellow;
568
+ verdictText = 'Warning — review before installing';
569
+ }
570
+ else {
571
+ verdictColor = colors.green;
572
+ verdictText = 'No known issues';
573
+ }
574
+ console.log(` ${verdictColor}${colors.bold}${verdictText}${RESET()}`);
575
+ console.log();
576
+ console.log(` Trust ${scoreMeter(score, maxScore)}`);
577
+ }
578
+ // ── Findings ────────────────────────────────────────────────────────
579
+ if (failed.length > 0) {
580
+ // Severity summary as colored pills
581
+ const summaryParts = [];
582
+ if (critical > 0)
583
+ summaryParts.push(`${colors.brightRed}${colors.bold}${critical} critical${RESET()}`);
584
+ if (high > 0)
585
+ summaryParts.push(`${colors.red}${colors.bold}${high} high${RESET()}`);
586
+ if (medium > 0)
587
+ summaryParts.push(`${colors.yellow}${medium} medium${RESET()}`);
588
+ if (low > 0)
589
+ summaryParts.push(`${colors.dim}${low} low${RESET()}`);
590
+ divider('Findings');
591
+ console.log(` ${summaryParts.join(' ')}`);
592
+ // High-count mode: group by category when > 20 findings
593
+ if (totalFindings > 20 && !verbose) {
594
+ const groups = new Map();
595
+ for (const f of failed) {
596
+ const key = f.category || f.name || 'Other';
597
+ if (!groups.has(key))
598
+ groups.set(key, { critical: 0, high: 0, medium: 0, low: 0, files: new Set() });
599
+ const g = groups.get(key);
600
+ g[f.severity]++;
601
+ if (f.file)
602
+ g.files.add(f.file.split('/')[0] || f.file);
603
+ }
604
+ const sorted = [...groups.entries()].sort((a, b) => {
605
+ const wa = a[1].critical * 4 + a[1].high * 3 + a[1].medium * 2 + a[1].low;
606
+ const wb = b[1].critical * 4 + b[1].high * 3 + b[1].medium * 2 + b[1].low;
607
+ return wb - wa;
608
+ });
609
+ console.log();
610
+ for (const [cat, g] of sorted.slice(0, 8)) {
611
+ const counts = [];
612
+ if (g.critical > 0)
613
+ counts.push(`${colors.brightRed}${g.critical} crit${RESET()}`);
614
+ if (g.high > 0)
615
+ counts.push(`${colors.red}${g.high} high${RESET()}`);
616
+ if (g.medium > 0)
617
+ counts.push(`${colors.dim}${g.medium} med${RESET()}`);
618
+ if (g.low > 0)
619
+ counts.push(`${colors.dim}${g.low} low${RESET()}`);
620
+ const fileHint = g.files.size <= 3 ? ` ${colors.dim}${[...g.files].join(', ')}${RESET()}` : '';
621
+ console.log(` ${colors.dim}│${RESET()} ${cat.padEnd(26)} ${counts.join(', ')}${fileHint}`);
622
+ }
623
+ if (sorted.length > 8) {
624
+ console.log(` ${colors.dim}│ + ${sorted.length - 8} more categories${RESET()}`);
625
+ }
626
+ // Top 3 issues with full detail
627
+ divider('Top Issues');
628
+ const topFindings = [...failed]
629
+ .sort((a, b) => {
630
+ const sw = { critical: 4, high: 3, medium: 2, low: 1 };
631
+ return (sw[b.severity] || 0) - (sw[a.severity] || 0);
632
+ })
633
+ .slice(0, 3);
634
+ for (const f of topFindings) {
635
+ const shortFile = f.file ? shortenPath(f.file) : '';
636
+ const loc = shortFile + (f.line ? `:${f.line}` : '');
637
+ const borderColor = SEVERITY_DISPLAY[f.severity].color();
638
+ console.log();
639
+ console.log(` ${borderColor}│${RESET()} ${sevBadge(f.severity)} ${colors.bold}${colors.white}${f.name || f.message}${RESET()}`);
640
+ if (loc)
641
+ console.log(` ${borderColor}│${RESET()} ${colors.dim}${loc}${RESET()}`);
642
+ if (f.guidance) {
643
+ console.log(` ${borderColor}│${RESET()} ${cleanFixText(f.guidance, f.file)}`);
644
+ }
645
+ if (f.fix) {
646
+ console.log(` ${borderColor}│${RESET()} ${colors.cyan}Fix:${RESET()} ${cleanFixText(f.fix, f.file)}`);
647
+ }
648
+ }
649
+ }
650
+ else {
651
+ // Normal mode: individual findings sorted by severity, with collapse
652
+ const sevWeight = { critical: 4, high: 3, medium: 2, low: 1 };
653
+ failed.sort((a, b) => (sevWeight[b.severity] || 0) - (sevWeight[a.severity] || 0));
654
+ const skipped = new Set();
655
+ let shown = 0;
656
+ const limit = verbose ? failed.length : 10;
657
+ for (let i = 0; i < failed.length; i++) {
658
+ if (shown >= limit)
659
+ break;
660
+ if (skipped.has(i))
661
+ continue;
662
+ const f = failed[i];
663
+ const shortFile = f.file ? shortenPath(f.file) : '';
664
+ const loc = shortFile + (f.line ? `:${f.line}` : '');
665
+ const borderColor = SEVERITY_DISPLAY[f.severity].color();
666
+ console.log();
667
+ console.log(` ${borderColor}│${RESET()} ${sevBadge(f.severity)} ${colors.bold}${colors.white}${f.name || f.message}${RESET()}`);
668
+ if (loc)
669
+ console.log(` ${borderColor}│${RESET()} ${colors.dim}${loc}${RESET()}`);
670
+ if (f.guidance) {
671
+ console.log(` ${borderColor}│${RESET()} ${cleanFixText(f.guidance, f.file)}`);
672
+ }
673
+ if (f.fix) {
674
+ console.log(` ${borderColor}│${RESET()} ${colors.cyan}Fix:${RESET()} ${cleanFixText(f.fix, f.file)}`);
675
+ }
676
+ if (verbose) {
677
+ if (f.checkId)
678
+ console.log(` ${borderColor}│${RESET()} ${colors.dim}Check: ${f.checkId}${RESET()}`);
679
+ if (f.category)
680
+ console.log(` ${borderColor}│${RESET()} ${colors.dim}Category: ${f.category}${RESET()}`);
681
+ }
682
+ shown++;
683
+ // Collapse similar
684
+ if (!verbose) {
685
+ const dir = f.file?.split('/').slice(0, -1).join('/') || '';
686
+ let similarCount = 0;
687
+ for (let j = i + 1; j < failed.length; j++) {
688
+ if (skipped.has(j))
689
+ continue;
690
+ const other = failed[j];
691
+ if (other.name === f.name) {
692
+ const otherDir = other.file?.split('/').slice(0, -1).join('/') || '';
693
+ if (otherDir === dir) {
694
+ skipped.add(j);
695
+ similarCount++;
696
+ }
697
+ }
698
+ }
699
+ if (similarCount > 0) {
700
+ console.log(` ${borderColor}│${RESET()} ${colors.dim}+ ${similarCount} similar${dir ? ` in ${shortenPath(dir)}` : ''}${RESET()}`);
701
+ }
702
+ }
703
+ }
704
+ const remaining = failed.length - shown - skipped.size;
705
+ if (remaining > 0) {
706
+ console.log(`\n ${colors.dim}+ ${remaining} more findings (use --verbose to see all)${RESET()}`);
707
+ }
708
+ }
709
+ // Path forward with recovery math
710
+ if (critical > 0 || high > 0) {
711
+ const recoveryParts = [];
712
+ if (critical > 0)
713
+ recoveryParts.push(`${critical} critical`);
714
+ if (high > 0)
715
+ recoveryParts.push(`${high} high`);
716
+ // Estimate recovered score: governance findings recover less per fix
717
+ const govFindings = failed.filter(f => {
718
+ const cat = (f.category || '').toLowerCase();
719
+ const id = f.checkId || '';
720
+ return cat === 'governance' || cat === 'injection-hardening' || cat === 'trust-hierarchy'
721
+ || id.startsWith('AST-GOV') || id.startsWith('AST-GOVERN')
722
+ || id.startsWith('AST-PROMPT') || id.startsWith('AST-HEARTBEAT');
723
+ });
724
+ const isGovernanceOnly = govFindings.length === failed.length;
725
+ const estRecovery = isGovernanceOnly
726
+ ? Math.min(100, score + (critical * 8 + high * 5))
727
+ : Math.min(100, score + (critical * 15 + high * 8));
728
+ console.log();
729
+ console.log(` ${colors.cyan}${colors.bold}Path forward:${RESET()} ${colors.cyan}${score} ${colors.dim}->${RESET()} ${colors.green}${colors.bold}${estRecovery}${RESET()} ${colors.cyan}by fixing ${recoveryParts.join(' + ')}${RESET()}`);
730
+ }
731
+ }
732
+ // ── Registry ────────────────────────────────────────────────────────
733
+ if (registry?.found) {
734
+ const trustScore = Math.round(registry.trustScore * 100);
735
+ if (localScan || nanomindScan) {
736
+ divider('Registry');
737
+ console.log(` Trust ${scoreMeter(trustScore)}`);
738
+ }
739
+ const tlColor = trustLevelColor(registry.trustLevel);
740
+ const tlLabel = trustLevelLabel(registry.trustLevel);
741
+ console.log(` Level ${tlColor}${colors.bold}${tlLabel}${RESET()} ${colors.dim}(${registry.trustLevel}/4)${RESET()}`);
742
+ if (registry.communityScans !== undefined) {
743
+ console.log(` Community ${registry.communityScans > 0 ? colors.green : colors.dim}${registry.communityScans} scan${registry.communityScans !== 1 ? 's' : ''} shared${RESET()}`);
744
+ }
745
+ if (registry.cveCount !== undefined && registry.cveCount > 0) {
746
+ console.log(` CVEs ${colors.brightRed}${colors.bold}${registry.cveCount}${RESET()}`);
747
+ }
748
+ if (registry.dependencies) {
749
+ const d = registry.dependencies;
750
+ const depParts = [];
751
+ if (d.totalDeps !== undefined)
752
+ depParts.push(`${d.totalDeps} total`);
753
+ if (d.vulnerableDeps !== undefined && d.vulnerableDeps > 0)
754
+ depParts.push(`${colors.red}${d.vulnerableDeps} vulnerable${RESET()}`);
755
+ if (d.minTrustLevel !== undefined)
756
+ depParts.push(`min trust ${d.minTrustLevel}/4`);
757
+ if (depParts.length > 0) {
758
+ console.log(` Deps ${depParts.join(`${colors.dim} · ${RESET()}`)}`);
759
+ }
760
+ }
761
+ // Trust level legend (when not fully verified)
762
+ if (registry.trustLevel < 4) {
763
+ const levels = ['Blocked', 'Warning', 'Listed', 'Scanned', 'Verified'];
764
+ const legend = levels.map((l, i) => {
765
+ if (i === registry.trustLevel)
766
+ return `${tlColor}${colors.bold}${l}${RESET()}`;
767
+ if (i < registry.trustLevel)
768
+ return `${colors.dim}${l}${RESET()}`;
769
+ return `${colors.dim}${l}${RESET()}`;
770
+ }).join(`${colors.dim} > ${RESET()}`);
771
+ console.log(` ${colors.dim}${legend}${RESET()}`);
772
+ }
773
+ }
774
+ // ── Next steps ──────────────────────────────────────────────────────
775
+ const hasGovIssues = failed.some(f => f.category === 'governance' || f.category === 'Governance' || f.checkId?.startsWith('AST-GOV') || f.checkId?.startsWith('AST-PROMPT'));
776
+ const hasCredIssues = failed.some(f => f.checkId?.startsWith('CRED-') || f.name?.toLowerCase().includes('credential') || f.name?.toLowerCase().includes('api key') || f.name?.toLowerCase().includes('hardcoded'));
777
+ const hasCodeVulns = failed.some(f => {
778
+ const cat = (f.category || '').toLowerCase();
779
+ return cat !== 'governance' && cat !== 'injection-hardening' && cat !== 'trust-hierarchy'
780
+ && !f.checkId?.startsWith('AST-GOV') && !f.checkId?.startsWith('AST-GOVERN')
781
+ && !f.checkId?.startsWith('AST-PROMPT') && !f.checkId?.startsWith('AST-HEARTBEAT');
782
+ });
783
+ printCheckNextSteps(name, {
784
+ hasGovernanceIssues: hasGovIssues,
785
+ hasFindings: totalFindings > 0,
786
+ hasCredentialFindings: hasCredIssues,
787
+ hasCodeVulns,
788
+ isCleanScan: totalFindings === 0 && (!!localScan || !!nanomindScan),
789
+ });
790
+ }
411
791
  function groupFindingsBySeverity(findings) {
412
792
  const grouped = {
413
793
  critical: [],
@@ -2862,7 +3242,7 @@ Examples:
2862
3242
  .argument('<target>', 'Target hostname or IP address')
2863
3243
  .option('--json', 'Output as JSON (for scripting/CI)')
2864
3244
  .option('-p, --ports <ports>', 'Comma-separated ports to scan (default: common MCP ports)')
2865
- .option('-t, --timeout <ms>', 'Connection timeout in milliseconds', '5000')
3245
+ .option('-t, --timeout <ms>', 'Connection timeout in milliseconds', '2000')
2866
3246
  .option('-v, --verbose', 'Show detailed finding information')
2867
3247
  .action(async (target, options) => {
2868
3248
  try {
@@ -2878,11 +3258,11 @@ Examples:
2878
3258
  `\n`);
2879
3259
  process.exit(1);
2880
3260
  }
2881
- const timeoutMs = parseInt(options.timeout ?? '5000', 10);
3261
+ const timeoutMs = parseInt(options.timeout ?? '2000', 10);
2882
3262
  const customPorts = options.ports
2883
3263
  ? options.ports.split(',').map((p) => parseInt(p.trim(), 10))
2884
3264
  : undefined;
2885
- const portCount = customPorts?.length ?? 5;
3265
+ const portCount = customPorts?.length ?? 2;
2886
3266
  if (!options.json) {
2887
3267
  console.log(`\nScanning ${target} (${portCount} ports, ${timeoutMs}ms timeout)...\n`);
2888
3268
  }
@@ -5197,6 +5577,28 @@ Examples:
5197
5577
  if (opts.json) {
5198
5578
  writeJsonStdout(result);
5199
5579
  }
5580
+ else if (result.found) {
5581
+ // Use the unified display (same as `check --no-scan`) for visual consistency
5582
+ const registryData = {
5583
+ found: true,
5584
+ name: result.name,
5585
+ trustScore: result.trustScore,
5586
+ trustLevel: result.trustLevel,
5587
+ verdict: result.verdict,
5588
+ scanStatus: result.scanStatus,
5589
+ lastScannedAt: result.lastScannedAt,
5590
+ packageType: result.packageType,
5591
+ recommendation: result.recommendation,
5592
+ cveCount: result.cveCount,
5593
+ communityScans: result.communityScans,
5594
+ dependencies: result.dependencies ? {
5595
+ totalDeps: result.dependencies.totalDeps,
5596
+ vulnerableDeps: result.dependencies.vulnerableDeps,
5597
+ minTrustLevel: result.dependencies.minTrustLevel,
5598
+ } : undefined,
5599
+ };
5600
+ displayUnifiedCheck({ name: packageName, registry: registryData, verbose: false });
5601
+ }
5200
5602
  else {
5201
5603
  process.stdout.write(formatTrustCheck(result));
5202
5604
  }
@@ -5215,7 +5617,7 @@ program
5215
5617
  .option('-d, --directory <dir>', 'Scan a specific directory to collect check metadata from findings')
5216
5618
  .option('--json', 'Output as JSON (default)')
5217
5619
  .action(async (options) => {
5218
- const { getAttackClass, getTaxonomyMap } = require('./hardening/taxonomy');
5620
+ const { getAttackClass, getTaxonomyMap, getCheckSeverity } = require('./hardening/taxonomy');
5219
5621
  // Build static registry from taxonomy map (covers all known checks)
5220
5622
  const taxMap = getTaxonomyMap();
5221
5623
  const metadata = {};
@@ -5227,7 +5629,7 @@ program
5227
5629
  name: checkId,
5228
5630
  category: prefix.toLowerCase(),
5229
5631
  attackClass: taxMap[checkId] || '',
5230
- severity: '',
5632
+ severity: getCheckSeverity(checkId),
5231
5633
  };
5232
5634
  }
5233
5635
  // If a directory is provided, enrich with actual finding data (names, severity, etc.)
@@ -5912,6 +6314,7 @@ async function queryRegistry(name) {
5912
6314
  const data = await response.json();
5913
6315
  if (!data.packageId)
5914
6316
  return null;
6317
+ const deps = data.dependencies;
5915
6318
  return {
5916
6319
  found: true,
5917
6320
  name: data.name ?? name,
@@ -5921,6 +6324,15 @@ async function queryRegistry(name) {
5921
6324
  scanStatus: data.scanStatus,
5922
6325
  lastScannedAt: data.lastScannedAt,
5923
6326
  packageType: data.packageType,
6327
+ recommendation: data.recommendation,
6328
+ cveCount: typeof data.cveCount === 'number' ? data.cveCount : undefined,
6329
+ communityScans: typeof data.communityScans === 'number' ? data.communityScans : undefined,
6330
+ dependencies: deps ? {
6331
+ totalDeps: typeof deps.totalDeps === 'number' ? deps.totalDeps : undefined,
6332
+ vulnerableDeps: typeof deps.vulnerableDeps === 'number' ? deps.vulnerableDeps : undefined,
6333
+ minTrustLevel: typeof deps.minTrustLevel === 'number' ? deps.minTrustLevel : undefined,
6334
+ riskSummary: deps.riskSummary,
6335
+ } : undefined,
5924
6336
  };
5925
6337
  }
5926
6338
  catch {
@@ -5954,7 +6366,9 @@ async function publishToRegistry(name, result) {
5954
6366
  score: result.score,
5955
6367
  maxScore: result.maxScore,
5956
6368
  projectType: result.projectType,
5957
- findings: result.findings.map(f => ({
6369
+ findings: result.findings
6370
+ .filter(f => !PACKAGE_SCAN_LOCAL_ONLY_CATEGORIES.has(f.category))
6371
+ .map(f => ({
5958
6372
  checkId: f.checkId,
5959
6373
  name: f.name,
5960
6374
  severity: f.severity,
@@ -6020,6 +6434,90 @@ function getFullScanHint() {
6020
6434
  return override;
6021
6435
  return `${CLI_PREFIX} secure <dir>`;
6022
6436
  }
6437
+ /**
6438
+ * Categories that describe local dev-environment setup, not package security.
6439
+ * Findings in these categories are filtered from display when scanning a
6440
+ * *downloaded* package (npm pack, pip download, git clone to temp dir).
6441
+ * They remain visible when scanning a user's own project directory.
6442
+ */
6443
+ const PACKAGE_SCAN_LOCAL_ONLY_CATEGORIES = new Set([
6444
+ 'git',
6445
+ 'permissions',
6446
+ 'environment',
6447
+ 'logging',
6448
+ 'claude-code',
6449
+ 'cursor',
6450
+ 'vscode',
6451
+ ]);
6452
+ /**
6453
+ * Paths that are AI tooling artifacts, not package source code.
6454
+ * Governance findings on these files are noise when scanning a downloaded
6455
+ * package or cloned repo — they're instructions to an AI assistant, not
6456
+ * security vulnerabilities in the package itself.
6457
+ */
6458
+ const AI_TOOLING_PATH_PATTERNS = [
6459
+ /^\.claude\//,
6460
+ /^CLAUDE\.md$/i,
6461
+ /^\.cursorrules$/i,
6462
+ /^\.aider/,
6463
+ /^\.copilot\//,
6464
+ /^\.github\/copilot/,
6465
+ /\.env\.example$/i, // Example env files are not real credentials
6466
+ /\.env\.sample$/i,
6467
+ /\.env\.template$/i,
6468
+ ];
6469
+ /** Governance-related categories/checkId prefixes that are noise on AI tooling files */
6470
+ const GOVERNANCE_CATEGORIES = new Set([
6471
+ 'governance',
6472
+ 'injection-hardening',
6473
+ 'trust-hierarchy',
6474
+ ]);
6475
+ const GOVERNANCE_CHECK_PREFIXES = ['AST-GOV', 'AST-GOVERN', 'AST-PROMPT'];
6476
+ /** Test file path patterns — findings here are lower risk */
6477
+ const TEST_FILE_PATTERNS = [
6478
+ /\btests?\//i,
6479
+ /\b__tests__\//,
6480
+ /\btest_[^/]+$/,
6481
+ /[^/]+_test\.\w+$/,
6482
+ /[^/]+\.test\.\w+$/,
6483
+ /[^/]+\.spec\.\w+$/,
6484
+ /\bfixtures?\//i,
6485
+ ];
6486
+ function isTestFile(filePath) {
6487
+ return TEST_FILE_PATTERNS.some(p => p.test(filePath));
6488
+ }
6489
+ function isAiToolingFile(filePath) {
6490
+ return AI_TOOLING_PATH_PATTERNS.some(p => p.test(filePath));
6491
+ }
6492
+ /**
6493
+ * Filter out local-dev-only findings that are meaningless for downloaded
6494
+ * packages (e.g. "Missing .gitignore" on an npm tarball). Also filters
6495
+ * governance findings on AI tooling files and demotes test file findings.
6496
+ * Mutates `result.findings` in place and recalculates the score.
6497
+ */
6498
+ function filterLocalOnlyFindings(result, scanner) {
6499
+ result.findings = result.findings.filter(f => {
6500
+ // Remove local-only categories (git, permissions, env, etc.)
6501
+ if (PACKAGE_SCAN_LOCAL_ONLY_CATEGORIES.has(f.category))
6502
+ return false;
6503
+ // Exclude ALL findings on AI tooling files (CLAUDE.md, .claude/, .cursorrules, etc.)
6504
+ // These files contain instructions to AI assistants, not package source code.
6505
+ // Credential patterns, injection patterns, and governance findings in these
6506
+ // files are false positives — they describe security practices, not vulnerabilities.
6507
+ if (f.file && isAiToolingFile(f.file))
6508
+ return false;
6509
+ return true;
6510
+ });
6511
+ // Demote test file findings to low severity (test code patterns are
6512
+ // lower risk — pickle.load in a test file is not an attack surface)
6513
+ for (const f of result.findings) {
6514
+ if (f.file && isTestFile(f.file) && (f.severity === 'critical' || f.severity === 'high')) {
6515
+ f.originalSeverity = f.severity;
6516
+ f.severity = 'low';
6517
+ }
6518
+ }
6519
+ result.score = scanner.calculateScore(result.findings.filter((f) => !f.passed && !f.fixed)).score;
6520
+ }
6023
6521
  /**
6024
6522
  * Print the standard 3-line next-steps footer shown after every `check`
6025
6523
  * invocation. Lines:
@@ -6031,13 +6529,30 @@ function getFullScanHint() {
6031
6529
  *
6032
6530
  * Suppressed in --ci so machine-readable output stays clean.
6033
6531
  */
6034
- function printCheckNextSteps(target) {
6532
+ function printCheckNextSteps(target, context) {
6035
6533
  if (globalCiMode)
6036
6534
  return;
6037
6535
  console.log();
6038
- console.log(` ${colors.dim}Run a fresh local scan: ${getCheckCommand()} ${target} --rescan${RESET()}`);
6039
- console.log(` ${colors.dim}Full project scan: ${getFullScanHint()}${RESET()}`);
6040
- console.log(` ${colors.dim}Also accepts: pip:<pkg> · <owner>/<repo> · ./<dir> · @publisher/skill${RESET()}`);
6536
+ console.log(` ${colors.dim}──${RESET()} ${colors.bold}Next Steps${RESET()} ${colors.dim}${'─'.repeat(49)}${RESET()}`);
6537
+ if (context?.hasGovernanceIssues) {
6538
+ console.log(` ${colors.cyan}Auto-fix governance:${RESET()} ${CLI_PREFIX} harden-soul ${target}`);
6539
+ }
6540
+ if (context?.hasCredentialFindings) {
6541
+ console.log(` ${colors.cyan}Protect credentials:${RESET()} npx secretless-ai scan`);
6542
+ }
6543
+ if (context?.hasCodeVulns) {
6544
+ console.log(` ${colors.cyan}Auto-fix all issues:${RESET()} ${CLI_PREFIX} secure --fix`);
6545
+ }
6546
+ if (context?.hasFindings) {
6547
+ console.log(` ${colors.cyan}Full project audit:${RESET()} ${getFullScanHint()}`);
6548
+ }
6549
+ else if (context?.isCleanScan) {
6550
+ console.log(` ${colors.cyan}Governance scan:${RESET()} ${CLI_PREFIX} scan-soul ${target}`);
6551
+ console.log(` ${colors.cyan}Red-team test:${RESET()} ${CLI_PREFIX} attack --local`);
6552
+ }
6553
+ else {
6554
+ console.log(` ${colors.cyan}Deep scan:${RESET()} ${CLI_PREFIX} check ${target} --rescan`);
6555
+ }
6041
6556
  console.log();
6042
6557
  }
6043
6558
  /**
@@ -6106,27 +6621,23 @@ async function suggestSimilarPackages(name) {
6106
6621
  async function checkGitHubRepo(target, options) {
6107
6622
  const { org, repo, cloneUrl } = parseGitHubTarget(target);
6108
6623
  const displayName = `${org}/${repo}`;
6109
- // Step 1: Check registry for existing trust data (unless --rescan forces a fresh scan)
6110
- if (!options.offline && !options.rescan) {
6111
- const registryData = await queryRegistry(displayName);
6112
- if (registryData?.found && !isScanStale(registryData.lastScannedAt)) {
6624
+ // Fetch registry data in parallel with clone (unless --no-registry)
6625
+ const registryPromise = options.registry === false ? Promise.resolve(null) : queryRegistry(displayName);
6626
+ // Registry-only mode (--no-scan): skip local scan
6627
+ if (options.scan === false) {
6628
+ const registryData = await registryPromise;
6629
+ if (registryData?.found) {
6113
6630
  if (options.json) {
6114
6631
  writeJsonStdout({ ...registryData, source: 'registry' });
6115
6632
  return;
6116
6633
  }
6117
- displayRegistryResult(registryData);
6634
+ displayUnifiedCheck({ name: displayName, sourceLabel: 'GitHub', registry: registryData, verbose: !!options.verbose });
6118
6635
  return;
6119
6636
  }
6120
- if (registryData?.found && registryData.lastScannedAt) {
6121
- if (!options.json && !globalCiMode) {
6122
- const days = Math.floor((Date.now() - new Date(registryData.lastScannedAt).getTime()) / (1000 * 60 * 60 * 24));
6123
- console.error(`\nRegistry data is ${days} day(s) old. Re-scanning...`);
6124
- }
6637
+ if (!options.json && !globalCiMode) {
6638
+ console.error(`No registry data found for ${displayName}. Running local scan...`);
6125
6639
  }
6126
6640
  }
6127
- else if (options.rescan && !options.json && !globalCiMode) {
6128
- console.error(`Forcing fresh local scan (--rescan)...`);
6129
- }
6130
6641
  // Step 2: Clone and scan
6131
6642
  const { mkdtemp, rm } = await Promise.resolve().then(() => __importStar(require('node:fs/promises')));
6132
6643
  const { tmpdir } = await Promise.resolve().then(() => __importStar(require('node:os')));
@@ -6157,6 +6668,8 @@ async function checkGitHubRepo(target, options) {
6157
6668
  catch {
6158
6669
  // NanoMind unavailable — use base scan results
6159
6670
  }
6671
+ // Filter local-dev-only findings irrelevant to cloned repos
6672
+ filterLocalOnlyFindings(result, scanner);
6160
6673
  const failed = result.findings.filter(f => !f.passed);
6161
6674
  const critical = failed.filter(f => f.severity === 'critical');
6162
6675
  const high = failed.filter(f => f.severity === 'high');
@@ -6174,19 +6687,21 @@ async function checkGitHubRepo(target, options) {
6174
6687
  });
6175
6688
  return;
6176
6689
  }
6177
- // Display results
6178
- const scoreRatio = result.score / result.maxScore;
6179
- const scoreColor = scoreRatio >= 0.7 ? colors.green : scoreRatio >= 0.4 ? colors.yellow : colors.red;
6180
- console.log(`\n ${displayName} ${colors.dim}(GitHub)${RESET()}`);
6181
- console.log(` Type: ${result.projectType}`);
6182
- console.log(` Score: ${scoreColor}${result.score}/${result.maxScore}${RESET()}`);
6183
- console.log(` Findings: ${critical.length} critical, ${high.length} high, ${medium.length} medium, ${low.length} low`);
6184
- displayCheckFindings(failed, !!options.verbose);
6185
- // Step 3: Community contribution
6690
+ // Await registry data (started in parallel with clone)
6691
+ const registryData = await registryPromise;
6692
+ // Display results using unified display
6693
+ displayUnifiedCheck({
6694
+ name: displayName,
6695
+ sourceLabel: 'GitHub',
6696
+ projectType: result.projectType,
6697
+ localScan: { score: result.score, maxScore: result.maxScore, findings: result.findings },
6698
+ registry: registryData,
6699
+ verbose: !!options.verbose,
6700
+ });
6701
+ // Community contribution
6186
6702
  if (process.stdin.isTTY && !globalCiMode) {
6187
6703
  const scanCount = incrementScanCounter();
6188
6704
  if (scanCount >= 3 && !hasContributeChoice()) {
6189
- console.log();
6190
6705
  console.log(` ${colors.dim}Your scans help other developers make safer choices.`);
6191
6706
  console.log(` Sharing adds anonymized results to the OpenA2A trust registry`);
6192
6707
  console.log(` so others can check packages before installing.${RESET()}`);
@@ -6201,7 +6716,7 @@ async function checkGitHubRepo(target, options) {
6201
6716
  if (wantsToShare) {
6202
6717
  const ok = await publishToRegistry(displayName, result);
6203
6718
  if (ok) {
6204
- console.error(` ${colors.green}Shared. Future scans will auto-share.${RESET()}`);
6719
+ console.error(`\n ${colors.green}Thanks for sharing! Future scans will auto-contribute.${RESET()}\n`);
6205
6720
  }
6206
6721
  else {
6207
6722
  queuePendingScan(displayName, result);
@@ -6215,7 +6730,6 @@ async function checkGitHubRepo(target, options) {
6215
6730
  queuePendingScan(displayName, result);
6216
6731
  }
6217
6732
  }
6218
- printCheckNextSteps(displayName);
6219
6733
  if (critical.length > 0 || high.length > 0)
6220
6734
  process.exit(1);
6221
6735
  }
@@ -6317,6 +6831,8 @@ async function checkPyPiPackage(target, options) {
6317
6831
  catch {
6318
6832
  // NanoMind unavailable -- use base scan results
6319
6833
  }
6834
+ // Filter local-dev-only findings irrelevant to downloaded packages
6835
+ filterLocalOnlyFindings(result, scanner);
6320
6836
  const failed = result.findings.filter(f => !f.passed);
6321
6837
  const critical = failed.filter(f => f.severity === 'critical');
6322
6838
  const high = failed.filter(f => f.severity === 'high');
@@ -6335,32 +6851,18 @@ async function checkPyPiPackage(target, options) {
6335
6851
  });
6336
6852
  return;
6337
6853
  }
6338
- // Display results
6339
- const scoreRatio = result.score / result.maxScore;
6340
- const scoreColor = scoreRatio >= 0.7 ? colors.green : scoreRatio >= 0.4 ? colors.yellow : colors.red;
6341
- console.log(`\n ${name} (PyPI)`);
6342
- console.log(` Version: ${meta.info.version}`);
6343
- console.log(` Type: ${result.projectType}`);
6344
- console.log(` Score: ${scoreColor}${result.score}/${result.maxScore}${RESET()}`);
6345
- console.log(` Findings: ${critical.length} critical, ${high.length} high, ${medium.length} medium, ${low.length} low`);
6346
- if (failed.length > 0) {
6347
- console.log();
6348
- const limit = options.verbose ? failed.length : 15;
6349
- for (const f of failed.slice(0, limit)) {
6350
- const sev = SEVERITY_DISPLAY[f.severity];
6351
- const attackClass = f.attackClass ? ` (${f.attackClass})` : '';
6352
- console.log(` ${sev.color()}${sev.symbol}${RESET()} ${f.name}: ${f.message}${colors.dim}${attackClass}${RESET()}`);
6353
- }
6354
- if (failed.length > limit) {
6355
- console.log(`\n ... and ${failed.length - limit} more (use --verbose to see all)`);
6356
- }
6357
- }
6358
- else {
6359
- console.log(`\n ${colors.green}No security issues found.${RESET()}`);
6360
- }
6361
- // Pass the original target (with pip: / pypi: prefix preserved) so the
6362
- // rescan hint stays runnable — `hackmyagent check requests` would try npm.
6363
- printCheckNextSteps(target);
6854
+ // Display results using unified display
6855
+ // Query registry for trust context (PyPI packages have pip: prefix in registry)
6856
+ const registryData = options.registry === false ? null : await queryRegistry(`pip:${name}`);
6857
+ displayUnifiedCheck({
6858
+ name,
6859
+ sourceLabel: 'PyPI',
6860
+ projectType: result.projectType,
6861
+ version: meta.info.version,
6862
+ localScan: { score: result.score, maxScore: result.maxScore, findings: result.findings },
6863
+ registry: registryData,
6864
+ verbose: !!options.verbose,
6865
+ });
6364
6866
  if (critical.length > 0 || high.length > 0)
6365
6867
  process.exit(1);
6366
6868
  }
@@ -6484,6 +6986,8 @@ async function checkRawUrl(url, options) {
6484
6986
  catch {
6485
6987
  // NanoMind unavailable — use base scan results
6486
6988
  }
6989
+ // Filter local-dev-only findings irrelevant to downloaded URLs
6990
+ filterLocalOnlyFindings(result, scanner);
6487
6991
  const failed = result.findings.filter(f => !f.passed);
6488
6992
  const critical = failed.filter(f => f.severity === 'critical');
6489
6993
  const high = failed.filter(f => f.severity === 'high');
@@ -6502,14 +7006,14 @@ async function checkRawUrl(url, options) {
6502
7006
  });
6503
7007
  return;
6504
7008
  }
6505
- // Display results
6506
- const scoreRatio = result.score / result.maxScore;
6507
- const scoreColor = scoreRatio >= 0.7 ? colors.green : scoreRatio >= 0.4 ? colors.yellow : colors.red;
6508
- console.log(`\n ${displayName} ${colors.dim}(URL)${RESET()}`);
6509
- console.log(` Type: ${result.projectType}`);
6510
- console.log(` Score: ${scoreColor}${result.score}/${result.maxScore}${RESET()}`);
6511
- console.log(` Findings: ${critical.length} critical, ${high.length} high, ${medium.length} medium, ${low.length} low`);
6512
- displayCheckFindings(failed, !!options.verbose);
7009
+ // Display results using unified display
7010
+ displayUnifiedCheck({
7011
+ name: displayName,
7012
+ sourceLabel: 'URL',
7013
+ projectType: result.projectType,
7014
+ localScan: { score: result.score, maxScore: result.maxScore, findings: result.findings },
7015
+ verbose: !!options.verbose,
7016
+ });
6513
7017
  // Community contribution (auto-share if opted in, no first-time prompt for URLs)
6514
7018
  if (process.stdin.isTTY && !globalCiMode) {
6515
7019
  if (isContributeEnabled()) {
@@ -6519,7 +7023,6 @@ async function checkRawUrl(url, options) {
6519
7023
  queuePendingScan(displayName, result);
6520
7024
  }
6521
7025
  }
6522
- printCheckNextSteps(displayName);
6523
7026
  if (critical.length > 0 || high.length > 0)
6524
7027
  process.exit(1);
6525
7028
  }
@@ -6544,30 +7047,24 @@ async function checkRawUrl(url, options) {
6544
7047
  }
6545
7048
  }
6546
7049
  async function checkNpmPackage(name, options) {
6547
- // Step 1: Check registry for existing trust data (unless --rescan forces a fresh scan)
6548
- if (!options.offline && !options.rescan) {
6549
- const registryData = await queryRegistry(name);
6550
- if (registryData?.found && !isScanStale(registryData.lastScannedAt)) {
6551
- // Fresh data in registry — show it
7050
+ // Fetch registry data in parallel with download+scan (unless --no-registry)
7051
+ const registryPromise = options.registry === false ? Promise.resolve(null) : queryRegistry(name);
7052
+ // Registry-only mode (--no-scan): skip local scan
7053
+ if (options.scan === false) {
7054
+ const registryData = await registryPromise;
7055
+ if (registryData?.found) {
6552
7056
  if (options.json) {
6553
7057
  writeJsonStdout({ ...registryData, source: 'registry' });
6554
7058
  return;
6555
7059
  }
6556
- displayRegistryResult(registryData);
7060
+ displayUnifiedCheck({ name, registry: registryData, verbose: !!options.verbose });
6557
7061
  return;
6558
7062
  }
6559
- // Stale or missing — tell the user we're scanning
6560
- if (registryData?.found && registryData.lastScannedAt) {
6561
- if (!options.json && !globalCiMode) {
6562
- const days = Math.floor((Date.now() - new Date(registryData.lastScannedAt).getTime()) / (1000 * 60 * 60 * 24));
6563
- console.error(`\nRegistry data is ${days} day(s) old. Re-scanning...`);
6564
- }
7063
+ if (!options.json && !globalCiMode) {
7064
+ console.error(`No registry data found for ${name}. Running local scan...`);
6565
7065
  }
6566
7066
  }
6567
- else if (options.rescan && !options.json && !globalCiMode) {
6568
- console.error(`Forcing fresh local scan (--rescan)...`);
6569
- }
6570
- // Step 2: Download and scan
7067
+ // Download and scan
6571
7068
  const { mkdtemp, rm } = await Promise.resolve().then(() => __importStar(require('node:fs/promises')));
6572
7069
  const { tmpdir } = await Promise.resolve().then(() => __importStar(require('node:os')));
6573
7070
  const { join } = await Promise.resolve().then(() => __importStar(require('node:path')));
@@ -6583,7 +7080,35 @@ async function checkNpmPackage(name, options) {
6583
7080
  const { stdout } = await execAsync('npm', ['pack', name, '--pack-destination', tempDir], { timeout: 60000 });
6584
7081
  const tarball = stdout.trim().split('\n').pop();
6585
7082
  await execAsync('tar', ['xzf', join(tempDir, tarball), '-C', tempDir], { timeout: 30000 });
6586
- const packageDir = join(tempDir, 'package');
7083
+ // npm tarballs normally extract to 'package/', but some packages (e.g. @types/*)
7084
+ // may use a different directory name. Detect the actual extracted directory.
7085
+ const { readdir, stat } = await Promise.resolve().then(() => __importStar(require('node:fs/promises')));
7086
+ let packageDir = join(tempDir, 'package');
7087
+ try {
7088
+ await stat(packageDir);
7089
+ }
7090
+ catch {
7091
+ // 'package/' doesn't exist — find the extracted directory (skip the .tgz file)
7092
+ const entries = await readdir(tempDir);
7093
+ const dirs = [];
7094
+ for (const entry of entries) {
7095
+ if (entry.endsWith('.tgz') || entry.endsWith('.tar.gz'))
7096
+ continue;
7097
+ const s = await stat(join(tempDir, entry));
7098
+ if (s.isDirectory())
7099
+ dirs.push(entry);
7100
+ }
7101
+ if (dirs.length === 1) {
7102
+ packageDir = join(tempDir, dirs[0]);
7103
+ }
7104
+ else if (dirs.length === 0) {
7105
+ throw new Error(`Tarball extraction produced no directory in ${tempDir}`);
7106
+ }
7107
+ else {
7108
+ // Multiple dirs — pick the first non-hidden one
7109
+ packageDir = join(tempDir, dirs.find(d => !d.startsWith('.')) || dirs[0]);
7110
+ }
7111
+ }
6587
7112
  // Run full HMA scan + NanoMind (same pipeline as `secure`)
6588
7113
  const scanner = new index_1.HardeningScanner();
6589
7114
  const result = await scanner.scan({ targetDir: packageDir, autoFix: false });
@@ -6599,11 +7124,11 @@ async function checkNpmPackage(name, options) {
6599
7124
  catch {
6600
7125
  // NanoMind unavailable — use base scan results
6601
7126
  }
7127
+ // Filter local-dev-only findings irrelevant to downloaded packages
7128
+ filterLocalOnlyFindings(result, scanner);
6602
7129
  const failed = result.findings.filter(f => !f.passed);
6603
7130
  const critical = failed.filter(f => f.severity === 'critical');
6604
7131
  const high = failed.filter(f => f.severity === 'high');
6605
- const medium = failed.filter(f => f.severity === 'medium');
6606
- const low = failed.filter(f => f.severity === 'low');
6607
7132
  if (options.json) {
6608
7133
  writeJsonStdout({
6609
7134
  name,
@@ -6616,19 +7141,20 @@ async function checkNpmPackage(name, options) {
6616
7141
  });
6617
7142
  return;
6618
7143
  }
6619
- // Display results
6620
- const scoreRatio = result.score / result.maxScore;
6621
- const scoreColor = scoreRatio >= 0.7 ? colors.green : scoreRatio >= 0.4 ? colors.yellow : colors.red;
6622
- console.log(`\n ${name}`);
6623
- console.log(` Type: ${result.projectType}`);
6624
- console.log(` Score: ${scoreColor}${result.score}/${result.maxScore}${RESET()}`);
6625
- console.log(` Findings: ${critical.length} critical, ${high.length} high, ${medium.length} medium, ${low.length} low`);
6626
- displayCheckFindings(failed, !!options.verbose);
6627
- // Step 3: Community contribution (after 3 scans, interactive only)
7144
+ // Await registry data (started in parallel with download)
7145
+ const registryData = await registryPromise;
7146
+ // Display results using unified display
7147
+ displayUnifiedCheck({
7148
+ name,
7149
+ projectType: result.projectType,
7150
+ localScan: { score: result.score, maxScore: result.maxScore, findings: result.findings },
7151
+ registry: registryData,
7152
+ verbose: !!options.verbose,
7153
+ });
7154
+ // Community contribution (after 3 scans, interactive only)
6628
7155
  if (process.stdin.isTTY && !globalCiMode) {
6629
7156
  const scanCount = incrementScanCounter();
6630
7157
  if (scanCount >= 3 && !hasContributeChoice()) {
6631
- console.log();
6632
7158
  console.log(` ${colors.dim}Your scans help other developers make safer choices.`);
6633
7159
  console.log(` Sharing adds anonymized results to the OpenA2A trust registry`);
6634
7160
  console.log(` so others can check packages before installing.${RESET()}`);
@@ -6643,10 +7169,9 @@ async function checkNpmPackage(name, options) {
6643
7169
  if (wantsToShare) {
6644
7170
  const ok = await publishToRegistry(name, result);
6645
7171
  if (ok) {
6646
- console.error(` ${colors.green}Shared. Future scans will auto-share.${RESET()}`);
7172
+ console.error(`\n ${colors.green}Thanks for sharing! Future scans will auto-contribute.${RESET()}\n`);
6647
7173
  }
6648
7174
  else {
6649
- // Silent — queue locally and retry on next scan
6650
7175
  queuePendingScan(name, result);
6651
7176
  }
6652
7177
  }
@@ -6659,7 +7184,6 @@ async function checkNpmPackage(name, options) {
6659
7184
  queuePendingScan(name, result);
6660
7185
  }
6661
7186
  }
6662
- printCheckNextSteps(name);
6663
7187
  if (critical.length > 0 || high.length > 0)
6664
7188
  process.exit(1);
6665
7189
  }
@@ -6667,21 +7191,10 @@ async function checkNpmPackage(name, options) {
6667
7191
  const message = err instanceof Error ? err.message : String(err);
6668
7192
  // Clean npm error messages
6669
7193
  if (message.includes('404') || message.includes('Not Found')) {
6670
- console.error(`Error: Package "${name}" not found on npm.`);
6671
- // Suggest similar packages via npm registry search
6672
- try {
6673
- const suggestions = await suggestSimilarPackages(name);
6674
- if (suggestions.length > 0) {
6675
- console.error(`\nDid you mean?`);
6676
- for (const s of suggestions) {
6677
- console.error(` ${s}`);
6678
- }
6679
- console.error();
6680
- }
6681
- }
6682
- catch {
6683
- // Search failed — just show the original error
6684
- }
7194
+ // Throw a typed error so the router can fall through to skill check
7195
+ const notFound = new Error(`NPM_NOT_FOUND:${name}`);
7196
+ notFound.name = 'NpmNotFoundError';
7197
+ throw notFound;
6685
7198
  }
6686
7199
  else {
6687
7200
  console.error(`Error: ${message}`);