@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 +534 -166
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
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 >
|
|
1174
|
-
const old = actions.slice(0, total -
|
|
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 -
|
|
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(
|
|
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
|
+
'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,
|
|
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(
|
|
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
|
-
'
|
|
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(
|
|
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(
|
|
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,
|
|
3966
|
-
|
|
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 =
|
|
3993
|
-
const CURSOR_CLOUD_TIMEOUT_MS =
|
|
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
|
|
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
|
-
'
|
|
4095
|
-
|
|
4096
|
-
'',
|
|
4256
|
+
'Working directory: ' + (cwd || '.'),
|
|
4257
|
+
'Repo: ' + (repo || 'unknown'),
|
|
4097
4258
|
sessionLog,
|
|
4098
|
-
'
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
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
|
-
`${
|
|
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
|
-
`
|
|
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(
|
|
5984
|
+
async function dockerUpdate(opts = {}) {
|
|
5684
5985
|
if (dockerStatus().running) {
|
|
5685
5986
|
await dockerSafeStop();
|
|
5686
5987
|
}
|
|
5687
5988
|
dockerRemove();
|
|
5688
|
-
await dockerInstall(
|
|
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,
|
|
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.
|
|
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
|
|
6410
|
-
const
|
|
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.
|
|
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
|
|
7467
|
-
restart
|
|
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: {
|