@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 +100 -44
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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
|
|
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 + '
|
|
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 + '
|
|
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 + '
|
|
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 + '
|
|
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 + '
|
|
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 + '
|
|
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 + '
|
|
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 + '
|
|
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 + '
|
|
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 + '
|
|
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 + '
|
|
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 + '
|
|
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 + '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 + '
|
|
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 + '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 + '
|
|
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 + '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3421
|
+
outputJson({ systemMessage: tagStr + ' stop \u2192 0 issues across ' + edits + ' edit(s), session complete' });
|
|
3403
3422
|
} else {
|
|
3404
|
-
outputJson({ systemMessage: tagStr + ' stop
|
|
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
|
|
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
|
|
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.
|
|
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) {
|