@synkro-sh/cli 1.6.3 → 1.6.4

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
@@ -967,7 +967,7 @@ export async function cweChannelUp(): Promise<boolean> {
967
967
 
968
968
  // \u2500\u2500\u2500 Mode Normalization \u2500\u2500\u2500
969
969
 
970
- function normalizeMode(m?: string): 'ask' | 'fix' {
970
+ export function normalizeMode(m?: string): 'ask' | 'fix' {
971
971
  if (m === 'blocking' || m === 'ask') return 'ask';
972
972
  if (m === 'audit' || m === 'fix') return 'fix';
973
973
  return 'ask';
@@ -1089,12 +1089,18 @@ export function tag(rt: string, config: HookConfig): string {
1089
1089
 
1090
1090
  type GradeRole = 'grade-edit' | 'grade-bash' | 'grade-plan' | 'grade-cwe';
1091
1091
 
1092
+ // Which coding agent fired this grade. The dispatcher routes grades to a
1093
+ // worker pool of the matching kind so a Cursor grade is judged by Cursor and
1094
+ // a Claude grade by Claude. Defaults to 'claude_code' so existing cc-* hook
1095
+ // call sites need no change; cursor-* hooks pass 'cursor' explicitly.
1096
+ export type AgentKind = 'claude_code' | 'cursor';
1097
+
1092
1098
  const ROLE_MAP: Record<string, GradeRole> = {
1093
1099
  edit: 'grade-edit', bash: 'grade-bash', plan: 'grade-plan', cwe: 'grade-cwe',
1094
1100
  };
1095
1101
 
1096
- async function channelGrade(role: GradeRole, prompt: string, _jwt: string, port: number, timeoutMs = 30000): Promise<string> {
1097
- const body = JSON.stringify({ role, payload: prompt, content: prompt });
1102
+ async function channelGrade(role: GradeRole, prompt: string, _jwt: string, port: number, timeoutMs = 30000, agentKind: AgentKind = 'claude_code'): Promise<string> {
1103
+ const body = JSON.stringify({ role, payload: prompt, content: prompt, agent_kind: agentKind });
1098
1104
 
1099
1105
  const resp = await fetch('http://127.0.0.1:' + port + '/submit', {
1100
1106
  method: 'POST',
@@ -1113,17 +1119,217 @@ async function channelGrade(role: GradeRole, prompt: string, _jwt: string, port:
1113
1119
  return String(data.result || '');
1114
1120
  }
1115
1121
 
1116
- export async function localGrade(surface: string, prompt: string, timeoutMs = 30000): Promise<string> {
1122
+ export async function localGrade(surface: string, prompt: string, timeoutMs = 30000, agentKind: AgentKind = 'claude_code'): Promise<string> {
1117
1123
  if (!(await channelUp())) throw new Error('SYNKRO_CHANNEL_DOWN');
1118
1124
  const jwt = loadJwt();
1119
1125
  if (!jwt) throw new Error('NO_JWT');
1120
- return channelGrade(ROLE_MAP[surface] || 'grade-edit', prompt, jwt, 18929, timeoutMs);
1126
+ return channelGrade(ROLE_MAP[surface] || 'grade-edit', prompt, jwt, 18929, timeoutMs, agentKind);
1121
1127
  }
1122
1128
 
1123
- export async function localGradeCwe(prompt: string): Promise<string> {
1129
+ export async function localGradeCwe(prompt: string, agentKind: AgentKind = 'claude_code'): Promise<string> {
1124
1130
  const jwt = loadJwt();
1125
1131
  if (!jwt) throw new Error('NO_JWT');
1126
- return channelGrade('grade-cwe', prompt, jwt, 18930, 45000);
1132
+ return channelGrade('grade-cwe', prompt, jwt, 18930, 45000, agentKind);
1133
+ }
1134
+
1135
+ // \u2500\u2500\u2500 Rule Pre-Filter (embedding-based) \u2500\u2500\u2500
1136
+
1137
+ export async function filterRules(commandText: string, allRules: Rule[]): Promise<Rule[]> {
1138
+ if (allRules.length <= 3) return allRules;
1139
+ const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
1140
+ try {
1141
+ const resp = await fetch('http://127.0.0.1:' + mcpPort + '/api/local/filter-rules', {
1142
+ method: 'POST',
1143
+ headers: { 'Content-Type': 'application/json' },
1144
+ body: JSON.stringify({ text: commandText, top_k: 3 }),
1145
+ signal: AbortSignal.timeout(500),
1146
+ });
1147
+ if (!resp.ok) return allRules;
1148
+ const data = await resp.json() as { rules?: Array<{ rule_id: string; similarity?: number }> };
1149
+ if (!data.rules || data.rules.length === 0) return allRules;
1150
+ const selectedIds = new Set(data.rules.map(r => r.rule_id));
1151
+ return allRules.filter(r => selectedIds.has(r.rule_id));
1152
+ } catch {
1153
+ return allRules;
1154
+ }
1155
+ }
1156
+
1157
+ // \u2500\u2500\u2500 Safe-read short-circuit (shared by cc-bash-judge + cursor-bash-judge) \u2500\u2500\u2500
1158
+ // Read-only tool calls, and bash pipelines where every segment is a pure
1159
+ // in-repo read, are allowed instantly without an LLM grade. Strict by design:
1160
+ // any $ / backtick / redirect / shell metachar, any non-whitelisted verb, any
1161
+ // .. traversal, or any absolute path outside repoRoot falls through to the judge.
1162
+
1163
+ const SAFE_READ_TOOLS = new Set([
1164
+ 'Read', 'ReadFile', 'read_file', 'Grep', 'grep_search', 'codebase_search',
1165
+ 'file_search', 'Glob', 'list_dir',
1166
+ ]);
1167
+ const SAFE_SHELL_TOOLS = new Set([
1168
+ 'Bash', 'Shell', 'terminal', 'run_terminal_cmd', 'execute_command',
1169
+ ]);
1170
+
1171
+ function isSafeBashSegment(seg: string, repoRoot: string): boolean {
1172
+ const UNSAFE_CHARS = ['>', ';', '&', '\`', '$'];
1173
+ for (const ch of UNSAFE_CHARS) { if (seg.indexOf(ch) !== -1) return false; }
1174
+ const padded = ' ' + seg + ' ';
1175
+ const UNSAFE_WORDS = [
1176
+ ' sudo ', ' su ', ' rm ', ' mv ', ' cp ', ' chmod ', ' chown ',
1177
+ ' tee ', ' kill ', ' sed -i', ' sed --in-place',
1178
+ ' sh -c', ' bash -c', ' zsh -c', ' eval ', ' exec ',
1179
+ ];
1180
+ for (const w of UNSAFE_WORDS) { if (padded.indexOf(w) !== -1) return false; }
1181
+ const SAFE_VERBS = new Set([
1182
+ 'cat','head','tail','less','more','grep','egrep','fgrep','rg','ag',
1183
+ 'find','fd','ls','wc','cmp','diff','file','stat','which','whereis','type',
1184
+ 'pwd','whoami','id','date','echo','printf','true','false',
1185
+ 'jq','yq','sort','uniq','cut','tr','xxd','hexdump','od','column',
1186
+ 'node','npm','pnpm','yarn','bun','python','python3','ruby','go','rustc','cargo',
1187
+ 'git',
1188
+ ]);
1189
+ const tokens = seg.trim().split(' ').filter(t => t.length > 0);
1190
+ const verb = tokens[0] || '';
1191
+ if (!SAFE_VERBS.has(verb)) return false;
1192
+ if (verb === 'find' || verb === 'fd') {
1193
+ const BAD = new Set([
1194
+ '-exec','-execdir','-ok','-okdir','-delete',
1195
+ '-fprint','-fprintf','-fprint0','-fls','--exec','--exec-batch',
1196
+ ]);
1197
+ for (const t of tokens) { if (BAD.has(t)) return false; }
1198
+ }
1199
+ if (verb === 'git') {
1200
+ const SAFE_GIT = new Set([
1201
+ 'log','show','diff','blame','status','rev-parse',
1202
+ 'ls-files','ls-tree','cat-file','shortlog','reflog',
1203
+ 'describe','symbolic-ref','--version',
1204
+ ]);
1205
+ const sub = tokens[1] || '';
1206
+ if (!SAFE_GIT.has(sub)) return false;
1207
+ } else if (['npm','pnpm','yarn','bun','cargo','go'].includes(verb)) {
1208
+ const sub = tokens[1] || '';
1209
+ const SAFE_PKG = new Set([
1210
+ '--version','-v','version','list','ls','why','view','show','info','outdated',
1211
+ '-h','--help','help',
1212
+ ]);
1213
+ if (!SAFE_PKG.has(sub)) return false;
1214
+ } else if (['node','python','python3','ruby','rustc'].includes(verb)) {
1215
+ const sub = tokens[1] || '';
1216
+ if (sub !== '--version' && sub !== '-v' && sub !== '-V') return false;
1217
+ }
1218
+ if (!repoRoot) return false;
1219
+ for (let i = 1; i < tokens.length; i++) {
1220
+ const stripped = tokens[i].replace(/^['"]/, '').replace(/['"]$/, '');
1221
+ if (stripped.startsWith('~')) return false;
1222
+ // Reject any .. traversal segment \u2014 a relative path with .. can escape the
1223
+ // repo root just as easily as an absolute one.
1224
+ if (stripped.split('/').some(p => p === '..')) return false;
1225
+ if (stripped.startsWith('/') && !isPathUnder(stripped, repoRoot)) return false;
1226
+ }
1227
+ return true;
1228
+ }
1229
+
1230
+ export function isSafeInRepoRead(toolName: string, command: string, repoRoot: string): boolean {
1231
+ if (SAFE_READ_TOOLS.has(toolName)) return true;
1232
+ if (!SAFE_SHELL_TOOLS.has(toolName)) return false;
1233
+ if (!command || !repoRoot) return false;
1234
+ const segments = command.split('|');
1235
+ for (const seg of segments) {
1236
+ const t = seg.trim();
1237
+ if (t.length === 0) return false;
1238
+ if (!isSafeBashSegment(t, repoRoot)) return false;
1239
+ }
1240
+ return true;
1241
+ }
1242
+
1243
+ // \u2500\u2500\u2500 Install protection: server-side pkg-scan (shared by both bash judges) \u2500\u2500\u2500
1244
+ // Parses an install command, scans the packages for CVEs / typosquats /
1245
+ // malicious tarballs / low reputation, and returns a structured verdict the
1246
+ // caller renders in its own (cc or cursor) output format.
1247
+
1248
+ export interface InstallScanResult {
1249
+ scanned: boolean;
1250
+ action: 'allow' | 'warn' | 'block';
1251
+ blockContext: string;
1252
+ summary: string;
1253
+ scannedLabel: string;
1254
+ findings: Array<{ advisoryId: string; name: string; version: string; severity: string; detail: string }>;
1255
+ violatedIds: string[];
1256
+ }
1257
+
1258
+ export async function runInstallScan(command: string, jwt: string): Promise<InstallScanResult> {
1259
+ const empty: InstallScanResult = {
1260
+ scanned: false, action: 'allow', blockContext: '', summary: '',
1261
+ scannedLabel: '', findings: [], violatedIds: [],
1262
+ };
1263
+ const pkgInstallMatch = command.match(
1264
+ /^(?:.*&&\\s*|.*;\\s*)?(?:npm\\s+(?:install|i|add)|pnpm\\s+(?:add|install|i)|yarn\\s+add|bun\\s+(?:add|install|i)|(?:uv\\s+)?pip3?\\s+install|go\\s+get|cargo\\s+add|gem\\s+install|composer\\s+require)\\s+([^|;&><]+)/
1265
+ );
1266
+ if (!pkgInstallMatch) return empty;
1267
+ const isPip = /(?:uv\\s+)?pip3?\\s+install/.test(command);
1268
+ const packages: Array<{ name: string; version: string; ecosystem: string }> = [];
1269
+ const tokens = pkgInstallMatch[1].split(/\\s+/);
1270
+ let skipNext = false;
1271
+ for (const token of tokens) {
1272
+ if (skipNext) { skipNext = false; continue; }
1273
+ if (!token || !/^[@a-zA-Z]/.test(token)) continue;
1274
+ if (token.startsWith('-')) {
1275
+ if (/^--(python|target|prefix|root|constraint|requirement|index-url|extra-index-url|find-links|build|src|cache-dir|filter|workspace)$/.test(token)) skipNext = true;
1276
+ continue;
1277
+ }
1278
+ const ecosystem = isPip ? 'PyPI' : 'npm';
1279
+ if (isPip) {
1280
+ const pipMatch = token.match(/^([a-zA-Z0-9_.-]+)(?:[=~!<>]=?(.+))?$/);
1281
+ if (pipMatch) {
1282
+ packages.push({ name: pipMatch[1], version: pipMatch[2]?.replace(/^=/, '') || '*', ecosystem });
1283
+ continue;
1284
+ }
1285
+ }
1286
+ const atIdx = token.lastIndexOf('@');
1287
+ if (atIdx > 0) packages.push({ name: token.slice(0, atIdx), version: token.slice(atIdx + 1), ecosystem });
1288
+ else packages.push({ name: token, version: '*', ecosystem });
1289
+ }
1290
+ if (packages.length === 0) return empty;
1291
+ const scannedLabel = packages.map(p => p.name + '@' + p.version).join(', ');
1292
+ try {
1293
+ const scanResp = await fetch(GATEWAY_URL + '/api/v1/pkg-scan', {
1294
+ method: 'POST',
1295
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
1296
+ body: JSON.stringify({ packages, command }),
1297
+ signal: AbortSignal.timeout(15000),
1298
+ }).then(r => r.json()) as any;
1299
+ const action = scanResp?.action || 'allow';
1300
+ const pkgResults = Array.isArray(scanResp?.packages) ? scanResp.packages : [];
1301
+ const summary = scanResp?.summary || '';
1302
+ if (action === 'block') {
1303
+ const blockSignals = pkgResults
1304
+ .flatMap((p: any) => (p.signals || []).filter((s: any) => s.severity === 'critical' || s.severity === 'high'))
1305
+ .slice(0, 5);
1306
+ const findings: InstallScanResult['findings'] = [];
1307
+ for (const p of pkgResults) {
1308
+ for (const s of (p.signals || [])) {
1309
+ if (s.severity === 'critical' || s.severity === 'high') {
1310
+ const advisoryMatch = (s.detail || '').match(/\\b(GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|CVE-\\d{4}-\\d+)\\b/i);
1311
+ findings.push({
1312
+ advisoryId: advisoryMatch ? advisoryMatch[1] : s.type,
1313
+ name: p.name, version: p.version, severity: s.severity, detail: s.detail,
1314
+ });
1315
+ }
1316
+ }
1317
+ }
1318
+ const details = blockSignals.map((s: any) => s.detail).join('\\n');
1319
+ return {
1320
+ scanned: true, action: 'block',
1321
+ blockContext: details + '\\nDo NOT install packages with security risks. Use a patched version or a different package.',
1322
+ summary, scannedLabel, findings,
1323
+ violatedIds: blockSignals.map((s: any) => s.type + ':' + (s.detail || '').slice(0, 40)),
1324
+ };
1325
+ }
1326
+ return {
1327
+ scanned: true, action: action === 'warn' ? 'warn' : 'allow',
1328
+ blockContext: '', summary, scannedLabel, findings: [], violatedIds: [],
1329
+ };
1330
+ } catch {
1331
+ return { scanned: true, action: 'allow', blockContext: '', summary: '', scannedLabel, findings: [], violatedIds: [] };
1332
+ }
1127
1333
  }
1128
1334
 
1129
1335
  // \u2500\u2500\u2500 Session Action Log \u2500\u2500\u2500
@@ -1147,10 +1353,23 @@ function sessionLogPath(sessionId: string): string | null {
1147
1353
  export function appendSessionAction(sessionId: string, entry: SessionAction): void {
1148
1354
  const logPath = sessionLogPath(sessionId);
1149
1355
  if (!logPath) return;
1356
+ let step = 0;
1150
1357
  try {
1151
1358
  mkdirSync(SESSIONS_DIR, { recursive: true });
1359
+ try { step = readFileSync(logPath, 'utf-8').split('\\n').filter(Boolean).length + 1; } catch { step = 1; }
1152
1360
  appendFileSync(logPath, JSON.stringify(entry) + '\\n', 'utf-8');
1153
1361
  } catch {}
1362
+
1363
+ const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
1364
+ let mcpToken = '';
1365
+ try { mcpToken = readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
1366
+ if (!mcpToken) return;
1367
+ fetch('http://127.0.0.1:' + mcpPort + '/api/session-action', {
1368
+ method: 'POST',
1369
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
1370
+ body: JSON.stringify({ session_id: sessionId, step, tool: entry.tool, summary: entry.summary, file: entry.file, outcome: entry.outcome }),
1371
+ signal: AbortSignal.timeout(2000),
1372
+ }).catch(() => {});
1154
1373
  }
1155
1374
 
1156
1375
  export function readSessionLog(sessionId: string): SessionAction[] {
@@ -1170,8 +1389,8 @@ export function compressSessionLog(actions: SessionAction[]): string {
1170
1389
  const total = actions.length;
1171
1390
  const lines: string[] = [];
1172
1391
 
1173
- if (total > 30) {
1174
- const old = actions.slice(0, total - 30);
1392
+ if (total > 200) {
1393
+ const old = actions.slice(0, total - 200);
1175
1394
  const counts: Record<string, number> = {};
1176
1395
  const dirs = new Set<string>();
1177
1396
  for (const a of old) {
@@ -1186,7 +1405,7 @@ export function compressSessionLog(actions: SessionAction[]): string {
1186
1405
  lines.push(' [' + old.length + ' earlier: ' + parts + dirHint + ']');
1187
1406
  }
1188
1407
 
1189
- const tier2Start = Math.max(0, total - 30);
1408
+ const tier2Start = Math.max(0, total - 200);
1190
1409
  const tier2End = Math.max(0, total - 10);
1191
1410
  for (let i = tier2Start; i < tier2End; i++) {
1192
1411
  const a = actions[i];
@@ -1921,7 +2140,7 @@ import {
1921
2140
  readStdin, extractTranscript, readLastPrompt, findNearestDeps, filePathFromToolInput,
1922
2141
  appendSessionAction, readSessionLog, compressSessionLog, log,
1923
2142
  outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, GATEWAY_URL,
1924
- logGraderUnavailable,
2143
+ logGraderUnavailable, filterRules, normalizeMode,
1925
2144
  type HookConfig, type Rule,
1926
2145
  } from './_synkro-common.ts';
1927
2146
  import { existsSync, readFileSync } from 'node:fs';
@@ -2000,6 +2219,8 @@ async function main() {
2000
2219
  // \u2500\u2500\u2500 Local grading: org rules ONLY (channel 1, port 18929) \u2500\u2500\u2500
2001
2220
  const proposedShort = proposed.slice(0, 4000);
2002
2221
  const sessionLog = compressSessionLog(readSessionLog(sessionId));
2222
+ const graderContent = 'file=' + filePath + ' content=' + proposedShort;
2223
+ const relevantRules = await filterRules(graderContent, config.rules);
2003
2224
  const graderPrompt = [
2004
2225
  'Working directory: ' + (cwd || '.'),
2005
2226
  'Repo: ' + (gitRepo || 'unknown'),
@@ -2009,7 +2230,7 @@ async function main() {
2009
2230
  proposedShort,
2010
2231
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
2011
2232
  'Last user prompt: ' + (lastPrompt || 'none'),
2012
- 'Org rules: ' + JSON.stringify(config.rules),
2233
+ 'Org rules: ' + JSON.stringify(relevantRules),
2013
2234
  'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix \u2014 your job is only to detect violations.',
2014
2235
  'When passing (ok=true), for EVERY rule: state whether it is relevant to this edit, and if relevant, why it passes. Format: "R001 (not relevant: no deployment). R003 relevant: no hardcoded secrets in file." Cover ALL rules \u2014 do not skip any. Be terse but specific per rule.',
2015
2236
  ].join('\\n');
@@ -2078,6 +2299,7 @@ async function main() {
2078
2299
  recent_user_messages: transcript.recentUserMessages,
2079
2300
  recent_messages: transcript.recentMessages,
2080
2301
  recent_actions: transcript.recentActions,
2302
+ session_history: compressSessionLog(readSessionLog(sessionId)),
2081
2303
  session_id: sessionId || null,
2082
2304
  tool_use_id: toolUseId || null,
2083
2305
  cwd: cwd || null,
@@ -2778,7 +3000,7 @@ import {
2778
3000
  parseVerdict, dispatchCapture, dispatchFinding, ruleMode, postWithRetry, readStdin,
2779
3001
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
2780
3002
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL,
2781
- logGraderUnavailable, isPathUnder,
3003
+ logGraderUnavailable, isPathUnder, filterRules, normalizeMode,
2782
3004
  type HookConfig, type Rule,
2783
3005
  } from './_synkro-common.ts';
2784
3006
 
@@ -2818,8 +3040,6 @@ async function main() {
2818
3040
  }
2819
3041
  if (!command) { outputEmpty(); return; }
2820
3042
 
2821
- appendSessionAction(sessionId, { ts: new Date().toISOString(), tool: toolName, summary: command.slice(0, 120) });
2822
-
2823
3043
  const cmdShort = command.slice(0, 80);
2824
3044
  log('bashGuard checking: ' + cmdShort);
2825
3045
 
@@ -3079,6 +3299,7 @@ async function main() {
3079
3299
 
3080
3300
  if (rt === 'local') {
3081
3301
  const sessionLog = compressSessionLog(readSessionLog(sessionId));
3302
+ const relevantRules = await filterRules(command, config.rules);
3082
3303
  const graderPrompt = [
3083
3304
  'Working directory: ' + (cwd || '.'),
3084
3305
  'Repo: ' + (gitRepo || 'unknown'),
@@ -3086,7 +3307,7 @@ async function main() {
3086
3307
  'Command: ' + command,
3087
3308
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
3088
3309
  'Last user prompt: ' + (lastPrompt || 'none'),
3089
- 'Org rules: ' + JSON.stringify(config.rules),
3310
+ 'Org rules: ' + JSON.stringify(relevantRules),
3090
3311
  'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix — your job is only to detect violations.',
3091
3312
  'When passing (ok=true), for EVERY rule: state whether it is relevant to this command, and if relevant, why it passes. Format: "R001 (not relevant: no deployment). R003 relevant: no secrets in grep args. R005 relevant: in-repo path only." Cover ALL rules — do not skip any. Be terse but specific per rule.',
3092
3313
  'Rules with preconditions (e.g. "run X before Y") are CONSUMED after the protected action completes. Use the session history timestamps to determine ordering: a precondition satisfied before the last occurrence of the protected action does NOT satisfy the next occurrence. Each new protected action needs its precondition re-satisfied.',
@@ -3148,6 +3369,7 @@ async function main() {
3148
3369
  recent_user_messages: transcript.recentUserMessages,
3149
3370
  recent_messages: transcript.recentMessages,
3150
3371
  recent_actions: transcript.recentActions,
3372
+ session_history: compressSessionLog(readSessionLog(sessionId)),
3151
3373
  session_id: sessionId || null,
3152
3374
  tool_use_id: toolUseId || null,
3153
3375
  cwd: cwd || null,
@@ -3202,7 +3424,7 @@ import {
3202
3424
  parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
3203
3425
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
3204
3426
  outputJson, outputEmpty, setupCursorHookSignals, isAgentTool, hookSessionId, GATEWAY_URL,
3205
- logGraderUnavailable,
3427
+ logGraderUnavailable, filterRules, normalizeMode,
3206
3428
  type HookConfig, type Rule,
3207
3429
  } from './_synkro-common.ts';
3208
3430
 
@@ -3256,6 +3478,8 @@ async function main() {
3256
3478
 
3257
3479
  if (rt === 'local') {
3258
3480
  const sessionLog = compressSessionLog(readSessionLog(sessionId));
3481
+ const agentText = 'agent=' + subagentType + ' description=' + description + ' prompt=' + prompt.slice(0, 2000);
3482
+ const relevantRules = await filterRules(agentText, config.rules);
3259
3483
  const graderPrompt = [
3260
3484
  'Working directory: ' + (cwd || '.'),
3261
3485
  'Repo: ' + (gitRepo || 'unknown'),
@@ -3267,7 +3491,7 @@ async function main() {
3267
3491
  prompt.slice(0, 4000),
3268
3492
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
3269
3493
  'Last user prompt: ' + (lastPrompt || 'none'),
3270
- 'Org rules: ' + JSON.stringify(config.rules),
3494
+ 'Org rules: ' + JSON.stringify(relevantRules),
3271
3495
  'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix \u2014 your job is only to detect violations.',
3272
3496
  ].filter(Boolean).join('\\n');
3273
3497
 
@@ -3326,6 +3550,7 @@ async function main() {
3326
3550
  recent_user_messages: transcript.recentUserMessages,
3327
3551
  recent_messages: transcript.recentMessages,
3328
3552
  recent_actions: transcript.recentActions,
3553
+ session_history: compressSessionLog(readSessionLog(sessionId)),
3329
3554
  session_id: sessionId || null,
3330
3555
  tool_use_id: toolUseId || null,
3331
3556
  cwd: cwd || null,
@@ -3365,6 +3590,7 @@ import {
3365
3590
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
3366
3591
  parseVerdict, dispatchCapture, appendSessionAction, readSessionLog, compressSessionLog, postWithRetry, readStdin, log,
3367
3592
  outputJson, outputEmpty, setupCursorHookSignals, isPlanTool, hookSessionId, GATEWAY_URL,
3593
+ filterRules,
3368
3594
  } from './_synkro-common.ts';
3369
3595
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
3370
3596
  import { join } from 'node:path';
@@ -3449,13 +3675,14 @@ async function main() {
3449
3675
 
3450
3676
  if (rt === 'local') {
3451
3677
  const sessionLog = compressSessionLog(readSessionLog(sessionId));
3678
+ const relevantRules = await filterRules(plan.slice(0, 2000), config.rules);
3452
3679
  const graderPrompt = [
3453
3680
  'Working directory: ' + (cwd || '.'),
3454
3681
  'Repo: ' + (gitRepo || 'unknown'),
3455
3682
  sessionLog,
3456
3683
  'Plan:',
3457
3684
  plan.slice(0, 8000),
3458
- 'Org rules: ' + JSON.stringify(config.rules),
3685
+ 'Org rules: ' + JSON.stringify(relevantRules),
3459
3686
  ].filter(Boolean).join('\\n');
3460
3687
 
3461
3688
  let gradeResp: string;
@@ -3962,8 +4189,10 @@ main();
3962
4189
  CURSOR_BASH_JUDGE_TS = `#!/usr/bin/env bun
3963
4190
  import {
3964
4191
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
3965
- parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
3966
- extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log, GATEWAY_URL,
4192
+ parseVerdict, dispatchCapture, dispatchFinding, ruleMode, normalizeMode, filterRules,
4193
+ isSafeInRepoRead, runInstallScan, postWithRetry, readStdin,
4194
+ extractTranscript, readLastPrompt, readSessionLog, compressSessionLog,
4195
+ appendLocalTelemetry, logGraderUnavailable, log, GATEWAY_URL,
3967
4196
  type Rule,
3968
4197
  } from './_synkro-common.ts';
3969
4198
  import { createHash } from 'node:crypto';
@@ -3989,8 +4218,8 @@ function isDuplicate(command: string, sessionId: string): boolean {
3989
4218
  }
3990
4219
 
3991
4220
  // Cursor beforeShellExecution timeout is 15s; stay under it (JWT refresh + grade).
3992
- const CURSOR_GRADE_TIMEOUT_MS = 7500;
3993
- const CURSOR_CLOUD_TIMEOUT_MS = 6000;
4221
+ const CURSOR_GRADE_TIMEOUT_MS = 12000;
4222
+ const CURSOR_CLOUD_TIMEOUT_MS = 9000;
3994
4223
 
3995
4224
  let hookDone = false;
3996
4225
 
@@ -4060,8 +4289,6 @@ async function main() {
4060
4289
  finishAllow();
4061
4290
  }
4062
4291
 
4063
- appendSessionAction(sessionId, { ts: new Date().toISOString(), tool: toolName, summary: command.slice(0, 120) });
4064
-
4065
4292
  const transcriptPath = typeof payload.transcript_path === 'string' ? payload.transcript_path : '';
4066
4293
  const rawModel = String(payload.model ?? payload.model_id ?? '');
4067
4294
  const KNOWN_MODELS = new Set(['gpt-4', 'gpt-4o', 'gpt-4.1', 'gpt-5', 'o1', 'o3', 'o4-mini', 'claude-sonnet-4-5', 'claude-opus-4-5', 'sonnet-4', 'sonnet-4-thinking', 'gemini-2.5-pro', 'gemini-2.5-flash']);
@@ -4071,6 +4298,18 @@ async function main() {
4071
4298
  const cmdShort = command.slice(0, 80);
4072
4299
  log('bashGuard checking: ' + cmdShort);
4073
4300
 
4301
+ // Instant-allow read-only tool calls + safe in-repo bash reads \u2014 no grade,
4302
+ // no network. Critical under Cursor's tight 15s beforeShellExecution budget.
4303
+ if (isSafeInRepoRead(toolName, command, cwd)) {
4304
+ log('bashGuard ' + cmdShort + ' \u2192 instant allow (safe in-repo read)');
4305
+ appendLocalTelemetry({
4306
+ capture_type: 'local_verdict', verdict: 'pass', hook_type: 'bash',
4307
+ category: 'safe_read', tool_name: toolName, command: command.slice(0, 200),
4308
+ session_id: sessionId, repo: cwd,
4309
+ });
4310
+ finishAllow();
4311
+ }
4312
+
4074
4313
  let jwt = loadJwt();
4075
4314
  if (!jwt) finishAllow();
4076
4315
  jwt = await ensureFreshJwt(jwt);
@@ -4084,28 +4323,55 @@ async function main() {
4084
4323
  const rt = await route(config);
4085
4324
  const tagStr = tag(rt, config);
4086
4325
 
4326
+ // Install protection \u2014 scan packages before any npm/pip/cargo/etc. install.
4327
+ if (SHELL_TOOL_NAMES.has(toolName)) {
4328
+ const scan = await runInstallScan(command, jwt);
4329
+ if (scan.action === 'block') {
4330
+ for (const f of scan.findings) {
4331
+ dispatchFinding(jwt, {
4332
+ session_id: sessionId, file_path: command,
4333
+ finding_type: 'cve' as const, finding_id: f.advisoryId + ':' + f.name,
4334
+ severity: f.severity, status: 'open', detail: f.detail,
4335
+ package_name: f.name, package_version: f.version,
4336
+ }, config.captureDepth);
4337
+ }
4338
+ dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
4339
+ 'Bash', repo, sessionId, config.captureDepth, {
4340
+ command, reasoning: scan.blockContext.slice(0, 200),
4341
+ violatedRules: scan.violatedIds, ccModel: model,
4342
+ });
4343
+ finishWith({
4344
+ permission: 'deny',
4345
+ user_message: tagStr + ' installScan \u2192 blocked: ' + cmdShort,
4346
+ agent_message: 'Synkro blocked this install \u2014 flagged package(s). ' + scan.blockContext,
4347
+ });
4348
+ } else if (scan.scanned && scan.action === 'warn') {
4349
+ log('bashGuard installScan warn: ' + scan.summary);
4350
+ }
4351
+ }
4352
+
4087
4353
  if (rt === 'local') {
4088
4354
  const sessionLog = compressSessionLog(readSessionLog(sessionId));
4089
- const rulesBlock = config.rules.map((r: Rule, i: number) =>
4090
- (i + 1) + '. [' + r.rule_id + '] (' + r.severity + '/' + r.mode + ') ' + r.text
4091
- ).join('\\n');
4355
+ const relevantRules = await filterRules(command, config.rules);
4092
4356
 
4093
4357
  const graderPrompt = [
4094
- 'RULES:',
4095
- rulesBlock || '(none)',
4096
- '',
4358
+ 'Working directory: ' + (cwd || '.'),
4359
+ 'Repo: ' + (repo || 'unknown'),
4097
4360
  sessionLog,
4098
- 'COMMAND TO EVALUATE:',
4099
- command,
4100
- '',
4361
+ 'Command: ' + command,
4101
4362
  'User intent (last human message): ' + (transcript.userIntent || lastPrompt || 'none stated'),
4102
4363
  'Last user prompt: ' + (lastPrompt || 'none'),
4364
+ 'Org rules: ' + JSON.stringify(relevantRules),
4365
+ 'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix \u2014 your job is only to detect violations.',
4366
+ 'When passing (ok=true), for EVERY rule: state whether it is relevant to this command, and if relevant, why it passes. Format: "R001 (not relevant: no deployment). R003 relevant: no secrets in grep args. R005 relevant: in-repo path only." Cover ALL rules \u2014 do not skip any. Be terse but specific per rule.',
4367
+ 'Rules with preconditions (e.g. "run X before Y") are CONSUMED after the protected action completes. Use the session history timestamps to determine ordering: a precondition satisfied before the last occurrence of the protected action does NOT satisfy the next occurrence. Each new protected action needs its precondition re-satisfied.',
4103
4368
  ].filter(Boolean).join('\\n');
4104
4369
 
4105
4370
  let gradeResp: string;
4106
4371
  try {
4107
- gradeResp = await localGrade('bash', graderPrompt, CURSOR_GRADE_TIMEOUT_MS);
4372
+ gradeResp = await localGrade('bash', graderPrompt, CURSOR_GRADE_TIMEOUT_MS, 'cursor');
4108
4373
  } catch (e) {
4374
+ logGraderUnavailable('bashGuard', command.slice(0, 200), (e as Error).message || String(e));
4109
4375
  log('bashGuard ' + cmdShort + ' \u2192 pass (grade unavailable): ' + String(e));
4110
4376
  finishWith({ permission: 'allow' });
4111
4377
  }
@@ -4120,7 +4386,7 @@ async function main() {
4120
4386
  ? 'Synkro safety judge. Fix this before retrying \u2014 do not ask the user. Reasoning: ' + (verdict.reason || guardReason)
4121
4387
  : 'Synkro safety judge. Ask the user for explicit consent before retrying. Reasoning: ' + (verdict.reason || guardReason);
4122
4388
  dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
4123
- 'Bash', gitRepo, sessionId, config.captureDepth, {
4389
+ 'Bash', repo, sessionId, config.captureDepth, {
4124
4390
  command, reasoning: guardReason,
4125
4391
  rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
4126
4392
  ccModel: model,
@@ -5423,6 +5689,26 @@ var init_promptFetcher = __esm({
5423
5689
  });
5424
5690
 
5425
5691
  // cli/local-cc/macKeychain.ts
5692
+ var macKeychain_exports = {};
5693
+ __export(macKeychain_exports, {
5694
+ CLAUDE_CREDS_DIR: () => CLAUDE_CREDS_DIR,
5695
+ CLAUDE_CREDS_FILE: () => CLAUDE_CREDS_FILE,
5696
+ CURSOR_API_KEY_FILE: () => CURSOR_API_KEY_FILE,
5697
+ CURSOR_CREDS_DIR: () => CURSOR_CREDS_DIR,
5698
+ KeychainExportError: () => KeychainExportError,
5699
+ SYNKRO_DIR: () => SYNKRO_DIR2,
5700
+ credsAreStale: () => credsAreStale,
5701
+ cursorApiKeyConfigured: () => cursorApiKeyConfigured,
5702
+ exportKeychainCreds: () => exportKeychainCreds,
5703
+ loadRefreshAgent: () => loadRefreshAgent,
5704
+ needsKeychainBridge: () => needsKeychainBridge,
5705
+ readExportedCreds: () => readExportedCreds,
5706
+ readKeychainCreds: () => readKeychainCreds,
5707
+ refreshCreds: () => refreshCreds,
5708
+ uninstallRefreshAgent: () => uninstallRefreshAgent,
5709
+ writeCursorApiKey: () => writeCursorApiKey,
5710
+ writeRefreshAgent: () => writeRefreshAgent
5711
+ });
5426
5712
  import { existsSync as existsSync7, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, chmodSync, readFileSync as readFileSync6, statSync } from "fs";
5427
5713
  import { homedir as homedir6, platform as platform3 } from "os";
5428
5714
  import { join as join6 } from "path";
@@ -5449,6 +5735,30 @@ function exportKeychainCreds() {
5449
5735
  chmodSync(CLAUDE_CREDS_FILE, 384);
5450
5736
  return CLAUDE_CREDS_FILE;
5451
5737
  }
5738
+ function cursorApiKeyConfigured() {
5739
+ try {
5740
+ return existsSync7(CURSOR_API_KEY_FILE) && readFileSync6(CURSOR_API_KEY_FILE, "utf-8").trim().length > 0;
5741
+ } catch {
5742
+ return false;
5743
+ }
5744
+ }
5745
+ function writeCursorApiKey(key) {
5746
+ const trimmed = key.trim();
5747
+ if (!trimmed) return;
5748
+ mkdirSync6(CURSOR_CREDS_DIR, { recursive: true });
5749
+ chmodSync(CURSOR_CREDS_DIR, 448);
5750
+ writeFileSync6(CURSOR_API_KEY_FILE, trimmed, "utf-8");
5751
+ chmodSync(CURSOR_API_KEY_FILE, 384);
5752
+ }
5753
+ function credsAreStale() {
5754
+ if (!existsSync7(CLAUDE_CREDS_FILE)) return true;
5755
+ try {
5756
+ const ageMs = Date.now() - statSync(CLAUDE_CREDS_FILE).mtimeMs;
5757
+ return ageMs > REFRESH_INTERVAL_SECONDS * 1e3;
5758
+ } catch {
5759
+ return true;
5760
+ }
5761
+ }
5452
5762
  function writeRefreshAgent(synkroBinPath) {
5453
5763
  if (platform3() !== "darwin") {
5454
5764
  throw new KeychainExportError("writeRefreshAgent is darwin-only");
@@ -5510,13 +5820,26 @@ function uninstallRefreshAgent() {
5510
5820
  } catch {
5511
5821
  }
5512
5822
  }
5513
- var SYNKRO_DIR2, CLAUDE_CREDS_DIR, CLAUDE_CREDS_FILE, KEYCHAIN_SERVICE, LAUNCHD_LABEL, LAUNCHD_PLIST, REFRESH_INTERVAL_SECONDS, KeychainExportError;
5823
+ function refreshCreds() {
5824
+ const path = exportKeychainCreds();
5825
+ return path !== null;
5826
+ }
5827
+ function readExportedCreds() {
5828
+ try {
5829
+ return readFileSync6(CLAUDE_CREDS_FILE, "utf-8");
5830
+ } catch {
5831
+ return null;
5832
+ }
5833
+ }
5834
+ var SYNKRO_DIR2, CLAUDE_CREDS_DIR, CLAUDE_CREDS_FILE, CURSOR_CREDS_DIR, CURSOR_API_KEY_FILE, KEYCHAIN_SERVICE, LAUNCHD_LABEL, LAUNCHD_PLIST, REFRESH_INTERVAL_SECONDS, KeychainExportError;
5514
5835
  var init_macKeychain = __esm({
5515
5836
  "cli/local-cc/macKeychain.ts"() {
5516
5837
  "use strict";
5517
5838
  SYNKRO_DIR2 = join6(homedir6(), ".synkro");
5518
5839
  CLAUDE_CREDS_DIR = join6(SYNKRO_DIR2, "claude-creds");
5519
5840
  CLAUDE_CREDS_FILE = join6(CLAUDE_CREDS_DIR, ".credentials.json");
5841
+ CURSOR_CREDS_DIR = join6(SYNKRO_DIR2, "cursor-creds");
5842
+ CURSOR_API_KEY_FILE = join6(CURSOR_CREDS_DIR, "api-key");
5520
5843
  KEYCHAIN_SERVICE = "Claude Code-credentials";
5521
5844
  LAUNCHD_LABEL = "com.synkro.cli.claude-creds-refresh";
5522
5845
  LAUNCHD_PLIST = join6(homedir6(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
@@ -5547,12 +5870,69 @@ __export(dockerInstall_exports, {
5547
5870
  dockerStop: () => dockerStop,
5548
5871
  dockerUpdate: () => dockerUpdate,
5549
5872
  imageTag: () => imageTag,
5873
+ resolveWorkerConfig: () => resolveWorkerConfig,
5874
+ splitWorkers: () => splitWorkers,
5550
5875
  waitForContainerReady: () => waitForContainerReady
5551
5876
  });
5552
5877
  import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync7, readdirSync } from "fs";
5553
5878
  import { homedir as homedir7 } from "os";
5554
5879
  import { join as join7 } from "path";
5555
5880
  import { spawnSync as spawnSync2 } from "child_process";
5881
+ function splitWorkers(total, providers) {
5882
+ const t = Math.max(0, Math.floor(total));
5883
+ const hasClaude = providers.includes("claude_code");
5884
+ const hasCursor = providers.includes("cursor");
5885
+ if (hasClaude && hasCursor) {
5886
+ const cursorWorkers = Math.floor(t / 2);
5887
+ return { claudeWorkers: t - cursorWorkers, cursorWorkers };
5888
+ }
5889
+ if (hasCursor) return { claudeWorkers: 0, cursorWorkers: t };
5890
+ return { claudeWorkers: t, cursorWorkers: 0 };
5891
+ }
5892
+ function normalizeProvider(p) {
5893
+ const v = p.trim().toLowerCase();
5894
+ if (v === "claude" || v === "claude-code" || v === "claude_code" || v === "cc") return "claude_code";
5895
+ if (v === "cursor") return "cursor";
5896
+ return null;
5897
+ }
5898
+ function resolveWorkerConfig(rest) {
5899
+ let workers = 8;
5900
+ let explicit = false;
5901
+ const providers = [];
5902
+ const addProviders = (csv) => {
5903
+ for (const p of csv.split(",")) {
5904
+ const np = normalizeProvider(p);
5905
+ if (np && !providers.includes(np)) providers.push(np);
5906
+ }
5907
+ };
5908
+ for (let i = 0; i < rest.length; i++) {
5909
+ const a = rest[i];
5910
+ if (a === "--workers" || a === "-w") {
5911
+ workers = parseInt(rest[++i] || "8", 10);
5912
+ explicit = true;
5913
+ } else if (a.startsWith("--workers=")) {
5914
+ workers = parseInt(a.slice("--workers=".length), 10);
5915
+ explicit = true;
5916
+ } else if (a === "--provider" || a === "--providers") {
5917
+ addProviders(rest[++i] || "");
5918
+ explicit = true;
5919
+ } else if (a.startsWith("--provider=")) {
5920
+ addProviders(a.slice("--provider=".length));
5921
+ explicit = true;
5922
+ } else if (a.startsWith("--providers=")) {
5923
+ addProviders(a.slice("--providers=".length));
5924
+ explicit = true;
5925
+ }
5926
+ }
5927
+ if (!Number.isFinite(workers) || workers < 1) workers = 8;
5928
+ workers = Math.min(workers, 64);
5929
+ let provs = providers;
5930
+ if (provs.length === 0) {
5931
+ provs = detectAgents().map((a) => a.kind);
5932
+ if (provs.length === 0) provs = ["claude_code"];
5933
+ }
5934
+ return { ...splitWorkers(workers, provs), explicit };
5935
+ }
5556
5936
  function imageTag() {
5557
5937
  const registry = process.env.SYNKRO_IMAGE_REGISTRY || "";
5558
5938
  const tag = process.env.SYNKRO_IMAGE_TAG || DEFAULT_IMAGE;
@@ -5576,7 +5956,9 @@ function claudeCredsHostDir() {
5576
5956
  async function dockerInstall(opts = {}) {
5577
5957
  assertDockerAvailable();
5578
5958
  const image = imageTag();
5579
- const workers = String(opts.workersPerPool ?? 8);
5959
+ const claudeWorkers = opts.claudeWorkers ?? opts.workersPerPool ?? 8;
5960
+ const cursorWorkers = opts.cursorWorkers ?? 0;
5961
+ const totalWorkers = claudeWorkers + cursorWorkers;
5580
5962
  mkdirSync7(PGDATA_PATH, { recursive: true });
5581
5963
  mkdirSync7(BACKUP_DIR, { recursive: true });
5582
5964
  mkdirSync7(CLAUDE_HOST_STATE_DIR, { recursive: true });
@@ -5589,12 +5971,20 @@ async function dockerInstall(opts = {}) {
5589
5971
  `MCP JWT missing at ${MCP_JWT_PATH}. The installer should mint this before calling dockerInstall.`
5590
5972
  );
5591
5973
  }
5974
+ mkdirSync7(CURSOR_CREDS_DIR, { recursive: true });
5592
5975
  if (needsKeychainBridge()) {
5593
- const path = exportKeychainCreds();
5594
- if (!path) {
5595
- throw new DockerInstallError(
5596
- "Claude Code keychain entry not found. Run `claude login` (or open Claude Code and sign in) before installing the container."
5597
- );
5976
+ if (claudeWorkers > 0) {
5977
+ const path = exportKeychainCreds();
5978
+ if (!path) {
5979
+ throw new DockerInstallError(
5980
+ "Claude Code keychain entry not found. Run `claude login` (or open Claude Code and sign in) before installing the container."
5981
+ );
5982
+ }
5983
+ }
5984
+ if (cursorWorkers > 0 && !cursorApiKeyConfigured()) {
5985
+ console.warn(" \u26A0 No Cursor API key found \u2014 Cursor grader workers will be idle.");
5986
+ console.warn(" Generate a key at cursor.com \u2192 Settings \u2192 API Keys, then:");
5987
+ console.warn(` echo 'YOUR_KEY' > ~/.synkro/cursor-creds/api-key && chmod 600 ~/.synkro/cursor-creds/api-key`);
5598
5988
  }
5599
5989
  const plist = writeRefreshAgent("/usr/local/bin/synkro");
5600
5990
  try {
@@ -5637,21 +6027,34 @@ async function dockerInstall(opts = {}) {
5637
6027
  `${PGDATA_PATH}:/data/pgdata`,
5638
6028
  "-v",
5639
6029
  `${BACKUP_DIR}:/data/backups`,
6030
+ // The whole host ~/.synkro directory, read-only. The container copies
6031
+ // .mcp-jwt and credentials.json out of it at boot. A directory mount
6032
+ // sidesteps Docker Desktop for macOS's unreliable single-file bind mounts
6033
+ // (which previously left a dangling symlink that blocked container start).
5640
6034
  "-v",
5641
- `${MCP_JWT_PATH}:/data/.mcp-jwt:ro`,
5642
- "-v",
5643
- `${SYNKRO_CREDS_PATH}:/data/credentials.json:ro`,
6035
+ `${SYNKRO_DIR3}:/data/synkro-host:ro`,
5644
6036
  "-v",
5645
6037
  `${credsDir}:/home/synkro/.claude:rw`,
5646
6038
  "-v",
5647
6039
  `${join7(homedir7(), ".claude")}:/data/claude-host:ro`,
5648
6040
  "-v",
5649
6041
  `${CLAUDE_HOST_STATE_DIR}:/data/claude-host-state:ro`,
6042
+ // Cursor creds — mounted RW so the in-container refresher can rotate the
6043
+ // access token in place. Only mounted when the install includes Cursor.
6044
+ ...cursorWorkers > 0 ? ["-v", `${CURSOR_CREDS_DIR}:/home/synkro/.cursor-creds:rw`] : [],
6045
+ "-e",
6046
+ `WORKERS_PER_POOL=${totalWorkers}`,
5650
6047
  "-e",
5651
- `WORKERS_PER_POOL=${workers}`,
6048
+ `CLAUDE_WORKERS=${claudeWorkers}`,
6049
+ "-e",
6050
+ `CURSOR_WORKERS=${cursorWorkers}`,
5652
6051
  // Pass through the batch-size lever if the operator set it. Defaults
5653
6052
  // inside the container to 5; clamped to [1, 20] by synkro-server.ts.
5654
6053
  ...process.env.SYNKRO_MAX_BATCH_SIZE ? ["-e", `SYNKRO_MAX_BATCH_SIZE=${process.env.SYNKRO_MAX_BATCH_SIZE}`] : [],
6054
+ // Cursor grading model — tunable like SYNKRO_MAX_BATCH_SIZE.
6055
+ ...process.env.SYNKRO_CURSOR_MODEL ? ["-e", `SYNKRO_CURSOR_MODEL=${process.env.SYNKRO_CURSOR_MODEL}`] : [],
6056
+ // Connected repo — the server seeds the local app + ruleset named after it.
6057
+ ...opts.connectedRepo ? ["-e", `SYNKRO_CONNECTED_REPO=${opts.connectedRepo}`] : [],
5655
6058
  image
5656
6059
  ];
5657
6060
  const run = spawnSync2("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
@@ -5680,12 +6083,12 @@ function dockerStop() {
5680
6083
  spawnSync2("docker", ["stop", "--timeout=30", CONTAINER_NAME], { encoding: "utf-8", timeout: 45e3 });
5681
6084
  spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5682
6085
  }
5683
- async function dockerUpdate(workersPerPool) {
6086
+ async function dockerUpdate(opts = {}) {
5684
6087
  if (dockerStatus().running) {
5685
6088
  await dockerSafeStop();
5686
6089
  }
5687
6090
  dockerRemove();
5688
- await dockerInstall({ workersPerPool });
6091
+ await dockerInstall(opts);
5689
6092
  }
5690
6093
  function dockerStatus() {
5691
6094
  const r = spawnSync2("docker", ["inspect", "--format", "{{.State.Status}}", CONTAINER_NAME], {
@@ -5814,14 +6217,14 @@ function checkPgdata() {
5814
6217
  if (!hasPgControl) return { healthy: false, details: "pg_control/global directory missing" };
5815
6218
  return { healthy: true, details: `${entries.length} entries, WAL present, no stale PID` };
5816
6219
  }
5817
- var SYNKRO_DIR3, MCP_JWT_PATH, SYNKRO_CREDS_PATH, PGDATA_PATH, CLAUDE_HOST_STATE_DIR, CLAUDE_HOST_STATE_FILE, HOST_MCP_PORT, HOST_GRADER_PORT, HOST_CWE_PORT, HOST_PG_PORT, CONTAINER_NAME, DEFAULT_IMAGE, DockerInstallError, BACKUP_DIR;
6220
+ var SYNKRO_DIR3, MCP_JWT_PATH, PGDATA_PATH, CLAUDE_HOST_STATE_DIR, CLAUDE_HOST_STATE_FILE, HOST_MCP_PORT, HOST_GRADER_PORT, HOST_CWE_PORT, HOST_PG_PORT, CONTAINER_NAME, DEFAULT_IMAGE, DockerInstallError, BACKUP_DIR;
5818
6221
  var init_dockerInstall = __esm({
5819
6222
  "cli/local-cc/dockerInstall.ts"() {
5820
6223
  "use strict";
6224
+ init_agentDetect();
5821
6225
  init_macKeychain();
5822
6226
  SYNKRO_DIR3 = join7(homedir7(), ".synkro");
5823
6227
  MCP_JWT_PATH = join7(SYNKRO_DIR3, ".mcp-jwt");
5824
- SYNKRO_CREDS_PATH = join7(SYNKRO_DIR3, "credentials.json");
5825
6228
  PGDATA_PATH = join7(SYNKRO_DIR3, "pgdata");
5826
6229
  CLAUDE_HOST_STATE_DIR = join7(SYNKRO_DIR3, "claude-host-state");
5827
6230
  CLAUDE_HOST_STATE_FILE = join7(CLAUDE_HOST_STATE_DIR, ".claude.json");
@@ -5863,6 +6266,7 @@ function parseArgs(argv) {
5863
6266
  for (const a of argv) {
5864
6267
  if (a.startsWith("--api-key=")) opts.apiKey = a.slice("--api-key=".length);
5865
6268
  else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
6269
+ else if (a.startsWith("--cursor-api-key=")) opts.cursorApiKey = a.slice("--cursor-api-key=".length);
5866
6270
  else if (a === "--skip-auth") opts.skipAuth = true;
5867
6271
  else if (a === "--no-mcp") opts.noMcp = true;
5868
6272
  else if (a === "--force" || a === "-f") opts.force = true;
@@ -5898,6 +6302,32 @@ async function promptAgentSelection(detected) {
5898
6302
  });
5899
6303
  return ask2();
5900
6304
  }
6305
+ async function promptCursorApiKey(opts) {
6306
+ const { cursorApiKeyConfigured: cursorApiKeyConfigured2, writeCursorApiKey: writeCursorApiKey2 } = await Promise.resolve().then(() => (init_macKeychain(), macKeychain_exports));
6307
+ if (cursorApiKeyConfigured2()) return;
6308
+ const provided = (opts.cursorApiKey || process.env.SYNKRO_CURSOR_API_KEY || "").trim();
6309
+ if (provided) {
6310
+ writeCursorApiKey2(provided);
6311
+ console.log(" \u2713 Cursor API key saved to ~/.synkro/cursor-creds/api-key");
6312
+ return;
6313
+ }
6314
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
6315
+ const key = await new Promise((resolve3) => {
6316
+ rl.question(
6317
+ "Cursor grading needs a Cursor API key (cursor.com \u2192 Settings \u2192 API Keys).\nPaste it now, or press Enter to skip (Cursor workers stay idle until set): ",
6318
+ (answer) => {
6319
+ rl.close();
6320
+ resolve3(answer.trim());
6321
+ }
6322
+ );
6323
+ });
6324
+ if (key) {
6325
+ writeCursorApiKey2(key);
6326
+ console.log(" \u2713 Cursor API key saved.");
6327
+ } else {
6328
+ console.log(" \u26A0 Skipped \u2014 Cursor workers will be idle. Re-run install or pass --cursor-api-key=\u2026 later.");
6329
+ }
6330
+ }
5901
6331
  function ensureSynkroDir() {
5902
6332
  mkdirSync8(SYNKRO_DIR4, { recursive: true });
5903
6333
  mkdirSync8(HOOKS_DIR, { recursive: true });
@@ -5999,7 +6429,7 @@ function writeConfigEnv(opts) {
5999
6429
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
6000
6430
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
6001
6431
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
6002
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.3")}`
6432
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.4")}`
6003
6433
  ];
6004
6434
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
6005
6435
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -6405,9 +6835,18 @@ async function installCommand(opts = {}) {
6405
6835
  \u2717 ${err.message}`);
6406
6836
  process.exit(1);
6407
6837
  }
6838
+ if (hasCursor) {
6839
+ await promptCursorApiKey(opts);
6840
+ }
6408
6841
  console.log("Installing Synkro server container...");
6409
- const workersPerPool = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "8", 10);
6410
- const { image, hostMcpPort, hostGraderPort, hostCwePort } = await dockerInstall({ workersPerPool });
6842
+ const totalWorkers = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "8", 10);
6843
+ const providers = [];
6844
+ if (hasClaudeCode) providers.push("claude_code");
6845
+ if (hasCursor) providers.push("cursor");
6846
+ const { claudeWorkers, cursorWorkers } = splitWorkers(totalWorkers, providers);
6847
+ console.log(` worker pool: ${claudeWorkers} claude + ${cursorWorkers} cursor`);
6848
+ const connectedRepo = detectGitRepo2() || void 0;
6849
+ const { image, hostMcpPort, hostGraderPort, hostCwePort } = await dockerInstall({ claudeWorkers, cursorWorkers, connectedRepo });
6411
6850
  console.log(` \u2713 pulled ${image}`);
6412
6851
  console.log(` container started \u2014 MCP=${hostMcpPort} general=${hostGraderPort} CWE=${hostCwePort}`);
6413
6852
  console.log(" waiting for container to be ready...");
@@ -7397,8 +7836,21 @@ async function stopCommand() {
7397
7836
  }
7398
7837
  console.log("\nServer stopped.");
7399
7838
  }
7400
- async function startCommand() {
7839
+ async function startCommand(rest = []) {
7401
7840
  assertDockerAvailable();
7841
+ const cfg = resolveWorkerConfig(rest);
7842
+ if (cfg.explicit) {
7843
+ console.log(`Synkro: starting server (${cfg.claudeWorkers} claude + ${cfg.cursorWorkers} cursor)
7844
+ `);
7845
+ await dockerUpdate({ claudeWorkers: cfg.claudeWorkers, cursorWorkers: cfg.cursorWorkers });
7846
+ const ready = await waitForContainerReady(6e4);
7847
+ if (!ready) {
7848
+ console.error("\n\u26A0 container did not pass /healthz within 60s");
7849
+ process.exit(1);
7850
+ }
7851
+ console.log("\nServer is running.");
7852
+ return;
7853
+ }
7402
7854
  console.log("Synkro: starting server\n");
7403
7855
  const result = await dockerSafeStart();
7404
7856
  if (!result.ok) {
@@ -7408,8 +7860,21 @@ Start failed: ${result.error}`);
7408
7860
  }
7409
7861
  console.log("\nServer is running.");
7410
7862
  }
7411
- async function restartCommand() {
7863
+ async function restartCommand(rest = []) {
7412
7864
  assertDockerAvailable();
7865
+ const cfg = resolveWorkerConfig(rest);
7866
+ if (cfg.explicit) {
7867
+ console.log(`Synkro: restarting server (${cfg.claudeWorkers} claude + ${cfg.cursorWorkers} cursor)
7868
+ `);
7869
+ await dockerUpdate({ claudeWorkers: cfg.claudeWorkers, cursorWorkers: cfg.cursorWorkers });
7870
+ const ready = await waitForContainerReady(6e4);
7871
+ if (!ready) {
7872
+ console.error("\n\u26A0 container did not pass /healthz within 60s");
7873
+ process.exit(1);
7874
+ }
7875
+ console.log("\nServer restarted successfully.");
7876
+ return;
7877
+ }
7413
7878
  console.log("Synkro: restarting server\n");
7414
7879
  const result = await dockerSafeRestart();
7415
7880
  if (!result.ok) {
@@ -7451,7 +7916,7 @@ var args = process.argv.slice(2);
7451
7916
  var cmd = args[0] || "";
7452
7917
  var subArgs = args.slice(1);
7453
7918
  function printVersion() {
7454
- console.log("1.6.3");
7919
+ console.log("1.6.4");
7455
7920
  }
7456
7921
  function printHelp() {
7457
7922
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -7463,10 +7928,15 @@ Commands:
7463
7928
  install [--force] Install or update Synkro
7464
7929
  uninstall [--purge] Remove Synkro hooks (--purge also removes ~/.synkro)
7465
7930
  stop Gracefully stop the server (snapshot + checkpoint)
7466
- start Start the server (with pgdata integrity check)
7467
- restart Safe restart (stop \u2192 start, data preserved)
7931
+ start [opts] Start the server (with pgdata integrity check)
7932
+ restart [opts] Safe restart (stop \u2192 start, data preserved)
7468
7933
  version Show version
7469
7934
 
7935
+ start/restart opts (recreate the worker pool):
7936
+ --workers N total grader workers (default 8, even-split)
7937
+ --providers a,b grading agents: claude, cursor (or both)
7938
+ e.g. synkro restart --workers 16 --providers claude,cursor
7939
+
7470
7940
  Quick start:
7471
7941
  $ synkro install # one-time setup
7472
7942
  $ claude # use Claude Code normally; Synkro judges in real time
@@ -7510,12 +7980,12 @@ async function main() {
7510
7980
  }
7511
7981
  case "start": {
7512
7982
  const { startCommand: startCommand2 } = await Promise.resolve().then(() => (init_lifecycle(), lifecycle_exports));
7513
- await startCommand2();
7983
+ await startCommand2(args.slice(1));
7514
7984
  break;
7515
7985
  }
7516
7986
  case "restart": {
7517
7987
  const { restartCommand: restartCommand2 } = await Promise.resolve().then(() => (init_lifecycle(), lifecycle_exports));
7518
- await restartCommand2();
7988
+ await restartCommand2(args.slice(1));
7519
7989
  break;
7520
7990
  }
7521
7991
  default: {