@synkro-sh/cli 1.6.73 → 1.6.74

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
@@ -1272,7 +1272,7 @@ export function findGitRoot(cwd: string): string {
1272
1272
  export interface SynkroFileConfig {
1273
1273
  version: number;
1274
1274
  harness: ('claude-code' | 'cursor')[];
1275
- grader: { pool: 'auto' | 'claude' | 'cursor'; mode?: string };
1275
+ grader: { pool: 'auto' | 'claude' | 'cursor'; mode?: string; combinedEditGrade?: boolean };
1276
1276
  workers: { claude?: number; cursor?: number };
1277
1277
  ruleset: string;
1278
1278
  skills: string[];
@@ -1379,6 +1379,7 @@ export function loadSynkroFile(cwd?: string): SynkroFileConfig {
1379
1379
  grader: {
1380
1380
  pool: normalizePoolToken(parsed.grader?.pool),
1381
1381
  mode: ['local', 'byok'].includes(parsed.grader?.mode) ? parsed.grader.mode : undefined,
1382
+ combinedEditGrade: parsed.grader?.combined_edit_grade === true,
1382
1383
  },
1383
1384
  workers: {
1384
1385
  // Counts may live under the grader block (unified form) or the legacy
@@ -1632,7 +1633,7 @@ export function tag(rt: string, config: HookConfig, grader?: string): string {
1632
1633
 
1633
1634
  // \u2500\u2500\u2500 Local Grading (direct channel call) \u2500\u2500\u2500
1634
1635
 
1635
- type GradeRole = 'grade-edit' | 'grade-bash' | 'grade-plan' | 'grade-cwe';
1636
+ type GradeRole = 'grade-edit' | 'grade-bash' | 'grade-plan' | 'grade-cwe' | 'grade-edit-cwe';
1636
1637
 
1637
1638
  // Which coding agent fired this grade. The dispatcher routes grades to a
1638
1639
  // worker pool of the matching kind so a Cursor grade is judged by Cursor and
@@ -1642,8 +1643,21 @@ export type AgentKind = 'claude_code' | 'cursor';
1642
1643
 
1643
1644
  const ROLE_MAP: Record<string, GradeRole> = {
1644
1645
  edit: 'grade-edit', bash: 'grade-bash', plan: 'grade-plan', cwe: 'grade-cwe',
1646
+ 'edit-cwe': 'grade-edit-cwe',
1645
1647
  };
1646
1648
 
1649
+ // Combined edit-time grade (org rules + CWE in one inference). OFF by default;
1650
+ // flipped on by env var or synkro.toml [grader] combined_edit_grade = true.
1651
+ // When on: the edit-precheck hook runs ONE grade-edit-cwe call and the
1652
+ // cwe-precheck hook no-ops (the combined call owns CWE) \u2014 halving inferences
1653
+ // per edit. See GRADER_PRIMER_EDIT_CWE.
1654
+ export function combinedEditGradeEnabled(synkroFile?: SynkroFileConfig): boolean {
1655
+ const env = process.env.SYNKRO_COMBINED_EDIT_GRADE;
1656
+ if (env === '1' || env === 'true') return true;
1657
+ if (env === '0' || env === 'false') return false;
1658
+ return synkroFile?.grader?.combinedEditGrade === true;
1659
+ }
1660
+
1647
1661
  async function channelGrade(role: GradeRole, prompt: string, _jwt: string, port: number, timeoutMs = 30000, agentKind: AgentKind = 'claude_code'): Promise<string> {
1648
1662
  const body = JSON.stringify({ role, payload: prompt, content: prompt, agent_kind: agentKind });
1649
1663
 
@@ -2188,6 +2202,67 @@ export function parseVerdict(resp: string): Verdict {
2188
2202
  return verdict;
2189
2203
  }
2190
2204
 
2205
+ export interface CombinedFinding {
2206
+ id: string;
2207
+ reason: string;
2208
+ suggestedFix: string;
2209
+ severity: string;
2210
+ ruleMode: string;
2211
+ codeSnippet: string;
2212
+ }
2213
+
2214
+ // Splits a grade-edit-cwe verdict (one <synkro-verdict> whose <violation> blocks
2215
+ // MIX org-rule ids and cwe-### ids) into (a) a rules Verdict the existing rule
2216
+ // enforcement consumes verbatim and (b) a list of CWE findings for the CWE block
2217
+ // path. A block whose rule_id starts with "cwe-" is a CWE finding; anything else
2218
+ // is an org-rule violation (org rules take precedence). Org-rule verdict is ok
2219
+ // UNLESS a non-cwe violation is present \u2014 so a CWE-only failure still passes the
2220
+ // rule gate while blocking on CWE.
2221
+ export function parseCombinedVerdict(resp: string): { ruleVerdict: Verdict; cweFindings: CombinedFinding[] } {
2222
+ const base = parseVerdict(resp);
2223
+ const ruleVerdict: Verdict = { ok: true, reason: '', suggestedFix: '', ruleId: '', ruleMode: '', severity: 'low', category: 'clean' };
2224
+ const cweFindings: CombinedFinding[] = [];
2225
+ const blocks = resp.match(/<violation>[\\s\\S]*?<\\/violation>/g) || [];
2226
+ let firstRule: CombinedFinding | null = null;
2227
+ for (const b of blocks) {
2228
+ const idRaw = (b.match(/<rule_id>([^<]+)<\\/rule_id>/) || [])[1];
2229
+ if (!idRaw) continue;
2230
+ const id = idRaw.trim();
2231
+ const finding: CombinedFinding = {
2232
+ id,
2233
+ reason: ((b.match(/<reason>([\\s\\S]*?)<\\/reason>/) || [])[1] || '').trim(),
2234
+ suggestedFix: ((b.match(/<suggested_fix>([\\s\\S]*?)<\\/suggested_fix>/) || [])[1] || '').trim(),
2235
+ severity: ((b.match(/<severity>([^<]+)<\\/severity>/) || [])[1] || '').trim(),
2236
+ ruleMode: ((b.match(/<rule_mode>([^<]+)<\\/rule_mode>/) || [])[1] || '').trim(),
2237
+ codeSnippet: ((b.match(/<code_snippet>([\\s\\S]*?)<\\/code_snippet>/) || [])[1] || '').replace(/^\\n+|\\n+$/g, ''),
2238
+ };
2239
+ if (/^cwe-/i.test(id)) {
2240
+ finding.id = id.replace(/^cwe-/i, 'CWE-');
2241
+ if (!cweFindings.some(f => f.id === finding.id)) cweFindings.push(finding);
2242
+ } else if (!firstRule) {
2243
+ firstRule = finding;
2244
+ }
2245
+ }
2246
+ if (firstRule) {
2247
+ ruleVerdict.ok = false;
2248
+ ruleVerdict.ruleId = firstRule.id;
2249
+ ruleVerdict.reason = firstRule.reason;
2250
+ ruleVerdict.suggestedFix = firstRule.suggestedFix;
2251
+ ruleVerdict.ruleMode = firstRule.ruleMode;
2252
+ ruleVerdict.severity = firstRule.severity || 'high';
2253
+ ruleVerdict.category = base.category || 'uncategorized';
2254
+ } else if (base.ok === false && base.ruleId && !/^cwe-/i.test(base.ruleId)) {
2255
+ // No <violation> blocks but a top-level rule_id failure (defensive).
2256
+ ruleVerdict.ok = false;
2257
+ ruleVerdict.ruleId = base.ruleId;
2258
+ ruleVerdict.reason = base.reason;
2259
+ ruleVerdict.suggestedFix = base.suggestedFix;
2260
+ ruleVerdict.ruleMode = base.ruleMode;
2261
+ ruleVerdict.severity = base.severity || 'high';
2262
+ }
2263
+ return { ruleVerdict, cweFindings };
2264
+ }
2265
+
2191
2266
  // \u2500\u2500\u2500 Telemetry Dispatch \u2500\u2500\u2500
2192
2267
 
2193
2268
  // Gated cloud telemetry POST \u2014 fires only when storage mode is 'cloud'. The
@@ -3636,7 +3711,7 @@ export function outputEmpty(): void {
3636
3711
  EDIT_PRECHECK_TS = `#!/usr/bin/env bun
3637
3712
  import {
3638
3713
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
3639
- parseVerdict, dispatchCapture, ruleMode, reconstructContent, isPathUnder, postWithRetry,
3714
+ parseVerdict, parseCombinedVerdict, combinedEditGradeEnabled, dispatchCapture, dispatchFinding, ruleMode, reconstructContent, isPathUnder, postWithRetry,
3640
3715
  readStdin, extractTranscript, readLastPrompt, findNearestDeps, filePathFromToolInput,
3641
3716
  appendSessionAction, readSessionLog, compressSessionLog, log,
3642
3717
  outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, hookSessionId, GATEWAY_URL,
@@ -3786,6 +3861,90 @@ async function main() {
3786
3861
  'The rules shown were pre-selected as the ones relevant to this edit \u2014 every rule here IS relevant, do not label any "not relevant". When passing (ok=true), give a terse, specific reason each rule passes. Format: "R003: no hardcoded secrets in file. R005: in-repo path only." Cover every rule shown.',
3787
3862
  ].join('\\n');
3788
3863
 
3864
+ // \u2500\u2500\u2500 Combined org-rules + CWE in ONE inference (SYNKRO_COMBINED_EDIT_GRADE) \u2500\u2500\u2500
3865
+ // Self-contained early-return branch \u2014 the default two-grade path below is
3866
+ // left untouched. Appends the CWE rule set to the rules prompt, runs one
3867
+ // grade-edit-cwe call, then splits the verdict: org-rule violations drive
3868
+ // the same ask/fix enforcement; cwe-### violations drive a fix-mode block.
3869
+ // The cwe-precheck hook no-ops when this flag is on (see CWE_PRECHECK_TS).
3870
+ if (combinedEditGradeEnabled(synkroFile)) {
3871
+ const fileExt = (filePath.match(/\\.([^.\\/]+)$/) || ['', ''])[1].toLowerCase();
3872
+ let cweRules: any[] = [];
3873
+ try {
3874
+ const cr = await fetch(GATEWAY_URL + '/api/v1/cwe-rules?ext=' + encodeURIComponent(fileExt), {
3875
+ headers: { Authorization: 'Bearer ' + jwt },
3876
+ signal: AbortSignal.timeout(4000),
3877
+ });
3878
+ if (cr.ok) cweRules = ((await cr.json() as any) || {}).rules || [];
3879
+ } catch { /* CWE rules optional \u2014 rule grading still runs in the combined pass */ }
3880
+
3881
+ const combinedPrompt = graderPrompt
3882
+ + '\\n\\nCWE rules to ALSO check the proposed content against (emit cwe-### violations with a <code_snippet>): '
3883
+ + JSON.stringify(cweRules);
3884
+
3885
+ let cResp: string;
3886
+ try {
3887
+ cResp = await localGrade('edit-cwe', combinedPrompt, undefined, graderPool);
3888
+ } catch (err) {
3889
+ const errMsg = (err as Error).message || String(err);
3890
+ logGraderUnavailable('editGuard', fileShort, errMsg);
3891
+ outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('editGuard', fileShort, errMsg, graderPool) });
3892
+ return;
3893
+ }
3894
+
3895
+ const { ruleVerdict, cweFindings } = parseCombinedVerdict(cResp);
3896
+ const editContent = 'file=' + filePath + ' content=' + proposed.slice(0, 2000);
3897
+ const violatedRules = ruleVerdict.ruleId ? [ruleVerdict.ruleId] : [];
3898
+ const cweBlock = cweFindings.slice(0, 5)
3899
+ .map(f => '[' + f.id + '] ' + (f.reason || 'weakness') + (f.suggestedFix ? ' Fix: ' + f.suggestedFix : ''))
3900
+ .join('\\n');
3901
+ for (const f of cweFindings) {
3902
+ dispatchFinding(jwt, { session_id: sessionId, file_path: filePath, finding_type: 'cwe', finding_id: f.id, status: 'open' }, config.captureDepth);
3903
+ }
3904
+
3905
+ // Org-rule violation \u2192 ask/fix enforcement (CWE findings appended to the deny).
3906
+ if (!ruleVerdict.ok) {
3907
+ const mode = normalizeMode(ruleVerdict.ruleMode || ruleMode(ruleVerdict.ruleId, config.rules));
3908
+ const guardReason = (ruleVerdict.ruleId ? '(' + ruleVerdict.ruleId + ') ' : '') + (ruleVerdict.reason || 'policy violation');
3909
+ let denyReason = mode === 'fix'
3910
+ ? 'Guard: ' + guardReason + '\\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the violation in code yourself.'
3911
+ : 'Guard: ' + guardReason + '\\nAsk the user for explicit consent before retrying.';
3912
+ if (cweBlock) denyReason += '\\nAlso resolve these CWE weaknesses in code:\\n' + cweBlock;
3913
+ dispatchCapture(jwt, 'edit', 'block', ruleVerdict.severity || 'critical', ruleVerdict.category || 'security',
3914
+ toolName, gitRepo, sessionId, config.captureDepth, {
3915
+ command: editContent, reasoning: guardReason,
3916
+ rulesChecked: relevantRules, violatedRules,
3917
+ ccModel: captureModel, ...lineMetrics,
3918
+ });
3919
+ outputJson({
3920
+ systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 blocked: ' + guardReason + (cweFindings.length ? ' (+' + cweFindings.length + ' CWE)' : ''),
3921
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: denyReason, additionalContext: denyReason },
3922
+ });
3923
+ return;
3924
+ }
3925
+
3926
+ // Rules clean but CWE weakness(es) \u2192 fix-mode block (resolve in code).
3927
+ if (cweFindings.length > 0) {
3928
+ const ctx = 'CWE: ' + cweBlock + '\\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the weakness in code yourself.';
3929
+ outputJson({
3930
+ systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 blocked (' + cweFindings.map(f => f.id).join(', ') + ')',
3931
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
3932
+ });
3933
+ return;
3934
+ }
3935
+
3936
+ // Both clean.
3937
+ dispatchCapture(jwt, 'edit', 'pass', 'clean', ruleVerdict.category || 'trivial_edit',
3938
+ toolName, gitRepo, sessionId, config.captureDepth, {
3939
+ command: editContent, reasoning: ruleVerdict.reason || 'no violations (rules + CWE)',
3940
+ rulesChecked: relevantRules, violatedRules: [],
3941
+ ccModel: captureModel, ...lineMetrics,
3942
+ });
3943
+ const cPassLine = tagStr + ' editGuard ' + fileShort + ' \u2192 pass: ' + (ruleVerdict.reason || 'clean (rules + CWE)');
3944
+ outputJson({ systemMessage: cPassLine, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro combined edit judge (rules + CWE). ' + (ruleVerdict.reason || 'clean') } });
3945
+ return;
3946
+ }
3947
+
3789
3948
  let gradeResp: string;
3790
3949
  try {
3791
3950
  gradeResp = await localGrade('edit', graderPrompt, undefined, graderPool);
@@ -3907,7 +4066,7 @@ import {
3907
4066
  outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, isShellTool, isCursorHookFormat,
3908
4067
  extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, cweSnippetLines, dispatchFinding, dispatchCapture, GATEWAY_URL,
3909
4068
  logGraderUnavailable, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
3910
- loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
4069
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage, combinedEditGradeEnabled,
3911
4070
  } from './_synkro-common.ts';
3912
4071
  import { basename, extname, resolve, join, dirname } from 'node:path';
3913
4072
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
@@ -4135,6 +4294,11 @@ async function main() {
4135
4294
  const graderPool = effectiveGraderPool(synkroFile, agentKind);
4136
4295
  const rt = await cweRoute(config, synkroFile);
4137
4296
 
4297
+ // Combined-grade mode: the edit-precheck hook runs CWE inside its single
4298
+ // grade-edit-cwe call, so this standalone CWE scan must no-op — otherwise the
4299
+ // edit would be graded for CWE twice (defeating the inference-merge).
4300
+ if (combinedEditGradeEnabled(synkroFile)) { outputEmpty(); return; }
4301
+
4138
4302
  if (config.silent) {
4139
4303
  outputJson({ systemMessage: '[synkro:' + rt + ':cweScan:' + (graderPool === 'claude_code' ? 'claude' : graderPool) + '] skipped (silent mode)' });
4140
4304
  return;
@@ -4560,14 +4724,16 @@ async function main() {
4560
4724
  jwt = await ensureFreshJwt(jwt);
4561
4725
 
4562
4726
  const config = await loadConfig(jwt);
4563
- const rt = await route(config);
4564
4727
 
4565
4728
  if (config.silent) {
4566
- outputJson({ systemMessage: '[synkro:' + rt + ':cveScan] ' + fileShort + ' \u2192 skipped (silent mode)' });
4729
+ outputJson({ systemMessage: '[synkro:cveScan] ' + fileShort + ' \u2192 skipped (silent mode)' });
4567
4730
  return;
4568
4731
  }
4569
4732
 
4570
- const cveTag = '[synkro:' + rt + ':cveScan]';
4733
+ // CVE scanning has no local/cloud distinction from the user's view \u2014 it's an
4734
+ // OSV database lookup that always runs server-side \u2014 so the tag omits the
4735
+ // location segment that grading/CWE carry. Just [synkro:cveScan].
4736
+ const cveTag = '[synkro:cveScan]';
4571
4737
 
4572
4738
  // Reconstruct proposed content
4573
4739
  const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
@@ -4667,7 +4833,7 @@ async function main() {
4667
4833
  finding_ids: violatedIds, severity: 'critical',
4668
4834
  repo: gitRepo || undefined,
4669
4835
  });
4670
- const tagStr = '[synkro:' + rt + ':pkgScan]';
4836
+ const tagStr = '[synkro:pkgScan]';
4671
4837
  const denyReason = tagStr + ' BLOCKED: ' + summary + '\\nDo not write this version. Pick a fixed/safe version instead.';
4672
4838
  outputJson({
4673
4839
  systemMessage: denyReason,
@@ -10403,7 +10569,7 @@ function writeConfigEnv(opts) {
10403
10569
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
10404
10570
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
10405
10571
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
10406
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.73")}`
10572
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.74")}`
10407
10573
  ];
10408
10574
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
10409
10575
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -14055,7 +14221,7 @@ var args = process.argv.slice(2);
14055
14221
  var cmd = args[0] || "";
14056
14222
  var subArgs = args.slice(1);
14057
14223
  function printVersion() {
14058
- console.log("1.6.73");
14224
+ console.log("1.6.74");
14059
14225
  }
14060
14226
  function printHelp2() {
14061
14227
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents