@synkro-sh/cli 1.6.100 → 1.7.1

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
@@ -1050,16 +1050,18 @@ export function log(msg: string): void {
1050
1050
  // \u2500\u2500\u2500 JWT Management \u2500\u2500\u2500
1051
1051
 
1052
1052
  export function loadJwt(): string | null {
1053
- // Cloud mode: prefer the long-lived (1yr) MCP token. It's org+user scoped and
1054
- // accepted on the whole cloud-hook surface (grade/submit, cwe-rules, hook/*,
1055
- // cli/*), so grading survives sessions far longer than the ~5-min WorkOS token.
1056
- // jwtIsExpired sees a year of runway, so ensureFreshJwt never needs to refresh.
1057
- if (process.env.SYNKRO_STORAGE_MODE === 'cloud') {
1058
- try {
1059
- const mcp = readFileSync(MCP_JWT_PATH, 'utf-8').trim();
1060
- if (mcp) return mcp;
1061
- } catch { /* no mcp token on disk \u2014 fall back to the login token below */ }
1062
- }
1053
+ // Prefer the long-lived (1yr) Synkro MCP token in BOTH cloud and local mode.
1054
+ // It's org+user scoped and accepted across the hook surface (grade/submit,
1055
+ // cwe-rules, hook/*, cli/*), so a single 'synkro login' keeps grading + CWE
1056
+ // detection alive for a year with NO refresh. The alternative \u2014 the ~5-min
1057
+ // WorkOS access_token \u2014 rides a rotating refresh chain that dies with the
1058
+ // WorkOS session (invalid_grant "session has already ended"), which silently
1059
+ // disabled CWE findings while editGuard/cveScan (which don't need this token)
1060
+ // kept working. jwtIsExpired sees a year of runway, so we never hit refreshJwt.
1061
+ try {
1062
+ const mcp = readFileSync(MCP_JWT_PATH, 'utf-8').trim();
1063
+ if (mcp) return mcp;
1064
+ } catch { /* no mcp token on disk \u2014 fall back to the login token below */ }
1063
1065
  try {
1064
1066
  if (!existsSync(CREDS_PATH)) return null;
1065
1067
  const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8'));
@@ -4617,8 +4619,16 @@ async function main() {
4617
4619
  const cweFindings = [...cweMap.values()];
4618
4620
  const editContent = 'file=' + filePath + ' content=' + proposed.slice(0, 2000);
4619
4621
  const violatedRules = ruleVerdict.ruleId ? [ruleVerdict.ruleId] : [];
4620
- const cweBlock = cweFindings.slice(0, 5)
4621
- .map(f => '[' + f.id + '] ' + (f.reason || 'weakness') + (f.suggestedFix ? ' Fix: ' + f.suggestedFix : ''))
4622
+ // Include EVERY flagged CWE's description (not a 5-cap) so the agent can fix
4623
+ // all of them \u2014 even 30 in one edit. Per-finding fallback: reason -> CWE name.
4624
+ const cweClean = (s) => String(s == null ? '' : s).split('').map(c => { const k = c.charCodeAt(0); return (k < 32 || k === 127) ? ' ' : c; }).join('').replace(/ +/g, ' ').trim().slice(0, 400);
4625
+ const cweId = (s) => String(s == null ? 'CWE-UNKNOWN' : s).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 20) || 'CWE-UNKNOWN';
4626
+ // One sentence each (trim at first . ! or ?) so several CWEs in one edit stay tight.
4627
+ const cweOne = (s) => { const t = cweClean(s); const i = t.search(/[.!?]( [A-Z]|$)/); return (i >= 0 ? t.slice(0, i + 1) : t).slice(0, 220); };
4628
+ // One line per CWE: explanation + Fix. Drop the Fix when it just restates the
4629
+ // explanation so a single CWE never double-prints the same prose.
4630
+ const cweBlock = cweFindings
4631
+ .map(f => { const why = cweOne(f.reason || f.name || 'code weakness'); const fix = f.suggestedFix ? cweOne(f.suggestedFix) : ''; const showFix = fix && why.toLowerCase().indexOf(fix.slice(0, 40).toLowerCase()) === -1; return '[' + cweId(f.id) + '] ' + why + (showFix ? ' Fix: ' + fix : ''); })
4622
4632
  .join('\\n');
4623
4633
  for (const f of cweFindings) {
4624
4634
  dispatchFinding(jwt, { session_id: sessionId, file_path: filePath, finding_type: 'cwe', finding_id: f.id, status: 'open' }, config.captureDepth);
@@ -5126,7 +5136,9 @@ async function main() {
5126
5136
  const findings = Array.isArray(cweResp?.findings) ? cweResp.findings : [];
5127
5137
  if (cweResp?.action === 'deny' && findings.length > 0) {
5128
5138
  const activeCweIds = findings
5129
- .filter((f: any) => f.mode === 'blocking' || f.mode === 'ask')
5139
+ // CWE is a separate, always-ask grader a detected weakness is ALWAYS
5140
+ // surfaced regardless of the rule's stored mode (fix/blocking/ask).
5141
+ // Only path exemptions suppress it (matches the local-route branch).
5130
5142
  .map((f: any) => f.cwe)
5131
5143
  .filter((id: string) => !exemptedCwes.has(id.toUpperCase()));
5132
5144
 
@@ -5139,12 +5151,41 @@ async function main() {
5139
5151
  const label = count === 1 ? 'match' : 'matches';
5140
5152
  const cweMsg = cweTag + ' ' + fileShort + ' \u2192 ' + count + ' CWE ' + label + ' (' + displayIds + ')';
5141
5153
 
5142
- const fixLines = findings
5143
- .filter((f: any) => activeCweIds.includes(f.cwe) && f.suggested_fix)
5144
- .map((f: any) => '[' + f.cwe + '] Fix: ' + f.suggested_fix);
5145
- const fixHint = fixLines.length > 0 ? '\n' + fixLines.join('\n') : '';
5146
- const denyDetail = '[' + displayIds + '] ' + (findings[0]?.reason || 'code weakness detected');
5147
- 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.';
5154
+ // Sanitize any scan-derived text before embedding it in the agent message
5155
+ // (CWE-20): bound length + strip control chars; constrain the CWE id shape.
5156
+ const sanitizeDesc = (s: any) => String(s == null ? '' : s).replace(/[\x00-\x1F\x7F]/g, ' ').replace(/\s{2,}/g, ' ').trim().slice(0, 400);
5157
+ const sanitizeId = (s: any) => String(s == null ? '' : s).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 20).toUpperCase() || 'CWE-UNKNOWN';
5158
+ // ONE sentence, hard-capped \u2014 keeps each line tight so 4+ CWEs in a single edit
5159
+ // never blow the message-size limit. Trims at the first sentence terminator.
5160
+ const oneSentence = (s: any) => { const t = sanitizeDesc(s); const m = t.match(/^[\s\S]*?[.!?](\s+(?=[A-Z])|$)/); return (m ? m[0] : t).trim().slice(0, 220); };
5161
+ // ONE line per flagged CWE: a one-sentence explanation + a one-sentence Fix.
5162
+ // The Fix is dropped when it merely restates the explanation (the grader
5163
+ // sometimes echoes it) so the line never double-prints the same prose.
5164
+ const cweSeen = new Set<string>();
5165
+ const cweBlocks = activeCweIds
5166
+ .map((cweId: string) => {
5167
+ const sid = sanitizeId(cweId);
5168
+ if (cweSeen.has(sid)) return '';
5169
+ cweSeen.add(sid);
5170
+ const f = findings.find((x: any) => x.cwe === cweId);
5171
+ const why = oneSentence(f && (f.reason || f.name) ? (f.reason || f.name) : 'code weakness detected');
5172
+ const fix = f && f.suggested_fix ? oneSentence(f.suggested_fix) : '';
5173
+ const showFix = !!fix && !why.toLowerCase().includes(fix.slice(0, 40).toLowerCase());
5174
+ return '[' + sid + '] ' + why + (showFix ? ' Fix: ' + fix : '');
5175
+ })
5176
+ .filter((line: string) => line.length > 0);
5177
+ // Bound the total: list as many CWEs as fit, spill the rest to a "+N more" note
5178
+ // (agent fixes the listed ones, then re-runs to surface the remainder).
5179
+ const MAX_CWE_CHARS = 4000;
5180
+ let denyDetail = cweBlocks.join('\n');
5181
+ if (denyDetail.length > MAX_CWE_CHARS) {
5182
+ const kept: string[] = [];
5183
+ let used = 0;
5184
+ for (const b of cweBlocks) { if (used + b.length + 1 > MAX_CWE_CHARS) break; kept.push(b); used += b.length + 1; }
5185
+ const more = cweBlocks.length - kept.length;
5186
+ denyDetail = kept.join('\n') + (more > 0 ? '\n(+' + more + ' more CWE weakness' + (more === 1 ? '' : 'es') + ' \u2014 fix these first, then re-run to surface the rest)' : '');
5187
+ }
5188
+ const ctx = 'CWE weaknesses to fix (' + count + '):\n' + denyDetail + '\nFix ALL of the above before retrying. Do NOT ask the user to make the edit manually \u2014 resolve every weakness in code yourself.';
5148
5189
 
5149
5190
  emitBlockScanFindings(
5150
5191
  jwt,
@@ -5290,6 +5331,7 @@ async function main() {
5290
5331
 
5291
5332
  const cweIds: string[] = [];
5292
5333
  const fixes: Record<string, string> = {};
5334
+ const reasons: Record<string, string> = {};
5293
5335
  const snippets: Record<string, string> = {};
5294
5336
  let mergedReason = '';
5295
5337
  let mergedSeverity = '';
@@ -5297,6 +5339,70 @@ async function main() {
5297
5339
  let anyFailed = false;
5298
5340
 
5299
5341
  for (const gradeResp of gradeResponses) {
5342
+ // PRIMARY shape — the live CWE grader returns ONE JSON object:
5343
+ // {"cweIds":[..],"reasons":{id:..},"fixes":{id:..},"snippets":{id:..},"mergedReason":".."}
5344
+ // Parse it directly so each CWE keeps its OWN contextual reason + specific
5345
+ // fix. Critically this also bounds detection to cweIds — NOT a regex scrape of
5346
+ // the prose (which used to grab CWE-### tokens out of mergedReason, e.g. a
5347
+ // "no CWE-502 found" sentence, and over-report findings).
5348
+ try {
5349
+ const objM = gradeResp.match(/\{[\s\S]*"cweIds"[\s\S]*\}/);
5350
+ if (objM) {
5351
+ const p = JSON.parse(objM[0]);
5352
+ if (Array.isArray(p.cweIds)) {
5353
+ const ids = p.cweIds.map((x: any) => String(x).trim().replace(/^cwe-/i, 'CWE-')).filter((id: string) => /^CWE-\d+$/.test(id));
5354
+ if (ids.length > 0) {
5355
+ anyFailed = true;
5356
+ if (!mergedReason && typeof p.mergedReason === 'string') mergedReason = p.mergedReason;
5357
+ for (const id of ids) {
5358
+ if (!cweIds.includes(id)) cweIds.push(id);
5359
+ const rsn = p.reasons && typeof p.reasons[id] === 'string' ? p.reasons[id] : '';
5360
+ const fx = p.fixes && typeof p.fixes[id] === 'string' ? p.fixes[id] : '';
5361
+ const sn = p.snippets && typeof p.snippets[id] === 'string' ? p.snippets[id] : '';
5362
+ if (rsn && !reasons[id]) reasons[id] = rsn;
5363
+ if (fx && !fixes[id]) fixes[id] = fx;
5364
+ if (sn && !snippets[id]) snippets[id] = sn;
5365
+ }
5366
+ continue;
5367
+ }
5368
+ }
5369
+ }
5370
+ } catch { /* not the JSON-object shape — fall through to legacy parsing */ }
5371
+ // The CWE grader emits findings, but its wrapper VARIES per call:
5372
+ // {"findings":[...]} | <findings>[...]</findings> | a bare [...] array.
5373
+ // Extract findings from any of them. Without this, parseVerdict sees no
5374
+ // <synkro-verdict> wrapper and defaults to ok:true, silently dropping every
5375
+ // detected CWE (a format mismatch). The <synkro-verdict> XML path below is
5376
+ // the fallback for the legacy format.
5377
+ let jsonFindings: any[] | null = null;
5378
+ try {
5379
+ const tagM = gradeResp.match(/<findings>([\s\S]*?)<\/findings>/);
5380
+ const src = tagM ? tagM[1] : gradeResp;
5381
+ const objM = src.match(/\{[\s\S]*"findings"[\s\S]*\}/);
5382
+ const arrM = src.match(/\[[\s\S]*\]/);
5383
+ if (objM) { const p = JSON.parse(objM[0]); if (Array.isArray(p.findings)) jsonFindings = p.findings; }
5384
+ if (!jsonFindings && arrM) { const p = JSON.parse(arrM[0]); if (Array.isArray(p)) jsonFindings = p; }
5385
+ } catch { /* not parseable JSON — fall through to XML parsing */ }
5386
+ if (jsonFindings) {
5387
+ if (jsonFindings.length > 0) {
5388
+ anyFailed = true;
5389
+ for (const f of jsonFindings.slice(0, 8)) {
5390
+ const id = String(f.cwe || f.rule_id || '').trim().replace(/^cwe-/i, 'CWE-');
5391
+ // Validate the shape (CWE-<number>) before using it as an object key —
5392
+ // the grade response is model output, so reject anything that isn't a
5393
+ // real CWE id (also blocks __proto__/constructor key injection).
5394
+ if (!/^CWE-\d+$/.test(id)) continue;
5395
+ if (!cweIds.includes(id)) cweIds.push(id);
5396
+ if (f.suggested_fix && !fixes[id]) fixes[id] = String(f.suggested_fix);
5397
+ if ((f.reason || f.summary || f.detail) && !reasons[id]) reasons[id] = String(f.reason || f.summary || f.detail);
5398
+ if (f.code_snippet && !snippets[id]) snippets[id] = String(f.code_snippet);
5399
+ if (!mergedReason && (f.summary || f.detail)) mergedReason = String(f.summary || f.detail);
5400
+ if (!mergedSeverity && f.severity) mergedSeverity = String(f.severity);
5401
+ if (!mergedCategory && f.category) mergedCategory = String(f.category);
5402
+ }
5403
+ }
5404
+ continue;
5405
+ }
5300
5406
  const v = parseVerdict(gradeResp);
5301
5407
  if (!v.ok) {
5302
5408
  anyFailed = true;
@@ -5325,6 +5431,10 @@ async function main() {
5325
5431
  const snip = snM[1].replace(/^\n+|\n+$/g, '');
5326
5432
  if (snip.trim()) snippets[id] = snip;
5327
5433
  }
5434
+ // Capture THIS violation's own reason so the deny can show a per-CWE
5435
+ // explanation, not just one merged reason for the whole batch.
5436
+ const rM = block.match(/<reason>([\s\S]*?)<\/reason>/);
5437
+ if (rM && !reasons[id]) { const rs = rM[1].trim(); if (rs) reasons[id] = rs; }
5328
5438
  }
5329
5439
  }
5330
5440
  }
@@ -5349,12 +5459,28 @@ async function main() {
5349
5459
  const count = activeCweIds.length;
5350
5460
  const label = count === 1 ? 'match' : 'matches';
5351
5461
  const cweMsg = cweTag + ' ' + fileShort + ' \u2192 ' + count + ' CWE ' + label + ' (' + displayIds + ')';
5352
- const denyDetail = '[' + displayIds + '] ' + (verdict.reason || 'code weakness detected');
5353
- const fixLines = activeCweIds
5354
- .filter(id => fixes[id])
5355
- .map(id => '[' + id + '] Fix: ' + fixes[id]);
5356
- const fixHint = fixLines.length > 0 ? '\n' + fixLines.join('\n') : '';
5357
- const ctx = 'CWE: ' + denyDetail + fixHint + '\nFix all issues before retrying. Do NOT ask the user to make the edit manually — resolve the weakness in code yourself.';
5462
+ // ONE sentence, hard-capped (strip control chars, trim at first terminator) so
5463
+ // the deny stays tight even with several CWEs in one edit.
5464
+ const oneSentence = (s: any) => { const t = String(s == null ? '' : s).replace(/[\x00-\x1F\x7F]/g, ' ').replace(/\s{2,}/g, ' ').trim().slice(0, 400); const m = t.match(/^[\s\S]*?[.!?](\s+(?=[A-Z])|$)/); return (m ? m[0] : t).trim().slice(0, 220); };
5465
+ // ONE line per flagged CWE: its own one-sentence explanation + a one-sentence Fix.
5466
+ // Per-CWE reason (reasons[id]) merged reason CWE name → generic fallback. The
5467
+ // Fix is dropped when it just restates the explanation so no line double-prints.
5468
+ const cweBlocks = activeCweIds.map(id => {
5469
+ const why = oneSentence(reasons[id] || verdict.reason || cweNameMap.get(id.toUpperCase()) || 'code weakness detected');
5470
+ const fix = fixes[id] ? oneSentence(fixes[id]) : '';
5471
+ const showFix = !!fix && !why.toLowerCase().includes(fix.slice(0, 40).toLowerCase());
5472
+ return '[' + id + '] ' + why + (showFix ? ' Fix: ' + fix : '');
5473
+ });
5474
+ // Bound the total: list as many CWEs as fit, spill the rest to a "+N more" note.
5475
+ let denyDetail = cweBlocks.join('\n');
5476
+ if (denyDetail.length > 4000) {
5477
+ const kept: string[] = [];
5478
+ let used = 0;
5479
+ for (const b of cweBlocks) { if (used + b.length + 1 > 4000) break; kept.push(b); used += b.length + 1; }
5480
+ const more = cweBlocks.length - kept.length;
5481
+ denyDetail = kept.join('\n') + (more > 0 ? '\n(+' + more + ' more CWE weakness' + (more === 1 ? '' : 'es') + ' — fix these first, then re-run to surface the rest)' : '');
5482
+ }
5483
+ const ctx = 'CWE weaknesses to fix (' + count + '):\n' + denyDetail + '\nFix ALL of the above before retrying. Do NOT ask the user to make the edit manually — resolve every weakness in code yourself.';
5358
5484
 
5359
5485
  emitBlockScanFindings(
5360
5486
  jwt,
@@ -11412,7 +11538,7 @@ function writeConfigEnv(opts) {
11412
11538
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
11413
11539
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
11414
11540
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
11415
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.100")}`
11541
+ `SYNKRO_VERSION=${shellQuoteSingle("1.7.1")}`
11416
11542
  ];
11417
11543
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
11418
11544
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -15475,7 +15601,7 @@ var args = process.argv.slice(2);
15475
15601
  var cmd = args[0] || "";
15476
15602
  var subArgs = args.slice(1);
15477
15603
  function printVersion() {
15478
- console.log("1.6.100");
15604
+ console.log("1.7.1");
15479
15605
  }
15480
15606
  function printHelp2() {
15481
15607
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents