@synkro-sh/cli 1.6.23 → 1.6.25
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 +939 -293
- package/dist/bootstrap.js.map +1 -1
- package/package.json +11 -10
package/dist/bootstrap.js
CHANGED
|
@@ -389,6 +389,20 @@ function installCursorHooks(hooksJsonPath, config) {
|
|
|
389
389
|
timeout: 10,
|
|
390
390
|
matcher: "Shell|Bash|terminal|run_terminal_cmd|execute_command|delete_file"
|
|
391
391
|
});
|
|
392
|
+
h.afterAgentThought = h.afterAgentThought ?? [];
|
|
393
|
+
h.afterAgentThought.push({
|
|
394
|
+
command: bunRunCmd(config.agentCaptureScriptPath),
|
|
395
|
+
timeout: 5,
|
|
396
|
+
failClosed: false,
|
|
397
|
+
[SYNKRO_MARKER2]: true
|
|
398
|
+
});
|
|
399
|
+
h.afterAgentResponse = h.afterAgentResponse ?? [];
|
|
400
|
+
h.afterAgentResponse.push({
|
|
401
|
+
command: bunRunCmd(config.agentCaptureScriptPath),
|
|
402
|
+
timeout: 5,
|
|
403
|
+
failClosed: false,
|
|
404
|
+
[SYNKRO_MARKER2]: true
|
|
405
|
+
});
|
|
392
406
|
writeHooksFileAtomic(hooksJsonPath, file);
|
|
393
407
|
}
|
|
394
408
|
function uninstallCursorHooks(hooksJsonPath) {
|
|
@@ -431,7 +445,9 @@ var init_cursorHookConfig = __esm({
|
|
|
431
445
|
"afterShellExecution",
|
|
432
446
|
"preToolUse",
|
|
433
447
|
"afterFileEdit",
|
|
434
|
-
"postToolUse"
|
|
448
|
+
"postToolUse",
|
|
449
|
+
"afterAgentThought",
|
|
450
|
+
"afterAgentResponse"
|
|
435
451
|
];
|
|
436
452
|
}
|
|
437
453
|
});
|
|
@@ -751,7 +767,7 @@ synkro_post_with_retry() {
|
|
|
751
767
|
});
|
|
752
768
|
|
|
753
769
|
// cli/installer/hookScriptsTs.ts
|
|
754
|
-
var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS;
|
|
770
|
+
var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS, CURSOR_AGENT_CAPTURE_TS;
|
|
755
771
|
var init_hookScriptsTs = __esm({
|
|
756
772
|
"cli/installer/hookScriptsTs.ts"() {
|
|
757
773
|
"use strict";
|
|
@@ -1474,15 +1490,21 @@ export async function runInstallScan(command: string, jwt: string): Promise<Inst
|
|
|
1474
1490
|
.flatMap((p: any) => (p.signals || []))
|
|
1475
1491
|
.filter((s: any) => s.type === 'cve').length;
|
|
1476
1492
|
const findings: InstallScanResult['findings'] = [];
|
|
1493
|
+
const seenFindingKeys = new Set<string>();
|
|
1477
1494
|
for (const p of pkgResults) {
|
|
1478
1495
|
for (const s of (p.signals || [])) {
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1496
|
+
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);
|
|
1497
|
+
const advisoryId = advisoryMatch ? advisoryMatch[1] : (s.type === 'cve' ? 'pkg:' + p.name : (s.type || 'signal'));
|
|
1498
|
+
const key = advisoryId + ':' + p.name + '@' + p.version;
|
|
1499
|
+
if (seenFindingKeys.has(key)) continue;
|
|
1500
|
+
seenFindingKeys.add(key);
|
|
1501
|
+
findings.push({
|
|
1502
|
+
advisoryId,
|
|
1503
|
+
name: p.name,
|
|
1504
|
+
version: p.version,
|
|
1505
|
+
severity: s.severity || 'high',
|
|
1506
|
+
detail: s.detail || summary,
|
|
1507
|
+
});
|
|
1486
1508
|
}
|
|
1487
1509
|
}
|
|
1488
1510
|
// Preview the top 5 detail lines; the headline carries the true total.
|
|
@@ -1741,11 +1763,13 @@ export function dispatchCapture(
|
|
|
1741
1763
|
violatedRules?: string[];
|
|
1742
1764
|
recentUserMessages?: string[];
|
|
1743
1765
|
ccModel?: string;
|
|
1766
|
+
linesAdded?: number;
|
|
1767
|
+
linesRemoved?: number;
|
|
1744
1768
|
},
|
|
1745
1769
|
): void {
|
|
1746
1770
|
// Fire-and-forget
|
|
1747
1771
|
const eventId = 'evt_' + Date.now() + '_' + process.pid;
|
|
1748
|
-
const model = opts?.ccModel || 'unknown';
|
|
1772
|
+
const model = normalizeCaptureModel(opts?.ccModel || 'unknown');
|
|
1749
1773
|
|
|
1750
1774
|
const body: Record<string, any> = {
|
|
1751
1775
|
capture_type: 'local_verdict',
|
|
@@ -1774,6 +1798,8 @@ export function dispatchCapture(
|
|
|
1774
1798
|
if (opts.rulesChecked) localBody.rules_checked = opts.rulesChecked;
|
|
1775
1799
|
if (opts.violatedRules) localBody.violated_rules = opts.violatedRules;
|
|
1776
1800
|
if (opts.recentUserMessages) localBody.recent_user_messages = opts.recentUserMessages;
|
|
1801
|
+
if (opts.linesAdded != null) localBody.lines_added = opts.linesAdded;
|
|
1802
|
+
if (opts.linesRemoved != null) localBody.lines_removed = opts.linesRemoved;
|
|
1777
1803
|
}
|
|
1778
1804
|
appendLocalTelemetry(localBody);
|
|
1779
1805
|
|
|
@@ -1934,7 +1960,7 @@ export function reconstructContent(toolName: string, toolInput: any, filePath: s
|
|
|
1934
1960
|
case 'apply_patch':
|
|
1935
1961
|
return contentFromPatch(patchTextFromToolInput(toolInput));
|
|
1936
1962
|
case 'Write':
|
|
1937
|
-
return toolInput.content || '';
|
|
1963
|
+
return toolInput.content || toolInput.contents || '';
|
|
1938
1964
|
case 'edit_file':
|
|
1939
1965
|
case 'reapply':
|
|
1940
1966
|
return toolInput.content || toolInput.new_string || toolInput.code_edit || '';
|
|
@@ -2028,7 +2054,303 @@ export async function readStdin(): Promise<string> {
|
|
|
2028
2054
|
return Buffer.concat(chunks).toString('utf-8');
|
|
2029
2055
|
}
|
|
2030
2056
|
|
|
2031
|
-
// \u2500\u2500\u2500 Transcript
|
|
2057
|
+
// \u2500\u2500\u2500 Transcript path + message extraction (CC + Cursor) \u2500\u2500\u2500
|
|
2058
|
+
|
|
2059
|
+
/** Cursor stores transcripts under ~/.cursor/projects/{slug}/agent-transcripts/ */
|
|
2060
|
+
export function cursorProjectSlug(workspaceRoot: string): string {
|
|
2061
|
+
return workspaceRoot.replace(/^/+/, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/** Resolve transcript JSONL \u2014 payload field, CURSOR_TRANSCRIPT_PATH, or Cursor agent-transcripts dir. */
|
|
2065
|
+
export function resolveTranscriptPath(payload: Record<string, unknown>): string {
|
|
2066
|
+
const explicit = typeof payload.transcript_path === 'string' ? payload.transcript_path.trim() : '';
|
|
2067
|
+
if (explicit && existsSync(explicit)) return explicit;
|
|
2068
|
+
|
|
2069
|
+
const fromEnv = (process.env.CURSOR_TRANSCRIPT_PATH || '').trim();
|
|
2070
|
+
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
|
2071
|
+
|
|
2072
|
+
if (!isCursorHookFormat()) return explicit || fromEnv || '';
|
|
2073
|
+
|
|
2074
|
+
const sessionId = hookSessionId(payload);
|
|
2075
|
+
const workspaceRoots = Array.isArray(payload.workspace_roots)
|
|
2076
|
+
? (payload.workspace_roots as unknown[]).filter((r): r is string => typeof r === 'string')
|
|
2077
|
+
: [];
|
|
2078
|
+
const cwd = (typeof payload.cwd === 'string' && payload.cwd)
|
|
2079
|
+
|| workspaceRoots[0]
|
|
2080
|
+
|| (process.env.CURSOR_PROJECT_DIR || '');
|
|
2081
|
+
if (!sessionId || !cwd) return explicit || fromEnv || '';
|
|
2082
|
+
|
|
2083
|
+
const base = join(HOME, '.cursor', 'projects', cursorProjectSlug(cwd), 'agent-transcripts');
|
|
2084
|
+
const nested = join(base, sessionId, sessionId + '.jsonl');
|
|
2085
|
+
if (existsSync(nested)) return nested;
|
|
2086
|
+
if (existsSync(join(base, sessionId))) return nested;
|
|
2087
|
+
const flat = join(base, sessionId + '.jsonl');
|
|
2088
|
+
if (existsSync(flat)) return flat;
|
|
2089
|
+
|
|
2090
|
+
return explicit || fromEnv || '';
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
function transcriptEntryType(entry: Record<string, unknown>): string {
|
|
2094
|
+
return String(entry.type || entry.role || '');
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
function transcriptContentBlock(entry: Record<string, unknown>): Record<string, unknown> | null {
|
|
2098
|
+
const msg = entry.message as Record<string, unknown> | string | undefined;
|
|
2099
|
+
const content = (msg && typeof msg === 'object' ? msg.content : undefined) ?? entry.content;
|
|
2100
|
+
if (content && typeof content === 'object' && !Array.isArray(content)) {
|
|
2101
|
+
return content as Record<string, unknown>;
|
|
2102
|
+
}
|
|
2103
|
+
return null;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
function isProviderRedactedPlaceholder(text: string): boolean {
|
|
2107
|
+
const t = text.trim();
|
|
2108
|
+
if (!t) return false;
|
|
2109
|
+
return t === '[REDACTED]' || /^\\[REDACTED\\](\\s*\\[REDACTED\\])*$/.test(t);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
function extractContentBlockText(block: Record<string, unknown>): string {
|
|
2113
|
+
const type = String(block.type || '');
|
|
2114
|
+
if (type === 'text' && typeof block.text === 'string') return block.text;
|
|
2115
|
+
if ((type === 'thinking' || type === 'redacted_thinking') && typeof block.thinking === 'string') {
|
|
2116
|
+
return block.thinking;
|
|
2117
|
+
}
|
|
2118
|
+
if (type === 'tool_result') {
|
|
2119
|
+
const c = block.content;
|
|
2120
|
+
if (typeof c === 'string') return c;
|
|
2121
|
+
if (Array.isArray(c)) {
|
|
2122
|
+
return c.map((part: unknown) => {
|
|
2123
|
+
if (typeof part === 'string') return part;
|
|
2124
|
+
const p = part as Record<string, unknown>;
|
|
2125
|
+
return typeof p.text === 'string' ? p.text : '';
|
|
2126
|
+
}).filter(Boolean).join('\\n');
|
|
2127
|
+
}
|
|
2128
|
+
return '';
|
|
2129
|
+
}
|
|
2130
|
+
return '';
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
function thoughtOverlayPath(sessionId: string): string {
|
|
2134
|
+
return join(HOME, '.synkro', 'sessions', sessionId, 'thought-overlay.jsonl');
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
/** Cursor afterAgentThought delivers full thinking; transcript JSONL stores [REDACTED]. Queue for transcript sync. */
|
|
2138
|
+
export function appendThoughtOverlay(sessionId: string, text: string): void {
|
|
2139
|
+
const trimmed = text.trim();
|
|
2140
|
+
if (!sessionId || !trimmed || isProviderRedactedPlaceholder(trimmed)) return;
|
|
2141
|
+
const path = thoughtOverlayPath(sessionId);
|
|
2142
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
2143
|
+
appendFileSync(path, JSON.stringify({ text: trimmed, ts: Date.now() }) + '\\n', 'utf-8');
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
function consumeThoughtOverlay(sessionId: string): string {
|
|
2147
|
+
const path = thoughtOverlayPath(sessionId);
|
|
2148
|
+
if (!existsSync(path)) return '';
|
|
2149
|
+
try {
|
|
2150
|
+
const lines = readFileSync(path, 'utf-8').split('\\n').filter(l => l.trim());
|
|
2151
|
+
if (lines.length === 0) return '';
|
|
2152
|
+
const first = JSON.parse(lines[0]) as { text?: string };
|
|
2153
|
+
const rest = lines.slice(1).join('\\n');
|
|
2154
|
+
if (rest.trim()) writeFileSync(path, rest + (rest.endsWith('\\n') ? '' : '\\n'), 'utf-8');
|
|
2155
|
+
else unlinkSync(path);
|
|
2156
|
+
const text = typeof first.text === 'string' ? first.text.trim() : '';
|
|
2157
|
+
return text && !isProviderRedactedPlaceholder(text) ? text : '';
|
|
2158
|
+
} catch {
|
|
2159
|
+
return '';
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
/** Push a single conversation turn (used by Cursor afterAgentThought/afterAgentResponse hooks). */
|
|
2164
|
+
export async function pushConversationMessage(
|
|
2165
|
+
sessionId: string,
|
|
2166
|
+
role: 'user' | 'assistant',
|
|
2167
|
+
content: string,
|
|
2168
|
+
opts: { gitRepo?: string; patchRedacted?: boolean; seq?: number } = {},
|
|
2169
|
+
): Promise<boolean> {
|
|
2170
|
+
const text = content.trim().slice(0, 8000);
|
|
2171
|
+
if (!sessionId || !text || isProviderRedactedPlaceholder(text)) return false;
|
|
2172
|
+
|
|
2173
|
+
const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
|
|
2174
|
+
let mcpToken = '';
|
|
2175
|
+
try { mcpToken = readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
|
|
2176
|
+
if (!mcpToken) return false;
|
|
2177
|
+
|
|
2178
|
+
const seq = typeof opts.seq === 'number'
|
|
2179
|
+
? opts.seq
|
|
2180
|
+
: Date.now() % 1_000_000_000;
|
|
2181
|
+
|
|
2182
|
+
try {
|
|
2183
|
+
const body: Record<string, unknown> = {
|
|
2184
|
+
session_id: sessionId,
|
|
2185
|
+
repo: opts.gitRepo || '',
|
|
2186
|
+
messages: [{
|
|
2187
|
+
type: role,
|
|
2188
|
+
content: text,
|
|
2189
|
+
ts: new Date().toISOString(),
|
|
2190
|
+
patch_redacted: opts.patchRedacted ?? true,
|
|
2191
|
+
}],
|
|
2192
|
+
};
|
|
2193
|
+
if (!opts.patchRedacted) {
|
|
2194
|
+
(body.messages as Record<string, unknown>[])[0].message_index = seq;
|
|
2195
|
+
}
|
|
2196
|
+
const resp = await fetch('http://127.0.0.1:' + mcpPort + '/api/conversation-sync', {
|
|
2197
|
+
method: 'POST',
|
|
2198
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
|
|
2199
|
+
body: JSON.stringify(body),
|
|
2200
|
+
signal: AbortSignal.timeout(5000),
|
|
2201
|
+
});
|
|
2202
|
+
return resp.ok;
|
|
2203
|
+
} catch {
|
|
2204
|
+
return false;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function extractTranscriptEntryText(
|
|
2209
|
+
entry: Record<string, unknown>,
|
|
2210
|
+
maxLen = 8000,
|
|
2211
|
+
sessionId = '',
|
|
2212
|
+
): string {
|
|
2213
|
+
const block = transcriptContentBlock(entry);
|
|
2214
|
+
if (block) {
|
|
2215
|
+
const type = String(block.type || '');
|
|
2216
|
+
if (type === 'tool_use' || type === 'tool_call') return '';
|
|
2217
|
+
let text = extractContentBlockText(block);
|
|
2218
|
+
if (isProviderRedactedPlaceholder(text) && sessionId) {
|
|
2219
|
+
const overlay = consumeThoughtOverlay(sessionId);
|
|
2220
|
+
if (overlay) text = overlay;
|
|
2221
|
+
}
|
|
2222
|
+
return text ? text.slice(0, maxLen) : '';
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
const msg = entry.message as Record<string, unknown> | string | undefined;
|
|
2226
|
+
const content = (msg && typeof msg === 'object' ? msg.content : undefined) ?? entry.content;
|
|
2227
|
+
if (typeof content === 'string') {
|
|
2228
|
+
let text = content;
|
|
2229
|
+
if (isProviderRedactedPlaceholder(text) && sessionId) {
|
|
2230
|
+
const overlay = consumeThoughtOverlay(sessionId);
|
|
2231
|
+
if (overlay) text = overlay;
|
|
2232
|
+
}
|
|
2233
|
+
return text ? text.slice(0, maxLen) : '';
|
|
2234
|
+
}
|
|
2235
|
+
if (Array.isArray(content)) {
|
|
2236
|
+
const text = content.map((c: unknown) => {
|
|
2237
|
+
if (typeof c === 'string') return c;
|
|
2238
|
+
const b = c as Record<string, unknown>;
|
|
2239
|
+
const type = String(b?.type || '');
|
|
2240
|
+
if (type === 'tool_use' || type === 'tool_call') return '';
|
|
2241
|
+
return extractContentBlockText(b);
|
|
2242
|
+
}).filter(Boolean).join('\\n\\n').trim();
|
|
2243
|
+
if (!text) return '';
|
|
2244
|
+
if (isProviderRedactedPlaceholder(text) && sessionId) {
|
|
2245
|
+
const overlay = consumeThoughtOverlay(sessionId);
|
|
2246
|
+
if (overlay) return overlay.slice(0, maxLen);
|
|
2247
|
+
}
|
|
2248
|
+
return text.slice(0, maxLen);
|
|
2249
|
+
}
|
|
2250
|
+
if (typeof msg === 'string') return msg.slice(0, maxLen);
|
|
2251
|
+
return '';
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
/** Offset-tracked ingest of new user/assistant turns into PGLite conversation_messages. */
|
|
2255
|
+
export async function syncConversationTranscript(
|
|
2256
|
+
sessionId: string,
|
|
2257
|
+
transcriptPath: string,
|
|
2258
|
+
gitRepo = '',
|
|
2259
|
+
): Promise<{ ingested: number; messages: Array<Record<string, unknown>> }> {
|
|
2260
|
+
if (!sessionId || !transcriptPath || !existsSync(transcriptPath)) return { ingested: 0, messages: [] };
|
|
2261
|
+
|
|
2262
|
+
const offsetDir = join(HOME, '.synkro', '.transcript-offsets');
|
|
2263
|
+
mkdirSync(offsetDir, { recursive: true });
|
|
2264
|
+
const offsetFile = join(offsetDir, sessionId);
|
|
2265
|
+
let offset = 0;
|
|
2266
|
+
if (existsSync(offsetFile)) {
|
|
2267
|
+
try { offset = parseInt(readFileSync(offsetFile, 'utf-8').trim(), 10) || 0; } catch {}
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
const raw = readFileSync(transcriptPath, 'utf-8');
|
|
2271
|
+
const allLines = raw.split('\\n').filter(l => l.trim());
|
|
2272
|
+
const totalLines = allLines.length;
|
|
2273
|
+
if (totalLines <= offset) return { ingested: 0, messages: [] };
|
|
2274
|
+
|
|
2275
|
+
let startIdx = offset;
|
|
2276
|
+
const delta = totalLines - offset;
|
|
2277
|
+
if (delta > 200) startIdx = totalLines - 200;
|
|
2278
|
+
|
|
2279
|
+
const messages: Array<Record<string, unknown>> = [];
|
|
2280
|
+
for (let i = startIdx; i < totalLines; i++) {
|
|
2281
|
+
try {
|
|
2282
|
+
const entry = JSON.parse(allLines[i]) as Record<string, unknown>;
|
|
2283
|
+
const kind = transcriptEntryType(entry);
|
|
2284
|
+
if (kind !== 'user' && kind !== 'assistant') continue;
|
|
2285
|
+
const text = extractTranscriptEntryText(entry, 8000, sessionId);
|
|
2286
|
+
if (!text) continue;
|
|
2287
|
+
|
|
2288
|
+
const msgObj = entry.message as Record<string, unknown> | undefined;
|
|
2289
|
+
const content = (msgObj && typeof msgObj === 'object' ? msgObj.content : undefined) ?? entry.content;
|
|
2290
|
+
const singleBlock = transcriptContentBlock(entry);
|
|
2291
|
+
const msg: Record<string, unknown> = {
|
|
2292
|
+
message_index: i,
|
|
2293
|
+
type: kind,
|
|
2294
|
+
content: text,
|
|
2295
|
+
ts: entry.timestamp || null,
|
|
2296
|
+
};
|
|
2297
|
+
if (kind === 'assistant') {
|
|
2298
|
+
const blocks = Array.isArray(content)
|
|
2299
|
+
? content
|
|
2300
|
+
: (singleBlock ? [singleBlock] : []);
|
|
2301
|
+
const toolCalls = blocks
|
|
2302
|
+
.filter((c: unknown) => {
|
|
2303
|
+
const b = c as Record<string, unknown>;
|
|
2304
|
+
return b?.type === 'tool_use' || b?.type === 'tool_call';
|
|
2305
|
+
})
|
|
2306
|
+
.map((c: unknown) => {
|
|
2307
|
+
const b = c as Record<string, unknown>;
|
|
2308
|
+
return {
|
|
2309
|
+
name: b.name,
|
|
2310
|
+
input: JSON.stringify(b.input || b.arguments || {}).slice(0, 500),
|
|
2311
|
+
id: b.id,
|
|
2312
|
+
};
|
|
2313
|
+
});
|
|
2314
|
+
if (toolCalls.length > 0) msg.tool_calls = toolCalls;
|
|
2315
|
+
if (msgObj && typeof msgObj === 'object') {
|
|
2316
|
+
msg.model = msgObj.model || null;
|
|
2317
|
+
const u = msgObj.usage as Record<string, unknown> | undefined;
|
|
2318
|
+
if (u) {
|
|
2319
|
+
msg.usage = {
|
|
2320
|
+
input_tokens: u.input_tokens,
|
|
2321
|
+
output_tokens: u.output_tokens,
|
|
2322
|
+
cache_creation_input_tokens: u.cache_creation_input_tokens,
|
|
2323
|
+
cache_read_input_tokens: u.cache_read_input_tokens,
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
messages.push(msg);
|
|
2329
|
+
} catch {}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
writeFileSync(offsetFile, String(totalLines), 'utf-8');
|
|
2333
|
+
if (messages.length === 0) return { ingested: 0, messages: [] };
|
|
2334
|
+
|
|
2335
|
+
const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
|
|
2336
|
+
let mcpToken = '';
|
|
2337
|
+
try { mcpToken = readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
|
|
2338
|
+
if (!mcpToken) return { ingested: 0, messages };
|
|
2339
|
+
|
|
2340
|
+
try {
|
|
2341
|
+
const resp = await fetch('http://127.0.0.1:' + mcpPort + '/api/conversation-sync', {
|
|
2342
|
+
method: 'POST',
|
|
2343
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
|
|
2344
|
+
body: JSON.stringify({ session_id: sessionId, repo: gitRepo, messages }),
|
|
2345
|
+
signal: AbortSignal.timeout(5000),
|
|
2346
|
+
});
|
|
2347
|
+
if (resp.ok) {
|
|
2348
|
+
const data = await resp.json() as { ingested?: number };
|
|
2349
|
+
return { ingested: data.ingested ?? messages.length, messages };
|
|
2350
|
+
}
|
|
2351
|
+
} catch {}
|
|
2352
|
+
return { ingested: 0, messages };
|
|
2353
|
+
}
|
|
2032
2354
|
|
|
2033
2355
|
export interface TranscriptContext {
|
|
2034
2356
|
userIntent: string;
|
|
@@ -2067,11 +2389,8 @@ export function extractTranscript(transcriptPath: string | undefined): Transcrip
|
|
|
2067
2389
|
// Recent user messages (last 5)
|
|
2068
2390
|
const userMsgs: string[] = [];
|
|
2069
2391
|
for (const entry of parsed) {
|
|
2070
|
-
if (entry
|
|
2071
|
-
const
|
|
2072
|
-
let text = '';
|
|
2073
|
-
if (typeof content === 'string') text = content;
|
|
2074
|
-
else if (Array.isArray(content)) text = content.map((c: any) => c.text || '').join(' ');
|
|
2392
|
+
if (transcriptEntryType(entry) !== 'user') continue;
|
|
2393
|
+
const text = extractTranscriptEntryText(entry);
|
|
2075
2394
|
if (text) userMsgs.push(text);
|
|
2076
2395
|
}
|
|
2077
2396
|
ctx.recentUserMessages = userMsgs.slice(-5);
|
|
@@ -2080,26 +2399,25 @@ export function extractTranscript(transcriptPath: string | undefined): Transcrip
|
|
|
2080
2399
|
// Recent messages (last 10, user + assistant)
|
|
2081
2400
|
const msgs: Array<{ type: string; text: string }> = [];
|
|
2082
2401
|
for (const entry of parsed) {
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
if (
|
|
2087
|
-
else if (Array.isArray(content)) text = content.map((c: any) => (c.text || '').slice(0, 300)).join(' ');
|
|
2088
|
-
msgs.push({ type: entry.type, text });
|
|
2402
|
+
const kind = transcriptEntryType(entry);
|
|
2403
|
+
if (kind !== 'user' && kind !== 'assistant') continue;
|
|
2404
|
+
const text = extractTranscriptEntryText(entry, 500);
|
|
2405
|
+
if (text) msgs.push({ type: kind, text });
|
|
2089
2406
|
}
|
|
2090
2407
|
ctx.recentMessages = msgs.slice(-10);
|
|
2091
2408
|
|
|
2092
2409
|
// Recent tool calls (last 5)
|
|
2093
2410
|
const actions: Array<{ tool: string; input: string }> = [];
|
|
2094
2411
|
for (const entry of parsed) {
|
|
2095
|
-
if (entry
|
|
2096
|
-
const
|
|
2412
|
+
if (transcriptEntryType(entry) !== 'assistant') continue;
|
|
2413
|
+
const msg = entry.message;
|
|
2414
|
+
const content = msg?.content ?? entry.content;
|
|
2097
2415
|
if (!Array.isArray(content)) continue;
|
|
2098
2416
|
for (const block of content) {
|
|
2099
|
-
if (block.type !== 'tool_use') continue;
|
|
2417
|
+
if (block.type !== 'tool_use' && block.type !== 'tool_call') continue;
|
|
2100
2418
|
actions.push({
|
|
2101
2419
|
tool: block.name || '',
|
|
2102
|
-
input: JSON.stringify(block.input || {}).slice(0, 200),
|
|
2420
|
+
input: JSON.stringify(block.input || block.arguments || {}).slice(0, 200),
|
|
2103
2421
|
});
|
|
2104
2422
|
}
|
|
2105
2423
|
}
|
|
@@ -2113,7 +2431,7 @@ export function extractTranscript(transcriptPath: string | undefined): Transcrip
|
|
|
2113
2431
|
}
|
|
2114
2432
|
|
|
2115
2433
|
// CC model
|
|
2116
|
-
const assistantEntries = parsed.filter(e => e
|
|
2434
|
+
const assistantEntries = parsed.filter(e => transcriptEntryType(e) === 'assistant');
|
|
2117
2435
|
if (assistantEntries.length > 0) {
|
|
2118
2436
|
const last = assistantEntries[assistantEntries.length - 1];
|
|
2119
2437
|
ctx.ccModel = last.message?.model || '';
|
|
@@ -2208,39 +2526,254 @@ export function hashCommand(cmd: string): string {
|
|
|
2208
2526
|
return createHash('sha256').update(cmd).digest('hex').slice(0, 16);
|
|
2209
2527
|
}
|
|
2210
2528
|
|
|
2211
|
-
// \u2500\u2500\u2500
|
|
2529
|
+
// \u2500\u2500\u2500 Edit line delta + transcript usage \u2500\u2500\u2500
|
|
2530
|
+
|
|
2531
|
+
export function countTextLines(text: string): number {
|
|
2532
|
+
if (!text) return 0;
|
|
2533
|
+
return text.split('\\n').length;
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
/** Count lines added/removed from a write, search-replace, or multi-edit payload. */
|
|
2537
|
+
export function countEditLineDelta(source: {
|
|
2538
|
+
writeContent?: string;
|
|
2539
|
+
/** When set, Write counts net delta vs the file on disk instead of full file length. */
|
|
2540
|
+
existingContent?: string;
|
|
2541
|
+
old_string?: string;
|
|
2542
|
+
new_string?: string;
|
|
2543
|
+
edits?: Array<{ old_string?: string; new_string?: string }>;
|
|
2544
|
+
}): { linesAdded: number; linesRemoved: number } {
|
|
2545
|
+
let linesAdded = 0;
|
|
2546
|
+
let linesRemoved = 0;
|
|
2547
|
+
if (source.writeContent != null && source.writeContent !== '') {
|
|
2548
|
+
const newLines = countTextLines(String(source.writeContent));
|
|
2549
|
+
if (source.existingContent != null && source.existingContent !== '') {
|
|
2550
|
+
const oldLines = countTextLines(String(source.existingContent));
|
|
2551
|
+
return {
|
|
2552
|
+
linesAdded: Math.max(0, newLines - oldLines),
|
|
2553
|
+
linesRemoved: Math.max(0, oldLines - newLines),
|
|
2554
|
+
};
|
|
2555
|
+
}
|
|
2556
|
+
return { linesAdded: newLines, linesRemoved: 0 };
|
|
2557
|
+
}
|
|
2558
|
+
if (source.old_string != null && source.new_string != null) {
|
|
2559
|
+
const oldLines = countTextLines(String(source.old_string));
|
|
2560
|
+
const newLines = countTextLines(String(source.new_string));
|
|
2561
|
+
return {
|
|
2562
|
+
linesAdded: Math.max(0, newLines - oldLines),
|
|
2563
|
+
linesRemoved: Math.max(0, oldLines - newLines),
|
|
2564
|
+
};
|
|
2565
|
+
}
|
|
2566
|
+
if (Array.isArray(source.edits)) {
|
|
2567
|
+
for (const e of source.edits) {
|
|
2568
|
+
if (!e || typeof e !== 'object') continue;
|
|
2569
|
+
const oldLines = countTextLines(String(e.old_string || ''));
|
|
2570
|
+
const newLines = countTextLines(String(e.new_string || ''));
|
|
2571
|
+
linesAdded += Math.max(0, newLines - oldLines);
|
|
2572
|
+
linesRemoved += Math.max(0, oldLines - newLines);
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
return { linesAdded, linesRemoved };
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
/** Normalize model names so the same Cursor session doesn't split across agent buckets. */
|
|
2579
|
+
export function normalizeCaptureModel(model: string): string {
|
|
2580
|
+
const m = (model || '').trim();
|
|
2581
|
+
if (!m || m === 'unknown') return 'unknown';
|
|
2582
|
+
if (m.startsWith('cursor/') || m.startsWith('claude-') || m.startsWith('gpt-') || m.startsWith('gemini-')) return m;
|
|
2583
|
+
if (/^composer/i.test(m) || m === 'auto' || m === 'default') return 'cursor/' + m;
|
|
2584
|
+
return m;
|
|
2585
|
+
}
|
|
2212
2586
|
|
|
2213
|
-
|
|
2214
|
-
|
|
2587
|
+
/** Line metrics for guard_checks \u2014 Cursor Edit/StrReplace post deltas via afterFileEdit. */
|
|
2588
|
+
export function captureLineMetrics(
|
|
2589
|
+
agentKind: 'cursor' | 'claude_code',
|
|
2590
|
+
toolName: string,
|
|
2591
|
+
linesAdded: number,
|
|
2592
|
+
linesRemoved: number,
|
|
2593
|
+
): { linesAdded?: number; linesRemoved?: number } {
|
|
2594
|
+
if (agentKind === 'cursor' && toolName !== 'Write') return {};
|
|
2595
|
+
return { linesAdded, linesRemoved };
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
function usageTextFromTranscriptEntry(entry: Record<string, unknown>): string {
|
|
2599
|
+
const msg = entry.message as Record<string, unknown> | undefined;
|
|
2600
|
+
const content = (msg && typeof msg === 'object' ? msg.content : undefined) ?? entry.content;
|
|
2601
|
+
if (typeof content === 'string') return content;
|
|
2602
|
+
if (content && typeof content === 'object' && !Array.isArray(content)) {
|
|
2603
|
+
const block = content as Record<string, unknown>;
|
|
2604
|
+
if (block.type === 'text' && typeof block.text === 'string') return block.text;
|
|
2605
|
+
if (typeof block.text === 'string') return block.text;
|
|
2606
|
+
return '';
|
|
2607
|
+
}
|
|
2608
|
+
if (!Array.isArray(content)) return '';
|
|
2609
|
+
return content.map((c: unknown) => {
|
|
2610
|
+
if (typeof c === 'string') return c;
|
|
2611
|
+
const b = c as Record<string, unknown>;
|
|
2612
|
+
if (b?.type === 'text' && typeof b.text === 'string') return b.text;
|
|
2613
|
+
if (typeof b?.text === 'string') return b.text;
|
|
2614
|
+
if (b?.type === 'tool_use' || b?.type === 'tool_call') {
|
|
2615
|
+
return JSON.stringify(b.input || b.arguments || {});
|
|
2616
|
+
}
|
|
2617
|
+
return '';
|
|
2618
|
+
}).join('\\n');
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
function addUsageBlock(
|
|
2622
|
+
result: { model: string; totals: Record<string, number> },
|
|
2623
|
+
u: Record<string, unknown> | null | undefined,
|
|
2624
|
+
): boolean {
|
|
2625
|
+
if (!u) return false;
|
|
2626
|
+
const inp = Number(u.input_tokens ?? u.inputTokens ?? 0) || 0;
|
|
2627
|
+
const out = Number(u.output_tokens ?? u.outputTokens ?? 0) || 0;
|
|
2628
|
+
const cw = Number(u.cache_creation_input_tokens ?? u.cacheCreationInputTokens ?? 0) || 0;
|
|
2629
|
+
const cr = Number(u.cache_read_input_tokens ?? u.cacheReadInputTokens ?? 0) || 0;
|
|
2630
|
+
if (inp + out + cw + cr <= 0) return false;
|
|
2631
|
+
result.totals.in += inp;
|
|
2632
|
+
result.totals.out += out;
|
|
2633
|
+
result.totals.cw += cw;
|
|
2634
|
+
result.totals.cr += cr;
|
|
2635
|
+
return true;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
function msgModel(entry: Record<string, unknown>): string {
|
|
2639
|
+
const msg = entry.message as Record<string, unknown> | undefined;
|
|
2640
|
+
return typeof msg?.model === 'string' ? msg.model : '';
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
/** Sum token usage from CC or Cursor agent-transcript JSONL. */
|
|
2644
|
+
export function aggregateUsage(
|
|
2645
|
+
transcriptPath: string,
|
|
2646
|
+
opts?: { modelFallback?: string },
|
|
2647
|
+
): { model: string; totals: Record<string, number> } {
|
|
2648
|
+
const result = { model: opts?.modelFallback || '', totals: { in: 0, out: 0, cw: 0, cr: 0 } };
|
|
2215
2649
|
if (!transcriptPath || !existsSync(transcriptPath)) return result;
|
|
2216
2650
|
try {
|
|
2217
2651
|
const raw = readFileSync(transcriptPath, 'utf-8');
|
|
2218
|
-
const
|
|
2219
|
-
for (const line of
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2652
|
+
const entries: Record<string, unknown>[] = [];
|
|
2653
|
+
for (const line of raw.split('\\n')) {
|
|
2654
|
+
if (!line.trim()) continue;
|
|
2655
|
+
try { entries.push(JSON.parse(line) as Record<string, unknown>); } catch {}
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
let sawExplicit = false;
|
|
2659
|
+
for (const entry of entries) {
|
|
2660
|
+
const tokenCount = (entry.tokenCount ?? entry.token_count) as Record<string, unknown> | undefined;
|
|
2661
|
+
const msg = entry.message as Record<string, unknown> | undefined;
|
|
2662
|
+
if (addUsageBlock({ model: '', totals: { in: 0, out: 0, cw: 0, cr: 0 } }, tokenCount)) sawExplicit = true;
|
|
2663
|
+
if (addUsageBlock({ model: '', totals: { in: 0, out: 0, cw: 0, cr: 0 } }, msg?.usage as Record<string, unknown>)) sawExplicit = true;
|
|
2664
|
+
if (addUsageBlock({ model: '', totals: { in: 0, out: 0, cw: 0, cr: 0 } }, entry.usage as Record<string, unknown>)) sawExplicit = true;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
for (const entry of entries) {
|
|
2668
|
+
const role = String(entry.role || entry.type || '');
|
|
2669
|
+
const modelInfo = entry.modelInfo as Record<string, unknown> | undefined;
|
|
2670
|
+
const model = (typeof modelInfo?.modelName === 'string' && modelInfo.modelName)
|
|
2671
|
+
|| (typeof modelInfo?.model === 'string' && modelInfo.model)
|
|
2672
|
+
|| (typeof entry.model === 'string' && entry.model)
|
|
2673
|
+
|| msgModel(entry);
|
|
2674
|
+
if (model) result.model = model;
|
|
2675
|
+
|
|
2676
|
+
const tokenCount = (entry.tokenCount ?? entry.token_count) as Record<string, unknown> | undefined;
|
|
2677
|
+
const msg = entry.message as Record<string, unknown> | undefined;
|
|
2678
|
+
const hadLine = addUsageBlock(result, tokenCount)
|
|
2679
|
+
|| addUsageBlock(result, msg?.usage as Record<string, unknown>)
|
|
2680
|
+
|| addUsageBlock(result, entry.usage as Record<string, unknown>);
|
|
2681
|
+
|
|
2682
|
+
if (hadLine || sawExplicit) continue;
|
|
2683
|
+
|
|
2684
|
+
const text = usageTextFromTranscriptEntry(entry);
|
|
2685
|
+
if (!text) continue;
|
|
2686
|
+
const est = Math.ceil(text.length / 4);
|
|
2687
|
+
if (role === 'user' || entry.type === 'user') result.totals.in += est;
|
|
2688
|
+
else if (role === 'assistant' || entry.type === 'assistant') result.totals.out += est;
|
|
2232
2689
|
}
|
|
2233
2690
|
} catch {}
|
|
2234
2691
|
return result;
|
|
2235
2692
|
}
|
|
2236
2693
|
|
|
2694
|
+
/** Emit a usage_tick telemetry row when transcript usage is non-zero. */
|
|
2695
|
+
export function emitUsageTick(params: {
|
|
2696
|
+
sessionId: string;
|
|
2697
|
+
usage: { model: string; totals: Record<string, number> };
|
|
2698
|
+
hookType: string;
|
|
2699
|
+
gitRepo?: string;
|
|
2700
|
+
modelFallback?: string;
|
|
2701
|
+
}): void {
|
|
2702
|
+
const { sessionId, usage, hookType, gitRepo, modelFallback } = params;
|
|
2703
|
+
if (!sessionId || usage.totals.in + usage.totals.out <= 0) return;
|
|
2704
|
+
let model = usage.model || modelFallback || 'unknown';
|
|
2705
|
+
if (modelFallback && !usage.model) model = modelFallback;
|
|
2706
|
+
if (isCursorHookFormat() && model && !model.startsWith('cursor/') && model !== 'cursor') {
|
|
2707
|
+
model = 'cursor/' + model;
|
|
2708
|
+
}
|
|
2709
|
+
appendLocalTelemetry({
|
|
2710
|
+
capture_type: 'usage_tick',
|
|
2711
|
+
event_id: 'usage_' + Date.now() + '_' + process.pid,
|
|
2712
|
+
hook_type: hookType,
|
|
2713
|
+
verdict: 'allow',
|
|
2714
|
+
severity: 'none',
|
|
2715
|
+
session_id: sessionId,
|
|
2716
|
+
model,
|
|
2717
|
+
cc_model: model,
|
|
2718
|
+
cc_usage: {
|
|
2719
|
+
input_tokens: usage.totals.in,
|
|
2720
|
+
output_tokens: usage.totals.out,
|
|
2721
|
+
cache_creation_input_tokens: usage.totals.cw,
|
|
2722
|
+
cache_read_input_tokens: usage.totals.cr,
|
|
2723
|
+
},
|
|
2724
|
+
...(gitRepo ? { repo: gitRepo } : {}),
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
export function cursorModelFromPayload(payload: Record<string, unknown>): string {
|
|
2729
|
+
const rawModel = String(payload.model ?? payload.model_id ?? '');
|
|
2730
|
+
if (!rawModel) return 'cursor';
|
|
2731
|
+
return rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel;
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2237
2734
|
// \u2500\u2500\u2500 Scan Finding Dispatch \u2500\u2500\u2500
|
|
2238
2735
|
|
|
2736
|
+
export type ScanFindingInput = {
|
|
2737
|
+
finding_type: 'cwe' | 'cve';
|
|
2738
|
+
finding_id: string;
|
|
2739
|
+
severity?: string;
|
|
2740
|
+
detail?: string;
|
|
2741
|
+
description?: string;
|
|
2742
|
+
package_name?: string;
|
|
2743
|
+
package_version?: string;
|
|
2744
|
+
fixed_version?: string;
|
|
2745
|
+
aliases?: string[];
|
|
2746
|
+
references?: Array<{ type: string; url: string }>;
|
|
2747
|
+
cwe_name?: string;
|
|
2748
|
+
};
|
|
2749
|
+
|
|
2750
|
+
/** Persist open scan_findings rows for a block and flush the telemetry spool. */
|
|
2751
|
+
export function emitBlockScanFindings(
|
|
2752
|
+
jwt: string,
|
|
2753
|
+
captureDepth: string,
|
|
2754
|
+
ctx: { session_id: string; file_path: string; repo?: string },
|
|
2755
|
+
findings: ScanFindingInput[],
|
|
2756
|
+
fallback?: ScanFindingInput,
|
|
2757
|
+
): void {
|
|
2758
|
+
const rows = findings.length > 0 ? findings : (fallback ? [fallback] : []);
|
|
2759
|
+
for (const f of rows) {
|
|
2760
|
+
dispatchFinding(jwt, {
|
|
2761
|
+
session_id: ctx.session_id,
|
|
2762
|
+
file_path: ctx.file_path,
|
|
2763
|
+
repo: ctx.repo,
|
|
2764
|
+
status: 'open',
|
|
2765
|
+
...f,
|
|
2766
|
+
}, captureDepth);
|
|
2767
|
+
}
|
|
2768
|
+
if (rows.length > 0) drainSpool().catch(() => {});
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2239
2771
|
export function dispatchFinding(
|
|
2240
2772
|
jwt: string,
|
|
2241
2773
|
finding: {
|
|
2242
2774
|
session_id: string;
|
|
2243
2775
|
file_path: string;
|
|
2776
|
+
repo?: string;
|
|
2244
2777
|
finding_type: 'cwe' | 'cve';
|
|
2245
2778
|
finding_id: string;
|
|
2246
2779
|
severity?: string;
|
|
@@ -2270,6 +2803,7 @@ export function dispatchFinding(
|
|
|
2270
2803
|
status: finding.status,
|
|
2271
2804
|
session_id: finding.session_id,
|
|
2272
2805
|
file_path: finding.file_path,
|
|
2806
|
+
repo: finding.repo,
|
|
2273
2807
|
package_name: finding.package_name,
|
|
2274
2808
|
package_version: finding.package_version,
|
|
2275
2809
|
fixed_version: finding.fixed_version,
|
|
@@ -2411,11 +2945,12 @@ import {
|
|
|
2411
2945
|
readStdin, extractTranscript, readLastPrompt, findNearestDeps, filePathFromToolInput,
|
|
2412
2946
|
appendSessionAction, readSessionLog, compressSessionLog, log,
|
|
2413
2947
|
outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, GATEWAY_URL,
|
|
2414
|
-
logGraderUnavailable, filterRules, ruleFilterText, normalizeMode,
|
|
2948
|
+
logGraderUnavailable, filterRules, ruleFilterText, normalizeMode, countEditLineDelta,
|
|
2949
|
+
captureLineMetrics, cursorModelFromPayload,
|
|
2415
2950
|
type HookConfig, type Rule,
|
|
2416
2951
|
} from './_synkro-common.ts';
|
|
2417
2952
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2418
|
-
import { basename,
|
|
2953
|
+
import { basename, join } from 'node:path';
|
|
2419
2954
|
|
|
2420
2955
|
const agentKind = process.env.SYNKRO_HOOK_FORMAT === 'cursor' ? 'cursor' : 'claude_code';
|
|
2421
2956
|
|
|
@@ -2438,7 +2973,7 @@ async function main() {
|
|
|
2438
2973
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
2439
2974
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
2440
2975
|
const permissionMode = payload.permission_mode || '';
|
|
2441
|
-
const transcriptPath = payload
|
|
2976
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
2442
2977
|
|
|
2443
2978
|
const filePath = filePathFromToolInput(toolInput);
|
|
2444
2979
|
if (!filePath) { outputEmpty(); return; }
|
|
@@ -2469,21 +3004,44 @@ async function main() {
|
|
|
2469
3004
|
if (toolInput.edits != null) diffField.edits = toolInput.edits;
|
|
2470
3005
|
}
|
|
2471
3006
|
|
|
3007
|
+
const fullPath = filePath.startsWith('/') ? filePath : (cwd ? join(cwd, filePath) : filePath);
|
|
3008
|
+
let existingContent = '';
|
|
3009
|
+
if (toolName === 'Write' && existsSync(fullPath)) {
|
|
3010
|
+
try { existingContent = readFileSync(fullPath, 'utf-8'); } catch {}
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
// Compute lines added/removed (Write = net delta vs file on disk)
|
|
3014
|
+
const { linesAdded, linesRemoved } = countEditLineDelta(
|
|
3015
|
+
toolName === 'Write'
|
|
3016
|
+
? {
|
|
3017
|
+
writeContent: toolInput.content || toolInput.contents || '',
|
|
3018
|
+
existingContent,
|
|
3019
|
+
}
|
|
3020
|
+
: {
|
|
3021
|
+
old_string: toolInput.old_string,
|
|
3022
|
+
new_string: toolInput.new_string,
|
|
3023
|
+
edits: Array.isArray(toolInput.edits) ? toolInput.edits : undefined,
|
|
3024
|
+
},
|
|
3025
|
+
);
|
|
3026
|
+
const lineMetrics = captureLineMetrics(agentKind, toolName, linesAdded, linesRemoved);
|
|
3027
|
+
|
|
2472
3028
|
// Read file before edit for cloud payload
|
|
2473
3029
|
let fileBefore = '';
|
|
2474
|
-
if (toolName !== 'Write' && filePath && isPathUnder(filePath, cwd || '.') && existsSync(
|
|
2475
|
-
try { fileBefore = readFileSync(
|
|
3030
|
+
if (toolName !== 'Write' && filePath && isPathUnder(filePath, cwd || '.') && existsSync(fullPath)) {
|
|
3031
|
+
try { fileBefore = readFileSync(fullPath, 'utf-8').slice(0, 65536); } catch {}
|
|
2476
3032
|
}
|
|
2477
3033
|
|
|
2478
3034
|
// Extract transcript context
|
|
2479
3035
|
const transcript = extractTranscript(transcriptPath);
|
|
2480
3036
|
const lastPrompt = readLastPrompt(sessionId);
|
|
2481
3037
|
|
|
3038
|
+
const captureModel = agentKind === 'cursor'
|
|
3039
|
+
? cursorModelFromPayload(payload)
|
|
3040
|
+
: (transcript.ccModel || String(payload.model ?? payload.model_id ?? ''));
|
|
3041
|
+
|
|
2482
3042
|
// Model detection: prefer transcript (CC), fall back to payload (Cursor)
|
|
2483
3043
|
if (!transcript.ccModel) {
|
|
2484
|
-
|
|
2485
|
-
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']);
|
|
2486
|
-
transcript.ccModel = rawModel ? (KNOWN_MODELS.has(rawModel) || rawModel.startsWith('claude-') || rawModel.startsWith('gpt-') || rawModel.startsWith('gemini-') || rawModel.startsWith('o1') || rawModel.startsWith('o3') ? rawModel : 'cursor/' + rawModel) : (agentKind === 'cursor' ? 'cursor' : '');
|
|
3044
|
+
transcript.ccModel = captureModel;
|
|
2487
3045
|
}
|
|
2488
3046
|
|
|
2489
3047
|
// Load config and decide route
|
|
@@ -2542,8 +3100,8 @@ async function main() {
|
|
|
2542
3100
|
dispatchCapture(jwt, 'edit', 'block', verdict.severity || 'critical', verdict.category || 'security',
|
|
2543
3101
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
2544
3102
|
command: editContent, reasoning: guardReason,
|
|
2545
|
-
rulesChecked:
|
|
2546
|
-
ccModel:
|
|
3103
|
+
rulesChecked: relevantRules, violatedRules,
|
|
3104
|
+
ccModel: captureModel, ...lineMetrics,
|
|
2547
3105
|
});
|
|
2548
3106
|
outputJson({
|
|
2549
3107
|
systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 blocked: ' + guardReason,
|
|
@@ -2556,8 +3114,8 @@ async function main() {
|
|
|
2556
3114
|
dispatchCapture(jwt, 'edit', 'pass', 'clean', verdict.category || 'trivial_edit',
|
|
2557
3115
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
2558
3116
|
command: editContent, reasoning: verdict.reason || 'no policy violations detected',
|
|
2559
|
-
rulesChecked:
|
|
2560
|
-
ccModel:
|
|
3117
|
+
rulesChecked: relevantRules, violatedRules: [],
|
|
3118
|
+
ccModel: captureModel, ...lineMetrics,
|
|
2561
3119
|
});
|
|
2562
3120
|
const passLine = tagStr + ' editGuard ' + fileShort + ' \u2192 pass: ' + (verdict.reason || 'no policy violations detected');
|
|
2563
3121
|
outputJson({ systemMessage: passLine, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro local edit judge. ' + (verdict.reason || 'no policy violations detected') } });
|
|
@@ -2636,7 +3194,7 @@ main();
|
|
|
2636
3194
|
import {
|
|
2637
3195
|
loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
|
|
2638
3196
|
localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
|
|
2639
|
-
outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, filePathFromToolInput, dispatchFinding, dispatchCapture, GATEWAY_URL,
|
|
3197
|
+
outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
|
|
2640
3198
|
logGraderUnavailable,
|
|
2641
3199
|
} from './_synkro-common.ts';
|
|
2642
3200
|
import { basename, extname, resolve, join, dirname } from 'node:path';
|
|
@@ -2644,6 +3202,12 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
|
2644
3202
|
|
|
2645
3203
|
const agentKind = process.env.SYNKRO_HOOK_FORMAT === 'cursor' ? 'cursor' : 'claude_code';
|
|
2646
3204
|
|
|
3205
|
+
function detectModel(payload: Record<string, unknown>): string {
|
|
3206
|
+
const raw = String(payload.model ?? payload.model_id ?? '');
|
|
3207
|
+
if (agentKind === 'cursor') return raw ? (raw.startsWith('cursor/') ? raw : 'cursor/' + raw) : 'cursor';
|
|
3208
|
+
return raw || '';
|
|
3209
|
+
}
|
|
3210
|
+
|
|
2647
3211
|
interface PackageCapability {
|
|
2648
3212
|
name: string;
|
|
2649
3213
|
description: string;
|
|
@@ -2767,11 +3331,12 @@ async function main() {
|
|
|
2767
3331
|
const sessionId = hookSessionId(payload);
|
|
2768
3332
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
2769
3333
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
2770
|
-
const transcriptPath = payload
|
|
3334
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
2771
3335
|
|
|
2772
3336
|
const filePath = filePathFromToolInput(toolInput);
|
|
2773
3337
|
if (!filePath) { outputEmpty(); return; }
|
|
2774
3338
|
const gitRepo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
|
|
3339
|
+
const ccModel = detectModel(payload);
|
|
2775
3340
|
|
|
2776
3341
|
if (filePath.includes('/.synkro/hooks/')) { outputEmpty(); return; }
|
|
2777
3342
|
|
|
@@ -2981,24 +3546,31 @@ async function main() {
|
|
|
2981
3546
|
const fixHint = fixLines.length > 0 ? '\n' + fixLines.join('\n') : '';
|
|
2982
3547
|
const ctx = 'CWE: ' + denyDetail + fixHint + '\nFix all issues before retrying. Do NOT ask the user to make the edit manually — resolve the weakness in code yourself.';
|
|
2983
3548
|
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
3549
|
+
emitBlockScanFindings(
|
|
3550
|
+
jwt,
|
|
3551
|
+
config.captureDepth,
|
|
3552
|
+
{ session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
|
|
3553
|
+
activeCweIds.map((cweId) => ({
|
|
3554
|
+
finding_type: 'cwe' as const,
|
|
2989
3555
|
finding_id: cweId,
|
|
2990
3556
|
severity: verdict.severity || 'high',
|
|
2991
|
-
status: 'open',
|
|
2992
3557
|
detail: verdict.reason || 'code weakness detected',
|
|
2993
3558
|
cwe_name: cweNameMap.get(cweId.toUpperCase()) || undefined,
|
|
2994
|
-
},
|
|
2995
|
-
|
|
3559
|
+
})),
|
|
3560
|
+
{
|
|
3561
|
+
finding_type: 'cwe',
|
|
3562
|
+
finding_id: activeCweIds[0] || 'CWE-UNKNOWN',
|
|
3563
|
+
severity: verdict.severity || 'high',
|
|
3564
|
+
detail: verdict.reason || denyDetail,
|
|
3565
|
+
},
|
|
3566
|
+
);
|
|
2996
3567
|
|
|
2997
3568
|
dispatchCapture(jwt, 'cwe', 'block', verdict.severity || 'high', verdict.category || 'security',
|
|
2998
3569
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
2999
3570
|
command: 'edit ' + filePath,
|
|
3000
3571
|
reasoning: denyDetail,
|
|
3001
3572
|
violatedRules: activeCweIds,
|
|
3573
|
+
ccModel,
|
|
3002
3574
|
});
|
|
3003
3575
|
|
|
3004
3576
|
outputJson({
|
|
@@ -3076,25 +3648,34 @@ async function main() {
|
|
|
3076
3648
|
const denyDetail = '[' + displayIds + '] ' + (findings[0]?.reason || 'code weakness detected');
|
|
3077
3649
|
const ctx = 'CWE: ' + denyDetail + fixHint + '\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the weakness in code yourself.';
|
|
3078
3650
|
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3651
|
+
emitBlockScanFindings(
|
|
3652
|
+
jwt,
|
|
3653
|
+
config.captureDepth,
|
|
3654
|
+
{ session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
|
|
3655
|
+
activeCweIds.map((cweId) => {
|
|
3656
|
+
const f = findings.find((x: any) => x.cwe === cweId);
|
|
3657
|
+
return {
|
|
3658
|
+
finding_type: 'cwe' as const,
|
|
3659
|
+
finding_id: cweId,
|
|
3660
|
+
severity: f?.severity || 'high',
|
|
3661
|
+
detail: f?.reason || 'code weakness detected',
|
|
3662
|
+
cwe_name: f?.name || undefined,
|
|
3663
|
+
};
|
|
3664
|
+
}),
|
|
3665
|
+
{
|
|
3084
3666
|
finding_type: 'cwe',
|
|
3085
|
-
finding_id:
|
|
3086
|
-
severity:
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
}, config.captureDepth);
|
|
3091
|
-
}
|
|
3667
|
+
finding_id: activeCweIds[0] || 'CWE-UNKNOWN',
|
|
3668
|
+
severity: findings[0]?.severity || 'high',
|
|
3669
|
+
detail: denyDetail,
|
|
3670
|
+
},
|
|
3671
|
+
);
|
|
3092
3672
|
|
|
3093
3673
|
dispatchCapture(jwt, 'cwe', 'block', findings[0]?.severity || 'high', 'security',
|
|
3094
3674
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3095
3675
|
command: 'edit ' + filePath,
|
|
3096
3676
|
reasoning: denyDetail,
|
|
3097
3677
|
violatedRules: activeCweIds,
|
|
3678
|
+
ccModel,
|
|
3098
3679
|
});
|
|
3099
3680
|
|
|
3100
3681
|
outputJson({
|
|
@@ -3126,9 +3707,10 @@ main();
|
|
|
3126
3707
|
import {
|
|
3127
3708
|
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
|
|
3128
3709
|
reconstructContent, readStdin, findNearestDeps, filePathFromToolInput, log,
|
|
3129
|
-
outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, GATEWAY_URL,
|
|
3710
|
+
outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, extractTranscript, emitBlockScanFindings, GATEWAY_URL,
|
|
3130
3711
|
} from './_synkro-common.ts';
|
|
3131
3712
|
import { basename } from 'node:path';
|
|
3713
|
+
import { readFileSync } from 'node:fs';
|
|
3132
3714
|
|
|
3133
3715
|
const MANIFEST_NAMES = new Set([
|
|
3134
3716
|
'package.json', 'requirements.txt', 'requirements-dev.txt', 'requirements-test.txt',
|
|
@@ -3160,7 +3742,7 @@ async function main() {
|
|
|
3160
3742
|
const sessionId = hookSessionId(payload);
|
|
3161
3743
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
3162
3744
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
3163
|
-
const transcriptPath = payload
|
|
3745
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
3164
3746
|
|
|
3165
3747
|
const filePath = filePathFromToolInput(toolInput);
|
|
3166
3748
|
if (!filePath) { outputEmpty(); return; }
|
|
@@ -3199,6 +3781,97 @@ async function main() {
|
|
|
3199
3781
|
deps = findNearestDeps(filePath);
|
|
3200
3782
|
}
|
|
3201
3783
|
|
|
3784
|
+
// For package.json edits, diff the proposed content against the file on
|
|
3785
|
+
// disk and scan only the added/upgraded packages. Catches the case the
|
|
3786
|
+
// bash installScan misses: a bare \`pnpm install\` after a manifest bump
|
|
3787
|
+
// arrives with no package tokens on the cmdline, so the install-time
|
|
3788
|
+
// scanner has nothing to feed OSV. By gating at the edit, the install
|
|
3789
|
+
// that follows is implicitly safe \u2014 there's nothing new to scan.
|
|
3790
|
+
if (fileShort === 'package.json') {
|
|
3791
|
+
try {
|
|
3792
|
+
let currentContent = '';
|
|
3793
|
+
try { currentContent = readFileSync(filePath, 'utf-8'); } catch {}
|
|
3794
|
+
const extractMod: any = await import(new URL('./installExtractCore.ts', import.meta.url).href);
|
|
3795
|
+
const deltaPkgs = extractMod.extractPackageJsonDelta(currentContent, proposed) as Array<{ name: string; version: string; ecosystem: string }>;
|
|
3796
|
+
if (deltaPkgs.length > 0) {
|
|
3797
|
+
const pkgScanResp = await fetch(GATEWAY_URL + '/api/v1/pkg-scan', {
|
|
3798
|
+
method: 'POST',
|
|
3799
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
3800
|
+
body: JSON.stringify({ command: 'edit ' + filePath, packages: deltaPkgs }),
|
|
3801
|
+
signal: AbortSignal.timeout(8000),
|
|
3802
|
+
}).then(r => r.json()).catch(() => null) as any;
|
|
3803
|
+
const action = pkgScanResp?.action || 'allow';
|
|
3804
|
+
if (action === 'block') {
|
|
3805
|
+
const pkgResults = Array.isArray(pkgScanResp?.packages) ? pkgScanResp.packages : [];
|
|
3806
|
+
const summary = pkgScanResp?.summary || (deltaPkgs.length + ' package(s) flagged');
|
|
3807
|
+
const violatedIds: string[] = [];
|
|
3808
|
+
const cveFindings: Array<{
|
|
3809
|
+
finding_type: 'cve'; finding_id: string; severity?: string; detail?: string;
|
|
3810
|
+
description?: string; package_name?: string; package_version?: string;
|
|
3811
|
+
fixed_version?: string; aliases?: string[]; references?: Array<{ type: string; url: string }>;
|
|
3812
|
+
}> = [];
|
|
3813
|
+
for (const p of pkgResults) {
|
|
3814
|
+
if (Array.isArray(p?.findings)) {
|
|
3815
|
+
for (const f of p.findings) {
|
|
3816
|
+
const aliasCve = Array.isArray(f?.aliases) ? f.aliases.find((a: string) => a.startsWith('CVE-')) : null;
|
|
3817
|
+
const fid = aliasCve || f?.id || 'unknown';
|
|
3818
|
+
if (fid && !violatedIds.includes(fid)) violatedIds.push(fid);
|
|
3819
|
+
cveFindings.push({
|
|
3820
|
+
finding_type: 'cve',
|
|
3821
|
+
finding_id: fid,
|
|
3822
|
+
severity: f?.severity || 'high',
|
|
3823
|
+
detail: f?.summary || f?.title || 'vulnerable dependency',
|
|
3824
|
+
description: f?.details || undefined,
|
|
3825
|
+
package_name: p?.name,
|
|
3826
|
+
package_version: p?.version,
|
|
3827
|
+
fixed_version: f?.fixed || undefined,
|
|
3828
|
+
aliases: f?.aliases || undefined,
|
|
3829
|
+
references: f?.references || undefined,
|
|
3830
|
+
});
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
emitBlockScanFindings(
|
|
3835
|
+
jwt,
|
|
3836
|
+
config.captureDepth,
|
|
3837
|
+
{ session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
|
|
3838
|
+
cveFindings,
|
|
3839
|
+
{
|
|
3840
|
+
finding_type: 'cve',
|
|
3841
|
+
finding_id: violatedIds[0] || 'SYNKRO_PKGSCAN',
|
|
3842
|
+
severity: 'critical',
|
|
3843
|
+
detail: summary.slice(0, 500),
|
|
3844
|
+
package_name: deltaPkgs[0]?.name,
|
|
3845
|
+
package_version: deltaPkgs[0]?.version,
|
|
3846
|
+
},
|
|
3847
|
+
);
|
|
3848
|
+
// Model name isn't in the PreToolUse payload \u2014 pull it from the
|
|
3849
|
+
// transcript (last assistant entry's message.model). Matches what
|
|
3850
|
+
// the bash judge does at capture time.
|
|
3851
|
+
let ccModel: string | undefined = String(payload.model ?? payload.model_id ?? '') || undefined;
|
|
3852
|
+
if (!ccModel && transcriptPath) {
|
|
3853
|
+
try { ccModel = extractTranscript(transcriptPath).ccModel || undefined; } catch {}
|
|
3854
|
+
}
|
|
3855
|
+
dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
|
|
3856
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3857
|
+
command: 'edit ' + filePath, reasoning: summary.slice(0, 200),
|
|
3858
|
+
violatedRules: violatedIds,
|
|
3859
|
+
ccModel,
|
|
3860
|
+
});
|
|
3861
|
+
const tagStr = '[synkro:' + rt + ':pkgScan]';
|
|
3862
|
+
const denyReason = tagStr + ' BLOCKED: ' + summary + '\\nDo not write this version. Pick a fixed/safe version instead.';
|
|
3863
|
+
outputJson({
|
|
3864
|
+
systemMessage: denyReason,
|
|
3865
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: denyReason, additionalContext: denyReason },
|
|
3866
|
+
});
|
|
3867
|
+
return;
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
} catch (err) {
|
|
3871
|
+
log('pkgDeltaScan error: ' + String(err));
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3202
3875
|
// CVE scan via OSV API
|
|
3203
3876
|
const cveBody = {
|
|
3204
3877
|
file_path: filePath,
|
|
@@ -3222,15 +3895,12 @@ async function main() {
|
|
|
3222
3895
|
|
|
3223
3896
|
const findings = Array.isArray(cveResp?.findings) ? cveResp.findings : [];
|
|
3224
3897
|
if (findings.length > 0) {
|
|
3225
|
-
|
|
3898
|
+
const cveRows = findings.slice(0, 10).map((f: any) => {
|
|
3226
3899
|
const cveId = (f.aliases || []).find((a: string) => a.startsWith('CVE-')) || f.id || 'unknown';
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
file_path: filePath,
|
|
3230
|
-
finding_type: 'cve',
|
|
3900
|
+
return {
|
|
3901
|
+
finding_type: 'cve' as const,
|
|
3231
3902
|
finding_id: cveId,
|
|
3232
3903
|
severity: f.severity || 'high',
|
|
3233
|
-
status: 'open',
|
|
3234
3904
|
detail: f.summary || f.title || 'vulnerable dependency',
|
|
3235
3905
|
description: f.details || undefined,
|
|
3236
3906
|
package_name: f.package || undefined,
|
|
@@ -3238,8 +3908,15 @@ async function main() {
|
|
|
3238
3908
|
fixed_version: f.fixed || undefined,
|
|
3239
3909
|
aliases: f.aliases || undefined,
|
|
3240
3910
|
references: f.references || undefined,
|
|
3241
|
-
}
|
|
3242
|
-
}
|
|
3911
|
+
};
|
|
3912
|
+
});
|
|
3913
|
+
emitBlockScanFindings(
|
|
3914
|
+
jwt,
|
|
3915
|
+
config.captureDepth,
|
|
3916
|
+
{ session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
|
|
3917
|
+
cveRows,
|
|
3918
|
+
cveRows[0],
|
|
3919
|
+
);
|
|
3243
3920
|
|
|
3244
3921
|
const formatFinding = (f: any): string => {
|
|
3245
3922
|
const id = (f.aliases || []).find((a: string) => a.startsWith('CVE-')) || f.id || '?';
|
|
@@ -3285,8 +3962,9 @@ main();
|
|
|
3285
3962
|
INSTALL_SCAN_TS = `#!/usr/bin/env bun
|
|
3286
3963
|
import {
|
|
3287
3964
|
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
|
|
3288
|
-
readStdin, runInstallScan,
|
|
3965
|
+
readStdin, runInstallScan, emitBlockScanFindings, dispatchCapture, hashCommand,
|
|
3289
3966
|
outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, log, GATEWAY_URL,
|
|
3967
|
+
resolveTranscriptPath, isCursorHookFormat,
|
|
3290
3968
|
} from './_synkro-common.ts';
|
|
3291
3969
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
3292
3970
|
import { join } from 'node:path';
|
|
@@ -3326,30 +4004,41 @@ async function main() {
|
|
|
3326
4004
|
const sessionId = hookSessionId(payload);
|
|
3327
4005
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
3328
4006
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
3329
|
-
const transcriptPath = payload
|
|
4007
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
3330
4008
|
const repo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
|
|
3331
4009
|
const config = await loadConfig(jwt);
|
|
3332
4010
|
const rt = await route(config);
|
|
3333
4011
|
const tagStr = tag(rt, config);
|
|
3334
4012
|
|
|
3335
4013
|
const rawModel = String(payload.model ?? payload.model_id ?? '');
|
|
3336
|
-
const
|
|
3337
|
-
const model =
|
|
4014
|
+
const isCursor = isCursorHookFormat();
|
|
4015
|
+
const model = isCursor ? (rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor') : (rawModel || '');
|
|
3338
4016
|
|
|
3339
4017
|
if (scan.action === 'block') {
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
4018
|
+
const pkgLabel = scan.scannedLabel || '';
|
|
4019
|
+
const pkgName = pkgLabel.split('@')[0] || undefined;
|
|
4020
|
+
const pkgVersion = pkgLabel.includes('@') ? pkgLabel.split('@').slice(1).join('@') : undefined;
|
|
4021
|
+
emitBlockScanFindings(
|
|
4022
|
+
jwt,
|
|
4023
|
+
config.captureDepth,
|
|
4024
|
+
{ session_id: sessionId, file_path: command, repo: repo || undefined },
|
|
4025
|
+
scan.findings.map((f) => ({
|
|
3344
4026
|
finding_type: 'cve' as const,
|
|
3345
4027
|
finding_id: f.advisoryId + ':' + f.name,
|
|
3346
4028
|
severity: f.severity,
|
|
3347
|
-
status: 'open',
|
|
3348
4029
|
detail: f.detail,
|
|
3349
4030
|
package_name: f.name,
|
|
3350
4031
|
package_version: f.version,
|
|
3351
|
-
},
|
|
3352
|
-
|
|
4032
|
+
})),
|
|
4033
|
+
{
|
|
4034
|
+
finding_type: 'cve',
|
|
4035
|
+
finding_id: 'SYNKRO_PKGSCAN',
|
|
4036
|
+
severity: 'critical',
|
|
4037
|
+
detail: scan.blockContext.slice(0, 500) || scan.summary || 'Blocked package install',
|
|
4038
|
+
package_name: pkgName,
|
|
4039
|
+
package_version: pkgVersion,
|
|
4040
|
+
},
|
|
4041
|
+
);
|
|
3353
4042
|
dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
|
|
3354
4043
|
'Bash', repo, sessionId, config.captureDepth, {
|
|
3355
4044
|
command, reasoning: scan.blockContext.slice(0, 200),
|
|
@@ -3442,7 +4131,7 @@ async function main() {
|
|
|
3442
4131
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
3443
4132
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
3444
4133
|
const permissionMode = payload.permission_mode || '';
|
|
3445
|
-
const transcriptPath = payload
|
|
4134
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
3446
4135
|
const transcript = extractTranscript(transcriptPath);
|
|
3447
4136
|
|
|
3448
4137
|
let command = '';
|
|
@@ -3512,20 +4201,20 @@ async function main() {
|
|
|
3512
4201
|
return;
|
|
3513
4202
|
}
|
|
3514
4203
|
|
|
3515
|
-
// ─── Install protection:
|
|
3516
|
-
// The install-scan hook (INSTALL_SCAN_TS) runs before this hook, calls
|
|
3517
|
-
// runInstallScan(), outputs its own system message, and caches the result.
|
|
3518
|
-
// We just read the cache to feed scanConcern into the grader prompt so
|
|
3519
|
-
// the consent-carryover flow works.
|
|
4204
|
+
// ─── Install protection: install-scan runs first and owns block traces ───
|
|
3520
4205
|
let scanConcern = '';
|
|
3521
4206
|
let scanBlockContext = '';
|
|
3522
4207
|
if (toolName === 'Bash') {
|
|
3523
4208
|
const scan = readCachedScan(command);
|
|
3524
|
-
if (scan
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
4209
|
+
if (scan?.action === 'block') {
|
|
4210
|
+
log('bashGuard deferring to install-scan block: ' + cmdShort);
|
|
4211
|
+
outputEmpty();
|
|
4212
|
+
return;
|
|
4213
|
+
}
|
|
4214
|
+
if (scan?.action === 'warn') {
|
|
4215
|
+
scanBlockContext = scan.blockContext || scan.summary || '';
|
|
4216
|
+
scanConcern = 'PACKAGE SCANNER WARNING: ' + scanBlockContext
|
|
4217
|
+
+ ' Mention this to the user before proceeding.';
|
|
3529
4218
|
}
|
|
3530
4219
|
}
|
|
3531
4220
|
|
|
@@ -3590,7 +4279,7 @@ async function main() {
|
|
|
3590
4279
|
});
|
|
3591
4280
|
dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
|
|
3592
4281
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3593
|
-
command, reasoning: guardReason, rulesChecked:
|
|
4282
|
+
command, reasoning: guardReason, rulesChecked: relevantRules, violatedRules,
|
|
3594
4283
|
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
3595
4284
|
});
|
|
3596
4285
|
} else {
|
|
@@ -3602,7 +4291,7 @@ async function main() {
|
|
|
3602
4291
|
dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'trivial_utility',
|
|
3603
4292
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3604
4293
|
command, reasoning: verdict.reason || 'no policy violations detected',
|
|
3605
|
-
rulesChecked:
|
|
4294
|
+
rulesChecked: relevantRules, violatedRules: [],
|
|
3606
4295
|
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
3607
4296
|
});
|
|
3608
4297
|
}
|
|
@@ -3700,7 +4389,7 @@ async function main() {
|
|
|
3700
4389
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
3701
4390
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
3702
4391
|
const permissionMode = payload.permission_mode || '';
|
|
3703
|
-
const transcriptPath = payload
|
|
4392
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
3704
4393
|
|
|
3705
4394
|
const prompt = toolInput.prompt || '';
|
|
3706
4395
|
const description = toolInput.description || '';
|
|
@@ -3723,8 +4412,11 @@ async function main() {
|
|
|
3723
4412
|
|
|
3724
4413
|
if (!transcript.ccModel) {
|
|
3725
4414
|
const rawModel = String(payload.model ?? payload.model_id ?? '');
|
|
3726
|
-
|
|
3727
|
-
|
|
4415
|
+
if (agentKind === 'cursor') {
|
|
4416
|
+
transcript.ccModel = rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor';
|
|
4417
|
+
} else {
|
|
4418
|
+
transcript.ccModel = rawModel || '';
|
|
4419
|
+
}
|
|
3728
4420
|
}
|
|
3729
4421
|
|
|
3730
4422
|
const config = await loadConfig(jwt);
|
|
@@ -3782,7 +4474,7 @@ async function main() {
|
|
|
3782
4474
|
});
|
|
3783
4475
|
dispatchCapture(jwt, 'agent', 'block', verdict.severity || 'critical', verdict.category || 'security',
|
|
3784
4476
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3785
|
-
command: agentContent, reasoning: guardReason, rulesChecked:
|
|
4477
|
+
command: agentContent, reasoning: guardReason, rulesChecked: relevantRules, violatedRules,
|
|
3786
4478
|
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
3787
4479
|
});
|
|
3788
4480
|
} else {
|
|
@@ -3791,7 +4483,7 @@ async function main() {
|
|
|
3791
4483
|
dispatchCapture(jwt, 'agent', 'pass', 'clean', verdict.category || 'subagent_spawn',
|
|
3792
4484
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3793
4485
|
command: agentContent, reasoning: verdict.reason || 'no policy violations detected',
|
|
3794
|
-
rulesChecked:
|
|
4486
|
+
rulesChecked: relevantRules, violatedRules: [],
|
|
3795
4487
|
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
3796
4488
|
});
|
|
3797
4489
|
}
|
|
@@ -3917,7 +4609,7 @@ async function main() {
|
|
|
3917
4609
|
const sessionId = hookSessionId(payload);
|
|
3918
4610
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
3919
4611
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
3920
|
-
const transcriptPath = payload
|
|
4612
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
3921
4613
|
const gitRepo = detectRepo(cwd, transcriptPath, plan, workspaceRoots);
|
|
3922
4614
|
|
|
3923
4615
|
appendSessionAction(sessionId, { ts: new Date().toISOString(), tool: 'ExitPlanMode', summary: 'plan review: ' + plan.slice(0, 80) });
|
|
@@ -3930,8 +4622,7 @@ async function main() {
|
|
|
3930
4622
|
jwt = await ensureFreshJwt(jwt);
|
|
3931
4623
|
|
|
3932
4624
|
const rawModel = String(payload.model ?? payload.model_id ?? '');
|
|
3933
|
-
const
|
|
3934
|
-
const ccModel = rawModel ? (KNOWN_MODELS.has(rawModel) || rawModel.startsWith('claude-') || rawModel.startsWith('gpt-') || rawModel.startsWith('gemini-') || rawModel.startsWith('o1') || rawModel.startsWith('o3') ? rawModel : 'cursor/' + rawModel) : (agentKind === 'cursor' ? 'cursor' : '');
|
|
4625
|
+
const ccModel = agentKind === 'cursor' ? (rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor') : (rawModel || '');
|
|
3935
4626
|
|
|
3936
4627
|
const config = await loadConfig(jwt);
|
|
3937
4628
|
const rt = await route(config);
|
|
@@ -3974,7 +4665,7 @@ async function main() {
|
|
|
3974
4665
|
dispatchCapture(jwt, 'plan_review', 'advisory', verdict.severity || 'medium', verdict.category || 'general',
|
|
3975
4666
|
'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
|
|
3976
4667
|
command: planContent, reasoning: verdict.reason || 'check org rules',
|
|
3977
|
-
rulesChecked:
|
|
4668
|
+
rulesChecked: relevantRules, violatedRules, ccModel: ccModel || undefined,
|
|
3978
4669
|
});
|
|
3979
4670
|
} else {
|
|
3980
4671
|
const reviewMsg = verdict.reason || 'no relevant org rules for this plan';
|
|
@@ -3984,7 +4675,7 @@ async function main() {
|
|
|
3984
4675
|
dispatchCapture(jwt, 'plan_review', 'clean', 'clean', verdict.category || 'general',
|
|
3985
4676
|
'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
|
|
3986
4677
|
command: planContent, reasoning: reviewMsg,
|
|
3987
|
-
rulesChecked:
|
|
4678
|
+
rulesChecked: relevantRules, violatedRules: [], ccModel: ccModel || undefined,
|
|
3988
4679
|
});
|
|
3989
4680
|
}
|
|
3990
4681
|
return;
|
|
@@ -4032,7 +4723,8 @@ main();
|
|
|
4032
4723
|
STOP_SUMMARY_TS = `#!/usr/bin/env bun
|
|
4033
4724
|
import {
|
|
4034
4725
|
loadJwt, detectRepo, loadConfig, tag, readStdin, aggregateUsage, cleanupSessionLog,
|
|
4035
|
-
outputJson, outputEmpty,
|
|
4726
|
+
outputJson, outputEmpty, shipCloud, setupCursorHookSignals, hookSessionId, GATEWAY_URL,
|
|
4727
|
+
resolveTranscriptPath, emitUsageTick, cursorModelFromPayload, log,
|
|
4036
4728
|
} from './_synkro-common.ts';
|
|
4037
4729
|
|
|
4038
4730
|
async function main() {
|
|
@@ -4047,35 +4739,16 @@ async function main() {
|
|
|
4047
4739
|
|
|
4048
4740
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
4049
4741
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
4050
|
-
const transcriptPath = payload
|
|
4742
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
4051
4743
|
const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
|
|
4744
|
+
const modelFallback = cursorModelFromPayload(payload);
|
|
4052
4745
|
|
|
4053
4746
|
let jwt = loadJwt();
|
|
4054
4747
|
if (!jwt) { outputEmpty(); return; }
|
|
4055
4748
|
|
|
4056
4749
|
if (transcriptPath) {
|
|
4057
|
-
const usage = aggregateUsage(transcriptPath);
|
|
4058
|
-
|
|
4059
|
-
const usageBody = {
|
|
4060
|
-
capture_type: 'usage_tick',
|
|
4061
|
-
event_id: 'usage_' + Date.now() + '_' + process.pid,
|
|
4062
|
-
hook_type: 'stop',
|
|
4063
|
-
verdict: 'allow',
|
|
4064
|
-
severity: 'none',
|
|
4065
|
-
model: usage.model || 'unknown',
|
|
4066
|
-
cc_model: usage.model || '',
|
|
4067
|
-
cc_usage: {
|
|
4068
|
-
input_tokens: usage.totals.in,
|
|
4069
|
-
output_tokens: usage.totals.out,
|
|
4070
|
-
cache_creation_input_tokens: usage.totals.cw,
|
|
4071
|
-
cache_read_input_tokens: usage.totals.cr,
|
|
4072
|
-
},
|
|
4073
|
-
...(gitRepo ? { repo: gitRepo } : {}),
|
|
4074
|
-
...(sessionId ? { session_id: sessionId } : {}),
|
|
4075
|
-
};
|
|
4076
|
-
appendLocalTelemetry(usageBody);
|
|
4077
|
-
shipCloud(jwt, '/api/v1/hook/capture', usageBody);
|
|
4078
|
-
}
|
|
4750
|
+
const usage = aggregateUsage(transcriptPath, { modelFallback });
|
|
4751
|
+
emitUsageTick({ sessionId, usage, hookType: 'session_end', gitRepo, modelFallback });
|
|
4079
4752
|
}
|
|
4080
4753
|
|
|
4081
4754
|
let resp: any;
|
|
@@ -4131,7 +4804,7 @@ async function main() {
|
|
|
4131
4804
|
const payload = JSON.parse(input);
|
|
4132
4805
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
4133
4806
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
4134
|
-
const transcriptPath = payload
|
|
4807
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
4135
4808
|
const sessionId = hookSessionId(payload);
|
|
4136
4809
|
const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
|
|
4137
4810
|
if (gitRepo) writeCachedRepo(gitRepo);
|
|
@@ -4263,10 +4936,9 @@ main();
|
|
|
4263
4936
|
import {
|
|
4264
4937
|
loadJwt, detectRepo, readStdin, aggregateUsage, appendLocalTelemetry,
|
|
4265
4938
|
outputEmpty, setupCursorHookSignals, hookSessionId, GATEWAY_URL, readSessionLog, shipCloud,
|
|
4939
|
+
resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload,
|
|
4266
4940
|
} from './_synkro-common.ts';
|
|
4267
|
-
import {
|
|
4268
|
-
import { join, dirname } from 'node:path';
|
|
4269
|
-
import { homedir } from 'node:os';
|
|
4941
|
+
import { readFileSync } from 'node:fs';
|
|
4270
4942
|
|
|
4271
4943
|
async function main() {
|
|
4272
4944
|
setupCursorHookSignals();
|
|
@@ -4276,11 +4948,11 @@ async function main() {
|
|
|
4276
4948
|
|
|
4277
4949
|
const payload = JSON.parse(input);
|
|
4278
4950
|
const sessionId = hookSessionId(payload);
|
|
4279
|
-
const transcriptPath = payload.transcript_path || '';
|
|
4280
4951
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
4281
4952
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
4953
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
4282
4954
|
|
|
4283
|
-
if (!sessionId || !transcriptPath
|
|
4955
|
+
if (!sessionId || !transcriptPath) {
|
|
4284
4956
|
outputEmpty();
|
|
4285
4957
|
return;
|
|
4286
4958
|
}
|
|
@@ -4288,104 +4960,21 @@ async function main() {
|
|
|
4288
4960
|
const jwt = loadJwt();
|
|
4289
4961
|
if (!jwt) { outputEmpty(); return; }
|
|
4290
4962
|
|
|
4291
|
-
const usage = aggregateUsage(transcriptPath);
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
model: usage.model || 'unknown',
|
|
4300
|
-
cc_model: usage.model || '',
|
|
4301
|
-
cc_usage: {
|
|
4302
|
-
input_tokens: usage.totals.in,
|
|
4303
|
-
output_tokens: usage.totals.out,
|
|
4304
|
-
cache_creation_input_tokens: usage.totals.cw,
|
|
4305
|
-
cache_read_input_tokens: usage.totals.cr,
|
|
4306
|
-
},
|
|
4307
|
-
session_id: sessionId,
|
|
4308
|
-
};
|
|
4309
|
-
appendLocalTelemetry(usageBody);
|
|
4310
|
-
shipCloud(jwt, '/api/v1/hook/capture', usageBody);
|
|
4311
|
-
}
|
|
4963
|
+
const usage = aggregateUsage(transcriptPath, { modelFallback: cursorModelFromPayload(payload) });
|
|
4964
|
+
emitUsageTick({
|
|
4965
|
+
sessionId,
|
|
4966
|
+
usage,
|
|
4967
|
+
hookType: 'stop',
|
|
4968
|
+
gitRepo: detectRepo(cwd, transcriptPath, '', workspaceRoots),
|
|
4969
|
+
modelFallback: cursorModelFromPayload(payload),
|
|
4970
|
+
});
|
|
4312
4971
|
|
|
4313
|
-
// Transcript consent gates only CLOUD transmission. Local persistence \u2014
|
|
4314
|
-
// this machine's own PGLite \u2014 is the same category as the local telemetry
|
|
4315
|
-
// already captured for every command, so it always runs.
|
|
4316
4972
|
const cloudConsent = process.env.SYNKRO_TRANSCRIPT_CONSENT !== 'no';
|
|
4317
4973
|
const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
|
|
4318
4974
|
|
|
4319
|
-
|
|
4320
|
-
const offsetDir = join(homedir(), '.synkro', '.transcript-offsets');
|
|
4321
|
-
mkdirSync(offsetDir, { recursive: true });
|
|
4322
|
-
const offsetFile = join(offsetDir, sessionId);
|
|
4323
|
-
let offset = 0;
|
|
4324
|
-
if (existsSync(offsetFile)) {
|
|
4325
|
-
try { offset = parseInt(readFileSync(offsetFile, 'utf-8').trim(), 10) || 0; } catch {}
|
|
4326
|
-
}
|
|
4327
|
-
|
|
4328
|
-
const raw = readFileSync(transcriptPath, 'utf-8');
|
|
4329
|
-
const allLines = raw.split('\\n').filter(l => l.trim());
|
|
4330
|
-
const totalLines = allLines.length;
|
|
4331
|
-
|
|
4332
|
-
if (totalLines <= offset) { outputEmpty(); return; }
|
|
4333
|
-
|
|
4334
|
-
let startIdx = offset;
|
|
4335
|
-
const delta = totalLines - offset;
|
|
4336
|
-
if (delta > 200) startIdx = totalLines - 200;
|
|
4337
|
-
|
|
4338
|
-
const messages: any[] = [];
|
|
4339
|
-
for (let i = startIdx; i < totalLines; i++) {
|
|
4340
|
-
try {
|
|
4341
|
-
const entry = JSON.parse(allLines[i]);
|
|
4342
|
-
if (entry.type !== 'user' && entry.type !== 'assistant') continue;
|
|
4343
|
-
const content = entry.message?.content;
|
|
4344
|
-
let text = '';
|
|
4345
|
-
if (typeof content === 'string') text = content.slice(0, 8000);
|
|
4346
|
-
else if (Array.isArray(content)) {
|
|
4347
|
-
text = content.map((c: any) => {
|
|
4348
|
-
if (typeof c === 'string') return c;
|
|
4349
|
-
if (c?.type === 'text') return c.text || '';
|
|
4350
|
-
return '';
|
|
4351
|
-
}).join(' ').slice(0, 8000);
|
|
4352
|
-
}
|
|
4353
|
-
|
|
4354
|
-
const msg: any = { message_index: i, type: entry.type, content: text, ts: entry.timestamp || null };
|
|
4355
|
-
if (entry.type === 'assistant') {
|
|
4356
|
-
const toolCalls = (Array.isArray(content) ? content : [])
|
|
4357
|
-
.filter((c: any) => c?.type === 'tool_use')
|
|
4358
|
-
.map((c: any) => ({ name: c.name, input: JSON.stringify(c.input || {}).slice(0, 500), id: c.id }));
|
|
4359
|
-
if (toolCalls.length > 0) msg.tool_calls = toolCalls;
|
|
4360
|
-
msg.model = entry.message?.model || null;
|
|
4361
|
-
const u = entry.message?.usage;
|
|
4362
|
-
if (u) msg.usage = { input_tokens: u.input_tokens, output_tokens: u.output_tokens, cache_creation_input_tokens: u.cache_creation_input_tokens, cache_read_input_tokens: u.cache_read_input_tokens };
|
|
4363
|
-
}
|
|
4364
|
-
messages.push(msg);
|
|
4365
|
-
} catch {}
|
|
4366
|
-
}
|
|
4367
|
-
|
|
4368
|
-
writeFileSync(offsetFile, String(totalLines), 'utf-8');
|
|
4369
|
-
|
|
4370
|
-
if (messages.length === 0) { outputEmpty(); return; }
|
|
4975
|
+
const { messages } = await syncConversationTranscript(sessionId, transcriptPath, gitRepo || '');
|
|
4371
4976
|
|
|
4372
|
-
|
|
4373
|
-
const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
|
|
4374
|
-
let mcpToken = '';
|
|
4375
|
-
try { mcpToken = readFileSync(join(homedir(), '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
|
|
4376
|
-
if (mcpToken) {
|
|
4377
|
-
fetch('http://127.0.0.1:' + mcpPort + '/api/conversation-sync', {
|
|
4378
|
-
method: 'POST',
|
|
4379
|
-
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
|
|
4380
|
-
body: JSON.stringify({ session_id: sessionId, repo: gitRepo || '', messages }),
|
|
4381
|
-
signal: AbortSignal.timeout(5000),
|
|
4382
|
-
}).catch(() => {});
|
|
4383
|
-
}
|
|
4384
|
-
|
|
4385
|
-
// Cloud sync \u2014 only when storage mode is cloud (and transcript consent
|
|
4386
|
-
// wasn't declined). Carries the transcript + the session step-log so cloud
|
|
4387
|
-
// storage matches the local PGLite shape.
|
|
4388
|
-
if (cloudConsent && gitRepo && (process.env.SYNKRO_STORAGE_MODE || 'local') === 'cloud') {
|
|
4977
|
+
if (cloudConsent && gitRepo && messages.length > 0 && (process.env.SYNKRO_STORAGE_MODE || 'local') === 'cloud') {
|
|
4389
4978
|
fetch(GATEWAY_URL + '/api/v1/cli/sync-transcripts', {
|
|
4390
4979
|
method: 'POST',
|
|
4391
4980
|
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
@@ -4406,7 +4995,7 @@ async function main() {
|
|
|
4406
4995
|
main();
|
|
4407
4996
|
`;
|
|
4408
4997
|
USER_PROMPT_SUBMIT_TS = `#!/usr/bin/env bun
|
|
4409
|
-
import { readStdin, appendLocalTelemetry, aggregateUsage, outputEmpty, setupCursorHookSignals, hookSessionId } from './_synkro-common.ts';
|
|
4998
|
+
import { readStdin, appendLocalTelemetry, aggregateUsage, outputEmpty, setupCursorHookSignals, hookSessionId, detectRepo, resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload } from './_synkro-common.ts';
|
|
4410
4999
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
4411
5000
|
import { join, dirname } from 'node:path';
|
|
4412
5001
|
import { homedir } from 'node:os';
|
|
@@ -4431,25 +5020,15 @@ async function main() {
|
|
|
4431
5020
|
}
|
|
4432
5021
|
|
|
4433
5022
|
const sessionId = hookSessionId(payload);
|
|
4434
|
-
const
|
|
5023
|
+
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
5024
|
+
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
5025
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
4435
5026
|
if (sessionId && transcriptPath) {
|
|
4436
|
-
const
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
hook_type: 'prompt_submit',
|
|
4442
|
-
session_id: sessionId,
|
|
4443
|
-
model: usage.model || 'unknown',
|
|
4444
|
-
cc_model: usage.model || '',
|
|
4445
|
-
cc_usage: {
|
|
4446
|
-
input_tokens: usage.totals.in,
|
|
4447
|
-
output_tokens: usage.totals.out,
|
|
4448
|
-
cache_creation_input_tokens: usage.totals.cw,
|
|
4449
|
-
cache_read_input_tokens: usage.totals.cr,
|
|
4450
|
-
},
|
|
4451
|
-
});
|
|
4452
|
-
}
|
|
5027
|
+
const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
|
|
5028
|
+
syncConversationTranscript(sessionId, transcriptPath, gitRepo || '').catch(() => {});
|
|
5029
|
+
const modelFallback = cursorModelFromPayload(payload);
|
|
5030
|
+
const usage = aggregateUsage(transcriptPath, { modelFallback });
|
|
5031
|
+
emitUsageTick({ sessionId, usage, hookType: 'prompt_submit', gitRepo, modelFallback });
|
|
4453
5032
|
}
|
|
4454
5033
|
outputEmpty();
|
|
4455
5034
|
} catch {
|
|
@@ -4463,7 +5042,7 @@ main();
|
|
|
4463
5042
|
import {
|
|
4464
5043
|
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
|
|
4465
5044
|
parseVerdict, dispatchCapture, dispatchFinding, ruleMode, normalizeMode, filterRules, ruleFilterText,
|
|
4466
|
-
isSafeInRepoRead, postWithRetry, readStdin, hashCommand,
|
|
5045
|
+
isSafeInRepoRead, resolveTranscriptPath, postWithRetry, readStdin, hashCommand,
|
|
4467
5046
|
extractTranscript, readLastPrompt, readSessionLog, compressSessionLog,
|
|
4468
5047
|
appendLocalTelemetry, logGraderUnavailable, log, GATEWAY_URL,
|
|
4469
5048
|
type Rule,
|
|
@@ -4576,10 +5155,9 @@ async function main() {
|
|
|
4576
5155
|
finishAllow();
|
|
4577
5156
|
}
|
|
4578
5157
|
|
|
4579
|
-
const transcriptPath =
|
|
5158
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
4580
5159
|
const rawModel = String(payload.model ?? payload.model_id ?? '');
|
|
4581
|
-
const
|
|
4582
|
-
const model = rawModel ? (KNOWN_MODELS.has(rawModel) || rawModel.startsWith('claude-') || rawModel.startsWith('gpt-') || rawModel.startsWith('gemini-') || rawModel.startsWith('o1') || rawModel.startsWith('o3') ? rawModel : 'cursor/' + rawModel) : 'cursor';
|
|
5160
|
+
const model = rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor';
|
|
4583
5161
|
const repo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
|
|
4584
5162
|
|
|
4585
5163
|
const cmdShort = command.slice(0, 80);
|
|
@@ -4612,16 +5190,19 @@ async function main() {
|
|
|
4612
5190
|
const rt = await route(config);
|
|
4613
5191
|
const tagStr = tag(rt, config);
|
|
4614
5192
|
|
|
4615
|
-
// Install protection \u2014
|
|
5193
|
+
// Install protection \u2014 install-scan runs first and owns block traces.
|
|
4616
5194
|
let scanConcern = '';
|
|
4617
5195
|
let scanBlockContext = '';
|
|
4618
5196
|
if (SHELL_TOOL_NAMES.has(toolName)) {
|
|
4619
5197
|
const scan = readCachedScan(command);
|
|
4620
|
-
if (scan
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
5198
|
+
if (scan?.action === 'block') {
|
|
5199
|
+
log('bashGuard deferring to install-scan block: ' + cmdShort);
|
|
5200
|
+
finishAllow();
|
|
5201
|
+
}
|
|
5202
|
+
if (scan?.action === 'warn') {
|
|
5203
|
+
scanBlockContext = scan.blockContext || scan.summary || '';
|
|
5204
|
+
scanConcern = 'PACKAGE SCANNER WARNING: ' + scanBlockContext
|
|
5205
|
+
+ ' Mention this to the user before proceeding.';
|
|
4625
5206
|
}
|
|
4626
5207
|
}
|
|
4627
5208
|
|
|
@@ -4676,7 +5257,7 @@ async function main() {
|
|
|
4676
5257
|
dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
|
|
4677
5258
|
'Bash', repo, sessionId, config.captureDepth, {
|
|
4678
5259
|
command, reasoning: guardReason,
|
|
4679
|
-
rulesChecked:
|
|
5260
|
+
rulesChecked: relevantRules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
|
|
4680
5261
|
ccModel: model,
|
|
4681
5262
|
});
|
|
4682
5263
|
finishWith({
|
|
@@ -4688,7 +5269,7 @@ async function main() {
|
|
|
4688
5269
|
dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'clean',
|
|
4689
5270
|
'Bash', repo, sessionId, config.captureDepth, {
|
|
4690
5271
|
command, reasoning: verdict.reason || 'no policy violations detected',
|
|
4691
|
-
rulesChecked:
|
|
5272
|
+
rulesChecked: relevantRules, violatedRules: [],
|
|
4692
5273
|
ccModel: model,
|
|
4693
5274
|
});
|
|
4694
5275
|
}
|
|
@@ -4755,8 +5336,9 @@ main().catch((e) => {
|
|
|
4755
5336
|
});`;
|
|
4756
5337
|
CURSOR_EDIT_CAPTURE_TS = `#!/usr/bin/env bun
|
|
4757
5338
|
import {
|
|
4758
|
-
loadJwt, ensureFreshJwt, detectRepo, readStdin,
|
|
5339
|
+
loadJwt, ensureFreshJwt, detectRepo, readStdin, resolveTranscriptPath,
|
|
4759
5340
|
appendSessionAction, appendLocalTelemetry, shipCloud, log, GATEWAY_URL,
|
|
5341
|
+
countEditLineDelta, dispatchCapture, hookSessionId, cursorModelFromPayload,
|
|
4760
5342
|
} from './_synkro-common.ts';
|
|
4761
5343
|
import { existsSync, readFileSync } from 'node:fs';
|
|
4762
5344
|
import { basename, dirname, join } from 'node:path';
|
|
@@ -4779,23 +5361,31 @@ async function main() {
|
|
|
4779
5361
|
const input = await readStdin();
|
|
4780
5362
|
if (!input.trim()) finish();
|
|
4781
5363
|
|
|
4782
|
-
const payload = JSON.parse(input)
|
|
4783
|
-
const filePath = payload.file_path || payload.path || payload.target_file || '';
|
|
5364
|
+
const payload = JSON.parse(input) as Record<string, unknown>;
|
|
5365
|
+
const filePath = String(payload.file_path || payload.path || payload.target_file || '');
|
|
4784
5366
|
if (!filePath) finish();
|
|
4785
5367
|
|
|
4786
|
-
const workspaceRoots = Array.isArray(payload.workspace_roots)
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
const
|
|
5368
|
+
const workspaceRoots = Array.isArray(payload.workspace_roots)
|
|
5369
|
+
? (payload.workspace_roots as unknown[]).filter((r): r is string => typeof r === 'string')
|
|
5370
|
+
: [];
|
|
5371
|
+
const cwd = (typeof payload.cwd === 'string' && payload.cwd) || workspaceRoots[0] || '';
|
|
5372
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
5373
|
+
const sessionId = hookSessionId(payload);
|
|
4790
5374
|
const repo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
|
|
5375
|
+
const model = cursorModelFromPayload(payload);
|
|
4791
5376
|
|
|
4792
|
-
const
|
|
4793
|
-
const
|
|
4794
|
-
const model = rawModel ? (KNOWN_MODELS.has(rawModel) || rawModel.startsWith('claude-') || rawModel.startsWith('gpt-') || rawModel.startsWith('gemini-') || rawModel.startsWith('o1') || rawModel.startsWith('o3') ? rawModel : 'cursor/' + rawModel) : 'cursor';
|
|
5377
|
+
const edits = Array.isArray(payload.edits) ? payload.edits as Array<{ old_string?: string; new_string?: string }> : [];
|
|
5378
|
+
const { linesAdded, linesRemoved } = countEditLineDelta({ edits });
|
|
4795
5379
|
|
|
4796
|
-
log('editScan ' + basename(filePath));
|
|
5380
|
+
log('editScan ' + basename(filePath) + ' +' + linesAdded + '/-' + linesRemoved);
|
|
4797
5381
|
|
|
4798
|
-
appendSessionAction(sessionId, {
|
|
5382
|
+
appendSessionAction(sessionId, {
|
|
5383
|
+
ts: new Date().toISOString(),
|
|
5384
|
+
tool: 'Edit',
|
|
5385
|
+
summary: 'wrote ' + basename(filePath) + (linesAdded || linesRemoved ? (' (+' + linesAdded + '/-' + linesRemoved + ')') : ''),
|
|
5386
|
+
file: filePath,
|
|
5387
|
+
outcome: 'ok',
|
|
5388
|
+
});
|
|
4799
5389
|
|
|
4800
5390
|
let jwt = loadJwt();
|
|
4801
5391
|
if (!jwt) finish();
|
|
@@ -4841,6 +5431,16 @@ async function main() {
|
|
|
4841
5431
|
appendLocalTelemetry(captureBody);
|
|
4842
5432
|
shipCloud(jwt, '/api/v1/hook/capture', captureBody, 10000);
|
|
4843
5433
|
|
|
5434
|
+
if (sessionId) {
|
|
5435
|
+
dispatchCapture(jwt, 'edit', 'pass', 'clean', 'trivial_edit', 'Edit', repo, sessionId, 'full', {
|
|
5436
|
+
command: 'file=' + filePath,
|
|
5437
|
+
reasoning: 'Cursor afterFileEdit',
|
|
5438
|
+
ccModel: model,
|
|
5439
|
+
linesAdded,
|
|
5440
|
+
linesRemoved,
|
|
5441
|
+
});
|
|
5442
|
+
}
|
|
5443
|
+
|
|
4844
5444
|
finish();
|
|
4845
5445
|
} catch (e) {
|
|
4846
5446
|
log('editScan error: ' + String(e));
|
|
@@ -4852,6 +5452,47 @@ main().catch((e) => {
|
|
|
4852
5452
|
log('editScan fatal: ' + String(e));
|
|
4853
5453
|
finish();
|
|
4854
5454
|
});`;
|
|
5455
|
+
CURSOR_AGENT_CAPTURE_TS = `#!/usr/bin/env bun
|
|
5456
|
+
/** Capture Cursor agent thinking/response text before transcript JSONL redacts it. */
|
|
5457
|
+
import {
|
|
5458
|
+
readStdin, outputEmpty, setupCursorHookSignals, hookSessionId, detectRepo,
|
|
5459
|
+
appendThoughtOverlay, pushConversationMessage,
|
|
5460
|
+
} from './_synkro-common.ts';
|
|
5461
|
+
|
|
5462
|
+
async function main() {
|
|
5463
|
+
setupCursorHookSignals();
|
|
5464
|
+
try {
|
|
5465
|
+
const input = await readStdin();
|
|
5466
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
5467
|
+
const payload = JSON.parse(input) as Record<string, unknown>;
|
|
5468
|
+
const event = String(payload.hook_event_name || '');
|
|
5469
|
+
const text = typeof payload.text === 'string' ? payload.text.trim() : '';
|
|
5470
|
+
if (!text || text === '[REDACTED]') { outputEmpty(); return; }
|
|
5471
|
+
|
|
5472
|
+
const sessionId = hookSessionId(payload);
|
|
5473
|
+
if (!sessionId) { outputEmpty(); return; }
|
|
5474
|
+
|
|
5475
|
+
const workspaceRoots = Array.isArray(payload.workspace_roots)
|
|
5476
|
+
? payload.workspace_roots.filter((r): r is string => typeof r === 'string')
|
|
5477
|
+
: [];
|
|
5478
|
+
const cwd = (typeof payload.cwd === 'string' && payload.cwd) || workspaceRoots[0] || '';
|
|
5479
|
+
const gitRepo = detectRepo(cwd, '', '', workspaceRoots);
|
|
5480
|
+
|
|
5481
|
+
if (event === 'afterAgentThought') {
|
|
5482
|
+
appendThoughtOverlay(sessionId, text);
|
|
5483
|
+
await pushConversationMessage(sessionId, 'assistant', text, { gitRepo, patchRedacted: true });
|
|
5484
|
+
} else if (event === 'afterAgentResponse') {
|
|
5485
|
+
await pushConversationMessage(sessionId, 'assistant', text, { gitRepo, patchRedacted: false });
|
|
5486
|
+
}
|
|
5487
|
+
|
|
5488
|
+
outputEmpty();
|
|
5489
|
+
} catch {
|
|
5490
|
+
outputEmpty();
|
|
5491
|
+
}
|
|
5492
|
+
}
|
|
5493
|
+
|
|
5494
|
+
main();
|
|
5495
|
+
`;
|
|
4855
5496
|
}
|
|
4856
5497
|
});
|
|
4857
5498
|
|
|
@@ -6793,6 +7434,7 @@ function writeHookScripts() {
|
|
|
6793
7434
|
const installScanScriptPath = join8(HOOKS_DIR, "cc-install-scan.ts");
|
|
6794
7435
|
const cursorBashJudgePath = join8(HOOKS_DIR, "cursor-bash-judge.ts");
|
|
6795
7436
|
const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
|
|
7437
|
+
const cursorAgentCapturePath = join8(HOOKS_DIR, "cursor-agent-capture.ts");
|
|
6796
7438
|
const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
|
|
6797
7439
|
writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
|
|
6798
7440
|
writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
|
|
@@ -6810,8 +7452,9 @@ function writeHookScripts() {
|
|
|
6810
7452
|
writeFileSync7(installScanScriptPath, INSTALL_SCAN_TS, "utf-8");
|
|
6811
7453
|
writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
|
|
6812
7454
|
writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
|
|
7455
|
+
writeFileSync7(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
|
|
6813
7456
|
writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
|
|
6814
|
-
writeFileSync7(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
|
|
7457
|
+
writeFileSync7(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\nconst PKG_JSON_DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];\n\nfunction safeParseObject(text: string): Record<string, any> | null {\n try {\n const v = JSON.parse(text);\n return v && typeof v === 'object' && !Array.isArray(v) ? v : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Diff two package.json contents and return the registry packages that are\n * newly added or whose version spec changed in the new content. The caller\n * scans these against the vuln DB before letting the edit land \u2014 so a bare\n * `pnpm install` afterwards has nothing left to vet. Non-registry sources\n * (file:, link:, workspace:, git, http, relative paths) are skipped.\n */\nexport function extractPackageJsonDelta(oldText: string, newText: string): DeterministicPkgRequest[] {\n const newJson = safeParseObject(newText);\n if (!newJson) return [];\n const oldJson = safeParseObject(oldText) || {};\n\n const out = new Map<string, DeterministicPkgRequest>();\n for (const field of PKG_JSON_DEP_FIELDS) {\n const oldDeps = (oldJson[field] && typeof oldJson[field] === 'object') ? oldJson[field] : {};\n const newDeps = (newJson[field] && typeof newJson[field] === 'object') ? newJson[field] : {};\n for (const [rawName, version] of Object.entries(newDeps)) {\n if (typeof version !== 'string') continue;\n if (oldDeps[rawName] === version) continue;\n const spec = version.trim();\n if (\n spec.startsWith('file:') || spec.startsWith('link:') ||\n spec.startsWith('http') || spec.startsWith('git') ||\n spec.startsWith('workspace:') || spec.startsWith('catalog:') ||\n spec.startsWith('npm:') ||\n spec.startsWith('./') || spec.startsWith('../') ||\n spec === '' || spec === '*'\n ) continue;\n const name = rawName.toLowerCase();\n out.set(name, {\n name,\n version: concretePin(spec) ?? '*',\n ecosystem: 'npm',\n });\n }\n }\n return [...out.values()];\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
|
|
6815
7458
|
const hooksPkgPath = join8(HOOKS_DIR, "package.json");
|
|
6816
7459
|
writeFileSync7(hooksPkgPath, JSON.stringify({
|
|
6817
7460
|
name: "synkro-hooks",
|
|
@@ -6839,6 +7482,7 @@ function writeHookScripts() {
|
|
|
6839
7482
|
chmodSync2(installScanScriptPath, 493);
|
|
6840
7483
|
chmodSync2(cursorBashJudgePath, 493);
|
|
6841
7484
|
chmodSync2(cursorEditCapturePath, 493);
|
|
7485
|
+
chmodSync2(cursorAgentCapturePath, 493);
|
|
6842
7486
|
chmodSync2(mcpStdioProxyPath, 493);
|
|
6843
7487
|
chmodSync2(installExtractCorePath, 493);
|
|
6844
7488
|
return {
|
|
@@ -6855,7 +7499,8 @@ function writeHookScripts() {
|
|
|
6855
7499
|
userPromptSubmitScript: userPromptSubmitScriptPath,
|
|
6856
7500
|
installScanScript: installScanScriptPath,
|
|
6857
7501
|
cursorBashJudgeScript: cursorBashJudgePath,
|
|
6858
|
-
cursorEditCaptureScript: cursorEditCapturePath
|
|
7502
|
+
cursorEditCaptureScript: cursorEditCapturePath,
|
|
7503
|
+
cursorAgentCaptureScript: cursorAgentCapturePath
|
|
6859
7504
|
};
|
|
6860
7505
|
}
|
|
6861
7506
|
function sanitizeConfigValue(raw, maxLen = 256) {
|
|
@@ -6887,7 +7532,7 @@ function writeConfigEnv(opts) {
|
|
|
6887
7532
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
6888
7533
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
6889
7534
|
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
6890
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.6.
|
|
7535
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.6.25")}`
|
|
6891
7536
|
];
|
|
6892
7537
|
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
6893
7538
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
@@ -7138,6 +7783,7 @@ async function installCommand(opts = {}) {
|
|
|
7138
7783
|
installCursorHooks(agent.settingsPath, {
|
|
7139
7784
|
bashJudgeScriptPath: scripts.cursorBashJudgeScript,
|
|
7140
7785
|
editCaptureScriptPath: scripts.cursorEditCaptureScript,
|
|
7786
|
+
agentCaptureScriptPath: scripts.cursorAgentCaptureScript,
|
|
7141
7787
|
bashFollowupScriptPath: scripts.bashFollowupScript,
|
|
7142
7788
|
editPrecheckScriptPath: scripts.editPrecheckScript,
|
|
7143
7789
|
cwePrecheckScriptPath: scripts.cwePrecheckScript,
|
|
@@ -9554,7 +10200,7 @@ var args = process.argv.slice(2);
|
|
|
9554
10200
|
var cmd = args[0] || "";
|
|
9555
10201
|
var subArgs = args.slice(1);
|
|
9556
10202
|
function printVersion() {
|
|
9557
|
-
console.log("1.6.
|
|
10203
|
+
console.log("1.6.25");
|
|
9558
10204
|
}
|
|
9559
10205
|
function printHelp2() {
|
|
9560
10206
|
console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
|