@synkro-sh/cli 1.4.59 → 1.4.61

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 {
@@ -901,69 +911,96 @@ function decodeJwtExp(jwt: string): number {
901
911
  }
902
912
 
903
913
  export async function refreshJwt(jwt: string): Promise<string> {
904
- try {
905
- const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8'));
906
- const rt = creds.refresh_token;
907
- if (!rt) return jwt;
914
+ const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8'));
915
+ const rt = creds.refresh_token;
916
+ if (!rt) return jwt;
908
917
 
909
- const resp = await fetch(GATEWAY_URL + '/api/auth/refresh', {
910
- method: 'POST',
911
- headers: { 'Content-Type': 'application/json' },
912
- body: JSON.stringify({ refresh_token: rt }),
913
- signal: AbortSignal.timeout(4000),
914
- });
915
- const data = await resp.json() as any;
916
- const newAt = data.access_token;
917
- if (!newAt) return jwt;
918
-
919
- const newRt = data.refresh_token || rt;
920
- const existing = (() => {
921
- try { return JSON.parse(readFileSync(CREDS_PATH, 'utf-8')); } catch { return {}; }
922
- })();
923
- const updated = { ...existing, access_token: newAt, refresh_token: newRt };
924
- const tmp = CREDS_PATH + '.synkro.tmp';
925
- writeFileSync(tmp, JSON.stringify(updated, null, 2));
926
- renameSync(tmp, CREDS_PATH);
927
- return newAt;
928
- } catch {
918
+ const resp = await fetch(GATEWAY_URL + '/api/auth/refresh', {
919
+ method: 'POST',
920
+ headers: { 'Content-Type': 'application/json' },
921
+ body: JSON.stringify({ refresh_token: rt }),
922
+ signal: AbortSignal.timeout(4000),
923
+ });
924
+
925
+ if (!resp.ok) {
926
+ log('refresh failed: HTTP ' + resp.status);
929
927
  return jwt;
930
928
  }
929
+
930
+ const data = await resp.json() as any;
931
+ const newAt = data.access_token;
932
+ if (!newAt) return jwt;
933
+
934
+ const newRt = data.refresh_token || rt;
935
+ const existing = (() => {
936
+ try { return JSON.parse(readFileSync(CREDS_PATH, 'utf-8')); } catch { return {}; }
937
+ })();
938
+ const updated = { ...existing, access_token: newAt, refresh_token: newRt };
939
+ const tmp = CREDS_PATH + '.synkro.tmp';
940
+ writeFileSync(tmp, JSON.stringify(updated, null, 2));
941
+ renameSync(tmp, CREDS_PATH);
942
+ return newAt;
943
+ }
944
+
945
+ function jwtIsExpired(jwt: string): boolean {
946
+ const exp = decodeJwtExp(jwt);
947
+ return exp - Math.floor(Date.now() / 1000) < 60;
948
+ }
949
+
950
+ function lockIsStale(lockfile: string, maxAgeMs = 8000): boolean {
951
+ try {
952
+ const stat = require('node:fs').statSync(lockfile);
953
+ return Date.now() - stat.mtimeMs > maxAgeMs;
954
+ } catch {
955
+ return false;
956
+ }
931
957
  }
932
958
 
933
959
  export async function ensureFreshJwt(jwt: string): Promise<string> {
934
960
  if (!jwt) return jwt;
935
- const exp = decodeJwtExp(jwt);
936
- const now = Math.floor(Date.now() / 1000);
937
- if (exp - now >= 60) return jwt;
961
+ if (!jwtIsExpired(jwt)) return jwt;
962
+
963
+ const lockfile = CREDS_PATH + '.lock';
964
+
965
+ // Clean stale lock left by a killed hook
966
+ if (existsSync(lockfile) && lockIsStale(lockfile)) {
967
+ try { unlinkSync(lockfile); } catch {}
968
+ }
938
969
 
939
- const lockdir = CREDS_PATH + '.lockdir';
940
- let acquired = false;
970
+ let fd = -1;
941
971
  try {
942
- mkdirSync(lockdir);
943
- acquired = true;
972
+ fd = openSync(lockfile, FS_CONSTANTS.O_WRONLY | FS_CONSTANTS.O_CREAT | FS_CONSTANTS.O_EXCL, 0o644);
944
973
  } catch {
945
- // Another process is refreshing \u2014 wait and re-read
946
- for (let i = 0; i < 5; i++) {
974
+ // Another process is refreshing \u2014 wait for it
975
+ for (let i = 0; i < 8; i++) {
947
976
  await new Promise(r => setTimeout(r, 500));
948
- if (!existsSync(lockdir)) break;
977
+ if (!existsSync(lockfile)) break;
949
978
  }
950
- // Re-read creds
979
+ // Stale lock after full wait \u2014 force-remove
980
+ if (existsSync(lockfile) && lockIsStale(lockfile)) {
981
+ try { unlinkSync(lockfile); } catch {}
982
+ }
983
+ // Re-read \u2014 the winner should have written a fresh JWT
951
984
  const fresh = loadJwt();
952
- return fresh || jwt;
985
+ if (fresh && !jwtIsExpired(fresh)) return fresh;
986
+ // Winner's refresh failed \u2014 try to acquire lock ourselves
987
+ try {
988
+ fd = openSync(lockfile, FS_CONSTANTS.O_WRONLY | FS_CONSTANTS.O_CREAT | FS_CONSTANTS.O_EXCL, 0o644);
989
+ } catch {
990
+ return fresh || jwt;
991
+ }
953
992
  }
954
993
 
955
994
  try {
956
- // Re-check \u2014 another hook may have just refreshed
995
+ // Re-check \u2014 another hook may have written fresh creds while we waited for lock
957
996
  const freshJwt = loadJwt();
958
- if (freshJwt) {
959
- const freshExp = decodeJwtExp(freshJwt);
960
- if (freshExp - Math.floor(Date.now() / 1000) >= 60) return freshJwt;
961
- }
997
+ if (freshJwt && !jwtIsExpired(freshJwt)) return freshJwt;
962
998
  return await refreshJwt(jwt);
999
+ } catch {
1000
+ return jwt;
963
1001
  } finally {
964
- if (acquired) {
965
- try { rmdirSync(lockdir); } catch {}
966
- }
1002
+ try { closeSync(fd); } catch {}
1003
+ try { unlinkSync(lockfile); } catch {}
967
1004
  }
968
1005
  }
969
1006
 
@@ -1014,7 +1051,7 @@ export interface HookConfig {
1014
1051
  silent: boolean;
1015
1052
  policyName: string;
1016
1053
  rules: Rule[];
1017
- scanExemptions: string[];
1054
+ scanExemptions: Array<{ path: string; cwe_id: string }>;
1018
1055
  }
1019
1056
 
1020
1057
  export async function loadConfig(jwt: string, query?: string): Promise<HookConfig> {
@@ -1038,7 +1075,9 @@ export async function loadConfig(jwt: string, query?: string): Promise<HookConfi
1038
1075
  config.silent = data.silent_mode === true || data.silent_mode === 'true';
1039
1076
  config.policyName = data.active_policy_name || '';
1040
1077
  if (Array.isArray(data.scan_exemptions)) {
1041
- config.scanExemptions = data.scan_exemptions.filter((s: any) => typeof s === 'string');
1078
+ config.scanExemptions = data.scan_exemptions
1079
+ .filter((e: any) => e && typeof e.path === 'string')
1080
+ .map((e: any) => ({ path: e.path, cwe_id: e.cwe_id || '' }));
1042
1081
  }
1043
1082
  if (Array.isArray(data.rules)) {
1044
1083
  config.rules = data.rules
@@ -1283,14 +1322,15 @@ export function ruleMode(ruleId: string, rules: Rule[]): 'blocking' | 'audit' {
1283
1322
 
1284
1323
  // \u2500\u2500\u2500 Content Reconstruction \u2500\u2500\u2500
1285
1324
 
1286
- export function reconstructContent(toolName: string, toolInput: any, filePath: string): string {
1325
+ export function reconstructContent(toolName: string, toolInput: any, filePath: string, cwd?: string): string {
1326
+ const canRead = filePath && (!cwd || isPathUnder(filePath, cwd));
1287
1327
  switch (toolName) {
1288
1328
  case 'Write':
1289
1329
  return toolInput.content || '';
1290
1330
  case 'Edit': {
1291
1331
  let content = '';
1292
1332
  try {
1293
- if (filePath && existsSync(filePath)) {
1333
+ if (canRead && existsSync(filePath)) {
1294
1334
  content = readFileSync(filePath, 'utf-8').slice(0, 65536);
1295
1335
  }
1296
1336
  } catch {}
@@ -1304,7 +1344,7 @@ export function reconstructContent(toolName: string, toolInput: any, filePath: s
1304
1344
  case 'MultiEdit': {
1305
1345
  let content = '';
1306
1346
  try {
1307
- if (filePath && existsSync(filePath)) {
1347
+ if (canRead && existsSync(filePath)) {
1308
1348
  content = readFileSync(filePath, 'utf-8').slice(0, 65536);
1309
1349
  }
1310
1350
  } catch {}
@@ -1589,7 +1629,7 @@ export function outputEmpty(): void {
1589
1629
  EDIT_PRECHECK_TS = `#!/usr/bin/env bun
1590
1630
  import {
1591
1631
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
1592
- parseVerdict, dispatchCapture, ruleMode, reconstructContent, postWithRetry,
1632
+ parseVerdict, dispatchCapture, ruleMode, reconstructContent, isPathUnder, postWithRetry,
1593
1633
  readStdin, extractTranscript, readLastPrompt, findNearestDeps, log,
1594
1634
  outputJson, outputEmpty, GATEWAY_URL,
1595
1635
  type HookConfig, type Rule,
@@ -1631,7 +1671,7 @@ async function main() {
1631
1671
  jwt = await ensureFreshJwt(jwt);
1632
1672
 
1633
1673
  // Reconstruct proposed content
1634
- const proposed = reconstructContent(toolName, toolInput, filePath);
1674
+ const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
1635
1675
  if (!proposed) { outputEmpty(); return; }
1636
1676
 
1637
1677
  // Build diff field
@@ -1645,7 +1685,7 @@ async function main() {
1645
1685
 
1646
1686
  // Read file before edit for cloud payload
1647
1687
  let fileBefore = '';
1648
- if (toolName !== 'Write' && filePath && existsSync(filePath)) {
1688
+ if (toolName !== 'Write' && filePath && isPathUnder(filePath, cwd || '.') && existsSync(filePath)) {
1649
1689
  try { fileBefore = readFileSync(filePath, 'utf-8').slice(0, 65536); } catch {}
1650
1690
  }
1651
1691
 
@@ -1835,17 +1875,19 @@ async function main() {
1835
1875
  jwt = await ensureFreshJwt(jwt);
1836
1876
 
1837
1877
  // Reconstruct proposed content
1838
- const proposed = reconstructContent(toolName, toolInput, filePath);
1878
+ const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
1839
1879
  if (!proposed) { outputEmpty(); return; }
1840
1880
 
1841
1881
  const config = await loadConfig(jwt);
1842
1882
  const rt = await cweRoute(config);
1843
1883
 
1844
- // Check scan exemptions from server
1845
- if (config.scanExemptions.length > 0 && config.scanExemptions.some(ex => filePath.includes(ex))) {
1846
- outputEmpty(); return;
1884
+ // Build set of exempted CWE IDs for this file path
1885
+ const exemptedCwes = new Set<string>();
1886
+ for (const ex of config.scanExemptions) {
1887
+ if (ex.cwe_id && filePath.includes(ex.path)) {
1888
+ exemptedCwes.add(ex.cwe_id.toUpperCase());
1889
+ }
1847
1890
  }
1848
-
1849
1891
  if (config.silent) {
1850
1892
  outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
1851
1893
  return;
@@ -1891,15 +1933,23 @@ async function main() {
1891
1933
  const verdict = parseVerdict(gradeResp);
1892
1934
 
1893
1935
  if (!verdict.ok) {
1894
- // Extract all CWE rule_ids from the raw response
1895
1936
  const ruleIdMatches = gradeResp.match(/<rule_id>([^<]+)<\\/rule_id>/g) || [];
1896
1937
  const cweIds: string[] = [];
1897
1938
  for (const match of ruleIdMatches.slice(0, 5)) {
1898
1939
  const id = match.replace(/<\\/?rule_id>/g, '').trim().replace(/^cwe-/, 'CWE-');
1899
1940
  if (id && !cweIds.includes(id)) cweIds.push(id);
1900
1941
  }
1901
- const displayIds = cweIds.slice(0, 3).join(', ');
1902
- const count = cweIds.length;
1942
+
1943
+ // Filter out exempted CWEs for this file
1944
+ const activeCweIds = cweIds.filter(id => !exemptedCwes.has(id.toUpperCase()));
1945
+
1946
+ if (activeCweIds.length === 0) {
1947
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean (exempted: ' + cweIds.join(', ') + ')' });
1948
+ return;
1949
+ }
1950
+
1951
+ const displayIds = activeCweIds.slice(0, 3).join(', ');
1952
+ const count = activeCweIds.length;
1903
1953
  const label = count === 1 ? 'match' : 'matches';
1904
1954
  const cweMsg = cweTag + ' ' + fileShort + ' \\u2192 ' + count + ' CWE ' + label + ' (' + displayIds + ')';
1905
1955
  const denyDetail = '[' + displayIds + '] ' + (verdict.reason || 'code weakness detected');
@@ -1985,7 +2035,7 @@ async function main() {
1985
2035
  const cveTag = '[synkro:' + rt + ':cveScan]';
1986
2036
 
1987
2037
  // Reconstruct proposed content
1988
- const proposed = reconstructContent(toolName, toolInput, filePath);
2038
+ const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
1989
2039
  if (!proposed) {
1990
2040
  outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 skip (no content)' });
1991
2041
  return;
@@ -2420,7 +2470,7 @@ main();
2420
2470
  STOP_SUMMARY_TS = `#!/usr/bin/env bun
2421
2471
  import {
2422
2472
  loadJwt, detectRepo, loadConfig, tag, readStdin, aggregateUsage,
2423
- outputJson, outputEmpty, GATEWAY_URL,
2473
+ outputJson, outputEmpty, appendLocalTelemetry, GATEWAY_URL,
2424
2474
  } from './_synkro-common.ts';
2425
2475
 
2426
2476
  async function main() {
@@ -2459,6 +2509,7 @@ async function main() {
2459
2509
  ...(gitRepo ? { repo: gitRepo } : {}),
2460
2510
  ...(sessionId ? { session_id: sessionId } : {}),
2461
2511
  };
2512
+ appendLocalTelemetry(usageBody);
2462
2513
  fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2463
2514
  method: 'POST',
2464
2515
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -2570,7 +2621,7 @@ main();
2570
2621
  BASH_FOLLOWUP_TS = `#!/usr/bin/env bun
2571
2622
  import {
2572
2623
  loadJwt, readStdin, hashCommand, consentGrant, consentHasActive, consentConsume,
2573
- outputEmpty, GATEWAY_URL,
2624
+ outputEmpty, appendLocalTelemetry, GATEWAY_URL,
2574
2625
  } from './_synkro-common.ts';
2575
2626
 
2576
2627
  async function main() {
@@ -2611,6 +2662,8 @@ async function main() {
2611
2662
  command_hash: cmdHash,
2612
2663
  };
2613
2664
 
2665
+ appendLocalTelemetry(body);
2666
+
2614
2667
  fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2615
2668
  method: 'POST',
2616
2669
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -2628,7 +2681,7 @@ main();
2628
2681
  `;
2629
2682
  TRANSCRIPT_SYNC_TS = `#!/usr/bin/env bun
2630
2683
  import {
2631
- loadJwt, detectRepo, readStdin, aggregateUsage,
2684
+ loadJwt, detectRepo, readStdin, aggregateUsage, appendLocalTelemetry,
2632
2685
  outputEmpty, GATEWAY_URL,
2633
2686
  } from './_synkro-common.ts';
2634
2687
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
@@ -2671,6 +2724,7 @@ async function main() {
2671
2724
  },
2672
2725
  session_id: sessionId,
2673
2726
  };
2727
+ appendLocalTelemetry(usageBody);
2674
2728
  fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2675
2729
  method: 'POST',
2676
2730
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -5113,7 +5167,7 @@ function writeConfigEnv(opts) {
5113
5167
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5114
5168
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5115
5169
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5116
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.59")}`
5170
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.61")}`
5117
5171
  ];
5118
5172
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5119
5173
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);