hackmyagent 0.11.4 → 0.11.5

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/README.md CHANGED
@@ -4,9 +4,9 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/hackmyagent.svg)](https://www.npmjs.com/package/hackmyagent)
6
6
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
7
- [![Tests](https://img.shields.io/badge/tests-765%20passing-brightgreen)](https://github.com/opena2a-org/hackmyagent)
7
+ [![Tests](https://img.shields.io/badge/tests-1050%20passing-brightgreen)](https://github.com/opena2a-org/hackmyagent)
8
8
 
9
- **163 security checks for AI agents. Find what can go wrong before an attacker does.**
9
+ **173 security checks for AI agents. Find what can go wrong before an attacker does.**
10
10
 
11
11
  Security scanner and red-team toolkit for Claude Code, Cursor, VS Code, and any MCP server setup.
12
12
 
@@ -30,14 +30,19 @@ npx opena2a-cli review
30
30
 
31
31
  ## What It Finds
32
32
 
33
- **Attack testing:**
33
+ **Attack testing (115 payloads across 11 categories):**
34
34
  - **Prompt injection** -- tests whether agents follow injected instructions from untrusted input
35
35
  - **Data exfiltration** -- checks if agents can be tricked into leaking sensitive data to external endpoints
36
36
  - **Jailbreak and context manipulation** -- probes agent guardrails with adversarial prompts
37
37
  - **MCP exploitation** -- tests MCP servers for tool misuse, capability abuse, and unauthorized access
38
38
  - **Capability abuse** -- verifies agents can't exceed their intended permissions
39
+ - **Supply chain attacks** -- dependency confusion, tool shadowing, package impersonation
40
+ - **Memory weaponization** -- persistent instruction injection via agent memory systems
41
+ - **A2A protocol attacks** -- identity spoofing, capability escalation in multi-agent communication
42
+ - **Context window attacks** -- token flooding, attention manipulation, context poisoning
39
43
 
40
- **Static analysis:**
44
+ **Static analysis (173 checks across 34 categories):**
45
+ - **Unicode steganography** -- invisible codepoints (variation selectors, tag characters), zero-width characters (U+200B-200D), mid-file BOM injection, bidi override attacks (U+202A-202E), homoglyph confusables (Cyrillic/Greek/Fullwidth lookalikes), GlassWorm decoder patterns, and eval-on-invisible-payload detection. Scans JS, TS, Python, Markdown, YAML, JSON, and TOML files. ([real-world: os-info-checker-es6 npm attack, May 2025](https://thehackernews.com/2025/05/malicious-npm-package-leverages-unicode.html))
41
46
  - **Hardcoded credentials** -- API keys, tokens, and passwords in source or config files
42
47
  - **MCP server misconfigurations** -- open ports, root filesystem access, missing auth
43
48
  - **AI agent CVE detection** -- scans for CVE-2026-25253 (OpenClaw WebSocket RCE), CVE-2026-25157, CVE-2026-24763, and ClawHavoc IOCs
@@ -45,8 +50,10 @@ npx opena2a-cli review
45
50
  - **Governance gaps** -- missing SOUL.md, no capability policies, unsigned MCP servers
46
51
  - **Credential scope drift** -- Google Maps keys accessing Gemini, AWS S3 keys reaching Bedrock
47
52
  - **Supply chain risks** -- vulnerable dependencies, unsigned skills, tampered packages
53
+ - **Memory and RAG poisoning** -- persistent instruction injection, knowledge base contamination
54
+ - **Agent identity** -- missing cryptographic identity, capability claims without attestation
48
55
 
49
- 163 checks across 34 categories. 55+ attack payloads. No flags needed.
56
+ 173 checks across 34 categories. 115 attack payloads. No flags needed.
50
57
 
51
58
  ---
52
59
 
@@ -69,7 +76,7 @@ npm install --save-dev hackmyagent
69
76
  ```
70
77
 
71
78
  ┌──────────────────────────────────────────┐
72
- │ HackMyAgent v0.10.1 — Security Scanner │
79
+ │ HackMyAgent v0.11.4 — Security Scanner │
73
80
  │ Found: 3 critical · 5 high · 12 medium │
74
81
  │ │
75
82
  │ CRED-001 critical Hardcoded API key in .env │
@@ -88,7 +95,7 @@ npm install --save-dev hackmyagent
88
95
 
89
96
  Step-by-step guides for common workflows:
90
97
 
91
- - **[Scan my agent](docs/use-cases/scan-my-agent.md)** -- Run all 163 checks and auto-fix findings (5 min)
98
+ - **[Scan my agent](docs/use-cases/scan-my-agent.md)** -- Run all 173 checks and auto-fix findings (5 min)
92
99
  - **[Red-team MCP servers](docs/use-cases/red-team-mcp.md)** -- Test MCP servers with adversarial payloads (10 min)
93
100
  - **[Secure OpenClaw](docs/use-cases/openclaw-security.md)** -- OpenClaw-specific checks, CVE detection, ClawHavoc IOC scanning (10 min)
94
101
  - **[CI/CD pipeline](docs/use-cases/ci-pipeline.md)** -- GitHub Actions with JSON/SARIF output (5 min)
@@ -124,7 +131,7 @@ hackmyagent secure --publish # push results to OpenA2A Registry
124
131
 
125
132
 
126
133
  <details>
127
- <summary>All 30 security categories</summary>
134
+ <summary>All 34 security categories</summary>
128
135
 
129
136
  | Category | Checks | What it detects |
130
137
  |----------|--------|-----------------|
@@ -157,7 +164,11 @@ hackmyagent secure --publish # push results to OpenA2A Registry
157
164
  | CONFIG | 9 | Insecure default settings |
158
165
  | SUPPLY | 8 | Supply chain attack vectors |
159
166
  | SKILL | 12 | Malicious skill/tool detection |
160
- | HEARTBEAT | 6 | Heartbeat/cron abuse |
167
+ | HEARTBEAT | 7 | Heartbeat/cron abuse |
168
+ | UNICODE-STEGO | 5 | Invisible codepoints, zero-width chars, bidi attacks, homoglyphs, GlassWorm decoders |
169
+ | MEM | 5 | Memory poisoning, context injection |
170
+ | RAG | 4 | RAG/knowledge base poisoning |
171
+ | AIM | 3 | Agent identity verification |
161
172
 
162
173
  </details>
163
174
 
@@ -185,7 +196,7 @@ Use `--dry-run` to preview changes. Backups are created in `.hackmyagent-backup/
185
196
 
186
197
  ### `hackmyagent attack` -- Red Team
187
198
 
188
- Test your AI agent with 55 adversarial payloads across 5 attack categories.
199
+ Test your AI agent with 115 adversarial payloads across 11 attack categories.
189
200
 
190
201
  ```bash
191
202
  hackmyagent attack --local # local simulation
@@ -205,6 +216,12 @@ hackmyagent attack https://api.example.com --fail-on-vulnerable medium # CI gat
205
216
  | `data-exfiltration` | 11 | Extract sensitive data, system prompts, credentials |
206
217
  | `capability-abuse` | 10 | Misuse agent tools for unintended actions |
207
218
  | `context-manipulation` | 10 | Poison agent context or memory |
219
+ | `supply-chain` | 10 | Dependency confusion, package impersonation |
220
+ | `tool-shadow` | 10 | Tool shadowing, capability escalation |
221
+ | `mcp-exploitation` | 10 | MCP protocol abuse, tool injection |
222
+ | `memory-weaponization` | 10 | Persistent memory poisoning attacks |
223
+ | `a2a-attacks` | 10 | Agent-to-agent identity spoofing |
224
+ | `context-window` | 10 | Token flooding, attention manipulation |
208
225
 
209
226
  > Only test systems you own or have written authorization to test.
210
227
 
package/dist/cli.js CHANGED
@@ -41,6 +41,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
41
41
  const commander_1 = require("commander");
42
42
  const index_1 = require("./index");
43
43
  const resolve_mcp_1 = require("./resolve-mcp");
44
+ const nemoclaw_scanner_1 = require("./hardening/nemoclaw-scanner");
44
45
  const program = new commander_1.Command();
45
46
  // Write JSON to stdout synchronously with retry for pipe backpressure.
46
47
  // process.stdout.write() is async and gets truncated when process.exit()
@@ -1544,16 +1545,21 @@ function resolvePackageVersion(targetDir) {
1544
1545
  * Determines whether to contribute based on:
1545
1546
  * 1. --contribute / --no-contribute CLI flags (highest priority)
1546
1547
  * 2. ~/.opena2a/config.json contribute.enabled setting
1547
- * 3. Interactive opt-in prompt (first scan or scan #10)
1548
1548
  *
1549
- * If contributing, builds an anonymized payload and submits it
1550
- * asynchronously (non-blocking). Failures are logged as warnings.
1549
+ * If contributing, queues an anonymized event to ~/.opena2a/contribute-queue.json
1550
+ * (compatible with @opena2a/contribute format) and flushes when threshold reached.
1551
+ *
1552
+ * Also records the scan and shows a delayed consent tip after the 3rd scan
1553
+ * if the user hasn't opted in or dismissed.
1551
1554
  */
1552
- async function handleContribution(contributeFlag, targetDir, findings, registryUrl, format) {
1555
+ async function handleContribution(contributeFlag, targetDir, findings, durationMs, registryUrl, format) {
1553
1556
  try {
1554
- const { isContributeEnabled, shouldPromptContribute, showContributePrompt, incrementScanCount, buildContributionPayloadFromDir, submitContribution, } = await Promise.resolve().then(() => __importStar(require('./telemetry')));
1555
- // Always increment scan count
1556
- incrementScanCount();
1557
+ const { isContributeEnabled, recordScanAndMaybeShowTip, buildScanEvent, queueAndMaybeFlush, } = await Promise.resolve().then(() => __importStar(require('./telemetry')));
1558
+ // Record scan count and maybe show the delayed consent tip
1559
+ const tip = recordScanAndMaybeShowTip();
1560
+ if (tip && format === 'text' && process.stdout.isTTY) {
1561
+ process.stdout.write(tip + '\n');
1562
+ }
1557
1563
  // Determine whether to contribute
1558
1564
  let shouldContribute;
1559
1565
  if (contributeFlag === true) {
@@ -1566,35 +1572,19 @@ async function handleContribution(contributeFlag, targetDir, findings, registryU
1566
1572
  }
1567
1573
  else {
1568
1574
  // Check config
1569
- const configSetting = isContributeEnabled();
1570
- if (configSetting === true) {
1571
- shouldContribute = true;
1572
- }
1573
- else if (configSetting === false) {
1574
- shouldContribute = false;
1575
- }
1576
- else {
1577
- // Not configured -- prompt after 3 scans (interactive TTY only)
1578
- if (format === 'text' && process.stdout.isTTY && shouldPromptContribute()) {
1579
- shouldContribute = await showContributePrompt();
1580
- }
1581
- else {
1582
- shouldContribute = false;
1583
- }
1584
- }
1575
+ shouldContribute = isContributeEnabled() === true;
1585
1576
  }
1586
1577
  if (!shouldContribute)
1587
1578
  return;
1588
- // Build and submit contribution (non-blocking)
1579
+ // Build and queue contribution event (non-blocking, flushes at threshold)
1589
1580
  const packageName = resolvePackageName(targetDir);
1590
1581
  if (!packageName)
1591
1582
  return;
1592
- const payload = buildContributionPayloadFromDir(packageName, targetDir, findings);
1593
- const result = await submitContribution(payload, registryUrl);
1594
- if (result.success && format === 'text') {
1595
- console.log('Contributed anonymized scan summary to OpenA2A Registry (--no-contribute to opt out)');
1583
+ const event = buildScanEvent(packageName, targetDir, findings, durationMs);
1584
+ await queueAndMaybeFlush(event, registryUrl, format === 'text');
1585
+ if (format === 'text') {
1586
+ process.stdout.write('Queued anonymized scan summary for OpenA2A Registry (--no-contribute to opt out)\n');
1596
1587
  }
1597
- // Failures are silently ignored -- contribution is best-effort
1598
1588
  }
1599
1589
  catch {
1600
1590
  // Non-fatal: contribution failure must never crash the scan
@@ -1606,7 +1596,7 @@ async function handleContribution(contributeFlag, targetDir, findings, registryU
1606
1596
  * Converts SoulScanResult controls into SecurityFinding-like objects
1607
1597
  * for the contribution module, then delegates to handleContribution.
1608
1598
  */
1609
- async function handleSoulContribution(contributeFlag, targetDir, result, registryUrl, format) {
1599
+ async function handleSoulContribution(contributeFlag, targetDir, result, durationMs, registryUrl, format) {
1610
1600
  // Convert soul controls into SecurityFinding-shaped objects
1611
1601
  const findings = [];
1612
1602
  for (const domain of result.domains) {
@@ -1625,7 +1615,7 @@ async function handleSoulContribution(contributeFlag, targetDir, result, registr
1625
1615
  });
1626
1616
  }
1627
1617
  }
1628
- await handleContribution(contributeFlag, targetDir, findings, registryUrl, format);
1618
+ await handleContribution(contributeFlag, targetDir, findings, durationMs, registryUrl, format);
1629
1619
  }
1630
1620
  program
1631
1621
  .command('secure')
@@ -1755,6 +1745,7 @@ Examples:
1755
1745
  }
1756
1746
  }
1757
1747
  const scanner = new index_1.HardeningScanner();
1748
+ const scanStartMs = Date.now();
1758
1749
  const result = await scanner.scan({
1759
1750
  targetDir,
1760
1751
  autoFix: options.fix ?? false,
@@ -1764,6 +1755,7 @@ Examples:
1764
1755
  cliName: CLI_PREFIX,
1765
1756
  onProgress,
1766
1757
  });
1758
+ const scanDurationMs = Date.now() - scanStartMs;
1767
1759
  // OASB-2 composite mode: infrastructure (50%) + governance (50%)
1768
1760
  if (isOasb2) {
1769
1761
  const infraResult = generateBenchmarkReport(result.allFindings || result.findings, level, options.category);
@@ -1899,7 +1891,7 @@ Examples:
1899
1891
  writeJsonStdout(jsonOutput);
1900
1892
  }
1901
1893
  // Community contribution (non-blocking, runs in JSON mode too)
1902
- await handleContribution(options.contribute, targetDir, result.findings, options.registryUrl, format);
1894
+ await handleContribution(options.contribute, targetDir, result.findings, scanDurationMs, options.registryUrl, format);
1903
1895
  const critHigh = result.findings.filter((f) => !f.passed && !f.fixed && (f.severity === 'critical' || f.severity === 'high'));
1904
1896
  if (critHigh.length > 0)
1905
1897
  process.exitCode = 1;
@@ -2128,7 +2120,7 @@ Examples:
2128
2120
  }
2129
2121
  }
2130
2122
  // Community contribution: share anonymized findings with OpenA2A Registry
2131
- await handleContribution(options.contribute, targetDir, result.findings, options.registryUrl, format);
2123
+ await handleContribution(options.contribute, targetDir, result.findings, scanDurationMs, options.registryUrl, format);
2132
2124
  // Star prompt (interactive TTY only, text format only)
2133
2125
  if (process.stdout.isTTY) {
2134
2126
  console.log(`${colors.cyan}Helpful?${RESET()} Star the project: https://github.com/opena2a-org/opena2a\n`);
@@ -2377,6 +2369,180 @@ Examples:
2377
2369
  process.exit(1);
2378
2370
  }
2379
2371
  });
2372
+ // NemoClaw-specific helpers
2373
+ const NEMOCLAW_CHECK_CATEGORIES = nemoclaw_scanner_1.NEMOCLAW_CATEGORIES;
2374
+ function detectNemoClawDirectory(providedDir) {
2375
+ const os = require('os');
2376
+ const fs = require('fs');
2377
+ const path = require('path');
2378
+ if (providedDir && providedDir !== '') {
2379
+ return providedDir.startsWith('/') ? providedDir : path.join(process.cwd(), providedDir);
2380
+ }
2381
+ const homeDir = os.homedir();
2382
+ const candidates = [
2383
+ path.join(homeDir, '.nemoclaw'),
2384
+ path.join(homeDir, '.openshell'),
2385
+ path.join(homeDir, '.openclaw'),
2386
+ ];
2387
+ for (const candidate of candidates) {
2388
+ if (fs.existsSync(candidate)) {
2389
+ return candidate;
2390
+ }
2391
+ }
2392
+ return process.cwd();
2393
+ }
2394
+ function filterNemoClawFindings(findings) {
2395
+ return findings.filter((f) => {
2396
+ const checkId = f.checkId.toUpperCase();
2397
+ return checkId.startsWith('HMA-NMC-');
2398
+ });
2399
+ }
2400
+ function assessNemoClawRiskLevel(findings) {
2401
+ const criticalCount = findings.filter((f) => f.severity === 'critical').length;
2402
+ const highCount = findings.filter((f) => f.severity === 'high').length;
2403
+ const mediumCount = findings.filter((f) => f.severity === 'medium').length;
2404
+ if (criticalCount > 0) {
2405
+ return {
2406
+ level: 'Critical',
2407
+ color: colors.brightRed,
2408
+ description: `${criticalCount} critical finding(s) with recommended fixes available.`,
2409
+ };
2410
+ }
2411
+ if (highCount > 0) {
2412
+ return {
2413
+ level: 'High',
2414
+ color: colors.red,
2415
+ description: `${highCount} high-severity finding(s) detected. Fixes available below.`,
2416
+ };
2417
+ }
2418
+ if (mediumCount > 0) {
2419
+ return {
2420
+ level: 'Moderate',
2421
+ color: colors.yellow,
2422
+ description: 'Some findings detected. Review the recommendations below.',
2423
+ };
2424
+ }
2425
+ if (findings.length === 0) {
2426
+ return {
2427
+ level: 'None',
2428
+ color: colors.dim,
2429
+ description: `No NemoClaw installation detected. Run \`${CLI_PREFIX} secure\` for a full scan.`,
2430
+ };
2431
+ }
2432
+ return {
2433
+ level: 'Low',
2434
+ color: colors.green,
2435
+ description: 'No critical or high findings detected.',
2436
+ };
2437
+ }
2438
+ program
2439
+ .command('secure-nemoclaw')
2440
+ .description(`Security scan for NVIDIA NemoClaw installations
2441
+
2442
+ Performs focused security checks for NemoClaw sandbox deployments:
2443
+ - Secrets: NVIDIA API key exposure in configs, logs, Docker, shell history
2444
+ - Network: Gateway/k3s/inference port binding, Docker socket, egress policies
2445
+ - Skills: Blueprint integrity, skill verification, directory permissions
2446
+ - Process: Sandbox privileges, seccomp/Landlock enforcement, root execution
2447
+ - OpenClaw layer: Inherited misconfigs that survive NemoClaw sandboxing
2448
+
2449
+ Auto-detects ~/.nemoclaw, ~/.openshell, or ~/.openclaw directories.
2450
+ Exit code 1 if critical/high issues found.
2451
+
2452
+ Examples:
2453
+ $ hackmyagent secure-nemoclaw Scan auto-detected directory
2454
+ $ hackmyagent secure-nemoclaw ~/.nemoclaw Scan specific directory
2455
+ $ hackmyagent secure-nemoclaw --json JSON output for CI`)
2456
+ .argument('[directory]', 'Directory to scan (default: ~/.nemoclaw or ~/.openshell)', '')
2457
+ .option('--json', 'Output as JSON (for scripting/CI)')
2458
+ .option('-v, --verbose', 'Show all checks including passed ones')
2459
+ .action(async (directory, options) => {
2460
+ try {
2461
+ const targetDir = detectNemoClawDirectory(directory);
2462
+ if (!options.json) {
2463
+ console.log(`\nNemoClaw Security Report`);
2464
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
2465
+ console.log(`Scanning ${targetDir}...\n`);
2466
+ }
2467
+ const scanner = new nemoclaw_scanner_1.NemoClawScanner();
2468
+ const findings = await scanner.scan(targetDir, {});
2469
+ // Enrich with taxonomy
2470
+ const { enrichWithTaxonomy } = require('./hardening/taxonomy');
2471
+ enrichWithTaxonomy(findings);
2472
+ const issues = findings.filter((f) => !f.passed);
2473
+ const passedFindings = findings.filter((f) => f.passed);
2474
+ if (options.json) {
2475
+ const jsonOutput = {
2476
+ target: targetDir,
2477
+ riskLevel: assessNemoClawRiskLevel(issues).level,
2478
+ totalChecks: findings.length,
2479
+ issues: issues.length,
2480
+ passed: passedFindings.length,
2481
+ findings: findings,
2482
+ };
2483
+ writeJsonStdout(jsonOutput);
2484
+ return;
2485
+ }
2486
+ // Risk assessment
2487
+ const risk = assessNemoClawRiskLevel(issues);
2488
+ console.log(`Risk Level: ${risk.color}${risk.level}${RESET()}`);
2489
+ console.log(`${risk.description}\n`);
2490
+ // Summary stats
2491
+ console.log(`Checks: ${findings.length} total | ${issues.length} issues | ${passedFindings.length} passed\n`);
2492
+ // Show issues
2493
+ if (issues.length > 0) {
2494
+ console.log(`${colors.red}Findings:${RESET()}\n`);
2495
+ for (const finding of issues) {
2496
+ const display = SEVERITY_DISPLAY[finding.severity];
2497
+ const location = finding.file
2498
+ ? finding.line
2499
+ ? `${finding.file}:${finding.line}`
2500
+ : finding.file
2501
+ : '';
2502
+ const sevLabel = finding.severity.charAt(0).toUpperCase() + finding.severity.slice(1);
2503
+ console.log(`${display.color()}${display.symbol} [${finding.checkId}] ${sevLabel}${RESET()}`);
2504
+ console.log(` ${finding.description}`);
2505
+ if (location) {
2506
+ console.log(` File: ${location}`);
2507
+ }
2508
+ if (finding.fix) {
2509
+ console.log(` ${colors.cyan}Recommended fix:${RESET()} ${finding.fix}`);
2510
+ }
2511
+ console.log();
2512
+ }
2513
+ }
2514
+ else {
2515
+ console.log(`${colors.green}No NemoClaw-specific issues found.${RESET()}\n`);
2516
+ }
2517
+ // Show passed checks in verbose mode
2518
+ if (options.verbose && passedFindings.length > 0) {
2519
+ console.log(`${colors.green}Passed Checks:${RESET()}`);
2520
+ for (const finding of passedFindings) {
2521
+ console.log(` ${colors.green}[ok]${RESET()} [${finding.checkId}] ${finding.name}`);
2522
+ }
2523
+ console.log();
2524
+ }
2525
+ // Shodan self-check guidance
2526
+ if (issues.some((f) => f.category === 'network')) {
2527
+ console.log(`${colors.yellow}Internet Exposure Check:${RESET()}`);
2528
+ console.log(` Check if your instance is visible on Shodan:`);
2529
+ console.log(` https://www.shodan.io/host/<YOUR-IP>`);
2530
+ console.log(` Known NemoClaw dorks: port:18789, port:6443 ssl.cert.subject.cn:"k3s-serving"\n`);
2531
+ }
2532
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
2533
+ console.log(`Run '${CLI_PREFIX} secure-openclaw' for OpenClaw-specific checks.`);
2534
+ console.log(`Run '${CLI_PREFIX} secure' for a full security scan.\n`);
2535
+ // Exit with non-zero if critical/high issues remain
2536
+ const criticalOrHigh = issues.filter((f) => f.severity === 'critical' || f.severity === 'high');
2537
+ if (criticalOrHigh.length > 0) {
2538
+ process.exit(1);
2539
+ }
2540
+ }
2541
+ catch (error) {
2542
+ console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
2543
+ process.exit(1);
2544
+ }
2545
+ });
2380
2546
  program
2381
2547
  .command('scan')
2382
2548
  .description(`Scan external target for exposed MCP endpoints
@@ -4047,12 +4213,14 @@ Examples:
4047
4213
  }
4048
4214
  const prefix = getCommandPrefix();
4049
4215
  const scanner = new index_1.SoulScanner();
4216
+ const soulScanStartMs = Date.now();
4050
4217
  const result = await scanner.scanSoul(targetDir, {
4051
4218
  verbose: options.verbose,
4052
4219
  tier: options.tier,
4053
4220
  profile: options.profile,
4054
4221
  deepAnalysis: options.deep,
4055
4222
  });
4223
+ const soulScanDurationMs = Date.now() - soulScanStartMs;
4056
4224
  // JSON output
4057
4225
  if (options.json) {
4058
4226
  // Run publish in JSON mode and include result in output
@@ -4090,6 +4258,7 @@ Examples:
4090
4258
  process.exit(1);
4091
4259
  }
4092
4260
  }
4261
+ await handleSoulContribution(options.contribute, targetDir, result, soulScanDurationMs, options.registryUrl, 'json');
4093
4262
  return;
4094
4263
  }
4095
4264
  // Text output
@@ -4203,7 +4372,7 @@ Examples:
4203
4372
  }
4204
4373
  // Community contribution: share anonymized findings with OpenA2A Registry
4205
4374
  const soulFormat = options.json ? 'json' : 'text';
4206
- await handleSoulContribution(options.contribute, targetDir, result, options.registryUrl, soulFormat);
4375
+ await handleSoulContribution(options.contribute, targetDir, result, soulScanDurationMs, options.registryUrl, soulFormat);
4207
4376
  // In CI mode, exit non-zero if any controls failed
4208
4377
  if (options.ci) {
4209
4378
  const failedControls = result.domains.flatMap(d => d.controls).filter(c => !c.passed);