@synkro-sh/cli 1.6.14 → 1.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bootstrap.js CHANGED
@@ -116,6 +116,17 @@ function installCCHooks(settingsPath, config) {
116
116
  settings.hooks.SessionEnd = settings.hooks.SessionEnd ?? [];
117
117
  settings.hooks.SessionStart = settings.hooks.SessionStart ?? [];
118
118
  settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit ?? [];
119
+ settings.hooks.PreToolUse.push({
120
+ matcher: "Bash",
121
+ hooks: [
122
+ {
123
+ type: "command",
124
+ command: config.installScanScriptPath,
125
+ timeout: 8
126
+ }
127
+ ],
128
+ [SYNKRO_MARKER]: true
129
+ });
119
130
  settings.hooks.PreToolUse.push({
120
131
  matcher: "Bash|Read|Grep|Glob",
121
132
  hooks: [
@@ -319,6 +330,12 @@ function installCursorHooks(hooksJsonPath, config) {
319
330
  pushCcHook(h, "beforeSubmitPrompt", config.userPromptSubmitScriptPath, { timeout: 5 });
320
331
  pushCcHook(h, "stop", config.transcriptSyncScriptPath, { timeout: 3 });
321
332
  h.beforeShellExecution = h.beforeShellExecution ?? [];
333
+ h.beforeShellExecution.push({
334
+ command: cursorCcCmd(config.installScanScriptPath),
335
+ timeout: 8,
336
+ failClosed: false,
337
+ [SYNKRO_MARKER2]: true
338
+ });
322
339
  h.beforeShellExecution.push({
323
340
  command: bunRunCmd(config.bashJudgeScriptPath),
324
341
  timeout: 15,
@@ -327,6 +344,13 @@ function installCursorHooks(hooksJsonPath, config) {
327
344
  });
328
345
  pushCcHook(h, "afterShellExecution", config.bashFollowupScriptPath, { timeout: 10 });
329
346
  h.preToolUse = h.preToolUse ?? [];
347
+ h.preToolUse.push({
348
+ command: cursorCcCmd(config.installScanScriptPath),
349
+ timeout: 8,
350
+ failClosed: false,
351
+ matcher: "Shell|Bash|terminal|run_terminal_cmd|execute_command",
352
+ [SYNKRO_MARKER2]: true
353
+ });
330
354
  h.preToolUse.push({
331
355
  command: bunRunCmd(config.bashJudgeScriptPath),
332
356
  timeout: 15,
@@ -727,7 +751,7 @@ synkro_post_with_retry() {
727
751
  });
728
752
 
729
753
  // cli/installer/hookScriptsTs.ts
730
- var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_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;
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;
731
755
  var init_hookScriptsTs = __esm({
732
756
  "cli/installer/hookScriptsTs.ts"() {
733
757
  "use strict";
@@ -975,12 +999,19 @@ export function normalizeMode(m?: string): 'ask' | 'fix' {
975
999
 
976
1000
  // \u2500\u2500\u2500 Config Loading \u2500\u2500\u2500
977
1001
 
1002
+ export interface RuleExample {
1003
+ text: string;
1004
+ verdict: 'violation' | 'ok';
1005
+ note?: string;
1006
+ }
1007
+
978
1008
  export interface Rule {
979
1009
  rule_id: string;
980
1010
  text: string;
981
1011
  severity: string;
982
1012
  category: string;
983
1013
  mode: string;
1014
+ examples?: RuleExample[];
984
1015
  }
985
1016
 
986
1017
  export interface HookConfig {
@@ -1032,6 +1063,7 @@ export async function loadConfig(jwt: string, query?: string): Promise<HookConfi
1032
1063
  severity: r.severity || '',
1033
1064
  category: r.category || '',
1034
1065
  mode: normalizeMode(r.mode),
1066
+ examples: Array.isArray(r.examples) ? r.examples : undefined,
1035
1067
  }));
1036
1068
  }
1037
1069
  config.silent = raw.silent === true;
@@ -1072,6 +1104,7 @@ export async function loadConfig(jwt: string, query?: string): Promise<HookConfi
1072
1104
  severity: r.severity || '',
1073
1105
  category: r.category || '',
1074
1106
  mode: normalizeMode(r.mode),
1107
+ examples: Array.isArray(r.examples) ? r.examples : undefined,
1075
1108
  }));
1076
1109
  }
1077
1110
  } catch {}
@@ -1322,9 +1355,15 @@ export async function runInstallScan(command: string, jwt: string): Promise<Inst
1322
1355
  const summary = scanResp?.summary || '';
1323
1356
  const scannedLabel = pkgResults.map((p: any) => p.name + '@' + p.version).join(', ');
1324
1357
  if (action === 'block') {
1325
- const blockSignals = pkgResults
1326
- .flatMap((p: any) => (p.signals || []).filter((s: any) => s.severity === 'critical' || s.severity === 'high'))
1327
- .slice(0, 5);
1358
+ // Every critical/high signal (uncapped) + the true CVE total. The grader
1359
+ // only sees what we put in blockContext \u2014 so the real count must be
1360
+ // STATED here; a bare preview lets it under-report (the count is data,
1361
+ // not something the grader should infer from a truncated list).
1362
+ const highSignals = pkgResults
1363
+ .flatMap((p: any) => (p.signals || []).filter((s: any) => s.severity === 'critical' || s.severity === 'high'));
1364
+ const cveCount = pkgResults
1365
+ .flatMap((p: any) => (p.signals || []))
1366
+ .filter((s: any) => s.type === 'cve').length;
1328
1367
  const findings: InstallScanResult['findings'] = [];
1329
1368
  for (const p of pkgResults) {
1330
1369
  for (const s of (p.signals || [])) {
@@ -1337,10 +1376,20 @@ export async function runInstallScan(command: string, jwt: string): Promise<Inst
1337
1376
  }
1338
1377
  }
1339
1378
  }
1340
- const details = blockSignals.map((s: any) => s.detail).join('\\n') || summary;
1379
+ // Preview the top 5 detail lines; the headline carries the true total.
1380
+ const blockSignals = highSignals.slice(0, 5);
1381
+ const headline = cveCount > 0
1382
+ ? cveCount + ' known CVE' + (cveCount === 1 ? '' : 's') + ' found in ' + (scannedLabel || 'the requested install') + '.\\n'
1383
+ : '';
1384
+ const preview = blockSignals.map((s: any) => s.detail).join('\\n') || summary;
1385
+ const more = highSignals.length > blockSignals.length
1386
+ ? '\\n(+' + (highSignals.length - blockSignals.length) + ' more critical/high findings not shown)'
1387
+ : '';
1341
1388
  return {
1342
1389
  scanned: true, action: 'block',
1343
- blockContext: details + '\\nDo NOT install packages with security risks. Use a patched version or a different package.',
1390
+ blockContext: headline + preview + more
1391
+ + '\\nReport the CVE count and fix version exactly as stated above \u2014 do not estimate.'
1392
+ + '\\nDo NOT install packages with security risks. Use a patched version or a different package.',
1344
1393
  summary, scannedLabel, findings,
1345
1394
  violatedIds: blockSignals.map((s: any) => s.type + ':' + (s.detail || '').slice(0, 40)),
1346
1395
  };
@@ -2255,6 +2304,8 @@ import {
2255
2304
  import { existsSync, readFileSync } from 'node:fs';
2256
2305
  import { basename, dirname, join } from 'node:path';
2257
2306
 
2307
+ const agentKind = process.env.SYNKRO_HOOK_FORMAT === 'cursor' ? 'cursor' : 'claude_code';
2308
+
2258
2309
  async function main() {
2259
2310
  setupCursorHookSignals();
2260
2311
  try {
@@ -2346,7 +2397,7 @@ async function main() {
2346
2397
 
2347
2398
  let gradeResp: string;
2348
2399
  try {
2349
- gradeResp = await localGrade('edit', graderPrompt);
2400
+ gradeResp = await localGrade('edit', graderPrompt, undefined, agentKind);
2350
2401
  } catch (err) {
2351
2402
  logGraderUnavailable('editGuard', fileShort, (err as Error).message || String(err));
2352
2403
  outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 local grader unavailable, skipped' });
@@ -2467,6 +2518,7 @@ import {
2467
2518
  import { basename, extname, resolve, join, dirname } from 'node:path';
2468
2519
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
2469
2520
 
2521
+ const agentKind = process.env.SYNKRO_HOOK_FORMAT === 'cursor' ? 'cursor' : 'claude_code';
2470
2522
 
2471
2523
  interface PackageCapability {
2472
2524
  name: string;
@@ -2727,8 +2779,8 @@ async function main() {
2727
2779
  const chunk2 = cweContent.slice(mid - OVERLAP);
2728
2780
  try {
2729
2781
  const [resp1, resp2] = await Promise.all([
2730
- localGradeCwe(buildCwePrompt(chunk1)),
2731
- localGradeCwe(buildCwePrompt(chunk2)),
2782
+ localGradeCwe(buildCwePrompt(chunk1), agentKind),
2783
+ localGradeCwe(buildCwePrompt(chunk2), agentKind),
2732
2784
  ]);
2733
2785
  gradeResponses = [resp1, resp2];
2734
2786
  } catch (gradeErr: any) {
@@ -2739,7 +2791,7 @@ async function main() {
2739
2791
  }
2740
2792
  } else {
2741
2793
  try {
2742
- gradeResponses = [await localGradeCwe(buildCwePrompt(cweContent))];
2794
+ gradeResponses = [await localGradeCwe(buildCwePrompt(cweContent), agentKind)];
2743
2795
  } catch (gradeErr: any) {
2744
2796
  const reason = gradeErr?.message || String(gradeErr);
2745
2797
  logGraderUnavailable('cweGuard', fileShort, reason);
@@ -3100,6 +3152,96 @@ async function main() {
3100
3152
  }
3101
3153
  }
3102
3154
 
3155
+ main();
3156
+ `;
3157
+ INSTALL_SCAN_TS = `#!/usr/bin/env bun
3158
+ import {
3159
+ loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
3160
+ readStdin, runInstallScan, dispatchFinding, dispatchCapture, hashCommand,
3161
+ outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, log, GATEWAY_URL,
3162
+ } from './_synkro-common.ts';
3163
+ import { writeFileSync, mkdirSync } from 'node:fs';
3164
+ import { join } from 'node:path';
3165
+
3166
+ const SCAN_CACHE_DIR = (process.env.HOME || '/tmp') + '/.synkro/.scan-cache';
3167
+
3168
+ async function main() {
3169
+ setupCursorHookSignals();
3170
+ try {
3171
+ const input = await readStdin();
3172
+ if (!input.trim()) { outputEmpty(); return; }
3173
+
3174
+ const payload = JSON.parse(input);
3175
+ const toolName = payload.tool_name || '';
3176
+ if (!isShellTool(toolName)) { outputEmpty(); return; }
3177
+
3178
+ const toolInput = payload.tool_input || {};
3179
+ const command = typeof payload.command === 'string' ? payload.command : (toolInput.command || '');
3180
+ if (!command) { outputEmpty(); return; }
3181
+
3182
+ let jwt = loadJwt();
3183
+ if (!jwt) { outputEmpty(); return; }
3184
+ jwt = await ensureFreshJwt(jwt);
3185
+
3186
+ const scan = await runInstallScan(command, jwt);
3187
+
3188
+ if (scan.scanned) {
3189
+ try {
3190
+ mkdirSync(SCAN_CACHE_DIR, { recursive: true });
3191
+ writeFileSync(join(SCAN_CACHE_DIR, hashCommand(command)), JSON.stringify(scan), 'utf-8');
3192
+ } catch {}
3193
+ }
3194
+
3195
+ if (!scan.scanned) { outputEmpty(); return; }
3196
+
3197
+ const sessionId = hookSessionId(payload);
3198
+ const cwd = payload.cwd || '';
3199
+ const repo = detectRepo(cwd);
3200
+ const config = await loadConfig(jwt);
3201
+ const rt = await route(config);
3202
+ const tagStr = tag(rt, config);
3203
+
3204
+ if (scan.action === 'block') {
3205
+ for (const f of scan.findings) {
3206
+ dispatchFinding(jwt, {
3207
+ session_id: sessionId,
3208
+ file_path: command,
3209
+ finding_type: 'cve' as const,
3210
+ finding_id: f.advisoryId + ':' + f.name,
3211
+ severity: f.severity,
3212
+ status: 'open',
3213
+ detail: f.detail,
3214
+ package_name: f.name,
3215
+ package_version: f.version,
3216
+ }, config.captureDepth);
3217
+ }
3218
+ dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
3219
+ 'Bash', repo, sessionId, config.captureDepth, {
3220
+ command, reasoning: scan.blockContext.slice(0, 200),
3221
+ violatedRules: scan.violatedIds,
3222
+ });
3223
+ outputJson({
3224
+ systemMessage: '[synkro:installScan] ' + scan.summary,
3225
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: '[synkro:installScan] ' + scan.summary },
3226
+ });
3227
+ } else if (scan.action === 'warn') {
3228
+ outputJson({
3229
+ systemMessage: '[synkro:installScan] ' + scan.summary,
3230
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: '[synkro:installScan] ' + scan.summary },
3231
+ });
3232
+ } else {
3233
+ const label = scan.scannedLabel || command.slice(0, 80);
3234
+ outputJson({
3235
+ systemMessage: '[synkro:installScan] ' + label + ' \\u2192 clean',
3236
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: '[synkro:installScan] ' + label + ' \\u2192 clean' },
3237
+ });
3238
+ }
3239
+ } catch (err) {
3240
+ process.stderr.write('[synkro] installScan error: ' + String(err) + '\\n');
3241
+ outputEmpty();
3242
+ }
3243
+ }
3244
+
3103
3245
  main();
3104
3246
  `;
3105
3247
  BASH_JUDGE_TS = String.raw`#!/usr/bin/env bun
@@ -3110,9 +3252,43 @@ import {
3110
3252
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
3111
3253
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL,
3112
3254
  logGraderUnavailable, filterRules, normalizeMode, appendLocalTelemetry, isSafeInRepoRead,
3113
- runInstallScan,
3255
+ hashCommand,
3114
3256
  type HookConfig, type Rule,
3115
3257
  } from './_synkro-common.ts';
3258
+ import { createHash } from 'node:crypto';
3259
+ import { existsSync, statSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
3260
+ import { join } from 'node:path';
3261
+
3262
+ const SCAN_CACHE_DIR = (process.env.HOME || '/tmp') + '/.synkro/.scan-cache';
3263
+
3264
+ function readCachedScan(command: string): any | null {
3265
+ try {
3266
+ const path = join(SCAN_CACHE_DIR, hashCommand(command));
3267
+ if (!existsSync(path)) return null;
3268
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
3269
+ unlinkSync(path);
3270
+ return data;
3271
+ } catch { return null; }
3272
+ }
3273
+
3274
+ const DEDUP_DIR = process.env.HOME + '/.synkro/.dedup';
3275
+ const DEDUP_TTL_MS = 3000;
3276
+
3277
+ function isDuplicate(command: string, sessionId: string): boolean {
3278
+ const hash = createHash('md5').update(sessionId + ':' + command).digest('hex').slice(0, 12);
3279
+ const marker = DEDUP_DIR + '/' + hash;
3280
+ try {
3281
+ if (existsSync(marker)) {
3282
+ const age = Date.now() - statSync(marker).mtimeMs;
3283
+ if (age < DEDUP_TTL_MS) return true;
3284
+ }
3285
+ } catch {}
3286
+ try {
3287
+ mkdirSync(DEDUP_DIR, { recursive: true });
3288
+ writeFileSync(marker, '', { flag: 'w' });
3289
+ } catch {}
3290
+ return false;
3291
+ }
3116
3292
 
3117
3293
  async function main() {
3118
3294
  setupCursorHookSignals();
@@ -3150,6 +3326,12 @@ async function main() {
3150
3326
  }
3151
3327
  if (!command) { outputEmpty(); return; }
3152
3328
 
3329
+ if (isDuplicate(command, sessionId)) {
3330
+ log('bashGuard skip (dedup): ' + command.slice(0, 80));
3331
+ outputEmpty();
3332
+ return;
3333
+ }
3334
+
3153
3335
  const cmdShort = command.slice(0, 80);
3154
3336
  log('bashGuard checking: ' + cmdShort);
3155
3337
 
@@ -3195,45 +3377,20 @@ async function main() {
3195
3377
  return;
3196
3378
  }
3197
3379
 
3198
- // ─── Install protection: server-side pkg-scan (CVE + typosquat + tarball + reputation) ───
3199
- // Detection + extraction happen server-side (runInstallScan); the hook
3200
- // relays the verdict. A block is handed to the grader as an authoritative
3201
- // concern so the normal ask + consent-carryover flow lets the user
3202
- // override it on the next turn.
3203
- let installScanMsg = '';
3380
+ // ─── Install protection: read cached scan from the install-scan hook ───
3381
+ // The install-scan hook (INSTALL_SCAN_TS) runs before this hook, calls
3382
+ // runInstallScan(), outputs its own system message, and caches the result.
3383
+ // We just read the cache to feed scanConcern into the grader prompt so
3384
+ // the consent-carryover flow works.
3204
3385
  let scanConcern = '';
3205
3386
  let scanBlockContext = '';
3206
3387
  if (toolName === 'Bash') {
3207
- const scan = await runInstallScan(command, jwt);
3208
- if (scan.action === 'block') {
3209
- for (const f of scan.findings) {
3210
- dispatchFinding(jwt, {
3211
- session_id: sessionId,
3212
- file_path: command,
3213
- finding_type: 'cve' as const,
3214
- finding_id: f.advisoryId + ':' + f.name,
3215
- severity: f.severity,
3216
- status: 'open',
3217
- detail: f.detail,
3218
- package_name: f.name,
3219
- package_version: f.version,
3220
- }, config.captureDepth);
3221
- }
3222
- dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
3223
- 'Bash', gitRepo, sessionId, config.captureDepth, {
3224
- command,
3225
- reasoning: scan.blockContext.slice(0, 200),
3226
- violatedRules: scan.violatedIds,
3227
- ccModel: transcript.ccModel,
3228
- });
3229
- scanBlockContext = scan.blockContext;
3388
+ const scan = readCachedScan(command);
3389
+ if (scan && scan.action === 'block') {
3390
+ scanBlockContext = scan.blockContext || '';
3230
3391
  scanConcern = 'PACKAGE SCANNER FLAG (authoritative — do NOT re-evaluate whether the vulnerability is real): '
3231
- + scan.blockContext
3392
+ + scanBlockContext
3232
3393
  + ' For this concern you MUST return ok=false with rule_id "SYNKRO_PKGSCAN", rule_mode "ask", and the reason above — UNLESS the user has explicitly consented in this conversation to installing this despite the warning, in which case return ok=true.';
3233
- } else if (scan.scanned && scan.action === 'warn') {
3234
- installScanMsg = '[synkro:installScan] ' + scan.summary;
3235
- } else if (scan.scanned) {
3236
- installScanMsg = '[synkro:installScan] ' + (scan.scannedLabel || cmdShort) + ' → clean';
3237
3394
  }
3238
3395
  }
3239
3396
 
@@ -3289,10 +3446,9 @@ async function main() {
3289
3446
  const blockMsg = mode === 'fix'
3290
3447
  ? tagStr + ' bashGuard → blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Fix this before retrying — do not ask the user.'
3291
3448
  : tagStr + ' bashGuard → blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
3292
- const combined = (installScanMsg ? installScanMsg + '\\n' : '') + blockMsg;
3293
3449
  outputJson({
3294
- systemMessage: combined,
3295
- hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: blockMsg, additionalContext: combined },
3450
+ systemMessage: blockMsg,
3451
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: blockMsg, additionalContext: blockMsg },
3296
3452
  });
3297
3453
  dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
3298
3454
  toolName, gitRepo, sessionId, config.captureDepth, {
@@ -3301,8 +3457,10 @@ async function main() {
3301
3457
  });
3302
3458
  } else {
3303
3459
  const reason = tagStr + ' bashGuard → pass: ' + (verdict.reason || 'no policy violations detected');
3304
- const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
3305
- outputJson({ systemMessage: combined, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: combined } });
3460
+ outputJson({
3461
+ systemMessage: reason,
3462
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: reason },
3463
+ });
3306
3464
  dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'trivial_utility',
3307
3465
  toolName, gitRepo, sessionId, config.captureDepth, {
3308
3466
  command, reasoning: verdict.reason || 'no policy violations detected',
@@ -3354,30 +3512,16 @@ async function main() {
3354
3512
 
3355
3513
  if (!resp) {
3356
3514
  log('bashGuard ' + cmdShort + ' → error (timeout)');
3357
- if (installScanMsg) {
3358
- outputJson({ systemMessage: installScanMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: installScanMsg } });
3359
- } else { outputEmpty(); }
3515
+ outputEmpty();
3360
3516
  return;
3361
3517
  }
3362
3518
 
3363
3519
  if (!resp.hook_response || typeof resp.hook_response !== 'object') {
3364
3520
  log('bashGuard ' + cmdShort + ' → pass (no hook_response)');
3365
- if (installScanMsg) {
3366
- outputJson({ systemMessage: installScanMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: installScanMsg } });
3367
- } else { outputEmpty(); }
3521
+ outputEmpty();
3368
3522
  return;
3369
3523
  }
3370
3524
 
3371
- if (installScanMsg) {
3372
- const existing = resp.hook_response.systemMessage || '';
3373
- resp.hook_response.systemMessage = installScanMsg + (existing ? '\\n' + existing : '');
3374
- if (resp.hook_response.hookSpecificOutput) {
3375
- const existingCtx = resp.hook_response.hookSpecificOutput.additionalContext || '';
3376
- resp.hook_response.hookSpecificOutput.additionalContext = installScanMsg + (existingCtx ? '\\n' + existingCtx : '');
3377
- } else {
3378
- resp.hook_response.hookSpecificOutput = { hookEventName: 'PreToolUse', additionalContext: resp.hook_response.systemMessage };
3379
- }
3380
- }
3381
3525
  outputJson(resp.hook_response);
3382
3526
  } catch (err) {
3383
3527
  process.stderr.write('[synkro] bashGuard error: ' + String(err) + '\\n');
@@ -3397,6 +3541,8 @@ import {
3397
3541
  type HookConfig, type Rule,
3398
3542
  } from './_synkro-common.ts';
3399
3543
 
3544
+ const agentKind = process.env.SYNKRO_HOOK_FORMAT === 'cursor' ? 'cursor' : 'claude_code';
3545
+
3400
3546
  async function main() {
3401
3547
  setupCursorHookSignals();
3402
3548
  try {
@@ -3466,7 +3612,7 @@ async function main() {
3466
3612
 
3467
3613
  let gradeResp: string;
3468
3614
  try {
3469
- gradeResp = await localGrade('bash', graderPrompt);
3615
+ gradeResp = await localGrade('bash', graderPrompt, undefined, agentKind);
3470
3616
  } catch (err) {
3471
3617
  logGraderUnavailable('agentGuard', subagentType || (description || '').slice(0, 100), (err as Error).message || String(err));
3472
3618
  outputJson({ systemMessage: tagStr + ' agentGuard \u2192 local grader unavailable, skipped' });
@@ -3565,6 +3711,8 @@ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from '
3565
3711
  import { join } from 'node:path';
3566
3712
  import { homedir } from 'node:os';
3567
3713
 
3714
+ const agentKind = process.env.SYNKRO_HOOK_FORMAT === 'cursor' ? 'cursor' : 'claude_code';
3715
+
3568
3716
  function findLatestPlanInDir(plansDir: string): string | null {
3569
3717
  if (!existsSync(plansDir)) return null;
3570
3718
  try {
@@ -3656,7 +3804,7 @@ async function main() {
3656
3804
 
3657
3805
  let gradeResp: string;
3658
3806
  try {
3659
- gradeResp = await localGrade('plan', graderPrompt);
3807
+ gradeResp = await localGrade('plan', graderPrompt, undefined, agentKind);
3660
3808
  } catch {
3661
3809
  outputJson({ systemMessage: tagStr + ' planReview \u2192 local grader unavailable, skipped' });
3662
3810
  return;
@@ -4147,13 +4295,26 @@ main();
4147
4295
  import {
4148
4296
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
4149
4297
  parseVerdict, dispatchCapture, dispatchFinding, ruleMode, normalizeMode, filterRules,
4150
- isSafeInRepoRead, runInstallScan, postWithRetry, readStdin,
4298
+ isSafeInRepoRead, postWithRetry, readStdin, hashCommand,
4151
4299
  extractTranscript, readLastPrompt, readSessionLog, compressSessionLog,
4152
4300
  appendLocalTelemetry, logGraderUnavailable, log, GATEWAY_URL,
4153
4301
  type Rule,
4154
4302
  } from './_synkro-common.ts';
4155
4303
  import { createHash } from 'node:crypto';
4156
- import { existsSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
4304
+ import { existsSync, statSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
4305
+ import { join } from 'node:path';
4306
+
4307
+ const SCAN_CACHE_DIR = (process.env.HOME || '/tmp') + '/.synkro/.scan-cache';
4308
+
4309
+ function readCachedScan(command: string): any | null {
4310
+ try {
4311
+ const path = join(SCAN_CACHE_DIR, hashCommand(command));
4312
+ if (!existsSync(path)) return null;
4313
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
4314
+ unlinkSync(path);
4315
+ return data;
4316
+ } catch { return null; }
4317
+ }
4157
4318
 
4158
4319
  const DEDUP_DIR = process.env.HOME + '/.synkro/.dedup';
4159
4320
  const DEDUP_TTL_MS = 3000;
@@ -4282,33 +4443,16 @@ async function main() {
4282
4443
  const rt = await route(config);
4283
4444
  const tagStr = tag(rt, config);
4284
4445
 
4285
- // Install protection \u2014 scan packages before any npm/pip/cargo/etc. install.
4286
- // A block is handed to the grader as an authoritative concern so the
4287
- // normal ask + consent-carryover flow can let the user override it.
4446
+ // Install protection \u2014 read cached scan from the install-scan hook.
4288
4447
  let scanConcern = '';
4289
4448
  let scanBlockContext = '';
4290
4449
  if (SHELL_TOOL_NAMES.has(toolName)) {
4291
- const scan = await runInstallScan(command, jwt);
4292
- if (scan.action === 'block') {
4293
- for (const f of scan.findings) {
4294
- dispatchFinding(jwt, {
4295
- session_id: sessionId, file_path: command,
4296
- finding_type: 'cve' as const, finding_id: f.advisoryId + ':' + f.name,
4297
- severity: f.severity, status: 'open', detail: f.detail,
4298
- package_name: f.name, package_version: f.version,
4299
- }, config.captureDepth);
4300
- }
4301
- dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
4302
- 'Bash', repo, sessionId, config.captureDepth, {
4303
- command, reasoning: scan.blockContext.slice(0, 200),
4304
- violatedRules: scan.violatedIds, ccModel: model,
4305
- });
4306
- scanBlockContext = scan.blockContext;
4450
+ const scan = readCachedScan(command);
4451
+ if (scan && scan.action === 'block') {
4452
+ scanBlockContext = scan.blockContext || '';
4307
4453
  scanConcern = 'PACKAGE SCANNER FLAG (authoritative \u2014 do NOT re-evaluate whether the vulnerability is real): '
4308
- + scan.blockContext
4454
+ + scanBlockContext
4309
4455
  + ' For this concern you MUST return ok=false with rule_id "SYNKRO_PKGSCAN", rule_mode "ask", and the reason above \u2014 UNLESS the user has explicitly consented in this conversation to installing this despite the warning, in which case return ok=true.';
4310
- } else if (scan.scanned && scan.action === 'warn') {
4311
- log('bashGuard installScan warn: ' + scan.summary);
4312
4456
  }
4313
4457
  }
4314
4458
 
@@ -5664,8 +5808,6 @@ async function dockerInstall(opts = {}) {
5664
5808
  `127.0.0.1:${HOST_GRADER_PORT}:8929`,
5665
5809
  "-p",
5666
5810
  `127.0.0.1:${HOST_CWE_PORT}:8930`,
5667
- "-p",
5668
- `127.0.0.1:${HOST_PG_PORT}:5433`,
5669
5811
  "-v",
5670
5812
  `${PGDATA_PATH}:/data/pgdata`,
5671
5813
  "-v",
@@ -5894,7 +6036,7 @@ function checkPgdata() {
5894
6036
  if (!hasPgControl) return { healthy: false, details: "pg_control/global directory missing" };
5895
6037
  return { healthy: true, details: `${entries.length} entries, WAL present, no stale PID` };
5896
6038
  }
5897
- var SYNKRO_DIR2, MCP_JWT_PATH, PGDATA_PATH, CLAUDE_HOST_STATE_DIR, CLAUDE_HOST_STATE_FILE, HOST_MCP_PORT, HOST_GRADER_PORT, HOST_CWE_PORT, HOST_PG_PORT, CONTAINER_NAME, DEFAULT_IMAGE, DockerInstallError, BACKUP_DIR;
6039
+ var SYNKRO_DIR2, MCP_JWT_PATH, PGDATA_PATH, CLAUDE_HOST_STATE_DIR, CLAUDE_HOST_STATE_FILE, HOST_MCP_PORT, HOST_GRADER_PORT, HOST_CWE_PORT, CONTAINER_NAME, DEFAULT_IMAGE, DockerInstallError, BACKUP_DIR;
5898
6040
  var init_dockerInstall = __esm({
5899
6041
  "cli/local-cc/dockerInstall.ts"() {
5900
6042
  "use strict";
@@ -5908,7 +6050,6 @@ var init_dockerInstall = __esm({
5908
6050
  HOST_MCP_PORT = parseInt(process.env.SYNKRO_HOST_MCP_PORT || "18931", 10);
5909
6051
  HOST_GRADER_PORT = parseInt(process.env.SYNKRO_HOST_GRADER_PORT || "18929", 10);
5910
6052
  HOST_CWE_PORT = parseInt(process.env.SYNKRO_HOST_CWE_PORT || "18930", 10);
5911
- HOST_PG_PORT = parseInt(process.env.SYNKRO_HOST_PG_PORT || "15433", 10);
5912
6053
  CONTAINER_NAME = "synkro-server";
5913
6054
  DEFAULT_IMAGE = "ghcr.io/synkro-sh/synkro-server:latest";
5914
6055
  DockerInstallError = class extends Error {
@@ -6424,6 +6565,7 @@ function writeHookScripts() {
6424
6565
  const userPromptSubmitScriptPath = join8(HOOKS_DIR, "cc-user-prompt-submit.ts");
6425
6566
  const commonScriptPath = join8(HOOKS_DIR, "_synkro-common.ts");
6426
6567
  const commonBashScriptPath = join8(HOOKS_DIR, "_synkro-common.sh");
6568
+ const installScanScriptPath = join8(HOOKS_DIR, "cc-install-scan.ts");
6427
6569
  const cursorBashJudgePath = join8(HOOKS_DIR, "cursor-bash-judge.ts");
6428
6570
  const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
6429
6571
  const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
@@ -6440,6 +6582,7 @@ function writeHookScripts() {
6440
6582
  writeFileSync7(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
6441
6583
  writeFileSync7(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
6442
6584
  writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
6585
+ writeFileSync7(installScanScriptPath, INSTALL_SCAN_TS, "utf-8");
6443
6586
  writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
6444
6587
  writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
6445
6588
  writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
@@ -6456,6 +6599,7 @@ function writeHookScripts() {
6456
6599
  chmodSync2(userPromptSubmitScriptPath, 493);
6457
6600
  chmodSync2(commonScriptPath, 493);
6458
6601
  chmodSync2(commonBashScriptPath, 493);
6602
+ chmodSync2(installScanScriptPath, 493);
6459
6603
  chmodSync2(cursorBashJudgePath, 493);
6460
6604
  chmodSync2(cursorEditCapturePath, 493);
6461
6605
  chmodSync2(mcpStdioProxyPath, 493);
@@ -6471,6 +6615,7 @@ function writeHookScripts() {
6471
6615
  sessionStartScript: sessionStartScriptPath,
6472
6616
  transcriptSyncScript: transcriptSyncScriptPath,
6473
6617
  userPromptSubmitScript: userPromptSubmitScriptPath,
6618
+ installScanScript: installScanScriptPath,
6474
6619
  cursorBashJudgeScript: cursorBashJudgePath,
6475
6620
  cursorEditCaptureScript: cursorEditCapturePath
6476
6621
  };
@@ -6504,7 +6649,7 @@ function writeConfigEnv(opts) {
6504
6649
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
6505
6650
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
6506
6651
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
6507
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.14")}`
6652
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.16")}`
6508
6653
  ];
6509
6654
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
6510
6655
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -6746,6 +6891,7 @@ async function installCommand(opts = {}) {
6746
6891
  sessionStartScriptPath: scripts.sessionStartScript,
6747
6892
  transcriptSyncScriptPath: scripts.transcriptSyncScript,
6748
6893
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
6894
+ installScanScriptPath: scripts.installScanScript,
6749
6895
  skipTranscriptSync: !transcriptConsent
6750
6896
  });
6751
6897
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
@@ -6763,7 +6909,8 @@ async function installCommand(opts = {}) {
6763
6909
  stopSummaryScriptPath: scripts.stopSummaryScript,
6764
6910
  sessionStartScriptPath: scripts.sessionStartScript,
6765
6911
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
6766
- transcriptSyncScriptPath: scripts.transcriptSyncScript
6912
+ transcriptSyncScriptPath: scripts.transcriptSyncScript,
6913
+ installScanScriptPath: scripts.installScanScript
6767
6914
  });
6768
6915
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
6769
6916
  }
@@ -9169,7 +9316,7 @@ var args = process.argv.slice(2);
9169
9316
  var cmd = args[0] || "";
9170
9317
  var subArgs = args.slice(1);
9171
9318
  function printVersion() {
9172
- console.log("1.6.14");
9319
+ console.log("1.6.16");
9173
9320
  }
9174
9321
  function printHelp2() {
9175
9322
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents