@synkro-sh/cli 1.6.22 → 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 +940 -295
- 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";
|
|
@@ -1297,8 +1313,7 @@ export function ruleFilterText(action: string, userMessage?: string | null): str
|
|
|
1297
1313
|
const act = (action || '').trim();
|
|
1298
1314
|
if (!user) return act.slice(0, 4000);
|
|
1299
1315
|
if (!act) return user.slice(0, 4000);
|
|
1300
|
-
return (user + '
|
|
1301
|
-
' + act).slice(0, 4000);
|
|
1316
|
+
return (user + '\\n' + act).slice(0, 4000);
|
|
1302
1317
|
}
|
|
1303
1318
|
|
|
1304
1319
|
export async function filterRules(commandText: string, allRules: Rule[]): Promise<Rule[]> {
|
|
@@ -1475,15 +1490,21 @@ export async function runInstallScan(command: string, jwt: string): Promise<Inst
|
|
|
1475
1490
|
.flatMap((p: any) => (p.signals || []))
|
|
1476
1491
|
.filter((s: any) => s.type === 'cve').length;
|
|
1477
1492
|
const findings: InstallScanResult['findings'] = [];
|
|
1493
|
+
const seenFindingKeys = new Set<string>();
|
|
1478
1494
|
for (const p of pkgResults) {
|
|
1479
1495
|
for (const s of (p.signals || [])) {
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
+
});
|
|
1487
1508
|
}
|
|
1488
1509
|
}
|
|
1489
1510
|
// Preview the top 5 detail lines; the headline carries the true total.
|
|
@@ -1742,11 +1763,13 @@ export function dispatchCapture(
|
|
|
1742
1763
|
violatedRules?: string[];
|
|
1743
1764
|
recentUserMessages?: string[];
|
|
1744
1765
|
ccModel?: string;
|
|
1766
|
+
linesAdded?: number;
|
|
1767
|
+
linesRemoved?: number;
|
|
1745
1768
|
},
|
|
1746
1769
|
): void {
|
|
1747
1770
|
// Fire-and-forget
|
|
1748
1771
|
const eventId = 'evt_' + Date.now() + '_' + process.pid;
|
|
1749
|
-
const model = opts?.ccModel || 'unknown';
|
|
1772
|
+
const model = normalizeCaptureModel(opts?.ccModel || 'unknown');
|
|
1750
1773
|
|
|
1751
1774
|
const body: Record<string, any> = {
|
|
1752
1775
|
capture_type: 'local_verdict',
|
|
@@ -1775,6 +1798,8 @@ export function dispatchCapture(
|
|
|
1775
1798
|
if (opts.rulesChecked) localBody.rules_checked = opts.rulesChecked;
|
|
1776
1799
|
if (opts.violatedRules) localBody.violated_rules = opts.violatedRules;
|
|
1777
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;
|
|
1778
1803
|
}
|
|
1779
1804
|
appendLocalTelemetry(localBody);
|
|
1780
1805
|
|
|
@@ -1935,7 +1960,7 @@ export function reconstructContent(toolName: string, toolInput: any, filePath: s
|
|
|
1935
1960
|
case 'apply_patch':
|
|
1936
1961
|
return contentFromPatch(patchTextFromToolInput(toolInput));
|
|
1937
1962
|
case 'Write':
|
|
1938
|
-
return toolInput.content || '';
|
|
1963
|
+
return toolInput.content || toolInput.contents || '';
|
|
1939
1964
|
case 'edit_file':
|
|
1940
1965
|
case 'reapply':
|
|
1941
1966
|
return toolInput.content || toolInput.new_string || toolInput.code_edit || '';
|
|
@@ -2029,7 +2054,303 @@ export async function readStdin(): Promise<string> {
|
|
|
2029
2054
|
return Buffer.concat(chunks).toString('utf-8');
|
|
2030
2055
|
}
|
|
2031
2056
|
|
|
2032
|
-
// \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
|
+
}
|
|
2033
2354
|
|
|
2034
2355
|
export interface TranscriptContext {
|
|
2035
2356
|
userIntent: string;
|
|
@@ -2068,11 +2389,8 @@ export function extractTranscript(transcriptPath: string | undefined): Transcrip
|
|
|
2068
2389
|
// Recent user messages (last 5)
|
|
2069
2390
|
const userMsgs: string[] = [];
|
|
2070
2391
|
for (const entry of parsed) {
|
|
2071
|
-
if (entry
|
|
2072
|
-
const
|
|
2073
|
-
let text = '';
|
|
2074
|
-
if (typeof content === 'string') text = content;
|
|
2075
|
-
else if (Array.isArray(content)) text = content.map((c: any) => c.text || '').join(' ');
|
|
2392
|
+
if (transcriptEntryType(entry) !== 'user') continue;
|
|
2393
|
+
const text = extractTranscriptEntryText(entry);
|
|
2076
2394
|
if (text) userMsgs.push(text);
|
|
2077
2395
|
}
|
|
2078
2396
|
ctx.recentUserMessages = userMsgs.slice(-5);
|
|
@@ -2081,26 +2399,25 @@ export function extractTranscript(transcriptPath: string | undefined): Transcrip
|
|
|
2081
2399
|
// Recent messages (last 10, user + assistant)
|
|
2082
2400
|
const msgs: Array<{ type: string; text: string }> = [];
|
|
2083
2401
|
for (const entry of parsed) {
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
if (
|
|
2088
|
-
else if (Array.isArray(content)) text = content.map((c: any) => (c.text || '').slice(0, 300)).join(' ');
|
|
2089
|
-
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 });
|
|
2090
2406
|
}
|
|
2091
2407
|
ctx.recentMessages = msgs.slice(-10);
|
|
2092
2408
|
|
|
2093
2409
|
// Recent tool calls (last 5)
|
|
2094
2410
|
const actions: Array<{ tool: string; input: string }> = [];
|
|
2095
2411
|
for (const entry of parsed) {
|
|
2096
|
-
if (entry
|
|
2097
|
-
const
|
|
2412
|
+
if (transcriptEntryType(entry) !== 'assistant') continue;
|
|
2413
|
+
const msg = entry.message;
|
|
2414
|
+
const content = msg?.content ?? entry.content;
|
|
2098
2415
|
if (!Array.isArray(content)) continue;
|
|
2099
2416
|
for (const block of content) {
|
|
2100
|
-
if (block.type !== 'tool_use') continue;
|
|
2417
|
+
if (block.type !== 'tool_use' && block.type !== 'tool_call') continue;
|
|
2101
2418
|
actions.push({
|
|
2102
2419
|
tool: block.name || '',
|
|
2103
|
-
input: JSON.stringify(block.input || {}).slice(0, 200),
|
|
2420
|
+
input: JSON.stringify(block.input || block.arguments || {}).slice(0, 200),
|
|
2104
2421
|
});
|
|
2105
2422
|
}
|
|
2106
2423
|
}
|
|
@@ -2114,7 +2431,7 @@ export function extractTranscript(transcriptPath: string | undefined): Transcrip
|
|
|
2114
2431
|
}
|
|
2115
2432
|
|
|
2116
2433
|
// CC model
|
|
2117
|
-
const assistantEntries = parsed.filter(e => e
|
|
2434
|
+
const assistantEntries = parsed.filter(e => transcriptEntryType(e) === 'assistant');
|
|
2118
2435
|
if (assistantEntries.length > 0) {
|
|
2119
2436
|
const last = assistantEntries[assistantEntries.length - 1];
|
|
2120
2437
|
ctx.ccModel = last.message?.model || '';
|
|
@@ -2209,39 +2526,254 @@ export function hashCommand(cmd: string): string {
|
|
|
2209
2526
|
return createHash('sha256').update(cmd).digest('hex').slice(0, 16);
|
|
2210
2527
|
}
|
|
2211
2528
|
|
|
2212
|
-
// \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
|
+
}
|
|
2213
2586
|
|
|
2214
|
-
|
|
2215
|
-
|
|
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 } };
|
|
2216
2649
|
if (!transcriptPath || !existsSync(transcriptPath)) return result;
|
|
2217
2650
|
try {
|
|
2218
2651
|
const raw = readFileSync(transcriptPath, 'utf-8');
|
|
2219
|
-
const
|
|
2220
|
-
for (const line of
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
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;
|
|
2233
2689
|
}
|
|
2234
2690
|
} catch {}
|
|
2235
2691
|
return result;
|
|
2236
2692
|
}
|
|
2237
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
|
+
|
|
2238
2734
|
// \u2500\u2500\u2500 Scan Finding Dispatch \u2500\u2500\u2500
|
|
2239
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
|
+
|
|
2240
2771
|
export function dispatchFinding(
|
|
2241
2772
|
jwt: string,
|
|
2242
2773
|
finding: {
|
|
2243
2774
|
session_id: string;
|
|
2244
2775
|
file_path: string;
|
|
2776
|
+
repo?: string;
|
|
2245
2777
|
finding_type: 'cwe' | 'cve';
|
|
2246
2778
|
finding_id: string;
|
|
2247
2779
|
severity?: string;
|
|
@@ -2271,6 +2803,7 @@ export function dispatchFinding(
|
|
|
2271
2803
|
status: finding.status,
|
|
2272
2804
|
session_id: finding.session_id,
|
|
2273
2805
|
file_path: finding.file_path,
|
|
2806
|
+
repo: finding.repo,
|
|
2274
2807
|
package_name: finding.package_name,
|
|
2275
2808
|
package_version: finding.package_version,
|
|
2276
2809
|
fixed_version: finding.fixed_version,
|
|
@@ -2412,11 +2945,12 @@ import {
|
|
|
2412
2945
|
readStdin, extractTranscript, readLastPrompt, findNearestDeps, filePathFromToolInput,
|
|
2413
2946
|
appendSessionAction, readSessionLog, compressSessionLog, log,
|
|
2414
2947
|
outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, GATEWAY_URL,
|
|
2415
|
-
logGraderUnavailable, filterRules, ruleFilterText, normalizeMode,
|
|
2948
|
+
logGraderUnavailable, filterRules, ruleFilterText, normalizeMode, countEditLineDelta,
|
|
2949
|
+
captureLineMetrics, cursorModelFromPayload,
|
|
2416
2950
|
type HookConfig, type Rule,
|
|
2417
2951
|
} from './_synkro-common.ts';
|
|
2418
2952
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2419
|
-
import { basename,
|
|
2953
|
+
import { basename, join } from 'node:path';
|
|
2420
2954
|
|
|
2421
2955
|
const agentKind = process.env.SYNKRO_HOOK_FORMAT === 'cursor' ? 'cursor' : 'claude_code';
|
|
2422
2956
|
|
|
@@ -2439,7 +2973,7 @@ async function main() {
|
|
|
2439
2973
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
2440
2974
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
2441
2975
|
const permissionMode = payload.permission_mode || '';
|
|
2442
|
-
const transcriptPath = payload
|
|
2976
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
2443
2977
|
|
|
2444
2978
|
const filePath = filePathFromToolInput(toolInput);
|
|
2445
2979
|
if (!filePath) { outputEmpty(); return; }
|
|
@@ -2470,21 +3004,44 @@ async function main() {
|
|
|
2470
3004
|
if (toolInput.edits != null) diffField.edits = toolInput.edits;
|
|
2471
3005
|
}
|
|
2472
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
|
+
|
|
2473
3028
|
// Read file before edit for cloud payload
|
|
2474
3029
|
let fileBefore = '';
|
|
2475
|
-
if (toolName !== 'Write' && filePath && isPathUnder(filePath, cwd || '.') && existsSync(
|
|
2476
|
-
try { fileBefore = readFileSync(
|
|
3030
|
+
if (toolName !== 'Write' && filePath && isPathUnder(filePath, cwd || '.') && existsSync(fullPath)) {
|
|
3031
|
+
try { fileBefore = readFileSync(fullPath, 'utf-8').slice(0, 65536); } catch {}
|
|
2477
3032
|
}
|
|
2478
3033
|
|
|
2479
3034
|
// Extract transcript context
|
|
2480
3035
|
const transcript = extractTranscript(transcriptPath);
|
|
2481
3036
|
const lastPrompt = readLastPrompt(sessionId);
|
|
2482
3037
|
|
|
3038
|
+
const captureModel = agentKind === 'cursor'
|
|
3039
|
+
? cursorModelFromPayload(payload)
|
|
3040
|
+
: (transcript.ccModel || String(payload.model ?? payload.model_id ?? ''));
|
|
3041
|
+
|
|
2483
3042
|
// Model detection: prefer transcript (CC), fall back to payload (Cursor)
|
|
2484
3043
|
if (!transcript.ccModel) {
|
|
2485
|
-
|
|
2486
|
-
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']);
|
|
2487
|
-
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;
|
|
2488
3045
|
}
|
|
2489
3046
|
|
|
2490
3047
|
// Load config and decide route
|
|
@@ -2543,8 +3100,8 @@ async function main() {
|
|
|
2543
3100
|
dispatchCapture(jwt, 'edit', 'block', verdict.severity || 'critical', verdict.category || 'security',
|
|
2544
3101
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
2545
3102
|
command: editContent, reasoning: guardReason,
|
|
2546
|
-
rulesChecked:
|
|
2547
|
-
ccModel:
|
|
3103
|
+
rulesChecked: relevantRules, violatedRules,
|
|
3104
|
+
ccModel: captureModel, ...lineMetrics,
|
|
2548
3105
|
});
|
|
2549
3106
|
outputJson({
|
|
2550
3107
|
systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 blocked: ' + guardReason,
|
|
@@ -2557,8 +3114,8 @@ async function main() {
|
|
|
2557
3114
|
dispatchCapture(jwt, 'edit', 'pass', 'clean', verdict.category || 'trivial_edit',
|
|
2558
3115
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
2559
3116
|
command: editContent, reasoning: verdict.reason || 'no policy violations detected',
|
|
2560
|
-
rulesChecked:
|
|
2561
|
-
ccModel:
|
|
3117
|
+
rulesChecked: relevantRules, violatedRules: [],
|
|
3118
|
+
ccModel: captureModel, ...lineMetrics,
|
|
2562
3119
|
});
|
|
2563
3120
|
const passLine = tagStr + ' editGuard ' + fileShort + ' \u2192 pass: ' + (verdict.reason || 'no policy violations detected');
|
|
2564
3121
|
outputJson({ systemMessage: passLine, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro local edit judge. ' + (verdict.reason || 'no policy violations detected') } });
|
|
@@ -2637,7 +3194,7 @@ main();
|
|
|
2637
3194
|
import {
|
|
2638
3195
|
loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
|
|
2639
3196
|
localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
|
|
2640
|
-
outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, filePathFromToolInput, dispatchFinding, dispatchCapture, GATEWAY_URL,
|
|
3197
|
+
outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
|
|
2641
3198
|
logGraderUnavailable,
|
|
2642
3199
|
} from './_synkro-common.ts';
|
|
2643
3200
|
import { basename, extname, resolve, join, dirname } from 'node:path';
|
|
@@ -2645,6 +3202,12 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
|
2645
3202
|
|
|
2646
3203
|
const agentKind = process.env.SYNKRO_HOOK_FORMAT === 'cursor' ? 'cursor' : 'claude_code';
|
|
2647
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
|
+
|
|
2648
3211
|
interface PackageCapability {
|
|
2649
3212
|
name: string;
|
|
2650
3213
|
description: string;
|
|
@@ -2768,11 +3331,12 @@ async function main() {
|
|
|
2768
3331
|
const sessionId = hookSessionId(payload);
|
|
2769
3332
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
2770
3333
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
2771
|
-
const transcriptPath = payload
|
|
3334
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
2772
3335
|
|
|
2773
3336
|
const filePath = filePathFromToolInput(toolInput);
|
|
2774
3337
|
if (!filePath) { outputEmpty(); return; }
|
|
2775
3338
|
const gitRepo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
|
|
3339
|
+
const ccModel = detectModel(payload);
|
|
2776
3340
|
|
|
2777
3341
|
if (filePath.includes('/.synkro/hooks/')) { outputEmpty(); return; }
|
|
2778
3342
|
|
|
@@ -2982,24 +3546,31 @@ async function main() {
|
|
|
2982
3546
|
const fixHint = fixLines.length > 0 ? '\n' + fixLines.join('\n') : '';
|
|
2983
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.';
|
|
2984
3548
|
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
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,
|
|
2990
3555
|
finding_id: cweId,
|
|
2991
3556
|
severity: verdict.severity || 'high',
|
|
2992
|
-
status: 'open',
|
|
2993
3557
|
detail: verdict.reason || 'code weakness detected',
|
|
2994
3558
|
cwe_name: cweNameMap.get(cweId.toUpperCase()) || undefined,
|
|
2995
|
-
},
|
|
2996
|
-
|
|
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
|
+
);
|
|
2997
3567
|
|
|
2998
3568
|
dispatchCapture(jwt, 'cwe', 'block', verdict.severity || 'high', verdict.category || 'security',
|
|
2999
3569
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3000
3570
|
command: 'edit ' + filePath,
|
|
3001
3571
|
reasoning: denyDetail,
|
|
3002
3572
|
violatedRules: activeCweIds,
|
|
3573
|
+
ccModel,
|
|
3003
3574
|
});
|
|
3004
3575
|
|
|
3005
3576
|
outputJson({
|
|
@@ -3077,25 +3648,34 @@ async function main() {
|
|
|
3077
3648
|
const denyDetail = '[' + displayIds + '] ' + (findings[0]?.reason || 'code weakness detected');
|
|
3078
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.';
|
|
3079
3650
|
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
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
|
+
{
|
|
3085
3666
|
finding_type: 'cwe',
|
|
3086
|
-
finding_id:
|
|
3087
|
-
severity:
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
}, config.captureDepth);
|
|
3092
|
-
}
|
|
3667
|
+
finding_id: activeCweIds[0] || 'CWE-UNKNOWN',
|
|
3668
|
+
severity: findings[0]?.severity || 'high',
|
|
3669
|
+
detail: denyDetail,
|
|
3670
|
+
},
|
|
3671
|
+
);
|
|
3093
3672
|
|
|
3094
3673
|
dispatchCapture(jwt, 'cwe', 'block', findings[0]?.severity || 'high', 'security',
|
|
3095
3674
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3096
3675
|
command: 'edit ' + filePath,
|
|
3097
3676
|
reasoning: denyDetail,
|
|
3098
3677
|
violatedRules: activeCweIds,
|
|
3678
|
+
ccModel,
|
|
3099
3679
|
});
|
|
3100
3680
|
|
|
3101
3681
|
outputJson({
|
|
@@ -3127,9 +3707,10 @@ main();
|
|
|
3127
3707
|
import {
|
|
3128
3708
|
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
|
|
3129
3709
|
reconstructContent, readStdin, findNearestDeps, filePathFromToolInput, log,
|
|
3130
|
-
outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, GATEWAY_URL,
|
|
3710
|
+
outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, extractTranscript, emitBlockScanFindings, GATEWAY_URL,
|
|
3131
3711
|
} from './_synkro-common.ts';
|
|
3132
3712
|
import { basename } from 'node:path';
|
|
3713
|
+
import { readFileSync } from 'node:fs';
|
|
3133
3714
|
|
|
3134
3715
|
const MANIFEST_NAMES = new Set([
|
|
3135
3716
|
'package.json', 'requirements.txt', 'requirements-dev.txt', 'requirements-test.txt',
|
|
@@ -3161,7 +3742,7 @@ async function main() {
|
|
|
3161
3742
|
const sessionId = hookSessionId(payload);
|
|
3162
3743
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
3163
3744
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
3164
|
-
const transcriptPath = payload
|
|
3745
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
3165
3746
|
|
|
3166
3747
|
const filePath = filePathFromToolInput(toolInput);
|
|
3167
3748
|
if (!filePath) { outputEmpty(); return; }
|
|
@@ -3200,6 +3781,97 @@ async function main() {
|
|
|
3200
3781
|
deps = findNearestDeps(filePath);
|
|
3201
3782
|
}
|
|
3202
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
|
+
|
|
3203
3875
|
// CVE scan via OSV API
|
|
3204
3876
|
const cveBody = {
|
|
3205
3877
|
file_path: filePath,
|
|
@@ -3223,15 +3895,12 @@ async function main() {
|
|
|
3223
3895
|
|
|
3224
3896
|
const findings = Array.isArray(cveResp?.findings) ? cveResp.findings : [];
|
|
3225
3897
|
if (findings.length > 0) {
|
|
3226
|
-
|
|
3898
|
+
const cveRows = findings.slice(0, 10).map((f: any) => {
|
|
3227
3899
|
const cveId = (f.aliases || []).find((a: string) => a.startsWith('CVE-')) || f.id || 'unknown';
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
file_path: filePath,
|
|
3231
|
-
finding_type: 'cve',
|
|
3900
|
+
return {
|
|
3901
|
+
finding_type: 'cve' as const,
|
|
3232
3902
|
finding_id: cveId,
|
|
3233
3903
|
severity: f.severity || 'high',
|
|
3234
|
-
status: 'open',
|
|
3235
3904
|
detail: f.summary || f.title || 'vulnerable dependency',
|
|
3236
3905
|
description: f.details || undefined,
|
|
3237
3906
|
package_name: f.package || undefined,
|
|
@@ -3239,8 +3908,15 @@ async function main() {
|
|
|
3239
3908
|
fixed_version: f.fixed || undefined,
|
|
3240
3909
|
aliases: f.aliases || undefined,
|
|
3241
3910
|
references: f.references || undefined,
|
|
3242
|
-
}
|
|
3243
|
-
}
|
|
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
|
+
);
|
|
3244
3920
|
|
|
3245
3921
|
const formatFinding = (f: any): string => {
|
|
3246
3922
|
const id = (f.aliases || []).find((a: string) => a.startsWith('CVE-')) || f.id || '?';
|
|
@@ -3286,8 +3962,9 @@ main();
|
|
|
3286
3962
|
INSTALL_SCAN_TS = `#!/usr/bin/env bun
|
|
3287
3963
|
import {
|
|
3288
3964
|
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
|
|
3289
|
-
readStdin, runInstallScan,
|
|
3965
|
+
readStdin, runInstallScan, emitBlockScanFindings, dispatchCapture, hashCommand,
|
|
3290
3966
|
outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, log, GATEWAY_URL,
|
|
3967
|
+
resolveTranscriptPath, isCursorHookFormat,
|
|
3291
3968
|
} from './_synkro-common.ts';
|
|
3292
3969
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
3293
3970
|
import { join } from 'node:path';
|
|
@@ -3327,30 +4004,41 @@ async function main() {
|
|
|
3327
4004
|
const sessionId = hookSessionId(payload);
|
|
3328
4005
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
3329
4006
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
3330
|
-
const transcriptPath = payload
|
|
4007
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
3331
4008
|
const repo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
|
|
3332
4009
|
const config = await loadConfig(jwt);
|
|
3333
4010
|
const rt = await route(config);
|
|
3334
4011
|
const tagStr = tag(rt, config);
|
|
3335
4012
|
|
|
3336
4013
|
const rawModel = String(payload.model ?? payload.model_id ?? '');
|
|
3337
|
-
const
|
|
3338
|
-
const model =
|
|
4014
|
+
const isCursor = isCursorHookFormat();
|
|
4015
|
+
const model = isCursor ? (rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor') : (rawModel || '');
|
|
3339
4016
|
|
|
3340
4017
|
if (scan.action === 'block') {
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
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) => ({
|
|
3345
4026
|
finding_type: 'cve' as const,
|
|
3346
4027
|
finding_id: f.advisoryId + ':' + f.name,
|
|
3347
4028
|
severity: f.severity,
|
|
3348
|
-
status: 'open',
|
|
3349
4029
|
detail: f.detail,
|
|
3350
4030
|
package_name: f.name,
|
|
3351
4031
|
package_version: f.version,
|
|
3352
|
-
},
|
|
3353
|
-
|
|
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
|
+
);
|
|
3354
4042
|
dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
|
|
3355
4043
|
'Bash', repo, sessionId, config.captureDepth, {
|
|
3356
4044
|
command, reasoning: scan.blockContext.slice(0, 200),
|
|
@@ -3443,7 +4131,7 @@ async function main() {
|
|
|
3443
4131
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
3444
4132
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
3445
4133
|
const permissionMode = payload.permission_mode || '';
|
|
3446
|
-
const transcriptPath = payload
|
|
4134
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
3447
4135
|
const transcript = extractTranscript(transcriptPath);
|
|
3448
4136
|
|
|
3449
4137
|
let command = '';
|
|
@@ -3513,20 +4201,20 @@ async function main() {
|
|
|
3513
4201
|
return;
|
|
3514
4202
|
}
|
|
3515
4203
|
|
|
3516
|
-
// ─── Install protection:
|
|
3517
|
-
// The install-scan hook (INSTALL_SCAN_TS) runs before this hook, calls
|
|
3518
|
-
// runInstallScan(), outputs its own system message, and caches the result.
|
|
3519
|
-
// We just read the cache to feed scanConcern into the grader prompt so
|
|
3520
|
-
// the consent-carryover flow works.
|
|
4204
|
+
// ─── Install protection: install-scan runs first and owns block traces ───
|
|
3521
4205
|
let scanConcern = '';
|
|
3522
4206
|
let scanBlockContext = '';
|
|
3523
4207
|
if (toolName === 'Bash') {
|
|
3524
4208
|
const scan = readCachedScan(command);
|
|
3525
|
-
if (scan
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
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.';
|
|
3530
4218
|
}
|
|
3531
4219
|
}
|
|
3532
4220
|
|
|
@@ -3591,7 +4279,7 @@ async function main() {
|
|
|
3591
4279
|
});
|
|
3592
4280
|
dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
|
|
3593
4281
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3594
|
-
command, reasoning: guardReason, rulesChecked:
|
|
4282
|
+
command, reasoning: guardReason, rulesChecked: relevantRules, violatedRules,
|
|
3595
4283
|
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
3596
4284
|
});
|
|
3597
4285
|
} else {
|
|
@@ -3603,7 +4291,7 @@ async function main() {
|
|
|
3603
4291
|
dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'trivial_utility',
|
|
3604
4292
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3605
4293
|
command, reasoning: verdict.reason || 'no policy violations detected',
|
|
3606
|
-
rulesChecked:
|
|
4294
|
+
rulesChecked: relevantRules, violatedRules: [],
|
|
3607
4295
|
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
3608
4296
|
});
|
|
3609
4297
|
}
|
|
@@ -3701,7 +4389,7 @@ async function main() {
|
|
|
3701
4389
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
3702
4390
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
3703
4391
|
const permissionMode = payload.permission_mode || '';
|
|
3704
|
-
const transcriptPath = payload
|
|
4392
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
3705
4393
|
|
|
3706
4394
|
const prompt = toolInput.prompt || '';
|
|
3707
4395
|
const description = toolInput.description || '';
|
|
@@ -3724,8 +4412,11 @@ async function main() {
|
|
|
3724
4412
|
|
|
3725
4413
|
if (!transcript.ccModel) {
|
|
3726
4414
|
const rawModel = String(payload.model ?? payload.model_id ?? '');
|
|
3727
|
-
|
|
3728
|
-
|
|
4415
|
+
if (agentKind === 'cursor') {
|
|
4416
|
+
transcript.ccModel = rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor';
|
|
4417
|
+
} else {
|
|
4418
|
+
transcript.ccModel = rawModel || '';
|
|
4419
|
+
}
|
|
3729
4420
|
}
|
|
3730
4421
|
|
|
3731
4422
|
const config = await loadConfig(jwt);
|
|
@@ -3783,7 +4474,7 @@ async function main() {
|
|
|
3783
4474
|
});
|
|
3784
4475
|
dispatchCapture(jwt, 'agent', 'block', verdict.severity || 'critical', verdict.category || 'security',
|
|
3785
4476
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3786
|
-
command: agentContent, reasoning: guardReason, rulesChecked:
|
|
4477
|
+
command: agentContent, reasoning: guardReason, rulesChecked: relevantRules, violatedRules,
|
|
3787
4478
|
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
3788
4479
|
});
|
|
3789
4480
|
} else {
|
|
@@ -3792,7 +4483,7 @@ async function main() {
|
|
|
3792
4483
|
dispatchCapture(jwt, 'agent', 'pass', 'clean', verdict.category || 'subagent_spawn',
|
|
3793
4484
|
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3794
4485
|
command: agentContent, reasoning: verdict.reason || 'no policy violations detected',
|
|
3795
|
-
rulesChecked:
|
|
4486
|
+
rulesChecked: relevantRules, violatedRules: [],
|
|
3796
4487
|
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
3797
4488
|
});
|
|
3798
4489
|
}
|
|
@@ -3918,7 +4609,7 @@ async function main() {
|
|
|
3918
4609
|
const sessionId = hookSessionId(payload);
|
|
3919
4610
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
3920
4611
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
3921
|
-
const transcriptPath = payload
|
|
4612
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
3922
4613
|
const gitRepo = detectRepo(cwd, transcriptPath, plan, workspaceRoots);
|
|
3923
4614
|
|
|
3924
4615
|
appendSessionAction(sessionId, { ts: new Date().toISOString(), tool: 'ExitPlanMode', summary: 'plan review: ' + plan.slice(0, 80) });
|
|
@@ -3931,8 +4622,7 @@ async function main() {
|
|
|
3931
4622
|
jwt = await ensureFreshJwt(jwt);
|
|
3932
4623
|
|
|
3933
4624
|
const rawModel = String(payload.model ?? payload.model_id ?? '');
|
|
3934
|
-
const
|
|
3935
|
-
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 || '');
|
|
3936
4626
|
|
|
3937
4627
|
const config = await loadConfig(jwt);
|
|
3938
4628
|
const rt = await route(config);
|
|
@@ -3975,7 +4665,7 @@ async function main() {
|
|
|
3975
4665
|
dispatchCapture(jwt, 'plan_review', 'advisory', verdict.severity || 'medium', verdict.category || 'general',
|
|
3976
4666
|
'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
|
|
3977
4667
|
command: planContent, reasoning: verdict.reason || 'check org rules',
|
|
3978
|
-
rulesChecked:
|
|
4668
|
+
rulesChecked: relevantRules, violatedRules, ccModel: ccModel || undefined,
|
|
3979
4669
|
});
|
|
3980
4670
|
} else {
|
|
3981
4671
|
const reviewMsg = verdict.reason || 'no relevant org rules for this plan';
|
|
@@ -3985,7 +4675,7 @@ async function main() {
|
|
|
3985
4675
|
dispatchCapture(jwt, 'plan_review', 'clean', 'clean', verdict.category || 'general',
|
|
3986
4676
|
'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
|
|
3987
4677
|
command: planContent, reasoning: reviewMsg,
|
|
3988
|
-
rulesChecked:
|
|
4678
|
+
rulesChecked: relevantRules, violatedRules: [], ccModel: ccModel || undefined,
|
|
3989
4679
|
});
|
|
3990
4680
|
}
|
|
3991
4681
|
return;
|
|
@@ -4033,7 +4723,8 @@ main();
|
|
|
4033
4723
|
STOP_SUMMARY_TS = `#!/usr/bin/env bun
|
|
4034
4724
|
import {
|
|
4035
4725
|
loadJwt, detectRepo, loadConfig, tag, readStdin, aggregateUsage, cleanupSessionLog,
|
|
4036
|
-
outputJson, outputEmpty,
|
|
4726
|
+
outputJson, outputEmpty, shipCloud, setupCursorHookSignals, hookSessionId, GATEWAY_URL,
|
|
4727
|
+
resolveTranscriptPath, emitUsageTick, cursorModelFromPayload, log,
|
|
4037
4728
|
} from './_synkro-common.ts';
|
|
4038
4729
|
|
|
4039
4730
|
async function main() {
|
|
@@ -4048,35 +4739,16 @@ async function main() {
|
|
|
4048
4739
|
|
|
4049
4740
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
4050
4741
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
4051
|
-
const transcriptPath = payload
|
|
4742
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
4052
4743
|
const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
|
|
4744
|
+
const modelFallback = cursorModelFromPayload(payload);
|
|
4053
4745
|
|
|
4054
4746
|
let jwt = loadJwt();
|
|
4055
4747
|
if (!jwt) { outputEmpty(); return; }
|
|
4056
4748
|
|
|
4057
4749
|
if (transcriptPath) {
|
|
4058
|
-
const usage = aggregateUsage(transcriptPath);
|
|
4059
|
-
|
|
4060
|
-
const usageBody = {
|
|
4061
|
-
capture_type: 'usage_tick',
|
|
4062
|
-
event_id: 'usage_' + Date.now() + '_' + process.pid,
|
|
4063
|
-
hook_type: 'stop',
|
|
4064
|
-
verdict: 'allow',
|
|
4065
|
-
severity: 'none',
|
|
4066
|
-
model: usage.model || 'unknown',
|
|
4067
|
-
cc_model: usage.model || '',
|
|
4068
|
-
cc_usage: {
|
|
4069
|
-
input_tokens: usage.totals.in,
|
|
4070
|
-
output_tokens: usage.totals.out,
|
|
4071
|
-
cache_creation_input_tokens: usage.totals.cw,
|
|
4072
|
-
cache_read_input_tokens: usage.totals.cr,
|
|
4073
|
-
},
|
|
4074
|
-
...(gitRepo ? { repo: gitRepo } : {}),
|
|
4075
|
-
...(sessionId ? { session_id: sessionId } : {}),
|
|
4076
|
-
};
|
|
4077
|
-
appendLocalTelemetry(usageBody);
|
|
4078
|
-
shipCloud(jwt, '/api/v1/hook/capture', usageBody);
|
|
4079
|
-
}
|
|
4750
|
+
const usage = aggregateUsage(transcriptPath, { modelFallback });
|
|
4751
|
+
emitUsageTick({ sessionId, usage, hookType: 'session_end', gitRepo, modelFallback });
|
|
4080
4752
|
}
|
|
4081
4753
|
|
|
4082
4754
|
let resp: any;
|
|
@@ -4132,7 +4804,7 @@ async function main() {
|
|
|
4132
4804
|
const payload = JSON.parse(input);
|
|
4133
4805
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
4134
4806
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
4135
|
-
const transcriptPath = payload
|
|
4807
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
4136
4808
|
const sessionId = hookSessionId(payload);
|
|
4137
4809
|
const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
|
|
4138
4810
|
if (gitRepo) writeCachedRepo(gitRepo);
|
|
@@ -4264,10 +4936,9 @@ main();
|
|
|
4264
4936
|
import {
|
|
4265
4937
|
loadJwt, detectRepo, readStdin, aggregateUsage, appendLocalTelemetry,
|
|
4266
4938
|
outputEmpty, setupCursorHookSignals, hookSessionId, GATEWAY_URL, readSessionLog, shipCloud,
|
|
4939
|
+
resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload,
|
|
4267
4940
|
} from './_synkro-common.ts';
|
|
4268
|
-
import {
|
|
4269
|
-
import { join, dirname } from 'node:path';
|
|
4270
|
-
import { homedir } from 'node:os';
|
|
4941
|
+
import { readFileSync } from 'node:fs';
|
|
4271
4942
|
|
|
4272
4943
|
async function main() {
|
|
4273
4944
|
setupCursorHookSignals();
|
|
@@ -4277,11 +4948,11 @@ async function main() {
|
|
|
4277
4948
|
|
|
4278
4949
|
const payload = JSON.parse(input);
|
|
4279
4950
|
const sessionId = hookSessionId(payload);
|
|
4280
|
-
const transcriptPath = payload.transcript_path || '';
|
|
4281
4951
|
const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
|
|
4282
4952
|
const cwd = payload.cwd || workspaceRoots[0] || '';
|
|
4953
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
4283
4954
|
|
|
4284
|
-
if (!sessionId || !transcriptPath
|
|
4955
|
+
if (!sessionId || !transcriptPath) {
|
|
4285
4956
|
outputEmpty();
|
|
4286
4957
|
return;
|
|
4287
4958
|
}
|
|
@@ -4289,104 +4960,21 @@ async function main() {
|
|
|
4289
4960
|
const jwt = loadJwt();
|
|
4290
4961
|
if (!jwt) { outputEmpty(); return; }
|
|
4291
4962
|
|
|
4292
|
-
const usage = aggregateUsage(transcriptPath);
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
model: usage.model || 'unknown',
|
|
4301
|
-
cc_model: usage.model || '',
|
|
4302
|
-
cc_usage: {
|
|
4303
|
-
input_tokens: usage.totals.in,
|
|
4304
|
-
output_tokens: usage.totals.out,
|
|
4305
|
-
cache_creation_input_tokens: usage.totals.cw,
|
|
4306
|
-
cache_read_input_tokens: usage.totals.cr,
|
|
4307
|
-
},
|
|
4308
|
-
session_id: sessionId,
|
|
4309
|
-
};
|
|
4310
|
-
appendLocalTelemetry(usageBody);
|
|
4311
|
-
shipCloud(jwt, '/api/v1/hook/capture', usageBody);
|
|
4312
|
-
}
|
|
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
|
+
});
|
|
4313
4971
|
|
|
4314
|
-
// Transcript consent gates only CLOUD transmission. Local persistence \u2014
|
|
4315
|
-
// this machine's own PGLite \u2014 is the same category as the local telemetry
|
|
4316
|
-
// already captured for every command, so it always runs.
|
|
4317
4972
|
const cloudConsent = process.env.SYNKRO_TRANSCRIPT_CONSENT !== 'no';
|
|
4318
4973
|
const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
|
|
4319
4974
|
|
|
4320
|
-
|
|
4321
|
-
const offsetDir = join(homedir(), '.synkro', '.transcript-offsets');
|
|
4322
|
-
mkdirSync(offsetDir, { recursive: true });
|
|
4323
|
-
const offsetFile = join(offsetDir, sessionId);
|
|
4324
|
-
let offset = 0;
|
|
4325
|
-
if (existsSync(offsetFile)) {
|
|
4326
|
-
try { offset = parseInt(readFileSync(offsetFile, 'utf-8').trim(), 10) || 0; } catch {}
|
|
4327
|
-
}
|
|
4328
|
-
|
|
4329
|
-
const raw = readFileSync(transcriptPath, 'utf-8');
|
|
4330
|
-
const allLines = raw.split('\\n').filter(l => l.trim());
|
|
4331
|
-
const totalLines = allLines.length;
|
|
4332
|
-
|
|
4333
|
-
if (totalLines <= offset) { outputEmpty(); return; }
|
|
4334
|
-
|
|
4335
|
-
let startIdx = offset;
|
|
4336
|
-
const delta = totalLines - offset;
|
|
4337
|
-
if (delta > 200) startIdx = totalLines - 200;
|
|
4338
|
-
|
|
4339
|
-
const messages: any[] = [];
|
|
4340
|
-
for (let i = startIdx; i < totalLines; i++) {
|
|
4341
|
-
try {
|
|
4342
|
-
const entry = JSON.parse(allLines[i]);
|
|
4343
|
-
if (entry.type !== 'user' && entry.type !== 'assistant') continue;
|
|
4344
|
-
const content = entry.message?.content;
|
|
4345
|
-
let text = '';
|
|
4346
|
-
if (typeof content === 'string') text = content.slice(0, 8000);
|
|
4347
|
-
else if (Array.isArray(content)) {
|
|
4348
|
-
text = content.map((c: any) => {
|
|
4349
|
-
if (typeof c === 'string') return c;
|
|
4350
|
-
if (c?.type === 'text') return c.text || '';
|
|
4351
|
-
return '';
|
|
4352
|
-
}).join(' ').slice(0, 8000);
|
|
4353
|
-
}
|
|
4354
|
-
|
|
4355
|
-
const msg: any = { message_index: i, type: entry.type, content: text, ts: entry.timestamp || null };
|
|
4356
|
-
if (entry.type === 'assistant') {
|
|
4357
|
-
const toolCalls = (Array.isArray(content) ? content : [])
|
|
4358
|
-
.filter((c: any) => c?.type === 'tool_use')
|
|
4359
|
-
.map((c: any) => ({ name: c.name, input: JSON.stringify(c.input || {}).slice(0, 500), id: c.id }));
|
|
4360
|
-
if (toolCalls.length > 0) msg.tool_calls = toolCalls;
|
|
4361
|
-
msg.model = entry.message?.model || null;
|
|
4362
|
-
const u = entry.message?.usage;
|
|
4363
|
-
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 };
|
|
4364
|
-
}
|
|
4365
|
-
messages.push(msg);
|
|
4366
|
-
} catch {}
|
|
4367
|
-
}
|
|
4368
|
-
|
|
4369
|
-
writeFileSync(offsetFile, String(totalLines), 'utf-8');
|
|
4370
|
-
|
|
4371
|
-
if (messages.length === 0) { outputEmpty(); return; }
|
|
4975
|
+
const { messages } = await syncConversationTranscript(sessionId, transcriptPath, gitRepo || '');
|
|
4372
4976
|
|
|
4373
|
-
|
|
4374
|
-
const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
|
|
4375
|
-
let mcpToken = '';
|
|
4376
|
-
try { mcpToken = readFileSync(join(homedir(), '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
|
|
4377
|
-
if (mcpToken) {
|
|
4378
|
-
fetch('http://127.0.0.1:' + mcpPort + '/api/conversation-sync', {
|
|
4379
|
-
method: 'POST',
|
|
4380
|
-
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
|
|
4381
|
-
body: JSON.stringify({ session_id: sessionId, repo: gitRepo || '', messages }),
|
|
4382
|
-
signal: AbortSignal.timeout(5000),
|
|
4383
|
-
}).catch(() => {});
|
|
4384
|
-
}
|
|
4385
|
-
|
|
4386
|
-
// Cloud sync \u2014 only when storage mode is cloud (and transcript consent
|
|
4387
|
-
// wasn't declined). Carries the transcript + the session step-log so cloud
|
|
4388
|
-
// storage matches the local PGLite shape.
|
|
4389
|
-
if (cloudConsent && gitRepo && (process.env.SYNKRO_STORAGE_MODE || 'local') === 'cloud') {
|
|
4977
|
+
if (cloudConsent && gitRepo && messages.length > 0 && (process.env.SYNKRO_STORAGE_MODE || 'local') === 'cloud') {
|
|
4390
4978
|
fetch(GATEWAY_URL + '/api/v1/cli/sync-transcripts', {
|
|
4391
4979
|
method: 'POST',
|
|
4392
4980
|
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
@@ -4407,7 +4995,7 @@ async function main() {
|
|
|
4407
4995
|
main();
|
|
4408
4996
|
`;
|
|
4409
4997
|
USER_PROMPT_SUBMIT_TS = `#!/usr/bin/env bun
|
|
4410
|
-
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';
|
|
4411
4999
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
4412
5000
|
import { join, dirname } from 'node:path';
|
|
4413
5001
|
import { homedir } from 'node:os';
|
|
@@ -4432,25 +5020,15 @@ async function main() {
|
|
|
4432
5020
|
}
|
|
4433
5021
|
|
|
4434
5022
|
const sessionId = hookSessionId(payload);
|
|
4435
|
-
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);
|
|
4436
5026
|
if (sessionId && transcriptPath) {
|
|
4437
|
-
const
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
hook_type: 'prompt_submit',
|
|
4443
|
-
session_id: sessionId,
|
|
4444
|
-
model: usage.model || 'unknown',
|
|
4445
|
-
cc_model: usage.model || '',
|
|
4446
|
-
cc_usage: {
|
|
4447
|
-
input_tokens: usage.totals.in,
|
|
4448
|
-
output_tokens: usage.totals.out,
|
|
4449
|
-
cache_creation_input_tokens: usage.totals.cw,
|
|
4450
|
-
cache_read_input_tokens: usage.totals.cr,
|
|
4451
|
-
},
|
|
4452
|
-
});
|
|
4453
|
-
}
|
|
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 });
|
|
4454
5032
|
}
|
|
4455
5033
|
outputEmpty();
|
|
4456
5034
|
} catch {
|
|
@@ -4464,7 +5042,7 @@ main();
|
|
|
4464
5042
|
import {
|
|
4465
5043
|
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
|
|
4466
5044
|
parseVerdict, dispatchCapture, dispatchFinding, ruleMode, normalizeMode, filterRules, ruleFilterText,
|
|
4467
|
-
isSafeInRepoRead, postWithRetry, readStdin, hashCommand,
|
|
5045
|
+
isSafeInRepoRead, resolveTranscriptPath, postWithRetry, readStdin, hashCommand,
|
|
4468
5046
|
extractTranscript, readLastPrompt, readSessionLog, compressSessionLog,
|
|
4469
5047
|
appendLocalTelemetry, logGraderUnavailable, log, GATEWAY_URL,
|
|
4470
5048
|
type Rule,
|
|
@@ -4577,10 +5155,9 @@ async function main() {
|
|
|
4577
5155
|
finishAllow();
|
|
4578
5156
|
}
|
|
4579
5157
|
|
|
4580
|
-
const transcriptPath =
|
|
5158
|
+
const transcriptPath = resolveTranscriptPath(payload);
|
|
4581
5159
|
const rawModel = String(payload.model ?? payload.model_id ?? '');
|
|
4582
|
-
const
|
|
4583
|
-
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';
|
|
4584
5161
|
const repo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
|
|
4585
5162
|
|
|
4586
5163
|
const cmdShort = command.slice(0, 80);
|
|
@@ -4613,16 +5190,19 @@ async function main() {
|
|
|
4613
5190
|
const rt = await route(config);
|
|
4614
5191
|
const tagStr = tag(rt, config);
|
|
4615
5192
|
|
|
4616
|
-
// Install protection \u2014
|
|
5193
|
+
// Install protection \u2014 install-scan runs first and owns block traces.
|
|
4617
5194
|
let scanConcern = '';
|
|
4618
5195
|
let scanBlockContext = '';
|
|
4619
5196
|
if (SHELL_TOOL_NAMES.has(toolName)) {
|
|
4620
5197
|
const scan = readCachedScan(command);
|
|
4621
|
-
if (scan
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
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.';
|
|
4626
5206
|
}
|
|
4627
5207
|
}
|
|
4628
5208
|
|
|
@@ -4677,7 +5257,7 @@ async function main() {
|
|
|
4677
5257
|
dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
|
|
4678
5258
|
'Bash', repo, sessionId, config.captureDepth, {
|
|
4679
5259
|
command, reasoning: guardReason,
|
|
4680
|
-
rulesChecked:
|
|
5260
|
+
rulesChecked: relevantRules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
|
|
4681
5261
|
ccModel: model,
|
|
4682
5262
|
});
|
|
4683
5263
|
finishWith({
|
|
@@ -4689,7 +5269,7 @@ async function main() {
|
|
|
4689
5269
|
dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'clean',
|
|
4690
5270
|
'Bash', repo, sessionId, config.captureDepth, {
|
|
4691
5271
|
command, reasoning: verdict.reason || 'no policy violations detected',
|
|
4692
|
-
rulesChecked:
|
|
5272
|
+
rulesChecked: relevantRules, violatedRules: [],
|
|
4693
5273
|
ccModel: model,
|
|
4694
5274
|
});
|
|
4695
5275
|
}
|
|
@@ -4756,8 +5336,9 @@ main().catch((e) => {
|
|
|
4756
5336
|
});`;
|
|
4757
5337
|
CURSOR_EDIT_CAPTURE_TS = `#!/usr/bin/env bun
|
|
4758
5338
|
import {
|
|
4759
|
-
loadJwt, ensureFreshJwt, detectRepo, readStdin,
|
|
5339
|
+
loadJwt, ensureFreshJwt, detectRepo, readStdin, resolveTranscriptPath,
|
|
4760
5340
|
appendSessionAction, appendLocalTelemetry, shipCloud, log, GATEWAY_URL,
|
|
5341
|
+
countEditLineDelta, dispatchCapture, hookSessionId, cursorModelFromPayload,
|
|
4761
5342
|
} from './_synkro-common.ts';
|
|
4762
5343
|
import { existsSync, readFileSync } from 'node:fs';
|
|
4763
5344
|
import { basename, dirname, join } from 'node:path';
|
|
@@ -4780,23 +5361,31 @@ async function main() {
|
|
|
4780
5361
|
const input = await readStdin();
|
|
4781
5362
|
if (!input.trim()) finish();
|
|
4782
5363
|
|
|
4783
|
-
const payload = JSON.parse(input)
|
|
4784
|
-
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 || '');
|
|
4785
5366
|
if (!filePath) finish();
|
|
4786
5367
|
|
|
4787
|
-
const workspaceRoots = Array.isArray(payload.workspace_roots)
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
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);
|
|
4791
5374
|
const repo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
|
|
5375
|
+
const model = cursorModelFromPayload(payload);
|
|
4792
5376
|
|
|
4793
|
-
const
|
|
4794
|
-
const
|
|
4795
|
-
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 });
|
|
4796
5379
|
|
|
4797
|
-
log('editScan ' + basename(filePath));
|
|
5380
|
+
log('editScan ' + basename(filePath) + ' +' + linesAdded + '/-' + linesRemoved);
|
|
4798
5381
|
|
|
4799
|
-
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
|
+
});
|
|
4800
5389
|
|
|
4801
5390
|
let jwt = loadJwt();
|
|
4802
5391
|
if (!jwt) finish();
|
|
@@ -4842,6 +5431,16 @@ async function main() {
|
|
|
4842
5431
|
appendLocalTelemetry(captureBody);
|
|
4843
5432
|
shipCloud(jwt, '/api/v1/hook/capture', captureBody, 10000);
|
|
4844
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
|
+
|
|
4845
5444
|
finish();
|
|
4846
5445
|
} catch (e) {
|
|
4847
5446
|
log('editScan error: ' + String(e));
|
|
@@ -4853,6 +5452,47 @@ main().catch((e) => {
|
|
|
4853
5452
|
log('editScan fatal: ' + String(e));
|
|
4854
5453
|
finish();
|
|
4855
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
|
+
`;
|
|
4856
5496
|
}
|
|
4857
5497
|
});
|
|
4858
5498
|
|
|
@@ -6794,6 +7434,7 @@ function writeHookScripts() {
|
|
|
6794
7434
|
const installScanScriptPath = join8(HOOKS_DIR, "cc-install-scan.ts");
|
|
6795
7435
|
const cursorBashJudgePath = join8(HOOKS_DIR, "cursor-bash-judge.ts");
|
|
6796
7436
|
const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
|
|
7437
|
+
const cursorAgentCapturePath = join8(HOOKS_DIR, "cursor-agent-capture.ts");
|
|
6797
7438
|
const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
|
|
6798
7439
|
writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
|
|
6799
7440
|
writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
|
|
@@ -6811,8 +7452,9 @@ function writeHookScripts() {
|
|
|
6811
7452
|
writeFileSync7(installScanScriptPath, INSTALL_SCAN_TS, "utf-8");
|
|
6812
7453
|
writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
|
|
6813
7454
|
writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
|
|
7455
|
+
writeFileSync7(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
|
|
6814
7456
|
writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
|
|
6815
|
-
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");
|
|
6816
7458
|
const hooksPkgPath = join8(HOOKS_DIR, "package.json");
|
|
6817
7459
|
writeFileSync7(hooksPkgPath, JSON.stringify({
|
|
6818
7460
|
name: "synkro-hooks",
|
|
@@ -6840,6 +7482,7 @@ function writeHookScripts() {
|
|
|
6840
7482
|
chmodSync2(installScanScriptPath, 493);
|
|
6841
7483
|
chmodSync2(cursorBashJudgePath, 493);
|
|
6842
7484
|
chmodSync2(cursorEditCapturePath, 493);
|
|
7485
|
+
chmodSync2(cursorAgentCapturePath, 493);
|
|
6843
7486
|
chmodSync2(mcpStdioProxyPath, 493);
|
|
6844
7487
|
chmodSync2(installExtractCorePath, 493);
|
|
6845
7488
|
return {
|
|
@@ -6856,7 +7499,8 @@ function writeHookScripts() {
|
|
|
6856
7499
|
userPromptSubmitScript: userPromptSubmitScriptPath,
|
|
6857
7500
|
installScanScript: installScanScriptPath,
|
|
6858
7501
|
cursorBashJudgeScript: cursorBashJudgePath,
|
|
6859
|
-
cursorEditCaptureScript: cursorEditCapturePath
|
|
7502
|
+
cursorEditCaptureScript: cursorEditCapturePath,
|
|
7503
|
+
cursorAgentCaptureScript: cursorAgentCapturePath
|
|
6860
7504
|
};
|
|
6861
7505
|
}
|
|
6862
7506
|
function sanitizeConfigValue(raw, maxLen = 256) {
|
|
@@ -6888,7 +7532,7 @@ function writeConfigEnv(opts) {
|
|
|
6888
7532
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
6889
7533
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
6890
7534
|
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
6891
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.6.
|
|
7535
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.6.25")}`
|
|
6892
7536
|
];
|
|
6893
7537
|
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
6894
7538
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
@@ -7139,6 +7783,7 @@ async function installCommand(opts = {}) {
|
|
|
7139
7783
|
installCursorHooks(agent.settingsPath, {
|
|
7140
7784
|
bashJudgeScriptPath: scripts.cursorBashJudgeScript,
|
|
7141
7785
|
editCaptureScriptPath: scripts.cursorEditCaptureScript,
|
|
7786
|
+
agentCaptureScriptPath: scripts.cursorAgentCaptureScript,
|
|
7142
7787
|
bashFollowupScriptPath: scripts.bashFollowupScript,
|
|
7143
7788
|
editPrecheckScriptPath: scripts.editPrecheckScript,
|
|
7144
7789
|
cwePrecheckScriptPath: scripts.cwePrecheckScript,
|
|
@@ -9555,7 +10200,7 @@ var args = process.argv.slice(2);
|
|
|
9555
10200
|
var cmd = args[0] || "";
|
|
9556
10201
|
var subArgs = args.slice(1);
|
|
9557
10202
|
function printVersion() {
|
|
9558
|
-
console.log("1.6.
|
|
10203
|
+
console.log("1.6.25");
|
|
9559
10204
|
}
|
|
9560
10205
|
function printHelp2() {
|
|
9561
10206
|
console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
|