hackmyagent 0.16.7 → 0.17.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.
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.
181
175
 
182
- Use --rescan to skip the registry cache and force a fresh local scan.
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
183
+
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;
@@ -366,10 +360,10 @@ Examples:
366
360
  });
367
361
  // Severity colors and symbols for secure command
368
362
  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 },
363
+ critical: { symbol: '[!!]', label: 'CRITICAL', color: () => colors.brightRed },
364
+ high: { symbol: '[!]', label: 'HIGH', color: () => colors.red },
365
+ medium: { symbol: '[~]', label: 'MEDIUM', color: () => colors.yellow },
366
+ low: { symbol: '[.]', label: 'LOW', color: () => colors.green },
373
367
  };
374
368
  /**
375
369
  * Display check command findings with optional verbose details.
@@ -408,6 +402,390 @@ function displayCheckFindings(failed, verbose) {
408
402
  console.log(`\n ${colors.green}No security issues found.${RESET()}`);
409
403
  }
410
404
  }
405
+ function stripAnsi(s) {
406
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
407
+ }
408
+ /** Right-align a value at a fixed column width */
409
+ function rightAlign(left, right, width = 68) {
410
+ const leftLen = stripAnsi(left).length;
411
+ const rightLen = stripAnsi(right).length;
412
+ const pad = Math.max(1, width - leftLen - rightLen);
413
+ return `${left}${' '.repeat(pad)}${right}`;
414
+ }
415
+ /** Extract the actionable core of a fix/guidance string.
416
+ * Takes the first sentence, strips file path prefixes that duplicate
417
+ * the finding header, and wraps at terminal width. Never truncates with "...".
418
+ */
419
+ function cleanFixText(text, fileAlreadyShown) {
420
+ // Take first meaningful line (skip blank lines)
421
+ let line = text.split('\n').map(l => l.trim()).filter(Boolean)[0] || text;
422
+ // Strip "In <file>," prefix when file is already shown in the finding header
423
+ if (fileAlreadyShown) {
424
+ const escapedFile = fileAlreadyShown.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
425
+ line = line.replace(new RegExp(`^In ${escapedFile},?\\s*`, 'i'), '');
426
+ // Capitalize first letter after stripping
427
+ if (line.length > 0)
428
+ line = line[0].toUpperCase() + line.slice(1);
429
+ }
430
+ return line;
431
+ }
432
+ /** Shorten a file path for display — show filename + parent dir only */
433
+ function shortenPath(filePath) {
434
+ const parts = filePath.split('/');
435
+ if (parts.length <= 2)
436
+ return filePath;
437
+ return parts.slice(-2).join('/');
438
+ }
439
+ function displayUnifiedCheck(opts) {
440
+ const { name, sourceLabel, projectType, localScan, registry, verbose, version, nanomindScan } = opts;
441
+ // ── Visual helpers ──────────────────────────────────────────────────
442
+ const METER_WIDTH = 20;
443
+ const divider = (label) => {
444
+ if (label) {
445
+ console.log(`\n ${colors.dim}──${RESET()} ${colors.bold}${label}${RESET()} ${colors.dim}${'─'.repeat(Math.max(1, 56 - label.length))}${RESET()}`);
446
+ }
447
+ else {
448
+ console.log(` ${colors.dim}${'─'.repeat(62)}${RESET()}`);
449
+ }
450
+ };
451
+ const scoreMeter = (value, max = 100) => {
452
+ const pct = Math.round((value / max) * METER_WIDTH);
453
+ const meterColor = value >= 70 ? colors.green : value >= 40 ? colors.yellow : colors.red;
454
+ const filled = '━'.repeat(pct);
455
+ const empty = '━'.repeat(METER_WIDTH - pct);
456
+ return `${meterColor}${filled}${RESET()}${colors.dim}${empty}${RESET()} ${meterColor}${colors.bold}${value}${RESET()}${colors.dim}/${max}${RESET()}`;
457
+ };
458
+ const sevBadge = (sev) => {
459
+ const d = SEVERITY_DISPLAY[sev];
460
+ return `${d.color()}${colors.bold}${d.label}${RESET()}`;
461
+ };
462
+ // ── Compute findings ────────────────────────────────────────────────
463
+ let failed = [];
464
+ let score = 0;
465
+ let maxScore = 100;
466
+ let critical = 0, high = 0, medium = 0, low = 0;
467
+ if (localScan) {
468
+ failed = localScan.findings.filter(f => !f.passed);
469
+ score = localScan.score;
470
+ maxScore = localScan.maxScore;
471
+ critical = failed.filter(f => f.severity === 'critical').length;
472
+ high = failed.filter(f => f.severity === 'high').length;
473
+ medium = failed.filter(f => f.severity === 'medium').length;
474
+ low = failed.filter(f => f.severity === 'low').length;
475
+ }
476
+ else if (nanomindScan) {
477
+ const issues = nanomindScan.findings.filter(f => !f.passed);
478
+ critical = issues.filter(f => f.severity === 'critical').length;
479
+ high = issues.filter(f => f.severity === 'high').length;
480
+ medium = issues.filter(f => f.severity === 'medium').length;
481
+ low = issues.filter(f => f.severity === 'low').length;
482
+ failed = issues.map(f => ({
483
+ checkId: f.checkId || '',
484
+ name: f.name || f.description || '',
485
+ description: f.description || '',
486
+ category: f.category || '',
487
+ severity: f.severity,
488
+ passed: false,
489
+ message: f.message || f.description || '',
490
+ fixable: false,
491
+ file: f.file,
492
+ line: f.line,
493
+ fix: f.fix,
494
+ guidance: f.guidance,
495
+ attackClass: f.attackClass,
496
+ }));
497
+ // Score governance-only scans more fairly — governance gaps aren't code vulns
498
+ const hasCodeFindings = issues.some(f => {
499
+ const cat = (f.category || '').toLowerCase();
500
+ const id = f.checkId || '';
501
+ return cat !== 'governance' && cat !== 'injection-hardening' && cat !== 'trust-hierarchy'
502
+ && !id.startsWith('AST-GOV') && !id.startsWith('AST-GOVERN')
503
+ && !id.startsWith('AST-PROMPT') && !id.startsWith('AST-HEARTBEAT');
504
+ });
505
+ if (hasCodeFindings) {
506
+ score = critical > 0 ? Math.max(10, 100 - critical * 20 - high * 10 - medium * 5) : high > 0 ? Math.max(30, 100 - high * 10 - medium * 5) : Math.max(50, 100 - medium * 5 - low * 2);
507
+ }
508
+ else {
509
+ // Governance-only: floor at 25, each finding costs less
510
+ score = Math.max(25, 100 - critical * 8 - high * 5 - medium * 3 - low * 1);
511
+ }
512
+ maxScore = 100;
513
+ }
514
+ else if (registry?.found) {
515
+ score = Math.round(registry.trustScore * 100);
516
+ maxScore = 100;
517
+ }
518
+ const totalFindings = critical + high + medium + low;
519
+ // ── Header ──────────────────────────────────────────────────────────
520
+ const typeLabel = (registry?.packageType || projectType || 'unknown').replace(/_/g, ' ');
521
+ const meta = [typeLabel];
522
+ if (version)
523
+ meta.unshift(`v${version}`);
524
+ if (sourceLabel)
525
+ meta.push(sourceLabel);
526
+ if (nanomindScan)
527
+ meta.push(`${nanomindScan.compiledArtifacts} files analyzed`);
528
+ if (localScan?.filesScanned)
529
+ meta.push(`${localScan.filesScanned} files scanned`);
530
+ console.log();
531
+ console.log(` ${colors.bold}${colors.white}${name}${RESET()} ${colors.dim}${meta.join(' · ')}${RESET()}`);
532
+ // ── Verdict + Score ─────────────────────────────────────────────────
533
+ if (localScan || nanomindScan) {
534
+ let verdictText;
535
+ let verdictColor;
536
+ if (critical > 0) {
537
+ verdictColor = colors.brightRed;
538
+ verdictText = `${critical} critical issue${critical > 1 ? 's' : ''} found`;
539
+ }
540
+ else if (high > 0) {
541
+ verdictColor = colors.red;
542
+ verdictText = `${high} high-severity issue${high > 1 ? 's' : ''} found`;
543
+ }
544
+ else if (totalFindings > 0) {
545
+ verdictColor = colors.yellow;
546
+ verdictText = `${totalFindings} issue${totalFindings > 1 ? 's' : ''} found`;
547
+ }
548
+ else {
549
+ verdictColor = colors.green;
550
+ verdictText = 'No security issues found';
551
+ }
552
+ console.log(` ${verdictColor}${colors.bold}${verdictText}${RESET()}`);
553
+ console.log();
554
+ console.log(` Security ${scoreMeter(score, maxScore)}`);
555
+ }
556
+ else if (registry?.found) {
557
+ const normalized = normalizeTrustVerdict(registry.verdict);
558
+ let verdictText;
559
+ let verdictColor;
560
+ if (normalized === 'blocked') {
561
+ verdictColor = colors.brightRed;
562
+ verdictText = 'Blocked by registry';
563
+ }
564
+ else if (normalized === 'warning') {
565
+ verdictColor = colors.yellow;
566
+ verdictText = 'Warning — review before installing';
567
+ }
568
+ else {
569
+ verdictColor = colors.green;
570
+ verdictText = 'No known issues';
571
+ }
572
+ console.log(` ${verdictColor}${colors.bold}${verdictText}${RESET()}`);
573
+ console.log();
574
+ console.log(` Trust ${scoreMeter(score, maxScore)}`);
575
+ }
576
+ // ── Findings ────────────────────────────────────────────────────────
577
+ if (failed.length > 0) {
578
+ // Severity summary as colored pills
579
+ const summaryParts = [];
580
+ if (critical > 0)
581
+ summaryParts.push(`${colors.brightRed}${colors.bold}${critical} critical${RESET()}`);
582
+ if (high > 0)
583
+ summaryParts.push(`${colors.red}${colors.bold}${high} high${RESET()}`);
584
+ if (medium > 0)
585
+ summaryParts.push(`${colors.yellow}${medium} medium${RESET()}`);
586
+ if (low > 0)
587
+ summaryParts.push(`${colors.dim}${low} low${RESET()}`);
588
+ divider('Findings');
589
+ console.log(` ${summaryParts.join(' ')}`);
590
+ // High-count mode: group by category when > 20 findings
591
+ if (totalFindings > 20 && !verbose) {
592
+ const groups = new Map();
593
+ for (const f of failed) {
594
+ const key = f.category || f.name || 'Other';
595
+ if (!groups.has(key))
596
+ groups.set(key, { critical: 0, high: 0, medium: 0, low: 0, files: new Set() });
597
+ const g = groups.get(key);
598
+ g[f.severity]++;
599
+ if (f.file)
600
+ g.files.add(f.file.split('/')[0] || f.file);
601
+ }
602
+ const sorted = [...groups.entries()].sort((a, b) => {
603
+ const wa = a[1].critical * 4 + a[1].high * 3 + a[1].medium * 2 + a[1].low;
604
+ const wb = b[1].critical * 4 + b[1].high * 3 + b[1].medium * 2 + b[1].low;
605
+ return wb - wa;
606
+ });
607
+ console.log();
608
+ for (const [cat, g] of sorted.slice(0, 8)) {
609
+ const counts = [];
610
+ if (g.critical > 0)
611
+ counts.push(`${colors.brightRed}${g.critical} crit${RESET()}`);
612
+ if (g.high > 0)
613
+ counts.push(`${colors.red}${g.high} high${RESET()}`);
614
+ if (g.medium > 0)
615
+ counts.push(`${colors.dim}${g.medium} med${RESET()}`);
616
+ if (g.low > 0)
617
+ counts.push(`${colors.dim}${g.low} low${RESET()}`);
618
+ const fileHint = g.files.size <= 3 ? ` ${colors.dim}${[...g.files].join(', ')}${RESET()}` : '';
619
+ console.log(` ${colors.dim}│${RESET()} ${cat.padEnd(26)} ${counts.join(', ')}${fileHint}`);
620
+ }
621
+ if (sorted.length > 8) {
622
+ console.log(` ${colors.dim}│ + ${sorted.length - 8} more categories${RESET()}`);
623
+ }
624
+ // Top 3 issues with full detail
625
+ divider('Top Issues');
626
+ const topFindings = [...failed]
627
+ .sort((a, b) => {
628
+ const sw = { critical: 4, high: 3, medium: 2, low: 1 };
629
+ return (sw[b.severity] || 0) - (sw[a.severity] || 0);
630
+ })
631
+ .slice(0, 3);
632
+ for (const f of topFindings) {
633
+ const shortFile = f.file ? shortenPath(f.file) : '';
634
+ const loc = shortFile + (f.line ? `:${f.line}` : '');
635
+ const borderColor = SEVERITY_DISPLAY[f.severity].color();
636
+ console.log();
637
+ console.log(` ${borderColor}│${RESET()} ${sevBadge(f.severity)} ${colors.bold}${colors.white}${f.name || f.message}${RESET()}`);
638
+ if (loc)
639
+ console.log(` ${borderColor}│${RESET()} ${colors.dim}${loc}${RESET()}`);
640
+ if (f.guidance) {
641
+ console.log(` ${borderColor}│${RESET()} ${cleanFixText(f.guidance, f.file)}`);
642
+ }
643
+ if (f.fix) {
644
+ console.log(` ${borderColor}│${RESET()} ${colors.cyan}Fix:${RESET()} ${cleanFixText(f.fix, f.file)}`);
645
+ }
646
+ }
647
+ }
648
+ else {
649
+ // Normal mode: individual findings sorted by severity, with collapse
650
+ const sevWeight = { critical: 4, high: 3, medium: 2, low: 1 };
651
+ failed.sort((a, b) => (sevWeight[b.severity] || 0) - (sevWeight[a.severity] || 0));
652
+ const skipped = new Set();
653
+ let shown = 0;
654
+ const limit = verbose ? failed.length : 10;
655
+ for (let i = 0; i < failed.length; i++) {
656
+ if (shown >= limit)
657
+ break;
658
+ if (skipped.has(i))
659
+ continue;
660
+ const f = failed[i];
661
+ const shortFile = f.file ? shortenPath(f.file) : '';
662
+ const loc = shortFile + (f.line ? `:${f.line}` : '');
663
+ const borderColor = SEVERITY_DISPLAY[f.severity].color();
664
+ console.log();
665
+ console.log(` ${borderColor}│${RESET()} ${sevBadge(f.severity)} ${colors.bold}${colors.white}${f.name || f.message}${RESET()}`);
666
+ if (loc)
667
+ console.log(` ${borderColor}│${RESET()} ${colors.dim}${loc}${RESET()}`);
668
+ if (f.guidance) {
669
+ console.log(` ${borderColor}│${RESET()} ${cleanFixText(f.guidance, f.file)}`);
670
+ }
671
+ if (f.fix) {
672
+ console.log(` ${borderColor}│${RESET()} ${colors.cyan}Fix:${RESET()} ${cleanFixText(f.fix, f.file)}`);
673
+ }
674
+ if (verbose) {
675
+ if (f.checkId)
676
+ console.log(` ${borderColor}│${RESET()} ${colors.dim}Check: ${f.checkId}${RESET()}`);
677
+ if (f.category)
678
+ console.log(` ${borderColor}│${RESET()} ${colors.dim}Category: ${f.category}${RESET()}`);
679
+ }
680
+ shown++;
681
+ // Collapse similar
682
+ if (!verbose) {
683
+ const dir = f.file?.split('/').slice(0, -1).join('/') || '';
684
+ let similarCount = 0;
685
+ for (let j = i + 1; j < failed.length; j++) {
686
+ if (skipped.has(j))
687
+ continue;
688
+ const other = failed[j];
689
+ if (other.name === f.name) {
690
+ const otherDir = other.file?.split('/').slice(0, -1).join('/') || '';
691
+ if (otherDir === dir) {
692
+ skipped.add(j);
693
+ similarCount++;
694
+ }
695
+ }
696
+ }
697
+ if (similarCount > 0) {
698
+ console.log(` ${borderColor}│${RESET()} ${colors.dim}+ ${similarCount} similar${dir ? ` in ${shortenPath(dir)}` : ''}${RESET()}`);
699
+ }
700
+ }
701
+ }
702
+ const remaining = failed.length - shown - skipped.size;
703
+ if (remaining > 0) {
704
+ console.log(`\n ${colors.dim}+ ${remaining} more findings (use --verbose to see all)${RESET()}`);
705
+ }
706
+ }
707
+ // Path forward with recovery math
708
+ if (critical > 0 || high > 0) {
709
+ const recoveryParts = [];
710
+ if (critical > 0)
711
+ recoveryParts.push(`${critical} critical`);
712
+ if (high > 0)
713
+ recoveryParts.push(`${high} high`);
714
+ // Estimate recovered score: governance findings recover less per fix
715
+ const govFindings = failed.filter(f => {
716
+ const cat = (f.category || '').toLowerCase();
717
+ const id = f.checkId || '';
718
+ return cat === 'governance' || cat === 'injection-hardening' || cat === 'trust-hierarchy'
719
+ || id.startsWith('AST-GOV') || id.startsWith('AST-GOVERN')
720
+ || id.startsWith('AST-PROMPT') || id.startsWith('AST-HEARTBEAT');
721
+ });
722
+ const isGovernanceOnly = govFindings.length === failed.length;
723
+ const estRecovery = isGovernanceOnly
724
+ ? Math.min(100, score + (critical * 8 + high * 5))
725
+ : Math.min(100, score + (critical * 15 + high * 8));
726
+ console.log();
727
+ 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()}`);
728
+ }
729
+ }
730
+ // ── Registry ────────────────────────────────────────────────────────
731
+ if (registry?.found) {
732
+ const trustScore = Math.round(registry.trustScore * 100);
733
+ if (localScan || nanomindScan) {
734
+ divider('Registry');
735
+ console.log(` Trust ${scoreMeter(trustScore)}`);
736
+ }
737
+ const tlColor = trustLevelColor(registry.trustLevel);
738
+ const tlLabel = trustLevelLabel(registry.trustLevel);
739
+ console.log(` Level ${tlColor}${colors.bold}${tlLabel}${RESET()} ${colors.dim}(${registry.trustLevel}/4)${RESET()}`);
740
+ if (registry.communityScans !== undefined) {
741
+ console.log(` Community ${registry.communityScans > 0 ? colors.green : colors.dim}${registry.communityScans} scan${registry.communityScans !== 1 ? 's' : ''} shared${RESET()}`);
742
+ }
743
+ if (registry.cveCount !== undefined && registry.cveCount > 0) {
744
+ console.log(` CVEs ${colors.brightRed}${colors.bold}${registry.cveCount}${RESET()}`);
745
+ }
746
+ if (registry.dependencies) {
747
+ const d = registry.dependencies;
748
+ const depParts = [];
749
+ if (d.totalDeps !== undefined)
750
+ depParts.push(`${d.totalDeps} total`);
751
+ if (d.vulnerableDeps !== undefined && d.vulnerableDeps > 0)
752
+ depParts.push(`${colors.red}${d.vulnerableDeps} vulnerable${RESET()}`);
753
+ if (d.minTrustLevel !== undefined)
754
+ depParts.push(`min trust ${d.minTrustLevel}/4`);
755
+ if (depParts.length > 0) {
756
+ console.log(` Deps ${depParts.join(`${colors.dim} · ${RESET()}`)}`);
757
+ }
758
+ }
759
+ // Trust level legend (when not fully verified)
760
+ if (registry.trustLevel < 4) {
761
+ const levels = ['Blocked', 'Warning', 'Listed', 'Scanned', 'Verified'];
762
+ const legend = levels.map((l, i) => {
763
+ if (i === registry.trustLevel)
764
+ return `${tlColor}${colors.bold}${l}${RESET()}`;
765
+ if (i < registry.trustLevel)
766
+ return `${colors.dim}${l}${RESET()}`;
767
+ return `${colors.dim}${l}${RESET()}`;
768
+ }).join(`${colors.dim} > ${RESET()}`);
769
+ console.log(` ${colors.dim}${legend}${RESET()}`);
770
+ }
771
+ }
772
+ // ── Next steps ──────────────────────────────────────────────────────
773
+ const hasGovIssues = failed.some(f => f.category === 'governance' || f.category === 'Governance' || f.checkId?.startsWith('AST-GOV') || f.checkId?.startsWith('AST-PROMPT'));
774
+ 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'));
775
+ const hasCodeVulns = failed.some(f => {
776
+ const cat = (f.category || '').toLowerCase();
777
+ return cat !== 'governance' && cat !== 'injection-hardening' && cat !== 'trust-hierarchy'
778
+ && !f.checkId?.startsWith('AST-GOV') && !f.checkId?.startsWith('AST-GOVERN')
779
+ && !f.checkId?.startsWith('AST-PROMPT') && !f.checkId?.startsWith('AST-HEARTBEAT');
780
+ });
781
+ printCheckNextSteps(name, {
782
+ hasGovernanceIssues: hasGovIssues,
783
+ hasFindings: totalFindings > 0,
784
+ hasCredentialFindings: hasCredIssues,
785
+ hasCodeVulns,
786
+ isCleanScan: totalFindings === 0 && (!!localScan || !!nanomindScan),
787
+ });
788
+ }
411
789
  function groupFindingsBySeverity(findings) {
412
790
  const grouped = {
413
791
  critical: [],
@@ -5912,6 +6290,7 @@ async function queryRegistry(name) {
5912
6290
  const data = await response.json();
5913
6291
  if (!data.packageId)
5914
6292
  return null;
6293
+ const deps = data.dependencies;
5915
6294
  return {
5916
6295
  found: true,
5917
6296
  name: data.name ?? name,
@@ -5921,6 +6300,15 @@ async function queryRegistry(name) {
5921
6300
  scanStatus: data.scanStatus,
5922
6301
  lastScannedAt: data.lastScannedAt,
5923
6302
  packageType: data.packageType,
6303
+ recommendation: data.recommendation,
6304
+ cveCount: typeof data.cveCount === 'number' ? data.cveCount : undefined,
6305
+ communityScans: typeof data.communityScans === 'number' ? data.communityScans : undefined,
6306
+ dependencies: deps ? {
6307
+ totalDeps: typeof deps.totalDeps === 'number' ? deps.totalDeps : undefined,
6308
+ vulnerableDeps: typeof deps.vulnerableDeps === 'number' ? deps.vulnerableDeps : undefined,
6309
+ minTrustLevel: typeof deps.minTrustLevel === 'number' ? deps.minTrustLevel : undefined,
6310
+ riskSummary: deps.riskSummary,
6311
+ } : undefined,
5924
6312
  };
5925
6313
  }
5926
6314
  catch {
@@ -5954,7 +6342,9 @@ async function publishToRegistry(name, result) {
5954
6342
  score: result.score,
5955
6343
  maxScore: result.maxScore,
5956
6344
  projectType: result.projectType,
5957
- findings: result.findings.map(f => ({
6345
+ findings: result.findings
6346
+ .filter(f => !PACKAGE_SCAN_LOCAL_ONLY_CATEGORIES.has(f.category))
6347
+ .map(f => ({
5958
6348
  checkId: f.checkId,
5959
6349
  name: f.name,
5960
6350
  severity: f.severity,
@@ -6020,6 +6410,88 @@ function getFullScanHint() {
6020
6410
  return override;
6021
6411
  return `${CLI_PREFIX} secure <dir>`;
6022
6412
  }
6413
+ /**
6414
+ * Categories that describe local dev-environment setup, not package security.
6415
+ * Findings in these categories are filtered from display when scanning a
6416
+ * *downloaded* package (npm pack, pip download, git clone to temp dir).
6417
+ * They remain visible when scanning a user's own project directory.
6418
+ */
6419
+ const PACKAGE_SCAN_LOCAL_ONLY_CATEGORIES = new Set([
6420
+ 'git',
6421
+ 'permissions',
6422
+ 'environment',
6423
+ 'logging',
6424
+ 'claude-code',
6425
+ 'cursor',
6426
+ 'vscode',
6427
+ ]);
6428
+ /**
6429
+ * Paths that are AI tooling artifacts, not package source code.
6430
+ * Governance findings on these files are noise when scanning a downloaded
6431
+ * package or cloned repo — they're instructions to an AI assistant, not
6432
+ * security vulnerabilities in the package itself.
6433
+ */
6434
+ const AI_TOOLING_PATH_PATTERNS = [
6435
+ /^\.claude\//,
6436
+ /^CLAUDE\.md$/i,
6437
+ /^\.cursorrules$/i,
6438
+ /^\.aider/,
6439
+ /^\.copilot\//,
6440
+ /^\.github\/copilot/,
6441
+ ];
6442
+ /** Governance-related categories/checkId prefixes that are noise on AI tooling files */
6443
+ const GOVERNANCE_CATEGORIES = new Set([
6444
+ 'governance',
6445
+ 'injection-hardening',
6446
+ 'trust-hierarchy',
6447
+ ]);
6448
+ const GOVERNANCE_CHECK_PREFIXES = ['AST-GOV', 'AST-GOVERN', 'AST-PROMPT'];
6449
+ /** Test file path patterns — findings here are lower risk */
6450
+ const TEST_FILE_PATTERNS = [
6451
+ /\btests?\//i,
6452
+ /\b__tests__\//,
6453
+ /\btest_[^/]+$/,
6454
+ /[^/]+_test\.\w+$/,
6455
+ /[^/]+\.test\.\w+$/,
6456
+ /[^/]+\.spec\.\w+$/,
6457
+ /\bfixtures?\//i,
6458
+ ];
6459
+ function isTestFile(filePath) {
6460
+ return TEST_FILE_PATTERNS.some(p => p.test(filePath));
6461
+ }
6462
+ function isAiToolingFile(filePath) {
6463
+ return AI_TOOLING_PATH_PATTERNS.some(p => p.test(filePath));
6464
+ }
6465
+ /**
6466
+ * Filter out local-dev-only findings that are meaningless for downloaded
6467
+ * packages (e.g. "Missing .gitignore" on an npm tarball). Also filters
6468
+ * governance findings on AI tooling files and demotes test file findings.
6469
+ * Mutates `result.findings` in place and recalculates the score.
6470
+ */
6471
+ function filterLocalOnlyFindings(result, scanner) {
6472
+ result.findings = result.findings.filter(f => {
6473
+ // Remove local-only categories (git, permissions, env, etc.)
6474
+ if (PACKAGE_SCAN_LOCAL_ONLY_CATEGORIES.has(f.category))
6475
+ return false;
6476
+ // Remove governance findings on AI tooling files (CLAUDE.md, .claude/, etc.)
6477
+ if (f.file && isAiToolingFile(f.file)) {
6478
+ if (GOVERNANCE_CATEGORIES.has(f.category))
6479
+ return false;
6480
+ if (GOVERNANCE_CHECK_PREFIXES.some(p => f.checkId.startsWith(p)))
6481
+ return false;
6482
+ }
6483
+ return true;
6484
+ });
6485
+ // Demote test file findings to low severity (test code patterns are
6486
+ // lower risk — pickle.load in a test file is not an attack surface)
6487
+ for (const f of result.findings) {
6488
+ if (f.file && isTestFile(f.file) && (f.severity === 'critical' || f.severity === 'high')) {
6489
+ f.originalSeverity = f.severity;
6490
+ f.severity = 'low';
6491
+ }
6492
+ }
6493
+ result.score = scanner.calculateScore(result.findings.filter((f) => !f.passed && !f.fixed)).score;
6494
+ }
6023
6495
  /**
6024
6496
  * Print the standard 3-line next-steps footer shown after every `check`
6025
6497
  * invocation. Lines:
@@ -6031,13 +6503,30 @@ function getFullScanHint() {
6031
6503
  *
6032
6504
  * Suppressed in --ci so machine-readable output stays clean.
6033
6505
  */
6034
- function printCheckNextSteps(target) {
6506
+ function printCheckNextSteps(target, context) {
6035
6507
  if (globalCiMode)
6036
6508
  return;
6037
6509
  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()}`);
6510
+ console.log(` ${colors.dim}──${RESET()} ${colors.bold}Next Steps${RESET()} ${colors.dim}${'─'.repeat(49)}${RESET()}`);
6511
+ if (context?.hasGovernanceIssues) {
6512
+ console.log(` ${colors.cyan}Auto-fix governance:${RESET()} ${CLI_PREFIX} harden-soul ${target}`);
6513
+ }
6514
+ if (context?.hasCredentialFindings) {
6515
+ console.log(` ${colors.cyan}Protect credentials:${RESET()} npx secretless-ai scan`);
6516
+ }
6517
+ if (context?.hasCodeVulns) {
6518
+ console.log(` ${colors.cyan}Auto-fix all issues:${RESET()} ${CLI_PREFIX} secure --fix`);
6519
+ }
6520
+ if (context?.hasFindings) {
6521
+ console.log(` ${colors.cyan}Full project audit:${RESET()} ${getFullScanHint()}`);
6522
+ }
6523
+ else if (context?.isCleanScan) {
6524
+ console.log(` ${colors.cyan}Governance scan:${RESET()} ${CLI_PREFIX} scan-soul ${target}`);
6525
+ console.log(` ${colors.cyan}Red-team test:${RESET()} ${CLI_PREFIX} attack --local`);
6526
+ }
6527
+ else {
6528
+ console.log(` ${colors.cyan}Deep scan:${RESET()} ${CLI_PREFIX} check ${target} --rescan`);
6529
+ }
6041
6530
  console.log();
6042
6531
  }
