@synkro-sh/cli 1.4.59 → 1.4.60

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
@@ -834,10 +834,11 @@ var init_hookScriptsTs = __esm({
834
834
  "use strict";
835
835
  SYNKRO_COMMON_TS = `
836
836
  // Shared Synkro hook utilities \u2014 imported by all hook scripts.
837
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, rmdirSync, existsSync, renameSync } from 'node:fs';
838
- import { join, dirname, basename, extname } from 'node:path';
837
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, openSync, closeSync, unlinkSync } from 'node:fs';
838
+ import { join, dirname, basename, extname, resolve as resolvePath } from 'node:path';
839
839
  import { homedir } from 'node:os';
840
840
  import { execSync, spawn } from 'node:child_process';
841
+ import { constants as FS_CONSTANTS } from 'node:fs';
841
842
 
842
843
  // \u2500\u2500\u2500 Config \u2500\u2500\u2500
843
844
 
@@ -868,6 +869,15 @@ export const GATEWAY_URL = process.env.SYNKRO_GATEWAY_URL || 'https://api.synkro
868
869
  export const CREDS_PATH = process.env.SYNKRO_CREDENTIALS_PATH || join(HOME, '.synkro', 'credentials.json');
869
870
  const LAST_PROMPT_FILE = join(HOME, '.synkro', '.last-prompt');
870
871
 
872
+ // \u2500\u2500\u2500 Path Validation \u2500\u2500\u2500
873
+
874
+ export function isPathUnder(filePath: string, cwd: string): boolean {
875
+ if (!filePath || !cwd) return false;
876
+ const resolved = resolvePath(filePath);
877
+ const base = resolvePath(cwd);
878
+ return resolved.startsWith(base + '/') || resolved === base;
879
+ }
880
+
871
881
  // \u2500\u2500\u2500 Logging \u2500\u2500\u2500
872
882
 
873
883
  export function log(msg: string): void {
@@ -936,24 +946,22 @@ export async function ensureFreshJwt(jwt: string): Promise<string> {
936
946
  const now = Math.floor(Date.now() / 1000);
937
947
  if (exp - now >= 60) return jwt;
938
948
 
939
- const lockdir = CREDS_PATH + '.lockdir';
940
- let acquired = false;
949
+ const lockfile = CREDS_PATH + '.lock';
950
+ let fd = -1;
941
951
  try {
942
- mkdirSync(lockdir);
943
- acquired = true;
952
+ fd = openSync(lockfile, FS_CONSTANTS.O_WRONLY | FS_CONSTANTS.O_CREAT | FS_CONSTANTS.O_EXCL, 0o644);
944
953
  } catch {
945
- // Another process is refreshing \u2014 wait and re-read
946
- for (let i = 0; i < 5; i++) {
947
- await new Promise(r => setTimeout(r, 500));
948
- if (!existsSync(lockdir)) break;
954
+ // Another process holds the lock \u2014 wait for it to finish and re-read
955
+ for (let i = 0; i < 10; i++) {
956
+ await new Promise(r => setTimeout(r, 300));
957
+ if (!existsSync(lockfile)) break;
949
958
  }
950
- // Re-read creds
951
959
  const fresh = loadJwt();
952
960
  return fresh || jwt;
953
961
  }
954
962
 
955
963
  try {
956
- // Re-check \u2014 another hook may have just refreshed
964
+ // Re-check \u2014 another hook may have just refreshed while we waited
957
965
  const freshJwt = loadJwt();
958
966
  if (freshJwt) {
959
967
  const freshExp = decodeJwtExp(freshJwt);
@@ -961,9 +969,8 @@ export async function ensureFreshJwt(jwt: string): Promise<string> {
961
969
  }
962
970
  return await refreshJwt(jwt);
963
971
  } finally {
964
- if (acquired) {
965
- try { rmdirSync(lockdir); } catch {}
966
- }
972
+ try { closeSync(fd); } catch {}
973
+ try { unlinkSync(lockfile); } catch {}
967
974
  }
968
975
  }
969
976
 
@@ -1014,7 +1021,7 @@ export interface HookConfig {
1014
1021
  silent: boolean;
1015
1022
  policyName: string;
1016
1023
  rules: Rule[];
1017
- scanExemptions: string[];
1024
+ scanExemptions: Array<{ path: string; cwe_id: string }>;
1018
1025
  }
1019
1026
 
1020
1027
  export async function loadConfig(jwt: string, query?: string): Promise<HookConfig> {
@@ -1038,7 +1045,9 @@ export async function loadConfig(jwt: string, query?: string): Promise<HookConfi
1038
1045
  config.silent = data.silent_mode === true || data.silent_mode === 'true';
1039
1046
  config.policyName = data.active_policy_name || '';
1040
1047
  if (Array.isArray(data.scan_exemptions)) {
1041
- config.scanExemptions = data.scan_exemptions.filter((s: any) => typeof s === 'string');
1048
+ config.scanExemptions = data.scan_exemptions
1049
+ .filter((e: any) => e && typeof e.path === 'string')
1050
+ .map((e: any) => ({ path: e.path, cwe_id: e.cwe_id || '' }));
1042
1051
  }
1043
1052
  if (Array.isArray(data.rules)) {
1044
1053
  config.rules = data.rules
@@ -1283,14 +1292,15 @@ export function ruleMode(ruleId: string, rules: Rule[]): 'blocking' | 'audit' {
1283
1292
 
1284
1293
  // \u2500\u2500\u2500 Content Reconstruction \u2500\u2500\u2500
1285
1294
 
1286
- export function reconstructContent(toolName: string, toolInput: any, filePath: string): string {
1295
+ export function reconstructContent(toolName: string, toolInput: any, filePath: string, cwd?: string): string {
1296
+ const canRead = filePath && (!cwd || isPathUnder(filePath, cwd));
1287
1297
  switch (toolName) {
1288
1298
  case 'Write':
1289
1299
  return toolInput.content || '';
1290
1300
  case 'Edit': {
1291
1301
  let content = '';
1292
1302
  try {
1293
- if (filePath && existsSync(filePath)) {
1303
+ if (canRead && existsSync(filePath)) {
1294
1304
  content = readFileSync(filePath, 'utf-8').slice(0, 65536);
1295
1305
  }
1296
1306
  } catch {}
@@ -1304,7 +1314,7 @@ export function reconstructContent(toolName: string, toolInput: any, filePath: s
1304
1314
  case 'MultiEdit': {
1305
1315
  let content = '';
1306
1316
  try {
1307
- if (filePath && existsSync(filePath)) {
1317
+ if (canRead && existsSync(filePath)) {
1308
1318
  content = readFileSync(filePath, 'utf-8').slice(0, 65536);
1309
1319
  }
1310
1320
  } catch {}
@@ -1589,7 +1599,7 @@ export function outputEmpty(): void {
1589
1599
  EDIT_PRECHECK_TS = `#!/usr/bin/env bun
1590
1600
  import {
1591
1601
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
1592
- parseVerdict, dispatchCapture, ruleMode, reconstructContent, postWithRetry,
1602
+ parseVerdict, dispatchCapture, ruleMode, reconstructContent, isPathUnder, postWithRetry,
1593
1603
  readStdin, extractTranscript, readLastPrompt, findNearestDeps, log,
1594
1604
  outputJson, outputEmpty, GATEWAY_URL,
1595
1605
  type HookConfig, type Rule,
@@ -1631,7 +1641,7 @@ async function main() {
1631
1641
  jwt = await ensureFreshJwt(jwt);
1632
1642
 
1633
1643
  // Reconstruct proposed content
1634
- const proposed = reconstructContent(toolName, toolInput, filePath);
1644
+ const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
1635
1645
  if (!proposed) { outputEmpty(); return; }
1636
1646
 
1637
1647
  // Build diff field
@@ -1645,7 +1655,7 @@ async function main() {
1645
1655
 
1646
1656
  // Read file before edit for cloud payload
1647
1657
  let fileBefore = '';
1648
- if (toolName !== 'Write' && filePath && existsSync(filePath)) {
1658
+ if (toolName !== 'Write' && filePath && isPathUnder(filePath, cwd || '.') && existsSync(filePath)) {
1649
1659
  try { fileBefore = readFileSync(filePath, 'utf-8').slice(0, 65536); } catch {}
1650
1660
  }
1651
1661
 
@@ -1835,17 +1845,19 @@ async function main() {
1835
1845
  jwt = await ensureFreshJwt(jwt);
1836
1846
 
1837
1847
  // Reconstruct proposed content
1838
- const proposed = reconstructContent(toolName, toolInput, filePath);
1848
+ const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
1839
1849
  if (!proposed) { outputEmpty(); return; }
1840
1850
 
1841
1851
  const config = await loadConfig(jwt);
1842
1852
  const rt = await cweRoute(config);
1843
1853
 
1844
- // Check scan exemptions from server
1845
- if (config.scanExemptions.length > 0 && config.scanExemptions.some(ex => filePath.includes(ex))) {
1846
- outputEmpty(); return;
1854
+ // Build set of exempted CWE IDs for this file path
1855
+ const exemptedCwes = new Set<string>();
1856
+ for (const ex of config.scanExemptions) {
1857
+ if (ex.cwe_id && filePath.includes(ex.path)) {
1858
+ exemptedCwes.add(ex.cwe_id.toUpperCase());
1859
+ }
1847
1860
  }
1848
-
1849
1861
  if (config.silent) {
1850
1862
  outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
1851
1863
  return;
@@ -1891,15 +1903,23 @@ async function main() {
1891
1903
  const verdict = parseVerdict(gradeResp);
1892
1904
 
1893
1905
  if (!verdict.ok) {
1894
- // Extract all CWE rule_ids from the raw response
1895
1906
  const ruleIdMatches = gradeResp.match(/<rule_id>([^<]+)<\\/rule_id>/g) || [];
1896
1907
  const cweIds: string[] = [];
1897
1908
  for (const match of ruleIdMatches.slice(0, 5)) {
1898
1909
  const id = match.replace(/<\\/?rule_id>/g, '').trim().replace(/^cwe-/, 'CWE-');
1899
1910
  if (id && !cweIds.includes(id)) cweIds.push(id);
1900
1911
  }
1901
- const displayIds = cweIds.slice(0, 3).join(', ');
1902
- const count = cweIds.length;
1912
+
1913
+ // Filter out exempted CWEs for this file
1914
+ const activeCweIds = cweIds.filter(id => !exemptedCwes.has(id.toUpperCase()));
1915
+
1916
+ if (activeCweIds.length === 0) {
1917
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean (exempted: ' + cweIds.join(', ') + ')' });
1918
+ return;
1919
+ }
1920
+
1921
+ const displayIds = activeCweIds.slice(0, 3).join(', ');
1922
+ const count = activeCweIds.length;
1903
1923
  const label = count === 1 ? 'match' : 'matches';
1904
1924
  const cweMsg = cweTag + ' ' + fileShort + ' \\u2192 ' + count + ' CWE ' + label + ' (' + displayIds + ')';
1905
1925
  const denyDetail = '[' + displayIds + '] ' + (verdict.reason || 'code weakness detected');
@@ -1985,7 +2005,7 @@ async function main() {
1985
2005
  const cveTag = '[synkro:' + rt + ':cveScan]';
1986
2006
 
1987
2007
  // Reconstruct proposed content
1988
- const proposed = reconstructContent(toolName, toolInput, filePath);
2008
+ const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
1989
2009
  if (!proposed) {
1990
2010
  outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 skip (no content)' });
1991
2011
  return;
@@ -2420,7 +2440,7 @@ main();
2420
2440
  STOP_SUMMARY_TS = `#!/usr/bin/env bun
2421
2441
  import {
2422
2442
  loadJwt, detectRepo, loadConfig, tag, readStdin, aggregateUsage,
2423
- outputJson, outputEmpty, GATEWAY_URL,
2443
+ outputJson, outputEmpty, appendLocalTelemetry, GATEWAY_URL,
2424
2444
  } from './_synkro-common.ts';
2425
2445
 
2426
2446
  async function main() {
@@ -2459,6 +2479,7 @@ async function main() {
2459
2479
  ...(gitRepo ? { repo: gitRepo } : {}),
2460
2480
  ...(sessionId ? { session_id: sessionId } : {}),
2461
2481
  };
2482
+ appendLocalTelemetry(usageBody);
2462
2483
  fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2463
2484
  method: 'POST',
2464
2485
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -2570,7 +2591,7 @@ main();
2570
2591
  BASH_FOLLOWUP_TS = `#!/usr/bin/env bun
2571
2592
  import {
2572
2593
  loadJwt, readStdin, hashCommand, consentGrant, consentHasActive, consentConsume,
2573
- outputEmpty, GATEWAY_URL,
2594
+ outputEmpty, appendLocalTelemetry, GATEWAY_URL,
2574
2595
  } from './_synkro-common.ts';
2575
2596
 
2576
2597
  async function main() {
@@ -2611,6 +2632,8 @@ async function main() {
2611
2632
  command_hash: cmdHash,
2612
2633
  };
2613
2634
 
2635
+ appendLocalTelemetry(body);
2636
+
2614
2637
  fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2615
2638
  method: 'POST',
2616
2639
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -2628,7 +2651,7 @@ main();
2628
2651
  `;
2629
2652
  TRANSCRIPT_SYNC_TS = `#!/usr/bin/env bun
2630
2653
  import {
2631
- loadJwt, detectRepo, readStdin, aggregateUsage,
2654
+ loadJwt, detectRepo, readStdin, aggregateUsage, appendLocalTelemetry,
2632
2655
  outputEmpty, GATEWAY_URL,
2633
2656
  } from './_synkro-common.ts';
2634
2657
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
@@ -2671,6 +2694,7 @@ async function main() {
2671
2694
  },
2672
2695
  session_id: sessionId,
2673
2696
  };
2697
+ appendLocalTelemetry(usageBody);
2674
2698
  fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2675
2699
  method: 'POST',
2676
2700
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -5113,7 +5137,7 @@ function writeConfigEnv(opts) {
5113
5137
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5114
5138
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5115
5139
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5116
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.59")}`
5140
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.60")}`
5117
5141
  ];
5118
5142
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5119
5143
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);