@synkro-sh/cli 1.6.2 → 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.',
@@ -3122,9 +3343,7 @@ async function main() {
3122
3343
  recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3123
3344
  });
3124
3345
  } else {
3125
- const fixRuleIds = (config.rules || []).filter((r: any) => r.mode === 'fix' || r.mode === 'audit').map((r: any) => r.rule_id || r.id).filter(Boolean);
3126
- const fixNote = fixRuleIds.length > 0 ? ' (fix rules checked: ' + fixRuleIds.join(', ') + ')' : '';
3127
- const reason = tagStr + ' bashGuard → pass: ' + (verdict.reason || 'no policy violations detected') + fixNote;
3346
+ const reason = tagStr + ' bashGuard pass: ' + (verdict.reason || 'no policy violations detected');
3128
3347
  const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
3129
3348
  outputJson({ systemMessage: combined, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: combined } });
3130
3349
  dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'trivial_utility',
@@ -3150,6 +3369,7 @@ async function main() {
3150
3369
  recent_user_messages: transcript.recentUserMessages,
3151
3370
  recent_messages: transcript.recentMessages,
3152
3371
  recent_actions: transcript.recentActions,
3372
+ session_history: compressSessionLog(readSessionLog(sessionId)),
3153
3373
  session_id: sessionId || null,
3154
3374
  tool_use_id: toolUseId || null,
3155
3375
  cwd: cwd || null,
@@ -3204,7 +3424,7 @@ import {
3204
3424
  parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
3205
3425
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
3206
3426
  outputJson, outputEmpty, setupCursorHookSignals, isAgentTool, hookSessionId, GATEWAY_URL,
3207
- logGraderUnavailable,
3427
+ logGraderUnavailable, filterRules, normalizeMode,
3208
3428
  type HookConfig, type Rule,
3209
3429
  } from './_synkro-common.ts';
3210
3430
 
@@ -3258,6 +3478,8 @@ async function main() {
3258
3478
 
3259
3479
  if (rt === 'local') {
3260
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);
3261
3483
  const graderPrompt = [
3262
3484
  'Working directory: ' + (cwd || '.'),
3263
3485
  'Repo: ' + (gitRepo || 'unknown'),
@@ -3269,7 +3491,7 @@ async function main() {
3269
3491
  prompt.slice(0, 4000),
3270
3492
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
3271
3493
  'Last user prompt: ' + (lastPrompt || 'none'),
3272
- 'Org rules: ' + JSON.stringify(config.rules),
3494
+ 'Org rules: ' + JSON.stringify(relevantRules),
3273
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.',
3274
3496
  ].filter(Boolean).join('\\n');
3275
3497
 
@@ -3328,6 +3550,7 @@ async function main() {
3328
3550
  recent_user_messages: transcript.recentUserMessages,
3329
3551
  recent_messages: transcript.recentMessages,
3330
3552
  recent_actions: transcript.recentActions,
3553
+ session_history: compressSessionLog(readSessionLog(sessionId)),
3331
3554
  session_id: sessionId || null,
3332
3555
  tool_use_id: toolUseId || null,
3333
3556
  cwd: cwd || null,
@@ -3367,6 +3590,7 @@ import {
3367
3590
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
3368
3591
  parseVerdict, dispatchCapture, appendSessionAction, readSessionLog, compressSessionLog, postWithRetry, readStdin, log,
3369
3592
  outputJson, outputEmpty, setupCursorHookSignals, isPlanTool, hookSessionId, GATEWAY_URL,
3593
+ filterRules,
3370
3594
  } from './_synkro-common.ts';
3371
3595
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
3372
3596
  import { join } from 'node:path';
@@ -3451,13 +3675,14 @@ async function main() {
3451
3675
 
3452
3676
  if (rt === 'local') {
3453
3677
  const sessionLog = compressSessionLog(readSessionLog(sessionId));
3678
+ const relevantRules = await filterRules(plan.slice(0, 2000), config.rules);
3454
3679
  const graderPrompt = [
3455
3680
  'Working directory: ' + (cwd || '.'),
3456
3681
  'Repo: ' + (gitRepo || 'unknown'),
3457
3682
  sessionLog,
3458
3683
  'Plan:',
3459
3684
  plan.slice(0, 8000),
3460
- 'Org rules: ' + JSON.stringify(config.rules),
3685
+ 'Org rules: ' + JSON.stringify(relevantRules),
3461
3686
  ].filter(Boolean).join('\\n');
3462
3687
 
3463
3688
  let gradeResp: string;
@@ -3964,8 +4189,10 @@ main();
3964
4189
  CURSOR_BASH_JUDGE_TS = `#!/usr/bin/env bun
3965
4190
  import {
3966
4191
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
3967
- parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
3968
- 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,
3969
4196
  type Rule,
3970
4197
  } from './_synkro-common.ts';
3971
4198
  import { createHash } from 'node:crypto';
@@ -3991,8 +4218,8 @@ function isDuplicate(command: string, sessionId: string): boolean {
3991
4218
  }
3992
4219
 
3993
4220
  // Cursor beforeShellExecution timeout is 15s; stay under it (JWT refresh + grade).
3994
- const CURSOR_GRADE_TIMEOUT_MS = 7500;
3995
- const CURSOR_CLOUD_TIMEOUT_MS = 6000;
4221
+ const CURSOR_GRADE_TIMEOUT_MS = 12000;
4222
+ const CURSOR_CLOUD_TIMEOUT_MS = 9000;
3996
4223
 
3997
4224
  let hookDone = false;
3998
4225
 
@@ -4062,8 +4289,6 @@ async function main() {
4062
4289
  finishAllow();
4063
4290
  }
4064
4291
 
4065
- appendSessionAction(sessionId, { ts: new Date().toISOString(), tool: toolName, summary: command.slice(0, 120) });
4066
-
4067
4292
  const transcriptPath = typeof payload.transcript_path === 'string' ? payload.transcript_path : '';
4068
4293
  const rawModel = String(payload.model ?? payload.model_id ?? '');
4069
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']);
@@ -4073,6 +4298,18 @@ async function main() {
4073
4298
  const cmdShort = command.slice(0, 80);
4074
4299
  log('bashGuard checking: ' + cmdShort);
4075
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
+
4076
4313
  let jwt = loadJwt();
4077
4314
  if (!jwt) finishAllow();
4078
4315
  jwt = await ensureFreshJwt(jwt);
@@ -4086,28 +4323,55 @@ async function main() {
4086
4323
  const rt = await route(config);
4087
4324
  const tagStr = tag(rt, config);
4088
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
+
4089
4353
  if (rt === 'local') {
4090
4354
  const sessionLog = compressSessionLog(readSessionLog(sessionId));
4091
- const rulesBlock = config.rules.map((r: Rule, i: number) =>
4092
- (i + 1) + '. [' + r.rule_id + '] (' + r.severity + '/' + r.mode + ') ' + r.text
4093
- ).join('\\n');
4355
+ const relevantRules = await filterRules(command, config.rules);
4094
4356
 
4095
4357
  const graderPrompt = [
4096
- 'RULES:',
4097
- rulesBlock || '(none)',
4098
- '',
4358
+ 'Working directory: ' + (cwd || '.'),
4359
+ 'Repo: ' + (repo || 'unknown'),
4099
4360
  sessionLog,
4100
- 'COMMAND TO EVALUATE:',
4101
- command,
4102
- '',
4361
+ 'Command: ' + command,
4103
4362
  'User intent (last human message): ' + (transcript.userIntent || lastPrompt || 'none stated'),
4104
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.',
4105
4368
  ].filter(Boolean).join('\\n');
4106
4369
 
4107
4370
  let gradeResp: string;
4108
4371
  try {
4109
- gradeResp = await localGrade('bash', graderPrompt, CURSOR_GRADE_TIMEOUT_MS);
4372
+ gradeResp = await localGrade('bash', graderPrompt, CURSOR_GRADE_TIMEOUT_MS, 'cursor');
4110
4373
  } catch (e) {
4374
+ logGraderUnavailable('bashGuard', command.slice(0, 200), (e as Error).message || String(e));
4111
4375
  log('bashGuard ' + cmdShort + ' \u2192 pass (grade unavailable): ' + String(e));
4112
4376
  finishWith({ permission: 'allow' });
4113
4377
  }
@@ -4122,7 +4386,7 @@ async function main() {
4122
4386
  ? 'Synkro safety judge. Fix this before retrying \u2014 do not ask the user. Reasoning: ' + (verdict.reason || guardReason)
4123
4387
  : 'Synkro safety judge. Ask the user for explicit consent before retrying. Reasoning: ' + (verdict.reason || guardReason);
4124
4388
  dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
4125
- 'Bash', gitRepo, sessionId, config.captureDepth, {
4389
+ 'Bash', repo, sessionId, config.captureDepth, {
4126
4390
  command, reasoning: guardReason,
4127
4391
  rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
4128
4392
  ccModel: model,
@@ -5425,6 +5689,26 @@ var init_promptFetcher = __esm({
5425
5689
  });
5426
5690
 
5427
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
+ });
5428
5712
  import { existsSync as existsSync7, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, chmodSync, readFileSync as readFileSync6, statSync } from "fs";
5429
5713
  import { homedir as homedir6, platform as platform3 } from "os";
5430
5714
  import { join as join6 } from "path";
@@ -5451,6 +5735,30 @@ function exportKeychainCreds() {
5451
5735
  chmodSync(CLAUDE_CREDS_FILE, 384);
5452
5736
  return CLAUDE_CREDS_FILE;
5453
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
+ }
5454
5762
  function writeRefreshAgent(synkroBinPath) {
5455
5763
  if (platform3() !== "darwin") {
5456
5764
  throw new KeychainExportError("writeRefreshAgent is darwin-only");
@@ -5512,13 +5820,26 @@ function uninstallRefreshAgent() {
5512
5820
  } catch {
5513
5821
  }
5514
5822
  }
5515
- 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;
5516
5835
  var init_macKeychain = __esm({
5517
5836
  "cli/local-cc/macKeychain.ts"() {
5518
5837
  "use strict";
5519
5838
  SYNKRO_DIR2 = join6(homedir6(), ".synkro");
5520
5839
  CLAUDE_CREDS_DIR = join6(SYNKRO_DIR2, "claude-creds");
5521
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");
5522
5843
  KEYCHAIN_SERVICE = "Claude Code-credentials";
5523
5844
  LAUNCHD_LABEL = "com.synkro.cli.claude-creds-refresh";
5524
5845
  LAUNCHD_PLIST = join6(homedir6(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
@@ -5549,12 +5870,69 @@ __export(dockerInstall_exports, {
5549
5870
  dockerStop: () => dockerStop,
5550
5871
  dockerUpdate: () => dockerUpdate,
5551
5872
  imageTag: () => imageTag,
5873
+ resolveWorkerConfig: () => resolveWorkerConfig,
5874
+ splitWorkers: () => splitWorkers,
5552
5875
  waitForContainerReady: () => waitForContainerReady
5553
5876
  });
5554
5877
  import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync7, readdirSync } from "fs";
5555
5878
  import { homedir as homedir7 } from "os";
5556
5879
  import { join as join7 } from "path";
5557
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
+ }
5558
5936
  function imageTag() {
5559
5937
  const registry = process.env.SYNKRO_IMAGE_REGISTRY || "";
5560
5938
  const tag = process.env.SYNKRO_IMAGE_TAG || DEFAULT_IMAGE;
@@ -5578,7 +5956,9 @@ function claudeCredsHostDir() {
5578
5956
  async function dockerInstall(opts = {}) {
5579
5957
  assertDockerAvailable();
5580
5958
  const image = imageTag();
5581
- 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;
5582
5962
  mkdirSync7(PGDATA_PATH, { recursive: true });
5583
5963
  mkdirSync7(BACKUP_DIR, { recursive: true });
5584
5964
  mkdirSync7(CLAUDE_HOST_STATE_DIR, { recursive: true });
@@ -5591,12 +5971,20 @@ async function dockerInstall(opts = {}) {
5591
5971
  `MCP JWT missing at ${MCP_JWT_PATH}. The installer should mint this before calling dockerInstall.`
5592
5972
  );
5593
5973
  }
5974
+ mkdirSync7(CURSOR_CREDS_DIR, { recursive: true });
5594
5975
  if (needsKeychainBridge()) {
5595
- const path = exportKeychainCreds();
5596
- if (!path) {
5597
- throw new DockerInstallError(
5598
- "Claude Code keychain entry not found. Run `claude login` (or open Claude Code and sign in) before installing the container."
5599
- );
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`);
5600
5988
  }
5601
5989
  const plist = writeRefreshAgent("/usr/local/bin/synkro");
5602
5990
  try {
@@ -5639,21 +6027,34 @@ async function dockerInstall(opts = {}) {
5639
6027
  `${PGDATA_PATH}:/data/pgdata`,
5640
6028
  "-v",
5641
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).
5642
6034
  "-v",
5643
- `${MCP_JWT_PATH}:/data/.mcp-jwt:ro`,
5644
- "-v",
5645
- `${SYNKRO_CREDS_PATH}:/data/credentials.json:ro`,
6035
+ `${SYNKRO_DIR3}:/data/synkro-host:ro`,
5646
6036
  "-v",
5647
6037
  `${credsDir}:/home/synkro/.claude:rw`,
5648
6038
  "-v",
5649
6039
  `${join7(homedir7(), ".claude")}:/data/claude-host:ro`,
5650
6040
  "-v",
5651
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}`,
5652
6047
  "-e",
5653
- `WORKERS_PER_POOL=${workers}`,
6048
+ `CLAUDE_WORKERS=${claudeWorkers}`,
6049
+ "-e",
6050
+ `CURSOR_WORKERS=${cursorWorkers}`,
5654
6051
  // Pass through the batch-size lever if the operator set it. Defaults
5655
6052
  // inside the container to 5; clamped to [1, 20] by synkro-server.ts.
5656
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}`] : [],
5657
6058
  image
5658
6059
  ];
5659
6060
  const run = spawnSync2("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
@@ -5682,12 +6083,12 @@ function dockerStop() {
5682
6083
  spawnSync2("docker", ["stop", "--timeout=30", CONTAINER_NAME], { encoding: "utf-8", timeout: 45e3 });
5683
6084
  spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5684
6085
  }
5685
- async function dockerUpdate(workersPerPool) {
6086
+ async function dockerUpdate(opts = {}) {
5686
6087
  if (dockerStatus().running) {
5687
6088
  await dockerSafeStop();
5688
6089
  }
5689
6090
  dockerRemove();
5690
- await dockerInstall({ workersPerPool });
6091
+ await dockerInstall(opts);
5691
6092
  }
5692
6093
  function dockerStatus() {
5693
6094
  const r = spawnSync2("docker", ["inspect", "--format", "{{.State.Status}}", CONTAINER_NAME], {
@@ -5816,14 +6217,14 @@ function checkPgdata() {
5816
6217
  if (!hasPgControl) return { healthy: false, details: "pg_control/global directory missing" };
5817
6218
  return { healthy: true, details: `${entries.length} entries, WAL present, no stale PID` };
5818
6219
  }
5819
- 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;
5820
6221
  var init_dockerInstall = __esm({
5821
6222
  "cli/local-cc/dockerInstall.ts"() {
5822
6223
  "use strict";
6224
+ init_agentDetect();
5823
6225
  init_macKeychain();
5824
6226
  SYNKRO_DIR3 = join7(homedir7(), ".synkro");
5825
6227
  MCP_JWT_PATH = join7(SYNKRO_DIR3, ".mcp-jwt");
5826
- SYNKRO_CREDS_PATH = join7(SYNKRO_DIR3, "credentials.json");
5827
6228
  PGDATA_PATH = join7(SYNKRO_DIR3, "pgdata");
5828
6229
  CLAUDE_HOST_STATE_DIR = join7(SYNKRO_DIR3, "claude-host-state");
5829
6230
  CLAUDE_HOST_STATE_FILE = join7(CLAUDE_HOST_STATE_DIR, ".claude.json");
@@ -5865,6 +6266,7 @@ function parseArgs(argv) {
5865
6266
  for (const a of argv) {
5866
6267
  if (a.startsWith("--api-key=")) opts.apiKey = a.slice("--api-key=".length);
5867
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);
5868
6270
  else if (a === "--skip-auth") opts.skipAuth = true;
5869
6271
  else if (a === "--no-mcp") opts.noMcp = true;
5870
6272
  else if (a === "--force" || a === "-f") opts.force = true;
@@ -5900,6 +6302,32 @@ async function promptAgentSelection(detected) {
5900
6302
  });
5901
6303
  return ask2();
5902
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
+ }
5903
6331
  function ensureSynkroDir() {
5904
6332
  mkdirSync8(SYNKRO_DIR4, { recursive: true });
5905
6333
  mkdirSync8(HOOKS_DIR, { recursive: true });
@@ -6001,7 +6429,7 @@ function writeConfigEnv(opts) {
6001
6429
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
6002
6430
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
6003
6431
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
6004
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.2")}`
6432
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.4")}`
6005
6433
  ];
6006
6434
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
6007
6435
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -6407,9 +6835,18 @@ async function installCommand(opts = {}) {
6407
6835
  \u2717 ${err.message}`);
6408
6836
  process.exit(1);
6409
6837
  }
6838
+ if (hasCursor) {
6839
+ await promptCursorApiKey(opts);
6840
+ }
6410
6841
  console.log("Installing Synkro server container...");
6411
- const workersPerPool = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "8", 10);
6412
- 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 });
6413
6850
  console.log(` \u2713 pulled ${image}`);
6414
6851
  console.log(` container started \u2014 MCP=${hostMcpPort} general=${hostGraderPort} CWE=${hostCwePort}`);
6415
6852
  console.log(" waiting for container to be ready...");
@@ -7399,8 +7836,21 @@ async function stopCommand() {
7399
7836
  }
7400
7837
  console.log("\nServer stopped.");
7401
7838
  }
7402
- async function startCommand() {
7839
+ async function startCommand(rest = []) {
7403
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
+ }
7404
7854
  console.log("Synkro: starting server\n");
7405
7855
  const result = await dockerSafeStart();
7406
7856
  if (!result.ok) {
@@ -7410,8 +7860,21 @@ Start failed: ${result.error}`);
7410
7860
  }
7411
7861
  console.log("\nServer is running.");
7412
7862
  }
7413
- async function restartCommand() {
7863
+ async function restartCommand(rest = []) {
7414
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
+ }
7415
7878
  console.log("Synkro: restarting server\n");
7416
7879
  const result = await dockerSafeRestart();
7417
7880
  if (!result.ok) {
@@ -7453,7 +7916,7 @@ var args = process.argv.slice(2);
7453
7916
  var cmd = args[0] || "";
7454
7917
  var subArgs = args.slice(1);
7455
7918
  function printVersion() {
7456
- console.log("1.6.2");
7919
+ console.log("1.6.4");
7457
7920
  }
7458
7921
  function printHelp() {
7459
7922
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -7465,10 +7928,15 @@ Commands:
7465
7928
  install [--force] Install or update Synkro
7466
7929
  uninstall [--purge] Remove Synkro hooks (--purge also removes ~/.synkro)
7467
7930
  stop Gracefully stop the server (snapshot + checkpoint)
7468
- start Start the server (with pgdata integrity check)
7469
- 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)
7470
7933
  version Show version
7471
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
+
7472
7940
  Quick start:
7473
7941
  $ synkro install # one-time setup
7474
7942
  $ claude # use Claude Code normally; Synkro judges in real time
@@ -7512,12 +7980,12 @@ async function main() {
7512
7980
  }
7513
7981
  case "start": {
7514
7982
  const { startCommand: startCommand2 } = await Promise.resolve().then(() => (init_lifecycle(), lifecycle_exports));
7515
- await startCommand2();
7983
+ await startCommand2(args.slice(1));
7516
7984
  break;
7517
7985
  }
7518
7986
  case "restart": {
7519
7987
  const { restartCommand: restartCommand2 } = await Promise.resolve().then(() => (init_lifecycle(), lifecycle_exports));
7520
- await restartCommand2();
7988
+ await restartCommand2(args.slice(1));
7521
7989
  break;
7522
7990
  }
7523
7991
  default: {