@synkro-sh/cli 1.4.62 → 1.4.64

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
@@ -837,7 +837,7 @@ var init_hookScriptsTs = __esm({
837
837
  import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, openSync, closeSync, unlinkSync } from 'node:fs';
838
838
  import { join, dirname, basename, extname, resolve as resolvePath } from 'node:path';
839
839
  import { homedir } from 'node:os';
840
- import { execSync, spawn } from 'node:child_process';
840
+ import { execSync } from 'node:child_process';
841
841
  import { constants as FS_CONSTANTS } from 'node:fs';
842
842
 
843
843
  // \u2500\u2500\u2500 Config \u2500\u2500\u2500
@@ -1116,56 +1116,79 @@ export function tag(rt: string, config: HookConfig): string {
1116
1116
  return '[synkro:' + rt + ':' + rs + ']';
1117
1117
  }
1118
1118
 
1119
- // \u2500\u2500\u2500 Local Grading \u2500\u2500\u2500
1119
+ // \u2500\u2500\u2500 Local Grading (direct channel call) \u2500\u2500\u2500
1120
1120
 
1121
- function spawnGrade(surface: string, prompt: string, envOverride?: Record<string, string>, timeoutMs = 22000): Promise<string> {
1122
- return new Promise((resolve, reject) => {
1123
- const cliBin = process.env.SYNKRO_CLI_BIN;
1124
- let cmd: string;
1125
- let args: string[];
1121
+ type GradeRole = 'grade-edit' | 'grade-bash' | 'grade-plan' | 'grade-cwe';
1126
1122
 
1127
- if (cliBin && existsSync(cliBin)) {
1128
- // Use the CLI binary directly with bun/node
1129
- cmd = 'node';
1130
- args = [cliBin, 'grade', surface];
1131
- } else {
1132
- cmd = 'synkro';
1133
- args = ['grade', surface];
1134
- }
1123
+ const ROLE_MAP: Record<string, GradeRole> = {
1124
+ edit: 'grade-edit', bash: 'grade-bash', plan: 'grade-plan', cwe: 'grade-cwe',
1125
+ };
1135
1126
 
1136
- const child = spawn(cmd, args, {
1137
- stdio: ['pipe', 'pipe', 'pipe'],
1138
- env: { ...process.env, ...envOverride },
1139
- });
1127
+ const PRIMER_KEY: Record<GradeRole, string> = {
1128
+ 'grade-edit': 'grader_primer_edit',
1129
+ 'grade-bash': 'grader_primer_bash',
1130
+ 'grade-plan': 'grader_primer_plan',
1131
+ 'grade-cwe': 'grader_primer_cwe',
1132
+ };
1140
1133
 
1141
- let stdout = '';
1142
- let stderr = '';
1143
- child.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
1144
- child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
1145
- child.stdin.write(prompt);
1146
- child.stdin.end();
1147
-
1148
- const timer = setTimeout(() => {
1149
- child.kill();
1150
- reject(new Error('SYNKRO_GRADE_TIMEOUT'));
1151
- }, timeoutMs);
1152
-
1153
- child.on('close', (code: number | null) => {
1154
- clearTimeout(timer);
1155
- if (code === 0) resolve(stdout);
1156
- else reject(new Error(stderr || 'exit ' + code));
1157
- });
1158
- child.on('error', (err: Error) => { clearTimeout(timer); reject(err); });
1134
+ let primerCache: { data: Record<string, string>; ts: number } | null = null;
1135
+
1136
+ async function fetchPrimer(role: GradeRole, jwt: string): Promise<string> {
1137
+ if (primerCache && Date.now() - primerCache.ts < 60_000) {
1138
+ const cached = primerCache.data[PRIMER_KEY[role]];
1139
+ if (cached) return cached;
1140
+ }
1141
+ const resp = await fetch(GATEWAY_URL + '/api/v1/cli/judge-prompts', {
1142
+ headers: { Authorization: 'Bearer ' + jwt },
1143
+ signal: AbortSignal.timeout(4000),
1144
+ });
1145
+ if (!resp.ok) throw new Error('primer fetch failed: ' + resp.status);
1146
+ const data = await resp.json() as Record<string, string>;
1147
+ primerCache = { data, ts: Date.now() };
1148
+ return data[PRIMER_KEY[role]] || '';
1149
+ }
1150
+
1151
+ const CHANNEL_REPLY_INSTRUCTIONS = \`
1152
+ DELIVERY METHOD \u2014 MANDATORY, OVERRIDES ALL OTHER OUTPUT RULES:
1153
+ You are running inside a Synkro MCP channel. Do NOT output your verdict as text.
1154
+ Instead, after generating your verdict, call the reply tool EXACTLY ONCE with:
1155
+ - req_id: the req_id from this channel event's meta
1156
+ - result: your complete verdict block as a string (the <synkro-verdict>\u2026</synkro-verdict> XML)
1157
+ Any text output is silently discarded. Only the reply tool call is captured.\`;
1158
+
1159
+ async function channelGrade(role: GradeRole, prompt: string, jwt: string, port: number, timeoutMs = 20000): Promise<string> {
1160
+ const primer = await fetchPrimer(role, jwt);
1161
+ const content = primer + '\\n\\n' + CHANNEL_REPLY_INSTRUCTIONS + '\\n\\n---\\nPAYLOAD (the input to evaluate):\\n\\n' + prompt;
1162
+ const body = JSON.stringify({ role, content });
1163
+
1164
+ const resp = await fetch('http://127.0.0.1:' + port + '/submit', {
1165
+ method: 'POST',
1166
+ headers: { 'Content-Type': 'application/json' },
1167
+ body,
1168
+ signal: AbortSignal.timeout(timeoutMs),
1159
1169
  });
1170
+
1171
+ if (!resp.ok) {
1172
+ const text = await resp.text().catch(() => '');
1173
+ throw new Error('channel ' + resp.status + ': ' + text.slice(0, 200));
1174
+ }
1175
+
1176
+ const data = await resp.json() as { result?: string; error?: string };
1177
+ if (data.error) throw new Error(data.error);
1178
+ return String(data.result || '');
1160
1179
  }
1161
1180
 
1162
1181
  export async function localGrade(surface: string, prompt: string): Promise<string> {
1163
1182
  if (!(await channelUp())) throw new Error('SYNKRO_CHANNEL_DOWN');
1164
- return spawnGrade(surface, prompt);
1183
+ const jwt = loadJwt();
1184
+ if (!jwt) throw new Error('NO_JWT');
1185
+ return channelGrade(ROLE_MAP[surface] || 'grade-edit', prompt, jwt, 8929);
1165
1186
  }
1166
1187
 
1167
1188
  export async function localGradeCwe(prompt: string): Promise<string> {
1168
- return spawnGrade('cwe', prompt, { SYNKRO_CHANNEL_PORT: '8930' }, 22000);
1189
+ const jwt = loadJwt();
1190
+ if (!jwt) throw new Error('NO_JWT');
1191
+ return channelGrade('grade-cwe', prompt, jwt, 8930, 20000);
1169
1192
  }
1170
1193
 
1171
1194
  // \u2500\u2500\u2500 Verdict Parsing \u2500\u2500\u2500
@@ -1335,7 +1358,7 @@ export function ruleMode(ruleId: string, rules: Rule[]): 'blocking' | 'audit' {
1335
1358
  // \u2500\u2500\u2500 Content Reconstruction \u2500\u2500\u2500
1336
1359
 
1337
1360
  export function reconstructContent(toolName: string, toolInput: any, filePath: string, cwd?: string): string {
1338
- const canRead = filePath && (!cwd || isPathUnder(filePath, cwd));
1361
+ const canRead = filePath && cwd && isPathUnder(filePath, cwd);
1339
1362
  switch (toolName) {
1340
1363
  case 'Write':
1341
1364
  return toolInput.content || '';
@@ -1628,6 +1651,66 @@ export function aggregateUsage(transcriptPath: string): { model: string; totals:
1628
1651
  return result;
1629
1652
  }
1630
1653
 
1654
+ // \u2500\u2500\u2500 Scan Finding Dispatch \u2500\u2500\u2500
1655
+
1656
+ export function dispatchFinding(
1657
+ jwt: string,
1658
+ finding: {
1659
+ session_id: string;
1660
+ file_path: string;
1661
+ finding_type: 'cwe' | 'cve';
1662
+ finding_id: string;
1663
+ severity?: string;
1664
+ status: 'open' | 'resolved' | 'exempted';
1665
+ detail?: string;
1666
+ description?: string;
1667
+ package_name?: string;
1668
+ package_version?: string;
1669
+ fixed_version?: string;
1670
+ aliases?: string[];
1671
+ references?: Array<{ type: string; url: string }>;
1672
+ cwe_name?: string;
1673
+ },
1674
+ captureDepth: string,
1675
+ ): void {
1676
+ const localEntry: Record<string, any> = {
1677
+ capture_type: 'scan_finding',
1678
+ ...finding,
1679
+ };
1680
+ appendLocalTelemetry(localEntry);
1681
+
1682
+ if (captureDepth === 'local_only') return;
1683
+
1684
+ const cloudBody: Record<string, any> = {
1685
+ finding_type: finding.finding_type,
1686
+ finding_id: finding.finding_id,
1687
+ severity: finding.severity,
1688
+ status: finding.status,
1689
+ session_id: finding.session_id,
1690
+ };
1691
+
1692
+ if (captureDepth === 'evidence_on_violation' || captureDepth === 'full') {
1693
+ cloudBody.file_path = finding.file_path;
1694
+ cloudBody.package_name = finding.package_name;
1695
+ cloudBody.package_version = finding.package_version;
1696
+ cloudBody.fixed_version = finding.fixed_version;
1697
+ cloudBody.aliases = finding.aliases;
1698
+ cloudBody.references = finding.references;
1699
+ cloudBody.cwe_name = finding.cwe_name;
1700
+ }
1701
+ if (captureDepth === 'full') {
1702
+ cloudBody.detail = finding.detail;
1703
+ cloudBody.description = finding.description;
1704
+ }
1705
+
1706
+ fetch(GATEWAY_URL + '/api/v1/hook/finding', {
1707
+ method: 'POST',
1708
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
1709
+ body: JSON.stringify(cloudBody),
1710
+ signal: AbortSignal.timeout(3000),
1711
+ }).catch(() => {});
1712
+ }
1713
+
1631
1714
  // \u2500\u2500\u2500 Output Helpers \u2500\u2500\u2500
1632
1715
 
1633
1716
  export function outputJson(obj: any): void {
@@ -1853,7 +1936,7 @@ main();
1853
1936
  import {
1854
1937
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
1855
1938
  localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
1856
- outputJson, outputEmpty, GATEWAY_URL,
1939
+ outputJson, outputEmpty, dispatchFinding, GATEWAY_URL,
1857
1940
  } from './_synkro-common.ts';
1858
1941
  import { basename, extname } from 'node:path';
1859
1942
 
@@ -1979,6 +2062,11 @@ async function main() {
1979
2062
  return;
1980
2063
  }
1981
2064
 
2065
+ const cweNameMap = new Map<string, string>();
2066
+ for (const r of cweRules) {
2067
+ if (r.cwe && r.name) cweNameMap.set(r.cwe.toUpperCase(), r.name);
2068
+ }
2069
+
1982
2070
  const displayIds = activeCweIds.slice(0, 3).join(', ');
1983
2071
  const count = activeCweIds.length;
1984
2072
  const label = count === 1 ? 'match' : 'matches';
@@ -1986,6 +2074,19 @@ async function main() {
1986
2074
  const denyDetail = '[' + displayIds + '] ' + (verdict.reason || 'code weakness detected');
1987
2075
  const ctx = 'CWE: ' + denyDetail + '\\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the weakness in code yourself.';
1988
2076
 
2077
+ for (const cweId of activeCweIds) {
2078
+ dispatchFinding(jwt, {
2079
+ session_id: sessionId,
2080
+ file_path: filePath,
2081
+ finding_type: 'cwe',
2082
+ finding_id: cweId,
2083
+ severity: verdict.severity || 'high',
2084
+ status: 'open',
2085
+ detail: verdict.reason || 'code weakness detected',
2086
+ cwe_name: cweNameMap.get(cweId.toUpperCase()) || undefined,
2087
+ }, config.captureDepth);
2088
+ }
2089
+
1989
2090
  outputJson({
1990
2091
  systemMessage: cweMsg,
1991
2092
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
@@ -1993,6 +2094,14 @@ async function main() {
1993
2094
  return;
1994
2095
  }
1995
2096
 
2097
+ dispatchFinding(jwt, {
2098
+ session_id: sessionId,
2099
+ file_path: filePath,
2100
+ finding_type: 'cwe',
2101
+ finding_id: 'pass',
2102
+ status: 'resolved',
2103
+ }, config.captureDepth);
2104
+
1996
2105
  outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean' });
1997
2106
  return;
1998
2107
  }
@@ -2012,7 +2121,7 @@ main();
2012
2121
  import {
2013
2122
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
2014
2123
  reconstructContent, readStdin, findNearestDeps, log,
2015
- outputJson, outputEmpty, GATEWAY_URL,
2124
+ outputJson, outputEmpty, dispatchFinding, GATEWAY_URL,
2016
2125
  } from './_synkro-common.ts';
2017
2126
  import { basename } from 'node:path';
2018
2127
 
@@ -2042,6 +2151,7 @@ async function main() {
2042
2151
  }
2043
2152
 
2044
2153
  const toolInput = payload.tool_input || {};
2154
+ const sessionId = payload.session_id || '';
2045
2155
  const cwd = payload.cwd || '';
2046
2156
 
2047
2157
  const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
@@ -2103,15 +2213,35 @@ async function main() {
2103
2213
 
2104
2214
  const findings = Array.isArray(cveResp?.findings) ? cveResp.findings : [];
2105
2215
  if (findings.length > 0) {
2106
- const top3 = findings.slice(0, 3).map((f: any) => {
2107
- const id = f.cve || f.id || '?';
2216
+ for (const f of findings.slice(0, 10)) {
2217
+ const cveId = (f.aliases || []).find((a: string) => a.startsWith('CVE-')) || f.id || 'unknown';
2218
+ dispatchFinding(jwt, {
2219
+ session_id: sessionId,
2220
+ file_path: filePath,
2221
+ finding_type: 'cve',
2222
+ finding_id: cveId,
2223
+ severity: f.severity || 'high',
2224
+ status: 'open',
2225
+ detail: f.summary || f.title || 'vulnerable dependency',
2226
+ description: f.details || undefined,
2227
+ package_name: f.package || undefined,
2228
+ package_version: f.version || undefined,
2229
+ fixed_version: f.fixed || undefined,
2230
+ aliases: f.aliases || undefined,
2231
+ references: f.references || undefined,
2232
+ }, config.captureDepth);
2233
+ }
2234
+
2235
+ const formatFinding = (f: any): string => {
2236
+ const id = (f.aliases || []).find((a: string) => a.startsWith('CVE-')) || f.id || '?';
2108
2237
  const pkg = f.package || '?';
2109
2238
  const ver = f.version || '?';
2110
2239
  const title = f.title || f.summary || 'vulnerable';
2111
- const fix = f.fixed ? ' (fix: >=' + f.fixed + ')' : ' (no safe version \u2014 use an alternative)';
2240
+ const fix = f.fixed ? ' (fix: >=' + f.fixed + ')' : ' (no safe version)';
2112
2241
  return '[' + id + '] ' + pkg + '@' + ver + ': ' + title + fix;
2113
- }).join('; ');
2242
+ };
2114
2243
 
2244
+ const top3 = findings.slice(0, 3).map(formatFinding).join('; ');
2115
2245
  const count = findings.length;
2116
2246
  const label = count === 1 ? 'advisory' : 'advisories';
2117
2247
  const cveMsg = cveTag + ' ' + fileShort + ' \\u2192 ' + count + ' ' + label;
@@ -2181,14 +2311,32 @@ async function main() {
2181
2311
  // \u2500\u2500\u2500 CVE scan for package install commands \u2500\u2500\u2500
2182
2312
  if (toolName === 'Bash') {
2183
2313
  const pkgInstallMatch = command.match(
2184
- /(?:npm\\s+(?:install|i|add)|pnpm\\s+(?:add|install|i)|yarn\\s+add|bun\\s+(?:add|install|i)|pip\\s+install|pip3\\s+install|go\\s+get|cargo\\s+add|gem\\s+install|composer\\s+require)\\s+(.+)/
2314
+ /(?: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+(.+)/
2185
2315
  );
2316
+ const isPip = /(?:uv\\s+)?pip3?\\s+install/.test(command);
2317
+ const isGo = command.match(/^go\\s+get/);
2318
+ const isCargo = command.match(/^cargo\\s+add/);
2319
+ const isGem = command.match(/^gem\\s+install/);
2320
+ const isComposer = command.match(/^composer\\s+require/);
2186
2321
  if (pkgInstallMatch) {
2187
2322
  const rawArgs = pkgInstallMatch[1];
2188
2323
  const deps: Record<string, string> = {};
2189
2324
  const tokens = rawArgs.split(/\\s+/);
2325
+ let skipNext = false;
2190
2326
  for (const token of tokens) {
2191
- if (token.startsWith('-')) continue;
2327
+ if (skipNext) { skipNext = false; continue; }
2328
+ if (token.startsWith('-')) {
2329
+ if (/^--(python|target|prefix|root|constraint|requirement|index-url|extra-index-url|find-links|build|src|cache-dir)$/.test(token)) skipNext = true;
2330
+ continue;
2331
+ }
2332
+ if (isPip) {
2333
+ const pipMatch = token.match(/^([a-zA-Z0-9_.-]+)(?:[=~!<>]=?(.+))?$/);
2334
+ if (pipMatch) {
2335
+ deps[pipMatch[1]] = pipMatch[2]?.replace(/^=/, '') || '*';
2336
+ continue;
2337
+ }
2338
+ }
2339
+ // npm/yarn: pkg@1.0
2192
2340
  const atIdx = token.lastIndexOf('@');
2193
2341
  if (atIdx > 0) {
2194
2342
  deps[token.slice(0, atIdx)] = token.slice(atIdx + 1);
@@ -2196,9 +2344,18 @@ async function main() {
2196
2344
  deps[token] = '*';
2197
2345
  }
2198
2346
  }
2347
+ const manifestFile = isPip ? 'requirements.txt'
2348
+ : isGo ? 'go.mod'
2349
+ : isCargo ? 'Cargo.toml'
2350
+ : isGem ? 'Gemfile'
2351
+ : isComposer ? 'composer.json'
2352
+ : 'package.json';
2353
+ const manifestContent = isPip
2354
+ ? Object.entries(deps).map(([k, v]) => v === '*' ? k : k + '==' + v).join('\\n')
2355
+ : JSON.stringify({ dependencies: deps });
2199
2356
  if (Object.keys(deps).length > 0) {
2200
2357
  try {
2201
- const cveBody = { file_path: 'package.json', content: JSON.stringify({ dependencies: deps }), dependencies: deps };
2358
+ const cveBody = { file_path: manifestFile, content: manifestContent, dependencies: deps };
2202
2359
  const cveResp = await fetch(GATEWAY_URL + '/api/v1/cve-scan', {
2203
2360
  method: 'POST',
2204
2361
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -2524,7 +2681,7 @@ async function main() {
2524
2681
  const usage = aggregateUsage(transcriptPath);
2525
2682
  if (usage.totals.in + usage.totals.out > 0) {
2526
2683
  const usageBody = {
2527
- capture_type: 'local_verdict',
2684
+ capture_type: 'usage_tick',
2528
2685
  event_id: 'usage_' + Date.now() + '_' + process.pid,
2529
2686
  hook_type: 'stop',
2530
2687
  verdict: 'allow',
@@ -2856,7 +3013,7 @@ async function main() {
2856
3013
  main();
2857
3014
  `;
2858
3015
  USER_PROMPT_SUBMIT_TS = `#!/usr/bin/env bun
2859
- import { readStdin } from './_synkro-common.ts';
3016
+ import { readStdin, appendLocalTelemetry, aggregateUsage } from './_synkro-common.ts';
2860
3017
  import { writeFileSync, mkdirSync } from 'node:fs';
2861
3018
  import { join, dirname } from 'node:path';
2862
3019
  import { homedir } from 'node:os';
@@ -2872,6 +3029,28 @@ async function main() {
2872
3029
  mkdirSync(dirname(promptFile), { recursive: true });
2873
3030
  writeFileSync(promptFile, msg, 'utf-8');
2874
3031
  }
3032
+
3033
+ const sessionId = payload.session_id || '';
3034
+ const transcriptPath = payload.transcript_path || '';
3035
+ if (sessionId && transcriptPath) {
3036
+ const usage = aggregateUsage(transcriptPath);
3037
+ if (usage.totals.in + usage.totals.out > 0) {
3038
+ appendLocalTelemetry({
3039
+ capture_type: 'usage_tick',
3040
+ event_id: 'usage_' + Date.now() + '_' + process.pid,
3041
+ hook_type: 'prompt_submit',
3042
+ session_id: sessionId,
3043
+ model: usage.model || 'unknown',
3044
+ cc_model: usage.model || '',
3045
+ cc_usage: {
3046
+ input_tokens: usage.totals.in,
3047
+ output_tokens: usage.totals.out,
3048
+ cache_creation_input_tokens: usage.totals.cw,
3049
+ cache_read_input_tokens: usage.totals.cr,
3050
+ },
3051
+ });
3052
+ }
3053
+ }
2875
3054
  } catch {}
2876
3055
  }
2877
3056
 
@@ -5201,7 +5380,7 @@ function writeConfigEnv(opts) {
5201
5380
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5202
5381
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5203
5382
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5204
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.62")}`
5383
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.64")}`
5205
5384
  ];
5206
5385
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5207
5386
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);