@synkro-sh/cli 1.4.58 → 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, 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,6 +1021,7 @@ export interface HookConfig {
1014
1021
  silent: boolean;
1015
1022
  policyName: string;
1016
1023
  rules: Rule[];
1024
+ scanExemptions: Array<{ path: string; cwe_id: string }>;
1017
1025
  }
1018
1026
 
1019
1027
  export async function loadConfig(jwt: string, query?: string): Promise<HookConfig> {
@@ -1023,6 +1031,7 @@ export async function loadConfig(jwt: string, query?: string): Promise<HookConfi
1023
1031
  silent: false,
1024
1032
  policyName: '',
1025
1033
  rules: [],
1034
+ scanExemptions: [],
1026
1035
  };
1027
1036
  try {
1028
1037
  const url = GATEWAY_URL + '/api/v1/hook/config' + (query ? '?' + query : '');
@@ -1035,6 +1044,11 @@ export async function loadConfig(jwt: string, query?: string): Promise<HookConfi
1035
1044
  config.tier = data.tier || 'standard';
1036
1045
  config.silent = data.silent_mode === true || data.silent_mode === 'true';
1037
1046
  config.policyName = data.active_policy_name || '';
1047
+ if (Array.isArray(data.scan_exemptions)) {
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 || '' }));
1051
+ }
1038
1052
  if (Array.isArray(data.rules)) {
1039
1053
  config.rules = data.rules
1040
1054
  .filter((r: any) => r.hook_stage === 'pre' || r.hook_stage === 'both' || r.hook_stage == null)
@@ -1250,6 +1264,8 @@ export function dispatchCapture(
1250
1264
  if (opts.recentUserMessages) body.recent_user_messages = opts.recentUserMessages;
1251
1265
  }
1252
1266
 
1267
+ appendLocalTelemetry(body);
1268
+
1253
1269
  fetch(GATEWAY_URL + '/api/v1/hook/capture', {
1254
1270
  method: 'POST',
1255
1271
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -1258,6 +1274,13 @@ export function dispatchCapture(
1258
1274
  }).catch(() => {});
1259
1275
  }
1260
1276
 
1277
+ export function appendLocalTelemetry(body: Record<string, any>): void {
1278
+ try {
1279
+ const telPath = join(HOME, '.synkro', 'telemetry.jsonl');
1280
+ appendFileSync(telPath, JSON.stringify({ ...body, _ts: new Date().toISOString() }) + '\\n', 'utf-8');
1281
+ } catch {}
1282
+ }
1283
+
1261
1284
  // \u2500\u2500\u2500 Rule Mode Lookup \u2500\u2500\u2500
1262
1285
 
1263
1286
  export function ruleMode(ruleId: string, rules: Rule[]): 'blocking' | 'audit' {
@@ -1269,14 +1292,15 @@ export function ruleMode(ruleId: string, rules: Rule[]): 'blocking' | 'audit' {
1269
1292
 
1270
1293
  // \u2500\u2500\u2500 Content Reconstruction \u2500\u2500\u2500
1271
1294
 
1272
- 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));
1273
1297
  switch (toolName) {
1274
1298
  case 'Write':
1275
1299
  return toolInput.content || '';
1276
1300
  case 'Edit': {
1277
1301
  let content = '';
1278
1302
  try {
1279
- if (filePath && existsSync(filePath)) {
1303
+ if (canRead && existsSync(filePath)) {
1280
1304
  content = readFileSync(filePath, 'utf-8').slice(0, 65536);
1281
1305
  }
1282
1306
  } catch {}
@@ -1290,7 +1314,7 @@ export function reconstructContent(toolName: string, toolInput: any, filePath: s
1290
1314
  case 'MultiEdit': {
1291
1315
  let content = '';
1292
1316
  try {
1293
- if (filePath && existsSync(filePath)) {
1317
+ if (canRead && existsSync(filePath)) {
1294
1318
  content = readFileSync(filePath, 'utf-8').slice(0, 65536);
1295
1319
  }
1296
1320
  } catch {}
@@ -1575,7 +1599,7 @@ export function outputEmpty(): void {
1575
1599
  EDIT_PRECHECK_TS = `#!/usr/bin/env bun
1576
1600
  import {
1577
1601
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
1578
- parseVerdict, dispatchCapture, ruleMode, reconstructContent, postWithRetry,
1602
+ parseVerdict, dispatchCapture, ruleMode, reconstructContent, isPathUnder, postWithRetry,
1579
1603
  readStdin, extractTranscript, readLastPrompt, findNearestDeps, log,
1580
1604
  outputJson, outputEmpty, GATEWAY_URL,
1581
1605
  type HookConfig, type Rule,
@@ -1617,7 +1641,7 @@ async function main() {
1617
1641
  jwt = await ensureFreshJwt(jwt);
1618
1642
 
1619
1643
  // Reconstruct proposed content
1620
- const proposed = reconstructContent(toolName, toolInput, filePath);
1644
+ const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
1621
1645
  if (!proposed) { outputEmpty(); return; }
1622
1646
 
1623
1647
  // Build diff field
@@ -1631,7 +1655,7 @@ async function main() {
1631
1655
 
1632
1656
  // Read file before edit for cloud payload
1633
1657
  let fileBefore = '';
1634
- if (toolName !== 'Write' && filePath && existsSync(filePath)) {
1658
+ if (toolName !== 'Write' && filePath && isPathUnder(filePath, cwd || '.') && existsSync(filePath)) {
1635
1659
  try { fileBefore = readFileSync(filePath, 'utf-8').slice(0, 65536); } catch {}
1636
1660
  }
1637
1661
 
@@ -1821,12 +1845,19 @@ async function main() {
1821
1845
  jwt = await ensureFreshJwt(jwt);
1822
1846
 
1823
1847
  // Reconstruct proposed content
1824
- const proposed = reconstructContent(toolName, toolInput, filePath);
1848
+ const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
1825
1849
  if (!proposed) { outputEmpty(); return; }
1826
1850
 
1827
1851
  const config = await loadConfig(jwt);
1828
1852
  const rt = await cweRoute(config);
1829
1853
 
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
+ }
1860
+ }
1830
1861
  if (config.silent) {
1831
1862
  outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
1832
1863
  return;
@@ -1872,15 +1903,23 @@ async function main() {
1872
1903
  const verdict = parseVerdict(gradeResp);
1873
1904
 
1874
1905
  if (!verdict.ok) {
1875
- // Extract all CWE rule_ids from the raw response
1876
1906
  const ruleIdMatches = gradeResp.match(/<rule_id>([^<]+)<\\/rule_id>/g) || [];
1877
1907
  const cweIds: string[] = [];
1878
1908
  for (const match of ruleIdMatches.slice(0, 5)) {
1879
1909
  const id = match.replace(/<\\/?rule_id>/g, '').trim().replace(/^cwe-/, 'CWE-');
1880
1910
  if (id && !cweIds.includes(id)) cweIds.push(id);
1881
1911
  }
1882
- const displayIds = cweIds.slice(0, 3).join(', ');
1883
- 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;
1884
1923
  const label = count === 1 ? 'match' : 'matches';
1885
1924
  const cweMsg = cweTag + ' ' + fileShort + ' \\u2192 ' + count + ' CWE ' + label + ' (' + displayIds + ')';
1886
1925
  const denyDetail = '[' + displayIds + '] ' + (verdict.reason || 'code weakness detected');
@@ -1966,7 +2005,7 @@ async function main() {
1966
2005
  const cveTag = '[synkro:' + rt + ':cveScan]';
1967
2006
 
1968
2007
  // Reconstruct proposed content
1969
- const proposed = reconstructContent(toolName, toolInput, filePath);
2008
+ const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
1970
2009
  if (!proposed) {
1971
2010
  outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 skip (no content)' });
1972
2011
  return;
@@ -2401,7 +2440,7 @@ main();
2401
2440
  STOP_SUMMARY_TS = `#!/usr/bin/env bun
2402
2441
  import {
2403
2442
  loadJwt, detectRepo, loadConfig, tag, readStdin, aggregateUsage,
2404
- outputJson, outputEmpty, GATEWAY_URL,
2443
+ outputJson, outputEmpty, appendLocalTelemetry, GATEWAY_URL,
2405
2444
  } from './_synkro-common.ts';
2406
2445
 
2407
2446
  async function main() {
@@ -2440,6 +2479,7 @@ async function main() {
2440
2479
  ...(gitRepo ? { repo: gitRepo } : {}),
2441
2480
  ...(sessionId ? { session_id: sessionId } : {}),
2442
2481
  };
2482
+ appendLocalTelemetry(usageBody);
2443
2483
  fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2444
2484
  method: 'POST',
2445
2485
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -2551,7 +2591,7 @@ main();
2551
2591
  BASH_FOLLOWUP_TS = `#!/usr/bin/env bun
2552
2592
  import {
2553
2593
  loadJwt, readStdin, hashCommand, consentGrant, consentHasActive, consentConsume,
2554
- outputEmpty, GATEWAY_URL,
2594
+ outputEmpty, appendLocalTelemetry, GATEWAY_URL,
2555
2595
  } from './_synkro-common.ts';
2556
2596
 
2557
2597
  async function main() {
@@ -2592,6 +2632,8 @@ async function main() {
2592
2632
  command_hash: cmdHash,
2593
2633
  };
2594
2634
 
2635
+ appendLocalTelemetry(body);
2636
+
2595
2637
  fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2596
2638
  method: 'POST',
2597
2639
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -2609,7 +2651,7 @@ main();
2609
2651
  `;
2610
2652
  TRANSCRIPT_SYNC_TS = `#!/usr/bin/env bun
2611
2653
  import {
2612
- loadJwt, detectRepo, readStdin, aggregateUsage,
2654
+ loadJwt, detectRepo, readStdin, aggregateUsage, appendLocalTelemetry,
2613
2655
  outputEmpty, GATEWAY_URL,
2614
2656
  } from './_synkro-common.ts';
2615
2657
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
@@ -2652,6 +2694,7 @@ async function main() {
2652
2694
  },
2653
2695
  session_id: sessionId,
2654
2696
  };
2697
+ appendLocalTelemetry(usageBody);
2655
2698
  fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2656
2699
  method: 'POST',
2657
2700
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -5094,7 +5137,7 @@ function writeConfigEnv(opts) {
5094
5137
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5095
5138
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5096
5139
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5097
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.58")}`
5140
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.60")}`
5098
5141
  ];
5099
5142
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5100
5143
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);