@synkro-sh/cli 1.6.3 → 1.6.5

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,9 +2230,9 @@ 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
- '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.',
2235
+ 'The rules shown were pre-selected as the ones relevant to this edit \u2014 every rule here IS relevant, do not label any "not relevant". When passing (ok=true), give a terse, specific reason each rule passes. Format: "R003: no hardcoded secrets in file. R005: in-repo path only." Cover every rule shown.',
2015
2236
  ].join('\\n');
2016
2237
 
2017
2238
  let gradeResp: string;
@@ -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, filterRules, normalizeMode, appendLocalTelemetry, isSafeInRepoRead,
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
 
@@ -2841,108 +3061,6 @@ async function main() {
2841
3061
  return;
2842
3062
  }
2843
3063
 
2844
- // ─── Hook-side short-circuit for safe in-repo reads ───
2845
- // The judge primer already deterministically allows these, but the round
2846
- // trip + batch queue still costs 1–25s per call. Skipping the grade for
2847
- // unambiguously read-only operations removes that latency for ~half of
2848
- // typical commands (cat/grep/git status/ls/etc.) and unblocks the worker
2849
- // pool to grade the operations that actually need judgment.
2850
- // Returning FALSE just means "don't short-circuit — let the LLM grade it."
2851
- // Never blocks. The judge sees the command, applies rules, returns its
2852
- // own verdict. Path scoping below: STRICT, only short-circuit when every
2853
- // absolute path is under the linked repo root.
2854
- function isSafeBashSegment(seg: string, repoRoot: string): boolean {
2855
- const UNSAFE_CHARS = ['>', ';', '&', '\`'];
2856
- for (const ch of UNSAFE_CHARS) { if (seg.indexOf(ch) !== -1) return false; }
2857
- const padded = ' ' + seg + ' ';
2858
- const UNSAFE_WORDS = [
2859
- ' sudo ', ' su ', ' rm ', ' mv ', ' cp ', ' chmod ', ' chown ',
2860
- ' tee ', ' kill ', ' sed -i', ' sed --in-place',
2861
- ' sh -c', ' bash -c', ' zsh -c', ' eval ', ' exec ',
2862
- '\$(',
2863
- ];
2864
- for (const w of UNSAFE_WORDS) { if (padded.indexOf(w) !== -1) return false; }
2865
-
2866
- // Narrowed verb set. Removed:
2867
- // awk: has system() / |& shell-spawn
2868
- // env: \`env FOO=bar evil_cmd\` runs evil_cmd
2869
- // sed: scripting + -i write capability; not worth parsing
2870
- const SAFE_VERBS = new Set([
2871
- 'cat','head','tail','less','more','grep','egrep','fgrep','rg','ag',
2872
- 'find','fd','ls','wc','cmp','diff','file','stat','which','whereis','type',
2873
- 'pwd','whoami','id','date','echo','printf','true','false',
2874
- 'jq','yq','sort','uniq','cut','tr','xxd','hexdump','od','column',
2875
- 'node','npm','pnpm','yarn','bun','python','python3','ruby','go','rustc','cargo',
2876
- 'git',
2877
- ]);
2878
- const tokens = seg.trim().split(' ').filter(t => t.length > 0);
2879
- const verb = tokens[0] || '';
2880
- if (!SAFE_VERBS.has(verb)) return false;
2881
-
2882
- // find/fd: reject any execution / mutation action flag.
2883
- if (verb === 'find' || verb === 'fd') {
2884
- const BAD = new Set([
2885
- '-exec','-execdir','-ok','-okdir','-delete',
2886
- '-fprint','-fprintf','-fprint0','-fls',
2887
- '--exec','--exec-batch',
2888
- ]);
2889
- for (const t of tokens) { if (BAD.has(t)) return false; }
2890
- }
2891
-
2892
- // git: only pure-read subcommands. branch/tag/remote/config dropped —
2893
- // each has flag combinations that mutate state.
2894
- if (verb === 'git') {
2895
- const SAFE_GIT = new Set([
2896
- 'log','show','diff','blame','status','rev-parse',
2897
- 'ls-files','ls-tree','cat-file','shortlog','reflog',
2898
- 'describe','symbolic-ref','--version',
2899
- ]);
2900
- const sub = tokens[1] || '';
2901
- if (!SAFE_GIT.has(sub)) return false;
2902
- } else if (['npm','pnpm','yarn','bun','cargo','go'].includes(verb)) {
2903
- const sub = tokens[1] || '';
2904
- const SAFE_PKG = new Set([
2905
- '--version','-v','version','list','ls','why','view','show','info','outdated',
2906
- '-h','--help','help',
2907
- ]);
2908
- if (!SAFE_PKG.has(sub)) return false;
2909
- } else if (['node','python','python3','ruby','rustc'].includes(verb)) {
2910
- const sub = tokens[1] || '';
2911
- if (sub !== '--version' && sub !== '-v' && sub !== '-V') return false;
2912
- }
2913
-
2914
- // STRICT path scoping. Absolute paths MUST resolve under repoRoot.
2915
- // Home-relative (~/...) paths fall through to the LLM. Relative paths
2916
- // are implicitly under cwd which is the repo root for the agent session.
2917
- if (!repoRoot) return false;
2918
- for (let i = 1; i < tokens.length; i++) {
2919
- const t = tokens[i];
2920
- const stripped = t.replace(/^['"]/, '').replace(/['"]$/, '');
2921
- if (stripped.startsWith('~')) return false;
2922
- if (stripped.startsWith('/')) {
2923
- if (!isPathUnder(stripped, repoRoot)) return false;
2924
- }
2925
- }
2926
- return true;
2927
- }
2928
-
2929
- function isSafeInRepoRead(tName: string, cmd: string, repoRoot: string): boolean {
2930
- if (tName === 'Read' || tName === 'Grep' || tName === 'Glob') return true;
2931
- if (tName !== 'Bash' && tName !== 'Shell' && tName !== 'terminal' &&
2932
- tName !== 'run_terminal_cmd' && tName !== 'execute_command') return false;
2933
- if (!cmd || !repoRoot) return false;
2934
- // Allow pipes only if EVERY segment is safe on its own. Catches
2935
- // \`grep ... | head\`, \`cat foo | wc -l\`, \`git log | less\`, etc.
2936
- // Empty segments (from \`||\`) cause rejection.
2937
- const segments = cmd.split('|');
2938
- for (const seg of segments) {
2939
- const t = seg.trim();
2940
- if (t.length === 0) return false;
2941
- if (!isSafeBashSegment(t, repoRoot)) return false;
2942
- }
2943
- return true;
2944
- }
2945
-
2946
3064
  if (isSafeInRepoRead(toolName, command, cwd)) {
2947
3065
  log('bashGuard ' + cmdShort + ' → instant allow (safe in-repo read)');
2948
3066
  appendLocalTelemetry({
@@ -3079,6 +3197,7 @@ async function main() {
3079
3197
 
3080
3198
  if (rt === 'local') {
3081
3199
  const sessionLog = compressSessionLog(readSessionLog(sessionId));
3200
+ const relevantRules = await filterRules(command, config.rules);
3082
3201
  const graderPrompt = [
3083
3202
  'Working directory: ' + (cwd || '.'),
3084
3203
  'Repo: ' + (gitRepo || 'unknown'),
@@ -3086,9 +3205,9 @@ async function main() {
3086
3205
  'Command: ' + command,
3087
3206
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
3088
3207
  'Last user prompt: ' + (lastPrompt || 'none'),
3089
- 'Org rules: ' + JSON.stringify(config.rules),
3208
+ 'Org rules: ' + JSON.stringify(relevantRules),
3090
3209
  '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
- '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.',
3210
+ 'The rules shown were pre-selected as the ones relevant to this command every rule here IS relevant, do not label any "not relevant". When passing (ok=true), give a terse, specific reason each rule passes. Format: "R003: no secrets in grep args. R005: in-repo path only." Cover every rule shown.',
3092
3211
  '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.',
3093
3212
  ].filter(Boolean).join('\\n');
3094
3213
 
@@ -3148,6 +3267,7 @@ async function main() {
3148
3267
  recent_user_messages: transcript.recentUserMessages,
3149
3268
  recent_messages: transcript.recentMessages,
3150
3269
  recent_actions: transcript.recentActions,
3270
+ session_history: compressSessionLog(readSessionLog(sessionId)),
3151
3271
  session_id: sessionId || null,
3152
3272
  tool_use_id: toolUseId || null,
3153
3273
  cwd: cwd || null,
@@ -3202,7 +3322,7 @@ import {
3202
3322
  parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
3203
3323
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
3204
3324
  outputJson, outputEmpty, setupCursorHookSignals, isAgentTool, hookSessionId, GATEWAY_URL,
3205
- logGraderUnavailable,
3325
+ logGraderUnavailable, filterRules, normalizeMode,
3206
3326
  type HookConfig, type Rule,
3207
3327
  } from './_synkro-common.ts';
3208
3328
 
@@ -3256,6 +3376,8 @@ async function main() {
3256
3376
 
3257
3377
  if (rt === 'local') {
3258
3378
  const sessionLog = compressSessionLog(readSessionLog(sessionId));
3379
+ const agentText = 'agent=' + subagentType + ' description=' + description + ' prompt=' + prompt.slice(0, 2000);
3380
+ const relevantRules = await filterRules(agentText, config.rules);
3259
3381
  const graderPrompt = [
3260
3382
  'Working directory: ' + (cwd || '.'),
3261
3383
  'Repo: ' + (gitRepo || 'unknown'),
@@ -3267,7 +3389,7 @@ async function main() {
3267
3389
  prompt.slice(0, 4000),
3268
3390
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
3269
3391
  'Last user prompt: ' + (lastPrompt || 'none'),
3270
- 'Org rules: ' + JSON.stringify(config.rules),
3392
+ 'Org rules: ' + JSON.stringify(relevantRules),
3271
3393
  '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
3394
  ].filter(Boolean).join('\\n');
3273
3395
 
@@ -3326,6 +3448,7 @@ async function main() {
3326
3448
  recent_user_messages: transcript.recentUserMessages,
3327
3449
  recent_messages: transcript.recentMessages,
3328
3450
  recent_actions: transcript.recentActions,
3451
+ session_history: compressSessionLog(readSessionLog(sessionId)),
3329
3452
  session_id: sessionId || null,
3330
3453
  tool_use_id: toolUseId || null,
3331
3454
  cwd: cwd || null,
@@ -3365,6 +3488,7 @@ import {
3365
3488
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
3366
3489
  parseVerdict, dispatchCapture, appendSessionAction, readSessionLog, compressSessionLog, postWithRetry, readStdin, log,
3367
3490
  outputJson, outputEmpty, setupCursorHookSignals, isPlanTool, hookSessionId, GATEWAY_URL,
3491
+ filterRules,
3368
3492
  } from './_synkro-common.ts';
3369
3493
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
3370
3494
  import { join } from 'node:path';
@@ -3449,13 +3573,14 @@ async function main() {
3449
3573
 
3450
3574
  if (rt === 'local') {
3451
3575
  const sessionLog = compressSessionLog(readSessionLog(sessionId));
3576
+ const relevantRules = await filterRules(plan.slice(0, 2000), config.rules);
3452
3577
  const graderPrompt = [
3453
3578
  'Working directory: ' + (cwd || '.'),
3454
3579
  'Repo: ' + (gitRepo || 'unknown'),
3455
3580
  sessionLog,
3456
3581
  'Plan:',
3457
3582
  plan.slice(0, 8000),
3458
- 'Org rules: ' + JSON.stringify(config.rules),
3583
+ 'Org rules: ' + JSON.stringify(relevantRules),
3459
3584
  ].filter(Boolean).join('\\n');
3460
3585
 
3461
3586
  let gradeResp: string;
@@ -3962,8 +4087,10 @@ main();
3962
4087
  CURSOR_BASH_JUDGE_TS = `#!/usr/bin/env bun
3963
4088
  import {
3964
4089
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
3965
- parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
3966
- extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log, GATEWAY_URL,
4090
+ parseVerdict, dispatchCapture, dispatchFinding, ruleMode, normalizeMode, filterRules,
4091
+ isSafeInRepoRead, runInstallScan, postWithRetry, readStdin,
4092
+ extractTranscript, readLastPrompt, readSessionLog, compressSessionLog,
4093
+ appendLocalTelemetry, logGraderUnavailable, log, GATEWAY_URL,
3967
4094
  type Rule,
3968
4095
  } from './_synkro-common.ts';
3969
4096
  import { createHash } from 'node:crypto';
@@ -3989,8 +4116,8 @@ function isDuplicate(command: string, sessionId: string): boolean {
3989
4116
  }
3990
4117
 
3991
4118
  // 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;
4119
+ const CURSOR_GRADE_TIMEOUT_MS = 12000;
4120
+ const CURSOR_CLOUD_TIMEOUT_MS = 9000;
3994
4121
 
3995
4122
  let hookDone = false;
3996
4123
 
@@ -4060,8 +4187,6 @@ async function main() {
4060
4187
  finishAllow();
4061
4188
  }
4062
4189
 
4063
- appendSessionAction(sessionId, { ts: new Date().toISOString(), tool: toolName, summary: command.slice(0, 120) });
4064
-
4065
4190
  const transcriptPath = typeof payload.transcript_path === 'string' ? payload.transcript_path : '';
4066
4191
  const rawModel = String(payload.model ?? payload.model_id ?? '');
4067
4192
  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 +4196,18 @@ async function main() {
4071
4196
  const cmdShort = command.slice(0, 80);
4072
4197
  log('bashGuard checking: ' + cmdShort);
4073
4198
 
4199
+ // Instant-allow read-only tool calls + safe in-repo bash reads \u2014 no grade,
4200
+ // no network. Critical under Cursor's tight 15s beforeShellExecution budget.
4201
+ if (isSafeInRepoRead(toolName, command, cwd)) {
4202
+ log('bashGuard ' + cmdShort + ' \u2192 instant allow (safe in-repo read)');
4203
+ appendLocalTelemetry({
4204
+ capture_type: 'local_verdict', verdict: 'pass', hook_type: 'bash',
4205
+ category: 'safe_read', tool_name: toolName, command: command.slice(0, 200),
4206
+ session_id: sessionId, repo: cwd,
4207
+ });
4208
+ finishAllow();
4209
+ }
4210
+
4074
4211
  let jwt = loadJwt();
4075
4212
  if (!jwt) finishAllow();
4076
4213
  jwt = await ensureFreshJwt(jwt);
@@ -4084,28 +4221,55 @@ async function main() {
4084
4221
  const rt = await route(config);
4085
4222
  const tagStr = tag(rt, config);
4086
4223
 
4224
+ // Install protection \u2014 scan packages before any npm/pip/cargo/etc. install.
4225
+ if (SHELL_TOOL_NAMES.has(toolName)) {
4226
+ const scan = await runInstallScan(command, jwt);
4227
+ if (scan.action === 'block') {
4228
+ for (const f of scan.findings) {
4229
+ dispatchFinding(jwt, {
4230
+ session_id: sessionId, file_path: command,
4231
+ finding_type: 'cve' as const, finding_id: f.advisoryId + ':' + f.name,
4232
+ severity: f.severity, status: 'open', detail: f.detail,
4233
+ package_name: f.name, package_version: f.version,
4234
+ }, config.captureDepth);
4235
+ }
4236
+ dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
4237
+ 'Bash', repo, sessionId, config.captureDepth, {
4238
+ command, reasoning: scan.blockContext.slice(0, 200),
4239
+ violatedRules: scan.violatedIds, ccModel: model,
4240
+ });
4241
+ finishWith({
4242
+ permission: 'deny',
4243
+ user_message: tagStr + ' installScan \u2192 blocked: ' + cmdShort,
4244
+ agent_message: 'Synkro blocked this install \u2014 flagged package(s). ' + scan.blockContext,
4245
+ });
4246
+ } else if (scan.scanned && scan.action === 'warn') {
4247
+ log('bashGuard installScan warn: ' + scan.summary);
4248
+ }
4249
+ }
4250
+
4087
4251
  if (rt === 'local') {
4088
4252
  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');
4253
+ const relevantRules = await filterRules(command, config.rules);
4092
4254
 
4093
4255
  const graderPrompt = [
4094
- 'RULES:',
4095
- rulesBlock || '(none)',
4096
- '',
4256
+ 'Working directory: ' + (cwd || '.'),
4257
+ 'Repo: ' + (repo || 'unknown'),
4097
4258
  sessionLog,
4098
- 'COMMAND TO EVALUATE:',
4099
- command,
4100
- '',
4259
+ 'Command: ' + command,
4101
4260
  'User intent (last human message): ' + (transcript.userIntent || lastPrompt || 'none stated'),
4102
4261
  'Last user prompt: ' + (lastPrompt || 'none'),
4262
+ 'Org rules: ' + JSON.stringify(relevantRules),
4263
+ '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.',
4264
+ 'The rules shown were pre-selected as the ones relevant to this command \u2014 every rule here IS relevant, do not label any "not relevant". When passing (ok=true), give a terse, specific reason each rule passes. Format: "R003: no secrets in grep args. R005: in-repo path only." Cover every rule shown.',
4265
+ '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
4266
  ].filter(Boolean).join('\\n');
4104
4267
 
4105
4268
  let gradeResp: string;
4106
4269
  try {
4107
- gradeResp = await localGrade('bash', graderPrompt, CURSOR_GRADE_TIMEOUT_MS);
4270
+ gradeResp = await localGrade('bash', graderPrompt, CURSOR_GRADE_TIMEOUT_MS, 'cursor');
4108
4271
  } catch (e) {
4272
+ logGraderUnavailable('bashGuard', command.slice(0, 200), (e as Error).message || String(e));
4109
4273
  log('bashGuard ' + cmdShort + ' \u2192 pass (grade unavailable): ' + String(e));
4110
4274
  finishWith({ permission: 'allow' });
4111
4275
  }
@@ -4120,7 +4284,7 @@ async function main() {
4120
4284
  ? 'Synkro safety judge. Fix this before retrying \u2014 do not ask the user. Reasoning: ' + (verdict.reason || guardReason)
4121
4285
  : 'Synkro safety judge. Ask the user for explicit consent before retrying. Reasoning: ' + (verdict.reason || guardReason);
4122
4286
  dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
4123
- 'Bash', gitRepo, sessionId, config.captureDepth, {
4287
+ 'Bash', repo, sessionId, config.captureDepth, {
4124
4288
  command, reasoning: guardReason,
4125
4289
  rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
4126
4290
  ccModel: model,
@@ -4132,7 +4296,7 @@ async function main() {
4132
4296
  });
4133
4297
  } else {
4134
4298
  dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'clean',
4135
- 'Bash', gitRepo, sessionId, config.captureDepth, {
4299
+ 'Bash', repo, sessionId, config.captureDepth, {
4136
4300
  command, reasoning: verdict.reason || 'no policy violations detected',
4137
4301
  rulesChecked: config.rules, violatedRules: [],
4138
4302
  ccModel: model,
@@ -5423,6 +5587,26 @@ var init_promptFetcher = __esm({
5423
5587
  });
5424
5588
 
5425
5589
  // cli/local-cc/macKeychain.ts
5590
+ var macKeychain_exports = {};
5591
+ __export(macKeychain_exports, {
5592
+ CLAUDE_CREDS_DIR: () => CLAUDE_CREDS_DIR,
5593
+ CLAUDE_CREDS_FILE: () => CLAUDE_CREDS_FILE,
5594
+ CURSOR_API_KEY_FILE: () => CURSOR_API_KEY_FILE,
5595
+ CURSOR_CREDS_DIR: () => CURSOR_CREDS_DIR,
5596
+ KeychainExportError: () => KeychainExportError,
5597
+ SYNKRO_DIR: () => SYNKRO_DIR2,
5598
+ credsAreStale: () => credsAreStale,
5599
+ cursorApiKeyConfigured: () => cursorApiKeyConfigured,
5600
+ exportKeychainCreds: () => exportKeychainCreds,
5601
+ loadRefreshAgent: () => loadRefreshAgent,
5602
+ needsKeychainBridge: () => needsKeychainBridge,
5603
+ readExportedCreds: () => readExportedCreds,
5604
+ readKeychainCreds: () => readKeychainCreds,
5605
+ refreshCreds: () => refreshCreds,
5606
+ uninstallRefreshAgent: () => uninstallRefreshAgent,
5607
+ writeCursorApiKey: () => writeCursorApiKey,
5608
+ writeRefreshAgent: () => writeRefreshAgent
5609
+ });
5426
5610
  import { existsSync as existsSync7, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, chmodSync, readFileSync as readFileSync6, statSync } from "fs";
5427
5611
  import { homedir as homedir6, platform as platform3 } from "os";
5428
5612
  import { join as join6 } from "path";
@@ -5449,6 +5633,30 @@ function exportKeychainCreds() {
5449
5633
  chmodSync(CLAUDE_CREDS_FILE, 384);
5450
5634
  return CLAUDE_CREDS_FILE;
5451
5635
  }
5636
+ function cursorApiKeyConfigured() {
5637
+ try {
5638
+ return existsSync7(CURSOR_API_KEY_FILE) && readFileSync6(CURSOR_API_KEY_FILE, "utf-8").trim().length > 0;
5639
+ } catch {
5640
+ return false;
5641
+ }
5642
+ }
5643
+ function writeCursorApiKey(key) {
5644
+ const trimmed = key.trim();
5645
+ if (!trimmed) return;
5646
+ mkdirSync6(CURSOR_CREDS_DIR, { recursive: true });
5647
+ chmodSync(CURSOR_CREDS_DIR, 448);
5648
+ writeFileSync6(CURSOR_API_KEY_FILE, trimmed, "utf-8");
5649
+ chmodSync(CURSOR_API_KEY_FILE, 384);
5650
+ }
5651
+ function credsAreStale() {
5652
+ if (!existsSync7(CLAUDE_CREDS_FILE)) return true;
5653
+ try {
5654
+ const ageMs = Date.now() - statSync(CLAUDE_CREDS_FILE).mtimeMs;
5655
+ return ageMs > REFRESH_INTERVAL_SECONDS * 1e3;
5656
+ } catch {
5657
+ return true;
5658
+ }
5659
+ }
5452
5660
  function writeRefreshAgent(synkroBinPath) {
5453
5661
  if (platform3() !== "darwin") {
5454
5662
  throw new KeychainExportError("writeRefreshAgent is darwin-only");
@@ -5510,13 +5718,26 @@ function uninstallRefreshAgent() {
5510
5718
  } catch {
5511
5719
  }
5512
5720
  }
5513
- var SYNKRO_DIR2, CLAUDE_CREDS_DIR, CLAUDE_CREDS_FILE, KEYCHAIN_SERVICE, LAUNCHD_LABEL, LAUNCHD_PLIST, REFRESH_INTERVAL_SECONDS, KeychainExportError;
5721
+ function refreshCreds() {
5722
+ const path = exportKeychainCreds();
5723
+ return path !== null;
5724
+ }
5725
+ function readExportedCreds() {
5726
+ try {
5727
+ return readFileSync6(CLAUDE_CREDS_FILE, "utf-8");
5728
+ } catch {
5729
+ return null;
5730
+ }
5731
+ }
5732
+ 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
5733
  var init_macKeychain = __esm({
5515
5734
  "cli/local-cc/macKeychain.ts"() {
5516
5735
  "use strict";
5517
5736
  SYNKRO_DIR2 = join6(homedir6(), ".synkro");
5518
5737
  CLAUDE_CREDS_DIR = join6(SYNKRO_DIR2, "claude-creds");
5519
5738
  CLAUDE_CREDS_FILE = join6(CLAUDE_CREDS_DIR, ".credentials.json");
5739
+ CURSOR_CREDS_DIR = join6(SYNKRO_DIR2, "cursor-creds");
5740
+ CURSOR_API_KEY_FILE = join6(CURSOR_CREDS_DIR, "api-key");
5520
5741
  KEYCHAIN_SERVICE = "Claude Code-credentials";
5521
5742
  LAUNCHD_LABEL = "com.synkro.cli.claude-creds-refresh";
5522
5743
  LAUNCHD_PLIST = join6(homedir6(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
@@ -5547,12 +5768,69 @@ __export(dockerInstall_exports, {
5547
5768
  dockerStop: () => dockerStop,
5548
5769
  dockerUpdate: () => dockerUpdate,
5549
5770
  imageTag: () => imageTag,
5771
+ resolveWorkerConfig: () => resolveWorkerConfig,
5772
+ splitWorkers: () => splitWorkers,
5550
5773
  waitForContainerReady: () => waitForContainerReady
5551
5774
  });
5552
5775
  import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync7, readdirSync } from "fs";
5553
5776
  import { homedir as homedir7 } from "os";
5554
5777
  import { join as join7 } from "path";
5555
5778
  import { spawnSync as spawnSync2 } from "child_process";
5779
+ function splitWorkers(total, providers) {
5780
+ const t = Math.max(0, Math.floor(total));
5781
+ const hasClaude = providers.includes("claude_code");
5782
+ const hasCursor = providers.includes("cursor");
5783
+ if (hasClaude && hasCursor) {
5784
+ const cursorWorkers = Math.floor(t / 2);
5785
+ return { claudeWorkers: t - cursorWorkers, cursorWorkers };
5786
+ }
5787
+ if (hasCursor) return { claudeWorkers: 0, cursorWorkers: t };
5788
+ return { claudeWorkers: t, cursorWorkers: 0 };
5789
+ }
5790
+ function normalizeProvider(p) {
5791
+ const v = p.trim().toLowerCase();
5792
+ if (v === "claude" || v === "claude-code" || v === "claude_code" || v === "cc") return "claude_code";
5793
+ if (v === "cursor") return "cursor";
5794
+ return null;
5795
+ }
5796
+ function resolveWorkerConfig(rest) {
5797
+ let workers = 8;
5798
+ let explicit = false;
5799
+ const providers = [];
5800
+ const addProviders = (csv) => {
5801
+ for (const p of csv.split(",")) {
5802
+ const np = normalizeProvider(p);
5803
+ if (np && !providers.includes(np)) providers.push(np);
5804
+ }
5805
+ };
5806
+ for (let i = 0; i < rest.length; i++) {
5807
+ const a = rest[i];
5808
+ if (a === "--workers" || a === "-w") {
5809
+ workers = parseInt(rest[++i] || "8", 10);
5810
+ explicit = true;
5811
+ } else if (a.startsWith("--workers=")) {
5812
+ workers = parseInt(a.slice("--workers=".length), 10);
5813
+ explicit = true;
5814
+ } else if (a === "--provider" || a === "--providers") {
5815
+ addProviders(rest[++i] || "");
5816
+ explicit = true;
5817
+ } else if (a.startsWith("--provider=")) {
5818
+ addProviders(a.slice("--provider=".length));
5819
+ explicit = true;
5820
+ } else if (a.startsWith("--providers=")) {
5821
+ addProviders(a.slice("--providers=".length));
5822
+ explicit = true;
5823
+ }
5824
+ }
5825
+ if (!Number.isFinite(workers) || workers < 1) workers = 8;
5826
+ workers = Math.min(workers, 64);
5827
+ let provs = providers;
5828
+ if (provs.length === 0) {
5829
+ provs = detectAgents().map((a) => a.kind);
5830
+ if (provs.length === 0) provs = ["claude_code"];
5831
+ }
5832
+ return { ...splitWorkers(workers, provs), explicit };
5833
+ }
5556
5834
  function imageTag() {
5557
5835
  const registry = process.env.SYNKRO_IMAGE_REGISTRY || "";
5558
5836
  const tag = process.env.SYNKRO_IMAGE_TAG || DEFAULT_IMAGE;
@@ -5576,7 +5854,9 @@ function claudeCredsHostDir() {
5576
5854
  async function dockerInstall(opts = {}) {
5577
5855
  assertDockerAvailable();
5578
5856
  const image = imageTag();
5579
- const workers = String(opts.workersPerPool ?? 8);
5857
+ const claudeWorkers = opts.claudeWorkers ?? opts.workersPerPool ?? 8;
5858
+ const cursorWorkers = opts.cursorWorkers ?? 0;
5859
+ const totalWorkers = claudeWorkers + cursorWorkers;
5580
5860
  mkdirSync7(PGDATA_PATH, { recursive: true });
5581
5861
  mkdirSync7(BACKUP_DIR, { recursive: true });
5582
5862
  mkdirSync7(CLAUDE_HOST_STATE_DIR, { recursive: true });
@@ -5589,12 +5869,20 @@ async function dockerInstall(opts = {}) {
5589
5869
  `MCP JWT missing at ${MCP_JWT_PATH}. The installer should mint this before calling dockerInstall.`
5590
5870
  );
5591
5871
  }
5872
+ mkdirSync7(CURSOR_CREDS_DIR, { recursive: true });
5592
5873
  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
- );
5874
+ if (claudeWorkers > 0) {
5875
+ const path = exportKeychainCreds();
5876
+ if (!path) {
5877
+ throw new DockerInstallError(
5878
+ "Claude Code keychain entry not found. Run `claude login` (or open Claude Code and sign in) before installing the container."
5879
+ );
5880
+ }
5881
+ }
5882
+ if (cursorWorkers > 0 && !cursorApiKeyConfigured()) {
5883
+ console.warn(" \u26A0 No Cursor API key found \u2014 Cursor grader workers will be idle.");
5884
+ console.warn(" Generate a key at cursor.com \u2192 Settings \u2192 API Keys, then:");
5885
+ console.warn(` echo 'YOUR_KEY' > ~/.synkro/cursor-creds/api-key && chmod 600 ~/.synkro/cursor-creds/api-key`);
5598
5886
  }
5599
5887
  const plist = writeRefreshAgent("/usr/local/bin/synkro");
5600
5888
  try {
@@ -5637,21 +5925,34 @@ async function dockerInstall(opts = {}) {
5637
5925
  `${PGDATA_PATH}:/data/pgdata`,
5638
5926
  "-v",
5639
5927
  `${BACKUP_DIR}:/data/backups`,
5928
+ // The whole host ~/.synkro directory, read-only. The container copies
5929
+ // .mcp-jwt and credentials.json out of it at boot. A directory mount
5930
+ // sidesteps Docker Desktop for macOS's unreliable single-file bind mounts
5931
+ // (which previously left a dangling symlink that blocked container start).
5640
5932
  "-v",
5641
- `${MCP_JWT_PATH}:/data/.mcp-jwt:ro`,
5642
- "-v",
5643
- `${SYNKRO_CREDS_PATH}:/data/credentials.json:ro`,
5933
+ `${SYNKRO_DIR3}:/data/synkro-host:ro`,
5644
5934
  "-v",
5645
5935
  `${credsDir}:/home/synkro/.claude:rw`,
5646
5936
  "-v",
5647
5937
  `${join7(homedir7(), ".claude")}:/data/claude-host:ro`,
5648
5938
  "-v",
5649
5939
  `${CLAUDE_HOST_STATE_DIR}:/data/claude-host-state:ro`,
5940
+ // Cursor creds — mounted RW so the in-container refresher can rotate the
5941
+ // access token in place. Only mounted when the install includes Cursor.
5942
+ ...cursorWorkers > 0 ? ["-v", `${CURSOR_CREDS_DIR}:/home/synkro/.cursor-creds:rw`] : [],
5943
+ "-e",
5944
+ `WORKERS_PER_POOL=${totalWorkers}`,
5945
+ "-e",
5946
+ `CLAUDE_WORKERS=${claudeWorkers}`,
5650
5947
  "-e",
5651
- `WORKERS_PER_POOL=${workers}`,
5948
+ `CURSOR_WORKERS=${cursorWorkers}`,
5652
5949
  // Pass through the batch-size lever if the operator set it. Defaults
5653
5950
  // inside the container to 5; clamped to [1, 20] by synkro-server.ts.
5654
5951
  ...process.env.SYNKRO_MAX_BATCH_SIZE ? ["-e", `SYNKRO_MAX_BATCH_SIZE=${process.env.SYNKRO_MAX_BATCH_SIZE}`] : [],
5952
+ // Cursor grading model — tunable like SYNKRO_MAX_BATCH_SIZE.
5953
+ ...process.env.SYNKRO_CURSOR_MODEL ? ["-e", `SYNKRO_CURSOR_MODEL=${process.env.SYNKRO_CURSOR_MODEL}`] : [],
5954
+ // Connected repo — the server seeds the local app + ruleset named after it.
5955
+ ...opts.connectedRepo ? ["-e", `SYNKRO_CONNECTED_REPO=${opts.connectedRepo}`] : [],
5655
5956
  image
5656
5957
  ];
5657
5958
  const run = spawnSync2("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
@@ -5680,12 +5981,12 @@ function dockerStop() {
5680
5981
  spawnSync2("docker", ["stop", "--timeout=30", CONTAINER_NAME], { encoding: "utf-8", timeout: 45e3 });
5681
5982
  spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5682
5983
  }
5683
- async function dockerUpdate(workersPerPool) {
5984
+ async function dockerUpdate(opts = {}) {
5684
5985
  if (dockerStatus().running) {
5685
5986
  await dockerSafeStop();
5686
5987
  }
5687
5988
  dockerRemove();
5688
- await dockerInstall({ workersPerPool });
5989
+ await dockerInstall(opts);
5689
5990
  }
5690
5991
  function dockerStatus() {
5691
5992
  const r = spawnSync2("docker", ["inspect", "--format", "{{.State.Status}}", CONTAINER_NAME], {
@@ -5814,14 +6115,14 @@ function checkPgdata() {
5814
6115
  if (!hasPgControl) return { healthy: false, details: "pg_control/global directory missing" };
5815
6116
  return { healthy: true, details: `${entries.length} entries, WAL present, no stale PID` };
5816
6117
  }
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;
6118
+ 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
6119
  var init_dockerInstall = __esm({
5819
6120
  "cli/local-cc/dockerInstall.ts"() {
5820
6121
  "use strict";
6122
+ init_agentDetect();
5821
6123
  init_macKeychain();
5822
6124
  SYNKRO_DIR3 = join7(homedir7(), ".synkro");
5823
6125
  MCP_JWT_PATH = join7(SYNKRO_DIR3, ".mcp-jwt");
5824
- SYNKRO_CREDS_PATH = join7(SYNKRO_DIR3, "credentials.json");
5825
6126
  PGDATA_PATH = join7(SYNKRO_DIR3, "pgdata");
5826
6127
  CLAUDE_HOST_STATE_DIR = join7(SYNKRO_DIR3, "claude-host-state");
5827
6128
  CLAUDE_HOST_STATE_FILE = join7(CLAUDE_HOST_STATE_DIR, ".claude.json");
@@ -5863,6 +6164,7 @@ function parseArgs(argv) {
5863
6164
  for (const a of argv) {
5864
6165
  if (a.startsWith("--api-key=")) opts.apiKey = a.slice("--api-key=".length);
5865
6166
  else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
6167
+ else if (a.startsWith("--cursor-api-key=")) opts.cursorApiKey = a.slice("--cursor-api-key=".length);
5866
6168
  else if (a === "--skip-auth") opts.skipAuth = true;
5867
6169
  else if (a === "--no-mcp") opts.noMcp = true;
5868
6170
  else if (a === "--force" || a === "-f") opts.force = true;
@@ -5898,6 +6200,32 @@ async function promptAgentSelection(detected) {
5898
6200
  });
5899
6201
  return ask2();
5900
6202
  }
6203
+ async function promptCursorApiKey(opts) {
6204
+ const { cursorApiKeyConfigured: cursorApiKeyConfigured2, writeCursorApiKey: writeCursorApiKey2 } = await Promise.resolve().then(() => (init_macKeychain(), macKeychain_exports));
6205
+ if (cursorApiKeyConfigured2()) return;
6206
+ const provided = (opts.cursorApiKey || process.env.SYNKRO_CURSOR_API_KEY || "").trim();
6207
+ if (provided) {
6208
+ writeCursorApiKey2(provided);
6209
+ console.log(" \u2713 Cursor API key saved to ~/.synkro/cursor-creds/api-key");
6210
+ return;
6211
+ }
6212
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
6213
+ const key = await new Promise((resolve3) => {
6214
+ rl.question(
6215
+ "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): ",
6216
+ (answer) => {
6217
+ rl.close();
6218
+ resolve3(answer.trim());
6219
+ }
6220
+ );
6221
+ });
6222
+ if (key) {
6223
+ writeCursorApiKey2(key);
6224
+ console.log(" \u2713 Cursor API key saved.");
6225
+ } else {
6226
+ console.log(" \u26A0 Skipped \u2014 Cursor workers will be idle. Re-run install or pass --cursor-api-key=\u2026 later.");
6227
+ }
6228
+ }
5901
6229
  function ensureSynkroDir() {
5902
6230
  mkdirSync8(SYNKRO_DIR4, { recursive: true });
5903
6231
  mkdirSync8(HOOKS_DIR, { recursive: true });
@@ -5999,7 +6327,7 @@ function writeConfigEnv(opts) {
5999
6327
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
6000
6328
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
6001
6329
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
6002
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.3")}`
6330
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.5")}`
6003
6331
  ];
6004
6332
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
6005
6333
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -6405,9 +6733,18 @@ async function installCommand(opts = {}) {
6405
6733
  \u2717 ${err.message}`);
6406
6734
  process.exit(1);
6407
6735
  }
6736
+ if (hasCursor) {
6737
+ await promptCursorApiKey(opts);
6738
+ }
6408
6739
  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 });
6740
+ const totalWorkers = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "8", 10);
6741
+ const providers = [];
6742
+ if (hasClaudeCode) providers.push("claude_code");
6743
+ if (hasCursor) providers.push("cursor");
6744
+ const { claudeWorkers, cursorWorkers } = splitWorkers(totalWorkers, providers);
6745
+ console.log(` worker pool: ${claudeWorkers} claude + ${cursorWorkers} cursor`);
6746
+ const connectedRepo = detectGitRepo2() || void 0;
6747
+ const { image, hostMcpPort, hostGraderPort, hostCwePort } = await dockerInstall({ claudeWorkers, cursorWorkers, connectedRepo });
6411
6748
  console.log(` \u2713 pulled ${image}`);
6412
6749
  console.log(` container started \u2014 MCP=${hostMcpPort} general=${hostGraderPort} CWE=${hostCwePort}`);
6413
6750
  console.log(" waiting for container to be ready...");
@@ -7397,8 +7734,21 @@ async function stopCommand() {
7397
7734
  }
7398
7735
  console.log("\nServer stopped.");
7399
7736
  }
7400
- async function startCommand() {
7737
+ async function startCommand(rest = []) {
7401
7738
  assertDockerAvailable();
7739
+ const cfg = resolveWorkerConfig(rest);
7740
+ if (cfg.explicit) {
7741
+ console.log(`Synkro: starting server (${cfg.claudeWorkers} claude + ${cfg.cursorWorkers} cursor)
7742
+ `);
7743
+ await dockerUpdate({ claudeWorkers: cfg.claudeWorkers, cursorWorkers: cfg.cursorWorkers });
7744
+ const ready = await waitForContainerReady(6e4);
7745
+ if (!ready) {
7746
+ console.error("\n\u26A0 container did not pass /healthz within 60s");
7747
+ process.exit(1);
7748
+ }
7749
+ console.log("\nServer is running.");
7750
+ return;
7751
+ }
7402
7752
  console.log("Synkro: starting server\n");
7403
7753
  const result = await dockerSafeStart();
7404
7754
  if (!result.ok) {
@@ -7408,8 +7758,21 @@ Start failed: ${result.error}`);
7408
7758
  }
7409
7759
  console.log("\nServer is running.");
7410
7760
  }
7411
- async function restartCommand() {
7761
+ async function restartCommand(rest = []) {
7412
7762
  assertDockerAvailable();
7763
+ const cfg = resolveWorkerConfig(rest);
7764
+ if (cfg.explicit) {
7765
+ console.log(`Synkro: restarting server (${cfg.claudeWorkers} claude + ${cfg.cursorWorkers} cursor)
7766
+ `);
7767
+ await dockerUpdate({ claudeWorkers: cfg.claudeWorkers, cursorWorkers: cfg.cursorWorkers });
7768
+ const ready = await waitForContainerReady(6e4);
7769
+ if (!ready) {
7770
+ console.error("\n\u26A0 container did not pass /healthz within 60s");
7771
+ process.exit(1);
7772
+ }
7773
+ console.log("\nServer restarted successfully.");
7774
+ return;
7775
+ }
7413
7776
  console.log("Synkro: restarting server\n");
7414
7777
  const result = await dockerSafeRestart();
7415
7778
  if (!result.ok) {
@@ -7451,7 +7814,7 @@ var args = process.argv.slice(2);
7451
7814
  var cmd = args[0] || "";
7452
7815
  var subArgs = args.slice(1);
7453
7816
  function printVersion() {
7454
- console.log("1.6.3");
7817
+ console.log("1.6.5");
7455
7818
  }
7456
7819
  function printHelp() {
7457
7820
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -7463,10 +7826,15 @@ Commands:
7463
7826
  install [--force] Install or update Synkro
7464
7827
  uninstall [--purge] Remove Synkro hooks (--purge also removes ~/.synkro)
7465
7828
  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)
7829
+ start [opts] Start the server (with pgdata integrity check)
7830
+ restart [opts] Safe restart (stop \u2192 start, data preserved)
7468
7831
  version Show version
7469
7832
 
7833
+ start/restart opts (recreate the worker pool):
7834
+ --workers N total grader workers (default 8, even-split)
7835
+ --providers a,b grading agents: claude, cursor (or both)
7836
+ e.g. synkro restart --workers 16 --providers claude,cursor
7837
+
7470
7838
  Quick start:
7471
7839
  $ synkro install # one-time setup
7472
7840
  $ claude # use Claude Code normally; Synkro judges in real time
@@ -7510,12 +7878,12 @@ async function main() {
7510
7878
  }
7511
7879
  case "start": {
7512
7880
  const { startCommand: startCommand2 } = await Promise.resolve().then(() => (init_lifecycle(), lifecycle_exports));
7513
- await startCommand2();
7881
+ await startCommand2(args.slice(1));
7514
7882
  break;
7515
7883
  }
7516
7884
  case "restart": {
7517
7885
  const { restartCommand: restartCommand2 } = await Promise.resolve().then(() => (init_lifecycle(), lifecycle_exports));
7518
- await restartCommand2();
7886
+ await restartCommand2(args.slice(1));
7519
7887
  break;
7520
7888
  }
7521
7889
  default: {