@synkro-sh/cli 1.4.83 → 1.4.85

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
@@ -1414,10 +1414,29 @@ export function dispatchCapture(
1414
1414
  }
1415
1415
 
1416
1416
  export function appendLocalTelemetry(body: Record<string, any>): void {
1417
- try {
1418
- const telPath = join(HOME, '.synkro', 'telemetry.jsonl');
1419
- appendFileSync(telPath, JSON.stringify({ ...body, _ts: new Date().toISOString() }) + '\\n', 'utf-8');
1420
- } catch {}
1417
+ const event = { ...body, _ts: new Date().toISOString() };
1418
+ const mcpPort = process.env.SYNKRO_MCP_PORT || '8931';
1419
+ let mcpToken = '';
1420
+ try { mcpToken = readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
1421
+
1422
+ if (mcpToken) {
1423
+ fetch(\`http://127.0.0.1:\${mcpPort}/api/ingest\`, {
1424
+ method: 'POST',
1425
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
1426
+ body: JSON.stringify({ data: event }),
1427
+ signal: AbortSignal.timeout(2000),
1428
+ }).catch(() => {
1429
+ try {
1430
+ const telPath = join(HOME, '.synkro', 'telemetry.jsonl');
1431
+ appendFileSync(telPath, JSON.stringify(event) + '\\n', 'utf-8');
1432
+ } catch {}
1433
+ });
1434
+ } else {
1435
+ try {
1436
+ const telPath = join(HOME, '.synkro', 'telemetry.jsonl');
1437
+ appendFileSync(telPath, JSON.stringify(event) + '\\n', 'utf-8');
1438
+ } catch {}
1439
+ }
1421
1440
  }
1422
1441
 
1423
1442
  // \u2500\u2500\u2500 Rule Mode Lookup \u2500\u2500\u2500
@@ -1991,7 +2010,7 @@ async function main() {
1991
2010
  const tagStr = tag(rt, config);
1992
2011
 
1993
2012
  if (config.silent) {
1994
- outputJson({ systemMessage: tagStr + ' editGuard \\u2192 skipped (silent mode)' });
2013
+ outputJson({ systemMessage: tagStr + ' editGuard \u2192 skipped (silent mode)' });
1995
2014
  return;
1996
2015
  }
1997
2016
 
@@ -2013,7 +2032,7 @@ async function main() {
2013
2032
  try {
2014
2033
  gradeResp = await localGrade('edit', graderPrompt);
2015
2034
  } catch {
2016
- outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 local grader unavailable, skipped' });
2035
+ outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 local grader unavailable, skipped' });
2017
2036
  return;
2018
2037
  }
2019
2038
 
@@ -2034,7 +2053,7 @@ async function main() {
2034
2053
  ccModel: transcript.ccModel,
2035
2054
  });
2036
2055
  outputJson({
2037
- systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 blocked: ' + guardReason,
2056
+ systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 blocked: ' + guardReason,
2038
2057
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: denyReason, additionalContext: denyReason },
2039
2058
  });
2040
2059
  return;
@@ -2047,7 +2066,7 @@ async function main() {
2047
2066
  rulesChecked: config.rules, violatedRules,
2048
2067
  ccModel: transcript.ccModel,
2049
2068
  });
2050
- outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 warning: ' + guardReason, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro local edit judge (audit). ' + guardReason } });
2069
+ outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 warning: ' + guardReason, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro local edit judge (audit). ' + guardReason } });
2051
2070
  return;
2052
2071
  }
2053
2072
 
@@ -2058,7 +2077,7 @@ async function main() {
2058
2077
  rulesChecked: config.rules, violatedRules: [],
2059
2078
  ccModel: transcript.ccModel,
2060
2079
  });
2061
- const passLine = tagStr + ' editGuard ' + fileShort + ' \\u2192 pass: ' + (verdict.reason || 'no policy violations detected');
2080
+ const passLine = tagStr + ' editGuard ' + fileShort + ' \u2192 pass: ' + (verdict.reason || 'no policy violations detected');
2062
2081
  outputJson({ systemMessage: passLine, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro local edit judge. ' + (verdict.reason || 'no policy violations detected') } });
2063
2082
  return;
2064
2083
  }
@@ -2093,13 +2112,13 @@ async function main() {
2093
2112
  const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
2094
2113
 
2095
2114
  if (!resp) {
2096
- log('editGuard ' + fileShort + ' \\u2192 error (timeout)');
2115
+ log('editGuard ' + fileShort + ' \u2192 error (timeout)');
2097
2116
  outputEmpty();
2098
2117
  return;
2099
2118
  }
2100
2119
 
2101
2120
  if (!resp.hook_response || typeof resp.hook_response !== 'object') {
2102
- log('editGuard ' + fileShort + ' \\u2192 pass (no hook_response)');
2121
+ log('editGuard ' + fileShort + ' \u2192 pass (no hook_response)');
2103
2122
  outputEmpty();
2104
2123
  return;
2105
2124
  }
@@ -2108,7 +2127,7 @@ async function main() {
2108
2127
  const decision = hookResp?.hookSpecificOutput?.permissionDecision;
2109
2128
 
2110
2129
  if (decision === 'deny' || decision === 'ask') {
2111
- log('editGuard ' + fileShort + ' \\u2192 BLOCKED');
2130
+ log('editGuard ' + fileShort + ' \u2192 BLOCKED');
2112
2131
  // Strip permissionDecision \u2014 we use systemMessage only
2113
2132
  const cleaned = { ...hookResp };
2114
2133
  if (cleaned.hookSpecificOutput) {
@@ -2119,7 +2138,7 @@ async function main() {
2119
2138
  outputJson(cleaned);
2120
2139
  } else {
2121
2140
  const reason = hookResp.reason || '';
2122
- log('editGuard ' + fileShort + ' \\u2192 pass' + (reason ? ': ' + reason : ''));
2141
+ log('editGuard ' + fileShort + ' \u2192 pass' + (reason ? ': ' + reason : ''));
2123
2142
  outputJson(hookResp);
2124
2143
  }
2125
2144
  } catch (err) {
@@ -2594,7 +2613,7 @@ async function main() {
2594
2613
  const rt = await route(config);
2595
2614
 
2596
2615
  if (config.silent) {
2597
- outputJson({ systemMessage: '[synkro:' + rt + ':cveScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
2616
+ outputJson({ systemMessage: '[synkro:' + rt + ':cveScan] ' + fileShort + ' \u2192 skipped (silent mode)' });
2598
2617
  return;
2599
2618
  }
2600
2619
 
@@ -2603,7 +2622,7 @@ async function main() {
2603
2622
  // Reconstruct proposed content
2604
2623
  const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
2605
2624
  if (!proposed) {
2606
- outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 skip (no content)' });
2625
+ outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \u2192 skip (no content)' });
2607
2626
  return;
2608
2627
  }
2609
2628
 
@@ -2632,7 +2651,7 @@ async function main() {
2632
2651
  });
2633
2652
  cveResp = await resp.json();
2634
2653
  } catch {
2635
- outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 error (timeout)' });
2654
+ outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \u2192 error (timeout)' });
2636
2655
  return;
2637
2656
  }
2638
2657
 
@@ -2669,7 +2688,7 @@ async function main() {
2669
2688
  const top3 = findings.slice(0, 3).map(formatFinding).join('; ');
2670
2689
  const count = findings.length;
2671
2690
  const label = count === 1 ? 'advisory' : 'advisories';
2672
- const cveMsg = cveTag + ' ' + fileShort + ' \\u2192 ' + count + ' ' + label;
2691
+ const cveMsg = cveTag + ' ' + fileShort + ' \u2192 ' + count + ' ' + label;
2673
2692
  const ctx = 'CVE: ' + top3 + '\\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 upgrade the vulnerable dependencies yourself.';
2674
2693
 
2675
2694
  const cveIds = findings.slice(0, 10).map((f: any) =>
@@ -2689,7 +2708,7 @@ async function main() {
2689
2708
  return;
2690
2709
  }
2691
2710
 
2692
- outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 clean' });
2711
+ outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \u2192 clean' });
2693
2712
  } catch (err) {
2694
2713
  process.stderr.write('[synkro] cveGuard error: ' + String(err) + '\\n');
2695
2714
  outputEmpty();
@@ -2862,7 +2881,7 @@ async function main() {
2862
2881
  const tagStr = tag(rt, config);
2863
2882
 
2864
2883
  if (config.silent) {
2865
- const msg = (installScanMsg ? installScanMsg + '\\n' : '') + tagStr + ' bashGuard \\u2192 skipped (silent mode)';
2884
+ const msg = (installScanMsg ? installScanMsg + '\\n' : '') + tagStr + ' bashGuard skipped (silent mode)';
2866
2885
  outputJson({ systemMessage: msg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } });
2867
2886
  return;
2868
2887
  }
@@ -2881,7 +2900,7 @@ async function main() {
2881
2900
  try {
2882
2901
  gradeResp = await localGrade('bash', graderPrompt);
2883
2902
  } catch {
2884
- outputJson({ systemMessage: tagStr + ' bashGuard \\u2192 local grader unavailable, skipped' });
2903
+ outputJson({ systemMessage: tagStr + ' bashGuard local grader unavailable, skipped' });
2885
2904
  return;
2886
2905
  }
2887
2906
 
@@ -2893,7 +2912,7 @@ async function main() {
2893
2912
  const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
2894
2913
 
2895
2914
  if (mode === 'audit') {
2896
- const reason = tagStr + ' bashGuard \\u2192 warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation');
2915
+ const reason = tagStr + ' bashGuard warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation');
2897
2916
  const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
2898
2917
  outputJson({ systemMessage: combined, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: combined } });
2899
2918
  dispatchCapture(jwt, 'bash', 'warning', verdict.severity || 'medium', verdict.category || 'security',
@@ -2902,7 +2921,7 @@ async function main() {
2902
2921
  recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
2903
2922
  });
2904
2923
  } else {
2905
- const reason = tagStr + ' bashGuard \\u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
2924
+ const reason = tagStr + ' bashGuard blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
2906
2925
  const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
2907
2926
  outputJson({
2908
2927
  systemMessage: combined,
@@ -2915,7 +2934,7 @@ async function main() {
2915
2934
  });
2916
2935
  }
2917
2936
  } else {
2918
- const reason = tagStr + ' bashGuard \\u2192 pass: ' + (verdict.reason || 'no policy violations detected');
2937
+ const reason = tagStr + ' bashGuard pass: ' + (verdict.reason || 'no policy violations detected');
2919
2938
  const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
2920
2939
  outputJson({ systemMessage: combined, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: combined } });
2921
2940
  dispatchCapture(jwt, 'bash', 'pass', 'audit', verdict.category || 'trivial_utility',
@@ -2955,7 +2974,7 @@ async function main() {
2955
2974
  const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
2956
2975
 
2957
2976
  if (!resp) {
2958
- log('bashGuard ' + cmdShort + ' \\u2192 error (timeout)');
2977
+ log('bashGuard ' + cmdShort + ' error (timeout)');
2959
2978
  if (installScanMsg) {
2960
2979
  outputJson({ systemMessage: installScanMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: installScanMsg } });
2961
2980
  } else { outputEmpty(); }
@@ -2963,7 +2982,7 @@ async function main() {
2963
2982
  }
2964
2983
 
2965
2984
  if (!resp.hook_response || typeof resp.hook_response !== 'object') {
2966
- log('bashGuard ' + cmdShort + ' \\u2192 pass (no hook_response)');
2985
+ log('bashGuard ' + cmdShort + ' pass (no hook_response)');
2967
2986
  if (installScanMsg) {
2968
2987
  outputJson({ systemMessage: installScanMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: installScanMsg } });
2969
2988
  } else { outputEmpty(); }
@@ -3039,7 +3058,7 @@ async function main() {
3039
3058
  const tagStr = tag(rt, config);
3040
3059
 
3041
3060
  if (config.silent) {
3042
- const msg = tagStr + ' agentGuard \\u2192 skipped (silent mode)';
3061
+ const msg = tagStr + ' agentGuard \u2192 skipped (silent mode)';
3043
3062
  outputJson({ systemMessage: msg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } });
3044
3063
  return;
3045
3064
  }
@@ -3062,7 +3081,7 @@ async function main() {
3062
3081
  try {
3063
3082
  gradeResp = await localGrade('bash', graderPrompt);
3064
3083
  } catch {
3065
- outputJson({ systemMessage: tagStr + ' agentGuard \\u2192 local grader unavailable, skipped' });
3084
+ outputJson({ systemMessage: tagStr + ' agentGuard \u2192 local grader unavailable, skipped' });
3066
3085
  return;
3067
3086
  }
3068
3087
 
@@ -3075,7 +3094,7 @@ async function main() {
3075
3094
  const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
3076
3095
 
3077
3096
  if (mode === 'audit') {
3078
- const reason = tagStr + ' agentGuard \\u2192 warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation');
3097
+ const reason = tagStr + ' agentGuard \u2192 warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation');
3079
3098
  outputJson({ systemMessage: reason, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: reason } });
3080
3099
  dispatchCapture(jwt, 'agent', 'warning', verdict.severity || 'medium', verdict.category || 'security',
3081
3100
  toolName, gitRepo, sessionId, config.captureDepth, {
@@ -3083,7 +3102,7 @@ async function main() {
3083
3102
  recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3084
3103
  });
3085
3104
  } else {
3086
- const reason = tagStr + ' agentGuard \\u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
3105
+ const reason = tagStr + ' agentGuard \u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
3087
3106
  outputJson({
3088
3107
  systemMessage: reason,
3089
3108
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, additionalContext: reason },
@@ -3095,7 +3114,7 @@ async function main() {
3095
3114
  });
3096
3115
  }
3097
3116
  } else {
3098
- const reason = tagStr + ' agentGuard \\u2192 pass: ' + (verdict.reason || 'no policy violations detected');
3117
+ const reason = tagStr + ' agentGuard \u2192 pass: ' + (verdict.reason || 'no policy violations detected');
3099
3118
  outputJson({ systemMessage: reason, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: reason } });
3100
3119
  dispatchCapture(jwt, 'agent', 'pass', 'audit', verdict.category || 'subagent_spawn',
3101
3120
  toolName, gitRepo, sessionId, config.captureDepth, {
@@ -3134,13 +3153,13 @@ async function main() {
3134
3153
  const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
3135
3154
 
3136
3155
  if (!resp) {
3137
- log('agentGuard ' + promptShort + ' \\u2192 error (timeout)');
3156
+ log('agentGuard ' + promptShort + ' \u2192 error (timeout)');
3138
3157
  outputEmpty();
3139
3158
  return;
3140
3159
  }
3141
3160
 
3142
3161
  if (!resp.hook_response || typeof resp.hook_response !== 'object') {
3143
- log('agentGuard ' + promptShort + ' \\u2192 pass (no hook_response)');
3162
+ log('agentGuard ' + promptShort + ' \u2192 pass (no hook_response)');
3144
3163
  outputEmpty();
3145
3164
  return;
3146
3165
  }
@@ -3235,7 +3254,7 @@ async function main() {
3235
3254
  const tagStr = tag(rt, config);
3236
3255
 
3237
3256
  if (config.silent) {
3238
- outputJson({ systemMessage: tagStr + ' planReview \\u2192 skipped (silent mode)' });
3257
+ outputJson({ systemMessage: tagStr + ' planReview \u2192 skipped (silent mode)' });
3239
3258
  return;
3240
3259
  }
3241
3260
 
@@ -3252,7 +3271,7 @@ async function main() {
3252
3271
  try {
3253
3272
  gradeResp = await localGrade('plan', graderPrompt);
3254
3273
  } catch {
3255
- outputJson({ systemMessage: tagStr + ' planReview \\u2192 local grader unavailable, skipped' });
3274
+ outputJson({ systemMessage: tagStr + ' planReview \u2192 local grader unavailable, skipped' });
3256
3275
  return;
3257
3276
  }
3258
3277
 
@@ -3263,7 +3282,7 @@ async function main() {
3263
3282
  if (!verdict.ok) {
3264
3283
  const reviewMsg = (verdict.ruleId ? '(first: ' + verdict.ruleId + ') ' : '') + (verdict.reason || 'check org rules during implementation');
3265
3284
  appendReviewToPlan(planFile, '\\u26a0\\ufe0f Advisory \\u2014 ' + reviewMsg);
3266
- const advLine = tagStr + ' planReview \\u2192 ' + reviewMsg;
3285
+ const advLine = tagStr + ' planReview \u2192 ' + reviewMsg;
3267
3286
  outputJson({ systemMessage: advLine, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro local plan judge (advisory). ' + reviewMsg } });
3268
3287
  dispatchCapture(jwt, 'plan_review', 'advisory', verdict.severity || 'medium', verdict.category || 'general',
3269
3288
  'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
@@ -3273,7 +3292,7 @@ async function main() {
3273
3292
  } else {
3274
3293
  const reviewMsg = verdict.reason || 'no relevant org rules for this plan';
3275
3294
  appendReviewToPlan(planFile, '\\u2705 Clean \\u2014 ' + reviewMsg);
3276
- const cleanLine = tagStr + ' planReview \\u2192 clean: ' + reviewMsg;
3295
+ const cleanLine = tagStr + ' planReview \u2192 clean: ' + reviewMsg;
3277
3296
  outputJson({ systemMessage: cleanLine, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro local plan judge. ' + reviewMsg } });
3278
3297
  dispatchCapture(jwt, 'plan_review', 'clean', 'audit', verdict.category || 'general',
3279
3298
  'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
@@ -3297,7 +3316,7 @@ async function main() {
3297
3316
  const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 12000);
3298
3317
 
3299
3318
  if (!resp) {
3300
- log('planReview \\u2192 error (timeout)');
3319
+ log('planReview \u2192 error (timeout)');
3301
3320
  outputEmpty();
3302
3321
  return;
3303
3322
  }
@@ -3309,7 +3328,7 @@ async function main() {
3309
3328
  if (decision) {
3310
3329
  const reason = hookResp?.hookSpecificOutput?.permissionDecisionReason || 'check org rules';
3311
3330
  appendReviewToPlan(planFile, '\\u26a0\\ufe0f Advisory \\u2014 ' + reason);
3312
- outputJson({ systemMessage: tagStr + ' planReview \\u2192 advisory: ' + reason });
3331
+ outputJson({ systemMessage: tagStr + ' planReview \u2192 advisory: ' + reason });
3313
3332
  } else {
3314
3333
  const cloudMsg = hookResp.systemMessage || '';
3315
3334
  if (cloudMsg) appendReviewToPlan(planFile, '\\u2705 ' + cloudMsg);
@@ -3399,9 +3418,9 @@ async function main() {
3399
3418
  const tagStr = tag('local', config);
3400
3419
 
3401
3420
  if (!findings) {
3402
- outputJson({ systemMessage: tagStr + ' stop \\u2192 0 issues across ' + edits + ' edit(s), session complete' });
3421
+ outputJson({ systemMessage: tagStr + ' stop \u2192 0 issues across ' + edits + ' edit(s), session complete' });
3403
3422
  } else {
3404
- outputJson({ systemMessage: tagStr + ' stop \\u2192 ' + findings + ' finding(s): ' + autoFixed + ' auto-fixed, ' + open + ' open' });
3423
+ outputJson({ systemMessage: tagStr + ' stop \u2192 ' + findings + ' finding(s): ' + autoFixed + ' auto-fixed, ' + open + ' open' });
3405
3424
  }
3406
3425
  } catch (err) {
3407
3426
  process.stderr.write('[synkro] stopSummary error: ' + String(err) + '\\n');
@@ -3464,9 +3483,9 @@ async function main() {
3464
3483
  if (!openFindings) {
3465
3484
  outputJson({ systemMessage: routeLine });
3466
3485
  } else if (openFindings === 1) {
3467
- outputJson({ systemMessage: routeLine + '\\n' + tagStr + ' session start \\u2192 1 open finding in this repo from a prior session.' });
3486
+ outputJson({ systemMessage: routeLine + '\\n' + tagStr + ' session start \u2192 1 open finding in this repo from a prior session.' });
3468
3487
  } else {
3469
- outputJson({ systemMessage: routeLine + '\\n' + tagStr + ' session start \\u2192 ' + openFindings + ' open findings in this repo from prior sessions.' });
3488
+ outputJson({ systemMessage: routeLine + '\\n' + tagStr + ' session start \u2192 ' + openFindings + ' open findings in this repo from prior sessions.' });
3470
3489
  }
3471
3490
  } catch (err) {
3472
3491
  process.stderr.write('[synkro] sessionStart error: ' + String(err) + '\\n');
@@ -6331,7 +6350,7 @@ function writeHookScripts() {
6331
6350
  writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
6332
6351
  writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
6333
6352
  writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
6334
- writeFileSync7(mcpLocalServerPath, "#!/usr/bin/env bun\n/**\n * Local MCP guardrails server \u2014 runs on port 8931, stores rules in ~/.synkro/rules.json.\n * JSON-RPC 2.0 over HTTP, same protocol as the cloud MCP server.\n * Bearer token auth (file-based shared secret), localhost only, no embedding API, no Inngest.\n */\nimport { existsSync, readFileSync, writeFileSync, renameSync, appendFileSync, mkdirSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst PORT = parseInt(process.env.SYNKRO_MCP_PORT || '8931', 10);\nconst HOME = homedir();\nconst RULES_PATH = join(HOME, '.synkro', 'rules.json');\nconst TELEMETRY_PATH = join(HOME, '.synkro', 'telemetry.jsonl');\nconst JWT_TOKEN_PATH = join(HOME, '.synkro', '.mcp-jwt');\n\n// Synkro-signed long-lived JWT \u2014 minted during `synkro install`, required on all POST requests.\n// If missing, the server still starts (for GET health checks) but rejects all tool calls.\nlet SERVER_TOKEN = '';\ntry { SERVER_TOKEN = readFileSync(JWT_TOKEN_PATH, 'utf-8').trim(); } catch {}\nif (!SERVER_TOKEN) console.warn('[synkro] \u26A0 No MCP JWT found \u2014 run `synkro install` to authenticate.');\nconst MAX_BODY_BYTES = 1_048_576;\n\nlet _writeLock: Promise<void> = Promise.resolve();\nfunction serialized<T>(fn: () => T | Promise<T>): Promise<T> {\n let release: () => void;\n const next = new Promise<void>(r => { release = r; });\n const prev = _writeLock;\n _writeLock = next;\n return prev.then(() => fn()).finally(() => release!());\n}\n\n// \u2500\u2500\u2500 Storage \u2500\u2500\u2500\n\ninterface Rule {\n rule_id: string;\n text: string;\n category: string;\n severity: string;\n mode: string;\n hook_stage: string;\n scope: string;\n}\n\ninterface Policy {\n id: string;\n name: string;\n rules: Rule[];\n ruleCount: number;\n scopeOwner: string;\n isActive: boolean;\n}\n\ninterface ScanExemption {\n path: string;\n cwe_id: string;\n reason?: string;\n}\n\ninterface RulesFile {\n policies: Policy[];\n config: { silent: boolean; activePolicyId: string };\n scanExemptions: ScanExemption[];\n}\n\nfunction readRules(): RulesFile {\n if (!existsSync(RULES_PATH)) {\n return {\n policies: [{\n id: 'local-policy',\n name: 'My Rules',\n rules: [],\n ruleCount: 0,\n scopeOwner: 'user',\n isActive: true,\n }],\n config: { silent: false, activePolicyId: 'local-policy' },\n scanExemptions: [],\n };\n }\n try {\n return JSON.parse(readFileSync(RULES_PATH, 'utf-8'));\n } catch {\n return {\n policies: [{ id: 'local-policy', name: 'My Rules', rules: [], ruleCount: 0, scopeOwner: 'user', isActive: true }],\n config: { silent: false, activePolicyId: 'local-policy' },\n scanExemptions: [],\n };\n }\n}\n\nfunction writeRules(data: RulesFile): void {\n for (const p of data.policies) p.ruleCount = p.rules.length;\n mkdirSync(join(HOME, '.synkro'), { recursive: true });\n const tmp = RULES_PATH + '.tmp';\n writeFileSync(tmp, JSON.stringify(data, null, 2) + '\\n', 'utf-8');\n renameSync(tmp, RULES_PATH);\n}\n\nfunction emitRuleSync(data: RulesFile): void {\n const active = data.policies.find(p => p.id === data.config.activePolicyId) || data.policies[0];\n const event = {\n capture_type: 'rule_sync',\n policy_id: active?.id || 'local-policy',\n policy_name: active?.name || 'My Rules',\n rules: active?.rules || [],\n rule_count: active?.ruleCount || 0,\n scan_exemptions: data.scanExemptions,\n silent: data.config.silent,\n _ts: new Date().toISOString(),\n };\n try {\n appendFileSync(TELEMETRY_PATH, JSON.stringify(event) + '\\n', 'utf-8');\n } catch {}\n}\n\nfunction genId(): string {\n return `r_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction getActivePolicy(data: RulesFile): Policy {\n return data.policies.find(p => p.id === data.config.activePolicyId) || data.policies[0];\n}\n\nfunction findOrCreatePolicy(data: RulesFile, name: string): Policy {\n const existing = data.policies.find(p => p.name.toLowerCase() === name.toLowerCase());\n if (existing) return existing;\n const p: Policy = {\n id: `policy_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,\n name,\n rules: [],\n ruleCount: 0,\n scopeOwner: 'user',\n isActive: true,\n };\n data.policies.push(p);\n return p;\n}\n\nfunction getAllRules(data: RulesFile): Array<Rule & { policyName: string; policyId: string }> {\n const all: Array<Rule & { policyName: string; policyId: string }> = [];\n for (const p of data.policies) {\n if (!p.isActive) continue;\n for (const r of p.rules) {\n all.push({ ...r, policyName: p.name, policyId: p.id });\n }\n }\n return all;\n}\n\n// \u2500\u2500\u2500 Keyword Search \u2500\u2500\u2500\n\nconst STOPWORDS = new Set(['the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'shall', 'can', 'need', 'dare', 'ought', 'used', 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'just', 'because', 'but', 'and', 'or', 'if', 'while', 'about', 'up', 'it', 'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', 'they', 'them', 'what', 'which', 'who', 'whom']);\n\nfunction tokenize(text: string): string[] {\n return text.toLowerCase().replace(/[^a-z0-9_-]/g, ' ').split(/\\s+/).filter(t => t.length > 1 && !STOPWORDS.has(t));\n}\n\nfunction keywordSearch(query: string, rules: Array<Rule & { policyName: string; policyId: string }>, topK: number): any[] {\n const qTokens = tokenize(query);\n if (qTokens.length === 0) return rules.slice(0, topK);\n\n const scored = rules.map(r => {\n const rTokens = new Set(tokenize(`${r.text} ${r.category} ${r.severity}`));\n const overlap = qTokens.filter(t => rTokens.has(t) || [...rTokens].some(rt => rt.includes(t) || t.includes(rt))).length;\n return { rule: r, score: overlap / qTokens.length };\n });\n\n scored.sort((a, b) => b.score - a.score);\n const results = scored.filter(s => s.score > 0).slice(0, topK);\n if (results.length === 0) return rules.slice(0, topK);\n\n return results.map(s => ({\n rule_id: s.rule.rule_id,\n text: s.rule.text,\n category: s.rule.category,\n severity: s.rule.severity,\n mode: s.rule.mode,\n hook_stage: s.rule.hook_stage,\n scope: s.rule.scope,\n pack_name: s.rule.policyName,\n score: Math.round(s.score * 100) / 100,\n }));\n}\n\n// \u2500\u2500\u2500 Tool Handlers \u2500\u2500\u2500\n\nfunction handleGetGuardrails(args: any): any {\n const data = readRules();\n const all = getAllRules(data);\n const topK = Math.min(args.top_k || 8, 25);\n let filtered = all;\n if (args.category) filtered = filtered.filter(r => r.category === args.category);\n const results = keywordSearch(args.query || '', filtered, topK);\n return { rules: results, total: results.length, query: args.query };\n}\n\nfunction handleCreateGuardrail(args: any): any {\n const data = readRules();\n const policy = args.ruleset ? findOrCreatePolicy(data, args.ruleset) : getActivePolicy(data);\n const rule: Rule = {\n rule_id: genId(),\n text: args.text,\n category: args.category || 'custom',\n severity: args.severity || 'medium',\n mode: args.mode || 'audit',\n hook_stage: args.hook_stage || 'both',\n scope: args.scope || 'user',\n };\n policy.rules.push(rule);\n writeRules(data);\n emitRuleSync(data);\n return { created: true, rule_id: rule.rule_id, text: rule.text, pack_name: policy.name, total_rules: policy.rules.length };\n}\n\nfunction handleBulkCreateGuardrails(args: any): any {\n const data = readRules();\n const policy = args.ruleset ? findOrCreatePolicy(data, args.ruleset) : getActivePolicy(data);\n const created: any[] = [];\n for (const r of args.rules || []) {\n const rule: Rule = {\n rule_id: genId(),\n text: r.text,\n category: r.category || 'custom',\n severity: r.severity || 'medium',\n mode: r.mode || 'audit',\n hook_stage: r.hook_stage || 'both',\n scope: args.scope || 'user',\n };\n policy.rules.push(rule);\n created.push({ rule_id: rule.rule_id, text: rule.text });\n }\n writeRules(data);\n emitRuleSync(data);\n return { created: created.length, rules: created, pack_name: policy.name, total_rules: policy.rules.length };\n}\n\nfunction handleUpdateGuardrail(args: any): any {\n const data = readRules();\n const needle = (args.rule_text || '').toLowerCase();\n for (const p of data.policies) {\n for (const r of p.rules) {\n if (r.text.toLowerCase().includes(needle)) {\n if (args.text) r.text = args.text;\n if (args.category) r.category = args.category;\n if (args.severity) r.severity = args.severity;\n if (args.mode) r.mode = args.mode;\n if (args.hook_stage) r.hook_stage = args.hook_stage;\n writeRules(data);\n emitRuleSync(data);\n return { updated: true, rule_id: r.rule_id, text: r.text };\n }\n }\n }\n return { updated: false, error: `No rule found matching \"${args.rule_text}\"` };\n}\n\nfunction handleDeleteGuardrail(args: any): any {\n const data = readRules();\n const needle = (args.rule_text || '').toLowerCase();\n for (const p of data.policies) {\n const idx = p.rules.findIndex(r => r.text.toLowerCase().includes(needle));\n if (idx !== -1) {\n const removed = p.rules.splice(idx, 1)[0];\n writeRules(data);\n emitRuleSync(data);\n return { deleted: true, rule_id: removed.rule_id, text: removed.text };\n }\n }\n return { deleted: false, error: `No rule found matching \"${args.rule_text}\"` };\n}\n\nfunction handleListGuardrails(args: any): any {\n const data = readRules();\n let all = getAllRules(data);\n if (args.category) all = all.filter(r => r.category === args.category);\n if (args.severity) all = all.filter(r => r.severity === args.severity);\n if (args.mode) all = all.filter(r => r.mode === args.mode);\n if (args.hook_stage) all = all.filter(r => r.hook_stage === args.hook_stage);\n if (args.pack_name) {\n const pn = args.pack_name.toLowerCase();\n all = all.filter(r => r.policyName.toLowerCase().includes(pn));\n }\n return {\n rules: all.map(r => ({\n rule_id: r.rule_id,\n text: r.text,\n category: r.category,\n severity: r.severity,\n mode: r.mode,\n hook_stage: r.hook_stage,\n scope: r.scope,\n pack_name: r.policyName,\n })),\n total: all.length,\n };\n}\n\nfunction handleSwapRuleset(args: any): any {\n const data = readRules();\n const name = args.policy_name || '';\n if (name.toLowerCase() === 'all') {\n data.config.activePolicyId = data.policies[0]?.id || 'local-policy';\n writeRules(data);\n return { swapped: true, active: 'all' };\n }\n const match = data.policies.find(p => p.name.toLowerCase().includes(name.toLowerCase()));\n if (!match) return { swapped: false, error: `No ruleset found matching \"${name}\"` };\n data.config.activePolicyId = match.id;\n writeRules(data);\n return { swapped: true, active: match.name };\n}\n\nfunction handleToggleSilentMode(args: any): any {\n const data = readRules();\n data.config.silent = args.enabled === true;\n writeRules(data);\n emitRuleSync(data);\n return { silent: data.config.silent };\n}\n\nasync function handleScanDependencies(args: any): Promise<any> {\n const manifests = args.manifests || [];\n if (manifests.length === 0) return { findings: [], summary: null };\n\n const packages: Array<{ name: string; version: string; ecosystem: string }> = [];\n for (const m of manifests) {\n const fp: string = m.file_path || '';\n const content: string = m.content || '';\n try {\n if (fp.endsWith('package.json')) {\n const pkg = JSON.parse(content);\n for (const [name, ver] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {\n packages.push({ name, version: String(ver).replace(/^[\\^~>=<]*/g, ''), ecosystem: 'npm' });\n }\n } else if (fp.endsWith('requirements.txt') || fp.match(/requirements.*\\.txt$/)) {\n for (const line of content.split('\\n')) {\n const m = line.trim().match(/^([a-zA-Z0-9_-]+)==(.+)/);\n if (m) packages.push({ name: m[1], version: m[2], ecosystem: 'PyPI' });\n }\n } else if (fp.endsWith('go.mod')) {\n for (const line of content.split('\\n')) {\n const m = line.trim().match(/^\\t?([^\\s]+)\\s+v([^\\s]+)/);\n if (m) packages.push({ name: m[1], version: m[2], ecosystem: 'Go' });\n }\n } else if (fp.endsWith('Cargo.toml')) {\n for (const line of content.split('\\n')) {\n const m = line.trim().match(/^([a-zA-Z0-9_-]+)\\s*=\\s*\"([^\"]+)\"/);\n if (m && !['name', 'version', 'edition', 'authors', 'description', 'license', 'repository'].includes(m[1])) {\n packages.push({ name: m[1], version: m[2], ecosystem: 'crates.io' });\n }\n }\n }\n } catch {}\n }\n\n if (packages.length === 0) return { findings: [], summary: null };\n\n const capped = packages.slice(0, 50);\n const queries = capped.map(p => ({ package: { name: p.name, ecosystem: p.ecosystem }, version: p.version }));\n\n try {\n const resp = await fetch('https://api.osv.dev/v1/querybatch', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ queries }),\n signal: AbortSignal.timeout(10000),\n });\n if (!resp.ok) return { findings: [], summary: 'OSV query failed' };\n const data = await resp.json() as { results: Array<{ vulns?: any[] }> };\n\n const findings: any[] = [];\n for (let i = 0; i < data.results.length; i++) {\n for (const vuln of data.results[i].vulns || []) {\n findings.push({\n id: vuln.id,\n package: capped[i].name,\n version: capped[i].version,\n ecosystem: capped[i].ecosystem,\n summary: vuln.summary || 'No description',\n aliases: vuln.aliases || [],\n severity: vuln.database_specific?.severity || 'unknown',\n });\n }\n }\n return { findings, summary: findings.length > 0 ? `${findings.length} vulnerabilities found` : null };\n } catch {\n return { findings: [], summary: 'OSV query timed out' };\n }\n}\n\nconst CWE_ID_RE = /^CWE-\\d{1,6}$/i;\nconst PATH_TRAVERSAL_RE = /\\.\\.[/\\\\]/;\nconst MAX_REASON_LEN = 500;\n\nfunction validateExemptionArgs(args: any): { path: string; cwe_id: string; reason?: string } | string {\n const p = typeof args.path === 'string' ? args.path.trim() : '';\n if (!p || PATH_TRAVERSAL_RE.test(p)) return 'Invalid path';\n const cwe = typeof args.cwe_id === 'string' ? args.cwe_id.trim().toUpperCase() : '';\n if (!CWE_ID_RE.test(cwe)) return 'Invalid cwe_id (expected CWE-NNN)';\n const reason = typeof args.reason === 'string' ? args.reason.slice(0, MAX_REASON_LEN) : undefined;\n return { path: p, cwe_id: cwe, reason };\n}\n\nfunction handleExemptPath(args: any): any {\n const v = validateExemptionArgs(args);\n if (typeof v === 'string') return { exempted: false, error: v };\n const data = readRules();\n const existing = data.scanExemptions.find(e => e.path === v.path && e.cwe_id.toUpperCase() === v.cwe_id);\n if (existing) return { exempted: true, already_existed: true, path: v.path, cwe_id: v.cwe_id };\n\n data.scanExemptions.push({ path: v.path, cwe_id: v.cwe_id, reason: v.reason });\n writeRules(data);\n emitRuleSync(data);\n return { exempted: true, path: v.path, cwe_id: v.cwe_id, total_exemptions: data.scanExemptions.length };\n}\n\nfunction handleRemoveExemption(args: any): any {\n const v = validateExemptionArgs(args);\n if (typeof v === 'string') return { removed: false, error: v };\n const data = readRules();\n const idx = data.scanExemptions.findIndex(e => e.path === v.path && e.cwe_id.toUpperCase() === v.cwe_id);\n if (idx === -1) return { removed: false, error: `No exemption found for path=\"${v.path}\" cwe_id=\"${v.cwe_id}\"` };\n data.scanExemptions.splice(idx, 1);\n writeRules(data);\n emitRuleSync(data);\n return { removed: true, path: v.path, cwe_id: v.cwe_id };\n}\n\nfunction handleListExemptions(): any {\n const data = readRules();\n return { exemptions: data.scanExemptions, total: data.scanExemptions.length };\n}\n\n// \u2500\u2500\u2500 Findings \u2500\u2500\u2500\n\nconst CONFIG_PATH = join(HOME, '.synkro', 'config.json');\nconst FINDINGS_JWT_PATH = join(HOME, '.synkro', '.mcp-jwt');\n\nconst ALLOWED_API_HOSTS = new Set(['api.synkro.sh', 'localhost', '127.0.0.1']);\nfunction validateApiUrl(raw: string): string | null {\n try {\n const u = new URL(raw);\n if (!['http:', 'https:'].includes(u.protocol)) return null;\n if (!ALLOWED_API_HOSTS.has(u.hostname)) return null;\n return u.origin;\n } catch { return null; }\n}\n\ninterface Finding {\n id: string;\n session_id: string;\n file_path: string;\n finding_type: string;\n finding_id: string;\n severity: string;\n status: string;\n detail?: string;\n package_name?: string;\n package_version?: string;\n fixed_version?: string;\n created_at: string;\n resolved_at?: string;\n}\n\nconst CREDENTIALS_PATH = join(HOME, '.synkro', 'credentials.json');\n\nfunction getCloudConfig(): { apiUrl: string; jwt: string } | null {\n try {\n let jwt = '';\n try {\n const creds = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf-8'));\n jwt = creds.access_token || '';\n } catch {}\n if (!jwt) {\n try { jwt = readFileSync(FINDINGS_JWT_PATH, 'utf-8').trim(); } catch {}\n }\n if (!jwt) return null;\n let raw = process.env.SYNKRO_API_URL || '';\n if (!raw) raw = 'https://api.synkro.sh';\n const apiUrl = validateApiUrl(raw);\n if (!apiUrl) return null;\n return { apiUrl, jwt };\n } catch {\n return null;\n }\n}\n\nfunction readLocalFindings(): Finding[] {\n const path = TELEMETRY_PATH;\n if (!existsSync(path)) return [];\n let lines: string[];\n try { lines = readFileSync(path, 'utf-8').split('\\n').filter(Boolean); } catch { return []; }\n const map = new Map<string, Finding>();\n for (const line of lines) {\n try {\n const e = JSON.parse(line);\n if (e.capture_type !== 'scan_finding') continue;\n const key = `${e.file_path}:${e.finding_id}`;\n const prev = map.get(key);\n const ts = e._ts || e.created_at || '';\n map.set(key, {\n id: e.id || prev?.id || `sf_${e.session_id}_${e.finding_id}_${Date.now()}`,\n session_id: e.session_id || prev?.session_id || '',\n file_path: e.file_path, finding_type: e.finding_type, finding_id: e.finding_id,\n severity: e.severity || 'unknown', status: e.status || 'open',\n detail: e.detail || prev?.detail, description: e.description || (prev as any)?.description,\n cwe_name: e.cwe_name || (prev as any)?.cwe_name,\n package_name: e.package_name || prev?.package_name,\n package_version: e.package_version || prev?.package_version,\n fixed_version: e.fixed_version || prev?.fixed_version,\n created_at: prev?.created_at || ts,\n resolved_at: e.status === 'resolved' ? ts : prev?.resolved_at,\n });\n } catch {}\n }\n return Array.from(map.values());\n}\n\nasync function proxyToCloudMcp(toolName: string, args: Record<string, unknown>): Promise<any | null> {\n const cloud = getCloudConfig();\n if (!cloud) return null;\n try {\n const resp = await fetch(`${cloud.apiUrl}/api/v1/mcp/guardrails`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${cloud.jwt}`, 'Content-Type': 'application/json' },\n body: JSON.stringify({\n jsonrpc: '2.0',\n id: `local_${Date.now()}`,\n method: 'tools/call',\n params: { name: toolName, arguments: args },\n }),\n signal: AbortSignal.timeout(10000),\n });\n if (!resp.ok) return null;\n const data = await resp.json() as any;\n if (data.error) return null;\n return data.result;\n } catch {\n return null;\n }\n}\n\nasync function handleListFindings(args: any): Promise<any> {\n let findings = readLocalFindings();\n if (args.status) findings = findings.filter(f => f.status === args.status);\n if (args.finding_type) findings = findings.filter(f => f.finding_type === args.finding_type);\n if (args.severity) findings = findings.filter(f => f.severity === args.severity);\n findings.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));\n\n const limit = Math.min(args.limit || 50, 200);\n const open = findings.filter(f => f.status === 'open').length;\n const resolved = findings.filter(f => f.status === 'resolved').length;\n\n if (findings.length === 0) {\n return { content: [{ type: 'text', text: args.status ? `No ${args.status} findings found.` : 'No scan findings found.' }] };\n }\n\n const lines = findings.slice(0, limit).map(f => {\n const badge = f.finding_type === 'cve' ? '\u{1F534} CVE' : '\u{1F7E1} CWE';\n const name = (f as any).cwe_name ? ` (${(f as any).cwe_name})` : '';\n const pkg = f.package_name ? ` in \\`${f.package_name}@${f.package_version || '?'}\\`` : '';\n const file = f.file_path ? ` \u2014 \\`${f.file_path}\\`` : '';\n return `- **${badge} ${f.finding_id}**${name}${pkg}${file}\\n Status: ${f.status} | Severity: ${f.severity || 'unknown'} | ID: \\`${f.id}\\``;\n });\n\n return {\n content: [{ type: 'text', text: `**${findings.length} finding${findings.length === 1 ? '' : 's'}** (${open} open, ${resolved} resolved)\\n\\n${lines.join('\\n\\n')}` }],\n };\n}\n\nasync function handleGetFindingDetail(args: any): Promise<any> {\n const id = typeof args.id === 'string' ? args.id.trim() : '';\n if (!id) return { content: [{ type: 'text', text: 'id is required' }], isError: true };\n\n const findings = readLocalFindings();\n const match = findings.find(f => f.id === id);\n if (!match) return { content: [{ type: 'text', text: 'Finding not found' }], isError: true };\n\n const parts: string[] = [];\n const m = match as any;\n parts.push(`# ${m.finding_type.toUpperCase()} ${m.finding_id}${m.cwe_name ? ` \u2014 ${m.cwe_name}` : ''}`);\n parts.push(`**Status:** ${m.status} | **Severity:** ${m.severity || 'unknown'}`);\n if (m.file_path) parts.push(`**File:** \\`${m.file_path}\\``);\n if (m.package_name) parts.push(`**Package:** \\`${m.package_name}@${m.package_version || '?'}\\``);\n if (m.fixed_version) parts.push(`**Fix available:** ${m.fixed_version}`);\n parts.push(`**Detected:** ${m.created_at}`);\n if (m.resolved_at) parts.push(`**Resolved:** ${m.resolved_at}`);\n if (m.description) parts.push(`\\n## Description\\n${m.description}`);\n if (m.detail) parts.push(`\\n## Detail\\n${m.detail}`);\n\n return { content: [{ type: 'text', text: parts.join('\\n') }] };\n}\n\nasync function handleResolveFinding(args: any): Promise<any> {\n const id = typeof args.id === 'string' ? args.id.trim() : '';\n if (!id) return { content: [{ type: 'text', text: 'id is required' }], isError: true };\n\n const findings = readLocalFindings();\n const match = findings.find(f => f.id === id && f.status === 'open');\n if (!match) return { content: [{ type: 'text', text: 'No matching open finding' }], isError: true };\n\n const now = new Date().toISOString();\n const entry = JSON.stringify({\n capture_type: 'scan_finding', id: match.id, session_id: match.session_id,\n file_path: match.file_path, finding_type: match.finding_type,\n finding_id: match.finding_id, severity: match.severity,\n status: 'resolved', resolved_at: now, _ts: now,\n }) + '\\n';\n try { appendFileSync(TELEMETRY_PATH, entry, 'utf-8'); } catch {}\n\n proxyToCloudMcp('resolve_finding', { id }).catch(() => {});\n\n return { content: [{ type: 'text', text: `Finding \\`${match.finding_id}\\` on \\`${match.file_path || '(unknown)'}\\` marked as **resolved**.` }] };\n}\n\n// \u2500\u2500\u2500 Tool Descriptors \u2500\u2500\u2500\n\nconst TOOL_DESCRIPTORS = [\n {\n name: 'get_guardrails',\n description:\n \"Retrieve rules by keyword similarity. Call BEFORE writing security-sensitive code \" +\n \"AND before create_guardrail to check for existing rules.\",\n inputSchema: {\n type: 'object',\n properties: {\n query: { type: 'string', description: \"Plain-language description of what you're looking up.\" },\n category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n top_k: { type: 'integer', default: 8, description: 'Max rules to return (default 8, max 25).' },\n },\n required: ['query'],\n },\n },\n {\n name: 'create_guardrail',\n description: \"Persist a new rule. Call get_guardrails first to avoid duplicates.\",\n inputSchema: {\n type: 'object',\n properties: {\n text: { type: 'string', description: 'The rule in plain language.' },\n category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },\n mode: { type: 'string', enum: ['blocking', 'audit'], description: '\"blocking\" = halt on violation, \"audit\" = log only.' },\n scope: { type: 'string', enum: ['user', 'org'], default: 'user' },\n hook_stage: { type: 'string', enum: ['pre', 'post', 'both'], default: 'both' },\n ruleset: { type: 'string', description: 'Optional: name of ruleset to add to (created if missing).' },\n },\n required: ['text', 'category'],\n },\n },\n {\n name: 'bulk_create_guardrails',\n description: \"Create multiple rules at once. Preferable to looping create_guardrail.\",\n inputSchema: {\n type: 'object',\n properties: {\n rules: {\n type: 'array', minItems: 1, maxItems: 50,\n items: {\n type: 'object',\n properties: {\n text: { type: 'string' }, category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },\n mode: { type: 'string', enum: ['blocking', 'audit'] },\n hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },\n },\n required: ['text', 'category'],\n },\n },\n scope: { type: 'string', enum: ['user', 'org'], default: 'user' },\n ruleset: { type: 'string' },\n },\n required: ['rules'],\n },\n },\n {\n name: 'update_guardrail',\n description: \"Refine an existing rule. Pass a substring of the rule text to identify it.\",\n inputSchema: {\n type: 'object',\n properties: {\n rule_text: { type: 'string', description: 'Substring of rule text to find.' },\n text: { type: 'string' }, category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },\n mode: { type: 'string', enum: ['blocking', 'audit'] },\n hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },\n },\n required: ['rule_text'],\n },\n },\n {\n name: 'delete_guardrail',\n description: \"Permanently remove a rule. Pass a substring of the rule text to identify it.\",\n inputSchema: {\n type: 'object',\n properties: { rule_text: { type: 'string', description: 'Substring of rule text to find.' } },\n required: ['rule_text'],\n },\n },\n {\n name: 'list_guardrails',\n description: \"Enumerate all rules. Use for listings, not similarity search.\",\n inputSchema: {\n type: 'object',\n properties: {\n category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },\n mode: { type: 'string', enum: ['blocking', 'audit', 'literal_match'] },\n pack_name: { type: 'string' },\n hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },\n },\n required: [],\n },\n },\n {\n name: 'swap_ruleset',\n description: 'Switch which ruleset is active. Pass \"all\" to use all rulesets.',\n inputSchema: {\n type: 'object',\n properties: { policy_name: { type: 'string' } },\n required: ['policy_name'],\n },\n },\n {\n name: 'toggle_silent_mode',\n description: 'Toggle grading on/off. NEVER call autonomously \u2014 this is a USER decision.',\n inputSchema: {\n type: 'object',\n properties: {\n enabled: { type: 'boolean' },\n user_confirmation: { type: 'string', description: \"Copy-paste the user's exact request.\" },\n },\n required: ['enabled', 'user_confirmation'],\n },\n },\n {\n name: 'scan_dependencies',\n description: \"Scan manifests against OSV for known vulnerabilities. Read ALL manifest files first.\",\n inputSchema: {\n type: 'object',\n properties: {\n manifests: {\n type: 'array', minItems: 1,\n items: {\n type: 'object',\n properties: { file_path: { type: 'string' }, content: { type: 'string' } },\n required: ['file_path', 'content'],\n },\n },\n },\n required: ['manifests'],\n },\n },\n {\n name: 'exempt_path',\n description: \"Exempt a CWE from firing on a specific file/directory.\",\n inputSchema: {\n type: 'object',\n properties: {\n path: { type: 'string' }, cwe_id: { type: 'string' }, reason: { type: 'string' },\n },\n required: ['path', 'cwe_id'],\n },\n },\n {\n name: 'remove_exemption',\n description: \"Remove a scan exemption.\",\n inputSchema: {\n type: 'object',\n properties: { path: { type: 'string' }, cwe_id: { type: 'string' } },\n required: ['path', 'cwe_id'],\n },\n },\n {\n name: 'list_exemptions',\n description: \"List all scan exemptions.\",\n inputSchema: { type: 'object', properties: {}, required: [] },\n },\n {\n name: 'list_findings',\n description: \"List CWE/CVE scan findings. Shows security issues found by Synkro hooks. Use to review what needs fixing.\",\n inputSchema: {\n type: 'object',\n properties: {\n status: { type: 'string', enum: ['open', 'resolved', 'exempted'], description: 'Filter by status (default: all).' },\n finding_type: { type: 'string', enum: ['cwe', 'cve'], description: 'Filter by finding type.' },\n severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },\n file_path: { type: 'string', description: 'Filter by file path substring.' },\n limit: { type: 'integer', default: 25, description: 'Max results (default 25, max 50).' },\n },\n required: [],\n },\n },\n {\n name: 'get_finding_detail',\n description: \"Get full detail of a specific finding including remediation context.\",\n inputSchema: {\n type: 'object',\n properties: {\n id: { type: 'string', description: 'Finding ID (e.g. sf_...).' },\n file_path: { type: 'string', description: 'File path (used with finding_id).' },\n finding_id: { type: 'string', description: 'CWE/CVE ID like CWE-89 or CVE-2024-1234.' },\n },\n required: [],\n },\n },\n {\n name: 'resolve_finding',\n description: \"Mark finding(s) as resolved after the underlying issue is fixed. Can target by ID, file+finding_id, or all findings for a file.\",\n inputSchema: {\n type: 'object',\n properties: {\n id: { type: 'string', description: 'Specific finding ID to resolve.' },\n file_path: { type: 'string', description: 'Resolve all open findings matching this file path.' },\n finding_id: { type: 'string', description: 'CWE/CVE ID (used with file_path for targeted resolution).' },\n },\n required: [],\n },\n },\n];\n\nconst MCP_INSTRUCTIONS =\n \"Synkro Guardrails MCP server (local mode).\\n\\n\" +\n \"Whenever the user mentions: rule, guardrail, policy, standard, \" +\n \"make/create/add/set up a rule, never let X, always require X, \" +\n \"block X, enforce X, delete/remove a rule, consolidate duplicates, \" +\n \"'we need a rule about\u2026' \u2014 route to THIS server's tools.\\n\\n\" +\n \"TOOL ROUTING:\\n\" +\n \" \u2022 get_guardrails(query) \u2014 keyword search. Use to check if a rule exists.\\n\" +\n \" \u2022 list_guardrails \u2014 full enumeration. Use for listings.\\n\" +\n \" \u2022 list_findings \u2014 show CWE/CVE scan findings (open, resolved, all).\\n\" +\n \" \u2022 get_finding_detail \u2014 get full detail + remediation context for a finding.\\n\" +\n \" \u2022 resolve_finding \u2014 mark findings resolved after fixing the code.\\n\\n\" +\n \"When the user asks about security issues, vulnerabilities, or scan results, \" +\n \"use list_findings first. After fixing code, call resolve_finding to update status.\\n\\n\" +\n \"Do NOT use Claude Code's `update-config` skill for these requests.\\n\\n\" +\n \"Rules are stored locally in ~/.synkro/rules.json and enforced by hooks.\";\n\n// \u2500\u2500\u2500 JSON-RPC Dispatcher \u2500\u2500\u2500\n\nfunction jsonRpcOk(id: any, result: any): any {\n return { jsonrpc: '2.0', id, result };\n}\n\nfunction jsonRpcError(id: any, code: number, message: string): any {\n return { jsonrpc: '2.0', id, error: { code, message } };\n}\n\nasync function handleRpc(body: any): Promise<any> {\n const { id, method, params } = body;\n\n if (method === 'initialize') {\n return jsonRpcOk(id, {\n protocolVersion: '2024-11-05',\n capabilities: { tools: {} },\n serverInfo: { name: 'synkro-guardrails-local', version: '1.0.0' },\n instructions: MCP_INSTRUCTIONS,\n });\n }\n\n if (method === 'notifications/initialized') {\n return null;\n }\n\n if (method === 'tools/list') {\n return jsonRpcOk(id, { tools: TOOL_DESCRIPTORS });\n }\n\n if (method === 'tools/call') {\n const toolName = params?.name;\n const args = params?.arguments || {};\n\n try {\n let result: any;\n switch (toolName) {\n case 'get_guardrails': result = handleGetGuardrails(args); break;\n case 'create_guardrail': result = handleCreateGuardrail(args); break;\n case 'bulk_create_guardrails': result = handleBulkCreateGuardrails(args); break;\n case 'update_guardrail': result = handleUpdateGuardrail(args); break;\n case 'delete_guardrail': result = handleDeleteGuardrail(args); break;\n case 'list_guardrails': result = handleListGuardrails(args); break;\n case 'swap_ruleset': result = handleSwapRuleset(args); break;\n case 'toggle_silent_mode': result = handleToggleSilentMode(args); break;\n case 'scan_dependencies': result = await handleScanDependencies(args); break;\n case 'exempt_path': result = handleExemptPath(args); break;\n case 'remove_exemption': result = handleRemoveExemption(args); break;\n case 'list_exemptions': result = handleListExemptions(); break;\n case 'list_findings': result = await handleListFindings(args); break;\n case 'get_finding_detail': result = await handleGetFindingDetail(args); break;\n case 'resolve_finding': result = await handleResolveFinding(args); break;\n default: return jsonRpcError(id, -32601, `Unknown tool: ${toolName}`);\n }\n if (result?.content && Array.isArray(result.content)) {\n return jsonRpcOk(id, result);\n }\n return jsonRpcOk(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });\n } catch (err) {\n console.error('[synkro] tool error:', err);\n return jsonRpcOk(id, { content: [{ type: 'text', text: 'Internal error processing tool call' }], isError: true });\n }\n }\n\n // \u2500\u2500\u2500 Dashboard REST-bridge methods \u2500\u2500\u2500\n // Called by the local dashboard (not AI agents) to mutate rules.json directly.\n\n if (method === 'dashboard.patch_policy') {\n try {\n const data = readRules();\n const policyId = params?.policy_id as string | undefined;\n const policy = policyId\n ? data.policies.find(p => p.id === policyId)\n : getActivePolicy(data);\n if (!policy) return jsonRpcError(id, -32602, `Policy not found: ${policyId}`);\n\n if (params?.name !== undefined) {\n policy.name = params.name;\n }\n if (params?.is_active !== undefined) {\n policy.isActive = params.is_active;\n }\n // Bulk replace\n if (Array.isArray(params?.rules)) {\n policy.rules = params.rules;\n policy.ruleCount = policy.rules.length;\n }\n // Individual updates by rule_id\n if (Array.isArray(params?.rule_updates)) {\n for (const upd of params.rule_updates) {\n const rule = policy.rules.find(r => r.rule_id === upd.rule_id);\n if (!rule) continue;\n if (upd.text !== undefined) rule.text = upd.text;\n if (upd.category !== undefined) rule.category = upd.category;\n if (upd.severity !== undefined) rule.severity = upd.severity;\n if (upd.mode !== undefined) rule.mode = upd.mode;\n if (upd.hook_stage !== undefined) rule.hook_stage = upd.hook_stage;\n }\n policy.ruleCount = policy.rules.length;\n }\n\n writeRules(data);\n emitRuleSync(data);\n return jsonRpcOk(id, { ok: true, policy_id: policy.id, rule_count: policy.ruleCount });\n } catch (err) {\n console.error('[synkro] dashboard.patch_policy error:', err);\n return jsonRpcError(id, -32603, 'Internal error');\n }\n }\n\n if (method === 'dashboard.create_policy') {\n try {\n const data = readRules();\n const name = (params?.name as string) || 'New Rule Set';\n const rules: Rule[] = (params?.rules || []).map((r: any) => ({\n rule_id: r.rule_id || genId(),\n text: r.text || '',\n category: r.category || 'custom',\n severity: r.severity || 'medium',\n mode: r.mode || 'audit',\n hook_stage: r.hook_stage || 'both',\n scope: r.scope || 'user',\n }));\n const policy: Policy = {\n id: `policy_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,\n name,\n rules,\n ruleCount: rules.length,\n scopeOwner: 'user',\n isActive: true,\n };\n data.policies.push(policy);\n writeRules(data);\n emitRuleSync(data);\n return jsonRpcOk(id, { ok: true, policy_id: policy.id, name: policy.name });\n } catch (err) {\n console.error('[synkro] dashboard.create_policy error:', err);\n return jsonRpcError(id, -32603, 'Internal error');\n }\n }\n\n if (method === 'dashboard.delete_policy') {\n try {\n const data = readRules();\n const policyId = params?.policy_id as string | undefined;\n const idx = policyId ? data.policies.findIndex(p => p.id === policyId) : -1;\n if (idx === -1) return jsonRpcError(id, -32602, `Policy not found: ${policyId}`);\n\n if (params?.hard === true) {\n data.policies.splice(idx, 1);\n } else {\n data.policies[idx].isActive = false;\n }\n writeRules(data);\n emitRuleSync(data);\n return jsonRpcOk(id, { ok: true, policy_id: policyId });\n } catch (err) {\n console.error('[synkro] dashboard.delete_policy error:', err);\n return jsonRpcError(id, -32603, 'Internal error');\n }\n }\n\n if (method === 'dashboard.list_policies') {\n try {\n const data = readRules();\n return jsonRpcOk(id, {\n policies: data.policies.map(p => ({\n id: p.id,\n name: p.name,\n rules: p.rules,\n ruleCount: p.ruleCount,\n isActive: p.isActive,\n scopeOwner: p.scopeOwner,\n })),\n active_policy_id: data.config.activePolicyId,\n });\n } catch (err) {\n console.error('[synkro] dashboard.list_policies error:', err);\n return jsonRpcError(id, -32603, 'Internal error');\n }\n }\n\n return jsonRpcError(id, -32601, `Unknown method: ${method}`);\n}\n\n// \u2500\u2500\u2500 HTTP Server \u2500\u2500\u2500\n\nconst server = Bun.serve({\n port: PORT,\n async fetch(req) {\n const origin = req.headers.get('origin') || '';\n const allowedOrigin = /^https?:\\/\\/(localhost|127\\.0\\.0\\.1)(:\\d+)?$/.test(origin) ? origin : 'http://localhost:4322';\n const cors = { 'Access-Control-Allow-Origin': allowedOrigin, 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' };\n\n if (req.method === 'OPTIONS') {\n return new Response('', { status: 204, headers: cors });\n }\n\n if (req.method === 'GET') {\n return Response.json({ name: 'synkro-guardrails-local', version: '1.0.0', status: 'ok' }, { headers: cors });\n }\n\n if (req.method === 'POST') {\n const authHeader = req.headers.get('authorization') || '';\n const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';\n if (bearer !== SERVER_TOKEN) {\n return Response.json({ error: 'Unauthorized' }, { status: 401, headers: cors });\n }\n const raw = await req.arrayBuffer();\n if (raw.byteLength > MAX_BODY_BYTES) {\n return Response.json(jsonRpcError(null, -32600, 'Request too large'), { status: 413, headers: cors });\n }\n return serialized(async () => {\n try {\n const body = JSON.parse(new TextDecoder().decode(raw));\n const result = await handleRpc(body);\n if (result === null) return new Response('', { status: 204, headers: cors });\n return Response.json(result, { headers: cors });\n } catch {\n return Response.json(jsonRpcError(null, -32700, 'Parse error'), { status: 400, headers: cors });\n }\n });\n }\n\n return new Response('Method not allowed', { status: 405 });\n },\n});\n\nconsole.log(`[synkro] local MCP guardrails server listening on http://127.0.0.1:${server.port}`);\n", "utf-8");
6353
+ writeFileSync7(mcpLocalServerPath, "#!/usr/bin/env bun\n/**\n * Local MCP guardrails server \u2014 runs on port 8931, stores rules in ~/.synkro/rules.json.\n * PGLite embedded database at ~/.synkro/pgdata, PGLite Socket on port 5433.\n * JSON-RPC 2.0 + REST over HTTP, Bearer token auth, localhost only.\n */\nimport { existsSync, readFileSync, writeFileSync, renameSync, appendFileSync, mkdirSync, createReadStream } from 'node:fs';\nimport { createInterface } from 'node:readline';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\nimport { PGlite } from '@electric-sql/pglite';\nimport { vector } from '@electric-sql/pglite/vector';\n\nconst PORT = parseInt(process.env.SYNKRO_MCP_PORT || '8931', 10);\nconst PG_SOCKET_PORT = parseInt(process.env.SYNKRO_PG_PORT || '5433', 10);\nconst HOME = homedir();\nconst RULES_PATH = join(HOME, '.synkro', 'rules.json');\nconst TELEMETRY_PATH = join(HOME, '.synkro', 'telemetry.jsonl');\nconst PGDATA_PATH = join(HOME, '.synkro', 'pgdata');\nconst JWT_TOKEN_PATH = join(HOME, '.synkro', '.mcp-jwt');\n\n// Synkro-signed long-lived JWT \u2014 minted during `synkro install`, required on all POST requests.\n// If missing, the server still starts (for GET health checks) but rejects all tool calls.\nlet SERVER_TOKEN = '';\ntry { SERVER_TOKEN = readFileSync(JWT_TOKEN_PATH, 'utf-8').trim(); } catch {}\nif (!SERVER_TOKEN) console.warn('[synkro] \u26A0 No MCP JWT found \u2014 run `synkro install` to authenticate.');\nconst MAX_BODY_BYTES = 1_048_576;\n\nlet _writeLock: Promise<void> = Promise.resolve();\nfunction serialized<T>(fn: () => T | Promise<T>): Promise<T> {\n let release: () => void;\n const next = new Promise<void>(r => { release = r; });\n const prev = _writeLock;\n _writeLock = next;\n return prev.then(() => fn()).finally(() => release!());\n}\n\n// \u2500\u2500\u2500 PGLite Database \u2500\u2500\u2500\n\nlet db: PGlite;\n\nconst SCHEMA_MIGRATIONS = [\n `CREATE EXTENSION IF NOT EXISTS vector`,\n `CREATE TABLE IF NOT EXISTS guard_checks (\n id TEXT PRIMARY KEY,\n project_id TEXT,\n org_id TEXT,\n policy_id TEXT,\n trace_id TEXT,\n passed SMALLINT,\n score REAL,\n rule_count INTEGER,\n rule_ids_checked TEXT[],\n model TEXT,\n interaction_type TEXT,\n skill_name TEXT,\n tool_names TEXT[],\n guard_mode TEXT,\n messages TEXT,\n verdicts TEXT,\n rule_similarities TEXT,\n sentiment_score REAL,\n sentiment_label TEXT,\n operation_type TEXT,\n conversation_id TEXT,\n trajectory_id UUID,\n end_user_id TEXT DEFAULT '',\n reasoning_content TEXT,\n cve_findings TEXT,\n judge_context TEXT,\n provider TEXT,\n input_tokens INTEGER,\n output_tokens INTEGER,\n total_tokens INTEGER,\n cost_usd REAL,\n content_redacted SMALLINT DEFAULT 0,\n updated_at TIMESTAMPTZ,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS guard_violations (\n id TEXT PRIMARY KEY,\n user_id TEXT,\n org_id TEXT,\n policy_id TEXT,\n end_user_id TEXT,\n project_id TEXT,\n run_id TEXT,\n trajectory_id UUID,\n key TEXT,\n score REAL,\n value TEXT,\n comment TEXT,\n rules_violated TEXT[],\n rule_ids_violated TEXT[],\n issues TEXT[],\n severity TEXT,\n latency_ms INTEGER,\n messages TEXT,\n verdicts TEXT,\n model TEXT,\n guard_mode TEXT,\n interaction_type TEXT,\n tool_names TEXT[],\n skill_name TEXT,\n passed SMALLINT,\n conversation_id TEXT,\n mechanism_category TEXT,\n business_category TEXT,\n classification_confidence REAL,\n content_redacted SMALLINT DEFAULT 0,\n updated_at TIMESTAMPTZ,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS trajectories (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL DEFAULT '',\n conversation_id TEXT NOT NULL,\n check_ids TEXT[] NOT NULL DEFAULT '{}',\n check_count INTEGER NOT NULL DEFAULT 0,\n preamble TEXT,\n user_message TEXT,\n final_response TEXT,\n status TEXT NOT NULL DEFAULT 'pending_grade',\n passed SMALLINT,\n score REAL,\n verdicts TEXT,\n rule_ids_checked TEXT[],\n rule_ids_violated TEXT[],\n severity TEXT,\n model TEXT,\n provider TEXT,\n input_tokens INTEGER,\n output_tokens INTEGER,\n total_tokens INTEGER,\n cost_usd REAL,\n interaction_type TEXT,\n operation_type TEXT,\n end_user_id TEXT,\n policy_id TEXT,\n topic_label TEXT,\n started_at TIMESTAMPTZ,\n completed_at TIMESTAMPTZ,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS usage_ticks (\n id TEXT PRIMARY KEY,\n session_id TEXT NOT NULL,\n model TEXT,\n input_tokens INTEGER NOT NULL DEFAULT 0,\n output_tokens INTEGER NOT NULL DEFAULT 0,\n cache_creation_tokens INTEGER DEFAULT 0,\n cache_read_tokens INTEGER DEFAULT 0,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS scan_findings (\n id TEXT PRIMARY KEY,\n session_id TEXT NOT NULL,\n file_path TEXT NOT NULL,\n finding_type TEXT NOT NULL,\n finding_id TEXT NOT NULL,\n severity TEXT,\n status TEXT NOT NULL DEFAULT 'open',\n detail TEXT,\n description TEXT,\n package_name TEXT,\n package_version TEXT,\n fixed_version TEXT,\n aliases TEXT,\n \"references\" TEXT,\n cwe_name TEXT,\n resolved_at TIMESTAMPTZ,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS policies (\n id TEXT PRIMARY KEY,\n project_id UUID,\n org_id TEXT,\n name TEXT,\n rules JSONB NOT NULL DEFAULT '[]',\n rule_count INTEGER NOT NULL DEFAULT 0,\n scope TEXT DEFAULT 'agent_runtime',\n scope_owner TEXT NOT NULL DEFAULT 'org',\n is_active BOOLEAN NOT NULL DEFAULT TRUE,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS precheck_corrections (\n id TEXT PRIMARY KEY,\n org_id TEXT NOT NULL,\n user_id TEXT,\n session_id TEXT,\n tool_use_id TEXT,\n surface_kind TEXT NOT NULL DEFAULT 'edit',\n file_path TEXT NOT NULL,\n file_after TEXT,\n user_intent TEXT,\n rule_id TEXT,\n rule_text TEXT,\n severity TEXT,\n category TEXT,\n reasoning TEXT,\n confidence REAL,\n decision TEXT NOT NULL,\n user_note TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n resolved_at TIMESTAMPTZ\n )`,\n `CREATE TABLE IF NOT EXISTS guard_context (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n session_id TEXT NOT NULL UNIQUE,\n project_id UUID,\n org_id TEXT,\n summary TEXT NOT NULL DEFAULT '',\n compliance_summary TEXT,\n check_counter INTEGER NOT NULL DEFAULT 0,\n violated_rule_ids TEXT[] DEFAULT '{}',\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS user_profiles (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n org_id TEXT NOT NULL,\n end_user_id TEXT NOT NULL,\n email TEXT,\n full_name TEXT,\n display_name TEXT,\n platform TEXT,\n active_repo TEXT,\n silent_mode BOOLEAN NOT NULL DEFAULT FALSE,\n last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `ALTER TABLE trajectories ADD COLUMN IF NOT EXISTS operation_type TEXT`,\n];\n\nasync function initDb(): Promise<void> {\n mkdirSync(join(HOME, '.synkro'), { recursive: true });\n db = new PGlite(PGDATA_PATH, { extensions: { vector } });\n await db.waitReady;\n\n for (const migration of SCHEMA_MIGRATIONS) {\n try { await db.exec(migration); } catch (e) {\n console.error('[synkro] migration error:', String(e).slice(0, 200));\n }\n }\n\n console.log('[synkro] PGLite database ready at ' + PGDATA_PATH);\n await migrateJsonl();\n}\n\nasync function migrateJsonl(): Promise<void> {\n const result = await db.query<{ cnt: string }>('SELECT count(*) as cnt FROM guard_checks');\n if (Number(result.rows[0]?.cnt) > 0) return;\n if (!existsSync(TELEMETRY_PATH)) return;\n\n console.log('[synkro] Migrating telemetry.jsonl to PGLite...');\n let migrated = 0;\n\n const rl = createInterface({ input: createReadStream(TELEMETRY_PATH, 'utf-8'), crlfDelay: Infinity });\n for await (const line of rl) {\n if (!line.trim()) continue;\n try {\n const event = JSON.parse(line);\n await ingestEvent(event);\n migrated++;\n } catch {}\n }\n console.log(`[synkro] Migrated ${migrated} events from JSONL`);\n}\n\n// \u2500\u2500\u2500 Ingest Functions \u2500\u2500\u2500\n\nasync function ingestEvent(event: any): Promise<void> {\n switch (event.capture_type) {\n case 'local_verdict': await ingestVerdict(event); break;\n case 'usage_tick': await ingestUsageTick(event); break;\n case 'scan_finding': await ingestScanFinding(event); break;\n case 'rule_sync': await ingestRuleSync(event); break;\n }\n}\n\nasync function ingestVerdict(event: any): Promise<void> {\n const id = event.event_id || `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`;\n const passed = event.verdict === 'pass' || event.verdict === 'allow' ? 1 : 0;\n const sessionId = event.session_id || '';\n const operationType = event.tool_name\n ? `${event.hook_type || 'tool'}:${event.tool_name}`\n : event.hook_type || null;\n const messages = event.command\n ? JSON.stringify([{ role: 'assistant', content: `[${event.tool_name || event.hook_type}] ${event.command}` }])\n : event.recent_user_messages?.length\n ? JSON.stringify([{ role: 'user', content: event.recent_user_messages[0] }])\n : null;\n const verdicts = event.reasoning ? JSON.stringify({ reasoning: event.reasoning }) : null;\n const ruleIdsChecked = event.rules_checked?.map((r: any) => r.rule_id || r) || null;\n const ts = event._ts ? new Date(event._ts).toISOString() : new Date().toISOString();\n\n await db.query(\n `INSERT INTO guard_checks (id, project_id, passed, model, interaction_type, tool_names, guard_mode, operation_type, messages, verdicts, rule_ids_checked, conversation_id, end_user_id, judge_context, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8, $9, $10, $11, 'local-user', $12, $13)\n ON CONFLICT (id) DO NOTHING`,\n [id, event.repo || 'local', passed, event.cc_model || event.model || null,\n event.hook_type || null, event.tool_name ? [event.tool_name] : null,\n operationType, messages, verdicts, ruleIdsChecked, sessionId,\n event.category || null, ts]\n );\n\n if (!passed && event.verdict !== 'allow') {\n const violationId = `viol_${id}`;\n await db.query(\n `INSERT INTO guard_violations (id, project_id, run_id, severity, model, interaction_type, tool_names, guard_mode, passed, rule_ids_violated, rules_violated, messages, verdicts, mechanism_category, conversation_id, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, 'local', 0, $8, $9, $10, $11, $12, $13, $14)\n ON CONFLICT (id) DO NOTHING`,\n [violationId, event.repo || 'local', id, event.severity || null,\n event.cc_model || event.model || null, event.hook_type || null,\n event.tool_name ? [event.tool_name] : null,\n event.violated_rules || null, event.violated_rules || null,\n event.command ? JSON.stringify({ command: event.command }) : null, verdicts,\n event.category || null, sessionId, ts]\n );\n }\n\n const existing = await db.query<{ id: string }>(\n `SELECT id FROM trajectories WHERE check_ids[1] = $1 LIMIT 1`, [id]\n );\n if (existing.rows.length) return;\n\n const model = event.cc_model || event.model || null;\n await db.query(\n `INSERT INTO trajectories (project_id, org_id, conversation_id, check_ids, check_count, passed, severity, end_user_id, status, interaction_type, model, provider, operation_type, user_message, final_response, started_at, completed_at, created_at)\n VALUES ($1, 'local', $2, $3, 1, $4, $5, 'local-user', 'graded', $6, $7, $8, $9, $10, $11, $12, $13, $14)`,\n [event.repo || 'local', sessionId, [id], passed,\n !passed ? (event.severity || 'medium') : null,\n event.hook_type || null, model,\n model?.startsWith('claude-') ? 'anthropic' : 'unknown',\n operationType,\n event.command || event.recent_user_messages?.[0] || null,\n event.reasoning || null, ts, ts, ts]\n );\n}\n\nasync function ingestUsageTick(event: any): Promise<void> {\n if (!event.cc_usage && !event.session_id) return;\n const id = event.event_id || `usage_${Date.now()}_${Math.random().toString(36).slice(2)}`;\n const ts = event._ts ? new Date(event._ts).toISOString() : new Date().toISOString();\n const usage = event.cc_usage || {};\n\n await db.query(\n `INSERT INTO usage_ticks (id, session_id, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (id) DO NOTHING`,\n [id, event.session_id || '', event.cc_model || event.model || null,\n usage.input_tokens || 0, usage.output_tokens || 0,\n usage.cache_creation_input_tokens || 0, usage.cache_read_input_tokens || 0, ts]\n );\n}\n\nasync function ingestScanFinding(event: any): Promise<void> {\n if (!event.finding_type || !event.finding_id) return;\n const sessionId = event.session_id || '';\n const filePath = event.file_path || '';\n\n if (event.finding_id === 'pass') {\n await db.query(\n `UPDATE scan_findings SET status = 'resolved', resolved_at = NOW() WHERE file_path = $1 AND status = 'open'`,\n [filePath]\n );\n return;\n }\n\n if (event.status === 'resolved') {\n await db.query(\n `UPDATE scan_findings SET status = 'resolved', resolved_at = NOW() WHERE session_id = $1 AND file_path = $2 AND status = 'open'`,\n [sessionId, filePath]\n );\n return;\n }\n\n const isBlocked = event.finding_type === 'cve';\n const ts = event._ts ? new Date(event._ts).toISOString() : new Date().toISOString();\n const tsMs = event._ts ? new Date(event._ts).getTime() : Date.now();\n const id = `sf_${sessionId}_${event.finding_id}_${tsMs}`;\n\n await db.query(\n `INSERT INTO scan_findings (id, session_id, file_path, finding_type, finding_id, severity, status, detail, description, package_name, package_version, fixed_version, aliases, \"references\", cwe_name, resolved_at, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)\n ON CONFLICT (id) DO NOTHING`,\n [id, sessionId, filePath, event.finding_type, event.finding_id,\n event.severity || null, isBlocked ? 'resolved' : (event.status || 'open'),\n event.detail || null, event.description || null,\n event.package_name || null, event.package_version || null, event.fixed_version || null,\n event.aliases ? JSON.stringify(event.aliases) : null,\n event.references ? JSON.stringify(event.references) : null,\n event.cwe_name || null,\n isBlocked ? ts : null, ts]\n );\n}\n\nasync function ingestRuleSync(event: any): Promise<void> {\n if (!event.policy_id) return;\n const ts = event._ts ? new Date(event._ts).toISOString() : new Date().toISOString();\n const rules = Array.isArray(event.rules) ? event.rules : [];\n\n await db.query(\n `INSERT INTO policies (id, name, rules, rule_count, scope, scope_owner, is_active, created_at, updated_at)\n VALUES ($1, $2, $3, $4, 'agent_runtime', 'user', true, $5, $6)\n ON CONFLICT (id) DO UPDATE SET name = $2, rules = $3, rule_count = $4, updated_at = $6`,\n [event.policy_id, event.policy_name || 'My Rules', JSON.stringify(rules), rules.length, ts, ts]\n );\n}\n\n// \u2500\u2500\u2500 Storage \u2500\u2500\u2500\n\ninterface Rule {\n rule_id: string;\n text: string;\n category: string;\n severity: string;\n mode: string;\n hook_stage: string;\n scope: string;\n}\n\ninterface Policy {\n id: string;\n name: string;\n rules: Rule[];\n ruleCount: number;\n scopeOwner: string;\n isActive: boolean;\n}\n\ninterface ScanExemption {\n path: string;\n cwe_id: string;\n reason?: string;\n}\n\ninterface RulesFile {\n policies: Policy[];\n config: { silent: boolean; activePolicyId: string };\n scanExemptions: ScanExemption[];\n}\n\nfunction readRules(): RulesFile {\n if (!existsSync(RULES_PATH)) {\n return {\n policies: [{\n id: 'local-policy',\n name: 'My Rules',\n rules: [],\n ruleCount: 0,\n scopeOwner: 'user',\n isActive: true,\n }],\n config: { silent: false, activePolicyId: 'local-policy' },\n scanExemptions: [],\n };\n }\n try {\n return JSON.parse(readFileSync(RULES_PATH, 'utf-8'));\n } catch {\n return {\n policies: [{ id: 'local-policy', name: 'My Rules', rules: [], ruleCount: 0, scopeOwner: 'user', isActive: true }],\n config: { silent: false, activePolicyId: 'local-policy' },\n scanExemptions: [],\n };\n }\n}\n\nfunction writeRules(data: RulesFile): void {\n for (const p of data.policies) p.ruleCount = p.rules.length;\n mkdirSync(join(HOME, '.synkro'), { recursive: true });\n const tmp = RULES_PATH + '.tmp';\n writeFileSync(tmp, JSON.stringify(data, null, 2) + '\\n', 'utf-8');\n renameSync(tmp, RULES_PATH);\n}\n\nfunction emitRuleSync(data: RulesFile): void {\n const active = data.policies.find(p => p.id === data.config.activePolicyId) || data.policies[0];\n const event = {\n capture_type: 'rule_sync',\n policy_id: active?.id || 'local-policy',\n policy_name: active?.name || 'My Rules',\n rules: active?.rules || [],\n rule_count: active?.ruleCount || 0,\n scan_exemptions: data.scanExemptions,\n silent: data.config.silent,\n _ts: new Date().toISOString(),\n };\n try {\n appendFileSync(TELEMETRY_PATH, JSON.stringify(event) + '\\n', 'utf-8');\n } catch {}\n if (db) ingestRuleSync(event).catch(() => {});\n}\n\nfunction genId(): string {\n return `r_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction getActivePolicy(data: RulesFile): Policy {\n return data.policies.find(p => p.id === data.config.activePolicyId) || data.policies[0];\n}\n\nfunction findOrCreatePolicy(data: RulesFile, name: string): Policy {\n const existing = data.policies.find(p => p.name.toLowerCase() === name.toLowerCase());\n if (existing) return existing;\n const p: Policy = {\n id: `policy_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,\n name,\n rules: [],\n ruleCount: 0,\n scopeOwner: 'user',\n isActive: true,\n };\n data.policies.push(p);\n return p;\n}\n\nfunction getAllRules(data: RulesFile): Array<Rule & { policyName: string; policyId: string }> {\n const all: Array<Rule & { policyName: string; policyId: string }> = [];\n for (const p of data.policies) {\n if (!p.isActive) continue;\n for (const r of p.rules) {\n all.push({ ...r, policyName: p.name, policyId: p.id });\n }\n }\n return all;\n}\n\n// \u2500\u2500\u2500 Keyword Search \u2500\u2500\u2500\n\nconst STOPWORDS = new Set(['the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'shall', 'can', 'need', 'dare', 'ought', 'used', 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'just', 'because', 'but', 'and', 'or', 'if', 'while', 'about', 'up', 'it', 'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', 'they', 'them', 'what', 'which', 'who', 'whom']);\n\nfunction tokenize(text: string): string[] {\n return text.toLowerCase().replace(/[^a-z0-9_-]/g, ' ').split(/\\s+/).filter(t => t.length > 1 && !STOPWORDS.has(t));\n}\n\nfunction keywordSearch(query: string, rules: Array<Rule & { policyName: string; policyId: string }>, topK: number): any[] {\n const qTokens = tokenize(query);\n if (qTokens.length === 0) return rules.slice(0, topK);\n\n const scored = rules.map(r => {\n const rTokens = new Set(tokenize(`${r.text} ${r.category} ${r.severity}`));\n const overlap = qTokens.filter(t => rTokens.has(t) || [...rTokens].some(rt => rt.includes(t) || t.includes(rt))).length;\n return { rule: r, score: overlap / qTokens.length };\n });\n\n scored.sort((a, b) => b.score - a.score);\n const results = scored.filter(s => s.score > 0).slice(0, topK);\n if (results.length === 0) return rules.slice(0, topK);\n\n return results.map(s => ({\n rule_id: s.rule.rule_id,\n text: s.rule.text,\n category: s.rule.category,\n severity: s.rule.severity,\n mode: s.rule.mode,\n hook_stage: s.rule.hook_stage,\n scope: s.rule.scope,\n pack_name: s.rule.policyName,\n score: Math.round(s.score * 100) / 100,\n }));\n}\n\n// \u2500\u2500\u2500 Tool Handlers \u2500\u2500\u2500\n\nfunction handleGetGuardrails(args: any): any {\n const data = readRules();\n const all = getAllRules(data);\n const topK = Math.min(args.top_k || 8, 25);\n let filtered = all;\n if (args.category) filtered = filtered.filter(r => r.category === args.category);\n const results = keywordSearch(args.query || '', filtered, topK);\n return { rules: results, total: results.length, query: args.query };\n}\n\nfunction handleCreateGuardrail(args: any): any {\n const data = readRules();\n const policy = args.ruleset ? findOrCreatePolicy(data, args.ruleset) : getActivePolicy(data);\n const rule: Rule = {\n rule_id: genId(),\n text: args.text,\n category: args.category || 'custom',\n severity: args.severity || 'medium',\n mode: args.mode || 'audit',\n hook_stage: args.hook_stage || 'both',\n scope: args.scope || 'user',\n };\n policy.rules.push(rule);\n writeRules(data);\n emitRuleSync(data);\n return { created: true, rule_id: rule.rule_id, text: rule.text, pack_name: policy.name, total_rules: policy.rules.length };\n}\n\nfunction handleBulkCreateGuardrails(args: any): any {\n const data = readRules();\n const policy = args.ruleset ? findOrCreatePolicy(data, args.ruleset) : getActivePolicy(data);\n const created: any[] = [];\n for (const r of args.rules || []) {\n const rule: Rule = {\n rule_id: genId(),\n text: r.text,\n category: r.category || 'custom',\n severity: r.severity || 'medium',\n mode: r.mode || 'audit',\n hook_stage: r.hook_stage || 'both',\n scope: args.scope || 'user',\n };\n policy.rules.push(rule);\n created.push({ rule_id: rule.rule_id, text: rule.text });\n }\n writeRules(data);\n emitRuleSync(data);\n return { created: created.length, rules: created, pack_name: policy.name, total_rules: policy.rules.length };\n}\n\nfunction handleUpdateGuardrail(args: any): any {\n const data = readRules();\n const needle = (args.rule_text || '').toLowerCase();\n for (const p of data.policies) {\n for (const r of p.rules) {\n if (r.text.toLowerCase().includes(needle)) {\n if (args.text) r.text = args.text;\n if (args.category) r.category = args.category;\n if (args.severity) r.severity = args.severity;\n if (args.mode) r.mode = args.mode;\n if (args.hook_stage) r.hook_stage = args.hook_stage;\n writeRules(data);\n emitRuleSync(data);\n return { updated: true, rule_id: r.rule_id, text: r.text };\n }\n }\n }\n return { updated: false, error: `No rule found matching \"${args.rule_text}\"` };\n}\n\nfunction handleDeleteGuardrail(args: any): any {\n const data = readRules();\n const needle = (args.rule_text || '').toLowerCase();\n for (const p of data.policies) {\n const idx = p.rules.findIndex(r => r.text.toLowerCase().includes(needle));\n if (idx !== -1) {\n const removed = p.rules.splice(idx, 1)[0];\n writeRules(data);\n emitRuleSync(data);\n return { deleted: true, rule_id: removed.rule_id, text: removed.text };\n }\n }\n return { deleted: false, error: `No rule found matching \"${args.rule_text}\"` };\n}\n\nfunction handleListGuardrails(args: any): any {\n const data = readRules();\n let all = getAllRules(data);\n if (args.category) all = all.filter(r => r.category === args.category);\n if (args.severity) all = all.filter(r => r.severity === args.severity);\n if (args.mode) all = all.filter(r => r.mode === args.mode);\n if (args.hook_stage) all = all.filter(r => r.hook_stage === args.hook_stage);\n if (args.pack_name) {\n const pn = args.pack_name.toLowerCase();\n all = all.filter(r => r.policyName.toLowerCase().includes(pn));\n }\n return {\n rules: all.map(r => ({\n rule_id: r.rule_id,\n text: r.text,\n category: r.category,\n severity: r.severity,\n mode: r.mode,\n hook_stage: r.hook_stage,\n scope: r.scope,\n pack_name: r.policyName,\n })),\n total: all.length,\n };\n}\n\nfunction handleSwapRuleset(args: any): any {\n const data = readRules();\n const name = args.policy_name || '';\n if (name.toLowerCase() === 'all') {\n data.config.activePolicyId = data.policies[0]?.id || 'local-policy';\n writeRules(data);\n return { swapped: true, active: 'all' };\n }\n const match = data.policies.find(p => p.name.toLowerCase().includes(name.toLowerCase()));\n if (!match) return { swapped: false, error: `No ruleset found matching \"${name}\"` };\n data.config.activePolicyId = match.id;\n writeRules(data);\n return { swapped: true, active: match.name };\n}\n\nfunction handleToggleSilentMode(args: any): any {\n const data = readRules();\n data.config.silent = args.enabled === true;\n writeRules(data);\n emitRuleSync(data);\n return { silent: data.config.silent };\n}\n\nasync function handleScanDependencies(args: any): Promise<any> {\n const manifests = args.manifests || [];\n if (manifests.length === 0) return { findings: [], summary: null };\n\n const packages: Array<{ name: string; version: string; ecosystem: string }> = [];\n for (const m of manifests) {\n const fp: string = m.file_path || '';\n const content: string = m.content || '';\n try {\n if (fp.endsWith('package.json')) {\n const pkg = JSON.parse(content);\n for (const [name, ver] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {\n packages.push({ name, version: String(ver).replace(/^[\\^~>=<]*/g, ''), ecosystem: 'npm' });\n }\n } else if (fp.endsWith('requirements.txt') || fp.match(/requirements.*\\.txt$/)) {\n for (const line of content.split('\\n')) {\n const m = line.trim().match(/^([a-zA-Z0-9_-]+)==(.+)/);\n if (m) packages.push({ name: m[1], version: m[2], ecosystem: 'PyPI' });\n }\n } else if (fp.endsWith('go.mod')) {\n for (const line of content.split('\\n')) {\n const m = line.trim().match(/^\\t?([^\\s]+)\\s+v([^\\s]+)/);\n if (m) packages.push({ name: m[1], version: m[2], ecosystem: 'Go' });\n }\n } else if (fp.endsWith('Cargo.toml')) {\n for (const line of content.split('\\n')) {\n const m = line.trim().match(/^([a-zA-Z0-9_-]+)\\s*=\\s*\"([^\"]+)\"/);\n if (m && !['name', 'version', 'edition', 'authors', 'description', 'license', 'repository'].includes(m[1])) {\n packages.push({ name: m[1], version: m[2], ecosystem: 'crates.io' });\n }\n }\n }\n } catch {}\n }\n\n if (packages.length === 0) return { findings: [], summary: null };\n\n const capped = packages.slice(0, 50);\n const queries = capped.map(p => ({ package: { name: p.name, ecosystem: p.ecosystem }, version: p.version }));\n\n try {\n const resp = await fetch('https://api.osv.dev/v1/querybatch', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ queries }),\n signal: AbortSignal.timeout(10000),\n });\n if (!resp.ok) return { findings: [], summary: 'OSV query failed' };\n const data = await resp.json() as { results: Array<{ vulns?: any[] }> };\n\n const findings: any[] = [];\n for (let i = 0; i < data.results.length; i++) {\n for (const vuln of data.results[i].vulns || []) {\n findings.push({\n id: vuln.id,\n package: capped[i].name,\n version: capped[i].version,\n ecosystem: capped[i].ecosystem,\n summary: vuln.summary || 'No description',\n aliases: vuln.aliases || [],\n severity: vuln.database_specific?.severity || 'unknown',\n });\n }\n }\n return { findings, summary: findings.length > 0 ? `${findings.length} vulnerabilities found` : null };\n } catch {\n return { findings: [], summary: 'OSV query timed out' };\n }\n}\n\nconst CWE_ID_RE = /^CWE-\\d{1,6}$/i;\nconst PATH_TRAVERSAL_RE = /\\.\\.[/\\\\]/;\nconst MAX_REASON_LEN = 500;\n\nfunction validateExemptionArgs(args: any): { path: string; cwe_id: string; reason?: string } | string {\n const p = typeof args.path === 'string' ? args.path.trim() : '';\n if (!p || PATH_TRAVERSAL_RE.test(p)) return 'Invalid path';\n const cwe = typeof args.cwe_id === 'string' ? args.cwe_id.trim().toUpperCase() : '';\n if (!CWE_ID_RE.test(cwe)) return 'Invalid cwe_id (expected CWE-NNN)';\n const reason = typeof args.reason === 'string' ? args.reason.slice(0, MAX_REASON_LEN) : undefined;\n return { path: p, cwe_id: cwe, reason };\n}\n\nfunction handleExemptPath(args: any): any {\n const v = validateExemptionArgs(args);\n if (typeof v === 'string') return { exempted: false, error: v };\n const data = readRules();\n const existing = data.scanExemptions.find(e => e.path === v.path && e.cwe_id.toUpperCase() === v.cwe_id);\n if (existing) return { exempted: true, already_existed: true, path: v.path, cwe_id: v.cwe_id };\n\n data.scanExemptions.push({ path: v.path, cwe_id: v.cwe_id, reason: v.reason });\n writeRules(data);\n emitRuleSync(data);\n return { exempted: true, path: v.path, cwe_id: v.cwe_id, total_exemptions: data.scanExemptions.length };\n}\n\nfunction handleRemoveExemption(args: any): any {\n const v = validateExemptionArgs(args);\n if (typeof v === 'string') return { removed: false, error: v };\n const data = readRules();\n const idx = data.scanExemptions.findIndex(e => e.path === v.path && e.cwe_id.toUpperCase() === v.cwe_id);\n if (idx === -1) return { removed: false, error: `No exemption found for path=\"${v.path}\" cwe_id=\"${v.cwe_id}\"` };\n data.scanExemptions.splice(idx, 1);\n writeRules(data);\n emitRuleSync(data);\n return { removed: true, path: v.path, cwe_id: v.cwe_id };\n}\n\nfunction handleListExemptions(): any {\n const data = readRules();\n return { exemptions: data.scanExemptions, total: data.scanExemptions.length };\n}\n\n// \u2500\u2500\u2500 Findings \u2500\u2500\u2500\n\nconst CONFIG_PATH = join(HOME, '.synkro', 'config.json');\nconst FINDINGS_JWT_PATH = join(HOME, '.synkro', '.mcp-jwt');\n\nconst ALLOWED_API_HOSTS = new Set(['api.synkro.sh', 'localhost', '127.0.0.1']);\nfunction validateApiUrl(raw: string): string | null {\n try {\n const u = new URL(raw);\n if (!['http:', 'https:'].includes(u.protocol)) return null;\n if (!ALLOWED_API_HOSTS.has(u.hostname)) return null;\n return u.origin;\n } catch { return null; }\n}\n\ninterface Finding {\n id: string;\n session_id: string;\n file_path: string;\n finding_type: string;\n finding_id: string;\n severity: string;\n status: string;\n detail?: string;\n package_name?: string;\n package_version?: string;\n fixed_version?: string;\n created_at: string;\n resolved_at?: string;\n}\n\nconst CREDENTIALS_PATH = join(HOME, '.synkro', 'credentials.json');\n\nfunction getCloudConfig(): { apiUrl: string; jwt: string } | null {\n try {\n let jwt = '';\n try {\n const creds = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf-8'));\n jwt = creds.access_token || '';\n } catch {}\n if (!jwt) {\n try { jwt = readFileSync(FINDINGS_JWT_PATH, 'utf-8').trim(); } catch {}\n }\n if (!jwt) return null;\n let raw = process.env.SYNKRO_API_URL || '';\n if (!raw) raw = 'https://api.synkro.sh';\n const apiUrl = validateApiUrl(raw);\n if (!apiUrl) return null;\n return { apiUrl, jwt };\n } catch {\n return null;\n }\n}\n\nasync function readLocalFindings(): Promise<Finding[]> {\n const result = await db.query<Finding>(\n `SELECT id, session_id, file_path, finding_type, finding_id, severity, status, detail,\n description, cwe_name, package_name, package_version, fixed_version,\n created_at::text as created_at, resolved_at::text as resolved_at\n FROM scan_findings ORDER BY created_at DESC`\n );\n return result.rows;\n}\n\nasync function proxyToCloudMcp(toolName: string, args: Record<string, unknown>): Promise<any | null> {\n const cloud = getCloudConfig();\n if (!cloud) return null;\n try {\n const resp = await fetch(`${cloud.apiUrl}/api/v1/mcp/guardrails`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${cloud.jwt}`, 'Content-Type': 'application/json' },\n body: JSON.stringify({\n jsonrpc: '2.0',\n id: `local_${Date.now()}`,\n method: 'tools/call',\n params: { name: toolName, arguments: args },\n }),\n signal: AbortSignal.timeout(10000),\n });\n if (!resp.ok) return null;\n const data = await resp.json() as any;\n if (data.error) return null;\n return data.result;\n } catch {\n return null;\n }\n}\n\nasync function handleListFindings(args: any): Promise<any> {\n let findings = await readLocalFindings();\n if (args.status) findings = findings.filter(f => f.status === args.status);\n if (args.finding_type) findings = findings.filter(f => f.finding_type === args.finding_type);\n if (args.severity) findings = findings.filter(f => f.severity === args.severity);\n\n const limit = Math.min(args.limit || 50, 200);\n const open = findings.filter(f => f.status === 'open').length;\n const resolved = findings.filter(f => f.status === 'resolved').length;\n\n if (findings.length === 0) {\n return { content: [{ type: 'text', text: args.status ? `No ${args.status} findings found.` : 'No scan findings found.' }] };\n }\n\n const lines = findings.slice(0, limit).map(f => {\n const badge = f.finding_type === 'cve' ? '\u{1F534} CVE' : '\u{1F7E1} CWE';\n const name = (f as any).cwe_name ? ` (${(f as any).cwe_name})` : '';\n const pkg = f.package_name ? ` in \\`${f.package_name}@${f.package_version || '?'}\\`` : '';\n const file = f.file_path ? ` \u2014 \\`${f.file_path}\\`` : '';\n return `- **${badge} ${f.finding_id}**${name}${pkg}${file}\\n Status: ${f.status} | Severity: ${f.severity || 'unknown'} | ID: \\`${f.id}\\``;\n });\n\n return {\n content: [{ type: 'text', text: `**${findings.length} finding${findings.length === 1 ? '' : 's'}** (${open} open, ${resolved} resolved)\\n\\n${lines.join('\\n\\n')}` }],\n };\n}\n\nasync function handleGetFindingDetail(args: any): Promise<any> {\n const id = typeof args.id === 'string' ? args.id.trim() : '';\n if (!id) return { content: [{ type: 'text', text: 'id is required' }], isError: true };\n\n const findings = await readLocalFindings();\n const match = findings.find(f => f.id === id);\n if (!match) return { content: [{ type: 'text', text: 'Finding not found' }], isError: true };\n\n const parts: string[] = [];\n const m = match as any;\n parts.push(`# ${m.finding_type.toUpperCase()} ${m.finding_id}${m.cwe_name ? ` \u2014 ${m.cwe_name}` : ''}`);\n parts.push(`**Status:** ${m.status} | **Severity:** ${m.severity || 'unknown'}`);\n if (m.file_path) parts.push(`**File:** \\`${m.file_path}\\``);\n if (m.package_name) parts.push(`**Package:** \\`${m.package_name}@${m.package_version || '?'}\\``);\n if (m.fixed_version) parts.push(`**Fix available:** ${m.fixed_version}`);\n parts.push(`**Detected:** ${m.created_at}`);\n if (m.resolved_at) parts.push(`**Resolved:** ${m.resolved_at}`);\n if (m.description) parts.push(`\\n## Description\\n${m.description}`);\n if (m.detail) parts.push(`\\n## Detail\\n${m.detail}`);\n\n return { content: [{ type: 'text', text: parts.join('\\n') }] };\n}\n\nasync function handleResolveFinding(args: any): Promise<any> {\n const id = typeof args.id === 'string' ? args.id.trim() : '';\n const filePath = typeof args.file_path === 'string' ? args.file_path.trim() : '';\n const findingId = typeof args.finding_id === 'string' ? args.finding_id.trim() : '';\n\n if (!id && !filePath) return { content: [{ type: 'text', text: 'id or file_path is required' }], isError: true };\n\n if (id) {\n const result = await db.query<Finding>(\n `SELECT * FROM scan_findings WHERE id = $1 AND status = 'open' LIMIT 1`, [id]\n );\n const match = result.rows[0];\n if (!match) return { content: [{ type: 'text', text: 'No matching open finding' }], isError: true };\n await db.query(`UPDATE scan_findings SET status = 'resolved', resolved_at = NOW() WHERE id = $1`, [id]);\n proxyToCloudMcp('resolve_finding', { id }).catch(() => {});\n return { content: [{ type: 'text', text: `Finding \\`${match.finding_id}\\` on \\`${match.file_path || '(unknown)'}\\` marked as **resolved**.` }] };\n }\n\n let where = `file_path = $1 AND status = 'open'`;\n const params: string[] = [filePath];\n if (findingId) { params.push(findingId); where += ` AND finding_id = $2`; }\n\n const before = await db.query<{ cnt: string }>(`SELECT count(*) as cnt FROM scan_findings WHERE ${where}`, params);\n const cnt = Number(before.rows[0]?.cnt || 0);\n if (cnt === 0) return { content: [{ type: 'text', text: `No open findings for \\`${filePath}\\`` }], isError: true };\n\n await db.query(`UPDATE scan_findings SET status = 'resolved', resolved_at = NOW() WHERE ${where}`, params);\n return { content: [{ type: 'text', text: `Resolved ${cnt} finding${cnt === 1 ? '' : 's'} on \\`${filePath}\\`.` }] };\n}\n\n// \u2500\u2500\u2500 Tool Descriptors \u2500\u2500\u2500\n\nconst TOOL_DESCRIPTORS = [\n {\n name: 'get_guardrails',\n description:\n \"Retrieve rules by keyword similarity. Call BEFORE writing security-sensitive code \" +\n \"AND before create_guardrail to check for existing rules.\",\n inputSchema: {\n type: 'object',\n properties: {\n query: { type: 'string', description: \"Plain-language description of what you're looking up.\" },\n category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n top_k: { type: 'integer', default: 8, description: 'Max rules to return (default 8, max 25).' },\n },\n required: ['query'],\n },\n },\n {\n name: 'create_guardrail',\n description: \"Persist a new rule. Call get_guardrails first to avoid duplicates.\",\n inputSchema: {\n type: 'object',\n properties: {\n text: { type: 'string', description: 'The rule in plain language.' },\n category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },\n mode: { type: 'string', enum: ['blocking', 'audit'], description: '\"blocking\" = halt on violation, \"audit\" = log only.' },\n scope: { type: 'string', enum: ['user', 'org'], default: 'user' },\n hook_stage: { type: 'string', enum: ['pre', 'post', 'both'], default: 'both' },\n ruleset: { type: 'string', description: 'Optional: name of ruleset to add to (created if missing).' },\n },\n required: ['text', 'category'],\n },\n },\n {\n name: 'bulk_create_guardrails',\n description: \"Create multiple rules at once. Preferable to looping create_guardrail.\",\n inputSchema: {\n type: 'object',\n properties: {\n rules: {\n type: 'array', minItems: 1, maxItems: 50,\n items: {\n type: 'object',\n properties: {\n text: { type: 'string' }, category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },\n mode: { type: 'string', enum: ['blocking', 'audit'] },\n hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },\n },\n required: ['text', 'category'],\n },\n },\n scope: { type: 'string', enum: ['user', 'org'], default: 'user' },\n ruleset: { type: 'string' },\n },\n required: ['rules'],\n },\n },\n {\n name: 'update_guardrail',\n description: \"Refine an existing rule. Pass a substring of the rule text to identify it.\",\n inputSchema: {\n type: 'object',\n properties: {\n rule_text: { type: 'string', description: 'Substring of rule text to find.' },\n text: { type: 'string' }, category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },\n mode: { type: 'string', enum: ['blocking', 'audit'] },\n hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },\n },\n required: ['rule_text'],\n },\n },\n {\n name: 'delete_guardrail',\n description: \"Permanently remove a rule. Pass a substring of the rule text to identify it.\",\n inputSchema: {\n type: 'object',\n properties: { rule_text: { type: 'string', description: 'Substring of rule text to find.' } },\n required: ['rule_text'],\n },\n },\n {\n name: 'list_guardrails',\n description: \"Enumerate all rules. Use for listings, not similarity search.\",\n inputSchema: {\n type: 'object',\n properties: {\n category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },\n mode: { type: 'string', enum: ['blocking', 'audit', 'literal_match'] },\n pack_name: { type: 'string' },\n hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },\n },\n required: [],\n },\n },\n {\n name: 'swap_ruleset',\n description: 'Switch which ruleset is active. Pass \"all\" to use all rulesets.',\n inputSchema: {\n type: 'object',\n properties: { policy_name: { type: 'string' } },\n required: ['policy_name'],\n },\n },\n {\n name: 'toggle_silent_mode',\n description: 'Toggle grading on/off. NEVER call autonomously \u2014 this is a USER decision.',\n inputSchema: {\n type: 'object',\n properties: {\n enabled: { type: 'boolean' },\n user_confirmation: { type: 'string', description: \"Copy-paste the user's exact request.\" },\n },\n required: ['enabled', 'user_confirmation'],\n },\n },\n {\n name: 'scan_dependencies',\n description: \"Scan manifests against OSV for known vulnerabilities. Read ALL manifest files first.\",\n inputSchema: {\n type: 'object',\n properties: {\n manifests: {\n type: 'array', minItems: 1,\n items: {\n type: 'object',\n properties: { file_path: { type: 'string' }, content: { type: 'string' } },\n required: ['file_path', 'content'],\n },\n },\n },\n required: ['manifests'],\n },\n },\n {\n name: 'exempt_path',\n description: \"Exempt a CWE from firing on a specific file/directory.\",\n inputSchema: {\n type: 'object',\n properties: {\n path: { type: 'string' }, cwe_id: { type: 'string' }, reason: { type: 'string' },\n },\n required: ['path', 'cwe_id'],\n },\n },\n {\n name: 'remove_exemption',\n description: \"Remove a scan exemption.\",\n inputSchema: {\n type: 'object',\n properties: { path: { type: 'string' }, cwe_id: { type: 'string' } },\n required: ['path', 'cwe_id'],\n },\n },\n {\n name: 'list_exemptions',\n description: \"List all scan exemptions.\",\n inputSchema: { type: 'object', properties: {}, required: [] },\n },\n {\n name: 'list_findings',\n description: \"List CWE/CVE scan findings. Shows security issues found by Synkro hooks. Use to review what needs fixing.\",\n inputSchema: {\n type: 'object',\n properties: {\n status: { type: 'string', enum: ['open', 'resolved', 'exempted'], description: 'Filter by status (default: all).' },\n finding_type: { type: 'string', enum: ['cwe', 'cve'], description: 'Filter by finding type.' },\n severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },\n file_path: { type: 'string', description: 'Filter by file path substring.' },\n limit: { type: 'integer', default: 25, description: 'Max results (default 25, max 50).' },\n },\n required: [],\n },\n },\n {\n name: 'get_finding_detail',\n description: \"Get full detail of a specific finding including remediation context.\",\n inputSchema: {\n type: 'object',\n properties: {\n id: { type: 'string', description: 'Finding ID (e.g. sf_...).' },\n file_path: { type: 'string', description: 'File path (used with finding_id).' },\n finding_id: { type: 'string', description: 'CWE/CVE ID like CWE-89 or CVE-2024-1234.' },\n },\n required: [],\n },\n },\n {\n name: 'resolve_finding',\n description: \"Mark finding(s) as resolved after the underlying issue is fixed. Can target by ID, file+finding_id, or all findings for a file.\",\n inputSchema: {\n type: 'object',\n properties: {\n id: { type: 'string', description: 'Specific finding ID to resolve.' },\n file_path: { type: 'string', description: 'Resolve all open findings matching this file path.' },\n finding_id: { type: 'string', description: 'CWE/CVE ID (used with file_path for targeted resolution).' },\n },\n required: [],\n },\n },\n];\n\nconst MCP_INSTRUCTIONS =\n \"Synkro Guardrails MCP server (local mode).\\n\\n\" +\n \"Whenever the user mentions: rule, guardrail, policy, standard, \" +\n \"make/create/add/set up a rule, never let X, always require X, \" +\n \"block X, enforce X, delete/remove a rule, consolidate duplicates, \" +\n \"'we need a rule about\u2026' \u2014 route to THIS server's tools.\\n\\n\" +\n \"TOOL ROUTING:\\n\" +\n \" \u2022 get_guardrails(query) \u2014 keyword search. Use to check if a rule exists.\\n\" +\n \" \u2022 list_guardrails \u2014 full enumeration. Use for listings.\\n\" +\n \" \u2022 list_findings \u2014 show CWE/CVE scan findings (open, resolved, all).\\n\" +\n \" \u2022 get_finding_detail \u2014 get full detail + remediation context for a finding.\\n\" +\n \" \u2022 resolve_finding \u2014 mark findings resolved after fixing the code.\\n\\n\" +\n \"When the user asks about security issues, vulnerabilities, or scan results, \" +\n \"use list_findings first. After fixing code, call resolve_finding to update status.\\n\\n\" +\n \"Do NOT use Claude Code's `update-config` skill for these requests.\\n\\n\" +\n \"Rules are stored locally in ~/.synkro/rules.json and enforced by hooks.\";\n\n// \u2500\u2500\u2500 JSON-RPC Dispatcher \u2500\u2500\u2500\n\nfunction jsonRpcOk(id: any, result: any): any {\n return { jsonrpc: '2.0', id, result };\n}\n\nfunction jsonRpcError(id: any, code: number, message: string): any {\n return { jsonrpc: '2.0', id, error: { code, message } };\n}\n\nasync function handleRpc(body: any): Promise<any> {\n const { id, method, params } = body;\n\n if (method === 'initialize') {\n return jsonRpcOk(id, {\n protocolVersion: '2024-11-05',\n capabilities: { tools: {} },\n serverInfo: { name: 'synkro-guardrails-local', version: '1.0.0' },\n instructions: MCP_INSTRUCTIONS,\n });\n }\n\n if (method === 'notifications/initialized') {\n return null;\n }\n\n if (method === 'tools/list') {\n return jsonRpcOk(id, { tools: TOOL_DESCRIPTORS });\n }\n\n if (method === 'tools/call') {\n const toolName = params?.name;\n const args = params?.arguments || {};\n\n try {\n let result: any;\n switch (toolName) {\n case 'get_guardrails': result = handleGetGuardrails(args); break;\n case 'create_guardrail': result = handleCreateGuardrail(args); break;\n case 'bulk_create_guardrails': result = handleBulkCreateGuardrails(args); break;\n case 'update_guardrail': result = handleUpdateGuardrail(args); break;\n case 'delete_guardrail': result = handleDeleteGuardrail(args); break;\n case 'list_guardrails': result = handleListGuardrails(args); break;\n case 'swap_ruleset': result = handleSwapRuleset(args); break;\n case 'toggle_silent_mode': result = handleToggleSilentMode(args); break;\n case 'scan_dependencies': result = await handleScanDependencies(args); break;\n case 'exempt_path': result = handleExemptPath(args); break;\n case 'remove_exemption': result = handleRemoveExemption(args); break;\n case 'list_exemptions': result = handleListExemptions(); break;\n case 'list_findings': result = await handleListFindings(args); break;\n case 'get_finding_detail': result = await handleGetFindingDetail(args); break;\n case 'resolve_finding': result = await handleResolveFinding(args); break;\n default: return jsonRpcError(id, -32601, `Unknown tool: ${toolName}`);\n }\n if (result?.content && Array.isArray(result.content)) {\n return jsonRpcOk(id, result);\n }\n return jsonRpcOk(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });\n } catch (err) {\n console.error('[synkro] tool error:', err);\n return jsonRpcOk(id, { content: [{ type: 'text', text: 'Internal error processing tool call' }], isError: true });\n }\n }\n\n // \u2500\u2500\u2500 Dashboard REST-bridge methods \u2500\u2500\u2500\n // Called by the local dashboard (not AI agents) to mutate rules.json directly.\n\n if (method === 'dashboard.patch_policy') {\n try {\n const data = readRules();\n const policyId = params?.policy_id as string | undefined;\n const policy = policyId\n ? data.policies.find(p => p.id === policyId)\n : getActivePolicy(data);\n if (!policy) return jsonRpcError(id, -32602, `Policy not found: ${policyId}`);\n\n if (params?.name !== undefined) {\n policy.name = params.name;\n }\n if (params?.is_active !== undefined) {\n policy.isActive = params.is_active;\n }\n // Bulk replace\n if (Array.isArray(params?.rules)) {\n policy.rules = params.rules;\n policy.ruleCount = policy.rules.length;\n }\n // Individual updates by rule_id\n if (Array.isArray(params?.rule_updates)) {\n for (const upd of params.rule_updates) {\n const rule = policy.rules.find(r => r.rule_id === upd.rule_id);\n if (!rule) continue;\n if (upd.text !== undefined) rule.text = upd.text;\n if (upd.category !== undefined) rule.category = upd.category;\n if (upd.severity !== undefined) rule.severity = upd.severity;\n if (upd.mode !== undefined) rule.mode = upd.mode;\n if (upd.hook_stage !== undefined) rule.hook_stage = upd.hook_stage;\n }\n policy.ruleCount = policy.rules.length;\n }\n\n writeRules(data);\n emitRuleSync(data);\n return jsonRpcOk(id, { ok: true, policy_id: policy.id, rule_count: policy.ruleCount });\n } catch (err) {\n console.error('[synkro] dashboard.patch_policy error:', err);\n return jsonRpcError(id, -32603, 'Internal error');\n }\n }\n\n if (method === 'dashboard.create_policy') {\n try {\n const data = readRules();\n const name = (params?.name as string) || 'New Rule Set';\n const rules: Rule[] = (params?.rules || []).map((r: any) => ({\n rule_id: r.rule_id || genId(),\n text: r.text || '',\n category: r.category || 'custom',\n severity: r.severity || 'medium',\n mode: r.mode || 'audit',\n hook_stage: r.hook_stage || 'both',\n scope: r.scope || 'user',\n }));\n const policy: Policy = {\n id: `policy_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,\n name,\n rules,\n ruleCount: rules.length,\n scopeOwner: 'user',\n isActive: true,\n };\n data.policies.push(policy);\n writeRules(data);\n emitRuleSync(data);\n return jsonRpcOk(id, { ok: true, policy_id: policy.id, name: policy.name });\n } catch (err) {\n console.error('[synkro] dashboard.create_policy error:', err);\n return jsonRpcError(id, -32603, 'Internal error');\n }\n }\n\n if (method === 'dashboard.delete_policy') {\n try {\n const data = readRules();\n const policyId = params?.policy_id as string | undefined;\n const idx = policyId ? data.policies.findIndex(p => p.id === policyId) : -1;\n if (idx === -1) return jsonRpcError(id, -32602, `Policy not found: ${policyId}`);\n\n if (params?.hard === true) {\n data.policies.splice(idx, 1);\n } else {\n data.policies[idx].isActive = false;\n }\n writeRules(data);\n emitRuleSync(data);\n return jsonRpcOk(id, { ok: true, policy_id: policyId });\n } catch (err) {\n console.error('[synkro] dashboard.delete_policy error:', err);\n return jsonRpcError(id, -32603, 'Internal error');\n }\n }\n\n if (method === 'dashboard.list_policies') {\n try {\n const data = readRules();\n return jsonRpcOk(id, {\n policies: data.policies.map(p => ({\n id: p.id,\n name: p.name,\n rules: p.rules,\n ruleCount: p.ruleCount,\n isActive: p.isActive,\n scopeOwner: p.scopeOwner,\n })),\n active_policy_id: data.config.activePolicyId,\n });\n } catch (err) {\n console.error('[synkro] dashboard.list_policies error:', err);\n return jsonRpcError(id, -32603, 'Internal error');\n }\n }\n\n return jsonRpcError(id, -32601, `Unknown method: ${method}`);\n}\n\n// \u2500\u2500\u2500 REST Query Handlers (for dashboard) \u2500\u2500\u2500\n\nconst MODEL_PRICING: Record<string, { input: number; output: number }> = {\n 'claude-opus-4-6': { input: 15, output: 75 },\n 'claude-opus-4-7': { input: 15, output: 75 },\n 'claude-sonnet-4-6': { input: 3, output: 15 },\n 'claude-haiku-4-5-20251001': { input: 0.8, output: 4 },\n 'gpt-4o': { input: 2.5, output: 10 },\n 'gpt-4o-mini': { input: 0.15, output: 0.6 },\n 'gemini-2.5-flash': { input: 0.15, output: 0.6 },\n 'gemini-2.5-pro': { input: 1.25, output: 10 },\n};\n\nfunction estimateCost(model: string | null, inputTokens: number, outputTokens: number): number {\n const pricing = MODEL_PRICING[model || ''] || MODEL_PRICING['claude-sonnet-4-6'];\n return (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000;\n}\n\nasync function restQueryChecks(params: URLSearchParams): Promise<any> {\n const days = parseInt(params.get('days') || '7', 10);\n const limit = Math.min(parseInt(params.get('limit') || '25', 10), 200);\n const offset = parseInt(params.get('offset') || '0', 10);\n const cutoff = new Date(Date.now() - days * 86400000).toISOString();\n\n const [rows, totalResult] = await Promise.all([\n db.query(\n `SELECT * FROM guard_checks WHERE created_at >= $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,\n [cutoff, limit, offset]\n ),\n db.query<{ cnt: string }>(\n `SELECT count(*) as cnt FROM guard_checks WHERE created_at >= $1`, [cutoff]\n ),\n ]);\n return { checks: rows.rows, total: Number(totalResult.rows[0]?.cnt || 0) };\n}\n\nasync function restQueryViolations(params: URLSearchParams): Promise<any> {\n const days = parseInt(params.get('days') || '7', 10);\n const limit = Math.min(parseInt(params.get('limit') || '25', 10), 200);\n const offset = parseInt(params.get('offset') || '0', 10);\n const cutoff = new Date(Date.now() - days * 86400000).toISOString();\n\n const [rows, totalResult] = await Promise.all([\n db.query(\n `SELECT * FROM guard_violations WHERE created_at >= $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,\n [cutoff, limit, offset]\n ),\n db.query<{ cnt: string }>(\n `SELECT count(*) as cnt FROM guard_violations WHERE created_at >= $1`, [cutoff]\n ),\n ]);\n return { violations: rows.rows, total: Number(totalResult.rows[0]?.cnt || 0) };\n}\n\nasync function restQueryTrajectories(params: URLSearchParams): Promise<any> {\n const days = parseInt(params.get('days') || '7', 10);\n const limit = Math.min(parseInt(params.get('limit') || '25', 10), 200);\n const offset = parseInt(params.get('offset') || '0', 10);\n const search = params.get('search') || '';\n const cutoff = new Date(Date.now() - days * 86400000).toISOString();\n\n let whereClause = `WHERE created_at >= $1`;\n const queryParams: any[] = [cutoff];\n if (search) {\n queryParams.push(`%${search}%`);\n whereClause += ` AND (conversation_id ILIKE $${queryParams.length} OR user_message ILIKE $${queryParams.length})`;\n }\n\n queryParams.push(limit, offset);\n const limitIdx = queryParams.length - 1;\n const offsetIdx = queryParams.length;\n\n const [rows, totalResult] = await Promise.all([\n db.query(\n `SELECT * FROM trajectories ${whereClause} ORDER BY created_at DESC LIMIT $${limitIdx} OFFSET $${offsetIdx}`,\n queryParams\n ),\n db.query<{ cnt: string }>(\n `SELECT count(*) as cnt FROM trajectories ${whereClause}`,\n search ? [cutoff, `%${search}%`] : [cutoff]\n ),\n ]);\n\n const mapped = rows.rows.map((r: any) => ({\n ...r,\n hasViolation: r.passed !== 1,\n passedCount: r.passed === 1 ? 1 : 0,\n failedCount: r.passed === 1 ? 0 : 1,\n stepCount: r.check_count,\n }));\n return { trajectories: mapped, total: Number(totalResult.rows[0]?.cnt || 0) };\n}\n\nasync function restQueryMetrics(params: URLSearchParams): Promise<any> {\n const days = parseInt(params.get('days') || '7', 10);\n const cutoff = new Date(Date.now() - days * 86400000).toISOString();\n\n const [checks, violations, usage] = await Promise.all([\n db.query<{ cnt: string; passed_cnt: string }>(\n `SELECT count(*) as cnt, count(*) FILTER (WHERE passed = 1) as passed_cnt FROM guard_checks WHERE created_at >= $1`, [cutoff]\n ),\n db.query<{ cnt: string }>(\n `SELECT count(*) as cnt FROM guard_violations WHERE created_at >= $1`, [cutoff]\n ),\n db.query<{ total_input: string; total_output: string }>(\n `SELECT COALESCE(sum(input_tokens), 0) as total_input, COALESCE(sum(output_tokens), 0) as total_output FROM usage_ticks WHERE created_at >= $1`, [cutoff]\n ),\n ]);\n\n const totalChecks = Number(checks.rows[0]?.cnt || 0);\n const passedChecks = Number(checks.rows[0]?.passed_cnt || 0);\n const totalViolations = Number(violations.rows[0]?.cnt || 0);\n const inputTokens = Number(usage.rows[0]?.total_input || 0);\n const outputTokens = Number(usage.rows[0]?.total_output || 0);\n\n return {\n totalChecks,\n passedChecks,\n failedChecks: totalChecks - passedChecks,\n totalViolations,\n passRate: totalChecks > 0 ? passedChecks / totalChecks : 1,\n inputTokens,\n outputTokens,\n estimatedCost: estimateCost(null, inputTokens, outputTokens),\n };\n}\n\nasync function restQueryTrends(params: URLSearchParams): Promise<any> {\n const days = parseInt(params.get('days') || '7', 10);\n const cutoff = new Date(Date.now() - days * 86400000).toISOString();\n\n const result = await db.query<{ day: string; checks: string; violations: string; passed: string }>(\n `SELECT date_trunc('day', created_at)::date::text as day,\n count(*) as checks,\n count(*) FILTER (WHERE passed = 0) as violations,\n count(*) FILTER (WHERE passed = 1) as passed\n FROM guard_checks WHERE created_at >= $1\n GROUP BY date_trunc('day', created_at)\n ORDER BY day`, [cutoff]\n );\n\n return { trends: result.rows.map(r => ({ date: r.day, checks: Number(r.checks), violations: Number(r.violations), passed: Number(r.passed) })) };\n}\n\nasync function restQueryFindings(params: URLSearchParams): Promise<any> {\n const status = params.get('status') || '';\n const findingType = params.get('finding_type') || '';\n const severity = params.get('severity') || '';\n const limit = Math.min(parseInt(params.get('limit') || '50', 10), 200);\n\n let where = 'WHERE 1=1';\n const queryParams: any[] = [];\n if (status) { queryParams.push(status); where += ` AND status = $${queryParams.length}`; }\n if (findingType) { queryParams.push(findingType); where += ` AND finding_type = $${queryParams.length}`; }\n if (severity) { queryParams.push(severity); where += ` AND severity = $${queryParams.length}`; }\n queryParams.push(limit);\n\n const result = await db.query(\n `SELECT * FROM scan_findings ${where} ORDER BY created_at DESC LIMIT $${queryParams.length}`,\n queryParams\n );\n\n const summary = await db.query<{ status: string; cnt: string }>(\n `SELECT status, count(*) as cnt FROM scan_findings GROUP BY status`\n );\n const counts: Record<string, number> = {};\n for (const r of summary.rows) counts[r.status] = Number(r.cnt);\n\n return { findings: result.rows, open: counts.open || 0, resolved: counts.resolved || 0, total: (counts.open || 0) + (counts.resolved || 0) };\n}\n\nasync function restQueryFindingsSummary(): Promise<any> {\n const result = await db.query<{ status: string; finding_type: string; severity: string; cnt: string }>(\n `SELECT status, finding_type, severity, count(*) as cnt FROM scan_findings GROUP BY status, finding_type, severity`\n );\n const open = result.rows.filter(r => r.status === 'open');\n const resolved = result.rows.filter(r => r.status === 'resolved');\n return {\n open: open.reduce((n, r) => n + Number(r.cnt), 0),\n resolved: resolved.reduce((n, r) => n + Number(r.cnt), 0),\n bySeverity: open.reduce((acc: Record<string, number>, r) => { acc[r.severity || 'unknown'] = (acc[r.severity || 'unknown'] || 0) + Number(r.cnt); return acc; }, {}),\n byType: open.reduce((acc: Record<string, number>, r) => { acc[r.finding_type] = (acc[r.finding_type] || 0) + Number(r.cnt); return acc; }, {}),\n };\n}\n\nasync function restQueryUsers(params: URLSearchParams): Promise<any> {\n const days = parseInt(params.get('days') || '30', 10);\n const cutoff = new Date(Date.now() - days * 86400000).toISOString();\n\n const result = await db.query<{ end_user_id: string; checks: string; violations: string; last_seen: string }>(\n `SELECT end_user_id, count(*) as checks,\n count(*) FILTER (WHERE passed = 0) as violations,\n max(created_at)::text as last_seen\n FROM guard_checks WHERE created_at >= $1 AND end_user_id IS NOT NULL AND end_user_id != ''\n GROUP BY end_user_id ORDER BY checks DESC`, [cutoff]\n );\n\n return { users: result.rows.map(r => ({ endUserId: r.end_user_id, checks: Number(r.checks), violations: Number(r.violations), lastSeen: r.last_seen })) };\n}\n\nasync function restQuerySearch(params: URLSearchParams): Promise<any> {\n const query = params.get('query') || '';\n const limit = Math.min(parseInt(params.get('limit') || '20', 10), 100);\n if (!query) return { results: [] };\n\n const pattern = `%${query}%`;\n const result = await db.query(\n `SELECT id, 'check' as type, messages as content, created_at FROM guard_checks WHERE messages ILIKE $1\n UNION ALL\n SELECT id, 'trajectory' as type, user_message as content, created_at FROM trajectories WHERE user_message ILIKE $1\n ORDER BY created_at DESC LIMIT $2`,\n [pattern, limit]\n );\n return { results: result.rows };\n}\n\nasync function restQueryProjects(): Promise<any> {\n const result = await db.query<{ project_id: string; checks: string; last_seen: string }>(\n `SELECT project_id, count(*) as checks, max(created_at)::text as last_seen\n FROM guard_checks WHERE project_id IS NOT NULL\n GROUP BY project_id ORDER BY last_seen DESC`\n );\n return result.rows.map(r => ({ id: r.project_id, name: r.project_id, checks: Number(r.checks), lastSeen: r.last_seen }));\n}\n\n// \u2500\u2500\u2500 HTTP Server \u2500\u2500\u2500\n\nasync function startServer(): Promise<void> {\n await initDb();\n\n // Start PGLite Socket for direct psql access\n try {\n const { PGliteSocketServer } = await import('@electric-sql/pglite-socket');\n const socketServer = new PGliteSocketServer({ db, port: PG_SOCKET_PORT, host: '127.0.0.1' });\n await socketServer.start();\n console.log(`[synkro] PGLite Socket listening on port ${PG_SOCKET_PORT} (psql -h 127.0.0.1 -p ${PG_SOCKET_PORT})`);\n } catch (e) {\n console.warn('[synkro] PGLite Socket not available:', String(e).slice(0, 100));\n }\n\n const server = Bun.serve({\n port: PORT,\n hostname: '127.0.0.1',\n async fetch(req) {\n const origin = req.headers.get('origin') || '';\n const allowedOrigin = /^https?:\\/\\/(localhost|127\\.0\\.0\\.1)(:\\d+)?$/.test(origin) ? origin : 'http://localhost:4322';\n const cors = { 'Access-Control-Allow-Origin': allowedOrigin, 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' };\n const url = new URL(req.url);\n const path = url.pathname;\n\n if (req.method === 'OPTIONS') {\n return new Response('', { status: 204, headers: cors });\n }\n\n // Health check (unauthenticated)\n if (req.method === 'GET' && (path === '/' || path === '/health')) {\n return Response.json({ name: 'synkro-guardrails-local', version: '2.0.0', status: 'ok', pgSocket: PG_SOCKET_PORT }, { headers: cors });\n }\n\n // GET /api/local/* \u2014 no Bearer required (server bound to 127.0.0.1 only)\n if (req.method === 'GET' && path.startsWith('/api/local/')) {\n if (path === '/api/local/checks') return Response.json(await restQueryChecks(url.searchParams), { headers: cors });\n if (path === '/api/local/violations') return Response.json(await restQueryViolations(url.searchParams), { headers: cors });\n if (path === '/api/local/trajectories') return Response.json(await restQueryTrajectories(url.searchParams), { headers: cors });\n if (path === '/api/local/metrics') return Response.json(await restQueryMetrics(url.searchParams), { headers: cors });\n if (path === '/api/local/trends') return Response.json(await restQueryTrends(url.searchParams), { headers: cors });\n if (path === '/api/local/findings/summary') return Response.json(await restQueryFindingsSummary(), { headers: cors });\n if (path === '/api/local/findings') return Response.json(await restQueryFindings(url.searchParams), { headers: cors });\n if (path === '/api/local/users') return Response.json(await restQueryUsers(url.searchParams), { headers: cors });\n if (path === '/api/local/search') return Response.json(await restQuerySearch(url.searchParams), { headers: cors });\n if (path === '/api/local/projects') return Response.json(await restQueryProjects(), { headers: cors });\n return Response.json({ error: 'Not found' }, { status: 404, headers: cors });\n }\n\n // Auth check for POST routes (ingest, JSON-RPC)\n const authHeader = req.headers.get('authorization') || '';\n const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';\n if (bearer !== SERVER_TOKEN) {\n return Response.json({ error: 'Unauthorized' }, { status: 401, headers: cors });\n }\n\n // \u2500\u2500\u2500 REST: Ingest \u2500\u2500\u2500\n if (req.method === 'POST' && path === '/api/ingest') {\n try {\n const body = await req.json() as any;\n await ingestEvent(body.data || body);\n return Response.json({ ok: true }, { headers: cors });\n } catch (e) {\n return Response.json({ error: String(e) }, { status: 400, headers: cors });\n }\n }\n\n if (req.method === 'POST' && path === '/api/ingest/batch') {\n try {\n const body = await req.json() as any;\n const events = Array.isArray(body) ? body : (body.events || []);\n let ingested = 0;\n for (const evt of events.slice(0, 500)) {\n try { await ingestEvent(evt.data || evt); ingested++; } catch {}\n }\n return Response.json({ ok: true, ingested }, { headers: cors });\n } catch (e) {\n return Response.json({ error: String(e) }, { status: 400, headers: cors });\n }\n }\n\n // \u2500\u2500\u2500 JSON-RPC (MCP protocol) \u2500\u2500\u2500\n if (req.method === 'POST') {\n const raw = await req.arrayBuffer();\n if (raw.byteLength > MAX_BODY_BYTES) {\n return Response.json(jsonRpcError(null, -32600, 'Request too large'), { status: 413, headers: cors });\n }\n return serialized(async () => {\n try {\n const body = JSON.parse(new TextDecoder().decode(raw));\n const result = await handleRpc(body);\n if (result === null) return new Response('', { status: 204, headers: cors });\n return Response.json(result, { headers: cors });\n } catch {\n return Response.json(jsonRpcError(null, -32700, 'Parse error'), { status: 400, headers: cors });\n }\n });\n }\n\n return new Response('Not found', { status: 404, headers: cors });\n },\n });\n\n console.log(`[synkro] local MCP server listening on http://127.0.0.1:${server.port}`);\n}\n\nstartServer().catch(err => {\n console.error('[synkro] Failed to start server:', err);\n process.exit(1);\n});\n", "utf-8");
6335
6354
  writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
6336
6355
  chmodSync2(bashScriptPath, 493);
6337
6356
  chmodSync2(bashFollowupScriptPath, 493);
@@ -6396,7 +6415,7 @@ function writeConfigEnv(opts) {
6396
6415
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
6397
6416
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
6398
6417
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
6399
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.83")}`
6418
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.85")}`
6400
6419
  ];
6401
6420
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
6402
6421
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -6640,12 +6659,49 @@ async function backfillLocalRules(gatewayUrl, token) {
6640
6659
  console.warn(` \u26A0 Cloud backfill failed: ${err.message}`);
6641
6660
  }
6642
6661
  }
6662
+ async function installMcpDependencies() {
6663
+ const pkgJsonPath = join11(SYNKRO_DIR2, "package.json");
6664
+ const requiredDeps = {
6665
+ "@electric-sql/pglite": "^0.2.0",
6666
+ "@electric-sql/pglite-socket": "^0.2.0"
6667
+ };
6668
+ let needsInstall = false;
6669
+ if (existsSync10(pkgJsonPath)) {
6670
+ try {
6671
+ const existing = JSON.parse(readFileSync10(pkgJsonPath, "utf-8"));
6672
+ const deps = existing.dependencies || {};
6673
+ for (const [name] of Object.entries(requiredDeps)) {
6674
+ if (!deps[name]) {
6675
+ needsInstall = true;
6676
+ break;
6677
+ }
6678
+ }
6679
+ } catch {
6680
+ needsInstall = true;
6681
+ }
6682
+ } else {
6683
+ needsInstall = true;
6684
+ }
6685
+ if (needsInstall) {
6686
+ const pkg = { name: "synkro-local", private: true, dependencies: requiredDeps };
6687
+ writeFileSync7(pkgJsonPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
6688
+ console.log(" Installing PGLite dependencies...");
6689
+ const { execSync: execSync7 } = await import("child_process");
6690
+ try {
6691
+ execSync7("bun install --no-save", { cwd: SYNKRO_DIR2, stdio: "pipe", timeout: 3e4 });
6692
+ console.log(" PGLite dependencies installed.");
6693
+ } catch (e) {
6694
+ console.warn(" \u26A0 Failed to install PGLite deps:", String(e).slice(0, 100));
6695
+ }
6696
+ }
6697
+ }
6643
6698
  async function startLocalMcpServer() {
6644
6699
  const serverScript = join11(HOOKS_DIR, "mcp-local-server.ts");
6645
6700
  if (!existsSync10(serverScript)) {
6646
6701
  console.warn(" \u26A0 Local MCP server script not found \u2014 skipping.");
6647
6702
  return;
6648
6703
  }
6704
+ await installMcpDependencies();
6649
6705
  try {
6650
6706
  const probe = await fetch(`http://127.0.0.1:${MCP_LOCAL_PORT}/`, { signal: AbortSignal.timeout(1e3) });
6651
6707
  if (probe.ok) {