@synkro-sh/cli 1.6.73 → 1.6.75

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,
@@ -9686,7 +9852,10 @@ function captureClaudeSetupToken() {
9686
9852
  const bin = "script";
9687
9853
  const args2 = isMac ? ["-q", tmpFile, "claude", "setup-token"] : ["-qec", "claude setup-token", tmpFile];
9688
9854
  return new Promise((resolve4, reject) => {
9689
- const proc = nodeSpawn(bin, args2, { stdio: "inherit" });
9855
+ const proc = nodeSpawn(bin, args2, {
9856
+ stdio: "inherit",
9857
+ env: { ...process.env, FORCE_COLOR: "3", COLORTERM: "truecolor", TERM: "xterm-256color" }
9858
+ });
9690
9859
  proc.on("error", (err) => reject(new Error(`Failed to spawn claude setup-token: ${err.message}`)));
9691
9860
  proc.on("close", (code) => {
9692
9861
  let raw = "";
@@ -9704,27 +9873,48 @@ function captureClaudeSetupToken() {
9704
9873
  reject(new Error(`claude setup-token exited with code ${code}`));
9705
9874
  return;
9706
9875
  }
9707
- const stripToToken = (s) => s.replace(/\x1B\[[0-9;?]*[a-zA-Z]/g, "").replace(/\x1B\][^\x07]*\x07/g, "").replace(/[\s\x00-\x1F\x7F]/g, "");
9708
- const yellowRe = /\x1B\[38;2;255;193;7m([^\x1B]*)/g;
9709
- let yellow = "";
9710
- let m;
9711
- while ((m = yellowRe.exec(raw)) !== null) yellow += m[1];
9712
- const yToken = stripToToken(yellow);
9713
- let lineMatch = "";
9714
- const clean = raw.replace(/\x1B\[[0-9;?]*[a-zA-Z]/g, "").replace(/\x1B\][^\x07]*\x07/g, "");
9715
- const lines = clean.split("\n").map((l) => l.trim());
9716
- for (let i = 0; i < lines.length; i++) {
9717
- const idx = lines[i].indexOf("sk-ant-oat01-");
9718
- if (idx < 0) continue;
9719
- let tok = (lines[i].slice(idx).match(/^sk-ant-oat01-[A-Za-z0-9_-]*/) || [""])[0];
9720
- for (let j = i + 1; j < lines.length && /^[A-Za-z0-9_-]+$/.test(lines[j]); j++) tok += lines[j];
9721
- lineMatch = tok;
9722
- break;
9876
+ const TOKEN_YELLOW = "2;255;193;7";
9877
+ let fg = "default";
9878
+ let token = "";
9879
+ let i = 0;
9880
+ while (i < raw.length) {
9881
+ if (raw[i] === "\x1B") {
9882
+ const sgr = /^\x1B\[([0-9;?]*)m/.exec(raw.slice(i));
9883
+ if (sgr) {
9884
+ const codes = sgr[1].split(";").map((n) => Number(n) || 0);
9885
+ for (let k = 0; k < codes.length; k++) {
9886
+ const c = codes[k];
9887
+ if (c === 0 || c === 39) fg = "default";
9888
+ else if (c === 38 && codes[k + 1] === 2) {
9889
+ fg = `2;${codes[k + 2]};${codes[k + 3]};${codes[k + 4]}`;
9890
+ k += 4;
9891
+ } else if (c === 38 && codes[k + 1] === 5) {
9892
+ fg = `5;${codes[k + 2]}`;
9893
+ k += 2;
9894
+ } else if (c >= 30 && c <= 37 || c >= 90 && c <= 97) fg = `basic;${c}`;
9895
+ }
9896
+ i += sgr[0].length;
9897
+ continue;
9898
+ }
9899
+ const csi = /^\x1B\[[0-9;?]*[ -/]*[@-~]/.exec(raw.slice(i));
9900
+ const osc = /^\x1B\][^\x07]*(?:\x07|\x1B\\)/.exec(raw.slice(i));
9901
+ if (csi) {
9902
+ i += csi[0].length;
9903
+ continue;
9904
+ }
9905
+ if (osc) {
9906
+ i += osc[0].length;
9907
+ continue;
9908
+ }
9909
+ i += 1;
9910
+ continue;
9911
+ }
9912
+ const ch = raw[i];
9913
+ if (fg === TOKEN_YELLOW && !/[\s\x00-\x1F\x7F]/.test(ch)) token += ch;
9914
+ i += 1;
9723
9915
  }
9724
- const valid = (t) => /^sk-ant-oat01-[A-Za-z0-9_-]{40,}$/.test(t);
9725
- const token = valid(yToken) ? yToken : valid(lineMatch) ? lineMatch : "";
9726
9916
  if (!token) {
9727
- reject(new Error(`Could not capture a full token from claude setup-token output (file=${raw.length}b, yellow=${yellow.length}b, yToken=${yToken.length}b)`));
9917
+ reject(new Error(`Captured no yellow token text from claude setup-token output (raw=${raw.length}b \u2014 is the terminal emitting color?)`));
9728
9918
  return;
9729
9919
  }
9730
9920
  resolve4(token);
@@ -10255,9 +10445,9 @@ function writeHookScripts(mode = "stub") {
10255
10445
  const taskActivateIntentScriptPath = join10(HOOKS_DIR, "cc-task-activate-intent.ts");
10256
10446
  const mcpGateScriptPath = join10(HOOKS_DIR, "cc-mcp-gate.ts");
10257
10447
  if (mode === "stub") {
10258
- const stubCommonPath = join10(HOOKS_DIR, "_synkro-stub-common.ts");
10448
+ const stubCommonPath2 = join10(HOOKS_DIR, "_synkro-stub-common.ts");
10259
10449
  const stubFiles = [
10260
- [stubCommonPath, STUB_COMMON_TS],
10450
+ [stubCommonPath2, STUB_COMMON_TS],
10261
10451
  [bashScriptPath, STUB_BASH_JUDGE_TS],
10262
10452
  [bashFollowupScriptPath, STUB_BASH_FOLLOWUP_TS],
10263
10453
  [editPrecheckScriptPath, STUB_EDIT_PRECHECK_TS],
@@ -10328,6 +10518,9 @@ function writeHookScripts(mode = "stub") {
10328
10518
  writeFileSync8(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
10329
10519
  writeFileSync8(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n if (ecosystem === 'Packagist') {\n // composer uses vendor/package:version-constraint (e.g. monolog/monolog:1.0.0).\n // Split on the first ':' after the vendor/ slash; never on the '/'.\n const ci = tok.indexOf(':');\n if (ci > 0) return { ecosystem, name: tok.slice(0, ci), versionSpec: tok.slice(ci + 1) || null, source: 'registry' };\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n // gem pins the version with a separate `-v`/`--version` flag rather than an\n // inline spec; capture it and apply to the package(s) in this segment.\n let flagVersion: string | null = null;\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n const lower = tok.toLowerCase();\n if (ecosystem === 'RubyGems' && (lower === '-v' || lower === '--version')) {\n flagVersion = (argv[i + 1] || '').replace(/^[v=\\s]+/, '') || null;\n i++;\n continue;\n }\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n if (flagVersion) {\n for (const ins of installs) {\n if (ins.source === 'registry' && !ins.versionSpec) ins.versionSpec = flagVersion;\n }\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\nconst PKG_JSON_DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];\n\nfunction safeParseObject(text: string): Record<string, any> | null {\n try {\n const v = JSON.parse(text);\n return v && typeof v === 'object' && !Array.isArray(v) ? v : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Diff two package.json contents and return the registry packages that are\n * newly added or whose version spec changed in the new content. The caller\n * scans these against the vuln DB before letting the edit land \u2014 so a bare\n * `pnpm install` afterwards has nothing left to vet. Non-registry sources\n * (file:, link:, workspace:, git, http, relative paths) are skipped.\n */\nexport function extractPackageJsonDelta(oldText: string, newText: string): DeterministicPkgRequest[] {\n const newJson = safeParseObject(newText);\n if (!newJson) return [];\n const oldJson = safeParseObject(oldText) || {};\n\n const out = new Map<string, DeterministicPkgRequest>();\n for (const field of PKG_JSON_DEP_FIELDS) {\n const oldDeps = (oldJson[field] && typeof oldJson[field] === 'object') ? oldJson[field] : {};\n const newDeps = (newJson[field] && typeof newJson[field] === 'object') ? newJson[field] : {};\n for (const [rawName, version] of Object.entries(newDeps)) {\n if (typeof version !== 'string') continue;\n if (oldDeps[rawName] === version) continue;\n const spec = version.trim();\n if (\n spec.startsWith('file:') || spec.startsWith('link:') ||\n spec.startsWith('http') || spec.startsWith('git') ||\n spec.startsWith('workspace:') || spec.startsWith('catalog:') ||\n spec.startsWith('npm:') ||\n spec.startsWith('./') || spec.startsWith('../') ||\n spec === '' || spec === '*'\n ) continue;\n const name = rawName.toLowerCase();\n out.set(name, {\n name,\n version: concretePin(spec) ?? '*',\n ecosystem: 'npm',\n });\n }\n }\n return [...out.values()];\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
10330
10520
  writeFileSync8(taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS, "utf-8");
10521
+ const stubCommonPath = join10(HOOKS_DIR, "_synkro-stub-common.ts");
10522
+ writeFileSync8(stubCommonPath, STUB_COMMON_TS, "utf-8");
10523
+ chmodSync2(stubCommonPath, 493);
10331
10524
  writeFileSync8(mcpGateScriptPath, STUB_MCP_GATE_TS, "utf-8");
10332
10525
  chmodSync2(bashScriptPath, 493);
10333
10526
  chmodSync2(bashFollowupScriptPath, 493);
@@ -10350,10 +10543,6 @@ function writeHookScripts(mode = "stub") {
10350
10543
  chmodSync2(installExtractCorePath, 493);
10351
10544
  chmodSync2(taskActivateIntentScriptPath, 493);
10352
10545
  chmodSync2(mcpGateScriptPath, 493);
10353
- try {
10354
- unlinkSync4(join10(HOOKS_DIR, "_synkro-stub-common.ts"));
10355
- } catch {
10356
- }
10357
10546
  return {
10358
10547
  bashScript: bashScriptPath,
10359
10548
  bashFollowupScript: bashFollowupScriptPath,
@@ -10403,7 +10592,7 @@ function writeConfigEnv(opts) {
10403
10592
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
10404
10593
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
10405
10594
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
10406
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.73")}`
10595
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.75")}`
10407
10596
  ];
10408
10597
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
10409
10598
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -10466,6 +10655,24 @@ async function provisionCloudContainer(opts) {
10466
10655
  console.error(" Cloud needs a Claude setup-token. Run `claude setup-token` manually, then re-run install.");
10467
10656
  process.exit(1);
10468
10657
  }
10658
+ console.log(" Validating Claude token...");
10659
+ try {
10660
+ const out = execSync6('claude --print --output-format json "say ok"', {
10661
+ env: { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: setupToken },
10662
+ encoding: "utf-8",
10663
+ timeout: 3e4,
10664
+ stdio: ["ignore", "pipe", "pipe"]
10665
+ });
10666
+ const result = JSON.parse(out);
10667
+ if (result.is_error) throw new Error(result.result || "auth failed");
10668
+ console.log(" \u2713 Claude token validated.\n");
10669
+ } catch (err) {
10670
+ const msg = err instanceof Error ? err.message : String(err);
10671
+ console.error(` \u2717 Claude token failed validation: ${msg}`);
10672
+ console.error(" The captured token can't authenticate (truncated capture, wrong account, or no API credits).");
10673
+ console.error(" Re-run install to re-authorize \u2014 nothing was stored.");
10674
+ process.exit(1);
10675
+ }
10469
10676
  const repo = detectGitRepo2() || void 0;
10470
10677
  const sf = readFullSynkroFile();
10471
10678
  const harness = [];
@@ -14055,7 +14262,7 @@ var args = process.argv.slice(2);
14055
14262
  var cmd = args[0] || "";
14056
14263
  var subArgs = args.slice(1);
14057
14264
  function printVersion() {
14058
- console.log("1.6.73");
14265
+ console.log("1.6.75");
14059
14266
  }
14060
14267
  function printHelp2() {
14061
14268
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents