@synkro-sh/cli 1.4.78 → 1.4.80

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/bootstrap.js CHANGED
@@ -2136,7 +2136,115 @@ import {
2136
2136
  localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
2137
2137
  outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, filePathFromToolInput, dispatchFinding, dispatchCapture, GATEWAY_URL,
2138
2138
  } from './_synkro-common.ts';
2139
- import { basename, extname } from 'node:path';
2139
+ import { basename, extname, resolve, join, dirname } from 'node:path';
2140
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
2141
+
2142
+
2143
+ interface PackageCapability {
2144
+ name: string;
2145
+ description: string;
2146
+ capabilities: string[];
2147
+ sourceExcerpt: string;
2148
+ }
2149
+
2150
+ const JS_DANGEROUS_MODULES = new Set([
2151
+ 'child_process', 'net', 'dgram', 'http', 'https', 'fs', 'vm',
2152
+ 'worker_threads', 'cluster', 'dns', 'tls', 'crypto',
2153
+ ]);
2154
+
2155
+ const PY_DANGEROUS_MODULES = new Set([
2156
+ 'subprocess', 'os', 'socket', 'requests', 'urllib', 'ctypes',
2157
+ 'importlib', 'shutil', 'tempfile', 'webbrowser',
2158
+ ]);
2159
+
2160
+ function detectNewImports(toolName: string, toolInput: any, fileExt: string): string[] {
2161
+ const newCode = toolName === 'Edit' || toolName === 'edit_file' || toolName === 'reapply'
2162
+ ? (toolInput.new_string || '')
2163
+ : toolName === 'ApplyPatch' || toolName === 'apply_patch'
2164
+ ? (toolInput.patch || toolInput.content || toolInput.code_edit || '')
2165
+ : toolName === 'MultiEdit'
2166
+ ? (Array.isArray(toolInput.edits) ? toolInput.edits.map((e: any) => e?.new_string || '').join('\n') : '')
2167
+ : (toolInput.content || toolInput.code_edit || '');
2168
+
2169
+ if (!newCode) return [];
2170
+ const imports: string[] = [];
2171
+
2172
+ if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(fileExt)) {
2173
+ const jsMatches = newCode.matchAll(/(?:from\s+['"]|require\s*\(\s*['"])([^./'"@][^'"]*|@[^'"]+)['"]/g);
2174
+ for (const m of jsMatches) {
2175
+ const pkg = m[1].startsWith('@') ? m[1].split('/').slice(0, 2).join('/') : m[1].split('/')[0];
2176
+ if (pkg && !imports.includes(pkg)) imports.push(pkg);
2177
+ }
2178
+ } else if (['.py'].includes(fileExt)) {
2179
+ const pyMatches = newCode.matchAll(/(?:^import\s+|^from\s+)([a-zA-Z_]\w*)/gm);
2180
+ for (const m of pyMatches) {
2181
+ if (m[1] && !imports.includes(m[1])) imports.push(m[1]);
2182
+ }
2183
+ }
2184
+
2185
+ return imports;
2186
+ }
2187
+
2188
+ function scanPackageCapabilities(pkgName: string, cwd: string): PackageCapability | null {
2189
+ const pkgDir = resolve(cwd, 'node_modules', pkgName);
2190
+ if (!existsSync(pkgDir)) return null;
2191
+
2192
+ try {
2193
+ const pkgJsonPath = join(pkgDir, 'package.json');
2194
+ if (!existsSync(pkgJsonPath)) return null;
2195
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
2196
+
2197
+ const capabilities: string[] = [];
2198
+ const dangerousFiles: string[] = [];
2199
+
2200
+ function scanDir(dir: string, depth: number) {
2201
+ if (depth > 3 || dangerousFiles.length >= 3) return;
2202
+ let entries: string[];
2203
+ try { entries = readdirSync(dir); } catch { return; }
2204
+ for (const entry of entries) {
2205
+ if (entry === 'node_modules' || entry.startsWith('.')) continue;
2206
+ const full = join(dir, entry);
2207
+ if (entry.endsWith('.js') || entry.endsWith('.mjs') || entry.endsWith('.cjs') || entry.endsWith('.ts')) {
2208
+ try {
2209
+ const src = readFileSync(full, 'utf-8').slice(0, 5000);
2210
+ for (const mod of JS_DANGEROUS_MODULES) {
2211
+ if (src.includes("'" + mod + "'") || src.includes('"' + mod + '"')) {
2212
+ if (!capabilities.includes(mod)) capabilities.push(mod);
2213
+ if (dangerousFiles.length < 3) {
2214
+ const short = full.replace(pkgDir + '/', '');
2215
+ const lines = src.split('\n').filter(l =>
2216
+ JS_DANGEROUS_MODULES.has(l.match(/require\s*\(\s*['"]([^'"]+)['"]\)/)?.[1] || '') ||
2217
+ JS_DANGEROUS_MODULES.has(l.match(/from\s+['"]([^'"]+)['"]/)?.[1] || '')
2218
+ ).slice(0, 5).join('\n');
2219
+ if (lines) dangerousFiles.push('--- ' + short + ' ---\n' + lines);
2220
+ }
2221
+ break;
2222
+ }
2223
+ }
2224
+ } catch {}
2225
+ } else {
2226
+ try {
2227
+ const stat = require('node:fs').statSync(full);
2228
+ if (stat.isDirectory()) scanDir(full, depth + 1);
2229
+ } catch {}
2230
+ }
2231
+ }
2232
+ }
2233
+
2234
+ scanDir(pkgDir, 0);
2235
+
2236
+ if (capabilities.length === 0) return null;
2237
+
2238
+ return {
2239
+ name: pkgName,
2240
+ description: pkgJson.description || '',
2241
+ capabilities,
2242
+ sourceExcerpt: dangerousFiles.join('\n'),
2243
+ };
2244
+ } catch {
2245
+ return null;
2246
+ }
2247
+ }
2140
2248
 
2141
2249
  async function main() {
2142
2250
  setupCursorHookSignals();
@@ -2222,6 +2330,18 @@ async function main() {
2222
2330
  return;
2223
2331
  }
2224
2332
 
2333
+ let localPkgContext = '';
2334
+ if (cwd) {
2335
+ const newImports = detectNewImports(toolName, toolInput, fileExt);
2336
+ const caps = newImports.slice(0, 5)
2337
+ .map(pkg => scanPackageCapabilities(pkg, cwd))
2338
+ .filter((c): c is PackageCapability => c !== null);
2339
+ if (caps.length > 0) {
2340
+ localPkgContext = '\n\nImported packages with notable capabilities:\n' +
2341
+ caps.map(c => '- ' + c.name + ' (claims: "' + c.description + '")\n Capabilities: ' + c.capabilities.join(', ') + '\n' + c.sourceExcerpt).join('\n');
2342
+ }
2343
+ }
2344
+
2225
2345
  const graderPrompt = [
2226
2346
  'File: ' + filePath,
2227
2347
  'Content:',
@@ -2229,7 +2349,7 @@ async function main() {
2229
2349
  '',
2230
2350
  'CWE rules to check against:',
2231
2351
  JSON.stringify(cweRules),
2232
- ].join('\n');
2352
+ ].join('\n') + localPkgContext;
2233
2353
 
2234
2354
  let gradeResp: string;
2235
2355
  try {
@@ -2314,18 +2434,104 @@ async function main() {
2314
2434
  status: 'resolved',
2315
2435
  }, config.captureDepth);
2316
2436
 
2317
- dispatchCapture(jwt, 'cwe', 'pass', 'audit', 'clean',
2437
+ const cleanMsg = cweTag + ' ' + fileShort + ' \u2192 clean' + (verdict.reason ? ' (' + verdict.reason + ')' : '');
2438
+ outputJson({ systemMessage: cleanMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: cleanMsg } });
2439
+ return;
2440
+ }
2441
+
2442
+ // Cloud path \u2014 thin client, all grading logic server-side
2443
+ // Detect imports and scan capabilities for cloud grading
2444
+ let packageContext: PackageCapability[] | undefined;
2445
+ if (cwd) {
2446
+ const newImports = detectNewImports(toolName, toolInput, fileExt);
2447
+ if (newImports.length > 0) {
2448
+ const caps = newImports
2449
+ .slice(0, 5)
2450
+ .map(pkg => scanPackageCapabilities(pkg, cwd))
2451
+ .filter((c): c is PackageCapability => c !== null);
2452
+ if (caps.length > 0) packageContext = caps;
2453
+ }
2454
+ }
2455
+ const scanBody: any = { file_path: filePath, content: cweContent };
2456
+ if (packageContext) scanBody.package_context = packageContext.map(c => ({
2457
+ name: c.name, description: c.description, capabilities: c.capabilities, source_excerpt: c.sourceExcerpt,
2458
+ }));
2459
+ let cweResp: any;
2460
+ try {
2461
+ const resp = await fetch(GATEWAY_URL + '/api/v1/cwe-scan', {
2462
+ method: 'POST',
2463
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2464
+ body: JSON.stringify(scanBody),
2465
+ signal: AbortSignal.timeout(12000),
2466
+ });
2467
+ cweResp = await resp.json();
2468
+ } catch {
2469
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 cloud grader timeout, skipped' });
2470
+ return;
2471
+ }
2472
+
2473
+ const findings = Array.isArray(cweResp?.findings) ? cweResp.findings : [];
2474
+ if (cweResp?.action === 'deny' && findings.length > 0) {
2475
+ const activeCweIds = findings
2476
+ .filter((f: any) => f.mode === 'blocking')
2477
+ .map((f: any) => f.cwe)
2478
+ .filter((id: string) => !exemptedCwes.has(id.toUpperCase()));
2479
+
2480
+ if (activeCweIds.length === 0) {
2481
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 clean (exempted)' });
2482
+ return;
2483
+ }
2484
+
2485
+ const displayIds = activeCweIds.slice(0, 3).join(', ');
2486
+ const count = activeCweIds.length;
2487
+ const label = count === 1 ? 'match' : 'matches';
2488
+ const cweMsg = cweTag + ' ' + fileShort + ' \u2192 ' + count + ' CWE ' + label + ' (' + displayIds + ')';
2489
+
2490
+ const fixLines = findings
2491
+ .filter((f: any) => activeCweIds.includes(f.cwe) && f.suggested_fix)
2492
+ .map((f: any) => '[' + f.cwe + '] Fix: ' + f.suggested_fix);
2493
+ const fixHint = fixLines.length > 0 ? '\n' + fixLines.join('\n') : '';
2494
+ const denyDetail = '[' + displayIds + '] ' + (findings[0]?.reason || 'code weakness detected');
2495
+ const ctx = 'CWE: ' + denyDetail + fixHint + '\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the weakness in code yourself.';
2496
+
2497
+ for (const cweId of activeCweIds) {
2498
+ const f = findings.find((x: any) => x.cwe === cweId);
2499
+ dispatchFinding(jwt, {
2500
+ session_id: sessionId,
2501
+ file_path: filePath,
2502
+ finding_type: 'cwe',
2503
+ finding_id: cweId,
2504
+ severity: f?.severity || 'high',
2505
+ status: 'open',
2506
+ detail: f?.reason || 'code weakness detected',
2507
+ cwe_name: f?.name || undefined,
2508
+ }, config.captureDepth);
2509
+ }
2510
+
2511
+ dispatchCapture(jwt, 'cwe', 'block', findings[0]?.severity || 'high', 'security',
2318
2512
  toolName, gitRepo, sessionId, config.captureDepth, {
2319
2513
  command: 'edit ' + filePath,
2320
- reasoning: verdict.reason || 'no CWE weaknesses detected',
2514
+ reasoning: denyDetail,
2515
+ violatedRules: activeCweIds,
2321
2516
  });
2322
2517
 
2323
- const cleanMsg = cweTag + ' ' + fileShort + ' \u2192 clean' + (verdict.reason ? ' (' + verdict.reason + ')' : '');
2324
- outputJson({ systemMessage: cleanMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: cleanMsg } });
2518
+ outputJson({
2519
+ systemMessage: cweMsg,
2520
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
2521
+ });
2325
2522
  return;
2326
2523
  }
2327
2524
 
2328
- outputEmpty();
2525
+ dispatchFinding(jwt, {
2526
+ session_id: sessionId,
2527
+ file_path: filePath,
2528
+ finding_type: 'cwe',
2529
+ finding_id: 'pass',
2530
+ status: 'resolved',
2531
+ }, config.captureDepth);
2532
+
2533
+ const cleanMsg = cweTag + ' ' + fileShort + ' \u2192 clean' + (cweResp?.summary ? ' (cloud)' : '');
2534
+ outputJson({ systemMessage: cleanMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: cleanMsg } });
2329
2535
  } catch (err) {
2330
2536
  process.stderr.write('[synkro] cweGuard error: ' + String(err) + '\n');
2331
2537
  outputEmpty();
@@ -2501,77 +2707,6 @@ import {
2501
2707
  type HookConfig, type Rule,
2502
2708
  } from './_synkro-common.ts';
2503
2709
 
2504
- const TOP_NPM_PKGS = new Set([
2505
- 'express','react','lodash','chalk','commander','debug','dotenv','webpack',
2506
- 'typescript','moment','uuid','cors','body-parser','mongoose','jsonwebtoken','bcrypt',
2507
- 'nodemon','eslint','prettier','jest','mocha','chai','sinon','supertest','request',
2508
- 'async','bluebird','underscore','ramda','rxjs','socket.io','redis','pg','mysql',
2509
- 'sequelize','knex','prisma','next','nuxt','vue','svelte','angular','ember',
2510
- 'react-dom','react-router','react-redux','redux','mobx','formik','yup','zod',
2511
- 'ajv','joi','helmet','morgan','passport','cookie-parser','express-session',
2512
- 'multer','sharp','jimp','puppeteer','playwright','cheerio','got','node-fetch',
2513
- 'superagent','inquirer','ora','yargs','minimist','glob','rimraf','mkdirp',
2514
- 'fs-extra','chokidar','ws','graphql','apollo-server','fastify','koa','hapi',
2515
- 'nest','drizzle-orm','typeorm','mikro-orm','bull','bullmq','ioredis','kafkajs',
2516
- 'amqplib','nodemailer','handlebars','ejs','pug','marked','highlight.js',
2517
- 'dayjs','date-fns','luxon','nanoid','cuid','short-uuid','colors','picocolors',
2518
- 'winston','pino','bunyan','semver','tar','archiver','unzipper','crypto-js',
2519
- 'bcryptjs','argon2','jose','openai','anthropic','langchain','tensorflow',
2520
- 'onnxruntime-node','sharp','canvas','three','d3','chart.js','echarts',
2521
- 'tailwindcss','postcss','autoprefixer','sass','less','styled-components',
2522
- 'emotion','framer-motion','gsap','lottie-web','swiper','i18next',
2523
- ]);
2524
-
2525
- const TOP_PYPI_PKGS = new Set([
2526
- 'requests','flask','django','numpy','pandas','scipy','matplotlib','scikit-learn',
2527
- 'tensorflow','torch','pytorch','keras','fastapi','uvicorn','gunicorn','celery',
2528
- 'redis','sqlalchemy','alembic','pydantic','httpx','aiohttp','beautifulsoup4',
2529
- 'scrapy','selenium','playwright','pillow','opencv-python','boto3','awscli',
2530
- 'google-cloud-storage','azure-storage-blob','psycopg2','pymongo','motor',
2531
- 'pytest','unittest2','mock','coverage','tox','black','flake8','mypy','ruff',
2532
- 'isort','pylint','bandit','cryptography','paramiko','fabric','click','typer',
2533
- 'rich','colorama','tqdm','loguru','python-dotenv','pyyaml','toml','orjson',
2534
- 'ujson','marshmallow','attrs','dataclasses-json','jinja2','mako','arrow',
2535
- 'pendulum','dateutil','pytz','regex','chardet','charset-normalizer',
2536
- 'langchain','openai','anthropic','transformers','huggingface-hub','tokenizers',
2537
- 'gradio','streamlit','dash','plotly','seaborn','bokeh','altair',
2538
- ]);
2539
-
2540
- function levenshtein(a: string, b: string): number {
2541
- const m = a.length, n = b.length;
2542
- if (Math.abs(m - n) > 2) return 3;
2543
- const dp: number[][] = Array.from({ length: m + 1 }, (_, i) => {
2544
- const row = new Array(n + 1).fill(0);
2545
- row[0] = i;
2546
- return row;
2547
- });
2548
- for (let j = 1; j <= n; j++) dp[0][j] = j;
2549
- for (let i = 1; i <= m; i++) {
2550
- for (let j = 1; j <= n; j++) {
2551
- dp[i][j] = a[i - 1] === b[j - 1]
2552
- ? dp[i - 1][j - 1]
2553
- : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
2554
- }
2555
- }
2556
- return dp[m][n];
2557
- }
2558
-
2559
- function checkTyposquat(pkg: string, isPip: boolean): string | null {
2560
- const topPkgs = isPip ? TOP_PYPI_PKGS : TOP_NPM_PKGS;
2561
- if (topPkgs.has(pkg)) return null;
2562
- const pkgLower = pkg.toLowerCase();
2563
- for (const known of topPkgs) {
2564
- const dist = levenshtein(pkgLower, known);
2565
- if (dist > 0 && dist <= 2) return known;
2566
- }
2567
- return null;
2568
- }
2569
-
2570
- interface PkgMeta {
2571
- deprecated?: string;
2572
- weeklyDownloads?: number;
2573
- }
2574
-
2575
2710
  async function main() {
2576
2711
  setupCursorHookSignals();
2577
2712
  try {
@@ -2615,21 +2750,17 @@ async function main() {
2615
2750
  if (!jwt) { outputEmpty(); return; }
2616
2751
  jwt = await ensureFreshJwt(jwt);
2617
2752
 
2618
- // \u2500\u2500\u2500 Install protection: CVE + typosquat + deprecated + popularity \u2500\u2500\u2500
2753
+ // \u2500\u2500\u2500 Install protection: server-side pkg-scan (CVE + typosquat + tarball + reputation) \u2500\u2500\u2500
2619
2754
  let installScanMsg = '';
2620
2755
  if (toolName === 'Bash') {
2621
2756
  const pkgInstallMatch = command.match(
2622
- /(?:npm\\s+(?:install|i|add)|pnpm\\s+(?:add|install|i)|yarn\\s+add|bun\\s+(?:add|install|i)|(?:uv\\s+)?pip3?\\s+install|go\\s+get|cargo\\s+add|gem\\s+install|composer\\s+require)\\s+([^|;&><]+)/
2757
+ /^(?:.*&&s*|.*;s*)?(?:npms+(?:install|i|add)|pnpms+(?:add|install|i)|yarns+add|buns+(?:add|install|i)|(?:uvs+)?pip3?s+install|gos+get|cargos+add|gems+install|composers+require)s+([^|;&><]+)/
2623
2758
  );
2624
- const isPip = /(?:uv\\s+)?pip3?\\s+install/.test(command);
2625
- const isGo = command.match(/^go\\s+get/);
2626
- const isCargo = command.match(/^cargo\\s+add/);
2627
- const isGem = command.match(/^gem\\s+install/);
2628
- const isComposer = command.match(/^composer\\s+require/);
2759
+ const isPip = /(?:uvs+)?pip3?s+install/.test(command);
2629
2760
  if (pkgInstallMatch) {
2630
2761
  const rawArgs = pkgInstallMatch[1];
2631
- const deps: Record<string, string> = {};
2632
- const tokens = rawArgs.split(/\\s+/);
2762
+ const packages: Array<{ name: string; version: string; ecosystem: string }> = [];
2763
+ const tokens = rawArgs.split(/s+/);
2633
2764
  let skipNext = false;
2634
2765
  for (const token of tokens) {
2635
2766
  if (skipNext) { skipNext = false; continue; }
@@ -2638,164 +2769,88 @@ async function main() {
2638
2769
  if (/^--(python|target|prefix|root|constraint|requirement|index-url|extra-index-url|find-links|build|src|cache-dir|filter|workspace)$/.test(token)) skipNext = true;
2639
2770
  continue;
2640
2771
  }
2772
+ const ecosystem = isPip ? 'PyPI' : 'npm';
2641
2773
  if (isPip) {
2642
2774
  const pipMatch = token.match(/^([a-zA-Z0-9_.-]+)(?:[=~!<>]=?(.+))?$/);
2643
2775
  if (pipMatch) {
2644
- deps[pipMatch[1]] = pipMatch[2]?.replace(/^=/, '') || '*';
2776
+ packages.push({ name: pipMatch[1], version: pipMatch[2]?.replace(/^=/, '') || '*', ecosystem });
2645
2777
  continue;
2646
2778
  }
2647
2779
  }
2648
2780
  const atIdx = token.lastIndexOf('@');
2649
2781
  if (atIdx > 0) {
2650
- deps[token.slice(0, atIdx)] = token.slice(atIdx + 1);
2782
+ packages.push({ name: token.slice(0, atIdx), version: token.slice(atIdx + 1), ecosystem });
2651
2783
  } else {
2652
- deps[token] = '*';
2784
+ packages.push({ name: token, version: '*', ecosystem });
2653
2785
  }
2654
2786
  }
2655
2787
 
2656
- if (Object.keys(deps).length > 0) {
2657
- const warnings: string[] = [];
2658
- const pkgMeta: Record<string, PkgMeta> = {};
2659
-
2660
- const metaLookups = Object.keys(deps).map(async (pkg) => {
2661
- try {
2662
- if (isPip) {
2663
- const r = await fetch('https://pypi.org/pypi/' + encodeURIComponent(pkg) + '/json', { signal: AbortSignal.timeout(4000) });
2664
- if (r.ok) {
2665
- const d = await r.json() as any;
2666
- if (deps[pkg] === '*' && d?.info?.version) deps[pkg] = d.info.version;
2667
- const classifiers: string[] = d?.info?.classifiers || [];
2668
- const isInactive = classifiers.some((c: string) => /Development Status :: [67]/.test(c));
2669
- if (isInactive) pkgMeta[pkg] = { ...pkgMeta[pkg], deprecated: 'package marked as inactive/obsolete' };
2670
- if (d?.info?.yanked) pkgMeta[pkg] = { ...pkgMeta[pkg], deprecated: d.info.yanked_reason || 'yanked from PyPI' };
2671
- } else if (r.status === 404) {
2672
- warnings.push('\\u26a0 ' + pkg + ': package not found on PyPI \\u2014 may not exist');
2673
- }
2674
- } else {
2675
- const verSlug = deps[pkg] !== '*' ? deps[pkg] : 'latest';
2676
- const [metaResp, dlResp] = await Promise.all([
2677
- fetch('https://registry.npmjs.org/' + encodeURIComponent(pkg) + '/' + verSlug, { signal: AbortSignal.timeout(4000) }),
2678
- fetch('https://api.npmjs.org/downloads/point/last-week/' + encodeURIComponent(pkg), { signal: AbortSignal.timeout(4000) }),
2679
- ]);
2680
- if (metaResp.ok) {
2681
- const d = await metaResp.json() as any;
2682
- if (deps[pkg] === '*' && d?.version) deps[pkg] = d.version;
2683
- if (d?.deprecated) pkgMeta[pkg] = { ...pkgMeta[pkg], deprecated: d.deprecated };
2684
- } else if (metaResp.status === 404) {
2685
- warnings.push('\\u26a0 ' + pkg + ': package not found on npm \\u2014 may not exist');
2686
- }
2687
- if (dlResp.ok) {
2688
- const d = await dlResp.json() as any;
2689
- if (typeof d?.downloads === 'number') pkgMeta[pkg] = { ...pkgMeta[pkg], weeklyDownloads: d.downloads };
2690
- }
2691
- }
2692
- } catch {}
2693
- });
2694
- await Promise.all(metaLookups);
2695
-
2696
- for (const pkg of Object.keys(deps)) {
2697
- const similar = checkTyposquat(pkg, isPip);
2698
- if (similar) {
2699
- const dl = pkgMeta[pkg]?.weeklyDownloads;
2700
- if (dl === undefined || dl < 1000) {
2701
- warnings.push('\\u26a0 ' + pkg + ': possible typosquat of "' + similar + '"' + (dl !== undefined ? ' (' + dl + ' weekly downloads)' : '') + ' \\u2014 verify package name');
2702
- }
2703
- }
2704
- }
2705
-
2706
- for (const [pkg, meta] of Object.entries(pkgMeta)) {
2707
- if (meta.deprecated) {
2708
- warnings.push('\\u26a0 ' + pkg + ': deprecated \\u2014 ' + meta.deprecated);
2709
- }
2710
- }
2711
-
2712
- if (!isPip) {
2713
- for (const [pkg, meta] of Object.entries(pkgMeta)) {
2714
- if (meta.weeklyDownloads !== undefined && meta.weeklyDownloads < 50 && !warnings.some(w => w.includes(pkg))) {
2715
- warnings.push('\\u26a0 ' + pkg + ': very low adoption (' + meta.weeklyDownloads + ' weekly downloads) \\u2014 consider a more established alternative');
2716
- }
2717
- }
2718
- }
2719
-
2720
- const manifestFile = isPip ? 'requirements.txt'
2721
- : isGo ? 'go.mod'
2722
- : isCargo ? 'Cargo.toml'
2723
- : isGem ? 'Gemfile'
2724
- : isComposer ? 'composer.json'
2725
- : 'package.json';
2726
- const manifestContent = isPip
2727
- ? Object.entries(deps).map(([k, v]) => v === '*' ? k : k + '==' + v).join('\\n')
2728
- : JSON.stringify({ dependencies: deps });
2729
-
2788
+ if (packages.length > 0) {
2730
2789
  try {
2731
- const cveBody = { file_path: manifestFile, content: manifestContent, dependencies: deps };
2732
- const cveResp = await fetch(GATEWAY_URL + '/api/v1/cve-scan', {
2790
+ const scanResp = await fetch(GATEWAY_URL + '/api/v1/pkg-scan', {
2733
2791
  method: 'POST',
2734
2792
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2735
- body: JSON.stringify(cveBody),
2736
- signal: AbortSignal.timeout(8000),
2793
+ body: JSON.stringify({ packages, command }),
2794
+ signal: AbortSignal.timeout(15000),
2737
2795
  }).then(r => r.json()) as any;
2738
2796
 
2739
- const findings = Array.isArray(cveResp?.findings) ? cveResp.findings : [];
2740
- const scannedPkgs = Object.entries(deps).map(([k, v]) => k + '@' + v).join(', ');
2741
-
2742
- if (findings.length > 0) {
2743
- const top3 = findings.slice(0, 3).map((f: any) => {
2744
- const id = f.cve || f.id || '?';
2745
- const pkg = f.package || '?';
2746
- const ver = f.version || '?';
2747
- const title = f.title || f.summary || 'vulnerable';
2748
- return '[' + id + '] ' + pkg + '@' + ver + ': ' + title;
2749
- }).join('; ');
2750
- const count = findings.length;
2751
- const label = count === 1 ? 'advisory' : 'advisories';
2752
- const cveMsg = '[synkro:installScan] ' + cmdShort + ' \\u2192 ' + count + ' ' + label;
2753
- const ctx = 'CVE: ' + top3 + '\\nDo NOT install packages with known vulnerabilities. Use a patched version or a different package.'
2754
- + (warnings.length > 0 ? '\\n' + warnings.join('\\n') : '');
2797
+ const action = scanResp?.action || 'allow';
2798
+ const pkgResults = Array.isArray(scanResp?.packages) ? scanResp.packages : [];
2799
+ const summary = scanResp?.summary || '';
2800
+
2801
+ if (action === 'block') {
2802
+ const blockSignals = pkgResults
2803
+ .flatMap((p: any) => (p.signals || []).filter((s: any) => s.severity === 'critical' || s.severity === 'high'))
2804
+ .slice(0, 5);
2805
+ const scanMsg = '[synkro:installScan] ' + cmdShort + ' \u2192 blocked';
2806
+ const details = blockSignals.map((s: any) => s.detail).join('
2807
+ ');
2808
+ const ctx = details + '
2809
+ Do NOT install packages with security risks. Use a patched version or a different package.';
2755
2810
 
2756
2811
  const config = await loadConfig(jwt);
2757
- for (const f of findings) {
2758
- dispatchFinding(jwt, {
2759
- session_id: sessionId,
2760
- file_path: command,
2761
- finding_type: 'cve',
2762
- finding_id: f.cve || f.id || f.package,
2763
- severity: typeof f.severity === 'number' ? (f.severity >= 9 ? 'critical' : f.severity >= 7 ? 'high' : f.severity >= 4 ? 'medium' : 'low') : (f.severity || 'medium'),
2764
- status: 'open',
2765
- detail: f.details || f.summary || null,
2766
- description: f.summary || null,
2767
- package_name: f.package || null,
2768
- package_version: f.version || null,
2769
- fixed_version: f.fixed || null,
2770
- aliases: f.aliases || [],
2771
- references: f.references || [],
2772
- }, config.captureDepth);
2812
+ for (const p of pkgResults) {
2813
+ for (const s of (p.signals || [])) {
2814
+ if (s.severity === 'critical' || s.severity === 'high') {
2815
+ dispatchFinding(jwt, {
2816
+ session_id: sessionId,
2817
+ file_path: command,
2818
+ finding_type: s.type === 'cve' ? 'cve' : 'pkg',
2819
+ finding_id: s.type + ':' + p.name,
2820
+ severity: s.severity,
2821
+ status: 'open',
2822
+ detail: s.detail,
2823
+ package_name: p.name,
2824
+ package_version: p.version,
2825
+ }, config.captureDepth);
2826
+ }
2827
+ }
2773
2828
  }
2774
2829
 
2775
- const cveIds = findings.map((f: any) => f.cve || f.id || f.package);
2776
- dispatchCapture(jwt, 'cve', 'block', 'critical', 'security',
2830
+ const violatedIds = blockSignals.map((s: any) => s.type + ':' + s.detail.slice(0, 40));
2831
+ dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
2777
2832
  'Bash', gitRepo, sessionId, config.captureDepth, {
2778
2833
  command,
2779
- reasoning: top3,
2780
- violatedRules: cveIds,
2834
+ reasoning: details.slice(0, 200),
2835
+ violatedRules: violatedIds,
2781
2836
  ccModel: transcript.ccModel,
2782
2837
  });
2783
2838
 
2784
2839
  outputJson({
2785
- systemMessage: cveMsg,
2840
+ systemMessage: scanMsg,
2786
2841
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
2787
2842
  });
2788
2843
  return;
2789
2844
  }
2790
2845
 
2791
- const parts: string[] = ['[synkro:installScan] ' + scannedPkgs + ' \\u2192 clean, no known vulnerabilities'];
2792
- if (warnings.length > 0) parts.push(...warnings);
2793
- installScanMsg = parts.join('\\n');
2794
- } catch (e) {
2795
- log('bashGuard install scan failed: ' + String(e));
2796
- if (warnings.length > 0) {
2797
- installScanMsg = '[synkro:installScan] ' + warnings.join('\\n');
2846
+ if (action === 'warn') {
2847
+ installScanMsg = '[synkro:installScan] ' + summary;
2848
+ } else {
2849
+ const scannedPkgs = packages.map(p => p.name + '@' + p.version).join(', ');
2850
+ installScanMsg = '[synkro:installScan] ' + scannedPkgs + ' \u2192 clean';
2798
2851
  }
2852
+ } catch (e) {
2853
+ log('bashGuard pkg-scan failed: ' + String(e));
2799
2854
  }
2800
2855
  }
2801
2856
  }
@@ -6342,7 +6397,7 @@ function writeConfigEnv(opts) {
6342
6397
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
6343
6398
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
6344
6399
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
6345
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.78")}`
6400
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.80")}`
6346
6401
  ];
6347
6402
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
6348
6403
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);