6043
6532
  /**
@@ -6106,27 +6595,23 @@ async function suggestSimilarPackages(name) {
6106
6595
  async function checkGitHubRepo(target, options) {
6107
6596
  const { org, repo, cloneUrl } = parseGitHubTarget(target);
6108
6597
  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)) {
6598
+ // Fetch registry data in parallel with clone (unless --no-registry)
6599
+ const registryPromise = options.registry === false ? Promise.resolve(null) : queryRegistry(displayName);
6600
+ // Registry-only mode (--no-scan): skip local scan
6601
+ if (options.scan === false) {
6602
+ const registryData = await registryPromise;
6603
+ if (registryData?.found) {
6113
6604
  if (options.json) {
6114
6605
  writeJsonStdout({ ...registryData, source: 'registry' });
6115
6606
  return;
6116
6607
  }
6117
- displayRegistryResult(registryData);
6608
+ displayUnifiedCheck({ name: displayName, sourceLabel: 'GitHub', registry: registryData, verbose: !!options.verbose });
6118
6609
  return;
6119
6610
  }
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
- }
6611
+ if (!options.json && !globalCiMode) {
6612
+ console.error(`No registry data found for ${displayName}. Running local scan...`);
6125
6613
  }
6126
6614
  }
6127
- else if (options.rescan && !options.json && !globalCiMode) {
6128
- console.error(`Forcing fresh local scan (--rescan)...`);
6129
- }
6130
6615
  // Step 2: Clone and scan
6131
6616
  const { mkdtemp, rm } = await Promise.resolve().then(() => __importStar(require('node:fs/promises')));
6132
6617
  const { tmpdir } = await Promise.resolve().then(() => __importStar(require('node:os')));
@@ -6157,6 +6642,8 @@ async function checkGitHubRepo(target, options) {
6157
6642
  catch {
6158
6643
  // NanoMind unavailable — use base scan results
6159
6644
  }
6645
+ // Filter local-dev-only findings irrelevant to cloned repos
6646
+ filterLocalOnlyFindings(result, scanner);
6160
6647
  const failed = result.findings.filter(f => !f.passed);
6161
6648
  const critical = failed.filter(f => f.severity === 'critical');
6162
6649
  const high = failed.filter(f => f.severity === 'high');
@@ -6174,19 +6661,21 @@ async function checkGitHubRepo(target, options) {
6174
6661
  });
6175
6662
  return;
6176
6663
  }
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
6664
+ // Await registry data (started in parallel with clone)
6665
+ const registryData = await registryPromise;
6666
+ // Display results using unified display
6667
+ displayUnifiedCheck({
6668
+ name: displayName,
6669
+ sourceLabel: 'GitHub',
6670
+ projectType: result.projectType,
6671
+ localScan: { score: result.score, maxScore: result.maxScore, findings: result.findings },
6672
+ registry: registryData,
6673
+ verbose: !!options.verbose,
6674
+ });
6675
+ // Community contribution
6186
6676
  if (process.stdin.isTTY && !globalCiMode) {
6187
6677
  const scanCount = incrementScanCounter();
6188
6678
  if (scanCount >= 3 && !hasContributeChoice()) {
6189
- console.log();
6190
6679
  console.log(` ${colors.dim}Your scans help other developers make safer choices.`);
6191
6680
  console.log(` Sharing adds anonymized results to the OpenA2A trust registry`);
6192
6681
  console.log(` so others can check packages before installing.${RESET()}`);
@@ -6201,7 +6690,7 @@ async function checkGitHubRepo(target, options) {
6201
6690
  if (wantsToShare) {
6202
6691
  const ok = await publishToRegistry(displayName, result);
6203
6692
  if (ok) {
6204
- console.error(` ${colors.green}Shared. Future scans will auto-share.${RESET()}`);
6693
+ console.error(`\n ${colors.green}Thanks for sharing! Future scans will auto-contribute.${RESET()}\n`);
6205
6694
  }
6206
6695
  else {
6207
6696
  queuePendingScan(displayName, result);
@@ -6215,7 +6704,6 @@ async function checkGitHubRepo(target, options) {
6215
6704
  queuePendingScan(displayName, result);
6216
6705
  }
6217
6706
  }
6218
- printCheckNextSteps(displayName);
6219
6707
  if (critical.length > 0 || high.length > 0)
6220
6708
  process.exit(1);
6221
6709
  }
@@ -6317,6 +6805,8 @@ async function checkPyPiPackage(target, options) {
6317
6805
  catch {
6318
6806
  // NanoMind unavailable -- use base scan results
6319
6807
  }
6808
+ // Filter local-dev-only findings irrelevant to downloaded packages
6809
+ filterLocalOnlyFindings(result, scanner);
6320
6810
  const failed = result.findings.filter(f => !f.passed);
6321
6811
  const critical = failed.filter(f => f.severity === 'critical');
6322
6812
  const high = failed.filter(f => f.severity === 'high');
@@ -6335,32 +6825,18 @@ async function checkPyPiPackage(target, options) {
6335
6825
  });
6336
6826
  return;
6337
6827
  }
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);
6828
+ // Display results using unified display
6829
+ // Query registry for trust context (PyPI packages have pip: prefix in registry)
6830
+ const registryData = options.registry === false ? null : await queryRegistry(`pip:${name}`);
6831
+ displayUnifiedCheck({
6832
+ name,
6833
+ sourceLabel: 'PyPI',
6834
+ projectType: result.projectType,
6835
+ version: meta.info.version,
6836
+ localScan: { score: result.score, maxScore: result.maxScore, findings: result.findings },
6837
+ registry: registryData,
6838
+ verbose: !!options.verbose,
6839
+ });
6364
6840
  if (critical.length > 0 || high.length > 0)
6365
6841
  process.exit(1);
6366
6842
  }
@@ -6484,6 +6960,8 @@ async function checkRawUrl(url, options) {
6484
6960
  catch {
6485
6961
  // NanoMind unavailable — use base scan results
6486
6962
  }
6963
+ // Filter local-dev-only findings irrelevant to downloaded URLs
6964
+ filterLocalOnlyFindings(result, scanner);
6487
6965
  const failed = result.findings.filter(f => !f.passed);
6488
6966
  const critical = failed.filter(f => f.severity === 'critical');
6489
6967
  const high = failed.filter(f => f.severity === 'high');
@@ -6502,14 +6980,14 @@ async function checkRawUrl(url, options) {
6502
6980
  });
6503
6981
  return;
6504
6982
  }
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);
6983
+ // Display results using unified display
6984
+ displayUnifiedCheck({
6985
+ name: displayName,
6986
+ sourceLabel: 'URL',
6987
+ projectType: result.projectType,
6988
+ localScan: { score: result.score, maxScore: result.maxScore, findings: result.findings },
6989
+ verbose: !!options.verbose,
6990
+ });
6513
6991
  // Community contribution (auto-share if opted in, no first-time prompt for URLs)
6514
6992
  if (process.stdin.isTTY && !globalCiMode) {
6515
6993
  if (isContributeEnabled()) {
@@ -6519,7 +6997,6 @@ async function checkRawUrl(url, options) {
6519
6997
  queuePendingScan(displayName, result);
6520
6998
  }
6521
6999
  }
6522
- printCheckNextSteps(displayName);
6523
7000
  if (critical.length > 0 || high.length > 0)
6524
7001
  process.exit(1);
6525
7002
  }
@@ -6544,30 +7021,24 @@ async function checkRawUrl(url, options) {
6544
7021
  }
6545
7022
  }
6546
7023
  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
7024
+ // Fetch registry data in parallel with download+scan (unless --no-registry)
7025
+ const registryPromise = options.registry === false ? Promise.resolve(null) : queryRegistry(name);
7026
+ // Registry-only mode (--no-scan): skip local scan
7027
+ if (options.scan === false) {
7028
+ const registryData = await registryPromise;
7029
+ if (registryData?.found) {
6552
7030
  if (options.json) {
6553
7031
  writeJsonStdout({ ...registryData, source: 'registry' });
6554
7032
  return;
6555
7033
  }
6556
- displayRegistryResult(registryData);
7034
+ displayUnifiedCheck({ name, registry: registryData, verbose: !!options.verbose });
6557
7035
  return;
6558
7036
  }
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
- }
7037
+ if (!options.json && !globalCiMode) {
7038
+ console.error(`No registry data found for ${name}. Running local scan...`);
6565
7039
  }
6566
7040
  }
6567
- else if (options.rescan && !options.json && !globalCiMode) {
6568
- console.error(`Forcing fresh local scan (--rescan)...`);
6569
- }
6570
- // Step 2: Download and scan
7041
+ // Download and scan
6571
7042
  const { mkdtemp, rm } = await Promise.resolve().then(() => __importStar(require('node:fs/promises')));
6572
7043
  const { tmpdir } = await Promise.resolve().then(() => __importStar(require('node:os')));
6573
7044
  const { join } = await Promise.resolve().then(() => __importStar(require('node:path')));
@@ -6599,11 +7070,11 @@ async function checkNpmPackage(name, options) {
6599
7070
  catch {
6600
7071
  // NanoMind unavailable — use base scan results
6601
7072
  }
7073
+ // Filter local-dev-only findings irrelevant to downloaded packages
7074
+ filterLocalOnlyFindings(result, scanner);
6602
7075
  const failed = result.findings.filter(f => !f.passed);
6603
7076
  const critical = failed.filter(f => f.severity === 'critical');
6604
7077
  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
7078
  if (options.json) {
6608
7079
  writeJsonStdout({
6609
7080
  name,
@@ -6616,19 +7087,20 @@ async function checkNpmPackage(name, options) {
6616
7087
  });
6617
7088
  return;
6618
7089
  }
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)
7090
+ // Await registry data (started in parallel with download)
7091
+ const registryData = await registryPromise;
7092
+ // Display results using unified display
7093
+ displayUnifiedCheck({
7094
+ name,
7095
+ projectType: result.projectType,
7096
+ localScan: { score: result.score, maxScore: result.maxScore, findings: result.findings },
7097
+ registry: registryData,
7098
+ verbose: !!options.verbose,
7099
+ });
7100
+ // Community contribution (after 3 scans, interactive only)
6628
7101
  if (process.stdin.isTTY && !globalCiMode) {
6629
7102
  const scanCount = incrementScanCounter();
6630
7103
  if (scanCount >= 3 && !hasContributeChoice()) {
6631
- console.log();
6632
7104
  console.log(` ${colors.dim}Your scans help other developers make safer choices.`);
6633
7105
  console.log(` Sharing adds anonymized results to the OpenA2A trust registry`);
6634
7106
  console.log(` so others can check packages before installing.${RESET()}`);
@@ -6643,10 +7115,9 @@ async function checkNpmPackage(name, options) {
6643
7115
  if (wantsToShare) {
6644
7116
  const ok = await publishToRegistry(name, result);
6645
7117
  if (ok) {
6646
- console.error(` ${colors.green}Shared. Future scans will auto-share.${RESET()}`);
7118
+ console.error(`\n ${colors.green}Thanks for sharing! Future scans will auto-contribute.${RESET()}\n`);
6647
7119
  }
6648
7120
  else {
6649
- // Silent — queue locally and retry on next scan
6650
7121
  queuePendingScan(name, result);
6651
7122
  }
6652
7123
  }
@@ -6659,7 +7130,6 @@ async function checkNpmPackage(name, options) {
6659
7130
  queuePendingScan(name, result);
6660
7131
  }
6661
7132
  }
6662
- printCheckNextSteps(name);
6663
7133
  if (critical.length > 0 || high.length > 0)
6664
7134
  process.exit(1);
6665
7135
  }